@sabaiway/agent-workflow-kit 5.2.0 → 5.4.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/bridges/codex-cli-bridge/SKILL.md +11 -3
  5. package/bridges/codex-cli-bridge/bin/codex-exec.sh +154 -35
  6. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +257 -4
  7. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  8. package/bridges/codex-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/references/driving-codex.md +5 -3
  10. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +33 -15
  11. package/capability.json +1 -1
  12. package/package.json +1 -1
  13. package/references/modes/core-evidence.md +1 -1
  14. package/references/modes/coverage-check.md +1 -1
  15. package/references/modes/gates.md +7 -2
  16. package/references/modes/recommendations.md +3 -1
  17. package/references/modes/upgrade.md +1 -1
  18. package/references/modes/velocity.md +5 -1
  19. package/references/scripts/archive-decisions.mjs +340 -15
  20. package/references/scripts/archive-decisions.test.mjs +522 -2
  21. package/references/scripts/migrate-gates.mjs +102 -10
  22. package/references/scripts/migrate-gates.test.mjs +37 -0
  23. package/tools/core-evidence.mjs +42 -2
  24. package/tools/coverage-check.mjs +23 -7
  25. package/tools/coverage-producer.mjs +68 -0
  26. package/tools/coverage-state.mjs +24 -0
  27. package/tools/declared-paths.mjs +32 -0
  28. package/tools/detect-backends.mjs +1 -0
  29. package/tools/dispatch-record.mjs +926 -0
  30. package/tools/doc-parity.mjs +19 -4
  31. package/tools/flow-check.mjs +48 -12
  32. package/tools/gates-declaration.mjs +49 -0
  33. package/tools/gates-init.mjs +83 -6
  34. package/tools/recommendations.mjs +63 -19
  35. package/tools/run-gates.mjs +111 -32
  36. package/tools/velocity-profile.mjs +102 -23
@@ -1,8 +1,8 @@
1
1
  import { describe, it, afterEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { join, dirname } from 'node:path';
6
6
  import {
7
7
  HOT_REL,
8
8
  WARM_REL,
@@ -23,6 +23,8 @@ import {
23
23
  lineCountOf,
24
24
  runCli,
25
25
  defaultRegenerateIndex,
26
+ verifyRewriteConservation,
27
+ writeInboundRewrites,
26
28
  } from './archive-decisions.mjs';
27
29
 
28
30
  // Hermetic: this test ships as deploy payload and runs inside CONSUMER repos via the pre-commit
@@ -940,3 +942,521 @@ describe('usage', () => {
940
942
  assert.match(text, /Usage: archive-decisions/);
941
943
  });
942
944
  });
945
+
946
+ // ── reference integrity — inbound anchor rewrite + check assertions (the anchor-orphan fix) ────
947
+ //
948
+ // Rotation/migration move ADR blocks out of HOT while inbound `decisions.md#ad-NNN…` links keep
949
+ // pointing at the old home. The write passes rewrite those links (monolith forms too under
950
+ // migrate) under a conservation invariant, `--check` asserts every inbound ADR reference
951
+ // resolves, and the exported verifier refuses a dropped/altered rewrite with nothing written.
952
+ // Fenced occurrences never count; inline code is treated as live (a stated limitation).
953
+
954
+ const writeDoc = (root, rel, text) => {
955
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
956
+ writeFileSync(join(root, rel), text);
957
+ };
958
+
959
+ // An over-cap migrated-shape HOT (no monolith): cap = rendered - 1 → exactly the oldest explodes.
960
+ const seedOverCapHot = (root, ids) => {
961
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
962
+ const blocks = ids.map((id) => adrBlock(id));
963
+ const preamble = '# ADRs\n\n> Newest at the bottom.';
964
+ const probe = tierText(9999, preamble, blocks);
965
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
966
+ };
967
+
968
+ describe('reference integrity — rotate/migrate rewrite inbound links, --check asserts them', () => {
969
+ // Legacy tree whose migrate moves AD-001…AD-005 (HOT overflows by one) and retains AD-006…AD-008,
970
+ // with inbound links at two depths: pages (needs ../) and history (monolith sibling).
971
+ const seedMigrateFixture = (root) => {
972
+ seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'], hotCapDelta: -1 });
973
+ writeDoc(root, 'docs/ai/pages/spec.md', [
974
+ 'hot [AD-005](../decisions.md#ad-005--decision-005)',
975
+ 'warm [AD-003](../history/decisions-archive.md#ad-003--decision-003)',
976
+ '',
977
+ ].join('\n'));
978
+ writeDoc(root, 'docs/ai/history/notes.md', 'cold sibling [AD-001](decisions-archive-early.md#ad-001--decision-001)\n');
979
+ };
980
+
981
+ it('rotate rewrites inbound decisions.md anchors to record links, fragment preserved', () => {
982
+ const root = makeRoot();
983
+ seedOverCapHot(root, ['001', '002', '003', '004']);
984
+ writeDoc(root, 'docs/ai/pages/spec.md', '# Spec\n\nsee [AD-001](../decisions.md#ad-001--decision-001) for the decision.\n');
985
+ const { code, errText } = run(['--today=2026-08-07'], root);
986
+ assert.equal(code, 0, errText);
987
+ const text = readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8');
988
+ assert.match(text, /\[AD-001\]\(\.\.\/adr\/AD-001-decision-001\.md#ad-001--decision-001\)/);
989
+ assert.doesNotMatch(text, /decisions\.md#ad-001(?!-)|\(\.\.\/decisions\.md/);
990
+ });
991
+
992
+ it('rotate rewrite is count-preserving: every moved-id link rewritten, all other bytes identical', () => {
993
+ const root = makeRoot();
994
+ seedOverCapHot(root, ['001', '002', '003', '004']);
995
+ const spec = [
996
+ '# Spec',
997
+ '',
998
+ 'moved [AD-001](../decisions.md#ad-001--decision-001) and inline `../decisions.md#ad-001--decision-001` on one line.',
999
+ 'retained [AD-004](../decisions.md#ad-004--decision-004) stays.',
1000
+ 'negatives: [other](../other-decisions.md#ad-001--decision-001) and [bare](#ad-001--decision-001).',
1001
+ '',
1002
+ ].join('\n');
1003
+ writeDoc(root, 'docs/ai/pages/spec.md', spec);
1004
+ writeDoc(root, 'docs/ai/history/notes.md', 'notes: [AD-001](../decisions.md#ad-001--decision-001)\n');
1005
+ const { code, errText } = run(['--today=2026-08-07'], root);
1006
+ assert.equal(code, 0, errText);
1007
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), [
1008
+ '# Spec',
1009
+ '',
1010
+ 'moved [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001) and inline `../adr/AD-001-decision-001.md#ad-001--decision-001` on one line.',
1011
+ 'retained [AD-004](../decisions.md#ad-004--decision-004) stays.',
1012
+ 'negatives: [other](../other-decisions.md#ad-001--decision-001) and [bare](#ad-001--decision-001).',
1013
+ '',
1014
+ ].join('\n'));
1015
+ assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), 'notes: [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
1016
+ });
1017
+
1018
+ it('rotate dry-run prints the rewrite set and writes nothing', () => {
1019
+ const root = makeRoot();
1020
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1021
+ const spec = 'see [AD-001](../decisions.md#ad-001--decision-001).\n';
1022
+ writeDoc(root, 'docs/ai/pages/spec.md', spec);
1023
+ const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
1024
+ const { code, text } = run(['--dry-run', '--today=2026-08-07'], root);
1025
+ assert.equal(code, 0, text);
1026
+ assert.match(text, /docs\/ai\/pages\/spec\.md:1 decisions\.md#ad-001--decision-001 → adr\/AD-001-decision-001\.md#ad-001--decision-001/);
1027
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), spec, 'the doc is byte-unchanged');
1028
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT is byte-unchanged');
1029
+ assert.deepEqual(adrFiles(root), [], 'no record written on a dry-run');
1030
+ });
1031
+
1032
+ it('--check exits 1 listing every stale decisions.md anchor with file:line', () => {
1033
+ const root = makeRoot();
1034
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
1035
+ writeDoc(root, 'docs/ai/pages/spec.md', [
1036
+ 'stale-but-archived [AD-001](../decisions.md#ad-001--decision-001)',
1037
+ 'nonexistent [AD-099](../decisions.md#ad-099--gone)',
1038
+ '',
1039
+ ].join('\n'));
1040
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
1041
+ assert.equal(code, 1);
1042
+ assert.match(errText, /docs\/ai\/pages\/spec\.md:1: .*ad-001/);
1043
+ assert.match(errText, /docs\/ai\/pages\/spec\.md:2: .*ad-099/);
1044
+ });
1045
+
1046
+ it('--check exits 1 with file:line on a dead adr record link (exact filename)', () => {
1047
+ const root = makeRoot();
1048
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
1049
+ writeDoc(root, 'docs/ai/pages/spec.md', [
1050
+ 'good [AD-001](../adr/AD-001-decision-001.md)',
1051
+ 'dead [AD-001](../adr/AD-001-wrong-slug.md)',
1052
+ '',
1053
+ ].join('\n'));
1054
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
1055
+ assert.equal(code, 1);
1056
+ assert.match(errText, /docs\/ai\/pages\/spec\.md:2: .*AD-001-wrong-slug\.md/);
1057
+ assert.doesNotMatch(errText, /spec\.md:1:/, 'the resolving record link is not flagged');
1058
+ });
1059
+
1060
+ it('--migrate --apply rewrites inbound links including monolith-form anchors, source-relative', () => {
1061
+ const root = makeRoot();
1062
+ seedMigrateFixture(root);
1063
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
1064
+ assert.equal(code, 0, errText);
1065
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), [
1066
+ 'hot [AD-005](../adr/AD-005-decision-005.md#ad-005--decision-005)',
1067
+ 'warm [AD-003](../adr/AD-003-decision-003.md#ad-003--decision-003)',
1068
+ '',
1069
+ ].join('\n'));
1070
+ assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), 'cold sibling [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
1071
+ assert.equal(run(['--check', '--today=2026-08-07'], root).code, 0, 'the rewritten tree is green end-to-end');
1072
+ });
1073
+
1074
+ it('migrate dry-run prints the monolith and HOT rewrite set and writes nothing', () => {
1075
+ const root = makeRoot();
1076
+ seedMigrateFixture(root);
1077
+ const specBefore = readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8');
1078
+ const notesBefore = readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8');
1079
+ const { code, text } = run(['--migrate', '--today=2026-08-07'], root);
1080
+ assert.equal(code, 0, text);
1081
+ assert.match(text, /DRY-RUN/);
1082
+ assert.match(text, /docs\/ai\/pages\/spec\.md:1 decisions\.md#ad-005--decision-005 → adr\/AD-005-decision-005\.md#ad-005--decision-005/);
1083
+ assert.match(text, /docs\/ai\/pages\/spec\.md:2 \.\.\/history\/decisions-archive\.md#ad-003--decision-003 → \.\.\/adr\/AD-003-decision-003\.md#ad-003--decision-003/);
1084
+ assert.match(text, /docs\/ai\/history\/notes\.md:1 decisions-archive-early\.md#ad-001--decision-001 → \.\.\/adr\/AD-001-decision-001\.md#ad-001--decision-001/);
1085
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), specBefore);
1086
+ assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), notesBefore);
1087
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
1088
+ assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no adr/ tree created on a dry-run');
1089
+ });
1090
+
1091
+ it('rotate and migrate refuse pre-write when the ADR corpus links a moved id', () => {
1092
+ // (a) the HOT preamble links the moved id
1093
+ {
1094
+ const root = makeRoot();
1095
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
1096
+ const preamble = '# ADRs\n\n> see [old](decisions.md#ad-005--decision-005)';
1097
+ const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
1098
+ const probe = tierText(9999, preamble, blocks);
1099
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
1100
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
1101
+ const { code, errText } = run(['--today=2026-08-07'], root);
1102
+ assert.equal(code, 1);
1103
+ assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
1104
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
1105
+ assert.deepEqual(adrFiles(root), [], 'no record written');
1106
+ }
1107
+ // (b) a HOT block body links the moved id
1108
+ {
1109
+ const root = makeRoot();
1110
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
1111
+ const linked = ['## AD-007 — Decision 007', '', '**Date:** 2026-01-01 · **Status:** Accepted', '', 'refers to [x](decisions.md#ad-005--decision-005)'].join('\n');
1112
+ const blocks = [adrBlock('005'), adrBlock('006'), linked, adrBlock('008')];
1113
+ const probe = tierText(9999, '# ADRs', blocks);
1114
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
1115
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
1116
+ const { code, errText } = run(['--today=2026-08-07'], root);
1117
+ assert.equal(code, 1);
1118
+ assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
1119
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
1120
+ assert.deepEqual(adrFiles(root), [], 'no record written');
1121
+ }
1122
+ // (c) an existing adr/ record block links the moved id
1123
+ {
1124
+ const root = makeRoot();
1125
+ seedOverCapHot(root, ['005', '006', '007', '008']);
1126
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-decision-001.md'), `${fm(RECORD_CAP)}\n## AD-001 — Decision 001\n\n**Date:** 2026-01-01\n\nsee [x](../decisions.md#ad-005--decision-005)\n`);
1127
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
1128
+ const { code, errText } = run(['--today=2026-08-07'], root);
1129
+ assert.equal(code, 1);
1130
+ assert.match(errText, /adr\/AD-001-decision-001\.md:\d+/);
1131
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
1132
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md'], 'no new record written');
1133
+ }
1134
+ // (d) a WARM monolith block links a moved id (migrate)
1135
+ {
1136
+ const root = makeRoot();
1137
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
1138
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
1139
+ const warmBlock = ['## AD-003 — Decision 003', '', '**Date:** 2026-01-01', '', 'see [x](../decisions.md#ad-001--decision-001)'].join('\n');
1140
+ writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [warmBlock]));
1141
+ writeFileSync(join(root, COLD_REL), tierText(500, COLD_PREAMBLE, [adrBlock('001')]));
1142
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
1143
+ assert.equal(code, 1);
1144
+ assert.match(errText, /docs\/ai\/history\/decisions-archive\.md:\d+/);
1145
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
1146
+ assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
1147
+ assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on the refusal');
1148
+ }
1149
+ // (e) a COLD monolith block carries a monolith-form link to a moved id (migrate)
1150
+ {
1151
+ const root = makeRoot();
1152
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
1153
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
1154
+ writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [adrBlock('003')]));
1155
+ const coldBlock = ['## AD-001 — Decision 001', '', '**Date:** 2026-01-01', '', 'see [x](decisions-archive.md#ad-003--decision-003)'].join('\n');
1156
+ writeFileSync(join(root, COLD_REL), tierText(500, COLD_PREAMBLE, [coldBlock]));
1157
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
1158
+ assert.equal(code, 1);
1159
+ assert.match(errText, /docs\/ai\/history\/decisions-archive-early\.md:\d+/);
1160
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
1161
+ assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
1162
+ }
1163
+ });
1164
+
1165
+ it('--check exits 1 on a dead reference inside an ADR block body', () => {
1166
+ const root = makeRoot();
1167
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
1168
+ const hotBlock = ['## AD-005 — Decision 005', '', '**Date:** 2026-01-01', '', 'cites [gone](decisions.md#ad-002--decision-002)'].join('\n');
1169
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [hotBlock]));
1170
+ writeFileSync(join(root, ADR_DIR_REL, 'AD-001-decision-001.md'), `${fm(RECORD_CAP)}\n## AD-001 — Decision 001\n\n**Date:** 2026-01-01\n\ncites [gone too](../decisions.md#ad-099--gone)\n`);
1171
+ assert.equal(run(['--write-navigator', '--today=2026-07-09'], root).code, 0);
1172
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
1173
+ assert.equal(code, 1);
1174
+ assert.match(errText, /docs\/ai\/decisions\.md:\d+: .*ad-002/);
1175
+ assert.match(errText, /docs\/ai\/adr\/AD-001-decision-001\.md:\d+: .*ad-099/);
1176
+ });
1177
+
1178
+ it('--check without an ADR substrate still fails on a matching reference', () => {
1179
+ const root = makeRoot();
1180
+ writeDoc(root, 'docs/ai/pages/spec.md', 'orphan [AD-001](../decisions.md#ad-001--decision-001)\n');
1181
+ const { code, errText } = run(['--check'], root);
1182
+ assert.equal(code, 1);
1183
+ assert.match(errText, /docs\/ai\/pages\/spec\.md:1/);
1184
+
1185
+ const clean = makeRoot();
1186
+ writeDoc(clean, 'docs/ai/pages/spec.md', 'no adr links here\n');
1187
+ const skip = run(['--check'], clean);
1188
+ assert.equal(skip.code, 0);
1189
+ assert.match(skip.text, /SKIP — no ADR substrate/);
1190
+ });
1191
+
1192
+ it('the rewrite conservation verifier refuses a dropped or altered link with nothing written', () => {
1193
+ const base = {
1194
+ rel: 'docs/ai/pages/spec.md',
1195
+ frontLines: 0,
1196
+ rewrites: [{ index: 0, line: 1, old: 'decisions.md#ad-001--decision-001', new: 'adr/AD-001-decision-001.md#ad-001--decision-001' }],
1197
+ };
1198
+ // a dropped link: the output line never received the planned rewrite
1199
+ assert.throws(() => verifyRewriteConservation({
1200
+ ...base,
1201
+ beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
1202
+ afterLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
1203
+ }), (e) => e.exitCode === 1 && /conservation/.test(e.message));
1204
+ // an altered byte outside the planned rewrite set
1205
+ assert.throws(() => verifyRewriteConservation({
1206
+ ...base,
1207
+ beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
1208
+ afterLines: ['see [x](../adr/AD-001-decision-001.md#ad-001--decision-001)', 'tail ALTERED'],
1209
+ }), (e) => e.exitCode === 1 && /conservation/.test(e.message));
1210
+ // the honest plan passes
1211
+ assert.doesNotThrow(() => verifyRewriteConservation({
1212
+ ...base,
1213
+ beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
1214
+ afterLines: ['see [x](../adr/AD-001-decision-001.md#ad-001--decision-001)', 'tail'],
1215
+ }));
1216
+ });
1217
+
1218
+ it('a fenced dead-link sample leaves --check green and survives rotate byte-identically', () => {
1219
+ const root = makeRoot();
1220
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
1221
+ writeDoc(root, 'docs/ai/pages/spec.md', ['fenced sample:', '', '```md', 'dead [x](../decisions.md#ad-099--gone)', '```', ''].join('\n'));
1222
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'a fenced dead link never counts');
1223
+
1224
+ const root2 = makeRoot();
1225
+ seedOverCapHot(root2, ['001', '002', '003', '004']);
1226
+ const doc2 = ['```md', 'fenced moved link [x](../decisions.md#ad-001--decision-001)', '```', ''].join('\n');
1227
+ writeDoc(root2, 'docs/ai/pages/spec.md', doc2);
1228
+ assert.equal(run(['--today=2026-08-07'], root2).code, 0);
1229
+ assert.equal(readFileSync(join(root2, 'docs/ai/pages/spec.md'), 'utf8'), doc2, 'the fenced occurrence survives rotate byte-identically');
1230
+ });
1231
+
1232
+ it('an unclosed fence in a scanned doc aborts BOTH write paths exit 1 with the tree untouched', () => {
1233
+ const root = makeRoot();
1234
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1235
+ writeDoc(root, 'docs/ai/pages/broken.md', '```md\nnever closed\n');
1236
+ const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
1237
+ const r = run(['--today=2026-08-07'], root);
1238
+ assert.equal(r.code, 1);
1239
+ assert.match(r.errText, /never closed/);
1240
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT untouched');
1241
+ assert.deepEqual(adrFiles(root), [], 'no record written');
1242
+
1243
+ const root2 = makeRoot();
1244
+ seedLegacy(root2, { hot: ['005'], warm: ['003'], cold: ['001'] });
1245
+ writeDoc(root2, 'docs/ai/pages/broken.md', '```md\nnever closed\n');
1246
+ const m = run(['--migrate', '--apply', '--today=2026-08-07'], root2);
1247
+ assert.equal(m.code, 1);
1248
+ assert.match(m.errText, /never closed/);
1249
+ assert.ok(existsSync(join(root2, WARM_REL)) && existsSync(join(root2, COLD_REL)), 'monoliths untouched');
1250
+ assert.ok(!existsSync(join(root2, ADR_DIR_REL)), 'no record written');
1251
+ });
1252
+
1253
+ it('the migrate rewrite set never targets a monolith file and written records preserve moved blocks verbatim', () => {
1254
+ const root = makeRoot();
1255
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
1256
+ writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
1257
+ const warmBlock = ['## AD-003 — Decision 003', '', '**Date:** 2026-01-01', '', 'links retained [AD-005](../decisions.md#ad-005--decision-005)'].join('\n');
1258
+ writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [warmBlock]));
1259
+ const dry = run(['--migrate', '--today=2026-08-07'], root);
1260
+ assert.equal(dry.code, 0, dry.errText);
1261
+ assert.doesNotMatch(dry.text, /history\/decisions-archive\.md:\d+ /, 'no rewrite entry targets a monolith file');
1262
+ const applied = run(['--migrate', '--apply', '--today=2026-08-07'], root);
1263
+ assert.equal(applied.code, 0, applied.errText);
1264
+ const rec = readFileSync(join(root, ADR_DIR_REL, 'AD-003-decision-003.md'), 'utf8');
1265
+ assert.match(rec, /links retained \[AD-005\]\(\.\.\/decisions\.md#ad-005--decision-005\)/, 'the moved block is preserved verbatim');
1266
+ });
1267
+
1268
+ it('D9 negative fixtures stay byte-identical: a different filename and a bare fragment never match', () => {
1269
+ const root = makeRoot();
1270
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1271
+ const doc = 'neg: [a](../my-decisions.md#ad-001--decision-001) [b](#ad-001--decision-001)\n';
1272
+ writeDoc(root, 'docs/ai/pages/neg.md', doc);
1273
+ const { code, errText } = run(['--today=2026-08-07'], root);
1274
+ assert.equal(code, 0, errText);
1275
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/neg.md'), 'utf8'), doc);
1276
+ });
1277
+
1278
+ it('an interrupted state (records + rewrites written, HOT untrimmed) re-runs to completion', () => {
1279
+ const root = makeRoot();
1280
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1281
+ const entry = parseDecisionsText(tierText(999, '# T', [adrBlock('001')]), 'x').entries[0];
1282
+ const [rec] = explode([entry], '2026-08-07');
1283
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
1284
+ writeDoc(root, 'docs/ai/pages/spec.md', 'see [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
1285
+ const { code, errText } = run(['--today=2026-08-07'], root);
1286
+ assert.equal(code, 0, errText);
1287
+ assert.deepEqual(idsIn(root, HOT_REL), ['002', '003', '004'], 'the re-run completed the trim');
1288
+ assert.equal(run(['--check', '--today=2026-08-07'], root).code, 0);
1289
+ });
1290
+
1291
+ it('--check stays green on a live tree with resolving anchors', () => {
1292
+ const root = makeRoot();
1293
+ seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
1294
+ writeDoc(root, 'docs/ai/pages/spec.md', [
1295
+ 'live [AD-005](../decisions.md#ad-005--decision-005)',
1296
+ 'archived [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)',
1297
+ '',
1298
+ ].join('\n'));
1299
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
1300
+ });
1301
+
1302
+ it('--migrate --apply --dry-run refuses loudly pre-spend', () => {
1303
+ const root = makeRoot();
1304
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
1305
+ const { code, errText } = run(['--migrate', '--apply', '--dry-run'], root);
1306
+ assert.equal(code, 2);
1307
+ assert.match(errText, /--migrate --apply --dry-run/);
1308
+ assert.ok(existsSync(join(root, WARM_REL)), 'nothing written');
1309
+ });
1310
+
1311
+ it('a symlinked markdown file in the scan tree refuses loudly (never read or written through)', () => {
1312
+ const root = makeRoot();
1313
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1314
+ writeFileSync(join(root, 'outside.md'), 'outside the docs tree\n');
1315
+ mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
1316
+ symlinkSync(join(root, 'outside.md'), join(root, 'docs/ai/pages/link.md'));
1317
+ const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
1318
+ const rotate = run(['--today=2026-08-07'], root);
1319
+ assert.equal(rotate.code, 1);
1320
+ assert.match(rotate.errText, /link\.md/);
1321
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT untouched');
1322
+ assert.deepEqual(adrFiles(root), [], 'no record written');
1323
+ assert.equal(readFileSync(join(root, 'outside.md'), 'utf8'), 'outside the docs tree\n', 'the symlink target is never touched');
1324
+ const check = run(['--check', '--today=2026-08-07'], root);
1325
+ assert.equal(check.code, 1);
1326
+ assert.match(check.errText, /link\.md/);
1327
+
1328
+ const clean = makeRoot();
1329
+ seedMigrated(clean, { hotIds: ['005'], storeIds: ['001'] });
1330
+ writeFileSync(join(clean, 'notes.txt'), 'plain\n');
1331
+ mkdirSync(join(clean, 'docs', 'ai', 'pages'), { recursive: true });
1332
+ symlinkSync(join(clean, 'notes.txt'), join(clean, 'docs/ai/pages/notes.txt'));
1333
+ assert.equal(run(['--check', '--today=2026-07-09'], clean).code, 0, 'a non-markdown symlink stays an ignored stray');
1334
+ });
1335
+
1336
+ it('a rewrite-scope file that changed after planning refuses pre-write with the human edit intact', () => {
1337
+ const root = makeRoot();
1338
+ mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
1339
+ const rel = 'docs/ai/pages/spec.md';
1340
+ const before = 'see [AD-001](../decisions.md#ad-001--decision-001)\n';
1341
+ writeFileSync(join(root, rel), before);
1342
+ const plan = {
1343
+ rel,
1344
+ frontmatter: '',
1345
+ frontLines: 0,
1346
+ beforeLines: before.split('\n'),
1347
+ afterLines: before.replace('decisions.md#ad-001--decision-001', 'adr/AD-001-decision-001.md#ad-001--decision-001').split('\n'),
1348
+ rewrites: [],
1349
+ };
1350
+ writeFileSync(join(root, rel), 'humanly edited meanwhile\n');
1351
+ assert.throws(() => writeInboundRewrites(root, [plan]), (e) => e.exitCode === 1 && /stale snapshot/.test(e.message));
1352
+ assert.equal(readFileSync(join(root, rel), 'utf8'), 'humanly edited meanwhile\n', 'the human edit survives');
1353
+ writeFileSync(join(root, rel), before);
1354
+ writeInboundRewrites(root, [plan]);
1355
+ assert.match(readFileSync(join(root, rel), 'utf8'), /adr\/AD-001-decision-001\.md#ad-001--decision-001/, 'the unchanged file is rewritten');
1356
+ });
1357
+
1358
+ it('a symlinked directory in the scan tree refuses loudly instead of hiding its subtree', () => {
1359
+ const root = makeRoot();
1360
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
1361
+ mkdirSync(join(root, 'elsewhere'), { recursive: true });
1362
+ writeFileSync(join(root, 'elsewhere', 'notes.md'), 'dead [x](../decisions.md#ad-099--gone)\n');
1363
+ symlinkSync(join(root, 'elsewhere'), join(root, 'docs/ai/pages'));
1364
+ const { code, errText } = run(['--check', '--today=2026-07-09'], root);
1365
+ assert.equal(code, 1);
1366
+ assert.match(errText, /docs\/ai\/pages/);
1367
+
1368
+ const root2 = makeRoot();
1369
+ seedMigrated(root2, { hotIds: ['005'], storeIds: ['001'] });
1370
+ symlinkSync(join(root2, 'gone-target'), join(root2, 'docs/ai/dangling'));
1371
+ const r2 = run(['--check', '--today=2026-07-09'], root2);
1372
+ assert.equal(r2.code, 1);
1373
+ assert.match(r2.errText, /dangling symlink/);
1374
+ });
1375
+
1376
+ it('migrate refuses pre-write when a monolith-form link targets an id outside the moved set', () => {
1377
+ const root = makeRoot();
1378
+ seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
1379
+ writeDoc(root, 'docs/ai/pages/spec.md', 'dead-after-apply [x](../history/decisions-archive.md#ad-777--nope)\n');
1380
+ const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
1381
+ assert.equal(code, 1);
1382
+ assert.match(errText, /docs\/ai\/pages\/spec\.md:1/);
1383
+ assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
1384
+ assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
1385
+ });
1386
+
1387
+ it('rotate refuses pre-write when a MOVING block links a retained id', () => {
1388
+ const root = makeRoot();
1389
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
1390
+ const moving = ['## AD-001 — Decision 001', '', '**Date:** 2026-01-01', '', 'see [x](decisions.md#ad-004--decision-004)'].join('\n');
1391
+ const blocks = [moving, adrBlock('002'), adrBlock('003'), adrBlock('004')];
1392
+ const probe = tierText(9999, '# ADRs', blocks);
1393
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
1394
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
1395
+ const { code, errText } = run(['--today=2026-08-07'], root);
1396
+ assert.equal(code, 1);
1397
+ assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
1398
+ assert.match(errText, /RETAINED/);
1399
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
1400
+ assert.deepEqual(adrFiles(root), [], 'no record written');
1401
+ });
1402
+
1403
+ it('rotate refuses pre-write when a MOVING block carries a record-form link', () => {
1404
+ const root = makeRoot();
1405
+ mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
1406
+ const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('001')]), 'x').entries, '2026-08-07')[0];
1407
+ writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
1408
+ const moving = ['## AD-005 — Decision 005', '', '**Date:** 2026-01-01', '', 'see [x](adr/AD-001-decision-001.md)'].join('\n');
1409
+ const blocks = [moving, adrBlock('006'), adrBlock('007'), adrBlock('008')];
1410
+ const probe = tierText(9999, '# ADRs', blocks);
1411
+ writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
1412
+ const before = readFileSync(join(root, HOT_REL), 'utf8');
1413
+ const { code, errText } = run(['--today=2026-08-07'], root);
1414
+ assert.equal(code, 1);
1415
+ assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
1416
+ assert.match(errText, /record link|relative ADR link/);
1417
+ assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
1418
+ assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md'], 'no new record written');
1419
+ });
1420
+
1421
+ it('a record-link match ends exactly at .md — a suffixed or child path is a different target', () => {
1422
+ const root = makeRoot();
1423
+ seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
1424
+ writeDoc(root, 'docs/ai/pages/spec.md', [
1425
+ 'not-a-record-link [a](../adr/AD-777-zzz.md.bak)',
1426
+ 'not-a-record-link [b](../adr/AD-001-decision-001.md/child)',
1427
+ '',
1428
+ ].join('\n'));
1429
+ assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'suffixed and child paths are different filenames, never record-form links');
1430
+ });
1431
+
1432
+ it('a negative sharing bytes with a valid link on ONE line never poisons the rewrite', () => {
1433
+ const root = makeRoot();
1434
+ seedOverCapHot(root, ['001', '002', '003', '004']);
1435
+ writeDoc(root, 'docs/ai/pages/spec.md', 'both [a](../decisions.md#ad-001--decision-001) and [n](../other-decisions.md#ad-001--decision-001)\n');
1436
+ const { code, errText } = run(['--today=2026-08-07'], root);
1437
+ assert.equal(code, 0, errText);
1438
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'),
1439
+ 'both [a](../adr/AD-001-decision-001.md#ad-001--decision-001) and [n](../other-decisions.md#ad-001--decision-001)\n');
1440
+ });
1441
+
1442
+ it('a drift in ANY planned file refuses the whole rewrite phase with nothing written', () => {
1443
+ const root = makeRoot();
1444
+ mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
1445
+ const before1 = 'one [AD-001](../decisions.md#ad-001--decision-001)\n';
1446
+ const before2 = 'two [AD-001](../decisions.md#ad-001--decision-001)\n';
1447
+ writeFileSync(join(root, 'docs/ai/pages/a.md'), before1);
1448
+ writeFileSync(join(root, 'docs/ai/pages/b.md'), before2);
1449
+ const planOf = (rel, before) => ({
1450
+ rel,
1451
+ frontmatter: '',
1452
+ frontLines: 0,
1453
+ beforeLines: before.split('\n'),
1454
+ afterLines: before.replace('decisions.md#ad-001--decision-001', 'adr/AD-001-decision-001.md#ad-001--decision-001').split('\n'),
1455
+ rewrites: [],
1456
+ });
1457
+ const plans = [planOf('docs/ai/pages/a.md', before1), planOf('docs/ai/pages/b.md', before2)];
1458
+ writeFileSync(join(root, 'docs/ai/pages/b.md'), 'moved meanwhile\n');
1459
+ assert.throws(() => writeInboundRewrites(root, plans), /stale snapshot/);
1460
+ assert.equal(readFileSync(join(root, 'docs/ai/pages/a.md'), 'utf8'), before1, 'the earlier planned file is byte-unchanged');
1461
+ });
1462
+ });