@sabaiway/agent-workflow-kit 10.2.0 → 10.4.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +7 -7
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/agents/executor.md +40 -0
  7. package/references/modes/agents.md +9 -4
  8. package/references/modes/procedures.md +17 -8
  9. package/references/modes/recipes.md +7 -4
  10. package/references/modes/recommendations.md +4 -1
  11. package/references/modes/set-recipe.md +22 -5
  12. package/references/modes/status.md +3 -3
  13. package/references/modes/upgrade.md +7 -5
  14. package/references/shared/composition-handoff.md +1 -1
  15. package/references/shared/deploy-tail.md +2 -2
  16. package/references/templates/agent_rules.md +3 -2
  17. package/references/templates/orchestration.json +1 -1
  18. package/tools/ack-store.mjs +57 -0
  19. package/tools/ack-write.mjs +1 -1
  20. package/tools/autonomy-config.mjs +1 -1
  21. package/tools/carriers.mjs +140 -0
  22. package/tools/cheap-agents-read.mjs +172 -0
  23. package/tools/cheap-agents.mjs +57 -105
  24. package/tools/commands.mjs +3 -3
  25. package/tools/direct-run.mjs +6 -0
  26. package/tools/doc-parity.mjs +8 -0
  27. package/tools/ensure-ops.mjs +18 -9
  28. package/tools/ensure-specs.mjs +3 -4
  29. package/tools/ensure-vocabulary.mjs +5 -2
  30. package/tools/family-registry.mjs +70 -21
  31. package/tools/flow-check.mjs +2 -7
  32. package/tools/inject-methodology.mjs +4 -0
  33. package/tools/lens-region.mjs +4 -1
  34. package/tools/node-evidence.mjs +77 -0
  35. package/tools/orchestration-config.mjs +34 -13
  36. package/tools/procedures.mjs +65 -52
  37. package/tools/recipes.mjs +156 -184
  38. package/tools/recommendations.mjs +145 -78
  39. package/tools/renderers.mjs +36 -7
  40. package/tools/review-state.mjs +10 -11
  41. package/tools/set-recipe.mjs +63 -24
  42. package/tools/spec-adoption.mjs +71 -0
  43. package/tools/spec-check.mjs +2 -2
  44. package/tools/upgrade-runlist.mjs +1 -1
  45. package/tools/view-model.mjs +19 -3
@@ -32,7 +32,6 @@
32
32
  // idiom).
33
33
 
34
34
  import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
35
- import { createHash } from 'node:crypto';
36
35
  import { homedir } from 'node:os';
37
36
  import { dirname, join, resolve } from 'node:path';
38
37
  import { fileURLToPath } from 'node:url';
@@ -52,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
52
51
  import { deriveDoctorPlan } from './autonomy-doctor.mjs';
53
52
  import { detectBackends, findOnPath } from './detect-backends.mjs';
54
53
  import { isDirectRun } from './direct-run.mjs';
55
- import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
+ import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
56
55
  import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
57
56
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
58
57
  import { shellQuoteArg } from './review-state.mjs';
@@ -83,7 +82,28 @@ import { loadConfig } from './orchestration-config.mjs';
83
82
  import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
84
83
  import { assertContainedRealPath } from './fs-safe.mjs';
85
84
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
86
- import { preflightCheapAgents } from './cheap-agents.mjs';
85
+ import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
+ // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
87
+ import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
88
+ // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
89
+ // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
90
+ import {
91
+ ACKS_FILE,
92
+ ACKS_LANE_KEY,
93
+ ACKS_WORKTREES_DIR_KEY,
94
+ ACKS_COVERAGE_DOMAIN_KEY,
95
+ ACKS_SOURCE_SIZE_COPY_KEY,
96
+ ACK_LANES,
97
+ factFingerprint,
98
+ readAckValue,
99
+ } from './ack-store.mjs';
100
+ import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
101
+ import { ENSURE_OPS } from './ensure-vocabulary.mjs';
102
+
103
+ // The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
104
+ const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
105
+
106
+ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_KEY, ACKS_SOURCE_SIZE_COPY_KEY, ACK_LANES, factFingerprint };
87
107
 
88
108
  const HERE = dirname(fileURLToPath(import.meta.url));
89
109
  const toolPath = (rel) => join(HERE, rel);
@@ -158,11 +178,20 @@ export const SEVERITIES = Object.freeze({
158
178
  'mcp-channel.masked': SEVERITY_OPTIONAL,
159
179
  'mcp-channel.differing': SEVERITY_ATTENTION,
160
180
  agents: SEVERITY_OPTIONAL,
181
+ // The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
182
+ // names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
183
+ // is broken, which is what attention means.
184
+ 'executor-vehicle': SEVERITY_ATTENTION,
161
185
  'family-freshness': SEVERITY_ATTENTION,
162
186
  'adr-store-migration': SEVERITY_ATTENTION,
163
187
  'sandbox-masks': SEVERITY_OPTIONAL,
164
188
  'sandbox-lane': SEVERITY_OPTIONAL,
165
189
  'worktrees-dir': SEVERITY_OPTIONAL,
190
+ // The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
191
+ // declaration that is broken): an absent store offers the seed, a store with no live contract offers
192
+ // the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
193
+ 'spec-adoption': SEVERITY_OPTIONAL,
194
+ 'spec-adoption.adopting': SEVERITY_OPTIONAL,
166
195
  });
167
196
  // The per-item render tags (frozen presentation data, same language contract as the templates).
168
197
  export const SEVERITY_LABELS = Object.freeze({
@@ -227,13 +256,16 @@ export const WHATS = Object.freeze({
227
256
  'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
228
257
  'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
229
258
  'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
230
- agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
259
+ agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
260
+ 'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
231
261
  'family-freshness': '{parts}',
232
262
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
233
263
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
234
264
  'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
235
265
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
236
266
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
267
+ 'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
268
+ 'spec-adoption.adopting': 'feature-spec store: {n} draft spec(s), no live contract — nothing governs a plan through it yet; land a live contract, or record the decline',
237
269
  });
238
270
 
239
271
  // ── the shape contract (D2): registry strings AND composed items stay one line under the cap ────
@@ -284,12 +316,14 @@ export const BENEFITS = Object.freeze({
284
316
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
285
317
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
286
318
  'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
287
- agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
319
+ agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
320
+ 'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
288
321
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
289
322
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
290
323
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
291
324
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
292
325
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
326
+ 'spec-adoption': 'contracts — a plan names the contract it builds to, and a change to a governed slice is visible at review instead of after it',
293
327
  });
294
328
 
295
329
  // ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
@@ -332,12 +366,19 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
332
366
  { id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
333
367
  { id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
334
368
  { id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
369
+ // The feature-spec layer is delivered by upgrade's spec-layer ensure (there is no specs mode), so
370
+ // its adoption state is declared where the store is seeded.
371
+ { id: 'spec-adoption', mode: 'upgrade', advisorKey: 'spec-adoption' },
335
372
  { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
336
373
  { id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
337
374
  // The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
338
375
  // which is why the review-recipe benefit is worded for either slot rather than for review alone.
339
376
  { id: 'delegated-execution', mode: 'set-recipe', advisorKey: 'review-recipe' },
340
377
  { id: 'agents', mode: 'agents', advisorKey: 'agents' },
378
+ // A DISTINCT capability from the offer above: the offer converges the moment nothing is left to
379
+ // PLACE, which a customized-but-unusable executor also satisfies — so it can never observe the
380
+ // state that makes a configured subagent carrier run solo.
381
+ { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
341
382
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
342
383
  // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
343
384
  // same doctrine that keeps sandbox network/filesystem allowances HAND-APPLY); velocity presents the
@@ -448,11 +489,12 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
448
489
  // The VALIDATED reader (Segment B): a schema-invalid config (unknown activity/slot,
449
490
  // bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
450
491
  const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
451
- const detection = detectBackends(deps);
492
+ const readiness = composeReadiness(root, { ...deps, detect: deps.detect ?? (() => detectBackends(deps)), onDetectError: (err) => { throw err; } });
452
493
  const degraded = [];
453
494
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
454
495
  for (const slot of Object.keys(def.slots)) {
455
- const r = resolveActivityRecipe({ config, readiness: detection, activity, slot });
496
+ if (config?.[activity]?.[slot] === 'subagent') continue; // the executor vehicle has its own probe
497
+ const r = resolveActivityRecipe({ config, readiness, activity, slot });
456
498
  if (r.degradedFrom) degraded.push(`${activity}.${slot}: configured ${r.degradedFrom} degrades to ${r.recipe} (${r.reason})`);
457
499
  }
458
500
  }
@@ -923,11 +965,9 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
923
965
  // skipped the per-vehicle plan the user is supposed to see before consenting.
924
966
  // The hidden-mode reconcile rides the detail, never the apply line: it is wrong to run on a
925
967
  // VISIBLE deployment, and the apply slot must stay one pure executable command.
926
- add(
927
- 'agents',
928
- fillTemplate(WHATS.agents, { n: toPlace.length }),
929
- `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`,
930
- 'agents',
968
+ const executors = toPlace.filter((item) => item.name === EXECUTOR_VEHICLE).length;
969
+ add('agents', fillTemplate(WHATS.agents, { n: toPlace.length, ex: executors, ro: toPlace.length - executors }),
970
+ `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`, 'agents',
931
971
  `hidden-mode deployments only: after the --apply the preview prints, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
932
972
  );
933
973
  } catch (err) {
@@ -935,6 +975,60 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
935
975
  }
936
976
  };
937
977
 
978
+ // The subagent carrier's ONE instrument (contract: kit/carriers). Neither neighbour can report this
979
+ // state: `probeReviewRecipe` skips a slot configured `subagent` by construction, and the offer above
980
+ // judges only what is left to PLACE — a customized-but-broken vehicle leaves nothing to place. So a
981
+ // project could declare the carrier and have every such slot run solo, unseen.
982
+ const VEHICLE_BROKEN_STATES = Object.freeze(['missing', 'unusable']);
983
+
984
+ const writerBlock = (root, deps) => {
985
+ try {
986
+ preflightCheapAgents({ cwd: root }, deps);
987
+ return null;
988
+ } catch (err) {
989
+ return safeLine(err?.message ?? String(err));
990
+ }
991
+ };
992
+
993
+ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
994
+ try {
995
+ // The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
996
+ // never an item computed over a shape nothing accepted.
997
+ const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
998
+ const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
999
+ Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
1000
+ if (configured.length === 0) return;
1001
+ const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
1002
+ if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
1003
+ const reason = safeLine(survey.reason ?? '');
1004
+ const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
1005
+ const preconditions = [
1006
+ ...(stamp === EXPECTED_WORKFLOW_VERSION ? [] : [`run /agent-workflow-kit upgrade first (deployment stamp ${stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`]),
1007
+ ...(survey.state === 'unusable' ? [`${reason || 'the vehicle file is unusable'} — fix that`] : []),
1008
+ ];
1009
+ // The writer refuses on ANY vehicle path it cannot touch (a symlinked read-only vehicle blocks a
1010
+ // missing executor's placement too), so its own preflight is the last precondition — named once.
1011
+ const blocked = writerBlock(root, deps);
1012
+ if (blocked && blocked !== reason) preconditions.push(`${blocked} — fix that`);
1013
+ const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
1014
+ add(
1015
+ 'executor-vehicle',
1016
+ fillTemplate(WHATS['executor-vehicle'], {
1017
+ n: configured.length,
1018
+ state: survey.state,
1019
+ reason: reason && room > 0 ? `: ${truncatedTo(reason, room)}` : '',
1020
+ }),
1021
+ // The writer places a MISSING vehicle; an unusable path (a symlink, a read-only customization)
1022
+ // is kept or refused, so that state's apply is a hand-apply precondition before the writer.
1023
+ `${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1024
+ 'executor-vehicle',
1025
+ `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1026
+ );
1027
+ } catch (err) {
1028
+ skip('executor-vehicle', err);
1029
+ }
1030
+ };
1031
+
938
1032
  const probeFamilyFreshness = ({ deps, add, skip }) => {
939
1033
  try {
940
1034
  const survey = deps.surveyFamily ?? surveyFamily;
@@ -1042,41 +1136,13 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
1042
1136
  if (abs === homeAbs) return '~';
1043
1137
  return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
1044
1138
  };
1045
- const canonical = JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() });
1046
- return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
1139
+ return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
1047
1140
  };
1048
1141
 
1049
- // The fingerprint for an acknowledgment whose subject is already a canonical STRING the census
1050
- // fact, the sorted set of declared tool-elsewhere claims rather than a hosts dirs recipe. Same
1051
- // 16-hex shape the ack writer validates, so every lane records one comparable token; the canonical
1052
- // form is the caller's, because only the caller knows which part of its fact is durable and which
1053
- // is churn (the census binds the verdict + extension set, never per-file counts).
1054
- export const factFingerprint = (fact) => createHash('sha256').update(fact).digest('hex').slice(0, 16);
1055
-
1056
- // The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
1057
- // validator guards — top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
1058
- // on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
1059
- // keys below are read for one deprecation window. The sandbox/permissions security keys are NEVER
1060
- // consulted as an ack.
1061
- export const ACKS_FILE = 'docs/ai/acks.json';
1062
- export const ACKS_LANE_KEY = 'sandboxLaneAck';
1063
- export const ACKS_WORKTREES_DIR_KEY = 'worktreesDirAck';
1064
- export const ACKS_COVERAGE_DOMAIN_KEY = 'coverageDomainAck';
1065
- export const ACKS_SOURCE_SIZE_COPY_KEY = 'sourceSizeCopyAck';
1066
- // The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's
1067
- // command line → the store key that writer sets. A lane the registry does not name is a usage
1068
- // refusal at the writer, never a newly-invented key in the shared store.
1069
- //
1070
- // An ack lane exists for a state the maintainer can only ANSWER, never converge: a tracked tree the
1071
- // coverage domain cannot reach, a checker deliberately vendored elsewhere. It is deliberately NOT
1072
- // available to a state that is simply BROKEN — a dead checker/producer pair is fixed, not
1073
- // acknowledged, so no lane names it.
1074
- export const ACK_LANES = Object.freeze({
1075
- 'sandbox-lane': ACKS_LANE_KEY,
1076
- 'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
1077
- 'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
1078
- 'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
1079
- });
1142
+ // The ack store (D4; AD-055 Part I) is the kit-owned PRIMARY ack channel; the legacy settings-scope
1143
+ // keys below are read for one deprecation window. An ack lane exists for a state the maintainer can
1144
+ // only ANSWER, never converge a dead checker/producer pair is fixed, not acknowledged, so no lane
1145
+ // names it. The store's path, keys, lane registry and reader are ack-store.mjs (re-exported above).
1080
1146
 
1081
1147
  // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
1082
1148
  // placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
@@ -1146,38 +1212,6 @@ const declarationCarriesMarker = (root, deps) => {
1146
1212
  export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
1147
1213
  export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
1148
1214
 
1149
- // Read the family-owned ack store. An ABSENT file (or absent docs/ai) is the NORMAL not-yet-acked
1150
- // state → null (plain fall-through, never a skip). A parse/IO error on an EXISTING file THROWS — the
1151
- // probe's catch turns it into a stated skip line (Decisions 2). A non-object root is a malformed
1152
- // store (fail-closed skip); a non-string value at the key is tolerated → null (the item re-fires).
1153
- // The WHOLE path chain (root / docs / ai / acks.json) is guarded WITHOUT following symlinks
1154
- // BEFORE any read: a symlinked ANCESTOR could otherwise read an ack from OUTSIDE the project (the
1155
- // writer refuses such a deployment — the reader must too), a symlinked/dangling LEAF must not read as
1156
- // not-yet-acked, and a non-regular target (FIFO/dir/device) is a fail-closed SKIP — never read it (a
1157
- // FIFO would BLOCK the advisor). ENOENT-safe: an absent file/dir is the NORMAL not-yet-acked null.
1158
- const readAckValue = (root, deps, ackKey) => {
1159
- const readFile = deps.readFile ?? readFileSync;
1160
- const lstat = deps.lstat ?? lstatSync;
1161
- const absPath = join(root, ACKS_FILE);
1162
- let st;
1163
- try {
1164
- assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
1165
- st = lstat(absPath);
1166
- } catch (err) {
1167
- if (err?.code === 'ENOENT') return null; // genuinely absent (file or docs/ai) — normal not-yet-acked
1168
- throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
1169
- }
1170
- if (!st.isFile()) {
1171
- throw new Error(`${ACKS_FILE} is not a regular file — refusing to read it`);
1172
- }
1173
- const parsed = JSON.parse(readFile(absPath, 'utf8'));
1174
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1175
- throw new Error(`${ACKS_FILE}: expected a JSON object`);
1176
- }
1177
- const value = parsed[ackKey];
1178
- return typeof value === 'string' ? value : null;
1179
- };
1180
-
1181
1215
  // Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
1182
1216
  // false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
1183
1217
  // an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
@@ -1208,7 +1242,38 @@ const readReadLaneToggle = (root, deps) => {
1208
1242
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1209
1243
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1210
1244
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1211
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel']);
1245
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption']);
1246
+
1247
+ // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1248
+ // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
1249
+ // started — an owner found the store absent only by asking. The survey reads the store through
1250
+ // spec-check's own census; a recorded decline (the `spec-adoption` ack lane) is the fact that
1251
+ // silences the item, and an unreadable store is a stated skip so the flow-optimal line never renders
1252
+ // over it. The not-adopted apply is the spec-layer ensure; the decline preview rides the recipe line.
1253
+ export const probeSpecAdoption = ({ root, deps, add, skip }) => {
1254
+ try {
1255
+ const survey = surveySpecAdoption(root, deps);
1256
+ if (survey.state === ADOPTION.UNREADABLE) {
1257
+ skip('spec-adoption', new Error(`${survey.reason} — the adoption state under ${SPEC_STORE_DIR_REL} cannot be judged`));
1258
+ return;
1259
+ }
1260
+ if (survey.state === ADOPTION.ADOPTED || readDeclineAck(root, deps)) return;
1261
+ const decline = `node ${q(toolPath('ack-write.mjs'))} --lane ${SPEC_ADOPTION_LANE} --fingerprint ${declineFingerprint()} --cwd ${q(root)}`;
1262
+ if (survey.state === ADOPTION.NOT_ADOPTED) {
1263
+ add(
1264
+ 'spec-adoption',
1265
+ fillTemplate(WHATS['spec-adoption'], {}),
1266
+ `node ${q(toolPath('ensure-configs.mjs'))} --reconcile --only ${SPEC_LAYER_ENSURE} --cwd ${q(root)}`,
1267
+ 'spec-adoption',
1268
+ `HAND-APPLY alternative (instead of the apply, never after it): decline the layer by recording it — ${decline}`,
1269
+ );
1270
+ return;
1271
+ }
1272
+ add('spec-adoption', fillTemplate(WHATS['spec-adoption.adopting'], { n: survey.draft }), decline, 'spec-adoption.adopting');
1273
+ } catch (err) {
1274
+ skip('spec-adoption', err);
1275
+ }
1276
+ };
1212
1277
 
1213
1278
  const probeSandboxLane = ({ root, deps, add, skip }) => {
1214
1279
  try {
@@ -1485,12 +1550,14 @@ const PROBES = Object.freeze([
1485
1550
  probeReadLane,
1486
1551
  probeStateBlockHook,
1487
1552
  probeCheapAgents,
1553
+ probeExecutorVehicle,
1488
1554
  probeFamilyFreshness,
1489
1555
  probeAdrStore,
1490
1556
  probeMasksItem,
1491
1557
  probeSandboxLane,
1492
1558
  probeWorktreesDir,
1493
1559
  probeMcpChannel,
1560
+ probeSpecAdoption,
1494
1561
  ]);
1495
1562
 
1496
1563
  export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
@@ -8,6 +8,7 @@
8
8
  // Pure, no side effects, Node >= 22.
9
9
 
10
10
  import { BLOCK_TITLES, SETTINGS_LABELS, glyphsFor, NO_DEPLOYMENT } from './presentation.mjs';
11
+ import { describeAdoption } from './spec-adoption.mjs';
11
12
 
12
13
  const MEMBER_COL = 20;
13
14
  const VERSION_COL = 12;
@@ -19,6 +20,10 @@ const SETTINGS_COL = 14;
19
20
  // checks, so a future token joins the render by joining this line.
20
21
  const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
21
22
 
23
+ const DEGRADE_ARROW = '←';
24
+ const EMPTY_CELL = '—';
25
+ const UNKNOWN_EXECUTOR = 'unknown';
26
+
22
27
  const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
23
28
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
24
29
  export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
@@ -96,6 +101,14 @@ const renderProject = (vm, { color }) => {
96
101
  if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
97
102
  lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
98
103
  }
104
+ // Every state renders — an owner opens this surface deliberately, so "not adopted" is the one line
105
+ // that must never be missing; an envelope without the field says so rather than inventing a state.
106
+ if (p.specs) {
107
+ const declineNote = p.specs.declineError ? ` (decline ack unreadable: ${p.specs.declineError})` : '';
108
+ lines.push(` ${pad('specs', STAMP_COL)}${describeAdoption(p.specs, { declined: p.specs.declined })}${declineNote}`);
109
+ } else {
110
+ lines.push(` ${pad('specs', STAMP_COL)}unknown — the installed kit predates the adoption state`);
111
+ }
99
112
  if (p.visibility) {
100
113
  const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
101
114
  lines.push(` ${pad('visibility', STAMP_COL)}${v}`);
@@ -107,13 +120,22 @@ const renderSettings = (vm, { color, glyph }) => {
107
120
  const s = vm.project?.settings;
108
121
  if (!s) return [];
109
122
  const lines = ['', heading(BLOCK_TITLES.settings, color)];
110
- // recipes — the effective recipe per slot, or a loud error; a detector floor adds a sub-line.
123
+ // recipes — one line per slot: the effective recipe, where it came from, and the requested value
124
+ // a degrade replaced (a joined single line could not carry three activities and their slots), or a
125
+ // loud error; a detector floor adds a sub-line.
111
126
  if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
112
127
  else if (s.recipes) {
113
- const joined = s.recipes.pairs.map((p) => `${p.key}=${p.recipe}`).join(' · ') || '—';
114
- lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}${joined}`);
128
+ const rows = s.recipes.pairs.map((p) => {
129
+ const source = p.source ? ` (${p.source})` : '';
130
+ const recovery = p.degradedFrom === 'subagent' ? ' — runs solo until the executor vehicle is usable (/agent-workflow-kit agents)' : '';
131
+ const degraded = p.degradedFrom ? ` ${DEGRADE_ARROW} degraded from ${p.degradedFrom}${recovery}` : '';
132
+ return `${p.key}: ${p.recipe}${source}${degraded}`;
133
+ });
134
+ (rows.length ? rows : [EMPTY_CELL]).forEach((row, i) => {
135
+ lines.push(` ${pad(i === 0 ? SETTINGS_LABELS.recipes : '', SETTINGS_COL)}${row}`);
136
+ });
115
137
  if (s.recipes.detectError) {
116
- lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
138
+ lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); bridge-backed recipes floored at solo; the executor vehicle is unaffected`);
117
139
  }
118
140
  }
119
141
  // attribution — effective includeCoAuthoredBy; a real local override is called out.
@@ -127,10 +149,17 @@ const renderSettings = (vm, { color, glyph }) => {
127
149
  else if (s.velocity) {
128
150
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
129
151
  }
130
- // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
131
- if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
152
+ // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle, then the
153
+ // split the subagent carrier turns on — the read-only vehicles and the ONE executor, whose state
154
+ // decides whether a slot configured `subagent` can ride it (an unusable one carries its reason).
155
+ if (s.agents?.error) {
156
+ const partial = s.agents.executor ? ` — executor ${s.agents.executor}${s.agents.executorReason ? ` (${s.agents.executorReason})` : ''}` : '';
157
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}${partial}`);
158
+ }
132
159
  else if (s.agents) {
133
- lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
160
+ const reason = s.agents.executorReason ? ` (${s.agents.executorReason})` : '';
161
+ const executor = s.agents.executor == null ? `${UNKNOWN_EXECUTOR} (the installed kit predates the field)` : `${s.agents.executor}${reason}`;
162
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}${s.agents.placed}/${s.agents.bundled} placed — ${s.agents.readOnly} read-only, executor ${executor}`);
134
163
  }
135
164
  // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
136
165
  // declared gate count (null → '?' — unknown is shown as unknown, never as a number).
@@ -93,9 +93,9 @@ import { join, dirname } from 'node:path';
93
93
  import { fileURLToPath } from 'node:url';
94
94
  import { spawnSync } from 'node:child_process';
95
95
  import { createHash } from 'node:crypto';
96
- import { detectBackends, READY } from './detect-backends.mjs';
96
+ import { detectBackends } from './detect-backends.mjs';
97
97
  import { isDirectRun } from './direct-run.mjs';
98
- import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
98
+ import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
99
99
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
100
100
  import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
101
101
  import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
@@ -309,16 +309,16 @@ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
309
309
  // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
310
310
  // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
311
311
  // flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
312
- export const buildState = ({ cwd, env = process.env, detect = detectBackends, lstat = lstatSync, readFile = readFileSync } = {}) => {
312
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
313
313
  const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
314
314
  const { config, source: configSource } = loadConfig(root);
315
- let detection = [];
315
+ // A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
316
+ // executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
316
317
  let detectionWarning = null;
317
- try {
318
- detection = detect();
319
- } catch (err) {
320
- detectionWarning = `backend detection failed (${(err && err.message) || err}) — readiness unknown.`;
321
- }
318
+ const onDetectError = (err) => {
319
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
320
+ };
321
+ const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
322
322
  // The resolver stays for DISPLAY/diagnostics only; the OBLIGATIONS come from the configured
323
323
  // recipe (never the readiness-degraded effective one — no silent solo).
324
324
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
@@ -439,7 +439,6 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, ls
439
439
  degradedExempt,
440
440
  maskedUntracked: countNeverCommittableUntracked(cwd, { lstat }),
441
441
  detectionWarning,
442
- anyReviewerReady: detection.some((b) => b.readiness === READY),
443
442
  flowPresent,
444
443
  flowArmed,
445
444
  flowBrokenReason,
@@ -733,7 +732,7 @@ export const main = (argv, ctx = {}) => {
733
732
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
734
733
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
735
734
  if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
736
- const state = buildState({ cwd, env, detect, lstat: ctx.lstat, readFile: ctx.readFile });
735
+ const state = buildState({ cwd, env, detect, surveyVehicle: ctx.surveyVehicle, lstat: ctx.lstat, readFile: ctx.readFile });
737
736
  const check = decideCheck(state);
738
737
  // The mask advisory is NON-FAILING by contract: one notice line, never an exit-code arm.
739
738
  const advisory = maskAdvisoryLine(state);