@evoclock/pi-agentic-driver 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.agentic-driver/loop-until-done.defaults.json +5 -0
  2. package/README.md +315 -33
  3. package/config/merge-policy.v1.example.json +4 -0
  4. package/config/relay-authority.v1.example.json +7 -0
  5. package/config/treehouse.v1.json +4 -0
  6. package/docs/durable-driver-api.md +23 -0
  7. package/docs/durable-persistence.md +32 -0
  8. package/docs/merge-policy.md +45 -0
  9. package/docs/relay-authority.md +9 -0
  10. package/extensions/aidr.ts +3 -0
  11. package/extensions/harvest-lessons.ts +8 -0
  12. package/extensions/herdr-dispatch.ts +32 -3
  13. package/extensions/loop-until-done.ts +7 -0
  14. package/extensions/recovery-evidence.ts +3 -3
  15. package/extensions/treehouse-recycle.ts +47 -0
  16. package/janus/README.md +3 -6
  17. package/janus/janus/config.ts +2 -2
  18. package/janus/janus/providers.ts +2 -4
  19. package/janus/tests/providers.test.ts +2 -2
  20. package/{extensions → lib}/task-store-adapter.ts +1 -5
  21. package/package.json +161 -7
  22. package/scripts/enforcement/approval_ledger_pi.js +135 -0
  23. package/scripts/enforcement/attended_authority_guard.js +113 -0
  24. package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
  25. package/scripts/enforcement/durable_assignment_pi.js +488 -0
  26. package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
  27. package/scripts/enforcement/durable_driver_pi.js +254 -0
  28. package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
  29. package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
  30. package/scripts/enforcement/herdr_communication_pi.js +205 -31
  31. package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
  32. package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
  33. package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
  34. package/scripts/enforcement/loop_until_done_guide.md +13 -0
  35. package/scripts/enforcement/loop_until_done_pi.js +214 -0
  36. package/scripts/enforcement/merge_policy.js +181 -0
  37. package/scripts/enforcement/outward_effect_pi.js +156 -0
  38. package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
  39. package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
  40. package/scripts/enforcement/task_board_core_pi.js +13 -100
  41. package/scripts/enforcement/task_board_hash_pi.js +86 -0
  42. package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
  43. package/scripts/harvest_lessons.js +219 -0
  44. package/scripts/relay/record_approval.mjs +10 -0
  45. package/scripts/relay/send_request.mjs +26 -0
  46. package/skills/architect-pi/SKILL.md +44 -0
  47. package/skills/arena-pi/SKILL.md +46 -0
  48. package/skills/automate-me-pi/SKILL.md +37 -0
  49. package/skills/benchmark-checklist-pi/SKILL.md +35 -0
  50. package/skills/blast-radius-pi/SKILL.md +24 -0
  51. package/skills/bro-pi/SKILL.md +17 -0
  52. package/skills/correct-pi/SKILL.md +36 -0
  53. package/skills/create-verification-skill-pi/LICENSE +21 -0
  54. package/skills/create-verification-skill-pi/SKILL.md +24 -0
  55. package/skills/decision-to-spec/SKILL.md +65 -0
  56. package/skills/figure-it-out-pi/SKILL.md +37 -0
  57. package/skills/harvest-lessons/SKILL.md +56 -0
  58. package/skills/harvest-lessons/harvest.example.json +8 -0
  59. package/skills/how/LICENSE +24 -0
  60. package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
  61. package/skills/how-pi/SKILL.md +33 -0
  62. package/skills/in-your-own-words-pi/SKILL.md +15 -0
  63. package/skills/interrogate-pi/SKILL.md +35 -0
  64. package/skills/ketchup-pi/SKILL.md +21 -0
  65. package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
  66. package/skills/make-bot-ui-pi/SKILL.md +33 -0
  67. package/skills/no-comments-pi/SKILL.md +33 -0
  68. package/skills/poteto-help-pi/SKILL.md +50 -0
  69. package/skills/poteto-mode-pi/LICENSE +21 -0
  70. package/skills/poteto-mode-pi/SKILL.md +41 -0
  71. package/skills/poteto-mode-pi/playbooks.md +95 -0
  72. package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
  73. package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
  74. package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
  75. package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
  76. package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
  77. package/skills/principle-experience-first-pi/SKILL.md +17 -0
  78. package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
  79. package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
  80. package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
  81. package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
  82. package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
  83. package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
  84. package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
  85. package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
  86. package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
  87. package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
  88. package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
  89. package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
  90. package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
  91. package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
  92. package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
  93. package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
  94. package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
  95. package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
  96. package/skills/public-pr-wording/SKILL.md +69 -0
  97. package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
  98. package/skills/public-pr-wording/pr_wording_lint.py +249 -0
  99. package/skills/public-pr-wording/pr_wording_rules.json +60 -0
  100. package/skills/pulse/SKILL.md +4 -2
  101. package/skills/recall-pi/SKILL.md +29 -0
  102. package/skills/reflect-pi/SKILL.md +27 -0
  103. package/skills/setup-pstack-pi/SKILL.md +23 -0
  104. package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
  105. package/skills/sharpen-pi/SKILL.md +19 -0
  106. package/skills/show-me-your-work-pi/SKILL.md +27 -0
  107. package/skills/swarm-pi/SKILL.md +27 -0
  108. package/skills/tasks/SKILL.md +4 -2
  109. package/skills/tdd-pi/SKILL.md +21 -0
  110. package/skills/teach-pi/SKILL.md +21 -0
  111. package/skills/technical-writing-pi/SKILL.md +27 -0
  112. package/skills/typescript-best-practices-pi/SKILL.md +21 -0
  113. package/skills/unslop-pi/SKILL.md +27 -0
  114. package/skills/why-pi/SKILL.md +37 -0
  115. package/PROVENANCE.md +0 -83
  116. package/config/herdr-worker-repositories.v1.json +0 -9
  117. package/docs/codebase-inventory.md +0 -15
  118. package/docs/recovery-evidence-store.md +0 -9
@@ -0,0 +1,254 @@
1
+ // SPDX-FileCopyrightText: 2026 evoclock
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Production run ledger. It records outward intent before returning a request to the host.
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { realpathSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { Harness, createRegistry, defineDoc } from "@earendil-works/pi-durable";
8
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
9
+ import { createModels } from "@earendil-works/pi-ai/models";
10
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
+ import durablePackage from "@earendil-works/pi-durable/package.json" with { type: "json" };
12
+ import { claimSeamLock, releaseSeamLock } from "./storage_owner_lock_pi.js";
13
+
14
+ export const DURABLE_DRIVER_PROTOCOL = "agentic-driver.run.v1";
15
+ const ID = /^[a-zA-Z0-9_-]{1,128}$/;
16
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
17
+ const copy = (value) => JSON.parse(JSON.stringify(value));
18
+ function validateId(value) {
19
+ if (typeof value !== "string" || !ID.test(value) || ["__proto__", "prototype", "constructor"].includes(value)) {
20
+ throw new Error("invalid durable identity");
21
+ }
22
+ return value;
23
+ }
24
+ function validatePlan(plan) {
25
+ validateId(plan?.runId);
26
+ if (!Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > 64) throw new Error("invalid step plan");
27
+ const ids = new Set();
28
+ for (const step of plan.steps) {
29
+ validateId(step?.id);
30
+ if (ids.has(step.id) || !["internal", "external"].includes(step.kind)) throw new Error("invalid or repeated step");
31
+ ids.add(step.id);
32
+ }
33
+ if (Object.keys(plan).some((key) => !["runId", "steps"].includes(key)) ||
34
+ plan.steps.some((step) => Object.keys(step).some((key) => !["id", "kind"].includes(key)))) {
35
+ throw new Error("unsupported plan field");
36
+ }
37
+ return { runId: plan.runId, steps: plan.steps.map(({ id, kind }) => ({ id, kind })) };
38
+ }
39
+
40
+ /** An exclusive same-owner directory contains state.sqlite. Call close before another process opens it.
41
+ * Returned external intents are NOT authorization to execute an effect: the host still enforces its own policy.
42
+ */
43
+ export async function openDurableDriver({ directory }) {
44
+ if (durablePackage.version !== "1.0.4") throw new Error("pi-durable 1.0.4 required");
45
+ if (realpathSync(directory) !== directory) throw new Error("canonical storage directory required");
46
+ const info = statSync(directory);
47
+ if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) {
48
+ throw new Error("private same-owner storage directory required");
49
+ }
50
+ const lease = claimSeamLock(directory);
51
+ let harness;
52
+ try {
53
+ const Runs = defineDoc({ kind: "driver.production-runs", version: 1, scope: "conversation",
54
+ history: "latest", fork: "initial", initial: () => ({ runs: {} }) });
55
+ const registry = createRegistry();
56
+ harness = await Harness.open(await openNodeSqliteStorage(join(directory, "state.sqlite")),
57
+ { models: createModels(), registry }, BACKGROUND_CONTEXT);
58
+ const root = await harness.root(BACKGROUND_CONTEXT);
59
+ // This is the pi-durable scheduler resume, not a reconstructed scheduler or replay of host effects.
60
+ harness.resume();
61
+ const owner = randomUUID();
62
+ let closed = false;
63
+ const workerFlights = new Set();
64
+ const ensureOpen = () => { if (closed) throw new Error("durable driver closed"); };
65
+ const snapshot = async (runId) => {
66
+ ensureOpen();
67
+ validateId(runId);
68
+ const doc = await harness.snapshot(Runs, root.id, BACKGROUND_CONTEXT);
69
+ const record = doc?.runs?.[runId];
70
+ return record ? copy(record) : null;
71
+ };
72
+ const change = async (runId, update) => {
73
+ ensureOpen();
74
+ validateId(runId);
75
+ return root.commit(async (tx) => {
76
+ const record = (await tx.doc(Runs, root.id)).runs[runId];
77
+ if (!record) throw new Error("unknown run");
78
+ return copy(update(record));
79
+ }, BACKGROUND_CONTEXT);
80
+ };
81
+ return {
82
+ schema: DURABLE_DRIVER_PROTOCOL,
83
+ async start(input) {
84
+ ensureOpen();
85
+ const plan = validatePlan(input);
86
+ const digest = hash(JSON.stringify(plan));
87
+ const receipt = await root.commit(async (tx) => {
88
+ const runs = (await tx.doc(Runs, root.id)).runs;
89
+ const previous = Object.hasOwn(runs, plan.runId) ? runs[plan.runId] : null;
90
+ if (previous) {
91
+ if (previous.digest !== digest) throw new Error("run identity conflict");
92
+ return { runId: plan.runId, digest, stepIds: previous.steps.map((step) => step.correlationId ?? step.taskId) };
93
+ }
94
+ if (Object.values(runs).filter((run) => ["active", "held"].includes(run.state)).length >= 64) {
95
+ throw new Error("live run capacity reached; reconciliation required");
96
+ }
97
+ const steps = [];
98
+ for (const item of plan.steps) {
99
+ const correlationId = hash(`${plan.runId}:${item.id}:${digest}`);
100
+ steps.push({ ...item, correlationId, state: "pending", requestKey: correlationId });
101
+ }
102
+ runs[plan.runId] = { schema: DURABLE_DRIVER_PROTOCOL, digest, runId: plan.runId, state: "active", steps };
103
+ return { runId: plan.runId, digest, stepIds: steps.map((step) => step.correlationId) };
104
+ }, BACKGROUND_CONTEXT);
105
+ harness.resume();
106
+ return copy(receipt);
107
+ },
108
+ status: snapshot,
109
+ // One external intent covers the complete host-owned invocation. The host owns
110
+ // its result and action receipts; this ledger never replays an uncertain turn.
111
+ async invokeWorker({ requestId, goal, transport, signal }) {
112
+ if (typeof requestId !== "string" || !requestId || requestId.length > 256 ||
113
+ typeof goal !== "string" || !goal || typeof transport?.invoke !== "function" ||
114
+ !(signal instanceof AbortSignal)) throw new Error("invalid worker invocation");
115
+ const runId = hash(`worker:${requestId}`);
116
+ const stepId = `worker_${hash(JSON.stringify([requestId, goal]))}`;
117
+ if (workerFlights.has(runId)) throw new Error("worker invocation already active");
118
+ workerFlights.add(runId);
119
+ try {
120
+ await this.start({ runId, steps: [{ id: stepId, kind: "external" }] });
121
+ const before = await this.status(runId);
122
+ if (before.state !== "active" || before.steps[0].state !== "pending") {
123
+ return { runId, state: before.state === "finished" ? "finished" : "held", step: before.steps[0] };
124
+ }
125
+ if (signal.aborted) {
126
+ await this.cancel(runId);
127
+ return { runId, state: "cancelled" };
128
+ }
129
+ const active = await this.resume(runId);
130
+ const step = active.steps[0];
131
+ // The intent has committed before crossing the worker boundary.
132
+ const onAbort = () => { void this.cancel(runId).catch(() => {}); };
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ try {
135
+ if (signal.aborted) { await this.cancel(runId); return { runId, state: "cancelled" }; }
136
+ const delivered = await transport.invoke({ requestId, goal, requestKey: step.requestKey, signal });
137
+ if (signal.aborted) { await this.cancel(runId); return { runId, state: "cancelled" }; }
138
+ if (delivered?.verified !== true || typeof delivered.reference !== "string" ||
139
+ !delivered.reference || delivered.reference.length > 512) {
140
+ throw new Error("verified worker receipt required");
141
+ }
142
+ await this.report(runId, { stepId, requestKey: step.requestKey,
143
+ receipt: { requestKey: step.requestKey, verified: true, reference: delivered.reference } });
144
+ return { runId, state: "finished", result: delivered.result };
145
+ } finally { signal.removeEventListener("abort", onAbort); }
146
+ } finally { workerFlights.delete(runId); }
147
+ },
148
+ // Resume advances only to the first unfinished step. An external intent is persisted
149
+ // before being returned. After interruption, an unreceipted intent is held, not reissued.
150
+ async resume(runId) {
151
+ return change(runId, (run) => {
152
+ if (run.state !== "active") return run;
153
+ const step = run.steps.find((item) => item.state !== "finished");
154
+ if (!step) { run.state = "finished"; return run; }
155
+ if (step.state === "intent" && step.intentOwner !== owner) {
156
+ step.state = "held"; run.state = "held"; return run;
157
+ }
158
+ if (step.state === "pending") {
159
+ step.state = step.kind === "external" ? "intent" : "ready";
160
+ if (step.state === "intent") step.intentOwner = owner;
161
+ }
162
+ return run;
163
+ });
164
+ },
165
+ // Only the host can prove an effect was never sent. This is a new attempt with a new key;
166
+ // an unknown send outcome must remain held and must not use this operation.
167
+ async release(runId, evidence) {
168
+ validateId(evidence?.stepId);
169
+ return change(runId, (run) => {
170
+ const step = run.steps.find((item) => item.id === evidence.stepId);
171
+ if (run.state !== "held" || step?.kind !== "external" || step.state !== "held" ||
172
+ step.requestKey !== evidence.requestKey) throw new Error("unknown held intent");
173
+ if (evidence.verifiedNotSent !== true || typeof evidence.reference !== "string" ||
174
+ !evidence.reference || evidence.reference.length > 512 ||
175
+ Object.keys(evidence).some((key) => !["stepId", "requestKey", "verifiedNotSent", "reference"].includes(key))) {
176
+ throw new Error("not-sent proof required");
177
+ }
178
+ step.releases = [...(step.releases ?? []), { requestKey: step.requestKey, reference: evidence.reference }];
179
+ step.requestKey = hash(`${step.requestKey}:${randomUUID()}`);
180
+ step.intentOwner = owner;
181
+ step.state = "intent";
182
+ run.state = "active";
183
+ return run;
184
+ });
185
+ },
186
+ // A report is evidence only. External effects require an independently verified receipt.
187
+ async report(runId, { stepId, requestKey, receipt }) {
188
+ validateId(stepId);
189
+ return change(runId, (run) => {
190
+ const step = run.steps.find((item) => item.id === stepId);
191
+ if (!step || step.requestKey !== requestKey) throw new Error("step identity conflict");
192
+ if (step.state === "finished") {
193
+ if (JSON.stringify(step.receipt ?? null) !== JSON.stringify(receipt ?? null)) throw new Error("receipt conflict");
194
+ return run;
195
+ }
196
+ if (run.state !== "active" || step !== run.steps.find((item) => item.state !== "finished")) {
197
+ throw new Error("step is not active");
198
+ }
199
+ if (step.kind === "external") {
200
+ if (step.state !== "intent" || receipt?.requestKey !== requestKey ||
201
+ receipt?.verified !== true || typeof receipt.reference !== "string" ||
202
+ !receipt.reference || receipt.reference.length > 512 ||
203
+ Object.keys(receipt).some((key) => !["requestKey", "verified", "reference"].includes(key))) {
204
+ throw new Error("verified external receipt required");
205
+ }
206
+ step.receipt = copy(receipt);
207
+ } else if (step.state !== "ready" || receipt !== undefined) {
208
+ throw new Error("internal step is not ready");
209
+ }
210
+ step.state = "finished";
211
+ if (run.steps.every((item) => item.state === "finished")) run.state = "finished";
212
+ return run;
213
+ });
214
+ },
215
+ // Reconciliation never sends an effect. A host may provide proof for a held intent;
216
+ // absence or disagreement leaves the run held for operator review.
217
+ async reconcile(runId, evidence) {
218
+ validateId(evidence?.stepId);
219
+ return change(runId, (run) => {
220
+ if (run.state === "cancelled") throw new Error("cancelled run cannot reconcile");
221
+ const step = run.steps.find((item) => item.id === evidence.stepId);
222
+ if (!step || step.kind !== "external" || step.requestKey !== evidence.requestKey) {
223
+ throw new Error("unknown external intent");
224
+ }
225
+ if (step.state === "finished") {
226
+ if (evidence.verified === true && evidence.reference === step.receipt?.reference) return run;
227
+ throw new Error("reconciliation conflict");
228
+ }
229
+ if (!["held", "intent"].includes(step.state)) throw new Error("unknown external intent");
230
+ if (evidence.verified === true && typeof evidence.reference === "string" &&
231
+ evidence.reference.length > 0 && evidence.reference.length <= 512) {
232
+ step.receipt = { requestKey: step.requestKey, verified: true, reference: evidence.reference };
233
+ step.state = "finished";
234
+ if (run.state !== "cancelled") run.state = run.steps.every((item) => item.state === "finished") ? "finished" : "active";
235
+ } else { step.state = "held"; if (run.state !== "cancelled") run.state = "held"; }
236
+ return run;
237
+ });
238
+ },
239
+ async cancel(runId) {
240
+ return change(runId, (run) => { if (run.state !== "finished") run.state = "cancelled"; return run; });
241
+ },
242
+ async close() {
243
+ if (closed) return;
244
+ if (workerFlights.size) throw new Error("worker invocation is active");
245
+ try { await harness.close(BACKGROUND_CONTEXT); }
246
+ finally { closed = true; releaseSeamLock(lease); }
247
+ },
248
+ };
249
+ } catch (error) {
250
+ try { if (harness) await harness.close(BACKGROUND_CONTEXT); }
251
+ finally { releaseSeamLock(lease); }
252
+ throw error;
253
+ }
254
+ }
@@ -12,6 +12,7 @@
12
12
  import {
13
13
  executeHerdrCommunication,
14
14
  executeHerdrPromptExchange,
15
+ inspectHerdrJourneyMarker,
15
16
  HERDR_REPORT_MARKERS,
16
17
  HERDR_COMMUNICATION_SCHEMA,
17
18
  HERDR_COMMUNICATION_ACTIONS,
@@ -21,6 +22,7 @@ import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvel
21
22
  import { sha256Hex } from "./task_board_core_pi.js";
22
23
  import { sessionTaskDisplayId, PROVENANCE_CAPTURE_STATUSES } from "./session_tasks_core_pi.js";
23
24
  import { execFileSync } from "node:child_process";
25
+ import { randomUUID } from "node:crypto";
24
26
 
25
27
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
26
28
  export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
@@ -43,19 +45,20 @@ export const REVIEW_REQUEST_MARKER = "[REVIEW_REQUESTED]";
43
45
  // other terminal state is an observed outcome, and no state is retried.
44
46
  export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
45
47
  "completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
46
- "review_requested",
48
+ "review_requested", "blocked",
47
49
  ]);
48
50
 
49
51
  export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
50
52
  type: "object",
51
53
  additionalProperties: false,
52
54
  properties: {
53
- action: { type: "string", enum: ["dispatch", "pulse"] },
55
+ action: { type: "string", enum: ["dispatch", "pulse", "journeys"] },
54
56
  role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
55
57
  mode: { type: "string", enum: WORKER_DISPATCH_MODES },
56
58
  autonomy: { type: "string", enum: WORKER_DISPATCH_AUTONOMY_MODES },
57
59
  maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
58
60
  stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
61
+ journeyId: { type: "string", pattern: "^[a-zA-Z0-9_-]{1,100}$", maxLength: 100 },
59
62
  model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
60
63
  cast: {
61
64
  type: "object",
@@ -70,11 +73,15 @@ export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
70
73
  additionalProperties: false,
71
74
  },
72
75
  },
73
- required: ["action", "role"],
76
+ required: ["action"],
74
77
  allOf: [
78
+ {
79
+ if: { properties: { action: { const: "pulse" } }, required: ["action"] },
80
+ then: { required: ["role"] },
81
+ },
75
82
  {
76
83
  if: { properties: { action: { const: "dispatch" } }, required: ["action"] },
77
- then: { required: ["stepPrompt"] },
84
+ then: { required: ["role", "stepPrompt"] },
78
85
  },
79
86
  ],
80
87
  });
@@ -296,7 +303,12 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
296
303
  const taskStore = typeof options.taskStoreFactory === "function"
297
304
  ? options.taskStoreFactory(context) : options.taskStore;
298
305
  const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
299
- const journey = { mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
306
+ const journeyId = params.journeyId ?? randomUUID();
307
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(journeyId)) {
308
+ return failure("dispatch", dispatchError("journey-id-invalid", "invalid journey ID", "denied"));
309
+ }
310
+ const persistence = options.journeyStore;
311
+ const journey = { journeyId, mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
300
312
  // Snapshot once at journey start; observational evidence never gates or
301
313
  // authorizes existing dispatch, replacement, or board execution.
302
314
  // Full immutable capture stays local for drift revalidation. The tool
@@ -340,7 +352,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
340
352
  const finish = async (requestedStatus) => {
341
353
  let status = requestedStatus;
342
354
  if (board && boardAttemptStarted && !envelopeConsumed) {
343
- const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
355
+ const reason = ["completed", "exhausted", "cancelled", "failed", "blocked", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
344
356
  ? status
345
357
  : status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
346
358
  let consumed;
@@ -363,6 +375,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
363
375
  schema: WORKER_DISPATCH_SCHEMA,
364
376
  ok: status === "completed" || status === "exhausted" || status === "waiting-approval" || status === "review_requested",
365
377
  action: "dispatch",
378
+ journeyId,
366
379
  mode,
367
380
  autonomy,
368
381
  ...(journey.cast ? { cast: journey.cast } : {}),
@@ -376,7 +389,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
376
389
  handoff: journey.handoff,
377
390
  taskProvenance: projectedTaskProvenance,
378
391
  nonAuthorizing: true,
379
- persisted: false,
392
+ persisted: Boolean(persistence),
380
393
  };
381
394
  };
382
395
 
@@ -522,10 +535,23 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
522
535
  for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
523
536
  if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
524
537
 
538
+ // Only in-flight and later checkpoints may skip dispatch checks: their
539
+ // send is uncertain or already recorded. Admitted has never sent, so
540
+ // restart must check liveness and obtain native confirmation again.
541
+ let recovering = false;
542
+ try {
543
+ recovering = Boolean(persistence && params.journeyId &&
544
+ (await persistence.listJourneys()).some((entry) => entry.journeyId === journeyId &&
545
+ entry.iteration === String(stepIndex) && entry.role === role && entry.state !== "admitted"));
546
+ } catch (error) {
547
+ journey.code = "journey-store-conflict";
548
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "blocked", error: String(error?.message || error).slice(0, 256) });
549
+ return finish("blocked");
550
+ }
525
551
  // Pulse: liveness and dispatch eligibility, no authority.
526
552
  let pulse;
527
553
  try {
528
- pulse = await workerPulse(role, context, communicationOptions, signal);
554
+ if (!recovering) pulse = await workerPulse(role, context, communicationOptions, signal);
529
555
  } catch (error) {
530
556
  journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
531
557
  journey.code = error?.code || "pulse-failed";
@@ -534,17 +560,17 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
534
560
  }
535
561
  // The pre-task pulse has not selected a task. Confirmed replacement here
536
562
  // cannot be tied to a task identity, so it must not offer a spawn.
537
- if (autonomy !== "autonomous" && (!pulse.alive || (!pulse.dispatchEligible && pulse.status !== "blocked"))) {
563
+ if (!recovering && autonomy !== "autonomous" && (!pulse.alive || (!pulse.dispatchEligible && pulse.status !== "blocked"))) {
538
564
  journey.status = "worker-unresponsive";
539
565
  journey.code = "worker-unresponsive";
540
566
  journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "worker-unresponsive", error: "worker unavailable before task selection" });
541
567
  journey.handoff = { attempted: false, reason: "no task selected for confirmed replacement" };
542
568
  return finish("worker-unresponsive");
543
569
  }
544
- if (!pulse.alive) {
570
+ if (!recovering && !pulse.alive) {
545
571
  return handoffToReplacement("worker role is not alive");
546
572
  }
547
- if (!pulse.dispatchEligible) {
573
+ if (!recovering && !pulse.dispatchEligible) {
548
574
  if (pulse.status === "blocked") {
549
575
  journey.status = "role-blocked";
550
576
  journey.code = "role_blocked";
@@ -581,9 +607,9 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
581
607
  return finish("waiting-approval");
582
608
  }
583
609
 
584
- // Consequential dispatch requires native confirmation, once per step,
585
- // unless the initial dispatch explicitly authorized autonomous progression.
586
- if (autonomy !== "autonomous") {
610
+ // Consequential dispatch requires native confirmation before each send;
611
+ // only a previously committed in-flight or later task is observe-only.
612
+ if (!recovering && autonomy !== "autonomous") {
587
613
  if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
588
614
  journey.status = "failed";
589
615
  journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "failed", error: "native TUI confirmation unavailable" });
@@ -622,17 +648,43 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
622
648
  }
623
649
  }
624
650
 
651
+ // Commit the effect intent before invoking Herdr. An interrupted intent is
652
+ // ambiguous: do not replay it just because this process did not see an ack.
653
+ const requestId = `${journeyId}:${stepIndex}`;
654
+ const recordId = `jrn-${sha256Hex(requestId).slice(0, 32)}`;
655
+ const digest = sha256Hex(JSON.stringify({ requestId, role, taskId: task.id, stepPrompt }));
625
656
  // One prompt exchange. Any failure is terminal for the journey; there is
626
657
  // no invisible retry or resend. The legacy blocking exchange is used
627
658
  // deliberately: a journey step is a single prompt→report round-trip, and
628
659
  // the tool-facing prompt action is now async delivery-only.
629
- const exchange = await executeHerdrPromptExchange(
630
- { action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
631
- context,
632
- communicationOptions,
633
- signal,
660
+ const send = (stepSignal) => executeHerdrPromptExchange(
661
+ { action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}\n[journey:${requestId}]`, timeoutMs: 120000 },
662
+ context, communicationOptions, stepSignal,
634
663
  );
664
+ let exchange;
665
+ try {
666
+ if (persistence) {
667
+ const step = await persistence.runJourneyStep({ id: recordId, digest, role, repository: context.cwd,
668
+ createdAt: Date.now(), journeyId, iteration: String(stepIndex), taskId: String(task.id),
669
+ ...(board?.envelope?.cardId ? { cardId: board.envelope.cardId } : {}) }, {
670
+ send,
671
+ observe: (stepSignal) => inspectHerdrJourneyMarker(role, requestId, context, communicationOptions, stepSignal),
672
+ boundary: (name) => options.journeyBoundary?.(name, { journeyId, requestId }),
673
+ });
674
+ exchange = step.state === "verified" ? { ok: true, report: step.report, agentStatus: null }
675
+ : { ok: false, code: "journey-effect-uncertain" };
676
+ } else exchange = await send(signal);
677
+ } catch (error) {
678
+ journey.code = "journey-store-conflict";
679
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "blocked", error: String(error?.message || error).slice(0, 256) });
680
+ return finish("blocked");
681
+ }
635
682
  if (exchange.ok !== true) {
683
+ if (persistence) {
684
+ journey.code = exchange.code || "journey-effect-uncertain";
685
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "blocked", error: "reconcile exchange before another send" });
686
+ return finish("blocked");
687
+ }
636
688
  const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "prompt_delivery_unknown" || exchange.code === "process_timeout";
637
689
  if (unresponsive) {
638
690
  if (autonomy === "autonomous" && castCheck?.authorized) {
@@ -740,7 +792,21 @@ export function registerWorkerDispatchInterface(pi, options = {}) {
740
792
  value = failure("pulse", error);
741
793
  }
742
794
  } else {
743
- value = await runWorkerJourney(params ?? {}, context, options, signal);
795
+ try {
796
+ const journeyStore = options.getJourneyStore ? await options.getJourneyStore(context) : options.journeyStore;
797
+ if (params?.action === "journeys") {
798
+ if (!journeyStore) throw new Error("journey store unavailable");
799
+ const records = await journeyStore.listJourneys();
800
+ value = { schema: WORKER_DISPATCH_SCHEMA, ok: true, action: "journeys", nonAuthorizing: true,
801
+ journeys: records.filter((record) => (!params.role || record.role === params.role) &&
802
+ (!params.journeyId || record.journeyId === params.journeyId) && record.repository === context.cwd)
803
+ .map(({ journeyId, iteration, state, taskId, cardId }) => ({ journeyId, iteration, state, taskId, cardId })) };
804
+ } else {
805
+ value = await runWorkerJourney(params ?? {}, context, { ...options, journeyStore }, signal);
806
+ }
807
+ } catch (error) {
808
+ value = failure("dispatch", dispatchError("journey-store-unavailable", String(error?.message || error)));
809
+ }
744
810
  }
745
811
  return {
746
812
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
- // Cross-repo async transport seam (vogelkop phase #47). A versioned,
4
+ // Cross-repo async transport seam. A versioned,
5
5
  // non-authorizing submit receipt is accepted immediately after a guarded
6
6
  // submission; settlement is observed later through separate poll/observe/read
7
7
  // operations. Nothing here retries, resends, or re-prompts: one submission is
@@ -15,13 +15,14 @@
15
15
  import { createHash } from "node:crypto";
16
16
  import {
17
17
  executeHerdrCommunication,
18
+ expectedRepository,
18
19
  reportMarkersForRole,
19
20
  HERDR_COMMUNICATION_SCHEMA,
20
21
  } from "./herdr_communication_pi.js";
21
22
 
22
23
  export const ASYNC_DISPATCH_TOOL = "agentic_worker_dispatch_async";
23
24
  export const ASYNC_DISPATCH_SCHEMA = "agentic-driver.async-dispatch.v1";
24
- export const ASYNC_RECEIPT_SCHEMA = "vogelkop.async-transport.receipt.v1";
25
+ export const ASYNC_RECEIPT_SCHEMA = "agentic-driver.async-transport.receipt.v2";
25
26
  export const ASYNC_SUBMIT_STATUS = "submitted";
26
27
  export const ASYNC_TERMINAL_STATES = Object.freeze([
27
28
  "completed", "exhausted", "review_requested", "failed", "cancelled",
@@ -74,7 +75,7 @@ function baseFields(submission) {
74
75
  driverSchema: ASYNC_DISPATCH_SCHEMA,
75
76
  nonAuthorizing: true,
76
77
  authorityCreated: false,
77
- persisted: false,
78
+ persisted: submission.persisted === true,
78
79
  };
79
80
  }
80
81
 
@@ -150,6 +151,88 @@ function getSubmission(id) {
150
151
  return submission;
151
152
  }
152
153
 
154
+ // Explicit OFFLINE opt-in. The store is injected; importing this module never
155
+ // opens SQLite, changes registration, or selects a live transport.
156
+ function offlineOptions(options) {
157
+ const communication = options.communication ?? options;
158
+ const store = options.offlinePersistence;
159
+ if (store?.schema !== "agentic-driver.offline-seam-store.v1" || typeof communication.runProcess !== "function" ||
160
+ ["get", "reserve", "update"].some((name) => typeof store?.[name] !== "function")) {
161
+ throw submissionError("offline-persistence-required", "offline store and injected process adapter required", "denied");
162
+ }
163
+ return { ...communication, offlinePersistence: store };
164
+ }
165
+
166
+ function offlineRepository(context) {
167
+ return expectedRepository(context);
168
+ }
169
+
170
+ function restoredSubmission(saved) {
171
+ return { ...saved, submissionId: saved.id, idempotencyKey: saved.digest, persisted: true };
172
+ }
173
+
174
+ async function persistedSubmission(id, context, options) {
175
+ offlineOptions(options);
176
+ if (typeof id !== "string" || !id.trim()) throw submissionError("submission-id-required", "submissionId required", "denied");
177
+ const saved = await options.offlinePersistence.get("submission", id);
178
+ if (!saved) throw submissionError("unknown-submission", "no persisted submission is known", "denied");
179
+ if (saved.repository !== offlineRepository(context)) throw submissionError("repository-mismatch", "submission belongs to another repository", "denied");
180
+ return restoredSubmission(saved);
181
+ }
182
+
183
+ async function submitPersistedAsync(params, context, options, signal) {
184
+ const communication = offlineOptions(options);
185
+ const repository = offlineRepository(context);
186
+ const digest = sha256Hex(JSON.stringify({ repository, role: params.role, prompt: params.prompt }));
187
+ const requestId = options.offlineRequestId ?? digest;
188
+ if (typeof requestId !== "string" || !/^[a-z0-9-]{1,128}$/.test(requestId)) throw submissionError("request-id-invalid", "bounded offline identity required", "denied");
189
+ const id = `sub-${sha256Hex(`${repository}\u0000${requestId}`).slice(0, 32)}`;
190
+ const deliveryIdentity = sha256Hex(`${repository}\u0000${id}`);
191
+ const deliveryId = `dlv-${deliveryIdentity.slice(0, 16)}-${deliveryIdentity.slice(16, 32)}`;
192
+ const store = options.offlinePersistence;
193
+ const reserved = await store.reserve("submission", { id, digest, role: params.role, repository,
194
+ createdAt: Date.now(), phase: "pending", deliveryId });
195
+ const record = restoredSubmission(reserved.record);
196
+ if (!reserved.created) {
197
+ const accepted = record.phase === "accepted";
198
+ return { ...submitResult(record, { duplicate: true, inFlight: false, promptSent: false }),
199
+ ok: accepted, status: accepted ? ASYNC_SUBMIT_STATUS : "held", held: !accepted };
200
+ }
201
+ try {
202
+ await options.offlineBoundary?.("after-submission-intent", { submissionId: id, deliveryId });
203
+ if ((await store.get("submission", id)).phase !== "pending") throw submissionError("submission-held", "submission held before handoff");
204
+ const sent = await executeHerdrCommunication({ action: "submit", role: params.role, prompt: params.prompt, timeoutMs: 15000 },
205
+ context, { ...communication, offlineRequestId: id }, signal);
206
+ if (!sent.ok || sent.deliveryId !== deliveryId) throw submissionError(sent.code ?? "delivery-unconfirmed", sent.reason ?? "delivery not confirmed; do not resend");
207
+ const accepted = await store.update("submission", id, digest, { phase: "accepted", acceptedAt: sent.acceptedAt });
208
+ await options.offlineBoundary?.("after-submission-ack", { submissionId: id, deliveryId });
209
+ if (accepted.phase !== "accepted") throw submissionError("submission-held", "ack retained for held submission");
210
+ return submitResult(restoredSubmission(accepted), { duplicate: false, inFlight: false, promptSent: true });
211
+ } catch (error) {
212
+ // An intent tombstone is retained even if its transport/ack commit failed.
213
+ // No automatic retry or role-keyed latest-report fallback can follow.
214
+ await store.update("submission", id, digest, { phase: "held" });
215
+ return { ...baseFields(record), ok: false, action: "submit", status: "held", held: true,
216
+ deliveryId, code: error?.code ?? "delivery-unconfirmed" };
217
+ }
218
+ }
219
+
220
+ async function observePersistedAsync(action, params, context, options, signal) {
221
+ const submission = await persistedSubmission(params?.submissionId, context, options);
222
+ if (submission.phase !== "accepted") {
223
+ return { ...baseFields(submission), ok: action !== "read", action, state: "unknown", terminal: false,
224
+ held: true, code: "delivery-unconfirmed", deliveryId: submission.deliveryId };
225
+ }
226
+ const observed = await executeHerdrCommunication({ action: "delivery", deliveryId: submission.deliveryId }, context, offlineOptions(options), signal);
227
+ const ready = observed.ok === true && observed.deliveryState === "answered";
228
+ const held = observed.held === true || ["unknown", "failed", "unattributed"].includes(observed.deliveryState);
229
+ return { ...baseFields(submission), ok: action === "read" ? ready : observed.ok === true, action,
230
+ state: ready ? "report-ready" : "unknown", terminal: false, held, deliveryId: submission.deliveryId,
231
+ deliveryState: observed.deliveryState, reportAvailable: ready,
232
+ ...(action === "read" && ready ? { report: observed.report, reportMarkers: observed.reportMarkers } : {}),
233
+ ...(!ready && action === "read" ? { code: "report-not-ready" } : {}), observedAt: new Date().toISOString() };
234
+ }
235
+
153
236
  // Async submit: one guarded submission attempt, receipt returned immediately.
154
237
  // No settlement wait, no retry, no resend. A repeated idempotencyKey returns
155
238
  // the original receipt without sending a second prompt (no duplicate prompt).
@@ -162,6 +245,7 @@ export async function submitAsyncDispatch(params, context, options = {}, signal)
162
245
  if (typeof params?.prompt !== "string" || !params.prompt.trim()) {
163
246
  throw submissionError("prompt-required", "submit requires non-empty brief text", "denied");
164
247
  }
248
+ if (options.offlinePersistence) return await submitPersistedAsync(params, context, options, signal);
165
249
  const keyMaterial = JSON.stringify({ role: params.role, prompt: params.prompt });
166
250
  const idempotencyKey = sha256Hex(keyMaterial);
167
251
  evictExpired();
@@ -212,6 +296,7 @@ export async function submitAsyncDispatch(params, context, options = {}, signal)
212
296
  export async function pollAsyncDispatch(params, context, options = {}, signal) {
213
297
  const action = "poll";
214
298
  try {
299
+ if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
215
300
  const submission = getSubmission(params?.submissionId);
216
301
  const observed = await executeHerdrCommunication(
217
302
  { action: "get", role: submission.role },
@@ -242,6 +327,7 @@ export async function pollAsyncDispatch(params, context, options = {}, signal) {
242
327
  export async function observeAsyncDispatch(params, context, options = {}, signal) {
243
328
  const action = "observe";
244
329
  try {
330
+ if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
245
331
  const submission = getSubmission(params?.submissionId);
246
332
  const observed = await executeHerdrCommunication(
247
333
  { action: "get", role: submission.role },
@@ -272,6 +358,7 @@ export async function observeAsyncDispatch(params, context, options = {}, signal
272
358
  export async function readAsyncDispatch(params, context, options = {}, signal) {
273
359
  const action = "read";
274
360
  try {
361
+ if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
275
362
  const submission = getSubmission(params?.submissionId);
276
363
  const read = await executeHerdrCommunication(
277
364
  { action: "read", role: submission.role },