@sabaiway/agent-workflow-kit 1.40.0 → 1.42.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.
@@ -270,6 +270,31 @@ const formatIndexRow = (row) => {
270
270
  return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
271
271
  };
272
272
 
273
+ // The one-file-per-ADR store (docs/ai/adr/) grows O(n) forever, so its rows would blow the index's
274
+ // own 80-line cap. It COLLAPSES to a single aggregate row (link → the navigator adr/log.md, record
275
+ // count + numeric id range) — while walkMarkdownFiles still finds + cap-checks every individual body
276
+ // (a body over its own cap still fails in the main flow; only the index RENDERING is collapsed).
277
+ const ADR_DIR_PREFIX = 'docs/ai/adr/';
278
+ const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
279
+ const ADR_NAV_PATH = 'docs/ai/adr/log.md';
280
+
281
+ // Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
282
+ // adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
283
+ // by the collapse (it also fails archive-decisions' own store-integrity check).
284
+ const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
285
+
286
+ const formatAdrCollapseRow = (adrRows) => {
287
+ const recs = adrRows
288
+ .map((r) => {
289
+ const m = r.path.match(ADR_RECORD_RE);
290
+ return m ? { idStr: m[1], idNum: Number(m[1]) } : null;
291
+ })
292
+ .filter(Boolean)
293
+ .sort((a, b) => a.idNum - b.idNum); // NUMERIC id ordering (AD-200 before AD-1000), never lexical
294
+ const range = recs.length > 0 ? `AD-${recs[0].idStr} … AD-${recs[recs.length - 1].idStr}` : '—';
295
+ return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
296
+ };
297
+
273
298
  // Pure index renderer — given inspected rows + the date to stamp in the header,
274
299
  // returns the exact bytes `docs/ai/index.md` should contain. Shared by
275
300
  // `--write-index` (writes it) and `--check-index` (diffs against on-disk).
@@ -277,13 +302,18 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
277
302
  const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
278
303
  const onDemandLinks = meta.onDemandLinks ?? [];
279
304
  const hierarchicalLinks = meta.hierarchicalLinks ?? [];
280
- const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
281
305
  const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
282
306
  const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
283
- const tableRows = sorted
284
- .filter((r) => r.path !== 'docs/ai/index.md')
285
- .map(formatIndexRow)
286
- .join('\n');
307
+ const nonAdr = [];
308
+ const adrRows = [];
309
+ for (const r of rows) {
310
+ if (r.path === 'docs/ai/index.md') continue;
311
+ (isCollapsibleAdr(r.path) ? adrRows : nonAdr).push(r);
312
+ }
313
+ const tableEntries = nonAdr.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
314
+ if (adrRows.length > 0) tableEntries.push({ sortPath: ADR_DIR_PREFIX, md: formatAdrCollapseRow(adrRows) });
315
+ tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
316
+ const tableRows = tableEntries.map((e) => e.md).join('\n');
287
317
  const onDemandSection =
288
318
  onDemandLinks.length > 0
289
319
  ? `\n\n## Skills (on-demand)\n\n${onDemandLinks.map((link) => `- ${link}`).join('\n')}`
@@ -164,6 +164,61 @@ const makeRow = (path, overrides = {}) => ({
164
164
  ...overrides,
165
165
  });
166
166
 
167
+ // The one-file-per-ADR store collapses to a SINGLE aggregate index row so the index stays under its
168
+ // own 80-line cap no matter how many records accumulate — while every body is still cap-checked.
169
+ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
170
+ it('200 synthetic AD-*.md records collapse to ONE aggregate row; the index stays ≤ 80 lines', () => {
171
+ const adr = Array.from({ length: 200 }, (_, i) => makeRow(`docs/ai/adr/AD-${String(i + 1).padStart(3, '0')}-record-${i}.md`, { frontmatter: { type: 'adr', maxLines: '400', lastUpdated: '2026-07-09', staleAfter: 'never' } }));
172
+ const rows = [makeRow('docs/ai/handover.md'), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), ...adr];
173
+ const out = buildIndex(rows, '2026-07-09');
174
+ const adrRowLines = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
175
+ expect(adrRowLines.length).toBe(1); // exactly one row references the whole adr/ tree
176
+ expect(out).not.toMatch(/AD-001-record-0\.md/); // individual records are NOT listed
177
+ expect(out.split('\n').length <= 80).toBe(true);
178
+ });
179
+
180
+ it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
181
+ const rows = [
182
+ makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
183
+ makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
184
+ makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
185
+ ];
186
+ const out = buildIndex(rows, '2026-07-09');
187
+ expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
188
+ const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
189
+ expect(aggregateRows.length).toBe(1); // exactly one aggregate row for the real record(s)
190
+ });
191
+
192
+ it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
193
+ const rows = [
194
+ makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
195
+ makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
196
+ makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
197
+ ];
198
+ const out = buildIndex(rows, '2026-07-09');
199
+ expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
200
+ });
201
+
202
+ it('adding a record drifts the collapse row → checkIndexFreshness flags it stale', () => {
203
+ const base = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
204
+ const onDisk = buildIndex(base, '2026-07-09');
205
+ const grown = [...base, makeRow('docs/ai/adr/AD-002-b.md', { frontmatter: { type: 'adr', maxLines: '400' } })];
206
+ expect(checkIndexFreshness(grown, onDisk).fresh).toBe(false);
207
+ });
208
+
209
+ it('a single adr record OVER its own cap still fails inspectFile (the collapse never hides a fat body)', async () => {
210
+ const dir = await mkdtemp(join(tmpdir(), 'adr-cap-'));
211
+ try {
212
+ const path = join(dir, 'AD-001-huge.md');
213
+ await writeFile(path, `---\ntype: adr\nlastUpdated: 2026-07-09\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 5\n---\n\n## AD-001 — Huge\n${'x\n'.repeat(20)}`);
214
+ const result = await inspectFile(path, computeToday('2026-07-09'));
215
+ expect(result.errors.some((e) => /lines > maxLines/.test(e))).toBe(true);
216
+ } finally {
217
+ await rm(dir, { recursive: true, force: true });
218
+ }
219
+ });
220
+ });
221
+
167
222
  describe('buildIndex', () => {
168
223
  it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
169
224
  const rows = [
@@ -0,0 +1,25 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: never
6
+ owner: none
7
+ maxLines: 200
8
+ ---
9
+
10
+ # ADR Navigator — active set (AD-001 … AD-001)
11
+
12
+ > **Auto-generated** (`archive-decisions.mjs --write-navigator`). The governing heads only —
13
+ > an ADR superseded/amended by another drops OUT (still reachable by filename, grep, or the
14
+ > `[[AD-NNN]]` chain). The HOT window lives in [`../decisions.md`](../decisions.md); every
15
+ > archived record is one file in this directory. This is a navigator, never a full ledger.
16
+
17
+ ## Governing (1) — accepted & not superseded
18
+
19
+ | ADR | Title | Record |
20
+ |-----|-------|--------|
21
+ | AD-001 | Adopt AI-agent memory system (`docs/ai/`) | [`../decisions.md`](../decisions.md) |
22
+
23
+ ## Recent (1)
24
+
25
+ - **AD-001** — governing — `decisions.md (HOT)`
@@ -0,0 +1,72 @@
1
+ # ADR record format — authoring & lifecycle reference
2
+
3
+ > How an Architecture Decision Record is authored, archived, and retrieved in this project's
4
+ > **one-file-per-ADR** store. Read this when you author a new ADR or change an existing one's
5
+ > lifecycle. This file is a reference — it is **not** deployed into `docs/ai/` (it never becomes a
6
+ > record itself).
7
+
8
+ ## Where ADRs live
9
+
10
+ - **HOT window — `docs/ai/decisions.md`.** The active ADR window. You **author here**, newest at the
11
+ bottom. It is self-bounding under its frontmatter `maxLines`.
12
+ - **Store — `docs/ai/adr/AD-NNN-slug.md`.** One immutable record per archived ADR (full frontmatter +
13
+ the verbatim `## AD-NNN — title` block). You **never hand-write** these — `archive-decisions.mjs`
14
+ produces them by exploding the oldest HOT entries beyond the cap.
15
+ - **Navigator — `docs/ai/adr/log.md`.** The one generated map: the currently-governing heads
16
+ (accepted ∧ not superseded) + a recent window. Superseded ADRs drop OUT (still reachable by
17
+ filename, grep, or the `[[AD-NNN]]` chain). It is a navigator, never a full ledger.
18
+
19
+ ## Authoring a new ADR (in the HOT window)
20
+
21
+ Append a block at the **bottom** of `docs/ai/decisions.md`:
22
+
23
+ ```
24
+ ## AD-NNN — <concise title>
25
+
26
+ **Date:** YYYY-MM-DD
27
+ **Status:** Accepted
28
+
29
+ **Context.** Why this decision is needed; the forces at play.
30
+ **Decision.** What was chosen (imperative, unambiguous).
31
+ **Consequences.** ➕ benefits · ➖ costs. Note any ADR this supersedes.
32
+ ```
33
+
34
+ - **ID grammar: `AD-\d{3,}`.** Three digits minimum; `AD-1000+` is valid. Allocate the next integer
35
+ after the highest id across HOT ∪ `adr/`. Ordering is always **numeric**, never lexical.
36
+ - **The `## AD-NNN — <title>` heading is strict.** Use the em dash ` — `. A non-canonical `## ` heading
37
+ is a loud parse failure — the store never silently glues an entry to the previous body.
38
+ - **`slug` is cosmetic and frozen at creation** (`slugify(title)`); the `AD-NNN` prefix is the key. A
39
+ retitle never renames the file.
40
+ - The block is preserved **verbatim** when it is archived, so write it as you want it to persist.
41
+
42
+ ## Lifecycle (the only mutable part after acceptance)
43
+
44
+ The **body is immutable** once accepted — only lifecycle changes. Express supersession in the body of
45
+ the **new** ADR; governance is then computed automatically (no predecessor-file edit is required):
46
+
47
+ | Body form (in the newer ADR) | Effect |
48
+ |----------------------------------|------------------------------------------------------------------|
49
+ | `Supersedes [[AD-NNN]]` | retires AD-NNN (when the citing ADR is `accepted`) |
50
+ | `Superseded by [[AD-NNN]]` | marks this ADR retired by AD-NNN |
51
+ | `Amended by [[AD-NNN]]` | marks this ADR amended (also drops it from the governing heads) |
52
+
53
+ - **`status`** is the leading word of `**Status:**`, lowercased (`accepted` / `superseded` /
54
+ `amended` / `deprecated`). A **missing** `**Status:**` line defaults to `accepted` (the in-force
55
+ default). Only `accepted` ADRs govern.
56
+ - When an ADR is archived, its record frontmatter backfills `status`, `date`, `supersedes`, and
57
+ `supersededBy` from these body forms — you do not maintain the frontmatter by hand.
58
+
59
+ ## Retrieval (never through an O(n) artifact)
60
+
61
+ - **by id** → the deterministic filename `docs/ai/adr/AD-NNN-*.md` (glob).
62
+ - **by topic** → `grep` the flat `docs/ai/adr/` tree.
63
+ - **by lifecycle** → the two-way `supersedes` / `supersededBy` frontmatter + the `[[AD-NNN]]` chain;
64
+ `ls docs/ai/adr/` IS the log.
65
+
66
+ ## Commands
67
+
68
+ - `node scripts/archive-decisions.mjs` — rotate: explode the oldest HOT entries beyond the cap into
69
+ `adr/` records, then regenerate the navigator + `docs/ai/index.md`.
70
+ - `node scripts/archive-decisions.mjs --write-navigator` — regenerate `docs/ai/adr/log.md` (and the
71
+ index) after authoring an ADR or editing a supersession, so `--check` stays green.
72
+ - `node scripts/archive-decisions.mjs --check` — verify HOT cap + store integrity + navigator freshness.
@@ -9,7 +9,11 @@ maxLines: 500
9
9
 
10
10
  # Architecture Decision Records (ADRs)
11
11
 
12
- > Every significant choice that has long-term consequences. Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.
12
+ > The **HOT window** of Architecture Decision Records every significant choice with long-term
13
+ > consequences, newest at the bottom. Link related ADRs with `[[AD-XXX]]`; retrieval is by the
14
+ > `AD-NNN` id (filename), grep over the flat store, or the `[[AD-NNN]]` supersession chain.
15
+
16
+ > **Archive:** older ADRs are stored one immutable file per record under [`adr/`](./adr/) — see the active-set navigator [`adr/log.md`](./adr/log.md). `archive-decisions.mjs` explodes the oldest entries beyond this window automatically; no cap is ever raised and there is no monolithic ledger.
13
17
 
14
18
  ## AD-001 — Adopt AI-agent memory system (`docs/ai/`)
15
19
 
@@ -18,27 +22,12 @@ maxLines: 500
18
22
 
19
23
  **Context.** Multi-session AI work loses context between runs. Without a structured handover, each new session re-reads code, re-discovers decisions, and repeats past mistakes.
20
24
 
21
- **Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + 3-tier archive via a pre-commit hook. Deployed via the `agent-workflow-kit` skill.
25
+ **Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + a one-file-per-ADR archive via a pre-commit hook. Deployed via the `agent-workflow-kit` skill.
22
26
 
23
- **Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + archive keep files scannable as history grows.
27
+ **Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + one-file-per-ADR archival keep every record scannable as the decision history grows without bound.
24
28
 
25
29
  **Consequences.**
26
30
  - ➕ Faster session start, less drift between agents.
27
31
  - ➕ ADRs as institutional memory; honest `known_issues.md`.
28
32
  - ➖ Discipline cost: docs updated alongside code.
29
33
  - ➖ A set of markdown files + scripts to maintain.
30
-
31
- ---
32
-
33
- ## AD-002 — {{Next decision}}
34
-
35
- **Date:** {{DATE}}
36
- **Status:** Proposed / Accepted / Superseded
37
-
38
- **Context.** {{...}}
39
- **Decision.** {{...}}
40
- **Consequences.** {{...}}
41
-
42
- ---
43
-
44
- > When this file nears ~90% of its cap, move the oldest Accepted/Superseded ADRs to `decisions-archive.md` (keep the IDs + a one-line pointer here) and record the split. Bumping the cap instead of splitting is a conscious exception, justified in an ADR.
@@ -34,7 +34,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
34
34
  export const AGENTS_DIR = '.claude/agents';
35
35
  export const CLAUDE_DIR = '.claude';
36
36
  export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
37
- export const EXPECTED_WORKFLOW_VERSION = '1.3.0';
37
+ export const EXPECTED_WORKFLOW_VERSION = '2.0.0';
38
38
  export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
39
39
 
40
40
  const EXIT_OK = 0;
@@ -70,6 +70,13 @@ const CATALOG = [
70
70
  kind: GUARDED,
71
71
  oneLine: 'Remove only what setup placed; it never deletes your notes and always previews before changing anything.',
72
72
  },
73
+ {
74
+ key: 'migrate-adr-store',
75
+ invocation: invocationOf('migrate-adr-store'),
76
+ group: 'Lifecycle',
77
+ kind: GUARDED,
78
+ oneLine: 'Migrate an older project’s ADR store to the one-file-per-ADR layout; it previews first, snapshots, and never commits.',
79
+ },
73
80
  {
74
81
  key: 'status',
75
82
  invocation: invocationOf('status'),
@@ -221,7 +228,7 @@ export const kindOf = (key) => byKey.get(key)?.kind ?? null;
221
228
  // and trailing args are ignored. Precise semantics:
222
229
  // undefined / null / '' / whitespace-only / the exact bare invocation → 'bootstrap'
223
230
  // a known first token (upgrade/status/setup/backends/recipes/procedures/velocity/agents/hook/
224
- // gates/set-recipe/uninstall/help) → that mode
231
+ // gates/set-recipe/uninstall/migrate-adr-store/help) → that mode
225
232
  // anything else (unrecognized / ambiguous) → 'help' (read-only — NEVER a writer/guarded mode)
226
233
  export const routeInvocation = (token) => {
227
234
  if (token == null) return BARE_INVOCATION_MODE;
@@ -325,9 +325,31 @@ const hasHiddenFence = (projectDir, deps = {}) => {
325
325
  }
326
326
  };
327
327
 
328
+ // The retired 3-tier ADR monoliths (AD-051): their presence is the old-layout signal a consumer must
329
+ // migrate away from via the opt-in `migrate-adr-store` mode. Stable relative paths (a status probe
330
+ // never imports the rotator).
331
+ const DECISIONS_MONOLITHS = ['docs/ai/history/decisions-archive.md', 'docs/ai/history/decisions-archive-early.md'];
332
+ const ADR_STORE_DIR = 'docs/ai/adr';
333
+
334
+ // The ADR-store layout axis: 'old' (a retired decisions-archive monolith is still on disk — needs
335
+ // the opt-in migration), 'migrated' (the one-file-per-ADR adr/ store is in place), or 'none' (no ADR
336
+ // substrate at all). Keys on the monolith presence, NOT on the stamp/head (Decision 6/13).
337
+ const surveyAdrLayout = (dir, exists) => {
338
+ const safe = (rel) => {
339
+ try {
340
+ return exists(join(dir, rel));
341
+ } catch {
342
+ return false;
343
+ }
344
+ };
345
+ if (DECISIONS_MONOLITHS.some(safe)) return 'old';
346
+ if (safe(ADR_STORE_DIR)) return 'migrated';
347
+ return 'none';
348
+ };
349
+
328
350
  // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
329
- // docs/ai/ exists, and whether the hidden-mode fence is present. Pure (fs reads only, all injectable),
330
- // no git subprocess — the read-only `status` view must never mutate or spawn anything.
351
+ // docs/ai/ exists, the ADR-store layout, and whether the hidden-mode fence is present. Pure (fs reads
352
+ // only, all injectable), no git subprocess — the read-only `status` view must never mutate or spawn.
331
353
  export const surveyProject = (projectDir, deps = {}) => {
332
354
  const exists = deps.exists ?? existsSync;
333
355
  const dir = resolve(projectDir);
@@ -342,7 +364,7 @@ export const surveyProject = (projectDir, deps = {}) => {
342
364
  }
343
365
  })();
344
366
  const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
345
- return { dir, deployed, docsAiPresent, hiddenFence: hasHiddenFence(dir, deps), stamps };
367
+ return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, exists), hiddenFence: hasHiddenFence(dir, deps), stamps };
346
368
  };
347
369
 
348
370
  // ── report ───────────────────────────────────────────────────────────────────────
@@ -579,6 +601,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
579
601
  dir: project.dir,
580
602
  deployed: project.deployed,
581
603
  docsAi: project.docsAiPresent,
604
+ adrLayout: project.adrLayout, // 'old' | 'migrated' | 'none' — a user-safe token, never a raw path
582
605
  // member + display + version only — never the internal stamp FILENAME (s.file).
583
606
  deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
584
607
  };
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ // migrate-adr-store.mjs — the consent-gated, opt-in migration of an EXISTING consumer's docs/ai from
3
+ // the retired 3-tier ADR cascade (HOT decisions.md → WARM/COLD monoliths) to the one-file-per-ADR
4
+ // store (HOT decisions.md + docs/ai/adr/AD-NNN-slug.md records + the docs/ai/adr/log.md navigator).
5
+ // Reached ONLY through the `migrate-adr-store` mode (SKILL.md), NEVER auto: a normal upgrade never
6
+ // installs the new-scheme rotator into an un-migrated consumer — the new rotator arrives ONLY here,
7
+ // which migrates in the same step (AD-051, Decision 13).
8
+ //
9
+ // What it does (in order, on --apply):
10
+ // 1. GATE — docs/ai must be deployed; the OLD layout must be present (a decisions-archive monolith
11
+ // on disk). No monolith → a stated no-op (already migrated, or a fresh new-scheme tree).
12
+ // 2. SNAPSHOT — write a durable pre-migration snapshot (decisions.md + both monoliths + the
13
+ // pre-refresh consumer scripts/ copies) to the project's git dir (uncommittable), with a
14
+ // stated out-of-tree fallback off git; fail LOUD if neither base is writable (Decision 5).
15
+ // 3. FORCE-REFRESH — overwrite the consumer's deployed enforcement scripts (the DIRECTIONAL subset:
16
+ // only kit-canon basenames the consumer's scripts/ already has) with this kit's bundled
17
+ // copies, so their ongoing pre-commit gates run the NEW rotator + the NEW collapse rule.
18
+ // A locally-edited script is snapshotted (step 2) before it is overwritten — never
19
+ // silently clobbered — and the dry-run preview names every script that differs.
20
+ // 4. MIGRATE — run the (new-scheme) rotator's conservation-checked --migrate --apply against the
21
+ // project root: explode the monoliths into adr/ records, retire them, regenerate the
22
+ // navigator + docs/ai/index.md. Idempotent / crash-resumable.
23
+ //
24
+ // Write discipline: preview (--dry-run) is the DEFAULT and writes NOTHING; --apply performs the
25
+ // migration. It NEVER commits. Exit codes: 0 done / dry-run / no-op; 1 precondition STOP (no
26
+ // deployment, no writable snapshot base, a failed migration); 2 usage. Dependency-free, Node >= 18.
27
+ // No side effects on import.
28
+
29
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
30
+ import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
31
+ import { fileURLToPath, pathToFileURL } from 'node:url';
32
+ import { spawnSync } from 'node:child_process';
33
+ import { tmpdir } from 'node:os';
34
+ import { writeContainedFileAtomic, assertDocsAiDeployment } from './atomic-write.mjs';
35
+ import {
36
+ monolithsPresent,
37
+ HOT_REL,
38
+ WARM_REL,
39
+ COLD_REL,
40
+ ADR_DIR_REL,
41
+ runCli as runArchiveDecisions,
42
+ } from '../references/scripts/archive-decisions.mjs';
43
+
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const KIT_ROOT = resolve(HERE, '..');
46
+ const KIT_SCRIPTS = join(KIT_ROOT, 'references', 'scripts');
47
+ const CONSUMER_SCRIPTS_REL = 'scripts';
48
+ const SNAPSHOT_PREFIX = 'agent-workflow-adr-migration-snapshot';
49
+
50
+ const EXIT_OK = 0;
51
+ const EXIT_PRECONDITION = 1;
52
+ const EXIT_USAGE = 2;
53
+
54
+ export const MIGRATE_ADR_STORE_STOP = 'MIGRATE_ADR_STORE_STOP';
55
+ const stop = (message) =>
56
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'MigrateAdrStoreStop', code: MIGRATE_ADR_STORE_STOP, exitCode: EXIT_PRECONDITION });
57
+ const usageFail = (message) =>
58
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: EXIT_USAGE });
59
+
60
+ const USAGE = `usage: migrate-adr-store [--dry-run | --apply] [--cwd <dir>] [--help]
61
+
62
+ Opt-in migration of a project's docs/ai from the retired 3-tier ADR cascade to the one-file-per-ADR
63
+ store. Default is --dry-run: prints the migration plan (monoliths to retire, scripts to refresh, the
64
+ conservation proof) and writes NOTHING. --apply snapshots, force-refreshes the enforcement scripts,
65
+ then runs the conservation-checked migration. It NEVER commits — review the tree and commit yourself.`;
66
+
67
+ // The mutually-exclusive dry-run/apply parse (a consent-gated writer never lets a later flag silently
68
+ // decide whether it mutates) + --cwd, cloned from the seed-gates writer contract.
69
+ export const parseArgs = (argv) => {
70
+ const parsed = argv.reduce(
71
+ (acc, a, i) => {
72
+ if (acc.skip) return { ...acc, skip: false };
73
+ if (a === '--help' || a === '-h') return { ...acc, help: true };
74
+ if (a === '--dry-run') {
75
+ if (acc.apply === true) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
76
+ return { ...acc, apply: false, dryRunExplicit: true };
77
+ }
78
+ if (a === '--apply') {
79
+ if (acc.dryRunExplicit) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
80
+ return { ...acc, apply: true };
81
+ }
82
+ if (a === '--cwd') {
83
+ const value = argv[i + 1];
84
+ if (value === undefined || value.startsWith('-')) throw usageFail('--cwd needs a value: --cwd <dir>');
85
+ return { ...acc, cwd: value, skip: true };
86
+ }
87
+ throw usageFail(`unknown argument "${a}"\n${USAGE}`);
88
+ },
89
+ { apply: false, dryRunExplicit: false, cwd: undefined, help: false, skip: false },
90
+ );
91
+ return { apply: parsed.apply === true, cwd: parsed.cwd, help: parsed.help };
92
+ };
93
+
94
+ // The DIRECTIONAL force-refresh set (Decision 12/13): a kit-canon enforcement script whose basename is
95
+ // ALSO present in the consumer's scripts/ — never ADD a script the consumer lacks, never touch a
96
+ // root-only/non-canon file. Returns [{ name, canon, dst, differs }] for every refresh candidate.
97
+ export const planScriptRefresh = (cwd, deps = {}) => {
98
+ const exists = deps.exists ?? existsSync;
99
+ const read = deps.read ?? readFileSync;
100
+ const kitScripts = deps.kitScripts ?? KIT_SCRIPTS;
101
+ const consumerScripts = join(cwd, CONSUMER_SCRIPTS_REL);
102
+ const out = [];
103
+ for (const name of readdirSync(kitScripts).sort()) {
104
+ const canon = join(kitScripts, name);
105
+ if (!statSync(canon).isFile()) continue;
106
+ const dst = join(consumerScripts, name);
107
+ if (!exists(dst)) continue; // directional: the consumer does not deploy this script — never add it
108
+ const differs = read(canon, 'utf8') !== read(dst, 'utf8');
109
+ out.push({ name, canon, dst, differs });
110
+ }
111
+ return out;
112
+ };
113
+
114
+ const gitDirOf = (cwd, spawn) => {
115
+ const r = spawn('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8' });
116
+ return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
117
+ };
118
+
119
+ // A path is INSIDE the work tree (stageable) when cwd contains it. The git dir is EXEMPT — it lives
120
+ // under cwd but git never stages its own contents (uncommittable by construction, the Decision-5 basis).
121
+ const isUnder = (child, parent) => {
122
+ const rel = relative(parent, child);
123
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
124
+ };
125
+
126
+ // The ordered snapshot bases that are provably NOT stageable: the git dir first (always safe), then the
127
+ // fallback base ONLY when its snapshot dir lands OUTSIDE cwd (else it is in the work tree and could be
128
+ // committed — reject it; codex R1 minor). Returns [{ base, dir, viaGitDir }] (possibly empty).
129
+ const snapshotBases = (cwd, stamp, gitDir, fallbackBase) => {
130
+ const bases = [];
131
+ if (gitDir) bases.push({ base: gitDir, dir: resolve(gitDir, `${SNAPSHOT_PREFIX}-${stamp}`), viaGitDir: true });
132
+ const fallbackDir = resolve(fallbackBase, `${SNAPSHOT_PREFIX}-${stamp}`);
133
+ if (!isUnder(fallbackDir, resolve(cwd))) bases.push({ base: fallbackBase, dir: fallbackDir, viaGitDir: false });
134
+ return bases;
135
+ };
136
+
137
+ // The pre-migration snapshot dir chosen for the preview: the first out-of-tree base (git dir, else a
138
+ // fallback proven outside cwd), or { dir: null } when none is available. Pure — creates nothing.
139
+ export const resolveSnapshotDir = (cwd, stamp, deps = {}) => {
140
+ const spawn = deps.spawnSync ?? spawnSync;
141
+ const fallbackBase = deps.snapshotFallbackBase ?? tmpdir();
142
+ const gitDir = gitDirOf(cwd, spawn);
143
+ const chosen = snapshotBases(cwd, stamp, gitDir, fallbackBase)[0] ?? null;
144
+ return chosen ? { base: chosen.base, gitDir, dir: chosen.dir, viaGitDir: chosen.viaGitDir } : { base: null, gitDir, dir: null, viaGitDir: false };
145
+ };
146
+
147
+ // Write the durable snapshot (decisions.md + both monoliths + the pre-refresh consumer scripts). Tries
148
+ // the git dir first, then an out-of-tree fallback; fails LOUD if none is available/writable (Decision 5).
149
+ // Paths are flattened (/ → __) exactly like the rotator's own snapshot.
150
+ export const writeSnapshot = (cwd, refresh, stamp, deps = {}) => {
151
+ const spawn = deps.spawnSync ?? spawnSync;
152
+ const read = deps.read ?? readFileSync;
153
+ const exists = deps.exists ?? existsSync;
154
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
155
+ const write = deps.write ?? ((p, b) => writeFileSync(p, b, 'utf8'));
156
+ const fallbackBase = deps.snapshotFallbackBase ?? tmpdir();
157
+ const gitDir = gitDirOf(cwd, spawn);
158
+ const bases = snapshotBases(cwd, stamp, gitDir, fallbackBase);
159
+ if (bases.length === 0) {
160
+ throw stop(`refusing to migrate: no out-of-tree snapshot location (not a git repo, and the fallback would land inside the work tree ${cwd}) — a durable, non-stageable pre-migration snapshot is mandatory`);
161
+ }
162
+
163
+ const files = [];
164
+ for (const rel of [HOT_REL, WARM_REL, COLD_REL]) {
165
+ const abs = join(cwd, rel);
166
+ if (exists(abs)) files.push({ rel, content: read(abs, 'utf8') });
167
+ }
168
+ for (const { name, dst } of refresh) {
169
+ if (exists(dst)) files.push({ rel: `${CONSUMER_SCRIPTS_REL}/${name}`, content: read(dst, 'utf8') });
170
+ }
171
+
172
+ let lastErr = null;
173
+ for (const { dir, viaGitDir } of bases) {
174
+ try {
175
+ mkdir(dir);
176
+ for (const { rel, content } of files) write(resolve(dir, rel.replace(/[/\\]/g, '__')), content);
177
+ return { dir, viaGitDir, fileCount: files.length };
178
+ } catch (err) {
179
+ lastErr = err;
180
+ }
181
+ }
182
+ throw stop(`refusing to migrate: no writable snapshot location (${lastErr && lastErr.message}) — a durable pre-migration snapshot is mandatory`);
183
+ };
184
+
185
+ // Overwrite each refresh target with the kit canon, atomically, preserving the canon's exec bit.
186
+ const applyScriptRefresh = (cwd, refresh, deps = {}) => {
187
+ const read = deps.read ?? readFileSync;
188
+ const chmod = deps.chmod ?? chmodSync;
189
+ const stat = deps.stat ?? statSync;
190
+ for (const { canon, dst, name } of refresh) {
191
+ writeContainedFileAtomic(cwd, dst, read(canon, 'utf8'), deps, { stop, label: `${CONSUMER_SCRIPTS_REL}/${name}` });
192
+ chmod(dst, stat(canon).mode & 0o777); // the exec bit is the git-tracked axis the mirror guard pins
193
+ }
194
+ };
195
+
196
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
197
+ const log = deps.log ?? console.log;
198
+ const error = deps.error ?? console.error;
199
+ const runMigrate = deps.runArchiveDecisions ?? runArchiveDecisions;
200
+ const stamp = deps.stamp ?? new Date().toISOString().replace(/[:.]/g, '-');
201
+ try {
202
+ const args = parseArgs(argv);
203
+ if (args.help) {
204
+ log(USAGE);
205
+ return EXIT_OK;
206
+ }
207
+ const cwd = resolve(args.cwd ?? process.cwd());
208
+ assertDocsAiDeployment(cwd, deps, { stop, noun: 'the ADR store', rel: 'the docs/ai ADR store' });
209
+
210
+ const monoliths = monolithsPresent(cwd);
211
+ if (monoliths.length === 0) {
212
+ const migrated = existsSync(join(cwd, ADR_DIR_REL));
213
+ log(migrated
214
+ ? '[migrate-adr-store] already migrated — the one-file-per-ADR store is in place (no legacy monolith); nothing to do.'
215
+ : '[migrate-adr-store] nothing to migrate — no legacy decisions-archive monolith found (a fresh new-scheme tree).');
216
+ return EXIT_OK;
217
+ }
218
+
219
+ const refresh = planScriptRefresh(cwd, deps);
220
+ const drifted = refresh.filter((r) => r.differs);
221
+
222
+ if (!args.apply) {
223
+ const preview = resolveSnapshotDir(cwd, stamp, deps);
224
+ log('[migrate-adr-store] --dry-run — no files will be changed. Planned migration:');
225
+ log(` old layout: ${monoliths.join(', ')} (will be exploded into ${ADR_DIR_REL}/ then retired)`);
226
+ log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
227
+ log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
228
+ log(' then the conservation-checked rotation:');
229
+ // Surface the rotation's own exit code (codex R1 major): a failed dry-run must NOT print the
230
+ // "run with --apply" go-ahead nor exit 0 — it would send the user to --apply on an unsafe tree.
231
+ const code = runMigrate(['--migrate'], { root: cwd, log: (m) => log(` ${m}`), logError: (m) => error(` ${m}`) });
232
+ if (code !== EXIT_OK) {
233
+ throw stop(`the dry-run rotation would not conserve every ADR (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
234
+ }
235
+ // A null preview means --apply would refuse (no out-of-tree snapshot base) — never green-light it
236
+ // (codex R2 minor: a dry-run go-ahead must not send the user to an apply that will STOP).
237
+ if (preview.dir === null) {
238
+ throw stop('no out-of-tree snapshot location — --apply would refuse; run inside a git repo (or point the fallback outside the project), then re-run.');
239
+ }
240
+ log('Run `/agent-workflow-kit migrate-adr-store` again with --apply to perform it (it never commits).');
241
+ return EXIT_OK;
242
+ }
243
+
244
+ // Pre-flight: validate the rotation on a dry-run (conservation + store integrity) BEFORE any
245
+ // mutation, so a failure aborts having touched nothing — no snapshot, no refreshed scripts, no
246
+ // half-migrated tree. The error surfaces on logError; the plan itself is suppressed (already shown).
247
+ const preflight = runMigrate(['--migrate'], { root: cwd, log: () => {}, logError: error });
248
+ if (preflight !== EXIT_OK) {
249
+ throw stop(`the migration would not conserve every ADR (dry-run exit ${preflight}) — refusing to touch the tree; fix the reported problem, then re-run.`);
250
+ }
251
+
252
+ const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
253
+ applyScriptRefresh(cwd, refresh, deps);
254
+ const code = runMigrate(['--migrate', '--apply'], { root: cwd, log, logError: error });
255
+ if (code !== EXIT_OK) {
256
+ throw stop(`the rotation failed (exit ${code}) — the pre-migration snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the migration is idempotent).`);
257
+ }
258
+ log('[migrate-adr-store] migrated the 3-tier ADR cascade → one-file-per-ADR store:');
259
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
260
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version`);
261
+ log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
262
+ log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
263
+ return EXIT_OK;
264
+ } catch (err) {
265
+ error(err.message);
266
+ return err.exitCode ?? EXIT_PRECONDITION;
267
+ }
268
+ };
269
+
270
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
271
+ if (isDirectRun) process.exitCode = main();
@@ -77,6 +77,10 @@ const renderProject = (vm, { color }) => {
77
77
  }
78
78
  for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
79
79
  lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
80
+ // Only the actionable 'old' layout renders a line — a migrated/none store needs no note (AD-051).
81
+ if (p.adrLayout === 'old') {
82
+ lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
83
+ }
80
84
  if (p.visibility) {
81
85
  const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
82
86
  lines.push(` ${pad('visibility', STAMP_COL)}${v}`);