@evoclock/pi-agentic-driver 0.8.4 → 0.9.1
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 +55 -27
- package/extensions/herdr-communication.ts +8 -1
- package/extensions/herdr-lifecycle.ts +4 -3
- package/extensions/pulse.ts +74 -0
- package/package.json +6 -2
- package/scripts/enforcement/herdr_async_dispatch_pi.js +152 -18
- package/scripts/enforcement/herdr_communication_pi.js +42 -11
- package/scripts/enforcement/pulse_core_pi.js +268 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +920 -0
- package/scripts/enforcement/task_board_core_pi.js +1331 -3
- package/skills/pulse/SKILL.md +39 -0
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,56 @@ 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
|
+
`agentic_kanban_pulse` checks the board for ready work and available capacity.
|
|
236
|
+
A check only reports which work is ready to run. It does not claim cards. Interactive host
|
|
237
|
+
work needs explicit policy and one native confirmation for the batch. Automated
|
|
238
|
+
work requires containment and never falls back to the host.
|
|
239
|
+
|
|
240
|
+
Coming next:
|
|
241
|
+
|
|
242
|
+
- contained Pulse execution through Testudo;
|
|
243
|
+
- Vogelkop support for the shared card fields; and
|
|
244
|
+
- our own Obsidian plugin for canonical `TASKS.md`.
|
|
217
245
|
|
|
218
246
|
</details>
|
|
219
247
|
|
|
@@ -388,10 +416,10 @@ end" or "don't wait for me" and the journey runs in autonomous mode. Say
|
|
|
388
416
|
"do these two things" and it runs in the normal mode, pausing for you
|
|
389
417
|
between steps. The mode is recorded in the report either way.
|
|
390
418
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
419
|
+
Autonomous journeys can run work you dispatch directly or work claimed from
|
|
420
|
+
a task board. Board-managed journeys use the card's authenticated assignment
|
|
421
|
+
envelope and the board's automation policy. Direct journeys continue to work
|
|
422
|
+
without a board.
|
|
395
423
|
|
|
396
424
|
### The cast
|
|
397
425
|
|
|
@@ -520,7 +548,7 @@ pi install npm:@evoclock/pi-agentic-driver
|
|
|
520
548
|
Or from Git at a pinned tag:
|
|
521
549
|
|
|
522
550
|
```sh
|
|
523
|
-
pi install git:github.com/evoclock/pi-agentic-driver@v0.
|
|
551
|
+
pi install git:github.com/evoclock/pi-agentic-driver@v0.9.0
|
|
524
552
|
```
|
|
525
553
|
|
|
526
554
|
Released extensions load standalone; neither requires the other.
|
|
@@ -534,7 +562,7 @@ extensions you want with the object form in your Pi settings:
|
|
|
534
562
|
{
|
|
535
563
|
"packages": [
|
|
536
564
|
{
|
|
537
|
-
"source": "npm:@evoclock/pi-agentic-driver@0.
|
|
565
|
+
"source": "npm:@evoclock/pi-agentic-driver@0.9.0",
|
|
538
566
|
"extensions": [
|
|
539
567
|
"extensions/aidr.ts",
|
|
540
568
|
"extensions/code-phage.js"
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
|
+
import { statSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
4
7
|
export default async function herdrCommunicationPi(pi) {
|
|
5
|
-
const
|
|
8
|
+
const moduleUrl = new URL("../scripts/enforcement/herdr_communication_pi.js", import.meta.url);
|
|
9
|
+
// Native ESM retains transitive modules across Pi's Jiti extension reload.
|
|
10
|
+
// Bind this import to the current file revision so /reload cannot reuse it.
|
|
11
|
+
moduleUrl.searchParams.set("mtime", String(statSync(fileURLToPath(moduleUrl)).mtimeMs));
|
|
12
|
+
const module = await import(moduleUrl.href);
|
|
6
13
|
return module.registerHerdrCommunicationInterface(pi);
|
|
7
14
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { registerHerdrLifecycleInterface } from "../scripts/enforcement/herdr_lifecycle_pi.js";
|
|
5
|
+
|
|
6
|
+
export default function herdrLifecyclePi(pi) {
|
|
7
|
+
return registerHerdrLifecycleInterface(pi);
|
|
7
8
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Board Pulse provider extension. Registers the deterministic
|
|
5
|
+
// agentic_kanban_pulse tool and owns the optional fixed-interval timer. The
|
|
6
|
+
// board is resolved per tool call from the calling session's working
|
|
7
|
+
// directory; a workspace with no board file gets a structured
|
|
8
|
+
// board-unavailable result. Board Pulse is capacity scheduling; it is
|
|
9
|
+
// distinct from Herdr worker liveness pulse.
|
|
10
|
+
//
|
|
11
|
+
// No resources start in the extension factory: the timer is created and
|
|
12
|
+
// started only on session_start and cleared idempotently on session_shutdown
|
|
13
|
+
// (PULSE_DESIGN_v3 §8). The worker spawn seam is the existing guarded
|
|
14
|
+
// herdr-lifecycle boundary — native confirmation, trusted repository,
|
|
15
|
+
// installed model roll, fixed argv, shell:false — never raw pane management.
|
|
16
|
+
|
|
17
|
+
import { executeHerdrSpawnWorker } from "../scripts/enforcement/herdr_lifecycle_pi.js";
|
|
18
|
+
import { resolveBoardPath, registerPulseTools, createPulseTimer, pulseTick, pulseWorkerSpawnSeam } from "../scripts/enforcement/pulse_scheduler_pi.js";
|
|
19
|
+
|
|
20
|
+
export default function pulsePi(pi) {
|
|
21
|
+
|
|
22
|
+
// Guarded worker-creation seam (§2.5): every Pulse spawn goes through
|
|
23
|
+
// pulseWorkerSpawnSeam, which is placement-aware and fails closed — host
|
|
24
|
+
// placements route to executeHerdrSpawnWorker (native confirmation,
|
|
25
|
+
// verification); container/microvm placements are denied before any host
|
|
26
|
+
// worker is ever started. No other creation path exists.
|
|
27
|
+
const spawnWorker = pulseWorkerSpawnSeam({ executeHerdrSpawnWorker });
|
|
28
|
+
|
|
29
|
+
const result = registerPulseTools(pi, {
|
|
30
|
+
resolveBoardPath: (ctx) => resolveBoardPath(typeof ctx === "string" ? ctx : ctx?.cwd || process.cwd()),
|
|
31
|
+
spawnWorker,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Optional timer (§8): created only after session_start, only when the
|
|
35
|
+
// resolved workspace policy enables Pulse in automated mode with a valid
|
|
36
|
+
// interval. fillOnStart queues one tick after startup; it is not catch-up.
|
|
37
|
+
// Ticks never overlap and missed ticks are never replayed. Reload/new/
|
|
38
|
+
// resume/fork emit session_shutdown first, so exactly one fresh runtime
|
|
39
|
+
// exists at a time and no in-memory single-flight state is inherited.
|
|
40
|
+
let timer = null;
|
|
41
|
+
const clearTimer = () => {
|
|
42
|
+
if (timer) {
|
|
43
|
+
timer.clear();
|
|
44
|
+
timer = null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const setupTimer = async (_event, ctx) => {
|
|
48
|
+
clearTimer();
|
|
49
|
+
try {
|
|
50
|
+
const boardPath = resolveBoardPath(ctx?.cwd || process.cwd());
|
|
51
|
+
if (!boardPath) return;
|
|
52
|
+
const { readAutomationPolicy, checkAutomationPolicy } = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
|
|
53
|
+
const policy = readAutomationPolicy(boardPath);
|
|
54
|
+
const check = checkAutomationPolicy(policy, { boardPath });
|
|
55
|
+
const pulse = check.ok ? check.policy.pulse : null;
|
|
56
|
+
if (!pulse || pulse.enabled !== true || pulse.mode !== "automated") return;
|
|
57
|
+
timer = createPulseTimer({
|
|
58
|
+
intervalSeconds: pulse.intervalSeconds,
|
|
59
|
+
fillOnStart: pulse.fillOnStart === true,
|
|
60
|
+
tick: () => pulseTick({ boardPath, spawnWorker, context: ctx }),
|
|
61
|
+
});
|
|
62
|
+
timer.start();
|
|
63
|
+
} catch {
|
|
64
|
+
// No-board/no-policy/disabled policy produce zero scheduling effects.
|
|
65
|
+
clearTimer();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
if (typeof pi?.on === "function") {
|
|
69
|
+
pi.on("session_start", setupTimer);
|
|
70
|
+
pi.on("session_shutdown", clearTimer);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"extensions/herdr-communication.ts",
|
|
41
41
|
"extensions/herdr-dispatch.ts",
|
|
42
42
|
"extensions/task-board.ts",
|
|
43
|
+
"extensions/pulse.ts",
|
|
43
44
|
"lib/adapters/diff-scope.mjs",
|
|
44
45
|
"lib/adapters/evidence.mjs",
|
|
45
46
|
"lib/adapters/narrative.mjs",
|
|
@@ -66,7 +67,10 @@
|
|
|
66
67
|
"extensions/aidr.ts",
|
|
67
68
|
"scripts/aidr_writing_review.js",
|
|
68
69
|
"templates/AGENTS.md",
|
|
69
|
-
"scripts/enforcement/task_board_core_pi.js"
|
|
70
|
+
"scripts/enforcement/task_board_core_pi.js",
|
|
71
|
+
"scripts/enforcement/pulse_core_pi.js",
|
|
72
|
+
"scripts/enforcement/pulse_scheduler_pi.js",
|
|
73
|
+
"skills/pulse/SKILL.md"
|
|
70
74
|
],
|
|
71
75
|
"pi": {
|
|
72
76
|
"extensions": [
|
|
@@ -16,6 +16,9 @@ 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, gitHead } from "./task_board_core_pi.js";
|
|
20
|
+
import { sha256Hex } from "./task_board_core_pi.js";
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
19
22
|
|
|
20
23
|
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
21
24
|
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
@@ -28,10 +31,17 @@ const MAX_JOURNEY_STEPS = 200;
|
|
|
28
31
|
const MAX_REPORT_BYTES = 32 * 1024;
|
|
29
32
|
const REGISTRATIONS = new WeakSet();
|
|
30
33
|
|
|
34
|
+
// The worker-side review-request marker. An implementation journey whose last
|
|
35
|
+
// complete report carries this marker terminates as review_requested (§6.3):
|
|
36
|
+
// it projects review readiness without approval. The board lane transition
|
|
37
|
+
// remains a human/writer decision; Pulse never infers it.
|
|
38
|
+
export const REVIEW_REQUEST_MARKER = "[REVIEW_REQUESTED]";
|
|
39
|
+
|
|
31
40
|
// Terminal journey states. Only `cancelled` and `failed` are failures; every
|
|
32
41
|
// other terminal state is an observed outcome, and no state is retried.
|
|
33
42
|
export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
|
|
34
43
|
"completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
|
|
44
|
+
"review_requested",
|
|
35
45
|
]);
|
|
36
46
|
|
|
37
47
|
export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
@@ -224,6 +234,46 @@ function journeyReceipt(journey) {
|
|
|
224
234
|
return body;
|
|
225
235
|
}
|
|
226
236
|
|
|
237
|
+
// §10.2 progress snapshot: a compact observation attached to the journey
|
|
238
|
+
// step/terminal evidence it belongs to. Not authority, not persisted
|
|
239
|
+
// separately, and not written on every timer tick — only at assignment start
|
|
240
|
+
// and meaningful checkpoints. Repository observations are read-only.
|
|
241
|
+
function progressSnapshot({ taskId = null, envelope = null, repository = null, workerStatus = null, lastReportAt = null }) {
|
|
242
|
+
const snapshot = {
|
|
243
|
+
schema: "agentic-driver.progress-snapshot.v1",
|
|
244
|
+
taskId,
|
|
245
|
+
at: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
if (envelope) {
|
|
248
|
+
snapshot.envelopeId = envelope.envelopeId ?? null;
|
|
249
|
+
snapshot.cardId = envelope.cardId ?? null;
|
|
250
|
+
snapshot.branch = envelope.branch ?? null;
|
|
251
|
+
}
|
|
252
|
+
if (repository) {
|
|
253
|
+
snapshot.repository = repository;
|
|
254
|
+
snapshot.head = gitHead(repository);
|
|
255
|
+
const diff = scopedDiffHash(repository);
|
|
256
|
+
snapshot.scopedDiffHash = diff.hash;
|
|
257
|
+
snapshot.changedPaths = diff.paths;
|
|
258
|
+
}
|
|
259
|
+
if (workerStatus !== null) snapshot.workerStatus = workerStatus;
|
|
260
|
+
if (lastReportAt !== null) snapshot.lastCompleteReportAt = lastReportAt;
|
|
261
|
+
return snapshot;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Hash of the repository's uncommitted diff (scoped work-in-progress signal).
|
|
265
|
+
// Returns {hash, paths}; a repository where git is unavailable yields nulls.
|
|
266
|
+
function scopedDiffHash(repository) {
|
|
267
|
+
try {
|
|
268
|
+
const paths = execFileSync("git", ["status", "--porcelain"], { cwd: repository, encoding: "utf8" })
|
|
269
|
+
.split("\n").map((line) => line.slice(3).trim()).filter(Boolean);
|
|
270
|
+
const diff = execFileSync("git", ["diff", "HEAD"], { cwd: repository, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
271
|
+
return { hash: sha256Hex(diff), paths: paths.slice(0, 64) };
|
|
272
|
+
} catch {
|
|
273
|
+
return { hash: null, paths: [] };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
227
277
|
// One continuous journey. Each step: pulse (liveness + eligibility), observe
|
|
228
278
|
// the next dispatchable item, one prompt exchange (no retry on any failure),
|
|
229
279
|
// read the marked report, collate. Bounded by maxSteps, never wall-clock.
|
|
@@ -246,24 +296,53 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
246
296
|
const communicationOptions = options.communication ?? options;
|
|
247
297
|
const replacementRole = options.replacementRole ?? role;
|
|
248
298
|
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
|
-
|
|
299
|
+
const board = options.board && typeof options.board === "object"
|
|
300
|
+
&& typeof options.board.boardPath === "string" && options.board.envelope
|
|
301
|
+
? options.board
|
|
302
|
+
: null;
|
|
303
|
+
let boardAttemptStarted = false;
|
|
304
|
+
let envelopeConsumed = false;
|
|
305
|
+
const finish = async (requestedStatus) => {
|
|
306
|
+
let status = requestedStatus;
|
|
307
|
+
if (board && boardAttemptStarted && !envelopeConsumed) {
|
|
308
|
+
const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
|
|
309
|
+
? status
|
|
310
|
+
: status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
|
|
311
|
+
let consumed;
|
|
312
|
+
try {
|
|
313
|
+
consumed = consumeEnvelope({ boardPath: board.boardPath, envelopeId: board.envelope.envelopeId, reason });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
consumed = { ok: false, code: error?.code, reason: String(error?.message || error) };
|
|
316
|
+
}
|
|
317
|
+
if (consumed?.ok !== true) {
|
|
318
|
+
status = "failed";
|
|
319
|
+
journey.status = "failed";
|
|
320
|
+
journey.code = consumed?.code || "envelope-consume-failed";
|
|
321
|
+
journey.steps.push({ step: journey.steps.length + 1, taskId: null, status: "failed", error: consumed?.reason || "envelope consumption failed closed" });
|
|
322
|
+
} else {
|
|
323
|
+
envelopeConsumed = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
journey.status = status;
|
|
327
|
+
return {
|
|
328
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
329
|
+
ok: status === "completed" || status === "exhausted" || status === "waiting-approval" || status === "review_requested",
|
|
330
|
+
action: "dispatch",
|
|
331
|
+
mode,
|
|
332
|
+
autonomy,
|
|
333
|
+
...(journey.cast ? { cast: journey.cast } : {}),
|
|
334
|
+
role,
|
|
335
|
+
status,
|
|
336
|
+
steps: journey.steps,
|
|
337
|
+
stepCount: journey.steps.filter((step) => step.status === "done").length,
|
|
338
|
+
code: journey.code,
|
|
339
|
+
report: journeyReceipt(journey),
|
|
340
|
+
reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
|
|
341
|
+
handoff: journey.handoff,
|
|
342
|
+
nonAuthorizing: true,
|
|
343
|
+
persisted: false,
|
|
344
|
+
};
|
|
345
|
+
};
|
|
267
346
|
|
|
268
347
|
// Explicit unstuck path: with native confirmation, spin up a replacement
|
|
269
348
|
// worker for the same trusted repository/role through the existing
|
|
@@ -375,6 +454,35 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
375
454
|
}
|
|
376
455
|
if (signal?.aborted) return finish("cancelled");
|
|
377
456
|
|
|
457
|
+
// Board-planned journey: authenticate the persisted envelope, prepare its
|
|
458
|
+
// assigned branch exactly once, then validate again. Every consequential
|
|
459
|
+
// prompt below repeats validation so mid-journey drift fails closed.
|
|
460
|
+
const checkBoardEnvelope = () => board
|
|
461
|
+
? validateEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope })
|
|
462
|
+
: { ok: true };
|
|
463
|
+
if (board) {
|
|
464
|
+
let guard;
|
|
465
|
+
try {
|
|
466
|
+
const prepared = prepareEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope });
|
|
467
|
+
if (!prepared.ok) guard = prepared;
|
|
468
|
+
else {
|
|
469
|
+
boardAttemptStarted = true;
|
|
470
|
+
guard = checkBoardEnvelope();
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
journey.status = "failed";
|
|
474
|
+
journey.code = error?.code || "envelope-invalid";
|
|
475
|
+
journey.steps.push({ step: 0, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
|
|
476
|
+
return finish("failed");
|
|
477
|
+
}
|
|
478
|
+
if (!guard.ok) {
|
|
479
|
+
journey.status = "failed";
|
|
480
|
+
journey.code = guard.code || "envelope-invalid";
|
|
481
|
+
journey.steps.push({ step: 0, taskId: null, status: "failed", error: guard.reason });
|
|
482
|
+
return finish("failed");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
378
486
|
for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
|
|
379
487
|
if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
|
|
380
488
|
|
|
@@ -452,6 +560,19 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
452
560
|
}
|
|
453
561
|
}
|
|
454
562
|
|
|
563
|
+
// Revalidate immediately before the consequential prompt. This catches
|
|
564
|
+
// expiry, card/claim changes, and repository/branch/revision drift that
|
|
565
|
+
// occurred during pulse, queue observation, or confirmation.
|
|
566
|
+
if (board) {
|
|
567
|
+
const guard = checkBoardEnvelope();
|
|
568
|
+
if (!guard.ok) {
|
|
569
|
+
journey.status = "failed";
|
|
570
|
+
journey.code = guard.code || "envelope-invalid";
|
|
571
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: guard.reason });
|
|
572
|
+
return finish("failed");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
455
576
|
// One prompt exchange. Any failure is terminal for the journey; there is
|
|
456
577
|
// no invisible retry or resend.
|
|
457
578
|
const exchange = await executeHerdrCommunication(
|
|
@@ -518,8 +639,21 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
518
639
|
status: "done",
|
|
519
640
|
workerStatus: exchange.agentStatus,
|
|
520
641
|
report: exchange.report,
|
|
642
|
+
// §10.2 progress snapshot at the meaningful checkpoint (step completion).
|
|
643
|
+
progress: progressSnapshot({
|
|
644
|
+
taskId: task.id,
|
|
645
|
+
envelope: board?.envelope ?? null,
|
|
646
|
+
repository: options.repository ?? null,
|
|
647
|
+
workerStatus: exchange.agentStatus ?? null,
|
|
648
|
+
lastReportAt: new Date().toISOString(),
|
|
649
|
+
}),
|
|
521
650
|
});
|
|
522
651
|
dispatched.add(task.id);
|
|
652
|
+
// §6.3 review routing: the last complete report requests review.
|
|
653
|
+
if (typeof exchange.report === "string" && exchange.report.includes(REVIEW_REQUEST_MARKER)) {
|
|
654
|
+
journey.status = "review_requested";
|
|
655
|
+
return finish("review_requested");
|
|
656
|
+
}
|
|
523
657
|
}
|
|
524
658
|
|
|
525
659
|
journey.status = "completed";
|
|
@@ -42,6 +42,10 @@ const MAX_IDENTITY_FIELD_BYTES = 4 * 1024;
|
|
|
42
42
|
const MAX_MARKER_HORIZONTAL_WHITESPACE = 128;
|
|
43
43
|
const MAX_PROMPT_CONTRACT_ECHO_BYTES = 4 * 1024;
|
|
44
44
|
const MAX_READ_LINES = 400;
|
|
45
|
+
// A valid 32 KiB report can contain more than 400 short lines. A second,
|
|
46
|
+
// finite read may cover one line per byte plus framing without relaxing the
|
|
47
|
+
// existing 128 KiB process-output ceiling.
|
|
48
|
+
const MAX_REPORT_READ_LINES = MAX_REPORT_BYTES + 16;
|
|
45
49
|
const MAX_WAIT_TIMEOUT_MS = 300_000;
|
|
46
50
|
const MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS = 120_000;
|
|
47
51
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
@@ -397,6 +401,14 @@ function validateParams(params) {
|
|
|
397
401
|
if (!isReviewerRole(params.role) && params.timeoutMs > MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS) {
|
|
398
402
|
throw communicationError("implementer_prompt_timeout_exceeded", "worker prompts are limited to one two-minute atomic step", "denied");
|
|
399
403
|
}
|
|
404
|
+
// Prompt freshness: each explicit prompt carries its own task scope and
|
|
405
|
+
// contract framing built from THAT call's prompt text. A stale or reused
|
|
406
|
+
// prompt text never binds a later exchange — provenance requires the
|
|
407
|
+
// echoed contract to match the current prompt exactly (provenancedReport
|
|
408
|
+
// Segment), so a prior exchange's scope cannot silently cover this one.
|
|
409
|
+
if (params.prompt.includes(REPORT_CONTRACT_LINE)) {
|
|
410
|
+
throw communicationError("prompt_preformatted", "the prompt must carry task scope only; the report contract is added by the transport (fails closed)", "denied");
|
|
411
|
+
}
|
|
400
412
|
}
|
|
401
413
|
if (action === "wait") {
|
|
402
414
|
if (!Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > MAX_WAIT_TIMEOUT_MS) {
|
|
@@ -458,7 +470,7 @@ function fixedArgv(action, params) {
|
|
|
458
470
|
case "read":
|
|
459
471
|
return [
|
|
460
472
|
"agent", "read", params.role,
|
|
461
|
-
"--source", "recent-unwrapped", "--lines", String(MAX_READ_LINES),
|
|
473
|
+
"--source", "recent-unwrapped", "--lines", String(params.readLines ?? MAX_READ_LINES),
|
|
462
474
|
"--format", "text",
|
|
463
475
|
];
|
|
464
476
|
default:
|
|
@@ -468,11 +480,11 @@ function fixedArgv(action, params) {
|
|
|
468
480
|
|
|
469
481
|
function promptWithReportRequirement(role, prompt) {
|
|
470
482
|
const marker = reportMarkersForRole(role);
|
|
483
|
+
// Communication adds transport framing only. Task scope and role behaviour
|
|
484
|
+
// belong to the caller's current prompt; injecting either here would make a
|
|
485
|
+
// task-scoped instruction silently bind every later exchange for that role.
|
|
471
486
|
const requirement = [
|
|
472
487
|
"",
|
|
473
|
-
...(isReviewerRole(role) ? ["Remain strictly read-only; do not modify files, state, or Git."] : [
|
|
474
|
-
"MANDATORY ATOMIC EXECUTION CONTRACT: execute one acceptance-checked step only; do not continue to a second file, test group, or follow-up; stop and report incomplete work as CHANGES_REQUIRED before the two-minute boundary.",
|
|
475
|
-
]),
|
|
476
488
|
REPORT_CONTRACT_LINE,
|
|
477
489
|
marker.open,
|
|
478
490
|
marker.close,
|
|
@@ -1223,11 +1235,23 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1223
1235
|
if (waitedStatus === "blocked") {
|
|
1224
1236
|
return errorResult(operation, communicationError("role_blocked", "the prompted role reached blocked state", "blocked"));
|
|
1225
1237
|
}
|
|
1226
|
-
|
|
1227
|
-
const post = readText(rawReport);
|
|
1238
|
+
let post = readText(await invokeHerdr("read", { action: "read", role }, context, options, signal));
|
|
1228
1239
|
const sentPrompt = promptWithReportRequirement(role, request.prompt);
|
|
1229
|
-
|
|
1230
|
-
|
|
1240
|
+
let report;
|
|
1241
|
+
let reportReadCount = 1;
|
|
1242
|
+
try {
|
|
1243
|
+
const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
|
|
1244
|
+
report = extractReportFromSegment(post, role, boundary.end);
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
if (!(error instanceof HerdrCommunicationError)
|
|
1247
|
+
|| !["report_scope_unavailable", "report_reversed"].includes(error.code)) throw error;
|
|
1248
|
+
// The ordinary terminal tail can begin inside a valid current report.
|
|
1249
|
+
// Expand once; never resend the prompt and never retry indefinitely.
|
|
1250
|
+
post = readText(await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal));
|
|
1251
|
+
reportReadCount = 2;
|
|
1252
|
+
const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
|
|
1253
|
+
report = extractReportFromSegment(post, role, boundary.end);
|
|
1254
|
+
}
|
|
1231
1255
|
return successResult(operation, {
|
|
1232
1256
|
status: "complete",
|
|
1233
1257
|
role,
|
|
@@ -1237,7 +1261,7 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1237
1261
|
promptSent: true,
|
|
1238
1262
|
invocationCount: 1,
|
|
1239
1263
|
waitCount: 1,
|
|
1240
|
-
readCount:
|
|
1264
|
+
readCount: reportReadCount,
|
|
1241
1265
|
report,
|
|
1242
1266
|
reportMarkers: reportMarkersForRole(role),
|
|
1243
1267
|
});
|
|
@@ -1250,8 +1274,15 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1250
1274
|
}
|
|
1251
1275
|
return successResult(operation, { role, status, agentStatus: status, repository });
|
|
1252
1276
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1277
|
+
let raw = await invokeHerdr(operation, request, context, options, signal);
|
|
1278
|
+
let report;
|
|
1279
|
+
try {
|
|
1280
|
+
report = extractLatestHerdrReport(readText(raw), role);
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
if (!(error instanceof HerdrCommunicationError) || error.code !== "report_reversed") throw error;
|
|
1283
|
+
raw = await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal);
|
|
1284
|
+
report = extractLatestHerdrReport(readText(raw), role);
|
|
1285
|
+
}
|
|
1255
1286
|
return successResult(operation, {
|
|
1256
1287
|
role,
|
|
1257
1288
|
report,
|