agentic-workflow-manager 9.22.0 → 9.22.1

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 (53) hide show
  1. package/dist/src/commands/change/index.js +2 -2
  2. package/dist/src/commands/init.js +8 -4
  3. package/dist/src/commands/registry/add.js +9 -3
  4. package/dist/src/commands/sync.js +11 -5
  5. package/dist/src/commands/update.js +23 -12
  6. package/dist/src/core/changes/derive.js +6 -3
  7. package/dist/src/core/changes/git.js +1 -2
  8. package/dist/src/core/changes/worktree.js +15 -3
  9. package/dist/src/core/init/steps.js +3 -8
  10. package/dist/src/core/registries.js +20 -15
  11. package/dist/src/core/registry-git-errors.js +107 -0
  12. package/dist/src/core/registry-git.js +93 -0
  13. package/dist/src/core/registry-sync-report.js +74 -0
  14. package/dist/src/core/versioning.js +3 -6
  15. package/dist/src/index.js +5 -8
  16. package/dist/tests/commands/change/new.test.js +103 -57
  17. package/dist/tests/commands/change/validate.test.js +164 -108
  18. package/dist/tests/commands/changes/list.test.js +41 -22
  19. package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
  20. package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
  21. package/dist/tests/commands/sensors/exec.test.js +3 -1
  22. package/dist/tests/commands/update.test.js +8 -8
  23. package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
  24. package/dist/tests/core/changes/helpers.js +15 -3
  25. package/dist/tests/core/changes/list-paging.test.js +131 -110
  26. package/dist/tests/core/changes/list.test.js +133 -83
  27. package/dist/tests/core/changes/receipt.test.js +18 -6
  28. package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
  29. package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
  30. package/dist/tests/core/registry-git.test.js +153 -0
  31. package/dist/tests/helpers/commit-seeded-registry.js +18 -0
  32. package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
  33. package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
  34. package/dist/tests/structural/active-documentation.test.js +0 -41
  35. package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
  36. package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
  37. package/dist/tests/structural/native-release-artifacts.test.js +116 -107
  38. package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
  39. package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
  40. package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
  41. package/package.json +2 -2
  42. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  43. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  44. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  45. package/prebuilds/win32-x64/secure_fs.node +0 -0
  46. package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
  47. package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
  48. package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
  49. package/dist/tests/structural/process-model-description-contract.test.js +0 -34
  50. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
  51. package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
  52. package/dist/tests/structural/sensor-execution-optimization-docs-contract.test.js +0 -102
  53. package/dist/tests/structural/sensor-slice-decision-contract.test.js +0 -63
@@ -57,20 +57,46 @@ afterEach(() => expect(listReal()).toEqual(realBefore));
57
57
  const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
58
58
  let repo;
59
59
  let template;
60
- beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
61
- afterAll(() => template.cleanup());
60
+ /** The template plus `buildHistory`, built once: `history()` starts a test from a copy of it (no git process). */
61
+ let historic;
62
+ beforeAll(() => {
63
+ template = (0, helpers_1.makeRepo)();
64
+ historic = (0, helpers_1.cloneRepo)(template);
65
+ buildHistory(historic);
66
+ });
67
+ afterAll(() => { historic.cleanup(); template.cleanup(); });
62
68
  beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
63
69
  afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
64
70
  const FIRST = '2026-10-16-first';
71
+ function buildHistory(into) {
72
+ into.commit({ 'old.ts': '1' }, 'feat: before adoption', '2026-10-16T09:00:00Z');
73
+ into.commit({ [(0, list_fixtures_1.recordPath)(FIRST)]: (0, list_fixtures_1.record)(FIRST), 'a.ts': '1' }, 'feat: first (#10)', '2026-10-16T20:00:00Z');
74
+ into.commit({ 'b.ts': '1' }, 'fix: no record', '2026-10-16T21:00:00Z');
75
+ into.commit({ 'c.ts': '1' }, 'chore(release): v1.0.0 [skip ci]\n\nChange-Record: none', '2026-10-16T22:00:00Z');
76
+ into.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-bad')]: '---\nschema: nope\n---\n' }, 'feat: bad record', '2026-10-17T01:00:00Z');
77
+ into.gitAt('2026-10-17T02:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
78
+ }
79
+ /** The repository after `buildHistory` (a copy of the prebuilt one). */
65
80
  function history() {
66
- repo.commit({ 'old.ts': '1' }, 'feat: before adoption', '2026-10-16T09:00:00Z');
67
- repo.commit({ [(0, list_fixtures_1.recordPath)(FIRST)]: (0, list_fixtures_1.record)(FIRST), 'a.ts': '1' }, 'feat: first (#10)', '2026-10-16T20:00:00Z');
68
- repo.commit({ 'b.ts': '1' }, 'fix: no record', '2026-10-16T21:00:00Z');
69
- repo.commit({ 'c.ts': '1' }, 'chore(release): v1.0.0 [skip ci]\n\nChange-Record: none', '2026-10-16T22:00:00Z');
70
- repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-bad')]: '---\nschema: nope\n---\n' }, 'feat: bad record', '2026-10-17T01:00:00Z');
71
- repo.gitAt('2026-10-17T02:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
81
+ repo.cleanup();
82
+ repo = (0, helpers_1.cloneRepo)(historic);
72
83
  }
73
84
  const run = (opts = {}, deps = {}) => (0, changes_1.runChangesList)(repo.root, opts, { now: list_fixtures_1.now, ...deps });
85
+ const sharedRuns = new Map();
86
+ /**
87
+ * `run(opts)` on the untouched prebuilt history. The command is read-only (R5.7, asserted below), so each distinct run is made once
88
+ * and its result shared by the tests that only read it.
89
+ */
90
+ function onHistory(opts) {
91
+ const key = JSON.stringify(Object.entries(opts).sort(([a], [b]) => a.localeCompare(b)));
92
+ let r = sharedRuns.get(key);
93
+ if (r === undefined) {
94
+ r = (0, changes_1.runChangesList)(historic.root, opts, { now: list_fixtures_1.now });
95
+ sharedRuns.set(key, r);
96
+ }
97
+ return r;
98
+ }
99
+ const historyJson = () => JSON.parse(onHistory({ since: '48h', format: 'json' }).stdout);
74
100
  const json = (opts = {}, deps = {}) => JSON.parse(run({ since: '48h', format: 'json', ...opts }, deps).stdout);
75
101
  const failure = (r, code) => {
76
102
  expect(r.code).toBe(2);
@@ -209,8 +235,7 @@ describe('awm changes list: adoption (R5.4)', () => {
209
235
  });
210
236
  describe('awm changes list: classification and derived fields (R5.1, R5.3, R5.4, R5.5)', () => {
211
237
  test('classifies and derives', () => {
212
- history();
213
- const out = json();
238
+ const out = historyJson();
214
239
  const byClass = Object.fromEntries(out.changes.map((c) => [c.subject, c.classification]));
215
240
  expect(byClass).toEqual({ 'feat: first (#10)': 'recorded', 'fix: no record': 'missing', 'chore(release): v1.0.0 [skip ci]': 'exempt', 'feat: bad record': 'invalid-record' });
216
241
  const first = out.changes.find((c) => c.subject === 'feat: first (#10)');
@@ -219,8 +244,7 @@ describe('awm changes list: classification and derived fields (R5.1, R5.3, R5.4,
219
244
  expect(out.repo).toBeNull();
220
245
  });
221
246
  test('pre-adoption commits are omitted', () => {
222
- history();
223
- expect(JSON.stringify(json())).not.toContain('before adoption');
247
+ expect(JSON.stringify(historyJson())).not.toContain('before adoption');
224
248
  });
225
249
  test('repo identity strips credentials', () => {
226
250
  history();
@@ -291,28 +315,24 @@ describe('awm changes list: mixed invalid commits, odd dates and shallow clones
291
315
  });
292
316
  describe('awm changes list: formats (R5.6)', () => {
293
317
  test('json is one document on one line', () => {
294
- history();
295
- const r = run({ since: '48h', format: 'json' });
318
+ const r = onHistory({ since: '48h', format: 'json' });
296
319
  expect(r.stdout.split('\n')).toHaveLength(2);
297
320
  expect(Object.keys(JSON.parse(r.stdout))).toEqual(['schema', 'repo', 'base', 'since', 'adopted', 'truncated', 'omitted', 'changes']);
298
321
  expect(JSON.parse(r.stdout).adopted).toBe(true);
299
322
  });
300
323
  test('voice speaks recorded changes and counts the rest', () => {
301
- history();
302
- const r = run({ since: '48h', format: 'voice' });
324
+ const r = onHistory({ since: '48h', format: 'voice' });
303
325
  expect(r.stdout).toContain('Primer cambio. feat, verificado, 2 validaciones manuales pendientes.');
304
326
  expect(r.stdout).toMatch(/1 cambio\(s\) sin record y 1 con record inválido/);
305
327
  });
306
328
  test('text is a table with the classification of each change', () => {
307
- history();
308
- const out = run({ since: '48h' }).stdout;
329
+ const out = onHistory({ since: '48h' }).stdout;
309
330
  for (const word of ['recorded', 'missing', 'exempt', 'invalid-record', 'v1.0.0', '#10'])
310
331
  expect(out).toContain(word);
311
332
  expect(out.split('\n')[0]).toMatch(/^WHEN\s+CLASSIFICATION/);
312
333
  });
313
334
  test('text is the default format', () => {
314
- history();
315
- expect(run({ since: '48h' }).stdout).toBe(run({ since: '48h', format: 'text' }).stdout);
335
+ expect(onHistory({ since: '48h' }).stdout).toBe(onHistory({ since: '48h', format: 'text' }).stdout);
316
336
  });
317
337
  test('an empty window says so in text', () => {
318
338
  history();
@@ -328,9 +348,8 @@ describe('awm changes list: formats (R5.6)', () => {
328
348
  expect(run({ since: '48h', format: 'voice' }, deps).stdout).toMatch(/Hay 2 cambio\(s\) más antiguo\(s\) sin mostrar/);
329
349
  });
330
350
  test('no format prints anything on stderr when it succeeds', () => {
331
- history();
332
351
  for (const format of ['text', 'json', 'voice'])
333
- expect(run({ since: '48h', format }).stderr).toBe('');
352
+ expect(onHistory({ since: '48h', format }).stderr).toBe('');
334
353
  });
335
354
  });
336
355
  describe('awm changes list: read-only (R5.7)', () => {
@@ -425,9 +425,20 @@ describe('reap — limpieza explicita con identidad validada (R2.2)', () => {
425
425
  expect(m.aliveWithIdentity).toBe(false);
426
426
  });
427
427
  test('planReap reporta aliveWithIdentity: true para un proceso genuinamente vivo', () => {
428
- const { spawnStructured } = require('../../../src/core/journal/process');
428
+ // Same full-spawn retry as process.test.ts: on windows-11-arm (and
429
+ // historically windows-latest) a freshly spawned `node -e` can die
430
+ // before OpenProcess/pidExistsNative sees it. Re-polling the same pid
431
+ // never helps; a fresh instance can. Three failures still fail loudly.
432
+ const { spawnStructured, refIsAlive } = require('../../../src/core/journal/process');
429
433
  const s = (0, types_1.emptyState)('r');
430
- const { child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive');
434
+ let { child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive');
435
+ for (let attempt = 1; attempt < 3 && !refIsAlive(ref); attempt++) {
436
+ try {
437
+ child.kill('SIGKILL');
438
+ }
439
+ catch { /* already gone */ }
440
+ ({ child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive'));
441
+ }
431
442
  s.jobs['vivo'] = job({ id: 'vivo', executionState: 'running', processRef: ref });
432
443
  try {
433
444
  const plan = (0, reap_1.planReap)(s);
@@ -0,0 +1,175 @@
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
+ // #238 S2 / SG-7 — soft (add/list) vs hard (sync/update/init/registry add) sync failure UX.
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const registry_sync_report_1 = require("../../src/core/registry-sync-report");
11
+ describe('registry-sync-report pure helpers (#238 S2)', () => {
12
+ it('formatClassifiedRegistryFailure includes name, remote, class, fatal, remedy — never URL userinfo', () => {
13
+ const token = 'ghp_TESTSECRET_DO_NOT_ECHO';
14
+ const text = (0, registry_sync_report_1.formatClassifiedRegistryFailure)({
15
+ name: 'baseline',
16
+ remote: `https://${token}@example.test/baseline.git`,
17
+ err: new Error(`fatal: could not read Username for 'https://${token}@example.test': terminal prompts disabled`),
18
+ });
19
+ expect(text).toContain('baseline');
20
+ expect(text).toContain('example.test/baseline.git');
21
+ expect(text).toMatch(/authentication/i);
22
+ expect(text).toMatch(/terminal prompts disabled/i);
23
+ expect(text).toMatch(/credential\.helper/);
24
+ expect(text).not.toContain(token);
25
+ expect(text).not.toMatch(/ghp_/);
26
+ });
27
+ it('formatRegistrySyncSoftFailure states cached version plus SG-6 cause/remedy', () => {
28
+ const classified = (0, registry_sync_report_1.formatClassifiedRegistryFailure)({
29
+ name: 'docs',
30
+ remote: 'https://example.test/docs.git',
31
+ err: new Error('fatal: unable to access: Could not resolve host: example.test'),
32
+ });
33
+ const soft = (0, registry_sync_report_1.formatRegistrySyncSoftFailure)({
34
+ name: 'docs',
35
+ cachedVersion: '1.4.0',
36
+ classifiedMessage: classified,
37
+ });
38
+ expect(soft).toMatch(/cached version v1\.4\.0/i);
39
+ expect(soft).toContain('docs');
40
+ expect(soft).toMatch(/network/i);
41
+ expect(soft).toMatch(/Could not resolve host/i);
42
+ expect(soft).toMatch(/remedy:/);
43
+ // B3: soft line must not say "sync failed" twice before the classified clause.
44
+ expect(soft.match(/sync failed/gi)?.length ?? 0).toBe(1);
45
+ });
46
+ it('formatRegistrySyncSoftFailure uses unknown when cachedVersion is null', () => {
47
+ const soft = (0, registry_sync_report_1.formatRegistrySyncSoftFailure)({
48
+ name: 'docs',
49
+ cachedVersion: null,
50
+ classifiedMessage: 'registry docs sync failed (class=network); remedy: check connectivity',
51
+ });
52
+ expect(soft).toMatch(/cached version unknown/i);
53
+ });
54
+ it('hardFailOnAnyRegistrySyncError is true when any result is action:error', () => {
55
+ const ok = [{ name: 'baseline', action: 'pulled', version: 'v1.0.0' }];
56
+ const mixed = [
57
+ { name: 'baseline', action: 'pulled', version: 'v1.0.0' },
58
+ { name: 'docs', action: 'error', error: 'network unreachable' },
59
+ ];
60
+ expect((0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(ok)).toBe(false);
61
+ expect((0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(mixed)).toBe(true);
62
+ });
63
+ it('formatSoftRegistrySyncFailures emits only usable errored registries with cached version', async () => {
64
+ const results = [
65
+ { name: 'ok', action: 'pulled', version: 'v1.0.0' },
66
+ { name: 'stale', action: 'error', error: 'registry stale sync failed (class=network); remedy: check connectivity' },
67
+ { name: 'gone', action: 'error', error: 'registry gone sync failed (class=not-found); remedy: verify the remote URL' },
68
+ ];
69
+ const lines = await (0, registry_sync_report_1.formatSoftRegistrySyncFailures)(results, {
70
+ isUsable: (name) => name === 'stale',
71
+ cachedVersion: (name) => (name === 'stale' ? '2.0.0' : null),
72
+ });
73
+ expect(lines).toHaveLength(1);
74
+ expect(lines[0]).toMatch(/stale/);
75
+ expect(lines[0]).toMatch(/cached version v2\.0\.0/i);
76
+ expect(lines[0]).toMatch(/class=network/);
77
+ expect(lines.join('\n')).not.toMatch(/\bgone\b/);
78
+ });
79
+ });
80
+ describe('awm sync / update hard-exit on sync error (#238 S2 / SG-7)', () => {
81
+ const realHome = process.env.HOME;
82
+ const realAwmHome = process.env.AWM_HOME;
83
+ let tmpHome;
84
+ beforeEach(() => {
85
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-honesty-'));
86
+ process.env.HOME = tmpHome;
87
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
88
+ fs_1.default.mkdirSync(process.env.AWM_HOME, { recursive: true });
89
+ jest.resetModules();
90
+ jest.doMock('@clack/prompts', () => ({
91
+ confirm: jest.fn(),
92
+ isCancel: jest.fn(),
93
+ intro: jest.fn(),
94
+ outro: jest.fn(),
95
+ spinner: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })),
96
+ }));
97
+ });
98
+ afterEach(() => {
99
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
100
+ if (realHome === undefined)
101
+ delete process.env.HOME;
102
+ else
103
+ process.env.HOME = realHome;
104
+ if (realAwmHome === undefined)
105
+ delete process.env.AWM_HOME;
106
+ else
107
+ process.env.AWM_HOME = realAwmHome;
108
+ jest.resetModules();
109
+ jest.dontMock('@clack/prompts');
110
+ });
111
+ function writePrefs() {
112
+ const { savePreferences } = require('../../src/utils/config');
113
+ savePreferences({
114
+ defaultAgent: 'claude-code',
115
+ enabledAgents: ['claude-code'],
116
+ installMethod: 'symlink',
117
+ defaultScope: 'local',
118
+ });
119
+ }
120
+ function seedUsableDocsRegistry() {
121
+ const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
122
+ fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs', 'skills'), { recursive: true });
123
+ fs_1.default.writeFileSync(path_1.default.join(registriesDir, 'docs', 'skills', 'x.md'), '# x\n');
124
+ fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
125
+ }
126
+ it('runSyncCore returns code !== 0 when any registry result is action:error (even if usable on disk)', async () => {
127
+ writePrefs();
128
+ seedUsableDocsRegistry();
129
+ const project = path_1.default.join(tmpHome, 'proj');
130
+ fs_1.default.mkdirSync(path_1.default.join(project, '.awm'), { recursive: true });
131
+ fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'profile.json'), JSON.stringify({ version: 1, extensions: [], agents: ['claude-code'] }));
132
+ const reconcile = jest.fn(() => []);
133
+ const { runSyncCore } = require('../../src/commands/sync');
134
+ const result = await runSyncCore({ cwd: project }, {
135
+ syncRegistries: async () => [
136
+ { name: 'docs', action: 'error', error: 'network unreachable' },
137
+ ],
138
+ verifyMinCliVersions: () => [],
139
+ verifyProjectPins: async () => [],
140
+ syncProfile: () => ({ extensions: [], installed: [], skipped: [], transactionIds: [] }),
141
+ reconcileProjectSkillLinks: reconcile,
142
+ });
143
+ expect(result.code).not.toBe(0);
144
+ // A1: later stages still run when usable content remains.
145
+ expect(reconcile).toHaveBeenCalled();
146
+ });
147
+ it('runUpdateCore returns code !== 0 when a registry fails even with usable on-disk content', async () => {
148
+ writePrefs();
149
+ seedUsableDocsRegistry();
150
+ const regenerate = jest.fn(() => []);
151
+ const planReconciliation = jest.fn(() => ({ operations: [], records: [], reports: [] }));
152
+ const applyInstallPlan = jest.fn(() => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }));
153
+ const offerSelfUpdate = jest.fn(async () => { });
154
+ const { runUpdateCore, updateOutro } = require('../../src/commands/update');
155
+ const result = await runUpdateCore({}, {
156
+ syncRegistries: async () => [
157
+ { name: 'docs', action: 'error', error: 'network unreachable' },
158
+ ],
159
+ verifyMinCliVersions: () => [],
160
+ regenerateGlobalContext: regenerate,
161
+ planReconciliation,
162
+ applyInstallPlan,
163
+ resyncInstalledHooks: () => [],
164
+ offerSelfUpdate,
165
+ });
166
+ expect(result.code).not.toBe(0);
167
+ expect(result.registries.failed).toEqual(['docs']);
168
+ // A1: pipeline continues after sync error when content is usable.
169
+ expect(regenerate).toHaveBeenCalled();
170
+ expect(planReconciliation).toHaveBeenCalled();
171
+ expect(applyInstallPlan).toHaveBeenCalled();
172
+ expect(offerSelfUpdate).toHaveBeenCalled();
173
+ expect(updateOutro(result)).toMatch(/Finished with registry sync errors|Update failed/i);
174
+ });
175
+ });
@@ -44,6 +44,7 @@ const exec_1 = require("../../../src/commands/sensors/exec");
44
44
  const result_1 = require("../../../src/commands/sensors/result");
45
45
  const baseline_1 = require("../../../src/commands/sensors/baseline");
46
46
  const prepare_1 = require("../../../src/commands/sensors/prepare");
47
+ const fixture_cleanup_1 = require("../../helpers/fixture-cleanup");
47
48
  const sensor = (overrides = {}) => ({
48
49
  name: 'lint',
49
50
  command: { kind: 'legacy', value: 'node -e "setTimeout(() => {}, 1000)"' },
@@ -401,7 +402,8 @@ describe('runStructuredCommand — Windows local native binaries', () => {
401
402
  delete process.env.PATHEXT;
402
403
  else
403
404
  process.env.PATHEXT = savedPathExt;
404
- fs_1.default.rmSync(dir, { recursive: true, force: true });
405
+ // Windows keeps a just-executed copy of node locked for a moment (EBUSY on unlink).
406
+ (0, fixture_cleanup_1.removeFixtureTree)(dir);
405
407
  });
406
408
  it('executes a contained native executable with literal shell-sensitive argv', async () => {
407
409
  const executable = path_1.default.join(dir, 'node_modules', '.bin', 'fixture.exe');
@@ -164,9 +164,9 @@ describe('awm update — honest outcome', () => {
164
164
  expect(result.code).toBe(1);
165
165
  expect(result.registries.failed).toEqual(['baseline']);
166
166
  });
167
- it('un registry que falló pero conserva contenido sigue, y el cierre lo nombra en vez de anunciar éxito parejo', async () => {
168
- // `unusableSyncedRegistries` mira contenido EN DISCO: se siembra el content root
169
- // y el registries.json para que `docs` cuente como stale, no como roto.
167
+ it('un registry que falló falla cerrado aunque conserve contenido en disco (SG-7)', async () => {
168
+ // Pre-SG-7 update warn-and-continued on usable cache; #238 SG-7 requires
169
+ // non-zero exit for init/update/sync/registry add on any sync failure.
170
170
  const registriesDir = path_1.default.join(process.env.AWM_HOME, 'registries');
171
171
  fs_1.default.mkdirSync(path_1.default.join(registriesDir, 'docs', 'skills'), { recursive: true });
172
172
  fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'docs', remote: 'https://example.test/docs.git' }]));
@@ -177,12 +177,12 @@ describe('awm update — honest outcome', () => {
177
177
  { name: 'docs', action: 'error', error: 'network unreachable' },
178
178
  ],
179
179
  }));
180
- expect(result.code).toBe(0);
180
+ expect(result.code).toBe(1);
181
181
  expect(result.registries).toEqual({ configured: 2, synced: 1, failed: ['docs'] });
182
- const outro = updateOutro(result);
183
- expect(outro).toMatch(/stale/i);
184
- expect(outro).toMatch(/docs/);
185
- expect(outro).not.toMatch(/^✅/);
182
+ expect(updateOutro(result)).toMatch(/Finished with registry sync errors/);
183
+ expect(updateOutro(result)).toMatch(/1\/2/);
184
+ expect(updateOutro(result)).toMatch(/docs/);
185
+ expect(errSpy.mock.calls.flat().join('\n')).toMatch(/docs/);
186
186
  });
187
187
  });
188
188
  describe('awm update — nunca se cuelga esperando a un humano que no está', () => {
@@ -758,21 +758,37 @@ describe('supervisor loop', () => {
758
758
  // subagent kept an idle leader while the work ran in processes outside its
759
759
  // process group, and the supervisor entered a false custody. Work in the
760
760
  // descendant tree is activity: observe, never custody, while it moves.
761
+ // The tree must move deterministically between the two ticks. A `while :; do
762
+ // sleep 0.02; done` loop leaves instants with no `sleep` child, so under CPU
763
+ // load two samples 150 ms apart could read identical (~1% of pairs) and the
764
+ // supervisor entered a real custody: a flaky red on linux-arm64. Each worker
765
+ // child lives 30 s, and the test itself makes the tree grow by one between ticks.
761
766
  itPosix('espera sana: lider ocioso + descendiente fuera del grupo trabajando => observa, no custodia (R4.2)', async () => {
762
767
  initUnattendedFixture(repo);
763
768
  (0, generations_1.beginGeneration)(repo, 'main');
764
- const worker = "require('child_process').spawn('sh', ['-c', 'while :; do sleep 0.02; done'], { detached: true, stdio: 'ignore' }).unref(); setTimeout(()=>{}, 20000)";
765
- const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', worker], process.cwd(), 'nWait');
769
+ const trigger = path_1.default.join(repo, 'grow-descendant');
770
+ const worker = [
771
+ "const { spawn } = require('child_process'); const fs = require('fs');",
772
+ "const grow = () => spawn('sleep', ['30'], { detached: true, stdio: 'ignore' }).unref();",
773
+ 'grow();',
774
+ 'const poll = setInterval(() => { if (fs.existsSync(process.argv[1])) { clearInterval(poll); grow(); } }, 10);',
775
+ 'setTimeout(() => {}, 20000);',
776
+ ].join(' ');
777
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', worker, trigger], process.cwd(), 'nWait');
766
778
  try {
767
779
  let s = (0, store_1.readJournal)(repo, 'main').state;
768
780
  (0, generations_1.activeGeneration)(s).processRef = ref;
769
781
  s.controllerHeartbeatAt = new Date(Date.now() - 3600000).toISOString(); // heartbeat vencido: bloqueado en la espera
770
782
  (0, store_1.writeJournal)(repo, 'main', s);
771
- await until(() => ((0, process_1.activitySnapshot)(ref)?.descendants ?? '0:none') !== '0:none');
783
+ const descendants = () => (0, process_1.activitySnapshot)(ref)?.descendants ?? '0:none';
784
+ await until(() => descendants().startsWith('1:'));
772
785
  const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 1, activityWindowMs: 50, tickMs: 20 };
773
786
  const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner, undefined, admitted);
774
787
  await sup.tick();
775
- await new Promise((r) => setTimeout(r, 150)); // mas que la ventana, pero el arbol se movio
788
+ const firstTick = Date.now();
789
+ fs_1.default.writeFileSync(trigger, ''); // el arbol crece: trabajo real fuera del grupo
790
+ await until(() => descendants().startsWith('2:'));
791
+ await new Promise((r) => setTimeout(r, Math.max(0, 150 - (Date.now() - firstTick)))); // mas que la ventana, pero el arbol se movio
776
792
  expect(await sup.tick()).not.toBe('custody');
777
793
  const after = (0, store_1.readJournal)(repo, 'main').state;
778
794
  expect(after.cycle.status).toBe('IN_PROGRESS');
@@ -780,7 +796,7 @@ describe('supervisor loop', () => {
780
796
  expect(child.killed).toBe(false);
781
797
  }
782
798
  finally {
783
- // The detached shell leads its own group: kill that group, never a pattern match.
799
+ // Each detached child leads its own group: kill that group, never a pattern match.
784
800
  let shells = [];
785
801
  try {
786
802
  shells = (0, child_process_1.execFileSync)('pgrep', ['-P', String(ref.pid)], { encoding: 'utf8' }).split('\n').filter(Boolean).map(Number);
@@ -67,19 +67,25 @@ function emptyGitConfig(home) {
67
67
  fs_1.default.writeFileSync(file, '');
68
68
  return file;
69
69
  }
70
+ /**
71
+ * Every test git child: no signing, and no automatic maintenance after a commit. A newer git runs that maintenance detached in
72
+ * the background, where its lock file under `.git/objects` appears and vanishes while a test copies or removes the repository
73
+ * (`cloneRepo` failed with ENOENT on macOS with git 2.55).
74
+ */
75
+ const GIT_TEST_CONFIG = ['-c', 'commit.gpgsign=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
70
76
  /** The git runner of a repository: identity and config isolation in an explicit environment, optional explicit dates. */
71
77
  function gitApi(root, home) {
72
78
  const env = {
73
79
  ...process.env, HOME: home, GIT_CONFIG_GLOBAL: path_1.default.join(home, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
74
80
  GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
75
81
  };
76
- const run = (extra, args) => (0, child_process_1.execFileSync)('git', ['-c', 'commit.gpgsign=false', ...args], { cwd: root, encoding: 'utf8', env: { ...env, ...extra } }).trim();
82
+ const run = (extra, args) => (0, child_process_1.execFileSync)('git', [...GIT_TEST_CONFIG, ...args], { cwd: root, encoding: 'utf8', env: { ...env, ...extra } }).trim();
77
83
  const dates = (date) => {
78
84
  const stamp = `${Math.floor(Date.parse(date) / 1000)} +0000`;
79
85
  return { GIT_AUTHOR_DATE: stamp, GIT_COMMITTER_DATE: stamp };
80
86
  };
81
87
  const git = (...args) => run({}, args);
82
- const gitInput = (input, ...args) => (0, child_process_1.execFileSync)('git', ['-c', 'commit.gpgsign=false', ...args], { cwd: root, encoding: 'utf8', env, input }).trim();
88
+ const gitInput = (input, ...args) => (0, child_process_1.execFileSync)('git', [...GIT_TEST_CONFIG, ...args], { cwd: root, encoding: 'utf8', env, input }).trim();
83
89
  const gitAt = (date, ...args) => run(dates(date), args);
84
90
  const commit = (files, message, date) => {
85
91
  for (const [rel, content] of Object.entries(files)) {
@@ -144,7 +150,13 @@ Object.defineProperty(exports, "posixOnly", { enumerable: true, get: function ()
144
150
  */
145
151
  function cloneRepo(template) {
146
152
  const root = fs_1.default.realpathSync.native(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-chg-')));
147
- fs_1.default.cpSync(template.root, root, { recursive: true });
153
+ try {
154
+ fs_1.default.cpSync(template.root, root, { recursive: true });
155
+ }
156
+ catch (error) {
157
+ fs_1.default.rmSync(root, { recursive: true, force: true });
158
+ throw error;
159
+ }
148
160
  const { git, gitInput, gitAt, commit } = gitApi(root, template.home);
149
161
  return { root, home: template.home, git, gitInput, gitAt, commit, cleanup: () => fs_1.default.rmSync(root, { recursive: true, force: true }) };
150
162
  }