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
@@ -6,17 +6,36 @@ 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
11
+ // core/paths.ts). Por eso estos tests controlan un PATH aislado en vez de
12
+ // mockear `execSync`: ademas de reflejar el mecanismo real, los vuelve
13
+ // deterministas, que antes no lo eran (el caso "binario ausente" pasaba solo
14
+ // en maquinas donde ese binario realmente no estuviera instalado).
13
15
  describe('computeSensorStatus', () => {
14
16
  let tmpDir;
17
+ let pathDir;
18
+ let originalPath;
15
19
  beforeEach(() => {
16
20
  tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-'));
17
- mockExecSync.mockReset();
21
+ pathDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-path-'));
22
+ originalPath = process.env.PATH;
23
+ process.env.PATH = pathDir; // PATH vacio salvo lo que cada test instale
18
24
  });
19
- afterEach(() => { fs_1.default.rmSync(tmpDir, { recursive: true }); });
25
+ afterEach(() => {
26
+ if (originalPath === undefined)
27
+ delete process.env.PATH;
28
+ else
29
+ process.env.PATH = originalPath;
30
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
31
+ fs_1.default.rmSync(pathDir, { recursive: true, force: true });
32
+ });
33
+ /** Instala un binario ejecutable real en el PATH aislado del test. */
34
+ function installOnPath(tool) {
35
+ const p = path_1.default.join(pathDir, tool);
36
+ fs_1.default.writeFileSync(p, '#!/bin/sh\nexit 0\n');
37
+ fs_1.default.chmodSync(p, 0o755);
38
+ }
20
39
  it('returns NOT_CONFIGURED when .awm/sensors.json missing', () => {
21
40
  const result = (0, status_1.computeSensorStatus)(tmpDir);
22
41
  expect(result.overall).toBe('NOT_CONFIGURED');
@@ -80,7 +99,7 @@ describe('computeSensorStatus', () => {
80
99
  pack: 'js-ts',
81
100
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
82
101
  }));
83
- mockExecSync.mockImplementation(() => { throw new Error('not found'); });
102
+ // PATH aislado y vacio => semgrep no resuelve.
84
103
  const result = (0, status_1.computeSensorStatus)(tmpDir);
85
104
  expect(result.overall).toBe('DEGRADED');
86
105
  expect(result.checks.security.ok).toBe(false);
@@ -93,17 +112,13 @@ describe('computeSensorStatus', () => {
93
112
  afterEach(() => {
94
113
  Object.defineProperty(process, 'platform', { value: originalPlatform });
95
114
  });
96
- it('resolves an installed binary using `command -v`, not `where`', () => {
115
+ it('resuelve un binario instalado recorriendo PATH, sin invocar un shell', () => {
97
116
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
98
117
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
99
118
  pack: 'js-ts',
100
119
  sensors: { security: { cmd: 'semgrep --json .', fast: false } }
101
120
  }));
102
- mockExecSync.mockImplementation(((cmd) => {
103
- if (cmd === 'command -v semgrep')
104
- return Buffer.from('/usr/bin/semgrep');
105
- throw new Error(`not found: ${cmd}`);
106
- }));
121
+ installOnPath('semgrep');
107
122
  const result = (0, status_1.computeSensorStatus)(tmpDir);
108
123
  expect(result.overall).toBe('HEALTHY');
109
124
  expect(result.checks.security.ok).toBe(true);
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // SECURITY regression: artifact names taken VERBATIM from a registry's
4
+ // `bundle.json` were joined onto the provider's install directory with no
5
+ // validation and no containment check, so `path.join(dir, name)` escaped the
6
+ // directory entirely:
7
+ //
8
+ // path.join('~/.claude/skills', '../../.ssh/authorized_keys')
9
+ // => '~/.ssh/authorized_keys'
10
+ //
11
+ // Impact went well past a stray write. `replaceArtifact` does
12
+ // `fs.rmSync(targetPath, {recursive:true, force:true})` before linking, so a
13
+ // name of `../../.ssh` RECURSIVELY DELETED the user's real ~/.ssh and replaced
14
+ // it with a symlink into the attacker's registry clone. Writing
15
+ // `~/.config/autostart/*.desktop` gives code execution at next login.
16
+ //
17
+ // Reachable from `awm add`, `awm sync`, `awm registry add` and `awm init` —
18
+ // and note that `awm init` auto-installs the first bundle declaring
19
+ // `scope: "baseline"` across ALL roots, so a malicious registry does not even
20
+ // need the user to pick its bundle.
21
+ //
22
+ // Every other registry-facing reader in this codebase already rejects `..` and
23
+ // separators (readRegistriesConfig, readRegistryManifest, readProfile). The
24
+ // bundle path was the one that never got the guard.
25
+ const artifact_name_1 = require("../../src/core/artifact-name");
26
+ describe('assertSafeArtifactName: contencion de nombres provenientes del registry', () => {
27
+ const TRAVERSAL = [
28
+ ['padre relativo', '../evil'],
29
+ ['padre profundo', '../../../../../../tmp/escape'],
30
+ ['ssh del usuario', '../../.ssh/authorized_keys'],
31
+ ['separador posix', 'sub/dir'],
32
+ ['separador windows', 'sub\\dir'],
33
+ ['absoluto posix', '/etc/passwd'],
34
+ ['absoluto windows', 'C:\\Windows\\System32\\x'],
35
+ ['unc windows', '\\\\server\\share'],
36
+ ['componente punto', '.'],
37
+ ['componente doble punto', '..'],
38
+ ['punto embebido', 'a/../b'],
39
+ ];
40
+ it.each(TRAVERSAL)('rechaza %s', (_name, candidate) => {
41
+ expect((0, artifact_name_1.isSafeArtifactName)(candidate)).toBe(false);
42
+ expect(() => (0, artifact_name_1.assertSafeArtifactName)(candidate, 'skill')).toThrow(/unsafe|invalid/i);
43
+ });
44
+ it.each([
45
+ ['vacio', ''],
46
+ ['solo espacios', ' '],
47
+ ['byte NUL', 'evil\u0000.md'],
48
+ ['byte de control', 'evil\u0001name'],
49
+ ])('rechaza %s', (_name, candidate) => {
50
+ expect((0, artifact_name_1.isSafeArtifactName)(candidate)).toBe(false);
51
+ });
52
+ it.each([
53
+ ['CON', 'CON'], ['con minuscula', 'con'], ['con extension', 'CON.md'],
54
+ ['AUX', 'AUX'], ['NUL', 'NUL'], ['PRN', 'PRN'],
55
+ ['COM1', 'COM1'], ['LPT1', 'LPT1'],
56
+ ])('rechaza el nombre reservado de Windows %s', (_name, candidate) => {
57
+ // Reservados en TODA plataforma a proposito: el registry es contenido
58
+ // compartido entre equipos, y un nombre que rompe solo en las maquinas
59
+ // Windows del equipo es peor que uno que se rechaza en todas.
60
+ expect((0, artifact_name_1.isSafeArtifactName)(candidate)).toBe(false);
61
+ });
62
+ it.each([
63
+ ['punto final', 'skill.'],
64
+ ['espacio final', 'skill '],
65
+ ])('rechaza %s (Windows los recorta en silencio y cambia el destino)', (_name, candidate) => {
66
+ expect((0, artifact_name_1.isSafeArtifactName)(candidate)).toBe(false);
67
+ });
68
+ it.each([
69
+ 'development-process',
70
+ 'skill_con_guion_bajo',
71
+ 'v1.2-migration',
72
+ 'extract-design-md',
73
+ 'a',
74
+ 'UPPER-case-Mixed',
75
+ 'skill.md',
76
+ ])('acepta el nombre legitimo %s', (candidate) => {
77
+ expect((0, artifact_name_1.isSafeArtifactName)(candidate)).toBe(true);
78
+ expect(() => (0, artifact_name_1.assertSafeArtifactName)(candidate, 'skill')).not.toThrow();
79
+ });
80
+ it('el mensaje de error nombra el tipo y el valor ofensivo, para que el operador pueda ubicarlo', () => {
81
+ expect(() => (0, artifact_name_1.assertSafeArtifactName)('../../.ssh', 'skill'))
82
+ .toThrow(/skill/);
83
+ expect(() => (0, artifact_name_1.assertSafeArtifactName)('../../.ssh', 'skill'))
84
+ .toThrow(/\.\.\/\.\.\/\.ssh/);
85
+ });
86
+ });
@@ -0,0 +1,69 @@
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: `managed-block.ts` soporta UN bloque gestionado por archivo, y
7
+ // tanto el contexto de cursor/copilot (`inject()`, cuyo destino ES el AGENTS.md
8
+ // del proyecto) como la guia de proyecto de codex (`injectProject()`) apuntan al
9
+ // mismo `<projectRoot>/AGENTS.md`. El que corriera ultimo ganaba:
10
+ //
11
+ // awm init -a copilot → AGENTS.md 140 lineas (contexto AWM completo)
12
+ // awm init -a codex → AGENTS.md 6 lineas (solo PROJECT_GUIDANCE)
13
+ // awm init -a copilot → 140 otra vez ... y asi oscilando
14
+ //
15
+ // Para Copilot ese archivo es su UNICO canal de entrega, asi que un init de
16
+ // codex lo dejaba literalmente sin contexto. El contenido de cursor/copilot es
17
+ // un SUPERCONJUNTO — termina con el mismo PROJECT_GUIDANCE — asi que pisarlo
18
+ // siempre es perder informacion.
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const codex_agents_1 = require("../../../src/core/context/strategies/codex-agents");
23
+ const managed_block_1 = require("../../../src/core/context/managed-block");
24
+ const providers_1 = require("../../../src/providers");
25
+ describe('AGENTS.md: un solo slot, dos escritores', () => {
26
+ let projectRoot;
27
+ let agentsMd;
28
+ beforeEach(() => {
29
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-agentsmd-'));
30
+ agentsMd = path_1.default.join(projectRoot, 'AGENTS.md');
31
+ });
32
+ afterEach(() => fs_1.default.rmSync(projectRoot, { recursive: true, force: true }));
33
+ const strategy = () => new codex_agents_1.CodexAgentsStrategy();
34
+ it('injectProject de codex NO pisa un bloque que ya cubre PROJECT_GUIDANCE', () => {
35
+ // Primero se deja que codex escriba, para capturar su PROJECT_GUIDANCE
36
+ // sin depender de un export interno.
37
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
38
+ const guidance = (0, managed_block_1.managedBlockBody)(fs_1.default.readFileSync(agentsMd, 'utf-8'));
39
+ // Ahora se simula lo que dejan cursor/copilot: contexto AWM completo
40
+ // que TERMINA con esa misma guia — un superconjunto.
41
+ const rich = `# Contexto AWM completo\n\nmuchas lineas de contexto\n\n${guidance}`;
42
+ fs_1.default.writeFileSync(agentsMd, `<!-- AWM:START -->\n${rich}\n<!-- AWM:END -->\n`);
43
+ const before = fs_1.default.readFileSync(agentsMd, 'utf-8');
44
+ // Un `awm init -a codex` posterior debe dejarlo intacto: antes lo
45
+ // reemplazaba por las 6 lineas de la guia, y para Copilot ese archivo es
46
+ // su unico canal de entrega.
47
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
48
+ expect(fs_1.default.readFileSync(agentsMd, 'utf-8')).toBe(before);
49
+ expect(fs_1.default.readFileSync(agentsMd, 'utf-8')).toContain('muchas lineas de contexto');
50
+ });
51
+ it('codex SI escribe la guia cuando el archivo no existe todavia', () => {
52
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
53
+ expect(fs_1.default.existsSync(agentsMd)).toBe(true);
54
+ expect((0, managed_block_1.managedBlockBody)(fs_1.default.readFileSync(agentsMd, 'utf-8'))).toBeTruthy();
55
+ });
56
+ it('codex es idempotente: una segunda pasada no cambia el archivo', () => {
57
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
58
+ const first = fs_1.default.readFileSync(agentsMd, 'utf-8');
59
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
60
+ expect(fs_1.default.readFileSync(agentsMd, 'utf-8')).toBe(first);
61
+ });
62
+ it('el contenido que el usuario tiene fuera del bloque gestionado se conserva', () => {
63
+ fs_1.default.writeFileSync(agentsMd, '# Mis notas\n\nEsto es mio.\n');
64
+ strategy().injectProject(projectRoot, (0, providers_1.providerFor)('codex'), 'codex');
65
+ const after = fs_1.default.readFileSync(agentsMd, 'utf-8');
66
+ expect(after).toContain('# Mis notas');
67
+ expect(after).toContain('Esto es mio.');
68
+ });
69
+ });
@@ -53,13 +53,13 @@ describe('regenerateGlobalContext', () => {
53
53
  it('returns empty when no config-instructions agent has a config file', () => {
54
54
  seedRegistry();
55
55
  const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
56
- expect(regenerateGlobalContext()).toEqual([]);
56
+ expect(regenerateGlobalContext(['opencode'])).toEqual([]);
57
57
  });
58
58
  it('skips an agent whose config exists but has no AWM sentinel', () => {
59
59
  seedRegistry();
60
60
  seedOpencode(['docs/rules.md']); // sin el sentinel
61
61
  const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
62
- expect(regenerateGlobalContext()).toEqual([{ agent: 'opencode', action: 'skipped' }]);
62
+ expect(regenerateGlobalContext(['opencode'])).toEqual([{ agent: 'opencode', action: 'skipped' }]);
63
63
  expect(fs_1.default.existsSync(contextPath())).toBe(false); // no se crea el archivo
64
64
  });
65
65
  it('refreshes a stale agent (sentinel present, materialized file absent) and recreates the file', () => {
@@ -67,7 +67,7 @@ describe('regenerateGlobalContext', () => {
67
67
  seedOpencode([contextPath()]); // sentinel presente, pero el archivo no existe → stale
68
68
  expect(fs_1.default.existsSync(contextPath())).toBe(false);
69
69
  const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
70
- expect(regenerateGlobalContext()).toEqual([{ agent: 'opencode', action: 'refreshed' }]);
70
+ expect(regenerateGlobalContext(['opencode'])).toEqual([{ agent: 'opencode', action: 'refreshed' }]);
71
71
  expect(fs_1.default.existsSync(contextPath())).toBe(true);
72
72
  expect(fs_1.default.readFileSync(contextPath(), 'utf-8')).toContain('USING-AWM-BODY');
73
73
  });
@@ -75,9 +75,9 @@ describe('regenerateGlobalContext', () => {
75
75
  seedRegistry();
76
76
  seedOpencode([contextPath()]);
77
77
  const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
78
- regenerateGlobalContext(); // 1ª pasada: stale → refreshed, crea el archivo
78
+ regenerateGlobalContext(['opencode']); // 1ª pasada: stale → refreshed, crea el archivo
79
79
  const mtime1 = fs_1.default.statSync(contextPath()).mtimeMs;
80
- const second = regenerateGlobalContext(); // 2ª pasada: ya injected → fresh
80
+ const second = regenerateGlobalContext(['opencode']); // 2ª pasada: ya injected → fresh
81
81
  expect(second).toEqual([{ agent: 'opencode', action: 'fresh' }]);
82
82
  expect(fs_1.default.statSync(contextPath()).mtimeMs).toBe(mtime1); // archivo intacto
83
83
  });
@@ -90,8 +90,8 @@ describe('regenerateGlobalContext', () => {
90
90
  fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2) + '\n');
91
91
  seedOpencode([contextPath()]);
92
92
  const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
93
- expect(() => regenerateGlobalContext()).not.toThrow();
94
- expect(regenerateGlobalContext()).toEqual([{ agent: 'opencode', action: 'skipped' }]);
93
+ expect(() => regenerateGlobalContext(['opencode'])).not.toThrow();
94
+ expect(regenerateGlobalContext(['opencode'])).toEqual([{ agent: 'opencode', action: 'skipped' }]);
95
95
  });
96
96
  it('skips agent when contextStatus throws an unexpected error', () => {
97
97
  seedRegistry();
@@ -100,7 +100,38 @@ describe('regenerateGlobalContext', () => {
100
100
  const { InjectionOrchestrator } = require('../../../src/core/context/orchestrator');
101
101
  const brokenOrch = new InjectionOrchestrator();
102
102
  brokenOrch.contextStatus = () => { throw new Error('unexpected orchestrator error'); };
103
- expect(regenerateGlobalContext(undefined, brokenOrch)).toEqual([{ agent: 'opencode', action: 'skipped' }]);
103
+ expect(regenerateGlobalContext(['opencode'], brokenOrch)).toEqual([{ agent: 'opencode', action: 'skipped' }]);
104
104
  expect(fs_1.default.existsSync(contextPath())).toBe(false); // installContext never called
105
105
  });
106
+ // Regresion: el filtro exigia `type === 'config-instructions'`, o sea que
107
+ // SOLO se regeneraba OpenCode. Para codex/cursor/copilot
108
+ // (`managed-agents-md`) `awm update` era un no-op silencioso — ni siquiera
109
+ // un aviso — y la unica forma de refrescar el contexto era volver a correr
110
+ // `awm init`. Y como doctor reporta `status: healthy` / exit 0 ante un
111
+ // contexto 'stale', un AGENTS.md desactualizado era invisible para
112
+ // cualquier gate de CI que mirara el codigo de salida.
113
+ it('codex (managed-agents-md con globalPath) ya no se saltea por tipo', () => {
114
+ seedRegistry();
115
+ const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
116
+ const seen = [];
117
+ const orch = {
118
+ contextStatus: (op) => { seen.push(op.agent); return 'absent'; },
119
+ installContext: () => undefined,
120
+ };
121
+ regenerateGlobalContext(['codex'], orch);
122
+ expect(seen).toEqual(['codex']);
123
+ });
124
+ it('cursor y copilot se saltean: no tienen archivo de contexto GLOBAL que regenerar', () => {
125
+ seedRegistry();
126
+ const { regenerateGlobalContext } = require('../../../src/core/context/regenerate');
127
+ const seen = [];
128
+ const orch = {
129
+ contextStatus: (op) => { seen.push(op.agent); return 'absent'; },
130
+ installContext: () => undefined,
131
+ };
132
+ // Su `injection.globalPath` es null — su contexto es project-local, y
133
+ // eso no es trabajo de una regeneracion global.
134
+ expect(regenerateGlobalContext(['cursor', 'copilot'], orch)).toEqual([]);
135
+ expect(seen).toEqual([]);
136
+ });
106
137
  });
@@ -4,7 +4,7 @@ const checks_1 = require("../../../src/core/diagnostics/checks");
4
4
  function healthyMachine() {
5
5
  return {
6
6
  registryCache: { present: true, gitState: 'clean' },
7
- hook: { present: true, degraded: false },
7
+ hook: { present: true, degraded: false, applicable: true },
8
8
  devCore: { present: true, brokenLinks: [] },
9
9
  ambient: { wanted: [], installed: [] },
10
10
  contextInjection: [],
@@ -16,6 +16,7 @@ function healthyProject() {
16
16
  root: '/repo/belanz',
17
17
  profile: { present: true, extensions: ['frontend'] },
18
18
  activeBundles: { expected: ['frontend-craft'], linked: ['frontend-craft'], broken: [] },
19
+ orphanLinks: { repairable: [], dead: [] },
19
20
  sensors: { present: true },
20
21
  constitution: { present: true },
21
22
  context: { present: true, file: 'CLAUDE.md' },
@@ -80,12 +81,12 @@ describe('runChecks — machine.cli', () => {
80
81
  describe('runChecks — machine.hook / devCore', () => {
81
82
  it('hook degraded → warn', () => {
82
83
  const m = healthyMachine();
83
- m.hook = { present: true, degraded: true };
84
+ m.hook = { present: true, degraded: true, applicable: true };
84
85
  expect(byId({ machine: m, project: null }, 'machine.hook').status).toBe('warn');
85
86
  });
86
87
  it('hook absent → missing + awm init', () => {
87
88
  const m = healthyMachine();
88
- m.hook = { present: false };
89
+ m.hook = { present: false, applicable: true };
89
90
  const c = byId({ machine: m, project: null }, 'machine.hook');
90
91
  expect(c.status).toBe('missing');
91
92
  expect(c.remedy).toEqual({ kind: 'command', value: 'awm init' });
@@ -162,7 +163,7 @@ describe('machineChecks — global skill integrity', () => {
162
163
  return {
163
164
  machine: {
164
165
  registryCache: { present: true, gitState: 'clean' },
165
- hook: { present: true, degraded: false },
166
+ hook: { present: true, degraded: false, applicable: true },
166
167
  devCore: { present: true, brokenLinks: [] },
167
168
  ambient: { wanted: [], installed: [] },
168
169
  contextInjection: [],
@@ -194,8 +195,15 @@ describe('computeProviderOverall (Task 9)', () => {
194
195
  checks: states.map((state, i) => ({ id: CHECK_IDS[i % CHECK_IDS.length], state })),
195
196
  };
196
197
  }
197
- it('healthy when every check is in an ok/pending/warn state', () => {
198
- expect((0, checks_1.computeProviderOverall)([providerWith(['supported', 'healthy', 'shared', 'pending-trust', 'stale'])])).toBe('healthy');
198
+ it('healthy when every check is in an ok/pending state', () => {
199
+ expect((0, checks_1.computeProviderOverall)([providerWith(['supported', 'healthy', 'shared', 'pending-trust'])])).toBe('healthy');
200
+ });
201
+ it('degraded cuando algun check esta stale — un contexto viejo tiene que verse en el exit code', () => {
202
+ // Antes `stale` quedaba fuera de los estados degradantes, asi que doctor
203
+ // imprimia ⚠ junto a `status: healthy` y exit 0. Un AGENTS.md
204
+ // desactualizado (o pisado por otro provider) era invisible para
205
+ // cualquier gate de CI que mirara el codigo de salida.
206
+ expect((0, checks_1.computeProviderOverall)([providerWith(['supported', 'healthy', 'stale'])])).toBe('degraded');
199
207
  });
200
208
  it('degrades on a single broken/missing/absent/unsupported/conflict check', () => {
201
209
  expect((0, checks_1.computeProviderOverall)([providerWith(['supported', 'absent'])])).toBe('degraded');
@@ -15,7 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  // `contextStatus` about 'global' for these providers always resolved to
16
16
  // 'absent' regardless of whether the local injection actually succeeded.
17
17
  // 3. `skillsGlobalCheck`'s renderer-awareness fix (deferred Task 4.3 finding):
18
- // `classifyGlobalSkills` only ever sees symlinks, so a rendered format
18
+ // `classifySkillLinks` only ever sees symlinks, so a rendered format
19
19
  // (cursor-mdc, copilot-instructions) always scanned as "0 broken" and
20
20
  // reported a false-green 'healthy' regardless of what was actually on
21
21
  // disk. Non-'link' renderers now report presence-only, honestly.
@@ -174,9 +174,9 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
174
174
  const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
175
175
  fs_1.default.mkdirSync(rulesDir, { recursive: true });
176
176
  fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'development-process.mdc'), '---\ndescription: dev process\nalwaysApply: true\n---\n\nBody.');
177
- // classifyGlobalSkills only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
177
+ // classifySkillLinks only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
178
178
  // — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
179
- // test proves the FIX (renderer-gating), not an accident of what classifyGlobalSkills does.
179
+ // test proves the FIX (renderer-gating), not an accident of what classifySkillLinks does.
180
180
  const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
181
181
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
182
182
  const facts = gatherProviderChecks(['cursor'], scanSkills);
@@ -0,0 +1,69 @@
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: el ESCRITOR estampa la extension del renderer sobre el nombre
7
+ // (`using-awm.mdc`, `using-awm.instructions.md`, `development-process.toml`)
8
+ // pero los LECTORES de diagnostico buscaban el nombre pelado. Consecuencia para
9
+ // Cursor y Copilot: nada figuraba jamas como instalado, asi que
10
+ // - `awm init` NUNCA era idempotente — reinstalaba el baseline entero en cada
11
+ // corrida, dejando 2 directorios de backup sin podar por corrida;
12
+ // - `awm doctor` mostraba `✖ dev-core` / `✖ active bundles` de forma
13
+ // permanente, con un remedio (`awm init` / `awm sync`) que no podia
14
+ // satisfacerlo nunca.
15
+ // Y lo mismo para los artefactos `agent` de claude-code — el provider POR
16
+ // DEFECTO — porque su installName es `<n>.md` y el lector mapeaba solo el caso
17
+ // `.toml` de codex.
18
+ //
19
+ // Estos tests recorren la misma funcion que usa el escritor
20
+ // (`renderedFilename`), asi que lector y escritor no pueden volver a discrepar.
21
+ const fs_1 = __importDefault(require("fs"));
22
+ const os_1 = __importDefault(require("os"));
23
+ const path_1 = __importDefault(require("path"));
24
+ const registry_1 = require("../../../src/core/renderers/registry");
25
+ const install_planner_1 = require("../../../src/core/install-planner");
26
+ describe('renderedFilename: una sola fuente de verdad para el nombre en disco', () => {
27
+ it.each([
28
+ ['link deja el nombre tal cual', 'using-awm', 'link', 'using-awm'],
29
+ ['link conserva el .md del installName', 'development-process.md', 'link', 'development-process.md'],
30
+ ['cursor-mdc', 'using-awm', 'cursor-mdc', 'using-awm.mdc'],
31
+ ['cursor-mdc recorta el .md antes de estampar', 'using-awm.md', 'cursor-mdc', 'using-awm.mdc'],
32
+ ['copilot-instructions', 'using-awm', 'copilot-instructions', 'using-awm.instructions.md'],
33
+ ['codex-agent-toml', 'development-process.md', 'codex-agent-toml', 'development-process.toml'],
34
+ ])('%s', (_n, installName, renderer, expected) => {
35
+ expect((0, registry_1.renderedFilename)(installName, renderer)).toBe(expected);
36
+ });
37
+ it('no usa path.parse().name — un nombre con puntos internos no se trunca', () => {
38
+ // `path.parse('v1.2-migration').name` === 'v1', lo que perderia
39
+ // `2-migration` y podria colisionar con otro skill llamado `v1`.
40
+ expect((0, registry_1.renderedFilename)('v1.2-migration', 'cursor-mdc')).toBe('v1.2-migration.mdc');
41
+ });
42
+ });
43
+ describe('el lector de diagnostico ve lo que el escritor dejo en disco', () => {
44
+ let projectRoot;
45
+ beforeEach(() => {
46
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rendered-'));
47
+ });
48
+ afterEach(() => {
49
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
50
+ });
51
+ // El invariante que importa: el nombre que el escritor produce y el que el
52
+ // lector busca salen de la MISMA funcion. Se verifica sobre el destino real
53
+ // que planifica `physicalTarget`, no sobre una reconstruccion a mano.
54
+ it.each([
55
+ ['cursor'], ['copilot'], ['claude-code'], ['codex'], ['opencode'], ['antigravity'],
56
+ ])('%s: el basename del destino planificado coincide con renderedFilename', (agent) => {
57
+ const { targetPath, renderer } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, agent, 'local', projectRoot);
58
+ expect(path_1.default.basename(targetPath)).toBe((0, registry_1.renderedFilename)('using-awm', renderer));
59
+ });
60
+ it('cursor: el destino termina en .mdc (el lector que buscaba el nombre pelado nunca lo encontraba)', () => {
61
+ const { targetPath } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, 'cursor', 'local', projectRoot);
62
+ expect(path_1.default.basename(targetPath)).toBe('using-awm.mdc');
63
+ expect(fs_1.default.existsSync(path_1.default.join(path_1.default.dirname(targetPath), 'using-awm'))).toBe(false);
64
+ });
65
+ it('copilot: el destino termina en .instructions.md', () => {
66
+ const { targetPath } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, 'copilot', 'local', projectRoot);
67
+ expect(path_1.default.basename(targetPath)).toBe('using-awm.instructions.md');
68
+ });
69
+ });
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // `awm init` and `awm update` disagreed about what "the baseline" is.
4
+ //
5
+ // init (steps.ts) bundles.find(b => b.scope === 'baseline') ← the FIRST one
6
+ // update (reconciliation.ts) bundles.filter(b => b.scope === 'baseline' …) ← all of them
7
+ //
8
+ // A registry shipping two baseline bundles therefore installed one on `awm init` and
9
+ // two on the next `awm update` — and the diagnostic that decides whether the baseline
10
+ // is satisfied used `find` too, so it agreed with init and disagreed with update. The
11
+ // second bundle appeared out of nowhere on an unrelated command, and nothing before
12
+ // that had reported it missing.
13
+ //
14
+ // `filter` is the correct reading: `scope: 'baseline'` is a property a bundle HAS, not
15
+ // a slot only one bundle can occupy.
16
+ const steps_1 = require("../../../src/core/init/steps");
17
+ function bundle(name, scope) {
18
+ return {
19
+ name, description: '', version: '1.0.0', scope, visibility: 'public',
20
+ dependsOn: [], skills: [], workflows: [], agents: [], source: `bundles/${name}`,
21
+ };
22
+ }
23
+ function deps(bundles) {
24
+ const installs = [];
25
+ const d = {
26
+ cwd: '/repo',
27
+ agent: 'claude-code',
28
+ enabledAgents: ['claude-code'],
29
+ bundles,
30
+ installMethod: 'symlink',
31
+ registryRoot: '',
32
+ contentDir: '',
33
+ sensorPacksRoot: '',
34
+ ctx: {
35
+ machine: { devCore: { present: false, brokenLinks: [] } },
36
+ project: null,
37
+ },
38
+ actions: { installBundle: (o) => { installs.push(o.bundleName); } },
39
+ };
40
+ return { d, installs };
41
+ }
42
+ describe('awm init installs every baseline bundle, like awm update reconciles every one', () => {
43
+ it('installs all of them, not just the first', () => {
44
+ const { d, installs } = deps([
45
+ bundle('dev-core', 'baseline'),
46
+ bundle('team-baseline', 'baseline'),
47
+ bundle('frontend', 'project'),
48
+ ]);
49
+ const result = (0, steps_1.stepDevCore)(d);
50
+ expect(installs).toEqual(['dev-core', 'team-baseline']);
51
+ expect(result.action).toBe('applied');
52
+ });
53
+ it('installs nothing from a non-baseline scope', () => {
54
+ const { d, installs } = deps([bundle('dev-core', 'baseline'), bundle('extras', 'ambient')]);
55
+ (0, steps_1.stepDevCore)(d);
56
+ expect(installs).toEqual(['dev-core']);
57
+ });
58
+ it('still skips entirely when the baseline is already satisfied', () => {
59
+ const { d, installs } = deps([bundle('dev-core', 'baseline'), bundle('team-baseline', 'baseline')]);
60
+ d.ctx.machine.devCore.present = true;
61
+ expect((0, steps_1.stepDevCore)(d).action).toBe('skipped');
62
+ expect(installs).toEqual([]);
63
+ });
64
+ });