@ikon85/agent-workflow-kit 0.33.0 → 0.34.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 (79) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +4 -3
  2. package/.agents/skills/audit-skills/SKILL.md +6 -0
  3. package/.agents/skills/board-to-waves/SKILL.md +6 -2
  4. package/.agents/skills/code-review/SKILL.md +6 -0
  5. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +11 -1
  6. package/.agents/skills/codex-adapter-sync/SKILL.md +23 -19
  7. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  8. package/.agents/skills/improve-codebase-architecture/SKILL.md +10 -1
  9. package/.agents/skills/kit-update/SKILL.md +13 -0
  10. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  11. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  12. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  13. package/.agents/skills/research/SKILL.md +6 -0
  14. package/.agents/skills/scale-check/SKILL.md +9 -7
  15. package/.agents/skills/setup-workflow/SKILL.md +47 -1
  16. package/.agents/skills/setup-workflow/board-sync.md +7 -2
  17. package/.agents/skills/setup-workflow/workflow-overview.md +1 -2
  18. package/.agents/skills/to-issues/SKILL.md +66 -8
  19. package/.agents/skills/to-waves/SKILL.md +24 -12
  20. package/.claude/skills/ask-matt/SKILL.md +4 -3
  21. package/.claude/skills/audit-skills/SKILL.md +6 -0
  22. package/.claude/skills/board-to-waves/SKILL.md +6 -2
  23. package/.claude/skills/code-review/SKILL.md +6 -0
  24. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +8 -0
  25. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +6 -0
  26. package/.claude/skills/improve-codebase-architecture/SKILL.md +6 -0
  27. package/.claude/skills/kit-update/SKILL.md +13 -0
  28. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  29. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +23 -11
  30. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +8 -0
  31. package/.claude/skills/research/SKILL.md +6 -0
  32. package/.claude/skills/scale-check/SKILL.md +9 -7
  33. package/.claude/skills/setup-workflow/SKILL.md +47 -1
  34. package/.claude/skills/setup-workflow/board-sync.md +7 -2
  35. package/.claude/skills/setup-workflow/workflow-overview.md +1 -2
  36. package/.claude/skills/to-issues/SKILL.md +66 -8
  37. package/.claude/skills/to-waves/SKILL.md +24 -12
  38. package/README.md +79 -11
  39. package/agent-workflow-kit.package.json +201 -41
  40. package/docs/adr/0005-to-issues-is-the-planning-facade.md +42 -0
  41. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +91 -0
  42. package/docs/agents/skills/local-ci.md +89 -0
  43. package/docs/agents/wave-anchor-template.md +2 -2
  44. package/docs/methodology.html +1 -1
  45. package/docs/methodology.svg +1 -1
  46. package/docs/workflow.html +2 -2
  47. package/docs/workflow.png +0 -0
  48. package/package.json +1 -1
  49. package/scripts/codex-exec.sh +47 -8
  50. package/scripts/codex-exec.test.mjs +56 -0
  51. package/scripts/test_codex_adapter_sync_contract.py +30 -15
  52. package/scripts/test_orchestrate_wave_contract.py +73 -0
  53. package/scripts/test_program_planning_contract.py +70 -0
  54. package/scripts/test_skill_portability_lint.py +54 -0
  55. package/src/cli.mjs +109 -2
  56. package/src/commands/init.mjs +17 -1
  57. package/src/commands/routing-policy-update.mjs +204 -0
  58. package/src/commands/update.mjs +22 -0
  59. package/src/lib/agentSurfaceRegistry.mjs +60 -0
  60. package/src/lib/bundle.mjs +24 -0
  61. package/src/lib/capabilityMatrix.mjs +85 -0
  62. package/src/lib/dispatchReceipt.mjs +162 -0
  63. package/src/lib/frontendWorkloads.mjs +170 -0
  64. package/src/lib/routeDispatcher.mjs +158 -0
  65. package/src/lib/routingAccessGraph.mjs +105 -0
  66. package/src/lib/routingAdapters/claude.mjs +62 -0
  67. package/src/lib/routingAdapters/codex.mjs +136 -0
  68. package/src/lib/routingCatalog.mjs +123 -0
  69. package/src/lib/routingEvidenceCache.mjs +222 -0
  70. package/src/lib/routingIntent.mjs +93 -0
  71. package/src/lib/routingPolicy.mjs +67 -0
  72. package/src/lib/routingProfile.mjs +334 -0
  73. package/src/lib/routingResolver.mjs +176 -0
  74. package/src/lib/routingSources/artificialAnalysis.mjs +129 -0
  75. package/src/lib/routingSources/benchlm.mjs +151 -0
  76. package/src/lib/routingSources/codeArena.mjs +162 -0
  77. package/src/lib/routingSources/deepswe.mjs +106 -0
  78. package/src/lib/routingSources/openhands.mjs +102 -0
  79. package/src/lib/routingSources/openhandsFrontend.mjs +135 -0
@@ -55,6 +55,17 @@ ENFORCED_CLASSES = {"generic", "vendored"}
55
55
  EXEMPT = "portability-lint: ok"
56
56
  MIRROR_XFORM_START = re.compile(r"^\s*<!--\s*mirror-xform:start(?:\s+([^>]*?))?\s*-->\s*$")
57
57
  MIRROR_XFORM_END = re.compile(r"^\s*<!--\s*mirror-xform:end\s*-->\s*$")
58
+ DIRECT_SPAWN_PATTERN = re.compile(
59
+ r"Run \*\*one read-only research subagent|"
60
+ r"Run both axes as \*\*parallel sub-agents|"
61
+ r"Spawn 3\+ sub-agents|"
62
+ r"spawn 3\+ sub-agents|"
63
+ r"make 3\+ parallel\s+`spawn_agent` calls|"
64
+ r"Then use the Agent tool|"
65
+ r"use `spawn_agent` with `agent_type: explorer`|"
66
+ r"use `spawn_agent` with only|"
67
+ r"Spin up a \*\*background agent",
68
+ )
58
69
 
59
70
  # Opaque, project-coupled board constants — meaningless in a foreign repo.
60
71
  CONSTANT_PATTERNS = [
@@ -240,6 +251,15 @@ def skill_md_set(tree: str, name: str) -> set[str]:
240
251
  return {p for p in skill_file_set(tree, name) if p.endswith(".md")}
241
252
 
242
253
 
254
+ def direct_spawn_contracts(tree: str) -> dict[str, str]:
255
+ root = REPO_ROOT / tree
256
+ return {
257
+ path.relative_to(root).as_posix(): path.read_text(encoding="utf-8")
258
+ for path in root.rglob("*.md")
259
+ if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
260
+ }
261
+
262
+
243
263
  def mirror_presence_drift(name: str, claude: set[str], codex: set[str]) -> list[str]:
244
264
  problems = [
245
265
  f"{name}: .claude/skills/{name}/{rel} has no codex mirror"
@@ -555,6 +575,40 @@ class MirrorContentParity(unittest.TestCase):
555
575
  "`<!-- mirror-xform:start <reason> -->` / `<!-- mirror-xform:end -->` "
556
576
  "regions:\n" + "\n".join(problems))
557
577
 
578
+ def test_every_codex_direct_spawn_contract_uses_the_shared_guard(self):
579
+ claude = direct_spawn_contracts(".claude/skills")
580
+ codex = direct_spawn_contracts(".agents/skills")
581
+ self.assertTrue(claude, "direct-spawn census unexpectedly empty")
582
+ self.assertEqual(set(codex), set(claude))
583
+
584
+ problems = []
585
+ required = (
586
+ "provider-neutral Routing intent",
587
+ "routeDispatcher.mjs",
588
+ "shared spawn guard",
589
+ "Dispatch receipt",
590
+ )
591
+ for relative, body in codex.items():
592
+ prose = " ".join(body.split())
593
+ missing = [fragment for fragment in required if fragment not in prose]
594
+ if missing:
595
+ problems.append(f"{relative}: missing {', '.join(missing)}")
596
+ unsupported = [
597
+ token for token in ("Agent tool", "subagent_type", "agent_type")
598
+ if token in prose
599
+ ]
600
+ if unsupported:
601
+ problems.append(
602
+ f"{relative}: unsupported Codex spawn syntax "
603
+ f"{', '.join(unsupported)}"
604
+ )
605
+ self.assertEqual(
606
+ problems,
607
+ [],
608
+ f"Codex direct-spawn guard coverage drift ({len(codex)} of "
609
+ f"{len(claude)}):\n" + "\n".join(problems),
610
+ )
611
+
558
612
 
559
613
  class MirrorParityFixture(unittest.TestCase):
560
614
  """The file-set comparison catches an orphaned mirror file (regression guard)."""
package/src/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { diff } from './commands/diff.mjs';
8
8
  import { uninstall } from './commands/uninstall.mjs';
9
9
  import { setOwnership } from './commands/own.mjs';
10
10
  import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
11
+ import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
11
12
  import { createCommandAdapter } from '../scripts/release-state.mjs';
12
13
  import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
13
14
 
@@ -29,12 +30,18 @@ p.intro('agent-workflow-kit');
29
30
 
30
31
  try {
31
32
  if (cmd === 'init') {
32
- const r = await init({ kitRoot: KIT_ROOT, consumerRoot, force });
33
+ const r = await init({
34
+ kitRoot: KIT_ROOT,
35
+ consumerRoot,
36
+ force,
37
+ routingProfile: routingProfileOptions(),
38
+ });
33
39
  p.note(
34
40
  `copied ${r.copied.length} · seeded ${r.seeded.length} stub(s)` +
35
41
  (r.skipped.length ? `\nskipped (pre-existing, use --force): ${r.skipped.join(', ')}` : ''),
36
42
  'init'
37
43
  );
44
+ printRoutingProfile(r.routingProfile);
38
45
  p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
39
46
  'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
40
47
  } else if (cmd === 'diff') {
@@ -45,9 +52,15 @@ try {
45
52
  const decide = (action, path) => decideUpdate(action, path, yes);
46
53
  const releaseIdentities = await readUpdateRelease();
47
54
  const r = await update({
48
- kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
55
+ kitRoot: KIT_ROOT,
56
+ consumerRoot,
57
+ now: stamp(),
58
+ decide,
59
+ releaseIdentities,
60
+ routingProfile: routingProfileOptions(),
49
61
  });
50
62
  printPlan(r);
63
+ printRoutingProfile(r.routingProfile);
51
64
  for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
52
65
  if (r.state === 'failed') throw new Error(renderUpdateFailure(r));
53
66
  if (r.state === 'conflicted') {
@@ -126,6 +139,100 @@ async function decideUpdate(action, path, yes) {
126
139
  throw new Error(`unknown update decision action: ${action}`);
127
140
  }
128
141
 
142
+ function routingProfileOptions() {
143
+ return {
144
+ currentSurface: currentAgentSurface(),
145
+ prompt: yes ? undefined : promptRoutingProfile,
146
+ };
147
+ }
148
+
149
+ function printRoutingProfile(result) {
150
+ if (!result) return;
151
+ const suffix = result.reasons?.length ? ` · ${result.reasons.join(', ')}` : '';
152
+ p.note(`${result.status}${suffix}`, 'routing profile');
153
+ }
154
+
155
+ async function promptRoutingProfile(question) {
156
+ if (question.kind === 'surfaces') {
157
+ return ensurePrompt(await p.multiselect({
158
+ message: question.message,
159
+ options: question.options.map(({ id, label }) => ({ value: id, label })),
160
+ initialValues: question.preselected,
161
+ required: true,
162
+ }), 'surface selection');
163
+ }
164
+ if (question.kind === 'autonomy') {
165
+ return ensurePrompt(await p.select({
166
+ message: question.message,
167
+ options: question.options,
168
+ }), 'switching choice');
169
+ }
170
+ if (question.kind === 'activation') {
171
+ const draft = question.advancedDraft ? ' · advanced draft ready' : '';
172
+ return ensurePrompt(await p.select({
173
+ message: `${question.message}${draft}`,
174
+ options: [
175
+ { value: 'approve', label: 'Approve' },
176
+ { value: 'safe-current-surface', label: 'Safe current surface' },
177
+ { value: 'back', label: 'Back' },
178
+ { value: 'advanced', label: 'Advanced' },
179
+ { value: 'decline', label: 'Decline' },
180
+ ],
181
+ }), 'activation choice');
182
+ }
183
+ if (question.kind === 'advanced') {
184
+ const optimization = ensurePrompt(await p.select({
185
+ message: question.message,
186
+ options: [
187
+ { value: 'balanced', label: 'Balanced' },
188
+ { value: 'quality', label: 'Quality' },
189
+ { value: 'cost', label: 'Cost' },
190
+ ],
191
+ initialValue: question.draft?.optimization ?? 'balanced',
192
+ }), 'advanced choice');
193
+ return { ...question.draft, optimization };
194
+ }
195
+ if (question.kind === 'reconcile') {
196
+ if (question.delta.type === 'missing-profile' || question.delta.type === 'invalid-profile') {
197
+ return ensurePrompt(await p.select({
198
+ message: question.message,
199
+ options: [
200
+ { value: 'review', label: 'Review routing choices now' },
201
+ { value: 'decline', label: 'Not now' },
202
+ ],
203
+ }), 'routing migration choice');
204
+ }
205
+ const additions = question.delta.newSurfaces;
206
+ if (additions.length) {
207
+ const removed = question.delta.removedSurfaces.map(({ label }) => label);
208
+ const change = [
209
+ `new: ${additions.map(({ label }) => label).join(', ')}`,
210
+ ...(removed.length ? [`unavailable: ${removed.join(', ')}`] : []),
211
+ ].join(' · ');
212
+ const addSurfaceIds = ensurePrompt(await p.multiselect({
213
+ message: `Routing choices changed — ${change}`,
214
+ options: additions.map(({ id, label }) => ({ value: id, label })),
215
+ initialValues: [],
216
+ required: false,
217
+ }), 'routing reconcile choice');
218
+ return { action: 'apply', addSurfaceIds };
219
+ }
220
+ const removed = question.delta.removedSurfaces.map(({ label }) => label).join(', ');
221
+ const message = removed
222
+ ? `Remove unavailable agent app from routing: ${removed}?`
223
+ : 'Refresh the routing profile registry revision?';
224
+ return ensurePrompt(await p.confirm({ message }), 'routing reconcile choice')
225
+ ? { action: 'apply', addSurfaceIds: [] }
226
+ : { action: 'decline' };
227
+ }
228
+ throw new Error(`unknown routing profile question: ${question.kind}`);
229
+ }
230
+
231
+ function ensurePrompt(value, label) {
232
+ if (p.isCancel(value)) throw new Error(`${label} cancelled`);
233
+ return value;
234
+ }
235
+
129
236
  async function readUpdateRelease() {
130
237
  const adapter = await createCommandAdapter({
131
238
  repoRoot: KIT_ROOT,
@@ -12,6 +12,9 @@ import {
12
12
  CONSUMER_ORIGIN,
13
13
  PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME,
14
14
  } from '../lib/manifest.mjs';
15
+ import {
16
+ inspectRoutingProfile, reconcileRoutingProfile, setupRoutingProfile,
17
+ } from '../lib/routingProfile.mjs';
15
18
 
16
19
  const exists = (p) => access(p).then(() => true, () => false);
17
20
 
@@ -24,7 +27,7 @@ const exists = (p) => access(p).then(() => true, () => false);
24
27
  * already exist — idempotent),
25
28
  * - never touches board-sync.md / CLAUDE.md / AGENTS.md.
26
29
  */
27
- export async function init({ kitRoot, consumerRoot, force = false }) {
30
+ export async function init({ kitRoot, consumerRoot, force = false, routingProfile }) {
28
31
  const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
29
32
  if (!pkg) throw new Error('kit package manifest not found');
30
33
  const prior = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
@@ -80,5 +83,18 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
80
83
  result.seeded.push(stub);
81
84
  }
82
85
 
86
+ if (routingProfile) {
87
+ const options = { consumerRoot, ...routingProfile };
88
+ const inspection = await inspectRoutingProfile(options);
89
+ result.routingProfile = prior
90
+ ? await reconcileRoutingProfile(options, inspection)
91
+ : (inspection.status === 'still valid'
92
+ ? { status: 'still valid' }
93
+ : await setupRoutingProfile({
94
+ ...options,
95
+ expectedFingerprint: inspection.fingerprint,
96
+ }));
97
+ }
98
+
83
99
  return result;
84
100
  }
@@ -0,0 +1,204 @@
1
+ import {
2
+ buildEvidenceCatalogFromSources,
3
+ commitRoutingEvidenceCache,
4
+ routingEvidenceSourcesFromCache,
5
+ validateRoutingEvidenceSourceSnapshot,
6
+ } from '../lib/routingEvidenceCache.mjs';
7
+
8
+ function message(error) {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ function observationDiff(previous, next) {
13
+ const before = new Map(previous.map((entry) => [entry.id, entry]));
14
+ const after = new Map(next.map((entry) => [entry.id, entry]));
15
+ const added = [...after.keys()].filter((id) => !before.has(id)).sort();
16
+ const removed = [...before.keys()].filter((id) => !after.has(id)).sort();
17
+ const changed = [...after.keys()].filter(
18
+ (id) => before.has(id)
19
+ && JSON.stringify(before.get(id)) !== JSON.stringify(after.get(id)),
20
+ ).sort();
21
+ return { added, changed, removed };
22
+ }
23
+
24
+ function validateSource(source, index) {
25
+ if (!source || typeof source !== 'object') {
26
+ throw new TypeError(`sources[${index}] must be an object`);
27
+ }
28
+ const { adapter, load } = source;
29
+ if (!adapter || typeof adapter !== 'object') {
30
+ throw new TypeError(`sources[${index}].adapter must be an object`);
31
+ }
32
+ for (const field of ['sourceId', 'owner', 'artifactUrl']) {
33
+ if (typeof adapter[field] !== 'string' || adapter[field].trim() === '') {
34
+ throw new TypeError(`sources[${index}].adapter.${field} must be a non-empty string`);
35
+ }
36
+ }
37
+ if (typeof adapter.ingest !== 'function') {
38
+ throw new TypeError(`sources[${index}].adapter.ingest must be a function`);
39
+ }
40
+ if (typeof load !== 'function') {
41
+ throw new TypeError(`sources[${index}].load must be a function`);
42
+ }
43
+ }
44
+
45
+ function modelsFor(observations, catalog) {
46
+ const identities = new Set(
47
+ observations.map(({ providerId, modelId }) => `${providerId}:${modelId}`),
48
+ );
49
+ return catalog.models.filter(
50
+ ({ providerId, modelId }) => identities.has(`${providerId}:${modelId}`),
51
+ );
52
+ }
53
+
54
+ function legacySourceSnapshots(cache) {
55
+ const groups = new Map();
56
+ for (const observation of cache.catalog.observations) {
57
+ const { source, freshness } = observation;
58
+ if (!source?.id || !source?.snapshotHash) continue;
59
+ const existing = groups.get(source.id);
60
+ if (existing && existing.snapshotHash !== source.snapshotHash) {
61
+ throw new TypeError(
62
+ `legacy source ${source.id} contains several snapshot hashes`,
63
+ );
64
+ }
65
+ const observations = existing?.observations ?? [];
66
+ observations.push(observation);
67
+ groups.set(source.id, {
68
+ sourceId: source.id,
69
+ owner: source.owner,
70
+ artifactUrl: source.url,
71
+ snapshotHash: source.snapshotHash,
72
+ observedAt: freshness.observedAt,
73
+ expiresAt: freshness.expiresAt,
74
+ models: [],
75
+ observations,
76
+ signals: [],
77
+ });
78
+ }
79
+ return [...groups.values()].map((source, index) =>
80
+ validateRoutingEvidenceSourceSnapshot({
81
+ ...source,
82
+ models: modelsFor(source.observations, cache.catalog),
83
+ }, index));
84
+ }
85
+
86
+ function cachedSources(cache) {
87
+ const explicit = routingEvidenceSourcesFromCache(cache);
88
+ return explicit.length > 0 ? explicit : legacySourceSnapshots(cache);
89
+ }
90
+
91
+ function cachedStatus(adapter, prior, reason) {
92
+ return {
93
+ sourceId: adapter.sourceId,
94
+ status: prior ? 'cached' : 'quarantined',
95
+ reason,
96
+ cachedAt: prior?.observedAt,
97
+ };
98
+ }
99
+
100
+ export async function refreshRoutingEvidence({
101
+ sources,
102
+ currentCache,
103
+ expectedRevision,
104
+ refreshedAt,
105
+ expiresAt,
106
+ onProgress = () => {},
107
+ }) {
108
+ if (!Array.isArray(sources)) throw new TypeError('sources must be an array');
109
+ sources.forEach(validateSource);
110
+ const requestedIds = new Set();
111
+ for (const { adapter } of sources) {
112
+ if (requestedIds.has(adapter.sourceId)) {
113
+ throw new TypeError(`duplicate routing evidence source: ${adapter.sourceId}`);
114
+ }
115
+ requestedIds.add(adapter.sourceId);
116
+ }
117
+
118
+ const sourceState = new Map(
119
+ cachedSources(currentCache).map((source) => [source.sourceId, source]),
120
+ );
121
+ const statuses = [];
122
+ const quarantines = [];
123
+
124
+ for (const { adapter, load } of sources) {
125
+ const prior = sourceState.get(adapter.sourceId);
126
+ onProgress({ sourceId: adapter.sourceId, status: 'loading' });
127
+ let loaded;
128
+ try {
129
+ loaded = await load();
130
+ } catch (error) {
131
+ const reason = `load failed: ${message(error)}`;
132
+ const status = cachedStatus(adapter, prior, reason);
133
+ if (!prior) quarantines.push({ sourceId: adapter.sourceId, reason });
134
+ statuses.push(status);
135
+ onProgress(status);
136
+ continue;
137
+ }
138
+
139
+ try {
140
+ const ingested = adapter.ingest({
141
+ payload: loaded.payload,
142
+ snapshotHash: loaded.snapshotHash,
143
+ observedAt: refreshedAt,
144
+ expiresAt,
145
+ });
146
+ if (ingested.sourceId !== adapter.sourceId) {
147
+ throw new TypeError(
148
+ `source identity changed from ${adapter.sourceId} to ${ingested.sourceId}`,
149
+ );
150
+ }
151
+ const candidate = validateRoutingEvidenceSourceSnapshot({
152
+ sourceId: adapter.sourceId,
153
+ owner: adapter.owner,
154
+ artifactUrl: adapter.artifactUrl,
155
+ snapshotHash: loaded.snapshotHash,
156
+ observedAt: refreshedAt,
157
+ expiresAt,
158
+ models: ingested.models,
159
+ observations: ingested.observations,
160
+ signals: ingested.signals ?? [],
161
+ });
162
+ const prospective = new Map(sourceState);
163
+ prospective.set(adapter.sourceId, candidate);
164
+ buildEvidenceCatalogFromSources(
165
+ [...prospective.values()],
166
+ `catalog-r${expectedRevision + 1}`,
167
+ );
168
+ sourceState.set(adapter.sourceId, candidate);
169
+ const status = { sourceId: adapter.sourceId, status: 'live' };
170
+ statuses.push(status);
171
+ onProgress(status);
172
+ } catch (error) {
173
+ const reason = message(error);
174
+ const status = cachedStatus(adapter, prior, reason);
175
+ quarantines.push({ sourceId: adapter.sourceId, reason });
176
+ onProgress({ sourceId: adapter.sourceId, status: 'quarantined', reason });
177
+ statuses.push(status);
178
+ if (prior) onProgress(status);
179
+ }
180
+ }
181
+
182
+ const nextSources = [...sourceState.values()];
183
+ const nextCatalog = buildEvidenceCatalogFromSources(
184
+ nextSources,
185
+ `catalog-r${expectedRevision + 1}`,
186
+ );
187
+ const cache = commitRoutingEvidenceCache({
188
+ current: currentCache,
189
+ expectedRevision,
190
+ nextCatalog,
191
+ nextSources,
192
+ refreshedAt,
193
+ expiresAt,
194
+ });
195
+ return {
196
+ cache,
197
+ sources: statuses,
198
+ quarantines,
199
+ diff: observationDiff(
200
+ currentCache.catalog.observations,
201
+ nextCatalog.observations,
202
+ ),
203
+ };
204
+ }
@@ -8,6 +8,9 @@ import { reconcile } from '../lib/updateReconcile.mjs';
8
8
  import {
9
9
  CONSUMER_MANIFEST_NAME, PACKAGE_MANIFEST_NAME, readManifest,
10
10
  } from '../lib/manifest.mjs';
11
+ import {
12
+ inspectRoutingProfile, reconcileRoutingProfile,
13
+ } from '../lib/routingProfile.mjs';
11
14
 
12
15
  const RELEASE_NAME = '@ikon85/agent-workflow-kit';
13
16
 
@@ -22,6 +25,25 @@ export function renderUpdateFailure(result) {
22
25
  * checking -> preview/awaiting_decision -> staging -> verifying -> terminal state.
23
26
  */
24
27
  export async function update(options) {
28
+ const preflight = options.routingProfile
29
+ ? await inspectRoutingProfile({ consumerRoot: options.consumerRoot, ...options.routingProfile })
30
+ : null;
31
+ const result = await updatePackage(options);
32
+ if (!preflight || options.dryRun || result.state !== 'applied') return result;
33
+ const inspection = await inspectRoutingProfile({
34
+ consumerRoot: options.consumerRoot,
35
+ ...options.routingProfile,
36
+ });
37
+ return {
38
+ ...result,
39
+ routingProfile: await reconcileRoutingProfile(
40
+ { consumerRoot: options.consumerRoot, ...options.routingProfile },
41
+ inspection,
42
+ ),
43
+ };
44
+ }
45
+
46
+ async function updatePackage(options) {
25
47
  const {
26
48
  kitRoot, consumerRoot, decide = () => false, dryRun = false,
27
49
  releaseIdentities, verify = verifyCandidate, activate = activateCandidate,
@@ -0,0 +1,60 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { delimiter, join } from 'node:path';
3
+
4
+ export const AGENT_SURFACE_REGISTRY_REVISION = 1;
5
+
6
+ export const AGENT_SURFACE_REGISTRY = Object.freeze([
7
+ Object.freeze({
8
+ id: 'claude-code',
9
+ label: 'Claude Code',
10
+ command: 'claude',
11
+ activeEnvironment: Object.freeze(['CLAUDE_CODE_ENTRYPOINT']),
12
+ adapter: Object.freeze({
13
+ providers: Object.freeze(['anthropic']),
14
+ transports: Object.freeze(['native', 'codex-cli']),
15
+ enforcement: Object.freeze({ model: 'named-agent', effort: 'named-agent' }),
16
+ }),
17
+ }),
18
+ Object.freeze({
19
+ id: 'codex',
20
+ label: 'Codex',
21
+ command: 'codex',
22
+ activeEnvironment: Object.freeze(['CODEX_THREAD_ID']),
23
+ adapter: Object.freeze({
24
+ providers: Object.freeze(['openai']),
25
+ transports: Object.freeze(['native', 'claude-cli']),
26
+ enforcement: Object.freeze({ model: 'per-spawn', effort: 'per-spawn' }),
27
+ }),
28
+ }),
29
+ ]);
30
+
31
+ async function commandOnPath(command, env = process.env) {
32
+ for (const directory of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
33
+ if (await access(join(directory, command)).then(() => true, () => false)) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ export async function detectAgentSurfaces({
39
+ registry = AGENT_SURFACE_REGISTRY,
40
+ commandAvailable = commandOnPath,
41
+ } = {}) {
42
+ return Promise.all(registry.map(async (surface) => Object.freeze({
43
+ id: surface.id,
44
+ label: surface.label,
45
+ detected: await commandAvailable(surface.command),
46
+ adapter: surface.adapter,
47
+ })));
48
+ }
49
+
50
+ export function surfaceById(id, registry = AGENT_SURFACE_REGISTRY) {
51
+ return registry.find((surface) => surface.id === id);
52
+ }
53
+
54
+ export function currentAgentSurface({
55
+ registry = AGENT_SURFACE_REGISTRY,
56
+ env = process.env,
57
+ } = {}) {
58
+ return registry.find((surface) =>
59
+ surface.activeEnvironment.some((name) => typeof env[name] === 'string' && env[name] !== ''))?.id;
60
+ }
@@ -77,6 +77,30 @@ export const HELPER_FILES = [
77
77
  { path: 'src/lib/reportValidator.mjs', kind: 'script', mode: 0o644 },
78
78
  // Fail-closed orchestration selector and its host-inventory adapters.
79
79
  { path: 'src/lib/capabilityMatrix.mjs', kind: 'script', mode: 0o644 },
80
+ // Provider-neutral routing runtime: one Evidence catalog, Access graph,
81
+ // Routing policy, resolver, spawn guard, receipt v2, and surface attestations.
82
+ // These modules form one consumer unit; omitting one leaves shipped dispatch
83
+ // prose pointing at a resolver or adapter that cannot execute.
84
+ { path: 'src/commands/routing-policy-update.mjs', kind: 'script', mode: 0o644 },
85
+ { path: 'src/lib/agentSurfaceRegistry.mjs', kind: 'script', mode: 0o644 },
86
+ { path: 'src/lib/dispatchReceipt.mjs', kind: 'script', mode: 0o644 },
87
+ { path: 'src/lib/frontendWorkloads.mjs', kind: 'script', mode: 0o644 },
88
+ { path: 'src/lib/routeDispatcher.mjs', kind: 'script', mode: 0o644 },
89
+ { path: 'src/lib/routingAccessGraph.mjs', kind: 'script', mode: 0o644 },
90
+ { path: 'src/lib/routingAdapters/claude.mjs', kind: 'script', mode: 0o644 },
91
+ { path: 'src/lib/routingAdapters/codex.mjs', kind: 'script', mode: 0o644 },
92
+ { path: 'src/lib/routingCatalog.mjs', kind: 'script', mode: 0o644 },
93
+ { path: 'src/lib/routingEvidenceCache.mjs', kind: 'script', mode: 0o644 },
94
+ { path: 'src/lib/routingIntent.mjs', kind: 'script', mode: 0o644 },
95
+ { path: 'src/lib/routingPolicy.mjs', kind: 'script', mode: 0o644 },
96
+ { path: 'src/lib/routingProfile.mjs', kind: 'script', mode: 0o644 },
97
+ { path: 'src/lib/routingResolver.mjs', kind: 'script', mode: 0o644 },
98
+ { path: 'src/lib/routingSources/artificialAnalysis.mjs', kind: 'script', mode: 0o644 },
99
+ { path: 'src/lib/routingSources/benchlm.mjs', kind: 'script', mode: 0o644 },
100
+ { path: 'src/lib/routingSources/codeArena.mjs', kind: 'script', mode: 0o644 },
101
+ { path: 'src/lib/routingSources/deepswe.mjs', kind: 'script', mode: 0o644 },
102
+ { path: 'src/lib/routingSources/openhands.mjs', kind: 'script', mode: 0o644 },
103
+ { path: 'src/lib/routingSources/openhandsFrontend.mjs', kind: 'script', mode: 0o644 },
80
104
  // Main-thread recon boundary shared by every orchestration path.
81
105
  { path: 'src/lib/reconcileReconReports.mjs', kind: 'script', mode: 0o644 },
82
106
  // Atomic compare-and-set wave claim: two sessions cannot orchestrate the same
@@ -62,6 +62,91 @@ export const capabilityAdapter = Object.freeze({
62
62
  codex: adaptInventory,
63
63
  });
64
64
 
65
+ const ROUTE_IDENTITY_FIELDS = [
66
+ 'id',
67
+ 'surfaceId',
68
+ 'providerId',
69
+ 'modelId',
70
+ 'transportId',
71
+ ];
72
+
73
+ const ROUTE_ENFORCEMENT_METHODS = [
74
+ 'per-spawn',
75
+ 'named-agent',
76
+ 'session-default',
77
+ 'none',
78
+ ];
79
+
80
+ const ROUTE_PRECEDENCE = [
81
+ 'explicit-argument',
82
+ 'agent-definition-over-environment',
83
+ 'environment-over-agent-definition',
84
+ 'session-default',
85
+ ];
86
+
87
+ function routingControl(control) {
88
+ const source = control && typeof control === 'object' && !Array.isArray(control)
89
+ ? control
90
+ : {};
91
+ return {
92
+ method: ROUTE_ENFORCEMENT_METHODS.includes(source.method) ? source.method : UNKNOWN,
93
+ enforced: triState(source.enforced),
94
+ precedence: ROUTE_PRECEDENCE.includes(source.precedence)
95
+ ? source.precedence
96
+ : UNKNOWN,
97
+ applied: typeof source.applied === 'string' && source.applied !== ''
98
+ ? source.applied
99
+ : UNKNOWN,
100
+ };
101
+ }
102
+
103
+ function routingPath(path) {
104
+ const source = path && typeof path === 'object' && !Array.isArray(path) ? path : {};
105
+ const normalized = Object.fromEntries(ROUTE_IDENTITY_FIELDS.map((field) => [
106
+ field,
107
+ typeof source[field] === 'string' && source[field] !== '' ? source[field] : UNKNOWN,
108
+ ]));
109
+ normalized.detected = triState(source.detected);
110
+ normalized.callable = triState(source.callable);
111
+ normalized.permitted = triState(source.permitted);
112
+ normalized.model = routingControl(source.model);
113
+ normalized.effort = routingControl(source.effort);
114
+ const failures = [];
115
+ if (ROUTE_IDENTITY_FIELDS.some((field) => normalized[field] === UNKNOWN)) {
116
+ failures.push('route identity is incomplete');
117
+ }
118
+ if (normalized.detected !== true) failures.push('transport is not detected');
119
+ if (normalized.callable !== true) failures.push('transport is not callable');
120
+ if (normalized.permitted !== true) failures.push('transport is not permitted');
121
+ for (const field of ['model', 'effort']) {
122
+ if (normalized[field].enforced !== true || normalized[field].method === 'none'
123
+ || normalized[field].method === UNKNOWN) {
124
+ failures.push(`${field} control is not enforced`);
125
+ }
126
+ if (normalized[field].precedence === UNKNOWN) {
127
+ failures.push(`${field} environment precedence is unverified`);
128
+ }
129
+ if (normalized[field].applied === UNKNOWN) {
130
+ failures.push(`${field} applied value is unverified`);
131
+ }
132
+ }
133
+ normalized.verified = failures.length === 0;
134
+ normalized.verificationFailures = failures;
135
+ return Object.freeze(normalized);
136
+ }
137
+
138
+ export function adaptClaudeRoutingInventory(inventory) {
139
+ const source = inventory && typeof inventory === 'object' && !Array.isArray(inventory)
140
+ ? inventory
141
+ : {};
142
+ return Object.freeze({
143
+ contractVersion: 1,
144
+ paths: source.contractVersion === 1 && Array.isArray(source.paths)
145
+ ? Object.freeze(source.paths.map(routingPath))
146
+ : Object.freeze([]),
147
+ });
148
+ }
149
+
65
150
  function proves(tool, capability) {
66
151
  return tool?.callable === true
67
152
  && tool.permitted === true