@sabaiway/agent-workflow-kit 10.1.0 → 10.3.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.
@@ -0,0 +1,57 @@
1
+ // ack-store.mjs — the family-owned neutral acknowledgement store: its path, its closed lane->key registry,
2
+ // the one fact fingerprint and the one guarded reader. Contract: docs/ai/specs/kit/ack-store.md.
3
+ // A READ-ONLY leaf (the writer is ack-write.mjs). Dependency-free, Node >= 22; no side effects on import.
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { lstatSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { assertContainedRealPath } from './fs-safe.mjs';
9
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
10
+
11
+ export const ACKS_FILE = 'docs/ai/acks.json';
12
+ export const ACKS_LANE_KEY = 'sandboxLaneAck';
13
+ export const ACKS_WORKTREES_DIR_KEY = 'worktreesDirAck';
14
+ export const ACKS_COVERAGE_DOMAIN_KEY = 'coverageDomainAck';
15
+ export const ACKS_SOURCE_SIZE_COPY_KEY = 'sourceSizeCopyAck';
16
+ export const ACKS_SPEC_ADOPTION_KEY = 'specAdoptionAck';
17
+
18
+ // The CLOSED-WORLD ack-lane registry: the lane name an advisor item renders on the writer's command line ->
19
+ // the store key that writer sets. A lane the registry does not name is a usage refusal at the writer.
20
+ export const ACK_LANES = Object.freeze({
21
+ 'sandbox-lane': ACKS_LANE_KEY,
22
+ 'worktrees-dir': ACKS_WORKTREES_DIR_KEY,
23
+ 'coverage-domain': ACKS_COVERAGE_DOMAIN_KEY,
24
+ 'source-size-copy': ACKS_SOURCE_SIZE_COPY_KEY,
25
+ 'spec-adoption': ACKS_SPEC_ADOPTION_KEY,
26
+ });
27
+
28
+ export const FINGERPRINT_LENGTH = 16;
29
+
30
+ // The one fingerprint over an acknowledged FACT (a canonical string the caller composed).
31
+ export const factFingerprint = (fact) => createHash('sha256').update(fact).digest('hex').slice(0, FINGERPRINT_LENGTH);
32
+
33
+ // readAckValue(root, deps, key) -> the recorded string at `key`, or null for the not-yet-acked states
34
+ // (an absent file or docs/ai, a non-string value). The path chain is guarded no-follow and the leaf is
35
+ // read descriptor-bound (`deps.nofollow` injects that door), so a leaf swapped after the guard cannot
36
+ // change the bytes read. A symlinked ancestor/leaf, an escape, a non-regular target, an IO error, a
37
+ // malformed or non-object store all THROW (the caller's stated-skip lane).
38
+ export const readAckValue = (root, deps = {}, ackKey) => {
39
+ const lstat = deps.lstat ?? lstatSync;
40
+ const absPath = join(root, ACKS_FILE);
41
+ try {
42
+ assertContainedRealPath(root, absPath, { lstat });
43
+ } catch (err) {
44
+ if (err?.code === 'ENOENT') return null;
45
+ throw err;
46
+ }
47
+ const read = readRegularFileNoFollow(absPath, deps.nofollow ?? {});
48
+ if (read.outcome === 'absent') return null;
49
+ if (read.outcome === 'foreign') throw new Error(`${ACKS_FILE} is a ${read.className}, not a regular file — refusing to read it`);
50
+ if (read.outcome !== 'ok') throw new Error(`${ACKS_FILE} cannot be read (${read.code})`);
51
+ const parsed = JSON.parse(read.content);
52
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
53
+ throw new Error(`${ACKS_FILE}: expected a JSON object`);
54
+ }
55
+ const value = parsed[ackKey];
56
+ return typeof value === 'string' ? value : null;
57
+ };
@@ -29,7 +29,7 @@ import { lstatSync, readFileSync } from 'node:fs';
29
29
  import { dirname, join, resolve } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { isDirectRun } from './direct-run.mjs';
32
- import { ACKS_FILE, ACK_LANES } from './recommendations.mjs';
32
+ import { ACKS_FILE, ACK_LANES } from './ack-store.mjs';
33
33
  import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
34
34
  import { shellQuoteArg } from './review-state.mjs';
35
35
 
@@ -72,6 +72,8 @@ import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabula
72
72
  // The MCP registration's four public strings. Imported from the READ-ONLY leaf, never from the
73
73
  // writer: a read-only lint must not pull the atomic-write core into its import graph.
74
74
  import { ENABLED_KEY as MCP_ENABLED_KEY, MCP_JSON_REL, SERVER_NAME as MCP_SERVER_NAME, allowRulesFor } from './mcp-registration.mjs';
75
+ // The spec-adoption state tokens the status mode doc must name (contract: kit/spec-adoption).
76
+ import { ADOPTION_STATES, SPEC_ADOPTION_LANE } from './spec-adoption.mjs';
75
77
 
76
78
  const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
77
79
  const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
@@ -86,6 +88,7 @@ const RECEIPT_DEADLINE_DOC = 'references/modes/receipt-deadline.md';
86
88
  const GATES_DOC = 'references/modes/gates.md';
87
89
  const MCP_DOC = 'references/modes/mcp.md';
88
90
  const UNINSTALL_DOC = 'references/modes/uninstall.md';
91
+ const STATUS_DOC = 'references/modes/status.md';
89
92
  // One literal for the dispatch mode doc: the structure leaf already names it as the file it anchors
90
93
  // its table in, and a second copy here is exactly the drift this lint exists to catch.
91
94
  const DISPATCH_DOC = ADVISOR_MATRIX_DOC;
@@ -236,6 +239,11 @@ export const BINDINGS = Object.freeze([
236
239
  valueBinding('mcp-enabled-key', MCP_ENABLED_KEY, `\`${MCP_ENABLED_KEY}\``, [MCP_DOC, UNINSTALL_DOC]),
237
240
  valueBinding('mcp-server-name', MCP_SERVER_NAME, `\`"${MCP_SERVER_NAME}"\``, [MCP_DOC, UNINSTALL_DOC]),
238
241
  ...allowRulesFor().map((rule) => valueBinding(`mcp-allow-rule:${rule}`, rule, `\`${rule}\``, [MCP_DOC, UNINSTALL_DOC])),
242
+ // The spec-adoption state tokens: status.md renders a plain phrase per token, so the doc must name
243
+ // every token the survey can answer — a fifth state added to the leaf with no phrase fails here.
244
+ // The decline lane rides both the status line and the advisor item, so both docs name it.
245
+ ...ADOPTION_STATES.map((state) => valueBinding(`spec-adoption:${state}`, state, `\`${state}\``, [STATUS_DOC])),
246
+ valueBinding('spec-adoption-lane', SPEC_ADOPTION_LANE, `--lane ${SPEC_ADOPTION_LANE}`, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
239
247
  ].map((b) => Object.freeze(b)));
240
248
 
241
249
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -16,8 +16,8 @@
16
16
  // • The DECISION lives where it already lived. The orchestration `_README` refresh asks
17
17
  // orchestration-config.mjs (refreshReadme / the known-prior canonical set) and writes through
18
18
  // orchestration-write.mjs — the file's one writer. Nothing here re-derives either.
19
- // • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node`
20
- // names the missing package.json; an ADR-layout read that fails is `adr-layout-unverifiable` and
19
+ // • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node-evidence`
20
+ // names every Node probe that answered absent; an ADR-layout read that fails is `adr-layout-unverifiable` and
21
21
  // writes NOTHING (the STRICT survey, fail-closed — the lenient status wrapper reads an unreadable
22
22
  // tree as `none`, which here would mean seeding a rotator beside a store nobody could inspect).
23
23
  // • A failed op is a non-zero signal, never a line that reads like success.
@@ -34,6 +34,7 @@ import { GATES_REL } from './gates-declaration.mjs';
34
34
  import { AUTONOMY_REL } from './autonomy-config.mjs';
35
35
  import { surveyAdrLayoutStrict } from './family-registry.mjs';
36
36
  import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
37
+ import { NODE_EVIDENCE, describeNodeProbes, probeNodeEvidence } from './node-evidence.mjs';
37
38
 
38
39
  // The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
39
40
  // relayed token set without importing this module's writer graph. Re-exported here because every
@@ -49,7 +50,6 @@ export {
49
50
  WRITE_TOKENS,
50
51
  } from './ensure-vocabulary.mjs';
51
52
 
52
- const PACKAGE_JSON = 'package.json';
53
53
  const SCRIPTS_DIR = 'scripts';
54
54
 
55
55
  const outcome = (op, token, lines, failed = false) => {
@@ -204,9 +204,19 @@ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
204
204
 
205
205
  // ── 4. scripts/ — the ADR-cascade enforcement pairs, detect-first ──────────────────────────────────
206
206
 
207
- // A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
208
- // never silent: the token names the evidence, and the three config ensures still run.
209
- export const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
207
+ // The Node-evidence refusal every ensure that places Node scripts shares (contract: kit/node-evidence):
208
+ // null when Node provably runs here, else the ONE outcome the caller returns a stated skip naming the
209
+ // probes that answered absent, or a fail-closed failure when a probe could not be read.
210
+ export const nodeEvidenceRefusal = (op, cwd, lstat) => {
211
+ const evidence = probeNodeEvidence(cwd, lstat);
212
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) {
213
+ return loud(op, 'node-evidence-unverifiable', `${SCRIPTS_DIR}/: whether Node runs here could not be read (${evidence.error}), so nothing was written — resolve it by hand, then re-run`);
214
+ }
215
+ if (evidence.state === NODE_EVIDENCE.NONE) {
216
+ return ok(op, 'skipped-no-node-evidence', `${SCRIPTS_DIR}/: no Node evidence in this tree — probed ${describeNodeProbes(evidence)}, no regular file present; the seeded pairs are Node enforcement, so nothing written`);
217
+ }
218
+ return null;
219
+ };
210
220
 
211
221
  const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
212
222
 
@@ -221,9 +231,8 @@ const partialNote = (lines) => {
221
231
  export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
222
232
  const lstat = deps.lstat ?? lstatSync;
223
233
  const read = deps.readFile ?? readFileSync;
224
- if (!isNodeProject(cwd, lstat)) {
225
- return ok('scripts', 'skipped-no-node', `${SCRIPTS_DIR}/: no ${PACKAGE_JSON} at the project root — the seeded pairs are Node enforcement; nothing written`);
226
- }
234
+ const refusal = nodeEvidenceRefusal('scripts', cwd, lstat);
235
+ if (refusal) return refusal;
227
236
  let layout;
228
237
  try {
229
238
  layout = surveyAdrLayoutStrict(cwd, deps);
@@ -26,7 +26,7 @@ import { readFileSync, lstatSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
28
28
  import { classifyDeployedScript } from './script-priors.mjs';
29
- import { composeFailure, composeOutcome, isNodeProject, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
29
+ import { composeFailure, composeOutcome, nodeEvidenceRefusal, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
30
30
 
31
31
  const OP = 'specs';
32
32
  const SCRIPTS_DIR = 'scripts';
@@ -159,9 +159,8 @@ const renderStoreRoot = (kitRoot, read, today) => {
159
159
  export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
160
160
  const lstat = deps.lstat ?? lstatSync;
161
161
  const read = deps.readFile ?? readFileSync;
162
- if (!isNodeProject(cwd, lstat)) {
163
- return ok('skipped-no-node', [`${SCRIPTS_DIR}/: no package.json at the project root — the spec reader and checker are Node scripts; nothing written`]);
164
- }
162
+ const refusal = nodeEvidenceRefusal(OP, cwd, lstat);
163
+ if (refusal) return refusal;
165
164
  const survey = (name) => surveyScript({ cwd, kitRoot, name, read, lstat });
166
165
  const reader = READER_PAIR.map(survey);
167
166
  const checker = CHECKER_PAIR.map(survey);
@@ -31,7 +31,7 @@ export const ENSURE_TOKENS = Object.freeze([
31
31
  'already-present',
32
32
  'customized-preserved',
33
33
  'malformed-preserved',
34
- 'skipped-no-node',
34
+ 'skipped-no-node-evidence',
35
35
  'old-adr-layout-migration-instructed',
36
36
  'failed',
37
37
  ]);
@@ -44,6 +44,9 @@ export const FAILURE_CAUSES = Object.freeze([
44
44
  'template-unreadable',
45
45
  'bundle-unreadable',
46
46
  'adr-layout-unverifiable',
47
+ // A Node probe (package.json, a kit-seeded script) failed with anything but ENOENT: whether Node runs
48
+ // here is unproven, so the ensures that place Node scripts write nothing (contract: kit/node-evidence).
49
+ 'node-evidence-unverifiable',
47
50
  'wrong-node-kind',
48
51
  'write-refused',
49
52
  'unexpected-error',
@@ -75,7 +78,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
75
78
  'customized-preserved',
76
79
  'malformed-preserved',
77
80
  'already-present',
78
- 'skipped-no-node',
81
+ 'skipped-no-node-evidence',
79
82
  'old-adr-layout-migration-instructed',
80
83
  'failed',
81
84
  ]);
@@ -82,6 +82,8 @@ import {
82
82
  import { detectSurface } from './surface.mjs';
83
83
  import { toViewModel } from './view-model.mjs';
84
84
  import { render } from './renderers.mjs';
85
+ // The feature-spec adoption state (contract: kit/spec-adoption) — read-only leaves, no cycle.
86
+ import { ADOPTION, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
85
87
 
86
88
  // ── manifestState values — re-export the EXACT public subset family-registry exported before B1 ─────
87
89
  // (the 7 state constants + DISPLAY_NAMES) so every existing importer (uninstall.mjs, the test suites)
@@ -428,9 +430,33 @@ const surveyAdrLayout = (dir, deps) => {
428
430
  }
429
431
  };
430
432
 
433
+ // The spec-adoption survey, LENIENT for the read-only view — two facts, each failing on its own: a
434
+ // survey that throws becomes the `unreadable` state with its reason, and an ack read that throws
435
+ // keeps the store state and carries `declineError` beside `declined: false` — the status line still
436
+ // renders, never a crash, never a silent "not adopted" and never a decline it could not read.
437
+ const surveySpecs = (dir, deps) => {
438
+ const survey = (() => {
439
+ try {
440
+ return surveySpecAdoption(dir, deps);
441
+ } catch (err) {
442
+ return { state: ADOPTION.UNREADABLE, live: 0, draft: 0, reason: localizeError(err) };
443
+ }
444
+ })();
445
+ const decline = (() => {
446
+ if (survey.state === ADOPTION.ADOPTED) return { declined: false, declineError: null };
447
+ try {
448
+ return { declined: readDeclineAck(dir, deps), declineError: null };
449
+ } catch (err) {
450
+ return { declined: false, declineError: localizeError(err) };
451
+ }
452
+ })();
453
+ return { state: survey.state, live: survey.live, draft: survey.draft, reason: survey.reason, ...decline };
454
+ };
455
+
431
456
  // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
432
- // docs/ai/ exists, the ADR-store layout, and whether the hidden-mode fence is present. Pure (fs reads
433
- // only, all injectable), no git subprocess — the read-only `status` view must never mutate or spawn.
457
+ // docs/ai/ exists, the ADR-store layout, the spec-adoption state, and whether the hidden-mode fence is
458
+ // present. Pure (fs reads only, all injectable), no git subprocess — the read-only `status` view must
459
+ // never mutate or spawn.
434
460
  export const surveyProject = (projectDir, deps = {}) => {
435
461
  const exists = deps.exists ?? existsSync;
436
462
  const dir = resolve(projectDir);
@@ -445,7 +471,7 @@ export const surveyProject = (projectDir, deps = {}) => {
445
471
  }
446
472
  })();
447
473
  const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
448
- return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
474
+ return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), specs: surveySpecs(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
449
475
  };
450
476
 
451
477
  // ── report ───────────────────────────────────────────────────────────────────────
@@ -683,6 +709,9 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
683
709
  deployed: project.deployed,
684
710
  docsAi: project.docsAiPresent,
685
711
  adrLayout: project.adrLayout, // 'old' | 'old-unrotated' | 'migrated' | 'none' — a user-safe token, never a raw path
712
+ // { state: not-adopted | adopting | adopted | unreadable, live, draft, reason, declined, declineError }
713
+ // — an envelope predating the field omits it (the view-model reads that as unknown, never as a state).
714
+ ...(project.specs ? { specs: project.specs } : {}),
686
715
  // member + display + version only — never the internal stamp FILENAME (s.file).
687
716
  deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
688
717
  };
@@ -62,7 +62,10 @@ Apply this as part of §2 before any user-facing summary:
62
62
  - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
63
63
  - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
64
64
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
65
- export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE];
65
+ // The canon that shipped between the closing-state-block contract and the contradicted-skip bullet.
66
+ const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
67
+ - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
68
+ export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
66
69
 
67
70
  const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
68
71
  const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
@@ -0,0 +1,77 @@
1
+ // node-evidence.mjs — does Node PROVABLY run in this project tree? Contract: docs/ai/specs/kit/node-evidence.md.
2
+ // Pure over an injectable lstat; no writes, no side effects on import. Dependency-free, Node >= 22.
3
+
4
+ import { lstatSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export const NODE_EVIDENCE = Object.freeze({
8
+ PACKAGE_JSON: 'package-json',
9
+ DEPLOYED_SCRIPTS: 'deployed-node-scripts',
10
+ NONE: 'none',
11
+ UNREADABLE: 'unreadable',
12
+ });
13
+
14
+ export const PACKAGE_JSON_REL = 'package.json';
15
+ export const SCRIPTS_DIR = 'scripts';
16
+
17
+ // The runnable scripts the bootstrap copies from references/scripts/ — pinned against the bundle by the suite.
18
+ export const NODE_EVIDENCE_SCRIPTS = Object.freeze([
19
+ 'archive-caps.mjs',
20
+ 'archive-changelog.mjs',
21
+ 'archive-decisions.mjs',
22
+ 'archive-issues.mjs',
23
+ 'check-docs-size.mjs',
24
+ 'install-git-hooks.mjs',
25
+ 'markdown-blocks.mjs',
26
+ 'migrate-gates.mjs',
27
+ 'spec-schema.mjs',
28
+ ]);
29
+
30
+ export const NODE_EVIDENCE_PROBES = Object.freeze([PACKAGE_JSON_REL, ...NODE_EVIDENCE_SCRIPTS.map((name) => `${SCRIPTS_DIR}/${name}`)]);
31
+
32
+ const ENOENT = 'ENOENT';
33
+ const kindOf = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : st.isFile() ? 'a regular file' : 'not a regular file');
34
+
35
+ const answer = (state, evidence, wrongKind, extra = {}) =>
36
+ Object.freeze({ state, evidence, probed: NODE_EVIDENCE_PROBES, wrongKind: Object.freeze(wrongKind), ...extra });
37
+
38
+ // probeNodeEvidence(cwd, lstat) -> { state, evidence, probed, wrongKind, error? }: the first regular file
39
+ // among the probes answers; a probe failing with anything but ENOENT answers unreadable at once; a path of
40
+ // the wrong node kind is not evidence — it is recorded in `wrongKind` and the walk continues. lstat does
41
+ // not follow the LEAF but walks THROUGH a symlinked scripts/, whose files are not this tree's — so the
42
+ // directory is proven plain before any script inside it counts.
43
+ export const probeNodeEvidence = (cwd, lstat = lstatSync) => {
44
+ const wrongKind = [];
45
+ const probeKind = (rel, wanted) => {
46
+ let st;
47
+ try {
48
+ st = lstat(join(cwd, rel));
49
+ } catch (err) {
50
+ if (err && err.code === ENOENT) return 'absent';
51
+ throw Object.assign(err, { probedRel: rel });
52
+ }
53
+ if (wanted === 'dir' ? st.isDirectory() && !st.isSymbolicLink() : st.isFile()) return wanted;
54
+ wrongKind.push(`${rel} is ${kindOf(st)}`);
55
+ return 'wrong-kind';
56
+ };
57
+ try {
58
+ if (probeKind(PACKAGE_JSON_REL, 'file') === 'file') return answer(NODE_EVIDENCE.PACKAGE_JSON, PACKAGE_JSON_REL, wrongKind);
59
+ if (probeKind(SCRIPTS_DIR, 'dir') === 'dir') {
60
+ for (const rel of NODE_EVIDENCE_PROBES.slice(1)) {
61
+ if (probeKind(rel, 'file') === 'file') return answer(NODE_EVIDENCE.DEPLOYED_SCRIPTS, rel, wrongKind);
62
+ }
63
+ }
64
+ } catch (err) {
65
+ return answer(NODE_EVIDENCE.UNREADABLE, null, wrongKind, { error: `${err.code || err.message || 'lstat failed'} on ${err.probedRel}` });
66
+ }
67
+ return answer(NODE_EVIDENCE.NONE, null, wrongKind);
68
+ };
69
+
70
+ export const hasNodeEvidence = (probe) => probe.state === NODE_EVIDENCE.PACKAGE_JSON || probe.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS;
71
+
72
+ // The sentence a skip line carries: every probe checked, and what of the wrong kind sat at any of them.
73
+ export const describeNodeProbes = (probe = null) => {
74
+ const probes = `${PACKAGE_JSON_REL} and the kit-seeded ${SCRIPTS_DIR}/ files (${NODE_EVIDENCE_SCRIPTS.join(', ')})`;
75
+ const wrong = probe?.wrongKind?.length ? ` — not evidence: ${probe.wrongKind.join('; ')}` : '';
76
+ return `${probes}${wrong}`;
77
+ };
@@ -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';
@@ -84,6 +83,25 @@ 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
85
  import { preflightCheapAgents } from './cheap-agents.mjs';
86
+ // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
87
+ // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
88
+ import {
89
+ ACKS_FILE,
90
+ ACKS_LANE_KEY,
91
+ ACKS_WORKTREES_DIR_KEY,
92
+ ACKS_COVERAGE_DOMAIN_KEY,
93
+ ACKS_SOURCE_SIZE_COPY_KEY,
94
+ ACK_LANES,
95
+ factFingerprint,
96
+ readAckValue,
97
+ } from './ack-store.mjs';
98
+ import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
99
+ import { ENSURE_OPS } from './ensure-vocabulary.mjs';
100
+
101
+ // The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
102
+ const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
103
+
104
+ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_KEY, ACKS_SOURCE_SIZE_COPY_KEY, ACK_LANES, factFingerprint };
87
105
 
88
106
  const HERE = dirname(fileURLToPath(import.meta.url));
89
107
  const toolPath = (rel) => join(HERE, rel);
@@ -163,6 +181,11 @@ export const SEVERITIES = Object.freeze({
163
181
  'sandbox-masks': SEVERITY_OPTIONAL,
164
182
  'sandbox-lane': SEVERITY_OPTIONAL,
165
183
  'worktrees-dir': SEVERITY_OPTIONAL,
184
+ // The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
185
+ // declaration that is broken): an absent store offers the seed, a store with no live contract offers
186
+ // the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
187
+ 'spec-adoption': SEVERITY_OPTIONAL,
188
+ 'spec-adoption.adopting': SEVERITY_OPTIONAL,
166
189
  });
167
190
  // The per-item render tags (frozen presentation data, same language contract as the templates).
168
191
  export const SEVERITY_LABELS = Object.freeze({
@@ -234,6 +257,8 @@ export const WHATS = Object.freeze({
234
257
  '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
258
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
236
259
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
260
+ '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',
261
+ '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
262
  });
238
263
 
239
264
  // ── the shape contract (D2): registry strings AND composed items stay one line under the cap ────
@@ -290,6 +315,7 @@ export const BENEFITS = Object.freeze({
290
315
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
291
316
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
292
317
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
318
+ '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
319
  });
294
320
 
295
321
  // ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
@@ -332,6 +358,9 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
332
358
  { id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
333
359
  { id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
334
360
  { id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
361
+ // The feature-spec layer is delivered by upgrade's spec-layer ensure (there is no specs mode), so
362
+ // its adoption state is declared where the store is seeded.
363
+ { id: 'spec-adoption', mode: 'upgrade', advisorKey: 'spec-adoption' },
335
364
  { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
336
365
  { id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
337
366
  // The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
@@ -1042,41 +1071,13 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
1042
1071
  if (abs === homeAbs) return '~';
1043
1072
  return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
1044
1073
  };
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);
1074
+ return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
1047
1075
  };
1048
1076
 
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
- });
1077
+ // The ack store (D4; AD-055 Part I) is the kit-owned PRIMARY ack channel; the legacy settings-scope
1078
+ // keys below are read for one deprecation window. An ack lane exists for a state the maintainer can
1079
+ // only ANSWER, never converge a dead checker/producer pair is fixed, not acknowledged, so no lane
1080
+ // names it. The store's path, keys, lane registry and reader are ack-store.mjs (re-exported above).
1080
1081
 
1081
1082
  // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
1082
1083
  // placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
@@ -1146,38 +1147,6 @@ const declarationCarriesMarker = (root, deps) => {
1146
1147
  export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
1147
1148
  export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
1148
1149
 
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
1150
  // Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
1182
1151
  // false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
1183
1152
  // an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
@@ -1208,7 +1177,38 @@ const readReadLaneToggle = (root, deps) => {
1208
1177
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1209
1178
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1210
1179
  // (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']);
1180
+ 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']);
1181
+
1182
+ // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1183
+ // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
1184
+ // started — an owner found the store absent only by asking. The survey reads the store through
1185
+ // spec-check's own census; a recorded decline (the `spec-adoption` ack lane) is the fact that
1186
+ // silences the item, and an unreadable store is a stated skip so the flow-optimal line never renders
1187
+ // over it. The not-adopted apply is the spec-layer ensure; the decline preview rides the recipe line.
1188
+ export const probeSpecAdoption = ({ root, deps, add, skip }) => {
1189
+ try {
1190
+ const survey = surveySpecAdoption(root, deps);
1191
+ if (survey.state === ADOPTION.UNREADABLE) {
1192
+ skip('spec-adoption', new Error(`${survey.reason} — the adoption state under ${SPEC_STORE_DIR_REL} cannot be judged`));
1193
+ return;
1194
+ }
1195
+ if (survey.state === ADOPTION.ADOPTED || readDeclineAck(root, deps)) return;
1196
+ const decline = `node ${q(toolPath('ack-write.mjs'))} --lane ${SPEC_ADOPTION_LANE} --fingerprint ${declineFingerprint()} --cwd ${q(root)}`;
1197
+ if (survey.state === ADOPTION.NOT_ADOPTED) {
1198
+ add(
1199
+ 'spec-adoption',
1200
+ fillTemplate(WHATS['spec-adoption'], {}),
1201
+ `node ${q(toolPath('ensure-configs.mjs'))} --reconcile --only ${SPEC_LAYER_ENSURE} --cwd ${q(root)}`,
1202
+ 'spec-adoption',
1203
+ `HAND-APPLY alternative (instead of the apply, never after it): decline the layer by recording it — ${decline}`,
1204
+ );
1205
+ return;
1206
+ }
1207
+ add('spec-adoption', fillTemplate(WHATS['spec-adoption.adopting'], { n: survey.draft }), decline, 'spec-adoption.adopting');
1208
+ } catch (err) {
1209
+ skip('spec-adoption', err);
1210
+ }
1211
+ };
1212
1212
 
1213
1213
  const probeSandboxLane = ({ root, deps, add, skip }) => {
1214
1214
  try {
@@ -1491,6 +1491,7 @@ const PROBES = Object.freeze([
1491
1491
  probeSandboxLane,
1492
1492
  probeWorktreesDir,
1493
1493
  probeMcpChannel,
1494
+ probeSpecAdoption,
1494
1495
  ]);
1495
1496
 
1496
1497
  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;
@@ -96,6 +97,14 @@ const renderProject = (vm, { color }) => {
96
97
  if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
97
98
  lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
98
99
  }
100
+ // Every state renders — an owner opens this surface deliberately, so "not adopted" is the one line
101
+ // that must never be missing; an envelope without the field says so rather than inventing a state.
102
+ if (p.specs) {
103
+ const declineNote = p.specs.declineError ? ` (decline ack unreadable: ${p.specs.declineError})` : '';
104
+ lines.push(` ${pad('specs', STAMP_COL)}${describeAdoption(p.specs, { declined: p.specs.declined })}${declineNote}`);
105
+ } else {
106
+ lines.push(` ${pad('specs', STAMP_COL)}unknown — the installed kit predates the adoption state`);
107
+ }
99
108
  if (p.visibility) {
100
109
  const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
101
110
  lines.push(` ${pad('visibility', STAMP_COL)}${v}`);