agentic-workflow-manager 8.2.1 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/commands/doctor.js +40 -1
  2. package/dist/src/commands/hooks/claude.js +47 -31
  3. package/dist/src/commands/hooks/index.js +1 -1
  4. package/dist/src/commands/hooks/shared.js +1 -1
  5. package/dist/src/commands/registry/add.js +6 -1
  6. package/dist/src/commands/registry/index.js +3 -0
  7. package/dist/src/core/context/orchestrator.js +15 -2
  8. package/dist/src/core/context/provider.js +29 -1
  9. package/dist/src/core/context/regenerate.js +29 -0
  10. package/dist/src/core/dashboard/collect.js +170 -0
  11. package/dist/src/core/dashboard/plan-state.js +44 -0
  12. package/dist/src/core/dashboard/render-html.js +82 -0
  13. package/dist/src/core/dashboard/render-terminal.js +43 -0
  14. package/dist/src/core/dashboard/sanitize.js +62 -0
  15. package/dist/src/core/dashboard/styles.js +51 -0
  16. package/dist/src/core/dashboard/types.js +21 -0
  17. package/dist/src/core/dashboard/validate.js +106 -0
  18. package/dist/src/core/dashboard/write-html.js +88 -0
  19. package/dist/src/core/diagnostics/context.js +1 -1
  20. package/dist/src/core/orchestrators.js +142 -0
  21. package/dist/tests/commands/doctor-is-read-only.test.js +54 -1
  22. package/dist/tests/commands/doctor.test.js +160 -0
  23. package/dist/tests/commands/hooks/install-symlink-fallback.test.js +13 -4
  24. package/dist/tests/commands/hooks/install.test.js +121 -3
  25. package/dist/tests/commands/hooks/resync.test.js +40 -2
  26. package/dist/tests/commands/registry/add.test.js +104 -0
  27. package/dist/tests/core/context/orchestrator.test.js +86 -0
  28. package/dist/tests/core/context/provider.test.js +137 -0
  29. package/dist/tests/core/context/regenerate.test.js +56 -0
  30. package/dist/tests/core/dashboard/collect.test.js +173 -0
  31. package/dist/tests/core/dashboard/contracts.test.js +92 -0
  32. package/dist/tests/core/dashboard/plan-state.test.js +32 -0
  33. package/dist/tests/core/dashboard/production-adapters.test.js +70 -0
  34. package/dist/tests/core/dashboard/render-html.test.js +175 -0
  35. package/dist/tests/core/dashboard/render-terminal.test.js +65 -0
  36. package/dist/tests/core/dashboard/write-html.test.js +112 -0
  37. package/dist/tests/core/orchestrators.test.js +236 -0
  38. package/dist/tests/helpers/dashboard-fixtures.js +66 -0
  39. package/package.json +1 -1
@@ -0,0 +1,142 @@
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
+ exports.readDeclaredOrchestrators = readDeclaredOrchestrators;
7
+ exports.collectDeclaredOrchestrators = collectDeclaredOrchestrators;
8
+ exports.collectAndWarn = collectAndWarn;
9
+ // cli/src/core/orchestrators.ts
10
+ // Lector de declaraciones de orquestador. A diferencia de readRegistryManifest
11
+ // (registries.ts), este parser NUNCA lanza: una declaracion malformada se
12
+ // rechaza y se reporta, sin invalidar el registry que la contiene ni a los
13
+ // demas (R1.2). El contrato admite exactamente cuatro campos — identidad,
14
+ // cuando aplica, y a quien cede el control — y rechaza cualquier otro, que
15
+ // es como se impide que vocabulario de un proceso concreto (o un secreto)
16
+ // entre al framework (R1.3, R5.3).
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const registries_1 = require("./registries");
20
+ const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
21
+ // These fields are semantically short (a short identity, a short trigger condition, a
22
+ // short target name) — no legitimate declaration needs more than this. A registry is
23
+ // untrusted input whose fields flow straight into the AI-provider context payload, so an
24
+ // unbounded string here would let a crafted registry bloat/DoS that context.
25
+ const MAX_FIELD_LENGTH = 500;
26
+ function readDeclaredOrchestrators(root) {
27
+ const file = path_1.default.join(root, registries_1.REGISTRY_MANIFEST_NAME);
28
+ // Shares the same trust boundary as readRegistryManifest (registries.ts): a manifest
29
+ // that is a symlink (or otherwise not a regular file) is rejected rather than followed.
30
+ // assertRegularRegistryFile throws on that case, so it's wrapped locally — this reader
31
+ // must never throw, only report (R1.2).
32
+ let exists;
33
+ try {
34
+ exists = (0, registries_1.assertRegularRegistryFile)(file);
35
+ }
36
+ catch (e) {
37
+ return { orchestrators: [], diagnostics: [`${file}: ${e instanceof Error ? e.message : String(e)}`] };
38
+ }
39
+ if (!exists)
40
+ return { orchestrators: [], diagnostics: [] };
41
+ let contents;
42
+ try {
43
+ contents = fs_1.default.readFileSync(file, 'utf-8');
44
+ }
45
+ catch (e) {
46
+ return { orchestrators: [], diagnostics: [`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`] };
47
+ }
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(contents);
51
+ }
52
+ catch (e) {
53
+ return { orchestrators: [], diagnostics: [`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`] };
54
+ }
55
+ const decl = raw?.orchestrator;
56
+ if (decl === undefined)
57
+ return { orchestrators: [], diagnostics: [] };
58
+ if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) {
59
+ return { orchestrators: [], diagnostics: [`${file}: "orchestrator" must be an object`] };
60
+ }
61
+ const problems = [];
62
+ const entries = decl;
63
+ for (const key of Object.keys(entries)) {
64
+ if (!ALLOWED_FIELDS.includes(key)) {
65
+ // key comes straight from an untrusted registry's JSON — JSON.stringify keeps the
66
+ // diagnostic single-line and unambiguous even if the key contains newlines or other
67
+ // control characters, which would otherwise let a crafted key forge extra log lines.
68
+ problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
69
+ }
70
+ }
71
+ for (const field of ALLOWED_FIELDS) {
72
+ const value = entries[field];
73
+ if (typeof value !== 'string' || value.trim() === '') {
74
+ problems.push(`"${field}" must be a non-empty string`);
75
+ }
76
+ else if (value.length > MAX_FIELD_LENGTH) {
77
+ problems.push(`"${field}" must be at most ${MAX_FIELD_LENGTH} characters`);
78
+ }
79
+ }
80
+ if (problems.length > 0) {
81
+ return { orchestrators: [], diagnostics: [`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`] };
82
+ }
83
+ return {
84
+ orchestrators: [{
85
+ name: entries.name,
86
+ appliesWhen: entries.appliesWhen,
87
+ terminatesTo: entries.terminatesTo,
88
+ }],
89
+ diagnostics: [],
90
+ };
91
+ }
92
+ /**
93
+ * Recolecta declaraciones de orquestador de TODOS los registries instalados (no solo
94
+ * el que se esta operando) y diagnosticos de las que estan rotas. Nunca lanza:
95
+ * `readDeclaredOrchestrators` ya garantiza eso por-registry (R1.2), asi que un registry
96
+ * con declaracion rota se omite del resultado sin impedir construir el contexto (R5.1).
97
+ *
98
+ * Vive aca (no en core/context/orchestrator.ts, que la definia originalmente) porque
99
+ * este modulo es una hoja: solo depende de `./registries`, que a su vez no depende de
100
+ * nada bajo commands/*. core/context/orchestrator.ts en cambio arrastra
101
+ * strategies/hook-merge.ts, que importa commands/hooks/install.ts — y claude.ts
102
+ * necesita esta funcion para cerrar el bypass del SKILL.md crudo (Task 6). Si
103
+ * `collectAndWarn` siguiera viviendo en orchestrator.ts, que commands/hooks/claude.ts
104
+ * la importara cerraria un ciclo real: claude.ts -> orchestrator.ts ->
105
+ * strategies/hook-merge.ts -> commands/hooks/install.ts -> claude.ts.
106
+ *
107
+ * Dedupe por "name" entre registries: dos registries instalados pueden declarar el mismo
108
+ * nombre (posiblemente con appliesWhen/terminatesTo distintos y contradictorios). En vez
109
+ * de emitir ambas filas al markdown compuesto, gana la primera en el orden de
110
+ * listRegistries() (= orden de registries.json, ver registries.ts) y la duplicada se
111
+ * descarta con un diagnostico — misma degradacion tolerante (reportar, no lanzar) que el
112
+ * resto de este modulo (R1.2, R5.1).
113
+ */
114
+ function collectDeclaredOrchestrators() {
115
+ const declared = [];
116
+ const diagnostics = [];
117
+ const seenNames = new Set();
118
+ for (const reg of (0, registries_1.listRegistries)()) {
119
+ const r = readDeclaredOrchestrators(reg.contentRoot);
120
+ for (const orch of r.orchestrators) {
121
+ if (seenNames.has(orch.name)) {
122
+ const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
123
+ diagnostics.push(`${file}: orchestrator "${orch.name}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
124
+ continue;
125
+ }
126
+ seenNames.add(orch.name);
127
+ declared.push(orch);
128
+ }
129
+ diagnostics.push(...r.diagnostics);
130
+ }
131
+ return { declared, diagnostics };
132
+ }
133
+ /** Recolecta declarados y emite sus diagnosticos como warnings. Punto unico usado por
134
+ * `InjectionOrchestrator.inputFor`/`statusInputFor` y por `commands/hooks/claude.ts`
135
+ * para que todos permanezcan sincronizados por construccion (ver R5.1 y el bug de
136
+ * staleness que motivo esta extraccion). */
137
+ function collectAndWarn() {
138
+ const { declared, diagnostics } = collectDeclaredOrchestrators();
139
+ for (const d of diagnostics)
140
+ console.warn(`warning: ${d}`);
141
+ return declared;
142
+ }
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  const fs_1 = __importDefault(require("fs"));
22
22
  const os_1 = __importDefault(require("os"));
23
23
  const path_1 = __importDefault(require("path"));
24
+ const child_process_1 = require("child_process");
24
25
  describe('awm doctor no escribe nada', () => {
25
26
  let home;
26
27
  let projectRoot;
@@ -32,7 +33,8 @@ describe('awm doctor no escribe nada', () => {
32
33
  process.env.HOME = home;
33
34
  process.env.AWM_HOME = path_1.default.join(home, '.awm');
34
35
  projectRoot = path_1.default.join(home, 'proj');
35
- fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.git'), { recursive: true });
36
+ fs_1.default.mkdirSync(projectRoot, { recursive: true });
37
+ (0, child_process_1.execFileSync)('git', ['init', '-q', projectRoot]);
36
38
  jest.resetModules();
37
39
  writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
38
40
  });
@@ -62,6 +64,20 @@ describe('awm doctor no escribe nada', () => {
62
64
  walk('.');
63
65
  return out.sort();
64
66
  };
67
+ const bytesOf = (dir) => {
68
+ const out = [];
69
+ const walk = (sub) => {
70
+ for (const entry of fs_1.default.readdirSync(path_1.default.join(dir, sub), { withFileTypes: true })) {
71
+ const rel = path_1.default.join(sub, entry.name);
72
+ if (entry.isDirectory())
73
+ walk(rel);
74
+ else
75
+ out.push([rel, fs_1.default.readFileSync(path_1.default.join(dir, rel)).toString('base64')]);
76
+ }
77
+ };
78
+ walk('.');
79
+ return out.sort(([left], [right]) => left.localeCompare(right));
80
+ };
65
81
  it('no crea AWM_HOME ni preferences.json en una máquina limpia', () => {
66
82
  const { runDoctor } = require('../../src/commands/doctor');
67
83
  const before = treeOf(home);
@@ -94,4 +110,41 @@ describe('awm doctor no escribe nada', () => {
94
110
  const emitted = writeSpy.mock.calls.map((c) => String(c[0])).join('');
95
111
  expect(JSON.parse(emitted).providers.map((p) => p.id)).toEqual(['claude-code']);
96
112
  });
113
+ it('collecting the dashboard leaves project, preferences, journal, ledger, and git bytes unchanged', () => {
114
+ fs_1.default.mkdirSync(path_1.default.join(home, '.awm', 'ledger'), { recursive: true });
115
+ fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'preferences.json'), '{"defaultAgent":"claude-code","enabledAgents":["claude-code"],"installMethod":"symlink","defaultScope":"local"}\n');
116
+ fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'ledger', 'events.jsonl'), '{"event":"before"}\n');
117
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm', 'journal'), { recursive: true });
118
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), '{"extensions":[]}\n');
119
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'journal', 'state.json'), '{"state":"active"}\n');
120
+ const before = { home: bytesOf(home), project: bytesOf(projectRoot), git: (() => { try {
121
+ return (0, child_process_1.execFileSync)('git', ['status', '--porcelain=v1'], { cwd: projectRoot, encoding: 'utf8' });
122
+ }
123
+ catch (error) {
124
+ return String(error);
125
+ } })() };
126
+ const { collectDashboardSnapshot } = require('../../src/core/dashboard/collect');
127
+ collectDashboardSnapshot({ cwd: projectRoot, now: '2026-08-22T00:00:00.000Z' });
128
+ const after = { home: bytesOf(home), project: bytesOf(projectRoot), git: (() => { try {
129
+ return (0, child_process_1.execFileSync)('git', ['status', '--porcelain=v1'], { cwd: projectRoot, encoding: 'utf8' });
130
+ }
131
+ catch (error) {
132
+ return String(error);
133
+ } })() };
134
+ expect(after).toEqual(before);
135
+ });
136
+ it('--html replaces only its explicitly requested target', () => {
137
+ const target = path_1.default.join(projectRoot, 'dashboard.html');
138
+ fs_1.default.writeFileSync(target, 'old dashboard');
139
+ const before = { home: bytesOf(home), project: bytesOf(projectRoot) };
140
+ const { runDoctor } = require('../../src/commands/doctor');
141
+ const code = runDoctor({ cwd: projectRoot, html: target, force: true });
142
+ const after = { home: bytesOf(home), project: bytesOf(projectRoot) };
143
+ expect([0, 1]).toContain(code);
144
+ const targetFromHome = path_1.default.join('proj', 'dashboard.html');
145
+ expect(after.home.filter(([name]) => name !== targetFromHome)).toEqual(before.home.filter(([name]) => name !== targetFromHome));
146
+ expect(after.project.filter(([name]) => name !== 'dashboard.html')).toEqual(before.project.filter(([name]) => name !== 'dashboard.html'));
147
+ expect(fs_1.default.readFileSync(target, 'utf8')).not.toBe('old dashboard');
148
+ expect(after.project.map(([name]) => name).filter((name) => name.includes('.tmp'))).toEqual([]);
149
+ });
97
150
  });
@@ -4,9 +4,169 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const doctor_1 = require("../../src/commands/doctor");
7
+ const commander_1 = require("commander");
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const os_1 = __importDefault(require("os"));
9
10
  const path_1 = __importDefault(require("path"));
11
+ const dashboard_fixtures_1 = require("../helpers/dashboard-fixtures");
12
+ const types_1 = require("../../src/core/dashboard/types");
13
+ const render_html_1 = require("../../src/core/dashboard/render-html");
14
+ describe('runDoctor legacy JSON fixtures', () => {
15
+ it.each(['bare-home', 'project'])('keeps %s JSON byte-for-byte compatible', (kind) => {
16
+ const captured = (0, dashboard_fixtures_1.captureDoctorJsonFixture)(kind);
17
+ try {
18
+ const expected = fs_1.default.readFileSync(path_1.default.join(__dirname, '..', 'fixtures', 'doctor-json', `${kind}.json`), 'utf-8');
19
+ expect(captured.output).toBe(expected);
20
+ expect(captured.code).toBe(1);
21
+ const parsed = JSON.parse(captured.output);
22
+ expect(parsed).toEqual(expect.objectContaining({
23
+ overall: 'degraded',
24
+ providers: expect.any(Array),
25
+ }));
26
+ const provider = parsed.providers[0];
27
+ expect(provider).toEqual(expect.objectContaining({
28
+ id: 'copilot',
29
+ label: 'Copilot',
30
+ tier: 'agents-md-managed',
31
+ checks: expect.any(Array),
32
+ }));
33
+ expect(provider.checks).toEqual(expect.arrayContaining([
34
+ expect.objectContaining({ id: 'context.global', state: kind === 'project' ? 'stale' : 'absent' }),
35
+ ]));
36
+ }
37
+ finally {
38
+ captured.cleanup();
39
+ }
40
+ });
41
+ });
42
+ describe('runDoctor dashboard modes', () => {
43
+ it('CLI rejects --html without an argument before collection or writes', async () => {
44
+ const program = new commander_1.Command();
45
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
46
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
47
+ const previousExitCode = process.exitCode;
48
+ try {
49
+ (0, doctor_1.registerDoctorCommand)(program);
50
+ await program.parseAsync(['node', 'awm', 'doctor', '--html']);
51
+ expect(process.exitCode).toBe(2);
52
+ expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('--html requires a file target');
53
+ expect(stdout).not.toHaveBeenCalled();
54
+ }
55
+ finally {
56
+ process.exitCode = previousExitCode;
57
+ stderr.mockRestore();
58
+ stdout.mockRestore();
59
+ }
60
+ });
61
+ it.each([
62
+ [{ json: true, full: true }, '--json cannot be combined with --full'],
63
+ [{ json: true, html: 'report.html' }, '--json cannot be combined with --html'],
64
+ [{ full: true, html: 'report.html' }, '--full cannot be combined with --html'],
65
+ [{ force: true }, '--force requires --html'],
66
+ [{ html: '' }, '--html requires a file target'],
67
+ ])('rejects incompatible options before collection', (options, message) => {
68
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
69
+ try {
70
+ expect((0, doctor_1.runDoctor)({ ...options, collectSnapshot: () => { throw new Error('collection must not run'); } })).toBe(2);
71
+ expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain(message);
72
+ }
73
+ finally {
74
+ stderr.mockRestore();
75
+ }
76
+ });
77
+ it('writes exact dashboard bytes and only prints the final path after successful --html', () => {
78
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-html-success-'));
79
+ const target = path_1.default.join(root, 'dashboard.html');
80
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
81
+ const snapshot = (0, types_1.dashboardSnapshot)();
82
+ try {
83
+ expect((0, doctor_1.runDoctor)({ cwd: root, html: target, collectSnapshot: () => snapshot })).toBe(0);
84
+ expect(fs_1.default.readFileSync(target, 'utf8')).toBe((0, render_html_1.renderDashboardHtml)(snapshot));
85
+ expect(stdout.mock.calls.map((call) => String(call[0]))).toEqual([`${target}\n`]);
86
+ }
87
+ finally {
88
+ stdout.mockRestore();
89
+ fs_1.default.rmSync(root, { recursive: true, force: true });
90
+ }
91
+ });
92
+ it('maps healthy full, invalid, and failing HTML modes to 0, 2, and 2', () => {
93
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
94
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
95
+ try {
96
+ expect((0, doctor_1.runDoctor)({ full: true, cwd: '/definitely-not-a-project', collectSnapshot: () => (0, types_1.dashboardSnapshot)() })).toBe(0);
97
+ expect((0, doctor_1.runDoctor)({ html: '' })).toBe(2);
98
+ expect((0, doctor_1.runDoctor)({ html: '/definitely-missing-parent/report.html' })).toBe(2);
99
+ }
100
+ finally {
101
+ stdout.mockRestore();
102
+ stderr.mockRestore();
103
+ }
104
+ });
105
+ it.each([{ full: true }, { html: 'report.html', force: true }])('returns 1 for a degraded dashboard mode', (options) => {
106
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-degraded-'));
107
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
108
+ try {
109
+ expect((0, doctor_1.runDoctor)({ ...options, cwd: root, collectSnapshot: () => (0, types_1.dashboardSnapshot)({ overall: 'degraded' }) })).toBe(1);
110
+ }
111
+ finally {
112
+ stdout.mockRestore();
113
+ fs_1.default.rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+ it('renders a real read-only machine finding and its remediation in full mode', () => {
117
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-real-dashboard-'));
118
+ const previousHome = process.env.HOME;
119
+ const previousAwmHome = process.env.AWM_HOME;
120
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
121
+ try {
122
+ process.env.HOME = home;
123
+ process.env.AWM_HOME = path_1.default.join(home, '.awm');
124
+ expect((0, doctor_1.runDoctor)({ full: true, cwd: home })).toBe(1);
125
+ const output = stdout.mock.calls.map((call) => String(call[0])).join('');
126
+ expect(output).toContain('machine.preferences.missing');
127
+ expect(output).toContain('awm init');
128
+ expect(fs_1.default.existsSync(path_1.default.join(home, '.awm', 'preferences.json'))).toBe(false);
129
+ }
130
+ finally {
131
+ stdout.mockRestore();
132
+ fs_1.default.rmSync(home, { recursive: true, force: true });
133
+ if (previousHome === undefined)
134
+ delete process.env.HOME;
135
+ else
136
+ process.env.HOME = previousHome;
137
+ if (previousAwmHome === undefined)
138
+ delete process.env.AWM_HOME;
139
+ else
140
+ process.env.AWM_HOME = previousAwmHome;
141
+ }
142
+ });
143
+ it('wires existing read-only project facts into the dashboard without inventing lifecycle observations', () => {
144
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-project-facts-'));
145
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
146
+ try {
147
+ fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}');
148
+ expect((0, doctor_1.runDoctor)({ full: true, cwd: root })).toBe(1);
149
+ const output = stdout.mock.calls.map((call) => String(call[0])).join('');
150
+ expect(output).toContain('project.profile.missing');
151
+ expect(output).toContain('project.sensors.unavailable');
152
+ expect(output).toContain('unavailable');
153
+ }
154
+ finally {
155
+ stdout.mockRestore();
156
+ fs_1.default.rmSync(root, { recursive: true, force: true });
157
+ }
158
+ });
159
+ it('applies doctor agent selection validation to full dashboard mode', () => {
160
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
161
+ try {
162
+ expect((0, doctor_1.runDoctor)({ full: true, cwd: '/definitely-not-a-project', agent: 'not-a-real-agent' })).toBe(2);
163
+ expect(stderr.mock.calls.map((call) => String(call[0])).join('')).toContain('Invalid agent');
164
+ }
165
+ finally {
166
+ stderr.mockRestore();
167
+ }
168
+ });
169
+ });
10
170
  function report(partial = {}) {
11
171
  return {
12
172
  results: [
@@ -6,7 +6,7 @@ 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
- describe('hooks/install — skill symlink fallback to copy', () => {
9
+ describe('hooks/install — symlink fallback to copy', () => {
10
10
  let tmpHome;
11
11
  let origHome;
12
12
  let origAwmHome;
@@ -40,10 +40,18 @@ describe('hooks/install — skill symlink fallback to copy', () => {
40
40
  fs_1.default.writeFileSync(path_1.default.join(hooksDir, 'run-hook.cmd'), '#!/bin/sh\n');
41
41
  fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), '# using-awm\n');
42
42
  }
43
- it('copies the skill when symlink throws (EPERM), preserving content', () => {
43
+ // Historical note: using-awm.md used to be installed via fs.symlinkSync with an
44
+ // EPERM fallback to a plain copy — this test used to exercise that fallback.
45
+ // Task 6 (writeMaterializedSkill, hooks/claude.ts) replaced the symlink entirely
46
+ // with a materialized write (buildContext() composed markdown via fs.writeFileSync,
47
+ // after an unlinkSync of any prior file): the skill file never routes through
48
+ // fs.symlinkSync at all anymore, for any installMethod. The EPERM mock is kept
49
+ // here specifically to prove that irrelevance — the assertions hold even with
50
+ // symlinkSync forced to throw, and the explicit "never called" check documents
51
+ // why: there's no fallback logic left to exercise for this file.
52
+ it('materializes the skill file — never attempts a symlink, so EPERM on symlinkSync never affects it', () => {
44
53
  const registryRoot = path_1.default.join(tmpHome, 'registry');
45
54
  seedRegistry(registryRoot);
46
- // Force symlinkSync to fail like a platform without symlink permission.
47
55
  symlinkSpy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => {
48
56
  const err = new Error('EPERM: operation not permitted, symlink');
49
57
  err.code = 'EPERM';
@@ -53,8 +61,9 @@ describe('hooks/install — skill symlink fallback to copy', () => {
53
61
  const result = installHook({ agent: 'claude-code', registryRoot, installMethod: 'copy' });
54
62
  const skillDest = path_1.default.join(result.scriptsDir, 'using-awm.md');
55
63
  expect(fs_1.default.existsSync(skillDest)).toBe(true);
56
- expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false); // it was copied, not linked
64
+ expect(fs_1.default.lstatSync(skillDest).isSymbolicLink()).toBe(false); // materialized, not linked
57
65
  expect(fs_1.default.readFileSync(skillDest, 'utf-8')).toContain('using-awm');
66
+ expect(symlinkSpy).not.toHaveBeenCalled(); // proves the EPERM mock above was moot
58
67
  });
59
68
  // Regression: syncExecutable (shared.ts) — used for the hook SCRIPT files
60
69
  // (session-start, run-hook.cmd), not just the bootstrap skill above — called
@@ -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');