@sabaiway/agent-workflow-kit 4.3.0 → 4.5.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.
@@ -342,23 +342,89 @@ const hasHiddenFence = (projectDir, deps = {}) => {
342
342
  // The retired 3-tier ADR monoliths (AD-051): their presence is the old-layout signal a consumer must
343
343
  // migrate away from via the opt-in `migrate-adr-store` mode. Stable relative paths (a status probe
344
344
  // never imports the rotator).
345
- const DECISIONS_MONOLITHS = ['docs/ai/history/decisions-archive.md', 'docs/ai/history/decisions-archive-early.md'];
346
- const ADR_STORE_DIR = 'docs/ai/adr';
345
+ // The ADR-layout probe's paths. LOCAL literals, not an import: this module backs the read-only
346
+ // `status` view and importing the rotator would drag child_process + crypto into a status read. A
347
+ // drift guard in family-registry.test.mjs pins each literal to the rotator's exported constant —
348
+ // the same baked-frozen-copy shape the gate hook uses for its velocity constants.
349
+ export const ADR_LAYOUT_PATHS = Object.freeze({
350
+ hot: 'docs/ai/decisions.md',
351
+ monoliths: Object.freeze(['docs/ai/history/decisions-archive.md', 'docs/ai/history/decisions-archive-early.md']),
352
+ store: 'docs/ai/adr',
353
+ // The DEPLOYED rotator is the discriminator: the new-scheme file names the store path, the
354
+ // pre-migration one does not. Never "has decisions.md, lacks adr/" — that shape false-positives a
355
+ // tree whose NEW rotator already reds its own gate, and every No-Node project.
356
+ rotator: 'scripts/archive-decisions.mjs',
357
+ storeMarker: 'docs/ai/adr',
358
+ });
359
+ const ENOENT = 'ENOENT';
347
360
 
348
361
  // The ADR-store layout axis: 'old' (a retired decisions-archive monolith is still on disk — needs
349
362
  // the opt-in migration), 'migrated' (the one-file-per-ADR adr/ store is in place), or 'none' (no ADR
350
363
  // substrate at all). Keys on the monolith presence, NOT on the stamp/head (Decision 6/13).
351
- const surveyAdrLayout = (dir, exists) => {
352
- const safe = (rel) => {
364
+ // The STRICT core. Absence is ENOENT and nothing else: existsSync collapses EVERY failure to false
365
+ // (EACCES included), so a strict policy layered on it would be vacuous in production and observable
366
+ // only through injected deps — and ENOTDIR means a file where a directory belongs, i.e. corruption,
367
+ // not "not there". Everything else propagates, so the advisor can degrade to a STATED SKIP instead
368
+ // of printing "flow optimal" over a layout it could not read.
369
+ export const PATH_DIR = 'dir';
370
+ export const PATH_FILE = 'file';
371
+ export const PATH_OTHER = 'other';
372
+ const probeStat = (path) => {
373
+ const st = statSync(path);
374
+ return st.isDirectory() ? PATH_DIR : st.isFile() ? PATH_FILE : PATH_OTHER;
375
+ };
376
+
377
+ export const surveyAdrLayoutStrict = (dir, deps = {}) => {
378
+ const stat = deps.statPath ?? probeStat;
379
+ const read = deps.readFile ?? readFileSync;
380
+ const absent = (err) => err && err.code === ENOENT;
381
+ // The TYPE is part of the question: a regular file named `docs/ai/adr` is not a store, and a
382
+ // directory named `archive-decisions.mjs` is not a rotator. Answering `migrated` off a name alone
383
+ // is the same fail-open this survey exists to avoid.
384
+ const typeAt = (rel) => {
353
385
  try {
354
- return exists(join(dir, rel));
355
- } catch {
356
- return false;
386
+ return stat(join(dir, rel));
387
+ } catch (err) {
388
+ if (absent(err)) return null;
389
+ throw err;
357
390
  }
358
391
  };
359
- if (DECISIONS_MONOLITHS.some(safe)) return 'old';
360
- if (safe(ADR_STORE_DIR)) return 'migrated';
361
- return 'none';
392
+ const isA = (rel, kind) => typeAt(rel) === kind;
393
+
394
+ if (ADR_LAYOUT_PATHS.monoliths.some((rel) => isA(rel, PATH_FILE))) return 'old';
395
+
396
+ // Rotator provenance is resolved BEFORE any verdict, because it also disambiguates a tree that
397
+ // HAS a store: an old-scheme rotator beside a store is not finished — `upgrade` preserves that
398
+ // script and it will write a monolith again the next time it rotates.
399
+ const rotatorScheme = (() => {
400
+ if (!isA(ADR_LAYOUT_PATHS.rotator, PATH_FILE)) return null;
401
+ try {
402
+ return String(read(join(dir, ADR_LAYOUT_PATHS.rotator), 'utf8')).includes(ADR_LAYOUT_PATHS.storeMarker) ? 'new' : 'old';
403
+ } catch (err) {
404
+ if (absent(err)) return null;
405
+ throw err;
406
+ }
407
+ })();
408
+
409
+ if (isA(ADR_LAYOUT_PATHS.store, PATH_DIR)) return rotatorScheme === 'old' ? 'old-unrotated' : 'migrated';
410
+ // No substrate at all: the crossing cannot seed a store with nothing to put in it, so the
411
+ // detector must not ask for one (this is also what the rotator's own no-substrate skip does).
412
+ if (!isA(ADR_LAYOUT_PATHS.hot, PATH_FILE)) return 'none';
413
+ // No deployed rotator: no evidence of scheme, and nothing actionable — upgrade's seed-if-missing
414
+ // owns that path, and on a No-Node project a Node tool would be a permanent unactionable nag.
415
+ // A NEW-scheme rotator with no store is cohort A: its own --check already reds on every commit
416
+ // naming --write-navigator, so it is never nagged twice.
417
+ return rotatorScheme === 'old' ? 'old-unrotated' : 'none';
418
+ };
419
+
420
+ // The LENIENT wrapper — the read-only status view must never crash, so any failure reads `none`.
421
+ // One implementation, two stated policies; the advisor uses the strict core deliberately.
422
+ const surveyAdrLayout = (dir, deps) => {
423
+ try {
424
+ return surveyAdrLayoutStrict(dir, deps);
425
+ } catch {
426
+ return 'none';
427
+ }
362
428
  };
363
429
 
364
430
  // surveyProject → the deploy axis for a target project dir: the per-member deployment stamps, whether
@@ -378,7 +444,7 @@ export const surveyProject = (projectDir, deps = {}) => {
378
444
  }
379
445
  })();
380
446
  const deployed = stamps.some((s) => s.version != null) || docsAiPresent;
381
- return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, exists), hiddenFence: hasHiddenFence(dir, deps), stamps };
447
+ return { dir, deployed, docsAiPresent, adrLayout: surveyAdrLayout(dir, deps), hiddenFence: hasHiddenFence(dir, deps), stamps };
382
448
  };
383
449
 
384
450
  // ── report ───────────────────────────────────────────────────────────────────────
@@ -615,7 +681,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
615
681
  dir: project.dir,
616
682
  deployed: project.deployed,
617
683
  docsAi: project.docsAiPresent,
618
- adrLayout: project.adrLayout, // 'old' | 'migrated' | 'none' — a user-safe token, never a raw path
684
+ adrLayout: project.adrLayout, // 'old' | 'old-unrotated' | 'migrated' | 'none' — a user-safe token, never a raw path
619
685
  // member + display + version only — never the internal stamp FILENAME (s.file).
620
686
  deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
621
687
  };
@@ -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, '..');
@@ -183,16 +191,181 @@ export const writeSnapshot = (cwd, refresh, stamp, deps = {}) => {
183
191
  };
184
192
 
185
193
  // Overwrite each refresh target with the kit canon, atomically, preserving the canon's exec bit.
194
+ //
195
+ // ORDER MATTERS: the rotation script is what the layout discriminator reads, so it is written LAST.
196
+ // A crash partway through a refresh that had already flipped it would otherwise leave a tree that
197
+ // LOOKS refreshed while other scripts are still the old copies — and a resume, keying on that same
198
+ // script, would skip them forever. Written last, an interrupted refresh always re-plans in full.
199
+ const DISCRIMINATOR_SCRIPT = ADR_LAYOUT_PATHS.rotator.split('/').pop();
200
+ const refreshOrder = (refresh) => [
201
+ ...refresh.filter((r) => r.name !== DISCRIMINATOR_SCRIPT),
202
+ ...refresh.filter((r) => r.name === DISCRIMINATOR_SCRIPT),
203
+ ];
204
+
186
205
  const applyScriptRefresh = (cwd, refresh, deps = {}) => {
187
206
  const read = deps.read ?? readFileSync;
188
207
  const chmod = deps.chmod ?? chmodSync;
189
208
  const stat = deps.stat ?? statSync;
190
- for (const { canon, dst, name } of refresh) {
209
+ for (const { canon, dst, name } of refreshOrder(refresh)) {
191
210
  writeContainedFileAtomic(cwd, dst, read(canon, 'utf8'), deps, { stop, label: `${CONSUMER_SCRIPTS_REL}/${name}` });
192
211
  chmod(dst, stat(canon).mode & 0o777); // the exec bit is the git-tracked axis the mirror guard pins
193
212
  }
194
213
  };
195
214
 
215
+ // ── the no-monolith crossing ─────────────────────────────────────────────────────
216
+ //
217
+ // A consumer on the RETIRED scheme that never rotated far enough to produce a monolith used to read
218
+ // "a fresh new-scheme tree" here and be sent away. The discriminator is the deployed rotation
219
+ // script's own provenance (family-registry.mjs), never "has decisions.md, lacks adr/".
220
+ //
221
+ // Re-entry is decided by what is FINISHED, never by one existence bit: the store directory existing
222
+ // does not prove the navigator was written or the index regenerated, so a crash there must not turn
223
+ // the next --apply into a no-op. Every write below is individually idempotent, which is why this
224
+ // needs no resume ledger.
225
+
226
+ // The crossing is COMPLETE when the navigator exists, the tree's own gate passes, AND the index the
227
+ // crossing regenerates is fresh. The index is part of the criterion because it is a real output of
228
+ // the crossing that `--check` never looks at: a crash (or a failed regeneration) between the
229
+ // navigator write and the index left a tree that reported "already migrated" on the retry and never
230
+ // repaired the index. An unreachable index generator is NOT treated as fresh — the crossing re-runs
231
+ // and fails loudly again, which is the honest outcome for a broken generator.
232
+ const INDEX_GENERATOR = join(KIT_SCRIPTS, 'check-docs-size.mjs');
233
+ const isIndexFresh = (cwd, deps = {}) => {
234
+ const spawn = deps.spawnSync ?? spawnSync;
235
+ const r = spawn(process.execPath, [INDEX_GENERATOR, '--check-index', `--root=${cwd}`], { encoding: 'utf8' });
236
+ return !r.error && r.status === 0;
237
+ };
238
+
239
+ // The layout verdict leads, and it is the SAME verdict the status line and the advisor read: an
240
+ // old-scheme rotator beside a finished store still answers `old-unrotated`, so treating that tree as
241
+ // done would leave the signal permanently lit with nothing able to clear it.
242
+ const isFinalised = (cwd, runMigrate, deps = {}) =>
243
+ surveyAdrLayoutStrict(cwd, deps) === 'migrated' &&
244
+ substratePresent(join(cwd, NAV_REL), deps) &&
245
+ runMigrate(['--check'], { root: cwd, log: () => {}, logError: () => {} }) === EXIT_OK &&
246
+ isIndexFresh(cwd, deps);
247
+
248
+ // `existsSync` answers false for EVERY failure, EACCES included — so asking it whether the substrate
249
+ // is there would turn an UNREADABLE tree into a confident "nothing to migrate", exit 0. Absence is
250
+ // ENOENT and nothing else; anything else is surfaced, never swallowed. Same policy the layout survey
251
+ // already enforces, now applied where the tool acts on it.
252
+ const substratePresent = (path, deps = {}) => {
253
+ const stat = deps.statSync ?? statSync;
254
+ try {
255
+ stat(path);
256
+ return true;
257
+ } catch (err) {
258
+ if (err && err.code === 'ENOENT') return false;
259
+ throw stop(`cannot read ${path} (${err && err.message}) — refusing to report on a tree it could not inspect`);
260
+ }
261
+ };
262
+
263
+ const crossWithoutMonolith = (cwd, args, stamp, { log, error, runMigrate, deps }) => {
264
+ const hasStore = substratePresent(join(cwd, ADR_DIR_REL), deps);
265
+ const hasHot = substratePresent(join(cwd, HOT_REL), deps);
266
+
267
+ if (!hasHot && !hasStore) {
268
+ // NOT "a fresh new-scheme tree" — this tree may well be old-scheme; it simply has no ADR
269
+ // substrate for the crossing to carry across, which is exactly the rotator's own skip.
270
+ log(`[migrate-adr-store] nothing to migrate — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}/) and no legacy monolith.`);
271
+ return EXIT_OK;
272
+ }
273
+ if (hasStore && isFinalised(cwd, runMigrate, deps)) {
274
+ log('[migrate-adr-store] already migrated — the one-file-per-ADR store is in place and its gate is green; nothing to do.');
275
+ return EXIT_OK;
276
+ }
277
+ if (!hasStore && surveyAdrLayoutStrict(cwd, deps) === 'none' && !substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)) {
278
+ // Nothing to refresh (the refresh is directional — it never ADDS a script) and nothing to
279
+ // maintain a store we might seed. The normal upgrade owns seeding the pair.
280
+ log('[migrate-adr-store] nothing to migrate — no deployed rotation script; run the normal upgrade first (it seeds the ADR enforcement pair).');
281
+ return EXIT_OK;
282
+ }
283
+
284
+ const refresh = planScriptRefresh(cwd, deps);
285
+ const drifted = refresh.filter((r) => r.differs);
286
+ // The read-only preflight: the SAME parse / half-migrated guard / store-integrity check the seed
287
+ // itself runs, stopping before every write. Without it a dry-run could green-light an apply that
288
+ // writes the store and only then discovers it cannot converge.
289
+ const preflight = (logError) => runMigrate(['--write-navigator', '--dry-run'], { root: cwd, log: () => {}, logError });
290
+
291
+ if (!args.apply) {
292
+ const preview = resolveSnapshotDir(cwd, stamp, deps);
293
+ // Three attempts to SUMMARISE why this tree needs the crossing produced three wrong sentences —
294
+ // each true of the common case and false of a state this arm deliberately supports. So the
295
+ // summary is gone: the preview states the two facts it actually knows, one per line, and the
296
+ // reader draws the conclusion. Nothing here can drift out of step with the tree, because nothing
297
+ // here is an inference. (Which scripts are stale is already reported by the refresh line below —
298
+ // never re-asserted here.)
299
+ const layout = surveyAdrLayoutStrict(cwd, deps);
300
+ const rotatorFact = layout === 'old-unrotated'
301
+ ? `predates the one-file-per-ADR store`
302
+ : substratePresent(join(cwd, ADR_LAYOUT_PATHS.rotator), deps)
303
+ ? `already names the store`
304
+ : `not deployed`; // and NOT "nothing to refresh" — a sibling script may still need one
305
+ log('[migrate-adr-store] --dry-run — no files will be changed. Planned crossing (no legacy monolith to retire):');
306
+ log(` deployed ${ADR_LAYOUT_PATHS.rotator}: ${rotatorFact}`);
307
+ log(` ${ADR_DIR_REL}/: ${hasStore ? 'present, but the crossing has not been completed' : 'absent'}`);
308
+ 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)'}`);
309
+ log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
310
+ log(` then seed the store: create ${ADR_DIR_REL}/, write ${NAV_REL} and regenerate docs/ai/index.md`);
311
+ const code = preflight((m) => error(` ${m}`));
312
+ if (code !== EXIT_OK) {
313
+ throw stop(`the tree cannot be seeded as it stands (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
314
+ }
315
+ if (preview.dir === null) {
316
+ 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.');
317
+ }
318
+ log(' index regeneration is verified at --apply time (a dry-run cannot observe it without writing).');
319
+ log('Run `/agent-workflow-kit migrate-adr-store` again with --apply to perform it (it never commits).');
320
+ return EXIT_OK;
321
+ }
322
+
323
+ const pre = preflight(error);
324
+ if (pre !== EXIT_OK) {
325
+ 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.`);
326
+ }
327
+
328
+ const snapshot = writeSnapshot(cwd, refresh, stamp, deps);
329
+ // The FULL refresh is re-planned and re-applied on every entry, so an interrupted one always
330
+ // completes; the discriminator script is written last (see refreshOrder).
331
+ applyScriptRefresh(cwd, refresh, deps);
332
+
333
+ // Capture the index-regeneration verdict instead of matching log prose: the rotator logs a failed
334
+ // regeneration and still returns 0, so "the gates are green" would not mean the index is fresh.
335
+ const regen = { ok: true, detail: '' };
336
+ const seed = runMigrate(['--write-navigator'], {
337
+ root: cwd,
338
+ log,
339
+ logError: error,
340
+ regenerateIndex: (root, today, d) => {
341
+ const r = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today, d);
342
+ regen.ok = r.ok;
343
+ regen.detail = r.detail;
344
+ return r;
345
+ },
346
+ });
347
+ if (seed !== EXIT_OK) {
348
+ 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).`);
349
+ }
350
+ if (!regen.ok) {
351
+ 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).`);
352
+ }
353
+ const verify = runMigrate(['--check'], { root: cwd, log: () => {}, logError: error });
354
+ if (verify !== EXIT_OK) {
355
+ 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).`);
356
+ }
357
+
358
+ // States what this run DID, never what the tree was before it: the same arm completes an
359
+ // interrupted crossing whose scripts were already current, which no "old-scheme" claim covers.
360
+ log('[migrate-adr-store] crossing complete — the one-file-per-ADR store is in place (no legacy monolith was present):');
361
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'}, ${snapshot.fileCount} file(s))`);
362
+ log(` refreshed ${refresh.length} enforcement script(s) to this kit's version`);
363
+ log(` seeded ${ADR_DIR_REL}/ with ${NAV_REL} and regenerated docs/ai/index.md`);
364
+ log(' next: run the normal upgrade (it re-stamps the deployment lineage to the current head),');
365
+ log(' then review the migrated docs/ai/ tree and the re-stamp together and commit them yourself — this command never commits.');
366
+ return EXIT_OK;
367
+ };
368
+
196
369
  export const main = (argv = process.argv.slice(2), deps = {}) => {
197
370
  const log = deps.log ?? console.log;
198
371
  const error = deps.error ?? console.error;
@@ -209,11 +382,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
209
382
 
210
383
  const monoliths = monolithsPresent(cwd);
211
384
  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;
385
+ return crossWithoutMonolith(cwd, args, stamp, { log, error, runMigrate, deps });
217
386
  }
218
387
 
219
388
  const refresh = planScriptRefresh(cwd, deps);