@sabaiway/agent-workflow-kit 4.4.0 → 5.0.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.
@@ -29,7 +29,7 @@ export const stop = (message, fields = {}) =>
29
29
  // ── registries ────────────────────────────────────────────────────────────────
30
30
 
31
31
  // The kit's OWN footprint — canonical anchored patterns. `/docs/ai/` subsumes the deployment stamp
32
- // (`.workflow-version`); the 8 enforcement scripts are enumerated (no bare `/scripts/` — a host repo
32
+ // (`.workflow-version`); the enforcement scripts are enumerated (no bare `/scripts/` — a host repo
33
33
  // may have unrelated scripts). `/.claude/settings.json` is carried HIDDEN-ONLY: in hidden mode the
34
34
  // kit's own attribution file is a footprint; in visible mode the kit commits it and never runs this
35
35
  // tool. It passes the same tracked→ASK classifier, so a project that already commits it gets an ASK,
@@ -43,13 +43,17 @@ export const KIT_OWN_PATHS = [
43
43
  '/scripts/_expect-shim.mjs',
44
44
  '/scripts/archive-changelog.mjs',
45
45
  '/scripts/archive-changelog.test.mjs',
46
+ '/scripts/archive-conservation.test.mjs',
46
47
  '/scripts/archive-decisions.mjs',
47
48
  '/scripts/archive-decisions.test.mjs',
48
49
  '/scripts/archive-issues.mjs',
49
50
  '/scripts/archive-issues.test.mjs',
51
+ '/scripts/archiver-structure.test.mjs',
50
52
  '/scripts/check-docs-size.mjs',
51
53
  '/scripts/check-docs-size.test.mjs',
52
54
  '/scripts/install-git-hooks.mjs',
55
+ '/scripts/markdown-blocks.mjs',
56
+ '/scripts/markdown-blocks.test.mjs',
53
57
  '/docs/plans/',
54
58
  '/.claude/settings.local.json',
55
59
  '/.claude/settings.json',
@@ -7,8 +7,13 @@
7
7
  // which migrates in the same step (AD-051, Decision 13).
8
8
  //
9
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).
10
+ // 1. GATE — docs/ai must be deployed, and the tree must be on the OLD layout. That is TWO shapes:
11
+ // a decisions-archive monolith on disk, OR no monolith at all but a deployed
12
+ // scripts/archive-decisions.mjs that predates the store (the project simply never
13
+ // rotated). The no-monolith shape runs the crossing WITHOUT an explosion — snapshot,
14
+ // script refresh, then SEED the store — and is re-runnable to completion from any crash
15
+ // point, because a store directory alone never counts as finished. A tree with no ADR
16
+ // substrate at all, or one already finalised, is a stated no-op.
12
17
  // 2. SNAPSHOT — write a durable pre-migration snapshot (decisions.md + both monoliths + the
13
18
  // pre-refresh consumer scripts/ copies) to the project's git dir (uncommittable), with a
14
19
  // stated out-of-tree fallback off git; fail LOUD if neither base is writable (Decision 5).
@@ -38,8 +43,11 @@ import {
38
43
  WARM_REL,
39
44
  COLD_REL,
40
45
  ADR_DIR_REL,
46
+ NAV_REL,
47
+ defaultRegenerateIndex,
41
48
  runCli as runArchiveDecisions,
42
49
  } from '../references/scripts/archive-decisions.mjs';
50
+ import { surveyAdrLayoutStrict, ADR_LAYOUT_PATHS } from './family-registry.mjs';
43
51
 
44
52
  const HERE = dirname(fileURLToPath(import.meta.url));
45
53
  const KIT_ROOT = resolve(HERE, '..');
@@ -111,6 +119,26 @@ export const planScriptRefresh = (cwd, deps = {}) => {
111
119
  return out;
112
120
  };
113
121
 
122
+ // COMPANION seeds: modules the refreshed archivers IMPORT. The refresh above is deliberately
123
+ // directional (never ADDS a basename the consumer lacks), but refreshing an OLD deployment's
124
+ // archivers to this kit's canon without their runtime dependency would leave every refreshed
125
+ // script crashing on a missing `./markdown-blocks.mjs` import until a separate upgrade run — so
126
+ // the dependency rides the SAME apply, atomically, written before its importers.
127
+ const COMPANION_SEEDS = ['markdown-blocks.mjs', 'markdown-blocks.test.mjs'];
128
+ export const planCompanionSeeds = (cwd, refresh, deps = {}) => {
129
+ if (refresh.length === 0) return [];
130
+ const exists = deps.exists ?? existsSync;
131
+ const kitScripts = deps.kitScripts ?? KIT_SCRIPTS;
132
+ const consumerScripts = join(cwd, CONSUMER_SCRIPTS_REL);
133
+ const out = [];
134
+ for (const name of COMPANION_SEEDS) {
135
+ const canon = join(kitScripts, name);
136
+ const dst = join(consumerScripts, name);
137
+ if (exists(canon) && !exists(dst)) out.push({ name, canon, dst });
138
+ }
139
+ return out;
140
+ };
141
+
114
142
  const gitDirOf = (cwd, spawn) => {
115
143
  const r = spawn('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8' });
116
144
  return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
@@ -183,14 +211,185 @@ export const writeSnapshot = (cwd, refresh, stamp, deps = {}) => {
183
211
  };
184
212
 
185
213
  // Overwrite each refresh target with the kit canon, atomically, preserving the canon's exec bit.
214
+ //
215
+ // ORDER MATTERS: the rotation script is what the layout discriminator reads, so it is written LAST.
216
+ // A crash partway through a refresh that had already flipped it would otherwise leave a tree that
217
+ // LOOKS refreshed while other scripts are still the old copies — and a resume, keying on that same
218
+ // script, would skip them forever. Written last, an interrupted refresh always re-plans in full.
219
+ const DISCRIMINATOR_SCRIPT = ADR_LAYOUT_PATHS.rotator.split('/').pop();
220
+ const refreshOrder = (refresh) => [
221
+ ...refresh.filter((r) => r.name !== DISCRIMINATOR_SCRIPT),
222
+ ...refresh.filter((r) => r.name === DISCRIMINATOR_SCRIPT),
223
+ ];
224
+
186
225
  const applyScriptRefresh = (cwd, refresh, deps = {}) => {
187
226
  const read = deps.read ?? readFileSync;
188
227
  const chmod = deps.chmod ?? chmodSync;
189
228
  const stat = deps.stat ?? statSync;
190
- for (const { canon, dst, name } of refresh) {
229
+ // Companion modules FIRST (a dependency must land before its importers), refresh order after —
230
+ // the discriminator still last, so an interrupted apply always re-plans in full. Returns the
231
+ // seeded names (computed pre-write; recomputing after would see them present and report none).
232
+ const seeds = planCompanionSeeds(cwd, refresh, deps);
233
+ for (const { canon, dst, name } of [...seeds, ...refreshOrder(refresh)]) {
191
234
  writeContainedFileAtomic(cwd, dst, read(canon, 'utf8'), deps, { stop, label: `${CONSUMER_SCRIPTS_REL}/${name}` });
192
235
  chmod(dst, stat(canon).mode & 0o777); // the exec bit is the git-tracked axis the mirror guard pins
193
236
  }
237
+ return seeds.map((s) => s.name);
238
+ };
239
+
240
+ // ── the no-monolith crossing ─────────────────────────────────────────────────────
241
+ //
242
+ // A consumer on the RETIRED scheme that never rotated far enough to produce a monolith used to read
243
+ // "a fresh new-scheme tree" here and be sent away. The discriminator is the deployed rotation
244
+ // script's own provenance (family-registry.mjs), never "has decisions.md, lacks adr/".
245
+ //
246
+ // Re-entry is decided by what is FINISHED, never by one existence bit: the store directory existing
247
+ // does not prove the navigator was written or the index regenerated, so a crash there must not turn
248
+ // the next --apply into a no-op. Every write below is individually idempotent, which is why this
249
+ // needs no resume ledger.
250
+
251
+ // The crossing is COMPLETE when the navigator exists, the tree's own gate passes, AND the index the
252
+ // crossing regenerates is fresh. The index is part of the criterion because it is a real output of
253
+ // the crossing that `--check` never looks at: a crash (or a failed regeneration) between the
254
+ // navigator write and the index left a tree that reported "already migrated" on the retry and never
255
+ // repaired the index. An unreachable index generator is NOT treated as fresh — the crossing re-runs
256
+ // and fails loudly again, which is the honest outcome for a broken generator.
257
+ const INDEX_GENERATOR = join(KIT_SCRIPTS, 'check-docs-size.mjs');
258
+ const isIndexFresh = (cwd, deps = {}) => {
259
+ const spawn = deps.spawnSync ?? spawnSync;
260
+ const r = spawn(process.execPath, [INDEX_GENERATOR, '--check-index', `--root=${cwd}`], { encoding: 'utf8' });
261
+ return !r.error && r.status === 0;
262
+ };
263
+
264
+ // The layout verdict leads, and it is the SAME verdict the status line and the advisor read: an
265
+ // old-scheme rotator beside a finished store still answers `old-unrotated`, so treating that tree as
266
+ // done would leave the signal permanently lit with nothing able to clear it.
267
+ const isFinalised = (cwd, runMigrate, deps = {}) =>
268
+ surveyAdrLayoutStrict(cwd, deps) === 'migrated' &&
269
+ substratePresent(join(cwd, NAV_REL), deps) &&
270
+ runMigrate(['--check'], { root: cwd, log: () => {}, logError: () => {} }) === EXIT_OK &&
271
+ isIndexFresh(cwd, deps);
272
+
273
+ // `existsSync` answers false for EVERY failure, EACCES included — so asking it whether the substrate
274
+ // is there would turn an UNREADABLE tree into a confident "nothing to migrate", exit 0. Absence is
275
+ // ENOENT and nothing else; anything else is surfaced, never swallowed. Same policy the layout survey
276
+ // already enforces, now applied where the tool acts on it.
277
+ const substratePresent = (path, deps = {}) => {
278
+ const stat = deps.statSync ?? statSync;
279
+ try {
280
+ stat(path);
281
+ return true;
282
+ } catch (err) {
283
+ if (err && err.code === 'ENOENT') return false;
284
+ throw stop(`cannot read ${path} (${err && err.message}) — refusing to report on a tree it could not inspect`);
285
+ }
286
+ };
287
+
288
+ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }) => {
289
+ const hasStore = substratePresent(join(cwd, ADR_DIR_REL), deps);
290
+ const hasHot = substratePresent(join(cwd, HOT_REL), deps);
291
+
292
+ if (!hasHot && !hasStore) {
293
+ // NOT "a fresh new-scheme tree" — this tree may well be old-scheme; it simply has no ADR
294
+ // substrate for the crossing to carry across, which is exactly the rotator's own skip.
295
+ log(`[migrate-adr-store] nothing to migrate — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}/) and no legacy monolith.`);
296
+ return EXIT_OK;
297
+ }
298
+ if (hasStore && isFinalised(cwd, runMigrate, deps)) {
299
+ log('[migrate-adr-store] already migrated — the one-file-per-ADR store is in place and its gate is green; nothing to do.');
300
+ return EXIT_OK;
301
+ }
302
+ if (!hasStore && surveyAdrLayoutStrict(cwd, deps) === 'none' && !substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)) {
303
+ // Nothing to refresh (the refresh is directional — it never ADDS a script) and nothing to
304
+ // maintain a store we might seed. The normal upgrade owns seeding the pair.
305
+ log('[migrate-adr-store] nothing to migrate — no deployed rotation script; run the normal upgrade first (it seeds the ADR enforcement pair).');
306
+ return EXIT_OK;
307
+ }
308
+
309
+ const refresh = planScriptRefresh(cwd, deps);
310
+ const drifted = refresh.filter((r) => r.differs);
311
+ // The read-only preflight: the SAME parse / half-migrated guard / store-integrity check the seed
312
+ // itself runs, stopping before every write. Without it a dry-run could green-light an apply that
313
+ // writes the store and only then discovers it cannot converge.
314
+ const preflight = (logError) => runMigrate(['--write-navigator', '--dry-run'], { root: cwd, log: () => {}, logError });
315
+
316
+ if (!args.apply) {
317
+ const preview = resolveSnapshotDir(cwd, stamp, deps);
318
+ // Three attempts to SUMMARISE why this tree needs the crossing produced three wrong sentences —
319
+ // each true of the common case and false of a state this arm deliberately supports. So the
320
+ // summary is gone: the preview states the two facts it actually knows, one per line, and the
321
+ // reader draws the conclusion. Nothing here can drift out of step with the tree, because nothing
322
+ // here is an inference. (Which scripts are stale is already reported by the refresh line below —
323
+ // never re-asserted here.)
324
+ const layout = surveyAdrLayoutStrict(cwd, deps);
325
+ const rotatorFact = layout === 'old-unrotated'
326
+ ? `predates the one-file-per-ADR store`
327
+ : substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)
328
+ ? `already names the store`
329
+ : `not deployed`; // and NOT "nothing to refresh" — a sibling script may still need one
330
+ log('[migrate-adr-store] --dry-run — no files will be changed. Planned crossing (no legacy monolith to retire):');
331
+ log(` deployed ${ADR_LAYOUT_PATHS.rotator}: ${rotatorFact}`);
332
+ log(` ${ADR_DIR_REL}/: ${hasStore ? 'present, but the crossing has not been completed' : 'absent'}`);
333
+ 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)'}`);
334
+ log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
335
+ for (const s of planCompanionSeeds(cwd, refresh, deps)) log(` seed companion module ${CONSUMER_SCRIPTS_REL}/${s.name} (imported by the refreshed archivers; absent at the consumer)`);
336
+ log(` then seed the store: create ${ADR_DIR_REL}/, write ${NAV_REL} and regenerate docs/ai/index.md`);
337
+ const code = preflight((m) => error(` ${m}`));
338
+ if (code !== EXIT_OK) {
339
+ throw stop(`the tree cannot be seeded as it stands (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
340
+ }
341
+ if (preview.dir === null) {
342
+ 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.');
343
+ }
344
+ log(' index regeneration is verified at --apply time (a dry-run cannot observe it without writing).');
345
+ log('Run `/agent-workflow-kit migrate-adr-store` again with --apply to perform it (it never commits).');
346
+ return EXIT_OK;
347
+ }
348
+
349
+ const pre = preflight(error);
350
+ if (pre !== EXIT_OK) {
351
+ throw stop(`the tree cannot be seeded as it stands (preflight exit ${pre}) — refusing to touch the tree; fix the reported problem, then re-run.`);
352
+ }
353
+
354
+ const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
355
+ // The FULL refresh is re-planned and re-applied on every entry, so an interrupted one always
356
+ // completes; the discriminator script is written last (see refreshOrder).
357
+ const seededNames = applyScriptRefresh(cwd, refresh, deps);
358
+
359
+ // Capture the index-regeneration verdict instead of matching log prose: the rotator logs a failed
360
+ // regeneration and still returns 0, so "the gates are green" would not mean the index is fresh.
361
+ const regen = { ok: true, detail: '' };
362
+ const seed = runMigrate(['--write-navigator'], {
363
+ root: cwd,
364
+ log,
365
+ logError: error,
366
+ regenerateIndex: (root, today, d) => {
367
+ const r = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today, d);
368
+ regen.ok = r.ok;
369
+ regen.detail = r.detail;
370
+ return r;
371
+ },
372
+ });
373
+ if (seed !== EXIT_OK) {
374
+ throw stop(`seeding the ADR store failed (exit ${seed}) — the pre-crossing snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the crossing is idempotent).`);
375
+ }
376
+ if (!regen.ok) {
377
+ throw stop(`the ADR store was seeded but docs/ai/index.md was NOT regenerated (${regen.detail}) — the pre-crossing snapshot is at ${snapshot.dir}; fix the index generator and re-run (the crossing is idempotent).`);
378
+ }
379
+ const verify = runMigrate(['--check'], { root: cwd, log: () => {}, logError: error });
380
+ if (verify !== EXIT_OK) {
381
+ throw stop(`the ADR store was seeded but its own gate does not pass (exit ${verify}) — the pre-crossing snapshot is at ${snapshot.dir}; resolve the reported problem and re-run (the crossing is idempotent).`);
382
+ }
383
+
384
+ // States what this run DID, never what the tree was before it: the same arm completes an
385
+ // interrupted crossing whose scripts were already current, which no "old-scheme" claim covers.
386
+ log('[migrate-adr-store] crossing complete — the one-file-per-ADR store is in place (no legacy monolith was present):');
387
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
388
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version${seededNames.length ? ` + seeded ${seededNames.join(', ')}` : ''}`);
389
+ log(` seeded ${ADR_DIR_REL}/ with ${NAV_REL} and regenerated docs/ai/index.md`);
390
+ log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
391
+ log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
392
+ return EXIT_OK;
194
393
  };
195
394
 
196
395
  export const main = (argv = process.argv.slice(2), deps = {}) => {
@@ -209,11 +408,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
209
408
 
210
409
  const monoliths = monolithsPresent(cwd);
211
410
  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;
411
+ return crossWithoutMonolith(cwd, args, stamp, { log, error, runMigrate, deps });
217
412
  }
218
413
 
219
414
  const refresh = planScriptRefresh(cwd, deps);
@@ -225,6 +420,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
225
420
  log(` old layout: ${monoliths.join(', ')} (will be exploded into ${ADR_DIR_REL}/ then retired)`);
226
421
  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
422
  log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
423
+ for (const s of planCompanionSeeds(cwd, refresh, deps)) log(` seed companion module ${CONSUMER_SCRIPTS_REL}/${s.name} (imported by the refreshed archivers; absent at the consumer)`);
228
424
  log(' then the conservation-checked rotation:');
229
425
  // Surface the rotation's own exit code: a failed dry-run must NOT print the
230
426
  // "run with --apply" go-ahead nor exit 0 — it would send the user to --apply on an unsafe tree.
@@ -250,14 +446,14 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
250
446
  }
251
447
 
252
448
  const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
253
- applyScriptRefresh(cwd, refresh, deps);
449
+ const seededNames = applyScriptRefresh(cwd, refresh, deps);
254
450
  const code = runMigrate(['--migrate', '--apply'], { root: cwd, log, logError: error });
255
451
  if (code !== EXIT_OK) {
256
452
  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
453
  }
258
454
  log('[migrate-adr-store] migrated the 3-tier ADR cascade → one-file-per-ADR store:');
259
455
  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`);
456
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version${seededNames.length ? ` + seeded ${seededNames.join(', ')}` : ''}`);
261
457
  log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
262
458
  log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
263
459
  return EXIT_OK;
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
51
51
  import { deriveDoctorPlan } from './autonomy-doctor.mjs';
52
52
  import { detectBackends, findOnPath } from './detect-backends.mjs';
53
53
  import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
- import { surveyFamily, surveyGateHook } from './family-registry.mjs';
54
+ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
55
55
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
57
  import { isFinalCapableDeclaration } from './run-gates.mjs';
@@ -108,6 +108,7 @@ export const SEVERITIES = Object.freeze({
108
108
  'state-block': SEVERITY_OPTIONAL,
109
109
  agents: SEVERITY_OPTIONAL,
110
110
  'family-freshness': SEVERITY_ATTENTION,
111
+ 'adr-store-migration': SEVERITY_ATTENTION,
111
112
  'sandbox-masks': SEVERITY_OPTIONAL,
112
113
  'sandbox-lane': SEVERITY_OPTIONAL,
113
114
  'worktrees-dir': SEVERITY_OPTIONAL,
@@ -165,6 +166,7 @@ export const WHATS = Object.freeze({
165
166
  'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
166
167
  agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
167
168
  'family-freshness': '{parts}',
169
+ 'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
168
170
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
169
171
  '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)',
170
172
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
@@ -218,6 +220,7 @@ export const BENEFITS = Object.freeze({
218
220
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
219
221
  agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
220
222
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
223
+ 'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
221
224
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
222
225
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
223
226
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
@@ -254,6 +257,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
254
257
  { id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
255
258
  { id: 'worktrees-dir', mode: 'worktrees', advisorKey: 'worktrees-dir' },
256
259
  { id: 'family-freshness', mode: 'upgrade', advisorKey: 'family-freshness' },
260
+ { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
257
261
  { id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
258
262
  // The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
259
263
  // which is why the review-recipe benefit is worded for either slot rather than for review alone.
@@ -788,7 +792,7 @@ const readReadLaneToggle = (root, deps) => {
788
792
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
789
793
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
790
794
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
791
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir']);
795
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration']);
792
796
 
793
797
  const probeSandboxLane = ({ root, deps, add, skip }) => {
794
798
  try {
@@ -965,6 +969,40 @@ const probeWorktreesDir = ({ root, deps, add, skip }) => {
965
969
  }
966
970
  };
967
971
 
972
+ // The ADR-store crossing. Until now this mode declared it had NO advisor capability, on the argument
973
+ // that status and upgrade already report the old layout — but they only reported the MONOLITH shape,
974
+ // so a project whose deployed rotator merely predates the store was told nothing by anything.
975
+ //
976
+ // Honest scope: the advisor is the deterministic section every `upgrade` run ends with, so this is
977
+ // NOT a new door for someone who never runs status or upgrade — it MECHANIZES the upgrade door.
978
+ //
979
+ // It reads the STRICT layout survey deliberately: the lenient one turns every fs failure into
980
+ // "no ADR layout here", which would print «flow optimal» over a layout the probe could not read. A
981
+ // failure must become a STATED SKIP, never an absence.
982
+ // Each shape states a fact about THIS tree that holds whether or not a store directory exists —
983
+ // `old-unrotated` also covers a tree whose store is already there but whose rotation script is not,
984
+ // and saying "the store is not in place" there would be false.
985
+ const ADR_LAYOUT_SHAPES = Object.freeze({
986
+ old: 'a legacy archive file is still on disk and must be exploded into the per-file store',
987
+ 'old-unrotated': 'the deployed rotation script predates the store and keeps writing the retired layout',
988
+ });
989
+ export const probeAdrStore = ({ root, deps, add, skip }) => {
990
+ try {
991
+ const shape = ADR_LAYOUT_SHAPES[surveyAdrLayoutStrict(root, deps)];
992
+ if (!shape) return; // migrated, or no ADR substrate at all — nothing to offer
993
+ // HAND-APPLY, not the standard lane: the consent flow executes the apply slot against the
994
+ // confirmation given BEFORE the preview, and this crossing requires informed consent AFTER its
995
+ // dry-run. A runnable one-liner here would auto-run a tree-mutating migration on stale consent.
996
+ add(
997
+ 'adr-store-migration',
998
+ fillTemplate(WHATS['adr-store-migration'], { shape }),
999
+ `HAND-APPLY: node ${q(toolPath('migrate-adr-store.mjs'))} --dry-run --cwd ${q(root)} — then re-run with --apply ONLY after showing the plan and getting fresh consent`,
1000
+ );
1001
+ } catch (err) {
1002
+ skip('adr-store-migration', err);
1003
+ }
1004
+ };
1005
+
968
1006
  // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
969
1007
  const PROBES = Object.freeze([
970
1008
  probeVelocityItems,
@@ -977,6 +1015,7 @@ const PROBES = Object.freeze([
977
1015
  probeStateBlockHook,
978
1016
  probeCheapAgents,
979
1017
  probeFamilyFreshness,
1018
+ probeAdrStore,
980
1019
  probeMasksItem,
981
1020
  probeSandboxLane,
982
1021
  probeWorktreesDir,
@@ -15,6 +15,10 @@ const READINESS_COL = 14;
15
15
  const STAMP_COL = 26;
16
16
  const SETTINGS_COL = 14;
17
17
 
18
+ // The ADR-layout tokens that carry an action for the user. Kept as a list, not a chain of equality
19
+ // checks, so a future token joins the render by joining this line.
20
+ const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
21
+
18
22
  const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
19
23
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
20
24
  export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
@@ -86,8 +90,10 @@ const renderProject = (vm, { color }) => {
86
90
  }
87
91
  for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
88
92
  lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
89
- // Only the actionable 'old' layout renders a line — a migrated/none store needs no note (AD-051).
90
- if (p.adrLayout === 'old') {
93
+ // Only an ACTIONABLE layout renders a line — a migrated/none store needs no note (AD-051). Both
94
+ // actionable tokens render the SAME line: 'old' (a monolith on disk) and 'old-unrotated' (an
95
+ // old-scheme rotator that never rotated) differ only in the discriminator, never in the remedy.
96
+ if (ACTIONABLE_ADR_LAYOUTS.includes(p.adrLayout)) {
91
97
  lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
92
98
  }
93
99
  if (p.visibility) {