SLOPSHOPPER

cadre

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

newpanebandrowsguardcommand
★ 1v0.6.1no licenseupdated 2026-10-10it-dainb/cadre
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cadre
│ ┃ cadre ✕ › fix the failing auth test and add an audit log call │ ┃ 1: tab1 7: tab7 8: tab8 [LIVE] 0m │ ┃ 0/0 done · ▶0 ⏸0 ◌0 ⏺ Read(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ no tasks yet ⏺ Bash(bun test) │ ┃ ───────────────────────────────────────────… ⎿ 3 pass, 1 fail │ ┃ LOG │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /cadre │ ┃ ⎿ cadre: cadre is off in this repo · run /cadre init │ ┃ ───────────────────────────────────────────… │ ┃ TIMELINE 1 min/col · ░ offline · ┃ 0m │ ┃ ───────────────────────────────────────────… │ ┃ n: n p: p h: h l: l b: b f: f e: e t: t g: g │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cadre
1: tab1 7: tab7 8: tab8 [LIVE] 0m 0/0 done · ▶0 ⏸0 ◌0 ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── no tasks yet ──────────────────────────────────────────────────────── LOG ──────────────────────────────────────────────────────── TIMELINE 1 min/col · ░ offline · ┃ 0m ──────────────────────────────────────────────────────── n: n p: p h: h l: l b: b f: f e: e t: t g: g
README

cadre

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.

Install

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.

First run: /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).

Per project: /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.

#StageResult
1repogit repo, default branch, dev branch, .git/info/exclude
2storebd init, custom statuses/types, journal consent
3missiongoal, constraints, non-goals recorded as a milestone
4discoveryexplorer reads CI/manifests; setup and checks are validated in a jail
5containmentsandbox settings merge + learned cache dirs
6workflow profiletype registry, role tiers, tdd.author, drive bounds
7changelogKeep-a-Changelog skeleton
8sync (optional)external tracker adapter

The loop

  1. Plan. Main drafts a plan with 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.
  2. Approve. You 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.
  3. Execute. Main spawns 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.
  4. Close. Main sets the task completed; the mod squashes into the epic branch. When all tasks are done, completing the epic merges --no-ff into dev.
  5. Release. /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).

Commands

/cadre …WhoEffect
approve <id> · approve <id>@<code8>humanopen the sheet · approve (code burned on use)
reject <planId> <why>humandrop drafts
`resolve <id> keep\accept\reject\cancel · resolve git keep\restore`humanclear flags / quarantined git effects
release [x.y.z]humancut a release
setup [confirm <code>]humanone-time machine check; proposes user-settings changes
doctoranyoneread-only diagnosis, report in the cadre pane
init [<stage>] [confirm <code>]humanstaged setup
sync add <adapter> · sync [now]human · anyoneexternal tracker setup · wake sync
adapter tokenhumannew adapter session token (toast only)
`config [show\set <k> <v>]`set: humanshow/change config
`drive on\off\status`on/off: humanautonomy: cadre's own bounded goal
statusanyonetext board and cost line
`open [epic\plan\task <id>\sync\cost\release\init]`anyoneopen 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.

Config

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.

KeyClassDefault
strictnessSstrict (advisory turns denials into warnings)
approval.allowSdkSfalse
mcpAllowS[]
jail, jailWritableSauto, []
effectCheckSauto
roles.testerViewSview (full = tester sees the worktree)
adapters.<name>Unone
store.kind, store.bin, store.dirUbeads, bd, repo
worktrees.rootU${XDG_CACHE_HOME:-$HOME/.cache}/cadre/wt
drive.{level,maxTurns,maxCostUsd,maxWallMinutes,noProgressTurns}Uepic, 100, 20, 240, 3
cost.prices, disableUnone, false
store.timeoutMs, store.pollMsO45000, 10000
typesOfeature/bug/refactor/docs/test/spike
`roles.<worker\tester\reviewer\explorer>.{tier,effort} · roles.tdd.author`Ostandard/default (explorer fast/low) · separate
modelsO{fast: haiku, standard: sonnet, deep: opus}
packages[]Odiscovered and validated by /cadre init
suites.integrationOall
review.{deepLabels,maxRework} · limits.reportRetriesO["security","breaking"], 3 · 3
changelog.pathOCHANGELOG.md
branches.{main,dev,epicPrefix}Oinit · dev · epic/
release.onEpicDoneOpropose
ui.glyphsOunicode (ascii for fonts without the glyphs)
maxParallel, maxRowsO3, 6
reuse.{maxContextPct,cacheTtlMs}O60, 3600000
cost.{clearHintPct,compactPct}O40, 60
sync.<target>Onone

Surfaces

  • Band + cadre pane (ui/): Epic and Plan tabs, approval sheet, events, timeline. Read-only clients of one versioned read model.
  • Text: /cadre status, toasts, the card returned in the Agent result.
  • Extension protocol (docs/protocol.md): events, snapshot, commands, and external process adapters over one protocol; adapters get the model view only and need grants.
  • Native tools: Task* answered by the mod; plan rules arrive just in time with the first TaskCreate.

Out of scope (v1)

ItemWhyEver?
Parsing shell command text or stderr; deny lists of channelsBrittle; layers decide on effects, not textnever
Language/ecosystem presets in codeStack specifics are discovered and validated confignever
Magic keywords, Stop-hook loops, /goal, statusLine HUD command, skillsOne loop authoritynever
Op-log sync queueSync is state-based reconciliationnever
Pane teammates, agent teams, workflow agentsUnseen or rewrite-proofroadmap (in-process teammates)
Creating remote projects or custom fieldsAdmin actions; setup maps to what existsroadmap flag
Everything on the DESIGN §15 roadmap lineBudget, YAGNIroadmap

cadre stops model drift, not the user: a user-tier mod can be switched off. It never pushes.

Known limitations

From the M0 spike (ADR 0001):

  • PENDING-HUMAN smokes (need an interactive TUI): S22 (background workers across /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.
  • A restored /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.
  • No bwrap overlay (needs ≥ 0.11; hosts have 0.8/0.9). Checks run in a copy-jail: a throwaway copy of the worktree, writable, network off. The cache audit uses an empty scratch HOME listing.
  • bd 1.3.1 has no --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).
  • Tester has Read/Write/Edit only (this build exposes no Grep/Glob, S45); briefs name the paths. Plan B (ADR 0004) is not needed.
  • Headless (-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.
  • Per-repo state: the mod's policy (repo, worktree root, locks) is keyed per repo, so two repos in one user config do not share it.
  • Workers cannot git add/commit (repo-level denyWrite, S24): the mod commits. /tmp is not writable for subagents; use $TMPDIR.
  • Per-agent 1 h cache TTL needs plugin agent files and may be ignored on accounts without usage credits; cadre reads the usage fields to detect it.

Development

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/.

Source 55 files
hooks/register.ts 164 lines
1// 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}
164
core/board.ts 60 lines
1// 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}
60
core/events.ts 93 lines
1// 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}
93
core/reuse.ts 152 lines
1// 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}
152
core/drive.ts 98 lines
1// 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 }
98
core/session.ts 118 lines
1// 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}
118
config/route.ts 21 lines
1// /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}
21
core/close.ts 332 lines
1// 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}
332
core/integrate.ts 137 lines
1// 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]) })
137
core/admission.ts 221 lines
1// 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}
221
policy/containment.ts 24 lines
1// 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}
24
policy/guards.ts 157 lines
1// 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