@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.
- package/CHANGELOG.md +58 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +4 -2
- package/bridges/antigravity-cli-bridge/bin/agy-review-harness.test.mjs +288 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-verdict.test.mjs +109 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +19 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +5 -336
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/status.md +1 -1
- package/references/modes/upgrade.md +6 -4
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/agent_rules.md +3 -2
- package/tools/ack-store.mjs +57 -0
- package/tools/ack-write.mjs +1 -1
- package/tools/doc-parity.mjs +8 -0
- package/tools/ensure-ops.mjs +18 -9
- package/tools/ensure-specs.mjs +3 -4
- package/tools/ensure-vocabulary.mjs +5 -2
- package/tools/family-registry.mjs +32 -3
- package/tools/lens-region.mjs +4 -1
- package/tools/node-evidence.mjs +77 -0
- package/tools/recommendations.mjs +68 -67
- package/tools/renderers.mjs +9 -0
- package/tools/spec-adoption.mjs +71 -0
- package/tools/spec-check.mjs +2 -2
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/view-model.mjs +2 -0
|
@@ -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
|
+
};
|
package/tools/ack-write.mjs
CHANGED
|
@@ -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 './
|
|
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
|
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -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) ────────────────────────
|
package/tools/ensure-ops.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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);
|
package/tools/ensure-specs.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
433
|
-
// only, all injectable), no git subprocess — the read-only `status` view must
|
|
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
|
};
|
package/tools/lens-region.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
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 = {} } = {}) => {
|
package/tools/renderers.mjs
CHANGED
|
@@ -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}`);
|