agentic-workflow-manager 6.5.2 → 8.1.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 (95) hide show
  1. package/dist/scripts/sensor-support-matrix.js +103 -0
  2. package/dist/scripts/support-matrix.js +40 -17
  3. package/dist/src/commands/init.js +1 -3
  4. package/dist/src/commands/ledger/index.js +6 -0
  5. package/dist/src/commands/preflight/checks.js +4 -4
  6. package/dist/src/commands/preflight/index.js +2 -2
  7. package/dist/src/commands/registry/add.js +2 -2
  8. package/dist/src/commands/registry/index.js +5 -0
  9. package/dist/src/commands/registry/status.js +7 -0
  10. package/dist/src/commands/sensors/compatibility/contract.js +382 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +92 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +207 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +134 -0
  15. package/dist/src/commands/sensors/compatibility/pack-source.js +78 -0
  16. package/dist/src/commands/sensors/compatibility/probe.js +47 -0
  17. package/dist/src/commands/sensors/compatibility/resolve.js +99 -0
  18. package/dist/src/commands/sensors/compatibility/types.js +2 -0
  19. package/dist/src/commands/sensors/coverage/contract.js +0 -50
  20. package/dist/src/commands/sensors/coverage/empirical.js +131 -0
  21. package/dist/src/commands/sensors/coverage/evaluate.js +85 -7
  22. package/dist/src/commands/sensors/coverage/evidence.js +5 -1
  23. package/dist/src/commands/sensors/coverage/index.js +44 -7
  24. package/dist/src/commands/sensors/coverage/render.js +214 -14
  25. package/dist/src/commands/sensors/coverage/resolve.js +24 -38
  26. package/dist/src/commands/sensors/exec.js +244 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +95 -6
  29. package/dist/src/commands/sensors/run.js +71 -0
  30. package/dist/src/commands/sensors/status.js +48 -4
  31. package/dist/src/commands/sync.js +7 -0
  32. package/dist/src/core/bundles.js +38 -3
  33. package/dist/src/core/init/steps.js +7 -4
  34. package/dist/src/core/ledger/scan.js +228 -0
  35. package/dist/src/core/ledger/store.js +89 -25
  36. package/dist/src/core/ledger/types.js +54 -0
  37. package/dist/src/core/paths.js +10 -9
  38. package/dist/src/core/registries.js +134 -14
  39. package/dist/src/index.js +24 -1
  40. package/dist/tests/commands/ledger/index.test.js +11 -0
  41. package/dist/tests/commands/preflight/preflight.test.js +64 -64
  42. package/dist/tests/commands/registry/add.test.js +69 -2
  43. package/dist/tests/commands/registry/status.test.js +14 -0
  44. package/dist/tests/commands/sensors/compatibility/contract.test.js +193 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/live.test.js +124 -0
  47. package/dist/tests/commands/sensors/compatibility/manifest.test.js +77 -0
  48. package/dist/tests/commands/sensors/compatibility/materialize.test.js +72 -0
  49. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  50. package/dist/tests/commands/sensors/compatibility/probe.test.js +37 -0
  51. package/dist/tests/commands/sensors/compatibility/python-venv-fixture.js +29 -0
  52. package/dist/tests/commands/sensors/compatibility/resolve.test.js +83 -0
  53. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  54. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  55. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  56. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  57. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  58. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  59. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  60. package/dist/tests/commands/sensors/exec-windows.test.js +140 -2
  61. package/dist/tests/commands/sensors/exec.test.js +115 -5
  62. package/dist/tests/commands/sensors/index.test.js +8 -0
  63. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  64. package/dist/tests/commands/sensors/init.test.js +204 -48
  65. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  66. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  67. package/dist/tests/commands/sensors/run.test.js +66 -3
  68. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  69. package/dist/tests/commands/sensors/status.test.js +74 -23
  70. package/dist/tests/commands/update.test.js +1 -1
  71. package/dist/tests/core/bundles.test.js +37 -0
  72. package/dist/tests/core/discovery.test.js +4 -0
  73. package/dist/tests/core/init/orchestrator.test.js +5 -1
  74. package/dist/tests/core/init/steps.test.js +20 -4
  75. package/dist/tests/core/ledger/scan.test.js +217 -0
  76. package/dist/tests/core/ledger/store.test.js +108 -0
  77. package/dist/tests/core/profile-pins.test.js +2 -1
  78. package/dist/tests/core/project-skill-links.test.js +22 -0
  79. package/dist/tests/core/registries-capability.test.js +10 -0
  80. package/dist/tests/core/registries-sync.test.js +16 -0
  81. package/dist/tests/core/registries.test.js +129 -1
  82. package/dist/tests/core/registry-manifest.test.js +7 -0
  83. package/dist/tests/core/same-existing-path.test.js +35 -0
  84. package/dist/tests/core/sync-gates.test.js +2 -1
  85. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  86. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  87. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  88. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  89. package/dist/tests/structural/active-documentation.test.js +128 -0
  90. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  91. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  92. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  93. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  94. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  95. package/package.json +8 -2
@@ -12,8 +12,10 @@ function mkTmp() {
12
12
  }
13
13
  // Define stable mock before jest.mock hoisting
14
14
  const mockRunCommand = jest.fn();
15
+ const mockRunStructuredCommand = jest.fn();
15
16
  jest.mock('../../../src/commands/sensors/exec', () => ({
16
17
  runCommand: (...args) => mockRunCommand(...args),
18
+ runStructuredCommand: (...args) => mockRunStructuredCommand(...args),
17
19
  }));
18
20
  const { ok, exited, timedOut } = require('./exec-fixtures');
19
21
  const MANIFEST = {
@@ -35,6 +37,7 @@ describe('runSensors', () => {
35
37
  fs_1.default.mkdirSync(path.join(tmpDir, '.awm'), { recursive: true });
36
38
  fs_1.default.writeFileSync(path.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify(MANIFEST));
37
39
  mockRunCommand.mockReset();
40
+ mockRunStructuredCommand.mockReset();
38
41
  });
39
42
  afterEach(() => { fs_1.default.rmSync(tmpDir, { recursive: true }); });
40
43
  const load = () => require('../../../src/commands/sensors/run');
@@ -56,7 +59,7 @@ describe('runSensors', () => {
56
59
  const result = await runSensors({ fast: true, cwd: tmpDir });
57
60
  expect(mockRunCommand).toHaveBeenCalledTimes(2); // typecheck + lint (security disabled, mutation disabled)
58
61
  expect(result.sensors.some((s) => s.name === 'security')).toBe(false);
59
- expect(result.overall).toBe('pass');
62
+ expect(result.overall).toBe('not_certified');
60
63
  });
61
64
  it('returns fail when a fast sensor has errors', async () => {
62
65
  mockRunCommand
@@ -175,7 +178,7 @@ describe('runSensors', () => {
175
178
  const tc = second.sensors.find((s) => s.name === 'typecheck');
176
179
  expect(tc.status).toBe('pass');
177
180
  expect(tc.baselineCount).toBe(1);
178
- expect(second.overall).toBe('pass');
181
+ expect(second.overall).toBe('not_certified');
179
182
  });
180
183
  it('baseline lets NEW findings through (still fails)', async () => {
181
184
  const { runSensors } = load();
@@ -200,6 +203,66 @@ describe('runSensors', () => {
200
203
  expect(result.overall).toBe('fail');
201
204
  });
202
205
  });
206
+ describe('runSensors v2 lifecycle contract', () => {
207
+ let project;
208
+ let home;
209
+ const version = '10.0.0';
210
+ beforeEach(() => {
211
+ jest.resetModules();
212
+ project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-v2-run-project-'));
213
+ home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-v2-run-home-'));
214
+ process.env.AWM_HOME = home;
215
+ const registry = path_1.default.join(home, 'registries', 'baseline', 'sensor-packs', 'js-ts');
216
+ fs_1.default.mkdirSync(registry, { recursive: true });
217
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'eslint.config.awm.mjs'), 'export default []');
218
+ fs_1.default.writeFileSync(path_1.default.join(home, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.test/baseline.git' }]));
219
+ fs_1.default.writeFileSync(path_1.default.join(project, 'package.json'), JSON.stringify({ scripts: { lint: 'eslint .' }, devDependencies: { eslint: '^10.0.0' } }));
220
+ fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', 'eslint'), { recursive: true });
221
+ fs_1.default.writeFileSync(path_1.default.join(project, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ version }));
222
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), JSON.stringify({
223
+ schemaVersion: 2, name: 'js-ts', description: 'fixture', detects: ['package.json'],
224
+ coverage: { schemaVersion: 1, classes: { lint: { description: 'lint', detectors: [{ sensor: 'lint' }], remedy: { summary: 'fix', command: 'awm sensors init' } } } },
225
+ sensors: { lint: { fast: true, applicability: { allFiles: ['package.json'] }, variants: [{
226
+ id: 'eslint-10', priority: 1, certifiedRange: '>=10 <11',
227
+ requirements: { tool: 'eslint', toolRange: '>=10 <11', runtime: 'node', runtimeRange: '>=0' },
228
+ assets: ['eslint.config.awm.mjs'], formatter: 'generic', probe: { kind: 'package-script-present' },
229
+ command: { executable: 'live-eslint', resolution: 'node-modules-bin', args: ['.'] },
230
+ }] } },
231
+ }));
232
+ fs_1.default.mkdirSync(path_1.default.join(project, '.awm'));
233
+ fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'sensors.json'), JSON.stringify({
234
+ schemaVersion: 2, pack: 'js-ts', registryRoot: path_1.default.join(home, 'registries', 'baseline'), sensors: { lint: {
235
+ enabled: true, fast: true, variantId: 'eslint-10',
236
+ command: { executable: 'stale-eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: ['eslint.config.awm.mjs'],
237
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: version, runtimeVersion: process.versions.node, certifiedRange: '>=10 <11', evidence: [] },
238
+ } },
239
+ }));
240
+ mockRunStructuredCommand.mockReset();
241
+ mockRunStructuredCommand.mockResolvedValue(ok());
242
+ });
243
+ afterEach(() => {
244
+ delete process.env.AWM_HOME;
245
+ fs_1.default.rmSync(project, { recursive: true, force: true });
246
+ fs_1.default.rmSync(home, { recursive: true, force: true });
247
+ });
248
+ it('runs the re-resolved command for an unchanged variant id, never the stale manifest command', async () => {
249
+ const { resolveLiveCompatibility } = require('../../../src/commands/sensors/compatibility/live');
250
+ await expect(resolveLiveCompatibility(project, 'js-ts', path_1.default.join(home, 'registries', 'baseline'))).resolves.toBeDefined();
251
+ const { runSensors } = require('../../../src/commands/sensors/run');
252
+ const result = await runSensors({ cwd: project, fast: true });
253
+ expect(result).toEqual(expect.objectContaining({ overall: 'pass' }));
254
+ expect(mockRunStructuredCommand).toHaveBeenCalledWith(expect.objectContaining({ executable: 'live-eslint' }), expect.any(Object));
255
+ });
256
+ it('honors disabled v2 sensors before dispatching them', async () => {
257
+ const manifest = JSON.parse(fs_1.default.readFileSync(path_1.default.join(project, '.awm', 'sensors.json'), 'utf8'));
258
+ manifest.sensors.lint.enabled = false;
259
+ fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'sensors.json'), JSON.stringify(manifest));
260
+ const { runSensors } = require('../../../src/commands/sensors/run');
261
+ const result = await runSensors({ cwd: project, fast: true });
262
+ expect(result.sensors).toEqual([expect.objectContaining({ status: 'skipped', skipReason: 'disabled' })]);
263
+ expect(mockRunStructuredCommand).not.toHaveBeenCalled();
264
+ });
265
+ });
203
266
  describe('runSensors — missing tool is a fail, not a skip', () => {
204
267
  let root;
205
268
  afterEach(() => {
@@ -276,7 +339,7 @@ describe('runSensors — not_certified + auto-discovery', () => {
276
339
  const nested = path_1.default.join(tmpDir, 'a', 'b');
277
340
  fs_1.default.mkdirSync(nested, { recursive: true });
278
341
  const out = await (0, run_1.runSensors)({ cwd: nested });
279
- expect(out.overall).toBe('pass');
342
+ expect(out.overall).toBe('not_certified');
280
343
  expect(out.sensors.length).toBe(1);
281
344
  });
282
345
  });
@@ -48,25 +48,25 @@ describe('computeSensorStatus — Windows PATH resolution', () => {
48
48
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
49
49
  }));
50
50
  }
51
- it('resuelve un binario instalado en win32 via PATHEXT (incluido un shim .cmd)', () => {
51
+ it('resuelve un binario instalado en win32 via PATHEXT (incluido un shim .cmd)', async () => {
52
52
  writeManifest();
53
53
  // En win32 el usuario escribe `semgrep` y en disco existe `semgrep.cmd`.
54
54
  fs_1.default.writeFileSync(path_1.default.join(pathDir, 'semgrep.cmd'), '@echo off\r\n');
55
- const result = (0, status_1.computeSensorStatus)(tmpDir);
56
- expect(result.overall).toBe('HEALTHY');
55
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
56
+ expect(result.overall).toBe('DEGRADED');
57
57
  expect(result.checks.security.ok).toBe(true);
58
58
  });
59
- it('reporta ok:false en win32 cuando el binario no esta en PATH', () => {
59
+ it('reporta ok:false en win32 cuando el binario no esta en PATH', async () => {
60
60
  writeManifest();
61
61
  // PATH aislado y vacio.
62
- const result = (0, status_1.computeSensorStatus)(tmpDir);
62
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
63
63
  expect(result.overall).toBe('DEGRADED');
64
64
  expect(result.checks.security.ok).toBe(false);
65
65
  });
66
- it('en win32 no exige bit de ejecucion — un .exe sin permisos POSIX igual resuelve', () => {
66
+ it('en win32 no exige bit de ejecucion — un .exe sin permisos POSIX igual resuelve', async () => {
67
67
  writeManifest();
68
68
  fs_1.default.writeFileSync(path_1.default.join(pathDir, 'semgrep.exe'), '');
69
69
  fs_1.default.chmodSync(path_1.default.join(pathDir, 'semgrep.exe'), 0o644);
70
- expect((0, status_1.computeSensorStatus)(tmpDir).checks.security.ok).toBe(true);
70
+ expect((await (0, status_1.computeSensorStatus)(tmpDir)).checks.security.ok).toBe(true);
71
71
  });
72
72
  });
@@ -7,6 +7,8 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const status_1 = require("../../../src/commands/sensors/status");
10
+ const checks_1 = require("../../../src/commands/preflight/checks");
11
+ const run_1 = require("../../../src/commands/sensors/run");
10
12
  // `resolveOnPath` resuelve PATH en proceso (ya no invoca un shell — ver
11
13
  // core/paths.ts). Por eso estos tests controlan un PATH aislado en vez de
12
14
  // mockear `execSync`: ademas de reflejar el mecanismo real, los vuelve
@@ -36,8 +38,8 @@ describe('computeSensorStatus', () => {
36
38
  fs_1.default.writeFileSync(p, '#!/bin/sh\nexit 0\n');
37
39
  fs_1.default.chmodSync(p, 0o755);
38
40
  }
39
- it('returns NOT_CONFIGURED when .awm/sensors.json missing', () => {
40
- const result = (0, status_1.computeSensorStatus)(tmpDir);
41
+ it('returns NOT_CONFIGURED when .awm/sensors.json missing', async () => {
42
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
41
43
  expect(result.overall).toBe('NOT_CONFIGURED');
42
44
  expect(result.pack).toBeNull();
43
45
  });
@@ -47,42 +49,42 @@ describe('computeSensorStatus', () => {
47
49
  fs_1.default.mkdirSync(binDir, { recursive: true });
48
50
  fs_1.default.writeFileSync(path_1.default.join(binDir, tool), '');
49
51
  }
50
- it('returns HEALTHY when an npx tool is installed locally', () => {
52
+ it('keeps an operational legacy manifest DEGRADED even when its npx tool is installed locally', async () => {
51
53
  installLocalBin('tsc');
52
54
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
53
55
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
54
56
  pack: 'js-ts',
55
57
  sensors: { typecheck: { cmd: 'npx tsc --noEmit', fast: true } }
56
58
  }));
57
- const result = (0, status_1.computeSensorStatus)(tmpDir);
58
- expect(result.overall).toBe('HEALTHY');
59
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
60
+ expect(result.overall).toBe('DEGRADED');
59
61
  expect(result.pack).toBe('js-ts');
60
62
  expect(result.checks.typecheck.ok).toBe(true);
61
63
  });
62
- it('marks an npx sensor DEGRADED when the tool is NOT installed locally (npx would fetch a remote package)', () => {
64
+ it('marks an npx sensor DEGRADED when the tool is NOT installed locally (npx would fetch a remote package)', async () => {
63
65
  // No node_modules/.bin/depcruise → status must NOT report ✔ just because npx exists.
64
66
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
65
67
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
66
68
  pack: 'js-ts',
67
69
  sensors: { depcheck: { cmd: 'npx depcruise --config .dep-cruiser.awm.js app', fast: false } }
68
70
  }));
69
- const result = (0, status_1.computeSensorStatus)(tmpDir);
71
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
70
72
  expect(result.overall).toBe('DEGRADED');
71
73
  expect(result.checks.depcheck.ok).toBe(false);
72
74
  expect(result.checks.depcheck.detail).toMatch(/not installed locally/i);
73
75
  });
74
- it('marks a sensor DEGRADED when its --config file is missing', () => {
76
+ it('marks a sensor DEGRADED when its --config file is missing', async () => {
75
77
  installLocalBin('eslint');
76
78
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
77
79
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
78
80
  pack: 'js-ts',
79
81
  sensors: { lint: { cmd: 'npx eslint . --config eslint.config.awm.mjs --format json', fast: true } }
80
82
  }));
81
- const result = (0, status_1.computeSensorStatus)(tmpDir);
83
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
82
84
  expect(result.checks.lint.ok).toBe(false);
83
85
  expect(result.checks.lint.detail).toMatch(/missing config/i);
84
86
  });
85
- it('is HEALTHY when the npx tool is installed and the --config file exists', () => {
87
+ it('is HEALTHY when the npx tool is installed and the --config file exists', async () => {
86
88
  installLocalBin('eslint');
87
89
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'eslint.config.awm.mjs'), 'export default []');
88
90
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
@@ -90,17 +92,17 @@ describe('computeSensorStatus', () => {
90
92
  pack: 'js-ts',
91
93
  sensors: { lint: { cmd: 'npx eslint . --config eslint.config.awm.mjs --format json', fast: true } }
92
94
  }));
93
- const result = (0, status_1.computeSensorStatus)(tmpDir);
95
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
94
96
  expect(result.checks.lint.ok).toBe(true);
95
97
  });
96
- it('returns DEGRADED when a binary is missing', () => {
98
+ it('returns DEGRADED when a binary is missing', async () => {
97
99
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
98
100
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
99
101
  pack: 'js-ts',
100
102
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
101
103
  }));
102
104
  // PATH aislado y vacio => semgrep no resuelve.
103
- const result = (0, status_1.computeSensorStatus)(tmpDir);
105
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
104
106
  expect(result.overall).toBe('DEGRADED');
105
107
  expect(result.checks.security.ok).toBe(false);
106
108
  });
@@ -112,19 +114,19 @@ describe('computeSensorStatus', () => {
112
114
  afterEach(() => {
113
115
  Object.defineProperty(process, 'platform', { value: originalPlatform });
114
116
  });
115
- it('resuelve un binario instalado recorriendo PATH, sin invocar un shell', () => {
117
+ it('resuelve un binario instalado recorriendo PATH, sin invocar un shell', async () => {
116
118
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
117
119
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
118
120
  pack: 'js-ts',
119
121
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
120
122
  }));
121
123
  installOnPath('semgrep');
122
- const result = (0, status_1.computeSensorStatus)(tmpDir);
123
- expect(result.overall).toBe('HEALTHY');
124
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
125
+ expect(result.overall).toBe('DEGRADED');
124
126
  expect(result.checks.security.ok).toBe(true);
125
127
  });
126
128
  });
127
- it('is DEGRADED (never HEALTHY) when the manifest has zero sensor entries', () => {
129
+ it('is DEGRADED (never HEALTHY) when the manifest has zero sensor entries', async () => {
128
130
  // `Object.values({}).every(...)` is vacuously true — guard against reading an
129
131
  // empty manifest (the honest floor when the registry had no pack.json for the
130
132
  // detected stack) as a clean run that found nothing wrong.
@@ -133,11 +135,11 @@ describe('computeSensorStatus', () => {
133
135
  pack: 'python',
134
136
  sensors: {},
135
137
  }));
136
- const result = (0, status_1.computeSensorStatus)(tmpDir);
138
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
137
139
  expect(result.overall).toBe('DEGRADED');
138
140
  expect(result.pack).toBe('python');
139
141
  });
140
- it('degrades gracefully (never throws) when sensors is null in a hand-edited manifest', () => {
142
+ it('degrades gracefully (never throws) when sensors is null in a hand-edited manifest', async () => {
141
143
  // Regression for Finding 3: `checkManifest` (preflight) already guards
142
144
  // `manifest.sensors ?? {}` — computeSensorStatus needs the same guard, or a
143
145
  // corrupted/hand-edited manifest with `"sensors": null` crashes
@@ -147,20 +149,69 @@ describe('computeSensorStatus', () => {
147
149
  pack: 'python',
148
150
  sensors: null,
149
151
  }));
150
- expect(() => (0, status_1.computeSensorStatus)(tmpDir)).not.toThrow();
151
- const result = (0, status_1.computeSensorStatus)(tmpDir);
152
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
152
153
  expect(result.overall).toBe('DEGRADED');
153
154
  expect(result.pack).toBe('python');
154
155
  expect(result.checks).toEqual({});
155
156
  });
156
- it('marks disabled sensors as ok', () => {
157
+ it('marks disabled sensors as ok', async () => {
157
158
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
158
159
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
159
160
  pack: 'js-ts',
160
161
  sensors: { mutation: { enabled: false } }
161
162
  }));
162
- const result = (0, status_1.computeSensorStatus)(tmpDir);
163
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
163
164
  expect(result.checks.mutation.ok).toBe(true);
164
165
  expect(result.checks.mutation.detail).toBe('disabled');
165
166
  });
167
+ it('does not trust the initialized v2 variant after local tool drift', async () => {
168
+ // The manifest records ESLint 9 at init time, but local evidence is now ESLint
169
+ // 10. Status must resolve the installed pack again, not certify the stale
170
+ // initializedCompatibility record.
171
+ const previousHome = process.env.AWM_HOME;
172
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-home-'));
173
+ try {
174
+ process.env.AWM_HOME = home;
175
+ const registry = path_1.default.join(home, 'registries', 'baseline');
176
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'sensor-packs', 'js-ts'), { recursive: true });
177
+ fs_1.default.writeFileSync(path_1.default.join(home, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.test/baseline.git' }]));
178
+ const variant = (id, range) => ({
179
+ id, priority: 10, certifiedRange: range,
180
+ requirements: { tool: 'eslint', toolRange: range, runtime: 'node', runtimeRange: '>=0.0.0' },
181
+ assets: ['eslint.config.awm.mjs'], formatter: 'eslint-llm', probe: { kind: 'config-present' },
182
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
183
+ });
184
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'sensor-packs', 'js-ts', 'pack.json'), JSON.stringify({
185
+ schemaVersion: 2, name: 'js-ts', description: 'test', detects: ['package.json'], coverage: { schemaVersion: 1, classes: { lint: { description: 'lint', detectors: [{ sensor: 'lint' }], remedy: { summary: 'fix lint', command: 'awm sensors init --pack js-ts' } } } },
186
+ sensors: { lint: { applicability: { allFiles: ['package.json'] }, variants: [variant('eslint-9', '>=9.0.0 <10.0.0'), variant('eslint-10', '>=10.0.0 <11.0.0')] } },
187
+ }));
188
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'package.json'), JSON.stringify({ devDependencies: { eslint: '^10.0.0' } }));
189
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'node_modules', 'eslint'), { recursive: true });
190
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ version: '10.0.0' }));
191
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
192
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
193
+ schemaVersion: 2, pack: 'js-ts', sensors: { lint: {
194
+ enabled: true, variantId: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
195
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: process.versions.node, certifiedRange: '>=9.0.0 <10.0.0', evidence: [] },
196
+ } },
197
+ }));
198
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
199
+ expect(result.overall).toBe('DEGRADED');
200
+ expect(result.checks.lint).toMatchObject({ ok: false, detail: expect.stringContaining('variant-drift') });
201
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'AGENTS.md'), '# test\n');
202
+ const gate = await (0, checks_1.preflight)(tmpDir);
203
+ expect(gate.status).toBe('degraded');
204
+ expect(gate.checks.find(check => check.id === 'tools')).toMatchObject({ ok: false, detail: expect.stringContaining('variant-drift') });
205
+ const run = await (0, run_1.runSensors)({ cwd: tmpDir, all: true });
206
+ expect(run.overall).toBe('not_certified');
207
+ expect(run.sensors).toEqual([expect.objectContaining({ name: 'lint', status: 'inconclusive', skipReason: expect.stringContaining('variant-drift') })]);
208
+ }
209
+ finally {
210
+ if (previousHome === undefined)
211
+ delete process.env.AWM_HOME;
212
+ else
213
+ process.env.AWM_HOME = previousHome;
214
+ fs_1.default.rmSync(home, { recursive: true, force: true });
215
+ }
216
+ });
166
217
  });
@@ -137,7 +137,7 @@ describe('awm update — honest outcome', () => {
137
137
  // `unusableSyncedRegistries` mira contenido EN DISCO: se siembra el content root
138
138
  // y el registries.json para que `docs` cuente como stale, no como roto.
139
139
  const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
140
- fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs'), { recursive: true });
140
+ fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs', 'skills'), { recursive: true });
141
141
  fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
142
142
  const { runUpdateCore, updateOutro } = require('../../src/commands/update');
143
143
  const result = await runUpdateCore({}, deps({
@@ -37,6 +37,14 @@ describe('readCatalog', () => {
37
37
  expect(entries.map((e) => e.name).sort()).toEqual(['dev', 'frontend']);
38
38
  expect(entries.find((e) => e.name === 'dev').scope).toBe('baseline');
39
39
  });
40
+ it('rejects a catalog symlink instead of reading outside the registry', () => {
41
+ const content = makeFixture();
42
+ const outside = path_1.default.join(path_1.default.dirname(content), 'outside-catalog.json');
43
+ fs_1.default.writeFileSync(outside, JSON.stringify({ bundles: [] }));
44
+ fs_1.default.rmSync(path_1.default.join(content, 'catalog.json'));
45
+ fs_1.default.symlinkSync(outside, path_1.default.join(content, 'catalog.json'));
46
+ expect(() => (0, bundles_1.readCatalog)(content)).toThrow(/symbolic link/);
47
+ });
40
48
  });
41
49
  describe('discoverBundles', () => {
42
50
  it('loads each bundle and normalizes skill refs (string | object)', () => {
@@ -54,6 +62,35 @@ describe('discoverBundles', () => {
54
62
  const empty = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-'));
55
63
  expect((0, bundles_1.discoverBundles)(empty)).toEqual([]);
56
64
  });
65
+ it('rejects catalog sources that escape the registry content root', () => {
66
+ const content = makeFixture();
67
+ const outside = path_1.default.join(path_1.default.dirname(content), 'outside');
68
+ fs_1.default.mkdirSync(outside, { recursive: true });
69
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'bundle.json'), JSON.stringify({ name: 'outside' }));
70
+ fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
71
+ bundles: [{ name: 'outside', source: '../outside', version: '1.0.0', scope: 'project' }],
72
+ }));
73
+ expect(() => (0, bundles_1.discoverBundles)(content)).toThrow(/invalid bundle source/i);
74
+ });
75
+ it('rejects a bundle manifest symlink instead of reading outside the registry', () => {
76
+ const content = makeFixture();
77
+ const outside = path_1.default.join(path_1.default.dirname(content), 'outside-bundle.json');
78
+ fs_1.default.writeFileSync(outside, JSON.stringify({ name: 'outside' }));
79
+ fs_1.default.rmSync(path_1.default.join(content, 'bundles', 'dev', 'bundle.json'));
80
+ fs_1.default.symlinkSync(outside, path_1.default.join(content, 'bundles', 'dev', 'bundle.json'));
81
+ expect(() => (0, bundles_1.discoverBundles)(content)).toThrow(/symbolic link/);
82
+ });
83
+ it('rejects a catalog source that reaches a bundle through an intermediate symlink', () => {
84
+ const content = makeFixture();
85
+ const outside = path_1.default.join(path_1.default.dirname(content), 'outside-bundle');
86
+ fs_1.default.mkdirSync(outside, { recursive: true });
87
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'bundle.json'), JSON.stringify({ name: 'outside' }));
88
+ fs_1.default.symlinkSync(outside, path_1.default.join(content, 'outside'));
89
+ fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
90
+ bundles: [{ name: 'outside', source: 'outside', version: '1.0.0', scope: 'project' }],
91
+ }));
92
+ expect(() => (0, bundles_1.discoverBundles)(content)).toThrow(/symbolic link/);
93
+ });
57
94
  });
58
95
  describe('resolveBundleSkills', () => {
59
96
  it('follows dependsOn transitively and dedupes', () => {
@@ -18,6 +18,10 @@ const WORKFLOWS_DIR = path_1.default.join(CONTENT_ROOT, 'workflows');
18
18
  describe('Artifact Discovery', () => {
19
19
  beforeEach(() => {
20
20
  jest.clearAllMocks();
21
+ fs_1.default.lstatSync.mockImplementation(() => {
22
+ const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
23
+ throw error;
24
+ });
21
25
  });
22
26
  describe('discoverSkills', () => {
23
27
  it('should return a list of skill directories that contain a SKILL.md', () => {
@@ -19,7 +19,11 @@ function seedRegistry(contentRoot) {
19
19
  fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', 'run-hook.cmd'), '#!/bin/sh\n');
20
20
  fs_1.default.chmodSync(path_1.default.join(contentRoot, 'hooks', 'run-hook.cmd'), 0o755);
21
21
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'sensor-packs', 'js-ts'), { recursive: true });
22
- fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'sensor-packs', 'js-ts', 'pack.json'), JSON.stringify({ sensors: { lint: { defaultCmd: 'eslint {{SOURCE_DIRS}}', fast: true } } }));
22
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'sensor-packs', 'js-ts', 'pack.json'),
23
+ // R3 validates even a legacy pack at the registry boundary. This fixture
24
+ // intentionally stays legacy (no schemaVersion) but must still carry its
25
+ // required stable identity, just like a real legacy registry pack.
26
+ JSON.stringify({ name: 'js-ts', sensors: { lint: { defaultCmd: 'eslint {{SOURCE_DIRS}}', fast: true } } }));
23
27
  // catalog + bundle dev (baseline)
24
28
  fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'catalog.json'), JSON.stringify({
25
29
  version: 1, bundles: [{ name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' }],
@@ -143,6 +143,22 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
143
143
  expect((0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a)).action).toBe('applied');
144
144
  expect(a.installBundle).toHaveBeenCalled();
145
145
  });
146
+ it('defers local-only baseline bundles during --machine-only (R7)', () => {
147
+ const a = spies();
148
+ const m = machine();
149
+ m.devCore = { present: false, brokenLinks: [] };
150
+ const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
151
+ agent: 'copilot',
152
+ enabledAgents: ['copilot'],
153
+ machineOnly: true,
154
+ }));
155
+ expect(r).toMatchObject({
156
+ action: 'skipped',
157
+ level: 'machine',
158
+ });
159
+ expect(r.detail).toMatch(/project scope.*deferred/i);
160
+ expect(a.installBundle).not.toHaveBeenCalled();
161
+ });
146
162
  // Regression for the confirmed production bug: `awm init -a copilot` crashed
147
163
  // 100% of the time with "machine.devCore: skill global scope is not
148
164
  // supported by Copilot...", rolling back the ENTIRE init transaction (even
@@ -386,13 +402,13 @@ describe('stepActivation', () => {
386
402
  });
387
403
  });
388
404
  describe('stepSensors', () => {
389
- it('skips when sensors present', () => {
405
+ it('skips when sensors present', async () => {
390
406
  const a = spies();
391
- expect((0, steps_1.stepSensors)(deps({ machine: machine(), project: project() }, a)).action).toBe('skipped');
407
+ expect((await (0, steps_1.stepSensors)(deps({ machine: machine(), project: project() }, a))).action).toBe('skipped');
392
408
  });
393
- it('inits sensors when absent', () => {
409
+ it('inits sensors when absent', async () => {
394
410
  const a = spies();
395
- const r = (0, steps_1.stepSensors)(deps({ machine: machine(), project: project({ sensors: { present: false } }) }, a));
411
+ const r = await (0, steps_1.stepSensors)(deps({ machine: machine(), project: project({ sensors: { present: false } }) }, a));
396
412
  expect(r.action).toBe('applied');
397
413
  expect(a.initSensors).toHaveBeenCalledWith({ cwd: '/repo', registryRoot: '/cache/registry', configure: true }); // sensorPacksRoot='/cache/registry' from deps()
398
414
  });