@ikon85/agent-workflow-kit 0.44.2 → 0.45.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 (64) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +7 -4
  2. package/.agents/skills/code-review/SKILL.md +7 -4
  3. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  4. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  5. package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
  6. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  7. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  8. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  9. package/.agents/skills/research/SKILL.md +7 -4
  10. package/.agents/skills/to-issues/SKILL.md +25 -4
  11. package/.claude/skills/audit-skills/SKILL.md +7 -4
  12. package/.claude/skills/code-review/SKILL.md +7 -4
  13. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  14. package/.claude/skills/codex-build/SKILL.md +13 -0
  15. package/.claude/skills/codex-review/SKILL.md +13 -0
  16. package/.claude/skills/grill-me-codex/SKILL.md +14 -0
  17. package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
  18. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  19. package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
  20. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  21. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  22. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  23. package/.claude/skills/research/SKILL.md +7 -4
  24. package/.claude/skills/skill-manifest.json +34 -23
  25. package/.claude/skills/to-issues/SKILL.md +25 -4
  26. package/README.md +64 -0
  27. package/agent-workflow-kit.package.json +149 -45
  28. package/package.json +1 -1
  29. package/scripts/doctrine-migration/index.mjs +296 -0
  30. package/scripts/kit-release.mjs +41 -9
  31. package/src/cli.mjs +515 -79
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/lib/bundle.mjs +158 -2
  34. package/src/lib/dispatchJournal.mjs +300 -0
  35. package/src/lib/dispatchPlan.mjs +286 -0
  36. package/src/lib/dispatchReceipt.mjs +226 -89
  37. package/src/lib/frontendWorkloads.mjs +35 -33
  38. package/src/lib/routeDispatcher.mjs +367 -70
  39. package/src/lib/routingAccessGraph.mjs +265 -20
  40. package/src/lib/routingAccessGraphStore.mjs +300 -0
  41. package/src/lib/routingAdapters/claude.mjs +104 -4
  42. package/src/lib/routingAdapters/codex.mjs +132 -7
  43. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  44. package/src/lib/routingCatalog.mjs +201 -24
  45. package/src/lib/routingDispatchLease.mjs +253 -0
  46. package/src/lib/routingEvidenceCache.mjs +78 -0
  47. package/src/lib/routingIntent.mjs +176 -10
  48. package/src/lib/routingIntentClassifier.mjs +137 -0
  49. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  50. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  51. package/src/lib/routingInventory.mjs +182 -0
  52. package/src/lib/routingPolicy.mjs +193 -6
  53. package/src/lib/routingProfile.mjs +1251 -123
  54. package/src/lib/routingProfilePolicy.mjs +156 -0
  55. package/src/lib/routingProfileStorage.mjs +299 -0
  56. package/src/lib/routingResolver.mjs +369 -86
  57. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  58. package/src/lib/routingSources/benchlm.mjs +5 -0
  59. package/src/lib/routingSources/codeArena.mjs +13 -3
  60. package/src/lib/routingSources/deepswe.mjs +19 -5
  61. package/src/lib/routingSources/openhands.mjs +17 -4
  62. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  63. package/src/lib/safeText.mjs +26 -0
  64. package/src/lib/updateCandidate.mjs +36 -3
@@ -0,0 +1,288 @@
1
+ /**
2
+ * `routing status` — what would be dispatched for one Routing intent, and why.
3
+ *
4
+ * The command answers a question; it never dispatches and never writes a routing
5
+ * document. It composes the Routing profile, derives the Routing policy, reads
6
+ * the Access graph and the Evidence catalog, and resolves the Route decision the
7
+ * dispatcher would resolve — so a human and a skill read the same answer.
8
+ *
9
+ * Two facts stay separate in the output, because they answer different
10
+ * questions: the Evidence catalog's `bestOverall` says which model the evidence
11
+ * ranks first, and `bestExecutable` says what this machine could actually run
12
+ * right now. A pick is either evidence-backed or a Standard route, and the
13
+ * document says which, so nothing reads as evidence that is not.
14
+ *
15
+ * The surface is attested by the environment, never guessed. An explicit
16
+ * `--surface` fills the gap when no attestation identifies the surface, and a
17
+ * conflict between the two is rejected rather than resolved in either direction:
18
+ * preferring one would silently answer for a surface the caller is not on.
19
+ *
20
+ * Every outcome has its own exit code and every diagnostic is redacted, so a
21
+ * skill can branch on the code and a terminal never learns where a user's files
22
+ * live.
23
+ */
24
+ import { readFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+
27
+ import {
28
+ AGENT_SURFACE_REGISTRY, currentAgentSurface, surfaceById,
29
+ } from '../lib/agentSurfaceRegistry.mjs';
30
+ import { readAccessGraphDocument } from '../lib/routingAccessGraphStore.mjs';
31
+ import { validateEvidenceCatalog } from '../lib/routingCatalog.mjs';
32
+ import { parseRoutingIntent } from '../lib/routingIntent.mjs';
33
+ import { readComposedRoutingProfile, routingProfileStorageRoot } from '../lib/routingProfile.mjs';
34
+ import { deriveRoutingPolicy } from '../lib/routingProfilePolicy.mjs';
35
+ import {
36
+ ROUTE_DECISION_ORIGINS, ROUTE_DECISION_STATES, resolveRoute,
37
+ } from '../lib/routingResolver.mjs';
38
+ import { sanitizeReadinessText } from '../lib/safeText.mjs';
39
+
40
+ export const ROUTING_STATUS_DOCUMENT_VERSION = 1;
41
+
42
+ /** Where the two routing documents this command reads live, beside the profile. */
43
+ export const ACCESS_GRAPH_FILE = 'access-graph.json';
44
+ export const EVIDENCE_CATALOG_FILE = 'evidence-catalog.json';
45
+
46
+ const INVALID_REQUEST = 'invalid-request';
47
+
48
+ /**
49
+ * One code per outcome, stable across runs: a skill branches on the number.
50
+ * Every Route decision state keeps its own code — a pending decision is not a
51
+ * blocked one — plus a rejected request and an internal failure.
52
+ */
53
+ export const ROUTING_STATUS_EXIT_CODES = Object.freeze({
54
+ ready: 0,
55
+ [INVALID_REQUEST]: 1,
56
+ blocked: 2,
57
+ 'approval-required': 3,
58
+ 'verification-required': 4,
59
+ failed: 5,
60
+ });
61
+
62
+ /** What one origin means, in the words the output uses. */
63
+ const ROUTE_ORIGINS = Object.freeze({
64
+ evidence: Object.freeze({ label: 'evidence-backed', evidenceBacked: true }),
65
+ standard: Object.freeze({ label: 'standard route', evidenceBacked: false }),
66
+ });
67
+
68
+ export function routingStatusExitCode(outcome) {
69
+ const code = ROUTING_STATUS_EXIT_CODES[outcome];
70
+ if (code === undefined) throw new TypeError(`unknown routing status outcome: ${outcome}`);
71
+ return code;
72
+ }
73
+
74
+ export function routeOrigin(origin) {
75
+ if (origin === null || origin === undefined) return { label: 'no route', evidenceBacked: false };
76
+ const known = ROUTE_ORIGINS[origin];
77
+ if (!known) throw new TypeError(`unknown route decision origin: ${origin}`);
78
+ return known;
79
+ }
80
+
81
+ // A resolver that grows a state or an origin must be given a code and a label
82
+ // here rather than falling through to a wrong exit code or a silent label.
83
+ for (const state of ROUTE_DECISION_STATES) routingStatusExitCode(state);
84
+ for (const origin of ROUTE_DECISION_ORIGINS) routeOrigin(origin);
85
+
86
+ /** Two or more path segments: the shape a filesystem path takes in a message. */
87
+ const PATH_LIKE = /(?:[A-Za-z]:)?(?:[\\/][\w.@+~%-]+){2,}/g;
88
+ const DIAGNOSTIC_LIMIT = 200;
89
+
90
+ /**
91
+ * A diagnostic reaches a terminal and a skill's log verbatim, so it carries no
92
+ * control characters, no filesystem location and no unbounded payload.
93
+ */
94
+ export function redactDiagnostic(value) {
95
+ const text = sanitizeReadinessText(value instanceof Error ? value.message : String(value ?? ''));
96
+ if (!text) return 'no detail';
97
+ return text.replace(PATH_LIKE, '<path>').slice(0, DIAGNOSTIC_LIMIT);
98
+ }
99
+
100
+ const diagnostic = (code, detail) => ({ code, detail: redactDiagnostic(detail) });
101
+
102
+ /** `--flag=value` and `--flag value` both name the same value. */
103
+ function flagValue(argv, name) {
104
+ const inline = argv.find((argument) => argument.startsWith(`--${name}=`));
105
+ if (inline !== undefined) return inline.slice(name.length + 3);
106
+ const index = argv.indexOf(`--${name}`);
107
+ return index >= 0 ? argv[index + 1] ?? '' : null;
108
+ }
109
+
110
+ const knownTransports = (registry = AGENT_SURFACE_REGISTRY) =>
111
+ [...new Set(registry.flatMap((surface) => surface.adapter.transports))];
112
+
113
+ const surfaceIds = (registry = AGENT_SURFACE_REGISTRY) => registry.map(({ id }) => id).join(', ');
114
+
115
+ /**
116
+ * The surface the decision is resolved for. Attestation is evidence and an
117
+ * explicit flag is a claim: the claim fills a gap, and a claim that contradicts
118
+ * the evidence stops the run instead of overruling it either way.
119
+ */
120
+ function resolveSurface(argv, env, diagnostics) {
121
+ const attested = currentAgentSurface({ env }) ?? null;
122
+ const explicit = flagValue(argv, 'surface');
123
+ if (explicit !== null && !surfaceById(explicit)) {
124
+ diagnostics.push(diagnostic('unknown-surface', `${explicit} is no known agent surface `
125
+ + `(${surfaceIds()})`));
126
+ return null;
127
+ }
128
+ if (explicit && attested && explicit !== attested) {
129
+ diagnostics.push(diagnostic('surface-conflict', `--surface=${explicit} contradicts the `
130
+ + `attested surface ${attested}`));
131
+ return null;
132
+ }
133
+ if (attested) return { id: attested, source: 'attested' };
134
+ if (explicit) return { id: explicit, source: 'explicit' };
135
+ diagnostics.push(diagnostic('surface-required', 'no environment attestation identifies the '
136
+ + `agent surface: pass --surface=<${surfaceIds()}>`));
137
+ return null;
138
+ }
139
+
140
+ async function readIntent(file, diagnostics) {
141
+ if (!file) {
142
+ diagnostics.push(diagnostic('intent-file-required', 'pass --intent-file=<file> carrying a '
143
+ + 'serialized Routing intent'));
144
+ return null;
145
+ }
146
+ let text;
147
+ try {
148
+ text = await readFile(file, 'utf8');
149
+ } catch (error) {
150
+ diagnostics.push(diagnostic('intent-file-unreadable', `${file}: ${error.message}`));
151
+ return null;
152
+ }
153
+ try {
154
+ return parseRoutingIntent(text);
155
+ } catch (error) {
156
+ diagnostics.push(diagnostic('intent-file-invalid', `${file}: ${error.message}`));
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** A stored document that is simply not there yet is a state, not a failure. */
162
+ async function loadDocument({ file, name, read }, diagnostics) {
163
+ try {
164
+ const document = await read(file);
165
+ if (document) return document;
166
+ } catch (error) {
167
+ if (error?.code !== 'ENOENT') {
168
+ diagnostics.push(diagnostic(`${name}-unreadable`, `${file}: ${error.message}`));
169
+ return null;
170
+ }
171
+ }
172
+ diagnostics.push(diagnostic(`${name}-missing`, `no ${name} document is stored yet`));
173
+ return null;
174
+ }
175
+
176
+ async function loadPolicy(options, diagnostics) {
177
+ const { profileRoot, consumerRoot, identity, runGit } = options;
178
+ try {
179
+ const profile = await readComposedRoutingProfile({
180
+ profileRoot, projectRoot: consumerRoot, identity, runGit,
181
+ });
182
+ if (!profile.composed) {
183
+ diagnostics.push(diagnostic('routing-profile-missing', profile.reasons.join(', ')));
184
+ return null;
185
+ }
186
+ return deriveRoutingPolicy({
187
+ composed: profile.composed,
188
+ globalGeneration: profile.global.generation,
189
+ projectGeneration: profile.project?.generation ?? null,
190
+ });
191
+ } catch (error) {
192
+ diagnostics.push(diagnostic('routing-profile-unusable', error.message));
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /** Everything the resolver needs, each part missing on its own terms. */
198
+ async function loadRoutingWorld(options, diagnostics) {
199
+ const storage = routingProfileStorageRoot(options.profileRoot);
200
+ return {
201
+ policy: await loadPolicy(options, diagnostics),
202
+ accessGraph: await loadDocument({
203
+ file: join(storage, ACCESS_GRAPH_FILE),
204
+ name: 'access-graph',
205
+ read: async (file) => (await readAccessGraphDocument(file))?.graph ?? null,
206
+ }, diagnostics),
207
+ catalog: await loadDocument({
208
+ file: join(storage, EVIDENCE_CATALOG_FILE),
209
+ name: 'evidence-catalog',
210
+ read: async (file) => validateEvidenceCatalog(JSON.parse(await readFile(file, 'utf8'))),
211
+ }, diagnostics),
212
+ };
213
+ }
214
+
215
+ /** The published per-attempt figure, as-is: the Kit derives no per-task cost. */
216
+ function costPerTask(decision) {
217
+ const cost = decision?.selected?.cost;
218
+ if (!cost) return null;
219
+ return { amount: cost.amount, currency: cost.currency, unit: cost.unit };
220
+ }
221
+
222
+ /** One key set, whatever the outcome — a machine reads the same shape every time. */
223
+ function statusDocument({ outcome, surface = null, intent = null, decision = null, diagnostics }) {
224
+ const origin = routeOrigin(decision?.origin ?? null);
225
+ return {
226
+ schemaVersion: ROUTING_STATUS_DOCUMENT_VERSION,
227
+ outcome,
228
+ exitCode: routingStatusExitCode(outcome),
229
+ surface,
230
+ intent: intent?.intent ?? null,
231
+ intentMigration: intent
232
+ ? { fromVersion: intent.fromVersion, defaulted: [...intent.defaulted] }
233
+ : null,
234
+ status: decision?.status ?? null,
235
+ reason: decision?.reason ?? null,
236
+ origin: decision?.origin ?? null,
237
+ state: decision?.state ?? null,
238
+ evidenceBacked: origin.evidenceBacked,
239
+ selected: decision?.selected ?? null,
240
+ bestOverall: decision?.bestOverall ?? null,
241
+ bestExecutable: decision?.bestExecutable ?? null,
242
+ costPerTask: costPerTask(decision),
243
+ blockers: decision ? [...decision.blockers] : [],
244
+ revisions: decision?.revisions ?? null,
245
+ diagnostics,
246
+ };
247
+ }
248
+
249
+ /** An unexpected failure still answers in the machine shape, redacted. */
250
+ export function routingStatusFailure(detail) {
251
+ return statusDocument({
252
+ outcome: 'failed', diagnostics: [diagnostic('routing-status-failed', detail)],
253
+ });
254
+ }
255
+
256
+ const answer = (document, json) => ({ document, json, exitCode: document.exitCode });
257
+
258
+ /**
259
+ * Resolve the Route decision for one intent. Every recoverable problem becomes a
260
+ * redacted diagnostic plus an outcome, so a caller never has to parse an
261
+ * exception to learn what happened.
262
+ */
263
+ export async function routingStatus(options = {}) {
264
+ const { argv = [], env = process.env, now = new Date().toISOString() } = options;
265
+ const json = argv.includes('--json');
266
+ const diagnostics = [];
267
+ const surface = resolveSurface(argv, env, diagnostics);
268
+ const intent = await readIntent(flagValue(argv, 'intent-file'), diagnostics);
269
+ if (!surface || !intent) {
270
+ return answer(statusDocument({ outcome: INVALID_REQUEST, surface, intent, diagnostics }), json);
271
+ }
272
+ const world = await loadRoutingWorld(options, diagnostics);
273
+ try {
274
+ const decision = resolveRoute({
275
+ ...world,
276
+ intent: intent.intent,
277
+ activeSurface: surface.id,
278
+ knownTransports: knownTransports(),
279
+ now,
280
+ });
281
+ return answer(statusDocument({
282
+ outcome: decision.state, surface, intent, decision, diagnostics,
283
+ }), json);
284
+ } catch (error) {
285
+ diagnostics.push(diagnostic('route-decision-failed', error.message));
286
+ return answer(statusDocument({ outcome: 'failed', surface, intent, diagnostics }), json);
287
+ }
288
+ }
@@ -1,5 +1,8 @@
1
- import { readdir } from 'node:fs/promises';
2
- import { join, relative } from 'node:path';
1
+ import { createHash } from 'node:crypto';
2
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, posix, relative } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
3
6
 
4
7
  // The planning skills are not usable without their helper ecosystem (Codex R2#1).
5
8
  // These ship alongside the skills. Paths are relative to the bundle/consumer root.
@@ -91,18 +94,34 @@ export const HELPER_FILES = [
91
94
  // These modules form one consumer unit; omitting one leaves shipped dispatch
92
95
  // prose pointing at a resolver or adapter that cannot execute.
93
96
  { path: 'src/commands/routing-policy-update.mjs', kind: 'script', mode: 0o644 },
97
+ { path: 'src/commands/routing-status.mjs', kind: 'script', mode: 0o644 },
94
98
  { path: 'src/lib/agentSurfaceRegistry.mjs', kind: 'script', mode: 0o644 },
99
+ { path: 'src/lib/dispatchJournal.mjs', kind: 'script', mode: 0o644 },
100
+ { path: 'src/lib/dispatchPlan.mjs', kind: 'script', mode: 0o644 },
95
101
  { path: 'src/lib/dispatchReceipt.mjs', kind: 'script', mode: 0o644 },
96
102
  { path: 'src/lib/frontendWorkloads.mjs', kind: 'script', mode: 0o644 },
97
103
  { path: 'src/lib/routeDispatcher.mjs', kind: 'script', mode: 0o644 },
104
+ { path: 'src/lib/safeText.mjs', kind: 'script', mode: 0o644 },
98
105
  { path: 'src/lib/routingAccessGraph.mjs', kind: 'script', mode: 0o644 },
106
+ { path: 'src/lib/routingAccessGraphStore.mjs', kind: 'script', mode: 0o644 },
99
107
  { path: 'src/lib/routingAdapters/claude.mjs', kind: 'script', mode: 0o644 },
100
108
  { path: 'src/lib/routingAdapters/codex.mjs', kind: 'script', mode: 0o644 },
109
+ { path: 'src/lib/routingAdapters/hostBridge.mjs', kind: 'script', mode: 0o644 },
101
110
  { path: 'src/lib/routingCatalog.mjs', kind: 'script', mode: 0o644 },
111
+ { path: 'src/lib/routingDispatchLease.mjs', kind: 'script', mode: 0o644 },
102
112
  { path: 'src/lib/routingEvidenceCache.mjs', kind: 'script', mode: 0o644 },
103
113
  { path: 'src/lib/routingIntent.mjs', kind: 'script', mode: 0o644 },
114
+ { path: 'src/lib/routingIntentClassifier.mjs', kind: 'script', mode: 0o644 },
104
115
  { path: 'src/lib/routingPolicy.mjs', kind: 'script', mode: 0o644 },
105
116
  { path: 'src/lib/routingProfile.mjs', kind: 'script', mode: 0o644 },
117
+ { path: 'src/lib/routingProfilePolicy.mjs', kind: 'script', mode: 0o644 },
118
+ { path: 'src/lib/routingProfileStorage.mjs', kind: 'script', mode: 0o644 },
119
+ // Pinned Model inventory: the module resolves its provenance-hashed source
120
+ // snapshots beside itself, so the three files are one indivisible unit. A
121
+ // consumer gets the model-and-effort table without any discovery request.
122
+ { path: 'src/lib/routingInventory.mjs', kind: 'script', mode: 0o644 },
123
+ { path: 'src/lib/routingInventory/snapshots/claude.json', kind: 'doc', mode: 0o644 },
124
+ { path: 'src/lib/routingInventory/snapshots/codex.json', kind: 'doc', mode: 0o644 },
106
125
  { path: 'src/lib/routingResolver.mjs', kind: 'script', mode: 0o644 },
107
126
  { path: 'src/lib/routingSources/artificialAnalysis.mjs', kind: 'script', mode: 0o644 },
108
127
  { path: 'src/lib/routingSources/benchlm.mjs', kind: 'script', mode: 0o644 },
@@ -315,3 +334,140 @@ export async function collectBundle(repoRoot, manifest) {
315
334
  }
316
335
  return { files, stubs: STUB_TARGETS };
317
336
  }
337
+
338
+ // --- bundle verification ---------------------------------------------------
339
+ // Counting files proves nothing about a shipped unit. Verification proves four
340
+ // things about a built bundle: the package manifest describes the bytes that
341
+ // shipped, every install role is known and matches its declaration, no consumer
342
+ // module imports outside the set a consumer actually receives, and the routing
343
+ // unit still works when it is installed as a consumer would install it.
344
+
345
+ export const INSTALL_ROLES = Object.freeze(['consumer', 'maintainer']);
346
+ const CONSUMER_ROLE = 'consumer';
347
+
348
+ /** The routing unit an installed consumer must receive whole. */
349
+ export const ROUTING_UNIT_PATTERN =
350
+ /^src\/(?:lib\/routing|lib\/(?:routeDispatcher|dispatchReceipt|dispatchJournal|dispatchPlan)\.mjs$|commands\/routing-)/;
351
+ const ROUTING_INVENTORY_MODULE = 'src/lib/routingInventory.mjs';
352
+ // `from './x'`, the side-effect `import './x'`, and dynamic `import('./x')`.
353
+ const RELATIVE_IMPORT = /\b(?:from|import)\s*\(?\s*['"](\.[^'"]+)['"]/g;
354
+
355
+ const roleOf = (entry) => entry.installRole ?? CONSUMER_ROLE;
356
+ const isConsumer = (entry) => roleOf(entry) === CONSUMER_ROLE;
357
+
358
+ async function checkManifestHashes(bundleRoot, entries, findings) {
359
+ for (const entry of entries) {
360
+ let content;
361
+ try {
362
+ content = await readFile(join(bundleRoot, entry.path));
363
+ } catch (error) {
364
+ findings.push(`shipped file missing from the bundle: ${entry.path} (${error.code ?? error.message})`);
365
+ continue;
366
+ }
367
+ const digest = createHash('sha256').update(content).digest('hex');
368
+ if (digest !== entry.sha256) findings.push(`package-manifest hash mismatch: ${entry.path}`);
369
+ }
370
+ }
371
+
372
+ function checkInstallRoles(entries, helperFiles, findings) {
373
+ const declared = new Map(helperFiles.map((h) => [h.path, h.installRole ?? CONSUMER_ROLE]));
374
+ for (const entry of entries) {
375
+ const role = roleOf(entry);
376
+ if (!INSTALL_ROLES.includes(role)) {
377
+ findings.push(`unknown install role: ${entry.path} -> ${role}`);
378
+ continue;
379
+ }
380
+ const declaredRole = declared.get(entry.path);
381
+ if (declaredRole && declaredRole !== role) {
382
+ findings.push(`install role drift: ${entry.path} declared ${declaredRole}, shipped ${role}`);
383
+ }
384
+ if (entry.kind === 'skill' && !entry.ownerSkill) {
385
+ findings.push(`skill file without an owning skill: ${entry.path}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ async function checkImportClosure(bundleRoot, entries, findings) {
391
+ const installed = new Set(entries.filter(isConsumer).map(({ path }) => path));
392
+ for (const path of installed) {
393
+ if (!path.endsWith('.mjs')) continue;
394
+ let source;
395
+ try {
396
+ source = await readFile(join(bundleRoot, path), 'utf8');
397
+ } catch { continue; } // already reported by the hash check
398
+ for (const [, specifier] of source.matchAll(RELATIVE_IMPORT)) {
399
+ const resolved = posix.normalize(posix.join(posix.dirname(path), specifier));
400
+ if (!installed.has(resolved)) {
401
+ findings.push(`import escapes the installed consumer: ${path} -> ${specifier}`);
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ /** Materialize exactly what `init` would copy into a consumer. */
408
+ async function materializeConsumerInstall(bundleRoot, entries) {
409
+ const root = await mkdtemp(join(tmpdir(), 'awkit-consumer-smoke-'));
410
+ for (const { path } of entries.filter(isConsumer)) {
411
+ await mkdir(dirname(join(root, path)), { recursive: true });
412
+ // A file the manifest promises but the bundle lacks is already a hash
413
+ // finding; the smoke run then fails on its own missing dependency.
414
+ await copyFile(join(bundleRoot, path), join(root, path)).catch(() => {});
415
+ }
416
+ return root;
417
+ }
418
+
419
+ /** Load the routing unit out of an installed consumer and use it once. */
420
+ export async function smokeRoutingUnit(installRoot, routingPaths) {
421
+ for (const path of routingPaths.filter((candidate) => candidate.endsWith('.mjs'))) {
422
+ await import(pathToFileURL(join(installRoot, path)).href);
423
+ }
424
+ const module = await import(pathToFileURL(join(installRoot, ROUTING_INVENTORY_MODULE)).href);
425
+ const inventory = await module.loadRoutingInventory();
426
+ if (!inventory.pairs.length) {
427
+ throw new Error('the installed routing inventory ships no model-and-effort pair');
428
+ }
429
+ return { revision: inventory.revision, pairCount: inventory.pairs.length };
430
+ }
431
+
432
+ /**
433
+ * Verify a built bundle. Returns a report; the caller decides what a red means.
434
+ */
435
+ export async function verifyBundle({
436
+ bundleRoot, manifest, helperFiles = HELPER_FILES, smoke = smokeRoutingUnit,
437
+ }) {
438
+ const entries = manifest.files ?? [];
439
+ const findings = {
440
+ manifestHashes: [], installRoles: [], importClosure: [], consumerSmoke: [],
441
+ };
442
+ await checkManifestHashes(bundleRoot, entries, findings.manifestHashes);
443
+ checkInstallRoles(entries, helperFiles, findings.installRoles);
444
+ await checkImportClosure(bundleRoot, entries, findings.importClosure);
445
+
446
+ const routingPaths = entries.filter(isConsumer)
447
+ .map(({ path }) => path).filter((path) => ROUTING_UNIT_PATTERN.test(path));
448
+ let smoked = null;
449
+ if (!routingPaths.includes(ROUTING_INVENTORY_MODULE)) {
450
+ findings.consumerSmoke.push(`the routing unit ships no ${ROUTING_INVENTORY_MODULE}`);
451
+ } else {
452
+ const installRoot = await materializeConsumerInstall(bundleRoot, entries);
453
+ try {
454
+ smoked = await smoke(installRoot, routingPaths);
455
+ } catch (error) {
456
+ findings.consumerSmoke.push(`installed-consumer smoke run failed: ${error.message}`);
457
+ } finally {
458
+ await rm(installRoot, { recursive: true, force: true });
459
+ }
460
+ }
461
+
462
+ const checks = Object.fromEntries(
463
+ Object.entries(findings).map(([check, list]) => [check, list.length === 0]),
464
+ );
465
+ return {
466
+ ok: Object.values(checks).every(Boolean),
467
+ checks,
468
+ findings: Object.entries(findings)
469
+ .flatMap(([check, list]) => list.map((detail) => ({ check, detail }))),
470
+ routingUnitCount: routingPaths.length,
471
+ inventoryRevision: smoked?.revision ?? null,
472
+ };
473
+ }