agentic-workflow-manager 3.3.1 → 3.5.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 (60) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/ledger/index.js +1 -1
  11. package/dist/src/commands/watch/apply.js +352 -0
  12. package/dist/src/commands/watch/generations.js +249 -0
  13. package/dist/src/commands/watch/index.js +49 -0
  14. package/dist/src/commands/watch/init.js +72 -0
  15. package/dist/src/commands/watch/lock.js +89 -0
  16. package/dist/src/commands/watch/runner.js +191 -0
  17. package/dist/src/commands/watch/supervisor.js +266 -0
  18. package/dist/src/core/atomic-file.js +31 -0
  19. package/dist/src/core/export/pack.js +7 -1
  20. package/dist/src/core/export/transform.js +51 -1
  21. package/dist/src/core/journal/adapter.js +27 -0
  22. package/dist/src/core/journal/fingerprint.js +80 -0
  23. package/dist/src/core/journal/paths.js +56 -0
  24. package/dist/src/core/journal/process.js +284 -0
  25. package/dist/src/core/journal/redact.js +142 -0
  26. package/dist/src/core/journal/requests.js +132 -0
  27. package/dist/src/core/journal/store.js +107 -0
  28. package/dist/src/core/journal/types.js +165 -0
  29. package/dist/src/core/ledger/cluster.js +138 -0
  30. package/dist/src/core/ledger/store.js +20 -11
  31. package/dist/src/index.js +4 -0
  32. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  33. package/dist/tests/commands/job/export.test.js +76 -0
  34. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  35. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  36. package/dist/tests/commands/job/verbs.test.js +56 -0
  37. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  38. package/dist/tests/commands/watch/apply.test.js +397 -0
  39. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  40. package/dist/tests/commands/watch/generations.test.js +115 -0
  41. package/dist/tests/commands/watch/integration.test.js +124 -0
  42. package/dist/tests/commands/watch/lock.test.js +60 -0
  43. package/dist/tests/commands/watch/runner.test.js +239 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  45. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  46. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  47. package/dist/tests/core/export/engine.test.js +7 -2
  48. package/dist/tests/core/export/transform.test.js +77 -0
  49. package/dist/tests/core/journal/adapter.test.js +27 -0
  50. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  51. package/dist/tests/core/journal/paths.test.js +35 -0
  52. package/dist/tests/core/journal/process.test.js +213 -0
  53. package/dist/tests/core/journal/redact.test.js +59 -0
  54. package/dist/tests/core/journal/requests.test.js +134 -0
  55. package/dist/tests/core/journal/store.test.js +88 -0
  56. package/dist/tests/core/journal/types.test.js +78 -0
  57. package/dist/tests/core/ledger/cluster.test.js +240 -0
  58. package/dist/tests/core/ledger/store.test.js +42 -7
  59. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  60. package/package.json +1 -1
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cluster_1 = require("../../../src/core/ledger/cluster");
4
+ describe('normalizeTokens', () => {
5
+ test('lowercases and splits slugs on non-alphanumeric boundaries', () => {
6
+ expect([...(0, cluster_1.normalizeTokens)('Validator-Skips_Agents.References')].sort())
7
+ .toEqual(['agents', 'references', 'skips', 'validator']);
8
+ });
9
+ test('drops tokens shorter than two characters', () => {
10
+ expect([...(0, cluster_1.normalizeTokens)('a b ok x')].sort()).toEqual(['ok']);
11
+ });
12
+ test('drops stopwords so they cannot inflate affinity', () => {
13
+ expect([...(0, cluster_1.normalizeTokens)('the gate walks only the skills')].sort())
14
+ .toEqual(['gate', 'skills', 'walks']);
15
+ });
16
+ test('merges tokens across every text it is given', () => {
17
+ expect([...(0, cluster_1.normalizeTokens)('gate-walks', 'skills dir')].sort())
18
+ .toEqual(['dir', 'gate', 'skills', 'walks']);
19
+ });
20
+ });
21
+ describe('affinity', () => {
22
+ test('is the overlap coefficient, so a contained short set scores 1', () => {
23
+ const short = (0, cluster_1.normalizeTokens)('validator gate');
24
+ const long = (0, cluster_1.normalizeTokens)('validator gate walks the skills directory only');
25
+ expect((0, cluster_1.affinity)(short, long)).toBe(1);
26
+ });
27
+ test('is 0 for disjoint sets', () => {
28
+ expect((0, cluster_1.affinity)((0, cluster_1.normalizeTokens)('alpha slug'), (0, cluster_1.normalizeTokens)('beta timeout'))).toBe(0);
29
+ });
30
+ test('is 0 when either side is empty', () => {
31
+ expect((0, cluster_1.affinity)(new Set(), (0, cluster_1.normalizeTokens)('alpha'))).toBe(0);
32
+ });
33
+ });
34
+ describe('normalizeRef', () => {
35
+ test('strips the line number and keeps the file locus', () => {
36
+ expect((0, cluster_1.normalizeRef)('scripts/validate-portability.mjs:41')).toBe('scripts/validate-portability.mjs');
37
+ });
38
+ test('accepts a bare filename with an extension', () => {
39
+ expect((0, cluster_1.normalizeRef)('split.ts:12')).toBe('split.ts');
40
+ });
41
+ test('rejects a non-file ref: a whole PR is not a defect locus', () => {
42
+ expect((0, cluster_1.normalizeRef)('PR #16')).toBeNull();
43
+ });
44
+ test('rejects a URL, whose pre-colon portion carries no locus', () => {
45
+ expect((0, cluster_1.normalizeRef)('https://github.com/Kodria/agentic-workflow/pull/15')).toBeNull();
46
+ });
47
+ test('returns null for a missing ref', () => {
48
+ expect((0, cluster_1.normalizeRef)(undefined)).toBeNull();
49
+ });
50
+ });
51
+ function entry(over = {}) {
52
+ return {
53
+ ts: '2026-07-25T00:00:00.000Z',
54
+ branch: 'feat-x',
55
+ phase: 'post-qa',
56
+ source_skill: 'post-implementation-qa',
57
+ polarity: 'finding',
58
+ class: 'logica',
59
+ signature: 'some-finding',
60
+ severity: 'important',
61
+ desc: 'something is wrong',
62
+ ref: 'src/some.ts:1',
63
+ ...over,
64
+ };
65
+ }
66
+ describe('clusterEntries — exact signature floor', () => {
67
+ test('groups identical signatures and honours min', () => {
68
+ const clusters = (0, cluster_1.clusterEntries)([
69
+ entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
70
+ entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
71
+ entry({ signature: 'solo', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
72
+ ], 2);
73
+ expect(clusters).toHaveLength(1);
74
+ expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
75
+ });
76
+ test('unions identical signatures even across unrelated files and descriptions', () => {
77
+ const clusters = (0, cluster_1.clusterEntries)([
78
+ entry({ signature: 'same-slug', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
79
+ entry({ signature: 'same-slug', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
80
+ ], 2);
81
+ expect(clusters).toHaveLength(1);
82
+ expect(clusters[0].count).toBe(2);
83
+ });
84
+ test('unions identical signatures regardless of polarity, as before', () => {
85
+ const clusters = (0, cluster_1.clusterEntries)([
86
+ entry({ signature: 'same-slug', polarity: 'finding' }),
87
+ entry({ signature: 'same-slug', polarity: 'win' }),
88
+ ], 2);
89
+ expect(clusters).toHaveLength(1);
90
+ });
91
+ });
92
+ describe('clusterEntries — convergence on a shared file', () => {
93
+ // El caso real del 2026-07-25: tres lentes aisladas, tres slugs distintos,
94
+ // un solo defecto (el gate de portabilidad recorría solo skills/).
95
+ const threeLenses = () => [
96
+ entry({
97
+ signature: 'validator-skips-agents-references',
98
+ desc: 'the portability validator never walks agents/ references',
99
+ ref: 'scripts/validate-portability.mjs:41',
100
+ source_skill: 'fidelity-lens',
101
+ }),
102
+ entry({
103
+ signature: 'validator-scope-skills-only',
104
+ desc: 'validator scope covers skills only, missing sibling trees',
105
+ ref: 'scripts/validate-portability.mjs:41',
106
+ source_skill: 'logic-lens',
107
+ }),
108
+ entry({
109
+ signature: 'gate-walks-skills-only',
110
+ desc: 'the gate walks skills and nothing else',
111
+ ref: 'scripts/validate-portability.mjs:58',
112
+ source_skill: 'robustness-lens',
113
+ }),
114
+ ];
115
+ test('clusters three independent lenses on one defect', () => {
116
+ const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
117
+ expect(clusters).toHaveLength(1);
118
+ expect(clusters[0].count).toBe(3);
119
+ });
120
+ test('labels the cluster convergent and lists every distinct signature', () => {
121
+ const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
122
+ expect(clusters[0].kind).toBe('convergent');
123
+ expect(clusters[0].signatures).toEqual([
124
+ 'gate-walks-skills-only',
125
+ 'validator-scope-skills-only',
126
+ 'validator-skips-agents-references',
127
+ ]);
128
+ });
129
+ test('does NOT merge two unrelated defects that happen to share a file', () => {
130
+ const clusters = (0, cluster_1.clusterEntries)([
131
+ entry({
132
+ signature: 'gate-walks-skills-only',
133
+ desc: 'the gate walks skills and nothing else',
134
+ ref: 'scripts/validate-portability.mjs:58',
135
+ }),
136
+ entry({
137
+ signature: 'exit-code-swallowed',
138
+ desc: 'process exits zero after a failed assertion',
139
+ ref: 'scripts/validate-portability.mjs:58',
140
+ }),
141
+ ], 2);
142
+ expect(clusters).toEqual([]);
143
+ });
144
+ test('does NOT merge a win with a finding on the strength of a shared file', () => {
145
+ const clusters = (0, cluster_1.clusterEntries)([
146
+ entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills only', ref: 'a.mjs:1', polarity: 'finding' }),
147
+ entry({ signature: 'gate-walks-skills-fix', desc: 'the gate walks skills only, now fixed', ref: 'a.mjs:1', polarity: 'win' }),
148
+ ], 2);
149
+ expect(clusters).toEqual([]);
150
+ });
151
+ test('a non-file ref contributes no clustering signal', () => {
152
+ const clusters = (0, cluster_1.clusterEntries)([
153
+ entry({ signature: 'alpha-defect', desc: 'alpha slug mismatch', ref: 'PR #16' }),
154
+ entry({ signature: 'beta-defect', desc: 'beta timeout on retry', ref: 'PR #16' }),
155
+ ], 2);
156
+ expect(clusters).toEqual([]);
157
+ });
158
+ });
159
+ describe('clusterEntries — lexical convergence without a shared file', () => {
160
+ test('clusters near-identical wording across different files', () => {
161
+ const clusters = (0, cluster_1.clusterEntries)([
162
+ entry({ signature: 'vacuous-test-asserts-nothing', desc: 'test asserts nothing meaningful', ref: 'tests/a.test.ts:3' }),
163
+ entry({ signature: 'vacuous-test-asserts-nothing-either', desc: 'test asserts nothing meaningful', ref: 'tests/b.test.ts:7' }),
164
+ ], 2);
165
+ expect(clusters).toHaveLength(1);
166
+ expect(clusters[0].kind).toBe('convergent');
167
+ });
168
+ test('leaves weakly-related findings on different files apart', () => {
169
+ const clusters = (0, cluster_1.clusterEntries)([
170
+ entry({ signature: 'validator-scope-skills-only', desc: 'validator scope covers skills', ref: 'src/a.ts:1' }),
171
+ entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills', ref: 'src/b.ts:1' }),
172
+ ], 2);
173
+ expect(clusters).toEqual([]);
174
+ });
175
+ test('merges A and C transitively through B, though A and C alone would not cluster', () => {
176
+ const chain = [
177
+ entry({ signature: 'aaa-marker', desc: 'alpha beta gamma delta', ref: 'src/a.ts:1' }),
178
+ entry({ signature: 'bbb-marker', desc: 'beta gamma delta epsilon', ref: 'src/b.ts:1' }),
179
+ entry({ signature: 'ccc-marker', desc: 'gamma delta epsilon zeta', ref: 'src/c.ts:1' }),
180
+ ];
181
+ const clusters = (0, cluster_1.clusterEntries)(chain, 2);
182
+ expect(clusters).toHaveLength(1);
183
+ expect(clusters[0]).toMatchObject({ count: 3, kind: 'convergent' });
184
+ expect(clusters[0].signatures).toEqual(['aaa-marker', 'bbb-marker', 'ccc-marker']);
185
+ // Control: without the bridging entry, A and C do not satisfy the
186
+ // threshold on their own (affinity 0.5 < LEXICAL_AFFINITY_MIN 0.6) —
187
+ // proving the merge above genuinely relies on transitive closure
188
+ // through B, not a coincidence of the threshold being lenient.
189
+ const withoutBridge = (0, cluster_1.clusterEntries)([chain[0], chain[2]], 2);
190
+ expect(withoutBridge).toEqual([]);
191
+ });
192
+ });
193
+ describe('clusterEntries — representative and ordering', () => {
194
+ test('representative signature is the most frequent one', () => {
195
+ const clusters = (0, cluster_1.clusterEntries)([
196
+ entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
197
+ entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
198
+ entry({ signature: 'alpha-rare', desc: 'gate walks skills only, second lens', ref: 'a.mjs:1' }),
199
+ ], 2);
200
+ expect(clusters).toHaveLength(1);
201
+ expect(clusters[0].signature).toBe('zeta-frequent');
202
+ expect(clusters[0].count).toBe(3);
203
+ });
204
+ test('ties on frequency resolve to the lexicographically first signature', () => {
205
+ const clusters = (0, cluster_1.clusterEntries)([
206
+ entry({ signature: 'zeta-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
207
+ entry({ signature: 'alpha-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
208
+ ], 2);
209
+ expect(clusters[0].signature).toBe('alpha-lens');
210
+ });
211
+ test('sorts by count desc, then convergent before exact, then signature asc', () => {
212
+ const clusters = (0, cluster_1.clusterEntries)([
213
+ // convergent cluster of 2 on one file
214
+ entry({ signature: 'mid-one', desc: 'gate walks skills only', ref: 'mid.mjs:1' }),
215
+ entry({ signature: 'mid-two', desc: 'gate walks skills only, other lens', ref: 'mid.mjs:1' }),
216
+ // exact cluster of 2, unrelated
217
+ entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
218
+ entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
219
+ // exact cluster of 3, unrelated — highest count wins regardless of kind
220
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
221
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
222
+ entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
223
+ ], 2);
224
+ expect(clusters.map((c) => [c.signature, c.count, c.kind])).toEqual([
225
+ ['top-dup', 3, 'exact'],
226
+ ['mid-one', 2, 'convergent'],
227
+ ['exact-dup', 2, 'exact'],
228
+ ]);
229
+ });
230
+ test('an empty ledger yields no clusters', () => {
231
+ expect((0, cluster_1.clusterEntries)([], 2)).toEqual([]);
232
+ });
233
+ test('min <= 0 still returns every group, including size-1 groups', () => {
234
+ const solo = entry({ signature: 'lonely', desc: 'nobody else mentions this', ref: 'z.ts:1' });
235
+ expect((0, cluster_1.clusterEntries)([solo], 0)).toEqual([
236
+ { signature: 'lonely', count: 1, kind: 'exact', signatures: ['lonely'], entries: [solo] },
237
+ ]);
238
+ expect((0, cluster_1.clusterEntries)([solo], -5)).toEqual((0, cluster_1.clusterEntries)([solo], 0));
239
+ });
240
+ });
@@ -54,6 +54,17 @@ describe('ledger store — add/list', () => {
54
54
  expect(got).toHaveLength(2);
55
55
  expect(got.map(e => e.signature)).toEqual(['public-fn-returns-infinity', 's2']);
56
56
  });
57
+ test('listEntries skips a shape-invalid (but syntactically valid) entry without throwing', () => {
58
+ const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
59
+ fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
60
+ const missingDesc = JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'missing-desc', severity: 'minor', ref: 'a.ts:1' });
61
+ const nullDesc = JSON.stringify({ ...entry(), desc: null });
62
+ const numericSignature = JSON.stringify({ ...entry(), signature: 42 });
63
+ fs_1.default.writeFileSync(p, [missingDesc, nullDesc, numericSignature, JSON.stringify(entry({ signature: 's2' }))].join('\n') + '\n');
64
+ const got = (0, store_1.listEntries)(cwd, 'feat-x');
65
+ expect(got).toHaveLength(1);
66
+ expect(got[0].signature).toBe('s2');
67
+ });
57
68
  });
58
69
  describe('ledger store — detectBranch', () => {
59
70
  test('falls back to _no-branch outside a git repo', () => {
@@ -84,10 +95,10 @@ describe('ledger store — recurring', () => {
84
95
  test('groups by signature and reports clusters with count >= min', () => {
85
96
  (0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
86
97
  (0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
87
- (0, store_1.addEntry)(cwd, entry({ signature: 'solo' }));
98
+ (0, store_1.addEntry)(cwd, entry({ signature: 'solo', ref: 'src/other.ts:3', desc: 'pagination cursor skips a page' }));
88
99
  const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
89
100
  expect(clusters).toHaveLength(1);
90
- expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2 });
101
+ expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
91
102
  expect(clusters[0].entries).toHaveLength(2);
92
103
  });
93
104
  test('respects --min: count 2 is excluded when min is 3', () => {
@@ -96,14 +107,38 @@ describe('ledger store — recurring', () => {
96
107
  expect((0, store_1.recurring)(cwd, 'feat-x', 3)).toEqual([]);
97
108
  });
98
109
  test('sorts clusters by count descending', () => {
99
- (0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
100
- (0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
101
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
102
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
103
- (0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
110
+ (0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
111
+ (0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
112
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
113
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
114
+ (0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
104
115
  const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
105
116
  expect(clusters.map(c => c.signature)).toEqual(['b', 'a']);
106
117
  });
118
+ test('reports independent lenses on one file as a single convergent cluster', () => {
119
+ (0, store_1.addEntry)(cwd, entry({
120
+ signature: 'validator-scope-skills-only',
121
+ desc: 'validator scope covers skills only',
122
+ ref: 'scripts/validate-portability.mjs:41',
123
+ }));
124
+ (0, store_1.addEntry)(cwd, entry({
125
+ signature: 'gate-walks-skills-only',
126
+ desc: 'the gate walks skills and nothing else',
127
+ ref: 'scripts/validate-portability.mjs:58',
128
+ }));
129
+ const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
130
+ expect(clusters).toHaveLength(1);
131
+ expect(clusters[0]).toMatchObject({ count: 2, kind: 'convergent' });
132
+ expect(clusters[0].signatures).toEqual(['gate-walks-skills-only', 'validator-scope-skills-only']);
133
+ });
134
+ test('recurring does not crash on a shape-invalid entry mixed into an otherwise valid ledger', () => {
135
+ (0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
136
+ (0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
137
+ const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
138
+ fs_1.default.appendFileSync(p, JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'no-desc', severity: 'minor', ref: 'b.ts:1' }) + '\n');
139
+ expect(() => (0, store_1.recurring)(cwd, 'feat-x', 2)).not.toThrow();
140
+ expect((0, store_1.recurring)(cwd, 'feat-x', 2)).toEqual([expect.objectContaining({ signature: 'dup', count: 2 })]);
141
+ });
107
142
  });
108
143
  describe('ledger store — archive', () => {
109
144
  let cwd;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ // Structural regression guard for the recurring class of bug found across
3
+ // R1's durable-controller work (harness-retro, 2026-08-01 — r1-durable-controller
4
+ // plan, Task 20 + post-implementation-qa): execFileSync/spawn/spawnSync calls
5
+ // with no explicit `stdio` option inherit Node's default `inheritStderr`
6
+ // relay, which pipes the SUBPROCESS's stderr onto the CALLING process's own
7
+ // stderr fd. When that fd is a destroyed/broken pipe (a detached wrapper
8
+ // whose parent already tore down its own pipes, a supervisor whose stdout
9
+ // went away), the relay itself raises an async EPIPE with no listener,
10
+ // which Node re-throws — silently crashing the caller before it can finish
11
+ // its work.
12
+ //
13
+ // The bug recurred FIVE separate times in the same session even after the
14
+ // first fix landed: spawnStructured's own detached-child spawn, then 4 more
15
+ // bare execFileSync('git'/'ps'/'pgrep', ...) call sites in DIFFERENT files
16
+ // (process.ts, lock.ts, job/index.ts, watch/index.ts, fingerprint.ts) plus a
17
+ // bonus spawnSync('zip', ...) in pack.ts — because each fix was scoped to
18
+ // the one call site a reviewer happened to be looking at, not the whole
19
+ // class. This test exercises the whole class in one pass: every
20
+ // execFileSync/spawn/spawnSync call site anywhere under cli/src must pass an
21
+ // explicit `stdio` option (or the shared `EXEC_STDIO` constant) — so a
22
+ // future call site added without one fails immediately instead of waiting
23
+ // for the next EPIPE crash to be noticed.
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ const fs_1 = __importDefault(require("fs"));
29
+ const path_1 = __importDefault(require("path"));
30
+ const SRC_ROOT = path_1.default.join(__dirname, '../../src');
31
+ const CALLEE_NAMES = ['execFileSync', 'spawnSync', 'spawn'];
32
+ function listTsFiles(dir) {
33
+ const out = [];
34
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
35
+ const full = path_1.default.join(dir, entry.name);
36
+ if (entry.isDirectory())
37
+ out.push(...listTsFiles(full));
38
+ else if (entry.isFile() && entry.name.endsWith('.ts'))
39
+ out.push(full);
40
+ }
41
+ return out;
42
+ }
43
+ // Strips comments before scanning: JSDoc/inline comments in this codebase
44
+ // routinely reference "spawn (...)" or "execFileSync(...)" in prose (e.g.
45
+ // process.ts's own EPIPE-hardening comment), which would otherwise register
46
+ // as phantom call sites. Naive block/line-comment stripping, not a full TS
47
+ // parser — sufficient for a structural guard, and a false positive here
48
+ // fails loud rather than silently missing a real site. The `[^:]` guard on
49
+ // `//` avoids treating a `https://` URL in a string as a line comment.
50
+ function stripComments(source) {
51
+ return source
52
+ .replace(/\/\*[\s\S]*?\*\//g, '')
53
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
54
+ }
55
+ // Extracts the paren-balanced argument text of every call to one of
56
+ // CALLEE_NAMES in `source` — e.g. "execFileSync('git', [...], {...})" -> the
57
+ // text between the outer parens. Balanced-paren scanning (not a single-line
58
+ // regex) is required because real call sites in this repo span multiple
59
+ // lines (e.g. process.ts's pgrep call).
60
+ function extractCalls(rawSource) {
61
+ const source = stripComments(rawSource);
62
+ const calls = [];
63
+ const calleeRe = new RegExp(`\\b(${CALLEE_NAMES.join('|')})\\s*\\(`, 'g');
64
+ let m;
65
+ while ((m = calleeRe.exec(source)) !== null) {
66
+ const start = m.index + m[0].length; // just after the opening '('
67
+ let depth = 1;
68
+ let i = start;
69
+ while (i < source.length && depth > 0) {
70
+ if (source[i] === '(')
71
+ depth++;
72
+ else if (source[i] === ')')
73
+ depth--;
74
+ i++;
75
+ }
76
+ calls.push({ callee: m[1], args: source.slice(start, i - 1) });
77
+ }
78
+ return calls;
79
+ }
80
+ describe('execFileSync/spawn/spawnSync invocations — explicit stdio required (structural)', () => {
81
+ it('every call site under cli/src passes an explicit stdio option, never relying on Node defaults', () => {
82
+ const violations = [];
83
+ for (const file of listTsFiles(SRC_ROOT)) {
84
+ const source = fs_1.default.readFileSync(file, 'utf8');
85
+ for (const call of extractCalls(source)) {
86
+ const hasExplicitStdio = /\bstdio\s*:/.test(call.args) || /\bEXEC_STDIO\b/.test(call.args);
87
+ if (!hasExplicitStdio) {
88
+ violations.push(`${path_1.default.relative(SRC_ROOT, file)}: ${call.callee}(...) has no explicit stdio option`);
89
+ }
90
+ }
91
+ }
92
+ expect(violations).toEqual([]);
93
+ });
94
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.3.1",
3
+ "version": "3.5.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"