agentic-workflow-manager 9.21.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 (71) hide show
  1. package/dist/src/commands/change/index.js +16 -5
  2. package/dist/src/commands/init.js +8 -4
  3. package/dist/src/commands/preflight/checks.js +22 -0
  4. package/dist/src/commands/registry/add.js +9 -3
  5. package/dist/src/commands/sensors/compatibility/contract.js +56 -3
  6. package/dist/src/commands/sensors/compatibility/manifest.js +16 -2
  7. package/dist/src/commands/sensors/index.js +10 -1
  8. package/dist/src/commands/sensors/inputs-digest.js +315 -0
  9. package/dist/src/commands/sensors/prepare.js +10 -0
  10. package/dist/src/commands/sensors/result-cache.js +278 -0
  11. package/dist/src/commands/sensors/result.js +31 -12
  12. package/dist/src/commands/sensors/run.js +265 -8
  13. package/dist/src/commands/sensors/status.js +66 -7
  14. package/dist/src/commands/sync.js +11 -5
  15. package/dist/src/commands/update.js +23 -12
  16. package/dist/src/core/changes/derive.js +6 -3
  17. package/dist/src/core/changes/git.js +1 -2
  18. package/dist/src/core/changes/receipt.js +219 -9
  19. package/dist/src/core/changes/worktree.js +15 -3
  20. package/dist/src/core/init/steps.js +3 -8
  21. package/dist/src/core/registries.js +20 -15
  22. package/dist/src/core/registry-git-errors.js +107 -0
  23. package/dist/src/core/registry-git.js +93 -0
  24. package/dist/src/core/registry-sync-report.js +74 -0
  25. package/dist/src/core/versioning.js +3 -6
  26. package/dist/src/index.js +5 -8
  27. package/dist/tests/commands/change/new.test.js +104 -58
  28. package/dist/tests/commands/change/validate-unmeasured.test.js +246 -0
  29. package/dist/tests/commands/change/validate.test.js +164 -108
  30. package/dist/tests/commands/changes/list.test.js +41 -22
  31. package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
  32. package/dist/tests/commands/preflight/sensors-inputs.test.js +110 -0
  33. package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
  34. package/dist/tests/commands/sensors/compatibility/contract.test.js +60 -0
  35. package/dist/tests/commands/sensors/compatibility/manifest.test.js +94 -0
  36. package/dist/tests/commands/sensors/exec.test.js +3 -1
  37. package/dist/tests/commands/sensors/inputs-digest.test.js +373 -0
  38. package/dist/tests/commands/sensors/result-cache.test.js +352 -0
  39. package/dist/tests/commands/sensors/run-cache.test.js +434 -0
  40. package/dist/tests/commands/sensors/run-slice.test.js +171 -0
  41. package/dist/tests/commands/sensors/status-inputs.test.js +271 -0
  42. package/dist/tests/commands/update.test.js +8 -8
  43. package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
  44. package/dist/tests/core/changes/helpers.js +15 -3
  45. package/dist/tests/core/changes/list-paging.test.js +131 -110
  46. package/dist/tests/core/changes/list.test.js +133 -83
  47. package/dist/tests/core/changes/receipt.test.js +279 -8
  48. package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
  49. package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
  50. package/dist/tests/core/registry-git.test.js +153 -0
  51. package/dist/tests/helpers/commit-seeded-registry.js +18 -0
  52. package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
  53. package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
  54. package/dist/tests/structural/active-documentation.test.js +0 -41
  55. package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
  56. package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
  57. package/dist/tests/structural/native-release-artifacts.test.js +116 -107
  58. package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
  59. package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
  60. package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
  61. package/package.json +3 -2
  62. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  63. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  64. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  65. package/prebuilds/win32-x64/secure_fs.node +0 -0
  66. package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
  67. package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
  68. package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
  69. package/dist/tests/structural/process-model-description-contract.test.js +0 -34
  70. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
  71. package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
@@ -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,110 @@
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 fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const checks_1 = require("../../../src/commands/preflight/checks");
10
+ const preflight_1 = require("../../../src/commands/preflight");
11
+ /**
12
+ * RF-3.4 — preflight surfaces a non-blocking (advisory) warning when configured
13
+ * enabled sensors lack effective `inputs`. Opt-out / native-gate must stay quiet.
14
+ */
15
+ describe('preflight sensors-inputs warning (RF-3.4)', () => {
16
+ const dirs = [];
17
+ const homes = [];
18
+ let previousHome;
19
+ beforeEach(() => {
20
+ previousHome = process.env.AWM_HOME;
21
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pf-inputs-home-'));
22
+ homes.push(home);
23
+ process.env.AWM_HOME = home;
24
+ fs_1.default.writeFileSync(path_1.default.join(home, 'registries.json'), JSON.stringify([]));
25
+ });
26
+ afterEach(() => {
27
+ if (previousHome === undefined)
28
+ delete process.env.AWM_HOME;
29
+ else
30
+ process.env.AWM_HOME = previousHome;
31
+ });
32
+ afterAll(() => {
33
+ for (const dir of dirs)
34
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
35
+ for (const home of homes)
36
+ fs_1.default.rmSync(home, { recursive: true, force: true });
37
+ });
38
+ function project(opts = {}) {
39
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pf-inputs-'));
40
+ dirs.push(dir);
41
+ for (const f of opts.context ?? ['AGENTS.md']) {
42
+ fs_1.default.writeFileSync(path_1.default.join(dir, f), '# ctx\n');
43
+ }
44
+ for (const b of opts.bins ?? ['eslint']) {
45
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'node_modules', '.bin'), { recursive: true });
46
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'node_modules', '.bin', b), '');
47
+ }
48
+ if (opts.manifest !== undefined) {
49
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
50
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), typeof opts.manifest === 'string' ? opts.manifest : JSON.stringify(opts.manifest));
51
+ }
52
+ return dir;
53
+ }
54
+ function check(report, id) {
55
+ return report.checks.find(c => c.id === id);
56
+ }
57
+ function onlyProjectSensor(extra = {}) {
58
+ return {
59
+ schemaVersion: 3,
60
+ mode: 'project-sensors',
61
+ sensors: {
62
+ custom: {
63
+ source: 'project',
64
+ enabled: true,
65
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
66
+ formatter: 'exit-code',
67
+ ...extra,
68
+ },
69
+ },
70
+ };
71
+ }
72
+ it('emits an advisory sensors-inputs warning when an enabled sensor lacks inputs', async () => {
73
+ const dir = project({ manifest: onlyProjectSensor() });
74
+ const report = await (0, checks_1.preflight)(dir);
75
+ expect(report.status).toBe('ready');
76
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(0);
77
+ expect(check(report, 'sensors-inputs')).toMatchObject({
78
+ ok: false,
79
+ advisory: true,
80
+ detail: expect.stringMatching(/custom/i),
81
+ remedy: expect.stringMatching(/inputs|cache/i),
82
+ });
83
+ expect(check(report, 'sensors-inputs').detail).toMatch(/not cacheable|no effective inputs|lacks inputs/i);
84
+ expect((0, preflight_1.formatReport)(report)).toMatch(/⚠.*sensors-inputs/s);
85
+ });
86
+ it('omits sensors-inputs when every enabled sensor has effective inputs', async () => {
87
+ const dir = project({ manifest: onlyProjectSensor({ inputs: ['**/*.ts'] }) });
88
+ const report = await (0, checks_1.preflight)(dir);
89
+ expect(report.status).toBe('ready');
90
+ expect(check(report, 'sensors-inputs')).toBeUndefined();
91
+ });
92
+ it('does not warn for disabled sensors that lack inputs', async () => {
93
+ const dir = project({
94
+ manifest: onlyProjectSensor({ enabled: false }),
95
+ });
96
+ const report = await (0, checks_1.preflight)(dir);
97
+ expect(check(report, 'sensors-inputs')).toBeUndefined();
98
+ });
99
+ it.each([
100
+ ['native-gate', 'CI is authoritative.', 'native_gate'],
101
+ ['opt-out', 'No local gate.', 'ungated'],
102
+ ])('emits no sensors-inputs warning for v3 %s', async (mode, reason, status) => {
103
+ const dir = project({
104
+ manifest: { schemaVersion: 3, mode, reason },
105
+ });
106
+ const report = await (0, checks_1.preflight)(dir);
107
+ expect(report.status).toBe(status);
108
+ expect(check(report, 'sensors-inputs')).toBeUndefined();
109
+ });
110
+ });
@@ -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
+ });
@@ -302,4 +302,64 @@ describe('sensor pack v2 contract', () => {
302
302
  ])('rejects malformed pack %j', (input, message) => {
303
303
  expect(() => (0, contract_1.parseSensorPack)(input, 'pack.json')).toThrow(message);
304
304
  });
305
+ describe('inputs and sensor-level closureOnly (RF-1.1, RF-1.2, RF-1.5, RF-1.7)', () => {
306
+ it('accepts inputs on variants and closureOnly on the sensor (RF-1.2 pack defaults)', () => {
307
+ const pack = validPack();
308
+ pack.sensors.lint.closureOnly = true;
309
+ pack.sensors.lint.variants[0].inputs = ['**/*.ts', '**/*.[cm]js'];
310
+ expect((0, contract_1.parseSensorPack)(pack, 'pack.json')).toMatchObject({
311
+ kind: 'v2',
312
+ pack: {
313
+ sensors: {
314
+ lint: {
315
+ closureOnly: true,
316
+ variants: [{ inputs: ['**/*.ts', '**/*.[cm]js'] }],
317
+ },
318
+ },
319
+ },
320
+ });
321
+ });
322
+ it('treats omitted sensor closureOnly as false and does not invent variant inputs', () => {
323
+ const parsed = (0, contract_1.parseSensorPack)(validPack(), 'pack.json');
324
+ expect(parsed).toMatchObject({ kind: 'v2' });
325
+ if (parsed.kind !== 'v2')
326
+ throw new Error('expected v2 pack');
327
+ const lint = parsed.pack.sensors.lint;
328
+ expect(lint.closureOnly ?? false).toBe(false);
329
+ expect('inputs' in lint.variants[0]).toBe(false);
330
+ });
331
+ it('rejects closureOnly on a variant (RF-1.7 sensor-level only)', () => {
332
+ const pack = validPack();
333
+ pack.sensors.lint.variants[0].closureOnly = true;
334
+ expect(() => (0, contract_1.parseSensorPack)(pack, 'pack.json')).toThrow(/unknown field "closureOnly"/);
335
+ });
336
+ it('rejects inputs on the sensor object (inputs belong on variants)', () => {
337
+ const pack = validPack();
338
+ pack.sensors.lint.inputs = ['**/*.ts'];
339
+ expect(() => (0, contract_1.parseSensorPack)(pack, 'pack.json')).toThrow(/unknown field "inputs"/);
340
+ });
341
+ it.each([
342
+ [[''], /nonempty single-line/],
343
+ [['/abs'], /absolute paths are invalid/],
344
+ [['has\u0000nul'], /NUL or control/],
345
+ [['../escape'], /contained relative glob/],
346
+ [['src/{a,b}.ts'], /brace expansion/],
347
+ [['!negated.ts'], /negation/],
348
+ [[], /must be a nonempty array/],
349
+ [['@(a|b).ts'], /extglob|parentheses/],
350
+ [['+(a).ts'], /extglob|parentheses/],
351
+ [['*(a).ts'], /extglob|parentheses/],
352
+ [['?(a).ts'], /extglob|parentheses/],
353
+ [['(a|b).ts'], /extglob|parentheses/],
354
+ ])('rejects invalid variant inputs %j', (inputs, reason) => {
355
+ const pack = validPack();
356
+ pack.sensors.lint.variants[0].inputs = inputs;
357
+ expect(() => (0, contract_1.parseSensorPack)(pack, 'pack.json')).toThrow(reason);
358
+ });
359
+ it('rejects non-boolean sensor closureOnly', () => {
360
+ const pack = validPack();
361
+ pack.sensors.lint.closureOnly = 1;
362
+ expect(() => (0, contract_1.parseSensorPack)(pack, 'pack.json')).toThrow(/closureOnly must be a boolean/);
363
+ });
364
+ });
305
365
  });
@@ -396,4 +396,98 @@ describe('sensor manifest contract', () => {
396
396
  expect(soft.invalidEntries.some(entry => /not valid JSON|valid JSON/i.test(entry.reason))).toBe(false);
397
397
  });
398
398
  });
399
+ describe('inputs and sensor-level closureOnly (RF-1.1, RF-1.3, RF-1.5, RF-1.7, RF-1.8)', () => {
400
+ it('accepts inputs and closureOnly on pack-bound and project-declared entries', () => {
401
+ const packBound = {
402
+ ...validV2Manifest().sensors.lint,
403
+ inputs: ['**/*.ts', 'src/**/?est.ts'],
404
+ closureOnly: true,
405
+ };
406
+ const project = projectSensor({
407
+ inputs: ['lib/**/*.js'],
408
+ closureOnly: false,
409
+ });
410
+ const parsed = (0, manifest_1.parseSensorManifest)({
411
+ schemaVersion: 3,
412
+ mode: 'project-sensors',
413
+ pack: 'js-ts',
414
+ source: { registry: 'baseline' },
415
+ sensors: { lint: packBound, custom: project },
416
+ }, 'sensors.json');
417
+ expect(parsed).toMatchObject({
418
+ kind: 'v3',
419
+ pack: {
420
+ sensors: {
421
+ lint: { inputs: ['**/*.ts', 'src/**/?est.ts'], closureOnly: true },
422
+ custom: { inputs: ['lib/**/*.js'], closureOnly: false },
423
+ },
424
+ },
425
+ });
426
+ });
427
+ it('accepts inputs and closureOnly on v2 pack-bound sensors', () => {
428
+ const manifest = validV2Manifest();
429
+ manifest.sensors.lint.inputs = ['**/*.[jt]s'];
430
+ manifest.sensors.lint.closureOnly = true;
431
+ expect((0, manifest_1.parseSensorManifest)(manifest, 'sensors.json')).toMatchObject({
432
+ kind: 'v2',
433
+ pack: { sensors: { lint: { inputs: ['**/*.[jt]s'], closureOnly: true } } },
434
+ });
435
+ });
436
+ it('treats omitted closureOnly as false and does not invent inputs (RF-1.8)', () => {
437
+ const parsed = (0, manifest_1.parseSensorManifest)(v3WithPackAndProject(), 'sensors.json');
438
+ expect(parsed).toMatchObject({ kind: 'v3' });
439
+ if (parsed.kind !== 'v3' || parsed.pack.mode !== 'project-sensors')
440
+ throw new Error('expected v3 project-sensors');
441
+ const lint = parsed.pack.sensors.lint;
442
+ const custom = parsed.pack.sensors.custom;
443
+ expect(lint.closureOnly ?? false).toBe(false);
444
+ expect(custom.closureOnly ?? false).toBe(false);
445
+ expect('inputs' in lint).toBe(false);
446
+ expect('inputs' in custom).toBe(false);
447
+ });
448
+ it.each([
449
+ [[''], /sensors\.custom\.inputs\[0\] must be a nonempty single-line/],
450
+ [['/abs/path'], /sensors\.custom\.inputs\[0\].*absolute paths are invalid/],
451
+ [['C:/windows'], /sensors\.custom\.inputs\[0\].*absolute paths are invalid/],
452
+ [['has\u0000nul'], /sensors\.custom\.inputs\[0\].*NUL or control/],
453
+ [['../escape'], /sensors\.custom\.inputs\[0\].*contained relative glob/],
454
+ [['foo/../bar'], /sensors\.custom\.inputs\[0\].*contained relative glob/],
455
+ [['a{b,c}'], /sensors\.custom\.inputs\[0\].*brace expansion/],
456
+ [['!negated'], /sensors\.custom\.inputs\[0\].*negation/],
457
+ [['path/with!bang'], /sensors\.custom\.inputs\[0\].*negation/],
458
+ [[], /sensors\.custom\.inputs must be a nonempty array/],
459
+ [['@(a|b).ts'], /sensors\.custom\.inputs\[0\].*(?:extglob|parentheses)/],
460
+ [['+(a).ts'], /sensors\.custom\.inputs\[0\].*(?:extglob|parentheses)/],
461
+ [['*(a).ts'], /sensors\.custom\.inputs\[0\].*(?:extglob|parentheses)/],
462
+ [['?(a).ts'], /sensors\.custom\.inputs\[0\].*(?:extglob|parentheses)/],
463
+ [['(a|b).ts'], /sensors\.custom\.inputs\[0\].*(?:extglob|parentheses)/],
464
+ ])('rejects invalid project inputs %j', (inputs, reason) => {
465
+ expect(() => (0, manifest_1.parseSensorManifest)(v3WithPackAndProject({ inputs }), 'sensors.json'))
466
+ .toThrow(reason);
467
+ });
468
+ it('soft-isolates a project entry with bad inputs while keeping siblings', () => {
469
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ inputs: ['**/{a,b}.ts'] }), 'sensors.json');
470
+ expect(soft.invalidEntries).toEqual([
471
+ expect.objectContaining({
472
+ name: 'custom',
473
+ reason: expect.stringMatching(/sensors\.custom\.inputs\[0\].*brace expansion/),
474
+ }),
475
+ ]);
476
+ expect(soft.invalidEntries[0]?.reason).not.toMatch(/Invalid sensor pack/);
477
+ expect(soft).toMatchObject({
478
+ kind: 'v3',
479
+ pack: { sensors: { lint: { variantId: 'eslint-9' } } },
480
+ });
481
+ expect('custom' in soft.pack.sensors).toBe(false);
482
+ });
483
+ it('rejects closureOnly on a non-boolean sensor field', () => {
484
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ closureOnly: 'yes' }), 'sensors.json');
485
+ expect(soft.invalidEntries).toEqual([
486
+ expect.objectContaining({
487
+ name: 'custom',
488
+ reason: expect.stringMatching(/sensors\.custom\.closureOnly must be a boolean/),
489
+ }),
490
+ ]);
491
+ });
492
+ });
399
493
  });
@@ -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');