@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
package/src/cli.mjs CHANGED
@@ -8,6 +8,9 @@ import { renderUpdateFailure, update } from './commands/update.mjs';
8
8
  import { diff } from './commands/diff.mjs';
9
9
  import { uninstall } from './commands/uninstall.mjs';
10
10
  import { setOwnership } from './commands/own.mjs';
11
+ import {
12
+ routeOrigin, routingStatus, routingStatusFailure,
13
+ } from './commands/routing-status.mjs';
11
14
  import {
12
15
  beginContributionBridge, prepareContributionArtifact,
13
16
  } from './lib/contributionBridge.mjs';
@@ -16,15 +19,84 @@ import {
16
19
  } from './lib/contributionRouting.mjs';
17
20
  import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
18
21
  import { nonInteractiveUpdateDecision } from './lib/updateDecisions.mjs';
22
+ import { sanitizeReadinessText } from './lib/updateCandidate.mjs';
19
23
  import {
20
24
  renderConsumerAdvisory, renderRequiredMigration,
21
25
  } from './lib/consumerMigrations.mjs';
22
- import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
26
+ import { currentAgentSurface, surfaceById } from './lib/agentSurfaceRegistry.mjs';
27
+ import { routingProfilePath } from './lib/routingProfile.mjs';
23
28
  import { createCommandAdapter } from '../scripts/release-state.mjs';
24
29
  import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
25
30
 
26
31
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
32
 
33
+ // Declared before the self-invocation below: `runCli` renders the plan during
34
+ // module evaluation, so a later `const` would be in its temporal dead zone.
35
+ const READINESS_STATE_PHRASE = {
36
+ missing: 'not configured yet',
37
+ pending: 'deferred as pending',
38
+ invalid: 'present but not valid',
39
+ 'not-applicable': 'recorded as not applicable',
40
+ };
41
+
42
+ // Declared here for the same reason: `promptRoutingProfile` runs inside
43
+ // `runCli`, i.e. during module evaluation, so these tables must be initialized
44
+ // before the self-invocation below.
45
+
46
+ /** What choosing an option means, per routing prompt. */
47
+ const ROUTING_HINTS = {
48
+ autonomy: {
49
+ automatic: 'the Kit may move a task to another selected app on its own',
50
+ ask: 'the Kit proposes a switch and waits for your confirmation',
51
+ 'current-surface-only': 'every task stays in the app it started in',
52
+ },
53
+ activation: {
54
+ approve: 'store this routing profile exactly as summarized above',
55
+ 'safe-current-surface': 'store it, but keep every task in the current app',
56
+ back: 'answer the app and switching questions again',
57
+ advanced: 'add optional preferences to the draft before anything is stored',
58
+ decline: 'store nothing; the Kit asks again on the next update',
59
+ },
60
+ advanced: {
61
+ balanced: 'note a preference for a balance of quality and cost',
62
+ quality: 'note a preference for the strongest model even when it costs more',
63
+ cost: 'note a preference for the cheaper model when it can do the job',
64
+ },
65
+ reconcile: {
66
+ review: 'answer the routing questions now and store an updated profile',
67
+ decline: 'change nothing now; the Kit asks again on the next update',
68
+ },
69
+ };
70
+
71
+ /** Option labels the routing question does not carry itself. */
72
+ const ROUTING_LABELS = {
73
+ activation: {
74
+ approve: 'Approve',
75
+ 'safe-current-surface': 'Safe current surface',
76
+ back: 'Back',
77
+ advanced: 'Advanced',
78
+ decline: 'Decline',
79
+ },
80
+ advanced: { balanced: 'Balanced', quality: 'Quality', cost: 'Cost' },
81
+ reconcile: { review: 'Review routing choices now', decline: 'Not now' },
82
+ };
83
+
84
+ const ADVANCED_OPTIMIZATIONS = ['balanced', 'quality', 'cost'];
85
+ const RECONCILE_MIGRATION_ACTIONS = ['review', 'decline'];
86
+ /** The answer that nominates no Standard route for a workload class. */
87
+ const NO_STANDARD_ROUTE = 'none';
88
+
89
+ const ROUTING_PAYLOADS = {
90
+ surfaces: surfacesPayload,
91
+ transports: transportsPayload,
92
+ autonomy: autonomyPayload,
93
+ roster: rosterPayload,
94
+ 'standard-route': standardRoutePayload,
95
+ activation: activationPayload,
96
+ advanced: advancedPayload,
97
+ reconcile: reconcilePayload,
98
+ };
99
+
28
100
  function stamp() {
29
101
  // backup suffix: YYYYMMDDTHHMMSS (no separators that collide with shells)
30
102
  return new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
@@ -48,6 +120,8 @@ export async function runCli({
48
120
  const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
49
121
  // Machine-readable rendering of the same update record the interactive run prints.
50
122
  const jsonReport = cmd === 'update' && args.includes('--json');
123
+ // The routing status machine contract: the document is the only thing on stdout.
124
+ const routingJson = cmd === 'routing' && args.includes('--json');
51
125
  let exitCode = 0;
52
126
 
53
127
  if (cmd === 'update' && keepDeleted && restoreDeleted) {
@@ -62,7 +136,7 @@ export async function runCli({
62
136
  return 1;
63
137
  }
64
138
 
65
- if (!jsonReport) p.intro('agent-workflow-kit');
139
+ if (!jsonReport && !routingJson) p.intro('agent-workflow-kit');
66
140
 
67
141
  try {
68
142
  if (cmd === 'init') {
@@ -77,7 +151,7 @@ export async function runCli({
77
151
  (r.skipped.length ? `\nskipped (pre-existing, use --force): ${r.skipped.join(', ')}` : ''),
78
152
  'init'
79
153
  );
80
- printRoutingProfile(r.routingProfile);
154
+ printRoutingProfile(r.routingProfile, consumerRoot);
81
155
  p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
82
156
  'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
83
157
  } else if (cmd === 'diff') {
@@ -105,7 +179,7 @@ export async function runCli({
105
179
  return r.state === 'conflicted' ? 2 : 0;
106
180
  }
107
181
  printPlan(r);
108
- printRoutingProfile(r.routingProfile);
182
+ printRoutingProfile(r.routingProfile, consumerRoot);
109
183
  for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
110
184
  if (r.state === 'failed') throw new Error(renderUpdateFailure(r));
111
185
  if (r.state === 'conflicted') {
@@ -171,6 +245,19 @@ export async function runCli({
171
245
  });
172
246
  p.outro(`prepared local contribution artifact ${output}; no remote was changed`);
173
247
  }
248
+ } else if (cmd === 'routing') {
249
+ if (args[1] !== 'status') {
250
+ throw new Error('Usage: agent-workflow-kit routing status --intent-file=<file> ' +
251
+ '[--surface=<id>] [--json]');
252
+ }
253
+ const status = await routingStatus({ argv: args.slice(2), consumerRoot });
254
+ if (routingJson) {
255
+ process.stdout.write(`${JSON.stringify(status.document, null, 2)}\n`);
256
+ return status.exitCode;
257
+ }
258
+ p.note(renderRoutingStatus(status.document), 'routing status');
259
+ p.outro(`routing status: ${status.document.outcome}`);
260
+ exitCode = status.exitCode;
174
261
  } else if (cmd === 'own' || cmd === 'disown') {
175
262
  if (!args[1]) {
176
263
  throw new Error(
@@ -187,9 +274,11 @@ export async function runCli({
187
274
  p.outro(`${args[1]} is now ${origin}-owned` +
188
275
  (origin === CONSUMER_ORIGIN ? ` (${ownershipState ?? 'explicit-fork'})` : ''));
189
276
  } else {
190
- p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown|contribute> ' +
277
+ p.note('Usage: agent-workflow-kit ' +
278
+ '<init|update|diff|uninstall|own|disown|contribute|routing status> ' +
191
279
  '[<path>] [--force] [--yes] [--keep-deleted|--restore-deleted] [--owned] ' +
192
- '[--as=contribution-bridge|explicit-fork]');
280
+ '[--as=contribution-bridge|explicit-fork] ' +
281
+ '[--intent-file=<file>] [--surface=<id>] [--json]');
193
282
  p.outro('');
194
283
  }
195
284
  } catch (err) {
@@ -199,6 +288,11 @@ export async function runCli({
199
288
  }, null, 2)}\n`);
200
289
  return 1;
201
290
  }
291
+ if (routingJson) {
292
+ const document = routingStatusFailure(err.message);
293
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
294
+ return document.exitCode;
295
+ }
202
296
  p.cancel(`Error: ${err.message}`);
203
297
  return 1;
204
298
  }
@@ -235,10 +329,11 @@ function printPlan(r) {
235
329
  if (r.availability) {
236
330
  for (const [key, label] of [
237
331
  ['newlyAvailable', 'newly available'], ['newlyDegraded', 'newly degraded'],
238
- ['newlyBlocked', 'newly blocked'], ['stillUnresolved', 'still unresolved'],
332
+ ['newlyBlocked', 'newly blocked'],
239
333
  ]) {
240
334
  lines.push(`${label}: ${r.availability[key].join(', ') || 'none'}`);
241
335
  }
336
+ lines.push(...renderReadinessAvailability(r.availability));
242
337
  }
243
338
  for (const action of r.requiredMigrations ?? []) {
244
339
  lines.push(renderRequiredMigration(action));
@@ -259,6 +354,77 @@ function printPlan(r) {
259
354
  p.note(lines.join('\n') || 'no changes', 'plan');
260
355
  }
261
356
 
357
+ /**
358
+ * Render unresolved readiness in plain terms — one sentence naming the
359
+ * capability and its state, one next step. Every line comes from the readiness
360
+ * catalog, so a new capability reads correctly without a code change; the
361
+ * legacy `capability:state` list stays the fallback for an older record.
362
+ */
363
+ export function renderReadinessAvailability(availability) {
364
+ const entries = availability?.unresolved;
365
+ if (!Array.isArray(entries) || !entries.length) {
366
+ return [`still unresolved: ${availability?.stillUnresolved?.join(', ') || 'none'}`];
367
+ }
368
+ const lines = ['still unresolved:'];
369
+ for (const entry of entries) {
370
+ const subject = sanitizeReadinessText(entry.title) ?? entry.capability;
371
+ const phrase = READINESS_STATE_PHRASE[entry.state] ?? entry.state;
372
+ const evidence = (entry.evidencePaths ?? [])
373
+ .map(sanitizeReadinessText).filter(Boolean).join(', ');
374
+ lines.push(` ${subject} is ${phrase}${evidence ? ` (evidence: ${evidence})` : ''}.`);
375
+ const remedy = sanitizeReadinessText(entry.remedy);
376
+ if (remedy) lines.push(` next step: ${remedy}`);
377
+ }
378
+ return lines;
379
+ }
380
+
381
+ /** A model-and-effort pair as a route reads out loud. */
382
+ function describeRoutePair(route) {
383
+ if (!route) return 'none';
384
+ return `${route.modelId} · ${route.effort ?? 'no effort axis'}`;
385
+ }
386
+
387
+ /** Where the route would run — the Access path, not the evidence. */
388
+ function describeRouteAccess(route) {
389
+ if (!route?.surfaceId) return '';
390
+ const app = sanitizeReadinessText(surfaceById(route.surfaceId)?.label ?? route.surfaceId);
391
+ return ` on ${app} · ${route.transportId}`;
392
+ }
393
+
394
+ /**
395
+ * The Route decision in the terms the question was asked in: what the evidence
396
+ * ranks first, what this machine could actually run, what it costs, what blocks
397
+ * it, and which revisions the answer stands on.
398
+ */
399
+ export function renderRoutingStatus(document) {
400
+ const lines = [`outcome: ${document.outcome} (exit ${document.exitCode})`];
401
+ if (document.surface) {
402
+ lines.push(`surface: ${document.surface.id} (${document.surface.source})`);
403
+ }
404
+ if (document.intent) {
405
+ lines.push(`intent: ${document.intent.workload} · ${document.intent.reasoning} reasoning · `
406
+ + `${document.intent.risk} risk · ${document.intent.autonomyRequirement}`);
407
+ }
408
+ if (document.state) {
409
+ lines.push(`pick: ${routeOrigin(document.origin).label} · ${document.state}`
410
+ + `${document.reason ? ` (${document.reason})` : ''}`);
411
+ lines.push(`best overall: ${describeRoutePair(document.bestOverall?.route)}`
412
+ + ` (${document.bestOverall?.status ?? 'unavailable'})`);
413
+ lines.push(`best executable: ${describeRoutePair(document.bestExecutable)}`
414
+ + describeRouteAccess(document.bestExecutable));
415
+ lines.push(`cost per task: ${document.costPerTask
416
+ ? `${document.costPerTask.amount} ${document.costPerTask.currency} `
417
+ + `per ${document.costPerTask.unit}`
418
+ : 'none published'}`);
419
+ lines.push(`blockers: ${document.blockers.join(', ') || 'none'}`);
420
+ lines.push(`revisions: policy ${document.revisions?.policy ?? 'none'} · `
421
+ + `catalog ${document.revisions?.catalog ?? 'none'} · `
422
+ + `access graph ${document.revisions?.accessGraph ?? 'none'}`);
423
+ }
424
+ for (const { code, detail } of document.diagnostics) lines.push(`${code}: ${detail}`);
425
+ return lines.join('\n');
426
+ }
427
+
262
428
  async function decideUpdate(action, path, yes, classification, choices = {}) {
263
429
  if (yes) return nonInteractiveUpdateDecision(action, choices);
264
430
  if (action === 'delete') {
@@ -289,86 +455,356 @@ function routingProfileOptions(yes) {
289
455
  };
290
456
  }
291
457
 
292
- function printRoutingProfile(result) {
293
- if (!result) return;
294
- const suffix = result.reasons?.length ? ` · ${result.reasons.join(', ')}` : '';
295
- p.note(`${result.status}${suffix}`, 'routing profile');
458
+ function printRoutingProfile(result, consumerRoot) {
459
+ const note = routingResultNote(result, consumerRoot);
460
+ if (note) p.note(note, 'routing profile');
296
461
  }
297
462
 
298
- async function promptRoutingProfile(question) {
299
- if (question.kind === 'surfaces') {
300
- return ensurePrompt(await p.multiselect({
301
- message: question.message,
302
- options: question.options.map(({ id, label }) => ({ value: id, label })),
303
- initialValues: question.preselected,
304
- required: true,
305
- }), 'surface selection');
463
+ /**
464
+ * The routing outcome in the terms a user can act on: what was decided, why,
465
+ * which profile it produced, and the user-local file that now holds it.
466
+ */
467
+ export function routingResultNote(result, consumerRoot, profileRoot) {
468
+ if (!result) return null;
469
+ const lines = [`status: ${result.status}`];
470
+ if (result.reasons?.length) lines.push(`reasons: ${result.reasons.join(', ')}`);
471
+ if (result.profile) {
472
+ lines.push(`agent apps: ${surfaceLabels(result.profile.selectedSurfaces)}`);
473
+ lines.push(`transports: ${describeTransports(result.profile.authorizedTransports)}`);
474
+ lines.push(`switching: ${describeSwitching(result.profile.switching)}`);
475
+ lines.push(`model roster: ${describeRoster(result.profile.roster)}`);
476
+ lines.push(`standard routes: ${describeStandardRoutes(result.profile.standardRoutes)}`);
306
477
  }
307
- if (question.kind === 'autonomy') {
308
- return ensurePrompt(await p.select({
309
- message: question.message,
310
- options: question.options,
311
- }), 'switching choice');
312
- }
313
- if (question.kind === 'activation') {
314
- const draft = question.advancedDraft ? ' · advanced draft ready' : '';
315
- return ensurePrompt(await p.select({
316
- message: `${question.message}${draft}`,
317
- options: [
318
- { value: 'approve', label: 'Approve' },
319
- { value: 'safe-current-surface', label: 'Safe current surface' },
320
- { value: 'back', label: 'Back' },
321
- { value: 'advanced', label: 'Advanced' },
322
- { value: 'decline', label: 'Decline' },
323
- ],
324
- }), 'activation choice');
478
+ lines.push(`profile file: ${routingProfilePath(consumerRoot, profileRoot)}`);
479
+ return lines.join('\n');
480
+ }
481
+
482
+ /**
483
+ * The exact payload a routing question renders as — every option carries a
484
+ * hint saying what choosing it means, so no prompt asks a user to guess.
485
+ */
486
+ export function routingPromptPayload(question) {
487
+ const build = ROUTING_PAYLOADS[question?.kind];
488
+ if (!build) throw new Error(`unknown routing profile question: ${question?.kind}`);
489
+ return build(question);
490
+ }
491
+
492
+ /** An option the Kit cannot explain is a bug, not a silently bare label. */
493
+ function hintedOptions(kind, options) {
494
+ return options.map(({ value, label }) => {
495
+ const hint = ROUTING_HINTS[kind]?.[value];
496
+ if (!hint) throw new Error(`missing routing prompt hint: ${kind}.${value}`);
497
+ return { value, label: label ?? value, hint };
498
+ });
499
+ }
500
+
501
+ function labelledOptions(kind, values) {
502
+ return values.map((value) => ({ value, label: ROUTING_LABELS[kind]?.[value] ?? value }));
503
+ }
504
+
505
+ function surfacesPayload(question) {
506
+ const preselected = new Set(question.preselected ?? []);
507
+ return {
508
+ control: 'multiselect',
509
+ label: 'surface selection',
510
+ message: question.message,
511
+ options: question.options.map(({ id, label }) => ({
512
+ value: id,
513
+ label,
514
+ hint: preselected.has(id)
515
+ ? `preselected — selecting it lets the Kit route work to ${label}`
516
+ : `not preselected — select it only if you actually use ${label}`,
517
+ })),
518
+ initialValues: question.preselected,
519
+ required: true,
520
+ };
521
+ }
522
+
523
+ function autonomyPayload(question) {
524
+ return {
525
+ control: 'select',
526
+ label: 'switching choice',
527
+ message: question.message,
528
+ options: hintedOptions('autonomy', question.options ?? []),
529
+ };
530
+ }
531
+
532
+ /** Prompt-side identity for a `(surface, transport)` authorization. */
533
+ const transportValue = ({ surface, transport }) => `${surface}/${transport}`;
534
+ /** Prompt-side identity for a model-and-effort pair; the label is the readable half. */
535
+ const pairValue = ({ model, effort }) => `${model}/${effort ?? ''}`;
536
+ const pairText = ({ model, effort }) => sanitizeReadinessText(
537
+ `${model} · ${effort ?? 'no effort axis'}`,
538
+ ) ?? model;
539
+
540
+ /**
541
+ * Authorizing an app is not authorizing it to drive another app's command line,
542
+ * so every transport says which of the two it is.
543
+ */
544
+ function transportsPayload(question) {
545
+ const preselected = new Set((question.preselected ?? []).map(transportValue));
546
+ const options = (question.options ?? []).map((option) => {
547
+ const app = sanitizeReadinessText(option.surfaceLabel) ?? option.surface;
548
+ return {
549
+ value: transportValue(option),
550
+ label: `${app} · ${option.transport}`,
551
+ hint: option.native
552
+ ? `${app} may drive its own runtime`
553
+ : `lets ${app} drive the ${option.transport} command line — selecting the app does not`,
554
+ };
555
+ });
556
+ return {
557
+ control: 'multiselect',
558
+ label: 'transport authorization',
559
+ message: question.message,
560
+ options,
561
+ initialValues: options.filter(({ value }) => preselected.has(value)).map(({ value }) => value),
562
+ required: false,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * The inventory can list dozens of pairs, so the roster question is grouped per
568
+ * agent app — detected apps first — and says how much it is showing. It also
569
+ * says what leaving a pair out means, because that answer is durable.
570
+ */
571
+ function rosterPayload(question) {
572
+ const groups = question.groups ?? [];
573
+ const options = {};
574
+ for (const group of groups) {
575
+ const app = sanitizeReadinessText(group.label) ?? group.surface;
576
+ options[app] = group.pairs.map((pair) => ({
577
+ value: pairValue(pair),
578
+ label: pairText(pair),
579
+ hint: group.detected
580
+ ? `${app} is installed here — selecting the pair authorizes it`
581
+ : `${app} is not installed here — authorizing it is still your choice`,
582
+ }));
325
583
  }
326
- if (question.kind === 'advanced') {
327
- const optimization = ensurePrompt(await p.select({
584
+ return {
585
+ control: 'groupmultiselect',
586
+ label: 'roster choice',
587
+ message: `${question.message} (${question.total} pairs across ${groups.length} agent apps; `
588
+ + 'a pair you leave out is recorded as declined and is not asked again)',
589
+ options,
590
+ initialValues: (question.preselected ?? []).map(pairValue),
591
+ required: false,
592
+ };
593
+ }
594
+
595
+ function standardRoutePayload(question) {
596
+ const options = (question.options ?? []).map((pair) => ({
597
+ value: pairValue(pair),
598
+ label: pairText(pair),
599
+ hint: `${question.workload} work uses this pair when no evidence covers the intent`,
600
+ }));
601
+ options.push({
602
+ value: NO_STANDARD_ROUTE,
603
+ label: 'Leave unset',
604
+ hint: `no Standard route for ${question.workload} work — the class blocks instead of guessing`,
605
+ });
606
+ return {
607
+ control: 'select',
608
+ label: 'standard route choice',
609
+ message: question.message,
610
+ options,
611
+ initialValue: question.current ? pairValue(question.current) : NO_STANDARD_ROUTE,
612
+ maxItems: question.pageSize,
613
+ };
614
+ }
615
+
616
+ function activationPayload(question) {
617
+ return {
618
+ control: 'select',
619
+ label: 'activation choice',
620
+ message: `${question.message}\n${activationSummary(question)}`,
621
+ options: hintedOptions('activation', labelledOptions('activation', question.actions ?? [])),
622
+ };
623
+ }
624
+
625
+ /** What the activation review is actually reviewing. */
626
+ function activationSummary(question) {
627
+ return [
628
+ `agent apps: ${surfaceLabels(question.selectedSurfaces)}`,
629
+ `transports: ${describeTransports(question.authorizedTransports)}`,
630
+ `switching: ${describeSwitching(question.switching)}`,
631
+ `model roster: ${describeRoster(question.roster)}`,
632
+ `standard routes: ${describeStandardRoutes(question.standardRoutes)}`,
633
+ `advanced draft: ${describeDraft(question.advancedDraft)}`,
634
+ ].join('\n');
635
+ }
636
+
637
+ function describeTransports(transports) {
638
+ const rendered = (transports ?? []).map(({ surface, transport }) =>
639
+ `${sanitizeReadinessText(surfaceById(surface)?.label ?? surface) ?? surface} · ${transport}`);
640
+ return sanitizeReadinessText(rendered.join(', ')) || 'none';
641
+ }
642
+
643
+ function describeRoster(roster) {
644
+ if (!Array.isArray(roster) || !roster.length) return 'none';
645
+ const counted = ['admitted', 'declined', 'withdrawn']
646
+ .map((state) => [state, roster.filter((entry) => entry.state === state).length])
647
+ .filter(([, count]) => count > 0)
648
+ .map(([state, count]) => `${count} ${state}`);
649
+ return counted.join(' · ') || 'none';
650
+ }
651
+
652
+ function describeStandardRoutes(routes) {
653
+ if (!routes || typeof routes !== 'object') return 'none';
654
+ const rendered = Object.entries(routes).map(([workload, entry]) => {
655
+ if (!entry) return `${workload}: unset`;
656
+ return `${workload}: ${pairText(entry)}${entry.state === 'unresolved' ? ' (unresolved)' : ''}`;
657
+ });
658
+ return sanitizeReadinessText(rendered.join(' · ')) || 'none';
659
+ }
660
+
661
+ function surfaceLabels(ids) {
662
+ const labels = (ids ?? [])
663
+ .map((id) => sanitizeReadinessText(surfaceById(id)?.label ?? id))
664
+ .filter(Boolean);
665
+ return labels.join(', ') || 'none';
666
+ }
667
+
668
+ function describeSwitching(value) {
669
+ const name = sanitizeReadinessText(value) ?? 'unknown';
670
+ const hint = ROUTING_HINTS.autonomy[value];
671
+ return hint ? `${name} — ${hint}` : name;
672
+ }
673
+
674
+ function describeDraft(draft) {
675
+ if (!draft || typeof draft !== 'object') return 'none';
676
+ const rendered = Object.entries(draft)
677
+ .map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`)
678
+ .join(' · ');
679
+ return sanitizeReadinessText(rendered) ?? 'none';
680
+ }
681
+
682
+ function advancedPayload(question) {
683
+ return {
684
+ control: 'select',
685
+ label: 'advanced choice',
686
+ message: `${question.message} — kept as an optional note`,
687
+ options: hintedOptions('advanced', labelledOptions('advanced', ADVANCED_OPTIMIZATIONS)),
688
+ initialValue: question.draft?.optimization ?? 'balanced',
689
+ };
690
+ }
691
+
692
+ /** What the roster and the Standard routes still owe an answer on, if anything. */
693
+ function rosterWork(delta) {
694
+ const roster = delta.roster ?? {};
695
+ return ['pending', 'withdrawn', 'reopenable', 'unresolvedRoutes']
696
+ .some((key) => (roster[key] ?? []).length > 0);
697
+ }
698
+
699
+ function reconcilePayload(question) {
700
+ const delta = question.delta;
701
+ if (delta.type === 'missing-profile' || delta.type === 'invalid-profile') {
702
+ return {
703
+ control: 'select',
704
+ label: 'routing migration choice',
328
705
  message: question.message,
329
- options: [
330
- { value: 'balanced', label: 'Balanced' },
331
- { value: 'quality', label: 'Quality' },
332
- { value: 'cost', label: 'Cost' },
333
- ],
334
- initialValue: question.draft?.optimization ?? 'balanced',
335
- }), 'advanced choice');
336
- return { ...question.draft, optimization };
706
+ options: hintedOptions(
707
+ 'reconcile', labelledOptions('reconcile', RECONCILE_MIGRATION_ACTIONS),
708
+ ),
709
+ };
337
710
  }
338
- if (question.kind === 'reconcile') {
339
- if (question.delta.type === 'missing-profile' || question.delta.type === 'invalid-profile') {
340
- return ensurePrompt(await p.select({
341
- message: question.message,
342
- options: [
343
- { value: 'review', label: 'Review routing choices now' },
344
- { value: 'decline', label: 'Not now' },
345
- ],
346
- }), 'routing migration choice');
347
- }
348
- const additions = question.delta.newSurfaces;
349
- if (additions.length) {
350
- const removed = question.delta.removedSurfaces.map(({ label }) => label);
351
- const change = [
352
- `new: ${additions.map(({ label }) => label).join(', ')}`,
353
- ...(removed.length ? [`unavailable: ${removed.join(', ')}`] : []),
354
- ].join(' · ');
355
- const addSurfaceIds = ensurePrompt(await p.multiselect({
356
- message: `Routing choices changed — ${change}`,
357
- options: additions.map(({ id, label }) => ({ value: id, label })),
358
- initialValues: [],
359
- required: false,
360
- }), 'routing reconcile choice');
361
- return { action: 'apply', addSurfaceIds };
362
- }
363
- const removed = question.delta.removedSurfaces.map(({ label }) => label).join(', ');
364
- const message = removed
711
+ if (delta.newSurfaces.length) return reconcileAdditionsPayload(delta);
712
+ const removed = delta.removedSurfaces.map(({ label }) => label).join(', ');
713
+ if (!removed && rosterWork(delta)) return reconcileRosterPayload(question, delta);
714
+ return {
715
+ control: 'confirm',
716
+ label: 'routing reconcile choice',
717
+ message: removed
365
718
  ? `Remove unavailable agent app from routing: ${removed}?`
366
- : 'Refresh the routing profile registry revision?';
367
- return ensurePrompt(await p.confirm({ message }), 'routing reconcile choice')
368
- ? { action: 'apply', addSurfaceIds: [] }
369
- : { action: 'decline' };
719
+ : 'Refresh the routing profile registry revision?',
720
+ active: 'Apply the change to the stored routing profile',
721
+ inactive: 'Leave the stored routing profile as it is',
722
+ };
723
+ }
724
+
725
+ /** A roster-only change names what moved before it asks whether to review it. */
726
+ function reconcileRosterPayload(question, delta) {
727
+ const roster = delta.roster ?? {};
728
+ const change = [
729
+ ['new pairs', roster.pending], ['pairs no longer offered', roster.withdrawn],
730
+ ['pairs available again', roster.reopenable],
731
+ ].filter(([, pairs]) => (pairs ?? []).length)
732
+ .map(([label, pairs]) => `${label}: ${pairs.map(pairText).join(', ')}`);
733
+ for (const { workload } of roster.unresolvedRoutes ?? []) {
734
+ change.push(`${workload} has no authorized Standard route any more`);
735
+ }
736
+ return {
737
+ control: 'select',
738
+ label: 'routing roster choice',
739
+ // Sanitized per line: collapsing the whole block would flatten the list.
740
+ message: [question.message, ...change.map(sanitizeReadinessText).filter(Boolean)].join('\n'),
741
+ options: hintedOptions('reconcile', labelledOptions('reconcile', RECONCILE_MIGRATION_ACTIONS)),
742
+ };
743
+ }
744
+
745
+ function reconcileAdditionsPayload(delta) {
746
+ const removed = delta.removedSurfaces.map(({ label }) => label);
747
+ const change = [
748
+ `new: ${delta.newSurfaces.map(({ label }) => label).join(', ')}`,
749
+ ...(removed.length ? [`unavailable: ${removed.join(', ')}`] : []),
750
+ ].join(' · ');
751
+ return {
752
+ control: 'multiselect',
753
+ label: 'routing reconcile choice',
754
+ message: `Routing choices changed — ${change}`,
755
+ options: delta.newSurfaces.map(({ id, label }) => ({
756
+ value: id,
757
+ label,
758
+ hint: `newly available — select it to let the Kit route work to ${label}`,
759
+ })),
760
+ initialValues: [],
761
+ required: false,
762
+ };
763
+ }
764
+
765
+ async function promptRoutingProfile(question) {
766
+ const { control, label, ...payload } = routingPromptPayload(question);
767
+ const answer = ensurePrompt(await askRouting(control, payload), label);
768
+ return decodeRoutingAnswer(question, answer);
769
+ }
770
+
771
+ function askRouting(control, payload) {
772
+ if (control === 'multiselect') return p.multiselect(payload);
773
+ if (control === 'groupmultiselect') return p.groupMultiselect(payload);
774
+ if (control === 'confirm') return p.confirm(payload);
775
+ return p.select(payload);
776
+ }
777
+
778
+ /** Map prompt values back onto the very objects the question offered. */
779
+ function decodeSelected(offered, identity, answer) {
780
+ const known = new Map(offered.map((option) => [identity(option), option]));
781
+ return (Array.isArray(answer) ? answer : [answer])
782
+ .map((value) => known.get(value))
783
+ .filter(Boolean);
784
+ }
785
+
786
+ /** The answer shape `routingProfile` expects back, per question. */
787
+ function decodeRoutingAnswer(question, answer) {
788
+ if (question.kind === 'advanced') return { ...question.draft, optimization: answer };
789
+ if (question.kind === 'transports') {
790
+ return decodeSelected(question.options ?? [], transportValue, answer)
791
+ .map(({ surface, transport }) => ({ surface, transport }));
792
+ }
793
+ if (question.kind === 'roster') {
794
+ const pairs = (question.groups ?? []).flatMap(({ pairs: group }) => group);
795
+ return decodeSelected(pairs, pairValue, answer);
796
+ }
797
+ if (question.kind === 'standard-route') {
798
+ return decodeSelected(question.options ?? [], pairValue, answer)[0] ?? null;
799
+ }
800
+ if (question.kind !== 'reconcile') return answer;
801
+ const delta = question.delta;
802
+ if (delta.type === 'missing-profile' || delta.type === 'invalid-profile') return answer;
803
+ if (delta.newSurfaces.length) return { action: 'apply', addSurfaceIds: answer };
804
+ if (!delta.removedSurfaces.length && rosterWork(delta)) {
805
+ return answer === 'review' ? { action: 'apply', addSurfaceIds: [] } : { action: 'decline' };
370
806
  }
371
- throw new Error(`unknown routing profile question: ${question.kind}`);
807
+ return answer === true ? { action: 'apply', addSurfaceIds: [] } : { action: 'decline' };
372
808
  }
373
809
 
374
810
  function ensurePrompt(value, label) {