@evoclock/pi-agentic-driver 0.8.4 → 0.9.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.
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pi-agentic-driver v0.
|
|
1
|
+
# pi-agentic-driver v0.9.0
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
|
|
10
|
-
<img src="https://img.shields.io/badge/version-0.
|
|
10
|
+
<img src="https://img.shields.io/badge/version-0.9.0-blue?style=flat" alt="Version 0.9.0"/>
|
|
11
11
|
<img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
|
|
12
12
|
<img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
|
|
13
13
|
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
|
|
@@ -51,6 +51,8 @@ proofs for agentic workflows.
|
|
|
51
51
|
| `agentic_worker_dispatch` | Runs controlled worker journeys and observes worker liveness. | shipped |
|
|
52
52
|
| `agentic_kanban_board` | Shows the workspace task board: lanes, flags, priorities, dependencies, and which cards can run. | shipped |
|
|
53
53
|
| `agentic_kanban_board_write` | Adds cards to the board through the trusted writer, which records who authorized the work. | shipped |
|
|
54
|
+
| `agentic_kanban_board_update` | Moves, closes, flags, edits, or removes cards on the board, always recording who authorized the change. | shipped |
|
|
55
|
+
| `agentic_kanban_board_dispatch` | Claims an eligible board card for automated contained work and creates its assignment envelope. | shipped |
|
|
54
56
|
|
|
55
57
|
**Status: active development and testing.** Each extension ships only after
|
|
56
58
|
it passes fixture-based acceptance, native tests, live-session checks, and
|
|
@@ -190,30 +192,51 @@ silently, and return results as untrusted evidence.
|
|
|
190
192
|
|
|
191
193
|
|
|
192
194
|
<details>
|
|
193
|
-
<summary><strong>task board, planned work you can see</strong> <em>(released, 0.
|
|
195
|
+
<summary><strong>task board, planned and automated work you can see</strong> <em>(released, 0.9.0)</em></summary>
|
|
194
196
|
|
|
195
|
-
Keep planned work
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
Keep planned work in canonical `TASKS.md`. You can read it directly or use
|
|
198
|
+
the generated `board.md` view in Obsidian. Agents use the same canonical
|
|
199
|
+
record through natural-language board tools.
|
|
198
200
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
You can tell an agent to add, edit, move, block, propose, close, or remove a
|
|
202
|
+
card. The trusted writer assigns its ID, checks its fields, records your
|
|
203
|
+
instruction, and protects its dispatch provenance.
|
|
201
204
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
instruction, or your approval of an agent's proposal. A card without that
|
|
206
|
-
record cannot be dispatched. A hand-edited card cannot fake the record, and
|
|
207
|
-
a tampered card refuses to run.
|
|
205
|
+
Agents cannot invent additional work or complete a card on their own. An
|
|
206
|
+
agent report is evidence for your review. Only your clear instruction or
|
|
207
|
+
direct board action completes the card.
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
agent to close it. An agent report that says the work is finished is
|
|
211
|
-
evidence for your review. It is never the completion itself.
|
|
209
|
+
For automated work, you set an explicit policy for the board. It defines:
|
|
212
210
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
- allowed roles and repositories;
|
|
212
|
+
- contained placement;
|
|
213
|
+
- the maximum concurrent work;
|
|
214
|
+
- the policy expiry; and
|
|
215
|
+
- the risk ceiling.
|
|
216
|
+
|
|
217
|
+
Without a valid policy, automated dispatch is refused.
|
|
218
|
+
|
|
219
|
+
`agentic_kanban_board_dispatch` claims the highest-priority eligible card and
|
|
220
|
+
creates a single-attempt assignment envelope. The envelope binds the card,
|
|
221
|
+
repository, starting revision, assigned branch, allowed paths, stopping
|
|
222
|
+
point, expiry, and interaction profile.
|
|
223
|
+
|
|
224
|
+
Blocked, altered, expired, reused, or unauthorized assignments fail closed.
|
|
225
|
+
The journey validates the authenticated envelope before each consequential
|
|
226
|
+
step. It runs on the assigned branch and consumes the envelope when the
|
|
227
|
+
attempt ends.
|
|
228
|
+
|
|
229
|
+
Consuming an envelope releases the claim. It does not mark the card done.
|
|
230
|
+
|
|
231
|
+
Claims and envelopes are kept in authenticated state beside `TASKS.md`. The
|
|
232
|
+
derived `board.md` view shows active claims but never becomes an authority
|
|
233
|
+
source.
|
|
234
|
+
|
|
235
|
+
Coming next:
|
|
236
|
+
|
|
237
|
+
- an optional watcher that checks for dispatchable work;
|
|
238
|
+
- Vogelkop support for the shared card fields; and
|
|
239
|
+
- our own Obsidian plugin for canonical `TASKS.md`.
|
|
217
240
|
|
|
218
241
|
</details>
|
|
219
242
|
|
|
@@ -388,10 +411,10 @@ end" or "don't wait for me" and the journey runs in autonomous mode. Say
|
|
|
388
411
|
"do these two things" and it runs in the normal mode, pausing for you
|
|
389
412
|
between steps. The mode is recorded in the report either way.
|
|
390
413
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
414
|
+
Autonomous journeys can run work you dispatch directly or work claimed from
|
|
415
|
+
a task board. Board-managed journeys use the card's authenticated assignment
|
|
416
|
+
envelope and the board's automation policy. Direct journeys continue to work
|
|
417
|
+
without a board.
|
|
395
418
|
|
|
396
419
|
### The cast
|
|
397
420
|
|
|
@@ -520,7 +543,7 @@ pi install npm:@evoclock/pi-agentic-driver
|
|
|
520
543
|
Or from Git at a pinned tag:
|
|
521
544
|
|
|
522
545
|
```sh
|
|
523
|
-
pi install git:github.com/evoclock/pi-agentic-driver@v0.
|
|
546
|
+
pi install git:github.com/evoclock/pi-agentic-driver@v0.9.0
|
|
524
547
|
```
|
|
525
548
|
|
|
526
549
|
Released extensions load standalone; neither requires the other.
|
|
@@ -534,7 +557,7 @@ extensions you want with the object form in your Pi settings:
|
|
|
534
557
|
{
|
|
535
558
|
"packages": [
|
|
536
559
|
{
|
|
537
|
-
"source": "npm:@evoclock/pi-agentic-driver@0.
|
|
560
|
+
"source": "npm:@evoclock/pi-agentic-driver@0.9.0",
|
|
538
561
|
"extensions": [
|
|
539
562
|
"extensions/aidr.ts",
|
|
540
563
|
"extensions/code-phage.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
HERDR_COMMUNICATION_ACTIONS,
|
|
17
17
|
} from "./herdr_communication_pi.js";
|
|
18
18
|
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
19
|
+
import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope } from "./task_board_core_pi.js";
|
|
19
20
|
|
|
20
21
|
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
21
22
|
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
@@ -246,24 +247,53 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
246
247
|
const communicationOptions = options.communication ?? options;
|
|
247
248
|
const replacementRole = options.replacementRole ?? role;
|
|
248
249
|
const replacementModel = options.replacementModel ?? options.model ?? params.model;
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
250
|
+
const board = options.board && typeof options.board === "object"
|
|
251
|
+
&& typeof options.board.boardPath === "string" && options.board.envelope
|
|
252
|
+
? options.board
|
|
253
|
+
: null;
|
|
254
|
+
let boardAttemptStarted = false;
|
|
255
|
+
let envelopeConsumed = false;
|
|
256
|
+
const finish = async (requestedStatus) => {
|
|
257
|
+
let status = requestedStatus;
|
|
258
|
+
if (board && boardAttemptStarted && !envelopeConsumed) {
|
|
259
|
+
const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive"].includes(status)
|
|
260
|
+
? status
|
|
261
|
+
: status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
|
|
262
|
+
let consumed;
|
|
263
|
+
try {
|
|
264
|
+
consumed = consumeEnvelope({ boardPath: board.boardPath, envelopeId: board.envelope.envelopeId, reason });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
consumed = { ok: false, code: error?.code, reason: String(error?.message || error) };
|
|
267
|
+
}
|
|
268
|
+
if (consumed?.ok !== true) {
|
|
269
|
+
status = "failed";
|
|
270
|
+
journey.status = "failed";
|
|
271
|
+
journey.code = consumed?.code || "envelope-consume-failed";
|
|
272
|
+
journey.steps.push({ step: journey.steps.length + 1, taskId: null, status: "failed", error: consumed?.reason || "envelope consumption failed closed" });
|
|
273
|
+
} else {
|
|
274
|
+
envelopeConsumed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
journey.status = status;
|
|
278
|
+
return {
|
|
279
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
280
|
+
ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
|
|
281
|
+
action: "dispatch",
|
|
282
|
+
mode,
|
|
283
|
+
autonomy,
|
|
284
|
+
...(journey.cast ? { cast: journey.cast } : {}),
|
|
285
|
+
role,
|
|
286
|
+
status,
|
|
287
|
+
steps: journey.steps,
|
|
288
|
+
stepCount: journey.steps.filter((step) => step.status === "done").length,
|
|
289
|
+
code: journey.code,
|
|
290
|
+
report: journeyReceipt(journey),
|
|
291
|
+
reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
|
|
292
|
+
handoff: journey.handoff,
|
|
293
|
+
nonAuthorizing: true,
|
|
294
|
+
persisted: false,
|
|
295
|
+
};
|
|
296
|
+
};
|
|
267
297
|
|
|
268
298
|
// Explicit unstuck path: with native confirmation, spin up a replacement
|
|
269
299
|
// worker for the same trusted repository/role through the existing
|
|
@@ -375,6 +405,35 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
375
405
|
}
|
|
376
406
|
if (signal?.aborted) return finish("cancelled");
|
|
377
407
|
|
|
408
|
+
// Board-planned journey: authenticate the persisted envelope, prepare its
|
|
409
|
+
// assigned branch exactly once, then validate again. Every consequential
|
|
410
|
+
// prompt below repeats validation so mid-journey drift fails closed.
|
|
411
|
+
const checkBoardEnvelope = () => board
|
|
412
|
+
? validateEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope })
|
|
413
|
+
: { ok: true };
|
|
414
|
+
if (board) {
|
|
415
|
+
let guard;
|
|
416
|
+
try {
|
|
417
|
+
const prepared = prepareEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope });
|
|
418
|
+
if (!prepared.ok) guard = prepared;
|
|
419
|
+
else {
|
|
420
|
+
boardAttemptStarted = true;
|
|
421
|
+
guard = checkBoardEnvelope();
|
|
422
|
+
}
|
|
423
|
+
} catch (error) {
|
|
424
|
+
journey.status = "failed";
|
|
425
|
+
journey.code = error?.code || "envelope-invalid";
|
|
426
|
+
journey.steps.push({ step: 0, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
|
|
427
|
+
return finish("failed");
|
|
428
|
+
}
|
|
429
|
+
if (!guard.ok) {
|
|
430
|
+
journey.status = "failed";
|
|
431
|
+
journey.code = guard.code || "envelope-invalid";
|
|
432
|
+
journey.steps.push({ step: 0, taskId: null, status: "failed", error: guard.reason });
|
|
433
|
+
return finish("failed");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
378
437
|
for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
|
|
379
438
|
if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
|
|
380
439
|
|
|
@@ -452,6 +511,19 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
452
511
|
}
|
|
453
512
|
}
|
|
454
513
|
|
|
514
|
+
// Revalidate immediately before the consequential prompt. This catches
|
|
515
|
+
// expiry, card/claim changes, and repository/branch/revision drift that
|
|
516
|
+
// occurred during pulse, queue observation, or confirmation.
|
|
517
|
+
if (board) {
|
|
518
|
+
const guard = checkBoardEnvelope();
|
|
519
|
+
if (!guard.ok) {
|
|
520
|
+
journey.status = "failed";
|
|
521
|
+
journey.code = guard.code || "envelope-invalid";
|
|
522
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: guard.reason });
|
|
523
|
+
return finish("failed");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
455
527
|
// One prompt exchange. Any failure is terminal for the journey; there is
|
|
456
528
|
// no invisible retry or resend.
|
|
457
529
|
const exchange = await executeHerdrCommunication(
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
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
|
+
import { execFileSync } from "node:child_process";
|
|
16
17
|
import { dirname, join } from "node:path";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
@@ -486,6 +487,10 @@ export function serializeObsidianCard(card) {
|
|
|
486
487
|
const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
|
|
487
488
|
const parts = [checkbox, card.title];
|
|
488
489
|
parts.push(fieldText("id", card.cardId));
|
|
490
|
+
// §3.6: a live claim is published through the derived projection as the
|
|
491
|
+
// card being active. Presentation only — the claim record in dispatcher
|
|
492
|
+
// state is canonical for run authority, never this field.
|
|
493
|
+
if (card.activeClaim) parts.push(fieldText("active", card.activeClaim));
|
|
489
494
|
if (card.hash) parts.push(fieldText("hash", card.hash));
|
|
490
495
|
if (card.priority) parts.push(fieldText("priority", card.priority));
|
|
491
496
|
for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
|
|
@@ -582,11 +587,19 @@ export function projectionPath(boardPath) {
|
|
|
582
587
|
// Recompute the projection from the canonical cards. Best-effort relative to
|
|
583
588
|
// the authoritative write: a projection failure is reported but never rolls
|
|
584
589
|
// back or invalidates the canonical persist.
|
|
585
|
-
export function writeProjection(boardPath, cards) {
|
|
590
|
+
export function writeProjection(boardPath, cards, claims = []) {
|
|
586
591
|
const path = projectionPath(boardPath);
|
|
587
592
|
try {
|
|
593
|
+
const claimedIds = new Set((Array.isArray(claims) ? claims : [])
|
|
594
|
+
.map((claim) => claim?.cardId)
|
|
595
|
+
.filter((id) => typeof id === "string"));
|
|
596
|
+
const annotated = cards.map((card) => {
|
|
597
|
+
if (!claimedIds.has(card.cardId)) return card;
|
|
598
|
+
const claim = (Array.isArray(claims) ? claims : []).find((entry) => entry?.cardId === card.cardId);
|
|
599
|
+
return { ...card, activeClaim: typeof claim?.role === "string" ? claim.role : "claimed" };
|
|
600
|
+
});
|
|
588
601
|
const frontmatter = "---\nkanban-plugin: board\n---\n\n";
|
|
589
|
-
const body = serializeBoard(
|
|
602
|
+
const body = serializeBoard(annotated, { surface: "obsidian" })
|
|
590
603
|
.replace(/^## backlog$/m, "## Backlog")
|
|
591
604
|
.replace(/^## in-progress$/m, "## In Progress")
|
|
592
605
|
.replace(/^## review$/m, "## Review")
|
|
@@ -632,13 +645,26 @@ function readWriterState(statePath) {
|
|
|
632
645
|
try {
|
|
633
646
|
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
634
647
|
const value = Number(state?.highWaterMark);
|
|
648
|
+
const generation = Number(state?.claimsGeneration);
|
|
635
649
|
return {
|
|
636
650
|
highWaterMark: Number.isInteger(value) && value >= 0 ? value : 0,
|
|
637
651
|
secret: typeof state?.secret === "string" && state.secret !== "" ? state.secret : null,
|
|
638
652
|
issuedCardIds: Array.isArray(state?.issuedCardIds) ? state.issuedCardIds.filter((id) => typeof id === "string") : [],
|
|
653
|
+
// Monotonic claims-generation anchor (deletion/rollback guard): the
|
|
654
|
+
// generation and digest of the LAST claims state this writer issued.
|
|
655
|
+
// A missing claims file is "fresh" only while no generation was ever
|
|
656
|
+
// issued; a lower generation or digest mismatch is replay/deletion.
|
|
657
|
+
claimsGeneration: Number.isInteger(generation) && generation >= 0 ? generation : 0,
|
|
658
|
+
claimsDigest: typeof state?.claimsDigest === "string" && state.claimsDigest !== "" ? state.claimsDigest : null,
|
|
659
|
+
// A complete next claims state is staged here before either authority
|
|
660
|
+
// file changes. Recovery always rolls it forward; it never guesses
|
|
661
|
+
// whether an older claims file is legitimate.
|
|
662
|
+
pendingClaimsState: state?.pendingClaimsState && typeof state.pendingClaimsState === "object"
|
|
663
|
+
? state.pendingClaimsState
|
|
664
|
+
: null,
|
|
639
665
|
};
|
|
640
666
|
} catch {
|
|
641
|
-
return { highWaterMark: 0, secret: null, issuedCardIds: [] };
|
|
667
|
+
return { highWaterMark: 0, secret: null, issuedCardIds: [], claimsGeneration: 0, claimsDigest: null, pendingClaimsState: null };
|
|
642
668
|
}
|
|
643
669
|
}
|
|
644
670
|
|
|
@@ -1254,6 +1280,912 @@ export function isDispatchable(card, boardIndex) {
|
|
|
1254
1280
|
return { dispatchable: failed.length === 0, failedConditions: failed };
|
|
1255
1281
|
}
|
|
1256
1282
|
|
|
1283
|
+
// ---------------------------------------------------------------------------
|
|
1284
|
+
// Dispatcher state (§3.6, §4): claims and assignment envelopes live OUTSIDE
|
|
1285
|
+
// the Markdown, beside the board, under the same writer lock as card writes.
|
|
1286
|
+
// Claim creation, envelope creation, and active-state publication happen in
|
|
1287
|
+
// one atomic operation. The claims file is canonical for run authority; the
|
|
1288
|
+
// Markdown stays canonical for task semantics.
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v2";
|
|
1292
|
+
export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v1";
|
|
1293
|
+
export const AUTOMATION_POLICY_SCHEMA = "agentic-driver.automation-policy.v1";
|
|
1294
|
+
export const PLACEMENTS = Object.freeze(["container", "host"]);
|
|
1295
|
+
export const RISK_LEVELS = Object.freeze(["low", "medium", "high"]);
|
|
1296
|
+
export const DEFAULT_ENVELOPE_EXPIRY_HOURS = 12;
|
|
1297
|
+
|
|
1298
|
+
// F1: the claims file is AUTHENTICATED exactly like the writer state — an
|
|
1299
|
+
// HMAC-SHA256 over its content, keyed by the writer-state secret, verified on
|
|
1300
|
+
// every read. Malformed, tampered, or HMAC-failing claims files fail closed:
|
|
1301
|
+
// dispatch is REFUSED, never treated as an empty claims list.
|
|
1302
|
+
|
|
1303
|
+
export function claimsPath(boardPath) {
|
|
1304
|
+
return `${boardPath}.claims.json`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
export function automationPolicyPath(boardPath) {
|
|
1308
|
+
return `${boardPath}.automation-policy.json`;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function readJsonFile(path) {
|
|
1312
|
+
try {
|
|
1313
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function writeJsonFileAtomic(path, value) {
|
|
1320
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1321
|
+
writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
1322
|
+
chmodSync(tmpPath, 0o600);
|
|
1323
|
+
renameSync(tmpPath, path);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// HMAC over the canonical JSON form of the claims content, keyed by the
|
|
1327
|
+
// writer-state secret (F1). Covers claims, consumed-claim records, and the
|
|
1328
|
+
// transaction record — every authority-bearing field of the file.
|
|
1329
|
+
function claimsFileHmac(value, secret) {
|
|
1330
|
+
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")
|
|
1338
|
+
.digest("hex");
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Item 6: CLOSED, strictly validated shapes. Every record trusted by the
|
|
1342
|
+
// dispatcher has an exact key set and typed/pattern-checked values — unknown
|
|
1343
|
+
// or missing keys fail closed before anything is trusted.
|
|
1344
|
+
const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
1345
|
+
|
|
1346
|
+
function exactKeys(value, keys) {
|
|
1347
|
+
const actual = Object.keys(value ?? {}).sort();
|
|
1348
|
+
const expected = [...keys].sort();
|
|
1349
|
+
return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const ENVELOPE_FIELDS = Object.freeze([
|
|
1353
|
+
"schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
|
|
1354
|
+
"baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
|
|
1355
|
+
"stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
|
|
1356
|
+
"riskCeiling", "mode", "createdAt", "expiry",
|
|
1357
|
+
]);
|
|
1358
|
+
|
|
1359
|
+
export function wellFormedEnvelope(env) {
|
|
1360
|
+
if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
|
|
1361
|
+
if (env.schema !== ENVELOPE_SCHEMA) return false;
|
|
1362
|
+
if (typeof env.envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(env.envelopeId)) return false;
|
|
1363
|
+
if (typeof env.cardId !== "string" || !CARD_ID_RE.test(env.cardId)) return false;
|
|
1364
|
+
if (env.cardHash !== null && !/^[0-9a-f]{64}$/.test(env.cardHash)) return false;
|
|
1365
|
+
if (typeof env.repository !== "string" || env.repository === "") return false;
|
|
1366
|
+
if (env.startingRevision !== null && !COMMIT_SHA_RE.test(env.startingRevision)) return false;
|
|
1367
|
+
if (env.baseRevision !== null && !COMMIT_SHA_RE.test(env.baseRevision)) return false;
|
|
1368
|
+
if (typeof env.branch !== "string" || !/^board\//.test(env.branch)) return false;
|
|
1369
|
+
if (!Array.isArray(env.allowedPaths) || !env.allowedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
|
|
1370
|
+
if (!Array.isArray(env.unchangedPaths) || !env.unchangedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
|
|
1371
|
+
if (!Array.isArray(env.capabilities) || !env.capabilities.every((c) => CAPABILITY_NAME_RE.test(c))) return false;
|
|
1372
|
+
if (env.stoppingPoint !== null && typeof env.stoppingPoint !== "string") return false;
|
|
1373
|
+
if (env.acceptance === null || typeof env.acceptance !== "object" || !exactKeys(env.acceptance, ["specHash", "dodHash"])) return false;
|
|
1374
|
+
if (env.acceptance.specHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.specHash)) return false;
|
|
1375
|
+
if (env.acceptance.dodHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.dodHash)) return false;
|
|
1376
|
+
if (!PLACEMENTS.includes(env.placement)) return false;
|
|
1377
|
+
if (!PLACEMENTS.includes(env.interactionProfile)) return false;
|
|
1378
|
+
if (!RISK_LEVELS.includes(env.risk)) return false;
|
|
1379
|
+
if (env.riskCeiling !== null && !RISK_LEVELS.includes(env.riskCeiling)) return false;
|
|
1380
|
+
if (env.mode !== "automated") return false;
|
|
1381
|
+
if (!ISO_TS_RE.test(env.createdAt) || !ISO_TS_RE.test(env.expiry)) return false;
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
|
|
1386
|
+
const CONSUMED_FIELDS = Object.freeze(["cardId", "envelopeId", "consumedAt", "reason"]);
|
|
1387
|
+
const CONSUMED_REASONS = Object.freeze([
|
|
1388
|
+
"expired", "reclaimed", "completed", "exhausted", "cancelled", "failed",
|
|
1389
|
+
"waiting-approval", "role-blocked", "worker-unresponsive",
|
|
1390
|
+
]);
|
|
1391
|
+
const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
|
|
1392
|
+
const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
|
|
1393
|
+
|
|
1394
|
+
function wellFormedClaim(claim) {
|
|
1395
|
+
return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CLAIM_FIELDS)
|
|
1396
|
+
&& CARD_ID_RE.test(claim.cardId)
|
|
1397
|
+
&& ISO_TS_RE.test(claim.claimedAt)
|
|
1398
|
+
&& ROLE_NAME_RE.test(claim.role)
|
|
1399
|
+
&& /^[0-9a-f]{32}$/.test(claim.envelopeId)
|
|
1400
|
+
&& wellFormedEnvelope(claim.envelope)
|
|
1401
|
+
&& claim.envelope.envelopeId === claim.envelopeId
|
|
1402
|
+
&& claim.envelope.cardId === claim.cardId;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function wellFormedConsumedClaim(claim) {
|
|
1406
|
+
return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CONSUMED_FIELDS)
|
|
1407
|
+
&& CARD_ID_RE.test(claim.cardId)
|
|
1408
|
+
&& /^[0-9a-f]{32}$/.test(claim.envelopeId)
|
|
1409
|
+
&& ISO_TS_RE.test(claim.consumedAt)
|
|
1410
|
+
&& CONSUMED_REASONS.includes(claim.reason);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function wellFormedTransaction(tx) {
|
|
1414
|
+
return tx === null
|
|
1415
|
+
|| (typeof tx === "object" && !Array.isArray(tx) && exactKeys(tx, TRANSACTION_FIELDS)
|
|
1416
|
+
&& tx.op === "claim" && CARD_ID_RE.test(tx.cardId)
|
|
1417
|
+
&& /^[0-9a-f]{32}$/.test(tx.envelopeId) && ISO_TS_RE.test(tx.at)
|
|
1418
|
+
&& tx.phase === "claims-written");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Read and VERIFY the claims file. Missing file → fresh ONLY while the
|
|
1422
|
+
// authenticated writer state proves no claims generation was ever issued;
|
|
1423
|
+
// once a generation is anchored, deletion fails closed. A generation older
|
|
1424
|
+
// than the anchored one, or a digest that does not match the anchored latest
|
|
1425
|
+
// digest, is replay of older correctly signed state — rejected. Malformed
|
|
1426
|
+
// JSON, wrong shape, or an HMAC that does not verify → {ok: false, reason};
|
|
1427
|
+
// the caller MUST fail closed (refuse dispatch).
|
|
1428
|
+
export function readClaimsState(boardPath) {
|
|
1429
|
+
const path = claimsPath(boardPath);
|
|
1430
|
+
const writerState = readWriterState(writerStatePath(boardPath));
|
|
1431
|
+
if (writerState.pendingClaimsState !== null) {
|
|
1432
|
+
return { ok: false, recoverable: true, reason: "a staged claims-anchor transaction requires recovery under the writer lock (fails closed)" };
|
|
1433
|
+
}
|
|
1434
|
+
if (!existsSync(path)) {
|
|
1435
|
+
if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
|
|
1436
|
+
return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
|
|
1437
|
+
}
|
|
1438
|
+
return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [] } };
|
|
1439
|
+
}
|
|
1440
|
+
const value = readJsonFile(path);
|
|
1441
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || !exactKeys(value, CLAIMS_STATE_FIELDS)) {
|
|
1442
|
+
return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
|
|
1443
|
+
}
|
|
1444
|
+
if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
|
|
1445
|
+
|| !Number.isInteger(value.generation) || value.generation < 0
|
|
1446
|
+
|| !wellFormedTransaction(value.transaction)
|
|
1447
|
+
|| !value.claims.every(wellFormedClaim)
|
|
1448
|
+
|| !value.consumedClaims.every(wellFormedConsumedClaim)) {
|
|
1449
|
+
return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
|
|
1450
|
+
}
|
|
1451
|
+
// F2 integrity: an ACTIVE claim may never reference a consumed envelope —
|
|
1452
|
+
// a consumed envelope cannot be reused; only a new envelopeId is valid.
|
|
1453
|
+
const consumedIds = new Set(value.consumedClaims.map((entry) => entry.envelopeId));
|
|
1454
|
+
if (value.claims.some((claim) => consumedIds.has(claim.envelopeId))) {
|
|
1455
|
+
return { ok: false, reason: "an active claim references a consumed envelope — the claims file is inconsistent (fails closed)" };
|
|
1456
|
+
}
|
|
1457
|
+
if (writerState.secret === null) {
|
|
1458
|
+
return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
|
|
1459
|
+
}
|
|
1460
|
+
if (typeof value.hmac !== "string" || value.hmac !== claimsFileHmac(value, writerState.secret)) {
|
|
1461
|
+
return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
|
|
1462
|
+
}
|
|
1463
|
+
// Deletion/rollback guard: the generation must be exactly the anchored
|
|
1464
|
+
// latest (older = replay even if correctly signed; newer = not ours).
|
|
1465
|
+
if (value.generation !== writerState.claimsGeneration) {
|
|
1466
|
+
return { ok: false, reason: `claims generation ${value.generation} does not match the anchored generation ${writerState.claimsGeneration} — deletion or replay is rejected (fails closed)` };
|
|
1467
|
+
}
|
|
1468
|
+
if (writerState.claimsDigest !== claimsAnchorDigest(value, writerState.secret)) {
|
|
1469
|
+
return { ok: false, reason: "the claims content does not match the writer-state anchor — rollback is rejected (fails closed)" };
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
ok: true,
|
|
1473
|
+
state: {
|
|
1474
|
+
generation: value.generation,
|
|
1475
|
+
transaction: value.transaction,
|
|
1476
|
+
claims: value.claims,
|
|
1477
|
+
consumedClaims: value.consumedClaims,
|
|
1478
|
+
},
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Convenience reader. Throws a coded error on corruption so a caller cannot
|
|
1483
|
+
// silently treat tampered state as an empty claims list (Sol minor 3).
|
|
1484
|
+
export function readClaims(boardPath) {
|
|
1485
|
+
const result = readClaimsState(boardPath);
|
|
1486
|
+
if (!result.ok) {
|
|
1487
|
+
throw Object.assign(new Error(result.reason), { code: "claims-corrupt" });
|
|
1488
|
+
}
|
|
1489
|
+
return result.state.claims;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function claimsAnchorDigest(value, secret) {
|
|
1493
|
+
return createHmac("sha256", secret)
|
|
1494
|
+
.update(canonicalJsonString({ generation: value.generation, hmac: value.hmac }), "utf8")
|
|
1495
|
+
.digest("hex");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Recover the two-file claims/anchor commit. The complete authenticated next
|
|
1499
|
+
// claims state is staged in writer state first, so either crash window rolls
|
|
1500
|
+
// forward deterministically rather than accepting an older file.
|
|
1501
|
+
function recoverClaimsAnchorLocked(boardPath) {
|
|
1502
|
+
const statePath = writerStatePath(boardPath);
|
|
1503
|
+
const writerState = readWriterState(statePath);
|
|
1504
|
+
const pending = writerState.pendingClaimsState;
|
|
1505
|
+
if (pending === null) return { ok: true, recovered: false };
|
|
1506
|
+
if (writerState.secret === null || !pending || typeof pending !== "object"
|
|
1507
|
+
|| !exactKeys(pending, CLAIMS_STATE_FIELDS)
|
|
1508
|
+
|| pending.hmac !== claimsFileHmac(pending, writerState.secret)) {
|
|
1509
|
+
return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
|
|
1510
|
+
}
|
|
1511
|
+
writeJsonFileAtomic(claimsPath(boardPath), pending);
|
|
1512
|
+
writeWriterState(statePath, {
|
|
1513
|
+
...writerState,
|
|
1514
|
+
claimsGeneration: pending.generation,
|
|
1515
|
+
claimsDigest: claimsAnchorDigest(pending, writerState.secret),
|
|
1516
|
+
pendingClaimsState: null,
|
|
1517
|
+
});
|
|
1518
|
+
return { ok: true, recovered: true };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function writeClaims(boardPath, claims, { consumedClaims = null, transaction = null } = {}) {
|
|
1522
|
+
const statePath = writerStatePath(boardPath);
|
|
1523
|
+
let writerState = readWriterState(statePath);
|
|
1524
|
+
if (writerState.secret === null) {
|
|
1525
|
+
throw Object.assign(new Error("writer state file has no secret (fails closed)"), { code: "writer-state-unavailable" });
|
|
1526
|
+
}
|
|
1527
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
1528
|
+
if (!recovery.ok) throw Object.assign(new Error(recovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
1529
|
+
writerState = readWriterState(statePath);
|
|
1530
|
+
const previous = readClaimsState(boardPath);
|
|
1531
|
+
if (!previous.ok) throw Object.assign(new Error(previous.reason), { code: "claims-corrupt" });
|
|
1532
|
+
const value = {
|
|
1533
|
+
schema: CLAIMS_SCHEMA,
|
|
1534
|
+
generation: previous.state.generation + 1,
|
|
1535
|
+
transaction,
|
|
1536
|
+
claims,
|
|
1537
|
+
consumedClaims: consumedClaims ?? previous.state.consumedClaims,
|
|
1538
|
+
};
|
|
1539
|
+
value.hmac = claimsFileHmac(value, writerState.secret);
|
|
1540
|
+
// Prepare → claims → commit. Because prepare contains the complete signed
|
|
1541
|
+
// next value, recovery can safely roll forward after either later write.
|
|
1542
|
+
writeWriterState(statePath, { ...writerState, pendingClaimsState: value });
|
|
1543
|
+
writeJsonFileAtomic(claimsPath(boardPath), value);
|
|
1544
|
+
writeWriterState(statePath, {
|
|
1545
|
+
...writerState,
|
|
1546
|
+
claimsGeneration: value.generation,
|
|
1547
|
+
claimsDigest: claimsAnchorDigest(value, writerState.secret),
|
|
1548
|
+
pendingClaimsState: null,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// The automation policy (§3.2, §4): an explicit, revocable record the USER
|
|
1553
|
+
// sets. No policy file = no overnight dispatch, ever (fails closed). Shape:
|
|
1554
|
+
// { roles: [...], placement: "container" | "host", maxConcurrent: N,
|
|
1555
|
+
// expiry: ISO yyyy-mm-dd (or full ISO timestamp), envelopeExpiryHours? }.
|
|
1556
|
+
export function readAutomationPolicy(boardPath, { configPath = null } = {}) {
|
|
1557
|
+
const path = configPath ?? automationPolicyPath(boardPath);
|
|
1558
|
+
const value = readJsonFile(path);
|
|
1559
|
+
if (value === null || typeof value !== "object") return null;
|
|
1560
|
+
return value;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Validate the policy shape and currency. The shape is CLOSED (F5): unknown
|
|
1564
|
+
// fields fail closed with a clear reason. Roles must match ROLE_NAME_RE.
|
|
1565
|
+
// The policy is bound to the board file it applies to (F4) and carries a risk
|
|
1566
|
+
// ceiling (F4). Returns {ok, policy, reason}.
|
|
1567
|
+
const POLICY_FIELDS = Object.freeze([
|
|
1568
|
+
"roles", "placement", "maxConcurrent", "expiry", "envelopeExpiryHours",
|
|
1569
|
+
"board", "riskCeiling", "allowPerCardRiskOverride", "acceptedRepositories",
|
|
1570
|
+
]);
|
|
1571
|
+
|
|
1572
|
+
export function checkAutomationPolicy(policy, { now = null, boardPath = null } = {}) {
|
|
1573
|
+
const at = now ?? new Date().toISOString();
|
|
1574
|
+
if (policy === null || policy === undefined) {
|
|
1575
|
+
return { ok: false, reason: "no automation policy is set — automated dispatch is refused (fails closed)" };
|
|
1576
|
+
}
|
|
1577
|
+
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
1578
|
+
return { ok: false, reason: "the automation policy is malformed (fails closed)" };
|
|
1579
|
+
}
|
|
1580
|
+
for (const key of Object.keys(policy)) {
|
|
1581
|
+
if (!POLICY_FIELDS.includes(key)) {
|
|
1582
|
+
return { ok: false, reason: `the automation policy has an unknown field "${key}" — the policy shape is closed (fails closed)` };
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
if (!Array.isArray(policy.roles) || policy.roles.length === 0
|
|
1586
|
+
|| !policy.roles.every((role) => typeof role === "string" && ROLE_NAME_RE.test(role))) {
|
|
1587
|
+
return { ok: false, reason: `the automation policy declares no valid roles (every role must match ${ROLE_NAME_RE.source}) (fails closed)` };
|
|
1588
|
+
}
|
|
1589
|
+
if (!PLACEMENTS.includes(policy.placement)) {
|
|
1590
|
+
return { ok: false, reason: `the automation policy placement must be one of ${PLACEMENTS.join(", ")} (fails closed)` };
|
|
1591
|
+
}
|
|
1592
|
+
const maxConcurrent = Number(policy.maxConcurrent);
|
|
1593
|
+
if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
|
|
1594
|
+
return { ok: false, reason: "the automation policy maxConcurrent must be a positive integer (fails closed)" };
|
|
1595
|
+
}
|
|
1596
|
+
const expiry = typeof policy.expiry === "string" ? policy.expiry : null;
|
|
1597
|
+
if (expiry === null || Number.isNaN(Date.parse(expiry))) {
|
|
1598
|
+
return { ok: false, reason: "the automation policy has no valid expiry (fails closed)" };
|
|
1599
|
+
}
|
|
1600
|
+
if (Date.parse(expiry) <= Date.parse(at)) {
|
|
1601
|
+
return { ok: false, reason: "the automation policy has expired — new dispatches are refused until it is renewed" };
|
|
1602
|
+
}
|
|
1603
|
+
if (policy.envelopeExpiryHours !== undefined
|
|
1604
|
+
&& (!Number.isFinite(Number(policy.envelopeExpiryHours)) || Number(policy.envelopeExpiryHours) <= 0)) {
|
|
1605
|
+
return { ok: false, reason: "the automation policy envelopeExpiryHours must be a positive number (fails closed)" };
|
|
1606
|
+
}
|
|
1607
|
+
if (typeof policy.board !== "string" || policy.board === "") {
|
|
1608
|
+
return { ok: false, reason: "the automation policy does not name the board file it applies to (fails closed)" };
|
|
1609
|
+
}
|
|
1610
|
+
if (boardPath !== null && policy.board !== boardPath) {
|
|
1611
|
+
return { ok: false, reason: `the automation policy is bound to board "${policy.board}", not this board (fails closed)` };
|
|
1612
|
+
}
|
|
1613
|
+
if (typeof policy.riskCeiling !== "string" || !RISK_LEVELS.includes(policy.riskCeiling)) {
|
|
1614
|
+
return { ok: false, reason: `the automation policy riskCeiling must be one of ${RISK_LEVELS.join(", ")} (fails closed)` };
|
|
1615
|
+
}
|
|
1616
|
+
if (policy.allowPerCardRiskOverride !== undefined && typeof policy.allowPerCardRiskOverride !== "boolean") {
|
|
1617
|
+
return { ok: false, reason: "the automation policy allowPerCardRiskOverride must be a boolean (fails closed)" };
|
|
1618
|
+
}
|
|
1619
|
+
// Repository/base policy (item 4): a CLOSED list of accepted repositories.
|
|
1620
|
+
// Dispatch may only run in a repository on this list; arbitrary repository
|
|
1621
|
+
// overrides outside it are rejected.
|
|
1622
|
+
if (!Array.isArray(policy.acceptedRepositories) || policy.acceptedRepositories.length === 0
|
|
1623
|
+
|| !policy.acceptedRepositories.every((repo) => typeof repo === "string" && repo !== "" && !repo.includes(".."))) {
|
|
1624
|
+
return { ok: false, reason: "the automation policy acceptedRepositories must be a non-empty closed list of repository paths (fails closed)" };
|
|
1625
|
+
}
|
|
1626
|
+
return { ok: true, policy, reason: null };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Item 4: a repository override is honored only when it is on the policy's
|
|
1630
|
+
// closed acceptedRepositories list (exact match).
|
|
1631
|
+
export function repositoryAccepted(policy, repository) {
|
|
1632
|
+
return Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.includes(repository);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// §3.2 assignment envelope: created ONCE per assignment, immutable (deep-
|
|
1636
|
+
// frozen, F2), and single-attempt. Retry, drift, or expiry require a NEW
|
|
1637
|
+
// envelope — never a mutation of this one. F4: the starting revision comes
|
|
1638
|
+
// from the CARD'S repository (resolved from the workspace the board lives
|
|
1639
|
+
// in), the card's base revision is bound in when present (branch chaining),
|
|
1640
|
+
// and the risk classification comes from the policy (per-card override only
|
|
1641
|
+
// when the policy allows).
|
|
1642
|
+
export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null }) {
|
|
1643
|
+
const at = now ?? new Date().toISOString();
|
|
1644
|
+
const envelopeId = randomBytes(16).toString("hex");
|
|
1645
|
+
const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
|
|
1646
|
+
? policy.envelopeExpiryHours
|
|
1647
|
+
: DEFAULT_ENVELOPE_EXPIRY_HOURS;
|
|
1648
|
+
let expiry = new Date(Date.parse(at) + expiryHours * 3_600_000).toISOString();
|
|
1649
|
+
// The envelope can never outlive the policy that authorized it.
|
|
1650
|
+
if (policy?.expiry && Date.parse(policy.expiry) < Date.parse(expiry)) expiry = policy.expiry;
|
|
1651
|
+
const repo = repository ?? (policy?.board ? dirname(policy.board) : process.cwd());
|
|
1652
|
+
const overrideAllowed = policy?.allowPerCardRiskOverride === true;
|
|
1653
|
+
const cardRisk = typeof card?.risk === "string" && RISK_LEVELS.includes(card.risk) ? card.risk : null;
|
|
1654
|
+
const risk = overrideAllowed && cardRisk !== null ? cardRisk : policy?.riskCeiling ?? "low";
|
|
1655
|
+
// Item 4: work STARTS from the declared base revision where present — the
|
|
1656
|
+
// envelope's starting revision IS the base (branch chaining), not merely a
|
|
1657
|
+
// copied field; execution validation enforces HEAD === startingRevision.
|
|
1658
|
+
const startRev = startingRevision ?? card.base ?? gitHead(repo);
|
|
1659
|
+
return deepFreeze({
|
|
1660
|
+
schema: ENVELOPE_SCHEMA,
|
|
1661
|
+
envelopeId,
|
|
1662
|
+
cardId: card.cardId,
|
|
1663
|
+
cardHash: card.hash ?? null,
|
|
1664
|
+
repository: repo,
|
|
1665
|
+
startingRevision: startRev,
|
|
1666
|
+
baseRevision: card.base ?? null,
|
|
1667
|
+
branch: `board/${card.cardId}-${envelopeId.slice(0, 8)}`,
|
|
1668
|
+
allowedPaths: Object.freeze([...(card.scope ?? [])]),
|
|
1669
|
+
unchangedPaths: Object.freeze([...(card.unchangedPaths ?? [])]),
|
|
1670
|
+
capabilities: Object.freeze([...(card.capabilities ?? [])]),
|
|
1671
|
+
stoppingPoint: card.stoppingPoint ?? null,
|
|
1672
|
+
acceptance: Object.freeze({ specHash: card.specHash ?? null, dodHash: card.dodHash ?? null }),
|
|
1673
|
+
placement: policy?.placement ?? null,
|
|
1674
|
+
interactionProfile: policy?.placement ?? null,
|
|
1675
|
+
risk,
|
|
1676
|
+
riskCeiling: policy?.riskCeiling ?? null,
|
|
1677
|
+
mode: "automated",
|
|
1678
|
+
createdAt: at,
|
|
1679
|
+
expiry,
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function deepFreeze(value) {
|
|
1684
|
+
if (value !== null && typeof value === "object") {
|
|
1685
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
1686
|
+
Object.freeze(value);
|
|
1687
|
+
}
|
|
1688
|
+
return value;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// Read-only git observation for the envelope's starting revision. Git
|
|
1692
|
+
// OPERATIONS belong to the git extension (§0.9); reading HEAD is not one.
|
|
1693
|
+
export function gitHead(cwd = process.cwd()) {
|
|
1694
|
+
try {
|
|
1695
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
|
|
1696
|
+
} catch {
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export function gitBranch(cwd = process.cwd()) {
|
|
1702
|
+
try {
|
|
1703
|
+
return execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim() || null;
|
|
1704
|
+
} catch {
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// §3.3 + §4 dispatch eligibility, evaluated under the writer lock: the card
|
|
1710
|
+
// must be dispatchable per the pure predicate (which already enforces the
|
|
1711
|
+
// blocked-by gate, hash validity, and — with statePath set — writer
|
|
1712
|
+
// provenance), not already claimed, and its cardId must be in the writer's
|
|
1713
|
+
// issued-IDs ledger.
|
|
1714
|
+
export function dispatchEligibility({ card, boardIndex, boardPath, activeClaims }) {
|
|
1715
|
+
const claimed = new Set((activeClaims ?? []).map((claim) => claim.cardId));
|
|
1716
|
+
if (claimed.has(card.cardId)) {
|
|
1717
|
+
return { eligible: false, reason: `card ${card.cardId} is already claimed` };
|
|
1718
|
+
}
|
|
1719
|
+
const withState = { ...card, statePath: writerStatePath(boardPath) };
|
|
1720
|
+
const result = isDispatchable(withState, boardIndex);
|
|
1721
|
+
if (!result.dispatchable) {
|
|
1722
|
+
return { eligible: false, reason: result.failedConditions.join("; ") };
|
|
1723
|
+
}
|
|
1724
|
+
return { eligible: true, reason: null };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const PRIORITY_ORDER = Object.freeze({ P0: 0, P1: 1, P2: 2, P3: 3 });
|
|
1728
|
+
|
|
1729
|
+
// Select the highest-priority dispatchable, unclaimed, provenance-verified
|
|
1730
|
+
// card. Optional cardId restricts selection to that card.
|
|
1731
|
+
export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId = null }) {
|
|
1732
|
+
const index = new Map(cards.map((card) => [card.cardId, card]));
|
|
1733
|
+
const candidates = cards
|
|
1734
|
+
.filter((card) => cardId === null || card.cardId === cardId)
|
|
1735
|
+
.map((card) => ({ card, eligibility: dispatchEligibility({ card, boardIndex: index, boardPath, activeClaims }) }))
|
|
1736
|
+
.filter((entry) => entry.eligibility.eligible)
|
|
1737
|
+
.sort((a, b) =>
|
|
1738
|
+
(PRIORITY_ORDER[a.card.priority] ?? 99) - (PRIORITY_ORDER[b.card.priority] ?? 99)
|
|
1739
|
+
|| String(a.card.cardId).localeCompare(String(b.card.cardId)));
|
|
1740
|
+
return candidates[0] ?? null;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// §3.6 + §4: THE atomic claim. Under the writer lock, in one operation:
|
|
1744
|
+
// eligibility check → envelope creation → claims-file persist → projection
|
|
1745
|
+
// republication with the card shown active. A crash leaves either the old or
|
|
1746
|
+
// the new complete state (both writes are atomic renames); two concurrent
|
|
1747
|
+
// claims can never both win because the entire read-decide-write sequence
|
|
1748
|
+
// holds the lock. Expired claims are released first, inside the same lock.
|
|
1749
|
+
export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null }) {
|
|
1750
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
1751
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
1752
|
+
}
|
|
1753
|
+
if (typeof role !== "string" || role === "") {
|
|
1754
|
+
throw Object.assign(new Error("role is required"), { code: "role-required" });
|
|
1755
|
+
}
|
|
1756
|
+
// Contended locks (two pulses racing across processes) retry briefly and
|
|
1757
|
+
// then fail as a structured contention error — never a crash. The claim
|
|
1758
|
+
// itself stays atomic: whoever takes the lock first wins the card.
|
|
1759
|
+
const { sleepSync } = { sleepSync: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) };
|
|
1760
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1761
|
+
try {
|
|
1762
|
+
return withWriterLock(boardPath, () =>
|
|
1763
|
+
claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }));
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
if (error?.code === "writer-lock-held" && attempt < 20) {
|
|
1766
|
+
sleepSync(25);
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
if (error?.code === "writer-lock-held") {
|
|
1770
|
+
return { ok: false, code: "lock-contention", reason: "the board writer lock stayed contended; the claim did not land (no card was double-claimed)" };
|
|
1771
|
+
}
|
|
1772
|
+
throw error;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }) {
|
|
1778
|
+
const at = now ?? new Date().toISOString();
|
|
1779
|
+
if (!existsSync(boardPath)) {
|
|
1780
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
1781
|
+
}
|
|
1782
|
+
if (!ROLE_NAME_RE.test(role)) {
|
|
1783
|
+
return { ok: false, code: "role-invalid", reason: `role "${role}" does not match ${ROLE_NAME_RE.source} (fails closed)` };
|
|
1784
|
+
}
|
|
1785
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
1786
|
+
if (!anchorRecovery.ok) {
|
|
1787
|
+
return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
1788
|
+
}
|
|
1789
|
+
// F1: read AND verify the claims file. Corruption fails closed.
|
|
1790
|
+
const claimsRead = readClaimsState(boardPath);
|
|
1791
|
+
if (!claimsRead.ok) {
|
|
1792
|
+
return { ok: false, code: "claims-corrupt", reason: claimsRead.reason };
|
|
1793
|
+
}
|
|
1794
|
+
// F3: reconcile an interrupted transaction before anything else. A crash
|
|
1795
|
+
// between the claims-file write and the projection republish leaves a
|
|
1796
|
+
// transaction record; roll it forward (republish the projection) so a live
|
|
1797
|
+
// claim never exists without its active-state publication, and an envelope
|
|
1798
|
+
// never exists without its claim (the envelope is written inside the same
|
|
1799
|
+
// claims record, so claims-file presence IS claim+envelope presence).
|
|
1800
|
+
// If reconciliation itself fails, REFUSE the mutation — never clear or
|
|
1801
|
+
// bypass the pending transaction (item 3).
|
|
1802
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: claimsRead.state });
|
|
1803
|
+
if (reconciliation !== null && reconciliation.failed) {
|
|
1804
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason,
|
|
1805
|
+
errors: [reconciliation.reason] };
|
|
1806
|
+
}
|
|
1807
|
+
// Automation policy first (§4): no policy = no overnight dispatch.
|
|
1808
|
+
const resolvedPolicy = policy ?? readAutomationPolicy(boardPath, { configPath });
|
|
1809
|
+
const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
|
|
1810
|
+
if (!policyCheck.ok) {
|
|
1811
|
+
return { ok: false, code: "policy-refused", reason: policyCheck.reason };
|
|
1812
|
+
}
|
|
1813
|
+
if (!policyCheck.policy.roles.includes(role)) {
|
|
1814
|
+
return { ok: false, code: "policy-role-refused", reason: `role "${role}" is not declared in the automation policy (fails closed)` };
|
|
1815
|
+
}
|
|
1816
|
+
// §3.4 mode table: automated placement is container/microVM, always.
|
|
1817
|
+
if (policyCheck.policy.placement !== "container") {
|
|
1818
|
+
return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
|
|
1819
|
+
}
|
|
1820
|
+
// F4: the starting revision comes from the card's repository — the
|
|
1821
|
+
// workspace the board lives in, not process.cwd(). A caller-supplied
|
|
1822
|
+
// repository override is honored ONLY when it is on the policy's closed
|
|
1823
|
+
// acceptedRepositories list (item 4).
|
|
1824
|
+
const defaultRepo = dirname(boardPath);
|
|
1825
|
+
const repo = repository !== undefined && repository !== null
|
|
1826
|
+
? (repositoryAccepted(policyCheck.policy, repository) ? repository : null)
|
|
1827
|
+
: defaultRepo;
|
|
1828
|
+
if (repo === null) {
|
|
1829
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${repository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
1830
|
+
}
|
|
1831
|
+
if (!repositoryAccepted(policyCheck.policy, repo)) {
|
|
1832
|
+
return { ok: false, code: "policy-repository-refused", reason: `repository "${repo}" is not on the automation policy's acceptedRepositories list (fails closed)` };
|
|
1833
|
+
}
|
|
1834
|
+
// Expired envelopes release their claims automatically on check (§4.6).
|
|
1835
|
+
const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: claimsRead.state });
|
|
1836
|
+
const concurrency = Number(policyCheck.policy.maxConcurrent);
|
|
1837
|
+
if (activeClaims.length >= concurrency) {
|
|
1838
|
+
return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
|
|
1839
|
+
}
|
|
1840
|
+
const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
1841
|
+
if (!validatedBoard.ok) {
|
|
1842
|
+
return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
|
|
1843
|
+
}
|
|
1844
|
+
// F2: an envelope whose attempt was consumed (expired, reclaimed, or
|
|
1845
|
+
// completed) can never be reused — the consumed-attempt marker is
|
|
1846
|
+
// HMAC-covered in the claims file, and readClaimsState refuses any active
|
|
1847
|
+
// claim referencing a consumed envelopeId. A retry mints a NEW envelope.
|
|
1848
|
+
const consumedIds = new Set(claimsRead.state.consumedClaims.map((entry) => entry.envelopeId));
|
|
1849
|
+
if (cardId !== null && claimsRead.state.claims.some((claim) => claim.cardId === cardId && consumedIds.has(claim.envelopeId))) {
|
|
1850
|
+
return { ok: false, code: "envelope-consumed", reason: `card ${cardId} has a consumed envelope attempt; a retry requires a new dispatch and a new envelope` };
|
|
1851
|
+
}
|
|
1852
|
+
const selected = selectDispatchableCard({ cards: validatedBoard.cards, boardPath, activeClaims, cardId });
|
|
1853
|
+
if (selected === null) {
|
|
1854
|
+
return { ok: false, code: "no-dispatchable-card", reason: cardId
|
|
1855
|
+
? `card ${cardId} is not dispatchable, is already claimed, or lacks writer provenance`
|
|
1856
|
+
: "no dispatchable unclaimed card is available" };
|
|
1857
|
+
}
|
|
1858
|
+
const card = selected.card;
|
|
1859
|
+
const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision });
|
|
1860
|
+
const claim = {
|
|
1861
|
+
cardId: card.cardId,
|
|
1862
|
+
claimedAt: at,
|
|
1863
|
+
role,
|
|
1864
|
+
envelopeId: envelope.envelopeId,
|
|
1865
|
+
envelope,
|
|
1866
|
+
};
|
|
1867
|
+
const nextClaims = [...activeClaims, claim];
|
|
1868
|
+
// F3: the transaction record goes into the claims file BEFORE the
|
|
1869
|
+
// projection rename. Both writes are atomic renames under the lock, so a
|
|
1870
|
+
// crash leaves either the old or the new complete state, and the record
|
|
1871
|
+
// drives roll-forward recovery on the next operation.
|
|
1872
|
+
const transaction = { op: "claim", cardId: card.cardId, envelopeId: envelope.envelopeId, at, phase: "claims-written" };
|
|
1873
|
+
writeClaims(boardPath, nextClaims, { transaction });
|
|
1874
|
+
// Active-state publication: the projection is recomputed with the claim
|
|
1875
|
+
// visible, in the same locked operation (§3.6).
|
|
1876
|
+
const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
|
|
1877
|
+
// Item 3: a projection failure returns ok:false as a STRUCTURED
|
|
1878
|
+
// RECOVERABLE failure — the claim and envelope are committed in the
|
|
1879
|
+
// authenticated claims file with the transaction retained; the next
|
|
1880
|
+
// operation rolls the projection forward. Never silently ok:true.
|
|
1881
|
+
if (!projection.written) {
|
|
1882
|
+
return {
|
|
1883
|
+
ok: false,
|
|
1884
|
+
claimed: true,
|
|
1885
|
+
code: "claim-recoverable",
|
|
1886
|
+
recoverable: true,
|
|
1887
|
+
reason: `projection publication failed: ${projection.error ?? "unknown"} (claim committed; transaction retained for roll-forward)`,
|
|
1888
|
+
cardId: card.cardId,
|
|
1889
|
+
role,
|
|
1890
|
+
claim,
|
|
1891
|
+
envelope,
|
|
1892
|
+
projection,
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
// F3: finalize — clear the transaction record now that the projection is
|
|
1896
|
+
// published.
|
|
1897
|
+
finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
|
|
1898
|
+
return {
|
|
1899
|
+
ok: true,
|
|
1900
|
+
claimed: true,
|
|
1901
|
+
cardId: card.cardId,
|
|
1902
|
+
role,
|
|
1903
|
+
claim,
|
|
1904
|
+
envelope,
|
|
1905
|
+
projection,
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// F3: roll an interrupted claim transaction forward — the claims (and their
|
|
1910
|
+
// envelopes) are already committed in the authenticated claims file, so only
|
|
1911
|
+
// the projection publication can be stale; republish it. Returns
|
|
1912
|
+
// {failed: true, reason} when roll-forward fails; callers must REFUSE the
|
|
1913
|
+
// mutation and never clear the pending transaction (item 3).
|
|
1914
|
+
function reconcileTransactionLocked({ boardPath, state }) {
|
|
1915
|
+
const transaction = state.transaction;
|
|
1916
|
+
if (transaction === null || transaction.phase !== "claims-written") return null;
|
|
1917
|
+
if (!existsSync(boardPath)) {
|
|
1918
|
+
return { failed: true, reason: "a pending claim transaction exists but the board file is unavailable — recovery failed (fails closed)" };
|
|
1919
|
+
}
|
|
1920
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
1921
|
+
if (!validated.ok) {
|
|
1922
|
+
return { failed: true, reason: "a pending claim transaction exists but the board is invalid — recovery failed (fails closed)" };
|
|
1923
|
+
}
|
|
1924
|
+
const projection = writeProjection(boardPath, validated.cards, state.claims);
|
|
1925
|
+
if (!projection.written) {
|
|
1926
|
+
return { failed: true, reason: `projection republication failed during recovery: ${projection.error ?? "unknown"} (transaction retained)` };
|
|
1927
|
+
}
|
|
1928
|
+
writeClaims(boardPath, state.claims, { transaction: null });
|
|
1929
|
+
return { failed: false };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// F3: clear the transaction record once the projection is published.
|
|
1933
|
+
function finalizeTransactionLocked({ boardPath, claims, projection }) {
|
|
1934
|
+
if (!projection?.written) {
|
|
1935
|
+
// Leave the transaction record in place so the next operation reconciles.
|
|
1936
|
+
return `projection publication failed: ${projection?.error ?? "unknown"} (transaction record retained for recovery)`;
|
|
1937
|
+
}
|
|
1938
|
+
writeClaims(boardPath, claims, { transaction: null });
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// F2/Item 2: whether an envelope attempt has been consumed (expired,
|
|
1943
|
+
// reclaimed, or completed) — the journey layer checks this before executing
|
|
1944
|
+
// an envelope.
|
|
1945
|
+
export function isEnvelopeConsumed(boardPath, envelopeId) {
|
|
1946
|
+
const read = readClaimsState(boardPath);
|
|
1947
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
1948
|
+
return read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Prepare the one assigned branch before execution. Authentication happens
|
|
1952
|
+
// first against the persisted claim; only a missing assigned branch may be
|
|
1953
|
+
// created, and only from the envelope's exact starting revision. Existing
|
|
1954
|
+
// branch mismatch is drift, not an implicit checkout.
|
|
1955
|
+
export function prepareEnvelopeForExecution({ boardPath, envelope }) {
|
|
1956
|
+
if (typeof boardPath !== "string" || boardPath === "" || !wellFormedEnvelope(envelope)) {
|
|
1957
|
+
return { ok: false, code: "envelope-invalid", reason: "a valid board path and envelope are required" };
|
|
1958
|
+
}
|
|
1959
|
+
try {
|
|
1960
|
+
return withWriterLock(boardPath, () => {
|
|
1961
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
1962
|
+
if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
|
|
1963
|
+
const guard = validateEnvelopeForExecutionLocked({ boardPath, envelope, requireBranch: false });
|
|
1964
|
+
if (!guard.ok) return guard;
|
|
1965
|
+
const current = gitBranch(envelope.repository);
|
|
1966
|
+
if (current === envelope.branch) return { ok: true, prepared: false, envelope: guard.envelope };
|
|
1967
|
+
if (gitHead(envelope.repository) !== envelope.startingRevision) {
|
|
1968
|
+
return { ok: false, code: "revision-drift", reason: "the repository is not at the envelope starting revision (fails closed)" };
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${envelope.branch}`], { cwd: envelope.repository });
|
|
1972
|
+
return { ok: false, code: "branch-drift", reason: `assigned branch ${envelope.branch} already exists but is not checked out (fails closed)` };
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
if (error?.status !== 1) return { ok: false, code: "branch-unreadable", reason: "the assigned branch state could not be verified (fails closed)" };
|
|
1975
|
+
}
|
|
1976
|
+
execFileSync("git", ["switch", "-c", envelope.branch, envelope.startingRevision], { cwd: envelope.repository, stdio: "ignore" });
|
|
1977
|
+
return { ok: true, prepared: true, envelope: guard.envelope };
|
|
1978
|
+
});
|
|
1979
|
+
} catch (error) {
|
|
1980
|
+
return { ok: false, code: error?.code || "branch-prepare-failed", reason: String(error?.message || error).slice(0, 512) };
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Item 2: the authoritative execution-boundary validation. Before an
|
|
1985
|
+
// envelope is executed or resumed, the journey layer MUST call this: it
|
|
1986
|
+
// validates authenticated consumption, expiry, card-hash drift, repository
|
|
1987
|
+
// drift, and HEAD/base/branch drift against the CURRENT board and repository.
|
|
1988
|
+
// Any drift fails closed — retry requires a new envelope.
|
|
1989
|
+
export function validateEnvelopeForExecution({ boardPath, envelope, now = null }) {
|
|
1990
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
1991
|
+
return { ok: false, code: "board-path-required", reason: "boardPath is required" };
|
|
1992
|
+
}
|
|
1993
|
+
try {
|
|
1994
|
+
return withWriterLock(boardPath, () => validateEnvelopeForExecutionLocked({ boardPath, envelope, now }));
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
return { ok: false, code: error?.code || "envelope-validation-failed", reason: String(error?.message || error).slice(0, 512) };
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function validateEnvelopeForExecutionLocked({ boardPath, envelope, now = null, requireBranch = true }) {
|
|
2001
|
+
const at = now ?? new Date().toISOString();
|
|
2002
|
+
if (!wellFormedEnvelope(envelope)) {
|
|
2003
|
+
return { ok: false, code: "envelope-invalid", reason: "the envelope is malformed or has an unknown shape (fails closed)" };
|
|
2004
|
+
}
|
|
2005
|
+
const recovery = recoverClaimsAnchorLocked(boardPath);
|
|
2006
|
+
if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
|
|
2007
|
+
const read = readClaimsState(boardPath);
|
|
2008
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2009
|
+
const claim = read.state.claims.find((entry) => entry.envelopeId === envelope.envelopeId);
|
|
2010
|
+
if (!claim) {
|
|
2011
|
+
const consumed = read.state.consumedClaims.some((entry) => entry.envelopeId === envelope.envelopeId);
|
|
2012
|
+
return { ok: false, code: "envelope-not-active",
|
|
2013
|
+
reason: consumed
|
|
2014
|
+
? "the envelope attempt was consumed — a retry requires a new envelope (single-attempt lifecycle)"
|
|
2015
|
+
: "the envelope has no active claim on the board (fails closed)" };
|
|
2016
|
+
}
|
|
2017
|
+
// The caller does not get to supply a well-formed variant. Execution uses
|
|
2018
|
+
// exactly the envelope authenticated inside the active claims state.
|
|
2019
|
+
if (canonicalJsonString(envelope) !== canonicalJsonString(claim.envelope)) {
|
|
2020
|
+
return { ok: false, code: "envelope-authentication-failed", reason: "the supplied envelope does not exactly match the authenticated active-claim envelope (fails closed)" };
|
|
2021
|
+
}
|
|
2022
|
+
// Single-attempt lifecycle: expiry.
|
|
2023
|
+
if (Date.parse(envelope.expiry) <= Date.parse(at)) {
|
|
2024
|
+
return { ok: false, code: "envelope-expired", reason: `the envelope expired at ${envelope.expiry} (single-attempt lifecycle)` };
|
|
2025
|
+
}
|
|
2026
|
+
// Card hash drift: the board's current card must still hash to the
|
|
2027
|
+
// envelope's binding.
|
|
2028
|
+
if (!existsSync(boardPath)) {
|
|
2029
|
+
return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
|
|
2030
|
+
}
|
|
2031
|
+
const board = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2032
|
+
if (!board.ok) return { ok: false, code: "board-invalid", reason: "the board is invalid (fails closed)" };
|
|
2033
|
+
const card = board.cards.find((entry) => entry.cardId === envelope.cardId);
|
|
2034
|
+
if (!card) {
|
|
2035
|
+
return { ok: false, code: "card-not-found", reason: `card ${envelope.cardId} no longer exists on the board (drift — new envelope required)` };
|
|
2036
|
+
}
|
|
2037
|
+
if ((card.hash ?? computeCardHash(card)) !== envelope.cardHash) {
|
|
2038
|
+
return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the envelope binding — semantic edits require a new envelope (fails closed)" };
|
|
2039
|
+
}
|
|
2040
|
+
// Repository drift.
|
|
2041
|
+
if (envelope.repository !== dirname(boardPath)) {
|
|
2042
|
+
return { ok: false, code: "repository-drift", reason: `the envelope is bound to repository "${envelope.repository}", not this board's repository (fails closed)` };
|
|
2043
|
+
}
|
|
2044
|
+
// HEAD/base/branch drift: work must start from the declared starting
|
|
2045
|
+
// revision (the card's base revision where present, per item 4) — the
|
|
2046
|
+
// repository's current HEAD must equal it.
|
|
2047
|
+
if (envelope.startingRevision !== null) {
|
|
2048
|
+
const head = gitHead(envelope.repository);
|
|
2049
|
+
if (head === null) {
|
|
2050
|
+
return { ok: false, code: "head-unreadable", reason: "the envelope's repository HEAD could not be read (fails closed)" };
|
|
2051
|
+
}
|
|
2052
|
+
if (head !== envelope.startingRevision) {
|
|
2053
|
+
return { ok: false, code: "revision-drift",
|
|
2054
|
+
reason: `repository HEAD ${head} does not match the envelope's starting revision ${envelope.startingRevision} (drift — new envelope required)` };
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
const branch = gitBranch(envelope.repository);
|
|
2058
|
+
if (requireBranch && branch !== envelope.branch) {
|
|
2059
|
+
return { ok: false, code: "branch-drift",
|
|
2060
|
+
reason: `repository branch ${branch ?? "(detached)"} does not match the assigned branch ${envelope.branch} (fails closed)` };
|
|
2061
|
+
}
|
|
2062
|
+
return { ok: true, claim, envelope: claim.envelope, reason: null };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Item 2: completion consumption — when the work reaches its stopping point,
|
|
2066
|
+
// the envelope attempt is consumed ("completed") and its claim released.
|
|
2067
|
+
// Never marks the card done (completion is human-only, §3.1).
|
|
2068
|
+
export function consumeEnvelope({ boardPath, envelopeId, reason = "completed" } = {}) {
|
|
2069
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2070
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2071
|
+
}
|
|
2072
|
+
if (!CONSUMED_REASONS.includes(reason)) {
|
|
2073
|
+
throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
|
|
2074
|
+
}
|
|
2075
|
+
return withWriterLock(boardPath, () => {
|
|
2076
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2077
|
+
if (!anchorRecovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
|
|
2078
|
+
const read = readClaimsState(boardPath);
|
|
2079
|
+
if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
|
|
2080
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2081
|
+
if (reconciliation?.failed) {
|
|
2082
|
+
return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
|
|
2083
|
+
}
|
|
2084
|
+
const claim = read.state.claims.find((entry) => entry.envelopeId === envelopeId);
|
|
2085
|
+
if (!claim) {
|
|
2086
|
+
return { ok: false, code: "envelope-not-active", reason: "the envelope has no active claim to consume" };
|
|
2087
|
+
}
|
|
2088
|
+
const kept = read.state.claims.filter((entry) => entry.envelopeId !== envelopeId);
|
|
2089
|
+
const consumedClaims = [...read.state.consumedClaims, {
|
|
2090
|
+
cardId: claim.cardId,
|
|
2091
|
+
envelopeId,
|
|
2092
|
+
consumedAt: new Date().toISOString(),
|
|
2093
|
+
reason,
|
|
2094
|
+
}];
|
|
2095
|
+
writeClaims(boardPath, kept, { consumedClaims });
|
|
2096
|
+
if (existsSync(boardPath)) {
|
|
2097
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2098
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, kept);
|
|
2099
|
+
}
|
|
2100
|
+
return { ok: true, consumed: true, envelopeId, reason, claims: kept };
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// Release every claim whose envelope expiry has passed. Returns the surviving
|
|
2105
|
+
// active claims and republishes the projection. Safe to call anytime.
|
|
2106
|
+
export function releaseExpiredClaims({ boardPath, now = null } = {}) {
|
|
2107
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2108
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2109
|
+
}
|
|
2110
|
+
return withWriterLock(boardPath, () => {
|
|
2111
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2112
|
+
if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
2113
|
+
const read = readClaimsState(boardPath);
|
|
2114
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
2115
|
+
// Item 3: every claims mutation reconciles first; refuse on failure.
|
|
2116
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2117
|
+
if (reconciliation?.failed) {
|
|
2118
|
+
throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
|
|
2119
|
+
}
|
|
2120
|
+
return { released: releaseExpiredClaimsLocked({ boardPath, at: now ?? new Date().toISOString(), state: read.state }) };
|
|
2121
|
+
}).released;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function releaseExpiredClaimsLocked({ boardPath, at, state = null }) {
|
|
2125
|
+
const existing = (state ?? readClaimsState(boardPath).state ?? { claims: [] }).claims;
|
|
2126
|
+
const active = existing.filter((claim) => {
|
|
2127
|
+
const expiry = claim?.envelope?.expiry;
|
|
2128
|
+
return typeof expiry === "string" && Date.parse(expiry) > Date.parse(at);
|
|
2129
|
+
});
|
|
2130
|
+
if (active.length !== existing.length) {
|
|
2131
|
+
// F2: a released envelope's attempt is consumed — it can never be
|
|
2132
|
+
// reused; a retry mints a new envelope.
|
|
2133
|
+
const consumedClaims = [...(state?.consumedClaims ?? readClaimsState(boardPath).state?.consumedClaims ?? []),
|
|
2134
|
+
...existing.filter((claim) => !active.includes(claim)).map((claim) => ({
|
|
2135
|
+
cardId: claim.cardId,
|
|
2136
|
+
envelopeId: claim.envelopeId,
|
|
2137
|
+
consumedAt: at,
|
|
2138
|
+
reason: "expired",
|
|
2139
|
+
}))];
|
|
2140
|
+
writeClaims(boardPath, active, { consumedClaims });
|
|
2141
|
+
if (existsSync(boardPath)) {
|
|
2142
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2143
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, active);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
return active;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Explicit reclaim: drop the claim for one card (or all claims with cardId
|
|
2150
|
+
// null). Releases the active-state publication. Never marks anything done.
|
|
2151
|
+
export function reclaimClaim({ boardPath, cardId = null, envelopeId = null } = {}) {
|
|
2152
|
+
if (typeof boardPath !== "string" || boardPath === "") {
|
|
2153
|
+
throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
|
|
2154
|
+
}
|
|
2155
|
+
return withWriterLock(boardPath, () => {
|
|
2156
|
+
const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
|
|
2157
|
+
if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
|
|
2158
|
+
const read = readClaimsState(boardPath);
|
|
2159
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
2160
|
+
// Item 3: reconcile first; refuse the mutation when recovery fails.
|
|
2161
|
+
const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
|
|
2162
|
+
if (reconciliation?.failed) {
|
|
2163
|
+
throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
|
|
2164
|
+
}
|
|
2165
|
+
const existing = read.state.claims;
|
|
2166
|
+
const kept = existing.filter((claim) =>
|
|
2167
|
+
(cardId !== null ? claim.cardId !== cardId : true)
|
|
2168
|
+
&& (envelopeId !== null ? claim.envelopeId !== envelopeId : true));
|
|
2169
|
+
if (kept.length === existing.length) {
|
|
2170
|
+
return { ok: false, code: "claim-not-found", reason: "no matching claim to reclaim" };
|
|
2171
|
+
}
|
|
2172
|
+
// F2: a reclaimed envelope's attempt is consumed too.
|
|
2173
|
+
const consumedClaims = [...read.state.consumedClaims,
|
|
2174
|
+
...existing.filter((claim) => !kept.includes(claim)).map((claim) => ({
|
|
2175
|
+
cardId: claim.cardId,
|
|
2176
|
+
envelopeId: claim.envelopeId,
|
|
2177
|
+
consumedAt: new Date().toISOString(),
|
|
2178
|
+
reason: "reclaimed",
|
|
2179
|
+
}))];
|
|
2180
|
+
writeClaims(boardPath, kept, { consumedClaims });
|
|
2181
|
+
if (existsSync(boardPath)) {
|
|
2182
|
+
const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
|
|
2183
|
+
if (validated.ok) writeProjection(boardPath, validated.cards, kept);
|
|
2184
|
+
}
|
|
2185
|
+
return { ok: true, reclaimed: existing.length - kept.length, claims: kept };
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
|
|
1257
2189
|
// ---------------------------------------------------------------------------
|
|
1258
2190
|
// Provider observation (§5 reversibility): everything registers behind the
|
|
1259
2191
|
// observation that a board file exists. No board file, no behavior change and
|
|
@@ -1595,5 +2527,63 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
|
|
|
1595
2527
|
});
|
|
1596
2528
|
registered.push("agentic_kanban_board_update");
|
|
1597
2529
|
}
|
|
2530
|
+
// The dispatch tool (§4): claims the highest-priority dispatchable,
|
|
2531
|
+
// unclaimed, provenance-verified card for a role under the user's
|
|
2532
|
+
// automation policy, creates the assignment envelope, and returns the
|
|
2533
|
+
// binding. The policy is the human decision; no policy = refusal. This
|
|
2534
|
+
// tool wires claim + envelope only — journey execution integration is a
|
|
2535
|
+
// follow-up.
|
|
2536
|
+
if (typeof pi?.registerTool === "function") {
|
|
2537
|
+
pi.registerTool({
|
|
2538
|
+
name: "agentic_kanban_board_dispatch",
|
|
2539
|
+
label: "Kanban Board Dispatch",
|
|
2540
|
+
description:
|
|
2541
|
+
"Claim the highest-priority dispatchable, unclaimed task-board card for a role and create its assignment envelope. Governed by the user's automation policy (roles, placement, maxConcurrent, expiry): no policy = refused; a role not in the policy = refused. Atomic: two concurrent claims can never claim the same card. Expired envelopes release automatically. Returns {cardId, envelope, branch, scope, stoppingPoint}.",
|
|
2542
|
+
parameters: {
|
|
2543
|
+
type: "object",
|
|
2544
|
+
additionalProperties: false,
|
|
2545
|
+
properties: {
|
|
2546
|
+
role: { type: "string", description: "The role claiming the card; must be declared in the automation policy." },
|
|
2547
|
+
cardId: { type: "string", description: "Optional: claim this specific card instead of the highest-priority dispatchable one." },
|
|
2548
|
+
},
|
|
2549
|
+
required: ["role"],
|
|
2550
|
+
},
|
|
2551
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
2552
|
+
const activeBoardPath = boardPathFor(ctx);
|
|
2553
|
+
if (!activeBoardPath || !existsSync(activeBoardPath)) {
|
|
2554
|
+
return unavailableResult();
|
|
2555
|
+
}
|
|
2556
|
+
if (typeof input?.role !== "string" || input.role === "") {
|
|
2557
|
+
const value = { ok: false, code: "invalid-input", reason: "role is required", errors: ["role is required"] };
|
|
2558
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2559
|
+
}
|
|
2560
|
+
let result;
|
|
2561
|
+
try {
|
|
2562
|
+
result = claimCard({ boardPath: activeBoardPath, role: input.role, cardId: input?.cardId ?? null });
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
const code = typeof error?.code === "string" && error.code !== "claims-corrupt" ? error.code : error.code;
|
|
2565
|
+
const value = { ok: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
|
|
2566
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2567
|
+
}
|
|
2568
|
+
let value;
|
|
2569
|
+
if (result.ok) {
|
|
2570
|
+
value = {
|
|
2571
|
+
ok: true,
|
|
2572
|
+
claimed: true,
|
|
2573
|
+
cardId: result.cardId,
|
|
2574
|
+
envelope: result.envelope,
|
|
2575
|
+
branch: result.envelope.branch,
|
|
2576
|
+
scope: result.envelope.allowedPaths,
|
|
2577
|
+
stoppingPoint: result.envelope.stoppingPoint,
|
|
2578
|
+
role: result.role,
|
|
2579
|
+
};
|
|
2580
|
+
} else {
|
|
2581
|
+
value = { ok: false, code: result.code, reason: result.reason ?? (result.errors ?? []).join("; "), errors: result.errors ?? [] };
|
|
2582
|
+
}
|
|
2583
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
2584
|
+
},
|
|
2585
|
+
});
|
|
2586
|
+
registered.push("agentic_kanban_board_dispatch");
|
|
2587
|
+
}
|
|
1598
2588
|
return { registered, observation: { ...observation, boardPath: observation.boardPath } };
|
|
1599
2589
|
}
|