@sabaiway/agent-workflow-kit 14.0.0 → 14.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/dispatch.md +4 -4
- package/references/modes/review-state.md +1 -1
- package/tools/checkpoint-restore.mjs +62 -5
- package/tools/checkpoint.mjs +16 -7
- package/tools/dispatch-baseline.mjs +49 -21
- package/tools/dispatch-record.mjs +8 -2
- package/tools/dispatch-store-read.mjs +35 -1
- package/tools/dispatch-store.mjs +10 -3
- package/tools/dispatch.mjs +58 -17
- package/tools/exec-producer.mjs +4 -4
- package/tools/held-session.mjs +57 -18
- package/tools/task-brief.mjs +20 -2
- package/tools/task-thread.mjs +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 14.2.0 — a task thread is measured over its own files: file-disjoint tasks of one checkpoint run as a wave, `open` refuses `task-files-scope`, `return` and `fold` refuse `out-of-files`, and `checkpoint restore <oid> --nonce <n>` undoes one task (AD-146)
|
|
8
|
+
|
|
9
|
+
**Tasks can now run side by side.** In 14.1.0 a delegation thread could name its task, but it was still measured over
|
|
10
|
+
the whole tree, so a second task's edits spoiled the first one's fingerprints and only one exec dispatch could run at a
|
|
11
|
+
time. From 14.2.0 a task thread is measured over the files its brief lists, so tasks whose files do not overlap can run
|
|
12
|
+
on one checkpoint at once, each on its own codex session. Untasked threads measure and restore exactly as in 14.1.0.
|
|
13
|
+
Story S9 of the epic `EPICS-STORIES-TASKS-AS-THE-UNIT-OF-WORK`; the contract `docs/ai/specs/kit/task-thread/` is now
|
|
14
|
+
live.
|
|
15
|
+
|
|
16
|
+
**Before you upgrade:** close every task thread opened under 14.1.0. A thread opened there is measured under two rules
|
|
17
|
+
and is not guaranteed to refuse. To undo a stray change: inside the files of a task thread closed without a fold, run
|
|
18
|
+
that thread's task restore on its own checkpoint; otherwise close every thread and run the whole-tree `restore <oid>`.
|
|
19
|
+
|
|
20
|
+
- **The files scope (`tools/dispatch-baseline.mjs`, `tools/dispatch-store.mjs`, `tools/exec-producer.mjs`).** The
|
|
21
|
+
base-relative diff, payload, fingerprint and clean probe take the thread's file list; the store fingerprint, the
|
|
22
|
+
returned-object enumeration and the returned diff pass it. Without a list every value is byte-identical to 14.1.0.
|
|
23
|
+
- **`dispatch open --task` refuses `task-files-scope`** for a listed path a task may not claim, naming the path and
|
|
24
|
+
its kind.
|
|
25
|
+
- **`dispatch return` and `fold` refuse `out-of-files`** for a task thread, naming every changed path that no open or
|
|
26
|
+
folded task thread on the same checkpoint lists (since the last commit); they fail closed on an undecidable domain or
|
|
27
|
+
an unreadable ledger.
|
|
28
|
+
- **`task-brief check` tolerates a wave.** A checkpoint dirtied only in files that other open or folded task threads on
|
|
29
|
+
it list (since the last commit) still passes; dirt in the brief's own files or in a path no such thread lists refuses
|
|
30
|
+
`checkpoint-stale`, and an unreadable ledger refuses with its reason.
|
|
31
|
+
- **`checkpoint restore <oid> --nonce <n>`** (`tools/checkpoint-restore.mjs` exports `restoreTaskThread`) restores
|
|
32
|
+
exactly one closed task thread's files and leaves every other path alone; it refuses `open-thread`, `task-folded` and
|
|
33
|
+
`task-target` in the contract's states, proves the result with git's own diff (external diff helpers and textconv off)
|
|
34
|
+
and prints `checkpoint restore <oid> task <n> proof clean`. The whole-tree `restore <oid>` is unchanged.
|
|
35
|
+
- **Copy.** `DISPATCH_CONTRACT` and the dispatch mode doc gain the task clause; D10 is stated as a bar for untasked
|
|
36
|
+
threads and a mechanism for task threads in the mode doc, the module comment and HELP, and `await`'s no-slot message
|
|
37
|
+
no longer restates it; the README's dispatch row names both refusals.
|
|
38
|
+
- **Tests.** New suites `dispatch-baseline.test/files-scope.test.mjs`, `dispatch.test/task-scope.test.mjs`,
|
|
39
|
+
`dispatch.test/task-wave.test.mjs` (two tasks, two sessions, no degrade) and `checkpoint.test/task-restore.test.mjs`;
|
|
40
|
+
six wave cases in `task-brief.test.mjs`.
|
|
41
|
+
|
|
42
|
+
## 14.1.0 — a dispatch names its task: `dispatch open --task <brief>` records the brief and its files, the ledger refuses overlapping and mixed task threads (`files-overlap`, `mixed-open`), and the held-session judge holds one session per task instead of one per commit (AD-145)
|
|
43
|
+
|
|
44
|
+
**A held session now belongs to a task.** Until 14.0.0 the held-session judge kept ONE held session per commit, so a
|
|
45
|
+
planned fresh session for the next task read as a forbidden substitution. From 14.1.0 a delegation thread can name the
|
|
46
|
+
task it carries out, and each task's threads form their own chain with their own held session. Every ledger a 14.x kit
|
|
47
|
+
wrote reads unchanged; a ledger holding a task record needs this kit to read it. Story S8 of the epic
|
|
48
|
+
`EPICS-STORIES-TASKS-AS-THE-UNIT-OF-WORK`; the contract is the draft `docs/ai/specs/kit/task-thread/` (S1–S4 and S6
|
|
49
|
+
bound), the live `docs/ai/specs/kit/held-session.md` revised. Parallel task threads are NOT part of this release: a
|
|
50
|
+
task thread is still measured over the whole tree, so D10's one-dispatch-at-a-time bar still binds every thread.
|
|
51
|
+
|
|
52
|
+
- **The task key (`tools/task-thread.mjs`, new, 70 lines).** A `dispatch` record may carry `task: { brief, files }`,
|
|
53
|
+
the dispatch kind's second optional key at schema 1: `brief` is `docs/plans/TASK-<stem>-T<n>.md`, `files` a
|
|
54
|
+
non-empty list of distinct normalized repo-relative paths in UTF-8 byte order, none a path prefix of another. Three
|
|
55
|
+
refusals by name — `task-brief`, `task-files`, `task-base` (a task on a head base) — beside the closed nested keys.
|
|
56
|
+
The pure leaf also exports `readsTask` (a record minted before the field reads `null`), `readsInsideEpoch` (moved
|
|
57
|
+
from `held-session.mjs`), `claimedPaths`, `epochClaims` and `byteOrder`; it imports nothing from the ledger modules.
|
|
58
|
+
- **`dispatch open --checkpoint <oid> --task <brief>`.** Reads the brief at the git top-level through the no-follow
|
|
59
|
+
reader, parses it with `task-brief`'s own parser and records its `Files:` paths in byte order. `--task` without
|
|
60
|
+
`--checkpoint` refuses `task-base`; a brief the parser refuses keeps the parser's name; nothing is written on a
|
|
61
|
+
refusal. A retry must record its origin's task. An open without `--task` on a ledger with no task record is unchanged.
|
|
62
|
+
- **Two refusals at append time (`tools/dispatch-store-read.mjs`, `tools/dispatch-store.mjs`).** Since the last
|
|
63
|
+
commit, `files-overlap` refuses a task thread whose files overlap by path prefix an open task thread's or a folded
|
|
64
|
+
thread's of another brief; `mixed-open` refuses an untasked open beside an open task thread, an untasked `code` open
|
|
65
|
+
of backend `codex` after a task fold, and a task open beside an open untasked thread. Both are judged under the store
|
|
66
|
+
lock against a HEAD read taken there, and the read audit never replays them, so moving HEAD back keeps the ledger
|
|
67
|
+
readable. An open without `--task` on a ledger with no task record takes no extra git read.
|
|
68
|
+
- **One held session per task chain (`tools/held-session.mjs`).** The chain key is the brief; threads without a task
|
|
69
|
+
keep the one commit-epoch chain. With a task `code` thread of backend `codex` in the epoch the judge reports `chains`
|
|
70
|
+
and a per-thread `chainKey`, keeps `heldId`, `folds` and `open` on the untasked chain while `substitution` and
|
|
71
|
+
`threads` span every chain; `review-state` prints `held sessions: <k> chain(s) — <label>: <id> (<n>) · …`,
|
|
72
|
+
`commit-guard --check` inherits its refusal verbatim, and `procedures plan-execution` prints one fold run line or
|
|
73
|
+
caveat per chain, a task chain's marked `(task <brief>)`. Without a task thread
|
|
74
|
+
the report, the line and the fold lane are byte-identical to 14.0.0.
|
|
75
|
+
- **Copy.** The dispatch mode doc and the README name `--task` and both refusals; the review-state mode doc names the
|
|
76
|
+
chain line; D10 stays a bar for every thread, the two refusals adding to it.
|
|
77
|
+
- **Tests.** Five new suites — `task-thread.test.mjs`, `dispatch.test/task.test.mjs`, `dispatch.test/task-overlap.test.mjs`,
|
|
78
|
+
`held-session-chain.test.mjs`, `review-state-task-chain.test.mjs` — and the record and tarball pins (313 files).
|
|
79
|
+
|
|
7
80
|
## 14.0.0 — the placed executor's body is derived from `docs/ai/vehicles.json`: the agents writer and a new eighth config ensure, `executor`, re-derive it instead of preserving a hand edit, the readiness survey reports an unreadable setting as an unusable vehicle, and the advisor reads the posture (AD-143)
|
|
8
81
|
|
|
9
82
|
**MAJOR: the never-clobber rule for the placed executor is withdrawn.** Until 13.3.0 the agents writer reported a
|
package/README.md
CHANGED
|
@@ -245,7 +245,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
245
245
|
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
246
246
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe, a feature-spec layer never adopted) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice, the agents hidden-mode reconcile follow-up, or the spec-adoption decline preview) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
247
247
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
248
|
-
| `/agent-workflow-kit dispatch` | any time | **the delegation engine** — makes "how much does delegating a sub-task actually buy?" a measured number instead of a feeling. `check <file>` validates a sub-task brief's contract block and exits 0/1 naming the first violated field — **form only**: whether the task is genuinely bounded, its design decided and its acceptance adequate stays your judgment, and a well-formed absurdity passes here by construction. `advise --step-class <c>` answers the question that comes BEFORE that one — which vehicle should carry this kind of sub-task, whether it is even present on this machine, and what the ledger has already recorded for threads of that class (finished, failed, closed without a fold, and still open — counted separately) — and it decides nothing: it refuses no dispatch, gates no verb, and a choice that diverges from the advice is noted rather than blocked. It reads the filesystem only: nothing is launched to find out what is installed, an absent ledger prints "no recorded history", an unreadable one prints the store's own words, and either way the advice still prints. The same block appears as a footer under a form-valid `check` — never under a refusal, so it can never hide one. `register` pre-registers an acceptance wave (step classes, pairing key, minimum observations per class, the mean and first-pass thresholds) so the bar can never be chosen after the results it judges — immutable per wave. `observe` records ONE hand-written observation (`solo-construction`, the baseline whose ratio is 1 by construction — except over a scope measuring zero bytes, which has no ratio at all and is recorded ineligible by name — or `self-reported`) — delegated numbers are never hand-written, they are derived. The writer verbs are where that derivation happens: `open` puts a delegated thread on the record with every mint-time field copied from the brief's own header, refusing a deadline the wrapper's cap plus its kill grace would not fit inside, and recording whether the tree it started from was clean against the thread's base — HEAD by default, or the tree `--checkpoint <oid>` names, so the second task of a story is measured over the first one's uncommitted work instead of opening dirty (a dirty one makes the result honestly unmeasurable rather than quietly counted); `await` waits for that one dispatch to answer and writes nothing — only the finished receipt satisfies it, a run still holding the nonce means keep waiting, and a wait that runs out says so with its own exit code, names whether the deadline or your own timeout ended it, and authorizes nothing: a wait that ended without an answer is a question for you, never permission to dispatch again; `return` absorbs the receipt the wrapper minted — only a finished one, checked against the contract it actually ran, its deadline and its own report, and refused outright when the tree hides changes from git or moves while the return is being computed — and derives the bytes from git rather than from anyone's claim; `fold` is the acceptance, and it refuses if the tree moved since the return — precisely, it re-confirms **equality of the visible canonical payload**, which is a change detector rather than a cryptographic identity of the tree: under the default head base that payload's untracked section is unframed and carries no file mode, so a content or symlink target that imitates the marker opening the next entry can alias two trees, and making an untracked file executable moves nothing (both named as residuals in the mode doc, with the fix queued); under a checkpoint base it is one indexed diff that carries modes. A change set carrying a binary, a non-regular path, a submodule, or a symlink whose target is not valid UTF-8 is **refused** at `return` and `fold` — CONSERVATIVELY, rather than promise what the default head payload may not be able to follow — and the same refusals hold for a checkpoint thread. `degrade` closes a thread that never earned a fold, on the record and with its reason. `aggregate` prints one wave: the registered thresholds, every observation as context, and per step class the finished threads — a folded success with git-provable bytes contributes its ratio, one whose bytes are unprovable is excluded from the mean but still counts in the first-pass rate, and a failed thread, a degrade-closed one, or one folded after failing acceptance each count as a real zero. Below the registered minimum nothing is computed (insufficient); at or above it is computed and labeled PILOT evidence. It REFUSES rather than guessing: no pre-registration, an unfinished thread in scope, a recorded refusal-to-delegate that never opened a thread, or an ambiguous wave each stop the computation by name. `handoff-return --slug <s>` closes the worktree loop after `land --prepare`: it re-attests that main still holds exactly the prepared tree under the same HEAD (both recorded in the handoff at prepare time), prints the satellite handoff's user-owned content byte for byte with its boundaries, byte lengths and the main-owned destinations each part folds into, prints the handoff digest and both OIDs as its proof, states the after-the-fold order (a fold landed after the gates leaves them stale), and records ONE self-reported worktree-stream observation only when the whole prepared change set is measurable — a deletion, a rename, a symlink, a submodule or a mode-only change ends with a named NOT RECORDED instead, never a partial number. Appends only to its own ledger inside the git dir (never committable); it also writes `handoff-return`'s `write-tree` tree object and, for a checkpoint measurement, the leaf's OWN temporary index file and the blobs its forced adds store — never a ref, never the real index; never commits, never runs a subscription CLI. |
|
|
248
|
+
| `/agent-workflow-kit dispatch` | any time | **the delegation engine** — makes "how much does delegating a sub-task actually buy?" a measured number instead of a feeling. `check <file>` validates a sub-task brief's contract block and exits 0/1 naming the first violated field — **form only**: whether the task is genuinely bounded, its design decided and its acceptance adequate stays your judgment, and a well-formed absurdity passes here by construction. `advise --step-class <c>` answers the question that comes BEFORE that one — which vehicle should carry this kind of sub-task, whether it is even present on this machine, and what the ledger has already recorded for threads of that class (finished, failed, closed without a fold, and still open — counted separately) — and it decides nothing: it refuses no dispatch, gates no verb, and a choice that diverges from the advice is noted rather than blocked. It reads the filesystem only: nothing is launched to find out what is installed, an absent ledger prints "no recorded history", an unreadable one prints the store's own words, and either way the advice still prints. The same block appears as a footer under a form-valid `check` — never under a refusal, so it can never hide one. `register` pre-registers an acceptance wave (step classes, pairing key, minimum observations per class, the mean and first-pass thresholds) so the bar can never be chosen after the results it judges — immutable per wave. `observe` records ONE hand-written observation (`solo-construction`, the baseline whose ratio is 1 by construction — except over a scope measuring zero bytes, which has no ratio at all and is recorded ineligible by name — or `self-reported`) — delegated numbers are never hand-written, they are derived. The writer verbs are where that derivation happens: `open` puts a delegated thread on the record with every mint-time field copied from the brief's own header, refusing a deadline the wrapper's cap plus its kill grace would not fit inside, and recording whether the tree it started from was clean against the thread's base — HEAD by default, or the tree `--checkpoint <oid>` names, so the second task of a story is measured over the first one's uncommitted work instead of opening dirty (a dirty one makes the result honestly unmeasurable rather than quietly counted), while `--task <brief>` also records which task brief the thread carries out and the files that brief lists, refusing, since the last commit, a task thread whose files overlap by path prefix those of an open task thread or of a folded thread of another brief (`files-overlap`), and a mix of the two kinds — an untasked open beside an open task thread or, for a `code` open of backend `codex`, after a task fold, and a task open beside an open untasked thread (`mixed-open`), refusing at open a Files path the snapshot scope can never hold (`task-files-scope`), and measuring a task thread over its files alone, so `return` and `fold` refuse a changed path that no open or folded task thread on the same base claims (`out-of-files`); `await` waits for that one dispatch to answer and writes nothing — only the finished receipt satisfies it, a run still holding the nonce means keep waiting, and a wait that runs out says so with its own exit code, names whether the deadline or your own timeout ended it, and authorizes nothing: a wait that ended without an answer is a question for you, never permission to dispatch again; `return` absorbs the receipt the wrapper minted — only a finished one, checked against the contract it actually ran, its deadline and its own report, and refused outright when the tree hides changes from git or moves while the return is being computed — and derives the bytes from git rather than from anyone's claim; `fold` is the acceptance, and it refuses if the tree moved since the return — precisely, it re-confirms **equality of the visible canonical payload**, which is a change detector rather than a cryptographic identity of the tree: under the default head base that payload's untracked section is unframed and carries no file mode, so a content or symlink target that imitates the marker opening the next entry can alias two trees, and making an untracked file executable moves nothing (both named as residuals in the mode doc, with the fix queued); under a checkpoint base it is one indexed diff that carries modes. A change set carrying a binary, a non-regular path, a submodule, or a symlink whose target is not valid UTF-8 is **refused** at `return` and `fold` — CONSERVATIVELY, rather than promise what the default head payload may not be able to follow — and the same refusals hold for a checkpoint thread. `degrade` closes a thread that never earned a fold, on the record and with its reason. `aggregate` prints one wave: the registered thresholds, every observation as context, and per step class the finished threads — a folded success with git-provable bytes contributes its ratio, one whose bytes are unprovable is excluded from the mean but still counts in the first-pass rate, and a failed thread, a degrade-closed one, or one folded after failing acceptance each count as a real zero. Below the registered minimum nothing is computed (insufficient); at or above it is computed and labeled PILOT evidence. It REFUSES rather than guessing: no pre-registration, an unfinished thread in scope, a recorded refusal-to-delegate that never opened a thread, or an ambiguous wave each stop the computation by name. `handoff-return --slug <s>` closes the worktree loop after `land --prepare`: it re-attests that main still holds exactly the prepared tree under the same HEAD (both recorded in the handoff at prepare time), prints the satellite handoff's user-owned content byte for byte with its boundaries, byte lengths and the main-owned destinations each part folds into, prints the handoff digest and both OIDs as its proof, states the after-the-fold order (a fold landed after the gates leaves them stale), and records ONE self-reported worktree-stream observation only when the whole prepared change set is measurable — a deletion, a rename, a symlink, a submodule or a mode-only change ends with a named NOT RECORDED instead, never a partial number. Appends only to its own ledger inside the git dir (never committable); it also writes `handoff-return`'s `write-tree` tree object and, for a checkpoint measurement, the leaf's OWN temporary index file and the blobs its forced adds store — never a ref, never the real index; never commits, never runs a subscription CLI. |
|
|
249
249
|
| `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first — printed only for a link whose RAW TARGET is MAIN's `node_modules`, since a foreign or unreadable link is reported as such with no removal advised); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `prompt <slug>` is read-only too and re-prints the satellite's cold-start prompt — the same text `provision` ends its report with: where the worktree is, its ONE seeded plan, the handoff as the one channel back, MAIN's orientation (shared series index, landing) and, under its own heading because it is probed on the satellite, that checkout's install posture — every value derived LIVE rather than replayed from the frozen provision record, a recorded value that no longer matches NAMED as a divergence (with the cause its source makes likely) instead of printed as the runnable one, every offered command marked with WHO runs it (`MAIN $ …` for the landing, `HERE $ …` for this checkout's own install, a posture with nothing to run staying prose), and a control character in any rendered value a typed STOP rather than a forged prompt line; `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
|
|
250
250
|
| `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
|
|
251
251
|
| `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '14.
|
|
6
|
+
version: '14.2.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.2.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -6,7 +6,7 @@ The **delegation engine** (delegation Plans 1–2): the sub-task **contract chec
|
|
|
6
6
|
|
|
7
7
|
Run **`node ${CLAUDE_SKILL_DIR}/tools/dispatch.mjs <check|advise|register|observe|open|await|return|fold|degrade|handoff-return|aggregate> …`**:
|
|
8
8
|
|
|
9
|
-
1. **`check <dispatch-file>`** — validates the ONE ```` ```aw-dispatch-contract ```` fenced block the dispatch file carries: exit `0` when its form is valid, `1` naming the FIRST violated field. The contract of the whole mode, in one sentence: the contract check is FORM-only — fields present, grammars respected, never boundedness, design-decidedness or acceptance adequacy — and `aggregate` REFUSES instead of computing acceptance for a wave with no pre-registration record, over an OPEN thread in scope, over a PRE-DISPATCH degrade that opens no thread, or across several waves with no `--wave`; the writer verbs add NO second legality door — the store's preflight is the only one, and its refusals travel verbatim — while `open` copies every mint-time field from the contract header and refuses a deadline below the wrapper cap plus the kill grace, `return` absorbs only a TERMINAL exec receipt whose backend, nonce and independently computed contractDigest match the dispatch it answers, and `fold` binds the CURRENT tree to the folded return's postTreeDigest, so a tree that moved between the two never folds, and `await` is satisfied ONLY by the TERMINAL exec receipt of its own dispatch's {backend, nonce} — never by a review receipt, a ledger line or a finding manifest — while an expiry names a supervision question and releases NO writer slot; `open` records the thread's BASE (`head` by default, `checkpoint` under `--checkpoint <oid>`), `return` and `fold` measure against the base the dispatch recorded, never a flag, and the `fold` VERB itself refuses a checkpoint `code` thread the held-session judge names SUBSTITUTED in that thread's own verdict — a precondition like its hidden-path and content-blind guards, the store's preflight staying the only door on the record
|
|
9
|
+
1. **`check <dispatch-file>`** — validates the ONE ```` ```aw-dispatch-contract ```` fenced block the dispatch file carries: exit `0` when its form is valid, `1` naming the FIRST violated field. The contract of the whole mode, in one sentence: the contract check is FORM-only — fields present, grammars respected, never boundedness, design-decidedness or acceptance adequacy — and `aggregate` REFUSES instead of computing acceptance for a wave with no pre-registration record, over an OPEN thread in scope, over a PRE-DISPATCH degrade that opens no thread, or across several waves with no `--wave`; the writer verbs add NO second legality door — the store's preflight is the only one, and its refusals travel verbatim — while `open` copies every mint-time field from the contract header and refuses a deadline below the wrapper cap plus the kill grace, `return` absorbs only a TERMINAL exec receipt whose backend, nonce and independently computed contractDigest match the dispatch it answers, and `fold` binds the CURRENT tree to the folded return's postTreeDigest, so a tree that moved between the two never folds, and `await` is satisfied ONLY by the TERMINAL exec receipt of its own dispatch's {backend, nonce} — never by a review receipt, a ledger line or a finding manifest — while an expiry names a supervision question and releases NO writer slot; `open` records the thread's BASE (`head` by default, `checkpoint` under `--checkpoint <oid>`), `return` and `fold` measure against the base the dispatch recorded, never a flag, and the `fold` VERB itself refuses a checkpoint `code` thread the held-session judge names SUBSTITUTED in that thread's own verdict — a precondition like its hidden-path and content-blind guards, the store's preflight staying the only door on the record; `open --task` records the task, a task thread is measured over its Files and `open`, `return` and `fold` refuse `files-overlap`, `mixed-open` and `out-of-files`. Whether the sub-task is genuinely bounded, whether its design is decided, and whether its acceptance criteria are adequate stay **explicit orchestrator judgment** — a well-formed absurdity passes here by construction.
|
|
10
10
|
2. **`advise --step-class <c>`** — the **vehicle-routing advisory**, and the question that comes BEFORE a contract check: *which vehicle carries this step class on THIS host, and what has the ledger already recorded for it.* It prints one block — the advised vehicle with its availability, the solo fallback, the recorded history, and the note below — and **decides nothing**: the advice never gates a dispatch: which vehicle carries a sub-task stays orchestrator judgment, and a divergence from the advice is recorded as a note rather than refused. Exit `0` for every legal step class, `2` only on usage. Its posture, stated exactly: the advisor module itself writes nothing, spawns nothing and opens no second ledger door; the VERB may run read-only git probes — the delegation store path (unless AW_DELEGATION_STORE names it outright) and the repository top-level the cheap vehicles are anchored at — and it never runs a vehicle, a subscription CLI, or anything that writes. Host capability is read from the filesystem — the execute backend from the bridge install (the detector spawns nothing at all), a cheap vehicle from the presence of `.claude/agents/<name>.md` **at the repository top-level**; where that root is not resolved, the lane answers **`unknown`** rather than claiming a vehicle is unplaced, because an absent file under an unanchored directory is not evidence of absence and a present one there is not evidence it is this repository's vehicle. The recorded history is the ledger's OWN thread walk, over the closed state set `folded | failure-terminal | degrade-closed | open` with **`open` counted separately** (a live thread is not evidence about a finished one). An **absent** ledger prints `no recorded history`; an **unreadable** one prints the store's own words and the advice still prints; neither is a refusal. The same block prints as a **footer on a form-valid `check`** (never under a refusal, so it cannot mask one), where the advised vehicle is compared against the contract's `vehicle.selected` and a difference is printed as a divergence NOTE naming `vehicle.requested` when the pair itself differs.
|
|
11
11
|
|
|
12
12
|
The vehicle-routing matrix — one row per step class, and this copy is held to the code's registry CELL for cell by a structure check (a reorder, a duplicate, a dropped row, a mis-bound vehicle and a drifted availability or returns cell all leave every token present, so a token check would pass every one of them). The check reads only the anchored surface between the two markers below, so a copy of this table anywhere else in the doc can neither stand in for it nor mask its drift:
|
|
@@ -30,7 +30,7 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/dispatch.mjs <check|advise|register|observ
|
|
|
30
30
|
|
|
31
31
|
3. **`register`** — appends the wave's **pre-registration** record (`--wave --step-classes --pairing-key --min-per-class --mean-l-threshold --first-pass-num --first-pass-den`). Acceptance is pre-registered so thresholds can never be chosen after the observations that will be judged against them; a registration is **immutable per wave** and a second one refuses. `--pairing-key` accepts only a key the aggregator actually implements (today `stepClass`) — a wave registered under any other key would record a contract the computation never follows, and the aggregator re-checks the registration independently, since a store can be hand-written.
|
|
32
32
|
4. **`observe`** — appends ONE **observation** (`--wave --step-class --scope --plan --phase --provenance [--denominator-bytes]`). Provenance is `solo-construction` or `self-reported` **only**: delegated accounting is DERIVED from nonce threads and is never hand-appended. The numerator is the post-image bytes of the objects the scope names; a **solo** observation takes that same number as its denominator — `L = 1` by construction, so `--denominator-bytes` is refused — while a self-reported one states its own. One stated exception: a solo scope that measures **zero** bytes has no ratio at all, and is recorded INELIGIBLE by name (`zero-denominator`) rather than as `L = 1` — `0/0` is undefined before it is one, and a named ineligibility is what this family records instead of a silent number. **`--scope` is repeatable**, one repo-relative path per occurrence: no separator is safe inside a POSIX path, so a split list could not express `docs/my file.md` and would silently measure the wrong set wherever its fragments happened to exist. Paths are anchored at the **git top-level**, never at the caller's cwd, and the record carries a **JSON-encoded array of canonical repo-relative paths, carried in the schema's string field** (the record's `scope` is a string by D3, so the array travels encoded). An object's identity IS its canonical path, so two files with identical bytes are two objects and count twice, while one path reached twice (a repeated listing, an in-repo ancestor symlink) is one object and counts once. The run refuses by name outside a git work tree, for an absent or non-regular path, and for a path whose **real** location leaves the repository (an ancestor symlink is not a way out).
|
|
33
|
-
5. **`open`** — appends the **dispatch** record that opens a thread (`--contract <dispatch-file> --wave --backend --rationale --wrapper-cap-s --kill-grace-s [--checkpoint <oid>] [--retry-of <nonce>]`). Every mint-time field — `nonce`, `stepClass`, `vehicle`, `deadlineS`, `retry.index`, `retry.cap` — is **COPIED from the contract header** and bound by `contractDigest`, so a dispatch can never disagree with the header it claims to carry. It fingerprints the tree (`preTreeDigest`) and probes it for a CLEAN baseline; a **dirty** one is recorded as such, and the eventual return is then metric-INELIGIBLE by the name `dirty-baseline` — the uncommitted-state fingerprint is blind to the index↔worktree split, so a dirty baseline cannot attribute bytes to a dispatch. It **refuses a `deadlineS` below `--wrapper-cap-s` + `--kill-grace-s`** (both explicit operands: the kit never reads another package's default), because a dispatch that cannot be capped inside its own deadline could never honour the terminal-exit rule. Every retry rule — one successor per thread, the cap read off the chain's ORIGIN, wave and class equality, a changed contract after a `contract-refusal` — is the **store's**, and `open` surfaces its refusals verbatim. `--checkpoint <oid>` records the checkpoint base: the operand must be a git object id of exactly 40 or 64 lowercase hex characters that resolves to a TREE (a commit, a blob, a tag and an unknown id each refuse by name, nothing written), and every measurement of that thread — the clean probe, `preTreeDigest`, the return's bracketing fingerprint and diff, the fold's fingerprint and enumeration — is computed against that tree over the snapshot scope — every work-tree path not ignored by git's standard excludes judged without the real index, plus every present kit-own path, plus every path HEAD's tree or the base tree names, minus `docs/plans/`, `.claude/settings.json`, `.claude/settings.local.json`, `.mcp.json`, every known foreign-tool footprint and `node_modules`; the base tree's non-excluded paths deleted since the snapshot are measured as deletions; without the flag the base is `head` and every byte of the MEASUREMENT is today's (the writer always mints the `baseline` key, so the record itself is not byte-identical to 13.0.0's). The CLEAN line of a checkpoint open names the base.
|
|
33
|
+
5. **`open`** — appends the **dispatch** record that opens a thread (`--contract <dispatch-file> --wave --backend --rationale --wrapper-cap-s --kill-grace-s [--checkpoint <oid>] [--task <brief>] [--retry-of <nonce>]`). Every mint-time field — `nonce`, `stepClass`, `vehicle`, `deadlineS`, `retry.index`, `retry.cap` — is **COPIED from the contract header** and bound by `contractDigest`, so a dispatch can never disagree with the header it claims to carry. It fingerprints the tree (`preTreeDigest`) and probes it for a CLEAN baseline; a **dirty** one is recorded as such, and the eventual return is then metric-INELIGIBLE by the name `dirty-baseline` — the uncommitted-state fingerprint is blind to the index↔worktree split, so a dirty baseline cannot attribute bytes to a dispatch. It **refuses a `deadlineS` below `--wrapper-cap-s` + `--kill-grace-s`** (both explicit operands: the kit never reads another package's default), because a dispatch that cannot be capped inside its own deadline could never honour the terminal-exit rule. Every retry rule — one successor per thread, the cap read off the chain's ORIGIN, wave and class equality, a changed contract after a `contract-refusal` — is the **store's**, and `open` surfaces its refusals verbatim. `--checkpoint <oid>` records the checkpoint base: the operand must be a git object id of exactly 40 or 64 lowercase hex characters that resolves to a TREE (a commit, a blob, a tag and an unknown id each refuse by name, nothing written), and every measurement of that thread — the clean probe, `preTreeDigest`, the return's bracketing fingerprint and diff, the fold's fingerprint and enumeration — is computed against that tree over the snapshot scope — every work-tree path not ignored by git's standard excludes judged without the real index, plus every present kit-own path, plus every path HEAD's tree or the base tree names, minus `docs/plans/`, `.claude/settings.json`, `.claude/settings.local.json`, `.mcp.json`, every known foreign-tool footprint and `node_modules`; the base tree's non-excluded paths deleted since the snapshot are measured as deletions; without the flag the base is `head` and every byte of the MEASUREMENT is today's (the writer always mints the `baseline` key, so the record itself is not byte-identical to 13.0.0's). The CLEAN line of a checkpoint open names the base. `--task <brief>` records the brief's path and its byte-ordered Files, requires `--checkpoint` (`task-base`), refuses a brief the parser refuses by the parser's name, refuses `task-files-scope` for a Files path the snapshot domain can never hold, and the store refuses `files-overlap` and `mixed-open` at append time; a task thread is measured over its Files.
|
|
34
34
|
6. **`await`** — watches for ONE dispatch to **answer**, and writes nothing at all (`--nonce [--timeout <s>]`). Satisfaction is the arrival of the **TERMINAL** exec receipt for the dispatch's own `{backend, nonce}`: a `reserved` artifact means the run holds the nonce and has published nothing about how it ended, so the wait continues — the same artifact that is a **supervision refusal** at `return`, because absorbing it would record an outcome nobody observed. Satisfaction is decided **positively**, by the exec-receipt reader itself, so an artifact of a neighbouring family standing at that path — a review receipt, a delegation ledger line, a finding manifest — **refuses** rather than satisfies: only an exec receipt answers an exec dispatch, which is the D10 rule the review waiter states from its own side. The bound is the **ABSOLUTE** deadline, measured from the DISPATCH record's timestamp rather than from whenever this wait began: `--timeout` defaults to the time **remaining**, and one reaching past the deadline is **refused** rather than silently clamped (a shortened wait would let a caller believe they waited longer than they did). An already-expired dispatch is **answered immediately**, never waited on. **Arrival is read first, before EVERY bound** — the two wait bounds and the `--timeout` admissibility check alike, so a receipt already on disk answers `ARRIVED` even when the supplied `--timeout` is inadmissible, and the check runs only once there genuinely IS a wait to bound. A receipt already on disk is a fact this verb reports; whether a *late* one may be absorbed is the absorb door's question, refused there by name — **lateness has exactly one decision site, and it is `return`**. The review waiter checks its deadline first for the opposite reason: a receipt *line* carries no dispatch-bound timestamp at all, so there the clock is the only evidence there is. Each pass consults the clock over the poll that has already happened, then sleeps and polls again, so a receipt landing during the last sleep is reported rather than lost to a cutoff a moment later. An unanswered wait exits **`3`** — its own status so a caller that **branches** on the code can tell it from a refusal, never a defence against one that discards failure wholesale — names whether the **deadline** or the `--timeout` ended it, and states that **no writer slot was released**: a wait that ended without an answer never authorizes the next dispatch. Every artifact read rides the store's own no-follow reader, so a symlinked, FIFO, unreadable or malformed artifact refuses **by class** instead of standing in for an answer.
|
|
35
35
|
7. **`return`** — absorbs the wrapper's exec receipt and appends the **return** record (`--nonce [--outcome <o>] [--no-receipt --exit-status <n>]`). The receipt and its report live **beside the ledger**, in the store's own directory. It reads the **TERMINAL** receipt — a `reserved` one is a **supervision question**, not a timeout, and refuses by saying so — then checks the receipt's independently computed `contractDigest` against the dispatch's (a run that executed a *different* contract would otherwise correlate cleanly), its `{backend, nonce}` body against the dispatch it answers (the filename alone is composed by this side), its `capS + killGraceS` against the recorded `deadlineS`, and its timestamp against the **ABSOLUTE** deadline (`dispatch.timestamp + deadlineS`) — a late return refuses by name. It re-verifies the report's digest and length against the receipt, refuses a change set **hidden from the plain `git diff`** the head-base metric is computed over (assume-unchanged, skip-worktree, `diff.ignoreSubmodules`), and then enumerates the returned objects and frames the canonical integration bundle, **bracketed by the tree fingerprint**: a tree that moves mid-computation refuses rather than mixing two change sets. `--outcome` records an orchestrator judgment under the **closed override form** — a wrapper outcome either stays itself or moves to `contract-refusal | partial-edit | acceptance-failure | stale-return | store-failure`, and `success` is recordable only from a receipt that already says success; the record vocabulary's own cross-field rules cut the remaining incompatible pairs. **`--no-receipt`** means "no TERMINAL receipt", never "no evidence": it absorbs a **reservation ONLY** — over a `terminal` artifact it refuses, because a hand-stated outcome there would discard an exit status, a session id and a report digest the run actually proved, and skip the report check that guards them. It builds the return from that reservation (`wrapperVersion` and `posture`, never hand-typed), requires `--exit-status` and an `--outcome` from `transport-failure | contract-refusal | store-failure | missing-identity` (so `partial-edit` and `stale-return` are inexpressible there by name), and reads the report **if present** — an absent one records length 0 and the metric is then ineligible **by name**: `empty-report` where there IS a diff, and `no-op-diff` where the tree is unchanged too, since the eligibility rule names the diff first. That keeps a failed REPORT write and a failed TERMINAL write two separately named lanes. With **no artifact at all** there is no honest return — `--no-receipt` reads the same path and would refuse the same way — so close the thread with `degrade`.
|
|
36
36
|
8. **`fold`** — appends the integration re-confirmation and CLOSES the thread (`--nonce --verdict <text>`). It runs the **same hidden-path guard** as `return`, and here for a sharper reason: `treeDigestAtFold` is computed from the very payload that is blind to those paths, so under a HEAD base a change made behind an `assume-unchanged` / `skip-worktree` bit between the return and the fold leaves the digest **equal** and the fold would accept bytes nobody returned — the digest cannot catch what the payload cannot see. It then computes the **current** tree digest, which the store binds to the folded return's `postTreeDigest`: a tree that moved between the return and the fold refuses in the store's own words, and the recovery it names is a fresh dispatch, never a fold. Under a HEAD base, staging usually moves the tree too — every change set carrying an untracked path does — so the order is `return` → review (own eyes + the configured review recipe) → a plain gate run → `fold` → `git add`. **The one shape staging does not move** is a tracked-only change passing from the worktree into a **clean** index: the payload concatenates the staged and unstaged diffs, so those bytes are identical either side of `git add` (the uncommitted-state fingerprint's stated blindness to the index↔worktree split). The fold is still honest there — identical payload bytes mean the folded content IS the returned content — so the rule is "**the fold precedes staging**", never "staging refuses the fold by construction". A checkpoint measurement never reads the real index, so staging moves nothing there, while the fold still precedes `git add` by the same rule. `fold` refuses a checkpoint `code` thread the held-session judge names SUBSTITUTED in its own verdict, naming the ledger degrade and retry as recovery, and fails closed on an error epoch or an unreadable ledger.
|
|
@@ -46,6 +46,6 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/dispatch.mjs <check|advise|register|observ
|
|
|
46
46
|
|
|
47
47
|
**Which ledger the tree-binding verbs use.** For `open`, `return` and `fold` the resolved store must be the **canonical** one, exactly — `<git common dir>/agent-workflow-delegation.jsonl` — and every other store refuses. Three failures collapse into that one equality: a store placed inside the work tree is carried by the payload *and enumerated as an object*, so the metric would count its own bookkeeping as delegated work while the append that follows `postTreeDigest` moves the tree every later fold binds; a store belonging to another repository would measure this tree against a foreign thread, the frozen record schema binding no worktree identity; and a **second** ledger in the same git dir would share artifact names with the first, since those are a function of `{backend, nonce}` alone — so a return could absorb the neighbouring ledger's receipt. One repository, one delegation ledger, which is what resolving to the git *common* dir already meant. `register`, `observe`, `await` and `aggregate` keep the unrestricted override — they bind no tree. Path comparison canonicalizes through symlinks and **fails closed** on any error but `ENOENT` (a path this kit cannot resolve is not one it may approve); the check is repeated immediately before every append, which *narrows* the replacement window rather than closing it — a check-then-write pair is not atomic, and this module defends against a buggy or interrupted producer, never a racing adversary. `open` additionally refuses **pre-spend** when either exec artifact name for its `{backend, nonce}` is already taken: one nonce, one artifact pair. **Stated residual:** two *linked worktrees* of one repository share a common dir by design (the ledger is worktree-shared), so this rule cannot separate them — opening in one linked worktree and returning or folding in another measures the wrong tree, is **forbidden** under the D10 one-writer bar, and is named here rather than mechanized, because the frozen record schema carries no worktree identity to bind it with.
|
|
48
48
|
|
|
49
|
-
**What the fold's binding IS, and its stated residuals.** The binding is **equality of the visible payload** between the return and the fold — never an identity of the tree, and the difference is not academic. Under a HEAD base the payload is an unframed concatenation, so **two different trees can produce the same bytes**: probed live, a single file containing the line `untracked:two.txt` yields the same fingerprint as a tree of two files, and a symlink's target can imitate the marker that opens the next untracked entry. Its UNTRACKED section carries **no git-relevant mode**, so `chmod 0644 → 0755` on an untracked file moves neither the fingerprint nor the enumeration — a blind attribute, not a fourth unsupported content class (a TRACKED file's mode flip rides the diff as `old mode` / `new mode` and does move it). Regular-file content and symlink targets are deliberately **not** subtracted: they are present in the payload, only ambiguously framed, and refusing them would refuse `new` and `symlink`, which is every delegated change set there is. The real fix — a framed payload carrying mode bits — is queued against that frozen surface, since it revalues every stored head-base fingerprint in the family. Under a checkpoint base the payload is one indexed diff: a mode change moves it and no untracked marker line exists. A **receipt is forgeable**, exactly like every record in this family, so what the absorb door defends against is a buggy or interrupted producer, not a hostile one; under a head base the numerator **over-counts a rename** (a delegate's rename arrives as a deletion plus a creation, and no identity matching is done — that machinery was built and deliberately subtracted), so the metric reads optimistic, except in the rename-then-recreate case, which is indeterminate and named in the producer's own header; under a checkpoint base git's own rename detection applies inside the one base-relative layer and a detected rename is counted once as `renamed`; and **D10 is a bar, not a mechanism** — at most ONE in-tree exec dispatch runs at a time, and nothing here refuses a second one
|
|
49
|
+
**What the fold's binding IS, and its stated residuals.** The binding is **equality of the visible payload** between the return and the fold — never an identity of the tree, and the difference is not academic. Under a HEAD base the payload is an unframed concatenation, so **two different trees can produce the same bytes**: probed live, a single file containing the line `untracked:two.txt` yields the same fingerprint as a tree of two files, and a symlink's target can imitate the marker that opens the next untracked entry. Its UNTRACKED section carries **no git-relevant mode**, so `chmod 0644 → 0755` on an untracked file moves neither the fingerprint nor the enumeration — a blind attribute, not a fourth unsupported content class (a TRACKED file's mode flip rides the diff as `old mode` / `new mode` and does move it). Regular-file content and symlink targets are deliberately **not** subtracted: they are present in the payload, only ambiguously framed, and refusing them would refuse `new` and `symlink`, which is every delegated change set there is. The real fix — a framed payload carrying mode bits — is queued against that frozen surface, since it revalues every stored head-base fingerprint in the family. Under a checkpoint base the payload is one indexed diff: a mode change moves it and no untracked marker line exists. A **receipt is forgeable**, exactly like every record in this family, so what the absorb door defends against is a buggy or interrupted producer, not a hostile one; under a head base the numerator **over-counts a rename** (a delegate's rename arrives as a deletion plus a creation, and no identity matching is done — that machinery was built and deliberately subtracted), so the metric reads optimistic, except in the rename-then-recreate case, which is indeterminate and named in the producer's own header; under a checkpoint base git's own rename detection applies inside the one base-relative layer and a detected rename is counted once as `renamed`; and **D10 is a bar, not a mechanism, for untasked threads** — at most ONE untasked in-tree exec dispatch runs at a time, and nothing here refuses a second one; for task threads it is a mechanism: file-disjoint task threads on one checkpoint may run as a wave, `files-overlap` and `mixed-open` refuse conflicting opens at append time, and `return` and `fold` refuse `out-of-files`.
|
|
50
50
|
|
|
51
|
-
**Invariants:** writer — appends only to the delegation ledger, `<git common dir>/agent-workflow-delegation.jsonl` (`AW_DELEGATION_STORE` overrides, absolute only), through the store's own lock-serialized append; the store's preflight is the single legality door and this mode adds no second validator · every verb that DERIVES anything from the ledger (`return`, `fold`, `degrade`, `aggregate`) replays the store's preflight over the whole file first and refuses at the first record the append path would have refused, naming its physical line — a verb never computes over a "legal prefix" · **never commits**, never runs a subscription CLI, spawns nothing but git READS — with TWO stated exceptions: `handoff-return` attests MAIN's index with `git write-tree`, which may write a tree OBJECT into the odb (the same probe `land --prepare` itself uses), and checkpoint measurement writes the leaf's OWN temporary index file and forced-add blob objects — never a ref, never the real index; the ledger stays the only thing this mode APPENDS to · the ledger is separate from the review receipts and the flow store, and a line of one family never satisfies a reader of another.
|
|
51
|
+
**Invariants:** writer — appends only to the delegation ledger, `<git common dir>/agent-workflow-delegation.jsonl` (`AW_DELEGATION_STORE` overrides, absolute only), through the store's own lock-serialized append; the store's preflight is the single legality door and this mode adds no second validator · every verb that DERIVES anything from the ledger (`return`, `fold`, `degrade`, `aggregate`) replays the store's preflight over the whole file first and refuses at the first record the append path would have refused, except `files-overlap` and `mixed-open`, which are judged at append time only, naming its physical line — a verb never computes over a "legal prefix" · **never commits**, never runs a subscription CLI, spawns nothing but git READS — with TWO stated exceptions: `handoff-return` attests MAIN's index with `git write-tree`, which may write a tree OBJECT into the odb (the same probe `land --prepare` itself uses), and checkpoint measurement writes the leaf's OWN temporary index file and forced-add blob objects — never a ref, never the real index; the ledger stays the only thing this mode APPENDS to · the ledger is separate from the review receipts and the flow store, and a line of one family never satisfies a reader of another.
|
|
@@ -6,7 +6,7 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
|
|
|
6
6
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
|
|
8
8
|
|
|
9
|
-
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, the `held session: <id> — <n> fold(s) rode it` / `none` / substitution line when configured delegated execution arms it, and per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
|
|
9
|
+
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, the `held session: <id> — <n> fold(s) rode it` / `none` / substitution line when configured delegated execution arms it (with a task `code` thread of backend `codex` in the commit epoch and no unresolved substitution, `held sessions: <k> chain(s) — <label>: <id> (<n>) · …` is printed instead, one entry per chain, labelled by its brief or `untasked`, with `none` for a chain with no held session), and per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
|
|
10
10
|
2. **`--check`** → the gate exit code. Before the review-recipe early return, a configured `plan-execution.execute = delegated` with a plan in flight and a dirty, fingerprintable tree audits the delegation ledger and refuses a substituted held session; an absent ledger is inert, while an unreadable, malformed, foreign, audit-refused ledger or failed HEAD read fails closed with its own reason. For a head-base thread, the accepted-replacement escape is a `codex-exec` degrade at the substituted return's tree, standing after every later fold, never the review receipt id `codex`. A checkpoint-based thread's substitution closes only by its own ledger `degrade` (which also withdraws the expectation, so the next fold establishes HELD) — no core-evidence degrade lifts it, and the report's recovery hint says so. The remaining **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (after the held-session arm above) (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`/`TASK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a cwd where git ITSELF answers not-a-repository under both discoveries (the ONLY non-work-tree location that passes — `error`, `redirected`, `no-work-tree` and `env-only` refuse by their state name once a receipt could be required), or every recipe-named backend SATISFIED for the current tree. **A clean-tree PASS under a NON-SOLO review obligation is never silent about a latent arm:** when a `reviewed` or `council` recipe is configured, it NAMES every plan in flight and states that this gate arms as soon as the tree is dirty — the condition is discoverable BEFORE it blocks, instead of surfacing at the worst moment (a pending commit, or the landing of a feature worktree when main first turns dirty). Nothing is announced under a configured `solo` recipe or a not-a-repository cwd, where the gate can never arm. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY review-backend escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (every edit the payload can see moves the fingerprint, and no more), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
|
|
11
11
|
3. **Wire it as a gate by hand OR via the explicit-consent init preview — never without consent (AD-021/D9).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated gates-init preview — `node ${CLAUDE_SKILL_DIR}/tools/gates-init.mjs --cwd <project>` (dry-run: it prints the derived entries and writes NOTHING; `--apply --only review-state` writes exactly this one on your explicit yes) — offers exactly this entry, path resolved and quoted, ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces). The full consent-fill contract is in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate — and `run-gates --final` requires this check among its canonical core gates.
|
|
12
12
|
4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. A held-session refusal and an **AUTHORITATIVE veto terminate the wait loudly BEFORE the deadline**: neither can be moved by waiting; only the held-session retry/degrade lane or a fresh review can move its corresponding refusal. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly. For waiting on **one dispatch's arrival** (never obligation satisfaction) use the receipt-arrival deadline runner, `${CLAUDE_SKILL_DIR}/references/modes/receipt-deadline.md`.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// spec:checkpoint
|
|
1
|
+
// restoreCheckpoint, restoreTaskThread — spec:checkpoint, spec:task-thread; docs/ai/specs/kit/checkpoint/restore.md
|
|
2
2
|
import { unlinkSync, rmdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { resolveTreeObject, snapshotScope } from './dispatch-baseline.mjs';
|
|
4
|
+
import { resolveTreeObject, snapshotScope, computeBasePayload } from './dispatch-baseline.mjs';
|
|
5
5
|
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
6
|
+
import { isThreadTerminalRecord } from './dispatch-record.mjs';
|
|
7
|
+
import { readsTask, readsInsideEpoch, epochClaims } from './task-thread.mjs';
|
|
8
|
+
import { hasPathOverlap } from './claim-relation.mjs';
|
|
6
9
|
import { CHECKPOINT_REF_PREFIX, withRepository, readGit, readGitBytes, readCheckpointRefs,
|
|
7
10
|
withTemporaryIndex, readScopeStat, writeSnapshot, findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
8
11
|
|
|
@@ -20,8 +23,14 @@ const SEQUENCE_REF_PATTERN = /^[^/]+\/(?:0|[1-9][0-9]*)$/;
|
|
|
20
23
|
const ALLOWED_MODES = ['100644', '100755', '120000'];
|
|
21
24
|
const LEDGER_ABSENT = 'absent';
|
|
22
25
|
const LEDGER_OK = 'ok';
|
|
26
|
+
const HEAD_ERROR = 'error';
|
|
27
|
+
const DISPATCH_KIND = 'dispatch';
|
|
28
|
+
const FOLD_KIND = 'fold';
|
|
29
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
30
|
+
const FOLDED = 'folded';
|
|
23
31
|
const NONEMPTY_CODES = ['ENOTEMPTY', 'EEXIST'];
|
|
24
|
-
const NAMES = { scope: 'target-scope', ledger: 'ledger', open: 'open-thread', conflict: 'type-conflict'
|
|
32
|
+
const NAMES = { scope: 'target-scope', ledger: 'ledger', open: 'open-thread', conflict: 'type-conflict',
|
|
33
|
+
task: 'task-target', folded: 'task-folded' };
|
|
25
34
|
const ENCODING_ERROR = 'target tree paths are not lossless UTF-8';
|
|
26
35
|
const ENTRY_ERROR = 'unreadable target tree entry';
|
|
27
36
|
const UNREFERENCED_ERROR = 'no checkpoint sequence carries target';
|
|
@@ -31,10 +40,11 @@ const GIT_ARGS = {
|
|
|
31
40
|
entries: ['ls-tree', '-r', '-z'],
|
|
32
41
|
read: ['read-tree'],
|
|
33
42
|
checkout: ['checkout-index', '-a', '-f'],
|
|
43
|
+
checkoutPaths: ['checkout-index', '-f', '--'],
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
const refuse = (reason) => ({ ok: false, code: REFUSE, reason });
|
|
37
|
-
const readTargetEntries = (context, target) => {
|
|
47
|
+
const readTargetEntries = (context, target, files = null) => {
|
|
38
48
|
const bytes = readGitBytes(context, [...GIT_ARGS.entries, target]);
|
|
39
49
|
const text = bytes.toString(UTF8);
|
|
40
50
|
if (!Buffer.from(text, UTF8).equals(bytes)) throw new Error(`${NAMES.scope}: ${ENCODING_ERROR}`);
|
|
@@ -42,11 +52,12 @@ const readTargetEntries = (context, target) => {
|
|
|
42
52
|
const parsed = ENTRY_PATTERN.exec(entry);
|
|
43
53
|
if (parsed === null) throw new Error(`${NAMES.scope}: ${ENTRY_ERROR}`);
|
|
44
54
|
const [, mode, path] = parsed;
|
|
55
|
+
if (files !== null && !files.includes(path)) return null;
|
|
45
56
|
if (!ALLOWED_MODES.includes(mode) || path.split(SLASH).some((segment) => FORBIDDEN_SEGMENTS.includes(segment))) {
|
|
46
57
|
throw new Error(`${NAMES.scope}: ${path}`);
|
|
47
58
|
}
|
|
48
59
|
return path;
|
|
49
|
-
});
|
|
60
|
+
}).filter((path) => path !== null);
|
|
50
61
|
};
|
|
51
62
|
const readHeldSet = (context, target) => {
|
|
52
63
|
const refs = readCheckpointRefs(context).filter(({ ref }) => SEQUENCE_REF_PATTERN.test(ref.slice(CHECKPOINT_REF_PREFIX.length)));
|
|
@@ -124,3 +135,49 @@ export const restoreCheckpoint = (cwd, oid, io = {}) => withRepository(cwd, io,
|
|
|
124
135
|
return snapshot.oid === target ? { ok: true, target, proof: snapshot.oid }
|
|
125
136
|
: refuse(`${MISMATCH}: ${target} ${snapshot.oid}`);
|
|
126
137
|
});
|
|
138
|
+
|
|
139
|
+
export const restoreTaskThread = (cwd, oid, nonce, io = {}) => withRepository(cwd, io, (context) => {
|
|
140
|
+
const tree = resolveTreeObject(context.top, oid, context.io);
|
|
141
|
+
if (!tree.ok) return refuse(tree.reason);
|
|
142
|
+
const target = readGit(context, [...GIT_ARGS.resolve, oid]);
|
|
143
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
144
|
+
if (ledger.state !== LEDGER_OK) return refuse(`${NAMES.ledger}: ${ledger.reason ?? ledger.state}`);
|
|
145
|
+
if (ledger.head.state === HEAD_ERROR) return refuse(`${NAMES.ledger}: ${ledger.head.reason}`);
|
|
146
|
+
const { records, head } = ledger;
|
|
147
|
+
const index = records.findIndex((record) => record.kind === DISPATCH_KIND && record.nonce === nonce);
|
|
148
|
+
if (index === LAST) return refuse(`${NAMES.task}: ${nonce}`);
|
|
149
|
+
const dispatch = records[index];
|
|
150
|
+
const task = readsTask(dispatch);
|
|
151
|
+
if (task === null) return refuse(`${NAMES.task}: ${nonce}`);
|
|
152
|
+
const later = records.slice(index + NEXT).filter((record) => record.nonce === nonce);
|
|
153
|
+
if (!later.some(isThreadTerminalRecord)) return refuse(`${NAMES.open}: ${nonce}`);
|
|
154
|
+
if (later.some((record) => record.kind === FOLD_KIND)) return refuse(`${NAMES.folded}: ${nonce}`);
|
|
155
|
+
if (dispatch.baseline.treeOid !== target) return refuse(`${NAMES.task}: ${nonce}: ${target}`);
|
|
156
|
+
const scope = snapshotScope(context.top, { baseTreeOid: target }, context.io);
|
|
157
|
+
if (!scope.ok) return refuse(scope.reason);
|
|
158
|
+
const paths = scope.paths.filter((path) => task.files.includes(path));
|
|
159
|
+
const domain = new Set(paths);
|
|
160
|
+
for (const claim of epochClaims(records, head, isThreadTerminalRecord)) {
|
|
161
|
+
if (claim.nonce === nonce || !claim.files.some((file) => paths.some((path) => hasPathOverlap(file, path)))) continue;
|
|
162
|
+
return refuse(`${claim.state === FOLDED ? NAMES.folded : NAMES.open}: ${claim.nonce}`);
|
|
163
|
+
}
|
|
164
|
+
const untasked = records.filter((record) => record.kind !== DISPATCH_KIND || readsTask(record) === null);
|
|
165
|
+
const folded = untasked.find((record, position) => record.kind === DISPATCH_KIND && readsInsideEpoch(record, head)
|
|
166
|
+
&& untasked.slice(position + NEXT).some((successor) => successor.nonce === record.nonce && successor.kind === FOLD_KIND));
|
|
167
|
+
if (folded !== undefined) return refuse(`${NAMES.folded}: ${folded.nonce}`);
|
|
168
|
+
const held = readHeldSet(context, target);
|
|
169
|
+
const open = findOpenCheckpointThread(untasked, held);
|
|
170
|
+
if (open !== undefined) return refuse(`${NAMES.open}: ${open.nonce}`);
|
|
171
|
+
const entries = readTargetEntries(context, target, task.files);
|
|
172
|
+
const outside = entries.find((path) => !domain.has(path));
|
|
173
|
+
if (outside !== undefined) return refuse(`${NAMES.scope}: ${outside}`);
|
|
174
|
+
const types = checkTypes(context, entries);
|
|
175
|
+
if (!types.ok) return types;
|
|
176
|
+
withTemporaryIndex(context.env, (env) => {
|
|
177
|
+
readGit(context, [...GIT_ARGS.read, target], env);
|
|
178
|
+
if (entries.length > START) readGit(context, [...GIT_ARGS.checkoutPaths, ...entries], env);
|
|
179
|
+
});
|
|
180
|
+
removeExtras(context, paths, new Set(entries));
|
|
181
|
+
const proof = computeBasePayload(context.top, { kind: CHECKPOINT_KIND, treeOid: target }, context.io, task.files);
|
|
182
|
+
return proof !== null && proof.length === START ? { ok: true, target, nonce } : refuse(MISMATCH);
|
|
183
|
+
});
|
package/tools/checkpoint.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { PLANS_REL, isScratchPlanName } from './plan-files.mjs';
|
|
|
7
7
|
import { isDirectRun } from './direct-run.mjs';
|
|
8
8
|
import { CHECKPOINT_REF_PREFIX, refuse, withRepository, readGit, readCheckpointRefs, writeSnapshot,
|
|
9
9
|
findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
10
|
-
import { restoreCheckpoint } from './checkpoint-restore.mjs';
|
|
10
|
+
import { restoreCheckpoint, restoreTaskThread } from './checkpoint-restore.mjs';
|
|
11
11
|
|
|
12
12
|
export { CHECKPOINT_REF_PREFIX } from './checkpoint-core.mjs';
|
|
13
13
|
const ACCEPT = 0;
|
|
@@ -30,11 +30,15 @@ const LEDGER_OK = 'ok';
|
|
|
30
30
|
const CLEAN = 'CLEAN';
|
|
31
31
|
const DIRTY = 'DIRTY';
|
|
32
32
|
const PROOF = 'proof';
|
|
33
|
+
const TASK_KIND = 'task';
|
|
34
|
+
const TASK_PROOF = 'clean';
|
|
33
35
|
const PLAN_FLAG = '--plan';
|
|
36
|
+
const NONCE_FLAG = '--nonce';
|
|
34
37
|
const PLAN_ARG_COUNT = 3;
|
|
35
38
|
const OID_ARG_COUNT = 2;
|
|
39
|
+
const TASK_ARG_COUNT = 4;
|
|
36
40
|
const ARGV_OFFSET = 2;
|
|
37
|
-
const USAGE_TEXT = 'usage: checkpoint mint|newest|prune --plan <plan> | verify|restore <oid>';
|
|
41
|
+
const USAGE_TEXT = 'usage: checkpoint mint|newest|prune --plan <plan> | verify <oid> | restore <oid> [--nonce <n>]';
|
|
38
42
|
const PLAN_ERROR = 'expected a readable regular plan directly under docs/plans';
|
|
39
43
|
const SEQUENCE_ERROR = 'expected contiguous numbered refs on trees';
|
|
40
44
|
const GIT_ARGS = {
|
|
@@ -122,17 +126,22 @@ const PLAN_OPERATIONS = { [VERBS.mint]: mintCheckpoint, [VERBS.newest]: newestCh
|
|
|
122
126
|
const formatRef = ({ stem, n, oid }) => `${CHECKPOINT_KIND} ${stem}/${n} ${oid}${NEWLINE}`;
|
|
123
127
|
const formatRefusal = ({ code, reason }) => ({ code, stdout: EMPTY, stderr: `${reason}${NEWLINE}` });
|
|
124
128
|
export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
|
|
125
|
-
const [verb, operand, planPath] = argv;
|
|
129
|
+
const [verb, operand, planPath, nonce] = argv;
|
|
126
130
|
const planVerb = Object.hasOwn(PLAN_OPERATIONS, verb);
|
|
127
131
|
const oidVerb = verb === VERBS.verify || verb === VERBS.restore;
|
|
132
|
+
const taskRestore = verb === VERBS.restore && argv.length === TASK_ARG_COUNT && operand
|
|
133
|
+
&& planPath === NONCE_FLAG && typeof nonce === STRING && nonce !== EMPTY;
|
|
128
134
|
if (!(planVerb && argv.length === PLAN_ARG_COUNT && operand === PLAN_FLAG && planPath)
|
|
129
|
-
&& !(oidVerb && argv.length === OID_ARG_COUNT && operand)) return formatRefusal(refuse(USAGE_TEXT, USAGE));
|
|
135
|
+
&& !(oidVerb && argv.length === OID_ARG_COUNT && operand) && !taskRestore) return formatRefusal(refuse(USAGE_TEXT, USAGE));
|
|
130
136
|
const cwd = deps.cwd ?? process.cwd();
|
|
131
137
|
const operation = verb === VERBS.restore ? restoreCheckpoint : verifyCheckpoint;
|
|
132
|
-
const result =
|
|
138
|
+
const result = taskRestore ? restoreTaskThread(cwd, operand, nonce, deps)
|
|
139
|
+
: planVerb ? PLAN_OPERATIONS[verb](cwd, planPath, deps) : operation(cwd, operand, deps);
|
|
133
140
|
if (!result.ok) return formatRefusal(result);
|
|
134
|
-
if (verb === VERBS.restore)
|
|
135
|
-
|
|
141
|
+
if (verb === VERBS.restore) {
|
|
142
|
+
const proof = taskRestore ? `${TASK_KIND} ${nonce} ${PROOF} ${TASK_PROOF}` : `${PROOF} ${result.proof}`;
|
|
143
|
+
return { code: ACCEPT, stdout: `${CHECKPOINT_KIND} ${VERBS.restore} ${result.target} ${proof}${NEWLINE}`, stderr: EMPTY };
|
|
144
|
+
}
|
|
136
145
|
if (verb === VERBS.verify) return { code: result.clean ? ACCEPT : REFUSE,
|
|
137
146
|
stdout: `${result.clean ? CLEAN : DIRTY} ${result.target} ${result.oid}${NEWLINE}`, stderr: EMPTY };
|
|
138
147
|
return { code: ACCEPT, stdout: verb === VERBS.prune ? EMPTY : formatRef(result), stderr: EMPTY };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dispatch-baseline.mjs — snapshotScope, computeBasePayload, computeBaseFingerprint,
|
|
1
|
+
// dispatch-baseline.mjs — snapshotScope, computeBasePayload, computeBaseFingerprint, taskFilesScopeRefusal,
|
|
2
2
|
// isCleanAgainstBase, runBaseDiff and resolveTreeObject; spec:dispatch-baseline.
|
|
3
3
|
// Writes only its own temporary indexes and the blob objects stored by forced adds.
|
|
4
4
|
// Head measurements delegate unchanged to core-evidence without a temporary index.
|
|
@@ -20,6 +20,8 @@ const PAYLOAD_DIFF_ARGS = ['--no-ext-diff', '--no-textconv', '--ignore-submodule
|
|
|
20
20
|
const SCOPE_EXCLUSIONS = ['/docs/plans/', '/.claude/settings.json', '/.claude/settings.local.json', '/.mcp.json'];
|
|
21
21
|
const MISSING_PATH_CODES = ['ENOENT', 'ENOTDIR'];
|
|
22
22
|
const ABSENT_HEAD_STATUS = 1;
|
|
23
|
+
const CHECK_IGNORE_CLEAR_STATUS = 1;
|
|
24
|
+
const EXCLUDE_SOURCES = ['.gitignore', '.gitattributes'];
|
|
23
25
|
const splitPaths = (bytes) => {
|
|
24
26
|
const text = bytes.toString('utf8');
|
|
25
27
|
if (!Buffer.from(text).equals(bytes)) throw new Error('git path names are not lossless UTF-8');
|
|
@@ -62,12 +64,12 @@ const readStat = (path, lstat) => {
|
|
|
62
64
|
throw error;
|
|
63
65
|
}
|
|
64
66
|
};
|
|
65
|
-
const readTreePaths = (run, top, treeOid, env) => splitPaths(readGit(run, ['ls-tree', '-r', '--name-only', '-z', treeOid], top, env));
|
|
66
|
-
const readHeadPaths = (run, top, env) => {
|
|
67
|
+
const readTreePaths = (run, top, treeOid, env, directories = false) => splitPaths(readGit(run, ['ls-tree', '-r', ...(directories ? ['-d', '-t'] : []), '--name-only', '-z', treeOid], top, env));
|
|
68
|
+
const readHeadPaths = (run, top, env, directories = false) => {
|
|
67
69
|
const head = run(['rev-parse', '--verify', '--quiet', 'HEAD'], top, undefined, env);
|
|
68
70
|
if (head && !head.error && !head.signal && head.status === ABSENT_HEAD_STATUS) return [];
|
|
69
71
|
if (!head || head.error || head.signal || head.status !== 0) throw new Error('cannot resolve HEAD');
|
|
70
|
-
return readTreePaths(run, top, 'HEAD', env);
|
|
72
|
+
return readTreePaths(run, top, 'HEAD', env, directories);
|
|
71
73
|
};
|
|
72
74
|
const excludePatterns = (top, paths) => [
|
|
73
75
|
...SCOPE_EXCLUSIONS,
|
|
@@ -99,8 +101,7 @@ const collectScope = (top, baseTreeOid, run, lstat, env) => withTemporaryIndex(e
|
|
|
99
101
|
|
|
100
102
|
export const snapshotScope = (cwd, { baseTreeOid = null } = {}, io = {}) => {
|
|
101
103
|
try {
|
|
102
|
-
const env = io.env ?? process.env;
|
|
103
|
-
const run = makeRunner(io);
|
|
104
|
+
const env = io.env ?? process.env, run = makeRunner(io);
|
|
104
105
|
const top = locateTop(cwd, run, env);
|
|
105
106
|
return { ok: true, paths: collectScope(top, baseTreeOid, run, io.lstat ?? lstatSync, env) };
|
|
106
107
|
} catch (error) {
|
|
@@ -110,8 +111,7 @@ export const snapshotScope = (cwd, { baseTreeOid = null } = {}, io = {}) => {
|
|
|
110
111
|
|
|
111
112
|
export const resolveTreeObject = (cwd, oid, io = {}) => {
|
|
112
113
|
try {
|
|
113
|
-
const env = io.env ?? process.env;
|
|
114
|
-
const run = makeRunner(io);
|
|
114
|
+
const env = io.env ?? process.env, run = makeRunner(io);
|
|
115
115
|
const top = locateTop(cwd, run, env);
|
|
116
116
|
const type = readGit(run, ['cat-file', '-t', oid], top, env).toString('utf8').trim();
|
|
117
117
|
return type === 'tree' ? { ok: true } : { ok: false, reason: `${oid} names a ${type}, not a tree` };
|
|
@@ -120,19 +120,47 @@ export const resolveTreeObject = (cwd, oid, io = {}) => {
|
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
export const
|
|
123
|
+
export const taskFilesScopeRefusal = (cwd, base, paths, io = {}) => {
|
|
124
|
+
if (paths.length === 0) return null;
|
|
125
|
+
const current = { path: paths[0] };
|
|
126
|
+
try {
|
|
127
|
+
const env = io.env ?? process.env, run = makeRunner(io), lstat = io.lstat ?? lstatSync;
|
|
128
|
+
const top = locateTop(cwd, run, env);
|
|
129
|
+
return withTemporaryIndex(env, (indexEnv) => {
|
|
130
|
+
readGit(run, ['read-tree', '--empty'], top, indexEnv);
|
|
131
|
+
const treePaths = readHeadPaths(run, top, indexEnv).concat(base.kind === 'checkpoint' ? readTreePaths(run, top, base.treeOid, indexEnv) : []);
|
|
132
|
+
const directories = readHeadPaths(run, top, indexEnv, true).concat(base.kind === 'checkpoint' ? readTreePaths(run, top, base.treeOid, indexEnv, true) : []);
|
|
133
|
+
const matchesPattern = (path, pattern) => { const probe = patternToProbe(pattern); return isDirPattern(pattern) ? path === probe.slice(0, -1) || path.startsWith(probe) : path === probe; };
|
|
134
|
+
const exclusions = excludePatterns(top, paths);
|
|
135
|
+
for (const path of paths) {
|
|
136
|
+
current.path = path;
|
|
137
|
+
if (EXCLUDE_SOURCES.includes(basename(path))) return `${path}: exclude-source`;
|
|
138
|
+
if (path.split('/').includes('node_modules') || exclusions.some((pattern) => matchesPattern(path, pattern))) return `${path}: excluded`;
|
|
139
|
+
if (readStat(join(top, path), lstat)?.isDirectory() || directories.includes(path)) return `${path}: directory`;
|
|
140
|
+
const prefixes = path.split('/').slice(0, -1).map((segment, index, parts) => parts.slice(0, index + 1).join('/'));
|
|
141
|
+
if (prefixes.some((prefix) => { const stat = readStat(join(top, prefix), lstat); return (stat !== null && !stat.isDirectory()) || treePaths.includes(prefix); })) return `${path}: beneath-non-directory`;
|
|
142
|
+
if (treePaths.includes(path) || KIT_OWN_PATHS.some((pattern) => matchesPattern(path, pattern))) continue;
|
|
143
|
+
const { GIT_LITERAL_PATHSPECS: _literalPathspecs, ...ignoreEnv } = indexEnv; const ignored = run(['check-ignore', '--no-index', '-q', '--', path], top, undefined, ignoreEnv);
|
|
144
|
+
if (!ignored || ignored.error || ignored.signal || ![0, CHECK_IGNORE_CLEAR_STATUS].includes(ignored.status)) throw new Error(`git check-ignore failed: ${ignored?.error?.message ?? String(ignored?.stderr ?? '').trim()}`);
|
|
145
|
+
if (ignored.status === 0) return `${path}: ignored`;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
});
|
|
149
|
+
} catch (error) { return `${current.path}: ${error.message}`; }
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const runBaseDiff = (cwd, base, args, io = {}, paths = null) => {
|
|
124
153
|
if (base.kind !== 'checkpoint') return null;
|
|
125
154
|
try {
|
|
126
|
-
const env = io.env ?? process.env;
|
|
127
|
-
const run = makeRunner(io);
|
|
155
|
+
const env = io.env ?? process.env, run = makeRunner(io);
|
|
128
156
|
const top = locateTop(cwd, run, env);
|
|
129
157
|
if (!resolveTreeObject(top, base.treeOid, io).ok) return null;
|
|
130
158
|
const scope = snapshotScope(top, { baseTreeOid: base.treeOid }, io);
|
|
131
159
|
if (!scope.ok) return null;
|
|
132
160
|
const basePaths = readTreePaths(run, top, base.treeOid, env);
|
|
133
|
-
const
|
|
134
|
-
const removed = [];
|
|
135
|
-
for (const path of
|
|
161
|
+
const scopedPaths = paths === null ? scope.paths : scope.paths.filter((path) => paths.includes(path));
|
|
162
|
+
const present = [], removed = [];
|
|
163
|
+
for (const path of scopedPaths) {
|
|
136
164
|
const stat = readStat(join(top, path), io.lstat ?? lstatSync);
|
|
137
165
|
const baseHoldsPath = basePaths.includes(path);
|
|
138
166
|
const neverCommittable = stat !== null && isNeverCommittableStat(stat);
|
|
@@ -141,7 +169,7 @@ export const runBaseDiff = (cwd, base, args, io = {}) => {
|
|
|
141
169
|
if (materialized) present.push(path);
|
|
142
170
|
if (!materialized && !neverCommittable && (stat === null || baseHoldsPath)) removed.push(path);
|
|
143
171
|
}
|
|
144
|
-
const excludedBasePaths = basePaths.filter((path) => !
|
|
172
|
+
const excludedBasePaths = basePaths.filter((path) => !scopedPaths.includes(path));
|
|
145
173
|
const conflict = present.find((path) => excludedBasePaths.some((excluded) =>
|
|
146
174
|
path.startsWith(`${excluded}/`) || excluded.startsWith(`${path}/`)));
|
|
147
175
|
if (conflict !== undefined) throw new Error(`live path ${conflict} conflicts with an excluded base path`);
|
|
@@ -156,17 +184,17 @@ export const runBaseDiff = (cwd, base, args, io = {}) => {
|
|
|
156
184
|
}
|
|
157
185
|
};
|
|
158
186
|
|
|
159
|
-
export const computeBasePayload = (cwd, base, io = {}) => base.kind === 'head'
|
|
187
|
+
export const computeBasePayload = (cwd, base, io = {}, paths = null) => base.kind === 'head'
|
|
160
188
|
? computeFingerprintPayload(cwd, io)
|
|
161
|
-
: runBaseDiff(cwd, base, PAYLOAD_DIFF_ARGS, io);
|
|
189
|
+
: runBaseDiff(cwd, base, PAYLOAD_DIFF_ARGS, io, paths);
|
|
162
190
|
|
|
163
|
-
export const computeBaseFingerprint = (cwd, base, io) => {
|
|
164
|
-
const payload = computeBasePayload(cwd, base, io);
|
|
191
|
+
export const computeBaseFingerprint = (cwd, base, io, paths = null) => {
|
|
192
|
+
const payload = computeBasePayload(cwd, base, io, paths);
|
|
165
193
|
return payload === null ? null : createHash(HASH_ALGORITHM).update(payload).digest('hex');
|
|
166
194
|
};
|
|
167
195
|
|
|
168
|
-
export const isCleanAgainstBase = (cwd, base, io) => {
|
|
196
|
+
export const isCleanAgainstBase = (cwd, base, io, paths = null) => {
|
|
169
197
|
if (base.kind === 'head') return isTreeClean(cwd, io);
|
|
170
|
-
const payload = computeBasePayload(cwd, base, io);
|
|
198
|
+
const payload = computeBasePayload(cwd, base, io, paths);
|
|
171
199
|
return payload === null ? null : payload.length === 0;
|
|
172
200
|
};
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
|
|
77
77
|
import { createHash } from 'node:crypto';
|
|
78
78
|
import { SAFE_NONCE_RE, flowCanonicalSerialization } from './flow-record.mjs';
|
|
79
|
+
import { validateTask } from './task-thread.mjs';
|
|
79
80
|
|
|
80
81
|
const deepFreeze = (value) => {
|
|
81
82
|
if (value !== null && typeof value === 'object') {
|
|
@@ -133,7 +134,7 @@ const short = (v) => {
|
|
|
133
134
|
export const DELEGATION_SCHEMA_VERSION = 1;
|
|
134
135
|
export const BASELINE_KINDS = deepFreeze(['head', 'checkpoint']);
|
|
135
136
|
export const HEAD_BASELINE = deepFreeze({ kind: 'head', treeOid: null });
|
|
136
|
-
export const OPTIONAL_FIELDS = deepFreeze({ dispatch: ['baseline'] });
|
|
137
|
+
export const OPTIONAL_FIELDS = deepFreeze({ dispatch: ['baseline', 'task'] });
|
|
137
138
|
const TREE_OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
138
139
|
export const isTreeOid = (v) => typeof v === 'string' && TREE_OID_RE.test(v);
|
|
139
140
|
export const readsBaseline = (dispatch) => Object.hasOwn(dispatch, 'baseline')
|
|
@@ -145,7 +146,7 @@ export const DELEGATION_KINDS = deepFreeze(['pre-registration', 'dispatch', 'ret
|
|
|
145
146
|
// The closed key set per kind (schema + kind are implicit on every record).
|
|
146
147
|
export const DELEGATION_KEY_SETS = deepFreeze({
|
|
147
148
|
'pre-registration': ['waveId', 'stepClasses', 'pairingKey', 'minPerClass', 'meanLThreshold', 'firstPassNum', 'firstPassDen', 'timestamp'],
|
|
148
|
-
dispatch: ['waveId', 'nonce', 'stepClass', 'vehicle', 'backend', 'contractDigest', 'preTreeDigest', 'baselineClean', 'deadlineS', 'retryOf', 'retryIndex', 'retryCap', 'rationale', 'timestamp', 'baseline'],
|
|
149
|
+
dispatch: ['waveId', 'nonce', 'stepClass', 'vehicle', 'backend', 'contractDigest', 'preTreeDigest', 'baselineClean', 'deadlineS', 'retryOf', 'retryIndex', 'retryCap', 'rationale', 'timestamp', 'baseline', 'task'],
|
|
149
150
|
return: ['role', 'backend', 'nonce', 'contractDigest', 'preTreeDigest', 'postTreeDigest', 'diffDigest', 'diffLength', 'reportDigest', 'reportLength', 'bundleDigest', 'bundleLength', 'metric', 'outcome', 'exitStatus', 'sessionId', 'wrapperVersion', 'posture', 'timestamp'],
|
|
150
151
|
fold: ['nonce', 'returnDigest', 'treeDigestAtFold', 'verdict', 'timestamp'],
|
|
151
152
|
observation: ['waveId', 'stepClass', 'scope', 'metric', 'planId', 'phase', 'timestamp'],
|
|
@@ -346,6 +347,7 @@ const FIELD_CHECKS = {
|
|
|
346
347
|
firstPassDen: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer first-pass denominator' },
|
|
347
348
|
vehicle: { ok: isPlainObject, want: 'the closed vehicle pair {requested, selected}' },
|
|
348
349
|
baseline: { ok: isPlainObject, want: 'the closed baseline pair {kind, treeOid}' },
|
|
350
|
+
task: { ok: isPlainObject, want: 'the closed task object {brief, files}' },
|
|
349
351
|
// The safe token grammar, not merely non-empty: the backend NAMES an artifact beside the store
|
|
350
352
|
// (the exec receipt and its report), so a name outside this grammar records a dispatch whose own
|
|
351
353
|
// receipt could never be written. One grammar on both sides, or the ledger accepts what the
|
|
@@ -519,6 +521,10 @@ export const validateDelegationRecord = (record) => {
|
|
|
519
521
|
const baseline = validateBaseline('dispatch: baseline', record.baseline);
|
|
520
522
|
if (!baseline.ok) return baseline;
|
|
521
523
|
}
|
|
524
|
+
if (Object.hasOwn(record, 'task')) {
|
|
525
|
+
const closed = checkClosedKeys('dispatch: task', record.task, ['brief', 'files']); if (!closed.ok) return closed;
|
|
526
|
+
const task = validateTask(record.task, readsBaseline(record).kind); if (!task.ok) return refuse(`dispatch: task: ${task.reason}`);
|
|
527
|
+
}
|
|
522
528
|
return vehicle.ok ? validateDispatchCrossFields(record) : vehicle;
|
|
523
529
|
}
|
|
524
530
|
if (record.kind === 'return') return validateReturnCrossFields(record);
|
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
} from './dispatch-record.mjs';
|
|
8
8
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
9
9
|
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
10
|
+
import { readsTask, readsInsideEpoch, epochClaims } from './task-thread.mjs';
|
|
11
|
+
import { hasPathOverlap } from './claim-relation.mjs';
|
|
12
|
+
import { HELD_RECEIPT_BACKEND } from './held-session.mjs';
|
|
10
13
|
|
|
11
14
|
export const DELEGATION_STORE_STOP = 'DELEGATION_STORE_STOP';
|
|
12
15
|
export const delegationStoreStop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'DelegationStoreStop', code: DELEGATION_STORE_STOP });
|
|
@@ -127,7 +130,34 @@ const describeClosure = (last) =>
|
|
|
127
130
|
|
|
128
131
|
const WAVE_SCOPED_KINDS = ['dispatch', 'observation', 'degrade'];
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
const checkTaskClaims = (records, snapshot, head) => {
|
|
134
|
+
if (head.state === 'error') throw stop(`${head.reason}; nothing was written`);
|
|
135
|
+
const task = readsTask(snapshot);
|
|
136
|
+
const claims = epochClaims(records, head, isThreadTerminalRecord);
|
|
137
|
+
if (task === null) {
|
|
138
|
+
const collision = claims.find((claim) => claim.state === 'open'
|
|
139
|
+
|| (claim.state === 'folded' && snapshot.stepClass === 'code' && snapshot.backend === HELD_RECEIPT_BACKEND));
|
|
140
|
+
if (collision !== undefined) {
|
|
141
|
+
throw stop(`mixed-open: untasked dispatch collides with task thread "${collision.nonce}" (${collision.state}); nothing was written`);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const untasked = records.find((record) => record.kind === 'dispatch' && readsTask(record) === null
|
|
146
|
+
&& readsInsideEpoch(record, head) && delegationThreadState(records, record.nonce).open);
|
|
147
|
+
if (untasked !== undefined) {
|
|
148
|
+
throw stop(`mixed-open: task dispatch collides with open untasked thread "${untasked.nonce}"; nothing was written`);
|
|
149
|
+
}
|
|
150
|
+
for (const claim of claims) {
|
|
151
|
+
if (claim.state === 'folded' && claim.brief === task.brief) continue;
|
|
152
|
+
const shared = [...new Set(task.files.flatMap((path) => claim.files.flatMap((other) =>
|
|
153
|
+
hasPathOverlap(path, other) ? [path, other] : [])))].sort();
|
|
154
|
+
if (shared.length > 0) {
|
|
155
|
+
throw stop(`files-overlap: task dispatch overlaps thread "${claim.nonce}" on ${shared.join(', ')}; nothing was written`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const delegationSemanticPreflight = ({ records, snapshot, storePath, head = null }) => {
|
|
131
161
|
const digest = canonicalDelegationDigest(snapshot);
|
|
132
162
|
if (records.some((record) => canonicalDelegationDigest(record) === digest)) {
|
|
133
163
|
throw stop(`refusing a canonical duplicate: ${storePath} already carries this exact record (${digest.slice(0, 12)}…), however its keys are ordered — a genuine new record carries new content or a new timestamp; nothing was written`);
|
|
@@ -180,6 +210,9 @@ export const delegationSemanticPreflight = ({ records, snapshot, storePath }) =>
|
|
|
180
210
|
if (base.kind !== priorBase.kind || base.treeOid !== priorBase.treeOid) {
|
|
181
211
|
throw stop(`refusing a retry: its base {kind "${base.kind}", treeOid ${base.treeOid}} differs from its retry origin "${snapshot.retryOf}"'s {kind "${priorBase.kind}", treeOid ${priorBase.treeOid}} — a retry chain is measured against ONE base; nothing was written`);
|
|
182
212
|
}
|
|
213
|
+
if (JSON.stringify(readsTask(snapshot)) !== JSON.stringify(readsTask(prior.dispatch))) {
|
|
214
|
+
throw stop(`refusing a retry: its task differs from its retry origin "${snapshot.retryOf}" in brief or files — a retry keeps the same task; nothing was written`);
|
|
215
|
+
}
|
|
183
216
|
const origin = findRetryChainOrigin(records, prior.dispatch);
|
|
184
217
|
if (snapshot.retryIndex > origin.retryCap) {
|
|
185
218
|
throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} exceeds the retryCap ${origin.retryCap} recorded on the thread's ORIGIN dispatch ("${origin.nonce}") — a fresh contract never manufactures a fresh retry budget; nothing was written`);
|
|
@@ -188,6 +221,7 @@ export const delegationSemanticPreflight = ({ records, snapshot, storePath }) =>
|
|
|
188
221
|
throw stop(`refusing a retry: it retries a contract-refusal thread and must carry a DIFFERENT contractDigest — an unchanged contract would only be refused again, and a retry loop on one contract is exactly what the cap exists to prevent; nothing was written`);
|
|
189
222
|
}
|
|
190
223
|
}
|
|
224
|
+
if (head !== null) checkTaskClaims(records, snapshot, head);
|
|
191
225
|
return;
|
|
192
226
|
}
|
|
193
227
|
if (state.dispatch === null) {
|
package/tools/dispatch-store.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
resolveDelegationStorePath,
|
|
10
10
|
parseDelegationStoreText,
|
|
11
11
|
delegationSemanticPreflight,
|
|
12
|
+
readHeadInstant,
|
|
12
13
|
} from './dispatch-store-read.mjs';
|
|
13
14
|
import { createStoreAppendLane } from './store-append.mjs';
|
|
15
|
+
import { readsTask } from './task-thread.mjs';
|
|
14
16
|
|
|
15
17
|
export {
|
|
16
18
|
DELEGATION_STORE_STOP,
|
|
@@ -31,8 +33,8 @@ export const resolveDelegationLockPath = (storePath) => `${storePath}${DELEGATIO
|
|
|
31
33
|
|
|
32
34
|
export const UNCOMMITTED_STATE_FINGERPRINT = 'the uncommitted-state fingerprint';
|
|
33
35
|
|
|
34
|
-
export const uncommittedStateFingerprint = (cwd = process.cwd(), fsx, base = HEAD_BASELINE) => {
|
|
35
|
-
const fingerprint = base.kind === 'head' ? computeTreeFingerprint(cwd, fsx) : computeBaseFingerprint(cwd, base, fsx);
|
|
36
|
+
export const uncommittedStateFingerprint = (cwd = process.cwd(), fsx, base = HEAD_BASELINE, paths = null) => {
|
|
37
|
+
const fingerprint = base.kind === 'head' ? computeTreeFingerprint(cwd, fsx) : computeBaseFingerprint(cwd, base, fsx, paths);
|
|
36
38
|
if (fingerprint == null) {
|
|
37
39
|
throw stop(`cannot compute ${UNCOMMITTED_STATE_FINGERPRINT} — not inside a git work tree (or a git probe failed); a record never carries a null tree digest (fail closed)`);
|
|
38
40
|
}
|
|
@@ -52,6 +54,11 @@ const delegationAppendLane = createStoreAppendLane({
|
|
|
52
54
|
export const appendDelegationRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
|
|
53
55
|
const { line, snapshot } = delegationAppendLane.captureRecordSnapshot(record);
|
|
54
56
|
return delegationAppendLane.appendResolvedRecord({
|
|
55
|
-
cwd, env, deps,
|
|
57
|
+
cwd, env, deps, makeRecord: () => ({ line, snapshot }),
|
|
58
|
+
preflight: ({ records, snapshot, line, storePath }) => delegationSemanticPreflight({
|
|
59
|
+
records, snapshot, line, storePath, cwd,
|
|
60
|
+
head: snapshot.kind === 'dispatch' && (readsTask(snapshot) !== null || records.some((record) => readsTask(record) !== null))
|
|
61
|
+
? readHeadInstant(cwd) : null,
|
|
62
|
+
}),
|
|
56
63
|
});
|
|
57
64
|
};
|
package/tools/dispatch.mjs
CHANGED
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
// `gate-output` component is emitted in v1. The metric counts the returned change set only.
|
|
54
54
|
// • a receipt is FORGEABLE, exactly like every record in this family. What the absorb door defends
|
|
55
55
|
// against is a buggy or interrupted producer, not a hostile one.
|
|
56
|
-
// • D10 stands as a bar, not a mechanism: at most ONE in-tree exec dispatch
|
|
57
|
-
// nothing here refuses a second one.
|
|
56
|
+
// • D10 stands as a bar, not a mechanism, for untasked threads: at most ONE untasked in-tree exec dispatch
|
|
57
|
+
// runs at a time, and nothing here refuses a second one; for task threads it is a mechanism: files-overlap and mixed-open refuse conflicting opens at append time, and return and fold refuse out-of-files.
|
|
58
58
|
// • `await` observes ARRIVAL and nothing else: an expiry never authorizes the next writer, and the
|
|
59
59
|
// verb releases no slot it never held. Whether the run may be ABSORBED stays `return`'s question. A parallel-write story is not this plan's.
|
|
60
60
|
//
|
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
DELEGATION_SCHEMA_VERSION, STEP_CLASSES, OBSERVATION_PROVENANCE, RETURN_OUTCOMES,
|
|
75
75
|
SESSION_ID_NULLABLE_OUTCOMES, checkDispatchContractForm, checkDispatchMintConsistency,
|
|
76
76
|
contractDigest, canonicalDelegationDigest, computeNumerator, evaluateMetricEligibility,
|
|
77
|
-
HEAD_BASELINE, readsBaseline, isTreeOid,
|
|
77
|
+
HEAD_BASELINE, readsBaseline, isTreeOid, isThreadTerminalRecord,
|
|
78
78
|
} from './dispatch-record.mjs';
|
|
79
79
|
import {
|
|
80
80
|
appendDelegationRecord, readDelegationStore, resolveDelegationStorePath, delegationThreadState,
|
|
@@ -89,7 +89,7 @@ import {
|
|
|
89
89
|
enumerateReturnedObjects, computeReturnedDiff, assembleIntegrationBundle,
|
|
90
90
|
} from './exec-producer.mjs';
|
|
91
91
|
import { gitBuf } from './core-evidence.mjs';
|
|
92
|
-
import { isCleanAgainstBase, resolveTreeObject } from './dispatch-baseline.mjs';
|
|
92
|
+
import { isCleanAgainstBase, resolveTreeObject, taskFilesScopeRefusal, runBaseDiff } from './dispatch-baseline.mjs';
|
|
93
93
|
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
94
94
|
import { judgeLedger, threadVerdict, HELD_RECEIPT_BACKEND } from './held-session.mjs';
|
|
95
95
|
import { degradeRecordSet, selectHeldSessionDegrades } from './review-state.mjs';
|
|
@@ -99,12 +99,14 @@ import {
|
|
|
99
99
|
resolveRepoRoot, measureScope, ratio, formatRatio, buildObservationRecord,
|
|
100
100
|
} from './observation-builder.mjs';
|
|
101
101
|
import { handoffReturn, HANDOFF_SLUG_RE } from './worktree-handoff-return.mjs';
|
|
102
|
+
import { parseBrief } from './task-brief.mjs';
|
|
103
|
+
import { byteOrder, validateTask, readsTask, claimedPaths } from './task-thread.mjs';
|
|
102
104
|
|
|
103
105
|
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
104
106
|
|
|
105
107
|
// The ONE contract sentence, doc-parity-bound into references/modes/dispatch.md: the FORM-only limit
|
|
106
108
|
// and the aggregator's refusals are what a reader must not be able to mis-learn from the mode doc.
|
|
107
|
-
export const DISPATCH_CONTRACT = 'the contract check is FORM-only — fields present, grammars respected, never boundedness, design-decidedness or acceptance adequacy — and `aggregate` REFUSES instead of computing acceptance for a wave with no pre-registration record, over an OPEN thread in scope, over a PRE-DISPATCH degrade that opens no thread, or across several waves with no `--wave`; the writer verbs add NO second legality door — the store\'s preflight is the only one, and its refusals travel verbatim — while `open` copies every mint-time field from the contract header and refuses a deadline below the wrapper cap plus the kill grace, `return` absorbs only a TERMINAL exec receipt whose backend, nonce and independently computed contractDigest match the dispatch it answers, and `fold` binds the CURRENT tree to the folded return\'s postTreeDigest, so a tree that moved between the two never folds, and `await` is satisfied ONLY by the TERMINAL exec receipt of its own dispatch\'s {backend, nonce} — never by a review receipt, a ledger line or a finding manifest — while an expiry names a supervision question and releases NO writer slot; `open` records the thread\'s BASE (`head` by default, `checkpoint` under `--checkpoint <oid>`), `return` and `fold` measure against the base the dispatch recorded, never a flag, and the `fold` VERB itself refuses a checkpoint `code` thread the held-session judge names SUBSTITUTED in that thread\'s own verdict — a precondition like its hidden-path and content-blind guards, the store\'s preflight staying the only door on the record';
|
|
109
|
+
export const DISPATCH_CONTRACT = 'the contract check is FORM-only — fields present, grammars respected, never boundedness, design-decidedness or acceptance adequacy — and `aggregate` REFUSES instead of computing acceptance for a wave with no pre-registration record, over an OPEN thread in scope, over a PRE-DISPATCH degrade that opens no thread, or across several waves with no `--wave`; the writer verbs add NO second legality door — the store\'s preflight is the only one, and its refusals travel verbatim — while `open` copies every mint-time field from the contract header and refuses a deadline below the wrapper cap plus the kill grace, `return` absorbs only a TERMINAL exec receipt whose backend, nonce and independently computed contractDigest match the dispatch it answers, and `fold` binds the CURRENT tree to the folded return\'s postTreeDigest, so a tree that moved between the two never folds, and `await` is satisfied ONLY by the TERMINAL exec receipt of its own dispatch\'s {backend, nonce} — never by a review receipt, a ledger line or a finding manifest — while an expiry names a supervision question and releases NO writer slot; `open` records the thread\'s BASE (`head` by default, `checkpoint` under `--checkpoint <oid>`), `return` and `fold` measure against the base the dispatch recorded, never a flag, and the `fold` VERB itself refuses a checkpoint `code` thread the held-session judge names SUBSTITUTED in that thread\'s own verdict — a precondition like its hidden-path and content-blind guards, the store\'s preflight staying the only door on the record; `open --task` records the task, a task thread is measured over its Files and `open`, `return` and `fold` refuse `files-overlap`, `mixed-open` and `out-of-files`';
|
|
108
110
|
|
|
109
111
|
// ── the flag surface (the CLI tests pin it against the D3 key sets) ────────────────────────────────
|
|
110
112
|
// flag → the record field it decides. The fields NOT on a flag are DERIVED and listed beside them,
|
|
@@ -145,6 +147,7 @@ export const OPEN_FLAG_FIELDS = Object.freeze({
|
|
|
145
147
|
'--rationale': 'rationale',
|
|
146
148
|
'--retry-of': 'retryOf',
|
|
147
149
|
'--checkpoint': 'baseline.treeOid',
|
|
150
|
+
'--task': 'task',
|
|
148
151
|
});
|
|
149
152
|
// COPIED from the contract header at mint and bound by contractDigest (D3): a dispatch that
|
|
150
153
|
// disagreed with the header it claims to carry is refused by checkDispatchMintConsistency.
|
|
@@ -476,7 +479,7 @@ const runObserve = ({ baseCwd, env, argv, now }) => {
|
|
|
476
479
|
// some other producer wrote — and a verb computing over such a ledger inherits its lie. So every
|
|
477
480
|
// verb that DERIVES anything from the store (`return`, `fold`, `degrade`, `aggregate`) reads it
|
|
478
481
|
// through this one door, which replays the store's own preflight in file order and stops at the
|
|
479
|
-
// first record the append path would have refused, naming its physical line.
|
|
482
|
+
// first record the append path would have refused, except files-overlap and mixed-open, which are judged at append time only, naming its physical line.
|
|
480
483
|
//
|
|
481
484
|
// `open` deliberately has no read-side audit: it derives nothing from the ledger. Its retry and wave
|
|
482
485
|
// rules are evaluated by the store, on the snapshot under the lock, which is the only place they can
|
|
@@ -539,7 +542,25 @@ const runOpen = ({ baseCwd, env, argv, now }) => {
|
|
|
539
542
|
if (!resolved.ok) return refusal('open', resolved.reason);
|
|
540
543
|
}
|
|
541
544
|
const base = oid === undefined ? HEAD_BASELINE : { kind: 'checkpoint', treeOid: oid };
|
|
542
|
-
const
|
|
545
|
+
const brief = values['--task'];
|
|
546
|
+
let task = null;
|
|
547
|
+
if (brief !== undefined) {
|
|
548
|
+
if (oid === undefined) return refusal('open', 'task-base: --task requires --checkpoint; nothing was written');
|
|
549
|
+
const root = resolveRepoRoot(cwd);
|
|
550
|
+
if (root === null) return refusal('open', 'not inside a git work tree — a dispatch is opened against a repository (fail closed); nothing was written');
|
|
551
|
+
const read = readRegularFileNoFollow(resolve(root, brief));
|
|
552
|
+
if (read.outcome !== 'ok') {
|
|
553
|
+
return refusal('open', `cannot read ${brief} (${read.code ?? read.className ?? read.outcome}); nothing was written`);
|
|
554
|
+
}
|
|
555
|
+
const parsed = parseBrief(read.content);
|
|
556
|
+
if (!parsed.ok) return refusal('open', parsed.reason);
|
|
557
|
+
task = { brief, files: [...new Set(parsed.files.map(({ path }) => path))].sort(byteOrder) };
|
|
558
|
+
const checked = validateTask(task, base.kind);
|
|
559
|
+
if (!checked.ok) return refusal('open', checked.reason);
|
|
560
|
+
const scope = taskFilesScopeRefusal(cwd, base, task.files);
|
|
561
|
+
if (scope !== null) return refusal('open', `task-files-scope: ${scope}; nothing was written`);
|
|
562
|
+
}
|
|
563
|
+
const baselineClean = isCleanAgainstBase(cwd, base, undefined, task?.files ?? null);
|
|
543
564
|
if (baselineClean === null) {
|
|
544
565
|
return refusal('open', 'the working state could not be probed, so the baseline is undecidable — a dispatch never records a guessed baseline, and outside a git work tree there is no tree to fingerprint either (fail closed); nothing was written');
|
|
545
566
|
}
|
|
@@ -558,7 +579,7 @@ const runOpen = ({ baseCwd, env, argv, now }) => {
|
|
|
558
579
|
// delegate's account at return time. `open` is where that lie is cheapest to catch.
|
|
559
580
|
const honest = hiddenFromPlainDiff(top);
|
|
560
581
|
if (!honest.ok) return refusal('open', `${honest.reason}; nothing was written`);
|
|
561
|
-
const preTreeDigest = uncommittedStateFingerprint(cwd, undefined, base);
|
|
582
|
+
const preTreeDigest = uncommittedStateFingerprint(cwd, undefined, base, task?.files ?? null);
|
|
562
583
|
const record = {
|
|
563
584
|
schema: DELEGATION_SCHEMA_VERSION,
|
|
564
585
|
kind: 'dispatch',
|
|
@@ -571,6 +592,7 @@ const runOpen = ({ baseCwd, env, argv, now }) => {
|
|
|
571
592
|
preTreeDigest,
|
|
572
593
|
baselineClean,
|
|
573
594
|
baseline: { kind: base.kind, treeOid: base.treeOid },
|
|
595
|
+
...(task === null ? {} : { task }),
|
|
574
596
|
deadlineS: contract.deadlineS,
|
|
575
597
|
retryOf: values['--retry-of'] ?? null,
|
|
576
598
|
retryIndex: contract.retry.index,
|
|
@@ -648,7 +670,7 @@ export const pollExecArrival = ({ dir, backend, nonce, io = {} }) => {
|
|
|
648
670
|
return { state: 'satisfied', reason: `the TERMINAL exec receipt landed (${path}) — outcome ${receipt.outcome} · exit ${receipt.exitStatus} · session ${receipt.sessionId ?? 'none'}` };
|
|
649
671
|
};
|
|
650
672
|
|
|
651
|
-
const NO_SLOT_RELEASED = 'NO writer slot was released: a wait that ended without an answer never authorizes the next dispatch
|
|
673
|
+
const NO_SLOT_RELEASED = 'NO writer slot was released: a wait that ended without an answer never authorizes the next dispatch';
|
|
652
674
|
|
|
653
675
|
// The bound arithmetic is BigInt, and it HAS to be. The frozen record vocabulary admits any positive
|
|
654
676
|
// SAFE INTEGER `deadlineS` (dispatch-record.mjs), so `deadlineS * 1000` leaves the exactly
|
|
@@ -1036,6 +1058,18 @@ export const treeDriftRefusal = (openingDigest, closingDigest) => (openingDigest
|
|
|
1036
1058
|
? null
|
|
1037
1059
|
: `the tree moved WHILE the return was being computed (${openingDigest.slice(0, 12)}… → ${closingDigest.slice(0, 12)}…) — the enumeration and the diff would then describe two different change sets, and the record would bind a postTreeDigest neither of them saw; nothing was written, so leave the tree alone and return again`);
|
|
1038
1060
|
|
|
1061
|
+
const getOutOfFilesRefusal = (cwd, base, env) => {
|
|
1062
|
+
const changed = runBaseDiff(cwd, base, ['--name-only', '--no-renames', '-z'], {}, null);
|
|
1063
|
+
if (changed === null) return 'out-of-files: the snapshot domain is undecidable (fail closed); nothing was written';
|
|
1064
|
+
const ledger = readDelegationLedger(cwd, env);
|
|
1065
|
+
if (ledger.state !== 'ok') return `out-of-files: ${ledger.reason ?? `delegation ledger is ${ledger.state}`} (fail closed); nothing was written`;
|
|
1066
|
+
const { records, head } = ledger;
|
|
1067
|
+
if (head.state === 'error') return `out-of-files: ${head.reason} (fail closed); nothing was written`;
|
|
1068
|
+
const claimed = new Set(claimedPaths(records, base.treeOid, head, isThreadTerminalRecord));
|
|
1069
|
+
const unclaimed = changed.toString('utf8').split('\0').filter((path) => path !== '' && !claimed.has(path)).sort(byteOrder);
|
|
1070
|
+
return unclaimed.length === 0 ? null : `out-of-files: ${unclaimed.join(', ')}; nothing was written`;
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1039
1073
|
const runReturn = ({ baseCwd, env, argv, now }) => {
|
|
1040
1074
|
const { values, operands, cwd } = scan(argv, { ...RETURN_FLAG_FIELDS, ...RETURN_INPUT_FLAGS }, baseCwd, { booleans: RETURN_BOOLEAN_FLAGS });
|
|
1041
1075
|
refuseOperands('return', operands);
|
|
@@ -1136,20 +1170,23 @@ const runReturn = ({ baseCwd, env, argv, now }) => {
|
|
|
1136
1170
|
// A tree that moved between them would hand the numerator one change set and the denominator
|
|
1137
1171
|
// another, and the record would bind a postTreeDigest neither of them describes.
|
|
1138
1172
|
const base = readsBaseline(dispatch);
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1173
|
+
const paths = readsTask(dispatch)?.files ?? null;
|
|
1174
|
+
const openingDigest = uncommittedStateFingerprint(cwd, undefined, base, paths);
|
|
1175
|
+
const enumerated = enumerateReturnedObjects(cwd, { base, paths });
|
|
1141
1176
|
if (!enumerated.ok) return refusal('return', enumerated.reason);
|
|
1142
1177
|
const opaque = contentBlindRefusal(enumerated.entries);
|
|
1143
1178
|
if (opaque !== null) return refusal('return', `${opaque}; nothing was written`);
|
|
1144
1179
|
const lostTarget = symlinkTargetRefusal(top, enumerated.entries);
|
|
1145
1180
|
if (lostTarget !== null) return refusal('return', `${lostTarget}; nothing was written`);
|
|
1146
|
-
const produced = computeReturnedDiff(cwd, { base });
|
|
1181
|
+
const produced = computeReturnedDiff(cwd, { base, paths });
|
|
1147
1182
|
if (!produced.ok) return refusal('return', produced.reason);
|
|
1148
1183
|
// The payload IS the diff and its digest IS the uncommitted-state fingerprint — one computation,
|
|
1149
1184
|
// so diffDigest and postTreeDigest are equal by construction rather than by coincidence.
|
|
1150
1185
|
const postTreeDigest = sha256(produced.diff);
|
|
1151
1186
|
const drift = treeDriftRefusal(openingDigest, postTreeDigest);
|
|
1152
1187
|
if (drift !== null) return refusal('return', drift);
|
|
1188
|
+
const outOfFiles = paths === null ? null : getOutOfFilesRefusal(cwd, base, env);
|
|
1189
|
+
if (outOfFiles !== null) return refusal('return', outOfFiles);
|
|
1153
1190
|
// The fail-closed arm of a producer contradiction (one objectId claimed at two sizes). The
|
|
1154
1191
|
// enumeration is this kit's own and emits one entry per object, so nothing here can reach it —
|
|
1155
1192
|
// the vocabulary stays the authority on what a component is, and its refusal is surfaced whole.
|
|
@@ -1270,7 +1307,8 @@ const runFold = ({ baseCwd, env, argv, now }) => {
|
|
|
1270
1307
|
// return and the fold without moving the digest this fold is about to bind. The enumeration runs
|
|
1271
1308
|
// here for that one question — the fold computes no metric of its own.
|
|
1272
1309
|
const base = readsBaseline(state.dispatch);
|
|
1273
|
-
const
|
|
1310
|
+
const paths = readsTask(state.dispatch)?.files ?? null;
|
|
1311
|
+
const enumerated = enumerateReturnedObjects(cwd, { base, paths });
|
|
1274
1312
|
if (!enumerated.ok) return refusal('fold', enumerated.reason);
|
|
1275
1313
|
const opaque = contentBlindRefusal(enumerated.entries);
|
|
1276
1314
|
if (opaque !== null) return refusal('fold', `${opaque}; nothing was written`);
|
|
@@ -1307,7 +1345,9 @@ const runFold = ({ baseCwd, env, argv, now }) => {
|
|
|
1307
1345
|
// fold is honest there — identical payload bytes, identical content — so the rule is "the fold
|
|
1308
1346
|
// precedes staging", never "staging refuses the fold by construction". A checkpoint measurement
|
|
1309
1347
|
// never reads the real index, so staging moves nothing there; the fold still precedes `git add`.
|
|
1310
|
-
const treeDigestAtFold = uncommittedStateFingerprint(cwd, undefined, base);
|
|
1348
|
+
const treeDigestAtFold = uncommittedStateFingerprint(cwd, undefined, base, paths);
|
|
1349
|
+
const outOfFiles = paths === null ? null : getOutOfFilesRefusal(cwd, base, env);
|
|
1350
|
+
if (outOfFiles !== null) return refusal('fold', outOfFiles);
|
|
1311
1351
|
const record = {
|
|
1312
1352
|
schema: DELEGATION_SCHEMA_VERSION,
|
|
1313
1353
|
kind: 'fold',
|
|
@@ -1600,7 +1640,7 @@ Usage:
|
|
|
1600
1640
|
[--denominator-bytes <n>] [--cwd <dir>]
|
|
1601
1641
|
node dispatch.mjs open --contract <dispatch-file> --wave <id> --backend <name>
|
|
1602
1642
|
--rationale <text> --wrapper-cap-s <n> --kill-grace-s <n>
|
|
1603
|
-
[--checkpoint <oid>] [--retry-of <nonce>] [--cwd <dir>]
|
|
1643
|
+
[--checkpoint <oid>] [--task <brief>] [--retry-of <nonce>] [--cwd <dir>]
|
|
1604
1644
|
node dispatch.mjs await --nonce <n> [--timeout <s>] [--cwd <dir>]
|
|
1605
1645
|
node dispatch.mjs return --nonce <n> [--outcome <o>]
|
|
1606
1646
|
[--no-receipt --exit-status <n>] [--cwd <dir>]
|
|
@@ -1652,6 +1692,7 @@ refusal from the store verbatim.
|
|
|
1652
1692
|
--checkpoint <oid> records a resolvable TREE object of exactly 40 or 64 lowercase hex characters
|
|
1653
1693
|
(a commit, blob, tag or unknown id refuses by name); the base rides the record and return/fold read
|
|
1654
1694
|
it there, while a checkpoint CLEAN line names the base.
|
|
1695
|
+
--task records the brief's path and its byte-ordered Files, requires --checkpoint (task-base), refuses a brief the parser refuses by the parser's name, refuses task-files-scope for a Files path the snapshot domain can never hold, and the store refuses files-overlap and mixed-open at append time; a task thread is measured over its Files.
|
|
1655
1696
|
|
|
1656
1697
|
THE LEDGER a tree-binding verb uses is the CANONICAL one, exactly: <git common dir>/
|
|
1657
1698
|
agent-workflow-delegation.jsonl. A store inside the work tree is measured as part of the change set
|
|
@@ -1779,8 +1820,8 @@ diff: a mode change moves it and no untracked marker line exists. Under a head b
|
|
|
1779
1820
|
arrives as a deletion plus a creation, over-counting a plain rename; under a checkpoint base git's own
|
|
1780
1821
|
rename detection applies inside the one base-relative layer and a detected rename is counted once.
|
|
1781
1822
|
A receipt is forgeable exactly like every record here;
|
|
1782
|
-
and D10 stands as a BAR, not a mechanism — at most one in-tree exec dispatch at a time,
|
|
1783
|
-
refuses a second.
|
|
1823
|
+
and D10 stands as a BAR, not a mechanism, for untasked threads — at most one untasked in-tree exec dispatch at a time,
|
|
1824
|
+
and nothing refuses a second; for task threads it is a mechanism: files-overlap and mixed-open refuse conflicting opens at append time, and return and fold refuse out-of-files.
|
|
1784
1825
|
|
|
1785
1826
|
Never commits, never runs a subscription CLI, spawns nothing but git READS — with TWO stated exceptions: handoff-return,
|
|
1786
1827
|
which attests MAIN's index with git write-tree: that may write a tree OBJECT into the odb and moves
|
package/tools/exec-producer.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// the DENOMINATOR) while HEAD→worktree shows nothing at all, so the object would vanish from the
|
|
15
15
|
// NUMERATOR and the two halves of one ratio would describe different change sets. The diff bytes are
|
|
16
16
|
// computeFingerprintPayload's own bytes, imported rather than rebuilt. Under a checkpoint base the
|
|
17
|
-
// walk is one layer against the base tree, through the leaf's temporary index.
|
|
17
|
+
// walk is one layer against the base tree, through the leaf's temporary index; io.paths absent or null = the whole domain.
|
|
18
18
|
//
|
|
19
19
|
// ONE OBJECT, ONE ENTRY. Under a head base an object touched in both layers is a single entry, and its pre-image is
|
|
20
20
|
// always the HEAD blob — never the index blob: the numerator answers what the delegate could have
|
|
@@ -359,11 +359,11 @@ const entryForUntracked = (top, rel, lstat) => {
|
|
|
359
359
|
};
|
|
360
360
|
|
|
361
361
|
const enumerateCheckpointObjects = (top, base, io) => {
|
|
362
|
-
const raw = runBaseDiff(top, base, BASE_RAW_ARGS, io);
|
|
362
|
+
const raw = runBaseDiff(top, base, BASE_RAW_ARGS, io, io.paths ?? null);
|
|
363
363
|
if (raw === null) return refuse('git could not read the base-relative change set (fail closed)');
|
|
364
364
|
const parsed = parseRawRecords(raw, 'the base-relative change set');
|
|
365
365
|
if (!parsed.ok) return parsed;
|
|
366
|
-
const numstat = runBaseDiff(top, base, BASE_NUMSTAT_ARGS, io);
|
|
366
|
+
const numstat = runBaseDiff(top, base, BASE_NUMSTAT_ARGS, io, io.paths ?? null);
|
|
367
367
|
if (numstat === null) return refuse('git could not read the numstat binary markers (fail closed)');
|
|
368
368
|
const binary = parseNumstatMarkers(numstat, 'the base-relative binary markers');
|
|
369
369
|
if (!binary.ok) return binary;
|
|
@@ -482,7 +482,7 @@ export const computeReturnedDiff = (cwd = process.cwd(), io = {}) => {
|
|
|
482
482
|
// caller that framed a bundle around a thrown read would have no bytes and no reason either.
|
|
483
483
|
try {
|
|
484
484
|
const base = io.base ?? HEAD_BASELINE;
|
|
485
|
-
const payload = base.kind === 'checkpoint' ? computeBasePayload(cwd, base, io) : computeFingerprintPayload(cwd);
|
|
485
|
+
const payload = base.kind === 'checkpoint' ? computeBasePayload(cwd, base, io, io.paths ?? null) : computeFingerprintPayload(cwd);
|
|
486
486
|
return payload == null
|
|
487
487
|
? refuse('the canonical payload could not be computed (fail closed)')
|
|
488
488
|
: { ok: true, diff: payload };
|
package/tools/held-session.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mj
|
|
|
2
2
|
import { wrapperCmdFor } from './detect-backends.mjs';
|
|
3
3
|
import { DISPLAY_ALIASES } from './recipes.mjs';
|
|
4
4
|
import { readsBaseline, TERMINAL_RETURN_OUTCOMES } from './dispatch-record.mjs';
|
|
5
|
+
import { readsInsideEpoch, readsTask } from './task-thread.mjs';
|
|
5
6
|
|
|
6
7
|
const CODE_STEP = 'code';
|
|
7
8
|
const FOLD_KIND = 'fold';
|
|
@@ -13,12 +14,6 @@ const UNPOPULATED_HELD_ID = '<held id>';
|
|
|
13
14
|
export const HELD_RECEIPT_BACKEND = DISPLAY_ALIASES[EXECUTE_BACKEND];
|
|
14
15
|
export const HELD_EXECUTE_WRAPPER = wrapperCmdFor(EXECUTE_BACKEND, 'execute');
|
|
15
16
|
|
|
16
|
-
const readsInsideEpoch = (dispatch, head) => {
|
|
17
|
-
if (head.state === 'unborn') return true;
|
|
18
|
-
const instant = Date.parse(dispatch.timestamp);
|
|
19
|
-
return Number.isFinite(instant) && Math.floor(instant / 1000) > head.seconds;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
17
|
const updatesThread = (threads, nonce, update) =>
|
|
23
18
|
threads.map((thread) => (thread.nonce === nonce ? { ...thread, ...update } : thread));
|
|
24
19
|
|
|
@@ -101,15 +96,7 @@ const countsHeldFolds = (records, epochNonces, heldId) => {
|
|
|
101
96
|
}).length;
|
|
102
97
|
};
|
|
103
98
|
|
|
104
|
-
|
|
105
|
-
if (head.state === 'error') {
|
|
106
|
-
return { state: 'error', cause: 'head', reason: head.reason, heldId: null, folds: 0, substitution: null, threads: [], open: [] };
|
|
107
|
-
}
|
|
108
|
-
const dispatches = records.filter((record) =>
|
|
109
|
-
record.kind === 'dispatch'
|
|
110
|
-
&& record.stepClass === CODE_STEP
|
|
111
|
-
&& record.backend === backend
|
|
112
|
-
&& readsInsideEpoch(record, head));
|
|
99
|
+
const judgeChain = (records, dispatches, degrades) => {
|
|
113
100
|
const epochNonces = new Set(dispatches.map((dispatch) => dispatch.nonce));
|
|
114
101
|
const initial = { epochNonces, dispatches: [], returns: [], heldId: null, substitutions: [], threads: [], open: [] };
|
|
115
102
|
const accumulated = records.reduce(accumulatesFacts(degrades), initial);
|
|
@@ -125,6 +112,44 @@ export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
|
|
|
125
112
|
};
|
|
126
113
|
};
|
|
127
114
|
|
|
115
|
+
export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
|
|
116
|
+
if (head.state === 'error') {
|
|
117
|
+
return { state: 'error', cause: 'head', reason: head.reason, heldId: null, folds: 0, substitution: null, threads: [], open: [] };
|
|
118
|
+
}
|
|
119
|
+
const dispatches = records.filter((record) =>
|
|
120
|
+
record.kind === 'dispatch'
|
|
121
|
+
&& record.stepClass === CODE_STEP
|
|
122
|
+
&& record.backend === backend
|
|
123
|
+
&& readsInsideEpoch(record, head));
|
|
124
|
+
if (!dispatches.some((dispatch) => readsTask(dispatch) !== null)) return judgeChain(records, dispatches, degrades);
|
|
125
|
+
const groups = new Map();
|
|
126
|
+
for (const dispatch of dispatches) {
|
|
127
|
+
const key = readsTask(dispatch)?.brief ?? null;
|
|
128
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
129
|
+
groups.get(key).push(dispatch);
|
|
130
|
+
}
|
|
131
|
+
const reports = new Map([...groups].map(([key, entries]) => {
|
|
132
|
+
const nonces = new Set(entries.map((dispatch) => dispatch.nonce));
|
|
133
|
+
return [key, judgeChain(records.filter((record) => nonces.has(record.nonce)), entries, degrades)];
|
|
134
|
+
}));
|
|
135
|
+
const untasked = reports.get(null);
|
|
136
|
+
const threads = new Map([...reports].flatMap(([chainKey, report]) =>
|
|
137
|
+
report.threads.map((thread) => [thread.nonce, { ...thread, chainKey }])));
|
|
138
|
+
const substitutions = new Map([...reports.values()]
|
|
139
|
+
.filter((report) => report.substitution !== null)
|
|
140
|
+
.map((report) => [report.substitution.nonce, report.substitution]));
|
|
141
|
+
const firstReturn = records.find((record) => record.kind === RETURN_KIND && substitutions.has(record.nonce));
|
|
142
|
+
return {
|
|
143
|
+
state: 'ok',
|
|
144
|
+
heldId: untasked?.heldId ?? null,
|
|
145
|
+
folds: untasked?.folds ?? 0,
|
|
146
|
+
substitution: firstReturn === undefined ? null : substitutions.get(firstReturn.nonce),
|
|
147
|
+
threads: dispatches.map((dispatch) => threads.get(dispatch.nonce)),
|
|
148
|
+
open: untasked?.open ?? [],
|
|
149
|
+
chains: [...reports].map(([key, { heldId, folds, open }]) => ({ key, heldId, folds, open })),
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
128
153
|
export const threadVerdict = (facts, nonce) => facts.threads.find((thread) => thread.nonce === nonce) ?? null;
|
|
129
154
|
|
|
130
155
|
export const judgeLedger = (ledger, { backend, degrades = [] }) => {
|
|
@@ -150,6 +175,11 @@ export const decideHeldSession = (facts) => {
|
|
|
150
175
|
const reason = `delegated code thread "${nonce}" substituted held session "${escapeForDisplay(expectedId)}" with "${escapeForDisplay(actualId)}"`;
|
|
151
176
|
return { code: 1, reason, line: `held session: SUBSTITUTED — ${reason}` };
|
|
152
177
|
}
|
|
178
|
+
if (facts.chains !== undefined) {
|
|
179
|
+
const entries = facts.chains.map(({ key, heldId, folds }) =>
|
|
180
|
+
`${key === null ? 'untasked' : escapeForDisplay(key)}: ${heldId === null ? 'none' : escapeForDisplay(heldId)} (${folds})`);
|
|
181
|
+
return { code: 0, reason: 'task chains have no unresolved substitution', line: `held sessions: ${facts.chains.length} chain(s) — ${entries.join(' · ')}` };
|
|
182
|
+
}
|
|
153
183
|
if (facts.heldId === null) return { code: 0, reason: 'no held session stands: no folded code thread established one in this commit epoch, or the last one was withdrawn by a checkpoint thread\'s ledger degrade', line: 'held session: none' };
|
|
154
184
|
const heldId = escapeForDisplay(facts.heldId);
|
|
155
185
|
return { code: 0, reason: `held session "${heldId}" is continuous`, line: `held session: ${heldId} — ${facts.folds} fold(s) rode it` };
|
|
@@ -164,15 +194,24 @@ const describesLaneCaveat = (facts) => {
|
|
|
164
194
|
return null;
|
|
165
195
|
};
|
|
166
196
|
|
|
197
|
+
const chainLaneLines = (facts, chain) => {
|
|
198
|
+
const substitution = facts.threads.find((thread) => thread.chainKey === chain.key && thread.substituted) ?? null;
|
|
199
|
+
const caveat = describesLaneCaveat({ state: facts.state, heldId: chain.heldId, substitution });
|
|
200
|
+
const task = chain.key === null ? '' : ` (task ${escapeForDisplay(chain.key)})`;
|
|
201
|
+
if (caveat !== null) return [` caveat: ${caveat}${task}`];
|
|
202
|
+
return [` run: ${HELD_EXECUTE_WRAPPER} --resume ${shellQuoteArg(chain.heldId)} --nonce <nonce> <fold-brief>${task}`];
|
|
203
|
+
};
|
|
204
|
+
|
|
167
205
|
export const foldLaneLines = (facts) => {
|
|
168
|
-
const caveat = describesLaneCaveat(facts);
|
|
169
|
-
const heldId = caveat === null ? shellQuoteArg(facts.heldId) : UNPOPULATED_HELD_ID;
|
|
170
206
|
const lines = [
|
|
171
207
|
'Fold lane (execute = delegated) — a fold rides the delegate\'s HELD session:',
|
|
172
|
-
` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`,
|
|
173
208
|
' the fold brief is a dispatch file carrying the finding and the accepted fold; dispatch open precedes the run, then dispatch return and fold follow it',
|
|
174
209
|
' the orchestrator runs the suites, verifies the returned diff, re-mints the red-proofs and owns the commit',
|
|
175
210
|
' a fresh session is a forbidden substitution; a retry of a failed thread or, for a head base, a recorded execute degrade is the exception (a checkpoint thread\'s substitution closes by its ledger degrade); the wrapper sidecar is never read',
|
|
176
211
|
];
|
|
212
|
+
if (facts.chains !== undefined) return [...lines, ...facts.chains.flatMap((chain) => chainLaneLines(facts, chain))];
|
|
213
|
+
const caveat = describesLaneCaveat(facts);
|
|
214
|
+
const heldId = caveat === null ? shellQuoteArg(facts.heldId) : UNPOPULATED_HELD_ID;
|
|
215
|
+
lines.splice(1, 0, ` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`);
|
|
177
216
|
return caveat === null ? lines : [...lines, ` caveat: ${caveat}`];
|
|
178
217
|
};
|
package/tools/task-brief.mjs
CHANGED
|
@@ -6,7 +6,10 @@ import { readFileBytesNoFollow } from './fs-read-nofollow.mjs';
|
|
|
6
6
|
import { parseLedger, isSweep, PLAN_HEADINGS } from './plan-shape.mjs';
|
|
7
7
|
import { expandSweepPaths } from './plan-shape-facts.mjs';
|
|
8
8
|
import { readStoryLine, PIN_FILE } from './plan-shape-ownership.mjs';
|
|
9
|
-
import { parseDispatchContract } from './dispatch-record.mjs';
|
|
9
|
+
import { parseDispatchContract, isThreadTerminalRecord } from './dispatch-record.mjs';
|
|
10
|
+
import { runBaseDiff } from './dispatch-baseline.mjs';
|
|
11
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
12
|
+
import { claimedPaths } from './task-thread.mjs';
|
|
10
13
|
import { newestCheckpoint, verifyCheckpoint } from './checkpoint.mjs';
|
|
11
14
|
import { withRepository, readGit } from './checkpoint-core.mjs';
|
|
12
15
|
import { isDirectRun } from './direct-run.mjs';
|
|
@@ -19,6 +22,7 @@ const START = 0;
|
|
|
19
22
|
const ONE = 1;
|
|
20
23
|
const EMPTY = '';
|
|
21
24
|
const NL = '\n';
|
|
25
|
+
const NUL = '\0';
|
|
22
26
|
const CR_END = /\r$/;
|
|
23
27
|
const UTF8 = 'utf8';
|
|
24
28
|
const STRING = 'string';
|
|
@@ -49,6 +53,9 @@ const GROUP_SEPARATOR = ', ';
|
|
|
49
53
|
const PARENT = '..';
|
|
50
54
|
const CURRENT = '.';
|
|
51
55
|
const READ_OK = 'ok';
|
|
56
|
+
const READ_ERROR = 'error';
|
|
57
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
58
|
+
const CHANGED_PATH_ARGS = ['--name-only', '--no-renames', '-z'];
|
|
52
59
|
const SCHEMA = 1;
|
|
53
60
|
const BINDING_KEYS = ['schema', 'plan', 'reads', 'checkpoint', 'head'];
|
|
54
61
|
const FILE_KEYS = ['path', 'sha256'];
|
|
@@ -243,7 +250,18 @@ export const checkBrief = (cwd, briefPath, { dispatchPath } = {}, io = {}) => wi
|
|
|
243
250
|
entry.path !== binding.reads[index].path || entry.sha256 !== binding.reads[index].sha256)) reject(NAMES.read);
|
|
244
251
|
if (binding.checkpoint !== newest.oid) reject(NAMES.checkpoint);
|
|
245
252
|
const verified = verifyCheckpoint(cwd, binding.checkpoint, context.io);
|
|
246
|
-
if (!verified.ok
|
|
253
|
+
if (!verified.ok) reject(NAMES.stale, verified.reason);
|
|
254
|
+
if (!verified.clean) {
|
|
255
|
+
const changed = runBaseDiff(context.top, { kind: CHECKPOINT_KIND, treeOid: binding.checkpoint }, CHANGED_PATH_ARGS, context.io);
|
|
256
|
+
if (changed === null) reject(NAMES.stale, verified.reason);
|
|
257
|
+
const paths = changed.toString(UTF8).split(NUL).filter(Boolean);
|
|
258
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
259
|
+
if (ledger.state === READ_ERROR) reject(NAMES.stale, ledger.reason);
|
|
260
|
+
const claimed = new Set(ledger.state === READ_OK
|
|
261
|
+
? claimedPaths(ledger.records, binding.checkpoint, ledger.head, isThreadTerminalRecord) : []);
|
|
262
|
+
const ownFiles = new Set(brief.files.map(({ path }) => path));
|
|
263
|
+
if (!paths.every((path) => claimed.has(path) && !ownFiles.has(path))) reject(NAMES.stale, verified.reason);
|
|
264
|
+
}
|
|
247
265
|
if (binding.head !== readHead(context, NAMES.boundHead)) reject(NAMES.boundHead);
|
|
248
266
|
if (dispatchPath !== undefined) checkDispatch(context, dispatchPath, briefPath, file.sha256);
|
|
249
267
|
return { ok: true, digest: file.sha256 };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { hasPathOverlap } from './claim-relation.mjs';
|
|
2
|
+
|
|
3
|
+
const TASK_BRIEF = /^docs\/plans\/TASK-[^/]+-T[1-9][0-9]*\.md$/u;
|
|
4
|
+
const INVALID_SEGMENTS = Object.freeze(['', '.', '..', '.git']);
|
|
5
|
+
const HEAD_BASELINE = Object.freeze({ kind: 'head', treeOid: null });
|
|
6
|
+
|
|
7
|
+
export const byteOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
8
|
+
|
|
9
|
+
export const readsTask = (dispatch) => Object.hasOwn(dispatch, 'task')
|
|
10
|
+
? { brief: dispatch.task.brief, files: [...dispatch.task.files] }
|
|
11
|
+
: null;
|
|
12
|
+
|
|
13
|
+
export const validateTask = (task, baseKind) => {
|
|
14
|
+
if (typeof task.brief !== 'string' || !TASK_BRIEF.test(task.brief)) {
|
|
15
|
+
return { ok: false, reason: 'task-brief: expected docs/plans/TASK-<stem>-T<n>.md with a positive task number' };
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(task.files) || task.files.length === 0) {
|
|
18
|
+
return { ok: false, reason: 'task-files: expected a non-empty array of paths' };
|
|
19
|
+
}
|
|
20
|
+
for (let index = 0; index < task.files.length; index += 1) {
|
|
21
|
+
const path = task.files[index];
|
|
22
|
+
if (typeof path !== 'string' || path.split('/').some((segment) => INVALID_SEGMENTS.includes(segment))) {
|
|
23
|
+
return { ok: false, reason: 'task-files: expected normalized repo-relative paths without empty, dot or .git segments' };
|
|
24
|
+
}
|
|
25
|
+
if (index > 0 && byteOrder(task.files[index - 1], path) >= 0) {
|
|
26
|
+
return { ok: false, reason: 'task-files: paths must be distinct and in byte order' };
|
|
27
|
+
}
|
|
28
|
+
if (task.files.slice(0, index).some((earlier) => hasPathOverlap(earlier, path))) {
|
|
29
|
+
return { ok: false, reason: 'task-files: one path is a prefix of another' };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (baseKind === 'head') {
|
|
33
|
+
return { ok: false, reason: 'task-base: a task requires a checkpoint baseline' };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const readsInsideEpoch = (dispatch, head) => {
|
|
39
|
+
if (head.state === 'unborn') return true;
|
|
40
|
+
const instant = Date.parse(dispatch.timestamp);
|
|
41
|
+
return Number.isFinite(instant) && Math.floor(instant / 1000) > head.seconds;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const collectClaims = (records, head, isTerminal) => {
|
|
45
|
+
const claims = [];
|
|
46
|
+
records.forEach((dispatch, index) => {
|
|
47
|
+
if (dispatch.kind !== 'dispatch' || !readsInsideEpoch(dispatch, head)) return;
|
|
48
|
+
const task = readsTask(dispatch);
|
|
49
|
+
if (task === null) return;
|
|
50
|
+
const later = records.slice(index + 1).filter((record) => record.nonce === dispatch.nonce);
|
|
51
|
+
const folded = later.some((record) => record.kind === 'fold');
|
|
52
|
+
if (!folded && later.some(isTerminal)) return;
|
|
53
|
+
// Keep the baseline reader local to preserve the acyclic ledger boundary.
|
|
54
|
+
const baseline = Object.hasOwn(dispatch, 'baseline') ? dispatch.baseline : HEAD_BASELINE;
|
|
55
|
+
claims.push({
|
|
56
|
+
nonce: dispatch.nonce, brief: task.brief, files: task.files,
|
|
57
|
+
state: folded ? 'folded' : 'open', treeOid: baseline.treeOid,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
return claims;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const claimedPaths = (records, treeOid, head, isTerminal) => [...new Set(
|
|
64
|
+
collectClaims(records, head, isTerminal)
|
|
65
|
+
.filter((claim) => claim.treeOid === treeOid)
|
|
66
|
+
.flatMap((claim) => claim.files),
|
|
67
|
+
)].sort(byteOrder);
|
|
68
|
+
|
|
69
|
+
export const epochClaims = (records, head, isTerminal) => collectClaims(records, head, isTerminal)
|
|
70
|
+
.map(({ nonce, brief, files, state }) => ({ nonce, brief, files, state }));
|