CLAUDE.md · git:20261015.7fe13d3 · 2026-10-15 · sha256 2714349894481ab1
CLAUDE.md git:20261015.7fe13d3B
Immutable. This exact content never changes and is served at /api/v1/blob/2714349894481ab1.
# Loom
Local-first AI project workspace that weaves real Claude Code sessions, Obsidian docs, and tasks into one fabric. Clean-slate successor to its predecessor.
**Design & decisions live in the Obsidian vault, not here:**
`Projects/Loom/Architecture.md` (full vision + architecture) and
`Projects/Loom/Vision & Architecture.md` (decisions + spike findings).
**A collapsed paste placeholder with no body (e.g. `[Pasted text #1 +348 lines]`) is Loom's own write,
not a human message — whatever the surrounding frame says.** Check your own context first — it's
usually content you already hold, most often your own startup prompt; look before concluding anything is
missing. Only if you genuinely don't have it, say what could not be read rather than asking a person to
re-send it (they didn't send it) — only the recipient can see the gap, and only if asked.
## Layout (pnpm + Turbo monorepo)
- `packages/shared` — the contract: `types` (Project/Topic/Session/Task + Session FSM),
`config` (platform default → per-project override, one `resolveConfig`), `protocol` (ws/REST).
- `packages/daemon` — owns everything durable: SQLite (`db.ts`), the PTY host (`pty/host.ts`),
the Fastify HTTP/WS gateway (`gateway/`), the project-scoped task MCP server (`mcp/`), read-only git (`git/`), and the vault auto-committer (`vault/`).
- `packages/web` — stateless React/Vite viewport; attaches/detaches over WebSockets.
## Run
```sh
pnpm install
pnpm build # builds shared first (turbo ^build)
pnpm daemon # dev daemon (tsx watch) on http://127.0.0.1:4317 (loopback only)
pnpm web # viewport on http://127.0.0.1:5317 (proxies /api + /ws to the daemon)
```
**Worker DoD test-gate — default to targeted tests; `run_gate` for load-bearing changes; never a hand-rolled recipe:** Loom's gate command is `pnpm build && pnpm --filter @loom/daemon test:daemon` (`orchestration.gateCommand` — the merge gate runs the same one), and `worker_merge_confirm` re-gates at merge time and is AUTHORITATIVE — that's the full-suite guarantee (unless a human has switched the project's HUMAN-only `orchestration.mergeGate` to `"off"`, card `e8df2659`: then `worker_merge_confirm`/`merge_batch` skip the gate command and record `skipReason:"gate-disabled"` — never a pass; with the HUMAN-only `orchestration.mergeGateInterval` = N (card `6f13746c`) N landings go ungated and the landing that would exceed N still runs the real gate, while the ungated ones record `skipReason:"gate-interval"`, see `docs/decisions/6f13746c-merge-gate-interval.md` — while the conflict/dirty-tree refusals and squash checks still run). **"Green on the gate" means green on Windows, though** — the gate runs on the owner's Windows host, CI (`.github/workflows/ci.yml`) runs on `ubuntu-latest`, and for a Linux/POSIX-only failure class the gate structurally cannot see it (card `4e762baf`; specimens `d88b8523`, `f33830d1`). `docs/releasing.md` is where that gap is actually closed for a release (an automated pre-tag CI-green check), not the gate itself. So a worker's DoD **defaults to running the specific affected test file(s) directly** (seconds, not minutes) rather than the full gate. Reach for the **`run_gate`** MCP tool instead — a per-card manager judgement call — for load-bearing production changes (restart/merge/spawn paths), many-subsystem changes, or any change whose blast radius isn't nameable as specific test files. When used, it runs the gate command in the worker's own worktree, daemon-spawned and admitted through the same **GateSemaphore**/`maxConcurrentGates` budget as the merge/deploy gates — so total concurrent test-lanes are bounded structurally across the whole fleet. **`run_gate`'s own tool description is the contract — read it there, not here.** Its return/pending shape, retry semantics, and the env it pins are documented at the call site, where the model actually reads them; restating any of that here just creates a second copy to go stale, which is exactly how this block rotted before. **Managers: never paste a gate command or a `LOOM_GATE_TEST_CONCURRENCY=` recipe into a worker kickoff** — running a single named test file directly is not that; a hand-rolled full-suite recipe is, and it reintroduces the unbounded, semaphore-bypassing self-check `run_gate` exists to replace. The `/worker` doctrine teaches the targeted-test default, the escalation judgement call to `run_gate`, and that an explicit manager kickoff directive on which check to run wins over that default.
**"Which extra tests does my change need?" is answered by WHAT you touched, never by a filename grep — two different triggers have two different, already-authoritative answers; don't re-derive either by hand, and don't let one answer the other (card `f53a9913`, from a real gate rejection: a worker correctly ran one and was bounced by the other).**
- **Touched/added a file under `packages/daemon/test/*.mjs`?** These guards — the judgment-curated `STATIC_GUARD_REPO_PATHS` list in `packages/daemon/src/git/worktrees.ts` (read it there; it changes over time) — exist so your new/changed file can itself be caught. **They are NOT uniformly whole-corpus scans:** every entry `readdirSync`s the WHOLE test directory except `fixed-wait-witness-guard.mjs`, which is diff-scoped against committed `HEAD` vs the mainline merge-base — its CORE scan (the one that mirrors the merge-review diffstat) is BLIND to anything not yet committed. **COMMIT your new/changed test file(s) before running `pnpm --filter @loom/daemon guards` — `git add` alone is NOT enough to put your work through that core scan.** What `git add` (short of a commit) DOES get you: two supplementary checks now cover the gap so a forgotten commit is caught rather than silently missed. A never-`git add`ed file is NAMED as untracked (card `40643460`) — presence alone fails the guard, since there's nothing to scan yet. A file that IS tracked but has a staged-and/or-unstaged uncommitted change is actually SCANNED for a real violation, not just named (card `21e12d47`) — a clean uncommitted edit still passes, but a real unwitnessed fixed-wait site in it is caught and fails the guard exactly like a committed one. Either way, committing first remains the correct order: it's what puts your diff through the SAME core scan the merge gate itself will re-run. **Run them with `pnpm --filter @loom/daemon guards`** (card `245a3708`) — a script that imports `STATIC_GUARD_REPO_PATHS` from that same file and runs exactly what it names, printing which guard ran and which failed, non-zero exit on any failure — so there is nothing left to re-derive by hand. Do **not** re-derive this via `grep -l readdirSync packages/daemon/test/*guard*.mjs` — that's a similar-looking but unmaintained folk recipe, not oral: it was prescribed verbatim in two agent briefs (`Dev` and `Bugfix`, since corrected) — a DB-resident instruction surface `git grep` cannot see, which is exactly why it spread unnoticed. It answers a different question than "what does `STATIC_GUARD_REPO_PATHS` name" and drifts independently of it, in BOTH directions, on its own clock — a file's `readdirSync` grep-match can flip the moment someone adds or removes that literal call, unrelated to whether the file belongs on the real list: it wrongly INCLUDES a file the real list deliberately excludes (e.g. `emit-compare-soundness-guard.mjs` — see that file's own membership-criterion doc comment) and wrongly OMITS files the real list includes — **which ones is not fixed, so don't cite a name from this sentence as still true**; as of 2026-09-04 that's `working-tree-eol-guard.mjs` and `exit-code-verdict-guard.mjs` (card `5784fb8f`, filed after this exact sentence's prior example — `fixed-wait-witness-guard.mjs` — rotted when that file picked up an unrelated `readdirSync` call). Re-derive the current mismatch yourself: diff the grep's own output against `STATIC_GUARD_REPO_PATHS` in `worktrees.ts`. And it finds **nothing** below. **`guards` is quiet by default** — one summary line per passing guard, full output only on an actual failure (card `616e5ec2`); `pnpm --filter @loom/daemon guards:verbose` gets the old full per-assertion dump back. And before reporting a new check's RED/GREEN proof by hand, see `pnpm --filter @loom/daemon negative-control --help` — it scripts that exact revert/build/test/restore/rebuild/retest cycle in one call. **A new test that calls the real `createWorktree(` must put a temp `LOOM_HOME` in force first** (`useOwnLoomHome("<prefix>-")` + `requireHermeticEnv()` above its `../dist` import) — `WORKTREES_DIR` is a sibling of `LOOM_HOME`, so relying on the harness to provide one still leaks into the real `~/.loom-worktrees` on a bare `node test/x.mjs` run; `createworktree-loom-home-guard.mjs` (card `f103dd2d`, in that list) fails it, with no allowlist — its header names what it does not cover.
- **Added ANY new file anywhere in the repo (not just `packages/daemon/test/`)?** `working-tree-eol-guard` (one of the guards `pnpm --filter @loom/daemon guards` runs) checks line-ending policy repo-wide, not scoped to that directory — any whole-file write that doesn't go back through git's checkout smudge filter can land bare LF where `.gitattributes` + `core.autocrlf` expect CRLF: the `Write` tool, a hand-rolled `writeFileSync` in a patch script, and equally `sed -i` (the hazard is whether the tool rewrites the WHOLE file, not whether it calls itself "in-place" — `sed -i` is named for being in-place and still flips the whole file) — an in-place `Edit` doesn't, since it preserves existing endings. Cheap standalone check on just the new file: first resolve what the path is actually pinned to via `git check-attr text eol -- <path>`, then compare `CR` vs `LF` byte counts (`tr -dc '\r' < f | wc -c` vs `tr -dc '\n' < f | wc -c`) against THAT — **`CR == LF` is NOT universal**: it's the PASS condition only for this tree's `text=auto` default (and the lone `eol=crlf` pin, `install.ps1`); a path pinned `eol=lf` (`packages/daemon/assets/skills/**`, `*.sh`) instead expects **`CR == 0`** — testing it against `CR == LF` is a false FAIL. Repair (no new commit, re-gate the same head): `git rm --cached <path>` then `git checkout HEAD -- <path>` — **only works once `<path>` is already committed** (`git checkout HEAD --` has no blob to restore from until then); for a not-yet-committed new file, commit it first, then repair, then amend. Detail: vault `Operations/Orchestrator Rules.md` §EOL-CHECK-EVERY-NEW-FILE / §PRE-MERGE-PAIR.
- **Added, removed, or renamed a tool on ANY MCP router, made an existing tool's registration conditional, or changed which role/session-type can call one?** None of the tests that catch this are named `*guard*`, and none scan a directory — run them directly: `agent-prompt-lint-surface-drift.mjs`, `agent-prompt-lint.mjs`, `surface-subset.mjs`, `audit-surface.mjs`, `mgmt-surface.mjs`, `operator-surface.mjs`, `setup-surface.mjs`, `user-audit-surface.mjs`, `platform-elevated-surface.mjs`, `platform-mgmt-surface.mjs`, `my-context-gate.mjs`, `event-trigger-mcp-absence.mjs`, `companion-capability-grants.mjs`, `companion-lead-mode.mjs`, `peer-message.mjs`, `task-delete.mjs`, `platform-agent-update.mjs`. (This is the list that caught the `f53a9913` incident — `agent-prompt-lint-surface-drift.mjs` asserts `agents/promptLint.ts`'s hand-authored tool tables match each router's REAL registered tools. `my-context-gate.mjs` is the odd one out by name: its `(S)` SURFACE block sweeps every manager-surface tool name against `/gate/i` and fails on any match outside a small, individually-justified allowlist — a trust-boundary check that an agent-writable `gateCommand`-setting tool never reaches the manager surface, caught only because card `a5d1ae04`'s worker added two new tools matching that pattern and was bounced by this test after all ten above passed clean; card `8e8cbf55`.) **Five more, found by that same card's own DoD-3 sweep (card `f435a0ef`):** `event-trigger-mcp-absence.mjs` sweeps the orchestration MCP surface for any tool name matching `/trigger/i` (event triggers are REST-only, never agent-configurable); `companion-capability-grants.mjs` sweeps every built companion surface for `/grant/i` (capability grants have no MCP write path); `companion-lead-mode.mjs` sweeps the lead-mode surface for `/lead.?mode/i` (no MCP tool can flip lead mode); `peer-message.mjs` sweeps the manager + platform surfaces for `/project.?link/i` (project links are human-REST-only); `task-delete.mjs` asserts the EXACT worker-role task-tool surface list AND sweeps for `/delete|remove|destroy/i` (task deletion is human-REST-only). **One more, found the hard way (card `ae1e50a7`, caught only by `run_gate`'s full suite after all of the above passed clean):** `platform-agent-update.mjs` asserts the `loom-platform` `agent_update` tool's `inputSchema` KEY SET, exhaustively — a bare `readdirSync`/name-pattern sweep can't find it, and its own assertion had gone stale relative to a legitimate schema addition, which is exactly the shape this whole note exists to stop recurring.
- **Edited a role doctrine skill's text (`SKILL.md` or any file under its `references/**`) so it newly names, or stops naming, an MCP surface tool?** A DIFFERENT trigger than the bullet above — that one fires on a ROUTER registration change, this one fires on a SKILL TEXT edit alone, with no router change at all, since prose can go stale against an unchanged surface just as easily as a surface change can go undocumented. `role-surface-tools-named-in-doctrine.mjs` is the test — run it directly (not in `STATIC_GUARD_REPO_PATHS`, so `guards` does not run it). It builds each role's REAL MCP surface (manager/worker/platform/auditor/workspace-auditor/setup) and asserts every registered tool is named as a whole token somewhere in that role's doctrine skill text, or carries a reasoned entry in the test's own `ALLOWLIST` — a stale allowlist entry (one that's now actually named) fails just as loud as a missing one. It is a name-PRESENCE check only (not that the doctrine explains the tool correctly, and not that an agent ever opens a reference). Read the test's own header for the current ROLE -> SKILL mapping and which surfaces have no doctrine skill at all (and so aren't checked) — don't restate either here.
- **Touched `packages/daemon/assets/**`?** A second, separate reduced-gate list is keyed to exactly that trigger — `ASSET_READING_TEST_REPO_PATHS` in `packages/daemon/src/git/worktrees.ts` (read it there; it changes over time, same posture as `STATIC_GUARD_REPO_PATHS` above). It differs from that guard list in one load-bearing way, stated in its own doc comment: the guards above run unconditionally, on every reduced gate, regardless of diff shape, while this list only folds into the reduced-gate command (`buildReducedGateCommand`) when the diff touches `packages/daemon/assets/**` at all — any changed path under that prefix, any status, widens the run to the whole certified set rather than trying to predict which member a given file could affect. As with the guard list, point at the source rather than restating its members or count here (card `f9c9b7e2`, filed right after the guard-list bullet above was fixed for exactly that defect — a conflated, stale count that turned out to point at this very list).
- **Touching, or adding, a runtime test that reads real `packages/daemon/src/**` `.ts` source TEXT and/or compiled `dist/**` TEXT (`fs.readFileSync`, not just `import()`) and pattern-matches its content?** A THIRD, separate reduced-gate list — `CHANGED_TS_TEXT_SCANNER_REPO_PATHS` in `packages/daemon/src/git/worktrees.ts` (named after its TRIGGER — a changed compiled `.ts` path in the diff — not its content, since it covers both `dist/**` and `src/**` readers on that same trigger, card `fab07aba`; renamed from `DIST_TEXT_SCANNER_REPO_PATHS` by card `7b0cf49b`; read its own doc comment for current scope, never its name) — runs every member automatically, whenever a reduced merge gate's diff contains a changed compiled `.ts` file (the one case `computeEmitCompareGate`'s own transpile-identity proof reduces the gate on): you don't need to do anything for an EXISTING member to run. A NEW test shaped like this belongs in that list — read it there for the membership criterion (a hand-derived judgment call, not grep-derivable) and what it deliberately excludes, and add yours if it qualifies. Exists because the transpile-identity proof only proves compiled BEHAVIOR unchanged (comments/whitespace stripped before comparing) — but tsc keeps comments in both the REAL `dist/**` emit and the `src/**` file it was compiled from, so a comment-only diff can still flip one of these text-scanning tests even though it's behaviorally inert. Same conditional-inclusion shape as the assets list immediately above (folds into `buildReducedGateCommand` only when a changed compiled `.ts` path is in the diff, run bare via `node <path>` like the guards, never through `test:daemon --only=`) — point at the source, never restate its members or count here (cards `abaaf16e`, `fab07aba`).
- **Touching, or adding, a runtime test that reads real `packages/daemon/scripts/**\/*.mjs` TEXT and pattern-matches its content?** A FOURTH, separate reduced-gate list — `CHANGED_SCRIPT_TEXT_SCANNER_REPO_PATHS` in `packages/daemon/src/git/worktrees.ts` (card `f862f9c5`, closing the gap `CHANGED_TS_TEXT_SCANNER_REPO_PATHS` used to defer) — on its OWN trigger, `changedScriptFiles` (a changed `packages/daemon/scripts/**` file), never `changedTsPaths`: folding a `scripts/**` reader into the `.ts`-triggered list runs it on unrelated `.ts` diffs and still misses a `scripts/**`-only one. Same membership criterion and derived-by-hand posture as its siblings — point at the source, never restate its members or count here.
- **Added a FIELD to an existing tool's RESPONSE (not a new/removed/renamed tool, so the surface-drift trigger above doesn't fire)?** A handful of tests separately pin a tool's response key SET exhaustively and reject any unlisted addition — run them directly if you touched a projected response shape: `worker-status-projection-guard.mjs`, `entity-row-fields-guard.mjs`, `agent-session-full-field-guard.mjs`, `platform-config-redaction-drift.mjs`. None are in `STATIC_GUARD_REPO_PATHS` despite the first being `*guard*`-named (card `808ee811`: an unlisted `ctxPct` addition bounced it at the merge gate after `guards` + the surface-drift tests both passed clean).
- **Changed how an MCP tool RETURNS a whole entity — wrapped it, spilled it (e.g. `spillRowsIfLarge`), re-projected it, or added a new tool/exit that returns a project?** The whole-entity twin of the FIELD bullet above (same tests apply when you changed a response shape — this trigger is about the entity's masking, not its key set). `mcp-project-fields-chokepoint-guard.mjs` scans `packages/daemon/src/mcp/*.ts` and fails when any `ok(...)` exit returns a whole raw `Project` without routing it through `projectFields()` (the sessionEnv-masking chokepoint in `mcp/entityRowFields.ts`); `project-mcp-sessionenv-redaction.mjs` pins the `project_configure` write-response side. If you touched the REST twin of a route, `project-response-redaction-drift-guard.mjs` is the equivalent scan for `gateway/`. None of the three is in `STATIC_GUARD_REPO_PATHS`, so `guards` does not run them — run them directly. That is deliberate, not a gap FOR A GATED LANDING: each is invalidated only by a behavioural `.ts` edit, which already forces the FULL gate (where they run), so the reduced-gate list's membership criterion (see its doc comment in `worktrees.ts`) excludes them; the cost is that YOUR pre-merge self-check must name them (card `40629178`, from a merge batch reddened by `list_all_projects` wrapped in `spillRowsIfLarge`). **This premise does not hold on an interval-UNGATED landing (card `bd9a483b`) — see the dedicated bullet below.** Shape note: the guard accepts a masker as the TAIL of a chain (`const rows = db.listAllProjects().map(projectFields)`), never a masker elsewhere in the expression — read its header before relying on a shape it doesn't list. Membership here is judgment-curated by reading each test, not a filename grep; add the next entity-projection chokepoint guard to this bullet when one is written.
- **Added or altered a content-bearing log line — a new `redactedExcerpt(` call, or any log that routes user/agent/session text — in `pty/host.ts` or `sessions/service.ts`?** (`pty/codex-host.ts` has no `redactedExcerpt(` call today and the census below does not scan it — a content-bearing line added there is covered by no test named here, so say so in your report rather than assuming it is.) Run `log-message-content-gate.mjs` directly: its section (4) is an EXACT-count census of `redactedExcerpt(` calls in those two files (comment-stripped first), and a new call site fails it by design — that failure is the deliberate review point. The fix is to update the enumerated SITES list in the file's own header AND the count, after confirming the new site is legitimately content-bearing and logs a disclosure-safe class label beside it where its siblings do — never to bump the number alone. Its neighbours, each read to confirm it belongs: `pty-prompt-mismatch.mjs` (pins the `redactedExcerpt` output shape at the near-miss log lines, e.g. `droppedChar=`/`excessChars=`/`divergedVsIntended=`), `prompt-mismatch-unmatched-push.mjs` and `prompt-mismatch-unresolved-excerpt.mjs` (the flag-gated raw text on the sender push and the durable event row, both polarities), and `pty-write-seq-log.mjs` (the `[pty-write]` sequence log — which deliberately does NOT route through `redactedExcerpt`, so it is the one content-adjacent line the census can never see). Deliberately excluded: `pty-prompt-mismatch-unresolved.mjs` and `pty-prompt-mismatch-wrapper-aware-fusion.mjs` — they test detector/timer logic and assert nothing about redaction. None of these is in `STATIC_GUARD_REPO_PATHS`, so `guards` does not run them — run them directly. That is deliberate, not a gap FOR A GATED LANDING: a behavioural `.ts` edit already forces the FULL gate (where they run), so the reduced-gate list's membership criterion (see its doc comment in `worktrees.ts`) excludes them; the gap is YOUR pre-merge self-check (card `666a3bef`). **This premise does not hold on an interval-UNGATED landing (card `bd9a483b`) — see the dedicated bullet below.** Membership is judgment-curated by reading each test, not a filename grep — `pty-prompt-mismatch*.mjs`/`prompt-mismatch-*.mjs` globs over-include, so don't re-derive it that way.
- **Added or changed a git call on the canonical merge path (`git/worktrees.ts`, `git/batch-merge.ts`)?** It must go through `canonicalGit` (`git/bounded.ts`) — the shared `.git` is worker-writable (merge drivers, replace refs); threat model + what is deliberately NOT hardened: `docs/decisions/356538ef-canonical-git-isolation.md`. Run `canonical-git-helper-guard.mjs` (source-text bypass scan) and `canonical-git-isolation.mjs` (real-git S11/S12) directly — neither is in `STATIC_GUARD_REPO_PATHS`, deliberately: a bypass is a behavioural `.ts` edit, which already forces the full gate where both run (card `356538ef`). **This premise does not hold on an interval-UNGATED landing (card `bd9a483b`) — see the dedicated bullet below; `canonical-git-helper-guard.mjs` specifically is what actually went red ungated in the incident that card fixed.**
- **An interval-UNGATED landing (`orchestration.mergeGateInterval` set, `skipReason:"gate-interval"`) runs NO gate at all** — the "a behavioural `.ts` edit already forces the FULL gate" premise the three bullets above lean on is simply false for this one landing kind, and a real incident (card `bd9a483b`, 2026-10-03) landed exactly such an edit ungated and turned `canonical-git-helper-guard.mjs` red on main with nothing to catch it until another worker happened to run that guard by hand. The fix is `orchestration.ungatedLandingCheckCommand` (card `bd9a483b`) — a per-project, HUMAN-only, `gateCommand`-shaped safety-net command (same trust tier: the agent validator rejects it, the elevated Lead's `project_configure` may set it) that runs INSTEAD of the real gate, right before an interval-skipped landing's squash, cap-admitted through the same shared `GateSemaphore`/`maxConcurrentGates` budget a real gate uses. A RED refuses the landing (reason `ungated_landing_check_failed`, never cached) WITHOUT counting as a gated pass or touching the interval counter/`gateOwed`; a GREEN (or the unset default — every OTHER project, and Loom's own project until the owner sets it) proceeds exactly as an ordinary ungated landing did before this existed. Recorded as its own distinct `gate_history`/`gate_status` row (`landingCheckOnly:true`) — never mistaken for a real `gateCommand` run by a reader deriving a last-gated-pass fact, a reduced-gate fact, `gateOwed`, or the interval counter. Settings UI: Settings → (per project) → Merge gate, right under the cadence control. **Loom's own recommended value** (measured on this host, card `bd9a483b`): `pnpm --filter @loom/daemon guards` plus the fast excluded source-text scanners named in the three bullets above (`canonical-git-helper-guard.mjs`, `mcp-project-fields-chokepoint-guard.mjs`, `project-mcp-sessionenv-redaction.mjs`, `project-response-redaction-drift-guard.mjs`, `log-message-content-gate.mjs`, `pty-prompt-mismatch.mjs`, `prompt-mismatch-unmatched-push.mjs`, `prompt-mismatch-unresolved-excerpt.mjs`) — joined with `&&`, each run bare via `node <path>` like a static guard — measured ≈211s + build, matching the costed "~3.5-4.5 min per ungated landing" the owner approved. **Deliberately excludes `canonical-git-isolation.mjs`** (measured 381s alone) — the owner's own approved proposal priced that one as conditional ("only when the change touches `packages/daemon/src/git/**`"), which this single fixed-string config key cannot express; a follow-up card covers the diff-conditional variant. This is a generic, project-agnostic mechanism — shipped skills/docs describe it without naming Loom's own command; the value above is recorded here, in Loom's own `CLAUDE.md`, for the owner to set via Settings (a human-only field) once this lands. **A SEPARATE, automatic, unconditional signal also runs after every interval-UNGATED solo landing on THIS repo specifically** (card `cee17efe`, design card `71a77fb2`): the daemon computes the landed commit's own changed `packages/daemon/src/**/*.ts` files, scans (in an isolated worktree cut at the landed sha — never the canonical repo) for every `packages/daemon/test/**/*.mjs` file that DIRECTLY imports one of the matching `dist/**` modules, and runs exactly those through `--only-file=` (a file written inside the check's own isolated worktree, never inline `--only=<names>` — a large selection can exceed cmd.exe's command-line-length limit on Windows), admitted at the LOW worker-self-check tier (no `repoPath` on its descriptor, so it never takes the per-repo merge-admission guard and never stalls a real merge). **Never skips** — when the matched set exceeds 60% of the live test corpus (a corpus-relative cap, not a fixed count), it runs the top cap-many files instead, ranked by how many touched modules each one imports (descending, alphabetical tiebreak — never by mtime, which degenerates to alphabetical in a fresh worktree where every file shares one checkout-time mtime). Unlike `ungatedLandingCheckCommand` above, this is NOT human-configured, NOT a merge gate, and NEVER refuses a landing — it only pushes an advisory `[loom:dist-importer-check]` nudge to the confirming manager (pass/fail, naming "ran N of M" when capped), and records a `distImporterCheckOnly:true`-tagged `worker_gate` row for every outcome EXCEPT the nothing-directly-imports case (no candidate test file at all — card cee17efe round-4 — which records no row and pushes no nudge, since there's nothing to report); it touches neither a gated pass, the interval counter, nor `gateOwed`. The two mechanisms are deliberately independent and neither reads, writes, or proposes changing the other's config.
- **Changed a value in `PLATFORM_DEFAULTS` / any `resolveConfig`-resolved default (e.g. in `packages/shared/src/config.ts`)?** ⭐ **The merge gate already catches this — the gap is your OWN pre-merge self-check, not the gate.** `packages/shared/src/config.ts` sits entirely outside `computeEmitCompareGate`'s four in-scope prefixes (`packages/daemon/{src,test,assets,scripts}/`), so a diff touching it is never eligible for reduction at all — the merge gate always runs the FULL suite for it. (A same-shaped constant living inside `packages/daemon/src/**` isn't safer either: a real value edit fails that function's own transpile-identity check outright, forcing the full gate the same way.) The incident that prompted this bullet (card `bc74fcaf`) cost a wasted ~26-minute gate cycle for exactly this reason: a worker ran a scoped, targeted subset — reasonable under the general default — reported green, and the merge gate then red on two files the subset never touched. **There is no fixed file list to give you here** — the affected set depends on which constant changed, and enumerating one would rot exactly like the folk `grep -l readdirSync` recipe above warns against. Use the method instead:
1. **Grep the test corpus for the literal old value, in both plain and underscore-separated numeral forms (e.g. `120000` and `120_000`), and classify every hit** — asserts-this-default → update it; unrelated coincidental use of the same number → leave it; descriptive prose → leave it. ⚠️ **Watch for a sibling field sharing a LINE, not just a name:** `gateCommandTimeoutMs` (default `600000`) and `deployCommandTimeoutMs` (default `120000`) sit on the SAME line of `config.ts`'s `PLATFORM_DEFAULTS` literal — a line-scoped edit or an `Edit`/`sed` match with too little context can flip both at once, even though they no longer share the same default value.
2. **Then check whether the NEW value collides with any fixture's own explicit override for that same field, elsewhere in the corpus.** A collision doesn't fail loud — it silently and permanently disarms whatever that fixture was there to prove: `gate-queue.mjs`'s own P2 fixture explicitly overrides this field to `600_000`, the exact value a real raise landed on — an assertion built on it would keep passing while losing the ability to ever again catch a real "fell back to the platform default instead of resolving its own override" bug on that fixture. The repair is to move the FIXTURE's value, never the default — pick one distinct from the old default, the new default, and every sibling fixture in the file.
3. **For every test you're tempted to skip as "obviously unrelated," ask "could this still fail if the bug came back?", not "does it mention the old literal."** A test can look topically adjacent and still be structurally incapable of catching the regression — the same near-miss showed up under a neighbouring trigger in this section (card `5d6e0ace`): five response-key-set tests came back clean only because none of them pinned the response surface that had actually changed.
⇒ **Because the true blast radius here is provably not nameable as a file list, prefer `run_gate` over the targeted-file default for this trigger** — explicitly overriding the general "default to targeted tests" guidance, the same way a load-bearing or many-subsystem change already does.
- **Changed or added behaviour gated behind `isLoomDev()` (`packages/daemon/src/paths.ts`) or `isSupervised()` (`packages/daemon/src/orchestration/restart.ts`)?** Run every test that reaches that code path under BOTH states of whichever flag it reads — `env -u LOOM_DEV node test/<file>.mjs` AND `LOOM_DEV=1 node test/<file>.mjs` for `isLoomDev()`; `env -u LOOM_SUPERVISED node test/<file>.mjs` AND `LOOM_SUPERVISED=1 node test/<file>.mjs` for `isSupervised()` — never just the one your shell happens to hand you. Both read their env var live, with no caching, so a test exercising either inherits whatever's ambient on the host it runs on: this repo's own self-hosting entry point (`pnpm daemon:stable`) defaults `LOOM_DEV=1` AND sets `LOOM_SUPERVISED=1`, and so do agent shells spawned under it, while ubuntu CI (`.github/workflows/ci.yml`) runs with both unset — so a test asserting a dev-only or supervised-only branch, with no pin of its own, can pass every local/gate check under the ambient `1` and still go red the moment it runs where that default isn't there. `isLoomDev()` and `isSupervised()` are independent axes that can diverge either way (`LOOM_DEV=1 pnpm daemon` is dev-but-unsupervised; `LOOM_DEV=0 pnpm daemon:stable` is supervised-but-non-dev) — don't assume pinning one also pins the other. Three real specimens of exactly this gap, all already fixed: card `1ad70201` (five `platform-*` surface tests asserting `resolveRole()` truthy, none pinning `LOOM_DEV` themselves), card `27e38729` (the separate `resolveLiveLeadHome`/`platformEscalate` chokepoint, more files with the same gap), and card `81331b0e` (the manager `daemon_restart` tool-surface tests, gated on `isSupervised()` instead). Don't assume a Linux-only CI failure with no obvious path/case/POSIX explanation is one of the known classes before grepping the failing file for `isLoomDev`/`LOOM_DEV`/`isSupervised`/`LOOM_SUPERVISED` usage in the path it exercises — it may be this ambient-env trap instead.
None of these lists is discoverable by filename pattern or by scanning for `readdirSync` — membership in each was a judgment call made once by reading the actual test, not a rule you can regrep. Extend the relevant list (and this note) when a new guard/surface/asset-reading/src-or-dist-text-scanning/scripts-text-scanning test of any of these kinds is added, rather than letting the next reader re-derive a recipe that looks reasonable and answers the wrong question.
**An agent brief must POINT AT a source of truth, never restate its content.** A gate command, a port, a file list, a path, or any other enumerable value copied into a brief silently drifts the moment the real source changes — and briefs live in the DB, invisible to `git grep`, so nothing catches the drift until it misleads someone. A 2026-08-23 audit of 9 previously-unaudited agent briefs found 3 with exactly this defect (a stale copy of a gate command, a hardcoded dev-server port/worktree path, and a stale partial copy of this file's "Vault structure" section) — all fixed by replacing the copy with a pointer to the live source.
**Why the pin is what it is (background, not a recipe):** `run_gate` pins `LOOM_GATE_TEST_CONCURRENCY=3` (owner decision, card `68920f5b`, raised 2→3 by card `2ff32b5c` alongside `DEFAULT_CONCURRENCY`'s own 2→3 raise in `packages/daemon/scripts/test-daemon.mjs`; renamed from the generic `LOOM_TEST_CONCURRENCY` by card `ba3c9580` — that name was unconditionally injected into every project's gate child regardless of whether its own harness happened to read the same generic name) — matching the merge gate's own default parallelism, and safe because both are admitted through the SAME `GateSemaphore` (`orchestration.maxConcurrentGates`, daemon-global default 1; the LIVE value is not restated here — read `gate_queue`'s `cap` field, the standing re-answer), so a `run_gate` peaks at the same lanes a merge gate already reaches, whatever the resolved cap is. The raw fallback — reachable only by a project with NO `gateCommand`, so never for this repo — stays pinned at `LOOM_GATE_TEST_CONCURRENCY=1` instead, because that path runs OUTSIDE the semaphore with no structural cap on concurrent gates; the two pins differ deliberately in VALUE for that reason, not by inconsistency — both now share the same, project-qualified NAME since they control the same reader (`packages/daemon/scripts/test-daemon.mjs`). (The 2026-07-15 incident that motivated pinning was a single **unpinned** gate spiking to **8** lanes and starving the host. The safety arithmetic here is a FUNCTION of the live cap, not a fixed fact — re-derive it from `gate_queue`'s `cap` field, not from this sentence: a pin of `N` lanes peaks at `N × cap` concurrent test processes. At cap=2 (the value checked when this was last written), a 3-lane pin peaks at 6, below the 8-lane incident level, which is why you must not raise this pin to 4 or beyond AT THAT CAP — `4 × 2` reaches 8, exactly the incident level. If the live cap has since changed, recompute both products before trusting this rule.)
**`LOOM_DEV` (dev-only Platform layer):** the **Platform layer** — the reserved "Loom Platform" project
+ Platform Lead/Auditor agents, the Platform-lead/Platform-audit profiles, and the platform-lead/
platform-audit skills — is gated behind `LOOM_DEV=1` (default OFF) and does **not** ship to regular `loomctl` users (the npm build omits the two platform skills from `packages/daemon/assets/skills/`). It stays in the repo and loads only in dev: boot the daemon with `LOOM_DEV=1` (e.g. `LOOM_DEV=1 pnpm daemon`) to seed it. **`pnpm daemon:stable` (the self-hosting entry point) defaults `LOOM_DEV=1` on** — override with `LOOM_DEV=0 pnpm daemon:stable` to test the non-dev path. The flag is read in ONE helper (`paths.ts` › `isLoomDev`, same shape as `LOOM_SCHEDULER_ENABLED`); CORE orchestration (Orchestrator/Dev/Bugfix/QA/Web Designer + their skills) always seeds, flag or not.
**Setup Assistant → "Platform" operator (ships to ALL users — the ungated, lower-priv cousin of the dev Platform layer):** a standing, user-facing operator agent — seeded and shown as **"Platform"** since 0.5.0 (legacy display name "Setup Assistant" retained for the rename migration), no longer just a one-time onboarding helper — on the `setup` SessionRole, served by a curated **fail-closed** `loom-setup` MCP router (`/mcp-setup/:sessionId`, role-gated — count `SetupMcpRouter.buildServer`'s own `registerTool` calls in `setup.ts` for the exact tool total rather than trusting a number here — incl. the one safe lifecycle cap `project_archive`: soft, reversible, and REFUSES a reserved/system home). It acts on the user's behalf — `project_create` (bind an EXISTING repo, OR a **vault-only** project via `vaultPath` with `repoPath` omitted)
+ `project_init` (0.9.0 — the operator's ONLY host-write: create a BRAND-NEW project dir under the SANCTIONED
`WORKSPACE_ROOT` base inside `LOOM_HOME` — name-derived + confined, traversal/escape rejected — and `git init` it, or `kind:"vault"` for a notes folder; so a no-repo user can be onboarded end-to-end) / `configure`/`update` via the **AGENT validator** (rejects `gateCommand`/`alertWebhook`), `agent_create` + `agent_update` (0.9.0 — edit an existing agent; least-priv: REJECTS assigning an elevated platform/auditor rig), `profile_create`/`update`/`assign`, the single-record `agent_get`/ `profile_get`/`project_get` + `list_all_*` reads, and `session_spawn` `manager|plain` only — with **no** writers/escalation/auditor/archive surface and no self-elevation. It follows the CORE-seed pattern (NOT `LOOM_DEV`-gated): a reserved "Platform" home (renamed from "Getting Started" in 0.9.0 via a boot migration; surfaced in the project picker) + the "Platform" operator agent + the `setup-assistant` skill, spawned human-REST / first-run-boot only (`startSetup` singleton; first-run auto-launch via an `app_meta` one-time marker, exactly-once). The `setup` `profile_create` rejects `platform`/`auditor` roles (least-privilege). Design: `Projects/Loom/Setup Assistant Design.md` in the vault.
## Distribution (the shipped `loomctl`, released as 0.3.0)
End users install globally — `npm i -g loomctl` (command stays `loom`) — and the `loom` bin is a management CLI: `loom start/stop/status/restart/open` (`--detach`, PID file under `LOOM_HOME`) + graceful loopback `POST /internal/shutdown`; cross-OS autostart via `loom service install/uninstall/status` (systemd `--user` / launchd / Task Scheduler); and `loom update [--channel stable|beta]` (channel persisted) backed by a packaged-only loopback `POST /internal/update` + a UI "update available" banner. **The end-user daemon runs NO supervisor** — `daemon-supervisor.mjs` is dev/self-host-only and not in the npm `files` set; the OS service manager owns keep-alive (it runs `loom start --no-open` foreground). `/internal/shutdown` and `/internal/update` are **loopback-only, human-only, NOT agent MCP tools** (same trust posture as the human-only REST vault/git writers below — the one agent-facing exception there is the `LOOM_DEV` Platform Lead surface, which `/internal/*` has no equivalent of). loomctl@0.3.0 is published via npm **trusted publishing** (OIDC, no `NPM_TOKEN`, auto-provenance — see `docs/releasing.md`).
**Self-hosting (orchestrating Loom WITH Loom):** use `pnpm daemon:stable`, not `pnpm daemon`. The dev daemon runs under `tsx watch`, so any worker merge that lands a change under `packages/daemon/src/**` (or `shared/dist`) restarts it mid-orchestration and kills the live manager/worker PTYs (the watch-restart-kills-PTYs gotcha — it caused an overnight cascade on 2026-06-03). `daemon:stable` runs the **supervisor** (`scripts/daemon-supervisor.mjs`): it builds once and runs the daemon from `dist/` with **no watcher**, so source merges don't restart the running daemon. The supervisor relaunches **only** on the explicit restart sentinel (exit `75`); any other exit (incl. a crash) stops the loop, so a broken daemon stays visibly down instead of crash-looping. A crash (no `restart-intent.json`, unlike a `daemon_restart`) still needs a **human** to notice and re-run `pnpm daemon:stable` — but on THAT relaunch, boot-reconcile now recovers a manager's in-flight WORKERS too, not just the standing sessions: it derives crash-orphaned workers (exited + resumable + parented to a manager/platform + task still non-terminal) straight from the DB, protects their worktrees from the same boot's GC pass, resumes their manager, then resumes each worker back into `worker_list` with its worktree/branch intact — closing the gap where a crash used to strand in-flight workers as silently-archived orphans until a manager noticed an empty `worker_list` and re-dispatched fresh, losing in-context reasoning.
- **The hosting terminal is a single point of failure for the WHOLE fleet — prefer `pwsh` (7) and, for
anything long-lived, run detached.** `daemon:stable` (foreground) shares fate with whatever shell
launched it: a hosting-terminal crash (a `powershell.exe` 5.1 PSReadLine `ReadKey`-thread crash, a
sleep/reboot, an unrelated console close) kills the daemon mid-execution with **no chance to run any
graceful-shutdown handler** — taking down every project's live managers/workers with it (card 2f146782,
filed after two such deaths in one day). `powershell.exe` (Windows PowerShell 5.1) is the more exposed
shell for this — its `GB18030BugFixConsolePal` `ReadKey` background thread is a known 5.1-only crash
surface that exists only because the shell is interactive; **prefer `pwsh` (PowerShell 7)** for hosting
the foreground supervisor, deliberately rather than incidentally. Even under `pwsh`, foreground still
shares fate with the console — `pwsh -NoProfile` narrows exposure a little (fewer profile-loaded
modules) but does **not** eliminate it. **`pnpm daemon:stable:detach` is the actual fix:** it launches
the SAME supervisor as a genuinely detached background process (`detached:true` + `windowsHide:true` +
stdio piped to a log file, never inherited from the console — the same shape `bin/loom.mjs`'s
`startDetached()` already proves works for the packaged CLI), refuses (with a plain log line, exit 0)
if a daemon is already answering on the target port rather than double-starting a second one, and waits
up to 240s for the daemon to actually become ready (there's a full build ahead of it) before returning —
a build failure minutes later is a printed failure, never a stale "started" message with nothing left
to show it. It logs to `<LOOM_HOME>/logs/daemon-supervisor.log` (build output, restart-loop messages
ONLY — the detached child's console mirror is off, so this does not also absorb the daemon's own stream)
and `<LOOM_HOME>/logs/daemon-output.log`, the bounded/rotating/timestamped copy of the daemon's own
output (as before). **`LOOM_LOG_MESSAGE_CONTENT` (default OFF, card `16c93a50`):** this log is shared
across every tenant on the host, so message CONTENT (a raw excerpt of session/agent text) is redacted to
a length+hash signature (length only below a minimum excerpt length — see `REDACTED_EXCERPT_MIN_HASH_LEN`,
`pty/host.ts`; a 32-bit hash of too short an excerpt is itself brute-forcible) at every content-bearing
diagnostic that routes through the `redactedExcerpt` chokepoint — `submit-write`/`prompt-mismatch`, and
(card `374c21b2`) `prompt-echo`'s displayed hash and the GIVE-UP RECOVERY exhausted-budget line; their
own underlying signatures used for real cross-generation matching stay full-fidelity, only the DISPLAY
is gated. **`[pty-write]`, the byte/call-sequence log at the real `pty.write()` call sites, does NOT
route through this chokepoint** — it unconditionally hashes what it writes, including a single chunk
that covers an entire write ≤1024 chars (i.e. most real writes); a known, tracked, still-open residual
— do not read this note as saying that exposure is closed. Set
`LOOM_LOG_MESSAGE_CONTENT=1` in the daemon's own env (e.g. `<LOOM_HOME>/.env`) to opt this host back into
raw content for local debugging — never flip it in code; see `paths.ts`'s `isLogMessageContentEnabled`.
Tracks its PID (+ port) at `<LOOM_HOME>/daemon-supervisor.pid`, best-effort-removed
on its own exit. Stop it with `pnpm daemon:stable:stop` (the same graceful `POST /internal/shutdown`
hook the packaged CLI's `loom stop` uses, resolving the port from the SAME pid-file record the launcher
wrote rather than re-deriving it from stop's own env — the two can otherwise disagree, e.g. a `LOOM_PORT`
set only via `<LOOM_HOME>/.env`; falls back to a hard kill of that captured PID ONLY after confirming its
live command line still names `daemon-supervisor.mjs` — REFUSES rather than signalling an unverified pid
a dead supervisor's slot could have been reused by — never by name/port alone). **Bare `pnpm
daemon:stable` (no flag) is unchanged** — still the foreground, watch-it-live workflow; detaching is
opt-in.
- **Manager/Platform-Lead self-restart:** under the supervisor, a manager (or, since card 39fcaad3, the
`LOOM_DEV`-gated Platform Lead on its own platform MCP surface — the Lead's cross-project restart responsibility named in `/platform-lead` doctrine no longer needs relaying through a project manager) that has merged daemon-`src` can make that code go live itself via the `daemon_restart` tool — it rebuilds first (a failed build aborts the restart and leaves the daemon up), then exits `75`, dropping **every live session across ALL projects** (not just its own subtree); the supervisor relaunches and boot re-resumes the whole fleet (via `~/.loom/restart-intent.json`) with the requester getting a "code is live" note. Outside the supervisor the manager's `daemon_restart` is now ABSENT from its tools/list entirely, not merely refusing (card `81331b0e` — registered only when `isSupervised()`; a non-dev npm install never runs under the supervisor, so without this gate every such manager was shown a tool that always refused) — the same posture C3 (card `2179a923`) gave the Platform Lead's own `daemon_restart` (registered only when `isLoomDev()`; the two flags are independent and can diverge either way, so neither surface's gate substitutes for the other's). Both surfaces still refuse AT CALL TIME (card 83718377) when `LOOM_SUPERVISED=1` but a live `daemon-supervisor.mjs` process can no longer be confirmed as an ancestor of the running daemon — that env var is inherited once at spawn and never re-checked, so on its own it can't tell "was launched under a supervisor" apart from "that supervisor has since died" — `isSupervisorProcessAlive` (`orchestration/restart.ts`) re-derives the answer from the live OS process table on every call instead.
- **Manager orchestration around a restart:** after **any** daemon restart — *especially one you did
not initiate* (e.g. the owner deploying) — don't trust the auto-resume to have put your workers back to work: run `worker_list` and read each live worker's transcript. A worker resumed but left **idle mid-task** (a generic "Continue" just draws "No response requested") needs a **specific** `worker_message` re-nudge naming where it left off — a generic nudge won't revive it. And a low-urgency `daemon_restart` that should wait for the fleet to go quiet is a **park, not a poll**: don't re-run `worker_list` in a wake loop watching for quiet — note the held restart in your resume doc, `idle_report('waiting', minutes=…)`, and resume on the next genuine event (a worker report, a wake), then re-check quietness **once** and fire `daemon_restart`.
- **Deploy-build gate integrity** (`restart.ts` › `deployBuildSteps`/`buildDaemon`): the deploy rebuild
is two ordered, fail-closed steps so a stale cache or a missing install can't verify a broken main green. (1) `pnpm install --frozen-lockfile` FIRST — a merged dep-add (package.json + lockfile) gets linked before the build, instead of failing to resolve the new import (`daemon_restart` never used to install). (2) `turbo build … --force` SECOND — `--force` is the **real** cache-defeating invocation ONLY when passed directly to turbo (`node <turbo> build … --force`); `pnpm <pkg> build --force` forwards `--force` to the build *script* (vite), NOT turbo, so the cache is **not** defeated and a stale FULL TURBO replay ships green (the aad5fff3 footgun). A failed install short-circuits the build. (turbo.json also keys all build caches on `pnpm-lock.yaml`, so a dep change busts the cache repo-wide.)
- **Caveat:** `packages/daemon/assets/**` is NOT uniformly live-read — hook-relay and vault-lint are, bundled skills are
NOT. A merged `packages/daemon/assets/hook-relay.mjs` or `packages/daemon/assets/vault-lint.mjs` change is read straight from the
package dir on next use, no restart needed. **Bundled skills route through a separate store, not the
package dir:** `skills/inject.ts` delivers a session's `.claude/skills` from the Loom-owned store
(`<LOOM_HOME>/skills/<name>/SKILL.md`), and a merged `packages/daemon/assets/skills/**` change only reaches that store
on **daemon boot/restart** (`seedGlobalSkills()`, called once at boot from `index.ts`) — and even then
**only** for a `customized:false` (pristine) skill; a user-edited skill is never auto-advanced and
waits for an explicit **adopt** (Skills UI or `POST /api/skills/<name>/adopt`) regardless of restarts
(`skills/seed.ts:73`). **Ordering trap:** if the store carries a Skills-UI edit not yet folded into
`packages/daemon/assets/`, adopting first silently discards it — land the content in `packages/daemon/assets/` first, then adopt.
**The store-grep check below answers ONE of three separate questions, never "is it live" — collapsing
them is exactly what card 13965c93 fixed in the merge-confirm nudge (`skillWarning`):** (1) did the
change reach the **STORE** (the grep below); (2) does a given **SESSION**'s own injected `.claude/skills`
copy reflect it — `injectSkills` is called unconditionally from `createPty` (`pty/host.ts:5597`), and
every resume/fork/recycle goes through `createPty`, so the copy is refreshed on every **resume**, not
frozen at first spawn; a session that stayed live across the restart still needs its own next resume before it reflects the
change; (3) has the **AGENT** actually opened it — ⛔ **no grep answers this.** `SKILL.md` is read
ambiently; `references/**` is read **on demand**, so a `references/**` change can be live in the store
and in a session's own copy and still be behaviourally absent, because nothing makes the agent open that
file.
**To check whether a merged skill change reached the STORE (question 1 only), grep the STORE, never a worktree:**
`grep -c "<new string>" "$LOOM_HOME/skills/<name>/SKILL.md"` — nonzero means the store has it (not that a
live session reflects it, and not that any agent has read a `references/**` change). Zero means
merged-and-dead. ⛔ Don't grep a dev worktree's own `.claude/skills` as a proxy — `scripts/sync-claude-skills.mjs`
mirrors `packages/daemon/assets/skills/**` straight into that worktree's `.claude/skills` on **every** build
of `@loom/daemon`, by DESIGN two ways at once (card `bce50c22`, belt and braces — see either file's own
comment before "deduping" one away): `@loom/daemon`'s own `build` script calls it inline (covers a direct
`pnpm --filter @loom/daemon build`, which bypasses turbo entirely), AND `turbo.json` also runs it as its
own uncached `skills-sync` task (same shape as `stamp`, covers a turbo-mediated build whose cached `build`
task itself hits the turbo cache and would otherwise skip the inline call) — independent of the store and
independent of any restart either way; a check against it can read green while the store — what real
sessions are actually injected from — is still stale. For full isolation, run the stable daemon from a
separate checkout (shares `~/.loom` state;
override `LOOM_HOME`/`LOOM_PORT` for two daemons side by side).
- **Caveat (supervisor code is NOT `daemon_restart`-deployable):** `daemon_restart` only rebuilds +
relaunches the daemon *process*; the **supervisor** (`scripts/daemon-supervisor.mjs`) and anything it loads are NOT re-read across exit `75` (the same running supervisor execs the new `dist/`). A merge that edits the supervisor needs a **human Ctrl-C + re-run of `pnpm daemon:stable`** to go live — a manager must flag that human action in its done-report (mirrors the unsupervised `restarting:false` refusal). If `daemon_restart`'s result (or the post-restart resume nudge) instead carries `supervisorCheckFailed` — the check itself failed, not a confirmed unchanged — flag that supervisor-liveness is **unknown**, not confirmed-fine; the check couldn't tell you either way, so don't assume the human step is unnecessary.
**Isolated-daemon testing on Windows (a throwaway daemon for UI/API review):** spinning up a second, disposable daemon (own `LOOM_HOME`, non-default port) to eyeball a change has burned cycles across worker sessions on these five footguns:
- A bash `run_in_background` PID is the **shell's** PID, not the daemon's — `taskkill //PID <that-pid>`
fails. Find the real node listener with `netstat -ano | grep :<port>`, then `taskkill //F //T //PID <that-pid>`, and confirm the port actually freed. (This identifies which PID holds the port, not how many listeners are on it — a matching row count is not a listener count.)
- A helper `.mjs` must **LIVE under `packages/daemon`** for its deps (e.g. `ws`, `better-sqlite3`) to
resolve — ESM bare-specifier resolution walks up from the **script's own location**, ignoring cwd, so
running a scratch-dir script "from inside packages/daemon" still hits `ERR_MODULE_NOT_FOUND`.
(Alternative: keep the script wherever and anchor resolution explicitly via
`createRequire(path.join(daemonPkgDir, 'package.json'))`.)
- Pass node / dynamic-`import()` paths as `file://` URLs (or `pathToFileURL`), never a bare drive-letter
absolute path — a bare path throws `ERR_UNSUPPORTED_ESM_URL_SCHEME` on Windows.
- `pnpm web`'s dev proxy (`packages/web/vite.config.ts`) targets `127.0.0.1:LOOM_PORT` (default `4317`)
— **if you run `pnpm web` without also setting `LOOM_PORT` to the throwaway daemon's port, it silently
proxies to your ORIGINAL daemon instead**, and the page renders perfectly either way: you'd be
reviewing the wrong daemon's sessions/board/settings with no visible sign. Set `LOOM_PORT` before
starting `pnpm web`, and check the `[web] proxying → http://127.0.0.1:NNNN` line it logs on startup to
confirm the target.
- A **fake-home smoke of `scripts/daemon-supervisor.mjs`** (overriding `HOME`/`USERPROFILE` to a throwaway
dir, same pattern as the isolated-daemon recipe above) fails turbo's own build step with `x Global
config path not found.` — turbo resolves its global config directory from the real OS profile dirs, and
a fake/empty `HOME`/`USERPROFILE` has none. Set `TURBO_CONFIG_DIR_PATH=<a dir under that same fake
home>` (create the dir first) alongside the fake `HOME`/`USERPROFILE` and it resolves normally.
## Load-bearing invariants (validated in the spike — do not regress)
- **Drive the REAL interactive `claude` via node-pty.** Never `claude -p`/headless.
- **Spawn recipe** (`pty/host.ts`): absolute claude path (Windows node-pty doesn't search %PATH%);
env scrub of `CLAUDECODE`/`CLAUDE_CODE_*`; `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` + `CLAUDE_CODE_ALT_SCREEN_FULL_REPAINT=1`; `--permission-mode <target>` (card 51926260 — `pty/host.ts`'s `computeBootMode` is the source of truth for `<target>`: it boots DIRECTLY at the session's real target mode when that target is itself acceptEdits/plan/auto, falling back to the acceptEdits boot + Shift+Tab climb only for a target that isn't directly expressible as a flag value) + allowlist (NOT `--dangerously-skip-permissions`, which shows a blocking gate); `--strict-mcp-config` WITH an explicit `--mcp-config` (suppresses the `.mcp.json` enable prompt); `--append-system-prompt` (unconditional, every role — `LOOM_PASTED_CONTENT_SYSTEM_PROMPT`, card 282107e8, see `docs/decisions/282107e8-pasted-content-is-the-sessions-real-user-turn.md`). This combo boots unattended.
- **Role-scoped human-prompt disallow** (`buildSpawnArgs`/`disallowedToolsForRole`): a Loom-DRIVEN role
(`LOOM_DRIVEN_ROLES`, `packages/shared/src/types.ts` — read it there rather than trusting a role list
restated here) spawns with `--disallowedTools AskUserQuestion ExitPlanMode EnterPlanMode`, so the
interactive human-prompt tools are REMOVED from the model's tool list and can never block the turn on
input that the human will never give (a worker's stdin is owned by its manager). The flag sits BEFORE
`--strict-mcp-config` so its variadic value list is terminated by that flag, keeping `--mcp-config` the
last real flag. **Manager/orchestrator + the human-driven platform lead are DELIBERATELY excluded** (a
manager legitimately surfaces decisions to the human), as are plain (role-less) sessions — for every
out-of-scope role the flag is omitted and the argv is byte-identical.
Computed at the single `createPty` spawn chokepoint, so every path (fresh/resume/fork/recycle/boot)
inherits it. `/worker` doctrine is the soft complement; this is the structural backstop (board card 8dd1dd1c).
- **Role-scoped harness-self-scheduling disallow** (`disallowedToolsForRole`, `HARNESS_SCHEDULING_TOOLS`
in `pty/host.ts` — read that const's own doc comment for the full role-scope + codex-parity rationale,
not restated here): the SAME `--disallowedTools` flag ALSO carries `ScheduleWakeup CronCreate
CronDelete CronList RemoteTrigger` for worker/setup/auditor/workspace-auditor/assistant/manager — the
harness's own self-scheduling/remote-trigger tools, a DIFFERENT hazard than the human-prompt tools
above (they don't block a turn, they arm autonomous behavior Loom cannot see or cancel; card 7a624213,
from a real incident: a worker's `ScheduleWakeup` tick fired the harness's own autonomous-`/loop`
mandate into a session whose real instructions come from its manager). `wake_me` (an MCP tool, Loom can
see and auto-cancel) is the sanctioned replacement — the `/worker` skill names it as such. Deliberately
excludes `run` (no idle boundary to collide at), `platform` (human-driven Lead), `operator`, and plain
sessions (an owner-interactive terminal may legitimately use `/loop`). **Codex parity: nothing to deny
and no lever to deny it with** — codex's own tool surface has no equivalent under any name, and
`createCodexPty` has no `--disallowedTools`-equivalent lever at all (see that method's own doc).
- **The startup/kickoff prompt does NOT ride argv, for ANY role (card 0050a17e).** It used to be positional, behind a `--` end-of-options separator — but that put its FULL TEXT on the Windows `CreateProcess` command line, which has a hard **32766-character** ceiling (`WINDOWS_COMMAND_LINE_LIMIT`); a large agent brief + kickoff (project memory, real CLAUDE.md/skill excerpts) could blow through it and refuse the spawn outright before the daemon ever produced a process — the exact incident that forced this change. Every role now boots with **no trailing prompt** (identical to how a `--resume`/`--fork-session` spawn already booted), then the SAME text is delivered once the session reaches `ready` (SessionStart + mode-cycles) via `submit()` — the same reliable path every later turn (and the §19c-b rate-limit replay) already uses (`scheduleKickoffGuarantee` — there is no more vendor-CLI auto-submit left to race, so this is the PRIMARY delivery path, not a backstop). The kickoff text is captured SYNCHRONOUSLY at `markReady` (a later unrelated drain must never be able to overwrite what gets replayed — `submit()` unconditionally overwrites `live.lastPrompt` with whatever IT is currently submitting, so capturing late would silently swap in that other text), but the actual DELIVERY is gated on `logLandedMode`'s footer-read + role-gated plan-mode auto-heal settling first: both read the same ring buffer / write to the same pty, so delivering concurrently with that read/heal would pollute the mode detection (→ the heal silently never fires for a role stranded in `plan` with `ExitPlanMode` disallowed) or interleave the heal's own Shift+Tab writes with the kickoff's own paste. **This means delivery is NOT next-tick** — it costs a real, accepted latency floor of one `MODE_LOG_POLL_MS` poll (~500ms), up to ~4s at production defaults if the footer read never resolves, plus `cycleToMode`'s own time on top if the auto-heal fires — a deliberate trade of turn-1 latency for closing that race, made once and not to be re-optimized here. `Live.lastPrompt` is still seeded SYNCHRONOUSLY at `spawn()` (independent of the argv change, and earlier than the capture above) so a crash before the first `submit()` still leaves something to re-submit on resume (§19c-b) — this is NOT redundant with the later `submit()` write; removing it would reopen that exact window. `preflightWindowsCommandLine` (pty/host.ts) still exists as a real, exact, real-spawn-time guard for whatever DOES remain on argv (the settings path, the inline `--mcp-config` JSON, `--disallowedTools`) — it is just far less likely to trip now that the prompt is gone. **`memory.budgetTokens` is no longer coupled to spawn headroom via this ceiling** — a project memory digest of any size can never again contribute to the Windows argv limit, since it rides inside the same post-ready `submit()` payload as the rest of the prompt, not argv. It still competes for the engine's own context-window/turn-1 token budget generally (a separate, unrelated concern from the Windows command-line ceiling this section is about — don't conflate the two).
- **Engine session id** captured via the SessionStart hook → `packages/daemon/assets/hook-relay.mjs` →
`/internal/hook`; persisted on receipt. New session delivers the topic startup prompt post-ready via
`submit()` (see above); resume injects nothing.
- **MCP scoping:** session id is in the URL path (`/mcp/:sessionId`); the project is derived
SERVER-SIDE. The agent never passes a projectId.
- **Sessions outlive viewers:** closing a ws never kills the pty. Fixed pty geometry (`pty.cols`/`pty.rows`
in `packages/shared/src/config.ts` — read it there rather than trusting a number restated here),
no resize. Stop: graceful (Ctrl-C ×2, clean) default, hard (`pty.kill`) escalation — both resumable and orphan-free. **Not a Job Object** (node-pty@1.1.0 has none): node-pty's own conpty kill path walks `_getConsoleProcessList()` and kills each pid, and Loom's own `reapOrphanedDescendants` at `onExit` is the backstop for anything that escapes it. **This is about AGENT-SESSION ptys specifically — card `1718416d` added a real Job Object, but only on the canonical-git-call path (`git/bounded.ts`'s win32 branch, via `native/win-job-launcher/launcher.cs`), never for a session's own claude/codex pty.** Don't conflate the two: a session kill still has no Job Object at all.
- **win-job-launcher (card `1718416d`) — Windows Job Object containment for canonical git calls, with a
mandatory fallback.** `git/bounded.ts`'s `spawnCanonicalGitTree` routes every win32 call through
`packages/daemon/assets/native/win-job-launcher.exe` (never git directly) when that file exists at its
fixed, package-relative build path; when it doesn't (not built, or non-win32), every call falls back
byte-identically to the pre-card taskkill-based path (`24c0bdba`). The exe is a build artifact, never
committed (`packages/daemon/assets/native/` is gitignored) — the tracked SOURCE lives at
`packages/daemon/native/win-job-launcher/launcher.cs`. Build it with `pnpm --filter @loom/daemon
build:native` (compiles via the in-box .NET Framework `csc.exe` — no MSVC, no node-gyp; a loud no-op on
non-win32 or when `csc.exe` can't be found). **Deliberately OUTSIDE the default `pnpm build`** — don't
add it there; a Windows-only, opt-in containment feature shouldn't make every ordinary build slower or
depend on a toolchain most dev/CI hosts don't need. The shipped npm tarball gets a prebuilt copy from a
dedicated `windows-latest` release-CI job (load-smoked + hard-failing the release on any build/smoke
failure — see `docs/releasing.md`), never a first-use compile. **`daemon_restart`'s deploy rebuild ALSO
runs `build:native` (card `0ec6fb57`)** — a third, win32-only step in `deployBuildSteps`, right after
the TypeScript build, so an ordinary merged `launcher.cs` fix now goes live through a normal restart; a
manual `build:native` in the main checkout is no longer needed for that. Unlike the install/build steps,
this one is deliberately NON-FATAL (`fatal:false`): a failure — a blocked `csc.exe`, or the exe being
locked because the live daemon still had it open for an in-flight git call — only logs a loud warning
(surfaced on the `daemon_restart` result as `nativeLauncherWarning`) and the deploy proceeds; it never
aborts the restart, since this containment is optional with an already-accepted fallback, not core
daemon correctness like install/build. **A failed compile leaves whatever exe was already in place —
stale or even planted — UNTOUCHED; it is NOT guaranteed overwritten until a LATER deploy's compile
actually succeeds** (see docs/decisions/0ec6fb57-*.md). Non-win32 hosts are untouched — the step is only
ever appended on win32. **The gate/`ci.yml` SKIP the four real-exe `win-job-launcher-*.mjs`
tests** (print `SKIP`, exit 0) whenever the exe isn't built — `ci.yml` runs on `ubuntu-latest`, where it
never is; only the release job's own Windows runner (see `docs/releasing.md`) exercises the real binary.
Full mechanism, the STEP 0/0b investigation (libuv's own global job's
`JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK`, the stdin-sharing hang this design avoids, MSYS `$$` ≠ the real
Windows pid), and the verdict protocol: `docs/decisions/1718416d-win-job-launcher-containment.md`.
- **Message drain is kind-classified: agent/human messages land ONE-per-turn ACROSS senders (same-sender bursts now coalesce); only Loom warning/system
injections coalesce regardless of sender** (`pty/host.ts` › `drainPending`, owner-directed 2026-07-03; AMENDED 2026-08-28 by card `eac3464d`). Every queued message carries a `kind: "warning" | "agent"` (`QueuedMessage`), set at the `enqueueStdin` boundary (defaults to `"warning"` so untouched callers stay byte-identical). **`"agent"`** = a message AUTHORED by an agent or human TO the recipient (manager→worker direction/redirect, worker→manager report, a Lead `session_message`, a human composer turn, companion inbound/heartbeat/reminder/`attention-push`) — a run of CONSECUTIVE queued entries from the SAME sender (same route + `senderId`) now coalesces into ONE turn (bounded by `AGENT_COALESCE_MAX_COUNT`/`AGENT_COALESCE_MAX_BYTES`, excluding any give-up/re-mint `giveUpGen`-tagged entry), but a DIFFERENT sender still breaks the run and drains as its own turn — busy re-arms synchronously and the next, different-sender agent message self-chains on the next Stop; the reconcile timer is the backstop — so two senders' distinct directives are still never mashed into one wall of text. **Same-sender coalescing only actually fires for a producer that threads a real, non-null `senderId` into `enqueueStdin`** (card `4458dd9e`, closing a gap `eac3464d` left open) — today that's manager→worker direction/redirect, worker→manager report, and a Lead `session_message` (all via `enqueueDurableMessage`'s `ctx.sender`), the human composer's `POST /api/sessions/:id/input` route (a fixed `HUMAN_COMPOSER_SENDER_ID` sentinel — see its own doc, `pty/host.ts`, for why a constant is correct for a single-user, loopback-trusted route), and a **GROUP-scope** companion inbound turn (the authenticated group member's real chat sender id, `chat-gateway.ts`'s `handleInbound`). ⚠️ A **DM-scope** companion inbound — the default, single-owner scope, and the dominant real-world case — passes `senderId:null` (`binding.scope === "group" ? … : null`): the chatId alone already identifies the one owner, so there's nothing to disambiguate. A companion heartbeat/reminder/`attention-push` alert, the composer's one-click `/end` wrap-up nudge, and a post-`daemon_restart` pending-queue replay also pass **no** `senderId` by design — a heartbeat/reminder/alert is a Loom-authored operational nudge, not a distinguishable sender identity worth minting, and a restart replay has no per-entry origin persisted to thread. So a DM companion turn and every one of those instead drains via the plain cross-sender one-per-turn path, same as any other distinct sender. `enqueueStdin` also reorders a same-sender agent-kind arrival to land right after that sender's own last queued entry (rather than the FIFO tail) so this coalescing has something adjacent to work with even when another sender's message interleaved the arrivals (a STATED fairness trade: a chatty sender can delay a quiet one, but only by `AGENT_COALESCE_MAX_COUNT` arrivals, never unbounded — card `e01687ea` correction: the CAP comes from a per-entry `leapfrogCount` on `QueuedMessage` that freezes an entry once it's been displaced that many times, not from the lookback window the reorder's scan uses to bound how far back it searches — the window alone does NOT age a quiet entry out, since the reorder always inserts immediately in front of it and its index grows in lockstep with the window). **`"warning"`** = a Loom operational nudge (idle/context/busy-stuck watchdogs, restart/boot continuation notes, memory-recall injection) — still coalesces the leading **same-route + same-kind** run into one turn, from any sender, unbounded (unchanged). Rate-limit replay is NOT a producer here: `resumeAfterRateLimit` replays `live.lastPrompt` via a direct `submit()` call, bypassing `enqueueStdin`/`live.pending` (and this classification) entirely, so it never participates in any drain-coalescing rule. A drain never mixes kinds. **`coalesceAgentMessages`** (PlatformConfig, daemon-global, human-only, **default `false`**; Settings UI "Message Delivery" panel) restores the legacy full-coalesce when `true` (kind AND sender ignored → whole same-route run coalesces). Route-keyed coalescing (companion cross-delivery safety), the M1/M2 busy-gate ordering, and `onDeliver`-fires-for-every-drained-entry are all preserved under every mode. The daemon still **refuses a worker's `done` report while it has unconsumed manager direction queued** (the hard guard), so a worker can't finish a now-stale plan; the `/worker` doctrine is the soft complement (re-check for newer `[loom:from-manager]` direction before reporting). For a "land it NOW" steer, the manager has **`worker_redirect`** — it interrupts the worker's current turn, **flushes** any superseded queued direction (so at drain time there's at most one pending redirect — same-sender agent coalescing does not regress the supersede path, since `flushPending` empties the whole queue before the one authoritative redirect is enqueued), and delivers ONE authoritative instruction immediately — the escalation above the additive `worker_message`.
- **Opt-in worker browser (`browserTesting`):** a session whose resolved Profile sets `browserTesting`
spawns with its OWN per-session stdio Playwright MCP (`@playwright/mcp`, absolute node + absolute `cli.js`, `--headless --isolated`) and that tool surface allowlisted. Default OFF + fully additive (every existing spawn byte-identical when off); pinned on the session row so resume/fork/recycle keep the browser. HUMAN-set only (Profiles UI/REST) — never an agent MCP tool (same capability-gating posture as gateCommand/shell). The MCP launches Chromium lazily on first use; needs a one-time `npx playwright install chromium`. The bundled browser-capable rigs are "QA Tester" (test/verify) and "Web Designer" (UI/frontend build) — both spawn the per-session Playwright MCP.
- **Default harness (`harness: {default, scope}`, card `66b1b40d`, multi-harness epic `df1f94b0`):** a human-only config key at TWO layers — platform (`PATCH /api/platform/config`, `PlatformConfigOverride.harness`) and project (`ProjectConfigOverride.harness`, human REST PATCH) — that fills an UNSET Profile `harness` for a fresh **`worker`** spawn. Precedence: Profile `harness` > project > platform > built-in `claude`/`workers` (`resolveHarnessConfig`/`harnessDefaultForRole`, `packages/shared/src/config.ts`); a resolved `claude` writes a NULL session column, so every existing spawn stays byte-identical. Read inside `SessionService.resolveAgentSpawn`, NOT off its `config` argument (every `resolveConfig(` caller there passes no platform layer). **HUMAN-only, same trust class as `profile.harness`/`gateCommand`:** the platform validator has no agent variant, the agent project-config shape drops the key (`.strict()` rejects it), and even the Platform Lead's elevated `project_configure` rejects it (`HUMAN_ONLY_PROJECT_CONFIG_KEYS`, `mcp/platform.ts`). **`scope:"fleet"` is FAIL-CLOSED** — both validators reject it with an error naming card `4c4eb9af` (codex has no doctrine/parity for non-worker roles yet) until the ONE shared allowlist `HARNESS_FLEET_ROLES` (`packages/shared/src/config.ts`, today `["worker"]`) reaches beyond `worker`; `harnessDefaultForRole` reads the same const (so a stored `fleet` reaches no more than that list), and the default reaches `worker` only today. Widening it is one role per card (never a free-form roles key; setup/auditors last). **A default-derived `codex` is applied ONLY to a codex-COMPATIBLE agent (card `961da6c6`):** `validateProfile`'s codex rejections fire only for an explicit `harness:"codex"` profile and codex silently ignores `restrictedTools`, so `SessionService.defaultHarnessForSpawn` keeps an agent needing `restrictedTools` (fail-open) or `browserTesting`/`documentConversion`/`capabilities` (purpose-defeating) on claude (`codexIncompatibilities`, `profiles/codex-compat.ts` — the one source of the reason strings; codescape is deliberately NOT a skip reason: it is fail-closed, only a loud spawn-time `onCodexUnsupportedCapability` degrade), and a fresh `worker_spawn`/`startNew` files a durable `harness_default_skipped` event (`worker_spawn`'s result also carries `harnessDefaultSkipped`); an explicit codex profile is unchanged. `forkSession` REFUSES a codex-pinned source (codex has no fork primitive). A FRESH spawn consults it, and so does a **manager / platform-lead recycle** (card `8d4b4433`: re-resolved through `resolveAgentSpawn`; a re-resolved `codex` is applied only if the OLD ROW's carried restrictedTools/browserTesting/documentConversion/capabilities are codex-compatible per `codexIncompatibilities`, else `old.harness` is kept and a `harness_default_skipped` event with `trigger:"recycle"` is filed; this skip is PERMANENT for a lineage whose row carries those fields, since recycle carries them forever and each recycle files another skip); worker recycle, resume, fork and boot-reconcile read the pinned row value, so flipping the default never migrates a live session or a worker. Tests: `packages/daemon/test/default-harness-config.mjs`, `packages/daemon/test/recycle-harness-reresolve.mjs`. **codex also never reads `opts.permission`** — an authored PROJECT `permission.deny` (profiles have no deny field of their own, and there is no platform permission layer) is a codex-incompatibility too (same `codexIncompatibilities` skip-to-claude shape as above), several other claude-side read-denies are only loudly disclosed (never enforced, and never into the codex session's own turn input) at spawn, and a RESOLVED codex harness is forced back to claude — at the `resolveAgentSpawn` chokepoint, keyed on the resolved session role, not the profile's own role field — for any role that gets claude's blanket transcript-root deny, with an explicit `harness:"codex"` profile for such a role rejected earlier too, at `validateProfile` save time — see `docs/decisions/7955458e` for exactly which denies, why, and what's still an open residual risk.
- **Eyeballing Loom's own web UI with a real, authenticated browser (claude-in-chrome — Lead-only /
special-case, per the orchestrate skill's division of labor):** a heavy page that mounts a live-session
terminal (the Overview / "cockpit" page, `App.tsx:99`) freezes its CDP renderer, so stick to LIGHT,
non-terminal routes — `/settings` (`Sidebar.tsx:181,208-209`), `/skills` (`App.tsx:298`), `/platform`
(`App.tsx:248,292`). The generic version of this constraint (prefer the headless Playwright path; if you
must use the real browser, pick a light page) lives in `orchestrate/SKILL.md` — this bullet is where its
Loom-specific route names live, since a shipped skill can't name them (they don't exist in an end user's
own project).
- **Opt-in document conversion (`documentConversion`):** a session whose resolved Profile sets
`documentConversion` spawns with its OWN per-session stdio markitdown MCP (Microsoft `markitdown-mcp`, resolved to an ABSOLUTE path) and its single tool `mcp__markitdown__convert_to_markdown` allowlisted — so a research/document rig can convert files (PDF/Office/images/HTML/…) to Markdown to save tokens. **ffmpeg caveat:** AUDIO conversion needs `ffmpeg` on PATH; document formats (PDF/Office/images/HTML) don't. Without ffmpeg, `markitdown[all]`'s `pydub` prints a harmless "Couldn't find ffmpeg or avconv" to stderr on every invocation — documents convert fine. Loom does NOT install or suppress it. Structurally identical to `browserTesting`: default OFF + fully additive (every existing spawn byte-identical when off); pinned on the session row so resume/fork/recycle keep it. HUMAN-set only (Profiles UI/REST) — never an agent MCP tool (it launches a host process; same capability-gating posture as browserTesting/gateCommand). The binary is resolved (`pty/host.ts` › `markitdownMcpServer`) via the shared Python venv (below): first the human-only `LOOM_MARKITDOWN_BIN` override (an already-installed binary — and the TEST seam, so CI never builds a venv), else a single `fs.existsSync(loomVenvBin(…))` — if the venv is warm, inject it; if cold, return null (THIS spawn skips the MCP, like Playwright's missing-cli) and kick BACKGROUND provisioning. A later spawn picks it up once the venv lands — and the kick is RETRYABLE + DIAGNOSTIC (a failed attempt no longer dead-ends; the classified reason + captured error tail is surfaced via the status model + REST below). **One-time USER setup is just a base Python ≥3.10** discoverable on PATH (or pointed at via `python.interpreterPath`) — Loom owns the venv and the install.
- **Shared Loom-managed Python venv (`python/venv.ts`) + `python.interpreterPath`:** Loom owns ONE shared
venv under `<LOOM_HOME>/python/venv` — NOT a venv-per-tool. **Event-loop discipline (load-bearing):** the spawn HOT PATH (`createPty` → `buildMcpServers`) does NO blocking work — only `fs.existsSync(loomVenvBin( binary))` (instant). Creating the venv + `pip install markitdown[all]` takes minutes, so provisioning is fully ASYNC (`child_process.spawn`, never `spawnSync`) and BEST-EFFORT, off the event loop — mirroring `git/worktrees.ts` `createWorktree`. A synchronous `spawnSync` venv-create/pip on the spawn path would FREEZE the whole daemon (every spawn/resume, the web UI, all HTTP/MCP) for the entire install; that bug is what this split avoids. `ensurePythonPackageAsync({ package, binary, probeImport?, timeoutMs?, interpreterOverride? })` is the reusable surface EVERY Python-backed capability calls OFF the hot path (e.g. from a background job): it creates the venv if missing (from a discovered base Python via async `discoverBasePythonAsync` — `python.interpreterPath` FIRST, then `python3` → `python` → win32 `py -3`), pip-installs, and returns a CLASSIFIED `{ binary, outcome, errorTail }` — the ABSOLUTE console-script path + `ready` on success, else `binary:null` with the SPECIFIC failure (`no-base-python` / `venv-create-failed` / `pip-failed` / `timeout` / `disabled`) and the captured ~4KB stdout+stderr tail. venv/pip run with PIPED stdio (NOT `stdio:'ignore'`), so the REAL cause (proxy / SSL / resolver / timeout) is logged + surfaced, not lumped into one opaque "venv/pip failed". Idempotent (a ready venv hits a fast path via an import probe), BOUNDED (every spawn has a timeout; the markitdown `pip install` gets a ~15-min / `900_000`ms bound — `markitdown[all]` is heavy: onnxruntime + many converters — killed-on-exceed ⇒ classified `timeout`, while venv-create/probe keep their fast bounds), NEVER throws. The markitdown consumer kicks it RETRYABLY — deduped ONLY while a job is genuinely IN-FLIGHT (concurrent spawns never launch parallel installs), but a fresh kick is allowed after a TERMINAL outcome, so a profile-save pre-warm, a later spawn, or the human-only retry below all actually retry (NOT the old PERMANENT one-shot that dead-ended every retry until a daemon restart). It
tracks a status `{ state: idle|installing|ready|failed, reason, errorTail, binary, lastAttemptAt }` read via
the human-only loopback `GET /api/python/provisioning`; `POST /api/python/provisioning/retry` re-kicks it off the event loop (both NOT agent MCP tools — provisioning launches a host process, same trust posture as the git/vault/gateCommand writers). On success the resolved path is memoized (the cold case re-checks `fs.existsSync` each spawn until warm). `LOOM_PYTHON_NO_PROVISION=1` disables real venv/pip provisioning (CI hermetic tests + an ops escape hatch). Loom installs PACKAGES, never the interpreter. `python.interpreterPath` is a top-level human-only config (resolved by `resolveConfig`, carried to the daemon resolver via the session-env transport `pythonSessionEnv` → `LOOM_PYTHON_INTERPRETER`); it points at a host EXECUTABLE, so — exactly like `obsidian.path`/`gateCommand` — the agent-facing config validator REJECTS it (human REST path only). The venv is PRE-WARMED off the hot path to close the cold-skip window (`python/prewarm.ts`): at daemon boot if any profile sets `documentConversion`, and when a profile is SAVED with it on — both reuse the SAME deduped async background kick (`prewarmMarkitdown`), so the first documentConversion session usually finds the MCP already warm. The carried `python.interpreterPath` (a per-project config with no platform-override layer) resolves to the first project that sets one, else PATH discovery.
- **Worktree dep-provisioning (`git/worktrees.ts`):** `createWorktree` best-effort-installs deps at
creation so a worker boots build-ready. It picks the package manager by lockfile marker IN the worktree root — deterministic precedence pnpm (`pnpm-lock.yaml`) → npm (`package-lock.json`) → yarn (`yarn.lock`), no marker → no-op — and runs the matching install ASYNC + bounded by `PROVISION_TIMEOUT_MS`, best-effort (all failures swallowed; the worker installs on its own), HARDCODED commands (never agent input). Each worktree gets its OWN node_modules; node_modules is NEVER shared/symlinked/junctioned across worktrees (native modules + concurrent install-state would break — load-bearing). **A fresh worktree does NOT carry gitignored files — notably `.env`/secrets are absent.** A worker that needs env vars must be told so in its kickoff; provisioning deliberately does not copy or widen the secret surface.
## Conventions
- Node 22 + TypeScript, ESM (`NodeNext`) in daemon/shared; `bundler` resolution in web.
- One config-resolution mechanism (`resolveConfig`) — never read defaults ad hoc.
- **Conventional Commits, going-forward only** (do NOT rewrite published history). Every Loom-authored
commit subject is `type(scope): summary` — lowercase type, imperative, no trailing period, ≤~72 chars. Allowed types: `feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert`. **The scope is REQUIRED** and must come from the "Commit scopes" list below. Managers title board cards in this form (NO `[Type, Priority]` bracket — priority is the card's field); **for a SOLO `worker_merge_confirm`**, the per-task squash merge uses the card title verbatim as the commit subject, so a conventional title is a conventional commit, and a title that slips is coerced by a merge-code safety-net (`toConventionalSubject` in `git/worktrees.ts`: legacy bracket → mapped type, bare prose → `chore:`) — but the net only guarantees a valid *type*; it can't know the scope, so scope is enforced by titling, not the helper. **Neither claim holds for a BATCHED landing (`merge_batch`, `git/batch-merge.ts`)** — card `a32533a1`: a batch never rewrites or coerces anything, it lands each candidate branch's OWN commit subjects verbatim (`toConventionalSubject` never runs on that path), so the card title never becomes the commit subject there and a non-conventional worker commit is never coerced. `worker_merge`'s review step surfaces `ownTipSubject`/`ownTipSubjectConventional` — the branch's uncoerced tip subject and whether it's already conventional — so a manager can title-check what a batch would actually commit before routing a worker into one instead of a solo confirm. Title it right at the source, on every worker commit, not just the card.
- **The subject states WHAT THE COMMIT DOES, not the defect it removes.** A defect-describing subject (e.g. "X gives no signal — a capped read returns...") is stale at merge by construction: the card is named for the problem but merged for the solution, so a present-tense defect description becomes permanent mainline history asserting a defect the commit just fixed. Put the defect/root-cause in the commit **body** instead — it is equally searchable there (`git log --grep` matches the FULL commit message, body included, not just subjects — verified: a token present in zero subjects across 400 commits still matched 1612 commits via `--grep`) and, unlike the subject, the body was never promoted into the one place this repo has a standing rule never to rewrite. **Scope (card `8b7b81e0`): this applies as written to a hand-authored commit, where you write the body yourself.** A worker's per-task **solo** squash merge is different (see the Conventional Commits bullet above for the `merge_batch` exception — its subject is NOT the card title) — its SUBJECT is always the card title verbatim (a worker never chooses it), and its BODY is auto-assembled by the merge code (`git/worktrees.ts`'s `mergeBranchLocked`, via `deriveWorkerCommitLogBody`) from the worker's OWN commit subjects on the branch — bounded (`WORKER_COMMIT_LOG_MAX_ENTRIES`/`WORKER_COMMIT_LOG_MAX_CHARS`) and omitted entirely when the branch is a single commit whose subject already matches the title. A worker who wants a defect/root-cause to survive on main must write it into their OWN commit **subject(s)** as they go, not just their `worker_report` or chat — `deriveWorkerCommitLogBody` reads `--format=%s` only, so a commit's own **body** text never propagates, only its subject line does — and only what actually landed on the branch feeds the squash body, still capped, not unlimited free text.
- **Worker commits omit the `Claude-Session:` trailer.** That harness-mandated trailer is for human-driven commits; a Loom WORKER's per-task squash merge auto-appends its own `Loom-Worker-Branch:` trailer instead, so a worker should not add `Claude-Session:` and then have to `git commit --amend` to strip it.
### Commit scopes
Loom's commit scope vocabulary, derived from the real tree. Pick the one subsystem a change lands in; prefer the finest-grained scope that fits.
- **Packages:** `daemon`, `web`, `shared`, `cli` (the `bin/loom.mjs` management CLI).
- **Daemon subsystems** (`packages/daemon/src/*`): `agents` (agent-definition validation + the prompt-lint tool tables `agent-prompt-lint-surface-drift.mjs` checks against real router registrations + agent cloning), `capabilities` (the browserTesting/documentConversion/github-binary capability registry + seed data gating per-session MCP provisioning), `codescape` (the code-graph MCP: manifest/supervisor/tools-probe/drift-notice), `companion`, `connections`, `gateway`, `git`, `keys` (secret-envelope encryption + API-key/gateway-token hashing behind every stored credential), `mcp`,
`memory` (project-scoped shared agent memory — `project_memory` FTS5 store + kickoff injection;
`memory_write` UPDATES an existing key only with a read-first `baseVersion` — the tool description owns the mechanics),
`orchestration`, `platform`, `profiles`, `projects` (repo binding/rebinding, deny-globs, reference-repo mirrors, vault-path resolution), `pty`, `python` (the shared Loom-managed Python venv + prewarm — see the Load-bearing invariants section above), `runs`, `sessions`, `setup`, `skills`,
`tasks` (the task board / cards domain), `update`, `vault`, `webhooks` (inbound webhook ingress/verification/storage + outbound alert formatting).
- **Cross-cutting / process:** `deps` (dependency bumps), `ci`, `release`, `docs`, `assets` (bundled
skills / hook-relay / vault-lint under `packages/daemon/assets/**`), `site` (the hand-authored GitHub Pages landing page under `site/**`).
- **Top-level `scripts/` files aren't their own scope** — pick the scope of what the script SERVES (e.g. a daemon-supervisor script → `daemon`, `check-main-ci` → `ci`).
This list is **Loom-specific**. Every project keeps its OWN "Commit scopes" list in its OWN `CLAUDE.md`; a project with no documented list gets one derived from its structure at intake, and only a project with no meaningful code subdivisions may go scopeless.
- Vault + git writes are enabled via a HUMAN-only REST surface (vault: `vault/writer.ts`; git:
`git/writer.ts` — checkout/commit/push/create-branch). These are trust-boundary surfaces like gateCommand: NO **core / project-session** MCP tool exposes GIT writes; an agent in an ordinary project session can never checkout/commit/push. VAULT is narrower, not absolute: the core surface DOES expose one gated project-session tool, `vault_write` (card `be8be211`, `mcp/server.ts`) — OMITTED from `tools/list` entirely (not merely denied) unless the session's Profile sets the `vaultWrite` key, which is OFF by default (no bundled profile seeds it) and settable ONLY by a human (Profiles UI/REST, never an agent); write-only (create/overwrite, no delete) and confined to that session's own project vault via the same `resolveInVault` traversal guard `vault/writer.ts` uses. Beyond that gated core-surface case, there are now two further deliberate exceptions to the human-only-REST default for GIT (and, for the first, vault too). (1) The `LOOM_DEV`-gated **Platform Lead** surface (`mcp/platform.ts`), which is itself human-driven and ABOVE all projects: it registers `git_checkout`/`git_create_branch`/`git_commit`/`git_push` + `vault_write` as agent tools (each reusing the same bounded writer code by explicit `projectId`). (2) **Codex worker auto-commit** (card `00a6cdd6`): a codex-harness worker's own uncommitted worktree changes are committed onto its OWN assigned branch as a side effect of `SessionService.workerReport` handling its `done` report (`git/worktrees.ts`'s `attemptCodexAutoCommit`) — daemon-side, worktree-scoped, branch-scoped, NO push, and never a new agent-callable tool; it exists only because a codex worker's own CLI sandbox denies every write under `.git` on every OS, so this is the sole way it can ever land a commit. Gated STRICTLY LAST among the method's own refusals: it never runs for a report already refused for an earlier reason (queued manager direction, the auto-recovery dedupe), and never at all when the report itself declares `noChanges:true` (a worker asserting no changes must never be auto-committed for, even over real dirty files). Every successful commit is audited immediately and unconditionally via a dedicated `codex_auto_commit` orchestration event — appended before anything downstream (the uncommitted-files precheck, `updateTask`, the report's own event) gets a chance to refuse or throw — so no daemon-authored commit can exist with nothing naming it. Every git write is bounded by a timeout so a hung git call can't wedge the daemon. `GIT_TERMINAL_PROMPT=0` is a separate, narrower guarantee on top of that: it applies to the paths that can actually reach the network (`git/writer.ts`'s fetch/push/clone surface) and is deliberately omitted where the call set is purely local — `vault/versioner.ts`'s `commitVault` (the primary vault commit path) is the live exception; see `boundedVaultGit`'s own doc comment there for why. The read-only log/branches view is unchanged.
### Comment taxonomy — the source-vs-record split
**The generic rule now lives in the shipped doctrine, not here.** The four comment classes (guard /
decision-record / contract-docs / noise), the `@decision <id>` / `@decision sha:<8hex>` anchor grammar,
the `docs/adr/` (immutable) vs `docs/decisions/` (mutable) split, and "one record file per id, always" —
read them in `packages/daemon/assets/skills/worker/SKILL.md` ("Extracting a decision record") and
`packages/daemon/assets/skills/orchestrate/SKILL.md` (the matching review-side bullet). Every Loom
project gets that doctrine, not just this one; what follows here is Loom's OWN tooling on top of it —
specific to this repo, not part of the generic rule.
**Loom keeps a THIRD register the generic doctrine doesn't have**, alongside `docs/adr/` and
`docs/decisions/`: `docs/investigations/` — narrative incident reports, not decisions. A sha-keyed record
(card `969b0e1c`, deciding card `966238f1`) lives in whichever of the three stores fits, named by the same
`<8hex>-slug.md` convention as a card-keyed one.
⛔ **A bare 8-hex sha after `@decision` is NOT the commit form and never has been** — `ANCHOR_RE` matches
an 8-hex sha byte-for-byte indistinguishably from a card id, so a bare 8-hex always means a board card;
the commit id-space requires the `sha:` sigil. `decision-records.mjs`'s resolver verifies a cited sha
(`git rev-parse --verify`) and REFUSES an anchor whose sha no longer resolves in this repo, rather than
silently falling through to a same-named record file.
**A record anchored this way gets a GUARD EXTRACT auto-injected alongside a `Read` whose range covers the
anchor line — never the full record** (the `decision-records.mjs` PostToolUse hook, card 661b7d46): the
record's title plus its `Do not` section(s) (or an explicit no-Do-not note, for the minority of records
without one), plus a pointer to the full file — the narrative around them is deliberately never injected
(`@decision abd049da`, `@decision 8449a258`). That extract is itself bounded by a per-record byte cap
(`PER_RECORD_MAX_BYTES` in that script; read it there rather than trusting a number restated here, per
this file's own "point at a source of truth" rule) — an over-cap extract still injects, but head+tail-
truncated with an explicit marker rather than complete. Follow the pointer and `Read` the record file
directly for the narrative. Keep a record under the cap, or expect truncation and don't be surprised by
it (card `da723d41`). **A single `Read` call is also capped in how many DISTINCT records it injects**
(`MAX_RECORDS_PER_CALL` in that same script — read it there, not here) — independent of the byte cap
above, since a byte budget alone doesn't bound record COUNT when many small records compete at one
anchor-dense site. Records beyond the cap, and any that still lose the shared byte budget, are omitted
whole and named explicitly in a bounded omission note (`OMISSION_NOTE_MAX_LISTED` — never one line per
omission) rather than silently dropped (card `65dddbe9`).
`comment-anchor-lint.mjs`'s `collidingRecords` check (CLI-scan only — see that script's own header) flags
a same-id-two-files violation after the fact, but it is a backstop, not the fix: by the time it fires, the
second file's own content has already been written and one of the two records has already gone
permanently unreachable — search first (`find docs/adr docs/decisions -iname "<id>*"`, or
`docs/investigations/<id>-*/`) before writing a new record, per the generic doctrine's own rule.
### Vault structure
Loom's design docs live in the Obsidian vault at `Projects/Loom/` in a **shallow (one-level), stable** taxonomy — not a flat wall of notes. **Fixed-path / canonical docs stay pinned at the vault root** — including the ones this `CLAUDE.md` references by exact path (`Architecture.md`, `Vision & Architecture.md`, `Setup Assistant Design.md`), which is *why* they're pinned: moving them would break those refs. The root-pinned set: `Architecture.md`, `Vision & Architecture.md`, `Setup Assistant Design.md`, `Companion Design.md`, `Loom.md`, `Platform Manager.md`, `Orchestrator Log.md`. (The Platform Lead's living resume doc is **not** a vault note — it's a LOOM_HOME operational file at `~/.loom/PLATFORM-LEAD-RESUME.md`, injected into each Lead spawn.) **Every other note lives in a taxonomy folder:** `Design/`, `Operations/`, `Roadmap/`, `Release/`, `Spikes/`. One non-note folder, **`Mockups/`**, archives design-mockup deliverable SETS the owner reviews before a build — one sub-folder per feature named `YYYY-MM-DD <Feature>`, each holding the interactive mockup HTML + rendered PNG directions (see `Mockups/README.md` for the set list + which direction was picked). The **LEAD files a completed mockup set there** (the Web Designer produces it and reports the path); verification screenshots of a *built* change are NOT mockups — those stay under `~/.loom/workspaces/`, never the vault. An **`_Index.md`** map-of-content at the vault root lists every note by group — **read it to locate a note instead of Globbing, and update its line when you add or move a note.** Wikilinks resolve by note name, so moving a note between folders never breaks a `[[link]]`.
Like "Commit scopes", this folder vocabulary is **Loom-specific** — every project keeps its own "Vault structure" section in its own `CLAUDE.md`; the shipped skills teach the generic shallow-taxonomy + `_Index.md` principle and read the folder names from here.