Plan, approve, and run tasks through an enforced pipeline: main plans, owners edit code in worktrees, the mod owns git.

A Claude Code mod that makes planning, approval and execution an enforced loop. Main plans and manages; it never edits code and has no shell. Each approved task runs its type's pipeline in its own git worktree (tester red → worker green → independent review → merge). The mod owns git; workers cannot commit or push. State lives in beads (bd).
Design: docs/design/DESIGN.md v2.5 · UI: docs/design/UI-DESIGN.md · M0 results: docs/adr/0001-m0-results.md · extension protocol: docs/protocol.md · module contracts: docs/CONTRACTS.md.
Requirements: Claude Code 2.1.295 (the pinned build), git, bd ≥ 1.3.0 (tested with 1.3.1), bwrap (bubblewrap ≥ 0.8, Linux) for jailed checks. Per repo: whatever toolchain its checks need (discovered, never assumed).
From the marketplace (this repo is one):
claude plugin marketplace add it-dainb/cadre # in Claude Code: /plugin marketplace add it-dainb/cadre
claude plugin install cadre@cadre # in Claude Code: /plugin install cadre@cadre
Development (from a checkout; use the 2.1.295 binary, not whatever claude is on PATH):
CC=<path to claude 2.1.295>
$CC plugin validate --strict . && $CC plugin test .
$CC --plugin-dir . # in the repo you want to manage
Debug a run: add --debug-file cadre.log. Live smokes/evals run in Docker via harness/ (also able to install from the marketplace: CADRE_SOURCE=marketplace). Live evidence (docs/evidence/) is local-only and gitignored.
/cadre setup, then /cadre init/cadre setup (once per machine, human only) checks Claude Code, git, bd, bwrap, socat, the worktree root and the user settings cadre relies on, then shows a plan in the cadre pane (Doctor tab). Everything it can fix without a password becomes an action (installing bd, the settings merge, creating the worktree folder); The decision card has [a] Fix all, [r] Re-check, [x] Cancel. Fix all runs the actions in order (including moving a stale bd in a folder you own aside as bd.cadre-old, never deleting it), re-checks, and, in a repo where cadre is not on, hands anything still failing (a failed action, an odd npm prefix) to Claude as one prompt; when Claude stops, the checks run again by themselves. In a repo where cadre is on, main has no shell, so only the scripted fixes run and the card says to open Claude in another folder. What needs root (installing bubblewrap and socat, the AppArmor profile for bwrap on Ubuntu 24.04+, removing a stale bd from a system folder) is never automated, because ! sudo inside Claude cannot ask for a password. Setup writes each such fix as a script under ~/.cache/cadre/fix/ (read it first; it does nothing until you run it) and the card shows one short line per fix, e.g. sudo sh ~/.cache/cadre/fix/bwrap-apparmor.sh; [y] copies it, you paste it into a terminal, then press [r]. Less safe alternatives are only described in ~/.cache/cadre/fix/README. A newer Claude Code than the tested build only warns. /cadre doctor re-runs the machine checks and adds the project ones (init stages, containment, beads, branches, leases, flags, sync, drive); it is read-only and safe any time. The command text is one line, the log gets one summary line, and the full report is in the pane: the 8: Doctor tab keeps the last report (/cadre open doctor reopens it; /cadre doctor re-runs it).
/cadre init/cadre init runs the next incomplete stage; /cadre init <stage> re-runs one. Each stage shows its diff on the decision card (init 2/8 · store, repo-relative paths): [a] Confirm validates by running, then saves (a .bak is written first), and the next stage's card follows on its own. A failed stage stays on the card with the reason (! last try: …) and a new code. The typed /cadre init confirm <code> still works; the code is redrawn on every attempt. The command answer is one line. Stages 3 and 4 hand Claude one prompt: it asks you for the mission (then you approve it on its card), and it spawns the discovery explorer (diff 2 follows its report). Stage 1 on a folder with no repo makes the first commit, which carries your git identity. If git has none, the card asks for your name and email, sets them (git config --global) and finishes the stage. /cadre setup does the same. See ADR 0009.
| # | Stage | Result |
|---|---|---|
| 1 | repo | git repo, default branch, dev branch, .git/info/exclude |
| 2 | store | bd init, custom statuses/types, journal consent |
| 3 | mission | goal, constraints, non-goals recorded as a milestone |
| 4 | discovery | explorer reads CI/manifests; setup and checks are validated in a jail |
| 5 | containment | sandbox settings merge + learned cache dirs |
| 6 | workflow profile | type registry, role tiers, tdd.author, drive bounds |
| 7 | changelog | Keep-a-Changelog skeleton |
| 8 | sync (optional) | external tracker adapter |
TaskCreate (metadata.kind:"plan", then tasks). Each task has a type, package, Given/When/Then scenarios, a changelog entry or {none, reason}, and an interface contract. Drafts live in beads and never run./cadre approve <planId>; the Plan tab shows the sheet. Approval needs a human origin (composer; SDK only if approval.allowSdk) and burns its code on every attempt. The mod cuts epic/<id>-<slug> from dev.Agent for a task. The mod claims it, makes a worktree, and runs the pipeline: cadre:tester (sees only the spec view) → red check must fail → cadre:worker → green check must pass → cadre:reviewer (verdict bound to a commit). Rework resumes the same agent; cap review.maxRework.completed; the mod squashes into the epic branch. When all tasks are done, completing the epic merges --no-ff into dev./cadre release [x.y.z] cuts the changelog and merges dev into main. cadre never pushes.Types (feature, bug, refactor, docs, test, spike) and their pipelines come from the registry (config types).
/cadre … | Who | Effect | ||||||
|---|---|---|---|---|---|---|---|---|
approve <id> · approve <id>@<code8> | human | open the sheet · approve (code burned on use) | ||||||
reject <planId> <why> | human | drop drafts | ||||||
| `resolve <id> keep\ | accept\ | reject\ | cancel · resolve git keep\ | restore` | human | clear flags / quarantined git effects | ||
release [x.y.z] | human | cut a release | ||||||
setup [confirm <code>] | human | one-time machine check; proposes user-settings changes | ||||||
doctor | anyone | read-only diagnosis, report in the cadre pane | ||||||
init [<stage>] [confirm <code>] | human | staged setup | ||||||
sync add <adapter> · sync [now] | human · anyone | external tracker setup · wake sync | ||||||
adapter token | human | new adapter session token (toast only) | ||||||
| `config [show\ | set <k> <v>]` | set: human | show/change config | |||||
| `drive on\ | off\ | status` | on/off: human | autonomy: cadre's own bounded goal | ||||
status | anyone | text board and cost line | ||||||
| `open [epic\ | plan\ | task <id>\ | sync\ | cost\ | release\ | init]` | anyone | open the cadre pane tab |
Decisions by click (0.4.0). Every human action above also appears as a decision card in the cadre pane (1 on the band opens it; it opens itself when there is room): a framed box with the question, a short description and buttons (Approve/Reject/Change, Confirm/Cancel, Accept/Reject, Keep/Restore, Release). A button press is human evidence and runs the same command bound to the code it was drawn with; a stale card is refused and redrawn. Where nothing draws (VS Code, -p) the typed commands above are unchanged. See ADR 0008.
Native tools main uses: TaskCreate/Update/List/Get, Agent. Agents use three MCP tools: report, inspect, run_check.
Layers, low → high: built-in defaults → plugin userConfig → ~/.claude/cadre.json (with per-repo sections) → repo .cadre/config.json (main checkout) → env (CADRE_STRICTNESS, CADRE_STORE, CADRE_ALLOW_SDK_APPROVAL, CADRE_DISABLE) → /cadre config set. S keys: a repo file may only tighten them. U: user-level only. O: ordinary.
| Key | Class | Default | |||
|---|---|---|---|---|---|
strictness | S | strict (advisory turns denials into warnings) | |||
approval.allowSdk | S | false | |||
mcpAllow | S | [] | |||
jail, jailWritable | S | auto, [] | |||
effectCheck | S | auto | |||
roles.testerView | S | view (full = tester sees the worktree) | |||
adapters.<name> | U | none | |||
store.kind, store.bin, store.dir | U | beads, bd, repo | |||
worktrees.root | U | ${XDG_CACHE_HOME:-$HOME/.cache}/cadre/wt | |||
drive.{level,maxTurns,maxCostUsd,maxWallMinutes,noProgressTurns} | U | epic, 100, 20, 240, 3 | |||
cost.prices, disable | U | none, false | |||
store.timeoutMs, store.pollMs | O | 45000, 10000 | |||
types | O | feature/bug/refactor/docs/test/spike | |||
| `roles.<worker\ | tester\ | reviewer\ | explorer>.{tier,effort} · roles.tdd.author` | O | standard/default (explorer fast/low) · separate |
models | O | {fast: haiku, standard: sonnet, deep: opus} | |||
packages[] | O | discovered and validated by /cadre init | |||
suites.integration | O | all | |||
review.{deepLabels,maxRework} · limits.reportRetries | O | ["security","breaking"], 3 · 3 | |||
changelog.path | O | CHANGELOG.md | |||
branches.{main,dev,epicPrefix} | O | init · dev · epic/ | |||
release.onEpicDone | O | propose | |||
ui.glyphs | O | unicode (ascii for fonts without the glyphs) | |||
maxParallel, maxRows | O | 3, 6 | |||
reuse.{maxContextPct,cacheTtlMs} | O | 60, 3600000 | |||
cost.{clearHintPct,compactPct} | O | 40, 60 | |||
sync.<target> | O | none |
cadre pane (ui/): Epic and Plan tabs, approval sheet, events, timeline. Read-only clients of one versioned read model./cadre status, toasts, the card returned in the Agent result.docs/protocol.md): events, snapshot, commands, and external process adapters over one protocol; adapters get the model view only and need grants.TaskCreate.| Item | Why | Ever? |
|---|---|---|
| Parsing shell command text or stderr; deny lists of channels | Brittle; layers decide on effects, not text | never |
| Language/ecosystem presets in code | Stack specifics are discovered and validated config | never |
Magic keywords, Stop-hook loops, /goal, statusLine HUD command, skills | One loop authority | never |
| Op-log sync queue | Sync is state-based reconciliation | never |
| Pane teammates, agent teams, workflow agents | Unseen or rewrite-proof | roadmap (in-process teammates) |
| Creating remote projects or custom fields | Admin actions; setup maps to what exists | roadmap flag |
| Everything on the DESIGN §15 roadmap line | Budget, YAGNI | roadmap |
cadre stops model drift, not the user: a user-tier mod can be switched off. It never pushes.
From the M0 spike (ADR 0001):
/clear), S37 (! raises no tool.call), S48 interactive /goal, S49 trigger ($.session.compact() is unavailable headless, so the 60 % context hint is a toast, not an automatic compact). S41's MCP grant is rechecked at M3./goal is undetectable (S48, A27). After /resume with drive on, a one-time band hint appears. Do not combine a restored /goal with /cadre drive on: two loops would run. /goal typed in the session is observed as prompt text and pauses drive.HOME listing.--if-revision. Drive and sync leases live as metadata on the cadre-control issue, with read-check-write-reread (guarded by --if-assignee/--if-status). Drive continues after /clear (DESIGN §9).-p) runs end with main's turn (a background agent is killed): spawn agents in the foreground. plugin eval --allow-tools refuses mcp__*: name mcp__cadre__report mcp__cadre__inspect mcp__cadre__run_check. Live results: ADR 0005.! shell commands in a human session may bypass the hooks (A20 unverified); the effect check treats such changes as human.git add/commit (repo-level denyWrite, S24): the mod commits. /tmp is not writable for subagents; use $TMPDIR.node scripts/budget.mjs # LOC budget: core ≤ 5000, ui ≤ 750, 3 MCP tools, 0 skills
$CC plugin validate --strict . && $CC plugin test .
node --experimental-transform-types --test tests/git/*.mjs tests/protocol/*.mjs
Live smokes: tests/smoke/ (S01–S61). Real-model evals: evals/, run through the harness (harness/run.sh), never on the host. Decisions: docs/adr/.
hooks/register.ts 164 lines1// The only file that calls on() or touches $. Registration ORDER is the chain order (DESIGN 2.2, outermost first).
2// Each slot calls a function in its owner module with a per-hook Host (never a module-level Host, L3). Gating registrations carry a fail-closed .catch (13.3).
3import { atom, read, update } from 'claude-code'
4import type { Register } from 'claude-code'
5import type { Host } from '../types'
6import { UI, emptySnapshot } from '../core/board'
7import { emptyRing, subscribe } from '../core/events'
8import { drain, step, compact, submit } from '../core/reuse'
9import { goal } from '../core/drive'
10import { wake, warm } from '../core/session'
11import '../config/route' // registers ROUTES.config
12import { observe } from '../core/close'
13import '../core/integrate' // registers ROUTES.release
14import { admit, tick as heartbeat } from '../core/admission'
15import { contain } from '../policy/containment'
16import { check, guard, send, judge, judgeSpawn, configSet } from '../policy/guards'
17import { answer, describe } from '../native/tasks'
18import { wrap } from '../core/effect'
19import { handle, SPECS } from '../tools/index'
20import { context, attachment } from '../core/briefs'
21import { press, run } from '../commands/cadre'
22import { afterFix, nudge, turnStarted } from '../commands/doctor'
23import { tick } from '../task-management/sync-engine/loop'
24import { onEvent, renderBand, renderCard, renderPane, tickUi, ack, mapCard, resumeBanner } from '../ui/index'
25import { load } from '../config/index'
26import { newToken } from '../core/dispatch'
27import { wire, repoKeyOf, rootFor } from '../commands/init'
28import { BdStore } from '../task-management/adapters/beads/store'
29import { MemoryStore } from '../task-management/adapters/memory/store'
30
31const board = atom({ plugin: 'cadre', key: 'board' } as const, emptySnapshot())
32const events = atom({ plugin: 'cadre', key: 'events' } as const, emptyRing())
33const secret = atom({ plugin: 'cadre', key: 'secret' } as const, {})
34const ui = atom({ plugin: 'cadre', key: 'ui' } as const, {})
35const git = atom({ plugin: 'cadre', key: 'git' } as const, { busy: false, epoch: 0 })
36const bind = atom({ plugin: 'cadre', key: 'bind' } as const, {})
37
38// The one TaskStore factory (init stage 2 and dispatch both use `wire.store`): memory for CADRE_STORE=memory (tests), else bd. Config is the full layered load.
39let mem: MemoryStore | undefined
40wire.store = async (h, cfg, root) => {
41 if (cfg.store.kind === 'memory') { if (!mem) { mem = new MemoryStore(); await mem.init({ types: cfg.types as any, journal: false }) } return mem }
42 const actor = String((await h.store.get(`cadre:${repoKeyOf(root)}:actor`)) ?? '') || `cadre/${h.rand(3)}`
43 await h.store.set(`cadre:${repoKeyOf(root)}:actor`, actor)
44 return new BdStore({ run: h.run, cwd: root, actor, bin: cfg.store.bin, timeoutMs: cfg.store.timeoutMs })
45}
46
47let options: Host['options'] = {} // set by register(); module vars reset on reload, which re-runs register
48
49// Host per hook entry: literal closures over $. Additions are append-only (types/index.d.ts).
50function host($): Host {
51 const h: Host = {
52 now: () => $.clock.now(),
53 rand: (n = 16) => Array.from(crypto.getRandomValues(new Uint8Array(n)), b => b.toString(16).padStart(2, '0')).join(''),
54 board: { read: () => read($, board), update: fn => update($, board, fn) },
55 events: { read: () => read($, events), update: fn => update($, events, fn) },
56 secret: { read: () => read($, secret), update: fn => update($, secret, fn) },
57 ui: { read: () => read($, ui), update: fn => update($, ui, fn) },
58 git: { read: () => read($, git), update: fn => update($, git, fn) },
59 bind: { read: () => read($, bind), update: fn => update($, bind, fn) },
60 store: { get: k => $.store.get(k), set: async (k, v) => { await $.store.set(k, v) }, del: async k => { await $.store.delete(k) } },
61 env: async () => ({
62 CADRE_STRICTNESS: await $.env.get('CADRE_STRICTNESS'), CADRE_STORE: await $.env.get('CADRE_STORE'),
63 CADRE_ALLOW_SDK_APPROVAL: await $.env.get('CADRE_ALLOW_SDK_APPROVAL'), CADRE_DISABLE: await $.env.get('CADRE_DISABLE'),
64 GIT_AUTHOR_NAME: await $.env.get('GIT_AUTHOR_NAME'), GIT_AUTHOR_EMAIL: await $.env.get('GIT_AUTHOR_EMAIL'), // identity prefill (core/git.identity)
65 }),
66 readText: async path => { try { return String(await $.fs.read(path)) } catch { return null } },
67 log: t => { void $.ui.log(t) },
68 toast: t => { void $.ui.toast(t) },
69 options,
70 stat: async (p, o) => { try { return await $.fs.stat(p, { resolve: !!o?.resolve }) } catch { return undefined } },
71 run: (argv, init) => $.process.run(argv, init),
72 settings: () => $.settings.read(),
73 spawn: async a => { // adapter protocol (SA-09): one request, stdin closed after `input`; leaving the loop kills the child
74 const it = $.process.spawn({ argv: [...a.argv], input: a.input })[Symbol.asyncIterator](), dead = new Promise<'t'>(r => { void $.clock.after(a.timeoutMs ?? 30_000, () => r('t')) })
75 let stdout = '', stderr = ''
76 try {
77 for (;;) {
78 const r: any = await Promise.race([it.next(), dead]); if (r === 't') throw new Error('timeout')
79 if (r.done) return { exitCode: r.value?.code ?? null, stdout, stderr }
80 if (r.value.stream === 'stderr') stderr += r.value.text; else stdout += r.value.text
81 }
82 } finally { void it.return?.(undefined) }
83 },
84 write: async (p, t) => { await $.fs.write(p, t) },
85 cwd: async () => (await read($, secret)).root ?? rootFor(((await $.store.get('cadre:roots')) ?? undefined) as Record<string, string> | undefined, await $.session.cwd()), home: async () => String((await $.env.get('HOME')) ?? ''),
86 ops: { // ui/ side effects; a press's own work and every timer run through after()
87 els: e => $.ui.resolve(e), open: a => $.ui.open(a), focused: async () => (await $.ui.panes()).some(p => p.id === 'cadre' && p.isFocused),
88 toast: (t, o) => { void $.ui.toast(t, o) }, status: t => { void $.ui.status(t) }, invalidate: () => { void $.ui.invalidate('ui.render') }, blit: a => $.ui.blit(a),
89 notify: async (t, o) => { try { return { isSent: !!(await $.ui.notify(t, o)).isSent } } catch { return { isSent: false } } },
90 peek: async () => (await $.prompt.read()).text, fill: async t => ({ isFilled: !!(await $.prompt.fill({ text: t, mode: 'replace' })).isFilled }), copy: async t => ({ isCopied: !!(await $.ui.copy({ text: t })).isCopied }),
91 after: (ms, fn) => { void $.clock.after(ms, () => { void Promise.resolve(fn()).catch(() => {}) }) },
92 },
93 }
94 h.config = async () => { const root = await h.cwd(); return (await load(h, { home: await h.home(), root, repoKey: repoKeyOf(root) })).config }
95 h.taskStore = async () => wire.store(h, await h.config!(), await h.cwd())
96 h.press = (id, key, hash) => press(h, id, key, hash)
97 h.sendTo = (agentId, text) => $.session.send({ to: { agentId }, text }) // core/reuse.drain only
98 h.submit = text => $.prompt.submit({ text }) // drive, from #1 after next only
99 h.usage = () => $.session.usage()
100 h.version = async () => String((await $.session.version()).version)
101 return h
102}
103
104// Fail-closed handlers (G1, G2). On re-entry every $ rejects, so decide from the event alone.
105const FAIL = 'refused: temporary failure; retry once, then ask the human'
106const why = ($, next) => { if (next.error && next.error.kind !== 're-entry') void $.ui.log(`cadre: hook failed: ${next.error.kind}: ${next.error.message}`); return FAIL } // the engine redacts the error in its own log: keep the reason in ours
107function denyCall($, e, next) { return next.error?.kind === 're-entry' ? (judge(e) ? { deny: judge(e) } : next(e)) : next.called ? next(e) : { deny: why($, next) } }
108function denySpawn($, e, next) { return next.error?.kind === 're-entry' ? (judgeSpawn(e) ? { deny: judgeSpawn(e) } : next(e)) : next.called ? next(e) : { deny: why($, next) } }
109function denyCheck($, e, next) { return next.error?.kind === 're-entry' || !next.called ? { decision: 'deny', reason: why($, next) } : next(e) }
110// prompt.submit / session.compact only add context or a summary: on failure fall back to the engine's own (deliberate fail-open). /cadre never acts on failure.
111function passOn($, e, next) { return next(e) }
112function denyCmd($, e, next) { return next.called ? next(e) : { text: FAIL } }
113function denySend($, e, next) { return next.error?.kind === 're-entry' ? next(e) : next.called ? next(e) : { isDelivered: false, reason: FAIL } }
114
115export const register: Register = (on, opts) => {
116 options = { strictness: opts?.strictness as string | undefined }
117 subscribe(onEvent) // ui/notify: toasts, notify, status line from the event stream
118 UI.mapCard = mapCard; UI.banner = resumeBanner // core -> ui hooks (core never imports ui/)
119
120 // session.start: agents, command, tools env, the one tick. Awaited before turn one (L7).
121 on('session.start', async ($, e, next) => {
122 await $.command.register({ name: 'cadre', description: 'cadre: plan, approve, status, resolve, release, drive, init, setup, doctor', argumentHint: '<sub> [args]', immediate: true })
123 const root = await $.session.cwd(); await update($, secret, x => ({ ...x, root })); await $.store.set('cadre:roots', { ...(((await $.store.get('cadre:roots')) ?? {}) as object), [repoKeyOf(root)]: root }) // the repo root for every hook: in a subagent hook $.session.cwd() is the agent's own view/worktree
124 await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1')
125 await $.env.set('ENABLE_TOOL_SEARCH', 'false') // S13: isDeferred:false alone does not stop deferral
126 await wake(host($)).catch(e => $.ui.log(`cadre: session start: ${e}`)) // active repos only: $.store 'policy' + cadre.ui.cfg + containment band (agents ship as agents/*.md files, A28)
127 for (const t of SPECS) await $.tool.register(t) // report, inspect, run_check: isDeferred:false (5.2)
128 { const h = host($); if (await wake(h).catch(() => false)) await nudge(h).catch(() => {}) } // active repos only: no setup stamp for this Claude Code build -> one toast
129 $.clock.every(5000, () => { const h = host($); void wake(h).then(act => { if (act) { void tick(h).catch(() => {}); void heartbeat(h).catch(() => {}) } }).catch(() => {}) }) // timer calls skip all our hooks (G6): queue/heal only
130 $.clock.every(30_000, () => { const h = host($); void wake(h).then(act => act && tickUi(h)).catch(() => {}) }) // live timeline cursor (blit, no re-render)
131 return next(e)
132 })
133
134 // 1. resume-drain: the only sender, after await next(e), never from a timer. 2. observe.
135 on('turn.complete', { reason: /./ }, async ($, e, next) => { if (!(await wake(host($)))) return next(e); return drain(host($), e, next) })
136 on('turn.complete', { reason: /^/ }, async ($, e, next) => { if (!(await wake(host($)))) return next(e); return observe(host($), e, next) })
137 on('turn.complete', { reason: /./ }, async ($, e, next) => { const r = await next(e); if (!e.agentId) await afterFix(host($), e).catch(() => {}); return r }) // setup's hand-off to main: send it / re-check when main stops; works in dormant repos too
138 on('turn.start', async ($, e, next) => { await turnStarted(host($), e).catch(() => {}); return next(e) }).catch(passOn) // marks the turn that setup's hand-off started
139 // 3. admission
140 on('agent.spawn', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return admit(host($), e, next) }).catch(denySpawn)
141 // 4. tool.check allow
142 on('tool.check', async ($, e, next) => { if (!(await wake(host($)))) return next(e); await warm(host($)); return check(host($), e, next) }).catch(denyCheck)
143 // 5. guards (L1, fence, Task* answers, MCP pre-checks): guards judge first, then main's Task* answers
144 on('tool.call', { tool: /./ }, async ($, e, next) => { if (!(await wake(host($)))) return next(e); await warm(host($)); return guard(host($), e, e2 => answer(host($), e2, next)) }).catch(denyCall)
145 // 6. wrap (L3 per-call fingerprint, delta drain, result card, tester log); then our own MCP tools answer innermost
146 on('tool.call', { tool: /^/ }, async ($, e, next) => { if (!(await wake(host($)))) return next(e); return wrap(host($), e, next) }).catch(denyCall)
147 on('tool.call', { tool: /^mcp__cadre__/ }, ($, e, next) => handle(host($), e, next)).catch(denyCall)
148 on('tool.describe', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return describe(host($), e, next) })
149 // 7. session.send
150 on('session.send', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return send(host($), e, next) }).catch(denySend)
151 // 8. prompt.submit, prompt.attachment, turn.step, session.compact, command.run, config.set
152 on('prompt.submit', async ($, e, next) => { if (!(await wake(host($)))) return next(e); const h = host($); if (!e.agentId && e.origin?.kind === 'composer') await ack(h).catch(() => {}); return submit(h, e, e2 => context(h, e2, next)) }).catch(passOn)
153 on('prompt.attachment', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return attachment(host($), e, next) })
154 on('turn.step', async function* ($, e, next) { const h = host($); if (!(await wake(h))) return yield* next(e); return yield* step(h, e, next) })
155 on('session.compact', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return compact(host($), e, next) }).catch(passOn)
156 on('command.run', { command: 'cadre' }, async ($, e, next) => { const h = host($); await ack(h).catch(() => {}); return run(h, e, next) }).catch(denyCmd)
157 on('command.run', { command: 'goal' }, ($, e, next) => goal(host($), e, next)).catch(passOn) // observer only: pass-through, never rewritten (9)
158 on('config.set', async ($, e, next) => { if (!(await wake(host($)))) return next(e); return configSet(host($), e, next) }).catch(denySpawn)
159 // ui (UI-DESIGN): every ui.render hook names its component (U7); render is pure, state is written from handlers only
160 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => renderBand(host($), e, next)) // not gated: a pending init/setup code draws here even while the repo is dormant; with nothing pending the band draws nothing
161 on('ui.render', { component: 'Pane', requestId: 'cadre' }, ($, e) => renderPane(host($), e))
162 on('ui.render', { component: 'ToolUse', props: { tool: 'Agent' } }, async ($, e, next) => { if (!(await wake(host($)))) return next(e); return renderCard(host($), e, next) })
163}
164core/board.ts 60 lines1// Read model (DESIGN 2.5): snapshot, model/human views, CAS helper, binding table. Snapshot types live in types/index.d.ts.
2import type { Binding, Host, Snapshot, SnapTask } from '../types'
3import type { Task } from '../task-management/store'
4
5export const emptySnapshot = (repo = ''): Snapshot => ({
6 schemaVersion: 1, seq: 0, repo, mission: null, epic: null, plan: null, tasks: [],
7 approval: { planId: null, missing: [] },
8 drive: { on: false, level: 'epic', epicId: null, turns: 0, costUsd: 0, startedAt: null },
9 sync: [], cost: { tokens: 0 }, containment: { state: 'unknown', unsandboxed: 0 }, resumesQueued: 0, init: [],
10})
11export const isCold = (s: Snapshot): boolean => s.seq === 0 // lazy rehydrate trigger (DESIGN 13.4)
12
13// canonical JSON: sorted keys, undefined dropped. fnv1a64 over it = the snapshot hash (diff gate) and code8's input.
14export const canon = (v: unknown): string =>
15 JSON.stringify(v, (_k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : 1))) : x)
16export function fnv1a64(s: string): string {
17 let h = 0xcbf29ce484222325n
18 for (const b of new TextEncoder().encode(s)) h = ((h ^ BigInt(b)) * 0x100000001b3n) & 0xffffffffffffffffn
19 return h.toString(16).padStart(16, '0')
20}
21
22// Two views of one schema. Neither carries a code (those live in the `secret` atom).
23export const humanView = (s: Snapshot): Snapshot => structuredClone(s) // band, pane, toasts, adapters granted `commands`
24// model view (main context, TaskGet, cards, adapters without grant): drops volatile fields so the hash only moves on real change
25export function modelView(s: Snapshot): Omit<Snapshot, 'seq' | 'cost'> {
26 const { seq: _s, cost: _c, decisions: _d, ...v } = structuredClone(s) // decisions are for the human only
27 v.tasks.forEach(t => { delete (t as Partial<SnapTask>).phaseSince; delete t.ctx }) // volatile: the hash only moves on real change
28 v.sync.forEach(x => { delete (x as Partial<typeof x>).at })
29 const { turns: _t, costUsd: _u, startedAt: _a, ...drive } = v.drive
30 return { ...v, drive: drive as Snapshot['drive'] }
31}
32export const viewHash = (s: Snapshot): string => fnv1a64(canon(modelView(s)))
33
34// condition {on,state} for a task with a conditional-blocks dep (runs only if upstream FAILS): derived, never stored
35export function conditionOf(t: SnapTask, all: SnapTask[]): SnapTask['condition'] {
36 const d = t.deps.find(x => x.type === 'conditional-blocks'); if (!d) return undefined
37 const up = all.find(x => x.id === d.id)?.state
38 return { on: d.id, state: up === 'cancelled' ? 'met' : up === 'done' ? 'not-met' : 'pending' }
39}
40export const withConditions = (s: Snapshot): Snapshot => ({ ...s, tasks: s.tasks.map(t => ({ ...t, condition: conditionOf(t, s.tasks) })) })
41
42// CAS: fn runs against the freshest board and may re-run on conflict, so it must be pure. `stamp` then syncs seq to the last applied event.
43export const commit = (h: Host, fn: (s: Snapshot) => Snapshot): Promise<Snapshot> => h.board.update(fn)
44export const stamp = (h: Host, seq: number): Promise<Snapshot> => h.board.update(s => (seq > s.seq ? { ...s, seq } : s))
45
46// Binding table: agentId -> Binding (DESIGN 4.4.4); one agent = one task.
47export const bindAgent = (h: Host, agentId: string, b: Binding) => h.bind.update(t => ({ ...t, [agentId]: b }))
48export const unbindAgent = (h: Host, agentId: string) => h.bind.update(({ [agentId]: _gone, ...rest }) => rest)
49export const bindingOf = (t: Record<string, Binding>, agentId?: string): Binding | undefined => (agentId ? t[agentId] : undefined)
50export const agentsOf = (t: Record<string, Binding>, taskId: string): string[] => Object.keys(t).filter(a => t[a].taskId === taskId)
51
52// ui/ hooks register here at load (core never imports ui/, DESIGN 2.3): register.ts sets them.
53export const UI: { mapCard?: (h: Host, toolUseId: string, taskId: string) => Promise<unknown>; banner?: (h: Host, o: { decisions?: number; next?: string; stale?: number }) => Promise<void> } = {}
54export const safe = async <T>(h: Host, what: string, f: () => Promise<T>) => { try { return await f() } catch (x) { h.log(`cadre: ${what} failed: ${x}`) } }
55// session = this process's random id (secret atom); a lease is live while leaseUntil is in the future
56export const leaseLive = (t: Pick<Task, 'leaseUntil'>, now: number) => !!t.leaseUntil && Date.parse(t.leaseUntil) > now
57export async function sessionOf(h: Host): Promise<string> {
58 const n = h.rand(4); await h.secret.update(s => (s.session ? s : { ...s, session: n })); return (await h.secret.read()).session!
59}
60core/events.ts 93 lines1// Domain events (DESIGN 2.5): envelope, per-type actor/severity/needsHuman table, in-module bus, 500/epic ring, rebuild hook.
2// The attention LEVEL (silent/band/toast/notify) is UI policy computed from severity + needsHuman; never stored here.
3import { ask, flagDecision, settle } from './decide'
4import type { Actor, CadreEvent, EventRing, EventType, Host, Severity } from '../types'
5import type { TaskStore } from '../task-management/store'
6
7export const CAP = 500
8type Row = [actor: Actor, severity: Severity, needsHuman: boolean, dataKeys: string[], optionalKeys?: string[]]
9// Defaults per type; an emit site may override actor/severity/needsHuman (e.g. sync offline -> warn, flag.raised needing /cadre resolve).
10export const TABLE: Record<EventType, Row> = {
11 'task.transitioned': ['mod', 'info', false, ['taskId', 'from', 'to'], ['cites']], // cites: a spike's citation count on done
12 'task.phase_changed': ['mod', 'info', false, ['taskId', 'from', 'to']],
13 'task.skipped': ['mod', 'info', false, ['taskId', 'on']],
14 'agent.bound': ['mod', 'info', false, ['agentId', 'taskId', 'role']],
15 'agent.unbound': ['mod', 'info', false, ['agentId', 'reason']], // reason: answer|stopped|stale|rebind
16 'check.ran': ['mod', 'info', false, ['taskId', 'name', 'ok'], ['phase', 'failing', 'secs', 'exit']],
17 'flag.raised': ['mod', 'warn', true, ['taskId', 'flag']],
18 'flag.resolved': ['human', 'info', false, ['taskId', 'flag']],
19 'approval.pending': ['mod', 'info', true, ['planId', 'missing']],
20 'sync.state_changed': ['sync', 'info', false, ['target', 'state', 'at'], ['pending', 'conflicts']],
21 'drive.status': ['mod', 'info', false, ['state']],
22 'cost.updated': ['mod', 'info', false, ['tokens']],
23 'init.stage': ['mod', 'info', false, ['stage', 'state'], ['name']],
24 'plan.landed': ['mod', 'info', false, ['planId', 'sha']],
25 'plan.released': ['mod', 'info', false, ['version', 'sha']],
26 'release.proposed': ['mod', 'info', true, ['version']],
27}
28const SECRET_KEYS = ['code8', 'code', 'nonce', 'token', 'initConfirm'] // D7: no event ever carries a code
29
30export type Sub = (h: Host, ev: CadreEvent) => unknown
31const subs: Sub[] = []
32export const subscribe = (fn: Sub): void => { subs.push(fn) } // board, toasts, delta queue, resume queue, sync wake
33
34type Opt = Partial<Pick<CadreEvent, 'taskId' | 'agentId' | 'actor' | 'severity' | 'needsHuman'>>
35const mk = (seq: number, at: number, type: EventType, data: Record<string, unknown>, o: Opt): CadreEvent => {
36 const [actor, severity, needsHuman] = TABLE[type]
37 return { schemaVersion: 1, seq, at, type, taskId: o.taskId, agentId: o.agentId, actor: o.actor ?? actor, severity: o.severity ?? severity, needsHuman: o.needsHuman ?? needsHuman, data }
38}
39const put = (r: EventRing, epic: string, ev: CadreEvent): EventRing =>
40 ({ seq: ev.seq, epics: { ...r.epics, [epic]: [...(r.epics[epic] ?? []), ev].slice(-CAP) } })
41
42export async function emit(h: Host, type: EventType, data: Record<string, unknown>, o: Opt = {}): Promise<CadreEvent> {
43 const keys = TABLE[type]?.[3]; if (!keys) throw new Error(`unknown event type ${type}`)
44 const miss = keys.filter(k => !(k in data)); if (miss.length) throw new Error(`${type}: missing ${miss.join(', ')}`)
45 const leak = Object.keys(data).find(k => SECRET_KEYS.includes(k)); if (leak) throw new Error(`${type}: "${leak}" must not ride an event`)
46 const b0 = await h.board.read(), epic = b0.epic ?? '', at = await h.now()
47 let ev!: CadreEvent
48 await h.events.update(r => { ev = mk(r.seq + 1, at, type, data, o); return put(r, epic, ev) })
49 try { // a flag is a decision for the human until it is resolved (ADR 0008)
50 const tid = String(data.taskId ?? ''), fl = String(data.flag ?? '')
51 if (type === 'flag.raised' && !(b0.decisions ?? []).some(d => d.id === `flag:${tid}:${fl}`)) { const d = flagDecision(tid, fl, b0.tasks.find(t => t.id === tid)?.title); if (d) await ask(h, d) }
52 if (type === 'flag.resolved') await settle(h, ...fl.split(',').map(f => `flag:${tid}:${f}`))
53 } catch (e) { h.log(`cadre: decision update failed: ${e}`) }
54 for (const s of subs) try { await s(h, ev) } catch (e) { h.log(`cadre: subscriber failed on ${type}: ${e}`) }
55 return ev
56}
57
58export const emptyRing = (): EventRing => ({ seq: 0, epics: {} })
59export const ofEpic = (r: EventRing, epic: string): CadreEvent[] => r.epics[epic] ?? []
60
61// Rehydrate after /clear (DESIGN 2.5): rebuild one epic's ring from beads records (+ journal). Sorted by `at`, fresh seqs, last 500.
62// Returns the first new seq; adapters then get `reset:true` + a snapshot (level-triggered).
63export async function rebuild(h: Host, epic: string, evs: { at: number; type: EventType; data: Record<string, unknown>; taskId?: string; agentId?: string }[]): Promise<number> {
64 let first = 0
65 await h.events.update(r => {
66 first = r.seq + 1
67 const out = [...evs].sort((a, b) => a.at - b.at).map((e, i) => mk(r.seq + 1 + i, e.at, e.type, e.data, e))
68 return { seq: r.seq + out.length, epics: { ...r.epics, [epic]: out.slice(-CAP) } }
69 })
70 return first
71}
72
73// ring rebuild (2.5): the journal gives at-stamped transitions; at-stamped records (cadre.rec.<kind>) give phases (and transitions without a journal). Fresh seqs.
74const fromRec = (kind: string, id: string, at: number, text: string, tr: boolean) => {
75 const ev = (type: 'task.phase_changed' | 'task.transitioned', from: string, to: string) => ({ at, type, taskId: id, data: { taskId: id, from, to } })
76 const T = (from: string, to: string) => (tr ? [ev('task.transitioned', from, to)] : [])
77 if (kind === 'red') return [ev('task.phase_changed', 'red', 'worker')]
78 if (kind === 'report') return [ev('task.phase_changed', 'worker', 'reviewer'), ...T('active', 'review')]
79 if (kind === 'review') return /verdict: pass/.test(text) ? [ev('task.phase_changed', 'reviewer', 'merge')] : [ev('task.phase_changed', 'reviewer', 'worker'), ...T('review', 'active')]
80 return kind === 'close' ? T('review', 'done') : kind === 'blocked' ? T('active', 'blocked') : kind === 'answer' ? T('active', 'done') : []
81}
82export async function ring(h: Host, store: TaskStore, epic: string): Promise<number> {
83 const tasks = await store.list({ parent: epic }), ids = new Set(tasks.map(t => t.id)), j = await store.events('0', 5000).catch(() => null), evs: Parameters<typeof rebuild>[2] = [], last: Record<string, string> = {}
84 for (const r of j?.events ?? []) {
85 if (!ids.has(r.id) || !r.category || r.at === undefined) continue
86 if (last[r.id] && last[r.id] !== r.category) evs.push({ at: r.at, type: 'task.transitioned', taskId: r.id, data: { taskId: r.id, from: last[r.id], to: r.category } })
87 last[r.id] = r.category
88 }
89 for (const t of tasks) for (const [k, v] of Object.entries(t.meta)) { const at = Number(/ @(\d+) /.exec(v)?.[1]); if (k.startsWith('cadre.rec.') && at) evs.push(...fromRec(k.slice(10), t.id, at, v, !j)) }
90 if (!evs.length) return 0
91 await rebuild(h, epic, evs); return evs.length
92}
93core/reuse.ts 152 lines1// The one sender (DESIGN 2.2, 4.4, 7, 8): resume queue per sessionId + drain (#1), resume vs fresh, checkpoints, cost ledger (turn.step), compaction answer, prompt.submit.
2// Rehydrate/start/prune live in core/session, drive and the /goal observer in core/drive.
3import type { Binding, Hook, Host } from '../types'
4import type { TaskStore } from '../task-management/store'
5import { scoped } from '../commands/init'
6import { cfgOf, storeOf } from './dispatch'
7import { UI, leaseLive, modelView, safe, sessionOf, viewHash } from './board'
8import { nextAction } from './render'
9import { subscribe } from './events'
10import { brief, drainDelta } from './briefs'
11import { resolveDeps } from './admission'
12import { checkpoint } from './effect'
13import { type Entry, cardOf, qk, savePointer, warm } from './session'
14import { drive, goalSeen } from './drive'
15
16const counted = (h: Host, q: Entry[], me: string) => h.board.update(s => ({ ...s, resumesQueued: q.filter(x => x.sessionId === me).length }))
17
18// ---- resume queue (4.4): anything may queue; only drain sends. One entry per agentId; a later text is appended. ----
19export async function queueResume(h: Host, r: { agentId: string; taskId: string; text: string }): Promise<void> {
20 const b = (await h.bind.read())[r.agentId]
21 if (b && b.taskId !== r.taskId) return void h.log(`cadre: resume to ${r.agentId} refused: it works on ${b.taskId}, not ${r.taskId}`)
22 const me = await sessionOf(h), k = await qk(h), q = ((await h.store.get(k)) as Entry[] | undefined) ?? [], old = q.find(x => x.agentId === r.agentId)
23 const text = old && old.text !== r.text ? `${old.text}\n\n${r.text}` : r.text
24 const next = [...q.filter(x => x.agentId !== r.agentId), { sessionId: b?.sessionId ?? me, agentId: r.agentId, taskId: r.taskId, text }]
25 await h.store.set(k, next); await counted(h, next, me)
26}
27
28// fresh instead of resume (4.4.3): over maxContextPct, or idle past the cache TTL with context > 30 %. Window = 200k unless the model says otherwise.
29const ctxPct = (b: Binding) => Math.round((b.ctxTokens / 200_000) * 100)
30const stale = (b: Binding, now: number, cfg: any) => ctxPct(b) > cfg.reuse.maxContextPct || (now - (b.lastAt ?? b.startedAt) > cfg.reuse.cacheTtlMs && ctxPct(b) > 30)
31async function retire(h: Host, a: string, b: Binding, why: string) { // the agent stays bound but retired: admission rebinds it fresh with a computed handoff
32 await h.bind.update(t => (t[a] ? { ...t, [a]: { ...t[a], retired: true } } : t))
33 h.toast(`cadre: ${b.taskId} needs a fresh agent · spawn TASK-${b.taskId}`)
34}
35async function sendMine(h: Host, store: TaskStore | undefined, cfg: any) {
36 const me = await sessionOf(h), k = await qk(h), q = ((await h.store.get(k)) as Entry[] | undefined) ?? [], mine = q.filter(x => x.sessionId === me)
37 if (!mine.length) return
38 await h.store.set(k, q.filter(x => x.sessionId !== me)); await counted(h, [], me) // taken before sending: a re-queue is a new entry, never a loop
39 const now = await h.now(), bind = await h.bind.read()
40 for (const r of mine) {
41 const b = bind[r.agentId]
42 if (store && !leaseLive(await store.get(r.taskId), now)) continue // stale lease: that agent is gone; the rebind handoff replaces it (8.4)
43 if (!b || b.retired) continue
44 if (stale(b, now, cfg)) { await retire(h, r.agentId, b, `context ${ctxPct(b)}%`); continue }
45 const delta = await drainDelta(h, r.agentId)
46 await h.ui.update(u => ({ ...u, drainTo: r.agentId })) // marks cadre's own send so policy/guards.send does not isolate it (reviewer findings quote other ids)
47 const res = await h.sendTo!(r.agentId, delta ? `${r.text}\n\n${delta}` : r.text).finally(() => h.ui.update(u => ({ ...u, drainTo: undefined })))
48 if (!res.isDelivered) await retire(h, r.agentId, b, res.reason ?? 'not delivered')
49 }
50}
51
52// roles.tdd.author:same (3.5): the agent that wrote the tests stays bound; after red✓ (red -> worker) it is resumed with the worker brief.
53subscribe(async (h, ev) => {
54 if (ev.type !== 'task.phase_changed' || ev.data.from !== 'red' || ev.data.to !== 'worker') return
55 const id = String(ev.data.taskId), bind = await h.bind.read(), a = Object.keys(bind).find(k => bind[k].taskId === id && bind[k].role === 'worker' && bind[k].step === 'tester')
56 if (!a) return
57 const t = await (await storeOf(h)).get(id)
58 await h.bind.update(x => (x[a] ? { ...x, [a]: { ...x[a], step: 'worker' } } : x))
59 await queueResume(h, { agentId: a, taskId: id, text: brief('worker', { id, title: t.title, type: t.type, description: t.description, scenarios: t.acceptance, contract: t.design,
60 locked: t.meta['cadre.locked'] ? JSON.parse(t.meta['cadre.locked']) : undefined, redCard: t.meta['cadre.red'] }) })
61})
62
63// ---- cost ledger (7): turn.step usage per loop, summed per agent/task; USD by cost.prices, else token-equivalents ----
64type U = { in: number; out: number; cr: number; cw: number; model?: string }
65const zero = (): U => ({ in: 0, out: 0, cr: 0, cw: 0 })
66const add = (a: U, u: any): U => ({ in: a.in + (u.input_tokens ?? 0), out: a.out + (u.output_tokens ?? 0), cr: a.cr + (u.cache_read_input_tokens ?? 0), cw: a.cw + (u.cache_creation_input_tokens ?? 0), model: u.model ?? a.model })
67const plus = (a: U, b: U): U => ({ in: a.in + b.in, out: a.out + b.out, cr: a.cr + b.cr, cw: a.cw + b.cw, model: b.model ?? a.model })
68export const tokensOf = (u: U) => Math.round(u.in + 1.25 * u.cw + 0.1 * u.cr + 5 * u.out)
69export const cacheOf = (u: U) => (u.in + u.cr + u.cw ? Math.round((100 * u.cr) / (u.in + u.cr + u.cw)) : 0)
70export function usdOf(u: U, cfg: any): number | undefined { // cost.prices: {<tier|alias>: {in, out, cacheRead, cacheWrite}} in $ per Mtok
71 const p = Object.entries((cfg.cost.prices ?? {}) as Record<string, any>).find(([k]) => u.model?.includes(cfg.models[k] ?? k))?.[1]
72 return p && (u.in * p.in + u.out * p.out + u.cr * (p.cacheRead ?? 0) + u.cw * (p.cacheWrite ?? 0)) / 1e6
73}
74const ck = (id: string) => `cadre:cost:${id}`
75export async function* step(h: Host, e: any, next: (e: any) => any): AsyncGenerator<any, any, any> {
76 const b = e.agentId && (await h.bind.read())[e.agentId], eff = b && (await safe(h, 'effort', async () => (await cfgOf(h)).roles[b.step === 'tester' ? 'tester' : b.role]?.effort))
77 const r = yield* next(eff && eff !== 'default' ? { ...e, effort: eff } : e) // TM-03: roles.<role>.effort; 'default' keeps the agent file's frontmatter
78 if (r?.usage) await safe(h, 'cost', async () => {
79 const id = e.agentId ?? 'main'
80 await h.store.set(ck(id), add(((await h.store.get(ck(id))) as U) ?? zero(), r.usage)); const ak = await scoped(h, 'cost:all'); await h.store.set(ak, add(((await h.store.get(ak)) as U) ?? zero(), r.usage))
81 const ctx = (r.usage.input_tokens ?? 0) + (r.usage.cache_read_input_tokens ?? 0) + (r.usage.cache_creation_input_tokens ?? 0), now = await h.now()
82 if (e.agentId) await h.bind.update(t => (t[e.agentId] ? { ...t, [e.agentId]: { ...t[e.agentId], ctxTokens: ctx, lastAt: now } } : t))
83 if (b) { const pct = ctxPct({ ...b, ctxTokens: ctx }); await h.board.update(s => ({ ...s, tasks: s.tasks.map(t => (t.id === b.taskId && t.ctx !== pct ? { ...t, ctx: pct } : t)) })) } // SnapTask.ctx: the owner's context fill
84 })
85 return r
86}
87// at an agent's turn.complete: fold its bucket into its task's and overwrite cadre.cost (no comments)
88async function ledger(h: Host, store: TaskStore, actor: string, cfg: any, agentId: string, taskId: string) {
89 const a = ((await h.store.get(ck(agentId))) as U) ?? zero(), t = plus(((await h.store.get(ck(`task:${taskId}`))) as U) ?? zero(), a)
90 await h.store.set(ck(`task:${taskId}`), t); await h.store.del(ck(agentId))
91 const usd = usdOf(t, cfg)
92 await store.setMeta(taskId, { 'cadre.cost': JSON.stringify({ tokens: tokensOf(t), cachePct: cacheOf(t), ...(usd !== undefined ? { usd: +usd.toFixed(4) } : {}) }) }, actor)
93}
94
95// ---- #8 session.compact (8.3): main = card + recent, answered without next (S49: toolUses:[]); agents = next with instructions ----
96export const compact: Hook = async (h, e, next) => {
97 const s = await h.board.read()
98 if (e.agentId) {
99 const b = (await h.bind.read())[e.agentId], t = b && s.tasks.find(x => x.id === b.taskId)
100 return next(t ? { ...e, instructions: [e.instructions, `Keep: task ${t.id} (${t.step}), the spec commit, the last check result, open findings.`].filter(Boolean).join('\n') } : e)
101 }
102 if (!s.plan) return next(e)
103 const card = (await cardOf(h, s, await storeOf(h).catch(() => undefined))).text, keep: any[] = []
104 let n = 0
105 for (const m of [...(e.messages ?? [])].reverse()) { n += String(m.text ?? '').length; if (keep.length >= 6 || n > 8000) break; keep.unshift(m) }
106 while (keep.length && (keep[0].role !== 'user' || keep[0].toolResults?.length)) keep.shift() // never start on an orphan tool result
107 await h.secret.update(x => ({ ...x, ctx: { key: viewHash(s) } })) // the card counts as the sent context: delivered once
108 return { messages: [{ role: 'user', text: card, toolUses: [] }, ...keep] }
109}
110
111// ---- #8 prompt.submit (main): lazy rehydrate, /goal sniff, task-notification -> result card (S40) ----
112export const submit: Hook = async (h, e, next) => {
113 if (e.agentId) return next(e)
114 await warm(h)
115 const m = /^\/goal\b(.*)/s.exec(String(e.text ?? '').trim())
116 if (m) await safe(h, '/goal observer', () => goalSeen(h, m[1]))
117 if (e.origin?.kind !== 'task-notification') return next(e)
118 const agent = /<task-id>([^<]+)<\/task-id>/.exec(e.text ?? '')?.[1], tu = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(e.text ?? '')?.[1]
119 const taskId = agent && ((await h.bind.read())[agent]?.taskId ?? Object.values((await h.events.read()).epics).flat().reverse().find(x => x.type === 'agent.bound' && x.data.agentId === agent)?.taskId)
120 const t = taskId && (await h.board.read()).tasks.find(x => x.id === taskId)
121 if (!t || !t.card) return next(e) // no card yet: the notification passes as is; the card rides the next context line
122 if (tu) await UI.mapCard?.(h, tu, t.id)
123 return next({ ...e, text: `Result of agent ${agent}: ${t.card.replace('\n', ' · ')}` })
124}
125
126// ---- #1 turn.complete: after next, send this session's resumes; agents: checkpoint + ledger; main: usage mirror, pointer, compact hint, drive ----
127export const drain: Hook = async (h, e, next) => {
128 await warm(h)
129 const r = await next(e)
130 await safe(h, 'drain', async () => {
131 const cfg = await cfgOf(h), store = await storeOf(h).catch(() => undefined)
132 await sendMine(h, store, cfg)
133 if (e.agentId) {
134 const b = (await h.bind.read())[e.agentId]
135 if (!b || !store) return
136 const d = await resolveDeps(h)
137 const tk = (await h.board.read()).tasks.find(x => x.id === b.taskId) // L3: only while the task works; writes after report{done} must not move the tip past sourceCommit
138 if (b.role === 'worker' && b.worktree && tk?.state === 'active' && tk.step === 'worker') await safe(h, 'checkpoint', () => checkpoint(h, store, d.actor, b.taskId, b.worktree))
139 return void (await safe(h, 'cost ledger', () => ledger(h, store, d.actor, cfg, e.agentId, b.taskId)))
140 }
141 const u = await h.usage?.(), all = ((await h.store.get(await scoped(h, 'cost:all'))) as U) ?? zero(), pct = u?.context?.percent
142 await h.board.update(s => ({ ...s, cost: { tokens: tokensOf(all), cachePct: cacheOf(all), ...(u?.cost ? { usd: u.cost.usd } : {}) } }))
143 if (pct !== undefined) await h.ui.update(x => ({ ...x, ctx: pct }))
144 await savePointer(h)
145 const s = await h.board.read(), ui = await h.ui.read()
146 if (s.plan?.landed && ui.clr !== s.plan.id) { await h.ui.update(x => ({ ...x, clr: s.plan!.id })); if ((pct ?? 0) > cfg.cost.clearHintPct) h.toast(`cadre: epic ${s.plan.id} landed · /clear for a fresh main`) } // 7: never automatic
147 if (pct !== undefined && pct > cfg.cost.compactPct && s.plan?.state === 'approved' && !(await h.ui.read()).cmp) { await h.ui.update(x => ({ ...x, cmp: true })); h.toast(`cadre: context ${pct}% · /compact now`) } // S49: compact() is headless-unavailable: hint, not automatic
148 await drive(h, cfg, u?.cost?.usd)
149 })
150 return r
151}
152core/drive.ts 98 lines1// Drive (DESIGN 9): state on the board + beads (control issue cadre-control, meta cadre.drive with lease {session, until}: A30 read-check-write), the /goal observer.
2import type { Hook, Host, Snapshot } from '../types'
3import { StoreError, type TaskStore } from '../task-management/store'
4import { controlOf } from '../task-management/sync-engine/loop'
5import { ROUTES, storeOf } from './dispatch'
6import { modelView, safe, sessionOf } from './board'
7import { emit } from './events'
8import { nextAction } from './render'
9
10export const LEASE = 600_000
11interface DriveRec { on: boolean; level: 'epic'; epicId: string; turns: number; cost: number; last: number; sess: string; startedAt: number; lastSeq: number; idle: number; lease: { session: string; until: number } }
12// CAS on the control issue: fn sees the current record and returns the next one (undefined = no write); a revision race retries (sync writes the same issue)
13export async function casDrive(h: Host, store: TaskStore, fn: (r: DriveRec | undefined) => DriveRec | undefined): Promise<DriveRec | undefined> {
14 const ctl = await controlOf(h, store)
15 for (let i = 0; i < 3; i++) {
16 const t = await store.get(ctl), r = t.meta['cadre.drive'] ? (JSON.parse(t.meta['cadre.drive']) as DriveRec) : undefined, n = fn(r)
17 if (!n) return r
18 try { await store.setMeta(ctl, { 'cadre.drive': JSON.stringify(n) }, 'cadre', t.revision); return n } catch (e) { if (!(e instanceof StoreError && e.code === 'GuardMismatch')) throw e }
19 }
20 throw new StoreError('Busy', 'drive state kept moving')
21}
22const held = (r: DriveRec | undefined, me: string, now: number) => !!r && r.lease.session !== me && r.lease.until > now
23async function driveEvent(h: Host, state: string, reason?: string) { await emit(h, 'drive.status', { state, ...(reason ? { reason } : {}) }) }
24async function pause(h: Host, why: string) {
25 if ((await h.board.read()).drive.paused === why) return
26 await h.board.update(s => ({ ...s, drive: { ...s.drive, paused: why } })); await driveEvent(h, 'paused', why)
27}
28const BOUND = /^(max |no progress|cost )/
29// the human gate in front of the epic, or null
30async function gate(h: Host, s: Snapshot): Promise<string | null> {
31 if (s.approval.planId) return `approve ${s.approval.planId}`
32 const b = s.tasks.find(t => t.state === 'blocked'); if (b) return `blocked ${b.id}`
33 const f = s.tasks.find(t => t.flags.length); if (f) return `resolve ${f.flags[0]}`
34 if (s.plan?.flags?.length) return `resolve ${s.plan.flags[0]}`
35 const g = Object.keys((await h.git.read()).flags ?? {}).find(k => k.startsWith('git:')); if (g) return `resolve ${g}`
36 if ((await h.ui.read()).goal) return 'two loop owners: /goal active'
37 return null
38}
39export async function drive(h: Host, cfg: any, usd: number | undefined) {
40 const s = await h.board.read(), d = s.drive
41 if (!d.on || !s.epic || !s.plan) return
42 if (s.plan.landed || s.plan.state === 'done-on-dev' || s.plan.state === 'released') { // computed done: integration landed it on dev with its changelog (3.9, plan.landed)
43 await h.board.update(x => ({ ...x, drive: { ...x.drive, on: false, paused: undefined } })); await driveEvent(h, 'finished')
44 await casDrive(h, await storeOf(h), r => r && { ...r, on: false, lease: { session: '', until: 0 } })
45 return void h.toast(`cadre: epic ${s.plan.id} landed; drive finished`) // the /clear hint is the drain's (7)
46 }
47 const g = await gate(h, s)
48 if (g) return pause(h, g)
49 if (d.paused && !BOUND.test(d.paused)) { await h.board.update(x => ({ ...x, drive: { ...x.drive, paused: undefined } })); await driveEvent(h, 'running') }
50 else if (d.paused) return
51 const store = await storeOf(h), me = await sessionOf(h), now = await h.now(), dc = cfg.drive, nx = nextAction(modelView(s))
52 const evs = (await h.events.read()).epics[s.epic] ?? [], seq = evs.filter(x => x.type === 'task.transitioned').reduce((m, x) => Math.max(m, x.seq), 0)
53 const o = { rec: undefined as DriveRec | undefined, bound: null as string | null, go: false, cost: d.costUsd } // what the CAS attempt that stood decided
54 const out = await casDrive(h, store, r => {
55 o.rec = r; if (!r?.on || r.epicId !== s.epic || held(r, me, now)) return undefined
56 const idle = seq > r.lastSeq || !r.turns ? 0 : r.idle + 1
57 const last = r.last ?? usd ?? 0, delta = usd === undefined || r.sess !== me ? 0 : usd >= last ? usd - last : usd // a new session starts at its own baseline; a usage that went down was reset: count it from 0
58 o.cost = +((r.cost ?? 0) + delta).toFixed(2)
59 o.bound = r.turns >= dc.maxTurns ? `max turns ${dc.maxTurns}` : o.cost >= dc.maxCostUsd ? `cost $${o.cost} ≥ $${dc.maxCostUsd}` : now - r.startedAt > dc.maxWallMinutes * 60_000 ? `max ${dc.maxWallMinutes} min` : idle >= dc.noProgressTurns ? `no progress in ${idle} turns` : null
60 o.go = !o.bound && nx !== 'wait'
61 return { ...r, cost: o.cost, last: usd ?? last, sess: me, turns: r.turns + (o.go ? 1 : 0), lastSeq: seq, idle: o.go ? idle : r.idle, lease: { session: me, until: now + LEASE } }
62 })
63 const rec = o.rec
64 if (!rec?.on || rec.epicId !== s.epic) return void (await h.board.update(x => ({ ...x, drive: { ...x.drive, on: false } })))
65 if (held(rec, me, now)) return void (await h.board.update(x => ({ ...x, drive: { ...x.drive, heldBy: rec.lease.session } })))
66 await h.board.update(x => ({ ...x, drive: { ...x.drive, turns: out!.turns, costUsd: o.cost, heldBy: undefined } }))
67 if (o.bound) return pause(h, o.bound)
68 if (dc.maxCostUsd && o.cost >= 0.8 * dc.maxCostUsd && d.costUsd < 0.8 * dc.maxCostUsd) await emit(h, 'cost.updated', { tokens: (await h.board.read()).cost.tokens, threshold: 80 })
69 if (o.go) await h.submit!(`Continue: ${nx}`) // plain text, never a slash command; P4: legal only here (#1 after next)
70}
71// /cadre drive on|off|status (dispatch gates on|off as human). Arming submits nothing: drive starts at the next turn.complete.
72ROUTES.drive = async (h, c) => {
73 const s = await h.board.read(), sub = c.args[0] ?? 'status'
74 if (sub === 'status') return { text: s.drive.on ? `drive on · ${s.drive.turns} turns · $${s.drive.costUsd}${s.drive.paused ? ` · paused: ${s.drive.paused}` : ''}${s.drive.heldBy ? ` · held by ${s.drive.heldBy}` : ''}` : 'drive off' }
75 if (!s.epic || !s.plan || s.plan.state === 'draft') return { text: 'refused: drive needs an approved epic' }
76 const store = await storeOf(h), me = await sessionOf(h), now = await h.now()
77 if (sub === 'off') {
78 await casDrive(h, store, r => r && { ...r, on: false, lease: { session: '', until: 0 } })
79 await h.board.update(x => ({ ...x, drive: { ...x.drive, on: false, paused: undefined } })); await driveEvent(h, 'off'); return { text: 'Drive off.' }
80 }
81 if (sub !== 'on') return { text: 'usage: /cadre drive on|off|status' }
82 const usd = (await h.usage?.())?.cost?.usd ?? 0, fresh: DriveRec = { on: true, level: 'epic', epicId: s.epic, turns: 0, cost: 0, last: usd, sess: me, startedAt: now, lastSeq: 0, idle: 0, lease: { session: me, until: now + LEASE } }
83 const r = await casDrive(h, store, x => (x?.on && held(x, me, now) ? undefined : fresh))
84 if (r !== fresh) return { text: `refused: drive: held by ${r!.lease.session}` }
85 await h.board.update(x => ({ ...x, drive: { on: true, level: 'epic', epicId: s.epic, turns: 0, costUsd: 0, startedAt: now } }))
86 await driveEvent(h, 'armed')
87 return { text: `Drive armed for ${s.plan.id}; it starts when this turn ends.` }
88}
89// /goal observer (9, S48): command.run {command:'goal'} and prompt.submit text '/goal …'. Never rewritten; pauses drive until /goal clear.
90export async function goalSeen(h: Host, args: string) {
91 const on = !/^\s*(clear|off|stop)?\s*$/.test(args)
92 await h.ui.update(u => ({ ...u, goal: on || undefined }))
93 const d = (await h.board.read()).drive
94 if (on && d.on) await pause(h, 'two loop owners: /goal active')
95 if (!on && d.paused?.startsWith('two loop owners')) await h.board.update(s => ({ ...s, drive: { ...s.drive, paused: undefined } }))
96}
97export const goal: Hook = async (h, e, next) => { const r = await next(e); await safe(h, '/goal observer', () => goalSeen(h, String(e.args ?? ''))); return r }
98core/session.ts 118 lines1// Session lifecycle (DESIGN 8.2, 13.4, 13.5): lazy rehydrate after /clear, /resume or a new session, the board pointer, the resume card, session.start policy, store hygiene.
2import type { Binding, Host, Snapshot } from '../types'
3import type { TaskStore } from '../task-management/store'
4import { resetDelivery } from '../task-management/sync-engine/loop'
5import { repoKeyOf, scoped } from '../commands/init'
6import { cfgOf, gateDraw, mirror, missionOf, redraw, registryOf, storeOf } from './dispatch'
7import { ask, flagDecision } from './decide'
8import { specAsk } from './tdd'
9import { propose } from './integrate'
10import { UI, isCold, leaseLive, modelView, safe, sessionOf, stamp } from './board'
11import { ring } from './events'
12import { nextAction } from './render'
13import { queueCard, renderCard } from './briefs'
14import { LEASE, casDrive } from './drive'
15import { realRoot, setPolicy } from '../policy/guards'
16import { epicBranch, slug, wtRootOf } from './branches'
17import { contain } from '../policy/containment'
18
19export const qk = async (h: Host) => `cadre:${repoKeyOf(await h.cwd())}:resumes`
20export interface Entry { sessionId: string; agentId: string; taskId: string; text: string }
21
22// ---- resume card (8.2) from the board (+ decisions from the store) ----
23export async function cardOf(h: Host, s: Snapshot, store?: TaskStore): Promise<{ text: string; decisions: number }> {
24 const dec = store ? await safe(h, 'decisions', async () => (await store.list({})).filter(t => t.type === 'decision').sort((a, b) => a.updatedAt.localeCompare(b.updatedAt)).map(t => t.title)) : undefined
25 const d = s.drive, drive = d.on ? (d.paused ? `paused (${d.paused})` : `on ${d.turns} turns`) : 'off'
26 const text = renderCard({ mission: s.mission, epic: s.plan && { id: s.plan.id, title: s.plan.slug, intent: s.plan.state }, decisions: dec ?? [], questions: s.tasks.filter(t => t.state === 'blocked').map(t => `${t.id}: blocked`),
27 inflight: s.tasks.filter(t => t.state === 'active' || t.state === 'review').map(t => ({ id: t.id, step: t.step, owner: t.owner })), drive, next: nextAction(modelView(s)) })
28 return { text, decisions: dec?.length ?? 0 }
29}
30
31// ---- lazy rehydrate (13.4) after /clear, /resume, a new session: board pointer + bindings from $.store, tasks from beads, then the card ----
32const pk = async (h: Host) => `cadre:${repoKeyOf(await h.cwd())}:board`
33export async function savePointer(h: Host) {
34 const s = await h.board.read()
35 if (!isCold(s)) await h.store.set(await pk(h), { mission: s.mission, epic: s.epic, plan: s.plan, drive: s.drive, bind: await h.bind.read(), session: await sessionOf(h) })
36}
37
38// cold board, no pointer (a fresh checkout, an eval scaffold): adopt the newest unfinished non-mission epic that has an approved (non-draft) child. Tried once per session.
39async function adopt(h: Host, b0: Snapshot) {
40 if ((await h.secret.read()).adoptTried) return undefined
41 await h.secret.update(x => ({ ...x, adoptTried: true }))
42 const store = await storeOf(h), all = await store.list({}).catch(() => undefined), prefix = (await cfgOf(h)).branches.epicPrefix
43 if (!all) return undefined // no store yet (before init stage 2): nothing to adopt, and not an error to show
44 const e = all.filter(t => t.type === 'epic' && !t.meta['cadre.kind'] && t.category !== 'done' && t.category !== 'cancelled' && all.some(c => c.parent === t.id && c.category !== 'draft')).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))[0]
45 const mission = b0.mission ?? (await missionOf(store))
46 if (!e && mission) await h.board.update(b => ({ ...b, mission }))
47 return e && { mission, epic: e.id, plan: { id: e.id, slug: slug(e.title), state: 'approved', epicBranch: epicBranch(prefix, e.id, e.title), approvedAt: await h.now() }, drive: b0.drive, bind: {}, session: undefined }
48}
49// Decisions live on the board, which a /clear empties: ask again what durable state says is still owed. A pending init/setup/sync proposal is dropped (its code and diff are per session): run the command again.
50async function reask(h: Host, store: TaskStore, plan: Snapshot['plan'] & {}) {
51 const kids = await store.list({ parent: plan.id }).catch(() => []), gate = kids.find(t => t.type === 'gate' && !['done', 'cancelled'].includes(t.category))
52 if (gate) await gateDraw(h, gate) // a gate and a plan share the one approval slot: the gate blocks the work, so it goes first
53 else if (plan.state === 'draft' || kids.some(t => t.category === 'draft' && t.type !== 'gate')) await redraw(h, store, await registryOf(h))
54 for (const t of kids) if (t.meta['cadre.blocked'] === 'spec_change') { await h.board.update(s => ({ ...s, tasks: s.tasks.map(x => (x.id === t.id && !x.flags.includes('spec_change') ? { ...x, flags: [...x.flags, 'spec_change'] } : x)) })); await specAsk(h, t.id, t.meta['cadre.specChange'] ?? '') }
55 for (const f of plan.flags ?? []) { const d = flagDecision(plan.id, f); if (d) await ask(h, d) }
56 for (const f of Object.keys((await h.git.read()).flags ?? {})) { const d = flagDecision('', f); if (d) await ask(h, d) }
57 if (plan.state === 'done-on-dev') await propose(h, await h.cwd(), (await cfgOf(h)).branches.dev)
58}
59export async function rehydrate(h: Host): Promise<boolean> {
60 const b0 = await h.board.read()
61 if (!isCold(b0) || b0.epic) return false
62 const p = ((await h.store.get(await pk(h))) as any) ?? (await adopt(h, b0))
63 if (!p?.epic) return false
64 const store = await storeOf(h), now = await h.now(), me = await sessionOf(h)
65 await h.board.update(s => ({ ...s, mission: p.mission, epic: p.epic, plan: p.plan, drive: { ...p.drive, on: false, paused: undefined } }))
66 await mirror(h, store, await registryOf(h))
67 await safe(h, 'decisions', () => reask(h, store, p.plan))
68 await safe(h, 'ring rebuild', async () => { if (await ring(h, store, p.epic)) await resetDelivery(h) })
69 const rec = await casDrive(h, store, r => (r?.on && r.epicId === p.epic && r.lease.session === p.session ? { ...r, lease: { session: me, until: now + LEASE } } : undefined)) // our own lease moves to the new session id
70 if (rec?.on && rec.epicId === p.epic) await h.board.update(s => ({ ...s, drive: { ...s.drive, on: true, turns: rec.turns } })) // drive survives /clear: it continues at the next turn.complete (9)
71 let gone = 0
72 for (const [a, b] of Object.entries((p.bind ?? {}) as Record<string, Binding>)) {
73 if (a.startsWith('~')) continue
74 if (leaseLive(await store.get(b.taskId).catch(() => ({}) as any), now)) await h.bind.update(t => ({ ...t, [a]: { ...b, sessionId: me } })); else gone++
75 }
76 const q = ((await h.store.get(await qk(h))) as Entry[] | undefined) ?? [], ids = new Set(Object.keys(await h.bind.read()))
77 await h.store.set(await qk(h), q.map(x => (ids.has(x.agentId) ? { ...x, sessionId: me } : x)))
78 await stamp(h, Math.max(1, (await h.events.read()).seq))
79 const s = await h.board.read(), c = await cardOf(h, s, store)
80 await queueCard(h, c.text)
81 if ((await h.board.read()).drive.on) h.toast('cadre: drive continues · /goal clear if one was restored')
82 await UI.banner?.(h, { decisions: c.decisions, next: nextAction(modelView(s)), stale: gone })
83 return true
84}
85
86// 13.4: any hook entry (agent tool.call/turn.complete, spawn, tick) warms a cold board, not only main's prompt.submit; cheap when warm
87export const warm = (h: Host) => safe(h, 'rehydrate', () => rehydrate(h))
88
89// ---- session.start (and after /cadre config set): $.store `cadre:<repoKey>:policy` for guards/effect (13.1-13.2), cfg.ui mirrored into cadre.ui.cfg ----
90export async function start(h: Host): Promise<void> {
91 const cfg = await cfgOf(h), root = await h.cwd(), home = await h.home(), repo = (await h.stat(root, { resolve: true }))?.realPath ?? root
92 await setPolicy(h, { repo, wtRoot: `${await realRoot(h, await wtRootOf(h, cfg))}/${repoKeyOf(root)}`, home, mcpAllow: cfg.mcpAllow,
93 protect: [cfg.branches.main || 'main', cfg.branches.dev], effectCheck: cfg.effectCheck, epicPrefix: cfg.branches.epicPrefix, changelog: cfg.changelog.path }) // locks are kept: core/tdd owns them
94 await h.ui.update(u => ({ ...u, cfg: { ...(u.cfg as object | undefined), glyphs: cfg.ui.glyphs, maxTurns: cfg.drive.maxTurns } }))
95 await safe(h, 'prune', () => prune(h))
96}
97// 13.5 $.store hygiene (ST-15): stale-lease resume entries, bindings of closed or expired tasks in the pointer, quarantine entries older than 30 days
98export async function prune(h: Host): Promise<void> {
99 const store = await storeOf(h), now = await h.now(), live: Record<string, boolean> = {}
100 const ok = async (id: string) => (live[id] ??= await store.get(id).then(t => leaseLive(t, now) && t.category !== 'done' && t.category !== 'cancelled', () => false))
101 const q = ((await h.store.get(await qk(h))) as Entry[] | undefined) ?? [], keep: Entry[] = []
102 for (const x of q) if (await ok(x.taskId)) keep.push(x)
103 if (keep.length !== q.length) await h.store.set(await qk(h), keep)
104 const p = (await h.store.get(await pk(h))) as any
105 if (p?.bind) { const b: Record<string, Binding> = {}; for (const [a, v] of Object.entries(p.bind as Record<string, Binding>)) if (!a.startsWith('~') && (await ok(v.taskId))) b[a] = v; await h.store.set(await pk(h), { ...p, bind: b }) }
106 const quk = await scoped(h, 'quarantine'), qu = ((await h.store.get(quk)) as { ts: number }[] | undefined) ?? []
107 if (qu.some(x => now - x.ts > 30 * 86_400_000)) await h.store.set(quk, qu.filter(x => now - x.ts <= 30 * 86_400_000))
108}
109
110// Per-repo opt-in: cadre acts only where `<repo>/.cadre/config.json` exists (init stage 1 writes it). Active is cached in the session secret; dormant re-checks (one read), so `/cadre init` takes effect without a restart.
111export async function wake(h: Host): Promise<boolean> {
112 if ((await h.secret.read()).active) return true
113 if (!(await h.readText(`${await h.cwd()}/.cadre/config.json`))) return false
114 await h.secret.update(s => ({ ...s, active: true }))
115 await start(h).catch(() => {}); await contain(h).catch(() => {}) // policy + containment band, once, when the repo turns on
116 return true
117}
118config/route.ts 21 lines1// /cadre config set <key> <json|text> (DESIGN 12): registers ROUTES.config at load (register.ts imports this module).
2import { ROUTES } from '../core/dispatch'
3import { start } from '../core/session'
4import { resolve } from './index'
5
6ROUTES.config = async (h, c) => {
7 const [sub, key, ...v] = c.args
8 if (sub !== 'set' || !key || !v.length) return { text: 'usage: /cadre config show | /cadre config set <key> <value>' }
9 const path = `${await h.home()}/.claude/cadre.json`, raw = v.join(' ')
10 let cur: any, val: unknown
11 try { cur = JSON.parse((await h.readText(path)) ?? '{}') } catch { return { text: `refused: ${path} is not valid JSON` } }
12 try { val = JSON.parse(raw) } catch { val = raw }
13 const ks = key.split('.'); let o = cur
14 for (const k of ks.slice(0, -1)) o = o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {}
15 o[ks[ks.length - 1]] = val
16 const bad = resolve([{ name: 'set', trust: 'user', value: (({ repos: _r, ...x }) => x)(cur) }]).diagnostics.filter(d => d.includes(key))
17 if (bad.length) return { text: `refused: ${bad.join('; ')}` }
18 await h.write(path, `${JSON.stringify(cur, null, 2)}\n`); await start(h)
19 return { text: `config ${key} = ${JSON.stringify(val)} (~/.claude/cadre.json)` }
20}
21core/close.ts 332 lines1// report -> review -> verdict -> close (DESIGN 3.4, 3.8, 6.3). Slot #2 `observe`; `report` is called by tools/index; LIFECYCLE + ROUTES.resolve register at load.
2// Every function takes explicit Deps (admission.resolveDeps in the slots) so tests drive them with a MemoryStore and a scripted run.
3import type { Binding, Hook, Host, SnapTask } from '../types'
4import type { Task } from '../task-management/store'
5import { pipeOf } from '../task-management/registry'
6import { LIFECYCLE } from '../native/tasks'
7import { ROUTES } from './dispatch'
8import { resolveDeps, type Deps } from './admission'
9import { agentsOf, unbindAgent } from './board'
10import { apply, STEP_CAT, transition } from './machine'
11import { emit } from './events'
12import { card, comment, commitMessage, noReportCard, type Rec } from './render'
13import { queueResume } from './reuse'
14import { checkpoint, resolveGit, sweep } from './effect'
15import { policyOf } from '../policy/guards'
16import { integrateEpic } from './integrate'
17import { green as tddGreen, lockedViolations, verifyOf, resolveSpec, specChange, testerDone } from './tdd'
18import { modeOf, pkgOf } from './specview'
19import * as G from './git'
20
21
22const NUDGE: Record<string, string> = { worker: 'You stopped without calling report. Call it now: {"schemaVersion":1,"role":"worker","outcome":"done","summary":"…"} (or outcome "blocked" with reason).', tester: 'You stopped without calling report. Call it now with role "tester", outcome "done", summary and tests.', reviewer: 'You stopped without calling report. Call it now with role "reviewer", verdict, summary, findings, coverage and contractStatus.', explorer: 'You stopped without calling report. Call it now with role "explorer" and your answer.' }
23const num = (v?: string) => Number(v ?? 0) || 0
24const sha7 = (s: string) => s.slice(0, 7)
25
26// ---- small shared pieces ----
27const patch = (h: Host, id: string, fn: (t: SnapTask) => SnapTask) => h.board.update(s => ({ ...s, tasks: s.tasks.map(t => (t.id === id ? fn(t) : t)) }))
28export const raise = async (h: Host, id: string, flag: string) => { await patch(h, id, t => (t.flags.includes(flag) ? t : { ...t, flags: [...t.flags, flag] })); await emit(h, 'flag.raised', { taskId: id, flag }, { taskId: id }) }
29export const clear = async (h: Host, id: string, pre: string) => {
30 const had = ((await h.board.read()).tasks.find(t => t.id === id)?.flags ?? []).filter(f => f.startsWith(pre))
31 await patch(h, id, t => ({ ...t, flags: t.flags.filter(f => !f.startsWith(pre)) }))
32 for (const f of had) await emit(h, 'flag.resolved', { taskId: id, flag: f }, { taskId: id })
33}
34async function unbind(h: Host, agentId: string, reason: string, taskId?: string) { await unbindAgent(h, agentId); await emit(h, 'agent.unbound', { agentId, reason }, { agentId, taskId }) }
35async function record(h: Host, d: Deps, id: string, kind: string, actor: string, computed: Record<string, unknown>, claimed: Record<string, unknown> = {}) { // 6.5: meta + comment, same text
36 const text = comment({ schemaVersion: 1, kind, taskId: id, at: await h.now(), actor, computed, claimed } as Rec)
37 await d.store.setMeta(id, { [`cadre.rec.${kind}`]: text.slice(0, 1800) }, d.actor); await d.store.comment(id, text, d.actor)
38}
39const pipe = (d: Deps) => pipeOf(d.reg)
40const ownerOf = (bind: Record<string, Binding>, id: string, role: string) => agentsOf(bind, id).find(a => !a.startsWith('~') && bind[a].role === role)
41
42// ---- checks in the jail (3.10): the named argv of the task's package, overlay jail on the tree; result = header + capped body + log path ----
43export async function runCheck(h: Host, d: Deps, task: Task, wt: string, name: string): Promise<{ found: boolean; ok: boolean; secs: number; text: string }> {
44 const pkg = pkgOf(d, task), argv = pkg?.checks?.[name]
45 if (!argv) return { found: false, ok: true, secs: 0, text: `no check "${name}" for package ${task.meta['cadre.package'] ?? '.'} (checks: ${Object.keys(pkg?.checks ?? {}).join(', ') || 'none'})` }
46 const pkgRoot = !pkg.root || pkg.root === '.' ? wt : `${wt}/${pkg.root}`, scratch = `${d.wtRoot}/${d.key}.scratch/${task.id}`, t0 = await h.now()
47 await h.run(['mkdir', '-p', scratch])
48 const r = await G.checkRun(h.run, await modeOf(h, d), { wt, scratch, pkgRoot, argv, writable: d.cfg.jailWritable })
49 const secs = Math.round(((await h.now()) - t0) / 1000), out = r.stdout + r.stderr, cut = out.length > 16384, path = `${d.root}/.cadre/tmp/logs/${task.id}-${name}-${t0}.log`
50 await h.write(path, out).catch(() => {})
51 await emit(h, 'check.ran', { taskId: task.id, name, ok: r.exitCode === 0, secs, ...(task.meta['cadre.step'] ? { phase: task.meta['cadre.step'] } : {}) }, { taskId: task.id })
52 return { found: true, ok: r.exitCode === 0, secs, text: `exit=${r.exitCode} · ${secs}s · truncated=${cut ? 'yes' : 'no'}\n${cut ? out.slice(-4096) : out}` }
53}
54
55// ---- 6.3 semantic validation of a reviewer report ----
56async function semantic(h: Host, task: Task, wt: string, r: any): Promise<string | null> {
57 const C = task.meta['cadre.sourceCommit'], fs: any[] = r.findings
58 if (!Array.isArray(fs) || !Array.isArray(r.coverage) || !Array.isArray(r.contractStatus)) return 'findings, coverage, contractStatus: expected arrays (may be empty)'
59 for (const [i, f] of fs.entries()) {
60 if (!['blocker', 'major', 'minor', 'nit'].includes(f?.severity) || typeof f?.file !== 'string' || typeof f?.issue !== 'string') return `findings[${i}]: expected {"severity":"major","file":"src/a.ts","line":3,"issue":"…","fix":"…"}, severity in blocker|major|minor|nit`
61 if (!(await G.ok(h.run, wt, ['cat-file', '-e', `${C}:${f.file}`]))) return `findings[${i}].file: ${f.file} does not exist at sourceCommit ${sha7(C)}`
62 }
63 if (r.verdict === 'changes' && !fs.some(f => f.severity === 'blocker' || f.severity === 'major')) return 'verdict changes needs at least one blocker or major finding; minor/nit alone is a pass'
64 if (r.verdict === 'pass' && fs.some(f => f.severity === 'blocker')) return 'verdict pass is refused while a blocker finding is listed'
65 const key = (s: unknown) => /^S\d+/.exec(String(s))?.[0] ?? String(s)
66 const want = (task.acceptance ?? '').split('\n').map(l => /^S\d+/.exec(l)?.[0]).filter(Boolean) as string[], got = r.coverage.map((c: any) => key(c?.scenario))
67 if (want.some(s => !got.includes(s))) return `coverage must name every scenario; missing ${want.filter(s => !got.includes(s)).join(', ')} (e.g. {"scenario":"S1","test":"path","ok":true})`
68 const syms: string[] = (task.design ? JSON.parse(task.design).symbols ?? [] : []).map((s: any) => s.name), have = r.contractStatus.map((c: any) => c?.name)
69 if (syms.some(s => !have.includes(s))) return `contractStatus must name every contract symbol; missing ${syms.filter(s => !have.includes(s)).join(', ')} (e.g. {"name":"login","ok":true})`
70 return null
71}
72
73// ---- worker done (also the auto-report): protected/locked paths, mod commit, green gate, -> review ----
74async function numstat(h: Host, wt: string, range: string) {
75 const rows = (await G.git(h.run, wt, ['diff', '--numstat', range])).split('\n').filter(Boolean).map(l => l.split('\t'))
76 return { files: rows.length, add: rows.reduce((n, r) => n + num(r[0]), 0), del: rows.reduce((n, r) => n + num(r[1]), 0) }
77}
78async function workerDone(h: Host, d: Deps, t: SnapTask, b: Binding, r: any, auto = false): Promise<string> {
79 const task = await d.store.get(t.id), wt = b.worktree, base = task.meta['cadre.base'], pol = await policyOf(h)
80 const dirty = (await G.git(h.run, wt, ['status', '--porcelain'])).split('\n').filter(Boolean).flatMap(l => l.slice(3).split(' -> ')), changed = new Set([...dirty, ...(await G.git(h.run, wt, ['diff', '--name-only', `${task.meta['cadre.specCommit'] ?? base}..HEAD`])).split('\n').filter(Boolean)]) // since the red commit: the spec files it added are the worker's baseline, not its edits
81 const locked = pol.locks?.[t.id] ?? [], bad = [...changed].filter(p => G.isProt(p, d.cfg.changelog.path) || locked.includes(`${wt}/${p}`))
82 if (bad.length) throw new Error(`protected or locked paths changed: ${bad.join(', ')}. Restore them (git checkout ${sha7(base)} -- <path>) and report again`)
83 const sum = String(r.summary ?? '').split('\n')[0].slice(0, 60)
84 const C = (await G.op(h, () => G.commitAll(h.run, wt, `wip(${t.id}): ${sum || 'work'}`))) ?? (await G.tip(h.run, wt, 'HEAD'))
85 if (C === base) throw new Error('nothing changed since the task base; make the change, then report')
86 let green: { ok: boolean; check: string; secs: number } | undefined
87 if (pipe(d)(t.type).includes('green')) {
88 const g = await tddGreen(h, d, task, wt); if (!g.ok) throw new Error(`green gate failed: ${g.why}\nFix it and report again`)
89 green = { ok: true, check: 'verify', secs: Math.round(Number(/\((\d+(?:\.\d+)?)s\)/.exec(g.card)?.[1] ?? 0)) }
90 }
91 const st = await numstat(h, wt, `${base}..${C}`), p = pipe(d)(t.type), at = await h.now()
92 for (let i = p.indexOf('worker'); p[i] !== 'reviewer'; i++) { const m = await apply(h, { kind: 'advance', id: t.id, at, actor: 'mod' }, pipe(d)); if (!m.ok) throw new Error(m.error) }
93 await d.store.setMeta(t.id, { 'cadre.sourceCommit': C, 'cadre.step': 'reviewer', 'cadre.report': JSON.stringify({ summary: r.summary, risks: r.risks, questions: r.questions }).slice(0, 1500), 'cadre.checks': green ? 'verify' : '', 'cadre.nudge': '' }, d.actor)
94 await d.store.move(t.id, 'review', d.actor)
95 await record(h, d, t.id, 'report', 'worker', { commit: C, ...st, green: green ? `${green.ok ? 'pass' : 'fail'} (${green.secs}s)` : 'n/a', auto }, { outcome: 'done', summary: r.summary, risks: r.risks })
96 if (auto) await raise(h, t.id, 'auto-reported')
97 await followups(d, t.id, r.followups)
98 const bind = await h.bind.read(), rv = ownerOf(bind, t.id, 'reviewer'), last = rv && bind[rv].lastReviewedCommit
99 if (rv) await queueResume(h, { agentId: rv, taskId: t.id, text: `Re-review ${t.id}: the worker reworked. Review ${last ? `${sha7(last)}..` : ''}${sha7(C)} (sourceCommit ${C}) and report your verdict.` })
100 const flags = ((await h.board.read()).tasks.find(x => x.id === t.id)?.flags) ?? []
101 await patch(h, t.id, x => ({ ...x, card: card({ id: t.id, title: t.title, role: 'worker', to: 'review', green, ...st, commit: C, flags, next: rv ? 'reviewer resumed' : `spawn TASK-${t.id} (reviewer)` }) }))
102 return `report accepted: commit ${sha7(C)}, ${st.files} files (+${st.add} −${st.del})${green ? `, verify ${green.ok ? '✓' : '✗'}` : ''}. Now in review; stop and wait.`
103}
104
105// 3.1 discovered work: each worker followup -> a draft in the lazily created `cadre-backlog` epic, discovered-from this task
106export async function followups(d: Deps, from: string, fs: unknown) {
107 const list = (Array.isArray(fs) ? fs : []).slice(0, 5).filter((f: any) => typeof f?.title === 'string' && f.title.trim())
108 if (!list.length) return
109 const backlog = (await d.store.list({})).find(x => x.type === 'epic' && x.title === 'cadre-backlog')?.id ?? await d.store.createEpic('cadre-backlog', 'discovered work, not yet planned')
110 for (const f of list as any[]) await d.store.createDraft({ title: f.title.slice(0, 120), description: String(f.description ?? '').slice(0, 1000), type: 'feature', parent: backlog, discoveredFrom: from })
111}
112async function blockTask(h: Host, d: Deps, t: SnapTask, why: string, flag: string, claimed: Record<string, unknown>) {
113 const m = await apply(h, { kind: 'block', id: t.id, at: await h.now(), actor: 'mod' }, pipe(d)); if (!m.ok) throw new Error(m.error)
114 await d.store.move(t.id, 'blocked', d.actor); await record(h, d, t.id, 'blocked', t.step, { why }, claimed); await raise(h, t.id, flag)
115}
116
117// ---- reviewer verdict ----
118async function verdict(h: Host, d: Deps, t: SnapTask, b: Binding, agentId: string, r: any): Promise<string> {
119 const task = await d.store.get(t.id), C = task.meta['cadre.sourceCommit'], wt = b.worktree, at = await h.now()
120 const inDiff = new Set((await G.git(h.run, wt, ['diff', '--name-only', `${task.meta['cadre.base'] ?? C}..${C}`])).split('\n'))
121 const findings = (r.findings as any[]).map(f => ({ ...f, inDiff: inDiff.has(f.file) }))
122 const m = await apply(h, { kind: 'verdict', id: t.id, at, actor: 'reviewer', verdict: r.verdict }, pipe(d)); if (!m.ok) throw new Error(m.error)
123 await h.bind.update(x => (x[agentId] ? { ...x, [agentId]: { ...x[agentId], lastReviewedCommit: C } } : x))
124 await record(h, d, t.id, 'review', 'reviewer', { verdict: r.verdict, sourceCommit: C, findings: findings.length }, { summary: r.summary, findings, coverage: r.coverage, contractStatus: r.contractStatus })
125 if (r.verdict === 'pass') {
126 await d.store.setMeta(t.id, { 'cadre.review': `pass@${C}`, 'cadre.step': 'merge' }, d.actor)
127 await patch(h, t.id, x => ({ ...x, card: card({ id: t.id, title: t.title, role: 'reviewer', to: 'review✓', commit: C, flags: x.flags, next: `complete TASK-${t.id}` }) }))
128 return `verdict accepted: pass@${sha7(C)}. Stop.`
129 }
130 const n = num(task.meta['cadre.rework']) + 1, max = d.cfg.review.maxRework
131 await d.store.resume(t.id, d.actor); await d.store.setMeta(t.id, { 'cadre.rework': String(n), 'cadre.step': 'worker', 'cadre.review': '' }, d.actor)
132 await clear(h, t.id, 'rework:'); if (n > max) await raise(h, t.id, `rework:${n}/${max}`)
133 const bind = await h.bind.read(), w = ownerOf(bind, t.id, 'worker')
134 const text = `Review of ${sha7(C)} asks for changes (${n}/${max}):\n${r.summary}\n${findings.map(f => `- [${f.severity}] ${f.file}${f.line ? `:${f.line}` : ''} ${f.issue}${f.fix ? ` (fix: ${f.fix})` : ''}`).join('\n')}\nFix them, then report done again.`.slice(0, 3000)
135 if (w) await queueResume(h, { agentId: w, taskId: t.id, text })
136 await patch(h, t.id, x => ({ ...x, card: card({ id: t.id, title: t.title, role: 'reviewer', to: 'worker', commit: C, flags: x.flags, next: w ? 'worker resumed' : `spawn TASK-${t.id} (worker)` }) }))
137 return `verdict accepted: changes (${n}/${max}). Stop.`
138}
139
140async function explorerDone(h: Host, d: Deps, t: SnapTask, agentId: string, r: any): Promise<string> {
141 const p = pipe(d), at = await h.now()
142 await apply(h, { kind: 'advance', id: t.id, at, actor: 'mod' }, p)
143 const m = await apply(h, { kind: 'close', id: t.id, at, actor: 'mod', cites: (r.citations ?? []).length }, p); if (!m.ok) throw new Error(m.error)
144 await record(h, d, t.id, 'answer', 'explorer', { citations: (r.citations ?? []).length }, { answer: r.answer })
145 await d.store.close(t.id, { outcome: 'done', note: String(r.answer ?? '').slice(0, 500), session: d.session })
146 await patch(h, t.id, x => ({ ...x, card: card({ id: t.id, title: t.title, role: 'explorer', to: 'done', flags: x.flags, next: 'read the answer (TaskGet)' }) }))
147 await unbind(h, agentId, 'answer', t.id); return 'answer recorded; the spike is done. Stop.'
148}
149
150// The one entry for an accepted report (tools/index validated the envelope). Returns the text the agent sees; a thrown Error is a refusal the agent may retry.
151export async function report(h: Host, d: Deps, agentId: string | undefined, r: any, o: { lax?: boolean } = {}): Promise<string> {
152 const b = agentId ? (await h.bind.read())[agentId] : undefined, s = await h.board.read(), t = b && s.tasks.find(x => x.id === b.taskId)
153 if (!b || !t) throw new Error('no task is bound to this agent; only a spawned owner reports')
154 if (r.role !== b.role) throw new Error(`role: this agent is bound as ${b.role}, got ${r.role}`)
155 if (!(t.step === b.step && STEP_CAT[b.step] === t.state)) throw new Error(`${t.id} is ${t.state}·${t.step}: nothing to report at your step`)
156 if (r.role === 'tester') {
157 const task = await d.store.get(t.id), g = await testerDone(h, d, task, task.meta['cadre.worktree'], b.worktree, r.tests, agentId)
158 if (!g.ok) throw new Error(`red gate refused: ${g.why}\n${g.card}`)
159 await record(h, d, t.id, 'red', 'tester', { gate: 'red ✓' }, { summary: r.summary, tests: r.tests }); await patch(h, t.id, x => ({ ...x, card: card({ id: t.id, title: t.title, role: 'tester', to: 'worker', flags: x.flags, next: `spawn TASK-${t.id} (worker)` }) }))
160 return `${g.card}\nred accepted; stop.`
161 }
162 if (r.role === 'explorer') return explorerDone(h, d, t, agentId!, r)
163 if (r.role === 'reviewer') { const err = o.lax ? null : await semantic(h, await d.store.get(t.id), b.worktree, r); if (err) throw new Error(err); return verdict(h, d, t, b, agentId!, r) }
164 if (r.outcome === 'done') return workerDone(h, d, t, b, r)
165 const spec = r.outcome === 'spec_change', why = String(r.reason ?? r.summary ?? '').slice(0, 400)
166 if (spec) { await specChange(h, d, t.id, why); await record(h, d, t.id, 'blocked', t.step, { why }, { summary: r.summary, reason: r.reason }); await raise(h, t.id, 'spec_change') }
167 else await blockTask(h, d, t, why, 'blocked', { summary: r.summary, reason: r.reason, questions: r.questions })
168 return spec ? 'spec change recorded; the human decides (/cadre resolve). Stop and wait.' : 'blocked recorded; main asks the human and you are resumed with the decision. Stop and wait.'
169}
170
171// ---- #2 turn.complete: L3 sweep every turn; an agent that stopped without reporting is nudged x2, auto-reported, or released ----
172async function release(h: Host, d: Deps, t: SnapTask, b: Binding, agentId: string, why: string) {
173 const task = await d.store.get(t.id), n = num(task.meta['cadre.attempts']) + 1
174 if (b.worktree) await checkpoint(h, d.store, d.actor, t.id, b.worktree).catch(() => {})
175 await unbind(h, agentId, 'stopped', t.id); await d.store.setMeta(t.id, { 'cadre.attempts': String(n), 'cadre.nudge': '' }, d.actor)
176 if (n >= 3) { await blockTask(h, d, t, `${n} attempts ended without a report`, 'attempts', {}); return }
177 await apply(h, { kind: 'release', id: t.id, at: await h.now(), actor: 'mod' }, pipe(d)); await d.store.release(t.id, d.actor, why)
178}
179export async function stopped(h: Host, d: Deps, e: any): Promise<void> {
180 const b = (await h.bind.read())[e.agentId], t = b && (await h.board.read()).tasks.find(x => x.id === b.taskId)
181 if (!b || !t || !(t.step === b.step && STEP_CAT[b.step] === t.state)) return // no binding, or it reported/moved on
182 if (e.reason === 'answer') {
183 const task = await d.store.get(t.id), [st, n] = (task.meta['cadre.nudge'] ?? '').split(':'), k = st === b.step ? num(n) : 0
184 if (k < 2) {
185 await d.store.setMeta(t.id, { 'cadre.nudge': `${b.step}:${k + 1}` }, d.actor); await queueResume(h, { agentId: e.agentId, taskId: t.id, text: NUDGE[b.role] })
186 await patch(h, t.id, x => ({ ...x, card: noReportCard(x, b.role, b.step) })) // (wrap builds the same card for the Agent result, which returns before this stop is observed)
187 return
188 }
189 if (b.role === 'reviewer') return unbind(h, e.agentId, 'stopped', t.id) // a verdict is never invented; main spawns a fresh reviewer
190 const text = String(e.answer ?? '').slice(0, 300)
191 try {
192 if (b.role === 'worker') await workerDone(h, d, t, b, { summary: `(auto-report: stopped without report) ${text}` }, true)
193 else if (b.role === 'explorer') await explorerDone(h, d, t, e.agentId, { answer: text || '(no answer)', citations: [] })
194 else await release(h, d, t, b, e.agentId, 'no report')
195 } catch (x) { await release(h, d, t, b, e.agentId, `auto-report failed: ${(x as Error).message}`.slice(0, 200)) }
196 return
197 }
198 if (b.role === 'reviewer') return unbind(h, e.agentId, 'stopped', t.id)
199 await release(h, d, t, b, e.agentId, String(e.reason ?? 'stopped'))
200}
201export async function observeWith(h: Host, d: Deps, e: any, next: (e: any) => any): Promise<any> {
202 await sweep(h).catch(x => h.log(`cadre: sweep failed: ${x}`))
203 if (e.agentId) await stopped(h, d, e).catch(x => h.log(`cadre: observe failed: ${x}`))
204 return next(e)
205}
206export const observe: Hook = async (h, e, next) => observeWith(h, await resolveDeps(h), e, next)
207
208// ---- close (3.4, 3.8): review✓ -> squash into the epic -> done -> cleanup ----
209async function cleanup(h: Host, d: Deps, id: string, task: Task, keep?: string) {
210 for (const a of agentsOf(await h.bind.read(), id)) await unbind(h, a, 'answer', id)
211 const wt = task.meta['cadre.worktree'], br = task.meta['cadre.branch']
212 if (keep && br && (await G.exists(h.run, d.root, br))) await G.git(h.run, d.root, ['update-ref', keep, await G.tip(h.run, d.root, br)])
213 if (wt) await G.op(h, () => G.worktreeRemove(h.run, d.root, wt, br))
214 await h.run(['rm', '-rf', `${d.wtRoot}/${d.key}.views/${id}`])
215}
216export async function closeTask(h: Host, d: Deps, id: string): Promise<string | null> {
217 const t = (await h.board.read()).tasks.find(x => x.id === id)
218 if (!t) return `no task ${id}`
219 if (!(t.state === 'review' && t.step === 'merge')) return `${id} is ${t.state}${t.step ? `·${t.step}` : ''}: only a review-passed task (review✓) can be completed`
220 await sweep(h).catch(() => {})
221 const gf = Object.keys((await h.git.read()).flags ?? {}).find(f => f.startsWith('git:')); if (gf) return `merges are refused while git flag ${gf} is open (/cadre resolve git keep|restore)`
222 const task = await d.store.get(id), C = task.meta['cadre.sourceCommit'], wt = task.meta['cadre.worktree'], base = task.meta['cadre.base'], spec = d.reg[t.type], plan = (await h.board.read()).plan
223 if (!plan || !C || !wt) return `${id} has no reviewed commit`
224 if (task.meta['cadre.review'] !== `pass@${C}`) return `${id} has no pass@${sha7(C)} review`
225 if ((await G.tip(h.run, wt, 'HEAD')) !== C) return `${id}: the worktree moved past the reviewed commit ${sha7(C)}`
226 if (await G.git(h.run, wt, ['status', '--porcelain'])) return `${id}: the worktree has uncommitted changes`
227 if (!task.meta['cadre.changelog']) return `${id}: no changelog decision`
228 const files = (await G.git(h.run, wt, ['diff', '--name-only', `${base}..${C}`])).split('\n').filter(Boolean), spc = task.meta['cadre.specCommit']
229 const bad = [...files.filter(p => G.isProt(p, d.cfg.changelog.path)), ...(spc ? await lockedViolations(h.run, wt, spc, JSON.parse(task.meta['cadre.locked'] ?? '[]'), C) : [])]; if (bad.length) return `${id}: protected or locked paths differ: ${bad.join(', ')}`
230 const rep = task.meta['cadre.report'] ? JSON.parse(task.meta['cadre.report']).summary : undefined
231 const msg = commitMessage({ type: spec.commit ?? 'chore', scope: task.meta['cadre.scope'] || undefined, breaking: task.meta['cadre.breaking'] === 'true', description: t.title, summary: rep, id, reviewSha: C, checks: task.meta['cadre.checks'] ? [task.meta['cadre.checks']] : [] })
232 let sha = '', fail = ''
233 const back = () => G.git(h.run, wt, ['checkout', '-q', '-f', task.meta['cadre.branch']]).catch(() => {}) // squash detaches; a failed close returns the worker to its branch, its files intact for the rework
234 for (let i = 0; i < 2 && !sha; i++) {
235 const r = await G.op(h, async () => {
236 const epicTip = await G.tip(h.run, d.root, plan.epicBranch)
237 let s: string
238 try { s = await G.squash(h.run, wt, epicTip, C, msg) } catch (x) { return { fail: `conflict: ${(x as Error).message}`, flag: 'conflict' } }
239 if (epicTip !== base) { // the package's own verify checks (as the green gate runs them); a missing check is a failure, not a pass
240 const pkg = pkgOf(d, task)
241 for (const n of pkg ? verifyOf(pkg) : ['verify']) { const c = await runCheck(h, d, task, wt, n); if (!c.found || !c.ok) return { fail: `verify (${n}) failed on the squash commit:\n${c.text.slice(0, 1200)}`, flag: 'merge-red' } }
242 }
243 return (await G.casRef(h.run, d.root, `refs/heads/${plan.epicBranch}`, s, epicTip)) ? { s } : { retry: true }
244 }) as { s?: string; fail?: string; flag?: string; retry?: boolean }
245 if (r.s) sha = r.s; else if (r.fail) { await back(); await blockTask(h, d, t, r.fail, r.flag!, {}); return r.fail } else fail = 'the epic moved twice while squashing'
246 }
247 if (!sha) { await back(); await blockTask(h, d, t, fail, 'conflict', {}); return fail }
248 const at = await h.now(), m = await apply(h, { kind: 'close', id, at, actor: 'main' }, pipe(d)); if (!m.ok) return m.error
249 await d.store.close(id, { outcome: 'done', note: `squashed ${sha7(sha)} into ${plan.epicBranch}`, session: d.session })
250 await record(h, d, id, 'close', 'mod', { squash: sha, epic: plan.epicBranch, reviewed: C })
251 await patch(h, id, x => ({ ...x, card: card({ id, title: t.title, role: 'main', to: 'done', commit: sha, flags: [], next: 'wait' }) }))
252 await cleanup(h, d, id, task); return null
253}
254
255// ---- release / cancel / decision (TaskUpdate pending|deleted, metadata.decision) ----
256async function releaseTask(h: Host, d: Deps, id: string): Promise<string | null> {
257 const t = (await h.board.read()).tasks.find(x => x.id === id), task = await d.store.get(id), wt = task.meta['cadre.worktree']
258 if (!t) return `no task ${id}`
259 if (!['active', 'review', 'blocked'].includes(t.state)) return `${id} is ${t.state}: nothing to release`
260 for (const a of agentsOf(await h.bind.read(), id)) await unbind(h, a, 'stopped', id)
261 if (wt && (await h.stat(wt))) await checkpoint(h, d.store, d.actor, id, wt).catch(() => {})
262 const m = await apply(h, { kind: 'release', id, at: await h.now(), actor: 'main' }, pipe(d)); if (!m.ok) return m.error
263 await d.store.release(id, d.actor, 'released by main'); return null
264}
265async function cancelTask(h: Host, d: Deps, id: string, why: string): Promise<string | null> {
266 const t = (await h.board.read()).tasks.find(x => x.id === id), task = await d.store.get(id)
267 if (!t) return `no task ${id}`
268 const m = await apply(h, { kind: 'cancel', id, at: await h.now(), actor: 'main' }, pipe(d)); if (!m.ok) return m.error
269 await cleanup(h, d, id, task, `refs/cadre/quarantine/${id}-${await h.now()}`)
270 await d.store.close(id, { outcome: 'cancelled', note: `failed: ${why}`.slice(0, 300), session: d.session }); return null
271}
272async function decide(h: Host, d: Deps, id: string, text: string): Promise<string | null> {
273 const t = (await h.board.read()).tasks.find(x => x.id === id)
274 if (t?.state !== 'blocked') return `${id} is not blocked; nothing to decide`
275 if ((await d.store.get(id)).meta['cadre.blocked'] === 'spec_change') return `${id} awaits a spec change decision: the human runs /cadre resolve ${id} accept|reject`
276 return unblock(h, d, id, `Decision from the human: ${text}`, 'blocked')
277}
278async function unblock(h: Host, d: Deps, id: string, text: string, flag: string): Promise<string | null> {
279 const m = await apply(h, { kind: 'unblock', id, at: await h.now(), actor: 'main' }, pipe(d)); if (!m.ok) return m.error
280 await d.store.resume(id, d.actor); await d.store.setMeta(id, { 'cadre.decision': text.slice(0, 1000) }, d.actor)
281 await d.store.record('decision', `${id}: ${text.slice(0, 60)}`, text, id); await clear(h, id, flag)
282 const w = ownerOf(await h.bind.read(), id, 'worker'); if (w) await queueResume(h, { agentId: w, taskId: id, text })
283 return null
284}
285const wrapL = (f: (h: Host, d: Deps, id: string, e: any) => Promise<string | null>) => async (h: Host, id: string, e: any) => f(h, await resolveDeps(h), id, e)
286LIFECYCLE.completed = wrapL(async (h, d, id, e) => (id === (await h.board.read()).epic ? integrateEpic(h, id) : closeTask(h, d, id)))
287LIFECYCLE.pending = wrapL((h, d, id) => releaseTask(h, d, id))
288LIFECYCLE.deleted = wrapL(async (h, d, id, e) => (id === (await h.board.read()).epic ? 'the epic cannot be deleted; reject the plan instead' : cancelTask(h, d, id, String(e.metadata?.reason ?? 'deleted by main'))))
289LIFECYCLE.decision = wrapL((h, d, id, e) => (typeof e.metadata?.decision === 'string' && e.metadata.decision.trim() ? decide(h, d, id, e.metadata.decision) : Promise.resolve('metadata.decision: expected the human\'s answer as a string')))
290
291// accept on a sync flag: conflict -> re-apply the remote values kept in the task's meta; drift -> adopt the remote status through the machine (refused when illegal)
292async function acceptRemote(h: Host, d: Deps, t: SnapTask): Promise<string | null> {
293 const task = await d.store.get(t.id), rem = (f: string) => task.meta[`cadre.remote.${f}`]
294 const up: Record<string, string | number> = {}
295 for (const f of ['title', 'description', 'acceptance', 'priority']) if (t.flags.includes('conflict') && rem(f) !== undefined) up[f] = f === 'priority' ? Number(rem(f)) : rem(f)
296 if (t.flags.includes('drift') && rem('status') !== undefined) {
297 const to = rem('status'), kind = ({ cancelled: 'cancel', blocked: 'block', done: 'close' } as const)[to as 'done'], pp = pipe(d)
298 if (!kind) return `the tracker's status "${to}" cannot be adopted; use keep, or move the task through its pipeline`
299 const m = transition(await h.board.read(), { kind, id: t.id, at: await h.now(), actor: 'human' }, pp); if (!m.ok) return m.error
300 await patch(h, t.id, x => ({ ...x, flags: x.flags.filter(f => f !== 'drift') })) // apply refuses a task with an open sync flag
301 if (kind === 'cancel') { const e = await cancelTask(h, d, t.id, 'cancelled on the tracker'); if (e) return e } else {
302 const r = await apply(h, { kind, id: t.id, at: await h.now(), actor: 'human' }, pp); if (!r.ok) return r.error
303 if (kind === 'block') await d.store.move(t.id, 'blocked', d.actor); else await d.store.close(t.id, { outcome: 'done', note: 'adopted from the tracker', session: d.session }) }
304 }
305 if (Object.keys(up).length) await d.store.update(t.id, up as any, d.actor)
306 return null
307}
308
309// ---- /cadre resolve <id> keep|accept|reject|cancel · resolve git keep|restore [flag] ----
310ROUTES.resolve = async (h, c) => {
311 const [a, b, f] = c.args
312 if (a === 'git') {
313 const open = Object.keys((await h.git.read()).flags ?? {}).filter(x => x.startsWith('git:') || x.startsWith('quarantine')), flag = f ?? (open.length === 1 ? open[0] : '')
314 if (b !== 'keep' && b !== 'restore') return { text: 'usage: /cadre resolve git keep|restore [flag]' }
315 if (!flag) return { text: `name the flag: ${open.join(', ') || 'none open'}` }
316 const r = await resolveGit(h, flag, b); return { text: r.ok ? `${flag}: ${b} done` : `refused: ${r.error}` }
317 }
318 const s0 = await h.board.read(), d = await resolveDeps(h)
319 if (s0.plan && (a === s0.plan.id || a === s0.epic) && b === 'keep') { // plan-level flags (integration-failed): a retry is TaskUpdate completed again
320 await h.board.update(x => (x.plan ? { ...x, plan: { ...x.plan, flags: [] } } : x)); await emit(h, 'flag.resolved', { taskId: a, flag: (s0.plan.flags ?? []).join(',') || 'plan' }, { taskId: a })
321 return { text: `${a}: plan flags cleared; call TaskUpdate completed on the epic again` }
322 }
323 const t = s0.tasks.find(x => x.id === a)
324 if (!t || !['keep', 'accept', 'reject', 'cancel'].includes(b)) return { text: 'usage: /cadre resolve <taskId> keep|accept|reject|cancel · /cadre resolve git keep|restore' }
325 const out = async (e: Promise<string | null>) => ({ text: (await e) ?? `${a}: ${b} done` })
326 if (b === 'cancel' || (b === 'reject' && t.flags.some(x => x.startsWith('rework:')))) return out(cancelTask(h, d, a, 'ended by the human'))
327 if (t.flags.includes('spec_change') && (b === 'accept' || b === 'reject')) { const r = await resolveSpec(h, a, b); if (/accepted|rejected/.test(r.text)) await clear(h, a, 'spec_change'); return r }
328 if (b === 'accept' && t.flags.some(x => x === 'conflict' || x === 'drift')) { const e = await acceptRemote(h, d, t); if (e) return { text: `refused: ${e}` } }
329 await patch(h, a, x => ({ ...x, flags: x.flags.filter(y => y.startsWith('git:')) })); await emit(h, 'flag.resolved', { taskId: a, flag: t.flags.join(',') || b }, { taskId: a }) // keep: accept the state, clear the task's flags
330 return { text: `${a}: flags cleared (${b})` }
331}
332core/integrate.ts 137 lines1// Epic integration and release (DESIGN 3.9). integrateEpic is called by the TaskUpdate{epic, completed} lifecycle (core/close); /cadre release is ROUTES.release.
2// Everything that moves a ref is inside git.op (branches.ts); suites run in a throwaway worktree, outside it.
3import type { Host } from '../types'
4import { ROUTES, cfgOf, storeOf } from './dispatch'
5import { emit } from './events'
6import { ask, settleIf } from './decide'
7import { BLOCKING, epicNext } from './machine'
8import { cut, insert } from './changelog'
9import { casRef, checkRun, exists, git, jailMode, op, tip, worktreeRemove, writableJail } from './git'
10import { inferVersion, landDev, lastTag, mergeEpic, release, wtPath, wtRootOf } from './branches'
11import { repoKeyOf } from '../commands/init'
12
13
14const setPlan = (h: Host, ev: string, patch: Record<string, unknown> = {}) =>
15 h.board.update(b => { const n = b.plan && epicNext(b.plan.state, ev); return n ? { ...b, plan: { ...b.plan!, ...patch, state: n } } : b })
16const gitFlags = async (h: Host) => Object.keys((await h.git.read()).flags ?? {}).filter(f => f.startsWith('git:'))
17const tail = (r: { stdout: string; stderr: string }) => `${r.stdout}\n${r.stderr}`.trim().slice(-400)
18// a branch checked out in the repo root follows its ref by a two-tree merge: unrelated human edits stay, conflicting ones refuse (never reset --hard: it eats them)
19const checkedOut = async (h: Host, root: string, br: string) => (await h.run(['git', '-C', root, 'symbolic-ref', '-q', 'HEAD'])).stdout.trim() === `refs/heads/${br}`
20const refresh = async (h: Host, root: string, br: string, was: boolean, old: string) => {
21 if (was) await op(h, async () => git(h.run, root, ['read-tree', '-m', '-u', old, await tip(h.run, root, br)])).catch(() => h.toast(`cadre: ${br} moved; your edits kept · git stash, then checkout ${br}`))
22}
23const commitPaths = async (h: Host, wt: string, paths: string[], msg: string, amend = false) => {
24 await git(h.run, wt, ['add', '--', ...paths]); await git(h.run, wt, ['commit', '--no-verify', ...(amend ? ['--amend', '--no-edit'] : ['-m', msg])]); return tip(h.run, wt, 'HEAD')
25}
26
27// setup + suites.integration per touched package (files = null: every package). Returns the failure text, or null when green.
28async function runSuites(h: Host, cfg: any, wt: string, files: string[] | null, epicId: string): Promise<string | null> {
29 const pkgs: any[] = cfg.packages ?? [], owner = (f: string) => pkgs.reduce<any>((best, p) => (p.root === '.' || f === p.root || f.startsWith(`${p.root}/`)) && (!best || p.root.length > best.root.length) ? p : best, null)
30 const hit = new Set(files?.map(owner)), touched = pkgs.filter(p => !files || hit.has(p))
31 const mode = cfg.jail === 'off' ? 'none' as const : await jailMode(h.run)
32 const scratch = `${wt}-scratch`; await h.run(['mkdir', '-p', scratch])
33 try {
34 for (const p of touched) {
35 const dir = p.root === '.' ? wt : `${wt}/${p.root}`, j = { wt, scratch, pkgRoot: dir }
36 if (p.setup?.length) { const r = await h.run(mode === 'none' ? p.setup : writableJail({ ...j, argv: p.setup }), { cwd: dir, timeoutMs: 600000 }); if (r.exitCode) return `${p.root}: setup failed: ${tail(r)}` }
37 const want = Array.isArray(cfg.suites?.integration) ? cfg.suites.integration : Object.keys(p.checks ?? {})
38 for (const n of want) {
39 if (!p.checks?.[n] || p.knownRed?.includes(n)) continue
40 const r = await checkRun(h.run, mode, { ...j, argv: p.checks[n], writable: cfg.jailWritable }); await emit(h, 'check.ran', { taskId: epicId, name: `${p.root}:${n}`, ok: !r.exitCode, exit: r.exitCode })
41 if (r.exitCode) return `${p.root}:${n} failed (exit ${r.exitCode}): ${tail(r)}`
42 }
43 }
44 } finally { await h.run(['rm', '-rf', scratch]) }
45 return null
46}
47
48export async function propose(h: Host, root: string, dev: string): Promise<void> {
49 const tag = await lastTag(h), subj = (await git(h.run, root, ['log', '--no-merges', '--format=%s', tag ? `${tag}..${dev}` : dev])).split('\n')
50 const version = inferVersion(tag, subj)
51 await h.ui.update(u => ({ ...u, rel: version }))
52 await ask(h, { id: `release:${version}`, kind: 'release', title: `release v${version}?`, lines: [`${dev} merges into main with an annotated tag v${version}`], hash: version, tab: 'epic', fallback: '/cadre release', options: [{ key: 'r', label: 'Release', verb: 'release', run: `release ${version}` }] })
53 await emit(h, 'release.proposed', { version }); h.toast(`Epic landed on ${dev} · release v${version}? /cadre release`)
54}
55
56// §3.9 Integration. null = landed on dev; otherwise the refusal / failure text (also left as a comment on the epic).
57export async function integrateEpic(h: Host, epicId: string): Promise<string | null> {
58 const s = await h.board.read(), p = s.plan
59 if (!p || s.epic !== epicId || p.id !== epicId) return `${epicId} is not the current epic`
60 if (p.state !== 'approved') return p.state === 'integrating' ? 'integration is already running' : `epic is ${p.state}; only an approved epic integrates`
61 const open = s.tasks.filter(t => t.state !== 'done' && t.state !== 'cancelled').map(t => t.id)
62 if (open.length) return `children still open: ${open.join(', ')}`
63 const gf = await gitFlags(h); if (gf.length) return `git flag ${gf[0]} is open (someone changed a protected ref); /cadre resolve git keep|restore first`
64 let got = false; await h.board.update(b => { got = b.plan?.state === 'approved'; return got ? { ...b, plan: { ...b.plan!, state: 'integrating' } } : b })
65 if (!got) return 'integration is already running'
66 const [root, cfg, store] = [await h.cwd(), await cfgOf(h), await storeOf(h)]
67 const dev = cfg.branches.dev, tmp = wtPath(await wtRootOf(h, cfg), repoKeyOf(root), `int-${epicId}`)
68 const fail = async (why: string, flag: boolean) => {
69 await worktreeRemove(h.run, root, tmp).catch(() => {})
70 await setPlan(h, 'fail', flag ? { flags: [...(p.flags ?? []).filter(f => f !== 'integration-failed'), 'integration-failed'] } : {})
71 if (flag) await emit(h, 'flag.raised', { taskId: epicId, flag: 'integration-failed' })
72 await store.comment(epicId, `integration ${flag ? 'failed' : 'refused'}: ${why}`, 'cadre').catch(() => {})
73 return why
74 }
75 try {
76 const m = await mergeEpic(h, { dev, epic: p.epicBranch, tmp, msg: `merge ${p.epicBranch} (${epicId})` }).catch(e => e as Error)
77 if (m instanceof Error) return await fail(`merge of ${p.epicBranch} into ${dev} failed: ${m.message}`, true)
78 let sha = m.sha
79 const path = `${tmp}/${cfg.changelog.path}`, text = await h.readText(path)
80 if (text !== null) { // each closed child's entry, amended into the merge
81 let out = text
82 for (const t of (await store.list({ parent: epicId })).filter(t => t.category === 'done').sort((a, b) => (a.id < b.id ? -1 : 1))) {
83 const c = t.meta['cadre.changelog'] ? JSON.parse(t.meta['cadre.changelog']) : null
84 if (c?.category && c.entry && !out.includes(`(${t.id})`)) out = insert(out, c.category, c.entry, t.id)
85 }
86 if (out !== text) { await h.write(path, out); sha = await commitPaths(h, tmp, [cfg.changelog.path], '', true) }
87 }
88 const files = (await git(h.run, tmp, ['diff', '--name-only', `${m.devTip}..${sha}`])).split('\n').filter(Boolean)
89 const red = await runSuites(h, cfg, tmp, files, epicId); if (red) return await fail(red, true)
90 const on = await checkedOut(h, root, dev)
91 if (!(await landDev(h, { dev, sha, old: m.devTip, tmp, epic: p.epicBranch }))) return await fail(`${dev} moved during integration; call TaskUpdate completed again`, false)
92 await refresh(h, root, dev, on, m.devTip)
93 const at = await h.now()
94 await setPlan(h, 'land', { landed: { dev, sha, at }, flags: (p.flags ?? []).filter(f => f !== 'integration-failed') })
95 await emit(h, 'plan.landed', { planId: epicId, sha })
96 if (cfg.release.onEpicDone === 'propose') await propose(h, root, dev)
97 return null
98 } catch (e) { return await fail(`integration error: ${(e as Error).message}`, false) }
99}
100
101// §3.9 /cadre release [x.y.z] (human-gated by the dispatcher)
102export async function releaseDev(h: Host, want?: string): Promise<string> {
103 const s = await h.board.read(), no = (w: string) => `refused: ${w}`
104 if (s.plan?.state === 'integrating') return no('an integration is running')
105 const gf = await gitFlags(h), bf = [...(s.plan?.flags ?? []), ...s.tasks.flatMap(t => t.flags)].find(f => BLOCKING.test(f))
106 if (gf.length || bf) return no(`flag ${gf[0] ?? bf} is open; /cadre resolve it first`)
107 if (want && !/^\d+\.\d+\.\d+$/.test(want)) return no(`version "${want}": expected x.y.z`)
108 const [root, cfg] = [await h.cwd(), await cfgOf(h)], dev = cfg.branches.dev
109 const main = cfg.branches.main || ((await exists(h.run, root, 'main')) ? 'main' : 'master')
110 if (!(await exists(h.run, root, dev))) return no(`no ${dev} branch`)
111 const devTip = await tip(h.run, root, dev), cl = await h.run(['git', '-C', root, 'show', `${devTip}:${cfg.changelog.path}`])
112 if (cl.exitCode) return no(`${cfg.changelog.path} is missing on ${dev}`)
113 const tag = await lastTag(h), version = want ?? inferVersion(tag, (await git(h.run, root, ['log', '--no-merges', '--format=%s', tag ? `${tag}..${dev}` : dev])).split('\n'))
114 let cutText: string
115 try { cutText = cut(cl.stdout, version, new Date(await h.now()).toISOString().slice(0, 10)) } catch (e) { return no((e as Error).message) }
116 if (await exists(h.run, root, `refs/tags/v${version}`)) return no(`tag v${version} already exists`)
117 const key = repoKeyOf(root), tmp = wtPath(await wtRootOf(h, cfg), key, `rel-${version}`)
118 await op(h, () => git(h.run, root, ['worktree', 'add', '--detach', tmp, devTip]))
119 let cdev = ''
120 try {
121 const red = await runSuites(h, cfg, tmp, null, s.epic ?? ''); if (red) return no(`${dev} is not green: ${red}`)
122 await git(h.run, tmp, ['reset', '--hard', '-q', devTip]); await git(h.run, tmp, ['clean', '-fdq']) // drop what setup left behind
123 await h.write(`${tmp}/${cfg.changelog.path}`, cutText); cdev = await commitPaths(h, tmp, [cfg.changelog.path], `chore(release): v${version}`)
124 } finally { await op(h, () => worktreeRemove(h.run, root, tmp)) }
125 const [onDev, onMain, mainWas] = [await checkedOut(h, root, dev), await checkedOut(h, root, main), await tip(h.run, root, main)]
126 if (!(await op(h, () => casRef(h.run, root, `refs/heads/${dev}`, cdev, devTip)))) return no(`${dev} moved during release; retry`)
127 let sha: string
128 try { sha = await release(h, { dev, main, version, tmp: wtPath(await wtRootOf(h, cfg), key, `relm-${version}`), msg: `release v${version}` }) }
129 catch (e) { await op(h, () => casRef(h.run, root, `refs/heads/${dev}`, devTip, cdev)); return no(`merge into ${main} failed: ${(e as Error).message}; ${dev} restored`) }
130 await refresh(h, root, dev, onDev, devTip); await refresh(h, root, main, onMain, mainWas)
131 if (s.plan?.state === 'done-on-dev') await setPlan(h, 'release', { released: { version, at: await h.now() } })
132 await h.ui.update(({ rel: _r, ...u }) => u); await settleIf(h, 'release:')
133 await emit(h, 'plan.released', { version, sha })
134 return `Released v${version}: ${dev} merged into ${main} (${sha.slice(0, 7)}), tag v${version}.`
135}
136ROUTES.release = async (h, c) => ({ text: await releaseDev(h, c.args[0]) })
137core/admission.ts 221 lines1// agent.spawn admission (DESIGN 3.4, 4.6, 8.4): claim, next step / rebind, review spawn, marker-less retype, lease staleness, recovery.
2// Judge before next; on spawn rejection release and rethrow (AG-12). `admit` is the slot; `admitWith` is the same flow over explicit Deps (tests).
3import type { Binding, Hook, Host, Role, Snapshot, SnapTask } from '../types'
4import type { Config } from '../config/defaults'
5import { StoreError, type Task, type TaskStore } from '../task-management/store'
6import { kindOf, pipeOf, type TypeRegistry } from '../task-management/registry'
7import { repoKeyOf } from '../commands/init'
8import { cfgOf, storeOf } from './dispatch'
9import { agentsOf, bindAgent, commit, leaseLive, safe, sessionOf, unbindAgent } from './board'
10import { savePointer } from './session'
11import { BLOCKING, apply, epicNext } from './machine'
12import { emit } from './events'
13import { flagName } from './render'
14import { DISCOVERY, brief, idsIn, type BriefIn } from './briefs'
15import { taskBranch, wtPath, wtRootOf } from './branches'
16import * as G from './git'
17import { checkpoint } from './effect'
18import { warm } from './session'
19import { buildView, pkgOf } from './specview'
20import { realRoot } from '../policy/guards'
21
22export interface Deps {
23 store: TaskStore; cfg: Config; reg: TypeRegistry; root: string; home: string; wtRoot: string; key: string; actor: string; session: string
24 view?: (h: Host, d: Deps, t: Task, wt: string) => Promise<string> // spec view builder (core/specview, M5); absent = tester/red are skipped (3.1)
25}
26const ROLE: Record<string, Role> = { tester: 'tester', worker: 'worker', reviewer: 'reviewer', explorer: 'explorer' } // red/green are mod-run, merge is main's close
27const deny = (why: string) => ({ deny: why })
28
29
30// Everything admission needs from the outside world, resolved per hook (never cached across hooks): store + config via the register.ts wiring.
31export async function resolveDeps(h: Host): Promise<Deps> {
32 const [root, home, cfg] = [await h.cwd(), await h.home(), await cfgOf(h)], store = await storeOf(h), key = repoKeyOf(root), ak = `cadre:${key}:actor`
33 let actor = (await h.store.get(ak)) as string | undefined
34 if (!actor) { actor = `cadre/${home.split('/').pop() || 'user'}/${h.rand(3)}`; await h.store.set(ak, actor) }
35 return { store, cfg, reg: cfg.types as TypeRegistry, root, home, wtRoot: await realRoot(h, await wtRootOf(h, cfg)), key, actor, session: await sessionOf(h), view: buildView }
36}
37export const admit: Hook = async (h, e, next) => { await warm(h); return admitWith(h, await resolveDeps(h), e, next) }
38
39const modelOf = (d: Deps, role: Role, t?: Task) => {
40 const tier = role === 'reviewer' && t?.labels.some(l => d.cfg.review.deepLabels.includes(l)) ? 'deep' : d.cfg.roles[role]?.tier
41 return tier ? (d.cfg.models as Record<string, string>)[tier] ?? tier : undefined
42}
43const stepsOf = (d: Deps) => (type: string) => { const p = pipeOf(d.reg)(type); return d.view ? p : p.filter(s => s !== 'tester' && s !== 'red') } // no view builder: tester/red skipped
44
45export async function admitWith(h: Host, d: Deps, e: any, next: (e: any) => any): Promise<any> {
46 if (e.isTeammate) return deny('agent teams are not available; use a plain Agent call')
47 if (e.workflow) return deny('workflow agents are not available; use a plain Agent call')
48 const s = await h.board.read(), ids = idsIn(e.prompt ?? '', s.tasks.map(t => t.id))
49 if (ids.length > 1) return deny(`one agent takes one task, but the prompt names ${ids.join(', ')}; spawn one agent per task`)
50 if (!ids.length) { // marker-less: explorer (or the discovery brief while init stage 4 is pending); forks pass untouched
51 if (e.fork) return next(e)
52 const disc = s.init.some(i => i.stage === 4 && i.state === 'pending'), m = modelOf(d, 'explorer')
53 const r = await next({ ...e, subagentType: 'cadre:explorer', ...(m ? { model: m } : {}), prompt: disc ? `${DISCOVERY}\n\n## Notes\n${String(e.prompt ?? '').slice(0, 500)}` : brief('explorer', { id: '', title: '', type: '', question: e.prompt }) })
54 if (disc && r?.agentId) await h.ui.update(u => ({ ...u, discoveryAgent: r.agentId })) // sec L2: only this agent's discovery report is accepted
55 return r
56 }
57 if (e.fork) return deny('a task agent cannot be a fork; spawn it without fork')
58 const t = s.tasks.find(x => x.id === ids[0])!
59 if (!d.reg[t.type]) return deny(`${t.id}: unknown type "${t.type}"`)
60 if (t.state === 'todo') return claim(h, d, e, next, s, t)
61 if (t.state === 'active' || t.state === 'blocked') return rebind(h, d, e, next, s, t)
62 if (t.state === 'review') return review(h, d, e, next, s, t)
63 return deny(`${t.id} is ${t.state}; nothing to spawn`)
64}
65
66// ---- shared tail: retype, model, cwd, next(), bind. A rejected or thrown spawn runs `undo` (release) and rethrows. ----
67async function launch(h: Host, d: Deps, e: any, next: (e: any) => any, t: SnapTask, role: Role, prompt: string, cwd: string, step: string, wt: string, undo: () => Promise<void>, task?: Task) {
68 const m = modelOf(d, role, task)
69 let r: any
70 try { r = await next({ ...e, subagentType: `cadre:${role}`, prompt, ...(m ? { model: m } : {}), cwd }) } catch (err) { await undo(); throw err }
71 if (r?.deny) { await undo(); return r }
72 await unbindAgent(h, `~${t.id}`)
73 if (r?.agentId) {
74 await bindAgent(h, r.agentId, { taskId: t.id, role, step, worktree: wt, sessionId: d.session, ctxTokens: 0, startedAt: await h.now() })
75 await emit(h, 'agent.bound', { agentId: r.agentId, taskId: t.id, role }, { agentId: r.agentId, taskId: t.id, actor: 'mod' })
76 await safe(h, 'pointer', () => savePointer(h)) // S22: a /clear before main's next turn.complete must still find this binding (the pointer was saved only there)
77 }
78 return r
79}
80// CAS reservation in the binding table ("claiming/reviewing" transients live in $.state): false when another spawn holds it or a live agent of that role is bound.
81async function reserve(h: Host, d: Deps, id: string, role: Role): Promise<boolean> {
82 let got = false
83 await h.bind.update(b => { got = !b[`~${id}`] && !agentsOf(b, id).some(a => b[a].role === role); return got ? { ...b, [`~${id}`]: { taskId: id, role, step: '', worktree: '~', sessionId: d.session, ctxTokens: 0, startedAt: 0 } } : b })
84 return got
85}
86const viewed = (d: Deps, role: Role) => role === 'tester' && !!d.view && d.cfg.roles.testerView !== 'full' && d.cfg.roles.tdd.author !== 'same' // testerView:full or author:same (3.5): the tester step works in the worktree
87const agentOf = (d: Deps, role: Role): Role => (role === 'tester' && d.cfg.roles.tdd.author === 'same' ? 'worker' : role) // author:same: the worker agent, tester brief (3.5)
88const briefOf = (s: Snapshot, t: SnapTask, task: Task, e: any, d: Deps, role: Role, extra: Partial<BriefIn> = {}): BriefIn => ({
89 testPaths: role === 'tester' ? pkgOf(d, task)?.tdd?.paths : undefined, locked: task.meta['cadre.locked'] ? JSON.parse(task.meta['cadre.locked']) : undefined, redCard: task.meta['cadre.red'],
90 id: t.id, title: t.title, type: t.type, description: task.description, scenarios: task.acceptance, contract: task.design, notes: e.prompt, protectedPaths: [...G.PROTECTED, ...(d.cfg.changelog.path !== 'CHANGELOG.md' ? [d.cfg.changelog.path] : [])],
91 deps: t.deps.filter(x => x.type !== 'conditional-blocks').map(x => ({ id: x.id, title: s.tasks.find(y => y.id === x.id)?.title ?? '' })), ...extra })
92
93async function setup(h: Host, d: Deps, task: Task, wt: string) { // packages[] are hand-written until M5; `setup` runs in the writable jail (network on)
94 const pkg = pkgOf(d, task) as any
95 const pkgRoot = !pkg || pkg.root === '.' || !pkg.root ? wt : `${wt}/${pkg.root}`, scratch = `${d.wtRoot}/${d.key}.scratch/${task.id}`
96 for (const argv of pkg?.setup?.length ? [pkg.setup as string[]] : []) { // Pkg.setup is one argv (discover.ts)
97 await h.run(['mkdir', '-p', scratch])
98 const r = d.cfg.jail === 'off' ? await h.run(argv, { cwd: pkgRoot }) : await h.run(G.writableJail({ wt, scratch, pkgRoot, argv, writable: d.cfg.jailWritable }))
99 if (r.exitCode) throw new Error(`setup failed: ${argv.join(' ')}: ${r.stderr.slice(-300)}`)
100 }
101}
102// worktree = the task branch (3.6). Reuses a kept worktree; re-adds one whose directory is gone (8.4); null when the branch itself is missing.
103async function ensureWt(h: Host, d: Deps, wt: string, branch: string): Promise<'kept' | 'readd' | null> {
104 if (await h.stat(wt)) return 'kept'
105 if (!(await G.exists(h.run, d.root, branch))) return null
106 await G.op(h, () => G.worktreeReadd(h.run, d.root, wt, branch)); return 'readd'
107}
108async function lostEpic(h: Host, s: Snapshot, t: SnapTask) { // epic branch missing: epic blocked, human resolves (re-cut from dev or cancel)
109 await commit(h, x => (x.plan && epicNext(x.plan.state, 'lost') ? { ...x, plan: { ...x.plan, state: epicNext(x.plan.state, 'lost')!, flags: [...new Set([...(x.plan.flags ?? []), 'lost-epic'])] } } : x))
110 await emit(h, 'flag.raised', { taskId: s.epic ?? t.id, flag: 'lost-epic' }, { taskId: t.id })
111 return deny(`epic branch ${s.plan?.epicBranch} is missing; ask the human to recreate it from dev or cancel the epic (/cadre resolve ${s.epic} recut|cancel)`)
112}
113
114// ---- todo -> active: the claim ----
115async function claim(h: Host, d: Deps, e: any, next: (e: any) => any, s: Snapshot, t: SnapTask) {
116 const spec = d.reg[t.type], pipe = stepsOf(d), first = pipe(t.type)[0]
117 if (!s.epic || !s.plan) return deny('no approved plan yet; ask the human to approve it')
118 if (first !== 'worker' && first !== 'explorer' && first !== 'tester') return deny(`${t.id}: type ${t.type} starts at "${first}", which is not available; pick another task`)
119 const flag = t.flags.find(f => BLOCKING.test(f)); if (flag) return deny(`${t.id} has flag ${flagName(flag)}; ask the human to resolve it (/cadre resolve)`)
120 if (t.condition?.state === 'not-met') return deny(`${t.id} never runs: its condition (${t.condition.on} failed) is not met`)
121 if (!(await d.store.ready(s.epic)).includes(t.id)) return deny(`${t.id} is not ready (waiting on a dependency or gate); spawn a task that TaskList shows ready`)
122 const running = s.tasks.filter(x => x.state === 'active').length
123 if (running >= d.cfg.maxParallel) return deny(`${running} agents already running (limit ${d.cfg.maxParallel}); wait for one to finish`)
124 const spike = kindOf(spec) === 'spike'
125 if (!spike && !(await G.exists(h.run, d.root, s.plan.epicBranch))) return lostEpic(h, s, t)
126 const res = await apply(h, { kind: 'claim', id: t.id, at: await h.now(), actor: 'mod', owner: d.actor }, pipe) // the CAS reserve: one winner (S2)
127 if (!res.ok) return deny(res.error)
128 const step = res.board.tasks.find(x => x.id === t.id)!.step, role = ROLE[step], branch = taskBranch(spec.commit ?? 'wip', t.id, t.title), wt = wtPath(d.wtRoot, d.key, t.id)
129 let claimed = false, created = false
130 const undo = async () => {
131 if (created) await G.op(h, () => G.worktreeRemove(h.run, d.root, wt, branch)).catch(() => {})
132 if (claimed) await d.store.release(t.id, d.actor, 'spawn failed').catch(() => {})
133 await apply(h, { kind: 'release', id: t.id, at: await h.now(), actor: 'mod' }, pipe)
134 }
135 try {
136 const task = await d.store.claim(t.id, d.actor); claimed = true
137 let cwd = d.root, meta: Record<string, string> = { 'cadre.step': step }
138 if (!spike) {
139 const base = await G.tip(h.run, d.root, s.plan.epicBranch)
140 if (!(await ensureWt(h, d, wt, branch))) { await G.op(h, () => G.worktreeAdd(h.run, d.root, wt, branch, base)); created = true; await setup(h, d, task, wt) }
141 cwd = viewed(d, role) ? await d.view!(h, d, task, wt) : wt
142 meta = { ...meta, 'cadre.base': task.meta['cadre.base'] ?? base, 'cadre.branch': branch, 'cadre.worktree': wt }
143 }
144 await d.store.setMeta(t.id, meta, d.actor)
145 return await launch(h, d, e, next, t, agentOf(d, role), brief(role, briefOf(s, t, task, e, d, role)), cwd, step, spike ? '' : cwd, undo, task)
146 } catch (err) {
147 await undo()
148 if (err instanceof StoreError) return deny(`${err.code}: ${err.message}`)
149 throw err
150 }
151}
152
153// ---- active|blocked: next step (new role) or rebind (stale lease). Same claim actor, same worktree, computed handoff. ----
154async function rebind(h: Host, d: Deps, e: any, next: (e: any) => any, s: Snapshot, t: SnapTask) {
155 const task = await d.store.get(t.id), role = ROLE[t.step], now = await h.now(), bind = await h.bind.read(), mine = agentsOf(bind, t.id)
156 if (!role || (role === 'tester' && !d.view)) return deny(`${t.id}: step ${t.step || '?'} runs without an agent; wait and call TaskList`)
157 if (mine.some(a => a.startsWith('~'))) return deny(`${t.id} is already being spawned; wait`)
158 if (role === 'worker' && stepsOf(d)(t.type).includes('red') && !task.meta['cadre.specCommit']) return deny(`${t.id}: its spec tests are not recorded yet; the tester step must finish first`)
159 const live = leaseLive(task, now)
160 if (live && !mine.length) return deny(`${t.id} is being worked on from another session; leave it`)
161 if (live && mine.some(a => bind[a].role === role && !bind[a].retired)) return deny(`${t.id} is running; wait for its result (TaskList)`) // retired (core/reuse: context or TTL over) = rebind fresh
162 if (t.state === 'blocked' && task.meta['cadre.decision'] === undefined) return deny(`${t.id} is blocked; ask the human to decide (/cadre resolve)`)
163 const spike = kindOf(d.reg[t.type]) === 'spike', wt = task.meta['cadre.worktree'] ?? wtPath(d.wtRoot, d.key, t.id), branch = task.meta['cadre.branch'] ?? ''
164 if (!spike) {
165 const how = await ensureWt(h, d, wt, branch)
166 if (!how) { // task branch missing: lost-wip, release to todo; the next claim starts fresh from the epic tip
167 await apply(h, { kind: 'release', id: t.id, at: now, actor: 'mod' }, stepsOf(d)); await d.store.release(t.id, d.actor, 'lost-wip').catch(() => {})
168 await emit(h, 'flag.raised', { taskId: t.id, flag: 'lost-wip' }, { taskId: t.id })
169 return deny(`${t.id}: its branch is gone; it was released, spawn it again for a fresh start`)
170 }
171 if (how === 'readd') await setup(h, d, task, wt)
172 }
173 for (const a of mine) { await unbindAgent(h, a); await emit(h, 'agent.unbound', { agentId: a, reason: live ? 'rebind' : 'stale' }, { agentId: a, taskId: t.id }) }
174 if (!(await reserve(h, d, t.id, role))) return deny(`${t.id} is already being spawned; wait`)
175 const undo = async () => { await unbindAgent(h, `~${t.id}`) }
176 try {
177 if (t.state === 'blocked') await apply(h, { kind: 'unblock', id: t.id, at: now, actor: 'mod' }, stepsOf(d))
178 await d.store.resume(t.id, d.actor)
179 // computed handoff (never a model summary): checkpoint, diff since the handoff base, last notes
180 let handoff = ''
181 if (!spike) {
182 const cp = (await checkpoint(h, d.store, d.actor, t.id, wt)) ?? (await G.tip(h.run, wt, 'HEAD')), // locked-dirty refuses the commit (8.4)
183 hb = (d.reg[t.type].pipeline.includes('red') && task.meta['cadre.specCommit']) || task.meta['cadre.base'] || cp
184 await d.store.setMeta(t.id, { 'cadre.checkpoint': cp }, d.actor)
185 handoff = [`checkpoint ${cp}`, await G.diffStat(h.run, wt, `${hb}..${cp}`), await G.diffPatch(h.run, wt, `${hb}..${cp}`), task.meta['cadre.report'] ?? ''].filter(Boolean).join('\n')
186 }
187 const cwd = spike ? d.root : viewed(d, role) ? await d.view!(h, d, task, wt) : wt
188 return await launch(h, d, e, next, t, agentOf(d, role), brief(role, briefOf(s, t, task, e, d, role, { handoff, decisions: task.meta['cadre.decision'] ? [task.meta['cadre.decision']] : undefined })), cwd, t.step, spike ? '' : cwd, undo, task)
189 } catch (err) { await undo(); throw err }
190}
191
192// ---- review -> reviewing: a fresh reviewer, never a fork, tier by labels ----
193async function review(h: Host, d: Deps, e: any, next: (e: any) => any, s: Snapshot, t: SnapTask) {
194 if (t.step !== 'reviewer') return deny(`${t.id} passed review; main completes it (TaskUpdate completed)`)
195 const task = await d.store.get(t.id), src = task.meta['cadre.sourceCommit']
196 if (!src) return deny(`${t.id} has no commit to review yet`)
197 if (!(await reserve(h, d, t.id, 'reviewer'))) return deny(`${t.id} already has a reviewer; wait for its verdict`)
198 const wt = task.meta['cadre.worktree'] ?? wtPath(d.wtRoot, d.key, t.id), base = task.meta['cadre.base'] ?? src, undo = async () => { await unbindAgent(h, `~${t.id}`) }
199 try {
200 const diffStat = await G.diffStat(h.run, wt, `${base}..${src}`), diff = await G.diffPatch(h.run, wt, `${base}..${src}`)
201 return await launch(h, d, e, next, t, 'reviewer', brief('reviewer', briefOf(s, t, task, e, d, 'reviewer', { sourceCommit: src, diffStat, diff, report: task.meta['cadre.report'], flags: t.flags })), wt, 'reviewer', wt, undo, task)
202 } catch (err) { await undo(); throw err }
203}
204
205// ---- lease heartbeat (8.4): the tick calls this; bd only, no hooks needed. Beats each of THIS session's bound tasks every `every` ms. ----
206export async function beat(h: Host, d: Deps, every = 120_000): Promise<void> {
207 const [bind, now] = [await h.bind.read(), await h.now()]
208 for (const [a, b] of Object.entries(bind)) {
209 if (a.startsWith('~') || b.sessionId !== d.session) continue
210 const k = `cadre:${d.key}:hb:${b.taskId}`
211 if (now - (((await h.store.get(k)) as number | undefined) ?? 0) < every) continue
212 await d.store.heartbeat(b.taskId).catch(err => h.log(`cadre: heartbeat ${b.taskId} failed: ${err}`)); await h.store.set(k, now)
213 }
214}
215
216// ---- tick (5 s): heartbeat this session's bound tasks; nothing else, never a send (G6) ----
217export async function tick(h: Host): Promise<void> {
218 await warm(h)
219 if (Object.keys(await h.bind.read()).some(a => !a.startsWith('~'))) await beat(h, await resolveDeps(h))
220}
221policy/containment.ts 24 lines1// L2 containment check at session.start (DESIGN 13.1): effective settings -> contained / degraded / N commands unsandboxed.
2import type { Host, Snapshot } from '../types'
3import { policyOf } from './guards'
4
5export const assess = (s: Record<string, any>, repo: string | undefined, wtRepo: string | undefined): { state: Snapshot['containment']['state']; unsandboxed: number; why: string[] } => {
6 const sb = s.sandbox ?? {}, fsw = sb.filesystem ?? {}, why: string[] = []
7 if (sb.enabled !== true) why.push('sandbox not enabled')
8 if (sb.failIfUnavailable !== true) why.push('failIfUnavailable off')
9 if (sb.allowUnsandboxedCommands !== false) why.push('allowUnsandboxedCommands not false')
10 if (!repo || !(fsw.denyWrite ?? []).includes(repo)) why.push('denyWrite lacks the repo')
11 if (!wtRepo || !(fsw.allowWrite ?? []).includes(wtRepo)) why.push('allowWrite lacks the worktree root')
12 if (sb.network?.allowLocalBinding) why.push('allowLocalBinding on')
13 return { state: why.length ? 'degraded' : 'contained', unsandboxed: Array.isArray(sb.excludedCommands) ? sb.excludedCommands.length : 0, why }
14}
15
16// session.start: read, assess, commit to the board band, toast when degraded. A read failure leaves it 'unknown' (L3 then audits every call).
17export async function contain(h: Host): Promise<void> {
18 const pol = await policyOf(h)
19 const r = await h.settings().then(s => assess(s, pol.repo, pol.wtRoot), () => ({ state: 'unknown' as const, unsandboxed: 0, why: ['settings unreadable'] }))
20 await h.board.update(b => ({ ...b, containment: { state: r.state, unsandboxed: r.unsandboxed } }))
21 if (r.state !== 'contained') h.toast(`cadre: containment ${r.state} · /cadre doctor`)
22 if (r.unsandboxed) h.toast(`cadre: ${r.unsandboxed} commands unsandboxed · /cadre doctor`)
23}
24policy/guards.ts 157 lines1// L1 capability allowlist, file fence (DESIGN 13.1-13.2), tool.check allow, session.send isolation (4.6), config.set guard. Slots #4, #5, #7, config.set.
2import type { Binding, Host, Hook } from '../types'
3import { MAIN_TOOLS, allowlist } from '../agents/index'
4import { repoKeyOf } from '../commands/init'
5import { logCall } from '../core/specview'
6import { PROTECTED } from '../core/git'
7import { idsIn } from '../core/briefs'
8import { strictOf } from '../config/index'
9
10// Paths the guards need, written to $.store `cadre:<repoKey>:policy` by init/session.start/tdd (absolute, realpath'd). Absent keys disable the rule they feed.
11export interface Policy { repo?: string; wtRoot?: string; home?: string; mcpAllow?: string[]; locks?: Record<string, string[]>; protect?: string[]; effectCheck?: string; epicPrefix?: string; changelog?: string }
12export const policyKey = async (h: Host) => `cadre:${repoKeyOf(await h.cwd())}:policy` // per repo (13.5): two repos never share one
13export const policyOf = async (h: Host): Promise<Policy> => ((await h.store.get(await policyKey(h))) as Policy | undefined) ?? {}
14export const setPolicy = async (h: Host, patch: Policy) => h.store.set(await policyKey(h), { ...(await policyOf(h)), ...patch })
15
16const SHELL = ['Bash', 'PowerShell', 'Monitor'], FILE = ['Read', 'Grep', 'Glob', 'Write', 'Edit', 'NotebookEdit'], FENCED = ['Edit', 'Write', 'NotebookEdit']
17const MOD_DIRS = PROTECTED.filter(p => p.endsWith('/')).map(p => p.slice(0, -1)), MOD_FILES = PROTECTED.filter(p => !p.endsWith('/'))
18const under = (p: string, root?: string) => !!root && (p === root || p.startsWith(root.replace(/\/$/, '') + '/'))
19const join = (cwd: string, p: string) => (p.startsWith('/') ? p : `${cwd.replace(/\/$/, '')}/${p}`)
20const modOwned = (real: string, home?: string, changelog?: string) => {
21 const seg = real.split('/')
22 return (!!changelog && (real === changelog || real.endsWith(`/${changelog}`))) || seg.some(s => MOD_DIRS.includes(s)) || MOD_FILES.includes(seg[seg.length - 1]) || (!!home && /^cadre\.json$|^settings.*\.json$/.test(seg[seg.length - 1]) && under(real, home + '/.claude'))
23}
24
25// Step 0 of the fence: the path a file tool will touch, per tool (input fields). Glob with an absolute pattern: its non-glob base dir.
26export function inputPath(tool: string, i: any, cwd: string): string | undefined {
27 const v = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
28 if (tool === 'Read' || tool === 'Write' || tool === 'Edit') return v(i?.file_path)
29 if (tool === 'NotebookEdit') return v(i?.notebook_path)
30 if (tool === 'Grep') return v(i?.path) ?? cwd
31 if (tool === 'Glob') {
32 const pat = v(i?.pattern)
33 if (pat?.startsWith('/')) { const seg = pat.split('/'), k = seg.findIndex(s => /[*?[{]/.test(s)); const base = (k < 0 ? seg.slice(0, -1) : seg.slice(0, k)).join('/'); return base || '/' }
34 return v(i?.path) ?? cwd
35 }
36}
37
38// Step 1: where the path lands; a new file resolves its folder (SA-04). undefined = cannot place it.
39async function placed(h: Host, p: string): Promise<string | undefined> {
40 const name = p.slice(p.lastIndexOf('/') + 1)
41 if (!name || name === '.' || name === '..') return (await h.stat(p, { resolve: true }))?.realPath
42 const own = await h.stat(p, { resolve: true })
43 if (own) return own.realPath
44 // a new file (Write creates its folders): the deepest existing ancestor's real path + the rest; a '..' in the rest cannot be placed
45 const rest: string[] = []
46 for (let q = p; q.includes('/'); ) {
47 rest.unshift(q.slice(q.lastIndexOf('/') + 1)); q = q.slice(0, q.lastIndexOf('/')) || '/'
48 const dir = await h.stat(q, { resolve: true })
49 if (dir?.realPath !== undefined) return rest.some(x => x === '..' || x === '.') ? undefined : `${dir.realPath.replace(/\/$/, '')}/${rest.join('/')}`
50 if (q === '/') break
51 }
52 return undefined
53}
54
55// Who is calling: role + binding. Unknown ids and marker-less forks are explorers; no agentId = main.
56async function who(h: Host, agentId?: string) {
57 const b: Binding | undefined = agentId ? (await h.bind.read())[agentId] : undefined
58 const task = b && (await h.board.read()).tasks.find(t => t.id === b.taskId)
59 return { b, task, main: !agentId, role: b?.role ?? 'explorer' as const, owner: !!b && task?.state === 'active' }
60}
61
62const strict = async (h: Host) => (await strictOf(h)) !== 'advisory'
63
64// Fence verdict for one file-tool call: undefined = allow-by-fence (continue), 'allow' = tester rule 2, else a deny reason.
65async function fence(h: Host, w: Awaited<ReturnType<typeof who>>, tool: string, input: unknown, pol: Policy): Promise<string | 'allow' | 'tmp' | undefined> {
66 const tester = w.role === 'tester' && !w.main
67 const fenced = FENCED.includes(tool) || (tool === 'Read' && !!pol.home && typeof (input as any)?.file_path === 'string' && (input as any).file_path.includes('/.claude/plugins/store/'))
68 if (!tester && !fenced) return
69 if (!FILE.includes(tool)) return tester && !tool.startsWith('mcp__cadre__') ? `${tool} is not available to you; use Read, Write or Edit inside your working directory` : undefined // the role's own report tool passed the allowlist
70 const cwd = w.b?.worktree ?? pol.repo ?? '/'
71 const p = inputPath(tool, input, cwd)
72 const real = p === undefined ? undefined : await placed(h, join(cwd, p))
73 if (real === undefined) return 'that path cannot be used; give a plain path inside your working directory'
74 if (tester) return under(real, w.b!.worktree) ? 'allow' : 'write only inside your working directory'
75 const own = w.owner && under(real, w.b!.worktree)
76 if (!w.main && !own) return under(real, (await h.env()).TMPDIR || '/tmp') ? 'tmp' : 'write only inside your working directory (or $TMPDIR)' // positive jail: file tools are not OS-sandboxed (ADR 0006)
77 if (!own && (under(real, pol.repo) || under(real, pol.wtRoot))) return 'project files are edited by a task agent only; plan the change with TaskCreate, or spawn the agent of a planned task'
78 if (modOwned(real, pol.home, pol.changelog)) return 'that path is managed for you and cannot be edited; leave it alone'
79 if (w.owner && w.task?.step === 'worker' && (pol.locks?.[w.b!.taskId] ?? []).some(l => real === l || under(real, l))) return 'locked spec test: do not edit it; if it is wrong, report outcome "spec_change"'
80}
81
82// Pure L1 facts that need no state: the sandbox flag.
83const escape = (e: any) => (e?.dangerouslyDisableSandbox === true || e?.input?.dangerouslyDisableSandbox === true ? 'running unrestricted is not allowed; run the command normally' : undefined)
84
85// Event-only judgement for the re-entry .catch (G2): a deny reason, or undefined to let the call through. Must not use $.
86export const judge = (e: any): string | undefined => {
87 if (escape(e)) return escape(e)
88 if (!e.agentId) return MAIN_TOOLS.includes(e.tool) ? undefined : `${e.tool} is not available to main; spawn an agent for it`
89 if (SHELL.includes(e.tool) || (e.tool.startsWith('mcp__') && !e.tool.startsWith('mcp__cadre__'))) return 'this tool is not available here; use a different one' // bindings are unreadable here: over-deny
90 const p = e.file_path ?? e.notebook_path
91 if (FENCED.includes(e.tool) && typeof p === 'string' && modOwned(p)) return 'that path is managed for you and cannot be edited; leave it alone'
92}
93
94// #5 tool.call guards: L1 allowlist, fence. Judge before next. Advisory: denies become warnings.
95export const guard: Hook = async (h, e, next) => {
96 const deny = async (): Promise<string | undefined> => {
97 const bad = escape(e); if (bad) return bad
98 const w = await who(h, e.agentId), pol = await policyOf(h)
99 const list = w.main ? MAIN_TOOLS : allowlist(w.role, w.role === 'worker' ? pol.mcpAllow : [])
100 if (!list.includes(e.tool)) return `${e.tool} is not available to ${w.main ? 'main' : `the ${w.role}`}; use another tool`
101 if (SHELL.includes(e.tool) && !(w.role === 'worker' && w.owner && w.task?.step === 'worker')) return 'shell is available only while you implement your task; use run_check or inspect'
102 const f = await fence(h, w, e.tool, e, pol)
103 return f === 'allow' || f === 'tmp' ? undefined : f
104 }
105 const why = await deny()
106 if (!why) return next(e)
107 const tb = e.agentId && (await h.bind.read())[e.agentId]
108 if (tb && tb.role === 'tester') await logCall(h, e.agentId, { tool: e.tool, path: inputPath(e.tool, e.input ?? e, tb.worktree), outcome: why }) // the view audit counts refusals too (3.5.2)
109 if (await strict(h)) return { deny: why }
110 h.log(`cadre advisory: ${why}`); return next(e)
111}
112
113// #4 tool.check allow mirrors fence rule 2 (tester) and rule 3 (owner); anything else is the engine's call. Fail-closed answer is {decision:'deny'}.
114export const check: Hook = async (h, e, next) => {
115 const tool = e.tool as string
116 if (!e.agentId || !FILE.includes(tool)) return next(e)
117 const w = await who(h, e.agentId)
118 if (!w.b) return next(e)
119 const pol = await policyOf(h)
120 if (tool === 'Read' && w.role !== 'tester' && w.b.worktree) { // S26: Read needs the allow too (outside the session cwd it prompts), only inside its own tree
121 const p = inputPath(tool, e.input, w.b.worktree), real = p === undefined ? undefined : await placed(h, join(w.b.worktree, p))
122 return real && under(real, w.b.worktree) ? { decision: 'allow', reason: 'cadre: inside its own tree' } : next(e)
123 }
124 if (w.role !== 'tester' && !(w.owner && FENCED.includes(tool))) return next(e)
125 const f = await fence(h, w, tool, e.input, pol)
126 return f === 'allow' || f === undefined ? { decision: 'allow', reason: 'cadre: inside its own tree' } : next(e)
127}
128
129// #7 session.send: SendMessage naming another task id -> {isDelivered:false} (4.6).
130export const send: Hook = async (h, e, next) => {
131 const b = (await h.bind.read())
132 const own = (e.agentId && b[e.agentId]) || b[e.to]
133 if (!own || (await h.ui.read()).drainTo === e.to) return next(e) // cadre's own queued resume (core/reuse.sendMine) is exempt
134 const known = new Set((await h.board.read()).tasks.map(t => t.id))
135 const other = idsIn(`${e.to} ${e.text}`, known).find(id => id !== own.taskId)
136 return other ? { isDelivered: false, reason: `you work on ${own.taskId} only; do not name ${other}` } : next(e)
137}
138
139// Re-entry (fail-closed .catch, 13.3): the board is unreadable, so judge a spawn from the event: a task marker in the prompt is refused, marker-less passes.
140export const judgeSpawn = (e: any): string | undefined => /\b(TASK|bd)-[A-Za-z0-9]/.test(String(e.prompt ?? '')) ? 'refused: temporary failure; retry once, then ask the human' : undefined
141
142// config.set: refuse strictness changes while tasks run.
143export const configSet: Hook = async (h, e, next) => {
144 if (!/(^|\.)strictness$/.test(e.key) || e.value === e.previous) return next(e)
145 const running = (await h.board.read()).tasks.some(t => t.state === 'active' || t.state === 'review')
146 return running ? { deny: 'strictness cannot be changed while tasks are running; retry when they finish' } : next(e)
147}
148
149// Realpath of the worktree root once (its deepest existing ancestor is resolved): fence rule 3, locks and bindings all compare real paths (M13).
150export async function realRoot(h: Host, p: string): Promise<string> {
151 for (let q = p, tail = ''; q && q !== '/'; tail = q.slice(q.lastIndexOf('/')) + tail, q = q.slice(0, q.lastIndexOf('/'))) {
152 const r = (await h.stat(q, { resolve: true }))?.realPath
153 if (r) return r.replace(/\/$/, '') + tail
154 }
155 return p
156}
157