agentic-workflow-manager 9.7.0 → 9.8.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 (114) hide show
  1. package/README.md +5 -1
  2. package/dist/src/commands/doctor.js +4 -2
  3. package/dist/src/commands/export.js +3 -0
  4. package/dist/src/commands/init.js +10 -2
  5. package/dist/src/commands/job/exec-wrapper.js +53 -3
  6. package/dist/src/commands/plan/index.js +124 -6
  7. package/dist/src/commands/registry/add.js +2 -2
  8. package/dist/src/commands/registry/index.js +7 -5
  9. package/dist/src/commands/registry/install-bundles.js +4 -4
  10. package/dist/src/commands/registry/status.js +4 -4
  11. package/dist/src/commands/sensors/bootstrap.js +36 -5
  12. package/dist/src/commands/sensors/compatibility/contract.js +19 -4
  13. package/dist/src/commands/sensors/compatibility/discovery.js +23 -3
  14. package/dist/src/commands/sensors/compatibility/live.js +18 -1
  15. package/dist/src/commands/sensors/compatibility/probe.js +8 -2
  16. package/dist/src/commands/sensors/compatibility/resolve.js +5 -2
  17. package/dist/src/commands/sensors/coverage/evidence.js +19 -10
  18. package/dist/src/commands/sensors/coverage/index.js +11 -2
  19. package/dist/src/commands/sensors/coverage/resolve.js +10 -0
  20. package/dist/src/commands/sensors/exec.js +16 -1
  21. package/dist/src/commands/sensors/migrate.js +62 -2
  22. package/dist/src/commands/sensors/prepare.js +2 -0
  23. package/dist/src/commands/sensors/result.js +2 -0
  24. package/dist/src/commands/sensors/run.js +64 -1
  25. package/dist/src/commands/sensors/status.js +6 -4
  26. package/dist/src/commands/sync.js +2 -1
  27. package/dist/src/commands/update.js +3 -1
  28. package/dist/src/commands/watch/archive-unused.js +138 -0
  29. package/dist/src/commands/watch/generations.js +32 -9
  30. package/dist/src/commands/watch/index.js +108 -2
  31. package/dist/src/commands/watch/init.js +149 -18
  32. package/dist/src/commands/watch/supervisor.js +167 -13
  33. package/dist/src/commands/watch/teardown-driver.js +13 -13
  34. package/dist/src/commands/watch/track-state.js +59 -0
  35. package/dist/src/commands/watch/tracks.js +38 -102
  36. package/dist/src/core/admission/index.js +181 -0
  37. package/dist/src/core/admission/registry-contracts.js +290 -0
  38. package/dist/src/core/bundle-install.js +2 -2
  39. package/dist/src/core/bundles.js +92 -19
  40. package/dist/src/core/diagnostics/context.js +1 -1
  41. package/dist/src/core/export/index.js +1 -1
  42. package/dist/src/core/export/resolve.js +2 -2
  43. package/dist/src/core/journal/fingerprint.js +35 -1
  44. package/dist/src/core/journal/paths.js +11 -0
  45. package/dist/src/core/journal/store.js +146 -4
  46. package/dist/src/core/journal/types.js +35 -1
  47. package/dist/src/core/migration/historical-provenance.js +18 -0
  48. package/dist/src/core/migration/index.js +322 -0
  49. package/dist/src/core/plan/identity.js +75 -0
  50. package/dist/src/core/plan/validate.js +178 -64
  51. package/dist/src/core/reconciliation.js +1 -1
  52. package/dist/src/index.js +46 -39
  53. package/dist/src/utils/grouping.js +1 -1
  54. package/dist/src/utils/registry-view.js +1 -1
  55. package/dist/tests/commands/bundle-diagnostics.test.js +145 -0
  56. package/dist/tests/commands/export.test.js +28 -2
  57. package/dist/tests/commands/job/exec-wrapper.test.js +16 -0
  58. package/dist/tests/commands/job/gate-reconcile.test.js +24 -10
  59. package/dist/tests/commands/multi-agent-targeting.test.js +1 -1
  60. package/dist/tests/commands/pin.test.js +23 -23
  61. package/dist/tests/commands/plan/index.test.js +630 -14
  62. package/dist/tests/commands/sensors/bootstrap.test.js +57 -4
  63. package/dist/tests/commands/sensors/compatibility/contract.test.js +21 -0
  64. package/dist/tests/commands/sensors/compatibility/discovery.test.js +19 -0
  65. package/dist/tests/commands/sensors/compatibility/probe.test.js +14 -1
  66. package/dist/tests/commands/sensors/compatibility/resolve.test.js +17 -0
  67. package/dist/tests/commands/sensors/coverage/evidence.test.js +51 -27
  68. package/dist/tests/commands/sensors/coverage/resolve.test.js +51 -0
  69. package/dist/tests/commands/sensors/exec.test.js +112 -0
  70. package/dist/tests/commands/sensors/run-changed.test.js +11 -0
  71. package/dist/tests/commands/sensors/run.test.js +15 -2
  72. package/dist/tests/commands/sensors/status.test.js +3 -3
  73. package/dist/tests/commands/watch/e2e-crash.test.js +126 -11
  74. package/dist/tests/commands/watch/generations.test.js +113 -4
  75. package/dist/tests/commands/watch/plan-lifecycle.test.js +263 -0
  76. package/dist/tests/commands/watch/supervisor-loop.test.js +247 -49
  77. package/dist/tests/commands/watch/track-finalize.test.js +20 -3
  78. package/dist/tests/commands/watch/track-freeze.test.js +43 -30
  79. package/dist/tests/commands/watch/track-state-dependency.test.js +15 -0
  80. package/dist/tests/commands/watch/watch-init.test.js +244 -0
  81. package/dist/tests/core/admission/index.test.js +87 -0
  82. package/dist/tests/core/bundle-install.test.js +36 -0
  83. package/dist/tests/core/bundles-multiroot.test.js +24 -2
  84. package/dist/tests/core/bundles-overrides.test.js +10 -7
  85. package/dist/tests/core/bundles.test.js +75 -6
  86. package/dist/tests/core/diagnostics/context.test.js +2 -2
  87. package/dist/tests/core/export/engine.test.js +1 -1
  88. package/dist/tests/core/export/resolve.test.js +1 -1
  89. package/dist/tests/core/init/mutation-targets.test.js +1 -1
  90. package/dist/tests/core/init/orchestrator.test.js +1 -1
  91. package/dist/tests/core/init/steps.test.js +1 -1
  92. package/dist/tests/core/journal/fingerprint.test.js +22 -0
  93. package/dist/tests/core/journal/store.test.js +53 -0
  94. package/dist/tests/core/journal/types.test.js +7 -0
  95. package/dist/tests/core/migration/facts.test.js +289 -0
  96. package/dist/tests/core/migration/historical-provenance.test.js +25 -0
  97. package/dist/tests/core/plan/validate.test.js +320 -6
  98. package/dist/tests/core/reconciliation.test.js +1 -1
  99. package/dist/tests/core/sync-profile-atomicity.test.js +1 -1
  100. package/dist/tests/integration/pack-e2e.test.js +4 -1
  101. package/dist/tests/integration/preflight-json-pipe.e2e.test.js +7 -3
  102. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +37 -0
  103. package/dist/tests/integration/sensor-structured-run.e2e.test.js +12 -10
  104. package/dist/tests/providers/injection-config.test.js +14 -6
  105. package/dist/tests/structural/bundle-skill-reference-contract.test.js +56 -0
  106. package/dist/tests/structural/jest-environment-is-isolated.test.js +29 -24
  107. package/dist/tests/structural/native-release-artifacts.test.js +16 -0
  108. package/dist/tests/utils/grouping.test.js +2 -2
  109. package/dist/tests/utils/registry-view.test.js +7 -7
  110. package/package.json +2 -2
  111. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  112. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  113. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  114. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -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');
@@ -38,20 +51,268 @@ function fixture(root, mutate) {
38
51
  return plan;
39
52
  }
40
53
  describe('validatePlanFile', () => {
54
+ it('validates an authenticated snapshot without reopening a swapped plan parent', () => {
55
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-'));
56
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-outside-'));
57
+ try {
58
+ fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
59
+ const snapshot = '### Task 1: anchored\n';
60
+ fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plan.md'), snapshot);
61
+ fs_1.default.rmSync(path_1.default.join(root, 'docs'), { recursive: true });
62
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'plan.md'), '<!-- attacker -->');
63
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs'));
64
+ expect((0, validate_1.validatePlanSnapshot)('docs/plan.md', root, snapshot)).toMatchObject({ state: 'migration-required' });
65
+ }
66
+ finally {
67
+ fs_1.default.rmSync(root, { recursive: true, force: true });
68
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
69
+ }
70
+ });
41
71
  let root;
42
72
  beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-')); });
43
73
  afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
74
+ test.each([
75
+ ['valid.md', 'valid'],
76
+ ['unmarked.md', 'migration-required'],
77
+ ['future-schema.md', 'unsupported'],
78
+ ['missing-section.md', 'invalid'],
79
+ ])('classifies the versioned v1 corpus member %s as %s without transforms', (file, state) => {
80
+ const corpus = path_1.default.resolve(__dirname, 'fixtures', 'compact-slices-v1');
81
+ expect((0, validate_1.validatePlanFile)(file, corpus).state).toBe(state);
82
+ });
44
83
  test('accepts a valid compact plan without modifying it', () => {
45
84
  const plan = fixture(root);
46
85
  const before = fs_1.default.readFileSync(plan, 'utf8');
47
86
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
48
87
  expect(fs_1.default.readFileSync(plan, 'utf8')).toBe(before);
49
88
  });
89
+ test('makes an approved valid report and its nested manifest immutable after verification', () => {
90
+ const report = (0, validate_1.validatePlanFile)(fixture(root), root);
91
+ expectApprovedPlanValid(report);
92
+ if (report.state !== 'valid')
93
+ throw new Error('expected an approved compact plan');
94
+ const slice = report.manifest.slices[0];
95
+ assertFrozenObjectGraph(report);
96
+ expect(Reflect.set(report.manifest.requirements, '0', 'R4-FORGED')).toBe(false);
97
+ expect(Reflect.set(report.manifest.sources[0], 'fact', 'forged fact')).toBe(false);
98
+ expect(Reflect.set(report.manifest.commands[0].args, '0', 'forged-arg')).toBe(false);
99
+ expect(Reflect.set(slice.fallback, '0', 'forged fallback')).toBe(false);
100
+ expect(Reflect.set(slice.reviewEvidence, '1', 'bogus')).toBe(false);
101
+ expect(Reflect.set(slice, 'risk', 'unbounded')).toBe(false);
102
+ expect(Reflect.set(report.manifest.closureCommands, '0', 'CMD-MISSING')).toBe(false);
103
+ expect(report.manifest.requirements).toEqual(['R4-VAL-2']);
104
+ expect(report.manifest.sources[0].fact).toBe('Known fact');
105
+ expect(report.manifest.commands[0].args).toEqual(['test']);
106
+ expect(slice.fallback).toEqual(['Use a reviewed fallback']);
107
+ expect(slice.reviewEvidence).toEqual(['specification', 'code-quality']);
108
+ expect(slice.risk).toBe('bounded');
109
+ expect(report.manifest.closureCommands).toEqual(['CMD-ONE']);
110
+ });
111
+ test('detects a selectively thawed command-args node even when every ancestor is frozen', () => {
112
+ const report = (0, validate_1.validatePlanFile)(fixture(root), root);
113
+ expectApprovedPlanValid(report);
114
+ const thawed = JSON.parse(JSON.stringify(report));
115
+ const mutableArgs = thawed.manifest.commands[0].args;
116
+ const freezeExceptArgs = (value) => {
117
+ if (value === null || typeof value !== 'object' || value === mutableArgs)
118
+ return;
119
+ for (const child of Object.values(value))
120
+ freezeExceptArgs(child);
121
+ Object.freeze(value);
122
+ };
123
+ freezeExceptArgs(thawed);
124
+ expect(Object.isFrozen(thawed.manifest.commands[0])).toBe(true);
125
+ expect(Object.isFrozen(mutableArgs)).toBe(false);
126
+ expect(Reflect.set(mutableArgs, '0', 'tampered')).toBe(true);
127
+ expect(() => assertFrozenObjectGraph(thawed)).toThrow('report.manifest.commands.0.args is mutable');
128
+ });
129
+ test('scans bounded markerless brace runs without quadratic retries', () => {
130
+ const plan = path_1.default.join(root, 'braces.md');
131
+ fs_1.default.writeFileSync(plan, '{'.repeat(32_000));
132
+ const started = Date.now();
133
+ const report = (0, validate_1.validatePlanFile)(plan, root);
134
+ expect(report).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
135
+ expect(Date.now() - started).toBeLessThan(500);
136
+ });
137
+ test('retains markerless unsupported classification for nested JSON in an unclosed outer object', () => {
138
+ const plan = path_1.default.join(root, 'nested-future.md');
139
+ fs_1.default.writeFileSync(plan, '# Notes\n{"outer":\n{"\\u0073chema":"compact\\u002dslices\\/v2"}\n');
140
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
141
+ });
142
+ test('uses exact bigint filesystem identities for legitimate Windows-sized inode values', () => {
143
+ const plan = fixture(root);
144
+ const nativeLstat = fs_1.default.lstatSync;
145
+ const nativeFstat = fs_1.default.fstatSync;
146
+ const largeIdentity = (observed) => Object.assign(Object.create(observed), {
147
+ ino: typeof observed.ino === 'bigint' ? 9007199254740993n : 9007199254740992,
148
+ });
149
+ const lstat = jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeLstat, fs_1.default, args))));
150
+ const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeFstat, fs_1.default, args))));
151
+ try {
152
+ expectApprovedPlanValid((0, validate_1.validatePlanFile)(plan, root));
153
+ }
154
+ finally {
155
+ lstat.mockRestore();
156
+ fstat.mockRestore();
157
+ }
158
+ });
159
+ test('retains the manifest byte limit beneath the bounded plan-file limit', () => {
160
+ const plan = fixture(root);
161
+ const text = fs_1.default.readFileSync(plan, 'utf8');
162
+ fs_1.default.writeFileSync(plan, text.replace(END, `${' '.repeat(256 * 1024)}\n${END}`));
163
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_LIMIT' })] });
164
+ });
165
+ test.each([
166
+ ['plan', 'PLAN_LIMIT'],
167
+ ['source', 'PLAN_SOURCE_LIMIT'],
168
+ ])('bounds %s bytes when the inspected file grows during descriptor read', (target, code) => {
169
+ const plan = fixture(root);
170
+ const targetPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
171
+ const targetInode = fs_1.default.statSync(targetPath, { bigint: true }).ino;
172
+ const nativeFstat = fs_1.default.fstatSync;
173
+ let grew = false;
174
+ const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation((descriptor) => {
175
+ const observed = nativeFstat(descriptor, { bigint: true });
176
+ if (!grew && observed.ino === targetInode) {
177
+ fs_1.default.appendFileSync(targetPath, Buffer.alloc(1024 * 1024));
178
+ grew = true;
179
+ }
180
+ return observed;
181
+ });
182
+ try {
183
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
184
+ expect(grew).toBe(true);
185
+ }
186
+ finally {
187
+ fstat.mockRestore();
188
+ }
189
+ });
190
+ test.each([
191
+ ['plan', 'final', 'PLAN_PATH_UNSAFE'],
192
+ ['plan', 'ancestor', 'PLAN_PATH_UNSAFE'],
193
+ ['source', 'final', 'PLAN_SOURCE_UNSAFE'],
194
+ ['source', 'ancestor', 'PLAN_SOURCE_UNSAFE'],
195
+ ])('rejects a %s %s symlink swap after inspection without reading outside', (target, swap, code) => {
196
+ let plan = fixture(root);
197
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-race-outside-'));
198
+ const targetDirectory = target === 'plan' ? path_1.default.join(root, 'plans') : path_1.default.join(root, 'docs');
199
+ if (target === 'plan') {
200
+ fs_1.default.mkdirSync(targetDirectory);
201
+ const nestedPlan = path_1.default.join(targetDirectory, 'plan.md');
202
+ fs_1.default.renameSync(plan, nestedPlan);
203
+ plan = nestedPlan;
204
+ }
205
+ const inspectedPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
206
+ const outsideFile = path_1.default.join(outside, path_1.default.basename(inspectedPath));
207
+ fs_1.default.copyFileSync(inspectedPath, outsideFile);
208
+ const outsideIdentity = fs_1.default.statSync(outsideFile);
209
+ const nativeRealpath = fs_1.default.realpathSync;
210
+ let swapped = false;
211
+ const realpath = jest.spyOn(fs_1.default, 'realpathSync').mockImplementation((file) => {
212
+ const resolved = nativeRealpath(file);
213
+ if (!swapped && file === inspectedPath) {
214
+ swapped = true;
215
+ if (swap === 'final') {
216
+ fs_1.default.renameSync(inspectedPath, `${inspectedPath}-original`);
217
+ fs_1.default.symlinkSync(outsideFile, inspectedPath, 'file');
218
+ }
219
+ else {
220
+ fs_1.default.renameSync(targetDirectory, `${targetDirectory}-original`);
221
+ fs_1.default.symlinkSync(outside, targetDirectory, 'dir');
222
+ }
223
+ }
224
+ return resolved;
225
+ });
226
+ const reads = jest.spyOn(fs_1.default, 'readFileSync');
227
+ const nativeReadSync = fs_1.default.readSync;
228
+ let outsideDescriptorReads = 0;
229
+ const descriptorReads = jest.spyOn(fs_1.default, 'readSync').mockImplementation((...args) => {
230
+ const opened = fs_1.default.fstatSync(args[0]);
231
+ if (opened.dev === outsideIdentity.dev && opened.ino === outsideIdentity.ino)
232
+ outsideDescriptorReads += 1;
233
+ return Reflect.apply(nativeReadSync, fs_1.default, args);
234
+ });
235
+ try {
236
+ expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
237
+ expect(swapped).toBe(true);
238
+ expect(reads.mock.calls.some(([file]) => file === inspectedPath)).toBe(false);
239
+ expect(outsideDescriptorReads).toBe(0);
240
+ }
241
+ finally {
242
+ descriptorReads.mockRestore();
243
+ reads.mockRestore();
244
+ realpath.mockRestore();
245
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
246
+ }
247
+ });
50
248
  test('accepts a valid compact plan converted to CRLF', () => {
51
249
  const plan = fixture(root);
52
250
  fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(/\n/g, '\r\n'));
53
251
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
54
252
  });
253
+ test('returns the SHA-256 identity of the complete normalized valid plan', () => {
254
+ const plan = fixture(root);
255
+ const lf = fs_1.default.readFileSync(plan, 'utf8');
256
+ const expected = crypto_1.default.createHash('sha256').update(lf).digest('hex');
257
+ const lfReport = (0, validate_1.validatePlanFile)(plan, root);
258
+ fs_1.default.writeFileSync(plan, lf.replace(/\n/g, '\r\n'));
259
+ const crlfReport = (0, validate_1.validatePlanFile)(plan, root);
260
+ expect(lfReport).toMatchObject({ state: 'valid', planDigest: expected });
261
+ expect(crlfReport).toMatchObject({ state: 'valid', planDigest: expected });
262
+ });
263
+ test.each([
264
+ ['manifest requirement', (text) => text.replace(/R4-VAL-2/g, 'R4-VAL-3')],
265
+ ['source fact', (text) => text.replace('Known fact', 'Changed fact')],
266
+ ['command argv', (text) => text.replace('"test"', '"build"')],
267
+ ['slice prose', (text) => text.replace('One evidence item.', 'Changed evidence item.')],
268
+ ['execution-mode prose', (text) => `${text}\nModo de ejecución: desatendido\n`],
269
+ ])('changes the plan identity when %s changes', (_name, edit) => {
270
+ const plan = fixture(root);
271
+ const first = (0, validate_1.validatePlanFile)(plan, root);
272
+ expect(first.state).toBe('valid');
273
+ if (first.state !== 'valid')
274
+ throw new Error('expected valid fixture');
275
+ fs_1.default.writeFileSync(plan, edit(fs_1.default.readFileSync(plan, 'utf8')));
276
+ const amended = (0, validate_1.validatePlanFile)(plan, root);
277
+ expect(amended.state).toBe('valid');
278
+ if (amended.state !== 'valid')
279
+ throw new Error('expected valid amended fixture');
280
+ expect(amended.planDigest).not.toBe(first.planDigest);
281
+ });
282
+ test('separates exact progress updates from the full normalized identity', () => {
283
+ const plan = fixture(root);
284
+ fs_1.default.appendFileSync(plan, '\n- [ ] Reviewed task.\n');
285
+ const before = (0, validate_1.validatePlanFile)(plan, root);
286
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Reviewed task.', '- [x] Reviewed task.')
287
+ .replace('#### Evidence', '<!-- awm-qa-complete: 2026-09-16 -->\n<!-- awm-docs-complete: 2026-09-16 -->\n<!-- awm-retro-complete: Release R1 -->\n#### Evidence'));
288
+ const after = (0, validate_1.validatePlanFile)(plan, root);
289
+ expect(before.executionDigest).toEqual(expect.any(String));
290
+ expect(after.planDigest).not.toBe(before.planDigest);
291
+ expect(after.executionDigest).toBe(before.executionDigest);
292
+ });
293
+ test.each([
294
+ ['task prose', '- [x] Changed task.\n'],
295
+ ['inline marker', 'prose <!-- awm-qa-complete: 2026-09-16 -->\n'],
296
+ ['unknown marker', '<!-- awm-qa-complete: arbitrary requirements -->\n'],
297
+ ['fenced task', '```md\n- [x] Reviewed task.\n```\n'],
298
+ ['fenced marker', '~~~~md\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n'],
299
+ ])('does not canonicalize %s away from execution identity', (_label, addition) => {
300
+ const plan = fixture(root);
301
+ const before = (0, validate_1.validatePlanFile)(plan, root);
302
+ fs_1.default.appendFileSync(plan, addition);
303
+ const after = (0, validate_1.validatePlanFile)(plan, root);
304
+ expect(before.executionDigest).toEqual(expect.any(String));
305
+ expect(after.executionDigest).not.toBe(before.executionDigest);
306
+ });
307
+ test('a false fence closer cannot make code checkboxes and markers into lifecycle progress', () => {
308
+ const plan = fixture(root);
309
+ fs_1.default.appendFileSync(plan, '\n~~~~md\n~~~~not-a-closer\n- [ ] Code task.\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n');
310
+ const before = (0, validate_1.validatePlanFile)(plan, root);
311
+ fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Code task.', '- [x] Code task.')
312
+ .replace('<!-- awm-qa-complete: 2026-09-16 -->', '<!-- awm-docs-complete: 2026-09-16 -->'));
313
+ const after = (0, validate_1.validatePlanFile)(plan, root);
314
+ expect(after.executionDigest).not.toBe(before.executionDigest);
315
+ });
55
316
  test('accepts a contained plan when path.relative returns Windows separators', () => {
56
317
  const plan = fixture(root);
57
318
  const nestedPlan = path_1.default.join(root, 'docs', 'plan.md');
@@ -73,6 +334,59 @@ describe('validatePlanFile', () => {
73
334
  const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
74
335
  expectApprovedPlanValid((0, validate_1.validatePlanFile)('docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md', repositoryRoot));
75
336
  });
337
+ test('preserves every previously valid tracked compact-v1 plan', () => {
338
+ const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
339
+ const tracked = jest.requireActual('child_process')
340
+ .execFileSync('git', ['ls-files', '--', 'docs/plans/*.md'], { cwd: repositoryRoot, encoding: 'utf8' })
341
+ .trim().split('\n').filter(Boolean);
342
+ const marked = tracked.filter((file) => fs_1.default.readFileSync(path_1.default.join(repositoryRoot, file), 'utf8').includes(START));
343
+ const previouslyValid = [
344
+ 'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md',
345
+ 'docs/plans/2026-08-27-sensor-portability-publication-a-plan.md',
346
+ 'docs/plans/2026-09-07-retire-onsignal.md',
347
+ 'docs/plans/2026-09-14-compact-only-bootstrap-plan.md',
348
+ ];
349
+ expect(marked.length).toBeGreaterThan(0);
350
+ expect(previouslyValid.length).toBeGreaterThan(0);
351
+ const corpusCounts = `marked=${marked.length}; previouslyValid=${previouslyValid.length}; historicalInvalid=${marked.length - previouslyValid.length}`;
352
+ for (const file of previouslyValid) {
353
+ if (!marked.includes(file))
354
+ throw new Error(`previously valid compact-v1 plan is not tracked with a marker: ${file}; ${corpusCounts}`);
355
+ const report = (0, validate_1.validatePlanFile)(file, repositoryRoot);
356
+ if (report.state !== 'valid')
357
+ throw new Error(`previously valid compact-v1 plan failed: ${file}; state=${report.state}; ${corpusCounts}`);
358
+ assertFrozenObjectGraph(report, file);
359
+ }
360
+ });
361
+ 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) => {
362
+ const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
363
+ manifest.requirements = [id];
364
+ manifest.commands[0].covers = [id];
365
+ manifest.slices[0].requirements = [id];
366
+ }), root);
367
+ expect(report).toMatchObject({ state: 'valid', manifest: { requirements: [id] } });
368
+ });
369
+ test.each([
370
+ '.RF-1', 'RF-1.', 'RF..1', 'RF--1', 'RF-', 'rf-1.3', 'RF-1.a', ' RF-1.3', 'RF-1.3 ',
371
+ 'RF-1. 3', 'RF-1/3', 'RF-1\\3', 'RF-1.é', 'RF-1.\u0000', 'RF-1.\u007f',
372
+ 'A'.repeat(63) + '.1',
373
+ ])('rejects malformed or oversized requirement ID %j', (id) => {
374
+ const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
375
+ manifest.requirements = [id];
376
+ manifest.commands[0].covers = [id];
377
+ manifest.slices[0].requirements = [id];
378
+ }), root);
379
+ expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SHAPE' })] });
380
+ });
381
+ test.each([
382
+ ['source', (m) => { m.sources[0].id = 'SRC.ONE'; }, 'PLAN_SOURCE_SHAPE'],
383
+ ['command', (m) => { m.commands[0].id = 'CMD.ONE'; }, 'PLAN_COMMAND_SHAPE'],
384
+ ['slice', (m) => { m.slices[0].id = 'S.1'; }, 'PLAN_SLICE_SHAPE'],
385
+ ])('keeps dotted %s entity IDs invalid', (_name, mutate, code) => {
386
+ expect((0, validate_1.validatePlanFile)(fixture(root, mutate), root)).toMatchObject({
387
+ state: 'invalid', diagnostics: [expect.objectContaining({ code })],
388
+ });
389
+ });
76
390
  test('performs no execution, network request, model work, grouping, or rewrite', () => {
77
391
  const plan = fixture(root);
78
392
  const before = fs_1.default.readFileSync(plan, 'utf8');
@@ -229,12 +543,12 @@ describe('validatePlanFile', () => {
229
543
  const report = (0, validate_1.validatePlanFile)(fixture(root, (m) => { m.slices[0].sectionAnchor = 'a'.repeat(4097); }), root);
230
544
  expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SLICE_SHAPE' })] });
231
545
  });
232
- test('treats a document without any optimized signal as legacy', () => {
546
+ test('requires migration for a document without any compact signal', () => {
233
547
  const plan = path_1.default.join(root, 'legacy.md');
234
548
  fs_1.default.writeFileSync(plan, '# Legacy plan\n');
235
- expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: 'legacy' });
549
+ expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
236
550
  });
237
- test('does not classify an escaped future schema signal as legacy', () => {
551
+ test('does not classify an escaped future schema signal as migration-required', () => {
238
552
  const plan = path_1.default.join(root, 'escaped-future.md');
239
553
  fs_1.default.writeFileSync(plan, '{"schema":"compact-slices\\u002fv2"}');
240
554
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
@@ -242,17 +556,17 @@ describe('validatePlanFile', () => {
242
556
  test.each([
243
557
  ['escaped schema key', '{"\\u0073chema":"compact-slices/v2"}'],
244
558
  ['escaped schema hyphen', '{"schema":"compact\\u002dslices/v2"}'],
245
- ])('does not classify a future schema with an %s as legacy', (_name, manifest) => {
559
+ ])('does not classify a future schema with an %s as migration-required', (_name, manifest) => {
246
560
  const plan = path_1.default.join(root, 'escaped-future-schema.md');
247
561
  fs_1.default.writeFileSync(plan, manifest);
248
562
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
249
563
  });
250
- test('does not classify an escaped future schema embedded in Markdown without markers as legacy', () => {
564
+ test('does not classify an escaped future schema embedded in Markdown without markers as migration-required', () => {
251
565
  const plan = path_1.default.join(root, 'embedded-escaped-future.md');
252
566
  fs_1.default.writeFileSync(plan, '# Plan notes\n\n```json\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"}\n```\n');
253
567
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
254
568
  });
255
- test('rejects an unparseable escaped compact schema embedded in Markdown rather than treating it as legacy', () => {
569
+ test('rejects an unparseable escaped compact schema embedded in Markdown rather than requiring migration', () => {
256
570
  const plan = path_1.default.join(root, 'embedded-escaped-malformed.md');
257
571
  fs_1.default.writeFileSync(plan, '# Plan notes\n\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"\n');
258
572
  expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
@@ -17,7 +17,7 @@ function writeBundle(contentRoot, name, scope, skills, dependsOn = []) {
17
17
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'bundles', name), { recursive: true });
18
18
  fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'bundles', name, 'bundle.json'), JSON.stringify({
19
19
  name, version: '1.0.0', scope, dependsOn,
20
- skills: skills.map((s) => ({ name: s })), workflows: [], agents: [],
20
+ skills, workflows: [], agents: [],
21
21
  }));
22
22
  for (const skill of skills) {
23
23
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', skill), { recursive: true });
@@ -27,7 +27,7 @@ const bundle_install_1 = require("../../src/core/bundle-install");
27
27
  function bundle(name, skills, contentRoot) {
28
28
  return {
29
29
  name, description: '', version: '1.0.0', scope: 'project', visibility: 'public',
30
- dependsOn: [], skills: skills.map(n => ({ name: n })), workflows: [], agents: [],
30
+ dependsOn: [], skills, workflows: [], agents: [],
31
31
  source: `bundles/${name}`, contentRoot,
32
32
  };
33
33
  }
@@ -38,7 +38,10 @@ it('el tarball empaquetado corre awm update sin el monorepo', () => {
38
38
  const awmHome = path_1.default.join(home, '.awm');
39
39
  fs_1.default.mkdirSync(awmHome, { recursive: true });
40
40
  fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: source }]));
41
- (0, child_process_1.execSync)(`node "${path_1.default.join(pkgDir, 'dist/src/index.js')}" update`, {
41
+ // El artefacto se debe ejecutar con el mismo runtime que corre la prueba:
42
+ // usar `node` desde PATH podría probar accidentalmente otra versión. En
43
+ // particular, esto cubre Node 22, declarado como compatible por package.json.
44
+ (0, child_process_1.execSync)(`"${process.execPath}" "${path_1.default.join(pkgDir, 'dist/src/index.js')}" update`, {
42
45
  env: { ...process.env, HOME: home, AWM_HOME: awmHome, AWM_NO_UPDATE_CHECK: '1' },
43
46
  stdio: 'pipe',
44
47
  });
@@ -105,13 +105,17 @@ test('verify-sensors degrades parseably for a v2 sensor that exits 2 without fin
105
105
  writeJson(path_1.default.join(project, 'package.json'), { name: 'preflight-verify-fixture', private: true });
106
106
  writeJson(path_1.default.join(project, 'node_modules', 'fixture-sensor', 'package.json'), { name: 'fixture-sensor', version: '1.0.0' });
107
107
  fs_1.default.writeFileSync(path_1.default.join(project, 'fixture-sensor.config.mjs'), 'export default {};\n');
108
- fs_1.default.writeFileSync(path_1.default.join(project, 'fixture-sensor.mjs'), 'process.exit(2);\n');
108
+ const fixtureArgs = ['-e', 'process.exit(2);', '--', '--fixture'];
109
+ const fixtureBin = path_1.default.join(project, 'node_modules', '.bin', process.platform === 'win32' ? 'fixture-sensor.exe' : 'fixture-sensor');
110
+ fs_1.default.mkdirSync(path_1.default.dirname(fixtureBin), { recursive: true });
111
+ fs_1.default.copyFileSync(process.execPath, fixtureBin);
112
+ fs_1.default.chmodSync(fixtureBin, 0o755);
109
113
  writeJson(path_1.default.join(registry, 'sensor-packs', 'fixture', 'pack.json'), {
110
114
  schemaVersion: 2, name: 'fixture', description: 'preflight exit-2 fixture', detects: ['package.json'],
111
115
  sensors: { lint: { applicability: { allFiles: ['package.json'] }, variants: [{
112
116
  id: 'fixture-v1', priority: 100,
113
117
  requirements: { tool: 'fixture-sensor', toolRange: '>=1 <2', runtime: 'node', runtimeRange: '>=20', configFiles: ['fixture-sensor.config.mjs'] },
114
- certifiedRange: '>=1 <2', command: { executable: 'node', resolution: 'path', args: ['fixture-sensor.mjs'] },
118
+ certifiedRange: '>=1 <2', command: { executable: 'fixture-sensor', resolution: 'node-modules-bin', args: fixtureArgs },
115
119
  assets: ['fixture-sensor.config.mjs'], formatter: 'generic', probe: { kind: 'config-present' },
116
120
  }] } },
117
121
  coverage: { schemaVersion: 1, classes: {
@@ -124,7 +128,7 @@ test('verify-sensors degrades parseably for a v2 sensor that exits 2 without fin
124
128
  writeJson(path_1.default.join(awmHome, 'registries.json'), [{ name: 'baseline', remote: 'fixture' }]);
125
129
  writeJson(path_1.default.join(project, '.awm', 'sensors.json'), {
126
130
  schemaVersion: 2, pack: 'fixture', packSelection: 'explicit', sensors: { lint: {
127
- enabled: true, fast: true, variantId: 'fixture-v1', command: { executable: 'node', resolution: 'path', args: ['fixture-sensor.mjs'] }, assets: ['fixture-sensor.config.mjs'],
131
+ enabled: true, fast: true, variantId: 'fixture-v1', command: { executable: 'fixture-sensor', resolution: 'node-modules-bin', args: fixtureArgs }, assets: ['fixture-sensor.config.mjs'],
128
132
  initializedCompatibility: { state: 'certified', reason: 'fixture', variantId: 'fixture-v1', toolVersion: '1.0.0', runtimeVersion: process.versions.node, certifiedRange: '>=1 <2', evidence: [] },
129
133
  } },
130
134
  });
@@ -14,6 +14,7 @@ const safe_file_1 = require("../../src/commands/sensors/compatibility/safe-file"
14
14
  const native_bridge_1 = require("../../src/core/secure-fs/native-bridge");
15
15
  const cliRoot = path_1.default.resolve(__dirname, '../..');
16
16
  const bin = path_1.default.join(cliRoot, 'dist/src/index.js');
17
+ const nativeArtifact = path_1.default.join(cliRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'secure_fs.node');
17
18
  const fixtureRoot = path_1.default.join(cliRoot, 'tests/fixtures/sensor-compatibility');
18
19
  function hashTree(root) {
19
20
  const hash = crypto_1.default.createHash('sha256');
@@ -70,6 +71,8 @@ function v2Manifest() {
70
71
  beforeAll(() => {
71
72
  if (!fs_1.default.existsSync(bin))
72
73
  throw new Error(`Sensor bootstrap E2E requires the compiled CLI at ${bin}; run npm run build before this test.`);
74
+ if (!fs_1.default.existsSync(nativeArtifact))
75
+ throw new Error(`Sensor bootstrap E2E requires the host secure-fs artifact at ${nativeArtifact}; run npm run native:build before this test.`);
73
76
  });
74
77
  test('compiled bootstrap creates once and the exact second invocation is a byte-stable no-op', () => {
75
78
  const subject = fixture();
@@ -163,3 +166,37 @@ test('compiled bootstrap migrates v2 once, preserves all non-manifest project by
163
166
  fs_1.default.rmSync(subject.root, { recursive: true, force: true });
164
167
  }
165
168
  });
169
+ test('compiled bootstrap requires explicit project-sensors mode before replacing a legacy manifest', () => {
170
+ const subject = fixture();
171
+ try {
172
+ const manifest = path_1.default.join(subject.project, '.awm', 'sensors.json');
173
+ const legacy = {
174
+ pack: 'js-ts',
175
+ sensors: { lint: { cmd: 'npx eslint .' } },
176
+ };
177
+ fs_1.default.writeFileSync(manifest, JSON.stringify(legacy, null, 2) + '\n');
178
+ const before = fs_1.default.readFileSync(manifest);
179
+ const machineBefore = hashTree(subject.awmHome);
180
+ const blocked = run(subject, '--dry-run');
181
+ expect(blocked.status).toBe(1);
182
+ expect(`${blocked.stdout}${blocked.stderr}`).toContain('legacy-replacement-requires-project-sensors-mode');
183
+ expect(fs_1.default.readFileSync(manifest)).toEqual(before);
184
+ const dryRun = run(subject, '--mode', 'project-sensors', '--dry-run');
185
+ expect(dryRun.status).toBe(0);
186
+ expect(fs_1.default.readFileSync(manifest)).toEqual(before);
187
+ const migrated = run(subject, '--mode', 'project-sensors');
188
+ expect(migrated.status).toBe(0);
189
+ expect(`${migrated.stdout}${migrated.stderr}`).toContain('migrated');
190
+ expect(`${migrated.stdout}${migrated.stderr}`).not.toContain('legacy-v1-preserved');
191
+ expect(JSON.parse(fs_1.default.readFileSync(manifest, 'utf8'))).toMatchObject({
192
+ schemaVersion: 3,
193
+ mode: 'project-sensors',
194
+ pack: 'js-ts',
195
+ source: { registry: 'baseline' },
196
+ });
197
+ expect(hashTree(subject.awmHome)).toBe(machineBefore);
198
+ }
199
+ finally {
200
+ fs_1.default.rmSync(subject.root, { recursive: true, force: true });
201
+ }
202
+ });
@@ -26,6 +26,7 @@ function createFixture() {
26
26
  const awmHome = path_1.default.join(root, 'awm-home');
27
27
  const registryRoot = path_1.default.join(awmHome, 'registries', 'baseline');
28
28
  const literal = shellSensitiveLiteral;
29
+ const args = ['-e', "require('fs').writeFileSync('structured-argv.json', JSON.stringify(process.argv.slice(1)));", '--', literal];
29
30
  writeJson(path_1.default.join(project, 'package.json'), { name: 'structured-run-fixture', private: true });
30
31
  // Local metadata is the compatibility evidence. It is not a tool found in a
31
32
  // global installation, so init and run must keep resolving this fixture's pack.
@@ -33,10 +34,12 @@ function createFixture() {
33
34
  name: 'fixture-sensor', version: '1.0.0',
34
35
  });
35
36
  fs_1.default.writeFileSync(path_1.default.join(project, 'fixture-sensor.config.mjs'), 'export default {};\n');
36
- fs_1.default.writeFileSync(path_1.default.join(project, 'fixture-sensor.mjs'), [
37
- "import fs from 'fs';",
38
- "fs.writeFileSync('structured-argv.json', JSON.stringify(process.argv.slice(2)));",
39
- ].join('\n'));
37
+ const fixtureBin = path_1.default.join(project, 'node_modules', '.bin', process.platform === 'win32' ? 'fixture-sensor.exe' : 'fixture-sensor');
38
+ fs_1.default.mkdirSync(path_1.default.dirname(fixtureBin), { recursive: true });
39
+ // A real local native executable works on Windows too; shebang scripts do
40
+ // not. The controlled program and adversarial literal remain separate argv.
41
+ fs_1.default.copyFileSync(process.execPath, fixtureBin);
42
+ fs_1.default.chmodSync(fixtureBin, 0o755);
40
43
  writeJson(path_1.default.join(registryRoot, 'sensor-packs', 'fixture', 'pack.json'), {
41
44
  schemaVersion: 2,
42
45
  name: 'fixture',
@@ -53,10 +56,9 @@ function createFixture() {
53
56
  runtime: 'node', runtimeRange: '>=20', configFiles: ['fixture-sensor.config.mjs'],
54
57
  },
55
58
  certifiedRange: '>=1 <2',
56
- // `node` is resolved from the process environment, but the actual
57
- // executable payload is the fixture-local script. Keeping the
58
- // literal as one argv element proves no shell parses it.
59
- command: { executable: 'node', resolution: 'path', args: ['fixture-sensor.mjs', literal] },
59
+ // The fixture executable is project-local. Keeping the literal
60
+ // as one argv element proves no shell parses it.
61
+ command: { executable: 'fixture-sensor', resolution: 'node-modules-bin', args },
60
62
  assets: ['fixture-sensor.config.mjs'], formatter: 'generic', probe: { kind: 'config-present' },
61
63
  }],
62
64
  },
@@ -72,7 +74,7 @@ function createFixture() {
72
74
  },
73
75
  });
74
76
  writeJson(path_1.default.join(awmHome, 'registries.json'), [{ name: 'baseline', remote: 'fixture' }]);
75
- return { root, project, awmHome, registryRoot, literal };
77
+ return { root, project, awmHome, registryRoot, literal, args };
76
78
  }
77
79
  function runCli(fixture, ...args) {
78
80
  return (0, child_process_1.spawnSync)(process.execPath, [bin, 'sensors', ...args], {
@@ -111,7 +113,7 @@ test('compiled sensors run materializes a v2 registry command and passes its lit
111
113
  mode: 'project-sensors',
112
114
  pack: 'fixture',
113
115
  source: { registry: 'baseline' },
114
- sensors: { structured: { variantId: 'fixture-v1', command: { executable: 'node', resolution: 'path', args: ['fixture-sensor.mjs', fixture.literal] } } },
116
+ sensors: { structured: { variantId: 'fixture-v1', command: { executable: 'fixture-sensor', resolution: 'node-modules-bin', args: fixture.args } } },
115
117
  });
116
118
  const result = runCli(fixture, 'run', '--fast');
117
119
  assertSuccessfulCli(result, 'run --fast', true);
@@ -6,18 +6,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const os_1 = __importDefault(require("os"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const providers_1 = require("../../src/providers");
9
+ const paths_1 = require("../../src/core/paths");
9
10
  describe('getInjection', () => {
10
11
  it('returns cc-settings-merge for claude-code', () => {
11
12
  const inj = (0, providers_1.getInjection)('claude-code');
12
13
  expect(inj?.type).toBe('cc-settings-merge');
13
14
  });
14
15
  it('returns config-instructions for opencode pointing at the global opencode.json', () => {
15
- const inj = (0, providers_1.getInjection)('opencode');
16
- expect(inj).toEqual({
17
- type: 'config-instructions',
18
- configPath: path_1.default.join(os_1.default.homedir(), '.config/opencode/opencode.json'),
19
- field: 'instructions',
20
- });
16
+ // Windows os.homedir() uses USERPROFILE while the CLI honors HOME.
17
+ const systemHome = jest.spyOn(os_1.default, 'homedir').mockReturnValue(path_1.default.resolve('different-system-profile'));
18
+ try {
19
+ const inj = (0, providers_1.getInjection)('opencode');
20
+ expect(inj).toEqual({
21
+ type: 'config-instructions',
22
+ configPath: path_1.default.join((0, paths_1.homeDir)(), '.config/opencode/opencode.json'),
23
+ field: 'instructions',
24
+ });
25
+ }
26
+ finally {
27
+ systemHome.mockRestore();
28
+ }
21
29
  });
22
30
  it('returns undefined for antigravity (no injection mechanism wired yet)', () => {
23
31
  expect((0, providers_1.getInjection)('antigravity')).toBeUndefined();