agentic-workflow-manager 6.5.2 → 8.0.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 (93) hide show
  1. package/dist/scripts/sensor-support-matrix.js +103 -0
  2. package/dist/scripts/support-matrix.js +40 -17
  3. package/dist/src/commands/init.js +1 -3
  4. package/dist/src/commands/ledger/index.js +6 -0
  5. package/dist/src/commands/preflight/checks.js +4 -4
  6. package/dist/src/commands/preflight/index.js +2 -2
  7. package/dist/src/commands/registry/add.js +2 -2
  8. package/dist/src/commands/registry/index.js +5 -0
  9. package/dist/src/commands/registry/status.js +7 -0
  10. package/dist/src/commands/sensors/compatibility/contract.js +261 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +59 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +197 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +126 -0
  15. package/dist/src/commands/sensors/compatibility/pack-source.js +78 -0
  16. package/dist/src/commands/sensors/compatibility/probe.js +47 -0
  17. package/dist/src/commands/sensors/compatibility/resolve.js +96 -0
  18. package/dist/src/commands/sensors/compatibility/types.js +2 -0
  19. package/dist/src/commands/sensors/coverage/contract.js +0 -50
  20. package/dist/src/commands/sensors/coverage/empirical.js +131 -0
  21. package/dist/src/commands/sensors/coverage/evaluate.js +85 -7
  22. package/dist/src/commands/sensors/coverage/evidence.js +5 -1
  23. package/dist/src/commands/sensors/coverage/index.js +44 -7
  24. package/dist/src/commands/sensors/coverage/render.js +214 -14
  25. package/dist/src/commands/sensors/coverage/resolve.js +24 -38
  26. package/dist/src/commands/sensors/exec.js +150 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +92 -6
  29. package/dist/src/commands/sensors/run.js +71 -0
  30. package/dist/src/commands/sensors/status.js +48 -4
  31. package/dist/src/commands/sync.js +7 -0
  32. package/dist/src/core/bundles.js +38 -3
  33. package/dist/src/core/init/steps.js +7 -4
  34. package/dist/src/core/ledger/scan.js +228 -0
  35. package/dist/src/core/ledger/store.js +89 -25
  36. package/dist/src/core/ledger/types.js +54 -0
  37. package/dist/src/core/paths.js +10 -9
  38. package/dist/src/core/registries.js +134 -14
  39. package/dist/src/index.js +24 -1
  40. package/dist/tests/commands/ledger/index.test.js +11 -0
  41. package/dist/tests/commands/preflight/preflight.test.js +64 -64
  42. package/dist/tests/commands/registry/add.test.js +69 -2
  43. package/dist/tests/commands/registry/status.test.js +14 -0
  44. package/dist/tests/commands/sensors/compatibility/contract.test.js +98 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/manifest.test.js +68 -0
  47. package/dist/tests/commands/sensors/compatibility/materialize.test.js +53 -0
  48. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  49. package/dist/tests/commands/sensors/compatibility/probe.test.js +27 -0
  50. package/dist/tests/commands/sensors/compatibility/resolve.test.js +75 -0
  51. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  52. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  53. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  54. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  55. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  56. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  57. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  58. package/dist/tests/commands/sensors/exec-windows.test.js +92 -2
  59. package/dist/tests/commands/sensors/exec.test.js +67 -5
  60. package/dist/tests/commands/sensors/index.test.js +8 -0
  61. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  62. package/dist/tests/commands/sensors/init.test.js +132 -48
  63. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  64. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  65. package/dist/tests/commands/sensors/run.test.js +66 -3
  66. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  67. package/dist/tests/commands/sensors/status.test.js +74 -23
  68. package/dist/tests/commands/update.test.js +1 -1
  69. package/dist/tests/core/bundles.test.js +37 -0
  70. package/dist/tests/core/discovery.test.js +4 -0
  71. package/dist/tests/core/init/orchestrator.test.js +5 -1
  72. package/dist/tests/core/init/steps.test.js +20 -4
  73. package/dist/tests/core/ledger/scan.test.js +217 -0
  74. package/dist/tests/core/ledger/store.test.js +108 -0
  75. package/dist/tests/core/profile-pins.test.js +2 -1
  76. package/dist/tests/core/project-skill-links.test.js +22 -0
  77. package/dist/tests/core/registries-capability.test.js +10 -0
  78. package/dist/tests/core/registries-sync.test.js +16 -0
  79. package/dist/tests/core/registries.test.js +129 -1
  80. package/dist/tests/core/registry-manifest.test.js +7 -0
  81. package/dist/tests/core/same-existing-path.test.js +35 -0
  82. package/dist/tests/core/sync-gates.test.js +2 -1
  83. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  84. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  85. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  86. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  87. package/dist/tests/structural/active-documentation.test.js +128 -0
  88. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  89. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  90. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  91. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  92. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  93. package/package.json +8 -2
@@ -47,56 +47,56 @@ const make = (o) => { const d = project(o); dirs.push(d); return d; };
47
47
  afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
48
48
  const check = (r, id) => r.checks.find(c => c.id === id);
49
49
  describe('preflight', () => {
50
- it('reports not_configured when no sensor manifest exists', () => {
50
+ it('reports not_configured when no sensor manifest exists', async () => {
51
51
  // The team-rollout case: a developer clones the repo and never runs
52
52
  // `awm sensors init`. Today nothing notices until an unattended run is already
53
53
  // in flight and every quality phase is consuming a gate that certifies nothing.
54
54
  const dir = make();
55
- const report = (0, checks_1.preflight)(dir);
55
+ const report = await (0, checks_1.preflight)(dir);
56
56
  expect(report.status).toBe('not_configured');
57
57
  expect(check(report, 'manifest').ok).toBe(false);
58
58
  });
59
- it('keeps not_configured and degraded apart', () => {
59
+ it('keeps not_configured and degraded apart', async () => {
60
60
  // "You never set this up" and "you set it up and it broke" need different
61
61
  // remedies. Collapsing them is how an absent check reads as a passing one.
62
62
  const never = make();
63
63
  const broken = make({
64
64
  manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } }, // no local eslint
65
65
  });
66
- expect((0, checks_1.preflight)(never).status).toBe('not_configured');
67
- expect((0, checks_1.preflight)(broken).status).toBe('degraded');
66
+ expect((await (0, checks_1.preflight)(never)).status).toBe('not_configured');
67
+ expect((await (0, checks_1.preflight)(broken)).status).toBe('degraded');
68
68
  });
69
- it('is ready when the declared sensors can actually run', () => {
69
+ it('is ready when the declared sensors can actually run', async () => {
70
70
  const dir = make({
71
71
  manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
72
72
  bins: ['eslint'],
73
73
  files: ['package.json'],
74
74
  });
75
- const report = (0, checks_1.preflight)(dir);
75
+ const report = await (0, checks_1.preflight)(dir);
76
76
  expect(report.status).toBe('ready');
77
77
  expect((0, preflight_1.exitCodeFor)(report)).toBe(0);
78
78
  });
79
- it('catches a sensor whose tool is not installed locally', () => {
79
+ it('catches a sensor whose tool is not installed locally', async () => {
80
80
  // This is the check that existed and nothing in the flow was calling.
81
81
  const dir = make({
82
82
  manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
83
83
  files: ['package.json'],
84
84
  });
85
- const report = (0, checks_1.preflight)(dir);
85
+ const report = await (0, checks_1.preflight)(dir);
86
86
  expect(report.status).toBe('degraded');
87
87
  expect(check(report, 'tools').detail).toContain('eslint');
88
88
  });
89
- it('accepts a deliberate opt-out, but only when it is written down', () => {
89
+ it('accepts a deliberate opt-out, but only when it is written down', async () => {
90
90
  // A repo may legitimately have no sensors — it just has to SAY so in a committed
91
91
  // file, so "we decided not to gate this" cannot be mistaken for "nobody set it up".
92
92
  const optedOut = make({
93
93
  manifest: { pack: 'generic', sensors: { security: { cmd: 'semgrep .', enabled: false } } },
94
94
  });
95
- const report = (0, checks_1.preflight)(optedOut);
95
+ const report = await (0, checks_1.preflight)(optedOut);
96
96
  expect(report.status).toBe('ready');
97
97
  expect(check(report, 'manifest').detail).toContain('opt-out');
98
98
  });
99
- it('flags a manifest with zero sensor entries as degraded, distinct from a deliberate opt-out', () => {
99
+ it('flags a manifest with zero sensor entries as degraded, distinct from a deliberate opt-out', async () => {
100
100
  // Genuinely different manifest shape from the opt-out test above: no sensor
101
101
  // NAMES at all, vs. an opt-out which lists every known sensor explicitly with
102
102
  // `enabled: false`. This is the honest-floor case from init.ts — the registry
@@ -104,14 +104,14 @@ describe('preflight', () => {
104
104
  const noPack = make({
105
105
  manifest: { pack: 'python', sensors: {} },
106
106
  });
107
- const report = (0, checks_1.preflight)(noPack);
107
+ const report = await (0, checks_1.preflight)(noPack);
108
108
  expect(report.status).toBe('degraded');
109
109
  expect(check(report, 'manifest').ok).toBe(false);
110
110
  expect(check(report, 'manifest').detail).not.toContain('opt-out');
111
111
  expect(check(report, 'manifest').detail).toContain('python');
112
112
  expect(check(report, 'manifest').remedy).toContain('python');
113
113
  });
114
- it('flags the tools check as failing (not "0/0 runnable") for a manifest with zero sensor entries', () => {
114
+ it('flags the tools check as failing (not "0/0 runnable") for a manifest with zero sensor entries', async () => {
115
115
  // Regression for Finding 6: `checkTools` independently inspects
116
116
  // `status.checks`, which is also `{}` for a zero-sensor manifest —
117
117
  // `Object.entries({}).filter(...)` is vacuously `[]`, so before the fix this
@@ -122,34 +122,34 @@ describe('preflight', () => {
122
122
  const noPack = make({
123
123
  manifest: { pack: 'python', sensors: {} },
124
124
  });
125
- const report = (0, checks_1.preflight)(noPack);
125
+ const report = await (0, checks_1.preflight)(noPack);
126
126
  expect(check(report, 'tools').ok).toBe(false);
127
127
  expect(report.status).toBe('degraded');
128
128
  });
129
- it('flags a manifest stuck on generic while the tree has a real stack', () => {
129
+ it('flags a manifest stuck on generic while the tree has a real stack', async () => {
130
130
  // The gate would run, report green, and have checked almost nothing.
131
131
  const dir = make({
132
132
  manifest: { pack: 'generic', sensors: {} },
133
133
  files: ['package.json'],
134
134
  });
135
- const report = (0, checks_1.preflight)(dir);
135
+ const report = await (0, checks_1.preflight)(dir);
136
136
  expect(report.status).toBe('degraded');
137
137
  expect(check(report, 'pack').ok).toBe(false);
138
138
  });
139
- it('flags a repo with no context contract at all', () => {
139
+ it('flags a repo with no context contract at all', async () => {
140
140
  const dir = make({
141
141
  context: [],
142
142
  manifest: { pack: 'generic', sensors: {} },
143
143
  });
144
- expect(check((0, checks_1.preflight)(dir), 'context').ok).toBe(false);
144
+ expect(check(await (0, checks_1.preflight)(dir), 'context').ok).toBe(false);
145
145
  });
146
- it('treats an unparseable manifest as a failure, not as absent', () => {
146
+ it('treats an unparseable manifest as a failure, not as absent', async () => {
147
147
  const dir = make({ manifest: '{ not json' });
148
- const report = (0, checks_1.preflight)(dir);
148
+ const report = await (0, checks_1.preflight)(dir);
149
149
  expect(report.status).toBe('degraded');
150
150
  expect(check(report, 'manifest').detail).toContain('not valid JSON');
151
151
  });
152
- it('exits non-zero for anything but ready, so the caller need not parse JSON', () => {
152
+ it('exits non-zero for anything but ready, so the caller need not parse JSON', async () => {
153
153
  // Unlike `awm sensors run` — which exits 0 on not_certified because exit 2 blocks
154
154
  // Claude Code hooks — preflight is never a hook, so the verdict rides the exit code
155
155
  // instead of depending on every agent remembering to read a field.
@@ -187,74 +187,74 @@ describe('preflight', () => {
187
187
  fs_1.default.writeFileSync(f, '#!/bin/sh\nexit 0\n');
188
188
  fs_1.default.chmodSync(f, 0o755);
189
189
  }
190
- it('reports github + gh available, and does not affect status', () => {
190
+ it('reports github + gh available, and does not affect status', async () => {
191
191
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
192
192
  gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
193
193
  installOnPath('gh');
194
- const report = (0, checks_1.preflight)(dir);
194
+ const report = await (0, checks_1.preflight)(dir);
195
195
  expect(check(report, 'host').ok).toBe(true);
196
196
  expect(check(report, 'host').detail).toBe('github detected, gh available');
197
197
  expect(report.status).toBe('ready');
198
198
  });
199
- it('is still ok:true (advisory only) when gitlab is detected but glab is not on PATH, and status stays ready', () => {
199
+ it('is still ok:true (advisory only) when gitlab is detected but glab is not on PATH, and status stays ready', async () => {
200
200
  // The only thing "wrong" in this fixture is the missing `glab` — proving the
201
201
  // advisory contract: it must not drag an otherwise-clean repo to `degraded`.
202
202
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
203
203
  gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
204
204
  // PATH aislado y vacio => glab no resuelve.
205
- const report = (0, checks_1.preflight)(dir);
205
+ const report = await (0, checks_1.preflight)(dir);
206
206
  expect(check(report, 'host').ok).toBe(true);
207
207
  expect(check(report, 'host').detail).toContain('glab not on PATH');
208
208
  expect(check(report, 'host').remedy).toContain('glab');
209
209
  expect(report.status).toBe('ready');
210
210
  });
211
- it('handles no origin remote gracefully — no throw, ok:true, minimal detail', () => {
211
+ it('handles no origin remote gracefully — no throw, ok:true, minimal detail', async () => {
212
212
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
213
213
  // Not a git repo at all — the common case for `execFileSync` failing here.
214
- const report = (0, checks_1.preflight)(dir);
214
+ const report = await (0, checks_1.preflight)(dir);
215
215
  expect(check(report, 'host').ok).toBe(true);
216
216
  expect(check(report, 'host').detail).toBe('no git remote detected — PR/MR automation not applicable');
217
217
  expect(check(report, 'host').remedy).toBeUndefined();
218
218
  expect(report.status).toBe('ready');
219
219
  });
220
- it('handles a git repo with no origin remote configured gracefully', () => {
220
+ it('handles a git repo with no origin remote configured gracefully', async () => {
221
221
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
222
222
  gitRepo(dir); // git init, no remote
223
- const report = (0, checks_1.preflight)(dir);
223
+ const report = await (0, checks_1.preflight)(dir);
224
224
  expect(check(report, 'host').ok).toBe(true);
225
225
  expect(check(report, 'host').detail).toContain('no git remote detected');
226
226
  });
227
- it('does not overclaim support for an unrecognized host', () => {
227
+ it('does not overclaim support for an unrecognized host', async () => {
228
228
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
229
229
  gitRepo(dir, 'git@bitbucket.org:kodria/agentic-workflow.git');
230
- const report = (0, checks_1.preflight)(dir);
230
+ const report = await (0, checks_1.preflight)(dir);
231
231
  expect(check(report, 'host').ok).toBe(true);
232
232
  expect(check(report, 'host').detail).toContain('not recognized');
233
233
  expect(report.status).toBe('ready');
234
234
  });
235
- it('does not misclassify a GitHub Enterprise host whose repo NAME contains "gitlab"', () => {
235
+ it('does not misclassify a GitHub Enterprise host whose repo NAME contains "gitlab"', async () => {
236
236
  // The bug: a bare `remote.includes('gitlab')` matches the full remote URL
237
237
  // string, so an org/repo name containing "gitlab" false-positives even though
238
238
  // the actual host is unrelated. Hostname must be extracted first and matched
239
239
  // in isolation.
240
240
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
241
241
  gitRepo(dir, 'git@github.enterprise.internal:kodria/gitlab-migration-tool.git');
242
- const report = (0, checks_1.preflight)(dir);
242
+ const report = await (0, checks_1.preflight)(dir);
243
243
  expect(check(report, 'host').ok).toBe(true);
244
244
  expect(check(report, 'host').detail).toContain('not recognized');
245
245
  expect(report.status).toBe('ready');
246
246
  });
247
- it('does not misclassify a non-GitHub host whose repo NAME contains "github"', () => {
247
+ it('does not misclassify a non-GitHub host whose repo NAME contains "github"', async () => {
248
248
  // Same class of bug on the github side: "something-github-tool" is a repo
249
249
  // name, not the host.
250
250
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
251
251
  gitRepo(dir, 'https://example.com/kodria/something-github-tool.git');
252
- const report = (0, checks_1.preflight)(dir);
252
+ const report = await (0, checks_1.preflight)(dir);
253
253
  expect(check(report, 'host').ok).toBe(true);
254
254
  expect(check(report, 'host').detail).toContain('not recognized');
255
255
  expect(report.status).toBe('ready');
256
256
  });
257
- it('does not misclassify a GitHub Enterprise host whose SSH USERNAME is "gitlab"', () => {
257
+ it('does not misclassify a GitHub Enterprise host whose SSH USERNAME is "gitlab"', async () => {
258
258
  // The related bug: the scheme-based regex captured everything between
259
259
  // `scheme://` and the first `/`, including `userinfo@` — so an SSH username
260
260
  // of "gitlab" leaked into the matched "host" string and false-positived the
@@ -269,25 +269,25 @@ describe('preflight', () => {
269
269
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
270
270
  gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
271
271
  // PATH real: estos casos no dependen de gh/glab.
272
- const report = (0, checks_1.preflight)(dir);
272
+ const report = await (0, checks_1.preflight)(dir);
273
273
  expect(check(report, 'host').ok).toBe(true);
274
274
  expect(check(report, 'host').detail).toContain('github detected');
275
275
  expect(check(report, 'host').detail).not.toContain('gitlab');
276
276
  expect(report.status).toBe('ready');
277
277
  });
278
- it('does not misclassify a host whose injected credential/token contains "gitlab"', () => {
278
+ it('does not misclassify a host whose injected credential/token contains "gitlab"', async () => {
279
279
  // A realistic CI credential-injection remote:
280
280
  // `git remote set-url origin https://x-access-token:$TOKEN@host/...`. If the
281
281
  // token or password happens to contain "gitlab", it must not leak into the
282
282
  // matched host either.
283
283
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
284
284
  gitRepo(dir, 'https://user:gitlab@example-host.com/org/repo.git');
285
- const report = (0, checks_1.preflight)(dir);
285
+ const report = await (0, checks_1.preflight)(dir);
286
286
  expect(check(report, 'host').ok).toBe(true);
287
287
  expect(check(report, 'host').detail).toContain('not recognized');
288
288
  expect(report.status).toBe('ready');
289
289
  });
290
- it('does not misclassify an SCP-style remote with a second "@" in it', () => {
290
+ it('does not misclassify an SCP-style remote with a second "@" in it', async () => {
291
291
  // `user@host:path` shorthand has no scheme for `URL` to parse, so it falls
292
292
  // back to a regex. A second "@" (e.g. a malformed/adversarial remote) must
293
293
  // not let a bogus "host@evil"-shaped capture slip past the colon check —
@@ -295,42 +295,42 @@ describe('preflight', () => {
295
295
  // falls through to "unrecognized" rather than misclassifying as gitlab.
296
296
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
297
297
  gitRepo(dir, 'user@github.com@gitlab.evil:org/repo.git');
298
- const report = (0, checks_1.preflight)(dir);
298
+ const report = await (0, checks_1.preflight)(dir);
299
299
  expect(check(report, 'host').ok).toBe(true);
300
300
  expect(check(report, 'host').detail).toContain('not recognized');
301
301
  expect(report.status).toBe('ready');
302
302
  });
303
- it('still detects github.com over HTTPS', () => {
303
+ it('still detects github.com over HTTPS', async () => {
304
304
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
305
305
  gitRepo(dir, 'https://github.com/org/repo.git');
306
306
  // PATH real: estos casos no dependen de gh/glab.
307
- const report = (0, checks_1.preflight)(dir);
307
+ const report = await (0, checks_1.preflight)(dir);
308
308
  expect(check(report, 'host').detail).toContain('github detected');
309
309
  });
310
- it('still detects github.com over SSH shorthand', () => {
310
+ it('still detects github.com over SSH shorthand', async () => {
311
311
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
312
312
  gitRepo(dir, 'git@github.com:org/repo.git');
313
313
  // PATH real: estos casos no dependen de gh/glab.
314
- const report = (0, checks_1.preflight)(dir);
314
+ const report = await (0, checks_1.preflight)(dir);
315
315
  expect(check(report, 'host').detail).toContain('github detected');
316
316
  });
317
- it('still detects gitlab over HTTPS', () => {
317
+ it('still detects gitlab over HTTPS', async () => {
318
318
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
319
319
  gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
320
320
  // PATH real: estos casos no dependen de gh/glab.
321
- const report = (0, checks_1.preflight)(dir);
321
+ const report = await (0, checks_1.preflight)(dir);
322
322
  expect(check(report, 'host').detail).toContain('gitlab detected');
323
323
  });
324
- it('still detects gitlab over SSH shorthand', () => {
324
+ it('still detects gitlab over SSH shorthand', async () => {
325
325
  const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
326
326
  gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
327
327
  // PATH real: estos casos no dependen de gh/glab.
328
- const report = (0, checks_1.preflight)(dir);
328
+ const report = await (0, checks_1.preflight)(dir);
329
329
  expect(check(report, 'host').detail).toContain('gitlab detected');
330
330
  });
331
331
  });
332
332
  describe('sensors-baseline check (advisory — never changes the exit code)', () => {
333
- it('nudges toward `awm sensors baseline` when sensors are configured but no baseline exists', () => {
333
+ it('nudges toward `awm sensors baseline` when sensors are configured but no baseline exists', async () => {
334
334
  // The team-rollout gap this addresses: a legacy repo adopts AWM, sensors get
335
335
  // configured, and the ratchet mechanism exists to snapshot pre-existing debt —
336
336
  // but nothing tells the operator it's there until they hit a wall of red
@@ -340,13 +340,13 @@ describe('preflight', () => {
340
340
  bins: ['eslint'],
341
341
  files: ['package.json'],
342
342
  });
343
- const report = (0, checks_1.preflight)(dir);
343
+ const report = await (0, checks_1.preflight)(dir);
344
344
  expect(check(report, 'sensors-baseline').ok).toBe(true);
345
345
  expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
346
346
  expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
347
347
  expect(report.status).toBe('ready');
348
348
  });
349
- it('reports the no-advisory-needed state when a baseline already exists, without nudging', () => {
349
+ it('reports the no-advisory-needed state when a baseline already exists, without nudging', async () => {
350
350
  const dir = make({
351
351
  manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
352
352
  bins: ['eslint'],
@@ -354,19 +354,19 @@ describe('preflight', () => {
354
354
  });
355
355
  fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
356
356
  fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), JSON.stringify({ lint: [] }));
357
- const report = (0, checks_1.preflight)(dir);
357
+ const report = await (0, checks_1.preflight)(dir);
358
358
  expect(check(report, 'sensors-baseline').ok).toBe(true);
359
359
  expect(check(report, 'sensors-baseline').detail).toBe('baseline present');
360
360
  expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
361
361
  expect(report.status).toBe('ready');
362
362
  });
363
- it('is omitted entirely when there is no manifest at all — nothing to baseline without sensors', () => {
363
+ it('is omitted entirely when there is no manifest at all — nothing to baseline without sensors', async () => {
364
364
  const dir = make();
365
- const report = (0, checks_1.preflight)(dir);
365
+ const report = await (0, checks_1.preflight)(dir);
366
366
  expect(report.checks.find(c => c.id === 'sensors-baseline')).toBeUndefined();
367
367
  expect(report.status).toBe('not_configured');
368
368
  });
369
- it('does not nudge on a deliberate opt-out (every sensor disabled) — nothing to baseline', () => {
369
+ it('does not nudge on a deliberate opt-out (every sensor disabled) — nothing to baseline', async () => {
370
370
  // Regression: the trigger condition originally checked only manifestExists, so a
371
371
  // repo that deliberately opted out (checkManifest's own documented pattern: every
372
372
  // sensor `enabled: false`) still got told to run `awm sensors baseline` — nothing
@@ -374,19 +374,19 @@ describe('preflight', () => {
374
374
  const dir = make({
375
375
  manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .', enabled: false } } },
376
376
  });
377
- const report = (0, checks_1.preflight)(dir);
377
+ const report = await (0, checks_1.preflight)(dir);
378
378
  expect(check(report, 'sensors-baseline').ok).toBe(true);
379
379
  expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
380
380
  expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
381
381
  });
382
- it('does not nudge on an unparseable manifest — nothing to baseline', () => {
382
+ it('does not nudge on an unparseable manifest — nothing to baseline', async () => {
383
383
  const dir = make({ manifest: '{not valid json' });
384
- const report = (0, checks_1.preflight)(dir);
384
+ const report = await (0, checks_1.preflight)(dir);
385
385
  expect(check(report, 'sensors-baseline').ok).toBe(true);
386
386
  expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
387
387
  expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
388
388
  });
389
- it('still nudges when the baseline path exists but is not a readable file (e.g. a stray directory)', () => {
389
+ it('still nudges when the baseline path exists but is not a readable file (e.g. a stray directory)', async () => {
390
390
  // Regression: checking presence via `fs.existsSync` alone would have reported
391
391
  // "baseline present" here, reassuring the operator that debt is suppressed —
392
392
  // but the real gate (`readBaseline`, used by `partition()`) treats an unreadable
@@ -398,13 +398,13 @@ describe('preflight', () => {
398
398
  files: ['package.json'],
399
399
  });
400
400
  fs_1.default.mkdirSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), { recursive: true });
401
- const report = (0, checks_1.preflight)(dir);
401
+ const report = await (0, checks_1.preflight)(dir);
402
402
  expect(check(report, 'sensors-baseline').ok).toBe(true);
403
403
  expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
404
404
  expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
405
405
  });
406
406
  });
407
- it('tells the operator not to hand a broken harness to an unattended run', () => {
407
+ it('tells the operator not to hand a broken harness to an unattended run', async () => {
408
408
  const out = (0, preflight_1.formatReport)({
409
409
  status: 'not_configured',
410
410
  checks: [{ id: 'manifest', ok: false, detail: 'no .awm/sensors.json', remedy: 'run `awm sensors init`' }],
@@ -412,7 +412,7 @@ describe('preflight', () => {
412
412
  expect(out).toContain('unattended');
413
413
  expect(out).toContain('awm sensors init');
414
414
  });
415
- it('pads the id column to the widest id actually present, not a hardcoded width', () => {
415
+ it('pads the id column to the widest id actually present, not a hardcoded width', async () => {
416
416
  // Regression: a literal `.padEnd(9)` silently misaligned once `sensors-baseline`
417
417
  // (16 chars) was added as a check id — every detail column shifted left of where
418
418
  // shorter ids' details landed. The width must be derived from the report itself.
@@ -12,9 +12,24 @@ const GIT = (cwd, cmd) => (0, child_process_1.execSync)(`git -c user.email=t@t.t
12
12
  function makeSourceRepo(base, opts) {
13
13
  const dir = path_1.default.join(base, `src-${opts.skill ?? 'empty'}`);
14
14
  fs_1.default.mkdirSync(dir, { recursive: true });
15
- if (!opts.empty && opts.skill) {
15
+ if (opts.contentSymlink) {
16
+ fs_1.default.symlinkSync(opts.contentSymlink, path_1.default.join(dir, 'skills'));
17
+ }
18
+ else if (opts.contentFile) {
19
+ fs_1.default.writeFileSync(path_1.default.join(dir, opts.contentFile), 'not a content directory');
20
+ }
21
+ else if (opts.nestedHookSymlink) {
22
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'hooks'), { recursive: true });
23
+ fs_1.default.symlinkSync(opts.nestedHookSymlink, path_1.default.join(dir, 'hooks', 'session-start'));
24
+ }
25
+ else if (!opts.empty && opts.skill) {
16
26
  fs_1.default.mkdirSync(path_1.default.join(dir, 'skills', opts.skill), { recursive: true });
17
- fs_1.default.writeFileSync(path_1.default.join(dir, 'skills', opts.skill, 'SKILL.md'), `---\nname: ${opts.skill}\ndescription: d\n---\n`);
27
+ if (opts.nestedSkillSymlink) {
28
+ fs_1.default.symlinkSync(opts.nestedSkillSymlink, path_1.default.join(dir, 'skills', opts.skill, 'SKILL.md'));
29
+ }
30
+ else {
31
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'skills', opts.skill, 'SKILL.md'), `---\nname: ${opts.skill}\ndescription: d\n---\n`);
32
+ }
18
33
  }
19
34
  else {
20
35
  fs_1.default.writeFileSync(path_1.default.join(dir, 'README.md'), 'no content dirs');
@@ -97,6 +112,48 @@ describe('addRegistry', () => {
97
112
  expect(readRegistriesConfig()).toEqual([]);
98
113
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/bad'))).toBe(false);
99
114
  });
115
+ it('rejects a regular file that masquerades as a content directory', async () => {
116
+ const source = makeSourceRepo(tmpWork, { contentFile: 'skills' });
117
+ const { addRegistry } = require('../../../src/commands/registry/add');
118
+ const result = await addRegistry(source, 'not-a-registry');
119
+ expect(result.ok).toBe(false);
120
+ expect(result.error).toMatch(/skills\/, bundles\/, workflows\/, agents\//);
121
+ const { readRegistriesConfig } = require('../../../src/core/registries');
122
+ expect(readRegistriesConfig()).toEqual([]);
123
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/not-a-registry'))).toBe(false);
124
+ });
125
+ it('rejects a symlink that masquerades as a content directory', async () => {
126
+ const source = makeSourceRepo(tmpWork, { contentSymlink: '.' });
127
+ const { addRegistry } = require('../../../src/commands/registry/add');
128
+ const result = await addRegistry(source, 'linked-content');
129
+ expect(result.ok).toBe(false);
130
+ expect(result.error).toMatch(/skills\/, bundles\/, workflows\/, agents\//);
131
+ const { readRegistriesConfig } = require('../../../src/core/registries');
132
+ expect(readRegistriesConfig()).toEqual([]);
133
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/linked-content'))).toBe(false);
134
+ });
135
+ it('rejects a nested artifact symlink before discovery can read it', async () => {
136
+ const outside = path_1.default.join(tmpWork, 'outside-skill.md');
137
+ fs_1.default.writeFileSync(outside, 'host-only content');
138
+ const source = makeSourceRepo(tmpWork, { skill: 'linked', nestedSkillSymlink: outside });
139
+ const { addRegistry } = require('../../../src/commands/registry/add');
140
+ const result = await addRegistry(source, 'nested-link');
141
+ expect(result.ok).toBe(false);
142
+ const { readRegistriesConfig } = require('../../../src/core/registries');
143
+ expect(readRegistriesConfig()).toEqual([]);
144
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/nested-link'))).toBe(false);
145
+ });
146
+ it('rejects a nested hook symlink before capability consumers can use it', async () => {
147
+ const outside = path_1.default.join(tmpWork, 'outside-hook');
148
+ fs_1.default.writeFileSync(outside, 'host-only content');
149
+ const source = makeSourceRepo(tmpWork, { nestedHookSymlink: outside });
150
+ const { addRegistry } = require('../../../src/commands/registry/add');
151
+ const result = await addRegistry(source, 'nested-hook-link');
152
+ expect(result.ok).toBe(false);
153
+ const { readRegistriesConfig } = require('../../../src/core/registries');
154
+ expect(readRegistriesConfig()).toEqual([]);
155
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/nested-hook-link'))).toBe(false);
156
+ });
100
157
  it('is atomic: artifact collision with existing configured registry → no config, cleanup, error names both', async () => {
101
158
  // First registry already registered with the 'alpha' skill
102
159
  const source1 = path_1.default.join(tmpWork, 'src-alpha-1');
@@ -133,6 +190,16 @@ describe('addRegistry', () => {
133
190
  const { readRegistriesConfig } = require('../../../src/core/registries');
134
191
  expect(readRegistriesConfig()).toEqual([]);
135
192
  });
193
+ it.each(['', '..', 'bad..name', 'a/b', 'a\\b'])('rejects unsafe registry name %s without cloning or writing config', async (name) => {
194
+ const source = makeSourceRepo(tmpWork, { skill: 'alpha' });
195
+ const { addRegistry } = require('../../../src/commands/registry/add');
196
+ const result = await addRegistry(source, name);
197
+ expect(result.ok).toBe(false);
198
+ expect(result.error).toMatch(/Invalid registry name/);
199
+ const { readRegistriesConfig } = require('../../../src/core/registries');
200
+ expect(readRegistriesConfig()).toEqual([]);
201
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries', name))).toBe(false);
202
+ });
136
203
  it('rejects duplicate registry name and clone failure without writing config', async () => {
137
204
  const source = makeSourceRepo(tmpWork, { skill: 'alpha' });
138
205
  const { addRegistry } = require('../../../src/commands/registry/add');
@@ -40,4 +40,18 @@ describe('registry override status', () => {
40
40
  const { overrideStatus } = require('../../../src/commands/registry/status');
41
41
  expect(overrideStatus(team, [])).toEqual([]);
42
42
  });
43
+ it('registry list delegates discovery eligibility to the validated-roots guard', () => {
44
+ const source = fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../src/commands/registry/index.ts'), 'utf8');
45
+ expect(source).toContain('const safeRoots = new Set(contentRoots());');
46
+ expect(source).toContain('if (!canDiscoverRegistryContent(r.contentRoot, safeRoots))');
47
+ });
48
+ it('prevents discovery when a registry root was excluded from validated roots', () => {
49
+ const { canDiscoverRegistryContent } = require('../../../src/commands/registry/status');
50
+ expect(canDiscoverRegistryContent('/unsafe/registry', new Set())).toBe(false);
51
+ expect(canDiscoverRegistryContent('/safe/registry', new Set(['/safe/registry']))).toBe(true);
52
+ });
53
+ it('registry list gives a recoverable instruction for an unsafe existing registry', () => {
54
+ const source = fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../src/commands/registry/index.ts'), 'utf8');
55
+ expect(source).toContain("unsafe layout — remove the registry and add it again");
56
+ });
43
57
  });
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const contract_1 = require("../../../../src/commands/sensors/compatibility/contract");
4
+ const coverage = {
5
+ schemaVersion: 1,
6
+ classes: {
7
+ formatting: {
8
+ description: 'Formatting is checked.',
9
+ detectors: [{ sensor: 'lint' }],
10
+ remedy: { summary: 'Add a formatter.', command: 'npm install prettier' },
11
+ },
12
+ },
13
+ };
14
+ function validPack() {
15
+ return {
16
+ schemaVersion: 2,
17
+ name: 'js-ts', description: 'fixture', detects: ['package.json'],
18
+ sensors: {
19
+ lint: {
20
+ applicability: { allFiles: ['package.json'] },
21
+ variants: [{
22
+ id: 'eslint-9',
23
+ priority: 10,
24
+ requirements: { tool: 'eslint', toolRange: '>=9.0.0 <10.0.0', runtime: 'node', runtimeRange: '>=20.0.0' },
25
+ certifiedRange: '>=9.0.0 <10.0.0',
26
+ assets: ['eslint.config.awm.mjs'], formatter: 'eslint-llm', probe: { kind: 'eslint-print-config' },
27
+ command: {
28
+ executable: 'eslint',
29
+ resolution: 'node-modules-bin',
30
+ args: ['--format', 'json', '{files}'],
31
+ fileInput: { placeholder: '{files}', extensions: ['.ts', '.tsx'] },
32
+ },
33
+ }],
34
+ },
35
+ },
36
+ coverage,
37
+ };
38
+ }
39
+ describe('sensor pack v2 contract', () => {
40
+ it('parses a valid versioned pack', () => {
41
+ expect((0, contract_1.parseSensorPack)(validPack(), 'pack.json')).toMatchObject({ kind: 'v2', pack: validPack() });
42
+ });
43
+ it('keeps an unversioned pack on the legacy compatibility path', () => {
44
+ const legacy = { name: 'legacy', sensors: {} };
45
+ expect((0, contract_1.parseSensorPack)(legacy, 'pack.json')).toMatchObject({ kind: 'legacy', pack: { ...legacy, compatibility: { state: 'compatible-unverified' } } });
46
+ });
47
+ test.each([
48
+ [{ name: 'legacy', detects: [null], sensors: {} }, 'detects[0]'],
49
+ [{ name: 'legacy', detects: ['package.json'], sensors: { lint: { unknown: true } } }, 'unknown field'],
50
+ [{ name: 'legacy', detects: ['package.json'], sensors: { lint: { defaultCmd: 'eslint\u0000 .' } } }, 'defaultCmd'],
51
+ [{ name: 'legacy', detects: ['package.json'], sensors: { lint: { changedExtensions: ['.ts', 3] } } }, 'changedExtensions[1]'],
52
+ [{ name: 'legacy', detects: ['package.json'], sensors: { 'bad/name': {} } }, 'sensor id'],
53
+ ])('rejects recursively malformed legacy content %j', (input, message) => {
54
+ expect(() => (0, contract_1.parseSensorPack)(input, 'legacy-pack.json')).toThrow(message);
55
+ });
56
+ it('preserves legacy config file fallbacks', () => {
57
+ expect((0, contract_1.parseSensorPack)({ name: 'legacy', sensors: { lint: { configFile: 'eslint.config.js', configFileFallback: '.eslintrc.json' } } }, 'legacy')).toMatchObject({ kind: 'legacy', pack: { sensors: { lint: { configFile: 'eslint.config.js', configFileFallback: '.eslintrc.json' } } } });
58
+ });
59
+ it('rejects malformed applicability and probe extensions', () => {
60
+ const variant = validPack().sensors.lint.variants[0];
61
+ expect(() => (0, contract_1.parseSensorPack)({ ...validPack(), sensors: { lint: { applicability: { allFiles: [3] }, variants: [variant] } } }, 'pack')).toThrow('allFiles[0]');
62
+ expect(() => (0, contract_1.parseSensorPack)({ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...variant, probe: { kind: 'version', extra: true } }] } } }, 'pack')).toThrow('unknown field');
63
+ });
64
+ it('preserves configFiles and rejects incomplete public overlap input', () => {
65
+ const variant = { ...validPack().sensors.lint.variants[0], requirements: { ...validPack().sensors.lint.variants[0].requirements, configFiles: ['eslint.config.js'] } };
66
+ expect((0, contract_1.parseSensorPack)({ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [variant] } } }, 'pack')).toMatchObject({ kind: 'v2', pack: { sensors: { lint: { variants: [{ requirements: { configFiles: ['eslint.config.js'] } }] } } } });
67
+ const complete = validPack().sensors.lint.variants[0];
68
+ expect(() => (0, contract_1.assertNoEqualPriorityOverlap)([{ ...complete, id: '../bad' }])).toThrow('stable lowercase id');
69
+ expect(() => (0, contract_1.assertNoEqualPriorityOverlap)([{ ...complete, command: undefined }])).toThrow('command');
70
+ });
71
+ it('rejects equal-priority variants with overlapping operational tool ranges', () => {
72
+ const first = validPack().sensors.lint.variants[0];
73
+ const second = { ...first, id: 'eslint-9-alt', certifiedRange: '>=10 <11', requirements: { ...first.requirements, toolRange: '>=9.5 <10.5' } };
74
+ expect(() => (0, contract_1.assertNoEqualPriorityOverlap)([first, second])).toThrow('overlap');
75
+ });
76
+ it('accepts overlapping tool ranges when runtime ranges are disjoint', () => {
77
+ const first = validPack().sensors.lint.variants[0];
78
+ const second = { ...first, id: 'eslint-9-node-old', requirements: { ...first.requirements, toolRange: '>=9.5 <10.5', runtimeRange: '>=18 <20' } };
79
+ expect(() => (0, contract_1.assertNoEqualPriorityOverlap)([first, second])).not.toThrow();
80
+ });
81
+ test.each([
82
+ [{ ...validPack(), schemaVersion: 3 }, 'supported: legacy, 2'],
83
+ [{ ...validPack(), sensors: { lint: { variants: [] } } }, 'variants'],
84
+ [{ ...validPack(), name: '../escape' }, 'name'],
85
+ [{ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...validPack().sensors.lint.variants[0], assets: ['../secret'] }] } } }, 'asset'],
86
+ [{ ...validPack(), sensors: { lint: { variants: [{ ...validPack().sensors.lint.variants[0], command: { executable: 'sh', resolution: 'path', args: ['-c', 'echo nope'] } }] } } }, 'executable'],
87
+ [{ ...validPack(), coverage: { ...coverage, schemaVersion: 2 } }, 'coverage.schemaVersion'],
88
+ [{ ...validPack(), sensors: { lint: { variants: [{ ...validPack().sensors.lint.variants[0], certifiedRange: 'not-a-range' }] } } }, 'certifiedRange'],
89
+ [{ ...validPack(), sensors: { lint: validPack().sensors.lint, lint2: validPack().sensors.lint } }, 'variant id'],
90
+ [{ ...validPack(), sensors: { lint: { variants: [validPack().sensors.lint.variants[0], { ...validPack().sensors.lint.variants[0], id: 'eslint-9-next', certifiedRange: '>=9.1.0 <10.0.0' }] } } }, 'overlap'],
91
+ [{ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...validPack().sensors.lint.variants[0], assets: ['C:/secret'] }] } } }, 'asset'],
92
+ [{ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...validPack().sensors.lint.variants[0], command: { executable: 'cmd.exe', resolution: 'path', args: ['x'] } }] } } }, 'executable'],
93
+ [{ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...validPack().sensors.lint.variants[0], command: { ...validPack().sensors.lint.variants[0].command, args: ['prefix{files}'] } }] } } }, 'embed'],
94
+ [{ ...validPack(), sensors: { lint: { ...validPack().sensors.lint, variants: [{ ...validPack().sensors.lint.variants[0], formatter: 'bad\nformat' }] } } }, 'formatter'],
95
+ ])('rejects malformed pack %j', (input, message) => {
96
+ expect(() => (0, contract_1.parseSensorPack)(input, 'pack.json')).toThrow(message);
97
+ });
98
+ });