@evoclock/pi-agentic-driver 0.9.1 → 0.9.2
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.
- package/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +92 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +12 -2
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +604 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// BOARD-1 semantic model, closed validator, hash canonicalization, both
|
|
5
5
|
// surface parsers/serializers, the trusted board writer, and the pure
|
|
@@ -15,6 +15,11 @@ import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
|
|
16
16
|
import { execFileSync } from "node:child_process";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
|
+
// R1/R2 (router design §7.2): the replacement writer derives route trust from
|
|
19
|
+
// the router config and the dead-route rule from observations. These imports
|
|
20
|
+
// are the pure router libraries; the board writer never mutates them.
|
|
21
|
+
import { isDeadRoute } from "./router_replacement_pi.js";
|
|
22
|
+
import { getRouteDecisionIdByDigest } from "./router_store_pi.js";
|
|
18
23
|
|
|
19
24
|
// ---------------------------------------------------------------------------
|
|
20
25
|
// Closed vocabularies (§1)
|
|
@@ -1288,8 +1293,8 @@ export function isDispatchable(card, boardIndex) {
|
|
|
1288
1293
|
// Markdown stays canonical for task semantics.
|
|
1289
1294
|
// ---------------------------------------------------------------------------
|
|
1290
1295
|
|
|
1291
|
-
export const CLAIMS_SCHEMA = "agentic-driver.board-claims.
|
|
1292
|
-
export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.
|
|
1296
|
+
export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v3";
|
|
1297
|
+
export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v2";
|
|
1293
1298
|
export const AUTOMATION_POLICY_SCHEMA = "agentic-driver.automation-policy.v1";
|
|
1294
1299
|
export const PLACEMENTS = Object.freeze(["container", "host"]);
|
|
1295
1300
|
export const RISK_LEVELS = Object.freeze(["low", "medium", "high"]);
|
|
@@ -1324,17 +1329,21 @@ function writeJsonFileAtomic(path, value) {
|
|
|
1324
1329
|
}
|
|
1325
1330
|
|
|
1326
1331
|
// HMAC over the canonical JSON form of the claims content, keyed by the
|
|
1327
|
-
// writer-state secret (F1). Covers claims, consumed-claim records,
|
|
1328
|
-
// transaction record — every authority-bearing
|
|
1329
|
-
|
|
1332
|
+
// writer-state secret (F1). Covers claims, consumed-claim records, the
|
|
1333
|
+
// transaction record, and the verdict-evidence map — every authority-bearing
|
|
1334
|
+
// field of the file. v2→v3 (router design §5.3): `evidence` joins the HMAC
|
|
1335
|
+
// input; v2 files are grandfathered (absent evidence reads as {}).
|
|
1336
|
+
function claimsFileHmac(value, secret, { includeEvidence = true } = {}) {
|
|
1337
|
+
const payload = {
|
|
1338
|
+
schema: value.schema,
|
|
1339
|
+
generation: value.generation,
|
|
1340
|
+
transaction: value.transaction ?? null,
|
|
1341
|
+
claims: value.claims ?? [],
|
|
1342
|
+
consumedClaims: value.consumedClaims ?? [],
|
|
1343
|
+
};
|
|
1344
|
+
if (includeEvidence) payload.evidence = value.evidence ?? {};
|
|
1330
1345
|
return createHmac("sha256", secret)
|
|
1331
|
-
.update(canonicalJsonString(
|
|
1332
|
-
schema: value.schema,
|
|
1333
|
-
generation: value.generation,
|
|
1334
|
-
transaction: value.transaction ?? null,
|
|
1335
|
-
claims: value.claims ?? [],
|
|
1336
|
-
consumedClaims: value.consumedClaims ?? [],
|
|
1337
|
-
}), "utf8")
|
|
1346
|
+
.update(canonicalJsonString(payload), "utf8")
|
|
1338
1347
|
.digest("hex");
|
|
1339
1348
|
}
|
|
1340
1349
|
|
|
@@ -1349,16 +1358,35 @@ function exactKeys(value, keys) {
|
|
|
1349
1358
|
return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
|
|
1350
1359
|
}
|
|
1351
1360
|
|
|
1361
|
+
// v1→v2 (router design §5.1): exactly these keys gained — seatId, accountId,
|
|
1362
|
+
// provider, model, role, effort, containmentTier, phase, routeDecisionDigest,
|
|
1363
|
+
// reservationId. v1 claims remain valid and dispatchable under the old
|
|
1364
|
+
// predicate; new claims require the v2 schema.
|
|
1352
1365
|
const ENVELOPE_FIELDS = Object.freeze([
|
|
1353
1366
|
"schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
|
|
1354
1367
|
"baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
|
|
1355
1368
|
"stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
|
|
1356
1369
|
"riskCeiling", "mode", "createdAt", "expiry",
|
|
1370
|
+
"seatId", "accountId", "provider", "model", "role", "effort",
|
|
1371
|
+
"containmentTier", "phase", "routeDecisionDigest", "reservationId",
|
|
1357
1372
|
]);
|
|
1373
|
+
const V1_ENVELOPE_FIELDS = Object.freeze([
|
|
1374
|
+
"schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
|
|
1375
|
+
"baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
|
|
1376
|
+
"stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
|
|
1377
|
+
"riskCeiling", "mode", "createdAt", "expiry",
|
|
1378
|
+
]);
|
|
1379
|
+
const EFFORT_LEVELS = Object.freeze(["low", "default", "high"]);
|
|
1380
|
+
const ENVELOPE_PHASES = Object.freeze(["plan", "implement", "review", "escalate"]);
|
|
1381
|
+
const CONTAINMENT_TIERS = Object.freeze(["testudo", "docker-policy", "none"]);
|
|
1358
1382
|
|
|
1359
|
-
|
|
1360
|
-
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env,
|
|
1361
|
-
if (env.schema !==
|
|
1383
|
+
function wellFormedV1Envelope(env) {
|
|
1384
|
+
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, V1_ENVELOPE_FIELDS)) return false;
|
|
1385
|
+
if (env.schema !== "agentic-driver.assignment-envelope.v1") return false;
|
|
1386
|
+
return v1EnvelopeCoreValid(env);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function v1EnvelopeCoreValid(env) {
|
|
1362
1390
|
if (typeof env.envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(env.envelopeId)) return false;
|
|
1363
1391
|
if (typeof env.cardId !== "string" || !CARD_ID_RE.test(env.cardId)) return false;
|
|
1364
1392
|
if (env.cardHash !== null && !/^[0-9a-f]{64}$/.test(env.cardHash)) return false;
|
|
@@ -1382,7 +1410,33 @@ export function wellFormedEnvelope(env) {
|
|
|
1382
1410
|
return true;
|
|
1383
1411
|
}
|
|
1384
1412
|
|
|
1385
|
-
|
|
1413
|
+
export function wellFormedEnvelope(env) {
|
|
1414
|
+
// Migration (§5.1): v1 claims remain valid and dispatchable under the old
|
|
1415
|
+
// predicate; new claims require the v2 schema.
|
|
1416
|
+
if (env !== null && typeof env === "object" && !Array.isArray(env) && env.schema === "agentic-driver.assignment-envelope.v1") {
|
|
1417
|
+
return wellFormedV1Envelope(env);
|
|
1418
|
+
}
|
|
1419
|
+
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
|
|
1420
|
+
if (env.schema !== ENVELOPE_SCHEMA) return false;
|
|
1421
|
+
if (!v1EnvelopeCoreValid(env)) return false;
|
|
1422
|
+
// Route fields (§5.1): seatId/provider/model/role non-empty strings;
|
|
1423
|
+
// accountId string or null; effort/containmentTier/phase closed enums;
|
|
1424
|
+
// routeDecisionDigest 64-hex; reservationId string or null.
|
|
1425
|
+
for (const field of ["seatId", "provider", "model", "role"]) {
|
|
1426
|
+
if (typeof env[field] !== "string" || env[field] === "") return false;
|
|
1427
|
+
}
|
|
1428
|
+
if (env.accountId !== null && (typeof env.accountId !== "string" || env.accountId === "")) return false;
|
|
1429
|
+
if (!EFFORT_LEVELS.includes(env.effort)) return false;
|
|
1430
|
+
if (!CONTAINMENT_TIERS.includes(env.containmentTier)) return false;
|
|
1431
|
+
if (!ENVELOPE_PHASES.includes(env.phase)) return false;
|
|
1432
|
+
if (!/^[0-9a-f]{64}$/.test(env.routeDecisionDigest ?? "")) return false;
|
|
1433
|
+
if (env.reservationId !== null && (typeof env.reservationId !== "string" || env.reservationId === "")) return false;
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope", "parentClaimId", "attemptIndex"]);
|
|
1438
|
+
// v2 grandfathering: claims in v2 files carry exactly the five legacy fields.
|
|
1439
|
+
const V2_CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
|
|
1386
1440
|
const CONSUMED_FIELDS = Object.freeze(["cardId", "envelopeId", "consumedAt", "reason"]);
|
|
1387
1441
|
const CONSUMED_REASONS = Object.freeze([
|
|
1388
1442
|
"expired", "reclaimed", "completed", "exhausted", "cancelled", "failed",
|
|
@@ -1390,17 +1444,27 @@ const CONSUMED_REASONS = Object.freeze([
|
|
|
1390
1444
|
]);
|
|
1391
1445
|
const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
|
|
1392
1446
|
const REPLACE_TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase", "oldEnvelopeId", "oldReason"]);
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1447
|
+
// v2→v3: verdict evidence is closed and HMAC-authenticated; claims also gain lineage.
|
|
1448
|
+
const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "evidence", "hmac"]);
|
|
1449
|
+
const V2_CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
|
|
1450
|
+
const EVIDENCE_KEY_RE = /^[0-9a-f]{64}$/;
|
|
1451
|
+
|
|
1452
|
+
function wellFormedClaim(claim, { legacy = false } = {}) {
|
|
1453
|
+
if (claim === null || typeof claim !== "object" || Array.isArray(claim)) return false;
|
|
1454
|
+
if (!exactKeys(claim, legacy ? V2_CLAIM_FIELDS : CLAIM_FIELDS)) return false;
|
|
1455
|
+
if (!CARD_ID_RE.test(claim.cardId)) return false;
|
|
1456
|
+
if (!ISO_TS_RE.test(claim.claimedAt)) return false;
|
|
1457
|
+
if (!ROLE_NAME_RE.test(claim.role)) return false;
|
|
1458
|
+
if (!/^[0-9a-f]{32}$/.test(claim.envelopeId)) return false;
|
|
1459
|
+
if (!wellFormedEnvelope(claim.envelope)) return false;
|
|
1460
|
+
if (claim.envelope.envelopeId !== claim.envelopeId) return false;
|
|
1461
|
+
if (claim.envelope.cardId !== claim.cardId) return false;
|
|
1462
|
+
if (legacy) return true;
|
|
1463
|
+
// Lineage (§7.3): parentClaimId is a claim identity string or null
|
|
1464
|
+
// (initial dispatch); attemptIndex is a non-negative integer starting
|
|
1465
|
+
// at 0 for the initial attempt.
|
|
1466
|
+
return (claim.parentClaimId === null || (typeof claim.parentClaimId === "string" && claim.parentClaimId !== ""))
|
|
1467
|
+
&& Number.isInteger(claim.attemptIndex) && claim.attemptIndex >= 0;
|
|
1404
1468
|
}
|
|
1405
1469
|
|
|
1406
1470
|
function wellFormedConsumedClaim(claim) {
|
|
@@ -1450,16 +1514,33 @@ export function readClaimsState(boardPath) {
|
|
|
1450
1514
|
if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
|
|
1451
1515
|
return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
|
|
1452
1516
|
}
|
|
1453
|
-
return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [] } };
|
|
1517
|
+
return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [], evidence: {} } };
|
|
1454
1518
|
}
|
|
1455
1519
|
const value = readJsonFile(path);
|
|
1456
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)
|
|
1520
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1457
1521
|
return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
|
|
1458
1522
|
}
|
|
1523
|
+
// Schema migration (§5.3): v2 files are grandfathered — an absent evidence
|
|
1524
|
+
// map reads as {}, claims keep the legacy five-field shape, and the HMAC is
|
|
1525
|
+
// verified over the legacy input. v3 requires the evidence key, the lineage
|
|
1526
|
+
// fields, and the extended HMAC.
|
|
1527
|
+
const legacy = value.schema === "agentic-driver.board-claims.v2";
|
|
1528
|
+
let evidence = value.evidence;
|
|
1529
|
+
if (legacy) {
|
|
1530
|
+
if (!exactKeys(value, V2_CLAIMS_STATE_FIELDS)) {
|
|
1531
|
+
return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
|
|
1532
|
+
}
|
|
1533
|
+
evidence = {};
|
|
1534
|
+
} else if (!exactKeys(value, CLAIMS_STATE_FIELDS)) {
|
|
1535
|
+
return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
|
|
1536
|
+
}
|
|
1537
|
+
if (!isPlainEvidenceMap(evidence)) {
|
|
1538
|
+
return { ok: false, reason: "the claims evidence map is malformed (fails closed)" };
|
|
1539
|
+
}
|
|
1459
1540
|
if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
|
|
1460
1541
|
|| !Number.isInteger(value.generation) || value.generation < 0
|
|
1461
1542
|
|| !wellFormedTransaction(value.transaction)
|
|
1462
|
-
|| !value.claims.every(wellFormedClaim)
|
|
1543
|
+
|| !value.claims.every((claim) => wellFormedClaim(claim, { legacy }))
|
|
1463
1544
|
|| !value.consumedClaims.every(wellFormedConsumedClaim)) {
|
|
1464
1545
|
return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
|
|
1465
1546
|
}
|
|
@@ -1472,7 +1553,8 @@ export function readClaimsState(boardPath) {
|
|
|
1472
1553
|
if (writerState.secret === null) {
|
|
1473
1554
|
return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
|
|
1474
1555
|
}
|
|
1475
|
-
if (typeof value.hmac !== "string"
|
|
1556
|
+
if (typeof value.hmac !== "string"
|
|
1557
|
+
|| value.hmac !== claimsFileHmac({ ...value, evidence }, writerState.secret, { includeEvidence: !legacy })) {
|
|
1476
1558
|
return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
|
|
1477
1559
|
}
|
|
1478
1560
|
// Deletion/rollback guard: the generation must be exactly the anchored
|
|
@@ -1490,6 +1572,7 @@ export function readClaimsState(boardPath) {
|
|
|
1490
1572
|
transaction: value.transaction,
|
|
1491
1573
|
claims: value.claims,
|
|
1492
1574
|
consumedClaims: value.consumedClaims,
|
|
1575
|
+
evidence,
|
|
1493
1576
|
},
|
|
1494
1577
|
};
|
|
1495
1578
|
}
|
|
@@ -1510,6 +1593,24 @@ function claimsAnchorDigest(value, secret) {
|
|
|
1510
1593
|
.digest("hex");
|
|
1511
1594
|
}
|
|
1512
1595
|
|
|
1596
|
+
const EVIDENCE_FIELDS = Object.freeze(["evalInputDigest", "answers", "probabilities", "thresholdPolicy", "versions", "requestId", "timestamp"]);
|
|
1597
|
+
function isPlainEvidenceMap(evidence) {
|
|
1598
|
+
if (evidence === null || typeof evidence !== "object" || Array.isArray(evidence)) return false;
|
|
1599
|
+
const keys = Object.keys(evidence);
|
|
1600
|
+
if (keys.length > 64) return false;
|
|
1601
|
+
for (const key of keys) {
|
|
1602
|
+
const r = evidence[key];
|
|
1603
|
+
if (!EVIDENCE_KEY_RE.test(key) || r === null || typeof r !== "object" || Array.isArray(r) || !exactKeys(r, EVIDENCE_FIELDS)) return false;
|
|
1604
|
+
if (r.evalInputDigest !== key || !EVIDENCE_KEY_RE.test(r.evalInputDigest)) return false;
|
|
1605
|
+
if (r.answers === null || typeof r.answers !== "object" || Array.isArray(r.answers)
|
|
1606
|
+
|| r.probabilities === null || typeof r.probabilities !== "object" || Array.isArray(r.probabilities)
|
|
1607
|
+
|| r.thresholdPolicy === null || typeof r.thresholdPolicy !== "object" || Array.isArray(r.thresholdPolicy)
|
|
1608
|
+
|| r.versions === null || typeof r.versions !== "object" || Array.isArray(r.versions)) return false;
|
|
1609
|
+
if (typeof r.requestId !== "string" || r.requestId === "" || !ISO_TS_RE.test(r.timestamp) || JSON.stringify(r).length > 8192) return false;
|
|
1610
|
+
}
|
|
1611
|
+
return true;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1513
1614
|
// Recover the two-file claims/anchor commit. The complete authenticated next
|
|
1514
1615
|
// claims state is staged in writer state first, so either crash window rolls
|
|
1515
1616
|
// forward deterministically rather than accepting an older file.
|
|
@@ -1518,9 +1619,15 @@ function recoverClaimsAnchorLocked(boardPath) {
|
|
|
1518
1619
|
const writerState = readWriterState(statePath);
|
|
1519
1620
|
const pending = writerState.pendingClaimsState;
|
|
1520
1621
|
if (pending === null) return { ok: true, recovered: false };
|
|
1521
|
-
if (writerState.secret === null || !pending || typeof pending !== "object"
|
|
1522
|
-
|
|
1523
|
-
|
|
1622
|
+
if (writerState.secret === null || !pending || typeof pending !== "object") {
|
|
1623
|
+
return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
|
|
1624
|
+
}
|
|
1625
|
+
const pendingLegacy = pending.schema === "agentic-driver.board-claims.v2";
|
|
1626
|
+
if (pendingLegacy ? !exactKeys(pending, V2_CLAIMS_STATE_FIELDS) : !exactKeys(pending, CLAIMS_STATE_FIELDS)
|
|
1627
|
+
|| !Array.isArray(pending.claims) || !pending.claims.every((claim) => wellFormedClaim(claim, { legacy: pendingLegacy }))) {
|
|
1628
|
+
return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
|
|
1629
|
+
}
|
|
1630
|
+
if (pending.hmac !== claimsFileHmac(pending, writerState.secret, { includeEvidence: !pendingLegacy })) {
|
|
1524
1631
|
return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
|
|
1525
1632
|
}
|
|
1526
1633
|
writeJsonFileAtomic(claimsPath(boardPath), pending);
|
|
@@ -1544,12 +1651,18 @@ function writeClaims(boardPath, claims, { consumedClaims = null, transaction = n
|
|
|
1544
1651
|
writerState = readWriterState(statePath);
|
|
1545
1652
|
const previous = readClaimsState(boardPath);
|
|
1546
1653
|
if (!previous.ok) throw Object.assign(new Error(previous.reason), { code: "claims-corrupt" });
|
|
1654
|
+
// v2→v3 migration: legacy claims are upgraded in place on the next write —
|
|
1655
|
+
// absent lineage fields take their initial-dispatch defaults (§7.3).
|
|
1656
|
+
const upgradedClaims = claims.map((claim) => claim.parentClaimId === undefined && claim.attemptIndex === undefined
|
|
1657
|
+
? { ...claim, parentClaimId: null, attemptIndex: 0 }
|
|
1658
|
+
: claim);
|
|
1547
1659
|
const value = {
|
|
1548
1660
|
schema: CLAIMS_SCHEMA,
|
|
1549
1661
|
generation: previous.state.generation + 1,
|
|
1550
1662
|
transaction,
|
|
1551
|
-
claims,
|
|
1663
|
+
claims: upgradedClaims,
|
|
1552
1664
|
consumedClaims: consumedClaims ?? previous.state.consumedClaims,
|
|
1665
|
+
evidence: previous.state.evidence ?? {},
|
|
1553
1666
|
};
|
|
1554
1667
|
value.hmac = claimsFileHmac(value, writerState.secret);
|
|
1555
1668
|
// Prepare → claims → commit. Because prepare contains the complete signed
|
|
@@ -1758,7 +1871,7 @@ export function repositoryAccepted(policy, repository) {
|
|
|
1758
1871
|
// in), the card's base revision is bound in when present (branch chaining),
|
|
1759
1872
|
// and the risk classification comes from the policy (per-card override only
|
|
1760
1873
|
// when the policy allows).
|
|
1761
|
-
export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null }) {
|
|
1874
|
+
export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null, route = null }) {
|
|
1762
1875
|
const at = now ?? new Date().toISOString();
|
|
1763
1876
|
const envelopeId = randomBytes(16).toString("hex");
|
|
1764
1877
|
const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
|
|
@@ -1775,6 +1888,22 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
|
|
|
1775
1888
|
// envelope's starting revision IS the base (branch chaining), not merely a
|
|
1776
1889
|
// copied field; execution validation enforces HEAD === startingRevision.
|
|
1777
1890
|
const startRev = startingRevision ?? card.base ?? gitHead(repo);
|
|
1891
|
+
const r = route;
|
|
1892
|
+
if (r === null || typeof r !== "object"
|
|
1893
|
+
|| typeof r.seatId !== "string" || r.seatId === ""
|
|
1894
|
+
|| typeof r.provider !== "string" || r.provider === ""
|
|
1895
|
+
|| typeof r.model !== "string" || r.model === ""
|
|
1896
|
+
|| typeof r.role !== "string" || r.role === ""
|
|
1897
|
+
|| !EFFORT_LEVELS.includes(r.effort) || !CONTAINMENT_TIERS.includes(r.containmentTier)
|
|
1898
|
+
|| !ENVELOPE_PHASES.includes(r.phase) || !/^[0-9a-f]{64}$/.test(r.routeDecisionDigest ?? "")) {
|
|
1899
|
+
throw Object.assign(new Error("new claims require a complete authenticated route decision"), { code: "route-required" });
|
|
1900
|
+
}
|
|
1901
|
+
const routeFields = {
|
|
1902
|
+
seatId: r.seatId, accountId: r.accountId ?? null, provider: r.provider,
|
|
1903
|
+
model: r.model, role: r.role, effort: r.effort, containmentTier: r.containmentTier,
|
|
1904
|
+
phase: r.phase, routeDecisionDigest: r.routeDecisionDigest,
|
|
1905
|
+
reservationId: typeof r.reservationId === "string" && r.reservationId !== "" ? r.reservationId : null,
|
|
1906
|
+
};
|
|
1778
1907
|
return deepFreeze({
|
|
1779
1908
|
schema: ENVELOPE_SCHEMA,
|
|
1780
1909
|
envelopeId,
|
|
@@ -1796,6 +1925,7 @@ export function createEnvelope({ card, policy, now = null, repository = null, st
|
|
|
1796
1925
|
mode: "automated",
|
|
1797
1926
|
createdAt: at,
|
|
1798
1927
|
expiry,
|
|
1928
|
+
...routeFields,
|
|
1799
1929
|
});
|
|
1800
1930
|
}
|
|
1801
1931
|
|
|
@@ -1865,8 +1995,8 @@ export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId
|
|
|
1865
1995
|
// the new complete state (both writes are atomic renames); two concurrent
|
|
1866
1996
|
// claims can never both win because the entire read-decide-write sequence
|
|
1867
1997
|
// holds the lock. Expired claims are released first, inside the same lock.
|
|
1868
|
-
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision }) {
|
|
1869
|
-
return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: null });
|
|
1998
|
+
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision, route = null }) {
|
|
1999
|
+
return claimCardInternal({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch: null, route });
|
|
1870
2000
|
}
|
|
1871
2001
|
|
|
1872
2002
|
// ---------------------------------------------------------------------------
|
|
@@ -1920,7 +2050,7 @@ export function claimCardForConfirmedBatch({ boardPath, cardId = null, role, pol
|
|
|
1920
2050
|
return result;
|
|
1921
2051
|
}
|
|
1922
2052
|
|
|
1923
|
-
function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null }) {
|
|
2053
|
+
function claimCardInternal({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null, confirmedBatch = null, route = null }) {
|
|
1924
2054
|
if (typeof boardPath !== "string" || boardPath === "") {
|
|
1925
2055
|
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
1926
2056
|
}
|
|
@@ -1934,7 +2064,7 @@ function claimCardInternal({ boardPath, cardId = null, role, policy = null, conf
|
|
|
1934
2064
|
for (let attempt = 0; ; attempt += 1) {
|
|
1935
2065
|
try {
|
|
1936
2066
|
return withWriterLock(boardPath, () =>
|
|
1937
|
-
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch }));
|
|
2067
|
+
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch, route }));
|
|
1938
2068
|
} catch (error) {
|
|
1939
2069
|
if (error?.code === "writer-lock-held" && attempt < 20) {
|
|
1940
2070
|
sleepSync(25);
|
|
@@ -1948,7 +2078,27 @@ function claimCardInternal({ boardPath, cardId = null, role, policy = null, conf
|
|
|
1948
2078
|
}
|
|
1949
2079
|
}
|
|
1950
2080
|
|
|
1951
|
-
|
|
2081
|
+
// Route fields for confirmed-batch claims: the batch entry carries the
|
|
2082
|
+
// route decision's identity (seatId/provider/model/digest/reservationId)
|
|
2083
|
+
// minted by the scheduler from the route decision — never recomputed here.
|
|
2084
|
+
function confirmedBatchRouteFor(cardId, confirmedBatch) {
|
|
2085
|
+
const entry = (confirmedBatch?.entries ?? []).find((e) => e.cardId === cardId);
|
|
2086
|
+
if (!entry) return null;
|
|
2087
|
+
return {
|
|
2088
|
+
seatId: entry.seatId,
|
|
2089
|
+
accountId: entry.accountId,
|
|
2090
|
+
provider: entry.provider,
|
|
2091
|
+
model: entry.model,
|
|
2092
|
+
role: entry.role,
|
|
2093
|
+
effort: entry.effort,
|
|
2094
|
+
containmentTier: entry.containmentTier,
|
|
2095
|
+
phase: entry.phase,
|
|
2096
|
+
routeDecisionDigest: entry.routeDecisionDigest,
|
|
2097
|
+
reservationId: entry.reservationId,
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision, confirmedBatch = null, route = null }) {
|
|
1952
2102
|
const at = now ?? new Date().toISOString();
|
|
1953
2103
|
if (!existsSync(boardPath)) {
|
|
1954
2104
|
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
@@ -2038,13 +2188,16 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
|
|
|
2038
2188
|
: "no dispatchable unclaimed card is available" };
|
|
2039
2189
|
}
|
|
2040
2190
|
const card = selected.card;
|
|
2041
|
-
const
|
|
2191
|
+
const selectedRoute = confirmedBatch !== null ? confirmedBatchRouteFor(card.cardId, confirmedBatch) : route;
|
|
2192
|
+
const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision, route: selectedRoute });
|
|
2042
2193
|
const claim = {
|
|
2043
2194
|
cardId: card.cardId,
|
|
2044
2195
|
claimedAt: at,
|
|
2045
2196
|
role,
|
|
2046
2197
|
envelopeId: envelope.envelopeId,
|
|
2047
2198
|
envelope,
|
|
2199
|
+
parentClaimId: null,
|
|
2200
|
+
attemptIndex: 0,
|
|
2048
2201
|
};
|
|
2049
2202
|
const nextClaims = [...activeClaims, claim];
|
|
2050
2203
|
// F3: the transaction record goes into the claims file BEFORE the
|
|
@@ -2473,13 +2626,71 @@ function replaceAttemptLocked({ boardPath, cardId, envelopeId, reason, replaceme
|
|
|
2473
2626
|
// carries the complete signed next value, so either crash window rolls
|
|
2474
2627
|
// forward deterministically (the same prepare→claims→commit scheme as
|
|
2475
2628
|
// claimCard, extended with the replace op and phase).
|
|
2476
|
-
const
|
|
2629
|
+
const oldRoute = {
|
|
2630
|
+
seatId: oldClaim.envelope.seatId, accountId: oldClaim.envelope.accountId,
|
|
2631
|
+
provider: oldClaim.envelope.provider, model: oldClaim.envelope.model, role: replacementRole,
|
|
2632
|
+
effort: oldClaim.envelope.effort, containmentTier: oldClaim.envelope.containmentTier,
|
|
2633
|
+
phase: oldClaim.envelope.phase, routeDecisionDigest: oldClaim.envelope.routeDecisionDigest,
|
|
2634
|
+
reservationId: oldClaim.envelope.reservationId,
|
|
2635
|
+
};
|
|
2636
|
+
const requestedRoute = replacement.route ?? oldRoute;
|
|
2637
|
+
if (requestedRoute.seatId === oldRoute.seatId
|
|
2638
|
+
&& canonicalJsonString(requestedRoute) !== canonicalJsonString(oldRoute)) {
|
|
2639
|
+
return { ok: false, code: "replacement-route-invalid", reason: "same-seat replacement is pinned to every field of the prior authenticated envelope (fails closed)" };
|
|
2640
|
+
}
|
|
2641
|
+
if (requestedRoute.seatId !== oldRoute.seatId) {
|
|
2642
|
+
// R1: different-seat replacement route fidelity. The caller-supplied
|
|
2643
|
+
// requestedRoute is NEVER the route authority. The seat is resolved from
|
|
2644
|
+
// the trusted router config for decision.selectedSeatId; the route fields
|
|
2645
|
+
// must then equal that seat's fields (effort follows the card), and the
|
|
2646
|
+
// decision's parentDecisionId must equal the prior decision id retrieved
|
|
2647
|
+
// from the store — never a merely non-null value.
|
|
2648
|
+
const decision = replacement.routeDecision;
|
|
2649
|
+
const routerConfig = replacement.routerConfig ?? null;
|
|
2650
|
+
const seat = decision !== null && typeof decision === "object" && Array.isArray(routerConfig?.seats)
|
|
2651
|
+
? routerConfig.seats.find((candidate) => candidate?.seatId === decision.selectedSeatId) ?? null
|
|
2652
|
+
: null;
|
|
2653
|
+
const digest = decision !== null && typeof decision === "object" ? sha256Hex(canonicalJsonString(decision)) : null;
|
|
2654
|
+
// R2: deadness is computed from supplied observations (reusing the pure
|
|
2655
|
+
// §7.2 rule), never accepted as a caller-supplied boolean.
|
|
2656
|
+
const dead = isDeadRoute({ seat, healthObservations: replacement.healthObservations ?? {},
|
|
2657
|
+
quotaObservations: replacement.quotaObservations ?? [], now: at });
|
|
2658
|
+
const priorDecisionId = replacement.routerStore !== null && replacement.routerStore !== undefined
|
|
2659
|
+
? getRouteDecisionIdByDigest(replacement.routerStore, oldClaim.envelope.routeDecisionDigest)
|
|
2660
|
+
: typeof replacement.priorDecisionId === "string" && replacement.priorDecisionId !== ""
|
|
2661
|
+
? replacement.priorDecisionId
|
|
2662
|
+
: null;
|
|
2663
|
+
const cardEffort = EFFORT_LEVELS.includes(card?.effort) ? card.effort : "default";
|
|
2664
|
+
const seatRoute = seat === null ? null : {
|
|
2665
|
+
seatId: seat.seatId, accountId: seat.accountId ?? null, provider: seat.provider,
|
|
2666
|
+
model: seat.model, containmentTier: seat.containmentTier, effort: cardEffort,
|
|
2667
|
+
};
|
|
2668
|
+
const routeMatchesSeat = seatRoute !== null
|
|
2669
|
+
&& requestedRoute.seatId === seatRoute.seatId
|
|
2670
|
+
&& requestedRoute.accountId === seatRoute.accountId
|
|
2671
|
+
&& requestedRoute.provider === seatRoute.provider
|
|
2672
|
+
&& requestedRoute.model === seatRoute.model
|
|
2673
|
+
&& requestedRoute.containmentTier === seatRoute.containmentTier
|
|
2674
|
+
&& requestedRoute.effort === seatRoute.effort
|
|
2675
|
+
&& requestedRoute.role === replacementRole;
|
|
2676
|
+
if (!decision || decision.schema !== "agentic-driver.route-decision.v1"
|
|
2677
|
+
|| seat === null || !routeMatchesSeat
|
|
2678
|
+
|| typeof priorDecisionId !== "string" || priorDecisionId === "" || decision.parentDecisionId !== priorDecisionId
|
|
2679
|
+
|| digest !== requestedRoute.routeDecisionDigest
|
|
2680
|
+
|| dead.dead
|
|
2681
|
+
|| (requestedRoute.reservationId !== null && replacement.reservationCreated !== true)) {
|
|
2682
|
+
return { ok: false, code: "replacement-route-invalid", reason: "different-seat replacement requires the config seat for the decision's selectedSeatId, an exact seat-derived route, the prior store decision id as parentDecisionId, a live (non-dead) route, a matching digest, and a reservation (fails closed)" };
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
const newEnvelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: replacementRepository, startingRevision: replacement.startingRevision ?? undefined, route: requestedRoute });
|
|
2477
2686
|
const newClaim = {
|
|
2478
2687
|
cardId,
|
|
2479
2688
|
claimedAt: at,
|
|
2480
2689
|
role: replacementRole,
|
|
2481
2690
|
envelopeId: newEnvelope.envelopeId,
|
|
2482
2691
|
envelope: newEnvelope,
|
|
2692
|
+
parentClaimId: envelopeId,
|
|
2693
|
+
attemptIndex: (oldClaim.attemptIndex ?? 0) + 1,
|
|
2483
2694
|
};
|
|
2484
2695
|
const keptClaims = activeClaims.filter((claim) => claim.envelopeId !== envelopeId);
|
|
2485
2696
|
const nextClaims = [...keptClaims, newClaim];
|
package/skills/pulse/SKILL.md
CHANGED