agentic-workflow-manager 8.2.0 → 8.3.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.
@@ -51,7 +51,10 @@ describe('installHook (happy path + merge)', () => {
51
51
  const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
52
52
  expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'session-start'))).toBe(true);
53
53
  expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'run-hook.cmd'))).toBe(true);
54
- expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(true);
54
+ // Task 6: using-awm.md is now a materialized file (buildContext's output), not a
55
+ // symlink to the raw SKILL.md — so declared orchestrators actually reach Claude Code.
56
+ expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(false);
57
+ expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'using-awm.md'), 'utf-8')).toContain('MUST invoke skills.');
55
58
  const settings = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.claude/settings.json'), 'utf-8'));
56
59
  expect(settings.hooks.SessionStart).toHaveLength(1);
57
60
  expect(settings.hooks.SessionStart[0].matcher).toBe('startup|clear|compact');
@@ -135,11 +138,126 @@ describe('installHook (happy path + merge)', () => {
135
138
  // Did not create settings.json
136
139
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude/settings.json'))).toBe(false);
137
140
  });
138
- it('symlinks using-awm.md even when installMethod is copy (UX choice)', () => {
141
+ // Task 6: using-awm.md is materialized (buildContext's composed output), never a
142
+ // symlink, regardless of installMethod — superseding the pre-Task-6 "UX choice" of
143
+ // always symlinking this one file even under installMethod 'copy'.
144
+ it('materializes using-awm.md (never a symlink) even when installMethod is copy', () => {
139
145
  const { installHook } = require('../../../src/commands/hooks/install');
140
146
  installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'copy' });
141
147
  const skillPath = path_1.default.join(tmpHome, '.awm/hooks/using-awm.md');
142
- expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(true);
148
+ expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(false);
149
+ expect(fs_1.default.readFileSync(skillPath, 'utf-8')).toContain('MUST invoke skills.');
150
+ });
151
+ // Non-regression net (Task 5, Step 1): fixes the hook's observable
152
+ // contract before Task 6 replaces the symlink with a materialized
153
+ // file write in claude.ts. Verifies R6.1.
154
+ it('el hook queda apuntando a un archivo legible con el contenido de using-awm', () => {
155
+ const { installHook } = require('../../../src/commands/hooks/install');
156
+ installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'symlink' });
157
+ const skillDest = path_1.default.join(tmpHome, '.awm/hooks/using-awm.md');
158
+ expect(fs_1.default.existsSync(skillDest)).toBe(true);
159
+ const content = fs_1.default.readFileSync(skillDest, 'utf-8');
160
+ expect(content).toContain('MUST invoke skills.');
161
+ });
162
+ // Task 6, Step 1: closes the bypass — everything buildContext composes (declared
163
+ // orchestrators, Tasks 1-4) must actually reach Claude Code's using-awm.md, not just
164
+ // the raw SKILL.md. Verifies R1.1.
165
+ //
166
+ // Investigation note: the plan's literal sample writes `awm-registry.json` straight
167
+ // into `tmpRegistry` and expects `collectAndWarn()` to pick it up via `listRegistries()`
168
+ // — but `listRegistries()` reads registries.json under AWM_HOME, and `tmpRegistry` here
169
+ // is a bare mkdtemp dir, never registered there via `awm registry add`. Writing the
170
+ // manifest into an unregistered dir would leave `declared` empty and this test green
171
+ // for the wrong reason (or red for the wrong reason, pre-fix). Instead this test
172
+ // registers a REAL listed registry via `writeRegistriesConfig` + `registryContentRoot`
173
+ // (the same pattern `tests/core/context/orchestrator.test.ts` already uses for this
174
+ // exact situation) and installs FROM that registry, so `options.registryRoot` and the
175
+ // one entry `listRegistries()` returns are the same directory — exercising the real
176
+ // collection path end to end.
177
+ it('el hook recibe los orquestadores declarados, no el SKILL.md crudo', () => {
178
+ const { installHook } = require('../../../src/commands/hooks/install');
179
+ const { writeRegistriesConfig, registryContentRoot } = require('../../../src/core/registries');
180
+ writeRegistriesConfig([{ name: 'declaring-test', remote: 'unused' }]);
181
+ const registryRoot = registryContentRoot('declaring-test');
182
+ const regHooks = path_1.default.join(registryRoot, 'hooks');
183
+ const regSkill = path_1.default.join(registryRoot, 'skills/using-awm');
184
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
185
+ fs_1.default.mkdirSync(regSkill, { recursive: true });
186
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
187
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
188
+ fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
189
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
190
+ installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
191
+ const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
192
+ expect(content).toContain('mi-proceso'); // la composicion LLEGA a Claude Code
193
+ expect(content).toContain('MUST invoke skills.'); // y el skill sigue entero
194
+ });
195
+ // Finding 2 (code quality review, Task 6): regression-locks what the reviewer verified
196
+ // by hand — a pre-Task-6 install left using-awm.md as a REAL symlink to the registry's
197
+ // raw SKILL.md. writeMaterializedSkill() must fs.unlinkSync() that symlink (removing
198
+ // only the directory entry) before writing the materialized file, never dereference it
199
+ // and clobber the registry's own SKILL.md.
200
+ it('migrates a pre-Task-6 symlinked using-awm.md to a materialized file without touching the registry SKILL.md', () => {
201
+ const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
202
+ fs_1.default.mkdirSync(scriptsDir, { recursive: true });
203
+ const skillDest = path_1.default.join(scriptsDir, 'using-awm.md');
204
+ const registrySkillPath = path_1.default.join(tmpRegistry, 'skills/using-awm/SKILL.md');
205
+ const originalSkillContent = fs_1.default.readFileSync(registrySkillPath, 'utf-8');
206
+ fs_1.default.symlinkSync(registrySkillPath, skillDest, 'file');
207
+ expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(true);
208
+ const { installHook } = require('../../../src/commands/hooks/install');
209
+ installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'symlink' });
210
+ expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false);
211
+ expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('MUST invoke skills.');
212
+ // The registry's own SKILL.md must be untouched — proves unlinkSync removed only
213
+ // the directory entry and never dereferenced/deleted the symlink's target.
214
+ expect(fs_1.default.existsSync(registrySkillPath)).toBe(true);
215
+ expect(fs_1.default.readFileSync(registrySkillPath, 'utf-8')).toBe(originalSkillContent);
216
+ });
217
+ // Finding 2 (post-implementation-qa, Release 2): R5.1's fail-safe guarantee (a broken
218
+ // declaration in one registry never blocks context construction for the others) was
219
+ // already regression-tested for the generic InjectionOrchestrator/opencode path
220
+ // (tests/core/context/orchestrator.test.ts, 'installContext still succeeds when a
221
+ // DIFFERENT installed registry has a broken declaration') but not through
222
+ // installClaudeHook/resyncClaudeHookFiles — Task 6's own highest-risk change. Mirrors
223
+ // that test's two-registries setup (one valid orchestrator declaration, one broken
224
+ // JSON) via writeRegistriesConfig/registryContentRoot, same as 'el hook recibe los
225
+ // orquestadores declarados...' above, but installs from the VALID registry and asserts
226
+ // the broken sibling never surfaces as a thrown error.
227
+ it('installHook succeeds and materializes the valid registry\'s orchestrator when a sibling registry has a broken declaration', () => {
228
+ const { installHook } = require('../../../src/commands/hooks/install');
229
+ const { writeRegistriesConfig, registryContentRoot } = require('../../../src/core/registries');
230
+ writeRegistriesConfig([
231
+ { name: 'broken-sibling', remote: 'unused' },
232
+ { name: 'valid-declaring', remote: 'unused' },
233
+ ]);
234
+ // Broken sibling: unparsable awm-registry.json. No hooks/skill content needed —
235
+ // it is never the registryRoot passed to installHook, only a sibling
236
+ // collectAndWarn() walks while gathering declared orchestrators.
237
+ const brokenRoot = registryContentRoot('broken-sibling');
238
+ fs_1.default.mkdirSync(brokenRoot, { recursive: true });
239
+ fs_1.default.writeFileSync(path_1.default.join(brokenRoot, 'awm-registry.json'), '{ not json');
240
+ // Valid registry: the one actually installed from.
241
+ const registryRoot = registryContentRoot('valid-declaring');
242
+ const regHooks = path_1.default.join(registryRoot, 'hooks');
243
+ const regSkill = path_1.default.join(registryRoot, 'skills/using-awm');
244
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
245
+ fs_1.default.mkdirSync(regSkill, { recursive: true });
246
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
247
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
248
+ fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
249
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
250
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
251
+ let result;
252
+ expect(() => {
253
+ result = installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
254
+ }).not.toThrow();
255
+ expect(result.status).toBe('installed');
256
+ const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
257
+ expect(content).toContain('proceso-valido'); // valid registry's declared orchestrator reached Claude Code
258
+ expect(content).toContain('MUST invoke skills.'); // and the skill body is intact
259
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('warning:')); // broken sibling warned, not thrown
260
+ warnSpy.mockRestore();
143
261
  });
144
262
  it('throws for unsupported agent target', () => {
145
263
  const { installHook } = require('../../../src/commands/hooks/install');
@@ -70,7 +70,10 @@ describe('resyncInstalledHooks', () => {
70
70
  expect(synced).toContain('NEW VERSION');
71
71
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(false);
72
72
  expect(() => fs_1.default.accessSync(path_1.default.join(scriptsDir, 'session-start'), fs_1.default.constants.X_OK)).not.toThrow();
73
- expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(true);
73
+ // Task 6: using-awm.md is materialized (buildContext's output), not a symlink to the
74
+ // raw SKILL.md — resync must not regress the install path back to a raw symlink.
75
+ expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(false);
76
+ expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'using-awm.md'), 'utf-8')).toContain('MUST invoke skills.');
74
77
  });
75
78
  it('does NOT touch anything when the hook was never installed (no settings entry)', () => {
76
79
  writeRegistry('#!/usr/bin/env bash\necho "NEW VERSION"');
@@ -82,6 +85,39 @@ describe('resyncInstalledHooks', () => {
82
85
  ]);
83
86
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks/session-start'))).toBe(false);
84
87
  });
88
+ // Task 6, Step 5 regression guard: the exact staleness the plan calls "el hallazgo mas
89
+ // importante" — if resyncClaudeHookFiles ever regressed back to symlinking the raw
90
+ // SKILL.md (instead of writing buildContext's materialized output, same as
91
+ // installClaudeHook), `awm update` would silently strip declared orchestrators back
92
+ // out of Claude Code's context on the very next resync after a correct install.
93
+ it('re-materializes using-awm.md with declared orchestrators on resync, not a raw-SKILL.md symlink', () => {
94
+ const { writeRegistriesConfig, registryContentRoot } = require('../../../src/core/registries');
95
+ writeRegistriesConfig([{ name: 'declaring-resync-test', remote: 'unused' }]);
96
+ const registryRoot = registryContentRoot('declaring-resync-test');
97
+ const regHooks = path_1.default.join(registryRoot, 'hooks');
98
+ const regSkill = path_1.default.join(registryRoot, 'skills/using-awm');
99
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
100
+ fs_1.default.mkdirSync(regSkill, { recursive: true });
101
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
102
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
103
+ fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
104
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
105
+ const { installHook } = require('../../../src/commands/hooks/install');
106
+ installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
107
+ const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
108
+ const skillDest = path_1.default.join(scriptsDir, 'using-awm.md');
109
+ expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('mi-proceso');
110
+ const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
111
+ const results = resyncInstalledHooks(registryRoot);
112
+ expect(results).toEqual([
113
+ { agent: 'claude-code', action: 'resynced' },
114
+ { agent: 'codex', action: 'not-installed' },
115
+ ]);
116
+ expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false);
117
+ const content = fs_1.default.readFileSync(skillDest, 'utf-8');
118
+ expect(content).toContain('mi-proceso');
119
+ expect(content).toContain('MUST invoke skills.');
120
+ });
85
121
  it('preserves symlink install method', () => {
86
122
  writeRegistry('#!/usr/bin/env bash\necho "V2"');
87
123
  const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
@@ -96,7 +132,9 @@ describe('resyncInstalledHooks', () => {
96
132
  { agent: 'codex', action: 'not-installed' },
97
133
  ]);
98
134
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(true);
99
- expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(true);
135
+ // Task 6: using-awm.md is materialized regardless of the scripts' install method —
136
+ // it was never governed by `method` in the first place, before or after this change.
137
+ expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(false);
100
138
  });
101
139
  it('skips with registry-missing when the registry has no hooks dir', () => {
102
140
  const scriptsDir = path_1.default.join(tmpHome, '.awm/hooks');
@@ -8,6 +8,20 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const os_1 = __importDefault(require("os"));
10
10
  const child_process_1 = require("child_process");
11
+ // @clack/prompts ships as ESM; mock it so Jest (CommonJS mode) can load
12
+ // `commands/registry/index.ts` (same pattern as hooks/router.test.ts and
13
+ // commands/update.test.ts). Only the CLI-wiring describe block below exercises
14
+ // this — the rest of this file talks to `addRegistry` directly and never
15
+ // touches @clack/prompts.
16
+ jest.mock('@clack/prompts', () => ({
17
+ intro: jest.fn(),
18
+ outro: jest.fn(),
19
+ confirm: jest.fn(),
20
+ isCancel: jest.fn(() => false),
21
+ spinner: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })),
22
+ multiselect: jest.fn(),
23
+ select: jest.fn(),
24
+ }));
11
25
  const GIT = (cwd, cmd) => (0, child_process_1.execSync)(`git -c user.email=t@t.t -c user.name=t ${cmd}`, { cwd, stdio: 'pipe' });
12
26
  function makeSourceRepo(base, opts) {
13
27
  const dir = path_1.default.join(base, `src-${opts.skill ?? 'empty'}`);
@@ -212,6 +226,33 @@ describe('addRegistry', () => {
212
226
  const { readRegistriesConfig } = require('../../../src/core/registries');
213
227
  expect(readRegistriesConfig().map((r) => r.name)).toEqual(['personal']);
214
228
  });
229
+ it('reporta una declaracion de orquestador invalida sin abortar la instalacion', async () => {
230
+ // Registry local con layout valido y declaracion rota
231
+ const src = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-src-'));
232
+ fs_1.default.mkdirSync(path_1.default.join(src, 'skills/mi-proceso'), { recursive: true });
233
+ fs_1.default.writeFileSync(path_1.default.join(src, 'skills/mi-proceso/SKILL.md'), '---\nname: mi-proceso\n---\nx');
234
+ fs_1.default.writeFileSync(path_1.default.join(src, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'roto' } }));
235
+ GIT(src, 'init -q');
236
+ GIT(src, 'add -A');
237
+ GIT(src, 'commit -qm init');
238
+ const { addRegistry } = require('../../../src/commands/registry/add');
239
+ const result = await addRegistry(src, 'roto-reg');
240
+ expect(result.ok).toBe(true); // la instalacion NO se aborta por esto
241
+ expect(result.ok && result.orchestratorDiagnostics).toBeDefined();
242
+ expect(result.ok && result.orchestratorDiagnostics.join('\n')).toMatch(/appliesWhen/);
243
+ });
244
+ it('un registry sin declaracion se instala sin diagnosticos', async () => {
245
+ const src = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-src-'));
246
+ fs_1.default.mkdirSync(path_1.default.join(src, 'skills/otro'), { recursive: true });
247
+ fs_1.default.writeFileSync(path_1.default.join(src, 'skills/otro/SKILL.md'), '---\nname: otro\n---\nx');
248
+ GIT(src, 'init -q');
249
+ GIT(src, 'add -A');
250
+ GIT(src, 'commit -qm init');
251
+ const { addRegistry } = require('../../../src/commands/registry/add');
252
+ const result = await addRegistry(src, 'sin-decl');
253
+ expect(result.ok).toBe(true);
254
+ expect(result.ok && (result.orchestratorDiagnostics ?? [])).toEqual([]);
255
+ });
215
256
  });
216
257
  describe('registry add + bundle install (post-add flow)', () => {
217
258
  let tmpHome;
@@ -315,3 +356,66 @@ describe('registry add + bundle install (post-add flow)', () => {
315
356
  expect(fs_1.default.existsSync(skillLink)).toBe(false);
316
357
  });
317
358
  });
359
+ describe('registry add — CLI wiring prints orchestrator diagnostics', () => {
360
+ let tmpHome;
361
+ let tmpWork;
362
+ let originalHome;
363
+ let originalAwmHome;
364
+ beforeEach(() => {
365
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-regadd-cli-home-'));
366
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-regadd-cli-work-'));
367
+ originalHome = process.env.HOME;
368
+ originalAwmHome = process.env.AWM_HOME;
369
+ process.env.HOME = tmpHome;
370
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
371
+ jest.resetModules();
372
+ });
373
+ afterEach(() => {
374
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
375
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
376
+ if (originalHome === undefined)
377
+ delete process.env.HOME;
378
+ else
379
+ process.env.HOME = originalHome;
380
+ if (originalAwmHome === undefined)
381
+ delete process.env.AWM_HOME;
382
+ else
383
+ process.env.AWM_HOME = originalAwmHome;
384
+ });
385
+ // Task 2 added a `console.warn` loop after a successful `awm registry add` that
386
+ // prints each orchestrator diagnostic (`result.orchestratorDiagnostics`) returned
387
+ // by `addRegistry`. `addRegistry` itself is already covered above ("reporta una
388
+ // declaracion de orquestador invalida sin abortar la instalacion"), but nothing
389
+ // proved the CLI command actually surfaces those diagnostics to the user — this
390
+ // drives the real commander-wired `registry add` action end-to-end (same pattern
391
+ // as tests/commands/sensors/index.test.ts's `program.parseAsync`).
392
+ it('prints each orchestrator diagnostic via console.warn after a successful add', async () => {
393
+ const src = fs_1.default.mkdtempSync(path_1.default.join(tmpWork, 'awm-cli-src-'));
394
+ fs_1.default.mkdirSync(path_1.default.join(src, 'skills/mi-proceso'), { recursive: true });
395
+ fs_1.default.writeFileSync(path_1.default.join(src, 'skills/mi-proceso/SKILL.md'), '---\nname: mi-proceso\n---\nx');
396
+ // Broken orchestrator declaration: missing appliesWhen/terminatesTo.
397
+ fs_1.default.writeFileSync(path_1.default.join(src, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'roto' } }));
398
+ GIT(src, 'init -q');
399
+ GIT(src, 'add -A');
400
+ GIT(src, 'commit -qm init');
401
+ const { Command } = require('commander');
402
+ const { registerRegistryCommand } = require('../../../src/commands/registry/index');
403
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
404
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
405
+ try {
406
+ const program = new Command();
407
+ registerRegistryCommand(program);
408
+ await program.parseAsync(['node', 'awm', 'registry', 'add', src, '--name', 'cli-roto', '--no-install']);
409
+ expect(warnSpy).toHaveBeenCalled();
410
+ const warned = warnSpy.mock.calls.map((c) => String(c[0])).join('\n');
411
+ expect(warned).toMatch(/appliesWhen/);
412
+ expect(warned).toMatch(/terminatesTo/);
413
+ const { readRegistriesConfig } = require('../../../src/core/registries');
414
+ expect(readRegistriesConfig().map((r) => r.name)).toEqual(['cli-roto']);
415
+ }
416
+ finally {
417
+ warnSpy.mockRestore();
418
+ logSpy.mockRestore();
419
+ }
420
+ });
421
+ });
@@ -53,4 +53,31 @@ describe('runCompatibilityProbe', () => {
53
53
  await (0, probe_1.runCompatibilityProbe)({ kind: 'version' }, { ...evidence, toolExecutable, toolResolution: 'python-environment' }, fakeExecutor);
54
54
  expect(fakeExecutor).toHaveBeenCalledWith(expect.objectContaining({ executable: toolExecutable, resolution: 'python-environment', args: ['--version'] }), expect.any(Object));
55
55
  });
56
+ // A pack asset is never named the tool's own default (e.g. `eslint.config.awm.mjs`,
57
+ // not `eslint.config.js`) so the real command always pins it via `--config`. A probe
58
+ // that omits the same flag never finds a config to load and always reports
59
+ // not-matched — a false negative on every AWM-configured project. The probe must
60
+ // mirror the variant's own `--config` argument, not guess a bare invocation.
61
+ it('carries the variant\'s --config argument into an eslint-print-config probe', async () => {
62
+ await (0, probe_1.runCompatibilityProbe)({ kind: 'eslint-print-config' }, {
63
+ ...evidence, variantArgs: ['.', '--config', 'eslint.config.awm.mjs', '--cache', '--format', 'json'],
64
+ }, fakeExecutor);
65
+ expect(fakeExecutor).toHaveBeenCalledWith(expect.objectContaining({ args: ['--config', 'eslint.config.awm.mjs', '--print-config', 'eslint.config.js'] }), expect.any(Object));
66
+ });
67
+ it('carries the variant\'s --config argument into a typescript-show-config probe', async () => {
68
+ await (0, probe_1.runCompatibilityProbe)({ kind: 'typescript-show-config' }, {
69
+ ...evidence, variantArgs: ['--config', 'tsconfig.awm.json', '--noEmit'],
70
+ }, fakeExecutor);
71
+ expect(fakeExecutor).toHaveBeenCalledWith(expect.objectContaining({ args: ['--config', 'tsconfig.awm.json', '--showConfig'] }), expect.any(Object));
72
+ });
73
+ it('carries the variant\'s --config argument into a semgrep-validate probe', async () => {
74
+ await (0, probe_1.runCompatibilityProbe)({ kind: 'semgrep-validate' }, {
75
+ ...evidence, variantArgs: ['--config', '.semgrep.awm.yml', '--json', '.'],
76
+ }, fakeExecutor);
77
+ expect(fakeExecutor).toHaveBeenCalledWith(expect.objectContaining({ args: ['--config', '.semgrep.awm.yml', '--validate'] }), expect.any(Object));
78
+ });
79
+ it('omits --config from the probe when the variant command declares none', async () => {
80
+ await (0, probe_1.runCompatibilityProbe)({ kind: 'eslint-print-config' }, { ...evidence, variantArgs: ['.', '--cache'] }, fakeExecutor);
81
+ expect(fakeExecutor).toHaveBeenCalledWith(expect.objectContaining({ args: ['--print-config', 'eslint.config.js'] }), expect.any(Object));
82
+ });
56
83
  });
@@ -182,6 +182,64 @@ describe('computeSensorStatus', () => {
182
182
  expect(result.pack).toBe('python');
183
183
  expect(result.checks).toEqual({});
184
184
  });
185
+ it('resolves v2 compatibility and structured-command assets against packageRoot in a monorepo', async () => {
186
+ // Monorepo support (mirrors run.ts/init.ts): the manifest lives at the repo
187
+ // root, but package.json/node_modules/the config asset all live under the
188
+ // declared packageRoot subdirectory. Without threading packageRoot through,
189
+ // detection finds no package.json at the manifest's own directory and every
190
+ // sensor reads back "not-applicable" — a false compatibility drift on every
191
+ // check, even immediately after a correct `awm sensors init --package-root`.
192
+ const previousHome = process.env.AWM_HOME;
193
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-home-'));
194
+ try {
195
+ process.env.AWM_HOME = home;
196
+ const registry = path_1.default.join(home, 'registries', 'baseline');
197
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'sensor-packs', 'js-ts'), { recursive: true });
198
+ fs_1.default.writeFileSync(path_1.default.join(home, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.test/baseline.git' }]));
199
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'sensor-packs', 'js-ts', 'pack.json'), JSON.stringify({
200
+ schemaVersion: 2, name: 'js-ts', description: 'test', detects: ['package.json'],
201
+ coverage: { schemaVersion: 1, classes: { lint: { description: 'lint', detectors: [{ sensor: 'lint' }], remedy: { summary: 'fix lint', command: 'awm sensors init --pack js-ts' } } } },
202
+ sensors: { lint: {
203
+ applicability: { allFiles: ['package.json'] },
204
+ variants: [{
205
+ id: 'eslint-10', priority: 10, certifiedRange: '>=10.0.0 <11.0.0',
206
+ requirements: { tool: 'eslint', toolRange: '>=10.0.0 <11.0.0', runtime: 'node', runtimeRange: '>=0.0.0' },
207
+ assets: ['eslint.config.awm.mjs'], formatter: 'eslint-llm', probe: { kind: 'package-script-present' },
208
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.', '--config', 'eslint.config.awm.mjs'] },
209
+ }],
210
+ } },
211
+ }));
212
+ const packageDir = path_1.default.join(tmpDir, 'cli');
213
+ fs_1.default.mkdirSync(packageDir, { recursive: true });
214
+ fs_1.default.writeFileSync(path_1.default.join(packageDir, 'package.json'), JSON.stringify({ devDependencies: { eslint: '^10.0.0' }, scripts: { lint: 'eslint .' } }));
215
+ fs_1.default.mkdirSync(path_1.default.join(packageDir, 'node_modules', 'eslint'), { recursive: true });
216
+ fs_1.default.writeFileSync(path_1.default.join(packageDir, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ version: '10.4.1' }));
217
+ fs_1.default.mkdirSync(path_1.default.join(packageDir, 'node_modules', '.bin'), { recursive: true });
218
+ fs_1.default.writeFileSync(path_1.default.join(packageDir, 'node_modules', '.bin', 'eslint'), '');
219
+ fs_1.default.writeFileSync(path_1.default.join(packageDir, 'eslint.config.awm.mjs'), 'export default []');
220
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
221
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
222
+ schemaVersion: 2, pack: 'js-ts', packageRoot: 'cli', registryRoot: registry,
223
+ sensors: { lint: {
224
+ enabled: true, variantId: 'eslint-10', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.', '--config', 'eslint.config.awm.mjs'] },
225
+ assets: ['eslint.config.awm.mjs'],
226
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: '10.4.1', runtimeVersion: process.versions.node, certifiedRange: '>=10.0.0 <11.0.0', evidence: [] },
227
+ } },
228
+ }));
229
+ const result = await (0, status_1.computeSensorStatus)(tmpDir);
230
+ expect(result.checks.lint).toMatchObject({ ok: true });
231
+ expect(result.overall).toBe('READY');
232
+ expect(runCommand).not.toHaveBeenCalled();
233
+ expect(runStructuredCommand).not.toHaveBeenCalled();
234
+ }
235
+ finally {
236
+ if (previousHome === undefined)
237
+ delete process.env.AWM_HOME;
238
+ else
239
+ process.env.AWM_HOME = previousHome;
240
+ fs_1.default.rmSync(home, { recursive: true, force: true });
241
+ }
242
+ });
185
243
  it('marks disabled sensors as ok', async () => {
186
244
  fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
187
245
  fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
@@ -17,6 +17,26 @@ jest.mock('../../../src/commands/hooks/status', () => ({
17
17
  const install_1 = require("../../../src/commands/hooks/install");
18
18
  const uninstall_1 = require("../../../src/commands/hooks/uninstall");
19
19
  const status_1 = require("../../../src/commands/hooks/status");
20
+ const registries_1 = require("../../../src/core/registries");
21
+ // inputFor/statusInputFor now call listRegistries() (collectDeclaredOrchestrators) on every
22
+ // install/status. Without isolating AWM_HOME here, that reads the REAL ~/.awm/registries.json
23
+ // of whatever machine runs the suite — exactly what CLAUDE.md's testing rule forbids ("ningun
24
+ // test puede tocar el ~/.awm real. Todos usan tmpdirs aislados"). An empty isolated AWM_HOME
25
+ // makes listRegistries() return [] deterministically, matching pre-change behavior everywhere.
26
+ let isolatedAwmHome;
27
+ let originalAwmHomeEnv;
28
+ beforeEach(() => {
29
+ isolatedAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-isolated-home-'));
30
+ originalAwmHomeEnv = process.env.AWM_HOME;
31
+ process.env.AWM_HOME = isolatedAwmHome;
32
+ });
33
+ afterEach(() => {
34
+ if (originalAwmHomeEnv === undefined)
35
+ delete process.env.AWM_HOME;
36
+ else
37
+ process.env.AWM_HOME = originalAwmHomeEnv;
38
+ fs_1.default.rmSync(isolatedAwmHome, { recursive: true, force: true });
39
+ });
20
40
  function tmpRegistry() {
21
41
  const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-'));
22
42
  const dir = path_1.default.join(root, 'skills/using-awm');
@@ -221,3 +241,69 @@ describe('InjectionOrchestrator (local scope materializes under the project, not
221
241
  expect(orch.contextStatus(op)).toBe('injected');
222
242
  });
223
243
  });
244
+ describe('InjectionOrchestrator (declared orchestrators from an installed registry reach status too)', () => {
245
+ // Regression guard for R5.1's wiring: installContext (inputFor) and contextStatus
246
+ // (statusInputFor) must collect the SAME declared-orchestrator set. If only inputFor
247
+ // did, the materialized file's hash would include the declared orchestrator while
248
+ // statusInputFor's "expected" hash would not — contextStatus would report 'stale'
249
+ // forever, even immediately after a correct install.
250
+ it('contextStatus reports injected (not stale) right after installing with a declared orchestrator', () => {
251
+ // The global beforeEach already points AWM_HOME at an isolated, empty home —
252
+ // register one real registry in it so listRegistries()/collectDeclaredOrchestrators
253
+ // actually has something to find.
254
+ (0, registries_1.writeRegistriesConfig)([{ name: 'declaring', remote: 'unused' }]);
255
+ const registryRoot = (0, registries_1.registryContentRoot)('declaring');
256
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/using-awm'), { recursive: true });
257
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/using-awm/SKILL.md'), '---\nversion: "1.0.0"\n---\nBODY');
258
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({
259
+ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' },
260
+ }));
261
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-oc-declared-'));
262
+ const configPath = path_1.default.join(dir, 'opencode.json');
263
+ const absPath = path_1.default.join(dir, 'awm-context.md');
264
+ const orch = new orchestrator_1.InjectionOrchestrator({
265
+ providerOverride: {
266
+ label: 'OpenCode', configHome: { envVar: null, dir: '.test', resolved: '/tmp/test-config-home' }, skill: { global: '', local: '', renderer: 'link' }, workflow: null, agent: null,
267
+ injection: { type: 'config-instructions', configPath, field: 'instructions' },
268
+ },
269
+ contextPathOverride: absPath,
270
+ });
271
+ const args = { agent: 'opencode', scope: 'global', registryRoot, installMethod: 'symlink', profileExtensions: [] };
272
+ orch.installContext(args);
273
+ expect(fs_1.default.readFileSync(absPath, 'utf-8')).toContain('mi-proceso');
274
+ expect(orch.contextStatus(args)).toBe('injected');
275
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
276
+ });
277
+ it('installContext still succeeds when a DIFFERENT installed registry has a broken declaration', () => {
278
+ // Two registries installed: "broken" has an unparsable awm-registry.json, "clean"
279
+ // is the one actually being operated on. The broken one must not block install.
280
+ (0, registries_1.writeRegistriesConfig)([
281
+ { name: 'broken', remote: 'unused' },
282
+ { name: 'clean', remote: 'unused' },
283
+ ]);
284
+ const brokenRoot = (0, registries_1.registryContentRoot)('broken');
285
+ fs_1.default.mkdirSync(brokenRoot, { recursive: true });
286
+ fs_1.default.writeFileSync(path_1.default.join(brokenRoot, 'awm-registry.json'), '{ not json');
287
+ const registryRoot = (0, registries_1.registryContentRoot)('clean');
288
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/using-awm'), { recursive: true });
289
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/using-awm/SKILL.md'), '---\nversion: "1.0.0"\n---\nBODY');
290
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-oc-brokensibling-'));
291
+ const configPath = path_1.default.join(dir, 'opencode.json');
292
+ const absPath = path_1.default.join(dir, 'awm-context.md');
293
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
294
+ const orch = new orchestrator_1.InjectionOrchestrator({
295
+ providerOverride: {
296
+ label: 'OpenCode', configHome: { envVar: null, dir: '.test', resolved: '/tmp/test-config-home' }, skill: { global: '', local: '', renderer: 'link' }, workflow: null, agent: null,
297
+ injection: { type: 'config-instructions', configPath, field: 'instructions' },
298
+ },
299
+ contextPathOverride: absPath,
300
+ });
301
+ const args = { agent: 'opencode', scope: 'global', registryRoot, installMethod: 'symlink', profileExtensions: [] };
302
+ expect(() => orch.installContext(args)).not.toThrow();
303
+ expect(fs_1.default.readFileSync(absPath, 'utf-8')).toContain('BODY');
304
+ expect(orch.contextStatus(args)).toBe('injected');
305
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('warning:'));
306
+ warnSpy.mockRestore();
307
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
308
+ });
309
+ });