@sabaiway/agent-workflow-kit 1.24.0 → 1.26.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.
@@ -540,3 +540,130 @@ describe('codex-review.sh — assemble & output edge cases', () => {
540
540
  assert.match(r.stdout, /FAKE_FINAL_MESSAGE/);
541
541
  });
542
542
  });
543
+
544
+ // ── driving contract: --help ⟷ manifest ⟷ real arg-parsing (drift-guarded) ─────
545
+ // The manifest roles.review.contract is the single machine-readable source of the
546
+ // driving contract; these suites pin (a) --help renders it verbatim (set-EQUALITY,
547
+ // both directions), (b) the wrapper's REAL parser arms equal the declared sets
548
+ // (source-level reverse guard), (c) each declared mode is really accepted.
549
+ // Helpers are inline — each bridge test file stays standalone (mirror byte-equality).
550
+
551
+ const MANIFEST = JSON.parse(readFileSync(join(HERE, '..', 'capability.json'), 'utf8'));
552
+ const REVIEW_CONTRACT = MANIFEST.roles.review.contract;
553
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
554
+ const setEq = (got, want, msg) => assert.deepEqual([...got].sort(), [...want].sort(), msg);
555
+
556
+ // Run `--help`/-h with PATH stripped of codex/agy/git, from a non-git cwd with no
557
+ // AGENTS.md — proving the short-circuit fires BEFORE every preflight.
558
+ const runHelp = (arg) => {
559
+ const root = mkdtempSync(join(tmpdir(), 'codex-review-help-'));
560
+ const nongit = join(root, 'nongit');
561
+ mkdirSync(nongit, { recursive: true });
562
+ const path = makePathWithout(root, ['codex', 'agy', 'git']);
563
+ const r = spawnSync('bash', [WRAPPER, arg], {
564
+ cwd: nongit, encoding: 'utf8', timeout: 15000, env: { HOME: root, PATH: path },
565
+ });
566
+ rmSync(root, { recursive: true, force: true });
567
+ return r;
568
+ };
569
+
570
+ // The lines of a labelled --help section (header line → the next blank line).
571
+ const helpSection = (text, header) => {
572
+ const lines = text.split('\n');
573
+ const i = lines.findIndex((l) => l.trim() === header);
574
+ assert.notEqual(i, -1, `--help must carry a "${header}" section`);
575
+ const out = [];
576
+ for (let j = i + 1; j < lines.length; j += 1) {
577
+ if (lines[j].trim() === '') break;
578
+ out.push(lines[j].trim());
579
+ }
580
+ return out;
581
+ };
582
+
583
+ // Source-level parser-arm extractor — the reverse drift guard. Scans ONLY `case`
584
+ // statements whose SUBJECT is a CLI-argument variable (allowlisted), skipping
585
+ // heredoc bodies (a heredoc may carry non-CLI `case` arms — e.g. codex-exec's
586
+ // git-shim). Returns Map(subject → [raw arm label, …]) in source order.
587
+ const ARG_SUBJECTS = new Set(['"$mode"', '"${1:-}"', '"$1"', '"$_arg"']);
588
+ const extractArgCaseArms = (source) => {
589
+ const arms = new Map();
590
+ const stack = [];
591
+ let heredoc = null;
592
+ for (const raw of source.split('\n')) {
593
+ if (heredoc) {
594
+ if (raw.trim() === heredoc) heredoc = null;
595
+ continue;
596
+ }
597
+ if (raw.trimStart().startsWith('#')) continue; // a comment line may carry a stray ')'
598
+ const hd = raw.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/);
599
+ if (hd) { heredoc = hd[1]; continue; }
600
+ const cs = raw.match(/^\s*case\s+(\S+)\s+in\b/);
601
+ if (cs) { stack.push(cs[1]); continue; }
602
+ if (/^\s*esac\b/.test(raw)) { stack.pop(); continue; }
603
+ if (stack.length && ARG_SUBJECTS.has(stack[stack.length - 1])) {
604
+ const arm = raw.match(/^\s*([^)(\s][^)(]*)\)/);
605
+ if (arm) {
606
+ const subject = stack[stack.length - 1];
607
+ if (!arms.has(subject)) arms.set(subject, []);
608
+ arms.get(subject).push(arm[1].trim());
609
+ }
610
+ }
611
+ }
612
+ return arms;
613
+ };
614
+ const splitArms = (labels) => (labels ?? []).flatMap((l) => l.split('|'));
615
+
616
+ describe('codex-review.sh — --help contract (manifest-pinned)', () => {
617
+ it('--help and -h exit 0 pre-preflight (no codex, no git, no AGENTS.md)', () => {
618
+ for (const arg of ['--help', '-h']) {
619
+ const r = runHelp(arg);
620
+ assert.equal(r.status, 0, `${arg}: ${r.stderr}`);
621
+ assert.match(r.stdout, /Usage:/, `${arg} prints the contract to stdout`);
622
+ assert.equal(r.stderr, '', `${arg} prints nothing to stderr`);
623
+ }
624
+ });
625
+
626
+ it('Usage set-EQUALS the manifest invocation descriptors (both directions)', () => {
627
+ const help = runHelp('--help').stdout;
628
+ const got = helpSection(help, 'Usage:').filter((l) => l.startsWith('codex-review')).map(norm);
629
+ assert.ok(REVIEW_CONTRACT.invocations.length > 0, 'manifest invocations must be non-empty');
630
+ setEq(got, REVIEW_CONTRACT.invocations.map(norm), 'help Usage ⟷ manifest invocations');
631
+ });
632
+
633
+ it('Grounding renders the manifest grounding note verbatim', () => {
634
+ const help = runHelp('--help').stdout;
635
+ assert.equal(norm(helpSection(help, 'Grounding:').join(' ')), norm(REVIEW_CONTRACT.grounding));
636
+ });
637
+
638
+ it('Round-2 / resume set-EQUALS the manifest continue descriptors (empty — one-shot)', () => {
639
+ const help = runHelp('--help').stdout;
640
+ const got = helpSection(help, 'Round-2 / resume:').filter((l) => l.startsWith('codex-review')).map(norm);
641
+ setEq(got, (REVIEW_CONTRACT.continue ?? []).map(norm), 'help continue ⟷ manifest continue');
642
+ assert.deepEqual(REVIEW_CONTRACT.continue, [], 'codex-review is one-shot — no continue descriptor');
643
+ });
644
+ });
645
+
646
+ describe('codex-review.sh — source-level reverse guard (parser arms ⟷ manifest)', () => {
647
+ const arms = extractArgCaseArms(readFileSync(WRAPPER, 'utf8'));
648
+
649
+ it('the real mode arms equal the manifest modes (adding a mode without the manifest fails here)', () => {
650
+ const modes = splitArms(arms.get('"$mode"')).filter((a) => a !== '*');
651
+ assert.ok(MANIFEST.roles.review.modes.length > 0, 'manifest modes must be non-empty');
652
+ setEq(new Set(modes), MANIFEST.roles.review.modes, 'parser mode arms ⟷ manifest modes');
653
+ });
654
+
655
+ it('the first-arg entrypoints are exactly --help/-h (no undeclared resume/flag entrypoint)', () => {
656
+ setEq(new Set(splitArms(arms.get('"${1:-}"'))), ['--help', '-h']);
657
+ });
658
+
659
+ it('every manifest mode is really accepted (forward guard)', () => {
660
+ const drive = { plan: ['plan', 'plan.md'], code: ['code'] };
661
+ for (const mode of MANIFEST.roles.review.modes) {
662
+ assert.ok(drive[mode], `no test drive for manifest mode "${mode}" — add one`);
663
+ const sb = makeSandbox();
664
+ const r = run(sb, { args: drive[mode] });
665
+ rmSync(sb.root, { recursive: true, force: true });
666
+ assert.equal(r.status, 0, `mode ${mode}: ${r.stderr}`);
667
+ }
668
+ });
669
+ });
@@ -3,11 +3,44 @@
3
3
  "schema": 1,
4
4
  "name": "codex-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "2.0.0",
6
+ "version": "2.1.0",
7
7
  "provides": ["execute", "review"],
8
8
  "roles": {
9
- "execute": { "cmd": "codex-exec", "source": "bin/codex-exec.sh", "output": "diff" },
10
- "review": { "cmd": "codex-review", "source": "bin/codex-review.sh", "modes": ["plan", "code"], "output": "advisory" }
9
+ "execute": {
10
+ "cmd": "codex-exec",
11
+ "source": "bin/codex-exec.sh",
12
+ "output": "diff",
13
+ "contract": {
14
+ "invocations": [
15
+ "codex-exec <plan-file|->",
16
+ "codex-exec <plan-file|-> -- <extra codex flags...>"
17
+ ],
18
+ "grounding": "automatic — the root AGENTS.md (Hard Constraints) is auto-merged into codex's context and the wrapper prepends the orchestrator execution contract; no grounding flags",
19
+ "continue": [
20
+ "codex-exec --resume-last <plan-file|->",
21
+ "codex-exec --resume <session-id> <plan-file|->"
22
+ ],
23
+ "passthrough": {
24
+ "policy": "guarded",
25
+ "blocked": ["-c*", "--config*", "-s*", "--sandbox*", "--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust", "--full-auto", "--oss", "--local-provider*", "-p*", "--profile*", "-m*", "--model*", "-o*", "--output-last-message*", "--json*", "--color*", "--output-schema*", "--ephemeral*"],
26
+ "probeRelaxed": ["--add-dir*", "-C*", "--cd*", "--skip-git-repo-check", "--ignore-rules", "--enable*", "--disable*"]
27
+ }
28
+ }
29
+ },
30
+ "review": {
31
+ "cmd": "codex-review",
32
+ "source": "bin/codex-review.sh",
33
+ "modes": ["plan", "code"],
34
+ "output": "advisory",
35
+ "contract": {
36
+ "invocations": [
37
+ "codex-review plan <plan-file>",
38
+ "codex-review code [extra focus...]"
39
+ ],
40
+ "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",
41
+ "continue": []
42
+ }
43
+ }
11
44
  },
12
45
  "detect": {
13
46
  "installed": {
@@ -50,7 +50,7 @@ object (raw-text fallback on failure).
50
50
  ```bash
51
51
  codex-exec docs/plans/<slug>.md # drive a plan file
52
52
  echo "apply review fix: tighten the guard in X, keep tests green" | codex-exec -
53
- codex-exec <file|-> -- <unguarded codex flags> # passthrough AFTER `--` (policy/model/capture flags are rejected)
53
+ codex-exec <file|-> -- <extra codex flags> # GUARDED passthrough AFTER `--` (policy/model/capture flags rejected; some relaxed only under CODEX_PROBE=1)
54
54
 
55
55
  codex-exec --resume-last docs/plans/<slug>.md # continue the last session, no re-send
56
56
  echo "now do step 2" | codex-exec --resume <id> -
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.24.0",
6
+ "version": "1.26.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.24.0",
3
+ "version": "1.26.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",
@@ -113,7 +113,7 @@ const CATALOG = [
113
113
  invocation: invocationOf('procedures'),
114
114
  group: 'Orchestrate',
115
115
  kind: READ_ONLY,
116
- oneLine: 'Show a named activity’s steps and which recipe applies at each stage here.',
116
+ oneLine: 'Show a named activity’s steps, the recipe per stage, and each dispatched backend’s exact driving contract (invocation + grounding + round-2 delta).',
117
117
  },
118
118
  {
119
119
  key: 'set-recipe',
@@ -60,6 +60,36 @@ const RAW_BACKENDS = [
60
60
  name: 'codex-cli-bridge',
61
61
  installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
62
62
  roleCmds: { execute: 'codex-exec', review: 'codex-review' },
63
+ // The per-role DRIVING CONTRACT (exact invocation descriptors + grounding + round-2 continue),
64
+ // mirroring the bridge manifest roles[role].contract byte-for-byte — drift-guarded like roleCmds.
65
+ // Scope = dispatchable recipe roles ONLY (review, execute): the probe role is never dispatched by
66
+ // an activity slot, so it carries NO contract here (wrapperContractFor(_, 'probe') → null).
67
+ roleContracts: {
68
+ execute: {
69
+ invocations: [
70
+ 'codex-exec <plan-file|->',
71
+ 'codex-exec <plan-file|-> -- <extra codex flags...>',
72
+ ],
73
+ grounding: "automatic — the root AGENTS.md (Hard Constraints) is auto-merged into codex's context and the wrapper prepends the orchestrator execution contract; no grounding flags",
74
+ continue: [
75
+ 'codex-exec --resume-last <plan-file|->',
76
+ 'codex-exec --resume <session-id> <plan-file|->',
77
+ ],
78
+ passthrough: {
79
+ policy: 'guarded',
80
+ blocked: ['-c*', '--config*', '-s*', '--sandbox*', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--oss', '--local-provider*', '-p*', '--profile*', '-m*', '--model*', '-o*', '--output-last-message*', '--json*', '--color*', '--output-schema*', '--ephemeral*'],
81
+ probeRelaxed: ['--add-dir*', '-C*', '--cd*', '--skip-git-repo-check', '--ignore-rules', '--enable*', '--disable*'],
82
+ },
83
+ },
84
+ review: {
85
+ invocations: [
86
+ 'codex-review plan <plan-file>',
87
+ 'codex-review code [extra focus...]',
88
+ ],
89
+ 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',
90
+ continue: [],
91
+ },
92
+ },
63
93
  bin: 'codex',
64
94
  credential: { env: 'CODEX_HOME', default: '~/.codex', file: 'auth.json' },
65
95
  setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/codex-cli-bridge/setup/README.md',
@@ -72,6 +102,26 @@ const RAW_BACKENDS = [
72
102
  name: 'antigravity-cli-bridge',
73
103
  installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
74
104
  roleCmds: { review: 'agy-review', probe: 'agy-run' },
105
+ // Mirror of the manifest roles.review.contract (see the codex entry note). probe: NO contract.
106
+ roleContracts: {
107
+ review: {
108
+ invocations: [
109
+ 'agy-review code [--facts @f] [--decided @f] [--focus "…"] [extra focus…]',
110
+ 'agy-review plan <plan-file> [--facts @f] [--decided @f] [--focus "…"]',
111
+ 'agy-review diff <diff-file> [--facts @f] [--decided @f] [--focus "…"]',
112
+ ],
113
+ 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)',
114
+ flags: [
115
+ '--facts @f — verified facts the review runs AGAINST (omit ⇒ loud ungrounded-review warning)',
116
+ '--decided @f — already-decided / already-addressed list; do NOT re-raise (anti-circling; the round-2 payload)',
117
+ '--focus "…" — extra focus (repeatable; code mode also takes trailing focus words)',
118
+ ],
119
+ continue: [
120
+ 'agy-review --continue [--decided @f] [--focus "…"]',
121
+ 'agy-review --conversation <id> [--decided @f] [--focus "…"]',
122
+ ],
123
+ },
124
+ },
75
125
  bin: 'agy',
76
126
  credential: { env: null, default: '~/.gemini/antigravity-cli', file: 'antigravity-oauth-token' },
77
127
  setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/antigravity-cli-bridge/setup/README.md',
@@ -91,6 +141,15 @@ export const KNOWN_BACKENDS = RAW_BACKENDS.map((entry) => ({ ...entry, wrapperCm
91
141
  export const wrapperCmdFor = (backendName, role) =>
92
142
  KNOWN_BACKENDS.find((b) => b.name === backendName)?.roleCmds?.[role] ?? null;
93
143
 
144
+ // Resolve a dispatched (backend, role) to its structured DRIVING CONTRACT — the registry mirror of
145
+ // the bridge manifest roles[role].contract: exact invocation descriptor(s), the grounding note, the
146
+ // closed flag set (when the wrapper's grammar is closed), the round-2/continue descriptor(s), and
147
+ // the guarded passthrough tiers (codex-exec). The point-of-use advisor (procedures.mjs) renders this
148
+ // VERBATIM so a driving agent never re-derives the contract from wrapper source. `null` when the
149
+ // pair is unknown or the role carries no contract (probe — never dispatched by an activity slot).
150
+ export const wrapperContractFor = (backendName, role) =>
151
+ KNOWN_BACKENDS.find((b) => b.name === backendName)?.roleContracts?.[role] ?? null;
152
+
94
153
  // ── pure helpers ─────────────────────────────────────────────────────────────
95
154
 
96
155
  // Expand a leading "~" / "~/x" against home; absolute and relative paths pass through untouched.
@@ -13,7 +13,8 @@
13
13
  // ── the unified registry ───────────────────────────────────────────────────────
14
14
  // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
15
15
  // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
16
- // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
16
+ // `npm` is the install package (null for the bridges placed by `setup`, and once placed refreshed
17
+ // by `init`/`upgrade` from the kit's bundled copies, never npm);
17
18
  // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
18
19
  // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
19
20
  // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
@@ -17,10 +17,13 @@
17
17
  // side effects on import (the isDirectRun idiom) — tests import the helpers with nothing run.
18
18
 
19
19
  import { existsSync, statSync, readFileSync, lstatSync } from 'node:fs';
20
- import { join, resolve } from 'node:path';
21
- import { pathToFileURL } from 'node:url';
20
+ import { join, resolve, dirname } from 'node:path';
21
+ import { pathToFileURL, fileURLToPath } from 'node:url';
22
22
  import os from 'node:os';
23
23
  import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
24
+ // The ONE dependency-free semver (shared with bin/install.mjs) — the bridge freshness probe compares
25
+ // the placed version against the kit-bundled mirror; null-on-unparseable maps to 'unknown' (INV-B).
26
+ import { parseSemver, compareSemver } from './semver-lite.mjs';
24
27
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
25
28
  import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
26
29
  import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
@@ -57,6 +60,10 @@ import {
57
60
  VISIBILITY_PUBLIC,
58
61
  DISPLAY_NAMES,
59
62
  displayOf,
63
+ FRESH_CURRENT,
64
+ FRESH_BEHIND,
65
+ FRESH_UNKNOWN,
66
+ FRESH_NOT_CHECKED,
60
67
  } from './labels.mjs';
61
68
  // The capability-adaptive direct-CLI presenter (Plan §4.2/§4.5): the surface detector, the
62
69
  // envelope→ViewModel transform, and the plain/ansi renderers. main() composes them; the agent-mediated
@@ -135,7 +142,18 @@ export const classifyMember = (member, deps = {}) => {
135
142
  const report = markerPresent ? validate(skillDir) : { result: NOT_INSTALLED };
136
143
  const manifestState = classifyState(markerPresent, report, member);
137
144
  const installed = manifestState !== NOT_INSTALLED;
138
- const version = manifestState === OK ? readVersion(skillDir).version ?? null : null;
145
+ // Crash-safe version read: readAuthoritativeVersion THROWS on a present-but-unreadable SKILL.md
146
+ // (stat needs no read permission, readFileSync does — a TOCTOU/EACCES window after validate).
147
+ // The read-only survey must never crash on it: degrade to null, which the bridge freshness probe
148
+ // then reports as 'unknown' (INV-B), never as a throw and never as a silent claim.
149
+ const version = (() => {
150
+ if (manifestState !== OK) return null;
151
+ try {
152
+ return readVersion(skillDir).version ?? null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ })();
139
157
 
140
158
  return { name: member.name, kind: member.kind, installed, skillDir: installed ? skillDir : null, manifestState, version };
141
159
  };
@@ -165,6 +183,51 @@ export const MEMORY_ORCH_TEMPLATE_REL = 'references/templates/orchestration.json
165
183
  const MEMORY_BEHIND_NOTE =
166
184
  "the memory installed here doesn't include the current orchestration template — refresh it with `npx @sabaiway/agent-workflow-memory@latest init`, then restart the session.";
167
185
 
186
+ // ── the bridge freshness probe (deterministic-first — INV-A / INV-B) ─────────────
187
+ // The bridges are not npm packages: their ONLY delivery channel is the copy bundled inside this kit
188
+ // (`bridges/<name>/`, placed by `/agent-workflow-kit setup`). A placed bridge therefore has exactly
189
+ // one authoritative freshness comparison — its installed version (readAuthoritativeVersion, already
190
+ // on the row) vs the kit-bundled mirror's capability.json. BOTH are local files, so the "never
191
+ // checks npm" invariant holds. The probe sets the internal row field `freshness`
192
+ // ('behind' | 'current' | 'unknown'); refreshOf derives the public refresh block STRUCTURALLY from
193
+ // that field (INV-2 — never parsed from caveat text). INV-B: an unreadable/unparseable version on
194
+ // EITHER side degrades to 'unknown' + a plain-language note — never a false claim in either direction.
195
+ const __dirname = dirname(fileURLToPath(import.meta.url));
196
+ // bridges/ ships beside tools/ in both the repo and the installed kit (the setup-backends.mjs
197
+ // resolution), so this resolves in both. Injectable via deps.bundleRoot for tests.
198
+ const BUNDLE_ROOT = resolve(__dirname, '..', 'bridges');
199
+
200
+ // Crash-safe on the bundled side: an absent / unreadable / malformed bundle manifest → null (INV-B
201
+ // 'unknown'), never a throw — the read-only survey must survive a broken kit install.
202
+ const readBundledBridgeVersion = (name, deps = {}) => {
203
+ const read = deps.readFile ?? readFileSync;
204
+ const root = deps.bundleRoot ?? BUNDLE_ROOT;
205
+ try {
206
+ const manifest = JSON.parse(String(read(join(root, name, 'capability.json'), 'utf8')));
207
+ return typeof manifest.version === 'string' ? manifest.version : null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ };
212
+
213
+ const bridgeBehindNote = (display, placed, bundled) =>
214
+ `the ${display} installed here (v${placed}) is older than the copy bundled with this kit (v${bundled}) — refresh it with \`/agent-workflow-kit setup\`.`;
215
+ // The recovery differs per failing SIDE: `setup` re-places FROM the bundle, so it can repair an
216
+ // unreadable INSTALLED copy but can never repair an unreadable BUNDLED copy — that needs a kit
217
+ // refresh first (the npx installer), then `setup`.
218
+ const bridgeUnknownNote = (display, side, recovery) =>
219
+ `couldn't compare the ${display} installed here with the copy bundled with this kit (${side}), so its freshness is unknown — ${recovery}.`;
220
+ const UNKNOWN_SIDES = Object.freeze({
221
+ placed: {
222
+ side: 'the installed copy has no readable version',
223
+ recovery: '`/agent-workflow-kit setup` re-places the bundled copy',
224
+ },
225
+ bundled: {
226
+ side: "the kit's bundled copy has no readable version",
227
+ recovery: 'refresh the kit first (`npx @sabaiway/agent-workflow-kit@latest init`), then `/agent-workflow-kit setup`',
228
+ },
229
+ });
230
+
168
231
  export const surveyFamily = (deps = {}) =>
169
232
  FAMILY_MEMBERS.map((member) => {
170
233
  const row = classifyMember(member, deps);
@@ -184,8 +247,30 @@ export const surveyFamily = (deps = {}) =>
184
247
  // Only attach when it is provably ABSENT (a non-ENOENT probe error → 'unknown' → skip, never a
185
248
  // false "missing" claim). Mirrors the engine-caveat SHAPE; keyed on the Step-2.4 required asset.
186
249
  if (row.kind === 'memory-substrate' && row.manifestState === OK && row.skillDir) {
187
- if (probeMarker(join(row.skillDir, MEMORY_ORCH_TEMPLATE_REL), deps) === 'absent') {
250
+ const templateProbe = probeMarker(join(row.skillDir, MEMORY_ORCH_TEMPLATE_REL), deps);
251
+ if (templateProbe === 'absent') {
188
252
  row.caveats = [...(row.caveats ?? []), MEMORY_BEHIND_NOTE];
253
+ } else if (templateProbe === 'unknown') {
254
+ // Could not verify → this row must not be counted "checked, current" by the verdict (INV-B).
255
+ row.freshness = FRESH_UNKNOWN;
256
+ }
257
+ }
258
+ // Bridge freshness probe (INV-A / INV-B): only for a provably-OURS placed bridge (manifestState
259
+ // OK). Compares two LOCAL files; a non-OK / absent bridge is out of scope ('not-checked').
260
+ if (row.kind === 'execution-backend' && row.manifestState === OK && row.skillDir) {
261
+ const bundled = readBundledBridgeVersion(row.name, deps);
262
+ const cmp = compareSemver(row.version, bundled);
263
+ if (cmp === null) {
264
+ const { side, recovery } = parseSemver(row.version) === null ? UNKNOWN_SIDES.placed : UNKNOWN_SIDES.bundled;
265
+ row.freshness = FRESH_UNKNOWN;
266
+ row.caveats = [...(row.caveats ?? []), bridgeUnknownNote(displayOf(row.name), side, recovery)];
267
+ } else if (cmp < 0) {
268
+ row.freshness = FRESH_BEHIND;
269
+ row.caveats = [...(row.caveats ?? []), bridgeBehindNote(displayOf(row.name), row.version, bundled)];
270
+ } else {
271
+ // Equal OR newer-than-bundled → not behind. A newer placed bridge is a Phase-2 concern for
272
+ // the refresh DRIVER (INV-D never-downgrade skip); the read-only status axis never flags it.
273
+ row.freshness = FRESH_CURRENT;
189
274
  }
190
275
  }
191
276
  return row;
@@ -376,18 +461,31 @@ export const surveyBridges = (deps = {}) => {
376
461
  });
377
462
  };
378
463
 
379
- // INV-2 (structural refresh — additive, derived, never parsed from a caveat string). A member is
380
- // `behind` when it is a refreshable CORE member (memory / engine) carrying a refresh-recommending
381
- // caveat surveyFamily attaches caveats ONLY to those two kinds, and today every such caveat is
382
- // refresh-recommending. `recommend` is composed from the member's npm package in the ONE registry
383
- // (FAMILY_MEMBERS[].npm), never extracted from the caveat text. The kit (composition-root) + the two
384
- // bridges (execution-backend) never carry such a caveat, so they derive { behind:false, recommend:null }
385
- // gated by the CAVEAT, not by npm-nullness (the kit's npm is non-null; only the bridges are null).
464
+ // INV-2 (structural refresh — additive, derived, never parsed from a caveat string), per KIND:
465
+ // memory / engine (REFRESHABLE_KINDS): `behind` an OK row carrying a refresh-recommending
466
+ // caveat (surveyFamily attaches caveats to those kinds only then, and every such caveat is
467
+ // refresh-recommending); `recommend` is composed from FAMILY_MEMBERS[].npm never the caveat text.
468
+ // execution-backend: `behind` the bridge freshness probe's row field (freshness === 'behind');
469
+ // `recommend` is composed PER-KIND bridges have npm:null (placed by `setup`, never npx), so the
470
+ // runnable recovery is `/agent-workflow-kit setup`, never an npx composition.
471
+ // • composition-root (the kit): no freshness probe exists on this surface (the two-axes doctrine —
472
+ // kit freshness is the npx installer's axis) → never behind, 'not-checked'.
473
+ // `freshness` is the checked-vs-unknown signal the zero-behind verdict scopes itself with (INV-C):
474
+ // behind:false alone cannot distinguish "checked, current" from "could not be checked".
386
475
  const REFRESHABLE_KINDS = new Set(['memory-substrate', 'methodology-engine']);
387
476
  const npmOf = (name) => FAMILY_MEMBERS.find((m) => m.name === name)?.npm ?? null;
477
+ export const BRIDGE_REFRESH_RECOMMEND = '/agent-workflow-kit setup';
388
478
  const refreshOf = (m) => {
479
+ if (m.kind === 'execution-backend') {
480
+ const freshness = m.freshness ?? FRESH_NOT_CHECKED;
481
+ const behind = freshness === FRESH_BEHIND;
482
+ return { behind, recommend: behind ? BRIDGE_REFRESH_RECOMMEND : null, freshness };
483
+ }
389
484
  const behind = REFRESHABLE_KINDS.has(m.kind) && Boolean(m.caveats?.length);
390
- return { behind, recommend: behind ? `npx ${npmOf(m.name)}@latest init` : null };
485
+ const freshness = REFRESHABLE_KINDS.has(m.kind) && m.manifestState === OK
486
+ ? m.freshness ?? (behind ? FRESH_BEHIND : FRESH_CURRENT)
487
+ : FRESH_NOT_CHECKED;
488
+ return { behind, recommend: behind ? `npx ${npmOf(m.name)}@latest init` : null, freshness };
391
489
  };
392
490
 
393
491
  export const buildEnvelope = (family, project = null, extras = {}) => {
@@ -399,7 +497,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
399
497
  state: STATE_PUBLIC[m.manifestState] ?? STATE_PUBLIC[UNKNOWN],
400
498
  };
401
499
  if (m.caveats?.length) entry.notes = m.caveats; // plain-language observations (Steps 2.2 / engine)
402
- entry.refresh = refreshOf(m); // additive (INV-1): always present; { behind, recommend } (INV-2)
500
+ entry.refresh = refreshOf(m); // additive (INV-1): always present; { behind, recommend, freshness } (INV-2)
403
501
  return entry;
404
502
  });
405
503
  const envelope = { deploymentHead: EXPECTED_WORKFLOW_VERSION, installed };
package/tools/labels.mjs CHANGED
@@ -41,6 +41,19 @@ export const STATE_PUBLIC = Object.freeze({
41
41
  // "hidden fence" / marker terms. ('hidden' here is the PUBLIC visibility word, not the internal fence.)
42
42
  export const VISIBILITY_PUBLIC = Object.freeze({ visible: 'visible', hidden: 'hidden', ambiguous: 'unclear' });
43
43
 
44
+ // ── refresh freshness tokens (PUBLIC — the envelope's installed[].refresh.freshness) ────────────────
45
+ // The checked-vs-unknown signal the zero-behind verdict scopes itself with (INV-C): `behind:false`
46
+ // alone cannot distinguish "checked, current" from "could not be checked".
47
+ // 'current' / 'behind' — a freshness probe RAN and concluded (these two are the "checked" scope);
48
+ // 'unknown' — a probe ran but could not conclude (INV-B: never collapsed to a claim
49
+ // in either direction);
50
+ // 'not-checked' — no freshness probe exists for this member/state (e.g. the kit itself:
51
+ // its freshness is the npx installer's axis, never checked here).
52
+ export const FRESH_CURRENT = 'current';
53
+ export const FRESH_BEHIND = 'behind';
54
+ export const FRESH_UNKNOWN = 'unknown';
55
+ export const FRESH_NOT_CHECKED = 'not-checked';
56
+
44
57
  // Short, user-facing labels for the version block (kit · memory · engine · codex-bridge · …), so the
45
58
  // render is deterministic and the agent never invents a label.
46
59
  export const DISPLAY_NAMES = Object.freeze({
@@ -38,6 +38,21 @@ Each `roles.<role>` is an object:
38
38
  - `template` (string, optional) — an in-skill prompt/template path (e.g.
39
39
  `references/review-prompt.md`); repo-relative, **must exist**.
40
40
  - `modes`, `output` (optional) — e.g. `["plan","code"]`, `"advisory"`.
41
+ - `contract` (object, optional; execution-backend bridges) — the machine-readable **driving
42
+ contract** for a dispatchable recipe role (`review` / `execute`; the `probe` role carries none).
43
+ Rides as a validator-tolerated extra field (format-checked by the bridge/kit drift-guard tests,
44
+ not by `validate.mjs`), and is the single source the point-of-use advisor
45
+ (`/agent-workflow-kit procedures`) and each wrapper's `--help` render verbatim:
46
+ - `invocations` (string[], non-empty) — exact copy-pasteable invocation descriptors, one per
47
+ mode/variant, incl. operand placeholders (`<plan-file>`, `[extra focus...]`).
48
+ - `grounding` (string) — the grounding note (e.g. agy's `--facts @f` / `--decided @f` levers, or
49
+ "automatic — …" when the wrapper grounds itself).
50
+ - `flags` (string[], optional) — the closed per-mode flag descriptor set (closed-grammar
51
+ wrappers only).
52
+ - `continue` (string[]) — round-2 / resume invocation descriptors; `[]` when one-shot.
53
+ - `passthrough` (object, optional) — the guarded `--` passthrough tiers:
54
+ `{ policy: "guarded", blocked: string[], probeRelaxed: string[] }`, matching the wrapper's
55
+ real case-arm patterns (pinned by the source-level reverse-guard test).
41
56
 
42
57
  ## Path-field rules (Windows-safe, traversal-safe)
43
58
 
@@ -19,7 +19,7 @@
19
19
  import { readFileSync, lstatSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
21
  import { pathToFileURL } from 'node:url';
22
- import { detectBackends, wrapperCmdFor } from './detect-backends.mjs';
22
+ import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
23
23
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
24
24
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
25
25
  // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
@@ -131,10 +131,16 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
131
131
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
132
132
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
133
133
  // manifest wrapper cmd via the bridge registry — no wrapper name is hand-composed here.
134
- const backends = planRecipe(resolved.recipe, detection).dispatch
135
- .map((d) => wrapperCmdFor(d.backend, d.role))
136
- .filter(Boolean);
137
- return { slot, ...resolved, backends };
134
+ const { dispatch } = planRecipe(resolved.recipe, detection);
135
+ const backends = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
136
+ // The full DRIVING CONTRACT per dispatched (backend, role) — resolved HERE, on the raw dispatch
137
+ // pairs, BEFORE they are flattened to wrapper names (the name array cannot reconstruct the role).
138
+ // Every slot with a non-empty dispatch gets contracts — including execute=delegated; the contract
139
+ // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
140
+ const contracts = dispatch
141
+ .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
142
+ .filter((c) => c.cmd && c.contract);
143
+ return { slot, ...resolved, backends, contracts };
138
144
  });
139
145
 
140
146
  // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
@@ -182,6 +188,28 @@ const reviewLoopAdvice = (slots) =>
182
188
  ]
183
189
  : [];
184
190
 
191
+ // The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
192
+ // the closed flag set, the grounding note, the round-2/continue delta, and the guarded passthrough
193
+ // tiers — every descriptor printed VERBATIM from the registry mirror of the bridge manifest
194
+ // roles[role].contract (drift-guarded), never re-derived or re-worded here. Rendered for EVERY
195
+ // dispatched backend of EVERY slot (review AND execute=delegated); each wrapper's --help prints the
196
+ // same contract, so the agent never needs to open the wrapper source.
197
+ const contractLines = ({ cmd, contract }) => {
198
+ const lines = [` ${cmd} — driving contract (as bundled with this kit; copy-paste — \`${cmd} --help\` prints the same):`];
199
+ for (const inv of contract.invocations) lines.push(` ${inv}`);
200
+ for (const f of contract.flags ?? []) lines.push(` ${f}`);
201
+ if (contract.grounding) lines.push(` grounding: ${contract.grounding}`);
202
+ const cont = contract.continue ?? [];
203
+ if (cont.length) {
204
+ lines.push(' round-2 delta (resume — never re-send the reviewed artifact):');
205
+ for (const c of cont) lines.push(` ${c}`);
206
+ }
207
+ if (contract.passthrough) {
208
+ lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
209
+ }
210
+ return lines;
211
+ };
212
+
185
213
  const formatHuman = ({ activity, section, slots, warnings }) => {
186
214
  const lines = [
187
215
  section,
@@ -192,6 +220,7 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
192
220
  const arrow = s.degradedFrom ? ` (requested ${s.degradedFrom} → degraded)` : '';
193
221
  lines.push(` ${s.slot}: ${s.recipe} — ${SOURCE_LABEL[s.source]}${arrow}${backendSetLabel(s.backends)}`);
194
222
  if (s.reason) lines.push(` ↳ ${s.reason}`);
223
+ for (const c of s.contracts ?? []) lines.push(...contractLines(c));
195
224
  }
196
225
  const advice = reviewLoopAdvice(slots);
197
226
  if (advice.length) lines.push('', ...advice);
@@ -206,7 +235,9 @@ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
206
235
  activity,
207
236
  section,
208
237
  slots: Object.fromEntries(
209
- slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends }]),
238
+ // `backends: string[]` is the STABLE pre-existing shape (wrapper names) never repurposed.
239
+ // `contracts` is the ADDITIVE per-dispatch driving-contract field (empty for solo).
240
+ slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
210
241
  ),
211
242
  reviewLoop: reviewLoopAdvice(slots),
212
243
  configSource,