@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,165 @@
1
1
  export const EVIDENCE_CATALOG_VERSION = 1;
2
+ export const UNKNOWN_EFFORT = 'unknown';
3
+
4
+ // Taxonomy — docs/research/agent-task-taxonomy-benchmark-coverage.md §1. The
5
+ // identity stays `workload:domain:axis`, and the frontend vocabulary is a strict
6
+ // subset of it: the generalization is additive.
7
+ const FRONTEND_WORKLOADS = Object.freeze(['frontend-greenfield', 'frontend-repository-repair']);
8
+
9
+ export const EVIDENCE_WORKLOADS = Object.freeze([
10
+ ...FRONTEND_WORKLOADS,
11
+ 'repository-repair', 'repository-comprehension', 'code-transformation', 'test-authoring',
12
+ 'greenfield-application', 'algorithmic-synthesis', 'terminal-operations', 'tool-orchestration',
13
+ 'knowledge-deliverable', 'architecture-reasoning', 'long-horizon-autonomy',
14
+ 'long-context-operation',
15
+ ]);
16
+
17
+ // A domain segment exists only where the owner publishes a separate score for it
18
+ // (§1.3); everything else stays `general`, so no aggregate is laundered.
19
+ const FRONTEND_DOMAINS = Object.freeze([
20
+ 'general', 'reference-design', 'marketing', 'analytics',
21
+ 'product', 'game', 'simulation', 'editor',
22
+ ]);
23
+ const GENERAL_DOMAIN = Object.freeze(['general']);
24
+ const TOOL_ORCHESTRATION_DOMAINS = Object.freeze([
25
+ 'general', 'airline', 'retail', 'telecom', 'banking',
26
+ ]);
27
+ // Four axes stay frontend-scoped (§1.4); `functional` is the general
28
+ // executable-verifier axis, exactly how `openhands-frontend` already uses it.
29
+ const FRONTEND_SCOPED_AXES = Object.freeze([
30
+ 'visual-fidelity', 'visual-preference', 'accessibility', 'responsive',
31
+ ]);
32
+ const GENERAL_AXES = Object.freeze([
33
+ 'functional', 'rubric-quality', 'answer-accuracy', 'blind-preference',
34
+ 'policy-adherence', 'time-horizon', 'context-retention',
35
+ ]);
36
+ export const EVIDENCE_AXES = Object.freeze([...GENERAL_AXES, ...FRONTEND_SCOPED_AXES]);
37
+ export const FRONTEND_QUALITY_AXES = Object.freeze(['functional', ...FRONTEND_SCOPED_AXES]);
38
+
39
+ // Owners publish cost per attempted task or per whole run (§6); a per-success
40
+ // figure is Kit-derived and says so. Currency stays its own field, so `USD` plus
41
+ // `attempt` is the researched `usd-per-attempt`.
42
+ export const EVIDENCE_COST_UNITS = Object.freeze(['attempt', 'run', 'success-derived']);
43
+ const DERIVED_COST_UNIT = 'success-derived';
44
+
45
+ function requireWorkload(workload) {
46
+ if (!EVIDENCE_WORKLOADS.includes(workload)) {
47
+ throw new TypeError(`unknown evidence workload: ${workload}`);
48
+ }
49
+ return FRONTEND_WORKLOADS.includes(workload);
50
+ }
51
+
52
+ export function evidenceDomainsFor(workload) {
53
+ if (requireWorkload(workload)) return FRONTEND_DOMAINS;
54
+ return workload === 'tool-orchestration' ? TOOL_ORCHESTRATION_DOMAINS : GENERAL_DOMAIN;
55
+ }
56
+
57
+ export function evidenceAxesFor(workload) {
58
+ return requireWorkload(workload) ? EVIDENCE_AXES : GENERAL_AXES;
59
+ }
60
+
61
+ export function evidenceIdentity({ workload, domain = 'general', axis }) {
62
+ if (!evidenceDomainsFor(workload).includes(domain)) {
63
+ throw new TypeError(`evidence domain ${domain} is not scored separately for ${workload}`);
64
+ }
65
+ if (!evidenceAxesFor(workload).includes(axis)) {
66
+ throw new TypeError(`evidence axis ${axis} is not defined for ${workload}`);
67
+ }
68
+ return `${workload}:${domain}:${axis}`;
69
+ }
70
+
71
+ export function parseEvidenceIdentity(identity) {
72
+ string(identity, 'evidence identity');
73
+ const [workload, domain, axis, ...rest] = identity.split(':');
74
+ if (rest.length > 0 || !workload || !domain || !axis) {
75
+ throw new TypeError(`evidence identity must name workload, domain, and axis: ${identity}`);
76
+ }
77
+ evidenceIdentity({ workload, domain, axis });
78
+ return Object.freeze({ workload, domain, axis });
79
+ }
80
+
81
+ // The three-part decisiveness test (§0, §4): a source is decisive only when it
82
+ // measures the exact triple and preserves effort and harness identity, and a
83
+ // false flag names the collapsed dimension rather than hiding it in a boolean.
84
+ // `freshness.maxAgeDays` is a Kit-side policy per source: almost no owner
85
+ // publishes a cadence (§7.8), so the expiry is the Kit's decision, never read
86
+ // from the owner.
87
+ function sourceClaim(sourceId, flags, maxAgeDays, reason) {
88
+ const [measuresTriple, preservesEffort, preservesHarness] = flags;
89
+ const dimensions = ['triple', 'effort', 'harness'];
90
+ return Object.freeze({
91
+ sourceId,
92
+ measuresTriple,
93
+ preservesEffort,
94
+ preservesHarness,
95
+ collapsedDimensions: Object.freeze(dimensions.filter((_, index) => !flags[index])),
96
+ freshness: Object.freeze({ maxAgeDays, basis: 'kit-policy' }),
97
+ reason,
98
+ });
99
+ }
100
+
101
+ // [sourceId, [measuresTriple, preservesEffort, preservesHarness], maxAgeDays, reason]
102
+ const SOURCE_CLAIM_ROWS = Object.freeze([
103
+ ['deepswe', [true, true, true], 30, 'Every row names effort, harness and run config.'],
104
+ ['artificial-analysis-coding-agents', [true, false, true], 30,
105
+ 'The Coding Agent Index runs each agent on its default reasoning effort settings.'],
106
+ ['openhands-evaluation', [true, false, true], 60,
107
+ 'The harness is named per row, but reasoning effort is not a reported dimension.'],
108
+ ['code-arena-webdev', [true, false, true], 30,
109
+ 'Per-domain boards with the harness in the model label, but no reasoning-effort column.'],
110
+ ['openhands-frontend', [true, false, true], 60, 'Names its SDK harness, not the effort.'],
111
+ ['benchlm', [false, false, false], 7, 'An aggregator: no triple, no effort, no harness.'],
112
+ ['vision2web', [true, false, false], 30,
113
+ "The active season's leaderboard is empty, so no row preserves harness or effort."],
114
+ ['design2code', [true, false, false], 30, 'A static artifact: no harness, no effort.'],
115
+ ]);
116
+
117
+ export const EVIDENCE_SOURCE_CLAIMS = Object.freeze(Object.fromEntries(
118
+ SOURCE_CLAIM_ROWS.map((row) => [row[0], sourceClaim(...row)]),
119
+ ));
120
+
121
+ export function evidenceSourceClaim(sourceId) {
122
+ const claim = EVIDENCE_SOURCE_CLAIMS[sourceId];
123
+ if (!claim) throw new TypeError(`unknown evidence source: ${sourceId}`);
124
+ return claim;
125
+ }
126
+
127
+ export function isDecisiveEvidence(claim) {
128
+ object(claim, 'evidence claim');
129
+ return claim.measuresTriple && claim.preservesEffort && claim.preservesHarness;
130
+ }
131
+
132
+ export function evidenceFreshness({ sourceId, observedAt }) {
133
+ const { freshness } = evidenceSourceClaim(sourceId);
134
+ const observed = Date.parse(timestamp(observedAt, 'observedAt'));
135
+ const window = freshness.maxAgeDays * 24 * 60 * 60 * 1000;
136
+ return Object.freeze({
137
+ observedAt,
138
+ expiresAt: new Date(observed + window).toISOString(),
139
+ maxAgeDays: freshness.maxAgeDays,
140
+ basis: freshness.basis,
141
+ });
142
+ }
143
+
144
+ export function assertPublishedEffort({ sourceId, effort }) {
145
+ const { preservesEffort } = evidenceSourceClaim(sourceId);
146
+ string(effort, `${sourceId} effort`);
147
+ if (!preservesEffort && effort !== UNKNOWN_EFFORT) {
148
+ throw new TypeError(`${sourceId} does not preserve reasoning effort: effort must be `
149
+ + `${UNKNOWN_EFFORT}, found ${effort}`);
150
+ }
151
+ if (preservesEffort && effort === UNKNOWN_EFFORT) {
152
+ throw new TypeError(`${sourceId} preserves reasoning effort: a row must name it`);
153
+ }
154
+ return effort;
155
+ }
156
+
157
+ export function assertCostUnit(unit, field = 'cost.unit') {
158
+ if (!EVIDENCE_COST_UNITS.includes(unit)) {
159
+ throw new TypeError(`${field} must be one of: ${EVIDENCE_COST_UNITS.join(', ')}`);
160
+ }
161
+ return unit;
162
+ }
2
163
 
3
164
  function object(value, field) {
4
165
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -21,14 +182,13 @@ function number(value, field) {
21
182
 
22
183
  function timestamp(value, field) {
23
184
  string(value, field);
24
- const parsed = Date.parse(value);
25
- if (!Number.isFinite(parsed)) throw new TypeError(`${field} must be an ISO timestamp`);
185
+ if (!Number.isFinite(Date.parse(value))) {
186
+ throw new TypeError(`${field} must be an ISO timestamp`);
187
+ }
26
188
  return value;
27
189
  }
28
190
 
29
- function clone(value) {
30
- return structuredClone(value);
31
- }
191
+ const clone = (value) => structuredClone(value);
32
192
 
33
193
  function deepFreeze(value) {
34
194
  if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
@@ -41,12 +201,40 @@ const identity = ({ providerId, modelId }) => `${providerId}:${modelId}`;
41
201
 
42
202
  function validateModel(model, index) {
43
203
  object(model, `models[${index}]`);
204
+ const field = `models[${index}]`;
44
205
  return {
45
- providerId: string(model.providerId, `models[${index}].providerId`),
46
- modelId: string(model.modelId, `models[${index}].modelId`),
206
+ providerId: string(model.providerId, `${field}.providerId`),
207
+ modelId: string(model.modelId, `${field}.modelId`),
47
208
  };
48
209
  }
49
210
 
211
+ // A taxonomy identity is checked against the researched vocabulary; a colon-free
212
+ // workload is a legacy aggregate that can never satisfy an evidence selection.
213
+ const isTaxonomyIdentity = (workload) => workload.includes(':');
214
+
215
+ function validateWorkloadIdentity(value, field) {
216
+ const workload = string(value, field);
217
+ if (isTaxonomyIdentity(workload)) parseEvidenceIdentity(workload);
218
+ return workload;
219
+ }
220
+
221
+ // An observation claiming a taxonomy identity carries the whole taxonomy
222
+ // contract, cost unit included; a legacy aggregate keeps its free-form unit.
223
+ function validateCost(cost, field, taxonomy) {
224
+ if (number(cost.amount, `${field}.amount`) < 0) {
225
+ throw new TypeError(`${field}.amount must be non-negative`);
226
+ }
227
+ string(cost.currency, `${field}.currency`);
228
+ if (!taxonomy) return string(cost.unit, `${field}.unit`);
229
+ assertCostUnit(cost.unit, `${field}.unit`);
230
+ if ((cost.derived === true) !== (cost.unit === DERIVED_COST_UNIT)) {
231
+ throw new TypeError(
232
+ `${field}: a per-completed-task value is Kit-derived and must carry derived: true`,
233
+ );
234
+ }
235
+ return cost.unit;
236
+ }
237
+
50
238
  function validateObservation(entry, index, knownModels) {
51
239
  const field = `observations[${index}]`;
52
240
  object(entry, field);
@@ -55,7 +243,7 @@ function validateObservation(entry, index, knownModels) {
55
243
  providerId: string(entry.providerId, `${field}.providerId`),
56
244
  modelId: string(entry.modelId, `${field}.modelId`),
57
245
  effort: string(entry.effort, `${field}.effort`),
58
- workload: string(entry.workload, `${field}.workload`),
246
+ workload: validateWorkloadIdentity(entry.workload, `${field}.workload`),
59
247
  harness: clone(object(entry.harness, `${field}.harness`)),
60
248
  score: number(entry.score, `${field}.score`),
61
249
  source: clone(object(entry.source, `${field}.source`)),
@@ -65,12 +253,9 @@ function validateObservation(entry, index, knownModels) {
65
253
  };
66
254
  string(observation.harness.id, `${field}.harness.id`);
67
255
  string(observation.harness.version, `${field}.harness.version`);
68
- string(observation.source.owner, `${field}.source.owner`);
69
- string(observation.source.id, `${field}.source.id`);
70
- string(observation.source.url, `${field}.source.url`);
71
- string(observation.source.benchmark, `${field}.source.benchmark`);
72
- string(observation.source.version, `${field}.source.version`);
73
- string(observation.source.snapshotHash, `${field}.source.snapshotHash`);
256
+ for (const key of ['owner', 'id', 'url', 'benchmark', 'version', 'snapshotHash']) {
257
+ string(observation.source[key], `${field}.source.${key}`);
258
+ }
74
259
  string(observation.uncertainty.kind, `${field}.uncertainty.kind`);
75
260
  number(observation.uncertainty.value, `${field}.uncertainty.value`);
76
261
  const observedAt = timestamp(observation.freshness.observedAt, `${field}.freshness.observedAt`);
@@ -78,10 +263,7 @@ function validateObservation(entry, index, knownModels) {
78
263
  if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
79
264
  throw new TypeError(`${field}.freshness.expiresAt must follow observedAt`);
80
265
  }
81
- const amount = number(observation.cost.amount, `${field}.cost.amount`);
82
- if (amount < 0) throw new TypeError(`${field}.cost.amount must be non-negative`);
83
- string(observation.cost.currency, `${field}.cost.currency`);
84
- string(observation.cost.unit, `${field}.cost.unit`);
266
+ validateCost(observation.cost, `${field}.cost`, isTaxonomyIdentity(observation.workload));
85
267
  if (!knownModels.has(identity(observation))) {
86
268
  throw new TypeError(`${field} references an unknown model`);
87
269
  }
@@ -114,10 +296,5 @@ export function validateEvidenceCatalog(input) {
114
296
  }
115
297
  observationIds.add(entry.id);
116
298
  }
117
- return deepFreeze({
118
- schemaVersion: EVIDENCE_CATALOG_VERSION,
119
- revision,
120
- models,
121
- observations,
122
- });
299
+ return deepFreeze({ schemaVersion: EVIDENCE_CATALOG_VERSION, revision, models, observations });
123
300
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The dispatch lease — the revision-bound window between deciding a route and
3
+ * spawning the agent that runs it.
4
+ *
5
+ * Comparing revisions before and after the handoff is not enough on its own: a
6
+ * comparison leaves a window between the check and the external task creation in
7
+ * which an authorization can be revoked, and the dispatch would still spawn. The
8
+ * lease closes that window from both sides. A dispatch holds it across the whole
9
+ * handoff, bound to the store-backed snapshot token it read; a writer must
10
+ * invalidate the lease or wait for it, and a lease whose revisions no longer
11
+ * match fails the dispatch instead of racing it.
12
+ *
13
+ * Four mechanics carry that:
14
+ *
15
+ * - **Fencing token.** Every acquisition raises a monotone counter per scope. A
16
+ * holder proves itself by its token, so a holder that lost the lease can
17
+ * neither continue nor release its successor's.
18
+ * - **Expiry.** A lease is only live until its deadline. A crashed holder
19
+ * therefore cannot block a scope forever.
20
+ * - **Abandoned-lease recovery.** An expired lease is taken over by the next
21
+ * acquirer or by a waiting writer, and the fence bump makes the abandoned
22
+ * holder's next check fail rather than letting it spawn late.
23
+ * - **Writer fairness.** A writer queues for its turn, and while any writer is
24
+ * queued no new dispatch may acquire the scope. Writers are served in the
25
+ * order they queued, so a stream of dispatches can never starve a revocation.
26
+ *
27
+ * The registry is per-process mutual exclusion. What catches a mutation from
28
+ * *another* process is the revision binding: the lease re-reads the persisted
29
+ * profile generations, inventory, Access graph and catalog and recomputes the
30
+ * Routing policy, then compares. Both halves are needed — a cooperating writer
31
+ * is serialized, an uncoordinated one is detected.
32
+ */
33
+ import { readAccessGraphDocument } from './routingAccessGraphStore.mjs';
34
+ import { readComposedRoutingProfile } from './routingProfile.mjs';
35
+ import {
36
+ assertRoutingSnapshotMatches,
37
+ assertRoutingStoreSnapshotUnchanged,
38
+ captureRoutingStoreSnapshot,
39
+ } from './routingEvidenceCache.mjs';
40
+
41
+ export const DISPATCH_LEASE_VERSION = 1;
42
+ export const DEFAULT_DISPATCH_LEASE_TTL_MS = 30_000;
43
+ export const DEFAULT_WRITER_TURN_TIMEOUT_MS = 5_000;
44
+
45
+ /** The Route decision axes a lease binds: exactly what the decision named. */
46
+ const DECISION_REVISION_FIELDS = Object.freeze(['catalog', 'accessGraph', 'policy']);
47
+
48
+ const sleepMs = (ms) => new Promise((done) => { setTimeout(done, ms); });
49
+
50
+ function scopeKey(value, field = 'dispatch lease key') {
51
+ if (typeof value !== 'string' || value.trim() === '') {
52
+ throw new TypeError(`${field} must be a non-empty string`);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /**
58
+ * One registry owns the leases of one process. `now` and `ttlMs` are injected so
59
+ * expiry, recovery and fairness are decidable without waiting for wall time.
60
+ */
61
+ export function createDispatchLeaseRegistry({
62
+ now = () => Date.now(),
63
+ ttlMs = DEFAULT_DISPATCH_LEASE_TTL_MS,
64
+ } = {}) {
65
+ const scopes = new Map();
66
+
67
+ const scopeOf = (key) => {
68
+ const name = scopeKey(key);
69
+ if (!scopes.has(name)) {
70
+ scopes.set(name, { fencingToken: 0, lease: null, writers: [], tickets: 0 });
71
+ }
72
+ return scopes.get(name);
73
+ };
74
+ /** A lease is live only while it exists and its deadline has not passed. */
75
+ const live = (scope, at) => (scope.lease && at < scope.lease.expiresAt ? scope.lease : null);
76
+
77
+ function acquire({ key, holder = null, ttlMs: leaseTtlMs = ttlMs } = {}) {
78
+ const scope = scopeOf(key);
79
+ if (scope.writers.length > 0) {
80
+ throw new Error(`dispatch lease is reserved for a writer: ${key}`);
81
+ }
82
+ const at = now();
83
+ if (live(scope, at)) throw new Error(`dispatch lease is held: ${key}`);
84
+ // An expired lease is abandoned: the recovering acquirer fences its holder.
85
+ scope.fencingToken += 1;
86
+ scope.lease = {
87
+ schemaVersion: DISPATCH_LEASE_VERSION,
88
+ key,
89
+ holder,
90
+ fencingToken: scope.fencingToken,
91
+ acquiredAt: at,
92
+ expiresAt: at + leaseTtlMs,
93
+ };
94
+ return Object.freeze({ ...scope.lease });
95
+ }
96
+
97
+ /** Still ours, still live — or the dispatch fails with what went wrong. */
98
+ function assertHeld(lease) {
99
+ const current = scopes.get(lease?.key)?.lease ?? null;
100
+ if (!current || current.fencingToken !== lease?.fencingToken) {
101
+ throw new Error(`dispatch lease superseded: ${lease?.key}`);
102
+ }
103
+ if (now() >= current.expiresAt) throw new Error(`dispatch lease expired: ${lease.key}`);
104
+ return true;
105
+ }
106
+
107
+ /** Only the current holder releases; a fenced holder cannot free its successor. */
108
+ function release(lease) {
109
+ const scope = scopes.get(lease?.key);
110
+ if (scope?.lease && scope.lease.fencingToken === lease.fencingToken) scope.lease = null;
111
+ return true;
112
+ }
113
+
114
+ /** Queue for a write. From here on, no new dispatch may take this scope. */
115
+ function requestWriterTurn({ key, writerId = null } = {}) {
116
+ const scope = scopeOf(key);
117
+ scope.tickets += 1;
118
+ const ticket = Object.freeze({ key, writerId, sequence: scope.tickets });
119
+ scope.writers.push(ticket);
120
+ return ticket;
121
+ }
122
+
123
+ /**
124
+ * Take the turn if it is this writer's and no live lease is in the way. The
125
+ * grant invalidates whatever remains of an abandoned lease and raises the
126
+ * fence, so a stale holder can no longer spawn against the old revisions.
127
+ */
128
+ function tryBeginWrite(ticket) {
129
+ const scope = scopeOf(ticket?.key);
130
+ const head = scope.writers[0];
131
+ if (!head || head.sequence !== ticket.sequence) {
132
+ return Object.freeze({ granted: false, reason: 'writer-not-next' });
133
+ }
134
+ if (live(scope, now())) {
135
+ return Object.freeze({ granted: false, reason: 'dispatch-lease-held' });
136
+ }
137
+ scope.lease = null;
138
+ scope.fencingToken += 1;
139
+ return Object.freeze({ granted: true, fencingToken: scope.fencingToken });
140
+ }
141
+
142
+ /** Leave the queue, whether the turn was taken or given up. */
143
+ function endWrite(ticket) {
144
+ const scope = scopeOf(ticket?.key);
145
+ scope.writers = scope.writers.filter((queued) => queued.sequence !== ticket.sequence);
146
+ return true;
147
+ }
148
+
149
+ function inspect(key) {
150
+ const scope = scopeOf(key);
151
+ const current = live(scope, now());
152
+ return Object.freeze({
153
+ fencingToken: scope.fencingToken,
154
+ held: Boolean(current),
155
+ holder: current?.holder ?? null,
156
+ expiresAt: scope.lease?.expiresAt ?? null,
157
+ writers: scope.writers.length,
158
+ });
159
+ }
160
+
161
+ return Object.freeze({
162
+ acquire, assertHeld, release, requestWriterTurn, tryBeginWrite, endWrite, inspect,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Wait for a queued writer's turn. The wait is bounded by wall time, so a holder
168
+ * whose process died mid-dispatch cannot keep a revocation waiting forever; the
169
+ * timeout names the reason it was still blocked.
170
+ */
171
+ export async function awaitWriteTurn(registry, ticket, options = {}) {
172
+ const {
173
+ timeoutMs = DEFAULT_WRITER_TURN_TIMEOUT_MS,
174
+ pollMs = 5,
175
+ sleep = sleepMs,
176
+ clock = Date.now,
177
+ } = options;
178
+ const deadline = clock() + timeoutMs;
179
+ for (;;) {
180
+ const attempt = registry.tryBeginWrite(ticket);
181
+ if (attempt.granted) return attempt;
182
+ if (clock() >= deadline) {
183
+ throw new Error(`dispatch lease writer timed out: ${ticket?.key} (${attempt.reason})`);
184
+ }
185
+ await sleep(pollMs);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The snapshot source over the real user-local stores: the committed profile
191
+ * generations plus the pinned inventory (composed and then derived into a
192
+ * policy), the stored Access graph, and the Evidence catalog its own reader
193
+ * supplies. Every `read` goes back to the bytes — nothing is cached across the
194
+ * handoff, because a cached read cannot see a concurrent writer.
195
+ */
196
+ export function createPersistedRoutingSnapshot({
197
+ profileRoot, projectRoot, identity, runGit, loadInventory, inventory,
198
+ accessGraphFile, readCatalog, policyOptions,
199
+ } = {}) {
200
+ scopeKey(accessGraphFile, 'persisted routing snapshot accessGraphFile');
201
+ return Object.freeze({
202
+ read: () => captureRoutingStoreSnapshot({
203
+ readProfile: () => readComposedRoutingProfile({
204
+ profileRoot, projectRoot, identity, runGit, loadInventory, inventory,
205
+ }),
206
+ readAccessGraph: () => readAccessGraphDocument(accessGraphFile),
207
+ readCatalog,
208
+ policyOptions,
209
+ }),
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Open the lease for one dispatch: take the scope first, then read the store and
215
+ * bind the lease to what the Route decision named. A store that already moved
216
+ * fails here, before an adapter prepares anything, and the scope is released
217
+ * again — an unopened lease never leaks.
218
+ */
219
+ export async function openDispatchLease({
220
+ registry, key, holder = null, ttlMs, snapshot, expected = null,
221
+ }) {
222
+ if (!registry || typeof registry.acquire !== 'function') {
223
+ throw new TypeError('a dispatch lease needs a dispatch lease registry');
224
+ }
225
+ if (!snapshot || typeof snapshot.read !== 'function') {
226
+ throw new TypeError('a dispatch lease needs a store-backed snapshot source');
227
+ }
228
+ const lease = registry.acquire({ key, holder, ttlMs });
229
+ try {
230
+ const token = await snapshot.read();
231
+ if (expected) assertRoutingSnapshotMatches(expected, token, DECISION_REVISION_FIELDS);
232
+ registry.assertHeld(lease);
233
+ return Object.freeze({
234
+ key: lease.key,
235
+ holder: lease.holder,
236
+ fencingToken: lease.fencingToken,
237
+ expiresAt: lease.expiresAt,
238
+ token,
239
+ /** The spawn handoff: re-read, compare, and prove the lease is still ours. */
240
+ revalidate: async () => {
241
+ registry.assertHeld(lease);
242
+ const current = await snapshot.read();
243
+ assertRoutingStoreSnapshotUnchanged(token, current);
244
+ registry.assertHeld(lease);
245
+ return current;
246
+ },
247
+ release: () => registry.release(lease),
248
+ });
249
+ } catch (error) {
250
+ registry.release(lease);
251
+ throw error;
252
+ }
253
+ }
@@ -1,8 +1,18 @@
1
1
  import { validateEvidenceCatalog } from './routingCatalog.mjs';
2
2
  import { validateAccessGraph } from './routingAccessGraph.mjs';
3
3
  import { validateRoutingPolicy } from './routingPolicy.mjs';
4
+ import { deriveRoutingPolicy } from './routingProfilePolicy.mjs';
4
5
 
5
6
  export const ROUTING_EVIDENCE_CACHE_VERSION = 1;
7
+ export const ROUTING_STORE_SNAPSHOT_VERSION = 1;
8
+
9
+ /** Every axis a store-backed snapshot compares, each named by what moved. */
10
+ export const ROUTING_STORE_SNAPSHOT_FIELDS = Object.freeze([
11
+ 'globalGeneration', 'projectGeneration', 'inventoryRevision', 'accessGraph', 'policy', 'catalog',
12
+ ]);
13
+
14
+ const PROFILE_MUTATION = 'concurrent routing profile mutation';
15
+ const CATALOG_MUTATION = 'concurrent evidence catalog mutation';
6
16
 
7
17
  function object(value, field) {
8
18
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -198,6 +208,74 @@ export function commitRoutingEvidenceCache({
198
208
  }, { now: refreshedAt });
199
209
  }
200
210
 
211
+ function snapshotReader(source, field) {
212
+ if (typeof source[field] !== 'function') {
213
+ throw new TypeError(`routing store snapshot ${field} must be a function`);
214
+ }
215
+ return source[field];
216
+ }
217
+
218
+ /**
219
+ * The store-backed snapshot token: the revisions of the *persisted* routing
220
+ * inputs, taken by re-reading them. The Routing policy is recomputed from the
221
+ * generations and the inventory revision just read — a policy is derived and is
222
+ * never read back from a store — so a revision a caller merely states cannot
223
+ * authorize a dispatch, which comparing a resolver input with itself would allow.
224
+ */
225
+ export async function captureRoutingStoreSnapshot(source) {
226
+ object(source, 'routing store snapshot source');
227
+ const [readProfile, readAccessGraph, readCatalog] = ['readProfile', 'readAccessGraph',
228
+ 'readCatalog'].map((field) => snapshotReader(source, field));
229
+ const profile = await readProfile();
230
+ if (!profile?.global || !profile.composed) {
231
+ throw new Error('routing profile store carries no committed authorization');
232
+ }
233
+ const projectGeneration = profile.project?.generation ?? null;
234
+ const policy = deriveRoutingPolicy({
235
+ composed: profile.composed,
236
+ globalGeneration: profile.global.generation,
237
+ projectGeneration,
238
+ ...(source.policyOptions ?? {}),
239
+ });
240
+ const accessGraph = await readAccessGraph();
241
+ const catalog = validateEvidenceCatalog(await readCatalog());
242
+ return Object.freeze({
243
+ schemaVersion: ROUTING_STORE_SNAPSHOT_VERSION,
244
+ globalGeneration: profile.global.generation,
245
+ projectGeneration,
246
+ inventoryRevision: profile.composed.rosterState?.inventoryRevision
247
+ ?? profile.composed.inventoryRevision ?? null,
248
+ accessGraph: accessGraph?.revision ?? null,
249
+ policy: policy.revision,
250
+ catalog: catalog.revision,
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Compare the named axes of two snapshot tokens. A moved axis names itself and
256
+ * fails the dispatch; the receipt reduces the message to a constant reason, so
257
+ * the compared values never leave this call.
258
+ */
259
+ export function assertRoutingSnapshotMatches(expected, actual, fields) {
260
+ for (const token of [expected, actual]) object(token, 'routing store snapshot');
261
+ for (const field of fields) {
262
+ if (expected[field] === actual[field]) continue;
263
+ throw new Error(`${field === 'catalog' ? CATALOG_MUTATION : PROFILE_MUTATION}: `
264
+ + `${field} changed from ${expected[field]} to ${actual[field]}`);
265
+ }
266
+ return true;
267
+ }
268
+
269
+ /** The full-axis comparison a held lease runs before it releases the spawn. */
270
+ export function assertRoutingStoreSnapshotUnchanged(before, after) {
271
+ return assertRoutingSnapshotMatches(before, after, ROUTING_STORE_SNAPSHOT_FIELDS);
272
+ }
273
+
274
+ /**
275
+ * The in-process check over one caller's own resolver input. It only sees a
276
+ * mutation this process made to that object, so it is the degenerate case for a
277
+ * dispatch without a store-backed lease — never the authority over the store.
278
+ */
201
279
  export function captureRoutingProfileSnapshot({ accessGraph, policy }) {
202
280
  const access = validateAccessGraph(accessGraph);
203
281
  const routingPolicy = validateRoutingPolicy(policy);