agentic-workflow-manager 9.0.2 → 9.2.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 (31) hide show
  1. package/dist/src/commands/context/index.js +47 -0
  2. package/dist/src/commands/ledger/index.js +13 -0
  3. package/dist/src/commands/process/index.js +58 -0
  4. package/dist/src/core/command-result.js +17 -0
  5. package/dist/src/core/context/provider.js +27 -18
  6. package/dist/src/core/dashboard/collect.js +30 -5
  7. package/dist/src/core/dashboard/sanitize.js +24 -2
  8. package/dist/src/core/orchestrators.js +24 -3
  9. package/dist/src/core/process/body.js +181 -0
  10. package/dist/src/core/process/discover.js +94 -0
  11. package/dist/src/core/process/model.js +109 -0
  12. package/dist/src/core/process/types.js +5 -0
  13. package/dist/src/core/text.js +58 -0
  14. package/dist/src/index.js +4 -0
  15. package/dist/tests/commands/context.test.js +64 -0
  16. package/dist/tests/commands/ledger/index.test.js +11 -0
  17. package/dist/tests/commands/process.test.js +88 -0
  18. package/dist/tests/core/context/provider.test.js +49 -0
  19. package/dist/tests/core/dashboard/collect.test.js +25 -21
  20. package/dist/tests/core/dashboard/processes-section.test.js +71 -0
  21. package/dist/tests/core/dashboard/production-adapters.test.js +58 -0
  22. package/dist/tests/core/orchestrators.test.js +38 -0
  23. package/dist/tests/core/process/body.test.js +239 -0
  24. package/dist/tests/core/process/discover.test.js +139 -0
  25. package/dist/tests/core/process/model.test.js +83 -0
  26. package/dist/tests/core/process/no-regression.test.js +57 -0
  27. package/dist/tests/core/text.test.js +29 -0
  28. package/dist/tests/integration/context-orchestrators-e2e.test.js +144 -0
  29. package/dist/tests/integration/process-model-e2e.test.js +161 -0
  30. package/dist/tests/structural/process-model-single-parser.test.js +63 -0
  31. package/package.json +1 -1
@@ -21,9 +21,10 @@ describe('collectDashboardSnapshot', () => {
21
21
  const project = jest.fn();
22
22
  const plans = jest.fn();
23
23
  const execution = jest.fn();
24
+ const processes = jest.fn();
24
25
  const snapshot = (0, collect_1.collectDashboardSnapshot)({
25
26
  cwd: '/definitely-not-a-project', now: fixedNow,
26
- adapters: { machine: () => ({ findings: [] }), project, plans, execution },
27
+ adapters: { machine: () => ({ findings: [] }), project, plans, execution, processes },
27
28
  });
28
29
  expect(snapshot.project).toEqual({ detected: false, label: 'No project detected' });
29
30
  expect(snapshot.sections.map((section) => section.id)).toEqual(['machine']);
@@ -31,14 +32,15 @@ describe('collectDashboardSnapshot', () => {
31
32
  expect(project).not.toHaveBeenCalled();
32
33
  expect(plans).not.toHaveBeenCalled();
33
34
  expect(execution).not.toHaveBeenCalled();
35
+ expect(processes).not.toHaveBeenCalled();
34
36
  });
35
37
  it('fails loudly for malformed central machine findings', () => {
36
- expect(() => (0, collect_1.collectDashboardSnapshot)({ cwd: '/definitely-not-a-project', now: fixedNow, adapters: { machine: () => ({ findings: [{ id: '', label: 'Preferences', state: 'ok' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() } })).toThrow(/finding/i);
38
+ expect(() => (0, collect_1.collectDashboardSnapshot)({ cwd: '/definitely-not-a-project', now: fixedNow, adapters: { machine: () => ({ findings: [{ id: '', label: 'Preferences', state: 'ok' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn(), processes: jest.fn() } })).toThrow(/finding/i);
37
39
  });
38
40
  it.each([undefined, null, {}, 'healthy'])('rejects a non-array central machine findings value: %p', (findings) => {
39
41
  expect(() => (0, collect_1.collectDashboardSnapshot)({
40
42
  cwd: '/definitely-not-a-project', now: fixedNow,
41
- adapters: { machine: () => ({ findings }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
43
+ adapters: { machine: () => ({ findings }), project: jest.fn(), plans: jest.fn(), execution: jest.fn(), processes: jest.fn() },
42
44
  })).toThrow('Dashboard findings must be an array');
43
45
  });
44
46
  it('uses exact verified remediation commands and stable ordered sections', () => {
@@ -49,6 +51,7 @@ describe('collectDashboardSnapshot', () => {
49
51
  project: () => ({ label: 'Demo', findings: [{ id: 'project.profile.missing', label: 'Profile', state: 'missing' }] }),
50
52
  plans: () => Array.from({ length: 2000 }, (_, index) => ({ id: `plan.${index}`, label: `Plan ${index}`, state: 'ok' })),
51
53
  execution: () => ({}),
54
+ processes: () => [],
52
55
  },
53
56
  });
54
57
  expect(snapshot.sections.map((section) => section.id)).toEqual(// verifies R6.3
@@ -59,11 +62,11 @@ describe('collectDashboardSnapshot', () => {
59
62
  expect(JSON.stringify(snapshot)).not.toMatch(/score|ranking/i);
60
63
  });
61
64
  it('el snapshot declara schema 2', () => {
62
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
65
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
63
66
  expect(snapshot.schema).toBe(2);
64
67
  });
65
- it('processes queda declarada no aplicable hasta R1', () => {
66
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
68
+ it('processes queda no aplicable cuando el adapter no declara modelos', () => {
69
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
67
70
  const processes = snapshot.sections.find((section) => section.id === 'processes');
68
71
  expect(processes).toEqual({ id: 'processes', availability: 'not_applicable', items: [] });
69
72
  });
@@ -73,7 +76,7 @@ describe('collectDashboardSnapshot', () => {
73
76
  adapters: {
74
77
  machine: () => ({ findings: [{ id: 'unknown', label: 'Unknown', state: 'missing' }] }),
75
78
  project: () => { throw new Error('corrupt project source'); },
76
- plans: () => [], execution: () => undefined,
79
+ plans: () => [], execution: () => undefined, processes: () => [],
77
80
  },
78
81
  });
79
82
  expect(snapshot.sections.find((section) => section.id === 'machine')?.items).toEqual([]);
@@ -83,7 +86,7 @@ describe('collectDashboardSnapshot', () => {
83
86
  it('marks execution-derived sections unavailable when no read-only execution source exists', () => {
84
87
  const snapshot = (0, collect_1.collectDashboardSnapshot)({
85
88
  cwd: process.cwd(), now: fixedNow,
86
- adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined },
89
+ adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] },
87
90
  });
88
91
  for (const id of ['execution', 'qa', 'docs', 'retro']) {
89
92
  expect(snapshot.sections.find((section) => section.id === id)?.availability).toBe('unavailable');
@@ -92,29 +95,29 @@ describe('collectDashboardSnapshot', () => {
92
95
  it('isolates malformed optional source data to its owning section', () => {
93
96
  const snapshot = (0, collect_1.collectDashboardSnapshot)({
94
97
  cwd: process.cwd(), now: fixedNow,
95
- adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: 'bad', label: 'Profile', state: 'invented' }] }), plans: () => [], execution: () => undefined },
98
+ adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: 'bad', label: 'Profile', state: 'invented' }] }), plans: () => [], execution: () => undefined, processes: () => [] },
96
99
  });
97
100
  expect(snapshot.sections.find((section) => section.id === 'project')?.availability).toBe('unavailable');
98
101
  expect(snapshot.sections.find((section) => section.id === 'machine')?.availability).toBe('available');
99
102
  });
100
103
  it('isolates malformed post-sanitization plan findings', () => {
101
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: '', label: 'Profile', state: 'ok' }], execution: () => undefined } });
104
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: '', label: 'Profile', state: 'ok' }], execution: () => undefined, processes: () => [] } });
102
105
  expect(snapshot.sections.find((section) => section.id === 'planning')?.availability).toBe('unavailable');
103
106
  });
104
107
  it('isolates malformed project findings without dropping other sections', () => {
105
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: '', label: 'Profile', state: 'ok' }] }), plans: () => [], execution: () => undefined } });
108
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [{ id: '', label: 'Profile', state: 'ok' }] }), plans: () => [], execution: () => undefined, processes: () => [] } });
106
109
  expect(snapshot.sections.find((section) => section.id === 'project')?.availability).toBe('unavailable');
107
110
  expect(snapshot.sections.find((section) => section.id === 'planning')?.availability).toBe('available');
108
111
  });
109
112
  it.each(['execution', 'qa', 'docs', 'retro'])('isolates malformed %s findings', (key) => {
110
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => ({ [key]: [{ id: '', label: 'Profile', state: 'ok' }] }) } });
113
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => ({ [key]: [{ id: '', label: 'Profile', state: 'ok' }] }), processes: () => [] } });
111
114
  expect(snapshot.sections.find((section) => section.id === key)?.availability).toBe('unavailable');
112
115
  });
113
116
  it('renders exact remediation only for a canonical optional source failure', () => {
114
117
  const knownFailure = Object.assign(new Error('sensors unavailable'), { findingId: 'project.sensors.unavailable', remediationVerified: true });
115
118
  const snapshot = (0, collect_1.collectDashboardSnapshot)({
116
119
  cwd: process.cwd(), now: fixedNow,
117
- adapters: { machine: () => ({ findings: [] }), project: () => { throw knownFailure; }, plans: () => [], execution: () => undefined },
120
+ adapters: { machine: () => ({ findings: [] }), project: () => { throw knownFailure; }, plans: () => [], execution: () => undefined, processes: () => [] },
118
121
  });
119
122
  expect(snapshot.sections.find((section) => section.id === 'project')?.items).toEqual([
120
123
  expect.objectContaining({ id: 'project.sensors.unavailable', state: 'unavailable', remediation: 'awm sensors status' }),
@@ -133,6 +136,7 @@ describe('collectDashboardSnapshot', () => {
133
136
  throw failure; return []; },
134
137
  execution: () => { if (adapter === 'execution')
135
138
  throw failure; return undefined; },
139
+ processes: () => [],
136
140
  },
137
141
  });
138
142
  expect(snapshot.sections.find((section) => section.id === sectionId)?.items[0]).toEqual(expect.objectContaining({ id: findingId, remediation }));
@@ -143,7 +147,7 @@ describe('collectDashboardSnapshot', () => {
143
147
  adapters: { machine: () => ({ findings: [
144
148
  { id: 'machine.registries.stale', label: 'Registries', state: 'attention' },
145
149
  { id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' },
146
- ] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
150
+ ] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn(), processes: jest.fn() },
147
151
  });
148
152
  expect(snapshot.sections[0].items.map((item) => item.id)).toEqual(['machine.preferences.missing', 'machine.registries.stale']);
149
153
  });
@@ -154,13 +158,13 @@ describe('collectDashboardSnapshot', () => {
154
158
  : expected === 'retro_pending' ? { markers: { qaComplete: true, docsComplete: true, retroComplete: false }, tasks: { total: 1, completed: 1 } }
155
159
  : expected === 'qa_pending' ? { markers: { qaComplete: false, docsComplete: false, retroComplete: false }, tasks: { total: 1, completed: 1 } }
156
160
  : { markers: { qaComplete: false, docsComplete: false, retroComplete: false }, tasks: { total: 0, completed: 0 } };
157
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: 'plan.lifecycle', label: 'Profile', state: 'ok', lifecycle }], execution: () => undefined } });
161
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: process.cwd(), now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [{ id: 'plan.lifecycle', label: 'Profile', state: 'ok', lifecycle }], execution: () => undefined, processes: () => [] } });
158
162
  expect(snapshot.sections.find((section) => section.id === 'planning')?.items[0].detail).toBe(expected);
159
163
  });
160
164
  it('degrades a machine-only dashboard for actionable machine findings', () => {
161
165
  const snapshot = (0, collect_1.collectDashboardSnapshot)({
162
166
  cwd: '/definitely-not-a-project', now: fixedNow,
163
- adapters: { machine: () => ({ findings: [{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn() },
167
+ adapters: { machine: () => ({ findings: [{ id: 'machine.preferences.missing', label: 'Preferences', state: 'missing' }] }), project: jest.fn(), plans: jest.fn(), execution: jest.fn(), processes: jest.fn() },
164
168
  });
165
169
  expect(snapshot.sections.map((section) => section.id)).toEqual(['machine']);
166
170
  expect(snapshot.overall).toBe('degraded');
@@ -169,7 +173,7 @@ describe('collectDashboardSnapshot', () => {
169
173
  const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-dashboard-'));
170
174
  fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}');
171
175
  (0, store_1.writeCycleEvidence)(root, (0, evidence_fixtures_1.cycleEvidenceFixture)());
172
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
176
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
173
177
  const history = snapshot.sections.find((section) => section.id === 'history');
174
178
  expect(snapshot.confidence).toBe('provisional');
175
179
  expect(history?.availability).toBe('available');
@@ -180,7 +184,7 @@ describe('collectDashboardSnapshot', () => {
180
184
  const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-dashboard-'));
181
185
  fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}');
182
186
  (0, store_1.writeCycleEvidence)(root, { ...(0, evidence_fixtures_1.cycleEvidenceFixture)(), cycleState: 'blocked', plan: { ref: 'docs/plans/current.md', state: 'blocked' } });
183
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
187
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
184
188
  expect(snapshot.sections.find((section) => section.id === 'history')?.items).toEqual([
185
189
  expect.objectContaining({ state: 'attention', remediation: 'awm preflight' }),
186
190
  ]);
@@ -203,7 +207,7 @@ describe('collectDashboardSnapshot', () => {
203
207
  fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'evidence'), { recursive: true });
204
208
  fs_1.default.symlinkSync(external, path_1.default.join(root, '.awm', 'evidence', 'cycles'));
205
209
  }
206
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
210
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
207
211
  const history = snapshot.sections.find((section) => section.id === 'history');
208
212
  expect(history).toEqual(expect.objectContaining({ availability: 'unavailable', items: [] }));
209
213
  fs_1.default.rmSync(root, { recursive: true, force: true });
@@ -219,7 +223,7 @@ describe('collectDashboardSnapshot', () => {
219
223
  const directory = path_1.default.join(root, '.awm', 'evidence', 'cycles');
220
224
  fs_1.default.mkdirSync(directory, { recursive: true });
221
225
  fs_1.default.symlinkSync(externalFile, path_1.default.join(directory, `${cycleId}.json`));
222
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
226
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
223
227
  expect(snapshot.sections.find((section) => section.id === 'history')).toEqual(expect.objectContaining({ availability: 'unavailable', items: [] }));
224
228
  fs_1.default.rmSync(root, { recursive: true, force: true });
225
229
  fs_1.default.rmSync(external, { recursive: true, force: true });
@@ -232,7 +236,7 @@ describe('collectDashboardSnapshot', () => {
232
236
  const record = (0, evidence_fixtures_1.cycleEvidenceFixture)();
233
237
  const filename = caseName === 'duplicate' ? `${'b'.repeat(64)}.json` : `${'b'.repeat(64)}.json`;
234
238
  fs_1.default.writeFileSync(path_1.default.join(directory, filename), JSON.stringify(caseName === 'duplicate' ? record : { ...record, cycleId: 'c'.repeat(64) }));
235
- const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined } });
239
+ const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: fixedNow, adapters: { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [] } });
236
240
  expect(snapshot.sections.find((section) => section.id === 'history')).toEqual(expect.objectContaining({ availability: 'unavailable', items: [] }));
237
241
  fs_1.default.rmSync(root, { recursive: true, force: true });
238
242
  });
@@ -0,0 +1,71 @@
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
+ const collect_1 = require("../../../src/core/dashboard/collect");
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ function adapters(processes) {
11
+ return { machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes };
12
+ }
13
+ describe('sección processes del Dashboard', () => {
14
+ let root;
15
+ beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-processes-section-')); fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}'); });
16
+ afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
17
+ function snapshot(a) {
18
+ return (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: '2026-08-23T00:00:00.000Z', adapters: a });
19
+ }
20
+ function section(a) {
21
+ return snapshot(a).sections.find((s) => s.id === 'processes');
22
+ }
23
+ it('puebla la sección desde el adapter', () => {
24
+ const s = section(adapters(() => [{ name: 'mi-proceso', status: 'active' }]));
25
+ expect(s.availability).toBe('available');
26
+ expect(s.items).toEqual([expect.objectContaining({ id: 'process.mi-proceso', label: 'Process', state: 'ok', detail: 'active' })]);
27
+ });
28
+ it('un draft se reporta como attention, no como ok', () => {
29
+ expect(section(adapters(() => [{ name: 'x', status: 'draft' }])).items[0]).toEqual(expect.objectContaining({ state: 'attention', detail: 'draft' }));
30
+ });
31
+ it('un draft trae remediation "awm process list" y degrada el overall del snapshot', () => {
32
+ const snap = snapshot(adapters(() => [{ name: 'x', status: 'draft' }]));
33
+ const s = snap.sections.find((sec) => sec.id === 'processes');
34
+ expect(s.items[0]).toEqual(expect.objectContaining({ state: 'attention', detail: 'draft', remediation: 'awm process list' }));
35
+ expect(snap.overall).toBe('degraded');
36
+ });
37
+ it('sin procesos la sección queda not_applicable, como antes de R1a', () => {
38
+ expect(section(adapters(() => []))).toEqual(expect.objectContaining({ availability: 'not_applicable', items: [] }));
39
+ });
40
+ it('un adapter que lanza degrada a unavailable sin tumbar el snapshot', () => {
41
+ const s = section(adapters(() => { throw new Error('/home/u/secreto boom'); }));
42
+ expect(s.availability).toBe('unavailable');
43
+ expect(JSON.stringify(s)).not.toContain('secreto');
44
+ });
45
+ it('un registry externo no puede inyectar markup ni rutas por el nombre', () => {
46
+ // El nombre ya fue validado como slug por el contrato (Task 1). Este test
47
+ // prueba la SEGUNDA barrera: aunque un adapter mal escrito dejara pasar
48
+ // algo hostil, el sanitizador lo neutraliza antes del render.
49
+ const s = section(adapters(() => [{ name: '<script>/etc/passwd', status: 'active' }]));
50
+ const serialized = JSON.stringify(s);
51
+ expect(serialized).not.toContain('<script>');
52
+ expect(serialized).not.toContain('/etc/passwd');
53
+ });
54
+ it('un nombre con forma de slug inválida no produce un id process.<bad-name>', () => {
55
+ // 'mi proceso' (con espacio) no matchea DANGEROUS (no es un path, token,
56
+ // secreto ni markup), así que sanitizeDashboardSource lo deja pasar tal
57
+ // cual sobre el source crudo. La SEGUNDA barrera tiene que actuar sobre
58
+ // el id ya construido (`process.mi proceso`), no sobre el nombre crudo:
59
+ // PROCESS_FINDING_ID no matchea ese id, así que debe caer al fallback
60
+ // hasheado `item-<hash>` — igual que cualquier otro id malformado — y
61
+ // nunca renderizarse como `process.mi proceso`.
62
+ const s = section(adapters(() => [{ name: 'mi proceso', status: 'active' }]));
63
+ expect(s.items).toHaveLength(1);
64
+ const item = s.items[0];
65
+ expect(item.id).toMatch(/^item-[0-9a-f]{16}$/);
66
+ expect(item.id).not.toContain('process.mi proceso');
67
+ expect(item.label).toBe('Process');
68
+ expect(item.state).toBe('ok');
69
+ expect(item.detail).toBe('active');
70
+ });
71
+ });
@@ -12,6 +12,44 @@ const child_process_1 = require("child_process");
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const os_1 = __importDefault(require("os"));
14
14
  const path_1 = __importDefault(require("path"));
15
+ const PROCESS_MODEL_FIXTURE = `---
16
+ awm: process-model
17
+ schema: 1
18
+ name: NAME
19
+ status: STATUS
20
+ entry_point: true
21
+ terminates_to: none
22
+ created: 2026-08-23
23
+ updated: 2026-08-23
24
+ ---
25
+
26
+ ## Objetivo
27
+
28
+ G — Objetivo.
29
+
30
+ ## Cuándo aplica
31
+
32
+ Siempre.
33
+
34
+ ## Estructura
35
+
36
+ - SG-1 — Uno
37
+ - OP-1.1 — Hacer
38
+
39
+ ## Ruteo
40
+
41
+ | Cuándo | Estado requerido | Va a | Termina en |
42
+ |---|---|---|---|
43
+ | Al empezar | | OP-1.1 | SG-1 |
44
+
45
+ ## Terminación
46
+
47
+ none
48
+
49
+ ## Sin verificar
50
+
51
+ - Nada.
52
+ `;
15
53
  function context() {
16
54
  return {
17
55
  machine: {},
@@ -174,4 +212,24 @@ describe('productionDashboardAdapters', () => {
174
212
  expect(JSON.stringify(snapshot)).not.toMatch(/dashboard-journal-only|dashboard@example|Dashboard Test/i);
175
213
  fs_1.default.rmSync(root, { recursive: true, force: true });
176
214
  });
215
+ it('processes delegates to discoverProcessModels() and maps its models to {name, status}', () => {
216
+ const awmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-dashboard-processes-home-'));
217
+ const originalAwmHome = process.env.AWM_HOME;
218
+ process.env.AWM_HOME = awmHome;
219
+ try {
220
+ fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.test/baseline.git' }]));
221
+ const skillDir = path_1.default.join(awmHome, 'registries', 'baseline', 'skills', 'mi-proceso');
222
+ fs_1.default.mkdirSync(skillDir, { recursive: true });
223
+ fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), PROCESS_MODEL_FIXTURE.replace('NAME', 'mi-proceso').replace('STATUS', 'active'));
224
+ const adapters = (0, collect_1.productionDashboardAdapters)(context());
225
+ expect(adapters.processes({ root: '/private/project' })).toEqual([{ name: 'mi-proceso', status: 'active' }]);
226
+ }
227
+ finally {
228
+ if (originalAwmHome === undefined)
229
+ delete process.env.AWM_HOME;
230
+ else
231
+ process.env.AWM_HOME = originalAwmHome;
232
+ fs_1.default.rmSync(awmHome, { recursive: true, force: true });
233
+ }
234
+ });
177
235
  });
@@ -217,6 +217,44 @@ describe('collectDeclaredOrchestrators', () => {
217
217
  expect(diagnostics[0]).toMatch(/shared/);
218
218
  expect(diagnostics[0]).toMatch(/duplicate|shadow/i);
219
219
  });
220
+ it('sanea el nombre antes de interpolarlo en el diagnostico de duplicado', () => {
221
+ (0, registries_1.writeRegistriesConfig)([
222
+ { name: 'first', remote: 'unused' },
223
+ { name: 'second', remote: 'unused' },
224
+ ]);
225
+ const nombreHostil = 'shared\x1b[31m';
226
+ writeManifest('first', {
227
+ orchestrator: { name: nombreHostil, appliesWhen: 'primero', terminatesTo: 'a' },
228
+ });
229
+ writeManifest('second', {
230
+ orchestrator: { name: nombreHostil, appliesWhen: 'segundo', terminatesTo: 'b' },
231
+ });
232
+ const { diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
233
+ expect(diagnostics).toHaveLength(1);
234
+ // eslint-disable-next-line no-control-regex -- verificamos la ausencia deliberada de C0
235
+ expect(diagnostics[0]).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/);
236
+ expect(diagnostics[0]).toContain('shared[31m');
237
+ });
238
+ it('emite un diagnostico de colision post-saneo entre nombres crudos distintos, sin descartar ninguno', () => {
239
+ (0, registries_1.writeRegistriesConfig)([
240
+ { name: 'first', remote: 'unused' },
241
+ { name: 'second', remote: 'unused' },
242
+ ]);
243
+ writeManifest('first', {
244
+ orchestrator: { name: 'foo_bar', appliesWhen: 'x', terminatesTo: 'a' },
245
+ });
246
+ writeManifest('second', {
247
+ orchestrator: { name: 'foo*bar', appliesWhen: 'y', terminatesTo: 'b' },
248
+ });
249
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
250
+ // Ambas declaraciones son genuinamente distintas (nombre crudo distinto) — ninguna se descarta.
251
+ expect(declared).toHaveLength(2);
252
+ expect(declared.map((d) => d.name).sort()).toEqual(['foo*bar', 'foo_bar']);
253
+ // Pero se advierte de la colision post-saneo (ambas renderizan como "foobar").
254
+ expect(diagnostics).toHaveLength(1);
255
+ expect(diagnostics[0]).toMatch(/foobar/);
256
+ expect(diagnostics[0]).toMatch(/collis|same composed name/i);
257
+ });
220
258
  it('no dedupea orquestadores con nombres distintos, ambos se conservan', () => {
221
259
  (0, registries_1.writeRegistriesConfig)([
222
260
  { name: 'first', remote: 'unused' },
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const body_1 = require("../../../src/core/process/body");
4
+ const body = `
5
+ # Ejemplo
6
+
7
+ ## Objetivo
8
+
9
+ G — Llevar una idea hasta una rama cerrada.
10
+
11
+ ## Cuándo aplica
12
+
13
+ Cuando hay una tarea de desarrollo sin plan previo.
14
+
15
+ ## Estructura
16
+
17
+ - SG-1 — Diseñar
18
+ - OP-1.1 — Elicitar requisitos
19
+ - OP-1.2 — Escribir el design doc
20
+ - SG-2 — Ejecutar
21
+ - OP-2.1 — Implementar por tasks
22
+
23
+ ## Ruteo
24
+
25
+ | Cuándo | Estado requerido | Va a | Termina en |
26
+ |---|---|---|---|
27
+ | No hay design doc | | OP-1.1 | SG-1 |
28
+ | Design doc aprobado | SG-1 | OP-2.1 | SG-2 |
29
+
30
+ ## Terminación
31
+
32
+ finishing-a-development-branch
33
+
34
+ ## Sin verificar
35
+
36
+ - Que el usuario tenga un registry propio instalado.
37
+ `;
38
+ describe('parseProcessBody', () => {
39
+ it('extrae las seis secciones', () => {
40
+ const r = (0, body_1.parseProcessBody)(body, 'p');
41
+ expect(r.diagnostics).toEqual([]);
42
+ expect(r.model.objective).toBe('G — Llevar una idea hasta una rama cerrada.');
43
+ expect(r.model.appliesWhen).toBe('Cuando hay una tarea de desarrollo sin plan previo.');
44
+ expect(r.model.termination).toBe('finishing-a-development-branch');
45
+ expect(r.model.unverified).toEqual(['Que el usuario tenga un registry propio instalado.']);
46
+ });
47
+ it('descompone Estructura en SG-# con sus OP-#', () => {
48
+ expect((0, body_1.parseProcessBody)(body, 'p').model.structure).toEqual([
49
+ { id: 'SG-1', text: 'Diseñar', operations: [
50
+ { id: 'OP-1.1', text: 'Elicitar requisitos' }, { id: 'OP-1.2', text: 'Escribir el design doc' }
51
+ ] },
52
+ { id: 'SG-2', text: 'Ejecutar', operations: [{ id: 'OP-2.1', text: 'Implementar por tasks' }] },
53
+ ]);
54
+ });
55
+ it('lee Ruteo con sus cuatro columnas, incluida la vacía', () => {
56
+ expect((0, body_1.parseProcessBody)(body, 'p').model.routing).toEqual([
57
+ { when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
58
+ { when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
59
+ ]);
60
+ });
61
+ it('conserva las filas de Ruteo como datos, sin evaluarlas ni colapsarlas', () => {
62
+ // WCP16 Deferred Choice: la condición se evalúa al llegar a la decisión,
63
+ // leyendo el estado real del proyecto. El parser no puede precomputar cuál
64
+ // fila gana — si lo hiciera, `show --json` emitiría una decisión ya tomada.
65
+ const r = (0, body_1.parseProcessBody)(body, 'p').model;
66
+ expect(r.routing).toHaveLength(2);
67
+ expect(Object.keys(r)).not.toContain('activeRoute');
68
+ expect(JSON.stringify(r.routing)).toContain('No hay design doc');
69
+ });
70
+ it.each(['## Objetivo', '## Ruteo', '## Terminación'])('rechaza si falta %s', (heading) => {
71
+ const r = (0, body_1.parseProcessBody)(body.replace(heading, '## Otra cosa'), 'p');
72
+ expect(r.model).toBeUndefined();
73
+ expect(r.diagnostics.join(' ')).toMatch(/missing/i);
74
+ });
75
+ it('rechaza una tabla de Ruteo con número de columnas equivocado', () => {
76
+ const r = (0, body_1.parseProcessBody)(body.replace('| No hay design doc | | OP-1.1 | SG-1 |', '| No hay design doc | OP-1.1 |'), 'p');
77
+ expect(r.model).toBeUndefined();
78
+ });
79
+ it('rechaza un id de operación que no cuelga de su subobjetivo', () => {
80
+ const r = (0, body_1.parseProcessBody)(body.replace('OP-2.1', 'OP-9.1'), 'p');
81
+ expect(r.model).toBeUndefined();
82
+ expect(r.diagnostics.join(' ')).toMatch(/OP-9\.1/);
83
+ });
84
+ it('nunca lanza ante entrada basura', () => {
85
+ for (const junk of ['', '## Objetivo', '| | | |', '#'.repeat(500)]) {
86
+ expect(() => (0, body_1.parseProcessBody)(junk, 'p')).not.toThrow();
87
+ }
88
+ });
89
+ it('rechaza un heading de nivel 2 repetido en vez de fusionarlo', () => {
90
+ // Un `## Ruteo` duplicado no debe fusionar sus líneas con la primera
91
+ // aparición: si lo hiciera, la fila de cabecera de la segunda tabla
92
+ // ("| Cuándo | ... |") caería en un índice no-cero del array fusionado
93
+ // y `parseRouting` la confundiría con una fila de datos — corrupción
94
+ // silenciosa, sin diagnóstico. Esto es especialmente grave porque este
95
+ // parser procesa contenido no confiable de registries externos.
96
+ const duped = `
97
+ ## Objetivo
98
+
99
+ G — Llevar una idea hasta una rama cerrada.
100
+
101
+ ## Cuándo aplica
102
+
103
+ Cuando hay una tarea de desarrollo sin plan previo.
104
+
105
+ ## Estructura
106
+
107
+ - SG-1 — Diseñar
108
+ - OP-1.1 — Elicitar requisitos
109
+
110
+ ## Ruteo
111
+
112
+ | Cuándo | Estado requerido | Va a | Termina en |
113
+ |---|---|---|---|
114
+ | No hay design doc | | OP-1.1 | SG-1 |
115
+
116
+ ## Ruteo
117
+
118
+ | Cuándo | Estado requerido | Va a | Termina en |
119
+ |---|---|---|---|
120
+ | Otra condición | | OP-1.1 | SG-1 |
121
+
122
+ ## Terminación
123
+
124
+ finishing-a-development-branch
125
+
126
+ ## Sin verificar
127
+
128
+ - Que el usuario tenga un registry propio instalado.
129
+ `;
130
+ const r = (0, body_1.parseProcessBody)(duped, 'p');
131
+ expect(r.model).toBeUndefined();
132
+ expect(r.diagnostics.join(' ')).toMatch(/duplicate/i);
133
+ });
134
+ it('parsea un heading con una racha larga de espacios finales sin colgarse (ReDoS)', () => {
135
+ // Regresión: `splitSections` matcheaba headings con `/^##\s+(.+?)\s*$/`.
136
+ // El grupo perezoso `(.+?)` seguido de `\s*` sobre una clase que lo
137
+ // solapa (`.` ⊃ `\s`) es cuadrático — ver AGENTS.md
138
+ // "regex-cuantificador-adyacente-a-clase-que-lo-solapa". Una sola línea
139
+ // `## `-prefijada con una racha larga de espacios finales (ni siquiera
140
+ // maliciosa, un typo alcanza) colgaba el proceso. El fix usa captura
141
+ // greedy a fin-de-línea (`(.*)$`, sin cuantificador adyacente) y hace
142
+ // `.trim()` en código. Este test prueba que el parseo completo del
143
+ // documento sigue siendo lineal, no solo el regex aislado.
144
+ const longTrailingSpace = '## Objetivo' + ' '.repeat(50000) + 'x';
145
+ const doc = body.replace('## Objetivo', longTrailingSpace);
146
+ const t0 = Date.now();
147
+ const r = (0, body_1.parseProcessBody)(doc, 'p');
148
+ const elapsed = Date.now() - t0;
149
+ expect(elapsed).toBeLessThan(500);
150
+ // El heading matcheado debe llevar el trim aplicado en código, no en el regex.
151
+ expect(r.diagnostics.join(' ')).toMatch(/missing required section "## Objetivo"/);
152
+ });
153
+ it('ignora una línea "## " (solo espacio, sin nombre) como contenido ordinario, sin abrir una sección fantasma', () => {
154
+ // Regresión del fix de ReDoS: la regex vieja `/^##\s+(.+?)\s*$/` exigía
155
+ // al menos un carácter no-blanco capturado, así que una línea "##"
156
+ // seguida solo de espacio NUNCA matcheaba como heading. La regex nueva
157
+ // `/^##\s+(.*)$/` + trim() sí puede capturar "" — sin el guard de
158
+ // finding-1, esa línea abriría una sección "" fantasma y desviaría lo
159
+ // que sigue (aquí, una fila real de la tabla de Ruteo) hacia ella,
160
+ // dejando `parseProcessBody` "exitoso" pero con datos perdidos y CERO
161
+ // diagnósticos. La línea debe tratarse como contenido ordinario de la
162
+ // sección abierta (`## Ruteo`), donde `parseRouting` ya la ignora por
163
+ // no empezar con "|".
164
+ const strayHeading = body.replace('## Ruteo\n\n| Cuándo | Estado requerido | Va a | Termina en |', '## Ruteo\n\n## \n\n| Cuándo | Estado requerido | Va a | Termina en |');
165
+ expect(strayHeading).not.toBe(body); // guard: el replace debe haber encontrado el punto de inserción
166
+ const r = (0, body_1.parseProcessBody)(strayHeading, 'p');
167
+ expect(r.diagnostics).toEqual([]);
168
+ expect(r.model.routing).toEqual([
169
+ { when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
170
+ { when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
171
+ ]);
172
+ });
173
+ it('reconoce un separador de tabla con colons de alineación GFM (|:---|:---:|) sin fabricar una fila', () => {
174
+ // finding-2: el detector viejo `/^-{1,}$/.test(cells.join(''))` solo
175
+ // reconoce separadores de dashes puros. `|:---|:---:|:---:|:---:|` es
176
+ // sintaxis GFM estándar de alineación de columnas (muy probable si el
177
+ // autor del registry usó cualquier formateador de tablas markdown) y
178
+ // NO era reconocida — la fila colaba como dato fabricado con celdas
179
+ // literales como ":---". El fix la reconoce por celda con un regex
180
+ // anclado `^:?-+:?$` (lineal, sin riesgo ReDoS).
181
+ const gfmAligned = body.replace('|---|---|---|---|', '|:---|:---:|:---:|:---:|');
182
+ expect(gfmAligned).not.toBe(body);
183
+ const r = (0, body_1.parseProcessBody)(gfmAligned, 'p');
184
+ expect(r.diagnostics).toEqual([]);
185
+ expect(r.model.routing).toEqual([
186
+ { when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
187
+ { when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
188
+ ]);
189
+ // Ninguna fila fabricada a partir del separador (p.ej. celdas ":---").
190
+ expect(JSON.stringify(r.model.routing)).not.toContain(':--');
191
+ });
192
+ it.each(['Objetivo', 'Cuándo aplica', 'Terminación'])('rechaza %s presente pero con contenido vacío', (heading) => {
193
+ // A diferencia del test "rechaza si falta %s" (heading ausente por
194
+ // completo), acá el heading `## ${heading}` SÍ está presente pero no
195
+ // tiene contenido no-blanco debajo antes del siguiente `##` — el
196
+ // rechazo lo produce el chequeo `value === ''` sobre `paragraph()`,
197
+ // no `splitSections`, y ese camino no tenía cobertura.
198
+ const emptied = body.replace(new RegExp(`## ${heading}\\n\\n[^#]*`), `## ${heading}\n\n \n\n`);
199
+ expect(emptied).not.toBe(body);
200
+ const r = (0, body_1.parseProcessBody)(emptied, 'p');
201
+ expect(r.model).toBeUndefined();
202
+ expect(r.diagnostics.join(' ')).toMatch(new RegExp(`"${heading}" is empty`));
203
+ });
204
+ it('rechaza una sección de prosa que excede el largo máximo', () => {
205
+ // finding-1 QA: ninguna sección de texto libre tenía cota de largo —
206
+ // un `## Objetivo` de 10MB en un registry no confiable se aceptaba
207
+ // sin rechazo ni truncamiento. `MAX_SECTION_LENGTH` en body.ts cierra
208
+ // ese hueco para las secciones de prosa (Objetivo/Cuándo
209
+ // aplica/Terminación).
210
+ const overlong = body.replace('G — Llevar una idea hasta una rama cerrada.', 'x'.repeat(5001));
211
+ expect(overlong).not.toBe(body);
212
+ const r = (0, body_1.parseProcessBody)(overlong, 'p');
213
+ expect(r.model).toBeUndefined();
214
+ expect(r.diagnostics.join(' ')).toMatch(/"Objetivo" must be at most 5000 characters/);
215
+ });
216
+ it('rechaza un texto de SG-# que excede el largo máximo de ítem', () => {
217
+ // Mismo hueco que la sección de prosa, pero para los ítems
218
+ // estructurales (texto de SG-#/OP-#, celdas de Ruteo, entradas de
219
+ // "Sin verificar"), acotados por `MAX_ITEM_LENGTH` — más corto que
220
+ // `MAX_SECTION_LENGTH` porque son título-cortos por contrato.
221
+ const overlong = body.replace('- SG-1 — Diseñar', `- SG-1 — ${'x'.repeat(501)}`);
222
+ expect(overlong).not.toBe(body);
223
+ const r = (0, body_1.parseProcessBody)(overlong, 'p');
224
+ expect(r.model).toBeUndefined();
225
+ expect(r.diagnostics.join(' ')).toMatch(/"SG-1" text must be at most 500 characters/);
226
+ });
227
+ it('rechaza un SG-# duplicado en Estructura', () => {
228
+ // finding-3: a diferencia de la validación OP-# <-> SG-# (que sí
229
+ // rechaza una pertenencia inconsistente), un SG-# repetido pasaba en
230
+ // silencio y producía un `structure` con dos entradas compartiendo el
231
+ // mismo `id` — inconsistente para cualquier consumidor que indexe por
232
+ // id. Mismo patrón `problems` que el resto del archivo.
233
+ const dupedSg = body.replace('- SG-1 — Diseñar\n - OP-1.1 — Elicitar requisitos\n - OP-1.2 — Escribir el design doc\n- SG-2 — Ejecutar', '- SG-1 — Diseñar\n - OP-1.1 — Elicitar requisitos\n - OP-1.2 — Escribir el design doc\n- SG-1 — Diseñar otra vez\n- SG-2 — Ejecutar');
234
+ expect(dupedSg).not.toBe(body);
235
+ const r = (0, body_1.parseProcessBody)(dupedSg, 'p');
236
+ expect(r.model).toBeUndefined();
237
+ expect(r.diagnostics.join(' ')).toMatch(/duplicate.*SG-1/i);
238
+ });
239
+ });