@sabaiway/agent-workflow-kit 13.1.0 → 13.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 +87 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-state.md +1 -1
- package/tools/checkpoint-core.mjs +118 -0
- package/tools/checkpoint-restore.mjs +126 -0
- package/tools/checkpoint.mjs +146 -0
- package/tools/plan-files.mjs +2 -1
- package/tools/review-state.mjs +1 -1
- package/tools/task-brief.mjs +270 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,93 @@ 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
|
+
## 13.2.0 — the task tier lands: `checkpoint.mjs` mints, orders, verifies and prunes tree snapshots of a plan's sequence, `checkpoint-restore.mjs` restores one in three proven steps, `task-brief.mjs` stamps and checks a brief against the newest checkpoint, and `TASK-` is a scratch name (AD-141)
|
|
8
|
+
|
|
9
|
+
**MINOR: three new tools and one added arm of the scratch-name predicate; no existing verb, record, transition or
|
|
10
|
+
metric moves.** Since 13.1.0 the delegation ledger could measure a thread against a `checkpoint` base — a tree the
|
|
11
|
+
orchestrator had to compute by hand. Now the kit mints it, keeps it in a sequence, restores to it and binds the
|
|
12
|
+
task's brief to it. The contract is `docs/ai/specs/kit/checkpoint/index.md` with its parts `restore.md` and
|
|
13
|
+
`task-brief.md` (live, revision 2, S1–S10 bound).
|
|
14
|
+
|
|
15
|
+
- **`tools/checkpoint.mjs` (146 lines).** `mint --plan docs/plans/<stem>.md` writes ONE tree over the snapshot
|
|
16
|
+
scope of 13.1.0 (`snapshotScope`, the newest checkpoint as the base tree) through its own temporary index — a
|
|
17
|
+
forced add of every present regular file or symlink, one `write-tree`, the index removed before every return —
|
|
18
|
+
so `docs/ai/` deliverables are in the snapshot and the brief under `docs/plans/` never is; an absent leaf and a
|
|
19
|
+
directory at a former file leaf are deletions; a present non-regular path refuses by name and writes nothing;
|
|
20
|
+
the real index, the work tree and every other ref are untouched. The plan must be a readable regular file that is
|
|
21
|
+
a DIRECT child of `docs/plans/`, not a scratch name, with a stem `git check-ref-format` admits, else `stem`. The
|
|
22
|
+
ref of checkpoint `n` is `refs/agent-workflow/checkpoints/<stem>/<n>` and points at the TREE (reachable across
|
|
23
|
+
`gc`, never a branch, a fetch or a push); numbers run contiguously from 0; a mint whose tree equals the newest
|
|
24
|
+
ref's writes nothing and prints the newest's line; the line is `checkpoint <stem>/<n> <oid>`. `newest --plan`
|
|
25
|
+
prints the greatest or refuses `no-checkpoint`; `verify <oid>` recomputes with the target as base and prints
|
|
26
|
+
CLEAN (exit 0) or DIRTY (exit 1) with both oids, a lost snapshot path counted as dirt; `prune --plan` deletes the
|
|
27
|
+
plan's refs (an empty sequence exits 0) and refuses `open-thread`, deleting nothing, while any delegation-ledger
|
|
28
|
+
thread whose dispatch base is one of those trees is OPEN — judged by base oid alone, so another sequence's thread
|
|
29
|
+
holds the ref exactly when the two share the tree, and an unfolded success return still holds it; a malformed
|
|
30
|
+
ledger refuses. A non-tree id refuses through 13.1.0's `resolveTreeObject` wording. Exactly three exit codes:
|
|
31
|
+
0 accept or CLEAN, 1 refuse or DIRTY, 2 usage; `main(argv, deps)` returns and never exits; git runs through the
|
|
32
|
+
same guarded runner discipline as the 13.1.0 leaf (a thrown runner is a failed spawn).
|
|
33
|
+
- **`tools/checkpoint-core.mjs` (118 lines).** The git seam the two halves share — the guarded runner, the
|
|
34
|
+
repository resolution, the sequence-ref reader (a listing line that is not `<ref> <oid>` refuses `sequence`),
|
|
35
|
+
the temporary-index discipline, the scope lstat and the snapshot write — so `checkpoint.mjs` and
|
|
36
|
+
`checkpoint-restore.mjs` both import the leaf; `checkpoint.mjs` still imports the restore module, which no longer
|
|
37
|
+
imports `checkpoint.mjs`. The pair had imported each other;
|
|
38
|
+
the tools graph's acyclic pin (`read-graph-purity`) caught it at the release gate, after the council.
|
|
39
|
+
- **`tools/checkpoint-restore.mjs` (126 lines) and the `restore <oid>` verb.** Whole-tree over the snapshot scope,
|
|
40
|
+
ORDERED, idempotent and proven. Refused BEFORE the first write on exactly five states: a non-tree target;
|
|
41
|
+
`target-scope` — a target leaf outside the domain (an excluded path such as `docs/plans/x`), under a `.git`
|
|
42
|
+
segment, with a gitlink mode, or a tree no sequence ref carries; `ledger` — an unreadable or malformed ledger
|
|
43
|
+
(an absent one holds no thread); `open-thread` — an OPEN thread whose base oid is in the HELD SET, the target
|
|
44
|
+
plus every tree of every sequence carrying it (a disjoint sequence and a head base proceed); `type-conflict` — a
|
|
45
|
+
directory (empty or not) where the target holds a blob or a symlink, or a file or symlink at a parent segment
|
|
46
|
+
where the target needs a directory. Then MATERIALIZE (`read-tree` + `checkout-index -a -f` under a temporary
|
|
47
|
+
index: a blob lands as a file, a symlink entry as a symlink), EXTRAS (every domain path the target does not hold
|
|
48
|
+
is unlinked at its own lstat-ed leaf, never through a link; a parent emptied by that deletion is removed, never
|
|
49
|
+
the repository top, never a directory that still holds anything), PROOF (the scope tree recomputed as `verify`
|
|
50
|
+
computes it; equal → `checkpoint restore <oid> proof <oid>`, exit 0; unequal → exit 1 naming both oids, never a
|
|
51
|
+
restore claimed). Each step is a pure function of the target and the tree, so a re-run after an interruption at
|
|
52
|
+
any step converges; a restored ignore file that UNCOVERS a path the first domain did not hold surfaces as a
|
|
53
|
+
proof mismatch; re-running the restore converges. The real index is never a domain nor written (a staged change survives; the work tree under it is the
|
|
54
|
+
snapshot's); `docs/plans/`, the three settings files, the footprint and `node_modules` are never read, written or
|
|
55
|
+
deleted; earlier checkpoints stay; a restore to an older checkpoint is legal and undoes every task after it.
|
|
56
|
+
- **`tools/task-brief.mjs` (270 lines).** A brief is `docs/plans/TASK-<stem>-T<n>.md`, its execute attempt's
|
|
57
|
+
dispatch file `TASK-<stem>-T<n>-a<attempt>.md` beside it — both scratch, both outside the scope. The grammar is
|
|
58
|
+
closed: `# Task: <name>`, the story line byte-equal to the plan's, `## Slice` (`Plan:`, `Row:`, `Grouping:`,
|
|
59
|
+
`Files:` of `- <path> :: <tag>`), `## Reads`, `## Acceptance` (`- <command> :: <expected>`), `## Negative cases`,
|
|
60
|
+
`## Budget` (`- <path> :: <max lines>`, one per file), at most one `aw-task-binding` block. Exactly seven states
|
|
61
|
+
refuse `shape`; a tag outside test/impl/pin, a `Grouping:` not equal to the present tags in that order, or the
|
|
62
|
+
pin rule broken (exactly one pin line naming the package-content path on that row, none elsewhere) refuse
|
|
63
|
+
`grouping`; a `Row:` naming no row, an impl path that is not the row's path or a sweep expansion (the anchor is
|
|
64
|
+
never a ground), or a test path not co-located refuse `row`; the story line absent or unequal refuses `story`.
|
|
65
|
+
`stamp <brief>` judges the grammar, then writes the ONE block `{ schema: 1, plan: { path, sha256 }, reads: [{
|
|
66
|
+
path, sha256 }], checkpoint: <newest tree oid>, head: <commit oid> }` over zero or one existing block and prints
|
|
67
|
+
`task-brief digest sha256:<hex>` of the whole stamped file; a plan or read that is not a readable regular file
|
|
68
|
+
inside the repository refuses `reads` (the descriptor-bound no-follow door), an empty sequence `no-checkpoint`,
|
|
69
|
+
an unresolvable HEAD `head`. `check <brief> [--dispatch <file>]` recomputes everything and refuses each stale
|
|
70
|
+
state by one name: `binding`, `no-checkpoint`, `reads`, `binding-plan`, `binding-read`, `binding-checkpoint`,
|
|
71
|
+
`checkpoint-stale` (the tree DIRTY against the bound checkpoint — mint again, stamp again), `binding-head`, and
|
|
72
|
+
with `--dispatch` `dispatch-inputs` when the attempt's `aw-dispatch-contract` header does not carry the brief
|
|
73
|
+
path and the current digest literal. Checkpoint facts are read through `checkpoint.mjs`'s own exports.
|
|
74
|
+
- **`tools/plan-files.mjs`.** `TASK-` joins `isScratchPlanName`, so a brief or a dispatch file is never an
|
|
75
|
+
in-flight plan and neither `plan-shape --in-flight` nor `review-state` judges it; the review-state mode document
|
|
76
|
+
and header name the prefix.
|
|
77
|
+
- **Tarball 305 → 309.** The four modules are pinned by name. Suites: `tools/checkpoint.test/mint.test.mjs`
|
|
78
|
+
(50), `tools/checkpoint.test/restore.test.mjs` (42), `tools/task-brief.test.mjs` (69), `tools/plan-files.test.mjs`
|
|
79
|
+
(2): every case of the first session written before the code existed and red, the three cases of the diff
|
|
80
|
+
review's fold red on the pre-fold code, then green; the release gate's coverage checker named the uncovered
|
|
81
|
+
sites and five cases closed them (each CLI run as a process, the malformed listing for both verbs, the
|
|
82
|
+
non-numeric suffix) — 311 of 311 across the seven files the story touches.
|
|
83
|
+
- **Stated limits.** A present non-regular scope path refuses the restore at the proof step, after the writes; the
|
|
84
|
+
extras of a restore are judged under the ignore rules the tree carries BEFORE step (1), so a restored ignore file
|
|
85
|
+
that RE-COVERS a path (the task had un-ignored a build directory the snapshot never held) deletes that path as an
|
|
86
|
+
extra with a matching proof — a task that edits an ignore file is restored under the rules it left behind; a
|
|
87
|
+
retry of a terminal failure opens on the same base BEFORE Cleanup's prune, because after the prune no sequence
|
|
88
|
+
ref carries the base and the restore the retry needs refuses `target-scope` (the tree object itself is not
|
|
89
|
+
deleted, only unreachable by name); the precondition of a restore is no concurrent in-scope writer — a parallel session's `docs/ai/` write
|
|
90
|
+
during the task is restored away with a matching proof, so holding it is the orchestrator's; the process-group
|
|
91
|
+
stop of a running wrapper stays the bridge's contract, the kit's door is the OPEN-thread refusal; no parallel
|
|
92
|
+
tasks over one plan and no satellite worktrees; the `task` and `epic` rows of the activity table are story S7's.
|
|
93
|
+
|
|
7
94
|
## 13.1.0 — the delegation ledger learns a base: a dispatch records `head` or `checkpoint`, every measurement of a checkpoint thread is base-relative over the snapshot scope, and a checkpoint thread holds the delegate's session (AD-140)
|
|
8
95
|
|
|
9
96
|
**MINOR: one optional key on the dispatch record, one new flag on `open`, one new IO leaf; every record minted
|
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: '13.
|
|
6
|
+
version: '13.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": "13.
|
|
3
|
+
"version": "13.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",
|
|
@@ -7,7 +7,7 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
|
|
8
8
|
|
|
9
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.
|
|
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-`-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.
|
|
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`.
|
|
13
13
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/index.md
|
|
2
|
+
import { lstatSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { snapshotScope } from './dispatch-baseline.mjs';
|
|
7
|
+
import { isNeverCommittableStat } from './core-evidence.mjs';
|
|
8
|
+
import { isThreadTerminalRecord } from './dispatch-record.mjs';
|
|
9
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, withGitPath } from './git-env.mjs';
|
|
10
|
+
|
|
11
|
+
export const CHECKPOINT_REF_PREFIX = 'refs/agent-workflow/checkpoints/';
|
|
12
|
+
const ACCEPT = 0;
|
|
13
|
+
const REFUSE = 1;
|
|
14
|
+
const INCREMENT = 1;
|
|
15
|
+
const EMPTY = '';
|
|
16
|
+
const SPACE = ' ';
|
|
17
|
+
const NEWLINE = '\n';
|
|
18
|
+
const UTF8 = 'utf8';
|
|
19
|
+
const TEMP_PREFIX = 'aw-checkpoint-';
|
|
20
|
+
const INDEX_NAME = 'index';
|
|
21
|
+
const LITERAL_PATHSPECS = '1';
|
|
22
|
+
const WORK_TREE = 'work-tree';
|
|
23
|
+
const MISSING_PATH_CODES = ['ENOENT', 'ENOTDIR'];
|
|
24
|
+
const OID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
25
|
+
const NAMES = { scope: 'scope', sequence: 'sequence' };
|
|
26
|
+
const DISPATCH_KIND = 'dispatch';
|
|
27
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
28
|
+
const LOCATION_ERROR = 'checkpoint requires a git work tree';
|
|
29
|
+
const SEQUENCE_ERROR = 'expected contiguous numbered refs on trees';
|
|
30
|
+
const STAT_ERROR = 'cannot classify path';
|
|
31
|
+
const TYPE_ERROR = 'present path is not a regular file, symlink or directory';
|
|
32
|
+
const OID_ERROR = 'git did not return a tree oid';
|
|
33
|
+
const GIT = 'git';
|
|
34
|
+
const GIT_ARGS = {
|
|
35
|
+
refs: ['for-each-ref', '--format=%(refname) %(objectname)'],
|
|
36
|
+
empty: ['read-tree', '--empty'],
|
|
37
|
+
add: ['add', '-f', '--'],
|
|
38
|
+
tree: ['write-tree'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const refuse = (reason, code = REFUSE) => ({ ok: false, code, reason });
|
|
42
|
+
const defaultRunGit = (args, cwd, input, env) => spawnSync(GIT, args, {
|
|
43
|
+
cwd, input, env: withGitPath(env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
export const makeRunner = ({ runGit = defaultRunGit, env = process.env } = {}) => (args, cwd, input, override = env) => {
|
|
46
|
+
try { return runGit(args, cwd, input, override); }
|
|
47
|
+
catch (error) { return { error, status: null, stdout: Buffer.alloc(ACCEPT) }; }
|
|
48
|
+
};
|
|
49
|
+
export const readGitBytes = ({ run, top, env }, args, override = env) => {
|
|
50
|
+
const result = run(args, top, undefined, override);
|
|
51
|
+
if (result == null || result.error || result.signal || result.status !== ACCEPT || result.stdout == null) {
|
|
52
|
+
throw new Error(`${GIT} ${args.join(SPACE)} failed: ${result?.error?.message ?? String(result?.stderr ?? EMPTY).trim()}`);
|
|
53
|
+
}
|
|
54
|
+
return Buffer.from(result.stdout);
|
|
55
|
+
};
|
|
56
|
+
export const readGit = (context, args, override = context.env) => readGitBytes(context, args, override).toString(UTF8).trimEnd();
|
|
57
|
+
export const withRepository = (cwd, io, action) => {
|
|
58
|
+
try {
|
|
59
|
+
const env = io.env ?? process.env;
|
|
60
|
+
const run = makeRunner({ ...io, env });
|
|
61
|
+
const location = resolveGitLocation(cwd, { env,
|
|
62
|
+
spawn: (command, args, options) => run(args, options.cwd, undefined, options.env) });
|
|
63
|
+
if (location.state !== WORK_TREE) return refuse(location.cause ?? LOCATION_ERROR);
|
|
64
|
+
return action({ cwd, top: location.top, env, run, io: { ...io, env, runGit: run } });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return refuse(error.message);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
export const readCheckpointRefs = (context, prefix = CHECKPOINT_REF_PREFIX) => {
|
|
70
|
+
const lines = readGit(context, [...GIT_ARGS.refs, prefix]).split(NEWLINE).filter(Boolean);
|
|
71
|
+
return lines.map((line) => {
|
|
72
|
+
const [ref, oid, extra] = line.split(SPACE);
|
|
73
|
+
if (!ref.startsWith(prefix) || !OID_PATTERN.test(oid) || extra !== undefined) {
|
|
74
|
+
throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
75
|
+
}
|
|
76
|
+
return { ref, oid };
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
export const withTemporaryIndex = (env, action) => {
|
|
80
|
+
const directory = mkdtempSync(join(tmpdir(), TEMP_PREFIX));
|
|
81
|
+
try {
|
|
82
|
+
return action({ ...env, GIT_INDEX_FILE: join(directory, INDEX_NAME), GIT_LITERAL_PATHSPECS: LITERAL_PATHSPECS });
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(directory, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
export const readScopeStat = (context, path) => {
|
|
88
|
+
try {
|
|
89
|
+
const stat = (context.io.lstat ?? lstatSync)(join(context.top, path));
|
|
90
|
+
if (stat == null) throw new Error(STAT_ERROR);
|
|
91
|
+
return stat;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (MISSING_PATH_CODES.includes(error.code)) return null;
|
|
94
|
+
throw new Error(`${NAMES.scope}: ${path}: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
export const writeSnapshot = (context, baseTreeOid) => {
|
|
98
|
+
const scope = snapshotScope(context.top, { baseTreeOid }, context.io);
|
|
99
|
+
if (!scope.ok) return refuse(scope.reason);
|
|
100
|
+
const paths = scope.paths.filter((path) => {
|
|
101
|
+
const stat = readScopeStat(context, path);
|
|
102
|
+
if (stat === null) return false;
|
|
103
|
+
if (isNeverCommittableStat(stat) || !(stat.isFile() || stat.isSymbolicLink() || stat.isDirectory())) {
|
|
104
|
+
throw new Error(`${NAMES.scope}: ${path}: ${TYPE_ERROR}`);
|
|
105
|
+
}
|
|
106
|
+
return stat.isFile() || stat.isSymbolicLink();
|
|
107
|
+
});
|
|
108
|
+
return withTemporaryIndex(context.env, (env) => {
|
|
109
|
+
readGit(context, GIT_ARGS.empty, env);
|
|
110
|
+
for (const path of paths) readGit(context, [...GIT_ARGS.add, path], env);
|
|
111
|
+
const oid = readGit(context, GIT_ARGS.tree, env);
|
|
112
|
+
if (!OID_PATTERN.test(oid)) return refuse(`${NAMES.scope}: ${OID_ERROR}`);
|
|
113
|
+
return { ok: true, oid };
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
export const findOpenCheckpointThread = (records, held) => records.find((record, index) =>
|
|
117
|
+
record.kind === DISPATCH_KIND && record.baseline?.kind === CHECKPOINT_KIND && held.has(record.baseline.treeOid)
|
|
118
|
+
&& !records.slice(index + INCREMENT).some((successor) => successor.nonce === record.nonce && isThreadTerminalRecord(successor)));
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/restore.md
|
|
2
|
+
import { unlinkSync, rmdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { resolveTreeObject, snapshotScope } from './dispatch-baseline.mjs';
|
|
5
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
6
|
+
import { CHECKPOINT_REF_PREFIX, withRepository, readGit, readGitBytes, readCheckpointRefs,
|
|
7
|
+
withTemporaryIndex, readScopeStat, writeSnapshot, findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
8
|
+
|
|
9
|
+
const REFUSE = 1;
|
|
10
|
+
const START = 0;
|
|
11
|
+
const NEXT = 1;
|
|
12
|
+
const LAST = -1;
|
|
13
|
+
const SLASH = '/';
|
|
14
|
+
const NUL = '\0';
|
|
15
|
+
const UTF8 = 'utf8';
|
|
16
|
+
const ROOT = '.';
|
|
17
|
+
const FORBIDDEN_SEGMENTS = ['', '.', '..', '.git'];
|
|
18
|
+
const ENTRY_PATTERN = /^([0-9]{6}) [^ ]+ [0-9a-f]+\t([\s\S]+)$/;
|
|
19
|
+
const SEQUENCE_REF_PATTERN = /^[^/]+\/(?:0|[1-9][0-9]*)$/;
|
|
20
|
+
const ALLOWED_MODES = ['100644', '100755', '120000'];
|
|
21
|
+
const LEDGER_ABSENT = 'absent';
|
|
22
|
+
const LEDGER_OK = 'ok';
|
|
23
|
+
const NONEMPTY_CODES = ['ENOTEMPTY', 'EEXIST'];
|
|
24
|
+
const NAMES = { scope: 'target-scope', ledger: 'ledger', open: 'open-thread', conflict: 'type-conflict' };
|
|
25
|
+
const ENCODING_ERROR = 'target tree paths are not lossless UTF-8';
|
|
26
|
+
const ENTRY_ERROR = 'unreadable target tree entry';
|
|
27
|
+
const UNREFERENCED_ERROR = 'no checkpoint sequence carries target';
|
|
28
|
+
const MISMATCH = 'checkpoint restore mismatch';
|
|
29
|
+
const GIT_ARGS = {
|
|
30
|
+
resolve: ['rev-parse', '--verify'],
|
|
31
|
+
entries: ['ls-tree', '-r', '-z'],
|
|
32
|
+
read: ['read-tree'],
|
|
33
|
+
checkout: ['checkout-index', '-a', '-f'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const refuse = (reason) => ({ ok: false, code: REFUSE, reason });
|
|
37
|
+
const readTargetEntries = (context, target) => {
|
|
38
|
+
const bytes = readGitBytes(context, [...GIT_ARGS.entries, target]);
|
|
39
|
+
const text = bytes.toString(UTF8);
|
|
40
|
+
if (!Buffer.from(text, UTF8).equals(bytes)) throw new Error(`${NAMES.scope}: ${ENCODING_ERROR}`);
|
|
41
|
+
return text.split(NUL).filter(Boolean).map((entry) => {
|
|
42
|
+
const parsed = ENTRY_PATTERN.exec(entry);
|
|
43
|
+
if (parsed === null) throw new Error(`${NAMES.scope}: ${ENTRY_ERROR}`);
|
|
44
|
+
const [, mode, path] = parsed;
|
|
45
|
+
if (!ALLOWED_MODES.includes(mode) || path.split(SLASH).some((segment) => FORBIDDEN_SEGMENTS.includes(segment))) {
|
|
46
|
+
throw new Error(`${NAMES.scope}: ${path}`);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const readHeldSet = (context, target) => {
|
|
52
|
+
const refs = readCheckpointRefs(context).filter(({ ref }) => SEQUENCE_REF_PATTERN.test(ref.slice(CHECKPOINT_REF_PREFIX.length)));
|
|
53
|
+
const sequences = new Set(refs.filter(({ oid }) => oid === target).map(({ ref }) => dirname(ref)));
|
|
54
|
+
if (sequences.size === START) throw new Error(`${NAMES.scope}: ${UNREFERENCED_ERROR}: ${target}`);
|
|
55
|
+
return new Set([target, ...refs.filter(({ ref }) => sequences.has(dirname(ref))).map(({ oid }) => oid)]);
|
|
56
|
+
};
|
|
57
|
+
const checkLedger = (context, held) => {
|
|
58
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
59
|
+
if (ledger.state !== LEDGER_ABSENT && ledger.state !== LEDGER_OK) return refuse(`${NAMES.ledger}: ${ledger.reason}`);
|
|
60
|
+
const open = findOpenCheckpointThread(ledger.records ?? [], held);
|
|
61
|
+
return open === undefined ? { ok: true } : refuse(`${NAMES.open}: ${open.nonce}`);
|
|
62
|
+
};
|
|
63
|
+
const listParents = (path) => {
|
|
64
|
+
const parts = path.split(SLASH).slice(START, LAST);
|
|
65
|
+
return parts.map((part, index) => parts.slice(START, index + NEXT).join(SLASH));
|
|
66
|
+
};
|
|
67
|
+
const checkTypes = (context, paths) => {
|
|
68
|
+
for (const path of paths) {
|
|
69
|
+
for (const parent of listParents(path)) {
|
|
70
|
+
const stat = readScopeStat(context, parent);
|
|
71
|
+
if (stat === null) break;
|
|
72
|
+
if (!stat.isDirectory()) return refuse(`${NAMES.conflict}: ${parent}: ${path}`);
|
|
73
|
+
}
|
|
74
|
+
if (readScopeStat(context, path)?.isDirectory()) return refuse(`${NAMES.conflict}: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
};
|
|
78
|
+
const removeEmptyParents = (context, path) => {
|
|
79
|
+
const parent = dirname(path);
|
|
80
|
+
if (parent === ROOT) return;
|
|
81
|
+
const stat = readScopeStat(context, parent);
|
|
82
|
+
if (stat === null || !stat.isDirectory()) return;
|
|
83
|
+
try {
|
|
84
|
+
rmdirSync(join(context.top, parent));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!NONEMPTY_CODES.includes(error.code)) throw error;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
removeEmptyParents(context, parent);
|
|
90
|
+
};
|
|
91
|
+
const removeExtras = (context, domain, targets) => {
|
|
92
|
+
for (const path of domain) {
|
|
93
|
+
if (targets.has(path) || path.split(SLASH).some((segment) => FORBIDDEN_SEGMENTS.includes(segment))) continue;
|
|
94
|
+
if (!listParents(path).every((parent) => readScopeStat(context, parent)?.isDirectory())) continue;
|
|
95
|
+
const stat = readScopeStat(context, path);
|
|
96
|
+
if (stat === null || !(stat.isFile() || stat.isSymbolicLink())) continue;
|
|
97
|
+
unlinkSync(join(context.top, path));
|
|
98
|
+
removeEmptyParents(context, path);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const restoreCheckpoint = (cwd, oid, io = {}) => withRepository(cwd, io, (context) => {
|
|
103
|
+
const tree = resolveTreeObject(context.top, oid, context.io);
|
|
104
|
+
if (!tree.ok) return refuse(tree.reason);
|
|
105
|
+
const target = readGit(context, [...GIT_ARGS.resolve, oid]);
|
|
106
|
+
const entries = readTargetEntries(context, target);
|
|
107
|
+
const scope = snapshotScope(context.top, { baseTreeOid: target }, context.io);
|
|
108
|
+
if (!scope.ok) return refuse(scope.reason);
|
|
109
|
+
const domain = new Set(scope.paths);
|
|
110
|
+
const outside = entries.find((path) => !domain.has(path));
|
|
111
|
+
if (outside !== undefined) return refuse(`${NAMES.scope}: ${outside}`);
|
|
112
|
+
const held = readHeldSet(context, target);
|
|
113
|
+
const ledger = checkLedger(context, held);
|
|
114
|
+
if (!ledger.ok) return ledger;
|
|
115
|
+
const types = checkTypes(context, entries);
|
|
116
|
+
if (!types.ok) return types;
|
|
117
|
+
withTemporaryIndex(context.env, (env) => {
|
|
118
|
+
readGit(context, [...GIT_ARGS.read, target], env);
|
|
119
|
+
readGit(context, GIT_ARGS.checkout, env);
|
|
120
|
+
});
|
|
121
|
+
removeExtras(context, scope.paths, new Set(entries));
|
|
122
|
+
const snapshot = writeSnapshot(context, target);
|
|
123
|
+
if (!snapshot.ok) return snapshot;
|
|
124
|
+
return snapshot.oid === target ? { ok: true, target, proof: snapshot.oid }
|
|
125
|
+
: refuse(`${MISMATCH}: ${target} ${snapshot.oid}`);
|
|
126
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/index.md
|
|
2
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { resolveTreeObject } from './dispatch-baseline.mjs';
|
|
5
|
+
import { readDelegationLedger } from './dispatch-store-read.mjs';
|
|
6
|
+
import { PLANS_REL, isScratchPlanName } from './plan-files.mjs';
|
|
7
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
8
|
+
import { CHECKPOINT_REF_PREFIX, refuse, withRepository, readGit, readCheckpointRefs, writeSnapshot,
|
|
9
|
+
findOpenCheckpointThread } from './checkpoint-core.mjs';
|
|
10
|
+
import { restoreCheckpoint } from './checkpoint-restore.mjs';
|
|
11
|
+
|
|
12
|
+
export { CHECKPOINT_REF_PREFIX } from './checkpoint-core.mjs';
|
|
13
|
+
const ACCEPT = 0;
|
|
14
|
+
const REFUSE = 1;
|
|
15
|
+
const USAGE = 2;
|
|
16
|
+
const FIRST_NUMBER = 0;
|
|
17
|
+
const INCREMENT = 1;
|
|
18
|
+
const LAST = -1;
|
|
19
|
+
const EMPTY = '';
|
|
20
|
+
const NEWLINE = '\n';
|
|
21
|
+
const SLASH = '/';
|
|
22
|
+
const STRING = 'string';
|
|
23
|
+
const MARKDOWN = '.md';
|
|
24
|
+
const NUMBER_PATTERN = /^(0|[1-9][0-9]*)$/;
|
|
25
|
+
const VERBS = { mint: 'mint', newest: 'newest', verify: 'verify', prune: 'prune', restore: 'restore' };
|
|
26
|
+
const NAMES = { stem: 'stem', sequence: 'sequence', ledger: 'ledger', open: 'open-thread', empty: 'no-checkpoint' };
|
|
27
|
+
const CHECKPOINT_KIND = 'checkpoint';
|
|
28
|
+
const LEDGER_ABSENT = 'absent';
|
|
29
|
+
const LEDGER_OK = 'ok';
|
|
30
|
+
const CLEAN = 'CLEAN';
|
|
31
|
+
const DIRTY = 'DIRTY';
|
|
32
|
+
const PROOF = 'proof';
|
|
33
|
+
const PLAN_FLAG = '--plan';
|
|
34
|
+
const PLAN_ARG_COUNT = 3;
|
|
35
|
+
const OID_ARG_COUNT = 2;
|
|
36
|
+
const ARGV_OFFSET = 2;
|
|
37
|
+
const USAGE_TEXT = 'usage: checkpoint mint|newest|prune --plan <plan> | verify|restore <oid>';
|
|
38
|
+
const PLAN_ERROR = 'expected a readable regular plan directly under docs/plans';
|
|
39
|
+
const SEQUENCE_ERROR = 'expected contiguous numbered refs on trees';
|
|
40
|
+
const GIT_ARGS = {
|
|
41
|
+
checkRef: ['check-ref-format'],
|
|
42
|
+
resolve: ['rev-parse', '--verify'],
|
|
43
|
+
update: ['update-ref', '--no-deref'],
|
|
44
|
+
remove: ['update-ref', '--no-deref', '-d'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const readPlan = (context, planPath) => {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof planPath !== STRING || isAbsolute(planPath)) return refuse(`${NAMES.stem}: ${PLAN_ERROR}`);
|
|
50
|
+
const path = resolve(context.cwd, planPath);
|
|
51
|
+
const parent = relative(context.top, dirname(path)).split(sep).join(SLASH);
|
|
52
|
+
const name = basename(path);
|
|
53
|
+
if (parent !== PLANS_REL || !name.endsWith(MARKDOWN) || isScratchPlanName(name)
|
|
54
|
+
|| !(context.io.lstat ?? lstatSync)(path).isFile()) return refuse(`${NAMES.stem}: ${PLAN_ERROR}`);
|
|
55
|
+
readFileSync(path);
|
|
56
|
+
const stem = name.slice(FIRST_NUMBER, -MARKDOWN.length);
|
|
57
|
+
readGit(context, [...GIT_ARGS.checkRef, `${CHECKPOINT_REF_PREFIX}${stem}/${FIRST_NUMBER}`]);
|
|
58
|
+
return { ok: true, stem };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return refuse(`${NAMES.stem}: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const readSequence = (context, stem) => {
|
|
64
|
+
const prefix = `${CHECKPOINT_REF_PREFIX}${stem}${SLASH}`;
|
|
65
|
+
const refs = readCheckpointRefs(context, prefix).map(({ ref, oid }) => {
|
|
66
|
+
const suffix = ref.slice(prefix.length);
|
|
67
|
+
if (!NUMBER_PATTERN.test(suffix)) {
|
|
68
|
+
throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
69
|
+
}
|
|
70
|
+
return { ref, oid, n: Number(suffix), stem };
|
|
71
|
+
}).sort((left, right) => left.n - right.n);
|
|
72
|
+
for (const [index, checkpoint] of refs.entries()) {
|
|
73
|
+
if (!Number.isSafeInteger(checkpoint.n) || checkpoint.n !== index) throw new Error(`${NAMES.sequence}: ${SEQUENCE_ERROR}`);
|
|
74
|
+
const tree = resolveTreeObject(context.top, checkpoint.oid, context.io);
|
|
75
|
+
if (!tree.ok) throw new Error(tree.reason);
|
|
76
|
+
}
|
|
77
|
+
return refs;
|
|
78
|
+
};
|
|
79
|
+
const withSequence = (cwd, planPath, io, action) => withRepository(cwd, io, (context) => {
|
|
80
|
+
const plan = readPlan(context, planPath);
|
|
81
|
+
if (!plan.ok) return plan;
|
|
82
|
+
return action(context, plan.stem, readSequence(context, plan.stem));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const mintCheckpoint = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
86
|
+
const newest = refs.at(LAST);
|
|
87
|
+
const snapshot = writeSnapshot(context, newest?.oid ?? null);
|
|
88
|
+
if (!snapshot.ok) return snapshot;
|
|
89
|
+
if (snapshot.oid === newest?.oid) return { ok: true, ...newest };
|
|
90
|
+
const n = newest === undefined ? FIRST_NUMBER : newest.n + INCREMENT;
|
|
91
|
+
const ref = `${CHECKPOINT_REF_PREFIX}${stem}/${n}`;
|
|
92
|
+
readGit(context, [...GIT_ARGS.update, ref, snapshot.oid]);
|
|
93
|
+
return { ok: true, stem, n, ref, oid: snapshot.oid };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const newestCheckpoint = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
97
|
+
const newest = refs.at(LAST);
|
|
98
|
+
return newest === undefined ? refuse(`${NAMES.empty}: ${stem}`) : { ok: true, ...newest };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const verifyCheckpoint = (cwd, oid, io = {}) => withRepository(cwd, io, (context) => {
|
|
102
|
+
const tree = resolveTreeObject(context.top, oid, context.io);
|
|
103
|
+
if (!tree.ok) return refuse(tree.reason);
|
|
104
|
+
const target = readGit(context, [...GIT_ARGS.resolve, oid]);
|
|
105
|
+
const snapshot = writeSnapshot(context, target);
|
|
106
|
+
if (!snapshot.ok) return snapshot;
|
|
107
|
+
return { ok: true, clean: target === snapshot.oid, target, oid: snapshot.oid };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const pruneCheckpoints = (cwd, planPath, io = {}) => withSequence(cwd, planPath, io, (context, stem, refs) => {
|
|
111
|
+
const ledger = readDelegationLedger(context.cwd, context.env);
|
|
112
|
+
if (ledger.state !== LEDGER_ABSENT && ledger.state !== LEDGER_OK) return refuse(`${NAMES.ledger}: ${ledger.reason}`);
|
|
113
|
+
const records = ledger.records ?? [];
|
|
114
|
+
const held = new Set(refs.map(({ oid }) => oid));
|
|
115
|
+
const open = findOpenCheckpointThread(records, held);
|
|
116
|
+
if (open !== undefined) return refuse(`${NAMES.open}: ${open.nonce}`);
|
|
117
|
+
for (const { ref } of [...refs].reverse()) readGit(context, [...GIT_ARGS.remove, ref]);
|
|
118
|
+
return { ok: true };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const PLAN_OPERATIONS = { [VERBS.mint]: mintCheckpoint, [VERBS.newest]: newestCheckpoint, [VERBS.prune]: pruneCheckpoints };
|
|
122
|
+
const formatRef = ({ stem, n, oid }) => `${CHECKPOINT_KIND} ${stem}/${n} ${oid}${NEWLINE}`;
|
|
123
|
+
const formatRefusal = ({ code, reason }) => ({ code, stdout: EMPTY, stderr: `${reason}${NEWLINE}` });
|
|
124
|
+
export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
|
|
125
|
+
const [verb, operand, planPath] = argv;
|
|
126
|
+
const planVerb = Object.hasOwn(PLAN_OPERATIONS, verb);
|
|
127
|
+
const oidVerb = verb === VERBS.verify || verb === VERBS.restore;
|
|
128
|
+
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));
|
|
130
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
131
|
+
const operation = verb === VERBS.restore ? restoreCheckpoint : verifyCheckpoint;
|
|
132
|
+
const result = planVerb ? PLAN_OPERATIONS[verb](cwd, planPath, deps) : operation(cwd, operand, deps);
|
|
133
|
+
if (!result.ok) return formatRefusal(result);
|
|
134
|
+
if (verb === VERBS.restore) return { code: ACCEPT,
|
|
135
|
+
stdout: `${CHECKPOINT_KIND} ${VERBS.restore} ${result.target} ${PROOF} ${result.proof}${NEWLINE}`, stderr: EMPTY };
|
|
136
|
+
if (verb === VERBS.verify) return { code: result.clean ? ACCEPT : REFUSE,
|
|
137
|
+
stdout: `${result.clean ? CLEAN : DIRTY} ${result.target} ${result.oid}${NEWLINE}`, stderr: EMPTY };
|
|
138
|
+
return { code: ACCEPT, stdout: verb === VERBS.prune ? EMPTY : formatRef(result), stderr: EMPTY };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (isDirectRun(import.meta.url)) {
|
|
142
|
+
const result = main();
|
|
143
|
+
process.stdout.write(result.stdout);
|
|
144
|
+
process.stderr.write(result.stderr);
|
|
145
|
+
process.exitCode = result.code;
|
|
146
|
+
}
|
package/tools/plan-files.mjs
CHANGED
|
@@ -9,12 +9,13 @@ import { join } from 'node:path';
|
|
|
9
9
|
|
|
10
10
|
export const PLANS_REL = 'docs/plans';
|
|
11
11
|
|
|
12
|
-
// Scratch by the naming convention: EXECUTE-/FEEDBACK- prefixes, or a name carrying PROMPT/prompt/
|
|
12
|
+
// Scratch by the naming convention: EXECUTE-/FEEDBACK-/TASK- prefixes, or a name carrying PROMPT/prompt/
|
|
13
13
|
// handoff. queue.md is the series index, never a plan.
|
|
14
14
|
export const isScratchPlanName = (name) =>
|
|
15
15
|
name === 'queue.md' ||
|
|
16
16
|
name.startsWith('EXECUTE-') ||
|
|
17
17
|
name.startsWith('FEEDBACK-') ||
|
|
18
|
+
name.startsWith('TASK-') ||
|
|
18
19
|
name.includes('PROMPT') ||
|
|
19
20
|
name.includes('prompt') ||
|
|
20
21
|
name.includes('handoff');
|
package/tools/review-state.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// exit 0 when the CONFIGURED plan-execution.review recipe is solo (or the computed default is —
|
|
19
19
|
// absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
|
|
20
20
|
// holds no top-level .md that is not queue.md and not scratch by the naming convention:
|
|
21
|
-
// prefixes EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when
|
|
21
|
+
// prefixes EXECUTE- / FEEDBACK- / TASK-, or a name containing PROMPT / prompt / handoff); when
|
|
22
22
|
// the tree is clean (nothing to review — under a non-solo review obligation the PASS
|
|
23
23
|
// still NAMES every plan in flight and states that the gate arms as soon as the tree
|
|
24
24
|
// turns dirty, so a latent arm is discoverable before it blocks a pending commit); when
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// spec:checkpoint — docs/ai/specs/kit/checkpoint/task-brief.md
|
|
2
|
+
import { writeFileSync, realpathSync, constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep, posix } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFileBytesNoFollow } from './fs-read-nofollow.mjs';
|
|
6
|
+
import { parseLedger, isSweep, PLAN_HEADINGS } from './plan-shape.mjs';
|
|
7
|
+
import { expandSweepPaths } from './plan-shape-facts.mjs';
|
|
8
|
+
import { readStoryLine, PIN_FILE } from './plan-shape-ownership.mjs';
|
|
9
|
+
import { parseDispatchContract } from './dispatch-record.mjs';
|
|
10
|
+
import { newestCheckpoint, verifyCheckpoint } from './checkpoint.mjs';
|
|
11
|
+
import { withRepository, readGit } from './checkpoint-core.mjs';
|
|
12
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
13
|
+
|
|
14
|
+
export const BRIEF_BINDING_INFO_STRING = 'aw-task-binding';
|
|
15
|
+
const ACCEPT = 0;
|
|
16
|
+
const REFUSE = 1;
|
|
17
|
+
const USAGE = 2;
|
|
18
|
+
const START = 0;
|
|
19
|
+
const ONE = 1;
|
|
20
|
+
const EMPTY = '';
|
|
21
|
+
const NL = '\n';
|
|
22
|
+
const CR_END = /\r$/;
|
|
23
|
+
const UTF8 = 'utf8';
|
|
24
|
+
const STRING = 'string';
|
|
25
|
+
const OBJECT = 'object';
|
|
26
|
+
const HASH = 'sha256';
|
|
27
|
+
const HEX = 'hex';
|
|
28
|
+
const SHA_PREFIX = 'sha256:';
|
|
29
|
+
const DIGEST_LINE = 'task-brief digest';
|
|
30
|
+
const FENCE = '```';
|
|
31
|
+
const FENCE_RE = /^(`{3,})(.*)$/;
|
|
32
|
+
const TITLE_RE = /^# Task: (\S.*)$/;
|
|
33
|
+
const HEADING_RE = /^#{1,2} /;
|
|
34
|
+
const STORY_PREFIX = 'Story:';
|
|
35
|
+
const HEADINGS = ['## Slice', '## Reads', '## Acceptance', '## Negative cases', '## Budget'];
|
|
36
|
+
const SLICE_FIELDS = [/^Plan: (docs\/plans\/[^/]+\.md)$/, /^Row: (\S+)$/, /^Grouping: (.*)$/, /^Files:$/];
|
|
37
|
+
const PAIR_RE = /^- (\S(?:.*\S)?) :: (\S(?:.*\S)?)$/;
|
|
38
|
+
const BULLET_RE = /^- (\S.*)$/;
|
|
39
|
+
const INTEGER_RE = /^[1-9]\d*$/;
|
|
40
|
+
const TEST_RE = /(?:^|\/)[^/]+\.test(?:\.mjs$|\/[^/].*$)/;
|
|
41
|
+
const EXTENSION_RE = /\.[^/.]+$/;
|
|
42
|
+
const TEST_SUFFIX = '.test.mjs';
|
|
43
|
+
const TEST_DIRECTORY = '.test/';
|
|
44
|
+
const TEST = 'test';
|
|
45
|
+
const IMPL = 'impl';
|
|
46
|
+
const PIN = 'pin';
|
|
47
|
+
const TAGS = [TEST, IMPL, PIN];
|
|
48
|
+
const GROUP_SEPARATOR = ', ';
|
|
49
|
+
const PARENT = '..';
|
|
50
|
+
const CURRENT = '.';
|
|
51
|
+
const READ_OK = 'ok';
|
|
52
|
+
const SCHEMA = 1;
|
|
53
|
+
const BINDING_KEYS = ['schema', 'plan', 'reads', 'checkpoint', 'head'];
|
|
54
|
+
const FILE_KEYS = ['path', 'sha256'];
|
|
55
|
+
const SHA_RE = /^[0-9a-f]{64}$/;
|
|
56
|
+
const OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
57
|
+
const HEAD_ARGS = ['rev-parse', '--verify', 'HEAD^{commit}'];
|
|
58
|
+
const STAMP = 'stamp';
|
|
59
|
+
const CHECK = 'check';
|
|
60
|
+
const DISPATCH_FLAG = '--dispatch';
|
|
61
|
+
const BASIC_ARGS = 2;
|
|
62
|
+
const DISPATCH_ARGS = 4;
|
|
63
|
+
const ARGV_OFFSET = 2;
|
|
64
|
+
const NAMES = { shape: 'shape', grouping: 'grouping', row: 'row', story: 'story', reads: 'reads', binding: 'binding',
|
|
65
|
+
plan: 'binding-plan', read: 'binding-read', checkpoint: 'binding-checkpoint', stale: 'checkpoint-stale',
|
|
66
|
+
head: 'head', boundHead: 'binding-head', dispatch: 'dispatch-inputs' };
|
|
67
|
+
const USAGE_TEXT = 'usage: task-brief stamp <brief> | check <brief> [--dispatch <file>]';
|
|
68
|
+
const WRITE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
|
|
69
|
+
|
|
70
|
+
const refuse = (reason, code = REFUSE) => ({ ok: false, code, reason });
|
|
71
|
+
const reject = (name, detail = EMPTY) => { throw new Error(`${name}: ${detail}`); };
|
|
72
|
+
const computeDigest = (bytes) => createHash(HASH).update(bytes).digest(HEX);
|
|
73
|
+
const matchLine = (pattern, line) => {
|
|
74
|
+
const match = pattern.exec(line ?? EMPTY);
|
|
75
|
+
if (match === null) reject(NAMES.shape, line);
|
|
76
|
+
return match.slice(ONE);
|
|
77
|
+
};
|
|
78
|
+
const splitBindingBlocks = (text) => {
|
|
79
|
+
const raw = text.split(NL);
|
|
80
|
+
const lines = raw.map((line) => line.replace(CR_END, EMPTY));
|
|
81
|
+
const state = lines.reduce((state, line, index) => {
|
|
82
|
+
const fence = FENCE_RE.exec(line);
|
|
83
|
+
if (fence === null) return state;
|
|
84
|
+
const ticks = fence[ONE].length;
|
|
85
|
+
const info = fence[BASIC_ARGS].trim();
|
|
86
|
+
if (state.open === null) return { ...state, open: { ticks, info, start: index } };
|
|
87
|
+
if (info !== EMPTY || ticks < state.open.ticks) return state;
|
|
88
|
+
const blocks = state.open.info === BRIEF_BINDING_INFO_STRING
|
|
89
|
+
? [...state.blocks, { start: state.open.start, end: index, source: lines.slice(state.open.start + ONE, index).join(NL) }]
|
|
90
|
+
: state.blocks;
|
|
91
|
+
return { open: null, blocks };
|
|
92
|
+
}, { open: null, blocks: [] });
|
|
93
|
+
const unclosed = state.open?.info === BRIEF_BINDING_INFO_STRING;
|
|
94
|
+
const blocks = unclosed ? [...state.blocks, { start: state.open.start, end: lines.length, source: null }] : state.blocks;
|
|
95
|
+
const outside = (index) => !blocks.some(({ start, end }) => index >= start && index <= end);
|
|
96
|
+
return { blocks, unclosed, body: raw.filter((line, index) => outside(index)).join(NL),
|
|
97
|
+
lines: lines.filter((line, index) => outside(index) && line.trim() !== EMPTY) };
|
|
98
|
+
};
|
|
99
|
+
export const parseBrief = (text) => {
|
|
100
|
+
try {
|
|
101
|
+
if (typeof text !== STRING) return refuse(NAMES.shape);
|
|
102
|
+
const document = splitBindingBlocks(text);
|
|
103
|
+
const lines = document.lines;
|
|
104
|
+
const [title] = matchLine(TITLE_RE, lines[START]);
|
|
105
|
+
const headings = lines.flatMap((line, index) => HEADING_RE.test(line) ? [{ text: line, index }] : []);
|
|
106
|
+
if (headings.length !== HEADINGS.length + ONE || HEADINGS.some((text, index) => headings[index + ONE]?.text !== text)) reject(NAMES.shape);
|
|
107
|
+
const sections = HEADINGS.map((heading, index) => lines.slice(headings[index + ONE].index + ONE, headings[index + BASIC_ARGS]?.index ?? lines.length));
|
|
108
|
+
const storyLines = lines.slice(ONE, headings[ONE].index);
|
|
109
|
+
if (storyLines.some((line) => !line.startsWith(STORY_PREFIX))) reject(NAMES.shape);
|
|
110
|
+
const [slice, reads, acceptance, negative, budget] = sections;
|
|
111
|
+
const [[planPath], [rowId], [grouping]] = SLICE_FIELDS.map((pattern, index) => matchLine(pattern, slice[index]));
|
|
112
|
+
const files = slice.slice(SLICE_FIELDS.length).map((line) => {
|
|
113
|
+
const [path, tag] = matchLine(PAIR_RE, line);
|
|
114
|
+
return { path, tag };
|
|
115
|
+
});
|
|
116
|
+
const budgets = budget.map((line) => {
|
|
117
|
+
const [path, count] = matchLine(PAIR_RE, line);
|
|
118
|
+
if (!INTEGER_RE.test(count) || !Number.isSafeInteger(Number(count))) reject(NAMES.shape, line);
|
|
119
|
+
return { path, maxLines: Number(count) };
|
|
120
|
+
});
|
|
121
|
+
const filePaths = new Set(files.map(({ path }) => path));
|
|
122
|
+
if (files.length === START || budgets.length !== filePaths.size || new Set(budgets.map(({ path }) => path)).size !== filePaths.size
|
|
123
|
+
|| budgets.some(({ path }) => !filePaths.has(path)) || acceptance.length === START || negative.length === START) reject(NAMES.shape);
|
|
124
|
+
return { ok: true, title, storyLines, planPath, rowId, grouping, files, budgets,
|
|
125
|
+
reads: reads.map((line) => matchLine(BULLET_RE, line)[START]),
|
|
126
|
+
acceptance: acceptance.map((line) => matchLine(PAIR_RE, line)), negative: negative.map((line) => matchLine(BULLET_RE, line)[START]),
|
|
127
|
+
blocks: document.blocks, unclosed: document.unclosed, body: document.body };
|
|
128
|
+
} catch (error) { return refuse(error.message); }
|
|
129
|
+
};
|
|
130
|
+
const isInside = (top, path) => {
|
|
131
|
+
const rel = relative(top, path);
|
|
132
|
+
return !isAbsolute(rel) && rel !== PARENT && !rel.startsWith(`${PARENT}${sep}`);
|
|
133
|
+
};
|
|
134
|
+
const readInside = (context, path, name = NAMES.reads) => {
|
|
135
|
+
try {
|
|
136
|
+
if (typeof path !== STRING || path === EMPTY || isAbsolute(path)) reject(name, path);
|
|
137
|
+
const absolute = resolve(context.cwd, path);
|
|
138
|
+
if (!isInside(context.top, absolute) || !isInside(realpathSync(context.top), realpathSync(dirname(absolute)))) reject(name, path);
|
|
139
|
+
const result = readFileBytesNoFollow(absolute, context.io);
|
|
140
|
+
if (result.outcome !== READ_OK) reject(name, `${path}: ${result.code ?? result.className ?? result.outcome}`);
|
|
141
|
+
return { path, absolute, bytes: result.bytes, sha256: computeDigest(result.bytes) };
|
|
142
|
+
} catch (error) { reject(name, `${path}: ${error.message}`); }
|
|
143
|
+
};
|
|
144
|
+
const checkGrouping = (brief, row) => {
|
|
145
|
+
if (brief.files.some(({ tag }) => !TAGS.includes(tag))
|
|
146
|
+
|| brief.grouping !== TAGS.filter((tag) => brief.files.some((file) => file.tag === tag)).join(GROUP_SEPARATOR)) reject(NAMES.grouping);
|
|
147
|
+
const pins = brief.files.filter(({ tag }) => tag === PIN);
|
|
148
|
+
if (row && posix.basename(row.path) === PIN_FILE) {
|
|
149
|
+
if (pins.length !== ONE || pins[START].path !== row.path) reject(NAMES.grouping);
|
|
150
|
+
} else if (pins.length !== START) reject(NAMES.grouping);
|
|
151
|
+
};
|
|
152
|
+
const checkRow = (context, brief, row) => {
|
|
153
|
+
if (!row?.valid) reject(NAMES.row, brief.rowId);
|
|
154
|
+
const files = brief.files.map((file) => {
|
|
155
|
+
const path = posix.normalize(file.path);
|
|
156
|
+
if (path !== file.path || path === CURRENT || path.startsWith(PARENT) || posix.isAbsolute(path)) reject(NAMES.row, file.path);
|
|
157
|
+
return { ...file, path };
|
|
158
|
+
});
|
|
159
|
+
const implementations = files.filter(({ tag }) => tag === IMPL).map(({ path }) => path);
|
|
160
|
+
const allowed = isSweep(row.path) ? expandSweepPaths(context.top, [row.path])[row.path] : [row.path];
|
|
161
|
+
if (implementations.some((path) => !allowed.includes(path))) reject(NAMES.row, row.id);
|
|
162
|
+
const tests = files.filter(({ tag }) => tag === TEST);
|
|
163
|
+
if (tests.some(({ path }) => TEST_RE.test(row.path) ? path !== row.path : !implementations.some((impl) => {
|
|
164
|
+
const stem = impl.replace(EXTENSION_RE, EMPTY);
|
|
165
|
+
return path === `${stem}${TEST_SUFFIX}` || path.startsWith(`${stem}${TEST_DIRECTORY}`);
|
|
166
|
+
}))) reject(NAMES.row, row.id);
|
|
167
|
+
};
|
|
168
|
+
const selectStory = (lines) => {
|
|
169
|
+
const entries = lines.map((text, index) => ({ text, line: index + ONE }));
|
|
170
|
+
const result = readStoryLine(entries);
|
|
171
|
+
return result.story && result.findings.length === START ? lines[result.story.line - ONE] : null;
|
|
172
|
+
};
|
|
173
|
+
const readGrammar = (context, briefPath) => {
|
|
174
|
+
const file = readInside(context, briefPath);
|
|
175
|
+
const brief = parseBrief(file.bytes.toString(UTF8));
|
|
176
|
+
if (!brief.ok) return brief;
|
|
177
|
+
const plan = readInside(context, brief.planPath);
|
|
178
|
+
const ledger = parseLedger(plan.bytes.toString(UTF8));
|
|
179
|
+
const row = ledger.rows.find(({ id }) => id === brief.rowId);
|
|
180
|
+
checkGrouping(brief, row);
|
|
181
|
+
checkRow(context, brief, row);
|
|
182
|
+
const goal = ledger.document.headings.find(({ text }) => text === PLAN_HEADINGS[START]);
|
|
183
|
+
const end = ledger.document.headings.find(({ index }) => index > goal?.index);
|
|
184
|
+
const story = goal ? selectStory(ledger.document.lines.slice(goal.index + ONE, end?.index)) : null;
|
|
185
|
+
if (story === null || selectStory(brief.storyLines) !== story) reject(NAMES.story);
|
|
186
|
+
return { ok: true, file, brief, plan };
|
|
187
|
+
};
|
|
188
|
+
const checkBlockCount = (brief, required) => {
|
|
189
|
+
if (brief.unclosed || brief.blocks.length > ONE || (required && brief.blocks.length !== ONE)) reject(NAMES.binding);
|
|
190
|
+
};
|
|
191
|
+
const hasClosedKeys = (value, keys) => value !== null && typeof value === OBJECT && !Array.isArray(value)
|
|
192
|
+
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
193
|
+
const isFileBinding = (value) => hasClosedKeys(value, FILE_KEYS) && typeof value.path === STRING && typeof value.sha256 === STRING && SHA_RE.test(value.sha256);
|
|
194
|
+
const readBinding = (brief) => {
|
|
195
|
+
checkBlockCount(brief, true);
|
|
196
|
+
try {
|
|
197
|
+
const value = JSON.parse(brief.blocks[START].source);
|
|
198
|
+
if (!hasClosedKeys(value, BINDING_KEYS) || value.schema !== SCHEMA || !isFileBinding(value.plan)
|
|
199
|
+
|| !Array.isArray(value.reads) || !value.reads.every(isFileBinding) || typeof value.checkpoint !== STRING
|
|
200
|
+
|| !OID_RE.test(value.checkpoint) || typeof value.head !== STRING || !OID_RE.test(value.head)) reject(NAMES.binding);
|
|
201
|
+
return value;
|
|
202
|
+
} catch (error) { reject(NAMES.binding, error.message); }
|
|
203
|
+
};
|
|
204
|
+
const readHead = (context, name) => {
|
|
205
|
+
try {
|
|
206
|
+
const oid = readGit(context, HEAD_ARGS);
|
|
207
|
+
if (!OID_RE.test(oid)) reject(name);
|
|
208
|
+
return oid;
|
|
209
|
+
} catch (error) { reject(name, error.message); }
|
|
210
|
+
};
|
|
211
|
+
const bindFile = ({ path, sha256 }) => ({ path, sha256 });
|
|
212
|
+
const checkDispatch = (context, dispatchPath, briefPath, digest) => {
|
|
213
|
+
const file = readInside(context, dispatchPath, NAMES.dispatch);
|
|
214
|
+
const parsed = parseDispatchContract(file.bytes.toString(UTF8));
|
|
215
|
+
if (!parsed.ok || typeof parsed.contract.inputs !== STRING || !parsed.contract.inputs.includes(briefPath)
|
|
216
|
+
|| !parsed.contract.inputs.includes(`${SHA_PREFIX}${digest}`)) reject(NAMES.dispatch);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const stampBrief = (cwd, briefPath, io = {}) => withRepository(cwd, io, (context) => {
|
|
220
|
+
const grammar = readGrammar(context, briefPath);
|
|
221
|
+
if (!grammar.ok) return grammar;
|
|
222
|
+
const { file, brief, plan } = grammar;
|
|
223
|
+
checkBlockCount(brief, false);
|
|
224
|
+
const reads = brief.reads.map((path) => bindFile(readInside(context, path)));
|
|
225
|
+
const newest = newestCheckpoint(cwd, brief.planPath, context.io);
|
|
226
|
+
if (!newest.ok) return newest;
|
|
227
|
+
const binding = { schema: SCHEMA, plan: bindFile(plan), reads, checkpoint: newest.oid, head: readHead(context, NAMES.head) };
|
|
228
|
+
const bytes = Buffer.from(`${brief.body.trimEnd()}${NL}${NL}${FENCE}${BRIEF_BINDING_INFO_STRING}${NL}${JSON.stringify(binding)}${NL}${FENCE}${NL}`, UTF8);
|
|
229
|
+
writeFileSync(file.absolute, bytes, { flag: WRITE_FLAGS });
|
|
230
|
+
return { ok: true, digest: computeDigest(bytes) };
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const checkBrief = (cwd, briefPath, { dispatchPath } = {}, io = {}) => withRepository(cwd, io, (context) => {
|
|
234
|
+
const grammar = readGrammar(context, briefPath);
|
|
235
|
+
if (!grammar.ok) return grammar;
|
|
236
|
+
const { file, brief, plan } = grammar;
|
|
237
|
+
const binding = readBinding(brief);
|
|
238
|
+
const newest = newestCheckpoint(cwd, brief.planPath, context.io);
|
|
239
|
+
if (!newest.ok) return newest;
|
|
240
|
+
const reads = brief.reads.map((path) => bindFile(readInside(context, path)));
|
|
241
|
+
if (binding.plan.path !== brief.planPath || binding.plan.sha256 !== plan.sha256) reject(NAMES.plan);
|
|
242
|
+
if (binding.reads.length !== reads.length || reads.some((entry, index) =>
|
|
243
|
+
entry.path !== binding.reads[index].path || entry.sha256 !== binding.reads[index].sha256)) reject(NAMES.read);
|
|
244
|
+
if (binding.checkpoint !== newest.oid) reject(NAMES.checkpoint);
|
|
245
|
+
const verified = verifyCheckpoint(cwd, binding.checkpoint, context.io);
|
|
246
|
+
if (!verified.ok || !verified.clean) reject(NAMES.stale, verified.reason);
|
|
247
|
+
if (binding.head !== readHead(context, NAMES.boundHead)) reject(NAMES.boundHead);
|
|
248
|
+
if (dispatchPath !== undefined) checkDispatch(context, dispatchPath, briefPath, file.sha256);
|
|
249
|
+
return { ok: true, digest: file.sha256 };
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const formatResult = (result) => result.ok
|
|
253
|
+
? { code: ACCEPT, stdout: `${DIGEST_LINE} ${SHA_PREFIX}${result.digest}${NL}`, stderr: EMPTY }
|
|
254
|
+
: { code: result.code, stdout: EMPTY, stderr: `${result.reason}${NL}` };
|
|
255
|
+
export const main = (argv = process.argv.slice(ARGV_OFFSET), deps = {}) => {
|
|
256
|
+
try {
|
|
257
|
+
const [verb, path, flag, dispatchPath] = argv;
|
|
258
|
+
if (!path || !(verb === STAMP || verb === CHECK) || !(argv.length === BASIC_ARGS
|
|
259
|
+
|| (verb === CHECK && argv.length === DISPATCH_ARGS && flag === DISPATCH_FLAG && dispatchPath))) return formatResult(refuse(USAGE_TEXT, USAGE));
|
|
260
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
261
|
+
return formatResult(verb === STAMP ? stampBrief(cwd, path, deps) : checkBrief(cwd, path, { dispatchPath }, deps));
|
|
262
|
+
} catch (error) { return formatResult(refuse(error.message)); }
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (isDirectRun(import.meta.url)) {
|
|
266
|
+
const result = main();
|
|
267
|
+
process.stdout.write(result.stdout);
|
|
268
|
+
process.stderr.write(result.stderr);
|
|
269
|
+
process.exitCode = result.code;
|
|
270
|
+
}
|