@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
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm } from 'node:fs/promises';
1
+ import { access, mkdir, open, readFile, rm } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
@@ -9,16 +9,51 @@ import {
9
9
  detectAgentSurfaces,
10
10
  surfaceById,
11
11
  } from './agentSurfaceRegistry.mjs';
12
+ import {
13
+ commitRoutingProfileGenerations,
14
+ readCommittedRoutingProfilePair,
15
+ resolveProjectIdentity,
16
+ } from './routingProfileStorage.mjs';
17
+ import { loadRoutingInventory, presentInventory } from './routingInventory.mjs';
12
18
 
13
- export const ROUTING_PROFILE_VERSION = 1;
19
+ export const ROUTING_PROFILE_VERSION = 2;
14
20
  export const ROUTING_PROFILE_PATH = 'routing-profile.json';
21
+ /** The workload classes a Standard route is nominated for — the resolver's vocabulary. */
22
+ export const STANDARD_ROUTE_CLASSES = Object.freeze(['mechanical', 'development', 'judgment']);
23
+ /**
24
+ * Per-pair roster state. `admitted` is the positive list a dispatch may pick
25
+ * from; `declined` is a user decision that must never prompt again; `withdrawn`
26
+ * is derived the moment the inventory no longer lists an admitted pair.
27
+ */
28
+ export const ROSTER_PAIR_STATES = Object.freeze(['admitted', 'declined', 'withdrawn']);
29
+ /** A Standard route is either nominated and authorized, or knowingly broken. */
30
+ export const STANDARD_ROUTE_STATES = Object.freeze(['configured', 'unresolved']);
15
31
 
32
+ /** Loosest first: a project narrowing may only move toward the end of this list. */
16
33
  const SWITCHING = Object.freeze(['automatic', 'ask', 'current-surface-only']);
17
34
  const ACTIVATION = Object.freeze(['approve', 'back', 'advanced', 'safe-current-surface', 'decline']);
35
+ /** The transport by which a surface drives its own runtime, as the registry names it. */
36
+ const NATIVE_TRANSPORT = 'native';
37
+ const SHARED_FIELDS = [
38
+ 'schemaVersion', 'registryRevision', 'selectedSurfaces', 'consideredSurfaces', 'switching',
39
+ 'advanced',
40
+ ];
18
41
  const PROFILE_FIELDS = new Set([
19
- 'schemaVersion', 'registryRevision', 'selectedSurfaces', 'consideredSurfaces',
20
- 'switching', 'advanced',
42
+ ...SHARED_FIELDS, 'authorizedTransports', 'roster', 'inventoryRevision', 'standardRoutes',
43
+ ]);
44
+ const PROFILE_FIELDS_V1 = new Set(SHARED_FIELDS);
45
+ /** A project document owns nothing; every field it may carry is a narrowing axis. */
46
+ const NARROWING_FIELDS = new Set([
47
+ 'schemaVersion', 'selectedSurfaces', 'authorizedTransports', 'switching', 'roster',
48
+ 'standardRoutes',
21
49
  ]);
50
+ const ADVANCED_FIELDS = new Set(['legacy']);
51
+ const PAIR_FIELDS = new Set(['model', 'effort']);
52
+ /** A pair plus the state it is recorded in — the shape of a roster entry and of a Standard route. */
53
+ const STATED_PAIR_FIELDS = new Set(['model', 'effort', 'state']);
54
+ const TRANSPORT_FIELDS = new Set(['surface', 'transport']);
55
+ /** A model id may carry a context variant (`opus[1m]`); the attested channel drops it. */
56
+ const CONTEXT_VARIANT = /\[[^\]]*\]$/;
22
57
 
23
58
  function uniqueStrings(value, field) {
24
59
  if (!Array.isArray(value) ||
@@ -28,15 +63,206 @@ function uniqueStrings(value, field) {
28
63
  return [...new Set(value)];
29
64
  }
30
65
 
31
- export function validateRoutingProfile(input) {
32
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
33
- throw new TypeError('routing profile must be an object');
34
- }
66
+ function assertFields(input, allowed, label) {
35
67
  for (const key of Object.keys(input)) {
36
- if (!PROFILE_FIELDS.has(key)) throw new TypeError(`unknown routing profile field: ${key}`);
68
+ if (!allowed.has(key)) throw new TypeError(`unknown ${label} field: ${key}`);
37
69
  }
38
- if (input.schemaVersion !== ROUTING_PROFILE_VERSION) {
39
- throw new TypeError(`routing profile schemaVersion must be ${ROUTING_PROFILE_VERSION}`);
70
+ }
71
+
72
+ function plainObject(value, field) {
73
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
74
+ throw new TypeError(`${field} must be an object`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ /** The roster identity rule: trim and drop the context variant. */
80
+ export function normalizeRosterModelId(value, field = 'model') {
81
+ if (typeof value !== 'string') throw new TypeError(`${field} must be a non-empty string`);
82
+ const model = value.trim().replace(CONTEXT_VARIANT, '').trim();
83
+ if (!model) throw new TypeError(`${field} must be a non-empty string`);
84
+ return model;
85
+ }
86
+
87
+ /**
88
+ * A model-and-effort pair. Effort domains are per model, so effort is always
89
+ * explicit: a value the model supports, or `null` for a model with no effort
90
+ * axis. A pair is authorized as a pair, never as a model with an effort
91
+ * attached afterwards.
92
+ */
93
+ function validatePair(input, field) {
94
+ plainObject(input, field);
95
+ assertFields(input, PAIR_FIELDS, field);
96
+ const model = normalizeRosterModelId(input.model, `${field}.model`);
97
+ if (!('effort' in input) || (input.effort !== null &&
98
+ (typeof input.effort !== 'string' || input.effort.trim() === ''))) {
99
+ throw new TypeError(`${field}.effort must be a non-empty string or null (no effort axis)`);
100
+ }
101
+ return { model, effort: input.effort === null ? null : input.effort.trim() };
102
+ }
103
+
104
+ function pairKey({ model, effort }) {
105
+ return `${model}\u0000${effort ?? ''}`;
106
+ }
107
+
108
+ /** The pair a diagnostic names, in the one form a human and a test both read. */
109
+ function pairLabel({ model, effort }) {
110
+ return `${model}/${effort ?? 'no-effort'}`;
111
+ }
112
+
113
+ const barePair = ({ model, effort }) => ({ model, effort });
114
+ const admittedPairs = (roster) => roster.filter(({ state }) => state === 'admitted').map(barePair);
115
+
116
+ /** A transport authorization is a `(surface, transport)` pair, never a bare transport. */
117
+ function validateTransport(input, field) {
118
+ plainObject(input, field);
119
+ assertFields(input, TRANSPORT_FIELDS, field);
120
+ for (const key of ['surface', 'transport']) {
121
+ if (typeof input[key] !== 'string' || input[key].trim() === '') {
122
+ throw new TypeError(`${field}.${key} must be a non-empty string`);
123
+ }
124
+ }
125
+ return { surface: input.surface.trim(), transport: input.transport.trim() };
126
+ }
127
+
128
+ const transportKey = ({ surface, transport }) => `${surface}\u0000${transport}`;
129
+
130
+ /**
131
+ * Selecting two agent apps does not authorize either to drive the other's CLI,
132
+ * so every authorized transport names the surface it belongs to and that surface
133
+ * must be one the document selected.
134
+ */
135
+ function validateTransports(value, field, surfaces) {
136
+ if (!Array.isArray(value)) {
137
+ throw new TypeError(`${field} must be an array of {surface, transport} pairs`);
138
+ }
139
+ const authorized = new Map();
140
+ value.forEach((input, index) => {
141
+ const transport = validateTransport(input, `${field}[${index}]`);
142
+ if (surfaces && !surfaces.includes(transport.surface)) {
143
+ throw new TypeError(
144
+ `${field}[${index}].surface must be a selected surface: ${transport.surface}`,
145
+ );
146
+ }
147
+ authorized.set(transportKey(transport), transport);
148
+ });
149
+ return [...authorized.values()];
150
+ }
151
+
152
+ function validateRosterEntry(input, field) {
153
+ plainObject(input, field);
154
+ assertFields(input, STATED_PAIR_FIELDS, field);
155
+ const pair = validatePair({ model: input.model, effort: input.effort }, field);
156
+ if (!ROSTER_PAIR_STATES.includes(input.state)) {
157
+ throw new TypeError(`${field}.state must be one of: ${ROSTER_PAIR_STATES.join(', ')}`);
158
+ }
159
+ return { ...pair, state: input.state };
160
+ }
161
+
162
+ /** The recorded roster: one state per pair. Two states for one pair is ambiguous. */
163
+ function validateRoster(value, field = 'roster') {
164
+ if (!Array.isArray(value)) throw new TypeError(`${field} must be an array of roster entries`);
165
+ const entries = new Map();
166
+ value.forEach((input, index) => {
167
+ const entry = validateRosterEntry(input, `${field}[${index}]`);
168
+ const prior = entries.get(pairKey(entry));
169
+ if (prior && prior.state !== entry.state) {
170
+ throw new TypeError(
171
+ `${field} records the same pair twice with different states: ${pairLabel(entry)}`,
172
+ );
173
+ }
174
+ entries.set(pairKey(entry), entry);
175
+ });
176
+ return [...entries.values()];
177
+ }
178
+
179
+ /** A narrowing does not decline a pair — it names the subset a project may use. */
180
+ function validateNarrowingRoster(value) {
181
+ if (!Array.isArray(value)) throw new TypeError('roster must be an array of pairs');
182
+ const pairs = new Map();
183
+ value.forEach((input, index) => {
184
+ const pair = validatePair(input, `roster[${index}]`);
185
+ pairs.set(pairKey(pair), pair);
186
+ });
187
+ return [...pairs.values()];
188
+ }
189
+
190
+ function validateStandardRouteEntry(input, field) {
191
+ plainObject(input, field);
192
+ assertFields(input, STATED_PAIR_FIELDS, field);
193
+ const pair = validatePair({ model: input.model, effort: input.effort }, field);
194
+ if (!STANDARD_ROUTE_STATES.includes(input.state)) {
195
+ throw new TypeError(`${field}.state must be one of: ${STANDARD_ROUTE_STATES.join(', ')}`);
196
+ }
197
+ return { ...pair, state: input.state };
198
+ }
199
+
200
+ /**
201
+ * The global document nominates every workload class explicitly. A `configured`
202
+ * route must name an admitted pair; an `unresolved` one is a knowingly broken
203
+ * route the profile keeps rather than a profile the Kit calls invalid.
204
+ */
205
+ function validateStandardRoutes(value, roster) {
206
+ plainObject(value, 'standardRoutes');
207
+ assertFields(value, new Set(STANDARD_ROUTE_CLASSES), 'standardRoutes');
208
+ const authorized = new Set(admittedPairs(roster).map(pairKey));
209
+ const routes = {};
210
+ for (const workload of STANDARD_ROUTE_CLASSES) {
211
+ if (!(workload in value)) {
212
+ throw new TypeError(
213
+ `standardRoutes must name every workload class: ${STANDARD_ROUTE_CLASSES.join(', ')}`,
214
+ );
215
+ }
216
+ if (value[workload] === null) {
217
+ routes[workload] = null;
218
+ continue;
219
+ }
220
+ const entry = validateStandardRouteEntry(value[workload], `standardRoutes.${workload}`);
221
+ if (entry.state === 'configured' && !authorized.has(pairKey(entry))) {
222
+ throw new TypeError(`standardRoutes.${workload} must name an admitted roster pair`);
223
+ }
224
+ routes[workload] = entry;
225
+ }
226
+ return routes;
227
+ }
228
+
229
+ /** A project overrides the classes it names and inherits the rest. */
230
+ function validateNarrowingStandardRoutes(value) {
231
+ plainObject(value, 'standardRoutes');
232
+ assertFields(value, new Set(STANDARD_ROUTE_CLASSES), 'standardRoutes');
233
+ const routes = {};
234
+ for (const workload of STANDARD_ROUTE_CLASSES) {
235
+ if (!(workload in value)) continue;
236
+ routes[workload] = value[workload] === null
237
+ ? null
238
+ : validateStandardRouteEntry(value[workload], `standardRoutes.${workload}`);
239
+ }
240
+ return routes;
241
+ }
242
+
243
+ /** The inventory revision the roster was reconciled against; never a profile revision. */
244
+ function validateInventoryRevision(value) {
245
+ if (value === null || value === undefined) return null;
246
+ if (typeof value !== 'string' || value.trim() === '') {
247
+ throw new TypeError('inventoryRevision must be a non-empty string or null');
248
+ }
249
+ return value;
250
+ }
251
+
252
+ /** v2 `advanced` carries preserved legacy evidence only — the optimization dial is gone. */
253
+ function validateAdvanced(value) {
254
+ if (value === null) return null;
255
+ plainObject(value, 'advanced');
256
+ assertFields(value, ADVANCED_FIELDS, 'advanced');
257
+ if (!('legacy' in value)) return null;
258
+ return { legacy: structuredClone(plainObject(value.legacy, 'advanced.legacy')) };
259
+ }
260
+
261
+ function validateSharedFields(input, fields, version) {
262
+ plainObject(input, 'routing profile');
263
+ assertFields(input, fields, 'routing profile');
264
+ if (input.schemaVersion !== version) {
265
+ throw new TypeError(`routing profile schemaVersion must be ${version}`);
40
266
  }
41
267
  if (!Number.isInteger(input.registryRevision) || input.registryRevision < 0) {
42
268
  throw new TypeError('routing profile registryRevision must be a non-negative integer');
@@ -53,25 +279,553 @@ export function validateRoutingProfile(input) {
53
279
  if (!SWITCHING.includes(input.switching)) {
54
280
  throw new TypeError(`switching must be one of: ${SWITCHING.join(', ')}`);
55
281
  }
56
- if (input.advanced !== null &&
57
- (!input.advanced || typeof input.advanced !== 'object' || Array.isArray(input.advanced))) {
58
- throw new TypeError('advanced must be an object or null');
59
- }
60
282
  return {
61
- schemaVersion: ROUTING_PROFILE_VERSION,
283
+ schemaVersion: version,
62
284
  registryRevision: input.registryRevision,
63
285
  selectedSurfaces,
64
286
  consideredSurfaces,
65
287
  switching: input.switching,
66
- advanced: input.advanced === null ? null : structuredClone(input.advanced),
67
288
  };
68
289
  }
69
290
 
70
- export function routingProfilePath(consumerRoot, profileRoot) {
71
- const root = profileRoot ??
291
+ /** The global document: the authorization every project narrowing is derived against. */
292
+ export function validateRoutingProfile(input) {
293
+ const shared = validateSharedFields(input, PROFILE_FIELDS, ROUTING_PROFILE_VERSION);
294
+ const roster = validateRoster(input.roster);
295
+ return {
296
+ ...shared,
297
+ authorizedTransports: validateTransports(
298
+ input.authorizedTransports, 'authorizedTransports', shared.selectedSurfaces,
299
+ ),
300
+ roster,
301
+ inventoryRevision: validateInventoryRevision(input.inventoryRevision),
302
+ standardRoutes: validateStandardRoutes(input.standardRoutes, roster),
303
+ advanced: validateAdvanced(input.advanced ?? null),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * The project document: every field is a narrowing axis and `null` means "this
309
+ * project narrows nothing here". A project owns no surfaces, no switching
310
+ * autonomy and no roster of its own — it only ever subtracts from the global
311
+ * authorization, which is why none of the global-only fields are accepted.
312
+ */
313
+ export function validateRoutingNarrowing(input) {
314
+ plainObject(input, 'routing narrowing');
315
+ assertFields(input, NARROWING_FIELDS, 'routing narrowing');
316
+ if (input.schemaVersion !== ROUTING_PROFILE_VERSION) {
317
+ throw new TypeError(`routing narrowing schemaVersion must be ${ROUTING_PROFILE_VERSION}`);
318
+ }
319
+ const selectedSurfaces = input.selectedSurfaces == null
320
+ ? null
321
+ : uniqueStrings(input.selectedSurfaces, 'selectedSurfaces');
322
+ if (selectedSurfaces && !selectedSurfaces.length) {
323
+ throw new TypeError('selectedSurfaces must not be empty (use null to narrow no surface)');
324
+ }
325
+ if (input.switching != null && !SWITCHING.includes(input.switching)) {
326
+ throw new TypeError(`switching must be one of: ${SWITCHING.join(', ')}`);
327
+ }
328
+ return {
329
+ schemaVersion: ROUTING_PROFILE_VERSION,
330
+ selectedSurfaces,
331
+ authorizedTransports: input.authorizedTransports == null
332
+ ? null
333
+ : validateTransports(input.authorizedTransports, 'authorizedTransports', selectedSurfaces),
334
+ switching: input.switching ?? null,
335
+ roster: input.roster == null ? null : validateNarrowingRoster(input.roster),
336
+ standardRoutes: input.standardRoutes == null
337
+ ? null
338
+ : validateNarrowingStandardRoutes(input.standardRoutes),
339
+ };
340
+ }
341
+
342
+ function validateRoutingProfileV1(input) {
343
+ const shared = validateSharedFields(input, PROFILE_FIELDS_V1, 1);
344
+ const advanced = input.advanced;
345
+ if (advanced !== null && (!advanced || typeof advanced !== 'object' || Array.isArray(advanced))) {
346
+ throw new TypeError('advanced must be an object or null');
347
+ }
348
+ return { ...shared, advanced: advanced === null ? null : structuredClone(advanced) };
349
+ }
350
+
351
+ function emptyStandardRoutes() {
352
+ return Object.fromEntries(STANDARD_ROUTE_CLASSES.map((workload) => [workload, null]));
353
+ }
354
+
355
+ /** Read one v1 model preference. Never throws: junk evidence is reported, not fatal. */
356
+ function readModelPreference(entry) {
357
+ try {
358
+ if (typeof entry === 'string') return { model: normalizeRosterModelId(entry), pair: null };
359
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && 'effort' in entry) {
360
+ return { model: null, pair: validatePair({ model: entry.model, effort: entry.effort }, 'p') };
361
+ }
362
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
363
+ return { model: normalizeRosterModelId(entry.model), pair: null };
364
+ }
365
+ } catch {
366
+ return { model: null, pair: null };
367
+ }
368
+ return { model: null, pair: null };
369
+ }
370
+
371
+ /**
372
+ * Reconcile v1 `advanced` explicitly: model preferences become roster pairs
373
+ * where the evidence authorizes a pair, the removed dial and every unreconciled
374
+ * preference are recorded, and the whole original object survives as legacy
375
+ * evidence. Nothing the user chose is dropped.
376
+ */
377
+ function reconcileV1Advanced(advanced) {
378
+ const notes = [];
379
+ const roster = [];
380
+ if (!advanced) return { roster, notes, preserved: null };
381
+ if ('optimization' in advanced) {
382
+ notes.push({ code: 'optimization-removed', value: advanced.optimization });
383
+ }
384
+ const preferences = Array.isArray(advanced.preferredModels) ? advanced.preferredModels : [];
385
+ preferences.forEach((entry, index) => {
386
+ const { model, pair } = readModelPreference(entry);
387
+ if (pair) {
388
+ roster.push({ ...pair, state: 'admitted' });
389
+ notes.push({ code: 'roster-pair-admitted', model: pair.model, effort: pair.effort });
390
+ } else if (model) {
391
+ notes.push({ code: 'model-preference-needs-effort', model });
392
+ } else {
393
+ notes.push({ code: 'model-preference-unreadable', index });
394
+ }
395
+ });
396
+ const preserved = Object.keys(advanced).length ? { legacy: structuredClone(advanced) } : null;
397
+ return { roster, notes, preserved };
398
+ }
399
+
400
+ function migrateRoutingProfileV1(document) {
401
+ const { roster, notes, preserved } = reconcileV1Advanced(document.advanced);
402
+ // v1 never asked a transport question. Reading the surfaces the user selected
403
+ // as "may drive its own runtime" preserves what v1 meant without inventing a
404
+ // cross-provider authorization, which stays the user's explicit decision.
405
+ const authorizedTransports = document.selectedSurfaces
406
+ .map((surface) => ({ surface, transport: NATIVE_TRANSPORT }));
407
+ notes.push({
408
+ code: 'transport-authorization-defaulted-to-native',
409
+ surfaces: [...document.selectedSurfaces],
410
+ });
411
+ const profile = validateRoutingProfile({
412
+ schemaVersion: ROUTING_PROFILE_VERSION,
413
+ registryRevision: document.registryRevision,
414
+ selectedSurfaces: document.selectedSurfaces,
415
+ consideredSurfaces: document.consideredSurfaces,
416
+ authorizedTransports,
417
+ switching: document.switching,
418
+ roster,
419
+ inventoryRevision: null,
420
+ standardRoutes: emptyStandardRoutes(),
421
+ advanced: preserved,
422
+ });
423
+ return {
424
+ profile,
425
+ migration: {
426
+ from: 1,
427
+ to: ROUTING_PROFILE_VERSION,
428
+ notes,
429
+ backup: structuredClone(document),
430
+ },
431
+ };
432
+ }
433
+
434
+ const DECODERS = new Map([
435
+ [1, (document) => migrateRoutingProfileV1(validateRoutingProfileV1(document))],
436
+ [2, (document) => ({ profile: validateRoutingProfile(document), migration: null })],
437
+ ]);
438
+
439
+ /**
440
+ * Version-aware decode: read `schemaVersion` first, decode with that version's
441
+ * own decoder, then migrate. An older document is never collapsed to invalid.
442
+ */
443
+ export function decodeRoutingProfile(document) {
444
+ plainObject(document, 'routing profile');
445
+ const decode = DECODERS.get(document.schemaVersion);
446
+ if (!decode) {
447
+ throw new TypeError(
448
+ `unsupported routing profile schemaVersion: ${JSON.stringify(document.schemaVersion)}`,
449
+ );
450
+ }
451
+ return decode(document);
452
+ }
453
+
454
+ /**
455
+ * Version-aware decode of a project narrowing. The two-level store is new, so
456
+ * there is exactly one narrowing version — an unknown one fails closed instead
457
+ * of being read as if it were the current shape.
458
+ */
459
+ export function decodeRoutingNarrowing(document) {
460
+ plainObject(document, 'routing narrowing');
461
+ if (document.schemaVersion !== ROUTING_PROFILE_VERSION) {
462
+ throw new TypeError(
463
+ `unsupported routing narrowing schemaVersion: ${JSON.stringify(document.schemaVersion)}`,
464
+ );
465
+ }
466
+ return { narrowing: validateRoutingNarrowing(document) };
467
+ }
468
+
469
+ const strictness = (switching) => SWITCHING.indexOf(switching);
470
+
471
+ /**
472
+ * Every way a project document could widen the global authorization, named. A
473
+ * narrowing may subtract from a set and move switching toward stricter — nothing
474
+ * else. This is an authorization check only: whether a pair can actually be
475
+ * reached is decided at dispatch time against the Access graph, not here.
476
+ */
477
+ export function narrowingViolations(globalDocument, narrowingDocument) {
478
+ const authorization = validateRoutingProfile(globalDocument);
479
+ const narrowing = validateRoutingNarrowing(narrowingDocument);
480
+ const violations = [];
481
+ const surfaces = new Set(authorization.selectedSurfaces);
482
+ for (const surface of narrowing.selectedSurfaces ?? []) {
483
+ if (!surfaces.has(surface)) violations.push({ code: 'surface-not-authorized', surface });
484
+ }
485
+ const transports = new Set(authorization.authorizedTransports.map(transportKey));
486
+ for (const transport of narrowing.authorizedTransports ?? []) {
487
+ if (!transports.has(transportKey(transport))) {
488
+ violations.push({ code: 'transport-not-authorized', ...transport });
489
+ }
490
+ }
491
+ const admitted = new Set(admittedPairs(authorization.roster).map(pairKey));
492
+ for (const pair of narrowing.roster ?? []) {
493
+ if (!admitted.has(pairKey(pair))) violations.push({ code: 'pair-not-authorized', ...pair });
494
+ }
495
+ if (narrowing.switching && strictness(narrowing.switching) < strictness(authorization.switching)) {
496
+ violations.push({
497
+ code: 'switching-loosened', from: authorization.switching, to: narrowing.switching,
498
+ });
499
+ }
500
+ // A replacement route must sit inside the roster this narrowing leaves behind,
501
+ // not merely inside the global one.
502
+ const effective = narrowing.roster
503
+ ? new Set(narrowing.roster.map(pairKey).filter((key) => admitted.has(key)))
504
+ : admitted;
505
+ for (const [workload, entry] of Object.entries(narrowing.standardRoutes ?? {})) {
506
+ if (entry && !effective.has(pairKey(entry))) {
507
+ violations.push({
508
+ code: 'standard-route-not-in-effective-roster',
509
+ workload, model: entry.model, effort: entry.effort,
510
+ });
511
+ }
512
+ }
513
+ return violations;
514
+ }
515
+
516
+ /** One line per violation, so a rejection names what it refused and why. */
517
+ function describeNarrowingViolations(violations) {
518
+ return violations.map((violation) => {
519
+ if (violation.code === 'surface-not-authorized') return `${violation.code}:${violation.surface}`;
520
+ if (violation.code === 'transport-not-authorized') {
521
+ return `${violation.code}:${transportKey(violation).replace('\u0000', '/')}`;
522
+ }
523
+ if (violation.code === 'switching-loosened') {
524
+ return `${violation.code}:${violation.from}->${violation.to}`;
525
+ }
526
+ if (violation.code === 'standard-route-not-in-effective-roster') {
527
+ return `${violation.code}:${violation.workload}=${pairLabel(violation)}`;
528
+ }
529
+ return `${violation.code}:${pairLabel(violation)}`;
530
+ }).join(', ');
531
+ }
532
+
533
+ /**
534
+ * The pinned inventory the roster is reconciled against. An explicit
535
+ * `inventory` — including an explicit `null` — wins; otherwise the shipped
536
+ * snapshots are read, and an unreadable snapshot yields no inventory plus the
537
+ * reason, rather than failing an update that has nothing to do with the roster.
538
+ */
539
+ async function resolveInventory(options) {
540
+ if (options.inventory !== undefined) return { inventory: options.inventory, error: null };
541
+ try {
542
+ return { inventory: await (options.loadInventory ?? loadRoutingInventory)(), error: null };
543
+ } catch (error) {
544
+ return { inventory: null, error: error.message };
545
+ }
546
+ }
547
+
548
+ /** The pairs an inventory knows, keyed by pair identity after normalization. */
549
+ function knownInventoryPairs(inventory) {
550
+ const known = new Map();
551
+ for (const candidate of inventory.pairs) {
552
+ const pair = {
553
+ model: normalizeRosterModelId(candidate.modelId, 'inventory modelId'),
554
+ effort: candidate.effort ?? null,
555
+ };
556
+ if (!known.has(pairKey(pair))) {
557
+ known.set(pairKey(pair), { ...pair, surface: candidate.surface, provider: candidate.provider });
558
+ }
559
+ }
560
+ return known;
561
+ }
562
+
563
+ /**
564
+ * The roster state machine, run against one recorded inventory revision.
565
+ *
566
+ * Transitions: a pair the inventory adds and the roster does not record is
567
+ * `pending` and the interview asks about it once; an `admitted` pair the
568
+ * inventory no longer lists is `withdrawn` immediately, derived rather than
569
+ * waiting for a write; a `declined` pair keeps its decline even when the pair
570
+ * disappears, so a user decision is never re-asked when it comes back; and a
571
+ * `withdrawn` pair the inventory lists again is `reopenable` — offered, never
572
+ * silently re-admitted.
573
+ */
574
+ export function reconcileRosterState({ roster = [], inventoryRevision = null, inventory }) {
575
+ if (!inventory || typeof inventory.revision !== 'string' || !Array.isArray(inventory.pairs)) {
576
+ throw new TypeError('a roster reconcile needs a loaded inventory with a revision and pairs');
577
+ }
578
+ const known = knownInventoryPairs(inventory);
579
+ const recorded = new Set();
580
+ const entries = [];
581
+ const newlyWithdrawn = [];
582
+ for (const entry of validateRoster(roster)) {
583
+ const withdrawn = entry.state === 'admitted' && !known.has(pairKey(entry));
584
+ if (withdrawn) newlyWithdrawn.push(barePair(entry));
585
+ entries.push(withdrawn ? { ...entry, state: 'withdrawn' } : entry);
586
+ recorded.add(pairKey(entry));
587
+ }
588
+ const inState = (state) => entries.filter((entry) => entry.state === state).map(barePair);
589
+ const withdrawn = inState('withdrawn');
590
+ return Object.freeze({
591
+ inventoryRevision: inventory.revision,
592
+ recordedInventoryRevision: inventoryRevision,
593
+ stale: inventoryRevision !== inventory.revision,
594
+ entries: Object.freeze(entries),
595
+ admitted: Object.freeze(inState('admitted')),
596
+ declined: Object.freeze(inState('declined')),
597
+ withdrawn: Object.freeze(withdrawn),
598
+ newlyWithdrawn: Object.freeze(newlyWithdrawn),
599
+ reopenable: Object.freeze(withdrawn.filter((pair) => known.has(pairKey(pair)))),
600
+ pending: Object.freeze([...known.values()]
601
+ .filter((pair) => !recorded.has(pairKey(pair))).map(barePair)),
602
+ known: Object.freeze([...known.values()]),
603
+ });
604
+ }
605
+
606
+ /** Intersect one narrowing axis with its authorization, naming what fell out. */
607
+ function intersectAxis(authorized, narrowed, { axis, key, label }, notes) {
608
+ if (!narrowed) return [...authorized];
609
+ const available = new Map(authorized.map((value) => [key(value), value]));
610
+ const wanted = new Set(narrowed.map(key));
611
+ for (const value of narrowed) {
612
+ if (!available.has(key(value))) {
613
+ notes.push({ code: 'narrowing-dropped-by-global-contraction', axis, value: label(value) });
614
+ }
615
+ }
616
+ return authorized.filter((value) => wanted.has(key(value)));
617
+ }
618
+
619
+ function composeStandardRoutes(authorization, narrowing, authorized, notes) {
620
+ const routes = {};
621
+ const blocked = [];
622
+ for (const workload of STANDARD_ROUTE_CLASSES) {
623
+ const override = narrowing?.standardRoutes && workload in narrowing.standardRoutes;
624
+ const entry = override ? narrowing.standardRoutes[workload] : authorization.standardRoutes[workload];
625
+ if (!entry) {
626
+ routes[workload] = null;
627
+ blocked.push({ workload, reason: 'standard-route-missing' });
628
+ continue;
629
+ }
630
+ if (entry.state === 'unresolved' || !authorized.has(pairKey(entry))) {
631
+ if (entry.state !== 'unresolved') {
632
+ notes.push({
633
+ code: 'standard-route-derived-unresolved',
634
+ workload, model: entry.model, effort: entry.effort,
635
+ });
636
+ }
637
+ routes[workload] = { ...barePair(entry), state: 'unresolved' };
638
+ blocked.push({ workload, reason: 'standard-route-unresolved' });
639
+ continue;
640
+ }
641
+ routes[workload] = { ...barePair(entry), state: 'configured' };
642
+ }
643
+ return { routes, blocked };
644
+ }
645
+
646
+ /**
647
+ * Compose the global authorization with this project's narrowing: intersection
648
+ * for surfaces, transports and roster, strictest-wins for switching.
649
+ *
650
+ * Composition answers exactly one question — what is authorized. It never
651
+ * consults the Access graph, so an unreachable or untested pair still composes;
652
+ * executability is decided at dispatch time. A global contraction therefore
653
+ * never invalidates an older narrowing: the elements it lost are dropped with a
654
+ * note, and a Standard route that named one of them is derived `unresolved` and
655
+ * blocks its workload class. The Kit picks no replacement.
656
+ */
657
+ export function composeRoutingProfile({ global, project = null, inventory = null }) {
658
+ const authorization = validateRoutingProfile(global);
659
+ const narrowing = project ? validateRoutingNarrowing(project) : null;
660
+ const notes = [];
661
+ const rosterState = inventory
662
+ ? reconcileRosterState({
663
+ roster: authorization.roster,
664
+ inventoryRevision: authorization.inventoryRevision,
665
+ inventory,
666
+ })
667
+ : null;
668
+ const admitted = rosterState ? [...rosterState.admitted] : admittedPairs(authorization.roster);
669
+ const selectedSurfaces = intersectAxis(authorization.selectedSurfaces, narrowing?.selectedSurfaces,
670
+ { axis: 'surface', key: (id) => id, label: (id) => id }, notes);
671
+ const authorizedTransports = intersectAxis(
672
+ authorization.authorizedTransports, narrowing?.authorizedTransports,
673
+ { axis: 'transport', key: transportKey, label: ({ surface, transport }) => `${surface}/${transport}` },
674
+ notes,
675
+ ).filter(({ surface }) => selectedSurfaces.includes(surface));
676
+ const roster = intersectAxis(admitted, narrowing?.roster,
677
+ { axis: 'pair', key: pairKey, label: pairLabel }, notes);
678
+ const { routes, blocked } = composeStandardRoutes(
679
+ authorization, narrowing, new Set(roster.map(pairKey)), notes,
680
+ );
681
+ return Object.freeze({
682
+ selectedSurfaces: Object.freeze(selectedSurfaces),
683
+ authorizedTransports: Object.freeze(authorizedTransports),
684
+ switching: strictness(narrowing?.switching) > strictness(authorization.switching)
685
+ ? narrowing.switching
686
+ : authorization.switching,
687
+ roster: Object.freeze(roster),
688
+ standardRoutes: Object.freeze(routes),
689
+ inventoryRevision: authorization.inventoryRevision,
690
+ rosterState,
691
+ blocked: Object.freeze(blocked),
692
+ notes: Object.freeze(notes),
693
+ });
694
+ }
695
+
696
+ function stateRoot(profileRoot) {
697
+ return profileRoot ??
72
698
  join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'agent-workflow-kit');
699
+ }
700
+
701
+ export function routingProfilePath(consumerRoot, profileRoot) {
73
702
  const consumerKey = createHash('sha256').update(resolve(consumerRoot)).digest('hex').slice(0, 20);
74
- return join(root, 'profiles', consumerKey, ROUTING_PROFILE_PATH);
703
+ return join(stateRoot(profileRoot), 'profiles', consumerKey, ROUTING_PROFILE_PATH);
704
+ }
705
+
706
+ /** Where the two-level store keeps its generations: one global, one per project key. */
707
+ export function routingProfileStorageRoot(profileRoot) {
708
+ return join(stateRoot(profileRoot), 'routing');
709
+ }
710
+
711
+ async function projectIdentity({ identity, projectRoot, runGit }) {
712
+ if (identity) return identity;
713
+ return resolveProjectIdentity({ projectRoot, runGit });
714
+ }
715
+
716
+ function globalGeneration(envelope) {
717
+ if (!envelope) return null;
718
+ const { profile, migration } = decodeRoutingProfile(envelope.document);
719
+ return {
720
+ generation: envelope.generation,
721
+ committedAt: envelope.committedAt,
722
+ profile,
723
+ migration,
724
+ };
725
+ }
726
+
727
+ function projectGeneration(envelope) {
728
+ if (!envelope) return null;
729
+ return {
730
+ generation: envelope.generation,
731
+ committedAt: envelope.committedAt,
732
+ authoredAgainstGlobalGeneration: envelope.authoredAgainstGlobalGeneration ?? null,
733
+ narrowing: decodeRoutingNarrowing(envelope.document).narrowing,
734
+ };
735
+ }
736
+
737
+ /**
738
+ * Read the latest committed global generation plus this project's own narrowing
739
+ * and compose them, so a global choice made after the narrowing is never
740
+ * invisible to the project. The generation a narrowing was authored against is
741
+ * reported for diagnostics, never used as the read key, and a project without a
742
+ * narrowing is a normal, safe state rather than an error.
743
+ */
744
+ export async function readComposedRoutingProfile(options) {
745
+ const { profileRoot, projectRoot, identity, runGit } = options;
746
+ const resolved = await projectIdentity({ identity, projectRoot, runGit });
747
+ const pair = await readCommittedRoutingProfilePair({
748
+ root: routingProfileStorageRoot(profileRoot),
749
+ projectKey: resolved.key,
750
+ });
751
+ const reasons = [];
752
+ if (!pair.global) reasons.push('no-global-authorization');
753
+ if (!pair.project) reasons.push('no-project-narrowing');
754
+ const global = globalGeneration(pair.global);
755
+ const project = projectGeneration(pair.project);
756
+ const { inventory } = await resolveInventory(options);
757
+ return {
758
+ identity: resolved,
759
+ global,
760
+ project,
761
+ composed: global
762
+ ? composeRoutingProfile({
763
+ global: global.profile, project: project?.narrowing ?? null, inventory,
764
+ })
765
+ : null,
766
+ reasons,
767
+ pendingTransactionId: pair.pendingTransactionId,
768
+ };
769
+ }
770
+
771
+ /** The authorization a project narrowing is derived against: the pair being committed, else the store's. */
772
+ async function authorizingGlobal(root, projectKey, globalDocument) {
773
+ if (globalDocument) return { authorization: globalDocument, generation: undefined };
774
+ const committed = await readCommittedRoutingProfilePair({ root, projectKey });
775
+ if (!committed.global) {
776
+ throw new Error('a project narrowing needs a committed global authorization: '
777
+ + 'no-global-authorization');
778
+ }
779
+ return {
780
+ authorization: decodeRoutingProfile(committed.global.document).profile,
781
+ generation: committed.global.generation,
782
+ };
783
+ }
784
+
785
+ /**
786
+ * Commit the global authorization, the project narrowing, or both as one
787
+ * transaction. Each document is validated against its own schema before it
788
+ * reaches the store — the envelope carries the generation, the documents never
789
+ * do — and a project document that would widen the authorization it narrows is
790
+ * refused here, naming every axis it widened.
791
+ */
792
+ export async function commitRoutingProfilePair({
793
+ profileRoot, projectRoot, identity, runGit, global = null, project = null,
794
+ expectedGlobalGeneration, expectedProjectGeneration, now,
795
+ }) {
796
+ const resolved = await projectIdentity({ identity, projectRoot, runGit });
797
+ const root = routingProfileStorageRoot(profileRoot);
798
+ const globalDocument = global ? validateRoutingProfile(global) : null;
799
+ const projectDocument = project ? validateRoutingNarrowing(project) : null;
800
+ let expectedGlobal = expectedGlobalGeneration;
801
+ if (projectDocument) {
802
+ const { authorization, generation } = await authorizingGlobal(
803
+ root, resolved.key, globalDocument,
804
+ );
805
+ const violations = narrowingViolations(authorization, projectDocument);
806
+ if (violations.length) {
807
+ throw new Error('project narrowing widens the global authorization: '
808
+ + describeNarrowingViolations(violations));
809
+ }
810
+ // The narrowing was checked against the generation just read: bind the commit
811
+ // to it so a global that moved in between fails instead of racing.
812
+ if (expectedGlobal === undefined && generation !== undefined) expectedGlobal = generation;
813
+ }
814
+ return commitRoutingProfileGenerations({
815
+ root,
816
+ identity: resolved,
817
+ globalDocument,
818
+ projectDocument,
819
+ expectedGlobalGeneration: expectedGlobal,
820
+ expectedProjectGeneration,
821
+ now,
822
+ });
823
+ }
824
+
825
+ /** Where the pre-migration document is preserved before a migrated profile is written. */
826
+ export function routingProfileBackupPath(consumerRoot, profileRoot, fromVersion) {
827
+ const path = routingProfilePath(consumerRoot, profileRoot);
828
+ return join(dirname(path), `routing-profile.v${fromVersion}.backup.json`);
75
829
  }
76
830
 
77
831
  async function readProfileSnapshot(consumerRoot, profileRoot) {
@@ -80,12 +834,15 @@ async function readProfileSnapshot(consumerRoot, profileRoot) {
80
834
  const bytes = await readFile(path, 'utf8');
81
835
  const fingerprint = createHash('sha256').update(bytes).digest('hex');
82
836
  try {
83
- return { profile: validateRoutingProfile(JSON.parse(bytes)), fingerprint, invalid: false };
837
+ const { profile, migration } = decodeRoutingProfile(JSON.parse(bytes));
838
+ return { profile, migration, bytes, fingerprint, invalid: false };
84
839
  } catch {
85
- return { profile: null, fingerprint, invalid: true };
840
+ return { profile: null, migration: null, bytes, fingerprint, invalid: true };
86
841
  }
87
842
  } catch (error) {
88
- if (error.code === 'ENOENT') return { profile: null, fingerprint: null, invalid: false };
843
+ if (error.code === 'ENOENT') {
844
+ return { profile: null, migration: null, bytes: null, fingerprint: null, invalid: false };
845
+ }
89
846
  throw error;
90
847
  }
91
848
  }
@@ -102,55 +859,102 @@ async function resolvedDetectedSurfaceIds(options) {
102
859
  return detected.filter((surface) => surface.detected).map((surface) => surface.id);
103
860
  }
104
861
 
105
- export async function inspectRoutingProfile(options) {
106
- const { consumerRoot, registry = AGENT_SURFACE_REGISTRY } = options;
107
- const detectedSurfaceIds = await resolvedDetectedSurfaceIds(options);
108
- const snapshot = await readProfileSnapshot(consumerRoot, options.profileRoot);
109
- const { profile, fingerprint } = snapshot;
110
- if (snapshot.invalid) {
862
+ /**
863
+ * What the roster and the Standard routes still owe the user, and nothing more.
864
+ *
865
+ * A pair the roster never recorded, an admitted pair the inventory dropped, and
866
+ * a withdrawn pair that came back each ask exactly one question. A bare
867
+ * inventory revision bump with no pair delta asks nothing — it rides along with
868
+ * the next authorized write, which is what keeps this from becoming the endless
869
+ * reconcile prompt the plan set out to remove. A Standard route already stored
870
+ * `unresolved` is a settled state and stays silent too; only a stored
871
+ * `configured` route whose pair is no longer authorized is a new fact.
872
+ */
873
+ function rosterDelta(profile, inventory, inventoryError) {
874
+ if (!inventory) {
111
875
  return {
112
- status: 'needs-reconcile',
113
- reasons: ['invalid'],
114
- delta: { type: 'invalid-profile' },
115
- profile: null,
116
- fingerprint,
117
- detectedSurfaceIds,
118
- };
119
- }
120
- if (!profile) {
121
- return {
122
- status: 'needs-reconcile',
123
- reasons: ['missing'],
124
- delta: { type: 'missing-profile' },
125
- profile: null,
126
- fingerprint,
127
- detectedSurfaceIds,
876
+ reasons: inventoryError ? ['roster-inventory-unreadable'] : [],
877
+ state: null,
878
+ delta: { inventoryUnreadable: Boolean(inventoryError) },
879
+ blocked: [],
128
880
  };
129
881
  }
882
+ const state = reconcileRosterState({
883
+ roster: profile.roster, inventoryRevision: profile.inventoryRevision, inventory,
884
+ });
885
+ const composed = composeRoutingProfile({ global: profile, inventory });
886
+ const unresolved = composed.notes.filter(({ code }) => code === 'standard-route-derived-unresolved');
887
+ const reasons = [];
888
+ if (state.pending.length) reasons.push('roster-pairs-unrecorded');
889
+ if (state.newlyWithdrawn.length) reasons.push('roster-pair-withdrawn');
890
+ if (state.reopenable.length) reasons.push('roster-pair-reopenable');
891
+ if (unresolved.length) reasons.push('standard-route-unresolved');
892
+ return {
893
+ reasons,
894
+ state,
895
+ blocked: composed.blocked,
896
+ delta: {
897
+ inventoryRevision: { from: state.recordedInventoryRevision, to: state.inventoryRevision },
898
+ pending: [...state.pending],
899
+ withdrawn: [...state.newlyWithdrawn],
900
+ reopenable: [...state.reopenable],
901
+ unresolvedRoutes: unresolved.map(({ workload, model, effort }) => ({ workload, model, effort })),
902
+ inventoryUnreadable: false,
903
+ },
904
+ };
905
+ }
906
+
907
+ /** Which surfaces the registry gained or lost since the profile was written. */
908
+ function surfaceDelta(profile, registry, detectedSurfaceIds) {
130
909
  const known = new Set(registry.map(({ id }) => id));
131
910
  const considered = new Set(profile.consideredSurfaces);
132
- const reasons = [];
133
911
  const removedSurfaceIds = profile.selectedSurfaces.filter((id) => !known.has(id));
134
912
  const newSurfaceIds = detectedSurfaceIds.filter((id) => known.has(id) && !considered.has(id));
913
+ const reasons = [];
135
914
  if (profile.registryRevision < AGENT_SURFACE_REGISTRY_REVISION) reasons.push('materially-stale');
136
915
  if (removedSurfaceIds.length) reasons.push('removed-route');
137
916
  if (newSurfaceIds.length) reasons.push('new-meaningful-surface');
138
917
  return {
139
- status: reasons.length ? 'needs-reconcile' : 'still valid',
140
918
  reasons,
141
919
  delta: {
142
- type: 'profile-delta',
143
- registryRevision: {
144
- from: profile.registryRevision,
145
- to: AGENT_SURFACE_REGISTRY_REVISION,
146
- },
920
+ registryRevision: { from: profile.registryRevision, to: AGENT_SURFACE_REGISTRY_REVISION },
147
921
  removedSurfaces: removedSurfaceIds.map((id) => ({ id, label: id })),
148
- newSurfaces: newSurfaceIds.map((id) => {
149
- const surface = surfaceById(id, registry);
150
- return { id, label: surface.label };
151
- }),
922
+ newSurfaces: newSurfaceIds.map((id) => ({ id, label: surfaceById(id, registry).label })),
152
923
  },
924
+ };
925
+ }
926
+
927
+ export async function inspectRoutingProfile(options) {
928
+ const { consumerRoot, registry = AGENT_SURFACE_REGISTRY } = options;
929
+ const detectedSurfaceIds = await resolvedDetectedSurfaceIds(options);
930
+ const snapshot = await readProfileSnapshot(consumerRoot, options.profileRoot);
931
+ const { profile, fingerprint, migration } = snapshot;
932
+ const unusable = snapshot.invalid ? 'invalid' : (profile ? null : 'missing');
933
+ if (unusable) {
934
+ return {
935
+ status: 'needs-reconcile',
936
+ reasons: [unusable],
937
+ delta: { type: `${unusable}-profile` },
938
+ profile: null,
939
+ migration: null,
940
+ rosterState: null,
941
+ fingerprint,
942
+ detectedSurfaceIds,
943
+ };
944
+ }
945
+ const surfaces = surfaceDelta(profile, registry, detectedSurfaceIds);
946
+ const { inventory, error } = await resolveInventory(options);
947
+ const roster = rosterDelta(profile, inventory, error);
948
+ const reasons = [...surfaces.reasons, ...roster.reasons];
949
+ return {
950
+ status: reasons.length ? 'needs-reconcile' : 'still valid',
951
+ reasons,
952
+ delta: { type: 'profile-delta', ...surfaces.delta, roster: roster.delta },
153
953
  profile,
954
+ migration,
955
+ rosterState: roster.state,
956
+ blocked: roster.blocked,
957
+ inventory,
154
958
  fingerprint,
155
959
  detectedSurfaceIds,
156
960
  };
@@ -177,12 +981,263 @@ function autonomyQuestion() {
177
981
  };
178
982
  }
179
983
 
984
+ /** Every `(surface, transport)` the selected apps declare, native first. */
985
+ function transportChoices(selectedSurfaces, registry) {
986
+ return selectedSurfaces.flatMap((id) => {
987
+ const surface = surfaceById(id, registry);
988
+ if (!surface) return [];
989
+ return [...surface.adapter.transports]
990
+ .sort((left, right) => Number(right === NATIVE_TRANSPORT) - Number(left === NATIVE_TRANSPORT))
991
+ .map((transport) => ({
992
+ surface: id,
993
+ surfaceLabel: surface.label,
994
+ transport,
995
+ native: transport === NATIVE_TRANSPORT,
996
+ }));
997
+ });
998
+ }
999
+
1000
+ function transportQuestion(choices) {
1001
+ return {
1002
+ kind: 'transports',
1003
+ message: 'Which runtime may each agent app drive?',
1004
+ options: choices,
1005
+ preselected: choices.filter(({ native }) => native)
1006
+ .map(({ surface, transport }) => ({ surface, transport })),
1007
+ };
1008
+ }
1009
+
1010
+ /** How many entries a long list shows at once before it scrolls. */
1011
+ const ROSTER_PAGE_SIZE = 10;
1012
+
1013
+ /**
1014
+ * The roster question: the inventory grouped per agent app, detected apps first,
1015
+ * with the pair count and a page size, so a list that grows to dozens of pairs
1016
+ * stays navigable instead of scrolling off the screen.
1017
+ */
1018
+ function rosterQuestion({ inventory, detectedSurfaceIds, registry }, selectable, message) {
1019
+ const wanted = new Set(selectable.map(pairKey));
1020
+ const seen = new Set();
1021
+ const groups = [];
1022
+ for (const candidate of presentInventory(inventory, detectedSurfaceIds).pairs) {
1023
+ const pair = {
1024
+ model: normalizeRosterModelId(candidate.modelId, 'inventory modelId'),
1025
+ effort: candidate.effort ?? null,
1026
+ };
1027
+ if (!wanted.has(pairKey(pair)) || seen.has(pairKey(pair))) continue;
1028
+ seen.add(pairKey(pair));
1029
+ const label = surfaceById(candidate.surface, registry)?.label ?? candidate.surface;
1030
+ let group = groups.find(({ surface }) => surface === candidate.surface);
1031
+ if (!group) {
1032
+ group = {
1033
+ surface: candidate.surface,
1034
+ label,
1035
+ detected: detectedSurfaceIds.includes(candidate.surface),
1036
+ pairs: [],
1037
+ };
1038
+ groups.push(group);
1039
+ }
1040
+ group.pairs.push(pair);
1041
+ }
1042
+ return { kind: 'roster', message, groups, total: seen.size, preselected: [] };
1043
+ }
1044
+
1045
+ function standardRouteQuestion(workload, admitted, current) {
1046
+ return {
1047
+ kind: 'standard-route',
1048
+ workload,
1049
+ message: `Which pair decides ${workload} work when no evidence covers it?`,
1050
+ options: admitted,
1051
+ current: current ?? null,
1052
+ pageSize: ROSTER_PAGE_SIZE,
1053
+ };
1054
+ }
1055
+
1056
+ /**
1057
+ * The interview, in order, with what the user sees at each stage. The loop below
1058
+ * is driven by this table, so the sequence a reader finds here is the sequence
1059
+ * that runs.
1060
+ */
1061
+ export const ROUTING_INTERVIEW_SEQUENCE = Object.freeze([
1062
+ Object.freeze({
1063
+ id: 'surfaces',
1064
+ kinds: Object.freeze(['surfaces']),
1065
+ asks: 'which agent apps you use',
1066
+ shows: 'every registered app, the detected entries preselected, one hint per app',
1067
+ }),
1068
+ Object.freeze({
1069
+ id: 'transports',
1070
+ kinds: Object.freeze(['transports']),
1071
+ asks: 'which runtime each selected app may drive',
1072
+ shows: 'one entry per app and transport, native preselected, cross-app CLI control spelled out',
1073
+ skippedWhen: 'no selected app declares a transport',
1074
+ }),
1075
+ Object.freeze({
1076
+ id: 'switching',
1077
+ kinds: Object.freeze(['autonomy']),
1078
+ asks: 'whether the Kit may move a task to another app',
1079
+ shows: 'the three switching modes with what each one does',
1080
+ skippedWhen: 'a single app is selected — there is nothing to switch to',
1081
+ }),
1082
+ Object.freeze({
1083
+ id: 'roster',
1084
+ kinds: Object.freeze(['roster']),
1085
+ asks: 'which model-and-effort pairs the Kit may pick from',
1086
+ shows: 'the pinned inventory grouped per app, detected apps first, with the pair count, '
1087
+ + 'a page size, and the warning that an unselected pair is recorded as declined',
1088
+ skippedWhen: 'the pinned inventory is unreadable or has nothing left to decide',
1089
+ }),
1090
+ Object.freeze({
1091
+ id: 'standardRoutes',
1092
+ kinds: Object.freeze(['standard-route']),
1093
+ asks: 'which admitted pair decides a workload class without decisive evidence',
1094
+ shows: 'one question per workload class over the admitted pairs, each answerable with none',
1095
+ skippedWhen: 'no pair is admitted, so there is nothing to nominate',
1096
+ }),
1097
+ Object.freeze({
1098
+ id: 'activation',
1099
+ kinds: Object.freeze(['activation', 'advanced']),
1100
+ asks: 'whether to store the reviewed profile',
1101
+ shows: 'the whole draft — apps, transports, switching, roster counts, Standard routes, '
1102
+ + 'advanced draft — plus what each action does',
1103
+ }),
1104
+ ]);
1105
+
1106
+ async function askSurfaces(context, draft) {
1107
+ const selected = uniqueStrings(
1108
+ await context.prompt(surfaceQuestion(context.registry, context.preselected)),
1109
+ 'selected surfaces',
1110
+ ).filter((id) => context.knownSurfaces.has(id));
1111
+ if (!selected.length) selected.push(context.currentSurface);
1112
+ draft.selectedSurfaces = selected;
1113
+ return 'ok';
1114
+ }
1115
+
1116
+ async function askTransports(context, draft) {
1117
+ const choices = transportChoices(draft.selectedSurfaces, context.registry);
1118
+ if (!choices.length) {
1119
+ draft.authorizedTransports = [];
1120
+ return 'ok';
1121
+ }
1122
+ const offered = new Map(choices.map((choice) => [
1123
+ transportKey(choice), { surface: choice.surface, transport: choice.transport },
1124
+ ]));
1125
+ const answer = await context.prompt(transportQuestion(choices));
1126
+ draft.authorizedTransports = (Array.isArray(answer) ? answer : [])
1127
+ .filter((entry) => entry && offered.has(transportKey(entry)))
1128
+ .map((entry) => offered.get(transportKey(entry)));
1129
+ return 'ok';
1130
+ }
1131
+
1132
+ async function askSwitching(context, draft) {
1133
+ if (draft.selectedSurfaces.length === 1) {
1134
+ draft.switching = 'current-surface-only';
1135
+ return 'ok';
1136
+ }
1137
+ const chosen = await context.prompt(autonomyQuestion());
1138
+ if (!SWITCHING.includes(chosen)) throw new TypeError('invalid autonomy choice');
1139
+ draft.switching = chosen;
1140
+ return 'ok';
1141
+ }
1142
+
1143
+ /**
1144
+ * Record one answer per offered pair: what the user picked is `admitted`, what
1145
+ * they left is `declined`. A decline is a durable decision, which is exactly why
1146
+ * the pair is never offered again until the user reopens it.
1147
+ */
1148
+ function mergeRosterAnswer(entries, selectable, answer) {
1149
+ const offered = new Set(selectable.map(pairKey));
1150
+ const chosen = new Set((Array.isArray(answer) ? answer : [])
1151
+ .filter((pair) => pair && offered.has(pairKey(pair)))
1152
+ .map(pairKey));
1153
+ const decided = new Map(entries.map((entry) => [pairKey(entry), entry]));
1154
+ for (const pair of selectable) {
1155
+ decided.set(pairKey(pair), {
1156
+ ...pair, state: chosen.has(pairKey(pair)) ? 'admitted' : 'declined',
1157
+ });
1158
+ }
1159
+ return [...decided.values()];
1160
+ }
1161
+
1162
+ async function askRoster(context, draft) {
1163
+ if (!context.inventory) {
1164
+ draft.roster = [];
1165
+ draft.inventoryRevision = null;
1166
+ return 'ok';
1167
+ }
1168
+ const state = reconcileRosterState({ roster: [], inventoryRevision: null, inventory: context.inventory });
1169
+ draft.inventoryRevision = state.inventoryRevision;
1170
+ draft.roster = state.pending.length
1171
+ ? mergeRosterAnswer([], [...state.pending],
1172
+ await context.prompt(rosterQuestion(context, state.pending,
1173
+ 'Which model-and-effort pairs may the Kit use?')))
1174
+ : [];
1175
+ return 'ok';
1176
+ }
1177
+
1178
+ /** Match an answer back onto the offered pairs; anything else nominates nothing. */
1179
+ function matchOffered(answer, offered) {
1180
+ const known = new Map(offered.map((pair) => [pairKey(pair), pair]));
1181
+ return answer && known.get(pairKey(answer)) ? known.get(pairKey(answer)) : null;
1182
+ }
1183
+
1184
+ async function askStandardRoutes(context, draft) {
1185
+ draft.standardRoutes = emptyStandardRoutes();
1186
+ const admitted = admittedPairs(draft.roster);
1187
+ if (!admitted.length) return 'ok';
1188
+ for (const workload of STANDARD_ROUTE_CLASSES) {
1189
+ const chosen = matchOffered(
1190
+ await context.prompt(standardRouteQuestion(workload, admitted, null)), admitted,
1191
+ );
1192
+ draft.standardRoutes[workload] = chosen ? { ...chosen, state: 'configured' } : null;
1193
+ }
1194
+ return 'ok';
1195
+ }
1196
+
1197
+ /** One activation review: back, decline, optional advanced draft, or activate. */
1198
+ async function askActivation(context, draft) {
1199
+ while (true) {
1200
+ const action = await context.prompt({
1201
+ kind: 'activation',
1202
+ message: 'Review routing activation',
1203
+ selectedSurfaces: draft.selectedSurfaces,
1204
+ authorizedTransports: draft.authorizedTransports,
1205
+ switching: draft.switching,
1206
+ roster: draft.roster,
1207
+ standardRoutes: draft.standardRoutes,
1208
+ advancedDraft: draft.advanced,
1209
+ actions: ACTIVATION,
1210
+ });
1211
+ if (!ACTIVATION.includes(action)) throw new TypeError('invalid activation choice');
1212
+ if (action === 'back') return 'back';
1213
+ if (action === 'decline') return 'declined';
1214
+ if (action === 'advanced') {
1215
+ draft.advanced = await context.prompt({
1216
+ kind: 'advanced',
1217
+ message: 'Optional model and optimization preferences',
1218
+ draft: draft.advanced,
1219
+ });
1220
+ if (!draft.advanced || typeof draft.advanced !== 'object' || Array.isArray(draft.advanced)) {
1221
+ throw new TypeError('advanced choice must be an object');
1222
+ }
1223
+ continue;
1224
+ }
1225
+ if (action === 'safe-current-surface') draft.switching = 'current-surface-only';
1226
+ return 'ok';
1227
+ }
1228
+ }
1229
+
1230
+ const STAGE_HANDLERS = Object.freeze({
1231
+ surfaces: askSurfaces,
1232
+ transports: askTransports,
1233
+ switching: askSwitching,
1234
+ roster: askRoster,
1235
+ standardRoutes: askStandardRoutes,
1236
+ activation: askActivation,
1237
+ });
1238
+
180
1239
  export async function setupRoutingProfile(options) {
181
- const {
182
- consumerRoot,
183
- prompt,
184
- registry = AGENT_SURFACE_REGISTRY,
185
- } = options;
1240
+ const { consumerRoot, prompt, registry = AGENT_SURFACE_REGISTRY } = options;
186
1241
  if (typeof prompt !== 'function') {
187
1242
  return { status: 'needs-reconcile', reasons: ['personal-choice-required'] };
188
1243
  }
@@ -192,66 +1247,53 @@ export async function setupRoutingProfile(options) {
192
1247
  const expectedFingerprint = options.expectedFingerprint === undefined
193
1248
  ? (await readProfileSnapshot(consumerRoot, options.profileRoot)).fingerprint
194
1249
  : options.expectedFingerprint;
195
- const known = new Set(registry.map(({ id }) => id));
196
- const preselected = detectedSurfaceIds.filter((id) => known.has(id));
1250
+ const knownSurfaces = new Set(registry.map(({ id }) => id));
1251
+ const preselected = detectedSurfaceIds.filter((id) => knownSurfaces.has(id));
197
1252
  if (!preselected.includes(currentSurface)) preselected.unshift(currentSurface);
1253
+ const { inventory } = await resolveInventory(options);
1254
+ const context = {
1255
+ prompt, registry, knownSurfaces, preselected, currentSurface, detectedSurfaceIds, inventory,
1256
+ };
198
1257
 
199
1258
  while (true) {
200
- const selectedSurfaces = uniqueStrings(
201
- await prompt(surfaceQuestion(registry, preselected)),
202
- 'selected surfaces',
203
- ).filter((id) => known.has(id));
204
- if (!selectedSurfaces.length) selectedSurfaces.push(currentSurface);
205
- let switching = selectedSurfaces.length === 1
206
- ? 'current-surface-only'
207
- : await prompt(autonomyQuestion());
208
- if (!SWITCHING.includes(switching)) throw new TypeError('invalid autonomy choice');
209
-
210
- let advanced = null;
211
- let revisitChoices = false;
212
- while (true) {
213
- const summary = {
214
- kind: 'activation',
215
- message: 'Review routing activation',
216
- selectedSurfaces,
217
- switching,
218
- advancedDraft: advanced,
219
- actions: ACTIVATION,
220
- };
221
- const action = await prompt(summary);
222
- if (!ACTIVATION.includes(action)) throw new TypeError('invalid activation choice');
223
- if (action === 'back') {
224
- revisitChoices = true;
225
- break;
226
- }
227
- if (action === 'decline') return { status: 'declined' };
228
- if (action === 'advanced') {
229
- advanced = await prompt({
230
- kind: 'advanced',
231
- message: 'Optional model and optimization preferences',
232
- draft: advanced,
233
- });
234
- if (!advanced || typeof advanced !== 'object' || Array.isArray(advanced)) {
235
- throw new TypeError('advanced choice must be an object');
236
- }
237
- continue;
238
- }
239
- if (action === 'safe-current-surface') switching = 'current-surface-only';
240
- const profile = validateRoutingProfile({
241
- schemaVersion: ROUTING_PROFILE_VERSION,
242
- registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
243
- selectedSurfaces,
244
- consideredSurfaces: [...new Set([...preselected, ...selectedSurfaces])],
245
- switching,
246
- advanced,
247
- });
248
- await writeProfileExpected(options, profile, expectedFingerprint);
249
- return { status: 'activated', profile };
250
- }
251
- if (revisitChoices) continue;
1259
+ const draft = { advanced: null };
1260
+ const outcome = await runInterview(context, draft);
1261
+ if (outcome === 'back') continue;
1262
+ if (outcome === 'declined') return { status: 'declined' };
1263
+ const profile = validateRoutingProfile({
1264
+ schemaVersion: ROUTING_PROFILE_VERSION,
1265
+ registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
1266
+ selectedSurfaces: draft.selectedSurfaces,
1267
+ consideredSurfaces: [...new Set([...preselected, ...draft.selectedSurfaces])],
1268
+ authorizedTransports: draft.authorizedTransports,
1269
+ switching: draft.switching,
1270
+ roster: draft.roster,
1271
+ inventoryRevision: draft.inventoryRevision,
1272
+ standardRoutes: draft.standardRoutes,
1273
+ advanced: legacyAdvanced(draft.advanced),
1274
+ });
1275
+ await writeProfileExpected(options, profile, expectedFingerprint);
1276
+ return { status: 'activated', profile };
252
1277
  }
253
1278
  }
254
1279
 
1280
+ /** Run the declared sequence once; `back` restarts it from the first stage. */
1281
+ async function runInterview(context, draft) {
1282
+ for (const stage of ROUTING_INTERVIEW_SEQUENCE) {
1283
+ const outcome = await STAGE_HANDLERS[stage.id](context, draft);
1284
+ if (outcome !== 'ok') return outcome;
1285
+ }
1286
+ return 'ok';
1287
+ }
1288
+
1289
+ /** An advanced draft the v2 schema does not model is kept verbatim as legacy evidence. */
1290
+ function legacyAdvanced(draft) {
1291
+ if (!draft || !Object.keys(draft).length) return null;
1292
+ const keys = Object.keys(draft);
1293
+ if (keys.length === 1 && keys[0] === 'legacy') return validateAdvanced(draft);
1294
+ return { legacy: structuredClone(draft) };
1295
+ }
1296
+
255
1297
  export async function reconcileRoutingProfile(options, inspection) {
256
1298
  if (inspection.status === 'still valid') return { status: 'still valid' };
257
1299
  if (typeof options.prompt !== 'function') {
@@ -277,6 +1319,27 @@ export async function reconcileRoutingProfile(options, inspection) {
277
1319
  return { status: 'declined', reasons: inspection.reasons };
278
1320
  }
279
1321
 
1322
+ const surfaces = reconcileSurfaces(options, inspection, choice);
1323
+ const roster = await reconcileRoster(options, inspection);
1324
+ const profile = validateRoutingProfile({
1325
+ ...inspection.profile,
1326
+ registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
1327
+ selectedSurfaces: surfaces.selectedSurfaces,
1328
+ consideredSurfaces: surfaces.consideredSurfaces,
1329
+ // A transport belongs to a surface: dropping the surface drops its authorization.
1330
+ authorizedTransports: inspection.profile.authorizedTransports
1331
+ .filter(({ surface }) => surfaces.selectedSurfaces.includes(surface)),
1332
+ switching: surfaces.switching,
1333
+ roster: roster.entries,
1334
+ inventoryRevision: roster.inventoryRevision,
1335
+ standardRoutes: await reconcileStandardRoutes(options, inspection, roster.entries),
1336
+ });
1337
+ await writeProfileExpected(options, profile, inspection.fingerprint);
1338
+ return { status: 'reconciled', reasons: inspection.reasons, profile };
1339
+ }
1340
+
1341
+ /** The surface half of a reconcile: only the surfaced delta may change a choice. */
1342
+ function reconcileSurfaces(options, inspection, choice) {
280
1343
  const removed = new Set(inspection.delta.removedSurfaces.map(({ id }) => id));
281
1344
  const requestedAdds = new Set(choice.addSurfaceIds ?? []);
282
1345
  const knownAdds = inspection.delta.newSurfaces
@@ -294,18 +1357,82 @@ export async function reconcileRoutingProfile(options, inspection) {
294
1357
  selectedSurfaces.push(safeSurface);
295
1358
  switching = 'current-surface-only';
296
1359
  }
297
- const profile = validateRoutingProfile({
298
- ...inspection.profile,
299
- registryRevision: AGENT_SURFACE_REGISTRY_REVISION,
1360
+ return {
300
1361
  selectedSurfaces,
301
1362
  consideredSurfaces: [
302
1363
  ...inspection.profile.consideredSurfaces.filter((id) => !removed.has(id)),
303
1364
  ...inspection.delta.newSurfaces.map(({ id }) => id),
304
1365
  ],
305
1366
  switching,
306
- });
307
- await writeProfileExpected(options, profile, inspection.fingerprint);
308
- return { status: 'reconciled', reasons: inspection.reasons, profile };
1367
+ };
1368
+ }
1369
+
1370
+ /**
1371
+ * The roster half of a reconcile: ask only about the pairs this run actually
1372
+ * changed. With nothing new to decide, the derived states are persisted and the
1373
+ * recorded inventory revision rides along with this authorized write.
1374
+ */
1375
+ async function reconcileRoster(options, inspection) {
1376
+ const state = inspection.rosterState;
1377
+ if (!state) {
1378
+ return {
1379
+ entries: inspection.profile.roster,
1380
+ inventoryRevision: inspection.profile.inventoryRevision,
1381
+ };
1382
+ }
1383
+ const selectable = [...state.pending, ...state.reopenable];
1384
+ if (!selectable.length) {
1385
+ return { entries: [...state.entries], inventoryRevision: state.inventoryRevision };
1386
+ }
1387
+ const context = {
1388
+ inventory: inspection.inventory,
1389
+ detectedSurfaceIds: inspection.detectedSurfaceIds,
1390
+ registry: options.registry ?? AGENT_SURFACE_REGISTRY,
1391
+ };
1392
+ const answer = await options.prompt(rosterQuestion(
1393
+ context, selectable, 'Your Model roster changed — which pairs may the Kit use?',
1394
+ ));
1395
+ return {
1396
+ entries: mergeRosterAnswer([...state.entries], selectable, answer),
1397
+ inventoryRevision: state.inventoryRevision,
1398
+ };
1399
+ }
1400
+
1401
+ /**
1402
+ * A Standard route whose pair is no longer admitted is persisted `unresolved`,
1403
+ * and the user is asked once for a replacement. Answering nothing leaves the
1404
+ * route unresolved and its workload class blocked — the Kit never picks a
1405
+ * replacement, and an already-unresolved route is not asked about again.
1406
+ */
1407
+ async function reconcileStandardRoutes(options, inspection, roster) {
1408
+ const admitted = admittedPairs(roster);
1409
+ const authorized = new Set(admitted.map(pairKey));
1410
+ const routes = { ...inspection.profile.standardRoutes };
1411
+ for (const workload of STANDARD_ROUTE_CLASSES) {
1412
+ const entry = routes[workload];
1413
+ if (!entry || entry.state !== 'configured' || authorized.has(pairKey(entry))) continue;
1414
+ routes[workload] = { ...barePair(entry), state: 'unresolved' };
1415
+ if (!admitted.length) continue;
1416
+ const chosen = matchOffered(
1417
+ await options.prompt(standardRouteQuestion(workload, admitted, routes[workload])), admitted,
1418
+ );
1419
+ if (chosen) routes[workload] = { ...chosen, state: 'configured' };
1420
+ }
1421
+ return routes;
1422
+ }
1423
+
1424
+ /**
1425
+ * Preserve the pre-migration document before a migrated profile replaces it.
1426
+ * The earliest preserved evidence wins: an existing backup is never overwritten.
1427
+ */
1428
+ async function backupLegacyDocument(options, snapshot) {
1429
+ const path = routingProfileBackupPath(
1430
+ options.consumerRoot,
1431
+ options.profileRoot,
1432
+ snapshot.migration.from,
1433
+ );
1434
+ if (await access(path).then(() => true, () => false)) return;
1435
+ await writeAtomic(path, snapshot.bytes);
309
1436
  }
310
1437
 
311
1438
  async function writeProfileExpected(options, profile, expectedFingerprint) {
@@ -322,10 +1449,11 @@ async function writeProfileExpected(options, profile, expectedFingerprint) {
322
1449
  throw error;
323
1450
  }
324
1451
  try {
325
- const actual = (await readProfileSnapshot(options.consumerRoot, options.profileRoot)).fingerprint;
326
- if (actual !== expectedFingerprint) {
1452
+ const snapshot = await readProfileSnapshot(options.consumerRoot, options.profileRoot);
1453
+ if (snapshot.fingerprint !== expectedFingerprint) {
327
1454
  throw new Error('concurrent routing profile mutation: profile changed during decision');
328
1455
  }
1456
+ if (snapshot.migration) await backupLegacyDocument(options, snapshot);
329
1457
  await writeAtomic(path, `${JSON.stringify(profile, null, 2)}\n`);
330
1458
  } finally {
331
1459
  await lock.close();