agentic-workflow-manager 3.13.5 → 3.13.6

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 (78) hide show
  1. package/dist/src/commands/add.js +5 -0
  2. package/dist/src/commands/doctor.js +1 -0
  3. package/dist/src/commands/hooks/claude.js +15 -2
  4. package/dist/src/commands/hooks/shared.js +6 -1
  5. package/dist/src/commands/init.js +1 -0
  6. package/dist/src/commands/pin.js +24 -4
  7. package/dist/src/commands/preflight/checks.js +3 -2
  8. package/dist/src/commands/registry/add.js +1 -1
  9. package/dist/src/commands/sensors/index.js +7 -0
  10. package/dist/src/commands/sensors/init.js +48 -7
  11. package/dist/src/commands/sensors/install.js +20 -11
  12. package/dist/src/commands/sensors/run.js +31 -29
  13. package/dist/src/commands/sync.js +29 -0
  14. package/dist/src/commands/watch/index.js +11 -1
  15. package/dist/src/core/artifact-name.js +80 -0
  16. package/dist/src/core/bundle-install.js +55 -12
  17. package/dist/src/core/bundles.js +72 -13
  18. package/dist/src/core/context/regenerate.js +21 -4
  19. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  20. package/dist/src/core/diagnostics/checks.js +26 -2
  21. package/dist/src/core/diagnostics/context.js +91 -25
  22. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  23. package/dist/src/core/discovery.js +2 -2
  24. package/dist/src/core/init/mutation-targets.js +11 -0
  25. package/dist/src/core/init/steps.js +70 -19
  26. package/dist/src/core/install-planner.js +62 -17
  27. package/dist/src/core/install-transaction.js +13 -1
  28. package/dist/src/core/journal/adapter.js +10 -1
  29. package/dist/src/core/paths.js +61 -8
  30. package/dist/src/core/profile.js +12 -0
  31. package/dist/src/core/provider-artifacts.js +38 -13
  32. package/dist/src/core/provider-version.js +12 -6
  33. package/dist/src/core/registries.js +40 -19
  34. package/dist/src/core/renderers/registry.js +37 -0
  35. package/dist/src/core/skill-integrity.js +71 -10
  36. package/dist/src/core/update-check.js +14 -2
  37. package/dist/src/core/versioning.js +17 -2
  38. package/dist/src/index.js +31 -23
  39. package/dist/src/providers/index.js +7 -2
  40. package/dist/src/utils/config.js +7 -8
  41. package/dist/tests/commands/doctor.test.js +6 -0
  42. package/dist/tests/commands/pin.test.js +29 -2
  43. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  44. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  45. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  46. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  47. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  48. package/dist/tests/commands/sensors/run.test.js +24 -51
  49. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  50. package/dist/tests/commands/sensors/status.test.js +27 -12
  51. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  52. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  53. package/dist/tests/core/context/regenerate.test.js +39 -8
  54. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  55. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  56. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  57. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  58. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  59. package/dist/tests/core/init/orchestrator.test.js +1 -1
  60. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  61. package/dist/tests/core/init/steps.test.js +11 -5
  62. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  63. package/dist/tests/core/paths.test.js +7 -34
  64. package/dist/tests/core/project-skill-links.test.js +159 -0
  65. package/dist/tests/core/provider-artifacts.test.js +66 -14
  66. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  67. package/dist/tests/core/reconciliation.test.js +15 -15
  68. package/dist/tests/core/registry-manifest.test.js +1 -1
  69. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  70. package/dist/tests/core/skill-integrity.test.js +10 -10
  71. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  72. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  73. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  74. package/dist/tests/providers/index.test.js +1 -1
  75. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  76. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  77. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  78. package/package.json +4 -1
@@ -34,7 +34,7 @@ describe('pin/unpin (editores de preferences)', () => {
34
34
  it('setPin escribe pins.base normalizado (acepta prefijo v)', () => {
35
35
  const { setPin } = require('../../src/commands/pin');
36
36
  setPin('base', 'v1.2.0');
37
- expect(readPrefs().pins).toEqual({ base: '1.2.0' });
37
+ expect(readPrefs().pins).toEqual({ baseline: '1.2.0' });
38
38
  });
39
39
  it('setPin acepta un registry adicional configurado', () => {
40
40
  const { writeRegistriesConfig } = require('../../src/core/registries');
@@ -66,7 +66,34 @@ describe('pin/unpin (editores de preferences)', () => {
66
66
  writeRegistriesConfig([{ name: 'equipo', remote: '/tmp/x' }]);
67
67
  setPin('equipo', '2.0.0');
68
68
  const prefs = readPrefs();
69
- expect(prefs.pins).toEqual({ base: '1.0.0', equipo: '2.0.0' });
69
+ expect(prefs.pins).toEqual({ baseline: '1.0.0', equipo: '2.0.0' });
70
70
  expect(prefs.defaultAgent).toBeDefined();
71
71
  });
72
72
  });
73
+ describe('awm pin base: el alias tiene que llegar a la clave que el resolutor lee', () => {
74
+ // Regresion: el registry base se llama `baseline` en disco, pero la ayuda
75
+ // del comando siempre documento `awm pin base <v>`. `base` se aceptaba y se
76
+ // persistia tal cual — en una clave que NADA lee. El comando reportaba
77
+ // "✓ base pinned to v1.0.0" y no cambiaba nada, incluso siendo la salida
78
+ // documentada de un bloqueo por `minCliVersion`, donde el usuario queda sin
79
+ // poder correr `awm update` ni `awm sync`.
80
+ it('setPin("base") persiste bajo "baseline", que es lo que consulta el resolutor', () => {
81
+ const { setPin } = require('../../src/commands/pin');
82
+ setPin('base', '1.5.0');
83
+ const { getPreferences } = require('../../src/utils/config');
84
+ expect(getPreferences().pins).toEqual({ baseline: '1.5.0' });
85
+ });
86
+ it('removePin("base") borra ese mismo pin (no una clave fantasma)', () => {
87
+ const { setPin, removePin } = require('../../src/commands/pin');
88
+ setPin('base', '1.5.0');
89
+ expect(removePin('base')).toBe(true);
90
+ const { getPreferences } = require('../../src/utils/config');
91
+ expect(getPreferences().pins).toEqual({});
92
+ });
93
+ it('"baseline" explicito sigue funcionando igual', () => {
94
+ const { setPin } = require('../../src/commands/pin');
95
+ setPin('baseline', '2.0.0');
96
+ const { getPreferences } = require('../../src/utils/config');
97
+ expect(getPreferences().pins).toEqual({ baseline: '2.0.0' });
98
+ });
99
+ });
@@ -163,18 +163,34 @@ describe('preflight', () => {
163
163
  // sensor configuration, and an empty sensors object now fails `checkManifest`
164
164
  // (see the `total === 0` branch), which would drag `report.status` off 'ready'
165
165
  // for reasons unrelated to what these tests exercise.
166
- beforeEach(() => { mockExecSync.mockReset(); });
166
+ // `resolveOnPath` resuelve PATH en proceso (ya no invoca un shell — ver la
167
+ // nota de seguridad en core/paths.ts), asi que la disponibilidad de `gh`/
168
+ // `glab` se simula con un PATH aislado, no mockeando `execSync`. Ademas de
169
+ // reflejar el mecanismo real, esto vuelve deterministas los casos de
170
+ // "no esta en PATH", que antes dependian de que la maquina no lo tuviera.
171
+ let pathDir;
172
+ let originalPath;
173
+ beforeEach(() => {
174
+ pathDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-path-'));
175
+ originalPath = process.env.PATH;
176
+ process.env.PATH = pathDir;
177
+ });
178
+ afterEach(() => {
179
+ if (originalPath === undefined)
180
+ delete process.env.PATH;
181
+ else
182
+ process.env.PATH = originalPath;
183
+ fs_1.default.rmSync(pathDir, { recursive: true, force: true });
184
+ });
185
+ function installOnPath(tool) {
186
+ const f = path_1.default.join(pathDir, tool);
187
+ fs_1.default.writeFileSync(f, '#!/bin/sh\nexit 0\n');
188
+ fs_1.default.chmodSync(f, 0o755);
189
+ }
167
190
  it('reports github + gh available, and does not affect status', () => {
168
191
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
169
192
  gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
170
- // `resolveOnPath('gh')` runs `command -v gh` on POSIX but `where gh` on
171
- // win32 (see paths.ts) — match both invocation forms so this test's
172
- // "gh is available" fixture holds on windows-latest CI too.
173
- mockExecSync.mockImplementation(((cmd) => {
174
- if (cmd === 'command -v gh' || cmd === 'where gh')
175
- return Buffer.from('/usr/bin/gh');
176
- throw new Error(`not found: ${cmd}`);
177
- }));
193
+ installOnPath('gh');
178
194
  const report = (0, checks_1.preflight)(dir);
179
195
  expect(check(report, 'host').ok).toBe(true);
180
196
  expect(check(report, 'host').detail).toBe('github detected, gh available');
@@ -185,9 +201,7 @@ describe('preflight', () => {
185
201
  // advisory contract: it must not drag an otherwise-clean repo to `degraded`.
186
202
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
187
203
  gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
188
- mockExecSync.mockImplementation((() => {
189
- throw new Error('not found');
190
- }));
204
+ // PATH aislado y vacio => glab no resuelve.
191
205
  const report = (0, checks_1.preflight)(dir);
192
206
  expect(check(report, 'host').ok).toBe(true);
193
207
  expect(check(report, 'host').detail).toContain('glab not on PATH');
@@ -254,7 +268,7 @@ describe('preflight', () => {
254
268
  // test guards is that it must never again read as gitlab.
255
269
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
256
270
  gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
257
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
271
+ // PATH real: estos casos no dependen de gh/glab.
258
272
  const report = (0, checks_1.preflight)(dir);
259
273
  expect(check(report, 'host').ok).toBe(true);
260
274
  expect(check(report, 'host').detail).toContain('github detected');
@@ -289,28 +303,28 @@ describe('preflight', () => {
289
303
  it('still detects github.com over HTTPS', () => {
290
304
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
291
305
  gitRepo(dir, 'https://github.com/org/repo.git');
292
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
306
+ // PATH real: estos casos no dependen de gh/glab.
293
307
  const report = (0, checks_1.preflight)(dir);
294
308
  expect(check(report, 'host').detail).toContain('github detected');
295
309
  });
296
310
  it('still detects github.com over SSH shorthand', () => {
297
311
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
298
312
  gitRepo(dir, 'git@github.com:org/repo.git');
299
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
313
+ // PATH real: estos casos no dependen de gh/glab.
300
314
  const report = (0, checks_1.preflight)(dir);
301
315
  expect(check(report, 'host').detail).toContain('github detected');
302
316
  });
303
317
  it('still detects gitlab over HTTPS', () => {
304
318
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
305
319
  gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
306
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
320
+ // PATH real: estos casos no dependen de gh/glab.
307
321
  const report = (0, checks_1.preflight)(dir);
308
322
  expect(check(report, 'host').detail).toContain('gitlab detected');
309
323
  });
310
324
  it('still detects gitlab over SSH shorthand', () => {
311
325
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
312
326
  gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
313
- mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
327
+ // PATH real: estos casos no dependen de gh/glab.
314
328
  const report = (0, checks_1.preflight)(dir);
315
329
  expect(check(report, 'host').detail).toContain('gitlab detected');
316
330
  });
@@ -38,8 +38,8 @@ describe('installBundlesFromRegistry', () => {
38
38
  fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
39
39
  });
40
40
  it('installs all ambient bundles of the given registry root for the agent', () => {
41
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
42
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'team');
41
+ const { registriesDir } = require('../../../src/core/registries');
42
+ const regRoot = path_1.default.join(registriesDir(), 'team');
43
43
  writeBundleRoot(regRoot, 'team-pack', 'team-skill');
44
44
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
45
45
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'registries.json'), JSON.stringify([{ name: 'team', remote: 'r' }]));
@@ -52,8 +52,8 @@ describe('installBundlesFromRegistry', () => {
52
52
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude', 'skills', 'team-skill'))).toBe(true);
53
53
  });
54
54
  it('returns empty when the registry has no bundles', () => {
55
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
56
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'empty');
55
+ const { registriesDir } = require('../../../src/core/registries');
56
+ const regRoot = path_1.default.join(registriesDir(), 'empty');
57
57
  fs_1.default.mkdirSync(path_1.default.join(regRoot, 'skills', 's'), { recursive: true });
58
58
  fs_1.default.writeFileSync(path_1.default.join(regRoot, 'skills', 's', 'SKILL.md'), `---\nname: s\ndescription: d\n---\n`);
59
59
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
@@ -62,8 +62,8 @@ describe('installBundlesFromRegistry', () => {
62
62
  expect(installBundlesFromRegistry(regRoot, 'all', ['claude-code'], tmpWork)).toEqual([]);
63
63
  });
64
64
  it('installs only the named bundles when a list is given', () => {
65
- const { REGISTRIES_DIR } = require('../../../src/core/registries');
66
- const regRoot = path_1.default.join(REGISTRIES_DIR, 'team');
65
+ const { registriesDir } = require('../../../src/core/registries');
66
+ const regRoot = path_1.default.join(registriesDir(), 'team');
67
67
  writeBundleRoot(regRoot, 'wanted', 'skill-w');
68
68
  // second bundle in same catalog
69
69
  fs_1.default.mkdirSync(path_1.default.join(regRoot, 'bundles', 'unwanted'), { recursive: true });
@@ -0,0 +1,96 @@
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
+ // `awm sensors init` auto-detects the stack, then reads that pack's defaults out of
7
+ // the registry. When the registry has no such pack — a pinned or stale registry, or one
8
+ // that simply never shipped it — `readPackDefaults` returned null and the manifest was
9
+ // written EMPTY, with no error and no warning:
10
+ //
11
+ // $ awm sensors init # python project, registry predates the python pack
12
+ // ✔ Detected: python (pyproject.toml)
13
+ // ✔ Wrote .awm/sensors.json ← {"pack":"python","sensors":{}}
14
+ //
15
+ // Nothing in that output says the quality gate now checks nothing. The operator finds
16
+ // out later, from an unrelated command (`awm preflight` exits 1, `awm sensors status`
17
+ // reports DEGRADED), and the manifest is by then committed.
18
+ //
19
+ // `--pack <name>` has always validated against the registry and thrown (assertPackExists).
20
+ // Auto-detection reaching the same dead end must not be quieter than the explicit path:
21
+ // it now falls back to a pack the registry actually has, and says which pack was missing
22
+ // so the operator hears it at the moment the decision is made.
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const os_1 = __importDefault(require("os"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const init_1 = require("../../../src/commands/sensors/init");
27
+ function mkRegistry(packs) {
28
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-packreg-'));
29
+ for (const [name, sensors] of Object.entries(packs)) {
30
+ const dir = path_1.default.join(root, 'sensor-packs', name);
31
+ fs_1.default.mkdirSync(dir, { recursive: true });
32
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'pack.json'), JSON.stringify({ name, sensors }));
33
+ }
34
+ return root;
35
+ }
36
+ function mkPythonProject() {
37
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-packproj-'));
38
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'pyproject.toml'), '[project]\nname = "x"\n');
39
+ return dir;
40
+ }
41
+ const GENERIC = { security: { defaultCmd: 'semgrep .', fast: false } };
42
+ const PYTHON = { typecheck: { defaultCmd: 'mypy .', fast: true, formatter: 'mypy' } };
43
+ describe('awm sensors init — detected pack missing from the registry', () => {
44
+ const made = [];
45
+ const registry = (packs) => { const r = mkRegistry(packs); made.push(r); return r; };
46
+ const project = () => { const p = mkPythonProject(); made.push(p); return p; };
47
+ const manifestOf = (dir) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
48
+ afterAll(() => { for (const d of made)
49
+ fs_1.default.rmSync(d, { recursive: true, force: true }); });
50
+ it('names the missing pack instead of writing an empty manifest silently', () => {
51
+ const cwd = project();
52
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {}, generic: GENERIC }) });
53
+ expect(result.unavailablePack).toBe('python');
54
+ expect(result.detection.pack).toBe('python'); // what the tree actually is — unchanged
55
+ });
56
+ it('falls back to a pack the registry does have, so the gate still measures something', () => {
57
+ const cwd = project();
58
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {}, generic: GENERIC }) });
59
+ expect(result.manifest.pack).toBe('generic');
60
+ expect(Object.keys(result.manifest.sensors)).toEqual(['security']);
61
+ expect(manifestOf(cwd).pack).toBe('generic');
62
+ });
63
+ it('stays on the detected pack when the registry has no fallback either', () => {
64
+ // Nothing better exists. The manifest is honestly empty and still says so —
65
+ // preflight's `manifest` check fails on `total === 0` with a registry remedy.
66
+ const cwd = project();
67
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ 'js-ts': {} }) });
68
+ expect(result.unavailablePack).toBe('python');
69
+ expect(result.manifest.pack).toBe('python');
70
+ expect(result.manifest.sensors).toEqual({});
71
+ });
72
+ it('is quiet when the registry does have the detected pack', () => {
73
+ const cwd = project();
74
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ python: PYTHON, generic: GENERIC }) });
75
+ expect(result.unavailablePack).toBeUndefined();
76
+ expect(result.manifest.pack).toBe('python');
77
+ expect(Object.keys(result.manifest.sensors)).toEqual(['typecheck']);
78
+ });
79
+ it('does not fall back when there is no registry to check against', () => {
80
+ // No registryRoot means nothing to validate against, the same tolerance
81
+ // `--pack` already has — not evidence that the pack is missing.
82
+ const cwd = project();
83
+ const result = (0, init_1.initSensors)({ cwd });
84
+ expect(result.unavailablePack).toBeUndefined();
85
+ expect(result.manifest.pack).toBe('python');
86
+ });
87
+ it('keeps sensors the user already had when falling back', () => {
88
+ // The fallback must not be a way to lose hand-written configuration.
89
+ const cwd = project();
90
+ fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
91
+ fs_1.default.writeFileSync(path_1.default.join(cwd, '.awm', 'sensors.json'), JSON.stringify({ pack: 'python', sensors: { test: { cmd: 'pytest -q', fast: false } } }));
92
+ const result = (0, init_1.initSensors)({ cwd, registryRoot: registry({ generic: GENERIC }) });
93
+ expect(result.manifest.sensors.test?.cmd).toBe('pytest -q');
94
+ expect(Object.keys(result.manifest.sensors).sort()).toEqual(['security', 'test']);
95
+ });
96
+ });
@@ -0,0 +1,87 @@
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
+ // Regresion: `installSensorHook` leia `~/.claude/settings.json` con
7
+ // `try { JSON.parse(...) } catch { return {} }` y despues escribia ese `{}` de
8
+ // vuelta — o sea, ante un JSON malformado (una coma de mas, el error de edicion
9
+ // a mano mas comun) BORRABA el archivo entero del usuario y reportaba exito.
10
+ //
11
+ // Verificado destruyendo `model`, `permissions` y el propio hook SessionStart de
12
+ // AWM. El escritor hermano (`installClaudeHook`) ya se negaba correctamente ante
13
+ // el mismo archivo: tres escritores del mismo archivo, tres niveles de dureza.
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const os_1 = __importDefault(require("os"));
16
+ const path_1 = __importDefault(require("path"));
17
+ describe('installSensorHook: nunca destruye settings.json del usuario', () => {
18
+ let home;
19
+ let settingsPath;
20
+ let originalHome;
21
+ let originalAwmHome;
22
+ beforeEach(() => {
23
+ home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sensor-hook-'));
24
+ originalHome = process.env.HOME;
25
+ originalAwmHome = process.env.AWM_HOME;
26
+ process.env.HOME = home;
27
+ process.env.AWM_HOME = path_1.default.join(home, '.awm');
28
+ fs_1.default.mkdirSync(path_1.default.join(home, '.claude'), { recursive: true });
29
+ settingsPath = path_1.default.join(home, '.claude', 'settings.json');
30
+ jest.resetModules();
31
+ });
32
+ afterEach(() => {
33
+ if (originalHome === undefined)
34
+ delete process.env.HOME;
35
+ else
36
+ process.env.HOME = originalHome;
37
+ if (originalAwmHome === undefined)
38
+ delete process.env.AWM_HOME;
39
+ else
40
+ process.env.AWM_HOME = originalAwmHome;
41
+ fs_1.default.rmSync(home, { recursive: true, force: true });
42
+ });
43
+ it('ante JSON malformado NO reescribe el archivo — falla en vez de destruirlo', () => {
44
+ // Coma de mas: sintacticamente invalido, semanticamente el archivo real
45
+ // del usuario con toda su configuracion.
46
+ const malformed = `{
47
+ "model": "claude-opus-4",
48
+ "permissions": { "allow": ["Bash(npm test)"] },
49
+ "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [] }] },
50
+ }`;
51
+ fs_1.default.writeFileSync(settingsPath, malformed);
52
+ const { installSensorHook } = require('../../../src/commands/sensors/install');
53
+ const result = (() => {
54
+ try {
55
+ return installSensorHook();
56
+ }
57
+ catch (e) {
58
+ return { threw: e };
59
+ }
60
+ })();
61
+ // El contrato es no-destruccion. Que lance o que reporte un fallo son
62
+ // dos formas aceptables de negarse; escribir sobre el archivo no lo es.
63
+ const after = fs_1.default.readFileSync(settingsPath, 'utf-8');
64
+ expect(after).toBe(malformed);
65
+ expect(after).toContain('"model"');
66
+ expect(after).toContain('"permissions"');
67
+ expect(JSON.stringify(result)).not.toContain('"status":"installed"');
68
+ });
69
+ it('sobre un settings.json valido, preserva las claves del usuario e instala el hook', () => {
70
+ fs_1.default.writeFileSync(settingsPath, JSON.stringify({
71
+ model: 'claude-opus-4',
72
+ permissions: { allow: ['Bash(npm test)'] },
73
+ }, null, 2));
74
+ const { installSensorHook } = require('../../../src/commands/sensors/install');
75
+ installSensorHook();
76
+ const after = JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf-8'));
77
+ expect(after.model).toBe('claude-opus-4');
78
+ expect(after.permissions).toEqual({ allow: ['Bash(npm test)'] });
79
+ expect(after.hooks).toBeDefined();
80
+ });
81
+ it('sin settings.json previo, lo crea sin romper nada', () => {
82
+ const { installSensorHook } = require('../../../src/commands/sensors/install');
83
+ expect(() => installSensorHook()).not.toThrow();
84
+ expect(fs_1.default.existsSync(settingsPath)).toBe(true);
85
+ expect(() => JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf-8'))).not.toThrow();
86
+ });
87
+ });
@@ -280,38 +280,12 @@ describe('runSensors — not_certified + auto-discovery', () => {
280
280
  expect(out.sensors.length).toBe(1);
281
281
  });
282
282
  });
283
- describe('reconcilePack', () => {
284
- // Build a minimal fake registry in a tmpdir (registry/ no longer lives in this repo —
285
- // content lives in awm-baseline-registry / awm-documentation-registry).
286
- let FAKE_REGISTRY;
287
- beforeAll(() => {
288
- FAKE_REGISTRY = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-fake-reg-'));
289
- const jstsPackDir = path_1.default.join(FAKE_REGISTRY, 'sensor-packs', 'js-ts');
290
- const genericPackDir = path_1.default.join(FAKE_REGISTRY, 'sensor-packs', 'generic');
291
- fs_1.default.mkdirSync(jstsPackDir, { recursive: true });
292
- fs_1.default.mkdirSync(genericPackDir, { recursive: true });
293
- // Minimal pack.json matching what the real js-ts pack ships
294
- fs_1.default.writeFileSync(path_1.default.join(jstsPackDir, 'pack.json'), JSON.stringify({
295
- name: 'js-ts',
296
- description: 'JavaScript/TypeScript sensor pack (test fixture)',
297
- sensors: {
298
- typecheck: { defaultCmd: 'npx tsc --noEmit', fast: true },
299
- lint: { defaultCmd: 'npx eslint . --format json', fast: true },
300
- security: { defaultCmd: 'semgrep --config .semgrep.awm.yml --json .', fast: false },
301
- mutation: { enabled: false },
302
- },
303
- }));
304
- fs_1.default.writeFileSync(path_1.default.join(genericPackDir, 'pack.json'), JSON.stringify({
305
- name: 'generic',
306
- description: 'Generic sensor pack (test fixture)',
307
- sensors: {
308
- security: { defaultCmd: 'semgrep .', fast: false },
309
- },
310
- }));
311
- });
312
- afterAll(() => { fs_1.default.rmSync(FAKE_REGISTRY, { recursive: true, force: true }); });
283
+ describe('detectPackDrift', () => {
284
+ // Pure detection: it reads the tree and reports. It does NOT rebuild the manifest
285
+ // and does NOT consult a registry see run-is-read-only.test.ts for why the
286
+ // rebuild it used to do (`reconcilePack`) was removed.
313
287
  function tmpProject(pack, withPackageJson) {
314
- const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-reconcile-'));
288
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-drift-'));
315
289
  fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
316
290
  fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack, sensors: pack === 'generic'
317
291
  ? { security: { cmd: 'semgrep .', fast: false } }
@@ -320,46 +294,45 @@ describe('reconcilePack', () => {
320
294
  fs_1.default.writeFileSync(path_1.default.join(dir, 'package.json'), '{}');
321
295
  return dir;
322
296
  }
323
- it('upgrades generic→js-ts when package.json is present', async () => {
324
- const { reconcilePack } = require('../../../src/commands/sensors/run');
297
+ it('reports generic→js-ts drift when package.json is present', () => {
298
+ const { detectPackDrift } = require('../../../src/commands/sensors/run');
325
299
  const dir = tmpProject('generic', true);
326
300
  try {
327
301
  const manifest = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
328
- const res = reconcilePack(dir, manifest, FAKE_REGISTRY);
329
- expect(res.manifest.pack).toBe('js-ts');
330
- expect(res.upgradedFrom).toBe('generic');
331
- expect(Object.keys(res.manifest.sensors)).toContain('typecheck');
332
- // custom sensor cmd preserved through merge
333
- expect(res.manifest.sensors.security?.cmd).toBe('semgrep .');
334
- // persisted to disk
302
+ const res = detectPackDrift(dir, manifest);
303
+ expect(res.detection.pack).toBe('js-ts');
304
+ expect(res.drift).toEqual({
305
+ manifest: 'generic',
306
+ detected: 'js-ts',
307
+ remedy: expect.stringContaining('awm sensors init'),
308
+ });
309
+ // the manifest on disk is untouched — reporting is not healing
335
310
  const onDisk = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
336
- expect(onDisk.pack).toBe('js-ts');
311
+ expect(onDisk.pack).toBe('generic');
337
312
  }
338
313
  finally {
339
314
  fs_1.default.rmSync(dir, { recursive: true });
340
315
  }
341
316
  });
342
- it('is a no-op when pack is already real (idempotent)', async () => {
343
- const { reconcilePack } = require('../../../src/commands/sensors/run');
317
+ it('reports no drift when the pack is already real', () => {
318
+ const { detectPackDrift } = require('../../../src/commands/sensors/run');
344
319
  const dir = tmpProject('js-ts', true);
345
320
  try {
346
321
  const manifest = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
347
- const res = reconcilePack(dir, manifest, FAKE_REGISTRY);
348
- expect(res.manifest.pack).toBe('js-ts');
349
- expect(res.upgradedFrom).toBeUndefined();
322
+ expect(detectPackDrift(dir, manifest).drift).toBeUndefined();
350
323
  }
351
324
  finally {
352
325
  fs_1.default.rmSync(dir, { recursive: true });
353
326
  }
354
327
  });
355
- it('does not upgrade a truly generic project (no indicators)', async () => {
356
- const { reconcilePack } = require('../../../src/commands/sensors/run');
328
+ it('reports no drift on a truly generic project (no indicators)', () => {
329
+ const { detectPackDrift } = require('../../../src/commands/sensors/run');
357
330
  const dir = tmpProject('generic', false);
358
331
  try {
359
332
  const manifest = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), 'utf-8'));
360
- const res = reconcilePack(dir, manifest, FAKE_REGISTRY);
361
- expect(res.manifest.pack).toBe('generic');
362
- expect(res.upgradedFrom).toBeUndefined();
333
+ const res = detectPackDrift(dir, manifest);
334
+ expect(res.detection.pack).toBe('generic');
335
+ expect(res.drift).toBeUndefined();
363
336
  }
364
337
  finally {
365
338
  fs_1.default.rmSync(dir, { recursive: true });
@@ -6,50 +6,67 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
- const child_process_1 = require("child_process");
10
9
  const status_1 = require("../../../src/commands/sensors/status");
11
- jest.mock('child_process', () => ({ execSync: jest.fn() }));
12
- const mockExecSync = child_process_1.execSync;
10
+ // `resolveOnPath` resuelve PATH en proceso (ya no invoca un shell — ver el
11
+ // comentario de seguridad en core/paths.ts). Estos tests controlan un PATH
12
+ // aislado en vez de mockear `execSync`: refleja el mecanismo real y los vuelve
13
+ // deterministas. Lo que se verifica en win32 sigue siendo lo mismo que motivo
14
+ // este archivo: que un shim `.cmd` de npm resuelva (bug real publicado en
15
+ // v3.9.0, donde los sensores quedaban todos "no encontrados" en Windows).
13
16
  describe('computeSensorStatus — Windows PATH resolution', () => {
14
17
  let tmpDir;
18
+ let pathDir;
19
+ let originalPath;
20
+ let originalPathExt;
15
21
  const originalPlatform = process.platform;
16
22
  beforeEach(() => {
17
23
  tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-win-'));
18
- mockExecSync.mockReset();
24
+ pathDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-win-path-'));
25
+ originalPath = process.env.PATH;
26
+ originalPathExt = process.env.PATHEXT;
27
+ process.env.PATH = pathDir;
28
+ process.env.PATHEXT = '.COM;.EXE;.BAT;.CMD';
19
29
  Object.defineProperty(process, 'platform', { value: 'win32' });
20
30
  });
21
31
  afterEach(() => {
22
- fs_1.default.rmSync(tmpDir, { recursive: true });
32
+ if (originalPath === undefined)
33
+ delete process.env.PATH;
34
+ else
35
+ process.env.PATH = originalPath;
36
+ if (originalPathExt === undefined)
37
+ delete process.env.PATHEXT;
38
+ else
39
+ process.env.PATHEXT = originalPathExt;
40
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
41
+ fs_1.default.rmSync(pathDir, { recursive: true, force: true });
23
42
  Object.defineProperty(process, 'platform', { value: originalPlatform });
24
43
  });
25
- it('resolves an installed binary on win32 using `where`, not `which`', () => {
44
+ function writeManifest() {
26
45
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
27
46
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
28
47
  pack: 'js-ts',
29
48
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
30
49
  }));
31
- mockExecSync.mockImplementation(((cmd) => {
32
- if (cmd.startsWith('where '))
33
- return Buffer.from('C:\\tools\\semgrep.exe');
34
- throw new Error(`not found: ${cmd}`);
35
- }));
50
+ }
51
+ it('resuelve un binario instalado en win32 via PATHEXT (incluido un shim .cmd)', () => {
52
+ writeManifest();
53
+ // En win32 el usuario escribe `semgrep` y en disco existe `semgrep.cmd`.
54
+ fs_1.default.writeFileSync(path_1.default.join(pathDir, 'semgrep.cmd'), '@echo off\r\n');
36
55
  const result = (0, status_1.computeSensorStatus)(tmpDir);
37
56
  expect(result.overall).toBe('HEALTHY');
38
57
  expect(result.checks.security.ok).toBe(true);
39
58
  });
40
- it('reports ok:false on win32 when `where` cannot find the binary', () => {
41
- fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
42
- fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
43
- pack: 'js-ts',
44
- sensors: { security: { cmd: 'semgrep --json .', fast: false } }
45
- }));
46
- mockExecSync.mockImplementation(((cmd) => {
47
- if (cmd.startsWith('where '))
48
- throw new Error(`not found: ${cmd}`);
49
- throw new Error(`not found: ${cmd}`);
50
- }));
59
+ it('reporta ok:false en win32 cuando el binario no esta en PATH', () => {
60
+ writeManifest();
61
+ // PATH aislado y vacio.
51
62
  const result = (0, status_1.computeSensorStatus)(tmpDir);
52
63
  expect(result.overall).toBe('DEGRADED');
53
64
  expect(result.checks.security.ok).toBe(false);
54
65
  });
66
+ it('en win32 no exige bit de ejecucion — un .exe sin permisos POSIX igual resuelve', () => {
67
+ writeManifest();
68
+ fs_1.default.writeFileSync(path_1.default.join(pathDir, 'semgrep.exe'), '');
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);
71
+ });
55
72
  });