@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,71 @@
1
+ // spec-adoption.mjs — which of the four adoption states the feature-spec store is in, and whether the layer
2
+ // was declined. Contract: docs/ai/specs/kit/spec-adoption.md. The census and the per-document read are
3
+ // spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
4
+
5
+ import { join } from 'node:path';
6
+ import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
7
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
8
+ import { probe as probePath, realpath as realpathOf, list as listDir } from './spec-check-cli.mjs';
9
+ import { walkStore, readClosure } from './spec-check.mjs';
10
+ import { ACKS_SPEC_ADOPTION_KEY, factFingerprint, readAckValue } from './ack-store.mjs';
11
+
12
+ export const ADOPTION = Object.freeze({
13
+ NOT_ADOPTED: 'not-adopted',
14
+ ADOPTING: 'adopting',
15
+ ADOPTED: 'adopted',
16
+ UNREADABLE: 'unreadable',
17
+ });
18
+ export const ADOPTION_STATES = Object.freeze(Object.values(ADOPTION));
19
+
20
+ export const STORE_DIR_REL = SPEC_SCHEMA.storePrefix.slice(0, -1);
21
+ export const SPEC_ADOPTION_LANE = 'spec-adoption';
22
+ export const DECLINE_FACT = `spec-adoption:declined:${SPEC_SCHEMA.storePrefix}`;
23
+
24
+ const CONTRACT_KIND = 'spec';
25
+ const LIVE = 'live';
26
+ const DRAFT = 'draft';
27
+ const RETIRED = 'retired';
28
+
29
+ const DEFAULT_IO = Object.freeze({ read: readRegularFileNoFollow, probe: probePath, realpath: realpathOf, list: listDir });
30
+
31
+ const verdict = (state, counts = { live: 0, draft: 0, retired: 0 }, reason = null) => Object.freeze({ state, ...counts, reason });
32
+
33
+ // surveySpecAdoption(root, deps) -> { state, live, draft, retired, reason }. `deps.io` overrides the four
34
+ // IO primitives (tests); every other answer comes from the store bytes through the one reader.
35
+ export const surveySpecAdoption = (root, deps = {}) => {
36
+ const io = { ...DEFAULT_IO, ...(deps.io ?? {}) };
37
+ const dirState = io.probe(join(root, STORE_DIR_REL));
38
+ if (dirState === 'absent') return verdict(ADOPTION.NOT_ADOPTED);
39
+ if (dirState !== 'dir') return verdict(ADOPTION.UNREADABLE, undefined, `${STORE_DIR_REL} is ${dirState === 'file' ? 'a file' : dirState}, not a directory`);
40
+ const rootReal = io.realpath(root);
41
+ if (rootReal === null) return verdict(ADOPTION.UNREADABLE, undefined, 'the project root does not resolve');
42
+ const findings = [];
43
+ const ctx = { io, at: (rel) => (rel === '' ? root : `${root}/${rel}`), rootReal, add: (rule, path, message) => findings.push({ rule, path, message }) };
44
+ const closure = walkStore(ctx).map((path) => ({ path, roles: ['present'] }));
45
+ const docs = findings.length === 0 ? readClosure(closure, ctx) : new Map();
46
+ if (findings.length > 0) return verdict(ADOPTION.UNREADABLE, undefined, `${findings[0].path}: ${findings[0].message}`);
47
+ const counts = { live: 0, draft: 0, retired: 0 };
48
+ for (const doc of docs.values()) {
49
+ if (doc.verdict?.kind !== CONTRACT_KIND) continue;
50
+ if (doc.verdict.status === LIVE) counts.live += 1;
51
+ else if (doc.verdict.status === DRAFT) counts.draft += 1;
52
+ else if (doc.verdict.status === RETIRED) counts.retired += 1;
53
+ }
54
+ return verdict(counts.live > 0 ? ADOPTION.ADOPTED : ADOPTION.ADOPTING, counts);
55
+ };
56
+
57
+ export const declineFingerprint = () => factFingerprint(DECLINE_FACT);
58
+
59
+ // True when the store's decline is recorded; the guarded reader's refusals propagate to the caller.
60
+ export const readDeclineAck = (root, deps = {}) => readAckValue(root, deps, ACKS_SPEC_ADOPTION_KEY) === declineFingerprint();
61
+
62
+ const plural = (n, noun) => `${n} ${noun}`;
63
+
64
+ // The one status line body, per state (the caller prefixes its own label).
65
+ export const describeAdoption = ({ state, live, draft, reason }, { declined = false } = {}) => {
66
+ const suffix = declined && state !== ADOPTION.ADOPTED ? ' — declined' : '';
67
+ if (state === ADOPTION.NOT_ADOPTED) return `not adopted${suffix}`;
68
+ if (state === ADOPTION.ADOPTING) return `adopting (${plural(draft, 'draft')})${suffix}`;
69
+ if (state === ADOPTION.ADOPTED) return `adopted (${plural(live, 'live')}, ${plural(draft, 'draft')})`;
70
+ return `could not be read — ${reason}`;
71
+ };
@@ -87,7 +87,7 @@ const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], docume
87
87
  // Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
88
88
  // bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
89
89
  // read, so a directory that resolves outside the root is never opened through.
90
- const readClosure = (closure, ctx) => {
90
+ export const readClosure = (closure, ctx) => {
91
91
  const { io, at, rootReal, add } = ctx;
92
92
  const docs = new Map();
93
93
  for (const { path, roles } of closure) {
@@ -247,7 +247,7 @@ const judgeListing = (doc, docs, add) => {
247
247
  // FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
248
248
  // store would be the one answer this lane must never give. A directory is contained BEFORE it is
249
249
  // listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
250
- const walkStore = (ctx) => {
250
+ export const walkStore = (ctx) => {
251
251
  const { io, at, rootReal, add } = ctx;
252
252
  const found = [];
253
253
  const stack = [STORE_DIR];
@@ -44,7 +44,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
44
44
  'customized-preserved',
45
45
  'malformed-preserved',
46
46
  'already-present',
47
- 'skipped-no-node',
47
+ 'skipped-no-node-evidence',
48
48
  'old-adr-layout-migration-instructed',
49
49
  'failed',
50
50
  ],
@@ -114,6 +114,8 @@ const projectVm = (p) =>
114
114
  deployed: p.deployed,
115
115
  docsAi: p.docsAi,
116
116
  adrLayout: p.adrLayout ?? null,
117
+ // null = an envelope predating the field (unknown), never a state.
118
+ specs: p.specs ?? null,
117
119
  deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
118
120
  visibility: visibilityVm(p.visibility),
119
121
  settings: settingsVm(p.settings),