@sabaiway/agent-workflow-kit 14.3.0 → 14.4.1
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 +44 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/procedures.md +1 -1
- package/references/modes/review-state.md +1 -1
- package/tools/procedures.mjs +6 -11
- package/tools/review-state-await.mjs +77 -0
- package/tools/review-state-build.mjs +163 -0
- package/tools/review-state-flow.mjs +110 -0
- package/tools/review-state-judge.mjs +331 -0
- package/tools/review-state-render.mjs +67 -0
- package/tools/review-state.mjs +34 -734
- package/tools/task-brief.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ 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.4.1 — `review-state.mjs` becomes a facade over five tested leaves; nothing the tool decides, prints or records changes (AD-149)
|
|
8
|
+
|
|
9
|
+
**The review-receipt checker is now five small modules behind its old entry point.** `tools/review-state.mjs` was one
|
|
10
|
+
918-line file. 14.4.1 moves its bodies into five library modules and keeps a 218-line facade that parses the command
|
|
11
|
+
line, composes them and keeps its 28 exported names, so no import site outside the leaf suites moves. Exit codes, reasons, the `--json`
|
|
12
|
+
output and the tool header are the same as in 14.4.0. Story S1 of the epic `REVIEW-STATE-AND-CORE-EVIDENCE-ARE-MONOLITHS`;
|
|
13
|
+
contract `docs/ai/specs/kit/review-state.md` (revision 1).
|
|
14
|
+
|
|
15
|
+
- **The leaves.** `review-state-judge.mjs` (receipt status, the degrade escape, the held-session arm, the check
|
|
16
|
+
decision), `review-state-build.mjs` (the read-only git queries and the state build; the only one that imports
|
|
17
|
+
`node:child_process`), `review-state-flow.mjs` (plan-adoption coverage and the armed-flow arms), `review-state-render.mjs` (the
|
|
18
|
+
human report and the mask advisory line) and `review-state-await.mjs` (the `--await` timeout grammar and poll loop).
|
|
19
|
+
No leaf imports the facade or runs as a script; each is under the 400-line cap, and the facade's size record is gone.
|
|
20
|
+
- **Tests.** Nine unit cases move from `review-state.test.mjs` (116 stay) into four new leaf suites, which add five
|
|
21
|
+
written cases; `review-state-await.test.mjs` imports its leaf; `review-state-harness.test.mjs` holds the shared
|
|
22
|
+
fixtures. `test/review-state-layout.test.mjs` (new) pins each exported name to its home module, the header's digest,
|
|
23
|
+
the import directions and the `--json` key order. `test/package-content.test.mjs` names the five leaves and pins 318
|
|
24
|
+
packed files.
|
|
25
|
+
|
|
26
|
+
## 14.4.0 — a delegated task engages the held session: `review-state --check` reads a configured `task.execute`, the fold lane follows a resolved one, and `task-brief check` admits a lone suite (AD-148)
|
|
27
|
+
|
|
28
|
+
**A task set to `delegated` is now gated and advised on its own.** In 14.3.0 the held-session gate and the fold lane
|
|
29
|
+
keyed only on `plan-execution.execute`, so a delegated task was covered only when the rows not carried as tasks were
|
|
30
|
+
delegated as well. 14.4.0 reads the task's own slot. Under a config that leaves `task.execute` unset or not
|
|
31
|
+
delegated, the gate and the lane behave exactly as before, except that `procedures task --override execute=delegated`
|
|
32
|
+
now renders the lane when the override resolves to delegated. Story S10 of the epic `EPICS-STORIES-TASKS-AS-THE-UNIT-OF-WORK`; contracts
|
|
33
|
+
`docs/ai/specs/kit/carriers/` revision 7, `held-session` revision 4, `checkpoint/` revision 4.
|
|
34
|
+
|
|
35
|
+
- **The gate (`tools/review-state.mjs`).** `--check` audits the delegation ledger when the configured
|
|
36
|
+
`plan-execution.execute` or the configured `task.execute` is `delegated`, each read on its own: a solo or subagent
|
|
37
|
+
value in one slot never masks the other. It reads the configuration, never a degraded recipe or a per-run override.
|
|
38
|
+
The judge, the refusal and the report line are unchanged. The tool header, `--help` and `modes/review-state.md` say so.
|
|
39
|
+
- **The fold lane (`tools/procedures.mjs`).** `procedures plan-execution` renders the lane once when its own `execute`
|
|
40
|
+
or `task.execute` resolves to delegated; a per-run `--override` moves only the rendered activity's own slot.
|
|
41
|
+
`procedures task` renders it when `task.execute` resolves to delegated. `task.author` never counts, and `foldLane`
|
|
42
|
+
stays `[]` for every other activity. The lane's lines and caveats are unchanged; `modes/procedures.md` says so.
|
|
43
|
+
- **A lone suite is briefable (`tools/task-brief.mjs`).** A slice with no `impl` line may carry the co-located test of
|
|
44
|
+
the row's path or of one of its sweep expansions. A slice with `impl` lines still admits only tests of its own `impl`
|
|
45
|
+
paths, and a test of any other path still refuses `row`.
|
|
46
|
+
- **Tests.** `review-state-task-execute.test.mjs` (new; carriers S19), `procedures-fold-lane.test.mjs` (S20) and
|
|
47
|
+
`task-brief.test.mjs` (checkpoint S11). The pinned cells of `review-state-held-session.test.mjs` pass unedited.
|
|
48
|
+
|
|
49
|
+
Published with engine 5.9.0, whose `task` step 5 states the same rule.
|
|
50
|
+
|
|
7
51
|
## 14.3.0 — the activity table gains its tier rows: `epic` (author, review) and `task` (author, execute) are configured, resolved and rendered like every other activity (AD-147)
|
|
8
52
|
|
|
9
53
|
**Epics and tasks now have recipe slots.** Until 14.2.0 the kit had an epic file and a task brief, but no slot said who
|
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.4.1'
|
|
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.4.1",
|
|
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",
|
|
@@ -23,7 +23,7 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
|
|
|
23
23
|
|
|
24
24
|
**The dispatch form (a slot resolved to `subagent`).** A carrier that is only NAMED is a name, not an instruction, so after such a slot's resolved-recipe line the advisor prints — indented under it, in place of the one-line vehicle mention — the form the dispatch must take, in five parts: (1) what a SLICE is for THIS slot — for `plan-execution.execute`, a set of file-disjoint ledger rows, wording copied verbatim where wording is a red line; for `plan-authoring.author`, a brief naming the goal, the governing spec(s) and the ledger constraints, whose returned draft the orchestrator reviews as its own; for `plan-authoring.fold`, the round's findings with their dispositions, whose edits the orchestrator follows with the self-consistency read; for `routine.carrier`, a bounded mechanical task, where a read-only chore (a sweep, gate triage) rides its placed read-only vehicle — or is carried solo with a stated reason when that vehicle is absent — a write-capable one (a regeneration, a fixture build) rides the executor, and the changelog stays the orchestrator's; for `epic.author`, a brief naming the intent, the value, the non-goals and the stories in order, from which the subagent drafts the epic file and the orchestrator runs the shape check on the draft as its own; for `task.author`, one ledger row of the plan with the contracts it reads, from which the subagent writes the task brief and the orchestrator stamps and checks it; for `task.execute`, one stamped brief, run inside its Files with the changed paths reported and the Acceptance commands run by the orchestrator; (2) the vehicle and its surveyed state — `.claude/agents/executor.md` (`placed` / `customized` / `unusable` / `missing`, from the same survey `status` and the advisor read) — dispatched **in the background**; (3) that the orchestrator **verifies every returned slice by running its suites itself**; (4) that the subagent is **never told to commit, never a review backend, never a bridge substitute**; (5) the **honest limit** — a Claude Code lane: on a host that cannot dispatch the vehicle, follow this form BY HAND and say so, never report it as a subagent dispatch. The wording is the kit's own (`tools/carriers.mjs`) and is printed, never re-worded. The dispatch-form branch is unchanged for a slot resolved to `solo` or `delegated`.
|
|
25
25
|
|
|
26
|
-
**Fold lane (an execute slot resolved to `delegated`).**
|
|
26
|
+
**Fold lane (an execute slot the activity reads resolved to `delegated`).** `procedures plan-execution` reads `plan-execution.execute` and `task.execute`, `procedures task` reads `task.execute` alone, and `task.author` never counts; a per-run `--override` moves only the rendered activity's own slot. The lane renders once, immediately after the rendered activity's `execute` slot block (after its driving contract when that slot itself resolves to `delegated`), as `codex-exec --resume <held id> --nonce <nonce> <fold-brief>` under the populated `run:` prefix, with the audited ledger's held id shell-quoted. `<nonce>` and `<fold-brief>` stay placeholders. One `caveat:` line replaces the id in each no-id case: no ledger; no folded code session in the commit epoch; a recorded substitution; an unresolvable, unreadable, malformed or audit-refused ledger (the reader reason verbatim); a failed HEAD read; or an id a one-line render cannot carry. An unborn branch runs the judge and is not a caveat. The block is advisory and never changes the exit code; `foldLane` carries the identical lines under `--json`, and is `[]` for every other activity and whenever no slot the activity reads resolves to `delegated`. The sidecar is never read.
|
|
27
27
|
|
|
28
28
|
**`routine`'s `parallel` switch renders against the EFFECTIVE carrier.** The flag always renders with its source (`parallel: on` / `parallel: off`); the concurrency claim — *file-disjoint slices dispatch concurrently* — renders **only** while the resolved `carrier` is `subagent`. Under a `solo` carrier the line states its own inertness instead, `(no effect while the carrier is solo)`: a switch never degrades, so the flag keeps the value you set and the render simply stops promising a concurrency nothing can perform. That makes four cells (`on`/`off` x `subagent`/`solo`), and only the `subagent`+`on` cell claims concurrency.
|
|
29
29
|
|
|
@@ -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 (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
|
-
2. **`--check`** → the gate exit code. Before the review-recipe early return, a configured `plan-execution.execute
|
|
10
|
+
2. **`--check`** → the gate exit code. Before the review-recipe early return, a configured `plan-execution.execute` or `task.execute` of `delegated` (each read on its own) 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
|
|
package/tools/procedures.mjs
CHANGED
|
@@ -629,17 +629,10 @@ const contractLines = ({ cmd, contract, settings }) => {
|
|
|
629
629
|
return lines;
|
|
630
630
|
};
|
|
631
631
|
|
|
632
|
-
const foldLaneAdvice = ({
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
env,
|
|
637
|
-
resolveStore,
|
|
638
|
-
readStore,
|
|
639
|
-
audit,
|
|
640
|
-
readHead,
|
|
641
|
-
}) => {
|
|
642
|
-
if (activity !== 'plan-execution' || slots.find((slot) => slot.slot === 'execute')?.recipe !== 'delegated') return [];
|
|
632
|
+
const foldLaneAdvice = ({ activity, slots, config, detection, cwd, env, resolveStore, readStore, audit, readHead }) => {
|
|
633
|
+
const own = slots.find((slot) => slot.slot === 'execute')?.recipe;
|
|
634
|
+
const task = activity === 'plan-execution' ? resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: 'task', slot: 'execute' }).recipe : null;
|
|
635
|
+
if (!['plan-execution', 'task'].includes(activity) || ![own, task].includes('delegated')) return [];
|
|
643
636
|
const ledger = readDelegationLedger(cwd, env, { resolveStore, readStore, audit, readHead });
|
|
644
637
|
return foldLaneLines(judgeLedger(ledger, { backend: HELD_RECEIPT_BACKEND, degrades: [] }));
|
|
645
638
|
};
|
|
@@ -809,6 +802,8 @@ export const main = (argv, ctx = {}) => {
|
|
|
809
802
|
const foldLane = foldLaneAdvice({
|
|
810
803
|
activity,
|
|
811
804
|
slots,
|
|
805
|
+
config,
|
|
806
|
+
detection,
|
|
812
807
|
cwd,
|
|
813
808
|
env,
|
|
814
809
|
resolveStore: ctx.resolveDelegationStorePath,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// review-state-await.mjs — the --timeout grammar and the poll loop.
|
|
2
|
+
|
|
3
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
4
|
+
import { fail } from './orchestration-config.mjs';
|
|
5
|
+
import { decideCheck } from './review-state-judge.mjs';
|
|
6
|
+
import { buildState } from './review-state-build.mjs';
|
|
7
|
+
|
|
8
|
+
// --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
|
|
9
|
+
// a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
|
|
10
|
+
// injectable clock) so hermetic tests never spend wall-clock.
|
|
11
|
+
export const DEFAULT_AWAIT_TIMEOUT_S = 900;
|
|
12
|
+
export const AWAIT_POLL_MS = 5000;
|
|
13
|
+
|
|
14
|
+
// ── --await: block until the configured review obligations are SATISFIED ───────────────
|
|
15
|
+
// It waits until `--check` would pass: under `reviewed`, a ship-class attestation from any
|
|
16
|
+
// review-capable backend; under `council`, every configured backend ship-class OR carrying a
|
|
17
|
+
// current-tree degrade RECORD in the core-evidence store (never all backends) — once such a
|
|
18
|
+
// record lands, --await stops waiting for that backend and returns READY (before, it waited
|
|
19
|
+
// forever for a receipt that never comes). It inherits everything for FREE — it polls the SAME
|
|
20
|
+
// decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT/RECORD
|
|
21
|
+
// (i.e. `--check` would PASS), NEVER a process event — a harness "completed" notification fires
|
|
22
|
+
// early and a bridge's output late-flushes, so polling state is the durable mechanization of
|
|
23
|
+
// receipts-not-pgrep. Stays read-only (it only re-reads state — now the evidence store too, a
|
|
24
|
+
// few KB per tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests
|
|
25
|
+
// never spend wall-clock.
|
|
26
|
+
|
|
27
|
+
const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
|
|
28
|
+
|
|
29
|
+
const parseAwaitTimeoutS = (argv) => {
|
|
30
|
+
const i = argv.indexOf('--timeout');
|
|
31
|
+
if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
|
|
32
|
+
const raw = argv[i + 1];
|
|
33
|
+
if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
|
|
34
|
+
return Number(raw);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const mainAwait = async (argv, ctx = {}) => {
|
|
38
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
39
|
+
const env = ctx.env ?? process.env;
|
|
40
|
+
const detect = ctx.detect ?? detectBackends;
|
|
41
|
+
const now = ctx.now ?? (() => Date.now());
|
|
42
|
+
const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
43
|
+
const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
|
|
44
|
+
const build = ctx.buildState ?? buildState;
|
|
45
|
+
try {
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
|
|
49
|
+
if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
|
|
50
|
+
}
|
|
51
|
+
const timeoutS = parseAwaitTimeoutS(argv);
|
|
52
|
+
const timeoutMs = timeoutS * 1000;
|
|
53
|
+
const start = now();
|
|
54
|
+
// Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
|
|
55
|
+
// plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
|
|
56
|
+
// every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
|
|
57
|
+
// is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
|
|
58
|
+
// over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
|
|
59
|
+
// sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
|
|
60
|
+
let lastReason = 'no poll completed before the deadline';
|
|
61
|
+
for (;;) {
|
|
62
|
+
const elapsed = now() - start;
|
|
63
|
+
if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
|
|
64
|
+
const check = decideCheck(build({ cwd, env, detect }));
|
|
65
|
+
lastReason = check.reason;
|
|
66
|
+
if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
|
|
67
|
+
if (check.heldSession === true) return { code: 1, stdout: '', stderr: `review-state --await: HELD SESSION — ${check.reason}` };
|
|
68
|
+
// Decision 4 (#50): an AUTHORITATIVE veto ends the wait loudly BEFORE the deadline — the
|
|
69
|
+
// dispatched review answered with a landed negative; classifying that as a timeout was the
|
|
70
|
+
// misclassification this amendment closes. Only a fresh review can move it, never waiting.
|
|
71
|
+
if (check.veto === true) return { code: 1, stdout: '', stderr: `review-state --await: VETO — ${check.reason}` };
|
|
72
|
+
await sleep(Math.min(pollMs, timeoutMs - elapsed));
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// review-state-build.mjs - read-only git plumbing and buildState.
|
|
2
|
+
|
|
3
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
7
|
+
import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
|
|
8
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
9
|
+
import { plansInFlight } from './plan-files.mjs';
|
|
10
|
+
import { GIT_MAX_BUFFER, resolveGitLocation, stripGitLocationEnv, withGitPath } from './git-env.mjs';
|
|
11
|
+
import {
|
|
12
|
+
computeTreeFingerprint,
|
|
13
|
+
isTreeClean,
|
|
14
|
+
isNeverCommittableStat,
|
|
15
|
+
resolveReceiptsPath,
|
|
16
|
+
readReceipts,
|
|
17
|
+
resolveBase,
|
|
18
|
+
} from './core-evidence.mjs';
|
|
19
|
+
import {
|
|
20
|
+
ACTIVITY, SLOT, backendReceiptStatus, buildHeldSessionState, degradeRecordSet, selectHeldSessionDegrades,
|
|
21
|
+
} from './review-state-judge.mjs';
|
|
22
|
+
import {
|
|
23
|
+
attestedPlanIdsFor, computePlanAdoptionCoverage, consumeVetoOverride, liftStaleRow, readFlowArming,
|
|
24
|
+
} from './review-state-flow.mjs';
|
|
25
|
+
|
|
26
|
+
// ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
|
|
27
|
+
|
|
28
|
+
const gitRaw = (args, cwd, env) =>
|
|
29
|
+
spawnSync('git', args, { cwd, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
|
|
30
|
+
|
|
31
|
+
// stdout Buffer of a git query, or null when git fails (not a repo / git absent).
|
|
32
|
+
const gitBuf = (args, cwd, env) => {
|
|
33
|
+
const r = gitRaw(args, cwd, env);
|
|
34
|
+
if (r.error || r.status !== 0) return null;
|
|
35
|
+
return r.stdout;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const gitLine = (args, cwd, env) => {
|
|
39
|
+
const buf = gitBuf(args, cwd, env);
|
|
40
|
+
return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── the sandbox-masks advisory (D lane, AD-044 Plan 4 Phase 1.5) ────────────────────
|
|
44
|
+
|
|
45
|
+
// Count the never-committable untracked paths the STANDARD walk still shows. The review domain
|
|
46
|
+
// ignores them by construction; this count only feeds ONE non-failing advisory line naming the
|
|
47
|
+
// cosmetic sandbox-masks apply — an applied managed block hides the paths from --exclude-standard,
|
|
48
|
+
// so the advisory disappears exactly when the status noise does (no standing detector).
|
|
49
|
+
export const countNeverCommittableUntracked = (cwd, { lstat = lstatSync, env } = {}) => {
|
|
50
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd, env);
|
|
51
|
+
if (top == null) return 0;
|
|
52
|
+
const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top, env);
|
|
53
|
+
if (untrackedZ == null) return 0;
|
|
54
|
+
return untrackedZ
|
|
55
|
+
.toString('utf8')
|
|
56
|
+
.split('\0')
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.filter((rel) => {
|
|
59
|
+
try {
|
|
60
|
+
return isNeverCommittableStat(lstat(join(top, rel)));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}).length;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
|
|
68
|
+
// EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
|
|
69
|
+
// work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
|
|
70
|
+
// must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
|
|
71
|
+
// flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
|
|
72
|
+
export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
|
|
73
|
+
// WHICH repository, first; the config/plan anchor is the stripped discovery's top (from a subdirectory the bare cwd would miss docs/plans — decideCheck refuses on an unanchored root).
|
|
74
|
+
const location = resolveGitLocation(cwd, { env });
|
|
75
|
+
const anchoredTop = location.top ?? gitLine(['rev-parse', '--show-toplevel'], cwd, stripGitLocationEnv(env));
|
|
76
|
+
const root = anchoredTop ?? cwd;
|
|
77
|
+
const { config, source: configSource } = loadConfig(root);
|
|
78
|
+
// A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
|
|
79
|
+
// executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
|
|
80
|
+
let detectionWarning = null;
|
|
81
|
+
const onDetectError = (err) => {
|
|
82
|
+
detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
|
|
83
|
+
};
|
|
84
|
+
const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
|
|
85
|
+
// The resolver stays for DISPLAY only; the OBLIGATIONS come from the configured recipe (never the readiness-degraded effective one — no silent solo).
|
|
86
|
+
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
|
|
87
|
+
const obligations = requiredBackendsForConfiguredRecipe({ config: config ?? {}, readiness: detection, detectionFailed: detectionWarning != null });
|
|
88
|
+
const requiredBackends = obligations.backends;
|
|
89
|
+
const plans = plansInFlight(root);
|
|
90
|
+
// The injected lstat threads through EVERY stat-dependent computation (fingerprint, clean, the
|
|
91
|
+
// mask count) — a partial injection would let a test observe an inconsistent state.
|
|
92
|
+
const fingerprint = location.state === 'work-tree' ? computeTreeFingerprint(cwd, { lstat, env }) : null;
|
|
93
|
+
const clean = fingerprint == null ? null : isTreeClean(cwd, { lstat, env });
|
|
94
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
95
|
+
const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
96
|
+
const { receipts, malformed } = receiptsRead;
|
|
97
|
+
const receiptsReadError = receiptsRead.readError ?? null;
|
|
98
|
+
const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
|
|
99
|
+
// The D3(b) degrade escape: read the core-evidence store ONLY here, ONLY for the exemption —
|
|
100
|
+
// the gate never otherwise depends on it (an unavailable store denies the exemption CLOSED,
|
|
101
|
+
// never fails a tree whose receipts independently satisfy the gate).
|
|
102
|
+
const base = resolveBase(cwd);
|
|
103
|
+
const degrade = degradeRecordSet({ cwd, env, fingerprint });
|
|
104
|
+
const degradedExempt = requiredBackends.filter((b) => degrade.set.has(b));
|
|
105
|
+
const heldSession = buildHeldSessionState({
|
|
106
|
+
cwd,
|
|
107
|
+
env,
|
|
108
|
+
configuredExecute: [config?.[ACTIVITY]?.execute, config?.task?.execute].find((recipe) => recipe === 'delegated'),
|
|
109
|
+
plans,
|
|
110
|
+
fingerprint,
|
|
111
|
+
clean,
|
|
112
|
+
degrades: selectHeldSessionDegrades(degrade.records),
|
|
113
|
+
});
|
|
114
|
+
// ── the Phase-2 flow arms' state (two-tier activation, P3) ──
|
|
115
|
+
const flow = readFlowArming({ cwd, env, lstat });
|
|
116
|
+
const planCoverage = flow.flowArmed
|
|
117
|
+
? computePlanAdoptionCoverage({ root, plans, records: flow.records, owner: flow.flowOwner, readFile })
|
|
118
|
+
: [];
|
|
119
|
+
const { attestedPlanIds, lensRefusedAttestations } = flow.flowArmed
|
|
120
|
+
? attestedPlanIdsFor({ records: flow.records, base, fingerprint })
|
|
121
|
+
: { attestedPlanIds: [], lensRefusedAttestations: [] };
|
|
122
|
+
const arms = { flowArmed: flow.flowArmed, receipts, records: flow.records, base, fingerprint, flowConfig: config?.flow ?? null };
|
|
123
|
+
const armedBackends = backends.map((b) => consumeVetoOverride(liftStaleRow(b, arms), arms));
|
|
124
|
+
// The solo-class veto probe (#48): under an armed solo recipe every review-capable backend's
|
|
125
|
+
// CURRENT-tree receipt is still consulted, so a recipe flip to solo never buries a standing veto.
|
|
126
|
+
const soloVetoRows = flow.flowArmed && obligations.recipe === 'solo'
|
|
127
|
+
? Object.values(DISPLAY_ALIASES).map((b) => consumeVetoOverride(liftStaleRow({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }, arms), arms))
|
|
128
|
+
: [];
|
|
129
|
+
return {
|
|
130
|
+
resolved,
|
|
131
|
+
configSource,
|
|
132
|
+
obligations,
|
|
133
|
+
requiredBackends,
|
|
134
|
+
backends: armedBackends,
|
|
135
|
+
plans,
|
|
136
|
+
root,
|
|
137
|
+
rootAnchored: anchoredTop !== null,
|
|
138
|
+
location,
|
|
139
|
+
fingerprint,
|
|
140
|
+
clean,
|
|
141
|
+
receiptsPath,
|
|
142
|
+
receiptCount: receipts.length,
|
|
143
|
+
malformed,
|
|
144
|
+
receiptsReadError,
|
|
145
|
+
base,
|
|
146
|
+
evidenceStorePath: degrade.storePath,
|
|
147
|
+
evidenceMalformed: degrade.malformed,
|
|
148
|
+
evidenceReadError: degrade.readError,
|
|
149
|
+
evidenceUnavailable: degrade.unavailable,
|
|
150
|
+
degradedExempt,
|
|
151
|
+
heldSession,
|
|
152
|
+
maskedUntracked: countNeverCommittableUntracked(cwd, { lstat, env }),
|
|
153
|
+
detectionWarning,
|
|
154
|
+
flowPresent: flow.flowPresent,
|
|
155
|
+
flowArmed: flow.flowArmed,
|
|
156
|
+
flowBrokenReason: flow.flowBrokenReason,
|
|
157
|
+
flowOwner: flow.flowOwner,
|
|
158
|
+
planCoverage,
|
|
159
|
+
attestedPlanIds,
|
|
160
|
+
lensRefusedAttestations,
|
|
161
|
+
soloVetoRows,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// review-state-flow.mjs - plan-adoption coverage and the armed-flow arms.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { DISPLAY_ALIASES } from './recipes.mjs';
|
|
7
|
+
import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
|
|
8
|
+
import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
|
|
9
|
+
import { selectReliedOnReceipt, evaluateVetoOverride, evaluateInternalAttestationLenses } from './flow-check.mjs';
|
|
10
|
+
import { PLANS_REL } from './plan-files.mjs';
|
|
11
|
+
import { summarizeReviewReceiptsForTree, isShipVerdict } from './core-evidence.mjs';
|
|
12
|
+
|
|
13
|
+
// The in-flight-plan → adopted-chain coverage map (P13/P19): ONE bounded read + ONE hash per plan
|
|
14
|
+
// file; coverage requires the frontmatter planId, the FULL content digest, and the owner to match
|
|
15
|
+
// the adoption record — a mismatch on any axis refuses internal-only arming by name.
|
|
16
|
+
export const computePlanAdoptionCoverage = ({ root, plans, records, owner, readFile = readFileSync }) =>
|
|
17
|
+
plans.map((plan) => {
|
|
18
|
+
let bytes;
|
|
19
|
+
try {
|
|
20
|
+
bytes = readFile(join(root, PLANS_REL, plan));
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { plan, planId: null, covered: false, reason: `the plan file is unreadable (${(err && err.code) || (err && err.message) || err})` };
|
|
23
|
+
}
|
|
24
|
+
const buf = typeof bytes === 'string' ? Buffer.from(bytes) : bytes;
|
|
25
|
+
const planId = readPlanFrontmatterId(buf.toString('utf8'));
|
|
26
|
+
if (planId == null) return { plan, planId: null, covered: false, reason: 'no frontmatter planId — plan filenames are never chain identity (#58); adopt the plan through the flow writer' };
|
|
27
|
+
const adoptionRecord = records.find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
|
|
28
|
+
if (adoptionRecord === undefined) return { plan, planId, covered: false, reason: `no adoption record for planId "${planId}"` };
|
|
29
|
+
if (adoptionRecord.planDigest !== createHash('sha256').update(buf).digest('hex')) {
|
|
30
|
+
return { plan, planId, covered: false, reason: 'the plan content no longer matches its adoption record (edited after adoption)' };
|
|
31
|
+
}
|
|
32
|
+
if (adoptionRecord.owner !== owner) {
|
|
33
|
+
return { plan, planId, covered: false, reason: `the adoption is owned by "${adoptionRecord.owner}", not this worktree ("${owner}") — a foreign-owner adoption never arms internal-only here` };
|
|
34
|
+
}
|
|
35
|
+
return { plan, planId, covered: true };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Tier 1 is store-file PRESENCE: no file ⇒ every field below stays inert and the decision is
|
|
39
|
+
// byte-identical to the pre-flow checker (the unarmed fast path also reads NO plan file).
|
|
40
|
+
export const readFlowArming = ({ cwd, env, lstat }) => {
|
|
41
|
+
const flowPath = resolveFlowStorePath(cwd, env);
|
|
42
|
+
const flowStat = (() => {
|
|
43
|
+
if (flowPath == null) return null;
|
|
44
|
+
try {
|
|
45
|
+
return lstat(flowPath);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return err && err.code === 'ENOENT' ? null : 'unstatable';
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
const flowPresent = flowStat !== null;
|
|
51
|
+
const flowRead = flowPresent && flowStat !== 'unstatable' ? readFlowStore(flowPath) : null;
|
|
52
|
+
const flowBrokenReason = !flowPresent ? null
|
|
53
|
+
: flowStat === 'unstatable' ? 'the store leaf cannot be stat-ed (fail closed)'
|
|
54
|
+
: flowRead.readError != null ? `read error: ${flowRead.readError}`
|
|
55
|
+
: flowRead.malformed > 0 ? `${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]})`
|
|
56
|
+
: null;
|
|
57
|
+
const flowArmed = flowPresent && flowBrokenReason == null
|
|
58
|
+
&& flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
|
|
59
|
+
const flowOwner = flowArmed ? deriveFlowOwner(cwd) : null;
|
|
60
|
+
return { flowPresent, flowArmed, flowBrokenReason, flowOwner, records: flowArmed ? flowRead.records : [] };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// The lens-substitution rung (#15/#3): an attestation whose lens set claims a review provider's
|
|
64
|
+
// slot without a then-active down-mark never counts — and its refusal is NAMED, never silent
|
|
65
|
+
// (lensRefusedAttestations feeds the internal-only floor's failure reason).
|
|
66
|
+
export const attestedPlanIdsFor = ({ records, base, fingerprint }) => {
|
|
67
|
+
const lensRefusedAttestations = [];
|
|
68
|
+
const attestedPlanIds = [...new Set(authoritativeFlowRecords(records)
|
|
69
|
+
.filter((r) => r.kind === 'internal-attestation' && r.base === base && r.fingerprint === fingerprint)
|
|
70
|
+
.filter((r) => {
|
|
71
|
+
const lensCheck = evaluateInternalAttestationLenses({ record: r, records, providerBackends: Object.values(DISPLAY_ALIASES) });
|
|
72
|
+
if (!lensCheck.ok) {
|
|
73
|
+
lensRefusedAttestations.push({ planId: r.planId, reason: lensCheck.reason });
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
})
|
|
78
|
+
.map((r) => r.planId))];
|
|
79
|
+
return { attestedPlanIds, lensRefusedAttestations };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// #61: the SHARED relied-on selector (one home with the flow-check coverage rung) — an unbroken
|
|
83
|
+
// declared-path bookkeeping-delta chain lifts the backend's last receipt; any break, fork, or
|
|
84
|
+
// cap exhaustion stays stale.
|
|
85
|
+
export const liftStaleRow = (row, { flowArmed, receipts, records, base, fingerprint, flowConfig }) => {
|
|
86
|
+
if (!flowArmed || row.state !== 'stale') return row;
|
|
87
|
+
const selected = selectReliedOnReceipt({
|
|
88
|
+
receipts, backend: row.backend, tree: { base, fingerprint },
|
|
89
|
+
records,
|
|
90
|
+
declaredPaths: [flowConfig?.debtQueue, flowConfig?.convergenceSummary].filter((p) => typeof p === 'string'),
|
|
91
|
+
refreshCap: flowConfig?.councilRounds,
|
|
92
|
+
});
|
|
93
|
+
if (selected.receipt == null || selected.lifted === 0) return row;
|
|
94
|
+
return {
|
|
95
|
+
...row, state: 'current', verdict: selected.receipt.verdict ?? 'unknown',
|
|
96
|
+
shipClass: isShipVerdict(selected.receipt.verdict), grounded: true,
|
|
97
|
+
timestamp: selected.receipt.timestamp ?? null, deltaLift: selected.lifted,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// #56/#38: a standing CURRENT-tree veto consults its override instance; a delta-lifted veto has
|
|
102
|
+
// no override lane here (the bound set pins the veto receipt's own tree).
|
|
103
|
+
export const consumeVetoOverride = (row, { flowArmed, receipts, records, base, fingerprint }) => {
|
|
104
|
+
if (!flowArmed || !(row.state === 'current' && !row.shipClass) || row.deltaLift != null) return row;
|
|
105
|
+
const own = receipts.filter((r) => r.backend === row.backend);
|
|
106
|
+
const summary = summarizeReviewReceiptsForTree(own, fingerprint);
|
|
107
|
+
if (summary.state !== 'current') return row;
|
|
108
|
+
const lift = evaluateVetoOverride({ records, vetoReceipt: summary.receipt, tree: { base, fingerprint } });
|
|
109
|
+
return lift.lifted ? { ...row, overrideLabel: lift.label } : row;
|
|
110
|
+
};
|