@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,23 @@
1
- export const ACCESS_GRAPH_VERSION = 1;
1
+ /**
2
+ * The Access graph — the user-local map of native and cross-provider paths by
3
+ * which an agent surface can reach a model runtime, together with dated
4
+ * capability attestations.
5
+ *
6
+ * A path identifies a model-AND-effort pair, so only an exact pair match
7
+ * resolves and availability is attested per pair rather than per model.
8
+ * Detection is never authorization: a freshly built path is `unknown`, and only
9
+ * a user-authorized capability probe promotes it to `available` or writes a
10
+ * dated `unavailable` attestation. `unknown` is not a deadlock — a supervised
11
+ * run may take the `verification-required` route to produce the proof, while an
12
+ * AFK dispatch stays blocked until that proof exists.
13
+ *
14
+ * Only a deterministic unsupported or authorization failure may mutate
15
+ * availability. A timeout, rate limit, malformed response or transient provider
16
+ * failure leaves the path `unknown`, so one bad minute cannot poison the graph.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+
20
+ export const ACCESS_GRAPH_VERSION = 2;
2
21
 
3
22
  export const ROUTE_AVAILABILITY = Object.freeze([
4
23
  'available',
@@ -13,17 +32,24 @@ export const ENFORCEMENT_METHODS = Object.freeze([
13
32
  'none',
14
33
  ]);
15
34
 
35
+ /** What one pair may do next, once the graph has been consulted. */
36
+ export const ACCESS_ROUTE_STATES = Object.freeze([
37
+ 'ready',
38
+ 'verification-required',
39
+ 'blocked',
40
+ ]);
41
+
16
42
  const PATH_FIELDS = new Set([
17
- 'id',
18
- 'surfaceId',
19
- 'providerId',
20
- 'modelId',
21
- 'transportId',
22
- 'availability',
23
- 'enforcement',
24
- 'capabilityEvidence',
43
+ 'id', 'surfaceId', 'providerId', 'modelId', 'effort', 'transportId',
44
+ 'availability', 'enforcement', 'capabilityEvidence', 'attestation',
25
45
  ]);
26
46
 
47
+ const ATTESTATION_FIELDS = new Set([
48
+ 'result', 'failureKind', 'probeId', 'authorizationId', 'observedAt', 'expiresAt',
49
+ ]);
50
+
51
+ const CAPABILITY_IDENTITY = ['surfaceId', 'providerId', 'modelId', 'transportId'];
52
+
27
53
  function object(value, field) {
28
54
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
29
55
  throw new TypeError(`${field} must be an object`);
@@ -49,6 +75,22 @@ function timestamp(value, field) {
49
75
  return value;
50
76
  }
51
77
 
78
+ function dated(input, field) {
79
+ const observedAt = timestamp(input.observedAt, `${field}.observedAt`);
80
+ const expiresAt = timestamp(input.expiresAt, `${field}.expiresAt`);
81
+ if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
82
+ throw new TypeError(`${field}.expiresAt must follow observedAt`);
83
+ }
84
+ return { observedAt, expiresAt };
85
+ }
86
+
87
+ function closedFields(value, allowed, message) {
88
+ for (const key of Object.keys(value)) {
89
+ if (!allowed.has(key)) throw new TypeError(`${message}: ${key}`);
90
+ }
91
+ return value;
92
+ }
93
+
52
94
  function deepFreeze(value) {
53
95
  if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
54
96
  Object.freeze(value);
@@ -56,35 +98,83 @@ function deepFreeze(value) {
56
98
  return value;
57
99
  }
58
100
 
101
+ function canonical(value) {
102
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
103
+ if (value && typeof value === 'object') {
104
+ return `{${Object.keys(value).sort()
105
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
106
+ }
107
+ return JSON.stringify(value ?? null);
108
+ }
109
+
110
+ /** The pair identity of a path: surface, provider, model, effort, transport. */
111
+ export function accessPairKey(path) {
112
+ return JSON.stringify([
113
+ path.surfaceId, path.providerId, path.modelId, path.effort ?? null, path.transportId,
114
+ ]);
115
+ }
116
+
117
+ /** Content-derived revision: an unchanged graph keeps its revision. */
118
+ export function deriveAccessGraphRevision(paths) {
119
+ return `sha256-${createHash('sha256').update(canonical(paths)).digest('base64url')}`;
120
+ }
121
+
122
+ function validateAttestation(input, field, availability) {
123
+ const attestation = `${field}.attestation`;
124
+ if (input == null) {
125
+ if (availability !== 'unknown') {
126
+ throw new TypeError(`${field}.availability requires a dated attestation`);
127
+ }
128
+ return null;
129
+ }
130
+ if (availability === 'unknown') {
131
+ throw new TypeError(`${field}: unknown availability must carry no attestation`);
132
+ }
133
+ object(input, attestation);
134
+ closedFields(input, ATTESTATION_FIELDS, 'unknown access attestation field');
135
+ const result = oneOf(input.result, ['available', 'unavailable'], `${attestation}.result`);
136
+ if (result !== availability) {
137
+ throw new TypeError(`${attestation} result must match the recorded availability`);
138
+ }
139
+ const failureKind = input.failureKind == null
140
+ ? null
141
+ : string(input.failureKind, `${attestation}.failureKind`);
142
+ if ((result === 'unavailable') !== (failureKind !== null)) {
143
+ throw new TypeError(`${attestation}.failureKind belongs to an unavailable attestation only`);
144
+ }
145
+ return {
146
+ result,
147
+ failureKind,
148
+ probeId: string(input.probeId, `${attestation}.probeId`),
149
+ authorizationId: string(input.authorizationId, `${attestation}.authorizationId`),
150
+ ...dated(input, attestation),
151
+ };
152
+ }
153
+
59
154
  function validatePath(path, index) {
60
155
  const field = `paths[${index}]`;
61
156
  object(path, field);
62
- for (const key of Object.keys(path)) {
63
- if (!PATH_FIELDS.has(key)) throw new TypeError(`unknown access path field: ${key}`);
64
- }
157
+ closedFields(path, PATH_FIELDS, 'unknown access path field');
65
158
  const enforcement = object(path.enforcement, `${field}.enforcement`);
66
159
  const evidence = object(path.capabilityEvidence, `${field}.capabilityEvidence`);
67
- const observedAt = timestamp(evidence.observedAt, `${field}.capabilityEvidence.observedAt`);
68
- const expiresAt = timestamp(evidence.expiresAt, `${field}.capabilityEvidence.expiresAt`);
69
- if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
70
- throw new TypeError(`${field}.capabilityEvidence.expiresAt must follow observedAt`);
71
- }
160
+ const availability = oneOf(path.availability, ROUTE_AVAILABILITY, `${field}.availability`);
72
161
  return {
73
162
  id: string(path.id, `${field}.id`),
74
163
  surfaceId: string(path.surfaceId, `${field}.surfaceId`),
75
164
  providerId: string(path.providerId, `${field}.providerId`),
76
165
  modelId: string(path.modelId, `${field}.modelId`),
166
+ effort: path.effort === null ? null : string(path.effort, `${field}.effort`),
77
167
  transportId: string(path.transportId, `${field}.transportId`),
78
- availability: oneOf(path.availability, ROUTE_AVAILABILITY, `${field}.availability`),
168
+ availability,
79
169
  enforcement: {
80
170
  model: oneOf(enforcement.model, ENFORCEMENT_METHODS, `${field}.enforcement.model`),
81
171
  effort: oneOf(enforcement.effort, ENFORCEMENT_METHODS, `${field}.enforcement.effort`),
82
172
  },
83
173
  capabilityEvidence: {
84
174
  revision: string(evidence.revision, `${field}.capabilityEvidence.revision`),
85
- observedAt,
86
- expiresAt,
175
+ ...dated(evidence, `${field}.capabilityEvidence`),
87
176
  },
177
+ attestation: validateAttestation(path.attestation, field, availability),
88
178
  };
89
179
  }
90
180
 
@@ -97,9 +187,164 @@ export function validateAccessGraph(input) {
97
187
  if (!Array.isArray(input.paths)) throw new TypeError('access graph paths must be an array');
98
188
  const paths = input.paths.map(validatePath);
99
189
  const ids = new Set();
190
+ const pairs = new Set();
100
191
  for (const path of paths) {
101
192
  if (ids.has(path.id)) throw new TypeError(`duplicate access path: ${path.id}`);
102
193
  ids.add(path.id);
194
+ const pair = accessPairKey(path);
195
+ if (pairs.has(pair)) {
196
+ throw new TypeError(`duplicate access pair: ${path.modelId}+${path.effort ?? 'none'}`);
197
+ }
198
+ pairs.add(pair);
103
199
  }
104
200
  return deepFreeze({ schemaVersion: ACCESS_GRAPH_VERSION, revision, paths });
105
201
  }
202
+
203
+ /** Validate a path list and stamp it with its content-derived revision. */
204
+ export function sealAccessGraph(paths) {
205
+ const validated = validateAccessGraph({
206
+ schemaVersion: ACCESS_GRAPH_VERSION,
207
+ revision: 'derived',
208
+ paths,
209
+ });
210
+ return validateAccessGraph({
211
+ ...validated,
212
+ revision: deriveAccessGraphRevision(validated.paths),
213
+ });
214
+ }
215
+
216
+ /** Exact pair match. A requested effort the path never attested never matches. */
217
+ export function accessPathMatchesPair(path, pair) {
218
+ if (!path || !pair) return false;
219
+ if (path.providerId !== pair.providerId || path.modelId !== pair.modelId) return false;
220
+ if ((path.effort ?? null) !== (pair.effort ?? null)) return false;
221
+ if (pair.surfaceId !== undefined && path.surfaceId !== pair.surfaceId) return false;
222
+ if (pair.transportId !== undefined && path.transportId !== pair.transportId) return false;
223
+ return true;
224
+ }
225
+
226
+ export function selectAccessPaths(graph, pair) {
227
+ return validateAccessGraph(graph).paths.filter((path) => accessPathMatchesPair(path, pair));
228
+ }
229
+
230
+ /**
231
+ * The executable state of one pair: attested access is `ready`, untested access
232
+ * is `verification-required` for a supervised run and blocked for an AFK one.
233
+ */
234
+ export function resolveAccessRoute(graph, pair, { afk = false } = {}) {
235
+ const matches = selectAccessPaths(graph, pair);
236
+ const label = `${pair?.providerId}:${pair?.modelId}:${pair?.effort ?? 'none'}`;
237
+ if (matches.length === 0) {
238
+ return Object.freeze({ state: 'blocked', path: null, reason: `pair-not-attested:${label}` });
239
+ }
240
+ const available = matches.find((path) => path.availability === 'available');
241
+ if (available) {
242
+ return Object.freeze({ state: 'ready', path: available, reason: `access-attested:${available.id}` });
243
+ }
244
+ const untested = matches.find((path) => path.availability === 'unknown');
245
+ if (untested) {
246
+ return Object.freeze(afk
247
+ ? { state: 'blocked', path: untested, reason: `afk-requires-attested-access:${untested.id}` }
248
+ : { state: 'verification-required', path: untested, reason: `access-unknown:${untested.id}` });
249
+ }
250
+ return Object.freeze({
251
+ state: 'blocked',
252
+ path: matches[0],
253
+ reason: `route-unavailable:${matches[0].id}`,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Assemble surface-adapter attestations into a graph. A path whose capability
259
+ * the surface could not attest never becomes dispatchable, a pair outside the
260
+ * model's effort domain is refused, and a previously recorded availability is
261
+ * carried forward so a rebuild never silently rewrites authorization.
262
+ */
263
+ export function buildAccessGraph({ attestations, previous = null, effortDomains = null } = {}) {
264
+ if (!Array.isArray(attestations)) {
265
+ throw new TypeError('access graph attestations must be an array');
266
+ }
267
+ const prior = previous == null
268
+ ? new Map()
269
+ : new Map(validateAccessGraph(previous).paths.map((path) => [accessPairKey(path), path]));
270
+ const paths = [];
271
+ for (const [index, record] of attestations.entries()) {
272
+ const field = `attestations[${index}]`;
273
+ object(record, field);
274
+ if (record.attested !== true) continue;
275
+ const providerId = string(record.providerId, `${field}.providerId`);
276
+ const modelId = string(record.modelId, `${field}.modelId`);
277
+ const effort = record.effort == null ? null : string(record.effort, `${field}.effort`);
278
+ const domain = effortDomains?.[`${providerId}:${modelId}`];
279
+ if (Array.isArray(domain) && !domain.includes(effort)) {
280
+ throw new TypeError(
281
+ `${field}.effort is outside the model effort domain: ${modelId}+${effort ?? 'none'}`,
282
+ );
283
+ }
284
+ const path = {
285
+ id: record.id,
286
+ surfaceId: record.surfaceId,
287
+ providerId,
288
+ modelId,
289
+ effort,
290
+ transportId: record.transportId,
291
+ availability: 'unknown',
292
+ enforcement: record.enforcement,
293
+ capabilityEvidence: record.capabilityEvidence,
294
+ attestation: null,
295
+ };
296
+ const carried = prior.get(accessPairKey(path));
297
+ if (carried) {
298
+ path.availability = carried.availability;
299
+ path.attestation = carried.attestation;
300
+ }
301
+ paths.push(path);
302
+ }
303
+ return sealAccessGraph(paths);
304
+ }
305
+
306
+ /** Adapter-side identity match: surface, provider, model and transport. */
307
+ export function capabilityPathMatchesIdentity(path, route) {
308
+ return CAPABILITY_IDENTITY.every((field) => path?.[field] === route?.[field]);
309
+ }
310
+
311
+ /** Adapter-side pair match: identity plus the exact attested effort. */
312
+ export function capabilityPathMatchesPair(path, route) {
313
+ const applied = path?.effort?.applied;
314
+ return capabilityPathMatchesIdentity(path, route)
315
+ && typeof applied === 'string'
316
+ && applied !== 'unknown'
317
+ && applied === route?.effort;
318
+ }
319
+
320
+ /**
321
+ * Pick the capability path for a requested route: the exact pair when one
322
+ * exists, otherwise the identity match, so an unattested control still reports
323
+ * its own verification failure instead of looking like a missing route.
324
+ */
325
+ export function selectCapabilityPath(paths, route) {
326
+ const candidates = paths.filter((path) => capabilityPathMatchesIdentity(path, route));
327
+ if (candidates.length === 0) return null;
328
+ return candidates.find((path) => capabilityPathMatchesPair(path, route)) ?? candidates[0];
329
+ }
330
+
331
+ /** Turn one normalized surface-capability path into an Access-graph attestation. */
332
+ export function attestAccessPath(path, { revision, observedAt, expiresAt } = {}) {
333
+ const capabilityEvidence = Object.freeze({
334
+ revision: string(revision, 'capability attestation revision'),
335
+ ...dated({ observedAt, expiresAt }, 'capability attestation'),
336
+ });
337
+ const attested = path.verified === true;
338
+ return Object.freeze({
339
+ id: path.id,
340
+ surfaceId: path.surfaceId,
341
+ providerId: path.providerId,
342
+ modelId: path.modelId,
343
+ effort: attested ? path.effort.applied : null,
344
+ transportId: path.transportId,
345
+ attested,
346
+ attestationFailures: Object.freeze([...path.verificationFailures]),
347
+ enforcement: Object.freeze({ model: path.model.method, effort: path.effort.method }),
348
+ capabilityEvidence,
349
+ });
350
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The Access-graph store — the locked, atomic, revisioned home of the user-local
3
+ * Access graph, plus the capability-probe lifecycle that moves one pair from
4
+ * `unknown` to `available` or `unavailable`.
5
+ *
6
+ * Every write is compare-and-swap against the revision the caller read, so two
7
+ * concurrent dispatches cannot each record an attestation the other never saw.
8
+ * A stale expected revision is rejected, never merged; a held lock fails the
9
+ * write instead of racing it; and a document that will not parse fails closed
10
+ * rather than resetting the graph to empty.
11
+ *
12
+ * Only a deterministic unsupported or authorization failure may mutate
13
+ * availability. Every other probe failure returns the graph untouched, so one
14
+ * bad minute cannot poison it.
15
+ */
16
+ import { mkdir, readFile, rmdir } from 'node:fs/promises';
17
+ import { dirname } from 'node:path';
18
+
19
+ import { writeAtomic } from './atomicWrite.mjs';
20
+ import {
21
+ buildAccessGraph,
22
+ resolveAccessRoute,
23
+ sealAccessGraph,
24
+ validateAccessGraph,
25
+ } from './routingAccessGraph.mjs';
26
+
27
+ /** User-local routing evidence: owner-only, like every other routing document. */
28
+ const STORE_MODE = 0o600;
29
+ const LOCK_TIMEOUT_MS = 2000;
30
+ const LOCK_POLL_MS = 10;
31
+
32
+ /**
33
+ * The probe failure taxonomy, typed by what a failure actually proves.
34
+ * `deterministic` kinds are the only ones allowed to mutate availability.
35
+ */
36
+ export const PROBE_FAILURE_KINDS = Object.freeze({
37
+ 'unsupported-model': 'deterministic',
38
+ 'unsupported-effort': 'deterministic',
39
+ 'not-authorized': 'deterministic',
40
+ timeout: 'transient',
41
+ 'rate-limited': 'transient',
42
+ 'malformed-response': 'transient',
43
+ 'provider-failure': 'transient',
44
+ });
45
+
46
+ const PROBE_FIELDS = new Set(['id', 'sideEffectFree', 'cost']);
47
+
48
+ const AUTHORIZATION_FIELDS = new Set(['id', 'actor', 'grantedAt']);
49
+
50
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
51
+
52
+ function object(value, field) {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw new TypeError(`${field} must be an object`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function string(value, field) {
60
+ if (typeof value !== 'string' || value.trim() === '') {
61
+ throw new TypeError(`${field} must be a non-empty string`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function timestamp(value, field) {
67
+ string(value, field);
68
+ if (!Number.isFinite(Date.parse(value))) throw new TypeError(`${field} must be an ISO timestamp`);
69
+ return value;
70
+ }
71
+
72
+ function closedFields(value, allowed, message) {
73
+ for (const key of Object.keys(value)) {
74
+ if (!allowed.has(key)) throw new TypeError(`${message}: ${key}`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ /**
80
+ * A capability probe is minimal, side-effect-free and cost-visible, and it
81
+ * carries no consumer task data — the closed field set is what enforces that.
82
+ */
83
+ export function validateCapabilityProbe(probe) {
84
+ object(probe, 'capability probe');
85
+ closedFields(probe, PROBE_FIELDS, 'capability probe must carry no consumer task data');
86
+ if (probe.sideEffectFree !== true) {
87
+ throw new TypeError('capability probe must be side-effect-free');
88
+ }
89
+ const cost = object(probe.cost, 'capability probe cost');
90
+ if (!Number.isFinite(cost.amount) || cost.amount < 0) {
91
+ throw new TypeError('capability probe cost.amount must be a non-negative number');
92
+ }
93
+ return Object.freeze({
94
+ id: string(probe.id, 'capability probe id'),
95
+ sideEffectFree: true,
96
+ cost: Object.freeze({
97
+ amount: cost.amount,
98
+ currency: string(cost.currency, 'capability probe cost.currency'),
99
+ unit: string(cost.unit, 'capability probe cost.unit'),
100
+ }),
101
+ });
102
+ }
103
+
104
+ /** A probe only runs, and only mutates the graph, under a named authorization. */
105
+ export function validateProbeAuthorization(authorization) {
106
+ object(authorization, 'probe authorization');
107
+ closedFields(authorization, AUTHORIZATION_FIELDS, 'unknown probe authorization field');
108
+ return Object.freeze({
109
+ id: string(authorization.id, 'probe authorization id'),
110
+ actor: string(authorization.actor, 'probe authorization actor'),
111
+ grantedAt: timestamp(authorization.grantedAt, 'probe authorization grantedAt'),
112
+ });
113
+ }
114
+
115
+ /** Type one probe failure. Anything untyped stays transient by construction. */
116
+ export function classifyProbeFailure(kind) {
117
+ const named = typeof kind === 'string' && kind.trim() !== '' ? kind : 'unclassified';
118
+ const determinism = PROBE_FAILURE_KINDS[named] === 'deterministic'
119
+ ? 'deterministic'
120
+ : 'transient';
121
+ return Object.freeze({
122
+ kind: named,
123
+ determinism,
124
+ mutatesAvailability: determinism === 'deterministic',
125
+ });
126
+ }
127
+
128
+ function attestedPaths(paths, index, attestation) {
129
+ return paths.map((path, position) => (position === index
130
+ ? { ...path, availability: attestation.result, attestation }
131
+ : path));
132
+ }
133
+
134
+ /**
135
+ * Apply one authorized probe result to a graph. Success attests `available`, a
136
+ * deterministic failure writes the dated `unavailable` attestation, and every
137
+ * other failure returns the graph untouched.
138
+ */
139
+ export function applyProbeOutcome(graph, outcome) {
140
+ const current = validateAccessGraph(graph);
141
+ object(outcome, 'probe outcome');
142
+ const pathId = string(outcome.pathId, 'probe outcome pathId');
143
+ const probe = validateCapabilityProbe(outcome.probe);
144
+ const authorization = validateProbeAuthorization(outcome.authorization);
145
+ const observedAt = timestamp(outcome.observedAt, 'probe outcome observedAt');
146
+ const expiresAt = timestamp(outcome.expiresAt, 'probe outcome expiresAt');
147
+ if (outcome.result !== 'succeeded' && outcome.result !== 'failed') {
148
+ throw new TypeError('probe outcome result must be one of: succeeded, failed');
149
+ }
150
+ const index = current.paths.findIndex((path) => path.id === pathId);
151
+ if (index < 0) throw new TypeError(`probe outcome names an unknown access path: ${pathId}`);
152
+ const failure = outcome.result === 'failed' ? classifyProbeFailure(outcome.failureKind) : null;
153
+ if (failure && !failure.mutatesAvailability) {
154
+ return Object.freeze({
155
+ graph: current,
156
+ changed: false,
157
+ availability: current.paths[index].availability,
158
+ failure,
159
+ reason: `probe-inconclusive:${failure.kind}`,
160
+ });
161
+ }
162
+ const availability = outcome.result === 'succeeded' ? 'available' : 'unavailable';
163
+ const next = sealAccessGraph(attestedPaths(current.paths, index, {
164
+ result: availability,
165
+ failureKind: failure?.kind ?? null,
166
+ probeId: probe.id,
167
+ authorizationId: authorization.id,
168
+ observedAt,
169
+ expiresAt,
170
+ }));
171
+ return Object.freeze({
172
+ graph: next,
173
+ changed: next.revision !== current.revision,
174
+ availability,
175
+ failure,
176
+ reason: `probe-${availability}:${pathId}`,
177
+ });
178
+ }
179
+
180
+ /** Read the stored graph. Missing is `null`; unreadable or invalid throws. */
181
+ export async function readAccessGraphDocument(file) {
182
+ let raw;
183
+ try {
184
+ raw = await readFile(file, 'utf8');
185
+ } catch (error) {
186
+ if (error.code === 'ENOENT') return null;
187
+ throw error;
188
+ }
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(raw);
192
+ } catch {
193
+ throw new Error(`access graph document is not valid JSON: ${file}`);
194
+ }
195
+ const graph = validateAccessGraph(parsed);
196
+ return Object.freeze({ graph, revision: graph.revision });
197
+ }
198
+
199
+ async function withStoreLock(file, lockTimeoutMs, run) {
200
+ const lock = `${file}.lock`;
201
+ const deadline = Date.now() + lockTimeoutMs;
202
+ for (;;) {
203
+ try {
204
+ await mkdir(lock);
205
+ break;
206
+ } catch (error) {
207
+ if (error.code !== 'EEXIST') throw error;
208
+ if (Date.now() >= deadline) throw new Error(`access graph store is locked: ${lock}`);
209
+ await sleep(LOCK_POLL_MS);
210
+ }
211
+ }
212
+ try {
213
+ return await run();
214
+ } finally {
215
+ await rmdir(lock).catch(() => {});
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Compare-and-swap write. `expectedRevision` is the revision the caller read,
221
+ * or `null` for a first write; anything else is a stale write and is rejected.
222
+ */
223
+ export async function writeAccessGraphDocument(file, graph, options = {}) {
224
+ const next = validateAccessGraph(graph);
225
+ const { expectedRevision, lockTimeoutMs = LOCK_TIMEOUT_MS } = options;
226
+ if (expectedRevision !== null && typeof expectedRevision !== 'string') {
227
+ throw new TypeError('access graph write requires an expected revision (null for a first write)');
228
+ }
229
+ await mkdir(dirname(file), { recursive: true });
230
+ return withStoreLock(file, lockTimeoutMs, async () => {
231
+ const found = (await readAccessGraphDocument(file))?.revision ?? null;
232
+ if (found !== expectedRevision) {
233
+ throw new Error(
234
+ `stale access graph revision: expected ${expectedRevision ?? 'none'}, `
235
+ + `found ${found ?? 'none'}`,
236
+ );
237
+ }
238
+ await writeAtomic(file, `${JSON.stringify(next, null, 2)}\n`, STORE_MODE);
239
+ return Object.freeze({ path: file, graph: next, revision: next.revision });
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Rebuild the graph from the current surface attestations and store it. An
245
+ * unchanged rebuild is a no-op: the revision is content-derived, so nothing is
246
+ * written and every recorded attestation survives.
247
+ */
248
+ export async function reconcileAccessGraph({
249
+ file,
250
+ attestations,
251
+ effortDomains = null,
252
+ lockTimeoutMs = LOCK_TIMEOUT_MS,
253
+ }) {
254
+ const current = await readAccessGraphDocument(file);
255
+ const next = buildAccessGraph({
256
+ attestations,
257
+ previous: current?.graph ?? null,
258
+ effortDomains,
259
+ });
260
+ if (current?.revision === next.revision) {
261
+ return Object.freeze({ graph: current.graph, revision: current.revision, changed: false });
262
+ }
263
+ const written = await writeAccessGraphDocument(file, next, {
264
+ expectedRevision: current?.revision ?? null,
265
+ lockTimeoutMs,
266
+ });
267
+ return Object.freeze({ graph: written.graph, revision: written.revision, changed: true });
268
+ }
269
+
270
+ /**
271
+ * Ask what one pair may do now. A supervised run gets the validated probe to
272
+ * run; an AFK run stays blocked until the proof exists.
273
+ */
274
+ export async function planCapabilityProbe({ file, pair, afk = false, probe }) {
275
+ const validated = validateCapabilityProbe(probe);
276
+ const current = await readAccessGraphDocument(file);
277
+ if (!current) throw new Error(`access graph document is missing: ${file}`);
278
+ const route = resolveAccessRoute(current.graph, pair, { afk });
279
+ return Object.freeze({
280
+ ...route,
281
+ revision: current.revision,
282
+ probe: route.state === 'verification-required' ? validated : null,
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Record one authorized probe result. A deterministic failure writes the dated
288
+ * unavailable attestation; every other failure leaves the stored graph alone.
289
+ */
290
+ export async function recordProbeOutcome({ file, outcome, lockTimeoutMs = LOCK_TIMEOUT_MS }) {
291
+ const current = await readAccessGraphDocument(file);
292
+ if (!current) throw new Error(`access graph document is missing: ${file}`);
293
+ const applied = applyProbeOutcome(current.graph, outcome);
294
+ if (!applied.changed) return Object.freeze({ ...applied, revision: current.revision });
295
+ const written = await writeAccessGraphDocument(file, applied.graph, {
296
+ expectedRevision: current.revision,
297
+ lockTimeoutMs,
298
+ });
299
+ return Object.freeze({ ...applied, revision: written.revision });
300
+ }