@sabaiway/agent-workflow-kit 1.49.0 → 2.1.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 +100 -0
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +17 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +73 -17
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +271 -13
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/capability.json +166 -5
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +6 -2
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +3 -2
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +133 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +22 -9
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +165 -2
- package/bridges/codex-cli-bridge/capability.json +118 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-ledger.md +1 -1
- package/references/modes/review-state.md +2 -2
- package/tools/detect-backends.mjs +6 -5
- package/tools/manifest/schema.md +62 -0
- package/tools/manifest/validate.mjs +327 -0
- package/tools/review-ledger-core.mjs +74 -0
- package/tools/review-ledger-write.mjs +19 -10
- package/tools/review-ledger.mjs +21 -12
- package/tools/review-state.mjs +89 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.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",
|
|
@@ -13,7 +13,7 @@ The review-round **LEDGER** (DEBT-REVIEW-CAP / AD-045) — it turns the prose re
|
|
|
13
13
|
- **`--telemetry`** → read-only COUNTS across ALL loops and BOTH ledgers (never combined with the other flags — a mixed-mode gate cmd would silently pass): rounds/segments per loop, finding-origin totals, classification distribution (incl. `refuted`), per-backend verdict + divergence-round counts, override usage by scope, gate-run counts (quality-green / red results by gate id), fold runs, observed-red receipts, quarantined probes. Counts only — which gates earn their keep stays the maintainer's judgment.
|
|
14
14
|
|
|
15
15
|
2. **Writer** — `node ${CLAUDE_SKILL_DIR}/tools/review-ledger-write.mjs record|classify|override|batch --json '<payload>'` (the SOLE writer, over the shared `atomic-write` core):
|
|
16
|
-
- `record` appends one round — `{ loop, round, origins, backends, findings }` (activity defaults to `plan-execution`). **The teeth (Decision 5 + AD-048, all per SEGMENT):** it REFUSES (typed STOP) to append a round WHILE `decideStop` on the segment's records is `triage-required` (an UNCLASSIFIED surviving blocking finding at/after the cap); refuses ANY round beyond the **hard-max ceiling of 3 within one segment** unconditionally (the counter reopens only at the next gated commit); refuses while a blocking finding of the segment's previous round **VANISHED unclassified** (present means present-as-blocking — a downgrade to minor does not survive; a pending `escalate` never clears; `refuted` is the honest phantom lane); refuses while the changed source surface exceeds the **diff-size cap** (`AW_REVIEW_DIFF_CAP`, default 400 new-side lines of assessable + unsupported SOURCE — tests and out-of-domain never count, pure deletions are free) without a recorded segment `size-cap` override; and refuses without a **quality-green gate-run** at the current fingerprint (`run-gates --record` — gates-before-review is computed, not remembered; a `--only` subset or a tree-changed run never satisfies; red PROCESS gates — the kit's own `--check` loop gates — never block). **Integrity binding (Decision 7):** each NON-degraded backend needs
|
|
16
|
+
- `record` appends one round — `{ loop, round, origins, backends, findings }` (activity defaults to `plan-execution`). **The teeth (Decision 5 + AD-048, all per SEGMENT):** it REFUSES (typed STOP) to append a round WHILE `decideStop` on the segment's records is `triage-required` (an UNCLASSIFIED surviving blocking finding at/after the cap); refuses ANY round beyond the **hard-max ceiling of 3 within one segment** unconditionally (the counter reopens only at the next gated commit); refuses while a blocking finding of the segment's previous round **VANISHED unclassified** (present means present-as-blocking — a downgrade to minor does not survive; a pending `escalate` never clears; `refuted` is the honest phantom lane); refuses while the changed source surface exceeds the **diff-size cap** (`AW_REVIEW_DIFF_CAP`, default 400 new-side lines of assessable + unsupported SOURCE — tests and out-of-domain never count, pure deletions are free) without a recorded segment `size-cap` override; and refuses without a **quality-green gate-run** at the current fingerprint (`run-gates --record` — gates-before-review is computed, not remembered; a `--only` subset or a tree-changed run never satisfies; red PROCESS gates — the kit's own `--check` loop gates — never block). **Integrity binding (Decision 7):** each NON-degraded backend needs an **attesting** code receipt for the current tree — a round cannot be recorded for a tree no bridge really reviewed. *Attesting* is the SHARED predicate (`review-ledger-core`) that `review-state` and the round cross-check read too, so the gates can never disagree about what counts: a fresh `code` receipt at the current fingerprint that is **grounded** AND carries an explicit **`probe:false`**. A `probe:true` receipt never attests (a probe review runs with the quality guards off), and a **malformed or ABSENT** marker is rejected fail-closed — silence is not a declaration, so a receipt from a pre-marker bridge no longer counts (refresh it: `npx @sabaiway/agent-workflow-kit@latest init`). Each exclusion states its own reason, because the recoveries differ. **`--from-receipts`** (BUGFREE-3 / AD-049) DRAFTS the `backends[]` from those receipts instead of hand-composing them: each recipe-named backend's verdict comes from its fresh grounded code receipt and its counts are computed from the supplied `findings` (`origins` / `findings` stay explicit input); a recipe-named backend with no receipt is a LOUD stop — the draft never invents one (supply it explicitly as a degraded backend if the bridge really is down).
|
|
17
17
|
- `classify` appends one triage — each surviving blocking finding of a SEGMENT round classified `fixable-bug` / `inherent-layer-residual` / `escalate` / **`refuted`** (v4 — the honest lane for a phantom finding, refuted against code: a MANDATORY non-empty `note` cites the grounds; never silently dropped, never folded). **A `fixable-bug` REQUIRES a `testId`** (M2/AD-046, schema v2) — the red→green test that pins the fold, formatted `<test-file>#<test-name-pattern>` (a `#` separator, both halves non-empty; no file-suffix rule); `inherent-layer-residual` / `escalate` may omit it. The writer validates the FORMAT only. **This is what BREAKS the deadlock:** once every surviving blocking finding is classified, `record` permits the next round (a `fixable-bug` classification lets the fix round run — no deadlock).
|
|
18
18
|
- `override` appends one override record (schema v3, BUGFREE-1/AD-047) — the LOUD, durable waiver the **fold-completeness** gate consumes, `{ loop, round, scope, reason }` plus the per-scope payload (EXACT — a stray key is refused): scope **`oracle-change`** → `files[]`, the repo-relative pre-existing test files whose tamper flag it lifts (a fix diff that deliberately rewrote test expectations); scope **`red-proof`** → `testId`, the ONE bound test whose observed-red receipt + custody it waives (a red genuinely unestablishable pre-fold, or a custody file legitimately edited post-fix); scope **`size-cap`** (v4) → `sanctionedLines`, the EXACT changed-surface magnitude the waiver sanctions — **segment-scoped** (it dies at the next commit; a grown surface needs a fresh recorded sanction). Teeth: schema validation, fail-closed ledger read, round-sequence integrity, and the loop must be the SINGLE in-flight plan. QUARANTINE (a flaky/timed-out probe) has NO override lane. Overrides never enter `decideStop` — the two v3 scopes are fold-completeness inputs; `size-cap` is a writer-tooth input.
|
|
19
19
|
- **`gate-run` records (v4, kind `gate-run`)** are minted by `run-gates --record` through the writer's `recordGateRun` API (the runner never opens the ledger itself) — the D5 green-baseline receipt: the FULL declaration + exactly what ran + the tree fingerprint BEFORE and AFTER the run, segment-framed with NO round number, minted only inside the single in-flight loop. A red run records honestly (telemetry fuel); **quality-green** (every declared NON-process gate green, tree unchanged under the run) is judged at read time. Revert-first beyond this is not mechanically observable at this layer — it ships as protocol + telemetry visibility (consecutive red gate-runs), stated plainly.
|
|
@@ -4,8 +4,8 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
|
|
|
4
4
|
|
|
5
5
|
Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
|
|
6
6
|
|
|
7
|
-
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / missing) with verdict + grounding + timestamp.
|
|
8
|
-
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), 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 non-git cwd, or every recipe-named backend receipted **current + grounded** — **or degraded-exempt**: the current segment's latest **review-ledger** round records that backend `degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present + grounded and the ledger reading clean (AD-050; a corrupt ledger DENIES the exemption fail-closed but never fails an otherwise-satisfied tree — the tool header is the single home). Exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council, and is not degraded-exempt. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree (a recorded degrade counts as "reviewed", never as "converged" — that stays `review-ledger`'s job). 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.
|
|
7
|
+
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
|
|
8
|
+
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), 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 non-git cwd, or every recipe-named backend receipted **current + grounded** — **or degraded-exempt**: the current segment's latest **review-ledger** round records that backend `degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present + grounded and the ledger reading clean (AD-050; a corrupt ledger DENIES the exemption fail-closed but never fails an otherwise-satisfied tree — the tool header is the single home). Exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council, and is not degraded-exempt. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree (a recorded degrade counts as "reviewed", never as "converged" — that stays `review-ledger`'s job). 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 — a backend whose only current receipts are probes fails with its own stated reason, distinct from stale. The filter is **per receipt**, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a marker-aware wrapper writes `probe` on **every** successful review — `true` or `false` — so the receipt states the fact itself, and a **malformed** *or* **absent** marker is rejected **fail-closed** and stated in the check line, never silently dropped. This is deliberately **not** keyed on the wrapper version: the version bumps in a different release phase than the marker lands, so a version floor would reject the very receipts the current wrappers write — and it could only ever proxy a fact the receipt already carries. Accepted cost: a pre-marker receipt stops satisfying — re-run the review with a marker-aware bridge. 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.
|
|
9
9
|
3. **Wire it as a gate by hand OR via the explicit-consent seeder — never without consent (AD-021/AD-042).** 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 seeder (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-seed section) 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); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
|
|
10
10
|
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 waits until each backend has a fresh grounded receipt **OR is degraded-exempt**: once a current-tree degrade is recorded in the review-ledger, `--await` stops waiting for that backend and returns READY (AD-050 — it inherits the `--check` exemption via the shared decision), so you no longer hand-`--await` around a known degrade. Still read-only (it only re-reads state, now the ledger too); solo / no-plan / clean-tree resolve instantly.
|
|
11
11
|
|
|
@@ -91,7 +91,7 @@ const RAW_BACKENDS = [
|
|
|
91
91
|
],
|
|
92
92
|
grounding: 'automatic — the wrapper precomputes the full working-tree change set (repo map, status, diffs, untracked contents) and codex auto-merges the root AGENTS.md; no grounding flags',
|
|
93
93
|
continue: [],
|
|
94
|
-
receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan mode; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); a write failure warns, never fails the review',
|
|
94
|
+
receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan mode; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); probe = whether the run relaxed the quality guards (CODEX_PROBE=1), written on EVERY receipt so it self-declares — the kit\'s review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); a write failure warns, never fails the review',
|
|
95
95
|
},
|
|
96
96
|
},
|
|
97
97
|
bin: 'codex',
|
|
@@ -110,13 +110,14 @@ const RAW_BACKENDS = [
|
|
|
110
110
|
roleContracts: {
|
|
111
111
|
review: {
|
|
112
112
|
invocations: [
|
|
113
|
-
'agy-review code [--facts @f] [--decided @f] [--focus "…"] [extra focus…]',
|
|
113
|
+
'agy-review code [--facts @f] [--ungrounded] [--decided @f] [--focus "…"] [extra focus…]',
|
|
114
114
|
'agy-review plan <plan-file> [--facts @f] [--decided @f] [--focus "…"]',
|
|
115
115
|
'agy-review diff <diff-file> [--facts @f] [--decided @f] [--focus "…"]',
|
|
116
116
|
],
|
|
117
|
-
grounding: 'grounded review — agy reads NOTHING by default, an ungrounded review GUESSES: --facts @f = the verified facts to review AGAINST; --decided @f = decisions already made, do NOT re-raise (anti-circling)',
|
|
117
|
+
grounding: 'grounded review — agy reads NOTHING by default, an ungrounded review GUESSES: --facts @f = the verified facts to review AGAINST; --decided @f = decisions already made, do NOT re-raise (anti-circling). code mode REQUIRES a non-empty --facts payload and refuses BEFORE spending a run (escapes: --ungrounded, AGY_PROBE=1); plan/diff proceed with a loud warning',
|
|
118
118
|
flags: [
|
|
119
|
-
'--facts @f — verified facts the review runs AGAINST (
|
|
119
|
+
'--facts @f — verified facts the review runs AGAINST (code mode REQUIRES a non-empty payload; plan/diff warn loudly when omitted)',
|
|
120
|
+
'--ungrounded — deliberately ungrounded CODE review, a throwaway opinion (code mode only, contradicts --facts; the receipt records grounded:false and never attests)',
|
|
120
121
|
'--decided @f — already-decided / already-addressed list; do NOT re-raise (anti-circling; the round-2 payload)',
|
|
121
122
|
'--focus "…" — extra focus (repeatable; code mode also takes trailing focus words)',
|
|
122
123
|
],
|
|
@@ -124,7 +125,7 @@ const RAW_BACKENDS = [
|
|
|
124
125
|
'agy-review --continue [--decided @f] [--focus "…"]',
|
|
125
126
|
'agy-review --conversation <id> [--decided @f] [--focus "…"]',
|
|
126
127
|
],
|
|
127
|
-
receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); a write failure warns, never fails the review",
|
|
128
|
+
receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); a write failure warns, never fails the review",
|
|
128
129
|
notes: [
|
|
129
130
|
'pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt',
|
|
130
131
|
],
|
package/tools/manifest/schema.md
CHANGED
|
@@ -20,6 +20,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
|
|
|
20
20
|
| `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
|
|
21
21
|
| `networkHosts` | string[] | no | the backend CLI's **observed egress host families** (see *Network hosts*). A malformed list **fails** `--strict`. |
|
|
22
22
|
| `writableDirs` | object[] | no | the backend CLI's **writable state-dir declarations** — `{env, default}` entries (see *Writable dirs*). A malformed list **fails** `--strict`. |
|
|
23
|
+
| `modeCatalog` | object[] | no | the bridge's **user-facing operating modes** — what each mode is FOR and when to reach for it (see *Mode catalog*). A malformed block **fails** `--strict`. |
|
|
23
24
|
| `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
|
|
24
25
|
| `cost` / `quota` / `provenance` | misc | no | informational |
|
|
25
26
|
| `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
|
|
@@ -128,6 +129,67 @@ declared path is only the **default under no override**. Rules:
|
|
|
128
129
|
`sandbox.filesystem.allowWrite` or any other filesystem allowance — the entries feed the
|
|
129
130
|
sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
|
|
130
131
|
|
|
132
|
+
## Mode catalog (BRIDGE-MODES-CATALOG)
|
|
133
|
+
|
|
134
|
+
`modeCatalog` answers the question the `contract` block deliberately does not: **what modes does
|
|
135
|
+
this bridge offer, and what is each one FOR?** The `contract` is the internal DRIVING contract (how
|
|
136
|
+
a dispatching agent invokes a role); the catalog is the **user-facing discovery surface**. They are
|
|
137
|
+
related, never nested: a catalog entry backed by a contract **composes by reference** and never
|
|
138
|
+
restates a descriptor.
|
|
139
|
+
|
|
140
|
+
**Additive-optional.** The schema stays `1`. An absent block is **valid** — a bridge released before
|
|
141
|
+
the catalog keeps validating, and the mode renders a stated *"no catalog — this bridge predates it"*
|
|
142
|
+
line rather than `invalid-manifest` or an empty silent list. A **present** block is typed-validated
|
|
143
|
+
like `settings`: the renderer prints these strings verbatim and builds invocation forms from the
|
|
144
|
+
refs, so a malformed entry would render a lying discovery surface.
|
|
145
|
+
|
|
146
|
+
**Currency claim (honest scope):** the catalog tracks the **documented wrapper mode set** — never
|
|
147
|
+
"the CLI's modes". Upstream CLI evolution enters through a bridge release, where the source-level
|
|
148
|
+
drift tests fail loudly until the catalog is updated. Nothing here ever probes a live CLI.
|
|
149
|
+
|
|
150
|
+
An **array** of entries (an object would silently dedupe keys under `JSON.parse`). Each entry:
|
|
151
|
+
|
|
152
|
+
- `key` (string, required) — a **unique bare token** (`exec`, `review.plan`, `exec.resume-last`).
|
|
153
|
+
For an `env-hook` the key **IS** its env-var name, so it must be UPPER_SNAKE_CASE.
|
|
154
|
+
- `kind` (string, required) — the closed taxonomy:
|
|
155
|
+
- `primary` — a mode you drive directly (`codex exec`, `agy-review code`, the raw `agy-run`).
|
|
156
|
+
- `continuation` — resumes a mode already in flight (`--resume-last`, `--continue`).
|
|
157
|
+
- `env-hook` — an env var that MODIFIES named modes (`CODEX_PROBE`). It names `parents[]`, never
|
|
158
|
+
a role: an env var is not a capability, and a fake role would corrupt the recipe planner.
|
|
159
|
+
- `role` (string) — required on `primary`/`continuation`, must be a declared key of `roles`;
|
|
160
|
+
**forbidden** on an `env-hook`.
|
|
161
|
+
- `parents` (string[]) — required and non-empty on an `env-hook`, resolving to catalog keys (never
|
|
162
|
+
itself); **forbidden** elsewhere.
|
|
163
|
+
- `submode` (string) — the **explicit** binding to the wrapper's parser mode arm. Present exactly
|
|
164
|
+
when the entry is a `primary` **and** its role declares `modes[]`; must be one of them. The
|
|
165
|
+
bridge drift tests set-equal these against the wrapper's REAL arms — the binding is never parsed
|
|
166
|
+
back out of the key.
|
|
167
|
+
- `purpose` (string, required) — one line: what the mode is for.
|
|
168
|
+
- `whenToUse` (string[], required, non-empty) / `whenNotTo` (string[], optional) — the reach-for-it
|
|
169
|
+
and reach-for-something-else signals.
|
|
170
|
+
- `invocationRefs` (object[]) — **composition by reference**: `{contractField, index}` into
|
|
171
|
+
`roles[<role>].contract.invocations` / `.continue`. Required (non-empty) on a contract-backed
|
|
172
|
+
entry; every ref must resolve, and a contract invocation is claimed by **at most one** entry.
|
|
173
|
+
- `descriptor` (string) — the literal invocation form. Allowed **only** where no contract exists (a
|
|
174
|
+
contract-free primary such as a raw-prompt mode, or an env-hook): for those the catalog IS
|
|
175
|
+
canonical — the stated exception to no-duplication.
|
|
176
|
+
- `operands` (object[], optional) — `{slot, required, description}`. Typed **catalog data**, never
|
|
177
|
+
parsed out of contract strings. Each `slot` is unique within the entry and must really appear in
|
|
178
|
+
one of the entry's rendered invocation forms — the render labels an unfilled form a **template**
|
|
179
|
+
and names each required operand, so a slot with nowhere to go would be a lie.
|
|
180
|
+
- `guardrails` (object[], optional) — `{value, enforcement, condition?, source}`. `enforcement` is
|
|
181
|
+
`enforced` **only** for an OS-/code-enforced fact; where the guarantee has a runtime bound, the
|
|
182
|
+
`condition` carries it (e.g. a hard timeout is enforced *only when `timeout(1)`/`gtimeout` is on
|
|
183
|
+
PATH*). Anything a prompt merely asks for renders `advisory`. `source` names where the fact lives.
|
|
184
|
+
- `customHooks` (string[], optional) — this mode's escape hatches, as catalog keys. Each is either
|
|
185
|
+
an **env-hook that really lists this mode in its `parents[]`** (a hook can never lie about a mode
|
|
186
|
+
it does not target), or the entry's **own key** — the raw-mode carve-out: a contract-free primary
|
|
187
|
+
IS its own escape, so it references itself rather than repeating one it does not have.
|
|
188
|
+
|
|
189
|
+
Every string the renderer prints (`purpose`, `whenToUse`/`whenNotTo` items, `descriptor`, operand
|
|
190
|
+
`description`, guardrail `value`/`condition`/`source`) is ONE line of at most **200** characters
|
|
191
|
+
with no control characters — the surface is a terminal-width list and a pasted form.
|
|
192
|
+
|
|
131
193
|
## Path-field rules (Windows-safe, traversal-safe)
|
|
132
194
|
|
|
133
195
|
These rules govern the **repo/project-relative path fields** enumerated below (`source`,
|
|
@@ -60,6 +60,31 @@ export const settingValueValid = (entry, value) => {
|
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// ── `modeCatalog` vocabulary (BRIDGE-MODES-CATALOG, D2/D4/D6) ────────────────────────
|
|
64
|
+
// Every `modeCatalog` string the mode renderer prints is ONE capped line: the surface is a
|
|
65
|
+
// terminal-width discovery list, and a control character would break the line (or a pasted form).
|
|
66
|
+
export const CATALOG_LINE_MAX = 200;
|
|
67
|
+
// The closed entry taxonomy (D4): a `primary` is a mode you drive; a `continuation` resumes one; an
|
|
68
|
+
// `env-hook` MODIFIES named parents (an env var is never a fake role).
|
|
69
|
+
export const CATALOG_KINDS = new Set(['primary', 'continuation', 'env-hook']);
|
|
70
|
+
// `enforced` is claimable only for an OS-/code-enforced fact (a runtime bound rides in `condition`);
|
|
71
|
+
// everything a prompt merely asks for is `advisory` (D6).
|
|
72
|
+
export const CATALOG_ENFORCEMENT = new Set(['enforced', 'advisory']);
|
|
73
|
+
// The AD-033 `contract` fields that carry invocation descriptors — the only referenceable ones.
|
|
74
|
+
export const CATALOG_CONTRACT_FIELDS = new Set(['invocations', 'continue']);
|
|
75
|
+
// The operand-slot grammar the RENDERER speaks: `<angle>` and `[bracket]` placeholders, with an
|
|
76
|
+
// optional `@` riding WITH the slot (`@<facts-file>` is one operand a user types, not a bare
|
|
77
|
+
// `<facts-file>` behind a stray character). Production owns it here; the bridge test suites keep
|
|
78
|
+
// their own local copy ON PURPOSE — they ship as standalone bridge payload and must not import the
|
|
79
|
+
// kit, so their regex is an INDEPENDENT drift oracle across the package boundary, not a duplicate.
|
|
80
|
+
export const CATALOG_SLOT_RE = /@?<[^<>]+>|\[[^[\]]*\]/g;
|
|
81
|
+
export const extractCatalogOperandSlots = (form) => (typeof form === 'string' ? form.match(CATALOG_SLOT_RE) ?? [] : []);
|
|
82
|
+
|
|
83
|
+
const CATALOG_KEY_RE = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
84
|
+
const ENV_HOOK_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
85
|
+
// eslint-disable-next-line no-control-regex
|
|
86
|
+
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
|
|
87
|
+
|
|
63
88
|
const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
|
|
64
89
|
const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
|
|
65
90
|
|
|
@@ -124,6 +149,292 @@ export const readAuthoritativeVersion = (skillDir) => {
|
|
|
124
149
|
return { version: null, from: 'no package.json or SKILL.md' };
|
|
125
150
|
};
|
|
126
151
|
|
|
152
|
+
// Validate a PRESENT `modeCatalog` block against `roles` (BRIDGE-MODES-CATALOG, D1/D2/D4/D6). It is
|
|
153
|
+
// the USER-FACING mode catalog the `bridge-modes` renderer prints verbatim and composes invocation
|
|
154
|
+
// forms from — RELATED TO, never shadowing, the AD-033 `contract` (the internal driving contract):
|
|
155
|
+
// a contract-backed entry composes BY REFERENCE and never restates a descriptor; a contract-FREE
|
|
156
|
+
// primary (a raw prompt mode) and an env-hook carry a literal one — the stated exception to
|
|
157
|
+
// no-duplication, because for them the catalog IS canonical. Errors are appended, never thrown.
|
|
158
|
+
const validateModeCatalog = (catalog, roles, errors) => {
|
|
159
|
+
const oneLine = (label, value) => {
|
|
160
|
+
if (typeof value !== 'string' || !value) {
|
|
161
|
+
errors.push(`${label} must be a non-empty string`);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (CONTROL_CHAR_RE.test(value)) {
|
|
165
|
+
errors.push(`${label} must not carry control characters`);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (value.length > CATALOG_LINE_MAX) {
|
|
169
|
+
errors.push(`${label} must be one line of at most ${CATALOG_LINE_MAX} characters`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
const stringList = (label, value) => {
|
|
175
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
176
|
+
errors.push(`${label} must be a non-empty array of one-line strings`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
value.forEach((v, i) => oneLine(`${label}[${i}]`, v));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// The key index is built FIRST: `parents[]` and `customHooks[]` resolve ACROSS entries, so a
|
|
183
|
+
// forward reference resolves exactly like a backward one (declaration order is not a contract).
|
|
184
|
+
const byKey = new Map();
|
|
185
|
+
for (const entry of catalog) {
|
|
186
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
187
|
+
if (typeof entry.key === 'string' && entry.key && !byKey.has(entry.key)) byKey.set(entry.key, entry);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const seenKeys = new Set();
|
|
191
|
+
const claimedRefs = new Set(); // "<role>.<field>[<index>]" — one contract invocation, at most one entry
|
|
192
|
+
catalog.forEach((entry, i) => {
|
|
193
|
+
const at = `\`modeCatalog[${i}]\``;
|
|
194
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
195
|
+
errors.push(`${at} must be an object`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const isEnvHook = entry.kind === 'env-hook';
|
|
199
|
+
|
|
200
|
+
if (typeof entry.key !== 'string' || !CATALOG_KEY_RE.test(entry.key)) {
|
|
201
|
+
errors.push(`${at}.key must be a bare token (a letter, then letters/digits/._-)`);
|
|
202
|
+
} else if (!oneLine(`${at}.key`, entry.key)) {
|
|
203
|
+
// The key IS the mode's printed identity — it obeys the same one-line contract as any other
|
|
204
|
+
// rendered string (the token regex already bars control characters; this bars a giant key).
|
|
205
|
+
} else if (seenKeys.has(entry.key)) {
|
|
206
|
+
errors.push(`duplicate modeCatalog key "${entry.key}" (${at})`);
|
|
207
|
+
} else {
|
|
208
|
+
seenKeys.add(entry.key);
|
|
209
|
+
if (isEnvHook && !ENV_HOOK_KEY_RE.test(entry.key)) {
|
|
210
|
+
errors.push(`${at}.key must be an UPPER_SNAKE_CASE env-var name (an env-hook's key IS its env var)`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!CATALOG_KINDS.has(entry.kind)) {
|
|
214
|
+
errors.push(`${at}.kind must be one of primary|continuation|env-hook`);
|
|
215
|
+
return; // every rule below is keyed on the kind
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
oneLine(`${at}.purpose`, entry.purpose);
|
|
219
|
+
stringList(`${at}.whenToUse`, entry.whenToUse);
|
|
220
|
+
if (Object.hasOwn(entry, 'whenNotTo')) stringList(`${at}.whenNotTo`, entry.whenNotTo);
|
|
221
|
+
|
|
222
|
+
let role = null;
|
|
223
|
+
if (isEnvHook) {
|
|
224
|
+
if (Object.hasOwn(entry, 'role')) errors.push(`${at}.role is not allowed on an env-hook (it names parents[], never a role)`);
|
|
225
|
+
if (!Array.isArray(entry.parents) || entry.parents.length === 0) {
|
|
226
|
+
errors.push(`${at}.parents is required for an env-hook (the catalog keys it modifies)`);
|
|
227
|
+
} else {
|
|
228
|
+
const seenParents = new Set();
|
|
229
|
+
for (const p of entry.parents) {
|
|
230
|
+
const target = typeof p === 'string' ? byKey.get(p) : undefined;
|
|
231
|
+
if (target === undefined) {
|
|
232
|
+
errors.push(`${at}.parents names ${JSON.stringify(p)} which is no modeCatalog key`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (p === entry.key) {
|
|
236
|
+
errors.push(`${at}.parents must not name the env-hook itself`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (seenParents.has(p)) {
|
|
240
|
+
errors.push(`duplicate parent ${JSON.stringify(p)} (${at})`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
seenParents.add(p);
|
|
244
|
+
if (target.kind === 'env-hook') {
|
|
245
|
+
errors.push(`${at}.parents names ${JSON.stringify(p)}, which is an env-hook — a hook modifies a mode, never another hook`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// The linkage is SYMMETRIC. The forward rule below stops a customHook from lying about a
|
|
249
|
+
// hook that does not target it; this is the reverse: a hook that claims a mode must be
|
|
250
|
+
// declared BY that mode, or the mode's rendered detail silently omits a hook that really
|
|
251
|
+
// changes how it runs — an incomplete discovery surface is the failure this block exists
|
|
252
|
+
// to prevent.
|
|
253
|
+
if (!Array.isArray(target.customHooks) || !target.customHooks.includes(entry.key)) {
|
|
254
|
+
errors.push(`${at}.parents names ${JSON.stringify(p)}, which does not list ${JSON.stringify(entry.key)} in its customHooks[]`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
if (Object.hasOwn(entry, 'parents')) errors.push(`${at}.parents is only allowed on an env-hook`);
|
|
260
|
+
if (!Object.hasOwn(entry, 'role')) errors.push(`${at}.role is required for a ${entry.kind} entry`);
|
|
261
|
+
else if (typeof entry.role !== 'string' || !Object.hasOwn(roles, entry.role)) {
|
|
262
|
+
errors.push(`${at}.role ${JSON.stringify(entry.role)} is no role of this manifest`);
|
|
263
|
+
} else {
|
|
264
|
+
role = entry.role;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// `submode` is the EXPLICIT parser-arm binding (D4) — the drift test set-equals these against the
|
|
269
|
+
// wrapper's real mode arms, so the binding is never parsed back out of the key.
|
|
270
|
+
const roleModes = role != null && Array.isArray(roles[role]?.modes) ? roles[role].modes : null;
|
|
271
|
+
if (Object.hasOwn(entry, 'submode')) {
|
|
272
|
+
if (typeof entry.submode !== 'string' || !entry.submode) errors.push(`${at}.submode must be a non-empty string (it names a parser mode arm)`);
|
|
273
|
+
else if (entry.kind !== 'primary') errors.push(`${at}.submode is only allowed on a primary entry (only a primary binds a parser mode arm)`);
|
|
274
|
+
else if (roleModes == null || roleModes.length === 0) errors.push(`${at}.submode is only allowed when the entry's role declares modes[]`);
|
|
275
|
+
else if (!roleModes.includes(entry.submode)) errors.push(`${at}.submode ${JSON.stringify(entry.submode)} is no declared mode of role "${role}"`);
|
|
276
|
+
} else if (entry.kind === 'primary' && roleModes != null && roleModes.length > 0) {
|
|
277
|
+
errors.push(`${at}.submode is required (a primary whose role declares modes[] binds one)`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const contract = role != null ? roles[role]?.contract : null;
|
|
281
|
+
const contractBacked = !isEnvHook && contract != null && typeof contract === 'object' && !Array.isArray(contract);
|
|
282
|
+
// The kind BINDS the contract field it may claim: a primary DRIVES a mode (`invocations`), a
|
|
283
|
+
// continuation RESUMES one (`continue`). Crossing them would render a resume form as a drive
|
|
284
|
+
// form (or the reverse) — a lie in exactly the surface the catalog exists to make honest.
|
|
285
|
+
const requiredField = entry.kind === 'primary' ? 'invocations' : 'continue';
|
|
286
|
+
// A continuation has nothing to be canonical ABOUT: D6's literal-descriptor exception covers a
|
|
287
|
+
// contract-free PRIMARY (a raw prompt mode) and an env-hook only. A role with no `continue`
|
|
288
|
+
// contract simply has no continuation to catalog.
|
|
289
|
+
if (entry.kind === 'continuation' && !contractBacked) {
|
|
290
|
+
errors.push(`${at}: a continuation must be contract-backed (the literal-descriptor exception covers contract-free primaries and env-hooks only)`);
|
|
291
|
+
}
|
|
292
|
+
// The invocation forms this entry really renders — the domain every operand slot must live in.
|
|
293
|
+
const forms = [];
|
|
294
|
+
if (contractBacked) {
|
|
295
|
+
if (Object.hasOwn(entry, 'descriptor')) errors.push(`${at}.descriptor is only allowed on a contract-free primary or an env-hook (a contract-backed entry composes BY REFERENCE)`);
|
|
296
|
+
if (!Array.isArray(entry.invocationRefs) || entry.invocationRefs.length === 0) {
|
|
297
|
+
errors.push(`${at}.invocationRefs is required (non-empty) for a contract-backed entry`);
|
|
298
|
+
} else {
|
|
299
|
+
entry.invocationRefs.forEach((ref, j) => {
|
|
300
|
+
const rat = `${at}.invocationRefs[${j}]`;
|
|
301
|
+
if (ref == null || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
302
|
+
errors.push(`${rat} must be a {contractField, index} object`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!CATALOG_CONTRACT_FIELDS.has(ref.contractField)) {
|
|
306
|
+
errors.push(`${rat}.contractField must be one of invocations|continue`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (ref.contractField !== requiredField) {
|
|
310
|
+
errors.push(`${rat}.contractField must be "${requiredField}" for a ${entry.kind} entry (a primary drives, a continuation resumes)`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!Number.isSafeInteger(ref.index) || ref.index < 0) {
|
|
314
|
+
errors.push(`${rat}.index must be a non-negative integer`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const declared = contract[ref.contractField];
|
|
318
|
+
const form = Array.isArray(declared) ? declared[ref.index] : undefined;
|
|
319
|
+
if (typeof form !== 'string' || !form) {
|
|
320
|
+
errors.push(`${rat} does not resolve (roles.${role}.contract.${ref.contractField}[${ref.index}])`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// A REFERENCED form is printed exactly like a literal descriptor, so it obeys the same
|
|
324
|
+
// one-line contract. The AD-033 `contract` block is otherwise validator-TOLERATED (its
|
|
325
|
+
// shape is drift-guarded by the bridge tests, not here) — this checks only the invocations
|
|
326
|
+
// a catalog entry causes to be PRINTED, and the label names the contract as their home.
|
|
327
|
+
if (!oneLine(`${rat} → roles.${role}.contract.${ref.contractField}[${ref.index}]`, form)) return;
|
|
328
|
+
const claim = `${role}.${ref.contractField}[${ref.index}]`;
|
|
329
|
+
if (claimedRefs.has(claim)) errors.push(`duplicate modeCatalog invocation reference ${claim} (${at})`);
|
|
330
|
+
else claimedRefs.add(claim);
|
|
331
|
+
forms.push(form);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
if (Object.hasOwn(entry, 'invocationRefs')) errors.push(`${at}.invocationRefs is only allowed on a contract-backed entry`);
|
|
336
|
+
if (!Object.hasOwn(entry, 'descriptor')) errors.push(`${at}.descriptor is required (a contract-free entry is canonical for its own invocation form)`);
|
|
337
|
+
else if (oneLine(`${at}.descriptor`, entry.descriptor)) forms.push(entry.descriptor);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Descriptor honesty (D2), BOTH ways. The render labels an unfilled form a TEMPLATE and names
|
|
341
|
+
// each required operand, so the declared slots and the placeholders the forms really carry must
|
|
342
|
+
// be the SAME set: an invented slot names an operand with nowhere to go, and an UNDECLARED
|
|
343
|
+
// placeholder is worse — the render shows a form as if it were ready to run while the reader has
|
|
344
|
+
// no idea what to put there or whether it is required. `required` is deliberately NOT inferred
|
|
345
|
+
// from the bracket shape (`<extra flags...>` is legitimately optional): the grammar fixes a
|
|
346
|
+
// slot's IDENTITY, the catalog fixes its semantics.
|
|
347
|
+
const renderedSlots = new Set(forms.flatMap((f) => extractCatalogOperandSlots(f)));
|
|
348
|
+
const hasOperands = Object.hasOwn(entry, 'operands');
|
|
349
|
+
if (forms.length > 0 && renderedSlots.size > 0 && !hasOperands) {
|
|
350
|
+
errors.push(`${at}.operands is required because its invocation forms contain rendered operand slots`);
|
|
351
|
+
}
|
|
352
|
+
if (hasOperands) {
|
|
353
|
+
if (!Array.isArray(entry.operands) || entry.operands.length === 0) {
|
|
354
|
+
errors.push(`${at}.operands must be an array of typed operand slots`);
|
|
355
|
+
} else {
|
|
356
|
+
const seenSlots = new Set();
|
|
357
|
+
entry.operands.forEach((op, j) => {
|
|
358
|
+
const oat = `${at}.operands[${j}]`;
|
|
359
|
+
if (op == null || typeof op !== 'object' || Array.isArray(op)) {
|
|
360
|
+
errors.push(`${oat} must be a {slot, required, description} object`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const slotOk = oneLine(`${oat}.slot`, op.slot);
|
|
364
|
+
if (typeof op.required !== 'boolean') errors.push(`${oat}.required must be a boolean`);
|
|
365
|
+
oneLine(`${oat}.description`, op.description);
|
|
366
|
+
if (!slotOk) return;
|
|
367
|
+
if (seenSlots.has(op.slot)) errors.push(`duplicate operand slot "${op.slot}" (${oat})`);
|
|
368
|
+
else seenSlots.add(op.slot);
|
|
369
|
+
// Checked only once the forms really resolved (else the error is noise).
|
|
370
|
+
if (forms.length > 0 && !renderedSlots.has(op.slot)) {
|
|
371
|
+
errors.push(`${oat}.slot "${op.slot}" is not a rendered placeholder in this entry's invocation forms`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (forms.length > 0) {
|
|
375
|
+
for (const slot of renderedSlots) {
|
|
376
|
+
if (!seenSlots.has(slot)) errors.push(`${at}.operands is missing rendered slot "${slot}"`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (Object.hasOwn(entry, 'guardrails')) {
|
|
383
|
+
if (!Array.isArray(entry.guardrails) || entry.guardrails.length === 0) {
|
|
384
|
+
errors.push(`${at}.guardrails must be an array of typed guardrail entries`);
|
|
385
|
+
} else {
|
|
386
|
+
entry.guardrails.forEach((g, j) => {
|
|
387
|
+
const gat = `${at}.guardrails[${j}]`;
|
|
388
|
+
if (g == null || typeof g !== 'object' || Array.isArray(g)) {
|
|
389
|
+
errors.push(`${gat} must be a {value, enforcement, condition?, source} object`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
oneLine(`${gat}.value`, g.value);
|
|
393
|
+
if (!CATALOG_ENFORCEMENT.has(g.enforcement)) errors.push(`${gat}.enforcement must be one of enforced|advisory`);
|
|
394
|
+
if (Object.hasOwn(g, 'condition')) oneLine(`${gat}.condition`, g.condition);
|
|
395
|
+
oneLine(`${gat}.source`, g.source);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (Object.hasOwn(entry, 'customHooks')) {
|
|
401
|
+
if (!Array.isArray(entry.customHooks) || entry.customHooks.length === 0) {
|
|
402
|
+
errors.push(`${at}.customHooks must be a non-empty array of modeCatalog keys`);
|
|
403
|
+
} else {
|
|
404
|
+
const seenHooks = new Set();
|
|
405
|
+
for (const hook of entry.customHooks) {
|
|
406
|
+
const target = typeof hook === 'string' ? byKey.get(hook) : undefined;
|
|
407
|
+
if (target === undefined) {
|
|
408
|
+
errors.push(`${at}.customHooks names ${JSON.stringify(hook)} which is no modeCatalog key`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (seenHooks.has(hook)) {
|
|
412
|
+
errors.push(`duplicate customHook "${hook}" (${at})`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
seenHooks.add(hook);
|
|
416
|
+
if (hook === entry.key) {
|
|
417
|
+
// The raw-mode carve-out (D4): a contract-free primary IS its own escape, so it names
|
|
418
|
+
// itself rather than repeating an escape it does not have.
|
|
419
|
+
if (contractBacked || entry.kind !== 'primary') {
|
|
420
|
+
errors.push(`${at}.customHooks may name this entry itself only on a contract-free primary (the raw mode)`);
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (target.kind !== 'env-hook') {
|
|
425
|
+
errors.push(`${at}.customHooks names ${JSON.stringify(hook)}, which is neither an env-hook nor this entry itself`);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
// A customHook can never LIE about a hook that does not target this mode.
|
|
429
|
+
if (!Array.isArray(target.parents) || !target.parents.includes(entry.key)) {
|
|
430
|
+
errors.push(`${at}.customHooks names env-hook "${hook}", which does not list ${JSON.stringify(entry.key)} in its parents[]`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
|
|
127
438
|
export const validateManifest = (skillDir) => {
|
|
128
439
|
const manifestPath = join(skillDir, 'capability.json');
|
|
129
440
|
let raw;
|
|
@@ -377,6 +688,22 @@ export const validateManifest = (skillDir) => {
|
|
|
377
688
|
}
|
|
378
689
|
}
|
|
379
690
|
|
|
691
|
+
// `modeCatalog` (BRIDGE-MODES-CATALOG, D1): the user-facing mode catalog. ADDITIVE-OPTIONAL —
|
|
692
|
+
// an absent block is VALID (a bridge predating the catalog stays valid; the mode renders a stated
|
|
693
|
+
// "no catalog" line, never invalid-manifest and never an empty silent list). A PRESENT block is
|
|
694
|
+
// typed-validated like `settings`: the renderer prints its strings verbatim and composes runnable
|
|
695
|
+
// forms from its refs, so a malformed entry would render a lying discovery surface.
|
|
696
|
+
// Presence is keyed on the KEY, never on non-null: an explicit `modeCatalog: null` is a PRESENT
|
|
697
|
+
// malformed block, not an absence (absence is "this bridge predates the catalog"). An EMPTY array
|
|
698
|
+
// is likewise invalid — it would render as "this bridge has no modes", which is never true and is
|
|
699
|
+
// exactly the silent empty list D1 forbids; a bridge with nothing to say omits the block.
|
|
700
|
+
if (Object.hasOwn(manifest, 'modeCatalog')) {
|
|
701
|
+
const modeCatalog = manifest.modeCatalog;
|
|
702
|
+
if (!Array.isArray(modeCatalog)) errors.push('`modeCatalog` must be an array of mode entries');
|
|
703
|
+
else if (modeCatalog.length === 0) errors.push('`modeCatalog` must not be empty — a declared catalog states at least one mode (omit the block instead)');
|
|
704
|
+
else validateModeCatalog(modeCatalog, roles, errors);
|
|
705
|
+
}
|
|
706
|
+
|
|
380
707
|
if (!isStub) {
|
|
381
708
|
const auth = readAuthoritativeVersion(skillDir);
|
|
382
709
|
if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
|
|
@@ -348,6 +348,80 @@ export const filterSegmentRecords = (records, { activity, loop, base }) =>
|
|
|
348
348
|
// (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
|
|
349
349
|
// round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
|
|
350
350
|
// stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
|
|
351
|
+
// ── the attesting-receipt predicate (BRIDGE-MODES-CATALOG, D3) ─────────────────────────
|
|
352
|
+
// The ONE place that decides whether a review receipt may attest a tree. It lives in this neutral
|
|
353
|
+
// core because all three consumers — review-state.mjs (the receipt gate), review-ledger.mjs (the
|
|
354
|
+
// round cross-check) and review-ledger-write.mjs (the round writer) — need it, and the core imports
|
|
355
|
+
// none of them: the same DAG reason this module exists at all. Two gates disagreeing about what
|
|
356
|
+
// counts as an attestation is exactly the class AD-050 closed; a second copy would re-open it.
|
|
357
|
+
export const REVIEW_RECEIPT_CLASS = Object.freeze({
|
|
358
|
+
NOT_CURRENT: 'not-current',
|
|
359
|
+
ATTESTING: 'attesting',
|
|
360
|
+
UNGROUNDED: 'ungrounded',
|
|
361
|
+
PROBE: 'probe',
|
|
362
|
+
UNMARKED: 'unmarked',
|
|
363
|
+
MALFORMED_MARKER: 'malformed-marker',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Classify ONE receipt against a tree fingerprint. Order is load-bearing: identity first (is this
|
|
367
|
+
// even about the current tree?), then the probe marker (may it attest at all?), then grounding.
|
|
368
|
+
export const classifyReviewReceiptForTree = (receipt, fingerprint) => {
|
|
369
|
+
if (!isPlainObject(receipt) || receipt.fresh !== true || receipt.artifact !== 'code' || receipt.fingerprint !== fingerprint) {
|
|
370
|
+
return REVIEW_RECEIPT_CLASS.NOT_CURRENT;
|
|
371
|
+
}
|
|
372
|
+
if (!Object.hasOwn(receipt, 'probe')) return REVIEW_RECEIPT_CLASS.UNMARKED;
|
|
373
|
+
if (typeof receipt.probe !== 'boolean') return REVIEW_RECEIPT_CLASS.MALFORMED_MARKER;
|
|
374
|
+
if (receipt.probe === true) return REVIEW_RECEIPT_CLASS.PROBE;
|
|
375
|
+
if (receipt.grounded !== true) return REVIEW_RECEIPT_CLASS.UNGROUNDED;
|
|
376
|
+
return REVIEW_RECEIPT_CLASS.ATTESTING;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Summarize one backend's receipts for a tree → { state, receipt, counts… }. `receipt` is the LATEST
|
|
380
|
+
// ATTESTING one — never simply the last line: a probe (or a forged marker) written after a real
|
|
381
|
+
// review must never become the authoritative verdict, which is precisely how a late probe SHIP could
|
|
382
|
+
// override an earlier real REWORK and let both gates report convergence.
|
|
383
|
+
export const summarizeReviewReceiptsForTree = (receipts, fingerprint) => {
|
|
384
|
+
const classified = receipts
|
|
385
|
+
.map((receipt) => ({ receipt, classification: classifyReviewReceiptForTree(receipt, fingerprint) }))
|
|
386
|
+
.filter(({ classification }) => classification !== REVIEW_RECEIPT_CLASS.NOT_CURRENT);
|
|
387
|
+
const rowsFor = (classification) => classified.filter((row) => row.classification === classification);
|
|
388
|
+
const attesting = rowsFor(REVIEW_RECEIPT_CLASS.ATTESTING);
|
|
389
|
+
const ungrounded = rowsFor(REVIEW_RECEIPT_CLASS.UNGROUNDED);
|
|
390
|
+
const probe = rowsFor(REVIEW_RECEIPT_CLASS.PROBE);
|
|
391
|
+
const unmarked = rowsFor(REVIEW_RECEIPT_CLASS.UNMARKED);
|
|
392
|
+
const malformedMarker = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_MARKER);
|
|
393
|
+
const counts = {
|
|
394
|
+
currentCount: classified.length,
|
|
395
|
+
ungroundedCount: ungrounded.length,
|
|
396
|
+
probeExcluded: probe.length,
|
|
397
|
+
markerRejected: malformedMarker.length,
|
|
398
|
+
unmarkedRejected: unmarked.length,
|
|
399
|
+
};
|
|
400
|
+
if (attesting.length > 0) return { state: 'current', receipt: attesting[attesting.length - 1].receipt, ...counts };
|
|
401
|
+
if (ungrounded.length > 0) return { state: 'ungrounded', receipt: ungrounded[ungrounded.length - 1].receipt, ...counts };
|
|
402
|
+
if (classified.length > 0) {
|
|
403
|
+
return { state: malformedMarker.length > 0 || unmarked.length > 0 ? 'rejected' : 'probe', receipt: null, ...counts };
|
|
404
|
+
}
|
|
405
|
+
return { state: 'none', receipt: null, ...counts };
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Why this backend has no attestation — one stated sentence, never a silent "no receipt". The four
|
|
409
|
+
// causes have DIFFERENT recoveries (run a real review / refresh the bridge / fix the receipt source /
|
|
410
|
+
// re-run grounded), so they are never collapsed into one message.
|
|
411
|
+
export const describeMissingReviewAttestation = (summary) => {
|
|
412
|
+
if (summary.state === 'current') return null;
|
|
413
|
+
const exclusions = [
|
|
414
|
+
summary.probeExcluded > 0 ? `${summary.probeExcluded} probe receipt(s)` : null,
|
|
415
|
+
summary.markerRejected > 0 ? `${summary.markerRejected} receipt(s) with a malformed probe marker` : null,
|
|
416
|
+
summary.unmarkedRejected > 0 ? `${summary.unmarkedRejected} receipt(s) with no probe marker` : null,
|
|
417
|
+
].filter(Boolean);
|
|
418
|
+
const exclusionSuffix = exclusions.length > 0 ? `; excluded ${exclusions.join(', ')}` : '';
|
|
419
|
+
if (summary.state === 'ungrounded') return `only ungrounded normal receipts exist for the current tree${exclusionSuffix}`;
|
|
420
|
+
if (summary.state === 'probe') return 'only probe receipts exist for the current tree — a probe review never attests';
|
|
421
|
+
if (summary.state === 'rejected') return `current-tree receipts have untrustworthy probe markers${exclusionSuffix}`;
|
|
422
|
+
return 'no fresh code receipt exists for the current tree';
|
|
423
|
+
};
|
|
424
|
+
|
|
351
425
|
export const roundSequenceIntact = (records) => {
|
|
352
426
|
const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
|
|
353
427
|
return nums.every((n, i) => n === i + 1);
|