@sabaiway/agent-workflow-kit 1.25.0 → 1.27.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.
@@ -26,15 +26,19 @@ const BARE_INVOCATION = `/${SKILL_NAME}`;
26
26
  const invocationOf = (token) => `${BARE_INVOCATION}${token ? ` ${token}` : ''}`;
27
27
 
28
28
  // ── kinds ────────────────────────────────────────────────────────────────────────
29
- // read-only — never writes, never commits, never runs a subscription CLI.
30
- // writer — writes files (a project deployment or a settings/skill placement).
31
- // guarded — a destructive teardown gated behind a mandatory dry-run-first + explicit consent.
29
+ // read-only — never writes, never commits, never runs a subscription CLI.
30
+ // writer — writes files (a project deployment or a settings/skill placement).
31
+ // guarded — a destructive teardown gated behind a mandatory dry-run-first + explicit consent.
32
+ // project-exec — the kit itself writes nothing, but the mode RUNS the project's own declared
33
+ // commands with the caller's privileges (the `gates` runner) — honest-tagged so a
34
+ // user never reads "read-only" on a surface that executes their gate matrix.
32
35
  // `writer` and `guarded` are the "acts on the system" kinds; only an explicit known token may reach
33
36
  // one (plus the bare bootstrap exception). Garbage routes to `help` (read-only) — see routeInvocation.
34
37
  export const READ_ONLY = 'read-only';
35
38
  export const WRITER = 'writer';
36
39
  export const GUARDED = 'guarded';
37
- const KINDS = new Set([READ_ONLY, WRITER, GUARDED]);
40
+ export const PROJECT_EXEC = 'project-exec';
41
+ const KINDS = new Set([READ_ONLY, WRITER, GUARDED, PROJECT_EXEC]);
38
42
 
39
43
  // ── groups (fixed render order) ────────────────────────────────────────────────────
40
44
  export const GROUP_ORDER = Object.freeze(['Inspect', 'Configure', 'Orchestrate', 'Lifecycle']);
@@ -87,6 +91,13 @@ const CATALOG = [
87
91
  kind: READ_ONLY,
88
92
  oneLine: 'List every command, grouped, marking each as read-only or as one that makes changes.',
89
93
  },
94
+ {
95
+ key: 'gates',
96
+ invocation: invocationOf('gates'),
97
+ group: 'Inspect',
98
+ kind: PROJECT_EXEC,
99
+ oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
100
+ },
90
101
  {
91
102
  key: 'setup',
92
103
  invocation: invocationOf('setup'),
@@ -101,6 +112,13 @@ const CATALOG = [
101
112
  kind: WRITER,
102
113
  oneLine: 'Seed a read-only command allowlist so routine read-only commands stop prompting (Claude Code; opt-in; preview first).',
103
114
  },
115
+ {
116
+ key: 'agents',
117
+ invocation: invocationOf('agents'),
118
+ group: 'Configure',
119
+ kind: WRITER,
120
+ oneLine: 'Place bundled cheap-model subagent definitions for mechanical work — sweeps, changelog skeletons, gate triage (Claude Code; opt-in; preview first).',
121
+ },
104
122
  {
105
123
  key: 'recipes',
106
124
  invocation: invocationOf('recipes'),
@@ -165,7 +183,12 @@ export const routeInvocation = (token) => {
165
183
 
166
184
  // ── render ──────────────────────────────────────────────────────────────────────────
167
185
 
168
- const KIND_TAG = { [READ_ONLY]: '[read-only]', [WRITER]: '[writer] ', [GUARDED]: '[guarded] ' };
186
+ const KIND_TAG = {
187
+ [READ_ONLY]: '[read-only] ',
188
+ [WRITER]: '[writer] ',
189
+ [GUARDED]: '[guarded] ',
190
+ [PROJECT_EXEC]: '[runs project cmds]',
191
+ };
169
192
  const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
170
193
 
171
194
  // formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
@@ -175,7 +198,7 @@ export const formatHelp = () => {
175
198
  const lines = [
176
199
  `${SKILL_NAME} — command index (this list is read-only)`,
177
200
  '',
178
- 'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first).',
201
+ 'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first) · runs project cmds (executes your own declared commands).',
179
202
  ];
180
203
  for (const group of GROUP_ORDER) {
181
204
  const inGroup = COMMANDS.filter((c) => c.group === group);
@@ -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 };
@@ -43,6 +43,8 @@ export const KIT_OWN_PATHS = [
43
43
  '/scripts/_expect-shim.mjs',
44
44
  '/scripts/archive-changelog.mjs',
45
45
  '/scripts/archive-changelog.test.mjs',
46
+ '/scripts/archive-decisions.mjs',
47
+ '/scripts/archive-decisions.test.mjs',
46
48
  '/scripts/archive-issues.mjs',
47
49
  '/scripts/archive-issues.test.mjs',
48
50
  '/scripts/check-docs-size.mjs',
@@ -59,6 +61,7 @@ export const KIT_OWN_PATHS = [
59
61
  // filesystem (expandGlob) to concrete present files before any git probe; never fed to ls-files.
60
62
  export const KNOWN_FOOTPRINT = [
61
63
  { pattern: '/.claude/skills/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'local-dev skills; absorbs the AD-013 one-off' },
64
+ { pattern: '/.claude/agents/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project subagent definitions (incl. the kit-placed cheap-lane vehicles)' },
62
65
  { pattern: '/.cursor/rules/', owner: 'Cursor', type: 'dir', falsePositiveRisk: false, note: 'project rule files' },
63
66
  { pattern: '/.cursorrules', owner: 'Cursor (legacy)', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
64
67
  { pattern: '/.codeium/', owner: 'Codeium/Windsurf', type: 'dir', falsePositiveRisk: false, note: 'home-scoped launchers live under ~/, out of scope' },
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({
@@ -188,6 +188,22 @@ const reviewLoopAdvice = (slots) =>
188
188
  ]
189
189
  : [];
190
190
 
191
+ // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
192
+ // at the point of use like reviewLoopAdvice paraphrases §9/§4). Rendered UNCONDITIONALLY for
193
+ // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
194
+ // which fires only when a review backend engages). It may name the kit's own GENERIC L0
195
+ // surfaces (the gate runner, the rotation checks, the cheap-agents vehicles) — point-of-use
196
+ // routing, still project-agnostic (never a project's publish mechanics). Its distinctive tokens
197
+ // are drift-guarded against the canon on both sides (procedures.test.mjs + the engine canon tests).
198
+ const costLanesAdvice = () => [
199
+ 'Cost lanes (orchestration.md §5) — route every step to the cheapest adequate executor:',
200
+ ' • L0 deterministic script — run the gate matrix as ONE batch: /agent-workflow-kit gates (the project-declared docs/ai/gates.json); rotation headroom: archive-changelog / archive-issues / archive-decisions --check. An exit code beats a model re-read.',
201
+ ' • L1 cheap subagent (small model, low effort, read-only tools — /agent-workflow-kit agents places the vehicles) — mechanical sweeps, changelog fact-skeletons, gate-failure triage. Extraction/drafting ONLY; the orchestrator verifies the output and owns every conclusion.',
202
+ ' • L2 subscription bridge (codex / agy) — reviews per the resolved recipe above, on frontier bridge models (quality-first).',
203
+ ' • L3 frontier — judgment: plan/fold/synthesis, ADR/handover/changelog-entry wording, persuasive copy, go/no-go, real code.',
204
+ ' • A step with no named guardrail does not move down a lane; red lines never move down (council review models · real code · memory/copy wording · the maintainer approval asks).',
205
+ ];
206
+
191
207
  // The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
192
208
  // the closed flag set, the grounding note, the round-2/continue delta, and the guarded passthrough
193
209
  // tiers — every descriptor printed VERBATIM from the registry mirror of the bridge manifest
@@ -224,6 +240,7 @@ const formatHuman = ({ activity, section, slots, warnings }) => {
224
240
  }
225
241
  const advice = reviewLoopAdvice(slots);
226
242
  if (advice.length) lines.push('', ...advice);
243
+ lines.push('', ...costLanesAdvice());
227
244
  if (warnings.length) {
228
245
  lines.push('', 'warnings:');
229
246
  for (const w of warnings) lines.push(` ⚠ ${w}`);
@@ -240,6 +257,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings }) => ({
240
257
  slots.map((s) => [s.slot, { recipe: s.recipe, source: s.source, degradedFrom: s.degradedFrom, reason: s.reason, backends: s.backends, contracts: s.contracts }]),
241
258
  ),
242
259
  reviewLoop: reviewLoopAdvice(slots),
260
+ // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
261
+ costLanes: costLanesAdvice(),
243
262
  configSource,
244
263
  warnings,
245
264
  });
package/tools/recipes.mjs CHANGED
@@ -283,21 +283,43 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
283
283
  };
284
284
  };
285
285
 
286
+ // ── the one-line backend status (deterministic-first: the tool speaks, the agent pastes) ───────────
287
+
288
+ // composeStatusLine(detection, recommendation) → the ENTIRE one-line backend-status summary the
289
+ // bootstrap/upgrade report footers print. Machine-composed so the agent pastes it verbatim and
290
+ // composes NOTHING factual (this closes the realistic-example contamination class: a session once
291
+ // echoed SKILL.md's canonical example while the detector said otherwise). Display names come from
292
+ // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
293
+ // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
294
+ // Always exactly one line: no part may carry a newline (pinned by tests).
295
+ export const composeStatusLine = (detection, recommendation) => {
296
+ const backends = [...detection]
297
+ .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
298
+ .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
299
+ .join(' · ');
300
+ return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
301
+ };
302
+
286
303
  // ── report + CLI ─────────────────────────────────────────────────────────────────
287
304
 
288
- // The structured report behind `--json` — the recipes, the recommendation, and a plan per recipe.
289
- export const buildReport = (detection) => ({
290
- recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
291
- id,
292
- title,
293
- role,
294
- minBackends,
295
- degradesTo,
296
- summary,
297
- })),
298
- recommendation: recommendRecipe(detection),
299
- plans: RECIPES.map((r) => planRecipe(r.id, detection)),
300
- });
305
+ // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
306
+ // (additive) the pasteable one-line backend status composed from the same detection.
307
+ export const buildReport = (detection) => {
308
+ const recommendation = recommendRecipe(detection);
309
+ return {
310
+ recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
311
+ id,
312
+ title,
313
+ role,
314
+ minBackends,
315
+ degradesTo,
316
+ summary,
317
+ })),
318
+ recommendation,
319
+ plans: RECIPES.map((r) => planRecipe(r.id, detection)),
320
+ statusLine: composeStatusLine(detection, recommendation),
321
+ };
322
+ };
301
323
 
302
324
  // formatRecipes(detection) → deterministic human advisor text: the four recipes, the recommendation,
303
325
  // and the per-recipe plan for the current environment (degradation reasons + dispatch + notes).
@@ -320,20 +342,37 @@ export const formatRecipes = (detection) => {
320
342
  return lines.join('\n');
321
343
  };
322
344
 
345
+ // The full argv vocabulary — anything else rejects LOUDLY. The old parse silently routed unknown
346
+ // args into the multi-line human render; with `--status-line` (whose output is pasted as fact) a
347
+ // mistyped flag masquerading as a mode would be a silent failure, so the parse is strict now.
348
+ const KNOWN_ARGS = new Set(['--help', '-h', '--json', '--status-line']);
349
+
323
350
  const main = (argv) => {
324
351
  if (argv.includes('--help') || argv.includes('-h')) {
325
352
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
326
353
 
327
354
  Usage:
328
- node recipes.mjs [--json]
355
+ node recipes.mjs [--json | --status-line]
329
356
 
330
357
  Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
331
- detector, plans + recommends one for the current environment. Detection only never writes, never
332
- commits, never runs a subscription CLI.`);
358
+ detector, plans + recommends one for the current environment. --status-line prints exactly ONE
359
+ line the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim.
360
+ --json emits the structured report (incl. the same line as \`statusLine\`); the two are mutually
361
+ exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
333
362
  return;
334
363
  }
364
+ const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
365
+ if (unknown !== undefined) {
366
+ console.error(`[agent-workflow-kit] unknown argument: ${unknown}`);
367
+ process.exit(1);
368
+ }
369
+ if (argv.includes('--json') && argv.includes('--status-line')) {
370
+ console.error('[agent-workflow-kit] --json and --status-line are mutually exclusive — pick one output');
371
+ process.exit(1);
372
+ }
335
373
  const detection = detectBackends();
336
- if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
374
+ if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
375
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
337
376
  else console.log(formatRecipes(detection));
338
377
  };
339
378
 
@@ -34,7 +34,18 @@ const renderMembers = (vm, { color, glyph }) => {
34
34
  // refresh.behind drives a HEADLINE COUNT only — the recovery command stays in the verbatim notes
35
35
  // above (the direct CLI never dedupes, never re-prints it). Omitted when nothing is behind.
36
36
  if (vm.headline.behind > 0) {
37
- lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above).`);
37
+ // A mixed behind+unknown state must not swallow the unknown count (INV-B): append it here so an
38
+ // uncheckable member never disappears behind the refresh headline.
39
+ const unknownTail = vm.headline.unknown > 0 ? ` · ${vm.headline.unknown} could not be checked` : '';
40
+ lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above)${unknownTail}.`);
41
+ } else if (vm.headline.unknown > 0) {
42
+ // INV-B × INV-C: an uncheckable member BLOCKS the all-current verdict — state the split, never a
43
+ // blanket freshness claim in either direction.
44
+ lines.push(` freshness: ${vm.headline.checked} member(s) checked and current · ${vm.headline.unknown} could not be checked.`);
45
+ } else if (vm.headline.checked > 0) {
46
+ // INV-C: the zero-behind verdict is composed by the TOOL, scoped to what was actually checked —
47
+ // the agent has no gap to fill with an "everything is fresh" gloss.
48
+ lines.push(` freshness: all ${vm.headline.checked} checked member(s) are current.`);
38
49
  }
39
50
  return lines;
40
51
  };