agentic-workflow-manager 9.7.1 → 9.9.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 (107) hide show
  1. package/README.md +5 -1
  2. package/dist/src/commands/job/exec-wrapper.js +53 -3
  3. package/dist/src/commands/job/gate.js +18 -0
  4. package/dist/src/commands/job/index.js +61 -0
  5. package/dist/src/commands/model-policy/index.js +73 -0
  6. package/dist/src/commands/plan/index.js +182 -7
  7. package/dist/src/commands/sensors/bootstrap.js +36 -5
  8. package/dist/src/commands/sensors/compatibility/contract.js +19 -4
  9. package/dist/src/commands/sensors/compatibility/discovery.js +23 -3
  10. package/dist/src/commands/sensors/compatibility/live.js +18 -1
  11. package/dist/src/commands/sensors/compatibility/probe.js +8 -2
  12. package/dist/src/commands/sensors/compatibility/resolve.js +5 -2
  13. package/dist/src/commands/sensors/coverage/evidence.js +19 -10
  14. package/dist/src/commands/sensors/coverage/index.js +11 -2
  15. package/dist/src/commands/sensors/coverage/resolve.js +10 -0
  16. package/dist/src/commands/sensors/exec.js +16 -1
  17. package/dist/src/commands/sensors/migrate.js +62 -2
  18. package/dist/src/commands/sensors/prepare.js +2 -0
  19. package/dist/src/commands/sensors/result.js +2 -0
  20. package/dist/src/commands/sensors/run.js +64 -1
  21. package/dist/src/commands/sensors/status.js +6 -4
  22. package/dist/src/commands/watch/apply.js +32 -0
  23. package/dist/src/commands/watch/archive-unused.js +138 -0
  24. package/dist/src/commands/watch/generations.js +32 -9
  25. package/dist/src/commands/watch/index.js +108 -2
  26. package/dist/src/commands/watch/init.js +149 -18
  27. package/dist/src/commands/watch/supervisor.js +167 -13
  28. package/dist/src/commands/watch/teardown-driver.js +13 -13
  29. package/dist/src/commands/watch/track-state.js +59 -0
  30. package/dist/src/commands/watch/tracks.js +38 -102
  31. package/dist/src/core/admission/index.js +231 -0
  32. package/dist/src/core/admission/registry-contracts.js +290 -0
  33. package/dist/src/core/journal/fingerprint.js +35 -1
  34. package/dist/src/core/journal/paths.js +11 -0
  35. package/dist/src/core/journal/requests.js +1 -1
  36. package/dist/src/core/journal/store.js +146 -4
  37. package/dist/src/core/journal/types.js +57 -2
  38. package/dist/src/core/migration/historical-provenance.js +18 -0
  39. package/dist/src/core/migration/index.js +322 -0
  40. package/dist/src/core/model-policy/canonical.js +18 -0
  41. package/dist/src/core/model-policy/capabilities.js +197 -0
  42. package/dist/src/core/model-policy/capability-types.js +2 -0
  43. package/dist/src/core/model-policy/journal.js +124 -0
  44. package/dist/src/core/model-policy/paths.js +12 -0
  45. package/dist/src/core/model-policy/resolve.js +116 -0
  46. package/dist/src/core/model-policy/store.js +182 -0
  47. package/dist/src/core/model-policy/types.js +2 -0
  48. package/dist/src/core/model-policy/validate.js +112 -0
  49. package/dist/src/core/plan/identity.js +77 -0
  50. package/dist/src/core/plan/validate.js +191 -78
  51. package/dist/src/index.js +2 -0
  52. package/dist/tests/commands/job/exec-wrapper.test.js +16 -0
  53. package/dist/tests/commands/job/gate-reconcile.test.js +40 -10
  54. package/dist/tests/commands/job/gate.test.js +9 -0
  55. package/dist/tests/commands/job/routing-report-cli.test.js +46 -0
  56. package/dist/tests/commands/job/routing.test.js +11 -0
  57. package/dist/tests/commands/model-policy/index.test.js +57 -0
  58. package/dist/tests/commands/pin.test.js +23 -23
  59. package/dist/tests/commands/plan/index.test.js +682 -14
  60. package/dist/tests/commands/sensors/bootstrap.test.js +57 -4
  61. package/dist/tests/commands/sensors/compatibility/contract.test.js +21 -0
  62. package/dist/tests/commands/sensors/compatibility/discovery.test.js +19 -0
  63. package/dist/tests/commands/sensors/compatibility/probe.test.js +14 -1
  64. package/dist/tests/commands/sensors/compatibility/resolve.test.js +17 -0
  65. package/dist/tests/commands/sensors/coverage/evidence.test.js +51 -27
  66. package/dist/tests/commands/sensors/coverage/resolve.test.js +51 -0
  67. package/dist/tests/commands/sensors/exec.test.js +112 -0
  68. package/dist/tests/commands/sensors/run-changed.test.js +11 -0
  69. package/dist/tests/commands/sensors/run.test.js +15 -2
  70. package/dist/tests/commands/sensors/status.test.js +3 -3
  71. package/dist/tests/commands/watch/apply.test.js +12 -0
  72. package/dist/tests/commands/watch/e2e-crash.test.js +126 -11
  73. package/dist/tests/commands/watch/generations.test.js +113 -4
  74. package/dist/tests/commands/watch/plan-lifecycle.test.js +263 -0
  75. package/dist/tests/commands/watch/supervisor-loop.test.js +247 -49
  76. package/dist/tests/commands/watch/track-finalize.test.js +20 -3
  77. package/dist/tests/commands/watch/track-freeze.test.js +43 -30
  78. package/dist/tests/commands/watch/track-state-dependency.test.js +15 -0
  79. package/dist/tests/commands/watch/watch-init.test.js +244 -0
  80. package/dist/tests/core/admission/index.test.js +140 -0
  81. package/dist/tests/core/journal/fingerprint.test.js +22 -0
  82. package/dist/tests/core/journal/store.test.js +53 -0
  83. package/dist/tests/core/journal/types.test.js +7 -0
  84. package/dist/tests/core/migration/facts.test.js +289 -0
  85. package/dist/tests/core/migration/historical-provenance.test.js +25 -0
  86. package/dist/tests/core/model-policy/capabilities.test.js +365 -0
  87. package/dist/tests/core/model-policy/journal.test.js +46 -0
  88. package/dist/tests/core/model-policy/policy.test.js +47 -0
  89. package/dist/tests/core/model-policy/resolve.test.js +111 -0
  90. package/dist/tests/core/model-policy/store.test.js +177 -0
  91. package/dist/tests/core/plan/validate.test.js +360 -8
  92. package/dist/tests/core/secure-fs/native-bridge.test.js +81 -4
  93. package/dist/tests/integration/pack-e2e.test.js +4 -1
  94. package/dist/tests/integration/preflight-json-pipe.e2e.test.js +7 -3
  95. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +37 -0
  96. package/dist/tests/integration/sensor-structured-run.e2e.test.js +12 -10
  97. package/dist/tests/providers/injection-config.test.js +14 -6
  98. package/dist/tests/structural/directory-fsync-is-single-source.test.js +97 -0
  99. package/dist/tests/structural/jest-environment-is-isolated.test.js +29 -24
  100. package/dist/tests/structural/native-release-artifacts.test.js +30 -0
  101. package/package.json +4 -3
  102. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  103. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  104. package/prebuilds/linux-arm64/secure_fs.node +0 -0
  105. package/prebuilds/linux-x64/secure_fs.node +0 -0
  106. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  107. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const atomic_file_1 = require("../../../src/core/atomic-file");
10
+ const canonical_1 = require("../../../src/core/model-policy/canonical");
11
+ const store_1 = require("../../../src/core/model-policy/store");
12
+ const paths_1 = require("../../../src/core/model-policy/paths");
13
+ const policy = () => ({ schema: 'model-policy/v1', mappings: [{ target: 'codex', runtimeKind: 'native', profiles: {
14
+ mechanical: { selector: { kind: 'model', id: 'gpt-5.6-luna' }, effort: { kind: 'explicit', value: 'medium' } },
15
+ integration: { selector: { kind: 'model', id: 'gpt-5.6-terra' }, effort: { kind: 'explicit', value: 'medium' } },
16
+ judgment: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'medium' } },
17
+ }, fullCapability: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'high' } }, degradation: { allowMissingModelOverride: false, allowMissingEffortOverride: false, allowMissingObservedIdentity: false } }], implementationBudget: { maxAttempts: 3, escalation: ['mechanical', 'integration', 'judgment'], judgmentEfforts: ['medium', 'high'] } });
18
+ function createIdentityFencedBoundary() {
19
+ const observations = new WeakMap();
20
+ return {
21
+ withProjectLease: (_root, operation) => operation(),
22
+ readRegularFile: (file) => {
23
+ const identity = Object.freeze({});
24
+ const bytes = fs_1.default.readFileSync(file);
25
+ observations.set(identity, { file, bytes });
26
+ return { bytes, identity };
27
+ },
28
+ writeProjectTransaction: (root, destination, content, options) => {
29
+ const file = path_1.default.join(root, destination);
30
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
31
+ if (options.mode === 'create' && fs_1.default.existsSync(file))
32
+ throw new Error('project destination already exists');
33
+ if (options.mode === 'replace') {
34
+ const observed = observations.get(options.expectedIdentity);
35
+ if (!observed || observed.file !== file || !observed.bytes.equals(options.expected) || !fs_1.default.readFileSync(file).equals(options.expected))
36
+ throw new Error('original changed before fenced replacement');
37
+ }
38
+ const temporary = `${file}.test-tmp`;
39
+ const fd = fs_1.default.openSync(temporary, 'wx', 0o600);
40
+ try {
41
+ fs_1.default.writeFileSync(fd, content);
42
+ fs_1.default.fsyncSync(fd);
43
+ fs_1.default.closeSync(fd);
44
+ fs_1.default.renameSync(temporary, file);
45
+ // La durabilidad de la ENTRADA publicada se pide por el unico helper
46
+ // que define esa politica (atomic-file.fsyncDirSync): fail-closed en
47
+ // POSIX y degradado SOLO ante el gap conocido de win32 (EPERM al
48
+ // fsync-ear un fd de directorio, capacidad que el SO no expone).
49
+ // Reimplementarla aca la hacia divergir y rompia la matriz Windows.
50
+ (0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(file));
51
+ }
52
+ finally {
53
+ try {
54
+ fs_1.default.rmSync(temporary, { force: true });
55
+ }
56
+ catch { /* preserve transaction result */ }
57
+ }
58
+ },
59
+ };
60
+ }
61
+ const identityFencedBoundary = createIdentityFencedBoundary();
62
+ const approve = (input) => (0, store_1.approvePolicyWithBoundary)(input, identityFencedBoundary);
63
+ describe('approved policy store', () => {
64
+ let root;
65
+ let oldHome;
66
+ let oldAwmHome;
67
+ beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-policy-')); oldHome = process.env.HOME; oldAwmHome = process.env.AWM_HOME; process.env.AWM_HOME = path_1.default.join(root, 'operator'); });
68
+ afterEach(() => { jest.restoreAllMocks(); delete process.env.POLICY_TEST_FAIL_WRITE; delete process.env.POLICY_TEST_MUTATE_DURING_REPLACE; if (oldHome === undefined)
69
+ delete process.env.HOME;
70
+ else
71
+ process.env.HOME = oldHome; if (oldAwmHome === undefined)
72
+ delete process.env.AWM_HOME;
73
+ else
74
+ process.env.AWM_HOME = oldAwmHome; fs_1.default.rmSync(root, { recursive: true, force: true }); });
75
+ const candidate = () => { const file = path_1.default.join(root, 'candidate.json'); fs_1.default.writeFileSync(file, JSON.stringify(policy())); return file; };
76
+ it('publishes only an expected digest and reads it without creating state', () => {
77
+ const file = candidate();
78
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
79
+ expect((0, store_1.readEffectivePolicy)(root)).toEqual({ state: 'absent' });
80
+ const approved = approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
81
+ expect(approved.contentDigest).toBe(digest);
82
+ expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'approved', provenance: 'user', policy: { contentDigest: digest } });
83
+ });
84
+ it('rejects a mismatched digest before writing', () => {
85
+ const file = candidate();
86
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: '0'.repeat(64) })).toThrow(/digest/i);
87
+ expect(fs_1.default.existsSync((0, paths_1.userPolicyPath)())).toBe(false);
88
+ });
89
+ it('allows exactly one initial writer and exact CAS replacement', () => {
90
+ const file = candidate();
91
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
92
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
93
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/replace|existing/i);
94
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: 'f'.repeat(64) })).toThrow(/predecessor|replace/i);
95
+ expect(approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }).lineage.previousDigest).toBe(digest);
96
+ });
97
+ it('rejects an unexpected opaque identity token before replacing bytes', () => {
98
+ const target = path_1.default.join(root, 'identity.json');
99
+ fs_1.default.writeFileSync(target, 'observed');
100
+ const observed = identityFencedBoundary.readRegularFile(target, 1024);
101
+ expect(() => identityFencedBoundary.writeProjectTransaction(root, 'identity.json', Buffer.from('replacement'), {
102
+ mode: 'replace', createParents: false, expected: observed.bytes, expectedIdentity: Object.freeze({}),
103
+ })).toThrow(/original changed/i);
104
+ expect(fs_1.default.readFileSync(target, 'utf8')).toBe('observed');
105
+ });
106
+ it('resolves a relative candidate through cwd instead of process cwd', () => {
107
+ const cwd = path_1.default.join(root, 'project');
108
+ fs_1.default.mkdirSync(cwd);
109
+ const file = path_1.default.join(cwd, 'candidate.json');
110
+ fs_1.default.writeFileSync(file, JSON.stringify(policy()));
111
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
112
+ expect(approve({ file: 'candidate.json', scope: 'project', cwd, expectedDigest: digest }).contentDigest).toBe(digest);
113
+ });
114
+ it('accepts exactly one of two competing fenced replacements', () => {
115
+ const file = candidate();
116
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
117
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
118
+ const input = { file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest };
119
+ let entered = false;
120
+ let accepted = 0;
121
+ const boundary = {
122
+ withProjectLease: (_root, operation) => operation(),
123
+ readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
124
+ writeProjectTransaction: (base, destination, bytes, options) => {
125
+ const target = path_1.default.join(base, destination);
126
+ if (options.mode === 'replace' && !entered) {
127
+ entered = true;
128
+ // Model a non-cooperating external process: the native
129
+ // identity fence, not this advisory lock, must reject us.
130
+ fs_1.default.rmSync(`${target}.lock`);
131
+ (0, store_1.approvePolicyWithBoundary)(input, boundary);
132
+ fs_1.default.writeFileSync(`${target}.lock`, 'outer lock placeholder');
133
+ }
134
+ if (options.mode === 'replace' && (!options.expected || !fs_1.default.readFileSync(target).equals(options.expected)))
135
+ throw new Error('original changed before fenced replacement');
136
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
137
+ fs_1.default.writeFileSync(target, bytes);
138
+ accepted++;
139
+ },
140
+ };
141
+ expect(() => (0, store_1.approvePolicyWithBoundary)(input, boundary)).toThrow(/changed|replacement/i);
142
+ expect(accepted).toBe(1);
143
+ });
144
+ it('returns a failed publication and preserves predecessor bytes on injected transaction failure', () => {
145
+ const file = candidate();
146
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
147
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
148
+ const before = fs_1.default.readFileSync((0, paths_1.userPolicyPath)());
149
+ const boundary = {
150
+ withProjectLease: (_root, operation) => operation(),
151
+ readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
152
+ writeProjectTransaction: () => { throw new Error('injected durable publication failure'); },
153
+ };
154
+ expect(() => (0, store_1.approvePolicyWithBoundary)({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }, boundary)).toThrow(/publication failure/);
155
+ expect(fs_1.default.readFileSync((0, paths_1.userPolicyPath)()).equals(before)).toBe(true);
156
+ });
157
+ it('blocks an invalid project policy instead of falling back to a valid user policy', () => {
158
+ const file = candidate();
159
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
160
+ approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
161
+ fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
162
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'model-policy.json'), '{"content":');
163
+ expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'invalid', provenance: 'project' });
164
+ });
165
+ it('rejects duplicate JSON keys and symlinked parent or destination without touching the target', () => {
166
+ const duplicate = path_1.default.join(root, 'duplicate.json');
167
+ fs_1.default.writeFileSync(duplicate, '{"schema":"model-policy/v1","schema":"model-policy/v1"}');
168
+ expect(() => approve({ file: duplicate, scope: 'user', cwd: root, expectedDigest: 'a'.repeat(64) })).toThrow(/duplicate/i);
169
+ const file = candidate();
170
+ const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
171
+ fs_1.default.mkdirSync(process.env.AWM_HOME);
172
+ fs_1.default.symlinkSync(root, path_1.default.join(process.env.AWM_HOME, 'link'));
173
+ const target = (0, paths_1.userPolicyPath)();
174
+ fs_1.default.symlinkSync(path_1.default.join(root, 'outside'), target);
175
+ expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/symlink/i);
176
+ });
177
+ });
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const child_process_1 = require("child_process");
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const crypto_1 = __importDefault(require("crypto"));
10
11
  const json_1 = require("../../../src/core/plan/json");
11
12
  const validate_1 = require("../../../src/core/plan/validate");
12
13
  jest.mock('child_process', () => ({ exec: jest.fn(), execFileSync: jest.fn(), execSync: jest.fn(), spawn: jest.fn(), spawnSync: jest.fn() }));
@@ -21,6 +22,18 @@ function expectApprovedPlanValid(report) {
21
22
  }
22
23
  expect(report.schema).toBe('compact-slices/v1');
23
24
  }
25
+ function assertFrozenObjectGraph(value, location = 'report', visited = new WeakSet()) {
26
+ if (value === null || typeof value !== 'object' || visited.has(value))
27
+ return;
28
+ visited.add(value);
29
+ if (!Object.isFrozen(value))
30
+ throw new Error(`${location} is mutable`);
31
+ for (const key of Reflect.ownKeys(value)) {
32
+ const property = Object.getOwnPropertyDescriptor(value, key);
33
+ if (property && 'value' in property)
34
+ assertFrozenObjectGraph(property.value, `${location}.${String(key)}`, visited);
35
+ }
36
+ }
24
37
  function fixture(root, mutate) {
25
38
  fs_1.default.mkdirSync(path_1.default.join(root, 'docs'), { recursive: true });
26
39
  fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'source.md'), '## Source\nKnown fact\n');
@@ -37,21 +50,307 @@ function fixture(root, mutate) {
37
50
  fs_1.default.writeFileSync(plan, markdown);
38
51
  return plan;
39
52
  }
53
+ function v2Fixture(root, profile = 'mechanical') {
54
+ const plan = fixture(root, manifest => {
55
+ manifest.schema = 'compact-slices/v2';
56
+ const slices = manifest.slices;
57
+ slices[0].implementerProfile = profile;
58
+ });
59
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(START, '<!-- AWM:COMPACT-SLICES:START v2 -->').replace(END, '<!-- AWM:COMPACT-SLICES:END v2 -->'));
60
+ return plan;
61
+ }
40
62
  describe('validatePlanFile', () => {
63
+ it('validates an authenticated snapshot without reopening a swapped plan parent', () => {
64
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-'));
65
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-outside-'));
66
+ try {
67
+ fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
68
+ const snapshot = '### Task 1: anchored\n';
69
+ fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plan.md'), snapshot);
70
+ fs_1.default.rmSync(path_1.default.join(root, 'docs'), { recursive: true });
71
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'plan.md'), '<!-- attacker -->');
72
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs'));
73
+ expect((0, validate_1.validatePlanSnapshot)('docs/plan.md', root, snapshot)).toMatchObject({ state: 'migration-required' });
74
+ }
75
+ finally {
76
+ fs_1.default.rmSync(root, { recursive: true, force: true });
77
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
78
+ }
79
+ });
41
80
  let root;
42
81
  beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-')); });
43
82
  afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
83
+ test.each([
84
+ ['valid.md', 'valid'],
85
+ ['unmarked.md', 'migration-required'],
86
+ ['future-schema.md', 'unsupported'],
87
+ ['missing-section.md', 'invalid'],
88
+ ])('classifies the versioned v1 corpus member %s as %s without transforms', (file, state) => {
89
+ const corpus = path_1.default.resolve(__dirname, 'fixtures', 'compact-slices-v1');
90
+ expect((0, validate_1.validatePlanFile)(file, corpus).state).toBe(state);
91
+ });
44
92
  test('accepts a valid compact plan without modifying it', () => {
45
93
  const plan = fixture(root);
46
94
  const before = fs_1.default.readFileSync(plan, 'utf8');
47
95
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
48
96
  expect(fs_1.default.readFileSync(plan, 'utf8')).toBe(before);
49
97
  });
98
+ test.each(['mechanical', 'integration', 'judgment'])('accepts v2 with the exact %s implementer profile', profile => {
99
+ const report = (0, validate_1.validatePlanFile)(v2Fixture(root, profile), root);
100
+ expect(report).toMatchObject({ state: 'valid', schema: 'compact-slices/v2', manifest: { slices: [expect.objectContaining({ implementerProfile: profile })] } });
101
+ });
102
+ test('rejects v1 profile without weakening exact keys', () => {
103
+ expect((0, validate_1.validatePlanFile)(fixture(root, manifest => { (manifest.slices[0]).implementerProfile = 'mechanical'; }), root)).toMatchObject({ state: 'invalid' });
104
+ });
105
+ test.each([
106
+ ['missing profile', (text) => text.replace(/,\n\s*"implementerProfile": "mechanical"/, '')],
107
+ ['concrete model', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "mechanical",\n "model": "vendor-model"')],
108
+ ['unknown profile', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "cheap"')],
109
+ ])('rejects v2 %s without weakening exact keys', (_name, mutate) => {
110
+ const plan = v2Fixture(root);
111
+ fs_1.default.writeFileSync(plan, mutate(fs_1.default.readFileSync(plan, 'utf8')));
112
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid' });
113
+ });
114
+ test('preserves v2 profile bytes in the execution identity', () => {
115
+ const mechanical = fs_1.default.readFileSync(v2Fixture(root, 'mechanical'), 'utf8');
116
+ const integration = mechanical.replace('"implementerProfile": "mechanical"', '"implementerProfile": "integration"');
117
+ const integrationPath = path_1.default.join(root, 'integration.md');
118
+ fs_1.default.writeFileSync(integrationPath, integration);
119
+ const left = (0, validate_1.validatePlanFile)(path_1.default.join(root, 'plan.md'), root);
120
+ const right = (0, validate_1.validatePlanFile)(integrationPath, root);
121
+ expect(left).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
122
+ expect(right).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
123
+ if (left.state !== 'valid' || right.state !== 'valid')
124
+ throw new Error('v2 fixture must validate');
125
+ expect(left.executionDigest).not.toBe(right.executionDigest);
126
+ });
127
+ test('makes an approved valid report and its nested manifest immutable after verification', () => {
128
+ const report = (0, validate_1.validatePlanFile)(fixture(root), root);
129
+ expectApprovedPlanValid(report);
130
+ if (report.state !== 'valid')
131
+ throw new Error('expected an approved compact plan');
132
+ const slice = report.manifest.slices[0];
133
+ assertFrozenObjectGraph(report);
134
+ expect(Reflect.set(report.manifest.requirements, '0', 'R4-FORGED')).toBe(false);
135
+ expect(Reflect.set(report.manifest.sources[0], 'fact', 'forged fact')).toBe(false);
136
+ expect(Reflect.set(report.manifest.commands[0].args, '0', 'forged-arg')).toBe(false);
137
+ expect(Reflect.set(slice.fallback, '0', 'forged fallback')).toBe(false);
138
+ expect(Reflect.set(slice.reviewEvidence, '1', 'bogus')).toBe(false);
139
+ expect(Reflect.set(slice, 'risk', 'unbounded')).toBe(false);
140
+ expect(Reflect.set(report.manifest.closureCommands, '0', 'CMD-MISSING')).toBe(false);
141
+ expect(report.manifest.requirements).toEqual(['R4-VAL-2']);
142
+ expect(report.manifest.sources[0].fact).toBe('Known fact');
143
+ expect(report.manifest.commands[0].args).toEqual(['test']);
144
+ expect(slice.fallback).toEqual(['Use a reviewed fallback']);
145
+ expect(slice.reviewEvidence).toEqual(['specification', 'code-quality']);
146
+ expect(slice.risk).toBe('bounded');
147
+ expect(report.manifest.closureCommands).toEqual(['CMD-ONE']);
148
+ });
149
+ test('detects a selectively thawed command-args node even when every ancestor is frozen', () => {
150
+ const report = (0, validate_1.validatePlanFile)(fixture(root), root);
151
+ expectApprovedPlanValid(report);
152
+ const thawed = JSON.parse(JSON.stringify(report));
153
+ const mutableArgs = thawed.manifest.commands[0].args;
154
+ const freezeExceptArgs = (value) => {
155
+ if (value === null || typeof value !== 'object' || value === mutableArgs)
156
+ return;
157
+ for (const child of Object.values(value))
158
+ freezeExceptArgs(child);
159
+ Object.freeze(value);
160
+ };
161
+ freezeExceptArgs(thawed);
162
+ expect(Object.isFrozen(thawed.manifest.commands[0])).toBe(true);
163
+ expect(Object.isFrozen(mutableArgs)).toBe(false);
164
+ expect(Reflect.set(mutableArgs, '0', 'tampered')).toBe(true);
165
+ expect(() => assertFrozenObjectGraph(thawed)).toThrow('report.manifest.commands.0.args is mutable');
166
+ });
167
+ test('scans bounded markerless brace runs without quadratic retries', () => {
168
+ const plan = path_1.default.join(root, 'braces.md');
169
+ fs_1.default.writeFileSync(plan, '{'.repeat(32_000));
170
+ const started = Date.now();
171
+ const report = (0, validate_1.validatePlanFile)(plan, root);
172
+ expect(report).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
173
+ expect(Date.now() - started).toBeLessThan(500);
174
+ });
175
+ test('retains markerless unsupported classification for nested JSON in an unclosed outer object', () => {
176
+ const plan = path_1.default.join(root, 'nested-future.md');
177
+ fs_1.default.writeFileSync(plan, '# Notes\n{"outer":\n{"\\u0073chema":"compact\\u002dslices\\/v2"}\n');
178
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
179
+ });
180
+ test('uses exact bigint filesystem identities for legitimate Windows-sized inode values', () => {
181
+ const plan = fixture(root);
182
+ const nativeLstat = fs_1.default.lstatSync;
183
+ const nativeFstat = fs_1.default.fstatSync;
184
+ const largeIdentity = (observed) => Object.assign(Object.create(observed), {
185
+ ino: typeof observed.ino === 'bigint' ? 9007199254740993n : 9007199254740992,
186
+ });
187
+ const lstat = jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeLstat, fs_1.default, args))));
188
+ const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeFstat, fs_1.default, args))));
189
+ try {
190
+ expectApprovedPlanValid((0, validate_1.validatePlanFile)(plan, root));
191
+ }
192
+ finally {
193
+ lstat.mockRestore();
194
+ fstat.mockRestore();
195
+ }
196
+ });
197
+ test('retains the manifest byte limit beneath the bounded plan-file limit', () => {
198
+ const plan = fixture(root);
199
+ const text = fs_1.default.readFileSync(plan, 'utf8');
200
+ fs_1.default.writeFileSync(plan, text.replace(END, `${' '.repeat(256 * 1024)}\n${END}`));
201
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_LIMIT' })] });
202
+ });
203
+ test.each([
204
+ ['plan', 'PLAN_LIMIT'],
205
+ ['source', 'PLAN_SOURCE_LIMIT'],
206
+ ])('bounds %s bytes when the inspected file grows during descriptor read', (target, code) => {
207
+ const plan = fixture(root);
208
+ const targetPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
209
+ const targetInode = fs_1.default.statSync(targetPath, { bigint: true }).ino;
210
+ const nativeFstat = fs_1.default.fstatSync;
211
+ let grew = false;
212
+ const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation((descriptor) => {
213
+ const observed = nativeFstat(descriptor, { bigint: true });
214
+ if (!grew && observed.ino === targetInode) {
215
+ fs_1.default.appendFileSync(targetPath, Buffer.alloc(1024 * 1024));
216
+ grew = true;
217
+ }
218
+ return observed;
219
+ });
220
+ try {
221
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
222
+ expect(grew).toBe(true);
223
+ }
224
+ finally {
225
+ fstat.mockRestore();
226
+ }
227
+ });
228
+ test.each([
229
+ ['plan', 'final', 'PLAN_PATH_UNSAFE'],
230
+ ['plan', 'ancestor', 'PLAN_PATH_UNSAFE'],
231
+ ['source', 'final', 'PLAN_SOURCE_UNSAFE'],
232
+ ['source', 'ancestor', 'PLAN_SOURCE_UNSAFE'],
233
+ ])('rejects a %s %s symlink swap after inspection without reading outside', (target, swap, code) => {
234
+ let plan = fixture(root);
235
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-race-outside-'));
236
+ const targetDirectory = target === 'plan' ? path_1.default.join(root, 'plans') : path_1.default.join(root, 'docs');
237
+ if (target === 'plan') {
238
+ fs_1.default.mkdirSync(targetDirectory);
239
+ const nestedPlan = path_1.default.join(targetDirectory, 'plan.md');
240
+ fs_1.default.renameSync(plan, nestedPlan);
241
+ plan = nestedPlan;
242
+ }
243
+ const inspectedPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
244
+ const outsideFile = path_1.default.join(outside, path_1.default.basename(inspectedPath));
245
+ fs_1.default.copyFileSync(inspectedPath, outsideFile);
246
+ const outsideIdentity = fs_1.default.statSync(outsideFile);
247
+ const nativeRealpath = fs_1.default.realpathSync;
248
+ let swapped = false;
249
+ const realpath = jest.spyOn(fs_1.default, 'realpathSync').mockImplementation((file) => {
250
+ const resolved = nativeRealpath(file);
251
+ if (!swapped && file === inspectedPath) {
252
+ swapped = true;
253
+ if (swap === 'final') {
254
+ fs_1.default.renameSync(inspectedPath, `${inspectedPath}-original`);
255
+ fs_1.default.symlinkSync(outsideFile, inspectedPath, 'file');
256
+ }
257
+ else {
258
+ fs_1.default.renameSync(targetDirectory, `${targetDirectory}-original`);
259
+ fs_1.default.symlinkSync(outside, targetDirectory, 'dir');
260
+ }
261
+ }
262
+ return resolved;
263
+ });
264
+ const reads = jest.spyOn(fs_1.default, 'readFileSync');
265
+ const nativeReadSync = fs_1.default.readSync;
266
+ let outsideDescriptorReads = 0;
267
+ const descriptorReads = jest.spyOn(fs_1.default, 'readSync').mockImplementation((...args) => {
268
+ const opened = fs_1.default.fstatSync(args[0]);
269
+ if (opened.dev === outsideIdentity.dev && opened.ino === outsideIdentity.ino)
270
+ outsideDescriptorReads += 1;
271
+ return Reflect.apply(nativeReadSync, fs_1.default, args);
272
+ });
273
+ try {
274
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
275
+ expect(swapped).toBe(true);
276
+ expect(reads.mock.calls.some(([file]) => file === inspectedPath)).toBe(false);
277
+ expect(outsideDescriptorReads).toBe(0);
278
+ }
279
+ finally {
280
+ descriptorReads.mockRestore();
281
+ reads.mockRestore();
282
+ realpath.mockRestore();
283
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
284
+ }
285
+ });
50
286
  test('accepts a valid compact plan converted to CRLF', () => {
51
287
  const plan = fixture(root);
52
288
  fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(/\n/g, '\r\n'));
53
289
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
54
290
  });
291
+ test('returns the SHA-256 identity of the complete normalized valid plan', () => {
292
+ const plan = fixture(root);
293
+ const lf = fs_1.default.readFileSync(plan, 'utf8');
294
+ const expected = crypto_1.default.createHash('sha256').update(lf).digest('hex');
295
+ const lfReport = (0, validate_1.validatePlanFile)(plan, root);
296
+ fs_1.default.writeFileSync(plan, lf.replace(/\n/g, '\r\n'));
297
+ const crlfReport = (0, validate_1.validatePlanFile)(plan, root);
298
+ expect(lfReport).toMatchObject({ state: 'valid', planDigest: expected });
299
+ expect(crlfReport).toMatchObject({ state: 'valid', planDigest: expected });
300
+ });
301
+ test.each([
302
+ ['manifest requirement', (text) => text.replace(/R4-VAL-2/g, 'R4-VAL-3')],
303
+ ['source fact', (text) => text.replace('Known fact', 'Changed fact')],
304
+ ['command argv', (text) => text.replace('"test"', '"build"')],
305
+ ['slice prose', (text) => text.replace('One evidence item.', 'Changed evidence item.')],
306
+ ['execution-mode prose', (text) => `${text}\nModo de ejecución: desatendido\n`],
307
+ ])('changes the plan identity when %s changes', (_name, edit) => {
308
+ const plan = fixture(root);
309
+ const first = (0, validate_1.validatePlanFile)(plan, root);
310
+ expect(first.state).toBe('valid');
311
+ if (first.state !== 'valid')
312
+ throw new Error('expected valid fixture');
313
+ fs_1.default.writeFileSync(plan, edit(fs_1.default.readFileSync(plan, 'utf8')));
314
+ const amended = (0, validate_1.validatePlanFile)(plan, root);
315
+ expect(amended.state).toBe('valid');
316
+ if (amended.state !== 'valid')
317
+ throw new Error('expected valid amended fixture');
318
+ expect(amended.planDigest).not.toBe(first.planDigest);
319
+ });
320
+ test('separates exact progress updates from the full normalized identity', () => {
321
+ const plan = fixture(root);
322
+ fs_1.default.appendFileSync(plan, '\n- [ ] Reviewed task.\n');
323
+ const before = (0, validate_1.validatePlanFile)(plan, root);
324
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Reviewed task.', '- [x] Reviewed task.')
325
+ .replace('#### Evidence', '<!-- awm-qa-complete: 2026-09-16 -->\n<!-- awm-docs-complete: 2026-09-16 -->\n<!-- awm-retro-complete: Release R1 -->\n#### Evidence'));
326
+ const after = (0, validate_1.validatePlanFile)(plan, root);
327
+ expect(before.executionDigest).toEqual(expect.any(String));
328
+ expect(after.planDigest).not.toBe(before.planDigest);
329
+ expect(after.executionDigest).toBe(before.executionDigest);
330
+ });
331
+ test.each([
332
+ ['task prose', '- [x] Changed task.\n'],
333
+ ['inline marker', 'prose <!-- awm-qa-complete: 2026-09-16 -->\n'],
334
+ ['unknown marker', '<!-- awm-qa-complete: arbitrary requirements -->\n'],
335
+ ['fenced task', '```md\n- [x] Reviewed task.\n```\n'],
336
+ ['fenced marker', '~~~~md\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n'],
337
+ ])('does not canonicalize %s away from execution identity', (_label, addition) => {
338
+ const plan = fixture(root);
339
+ const before = (0, validate_1.validatePlanFile)(plan, root);
340
+ fs_1.default.appendFileSync(plan, addition);
341
+ const after = (0, validate_1.validatePlanFile)(plan, root);
342
+ expect(before.executionDigest).toEqual(expect.any(String));
343
+ expect(after.executionDigest).not.toBe(before.executionDigest);
344
+ });
345
+ test('a false fence closer cannot make code checkboxes and markers into lifecycle progress', () => {
346
+ const plan = fixture(root);
347
+ fs_1.default.appendFileSync(plan, '\n~~~~md\n~~~~not-a-closer\n- [ ] Code task.\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n');
348
+ const before = (0, validate_1.validatePlanFile)(plan, root);
349
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Code task.', '- [x] Code task.')
350
+ .replace('<!-- awm-qa-complete: 2026-09-16 -->', '<!-- awm-docs-complete: 2026-09-16 -->'));
351
+ const after = (0, validate_1.validatePlanFile)(plan, root);
352
+ expect(after.executionDigest).not.toBe(before.executionDigest);
353
+ });
55
354
  test('accepts a contained plan when path.relative returns Windows separators', () => {
56
355
  const plan = fixture(root);
57
356
  const nestedPlan = path_1.default.join(root, 'docs', 'plan.md');
@@ -73,6 +372,59 @@ describe('validatePlanFile', () => {
73
372
  const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
74
373
  expectApprovedPlanValid((0, validate_1.validatePlanFile)('docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md', repositoryRoot));
75
374
  });
375
+ test('preserves every previously valid tracked compact-v1 plan', () => {
376
+ const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
377
+ const tracked = jest.requireActual('child_process')
378
+ .execFileSync('git', ['ls-files', '--', 'docs/plans/*.md'], { cwd: repositoryRoot, encoding: 'utf8' })
379
+ .trim().split('\n').filter(Boolean);
380
+ const marked = tracked.filter((file) => fs_1.default.readFileSync(path_1.default.join(repositoryRoot, file), 'utf8').includes(START));
381
+ const previouslyValid = [
382
+ 'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md',
383
+ 'docs/plans/2026-08-27-sensor-portability-publication-a-plan.md',
384
+ 'docs/plans/2026-09-07-retire-onsignal.md',
385
+ 'docs/plans/2026-09-14-compact-only-bootstrap-plan.md',
386
+ ];
387
+ expect(marked.length).toBeGreaterThan(0);
388
+ expect(previouslyValid.length).toBeGreaterThan(0);
389
+ const corpusCounts = `marked=${marked.length}; previouslyValid=${previouslyValid.length}; historicalInvalid=${marked.length - previouslyValid.length}`;
390
+ for (const file of previouslyValid) {
391
+ if (!marked.includes(file))
392
+ throw new Error(`previously valid compact-v1 plan is not tracked with a marker: ${file}; ${corpusCounts}`);
393
+ const report = (0, validate_1.validatePlanFile)(file, repositoryRoot);
394
+ if (report.state !== 'valid')
395
+ throw new Error(`previously valid compact-v1 plan failed: ${file}; state=${report.state}; ${corpusCounts}`);
396
+ assertFrozenObjectGraph(report, file);
397
+ }
398
+ });
399
+ test.each(['RF-1.3', 'RNF-T.2', 'R4-VAL-2', 'A'.repeat(62) + '.1'])('accepts canonical bounded requirement ID %s in every requirement reference', (id) => {
400
+ const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
401
+ manifest.requirements = [id];
402
+ manifest.commands[0].covers = [id];
403
+ manifest.slices[0].requirements = [id];
404
+ }), root);
405
+ expect(report).toMatchObject({ state: 'valid', manifest: { requirements: [id] } });
406
+ });
407
+ test.each([
408
+ '.RF-1', 'RF-1.', 'RF..1', 'RF--1', 'RF-', 'rf-1.3', 'RF-1.a', ' RF-1.3', 'RF-1.3 ',
409
+ 'RF-1. 3', 'RF-1/3', 'RF-1\\3', 'RF-1.é', 'RF-1.\u0000', 'RF-1.\u007f',
410
+ 'A'.repeat(63) + '.1',
411
+ ])('rejects malformed or oversized requirement ID %j', (id) => {
412
+ const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
413
+ manifest.requirements = [id];
414
+ manifest.commands[0].covers = [id];
415
+ manifest.slices[0].requirements = [id];
416
+ }), root);
417
+ expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SHAPE' })] });
418
+ });
419
+ test.each([
420
+ ['source', (m) => { m.sources[0].id = 'SRC.ONE'; }, 'PLAN_SOURCE_SHAPE'],
421
+ ['command', (m) => { m.commands[0].id = 'CMD.ONE'; }, 'PLAN_COMMAND_SHAPE'],
422
+ ['slice', (m) => { m.slices[0].id = 'S.1'; }, 'PLAN_SLICE_SHAPE'],
423
+ ])('keeps dotted %s entity IDs invalid', (_name, mutate, code) => {
424
+ expect((0, validate_1.validatePlanFile)(fixture(root, mutate), root)).toMatchObject({
425
+ state: 'invalid', diagnostics: [expect.objectContaining({ code })],
426
+ });
427
+ });
76
428
  test('performs no execution, network request, model work, grouping, or rewrite', () => {
77
429
  const plan = fixture(root);
78
430
  const before = fs_1.default.readFileSync(plan, 'utf8');
@@ -122,7 +474,7 @@ describe('validatePlanFile', () => {
122
474
  test('keeps a future schema behind partial markers invalid rather than unsupported', () => {
123
475
  const plan = path_1.default.join(root, 'partial-future.md');
124
476
  fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}`);
125
- expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
477
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
126
478
  });
127
479
  test('applies scalar limits before classifying a future schema as unsupported', () => {
128
480
  const plan = path_1.default.join(root, 'future-too-large.md');
@@ -229,12 +581,12 @@ describe('validatePlanFile', () => {
229
581
  const report = (0, validate_1.validatePlanFile)(fixture(root, (m) => { m.slices[0].sectionAnchor = 'a'.repeat(4097); }), root);
230
582
  expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SLICE_SHAPE' })] });
231
583
  });
232
- test('treats a document without any optimized signal as legacy', () => {
584
+ test('requires migration for a document without any compact signal', () => {
233
585
  const plan = path_1.default.join(root, 'legacy.md');
234
586
  fs_1.default.writeFileSync(plan, '# Legacy plan\n');
235
- expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: 'legacy' });
587
+ expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
236
588
  });
237
- test('does not classify an escaped future schema signal as legacy', () => {
589
+ test('does not classify an escaped future schema signal as migration-required', () => {
238
590
  const plan = path_1.default.join(root, 'escaped-future.md');
239
591
  fs_1.default.writeFileSync(plan, '{"schema":"compact-slices\\u002fv2"}');
240
592
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
@@ -242,17 +594,17 @@ describe('validatePlanFile', () => {
242
594
  test.each([
243
595
  ['escaped schema key', '{"\\u0073chema":"compact-slices/v2"}'],
244
596
  ['escaped schema hyphen', '{"schema":"compact\\u002dslices/v2"}'],
245
- ])('does not classify a future schema with an %s as legacy', (_name, manifest) => {
597
+ ])('does not classify a future schema with an %s as migration-required', (_name, manifest) => {
246
598
  const plan = path_1.default.join(root, 'escaped-future-schema.md');
247
599
  fs_1.default.writeFileSync(plan, manifest);
248
600
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
249
601
  });
250
- test('does not classify an escaped future schema embedded in Markdown without markers as legacy', () => {
602
+ test('does not classify an escaped future schema embedded in Markdown without markers as migration-required', () => {
251
603
  const plan = path_1.default.join(root, 'embedded-escaped-future.md');
252
604
  fs_1.default.writeFileSync(plan, '# Plan notes\n\n```json\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"}\n```\n');
253
605
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
254
606
  });
255
- test('rejects an unparseable escaped compact schema embedded in Markdown rather than treating it as legacy', () => {
607
+ test('rejects an unparseable escaped compact schema embedded in Markdown rather than requiring migration', () => {
256
608
  const plan = path_1.default.join(root, 'embedded-escaped-malformed.md');
257
609
  fs_1.default.writeFileSync(plan, '# Plan notes\n\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"\n');
258
610
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
@@ -419,7 +771,7 @@ describe('validatePlanFile', () => {
419
771
  test('recognizes a parseable future schema when only the END marker is broken', () => {
420
772
  const plan = path_1.default.join(root, 'future-missing-end.md');
421
773
  fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}\n`);
422
- expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
774
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
423
775
  });
424
776
  test('accepts CRLF Markdown with nested headings and fenced code in a required section', () => {
425
777
  const plan = fixture(root);