@sabaiway/agent-workflow-kit 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +17 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +10 -7
- package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +33 -11
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +842 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +87 -10
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
|
@@ -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
|
+
});
|
package/tools/cheap-agents.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
29
29
|
import { join, resolve, dirname } from 'node:path';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
-
import { shellQuoteArg } from './
|
|
31
|
+
import { shellQuoteArg } from './repo-lex.mjs';
|
|
32
32
|
|
|
33
33
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
34
|
|
|
@@ -48,6 +48,12 @@ export const CHEAP_AGENTS_STAMP = 'CHEAP_AGENTS_STAMP';
|
|
|
48
48
|
export const CHEAP_AGENTS_SYMLINK = 'CHEAP_AGENTS_SYMLINK';
|
|
49
49
|
export const CHEAP_AGENTS_BUNDLE = 'CHEAP_AGENTS_BUNDLE';
|
|
50
50
|
|
|
51
|
+
// The fallback-lens contract, formalized where it lives (flow-orchestration #15/#3, Phase 4.3):
|
|
52
|
+
// the internal-attestation evaluation consumes this sentence — a lens set claiming a configured
|
|
53
|
+
// backend's slot without a then-active down-mark REFUSES, quoting it (substitution is recorded,
|
|
54
|
+
// never silent).
|
|
55
|
+
export const FALLBACK_LENS_ADDITIONAL_ONLY = 'review-lens is an ADDITIONAL read-only review opinion, not a replacement for your configured review recipe.';
|
|
56
|
+
|
|
51
57
|
const USAGE = `usage: cheap-agents [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
52
58
|
|
|
53
59
|
Places the bundled READ-ONLY subagent definitions into the project's ${AGENTS_DIR}/. No vehicle
|
|
@@ -191,7 +197,7 @@ export const formatResult = (result) => {
|
|
|
191
197
|
}
|
|
192
198
|
lines.push(
|
|
193
199
|
'the vehicles are Claude Code subagents with READ-ONLY tools and NO shell — so a fan-out can never turn into a wave of approval prompts.',
|
|
194
|
-
|
|
200
|
+
`three ride the cheap lane (model: haiku, effort: low) for mechanical work; ${FALLBACK_LENS_ADDITIONAL_ONLY} Writing code and running gates stay on your main lane.`,
|
|
195
201
|
);
|
|
196
202
|
// A preview must print the EXACT command that applies it. The advisor renders this dry-run as an
|
|
197
203
|
// item's one-liner, and that flow's contract is "run the printed command, no improvisation" — a
|
package/tools/commands.mjs
CHANGED
|
@@ -196,6 +196,20 @@ const CATALOG = [
|
|
|
196
196
|
kind: WRITER,
|
|
197
197
|
oneLine: 'Set the per-project autonomy policy from plain language — which actions always ask (commit/push/publish/network) and how autonomously each activity runs; previews the change, then writes the policy when you confirm.',
|
|
198
198
|
},
|
|
199
|
+
{
|
|
200
|
+
key: 'set-flow',
|
|
201
|
+
invocation: invocationOf('set-flow'),
|
|
202
|
+
group: 'Orchestrate',
|
|
203
|
+
kind: WRITER,
|
|
204
|
+
oneLine: 'Arm the per-project review-flow settings from a preset plus explicit keys — previews the merged flow block, verifies the declared bookkeeping files and the kit version floor, then writes the config when you confirm.',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: 'flow-writer',
|
|
208
|
+
invocation: invocationOf('flow-writer'),
|
|
209
|
+
group: 'Orchestrate',
|
|
210
|
+
kind: WRITER,
|
|
211
|
+
oneLine: 'Record an explicit review-flow step (adoption/park/resume/complete of a plan, re-baseline, a rerun cause, a reviewer down-mark, a degrade justification, a checkpoint-approved override, a manifest-bound consult attestation) into the shared flow record store — flow-check refusals print the exact command to paste, and the store itself validates every append.',
|
|
212
|
+
},
|
|
199
213
|
{
|
|
200
214
|
key: 'review-state',
|
|
201
215
|
invocation: invocationOf('review-state'),
|
|
@@ -203,6 +217,13 @@ const CATALOG = [
|
|
|
203
217
|
kind: READ_ONLY,
|
|
204
218
|
oneLine: 'Check that every configured review backend has receipted the current uncommitted tree with a fresh grounded review; --check turns it into a gate exit code.',
|
|
205
219
|
},
|
|
220
|
+
{
|
|
221
|
+
key: 'receipt-deadline',
|
|
222
|
+
invocation: invocationOf('receipt-deadline'),
|
|
223
|
+
group: 'Orchestrate',
|
|
224
|
+
kind: READ_ONLY,
|
|
225
|
+
oneLine: 'Wait for ONE dispatched review to answer — a strictly-newer receipt line from that backend past the pre-dispatch watermark (or its nonce-named finding manifest); a shrunken or rewritten receipts file refuses loudly, and the timeout names the watermark.',
|
|
226
|
+
},
|
|
206
227
|
{
|
|
207
228
|
key: 'grounding',
|
|
208
229
|
invocation: invocationOf('grounding'),
|
|
@@ -271,8 +292,9 @@ export const kindOf = (key) => byKey.get(key)?.kind ?? null;
|
|
|
271
292
|
// (`upgrade`) OR the full slash form (`/agent-workflow-kit upgrade`); the first word is significant
|
|
272
293
|
// and trailing args are ignored. Precise semantics:
|
|
273
294
|
// undefined / null / '' / whitespace-only / the exact bare invocation → 'bootstrap'
|
|
274
|
-
// a known first token (upgrade/status/setup/backends/recipes/
|
|
275
|
-
// gates/set-recipe/
|
|
295
|
+
// a known first token (any non-bootstrap catalog key — upgrade/status/setup/backends/recipes/
|
|
296
|
+
// procedures/velocity/agents/hook/gates/set-recipe/set-flow/flow-writer/uninstall/
|
|
297
|
+
// migrate-adr-store/help/…) → that mode
|
|
276
298
|
// anything else (unrecognized / ambiguous) → 'help' (read-only — NEVER a writer/guarded mode)
|
|
277
299
|
export const routeInvocation = (token) => {
|
|
278
300
|
if (token == null) return BARE_INVOCATION_MODE;
|