@ikon85/agent-workflow-kit 0.44.1 → 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 (67) 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 +71 -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 +521 -80
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/commands/update.mjs +9 -1
  34. package/src/consumer-migrations.json +23 -1
  35. package/src/lib/bundle.mjs +158 -2
  36. package/src/lib/consumerMigrations.mjs +55 -0
  37. package/src/lib/dispatchJournal.mjs +300 -0
  38. package/src/lib/dispatchPlan.mjs +286 -0
  39. package/src/lib/dispatchReceipt.mjs +226 -89
  40. package/src/lib/frontendWorkloads.mjs +35 -33
  41. package/src/lib/routeDispatcher.mjs +367 -70
  42. package/src/lib/routingAccessGraph.mjs +265 -20
  43. package/src/lib/routingAccessGraphStore.mjs +300 -0
  44. package/src/lib/routingAdapters/claude.mjs +104 -4
  45. package/src/lib/routingAdapters/codex.mjs +132 -7
  46. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  47. package/src/lib/routingCatalog.mjs +201 -24
  48. package/src/lib/routingDispatchLease.mjs +253 -0
  49. package/src/lib/routingEvidenceCache.mjs +78 -0
  50. package/src/lib/routingIntent.mjs +176 -10
  51. package/src/lib/routingIntentClassifier.mjs +137 -0
  52. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  53. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  54. package/src/lib/routingInventory.mjs +182 -0
  55. package/src/lib/routingPolicy.mjs +193 -6
  56. package/src/lib/routingProfile.mjs +1251 -123
  57. package/src/lib/routingProfilePolicy.mjs +156 -0
  58. package/src/lib/routingProfileStorage.mjs +299 -0
  59. package/src/lib/routingResolver.mjs +369 -86
  60. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  61. package/src/lib/routingSources/benchlm.mjs +5 -0
  62. package/src/lib/routingSources/codeArena.mjs +13 -3
  63. package/src/lib/routingSources/deepswe.mjs +19 -5
  64. package/src/lib/routingSources/openhands.mjs +17 -4
  65. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  66. package/src/lib/safeText.mjs +26 -0
  67. package/src/lib/updateCandidate.mjs +36 -3
@@ -1,4 +1,5 @@
1
- export const ROUTING_INTENT_VERSION = 1;
1
+ export const ROUTING_INTENT_VERSION = 2;
2
+ export const ROUTING_INTENT_LEGACY_VERSION = 1;
2
3
 
3
4
  export const ROUTING_WORKLOADS = Object.freeze([
4
5
  'judgment',
@@ -12,7 +13,62 @@ export const ROUTING_REASONING = Object.freeze([
12
13
  'light',
13
14
  ]);
14
15
 
15
- const FIELDS = new Set(['version', 'workload', 'reasoning', 'evidenceSelection']);
16
+ export const ROUTING_TASK_SHAPES = Object.freeze([
17
+ 'single-step',
18
+ 'multi-step',
19
+ 'long-horizon',
20
+ ]);
21
+
22
+ export const ROUTING_RISKS = Object.freeze([
23
+ 'low',
24
+ 'moderate',
25
+ 'high',
26
+ ]);
27
+
28
+ export const ROUTING_AUTONOMY_REQUIREMENTS = Object.freeze([
29
+ 'supervised',
30
+ 'afk',
31
+ ]);
32
+
33
+ export const ROUTING_CONTEXT_NEEDS = Object.freeze([
34
+ 'focused',
35
+ 'repository',
36
+ 'long-context',
37
+ ]);
38
+
39
+ // A v1 intent proves nothing about the dimensions v2 adds, so the migration
40
+ // records every field it had to default. Autonomy is fail-closed — a migrated
41
+ // intent never claims an unattended run the user never authorized. The other
42
+ // three take the neutral middle of their scale so the migration neither
43
+ // inflates nor understates the work.
44
+ export const ROUTING_INTENT_MIGRATION_DEFAULTS = Object.freeze({
45
+ taskShape: 'multi-step',
46
+ risk: 'moderate',
47
+ autonomyRequirement: 'supervised',
48
+ contextNeed: 'repository',
49
+ });
50
+
51
+ const DIMENSIONS = Object.freeze([
52
+ Object.freeze({ field: 'workload', key: 'routing-intent', allowed: ROUTING_WORKLOADS }),
53
+ Object.freeze({ field: 'reasoning', key: 'reasoning-intent', allowed: ROUTING_REASONING }),
54
+ Object.freeze({ field: 'taskShape', key: 'task-shape', allowed: ROUTING_TASK_SHAPES }),
55
+ Object.freeze({ field: 'risk', key: 'risk', allowed: ROUTING_RISKS }),
56
+ Object.freeze({
57
+ field: 'autonomyRequirement',
58
+ key: 'autonomy-requirement',
59
+ allowed: ROUTING_AUTONOMY_REQUIREMENTS,
60
+ }),
61
+ Object.freeze({ field: 'contextNeed', key: 'context-need', allowed: ROUTING_CONTEXT_NEEDS }),
62
+ ]);
63
+
64
+ const VERSION_KEY = 'intent-version';
65
+ const EVIDENCE_SELECTION_KEY = 'evidence-selection';
66
+ const FIELDS = new Set([
67
+ 'version',
68
+ ...DIMENSIONS.map((dimension) => dimension.field),
69
+ 'evidenceSelection',
70
+ ]);
71
+ const LEGACY_FIELDS = new Set(['version', 'workload', 'reasoning', 'evidenceSelection']);
16
72
  const EVIDENCE_SELECTION_FIELDS = new Set(['workload', 'domain', 'axes']);
17
73
 
18
74
  function requireEnum(value, allowed, field) {
@@ -71,23 +127,133 @@ export function evidenceSelectionMatchesObservation(input, observationWorkload)
71
127
  && selection.axes.includes(axis);
72
128
  }
73
129
 
74
- export function validateRoutingIntent(input) {
130
+ function requireIntentDocument(input, allowedFields) {
75
131
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
76
132
  throw new TypeError('routing intent must be an object');
77
133
  }
78
134
  for (const field of Object.keys(input)) {
79
- if (!FIELDS.has(field)) throw new TypeError(`unknown routing intent field: ${field}`);
135
+ if (!allowedFields.has(field)) throw new TypeError(`unknown routing intent field: ${field}`);
136
+ }
137
+ return input;
138
+ }
139
+
140
+ export function validateRoutingIntent(input) {
141
+ if (input && typeof input === 'object' && !Array.isArray(input)
142
+ && input.version === ROUTING_INTENT_LEGACY_VERSION) {
143
+ return migrateRoutingIntent(input).intent;
80
144
  }
145
+ requireIntentDocument(input, FIELDS);
81
146
  if (input.version !== ROUTING_INTENT_VERSION) {
82
- throw new TypeError(`routing intent version must be ${ROUTING_INTENT_VERSION}`);
147
+ throw new TypeError(
148
+ `routing intent version must be ${ROUTING_INTENT_LEGACY_VERSION} or ${ROUTING_INTENT_VERSION}`,
149
+ );
150
+ }
151
+ const intent = { version: ROUTING_INTENT_VERSION };
152
+ for (const { field, allowed } of DIMENSIONS) {
153
+ intent[field] = requireEnum(input[field], allowed, field);
83
154
  }
84
- const intent = {
85
- version: ROUTING_INTENT_VERSION,
86
- workload: requireEnum(input.workload, ROUTING_WORKLOADS, 'workload'),
87
- reasoning: requireEnum(input.reasoning, ROUTING_REASONING, 'reasoning'),
88
- };
89
155
  if (input.evidenceSelection !== undefined) {
90
156
  intent.evidenceSelection = validateEvidenceSelection(input.evidenceSelection);
91
157
  }
92
158
  return Object.freeze(intent);
93
159
  }
160
+
161
+ export function migrateRoutingIntent(input) {
162
+ if (input && typeof input === 'object' && !Array.isArray(input)
163
+ && input.version === ROUTING_INTENT_VERSION) {
164
+ return Object.freeze({
165
+ intent: validateRoutingIntent(input),
166
+ fromVersion: ROUTING_INTENT_VERSION,
167
+ defaulted: Object.freeze([]),
168
+ });
169
+ }
170
+ requireIntentDocument(input, LEGACY_FIELDS);
171
+ if (input.version !== ROUTING_INTENT_LEGACY_VERSION) {
172
+ throw new TypeError(
173
+ `routing intent version must be ${ROUTING_INTENT_LEGACY_VERSION} or ${ROUTING_INTENT_VERSION}`,
174
+ );
175
+ }
176
+ // A v1 document cannot carry any v2 dimension, so every one of them is defaulted.
177
+ const defaulted = Object.freeze(Object.keys(ROUTING_INTENT_MIGRATION_DEFAULTS));
178
+ return Object.freeze({
179
+ intent: validateRoutingIntent({
180
+ ...input,
181
+ version: ROUTING_INTENT_VERSION,
182
+ ...ROUTING_INTENT_MIGRATION_DEFAULTS,
183
+ }),
184
+ fromVersion: ROUTING_INTENT_LEGACY_VERSION,
185
+ defaulted,
186
+ });
187
+ }
188
+
189
+ function serializeEvidenceSelection(selection) {
190
+ for (const segment of [selection.workload, selection.domain, ...selection.axes]) {
191
+ if (segment.includes(',')) {
192
+ throw new TypeError('evidence selection axis must not contain a comma');
193
+ }
194
+ }
195
+ return `${selection.workload}:${selection.domain}:${selection.axes.join(',')}`;
196
+ }
197
+
198
+ export function serializeRoutingIntent(input) {
199
+ const intent = validateRoutingIntent(input);
200
+ const lines = [`${VERSION_KEY}: ${intent.version}`];
201
+ for (const { field, key } of DIMENSIONS) lines.push(`${key}: ${intent[field]}`);
202
+ if (intent.evidenceSelection !== undefined) {
203
+ lines.push(`${EVIDENCE_SELECTION_KEY}: ${serializeEvidenceSelection(intent.evidenceSelection)}`);
204
+ }
205
+ return lines.join('\n');
206
+ }
207
+
208
+ const ISSUE_KEYS = new Map([
209
+ ...DIMENSIONS.map(({ key, field }) => [key, field]),
210
+ [VERSION_KEY, 'version'],
211
+ [EVIDENCE_SELECTION_KEY, 'evidenceSelection'],
212
+ ]);
213
+ const INTENT_LINE = /^\s*([a-z][a-z-]*)\s*:\s*(\S.*?)\s*$/;
214
+ const WORKLOAD_KEY = DIMENSIONS[0].key;
215
+
216
+ // Only the one blank-line-delimited block that names the workload key is read,
217
+ // so an unrelated `risk:` line elsewhere in an issue body can never be mistaken
218
+ // for a Routing intent dimension.
219
+ function readIntentBlock(text) {
220
+ if (typeof text !== 'string') throw new TypeError('routing intent block must be a string');
221
+ const blocks = text.split(/\n[ \t]*\n/).filter((block) => block
222
+ .split('\n')
223
+ .some((line) => INTENT_LINE.exec(line)?.[1] === WORKLOAD_KEY));
224
+ if (blocks.length === 0) {
225
+ throw new TypeError(`routing intent block must name ${WORKLOAD_KEY}`);
226
+ }
227
+ if (blocks.length > 1) throw new TypeError('routing intent block must appear once');
228
+ const document = {};
229
+ for (const line of blocks[0].split('\n')) {
230
+ const match = INTENT_LINE.exec(line);
231
+ if (!match || !ISSUE_KEYS.has(match[1])) continue;
232
+ const field = ISSUE_KEYS.get(match[1]);
233
+ if (document[field] !== undefined) {
234
+ throw new TypeError(`duplicate routing intent field: ${match[1]}`);
235
+ }
236
+ document[field] = match[2];
237
+ }
238
+ return document;
239
+ }
240
+
241
+ function parseEvidenceSelection(value) {
242
+ const [workload, domain, axes, ...rest] = value.split(':');
243
+ if (rest.length > 0 || !workload || !domain || !axes) {
244
+ throw new TypeError(`${EVIDENCE_SELECTION_KEY} must name workload, domain, and axes`);
245
+ }
246
+ return { workload, domain, axes: axes.split(',') };
247
+ }
248
+
249
+ export function parseRoutingIntent(text) {
250
+ const document = readIntentBlock(text);
251
+ const version = document.version === undefined
252
+ ? ROUTING_INTENT_LEGACY_VERSION
253
+ : Number(document.version);
254
+ const decoded = { ...document, version };
255
+ if (document.evidenceSelection !== undefined) {
256
+ decoded.evidenceSelection = parseEvidenceSelection(document.evidenceSelection);
257
+ }
258
+ return migrateRoutingIntent(decoded);
259
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Intent resolution — the first of the two stages a dispatch runs through.
3
+ *
4
+ * Stage one resolves *what the work is*: an explicit Routing intent stated in
5
+ * the issue or the task wins, and only when none is stated does a
6
+ * provider-neutral workflow classifier derive one. Stage two — route selection
7
+ * against the Evidence catalog and the Standard route — happens elsewhere, in
8
+ * the resolver, and never runs backwards: **a route is never an intent source.**
9
+ * This module therefore knows nothing about models, efforts, surfaces or
10
+ * transports, and rejects a caller that tries to feed one in, because a signal
11
+ * named after a route would let a chosen model justify the intent that chose it.
12
+ *
13
+ * The classifier is a total, inspectable table over five declared workflow
14
+ * signals. Four of them map one-to-one onto an intent dimension; `reasoning` is
15
+ * the one dimension a workflow cannot state without opinionating, so it is
16
+ * derived from what the unit produces and what a wrong answer costs.
17
+ */
18
+ import {
19
+ ROUTING_INTENT_VERSION,
20
+ migrateRoutingIntent,
21
+ parseRoutingIntent,
22
+ validateRoutingIntent,
23
+ } from './routingIntent.mjs';
24
+
25
+ /** The provider-neutral facts a delegating workflow already knows about a unit. */
26
+ export const WORKFLOW_SIGNAL_VOCABULARY = Object.freeze({
27
+ outcome: Object.freeze(['decision', 'implementation', 'transformation']),
28
+ steps: Object.freeze(['single', 'several', 'session-spanning']),
29
+ blastRadius: Object.freeze(['contained', 'shared', 'irreversible']),
30
+ supervision: Object.freeze(['attended', 'unattended']),
31
+ breadth: Object.freeze(['single-file', 'repository', 'cross-repository']),
32
+ });
33
+
34
+ const SIGNAL_DIMENSIONS = Object.freeze({
35
+ outcome: Object.freeze({
36
+ field: 'workload',
37
+ values: Object.freeze({
38
+ decision: 'judgment', implementation: 'development', transformation: 'mechanical',
39
+ }),
40
+ }),
41
+ steps: Object.freeze({
42
+ field: 'taskShape',
43
+ values: Object.freeze({
44
+ single: 'single-step', several: 'multi-step', 'session-spanning': 'long-horizon',
45
+ }),
46
+ }),
47
+ blastRadius: Object.freeze({
48
+ field: 'risk',
49
+ values: Object.freeze({ contained: 'low', shared: 'moderate', irreversible: 'high' }),
50
+ }),
51
+ supervision: Object.freeze({
52
+ field: 'autonomyRequirement',
53
+ values: Object.freeze({ attended: 'supervised', unattended: 'afk' }),
54
+ }),
55
+ breadth: Object.freeze({
56
+ field: 'contextNeed',
57
+ values: Object.freeze({
58
+ 'single-file': 'focused', repository: 'repository', 'cross-repository': 'long-context',
59
+ }),
60
+ }),
61
+ });
62
+
63
+ /**
64
+ * The vocabulary of the other stage. A signal named after any of these is not a
65
+ * typo to correct but a route leaking into intent resolution, so it is named as
66
+ * such rather than reported as an unknown field.
67
+ */
68
+ const ROUTE_FIELDS = Object.freeze(new Set([
69
+ 'route', 'model', 'modelId', 'effort', 'provider', 'providerId',
70
+ 'surface', 'surfaceId', 'transport', 'transportId', 'accessPathId', 'pair',
71
+ ]));
72
+
73
+ function plainObject(value, field) {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
75
+ throw new TypeError(`${field} must be an object`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * How much reasoning the unit is worth. Judgment work has no decisive evidence
82
+ * and always reasons deeply; the other two escalate with the blast radius, so a
83
+ * mechanical change that cannot be taken back stops being treated as mechanical.
84
+ */
85
+ function derivedReasoning(workload, risk) {
86
+ if (workload === 'judgment') return 'deep';
87
+ if (workload === 'development') return risk === 'high' ? 'deep' : 'balanced';
88
+ return risk === 'low' ? 'light' : 'balanced';
89
+ }
90
+
91
+ export function classifyWorkflowIntent(input) {
92
+ plainObject(input, 'workflow signals');
93
+ for (const field of Object.keys(input)) {
94
+ if (ROUTE_FIELDS.has(field)) {
95
+ throw new TypeError(`a route is never an intent source: ${field}`);
96
+ }
97
+ if (!(field in SIGNAL_DIMENSIONS)) throw new TypeError(`unknown workflow signal: ${field}`);
98
+ }
99
+ const document = { version: ROUTING_INTENT_VERSION };
100
+ for (const [signal, { field, values }] of Object.entries(SIGNAL_DIMENSIONS)) {
101
+ const mapped = Object.hasOwn(values, input[signal]) ? values[input[signal]] : undefined;
102
+ if (mapped === undefined) {
103
+ throw new TypeError(`${signal} must be one of: ${WORKFLOW_SIGNAL_VOCABULARY[signal].join(', ')}`);
104
+ }
105
+ document[field] = mapped;
106
+ }
107
+ document.reasoning = derivedReasoning(document.workload, document.risk);
108
+ return validateRoutingIntent(document);
109
+ }
110
+
111
+ /**
112
+ * The resolved Routing intent plus how it was resolved, so a Dispatch plan can
113
+ * show that an intent was stated rather than guessed — and which dimensions a
114
+ * migrated v1 intent never carried.
115
+ */
116
+ export function resolveRoutingIntent(input) {
117
+ plainObject(input, 'intent resolution input');
118
+ const explicit = input.explicit ?? null;
119
+ if (explicit !== null) {
120
+ // A stated intent may be a document or the block an issue body carries; a
121
+ // legacy one migrates and reports every dimension it could not have stated.
122
+ const stated = typeof explicit === 'string'
123
+ ? parseRoutingIntent(explicit)
124
+ : migrateRoutingIntent(explicit);
125
+ return Object.freeze({
126
+ source: 'explicit', intent: stated.intent, defaulted: stated.defaulted,
127
+ });
128
+ }
129
+ if (input.signals == null) {
130
+ throw new TypeError('intent resolution needs an explicit intent or workflow signals');
131
+ }
132
+ return Object.freeze({
133
+ source: 'classifier',
134
+ intent: classifyWorkflowIntent(input.signals),
135
+ defaulted: Object.freeze([]),
136
+ });
137
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "snapshotVersion": 1,
3
+ "surface": "claude-code",
4
+ "provider": "anthropic",
5
+ "provenance": {
6
+ "sourceKind": "maintainer-reading",
7
+ "sourceRef": "claude --help model and effort domains, Claude Code 2.1.220; the surface publishes no machine-readable model inventory, so this half is pinned by maintainer reading rather than by a fetch",
8
+ "capturedAt": "2026-07-28",
9
+ "integrity": "sha256-B2mGRS1jnYS_2JCmvkZum0vV-yliwOGS1Gtu_1Ghv2k"
10
+ },
11
+ "models": [
12
+ {
13
+ "modelId": "opus",
14
+ "identifiers": ["opus", "opus[1m]", "claude-opus-5", "claude-opus-5[1m]"],
15
+ "effortAxis": true,
16
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
17
+ "defaultEffort": null,
18
+ "visibility": "list",
19
+ "effortSource": "cli-effort-domain; applied effort attested by per-spawn readback in verify-spike 18c"
20
+ },
21
+ {
22
+ "modelId": "sonnet",
23
+ "identifiers": ["sonnet"],
24
+ "effortAxis": true,
25
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
26
+ "defaultEffort": null,
27
+ "visibility": "list",
28
+ "effortSource": "cli-effort-domain"
29
+ },
30
+ {
31
+ "modelId": "haiku",
32
+ "identifiers": ["haiku"],
33
+ "effortAxis": false,
34
+ "efforts": [],
35
+ "defaultEffort": null,
36
+ "visibility": "list",
37
+ "effortSource": "attested absent: a haiku run records no effort because none is applied"
38
+ },
39
+ {
40
+ "modelId": "fable",
41
+ "identifiers": ["fable", "claude-fable-5"],
42
+ "effortAxis": true,
43
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
44
+ "defaultEffort": null,
45
+ "visibility": "list",
46
+ "effortSource": "cli-effort-domain"
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,109 @@
1
+ {
2
+ "snapshotVersion": 1,
3
+ "surface": "codex",
4
+ "provider": "openai",
5
+ "provenance": {
6
+ "sourceKind": "capability-cache",
7
+ "sourceRef": "local Codex capability cache (models_cache.json) fetched under the existing subscription login, ETag-versioned; client 0.145.0",
8
+ "capturedAt": "2026-07-28",
9
+ "integrity": "sha256-SS-M-6oo94SpVYbsyIw7-SfiEf_pQyGmX5RaFWACtns"
10
+ },
11
+ "models": [
12
+ {
13
+ "modelId": "gpt-5.6-sol",
14
+ "identifiers": ["gpt-5.6-sol"],
15
+ "effortAxis": true,
16
+ "efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
17
+ "defaultEffort": "low",
18
+ "visibility": "list",
19
+ "supportedInApi": true,
20
+ "contextWindow": 272000,
21
+ "maxContextWindow": 272000,
22
+ "effortSource": "capability-cache"
23
+ },
24
+ {
25
+ "modelId": "gpt-5.6-terra",
26
+ "identifiers": ["gpt-5.6-terra"],
27
+ "effortAxis": true,
28
+ "efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
29
+ "defaultEffort": "medium",
30
+ "visibility": "list",
31
+ "supportedInApi": true,
32
+ "contextWindow": 272000,
33
+ "maxContextWindow": 272000,
34
+ "effortSource": "capability-cache"
35
+ },
36
+ {
37
+ "modelId": "gpt-5.6-luna",
38
+ "identifiers": ["gpt-5.6-luna"],
39
+ "effortAxis": true,
40
+ "efforts": ["low", "medium", "high", "xhigh", "max"],
41
+ "defaultEffort": "medium",
42
+ "visibility": "list",
43
+ "supportedInApi": true,
44
+ "contextWindow": 272000,
45
+ "maxContextWindow": 272000,
46
+ "effortSource": "capability-cache"
47
+ },
48
+ {
49
+ "modelId": "gpt-5.5",
50
+ "identifiers": ["gpt-5.5"],
51
+ "effortAxis": true,
52
+ "efforts": ["low", "medium", "high", "xhigh"],
53
+ "defaultEffort": "medium",
54
+ "visibility": "list",
55
+ "supportedInApi": true,
56
+ "contextWindow": 272000,
57
+ "maxContextWindow": 272000,
58
+ "effortSource": "capability-cache"
59
+ },
60
+ {
61
+ "modelId": "gpt-5.4",
62
+ "identifiers": ["gpt-5.4"],
63
+ "effortAxis": true,
64
+ "efforts": ["low", "medium", "high", "xhigh"],
65
+ "defaultEffort": "medium",
66
+ "visibility": "list",
67
+ "supportedInApi": true,
68
+ "contextWindow": 272000,
69
+ "maxContextWindow": 1000000,
70
+ "effortSource": "capability-cache"
71
+ },
72
+ {
73
+ "modelId": "gpt-5.4-mini",
74
+ "identifiers": ["gpt-5.4-mini"],
75
+ "effortAxis": true,
76
+ "efforts": ["low", "medium", "high", "xhigh"],
77
+ "defaultEffort": "medium",
78
+ "visibility": "list",
79
+ "supportedInApi": true,
80
+ "contextWindow": 272000,
81
+ "maxContextWindow": 272000,
82
+ "effortSource": "capability-cache"
83
+ },
84
+ {
85
+ "modelId": "gpt-5.3-codex-spark",
86
+ "identifiers": ["gpt-5.3-codex-spark"],
87
+ "effortAxis": true,
88
+ "efforts": ["low", "medium", "high", "xhigh"],
89
+ "defaultEffort": "high",
90
+ "visibility": "list",
91
+ "supportedInApi": false,
92
+ "contextWindow": 128000,
93
+ "maxContextWindow": 128000,
94
+ "effortSource": "capability-cache"
95
+ },
96
+ {
97
+ "modelId": "codex-auto-review",
98
+ "identifiers": ["codex-auto-review"],
99
+ "effortAxis": true,
100
+ "efforts": ["low", "medium", "high", "xhigh"],
101
+ "defaultEffort": "medium",
102
+ "visibility": "hide",
103
+ "supportedInApi": true,
104
+ "contextWindow": 272000,
105
+ "maxContextWindow": 1000000,
106
+ "effortSource": "capability-cache"
107
+ }
108
+ ]
109
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The pinned Model inventory: every model-and-effort pair the Kit knows about,
3
+ * per agent surface, read from provenance-hashed source snapshots committed
4
+ * beside this module.
5
+ *
6
+ * Pinned, not live. Release preparation stays deterministic and offline: this
7
+ * module only reads local files, never a provider API. Refreshing a snapshot is
8
+ * a separate maintainer step.
9
+ *
10
+ * Unfiltered by construction. The inventory lists every known pair, including
11
+ * pairs the local machine cannot reach and models a surface hides from its own
12
+ * picker. Local detection feeds Access-graph attestation and the presentation
13
+ * order of a setup list — never authorization. Authorization is the Model
14
+ * roster, and it lives in the Routing profile.
15
+ *
16
+ * The digest covers the capability payload (surface, provider, snapshot
17
+ * version, captured date, source kind, and the model records), not the free
18
+ * prose of `provenance.sourceRef`: publish-time scrubbing rewrites prose but
19
+ * must never touch a capability fact. It is written in the self-describing
20
+ * `sha256-<base64url>` integrity form, so a shipped digest reads as a digest
21
+ * rather than as an unexplained high-entropy string.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { readFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ /** Snapshot files, in the order that fixes pair order. */
29
+ export const INVENTORY_SNAPSHOT_FILES = Object.freeze(['claude.json', 'codex.json']);
30
+
31
+ export const INVENTORY_SNAPSHOT_VERSION = 1;
32
+
33
+ /** Untested access is attested `unknown`; detection alone never promotes it. */
34
+ export const UNTESTED_ACCESS = 'unknown';
35
+
36
+ const DEFAULT_SNAPSHOT_DIR = fileURLToPath(
37
+ new URL('./routingInventory/snapshots/', import.meta.url),
38
+ );
39
+
40
+ function canonical(value) {
41
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
42
+ if (value && typeof value === 'object') {
43
+ return `{${Object.keys(value).sort()
44
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
45
+ }
46
+ return JSON.stringify(value ?? null);
47
+ }
48
+
49
+ const sha256 = (text) => `sha256-${createHash('sha256').update(text).digest('base64url')}`;
50
+
51
+ /** Digest of a snapshot's capability payload — the value `provenance.integrity` pins. */
52
+ export function snapshotDigest(snapshot) {
53
+ return sha256(canonical({
54
+ snapshotVersion: snapshot.snapshotVersion,
55
+ surface: snapshot.surface,
56
+ provider: snapshot.provider,
57
+ capturedAt: snapshot.provenance?.capturedAt ?? null,
58
+ sourceKind: snapshot.provenance?.sourceKind ?? null,
59
+ models: snapshot.models,
60
+ }));
61
+ }
62
+
63
+ function assertModel(file, model) {
64
+ const fail = (reason) => {
65
+ throw new Error(`routing inventory snapshot ${file}: ${reason}`);
66
+ };
67
+ if (!model || typeof model.modelId !== 'string' || !model.modelId) fail('a model has no modelId');
68
+ if (!Array.isArray(model.identifiers) || !model.identifiers.includes(model.modelId)) {
69
+ fail(`model ${model.modelId} does not list its own identifier`);
70
+ }
71
+ if (typeof model.effortAxis !== 'boolean') fail(`model ${model.modelId} has no effortAxis`);
72
+ if (!Array.isArray(model.efforts)) fail(`model ${model.modelId} has no effort domain`);
73
+ if (model.effortAxis === (model.efforts.length === 0)) {
74
+ fail(`model ${model.modelId} contradicts its effort axis`);
75
+ }
76
+ if (new Set(model.efforts).size !== model.efforts.length) {
77
+ fail(`model ${model.modelId} repeats an effort`);
78
+ }
79
+ const fallback = model.defaultEffort ?? null;
80
+ if (fallback !== null && !model.efforts.includes(fallback)) {
81
+ fail(`model ${model.modelId} defaults to an effort outside its domain`);
82
+ }
83
+ }
84
+
85
+ function assertSnapshot(file, snapshot) {
86
+ const fail = (reason) => {
87
+ throw new Error(`routing inventory snapshot ${file}: ${reason}`);
88
+ };
89
+ if (snapshot?.snapshotVersion !== INVENTORY_SNAPSHOT_VERSION) {
90
+ fail(`unsupported snapshot version ${snapshot?.snapshotVersion}`);
91
+ }
92
+ for (const field of ['surface', 'provider']) {
93
+ if (typeof snapshot[field] !== 'string' || !snapshot[field]) fail(`missing ${field}`);
94
+ }
95
+ if (!Array.isArray(snapshot.models) || !snapshot.models.length) fail('lists no model');
96
+ for (const model of snapshot.models) assertModel(file, model);
97
+ if (snapshotDigest(snapshot) !== snapshot.provenance?.integrity) {
98
+ fail('payload does not match its recorded provenance hash');
99
+ }
100
+ }
101
+
102
+ async function readSnapshot(snapshotDir, file) {
103
+ let raw;
104
+ try {
105
+ raw = await readFile(join(snapshotDir, file), 'utf8');
106
+ } catch (error) {
107
+ throw new Error(`routing inventory snapshot ${file} is unreadable: ${error.code ?? error.message}`);
108
+ }
109
+ let snapshot;
110
+ try {
111
+ snapshot = JSON.parse(raw);
112
+ } catch (error) {
113
+ throw new Error(`routing inventory snapshot ${file} is not valid JSON: ${error.message}`);
114
+ }
115
+ assertSnapshot(file, snapshot);
116
+ return snapshot;
117
+ }
118
+
119
+ function pairsOf(snapshot) {
120
+ return snapshot.models.flatMap((model) => {
121
+ const efforts = model.effortAxis ? model.efforts : [null];
122
+ return efforts.map((effort) => Object.freeze({
123
+ surface: snapshot.surface,
124
+ provider: snapshot.provider,
125
+ modelId: model.modelId,
126
+ effort,
127
+ }));
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Read the pinned inventory. Fails closed: an unreadable, malformed, or
133
+ * tampered snapshot throws instead of yielding a partial pair list.
134
+ */
135
+ export async function loadRoutingInventory({ snapshotDir = DEFAULT_SNAPSHOT_DIR } = {}) {
136
+ const snapshots = [];
137
+ for (const file of INVENTORY_SNAPSHOT_FILES) {
138
+ snapshots.push(Object.freeze({ file, ...await readSnapshot(snapshotDir, file) }));
139
+ }
140
+ const revision = sha256(canonical(snapshots.map(({ file, provenance }) => ({
141
+ file, digest: provenance.integrity,
142
+ }))));
143
+ return Object.freeze({
144
+ revision,
145
+ snapshots: Object.freeze(snapshots),
146
+ pairs: Object.freeze(snapshots.flatMap(pairsOf)),
147
+ });
148
+ }
149
+
150
+ function detectedSurfaceIds(surfaces) {
151
+ return new Set(surfaces
152
+ .map((surface) => (typeof surface === 'string' ? surface : (surface?.detected ? surface.id : null)))
153
+ .filter(Boolean));
154
+ }
155
+
156
+ /**
157
+ * Order the inventory for presentation and derive its Access-graph attestation
158
+ * input. Detected surfaces sort first; nothing is ever removed, and every
159
+ * attestation stays `unknown` until a probe proves otherwise.
160
+ */
161
+ export function presentInventory(inventory, surfaces = []) {
162
+ const detected = detectedSurfaceIds(surfaces);
163
+ const ordered = inventory.pairs
164
+ .map((pair, index) => ({ pair, index }))
165
+ .sort((a, b) => (Number(detected.has(b.pair.surface)) - Number(detected.has(a.pair.surface)))
166
+ || (a.index - b.index))
167
+ .map(({ pair }) => pair);
168
+ if (ordered.length !== inventory.pairs.length) {
169
+ throw new Error('presentation reordered the inventory into a different pair set');
170
+ }
171
+ return Object.freeze({
172
+ pairs: Object.freeze(ordered),
173
+ attestations: Object.freeze(ordered.map((pair) => Object.freeze({
174
+ surface: pair.surface,
175
+ provider: pair.provider,
176
+ modelId: pair.modelId,
177
+ effort: pair.effort,
178
+ detectedSurface: detected.has(pair.surface),
179
+ access: UNTESTED_ACCESS,
180
+ }))),
181
+ });
182
+ }