@evoclock/pi-agentic-driver 0.10.0 → 1.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 (30) hide show
  1. package/.agentic-driver/nudge.defaults.json +11 -0
  2. package/.githooks/pre-commit +4 -0
  3. package/README.md +43 -31
  4. package/config/codebase-inventory.schema.json +23 -0
  5. package/docs/codebase-inventory.md +15 -0
  6. package/docs/recovery-evidence-store.md +9 -0
  7. package/extensions/herdr-dispatch.ts +15 -2
  8. package/extensions/recovery-evidence.ts +14 -0
  9. package/extensions/task-store-adapter.ts +101 -0
  10. package/extensions/tasks.ts +34 -11
  11. package/package.json +19 -1
  12. package/scripts/codebase_inventory.py +105 -0
  13. package/scripts/codebase_inventory_gate.py +48 -0
  14. package/scripts/enforcement/board_claim_bridge_pi.js +204 -0
  15. package/scripts/enforcement/herdr_async_dispatch_pi.js +83 -30
  16. package/scripts/enforcement/herdr_async_seam_pi.js +347 -0
  17. package/scripts/enforcement/herdr_communication_pi.js +608 -95
  18. package/scripts/enforcement/herdr_lifecycle_pi.js +266 -13
  19. package/scripts/enforcement/linux_microvm_remote_fixture.sh +6 -6
  20. package/scripts/enforcement/native_tui_context.js +16 -0
  21. package/scripts/enforcement/nudge_classifier_pi.js +176 -0
  22. package/scripts/enforcement/nudge_config_pi.js +234 -0
  23. package/scripts/enforcement/nudge_ledger_pi.js +514 -0
  24. package/scripts/enforcement/nudge_reason_state_pi.js +135 -0
  25. package/scripts/enforcement/nudge_validate_pi.js +224 -0
  26. package/scripts/enforcement/pulse_scheduler_pi.js +15 -3
  27. package/scripts/enforcement/recovery_evidence_review_pi.js +269 -0
  28. package/scripts/enforcement/recovery_evidence_store_pi.js +170 -0
  29. package/scripts/enforcement/session_tasks_core_pi.js +14 -0
  30. package/scripts/enforcement/task_board_core_pi.js +96 -7
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic, static inventory of staged repository code (without executing it)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import ast
8
+ import hashlib
9
+ import json
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ EXTENSIONS = {".py": "python", ".sh": "shell", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".mts": "typescript", ".tsx": "typescript", ".jsx": "javascript"}
15
+ JS_FUNCTION = re.compile(r"\b(?:async\s+)?function\s+([\w$]+)\s*\(|\b(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>|\b(?:async\s+)?([\w$]+)\s*\([^\n()]*\)\s*\{", re.M)
16
+ JS_CLASS = re.compile(r"\bclass\s+([\w$]+)")
17
+ JS_DEP = re.compile(r"\b(?:import|export)\s+(?:[^;\n]*?\s+from\s+)?['\"]([^'\"]+)['\"]|\b(?:require|import)\s*\(\s*['\"]([^'\"]+)['\"]")
18
+ JS_EXPORT = re.compile(r"\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([\w$]+)|\bexports\.([\w$]+)")
19
+ SHELL_FUNCTION = re.compile(r"^\s*(?:function\s+)?([A-Za-z_][\w]*)\s*\(\)\s*\{", re.M)
20
+ SHELL_SOURCE = re.compile(r"^\s*(?:source|\.)\s+['\"]?([^'\"\s]+)", re.M)
21
+
22
+
23
+ def git(repo: Path, *args: str) -> bytes:
24
+ return subprocess.check_output(["git", "-C", str(repo), *args])
25
+
26
+
27
+ def names(pattern: re.Pattern, text: str) -> list[str]:
28
+ return sorted({part for hit in pattern.findall(text) for part in (hit if isinstance(hit, tuple) else (hit,)) if part})
29
+
30
+
31
+ def record(repo: Path, relative: str) -> dict | None:
32
+ path = repo / relative
33
+ if path.is_symlink() or not path.is_file():
34
+ return None
35
+ language = EXTENSIONS.get(path.suffix)
36
+ data = path.read_bytes()
37
+ if language is None and not path.suffix:
38
+ first = data.split(b"\n", 1)[0].lower()
39
+ if first.startswith(b"#!"):
40
+ language = "python" if b"python" in first else "shell" if b"sh" in first else None
41
+ if language is None:
42
+ return None
43
+ text = data.decode("utf-8", errors="replace")
44
+ functions: list[str] = []
45
+ classes: list[str] = []
46
+ dependencies: list[str] = []
47
+ exports: list[str] = []
48
+ if language == "python":
49
+ try:
50
+ tree = ast.parse(text, filename=relative)
51
+ except SyntaxError:
52
+ tree = None # A broken source file remains visible, never silently omitted.
53
+ if tree:
54
+ functions = sorted({node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))})
55
+ classes = sorted({node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)})
56
+ dependencies = sorted({node.module or "." * node.level for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)} | {alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names})
57
+ exports = sorted(name for name in functions + classes if not name.startswith("_"))
58
+ elif language == "shell":
59
+ functions = names(SHELL_FUNCTION, text)
60
+ dependencies = names(SHELL_SOURCE, text)
61
+ exports = functions
62
+ else:
63
+ functions = [name for name in names(JS_FUNCTION, text) if name not in {"if", "for", "while", "switch", "catch", "function"}]
64
+ classes = names(JS_CLASS, text)
65
+ dependencies = names(JS_DEP, text)
66
+ exports = names(JS_EXPORT, text)
67
+ return {
68
+ "path": relative,
69
+ "subdir": str(Path(relative).parent),
70
+ "filename": path.name,
71
+ "blob_sha": hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest(),
72
+ "loc": len(text.splitlines()),
73
+ "size_bytes": len(data),
74
+ "language": language,
75
+ "functions": [{"name": name} for name in functions],
76
+ "classes": [{"name": name} for name in classes],
77
+ "dependencies": dependencies,
78
+ "exported_symbols": exports,
79
+ }
80
+
81
+
82
+ def generate(repo: Path) -> bytes:
83
+ tracked = git(repo, "ls-files", "--cached", "--full-name", "-z").decode("utf-8", errors="surrogateescape").split("\0")
84
+ rows = [row for rel in sorted(set(tracked)) if rel and (row := record(repo, rel)) is not None]
85
+ return ("".join(json.dumps(row, sort_keys=True, ensure_ascii=True, separators=(",", ":")) + "\n" for row in rows)).encode()
86
+
87
+
88
+ def main() -> int:
89
+ parser = argparse.ArgumentParser(description=__doc__)
90
+ parser.add_argument("--source-root", type=Path, default=Path.cwd())
91
+ parser.add_argument("--check", action="store_true", help="compare without writing")
92
+ args = parser.parse_args()
93
+ repo = Path(git(args.source_root.resolve(), "rev-parse", "--show-toplevel").decode().strip())
94
+ output = repo / "pipeline_output/codebase_inventory.jsonl"
95
+ content = generate(repo)
96
+ if args.check:
97
+ return 0 if output.is_file() and output.read_bytes() == content else 1
98
+ output.parent.mkdir(parents=True, exist_ok=True)
99
+ if not output.is_file() or output.read_bytes() != content:
100
+ output.write_bytes(content)
101
+ return 0
102
+
103
+
104
+ if __name__ == "__main__":
105
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-commit inventory gate: refresh, then require the exact refreshed bytes staged."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from codebase_inventory import EXTENSIONS, generate, git
12
+
13
+ INVENTORY = "pipeline_output/codebase_inventory.jsonl"
14
+
15
+
16
+ def check(repo: Path) -> tuple[bool, str]:
17
+ repo = Path(git(repo.resolve(), "rev-parse", "--show-toplevel").decode().strip())
18
+ output = repo / INVENTORY
19
+ content = generate(repo)
20
+ output.parent.mkdir(parents=True, exist_ok=True)
21
+ if not output.is_file() or output.read_bytes() != content:
22
+ output.write_bytes(content)
23
+ # A worktree inventory cannot describe a different staged version of the source.
24
+ unstaged = git(repo, "diff", "--name-only", "-z", "--diff-filter=ACMRT")
25
+ changed = unstaged.decode("utf-8", errors="surrogateescape").split("\0")
26
+ if any(Path(name).suffix in EXTENSIONS for name in changed if name):
27
+ return False, "source has unstaged changes; stage the intended code before refreshing the inventory"
28
+ staged = subprocess.run(["git", "-C", str(repo), "show", f":{INVENTORY}"], capture_output=True)
29
+ if staged.returncode != 0 or staged.stdout != content:
30
+ return False, "review and stage pipeline_output/codebase_inventory.jsonl, then retry the commit"
31
+ return True, "inventory matches the staged source"
32
+
33
+
34
+ def main() -> int:
35
+ parser = argparse.ArgumentParser(description=__doc__)
36
+ parser.add_argument("--repo", type=Path, default=Path.cwd())
37
+ args = parser.parse_args()
38
+ try:
39
+ allowed, reason = check(args.repo)
40
+ except (OSError, subprocess.CalledProcessError) as error:
41
+ allowed, reason = False, str(error)
42
+ if not allowed:
43
+ print(f"codebase-inventory: BLOCKED -- {reason}", file=sys.stderr)
44
+ return 0 if allowed else 1
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())
@@ -0,0 +1,204 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Board-claim bridge for Vogelkop (vogelkop phase #47, step 3).
5
+ //
6
+ // Vogelkop reads its TASKS.md, selects the next eligible governed card, and
7
+ // emits a versioned claim request (vogelkop.board-claim-request.v1). This
8
+ // module services that request by DELEGATING to the existing board authority
9
+ // implementation — claimCard in task_board_core_pi.js — which already owns
10
+ // the writer-lock CAS, the claims-file HMAC, expiry/single-attempt, and
11
+ // card-hash verification. Nothing here reimplements claims, lanes, HMACs, or
12
+ // writer state.
13
+ //
14
+ // The one thing this module adds is the pre-claim card-hash binding: the
15
+ // request carries the card's own on-disk [hash::] value, and it is verified
16
+ // against the board bytes BEFORE the claim is attempted, so a stale or
17
+ // tampered card is refused with a precise reason instead of surfacing as a
18
+ // generic no-dispatchable-card failure. The authoritative hash verification
19
+ // still happens inside claimCard's own dispatchability predicate.
20
+ //
21
+ // Route authority: the request's route is explicit user-authored board
22
+ // metadata (a #route-<seat-alias> tag) or an injected Jev fallback. The
23
+ // bridge NEVER re-routes: it records the route source on the receipt and
24
+ // binds the route into the envelope through claimCard's `route` parameter
25
+ // (the route decision identity the envelope carries). The model-moat gate
26
+ // result travels on the request and is recorded, not re-judged.
27
+ //
28
+ // The receipt is a versioned, non-authorizing observation. It contains no
29
+ // secrets and no endpoints.
30
+
31
+ import {
32
+ claimCard,
33
+ validateBoard,
34
+ readClaimsState,
35
+ computeCardHash,
36
+ canonicalJsonString,
37
+ sha256Hex,
38
+ createEnvelope as createEnvelopeBase,
39
+ workspaceRegistriesFor,
40
+ } from "./task_board_core_pi.js";
41
+ import { readFileSync, existsSync } from "node:fs";
42
+
43
+ export const BOARD_CLAIM_BRIDGE_SCHEMA = "agentic-driver.board-claim-bridge.v1";
44
+ export const BOARD_CLAIM_RECEIPT_SCHEMA = "vogelkop.board-claim-receipt.v1";
45
+ export const REQUEST_SCHEMA = "vogelkop.board-claim-request.v1";
46
+
47
+ const ROLE_NAME_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
48
+ const ROUTE_SOURCE_KINDS = new Set(["explicit-board-route", "jev-fallback"]);
49
+
50
+ function verifiedMoat({ request, backendBaseUrlFor, moatPolicy }) {
51
+ if (typeof backendBaseUrlFor !== "function") {
52
+ return fail("moat-evidence-required", "the authority boundary requires a trusted backend registry lookup (fails closed)");
53
+ }
54
+ const baseUrl = backendBaseUrlFor(request.route.seatAlias);
55
+ let host;
56
+ try { host = new URL(baseUrl).hostname; } catch {
57
+ return fail("moat-evidence-invalid", "the trusted backend registry did not provide a valid URL (fails closed)");
58
+ }
59
+ const local = host === "127.0.0.1" || host === "localhost" || host === "::1";
60
+ if (!local && moatPolicy?.allowHostedEgress !== true) {
61
+ return fail("moat-denied", `backend host "${host}" is not loopback — hosted egress is denied (fails closed)`);
62
+ }
63
+ return { ok: true, evidence: { ok: true, locality: local ? "local" : "hosted", basis: "trusted-backend-registry" } };
64
+ }
65
+
66
+ function fail(code, reason, extra = {}) {
67
+ return { ok: false, schema: BOARD_CLAIM_BRIDGE_SCHEMA, code, reason, ...extra };
68
+ }
69
+
70
+ /**
71
+ * Verify the request's cardHash against the CURRENT board bytes. This is the
72
+ * stale/tampered-card gate: a card edited after the request was minted is
73
+ * refused here, before any claim machinery runs.
74
+ */
75
+ export function verifyRequestedCard({ boardPath, request }) {
76
+ if (!existsSync(boardPath)) return fail("board-unavailable", "board file is not present");
77
+ let registries;
78
+ try { registries = workspaceRegistriesFor(boardPath); }
79
+ catch (error) { return fail(error?.code ?? "capability-registry-invalid", error?.message ?? String(error)); }
80
+ const validated = validateBoard(readFileSync(boardPath, "utf8"), registries);
81
+ if (!validated.ok) return fail("board-invalid", validated.errors.join("; "), { errors: validated.errors });
82
+ const card = validated.cards.find((entry) => entry.cardId === request.cardId);
83
+ if (!card) return fail("card-not-found", `card ${request.cardId} is not on the board`);
84
+ const onDiskHash = card.hash ?? computeCardHash(card);
85
+ if (onDiskHash !== request.cardHash) {
86
+ return fail("card-hash-mismatch",
87
+ `request cardHash does not match the on-disk card hash (stale or tampered request — fails closed)`);
88
+ }
89
+ return { ok: true, card, validated };
90
+ }
91
+
92
+ /**
93
+ * Mint the route-decision identity the envelope carries. The bridge does not
94
+ * re-route: the seat/model identity comes from the request's resolved route
95
+ * (explicit board route or injected Jev fallback), and the digest binds it
96
+ * deterministically so the envelope's routeDecisionDigest is reproducible
97
+ * from the receipt. No router config, no capacity snapshot, no janus call.
98
+ */
99
+ export function bridgeRouteFor({ request, repository }) {
100
+ return {
101
+ seatId: `vogelkop-${request.route.seatAlias}`,
102
+ accountId: null,
103
+ provider: "vogelkop-router",
104
+ model: request.route.seatAlias,
105
+ role: request.route.seat,
106
+ effort: "default",
107
+ containmentTier: "testudo",
108
+ phase: "implement",
109
+ routeDecisionDigest: sha256Hex(canonicalJsonString({
110
+ schema: "vogelkop.board-claim-route.v1",
111
+ cardId: request.cardId,
112
+ seatAlias: request.route.seatAlias,
113
+ seat: request.route.seat,
114
+ routeSource: request.route.source,
115
+ moat: request.moat,
116
+ repository,
117
+ })),
118
+ reservationId: null,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Service one claim request. Single attempt, no retry: whatever claimCard
124
+ * returns is the outcome. The receipt is a versioned, non-authorizing
125
+ * observation carrying the card binding, the claim/envelope identity, the
126
+ * route source and model, the moat result, and the branch/base/scope.
127
+ */
128
+ export function claimFromVogelkopRequest({ boardPath, request, policy = null, now = null, backendBaseUrlFor = null, moatPolicy = null }) {
129
+ if (request?.schema !== REQUEST_SCHEMA) {
130
+ return fail("request-schema-invalid", `unknown request schema "${String(request?.schema)}"`);
131
+ }
132
+ if (request.boardPathBasename !== "TASKS.md") {
133
+ return fail("request-board-invalid", "the request must name the canonical TASKS.md board");
134
+ }
135
+ if (typeof request.cardId !== "string" || request.cardId === "") {
136
+ return fail("request-card-invalid", "the request carries no cardId");
137
+ }
138
+ if (!/^[0-9a-f]{64}$/.test(request.cardHash ?? "")) {
139
+ return fail("request-hash-invalid", "the request cardHash is not a sha256 hex digest");
140
+ }
141
+ if (request.authorityCreated !== false) {
142
+ return fail("request-authority-invalid", "a claim request never carries authority (fails closed)");
143
+ }
144
+ if (typeof request.route?.seatAlias !== "string" || request.route.seatAlias === ""
145
+ || typeof request.route?.seat !== "string" || !ROLE_NAME_RE.test(request.route.seat)) {
146
+ return fail("request-route-invalid", "the request carries no valid resolved route seat and alias");
147
+ }
148
+ if (!ROUTE_SOURCE_KINDS.has(request.route?.source?.kind)) {
149
+ return fail("request-route-source-invalid", "the route source is not in the closed source vocabulary");
150
+ }
151
+ const moat = verifiedMoat({ request, backendBaseUrlFor, moatPolicy });
152
+ if (!moat.ok) return moat;
153
+
154
+ const verify = verifyRequestedCard({ boardPath, request });
155
+ if (!verify.ok) return verify;
156
+
157
+ // Delegation: the existing claim machinery owns exclusivity, HMAC,
158
+ // expiry, and authoritative hash verification. The route is bound as the
159
+ // envelope's route identity; the policy is the caller's (the automation
160
+ // policy file beside the board is used when none is supplied).
161
+ const route = bridgeRouteFor({ request, repository: boardPath.replace(/\/[^/]*$/, "") });
162
+ const result = claimCard({ boardPath, cardId: request.cardId, role: request.route.seat, policy, now, route });
163
+ if (!result.ok && result.code !== "claim-recoverable") {
164
+ return fail(result.code ?? "claim-refused", result.reason ?? (result.errors ?? []).join("; "), {
165
+ errors: result.errors ?? [],
166
+ });
167
+ }
168
+
169
+ const envelope = result.envelope;
170
+ const claims = readClaimsState(boardPath);
171
+ const claimRecord = claims.ok
172
+ ? claims.state.claims.find((entry) => entry.envelopeId === envelope.envelopeId) ?? null
173
+ : null;
174
+ return {
175
+ ok: result.ok,
176
+ schema: BOARD_CLAIM_RECEIPT_SCHEMA,
177
+ code: result.code ?? null,
178
+ recoverable: result.recoverable === true,
179
+ claimed: true,
180
+ nonAuthorizing: true,
181
+ authorityCreated: false,
182
+ persisted: true,
183
+ cardId: envelope.cardId,
184
+ cardHash: envelope.cardHash,
185
+ claimId: claimRecord ? `${claimRecord.cardId}@${claimRecord.claimedAt}` : null,
186
+ envelopeId: envelope.envelopeId,
187
+ route: {
188
+ source: request.route.source,
189
+ seat: request.route.seat,
190
+ seatAlias: request.route.seatAlias,
191
+ model: envelope.model,
192
+ provider: envelope.provider,
193
+ },
194
+ moat: moat.evidence,
195
+ branch: envelope.branch,
196
+ baseRevision: envelope.baseRevision,
197
+ startingRevision: envelope.startingRevision,
198
+ scope: [...envelope.allowedPaths],
199
+ stoppingPoint: envelope.stoppingPoint,
200
+ repository: envelope.repository,
201
+ claimedAt: claimRecord?.claimedAt ?? null,
202
+ expiresAt: envelope.expiry,
203
+ };
204
+ }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import {
13
13
  executeHerdrCommunication,
14
+ executeHerdrPromptExchange,
14
15
  HERDR_REPORT_MARKERS,
15
16
  HERDR_COMMUNICATION_SCHEMA,
16
17
  HERDR_COMMUNICATION_ACTIONS,
@@ -18,6 +19,7 @@ import {
18
19
  import { isNativeTuiContext } from "./native_tui_context.js";
19
20
  import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope, gitHead } from "./task_board_core_pi.js";
20
21
  import { sha256Hex } from "./task_board_core_pi.js";
22
+ import { sessionTaskDisplayId, PROVENANCE_CAPTURE_STATUSES } from "./session_tasks_core_pi.js";
21
23
  import { execFileSync } from "node:child_process";
22
24
 
23
25
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
@@ -225,7 +227,7 @@ function journeyReceipt(journey) {
225
227
  `status: ${journey.status}`,
226
228
  ...(journey.handoff ? [`handoff: attempted=${journey.handoff.attempted} ok=${journey.handoff.ok ?? false} role=${journey.handoff.role ?? journey.role} reason=${journey.handoff.reason ?? "none"}`] : []),
227
229
  ...journey.steps.map((step, index) =>
228
- `step ${index + 1}: task=${step.taskId ?? "none"} status=${step.status} report=${step.report ?? "(none)"}`),
230
+ `step ${index + 1}: taskDisplayId=${step.taskDisplayId ?? "none"} status=${step.status} report=${step.report ?? "(none)"}`),
229
231
  "[WORKER_JOURNEY_REPORT_END]",
230
232
  ].join("\n");
231
233
  if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
@@ -238,10 +240,10 @@ function journeyReceipt(journey) {
238
240
  // step/terminal evidence it belongs to. Not authority, not persisted
239
241
  // separately, and not written on every timer tick — only at assignment start
240
242
  // and meaningful checkpoints. Repository observations are read-only.
241
- function progressSnapshot({ taskId = null, envelope = null, repository = null, workerStatus = null, lastReportAt = null }) {
243
+ function progressSnapshot({ taskDisplayId = null, envelope = null, repository = null, workerStatus = null, lastReportAt = null }) {
242
244
  const snapshot = {
243
245
  schema: "agentic-driver.progress-snapshot.v1",
244
- taskId,
246
+ taskDisplayId,
245
247
  at: new Date().toISOString(),
246
248
  };
247
249
  if (envelope) {
@@ -289,9 +291,42 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
289
291
  }
290
292
  const role = params.role;
291
293
  const stepPrompt = params.stepPrompt;
292
- const taskStore = options.taskStore;
294
+ // A factory scopes observational advancement to this journey. Direct callers
295
+ // may still inject the original read-only taskStore contract unchanged.
296
+ const taskStore = typeof options.taskStoreFactory === "function"
297
+ ? options.taskStoreFactory(context) : options.taskStore;
293
298
  const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
294
299
  const journey = { mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
300
+ // Snapshot once at journey start; observational evidence never gates or
301
+ // authorizes existing dispatch, replacement, or board execution.
302
+ // Full immutable capture stays local for drift revalidation. The tool
303
+ // result only ever carries the sanitized projection: task id, digest, and
304
+ // source identity — never raw title/description planning text.
305
+ let taskProvenance = { status: "source-unavailable", tasks: [] };
306
+ try {
307
+ if (typeof taskStore?.captureProvenance === "function") taskProvenance = taskStore.captureProvenance();
308
+ } catch {
309
+ taskProvenance = { status: "source-unavailable", tasks: [] };
310
+ }
311
+ let projectedTaskProvenance;
312
+ try {
313
+ projectedTaskProvenance = typeof taskStore?.projectProvenance === "function"
314
+ ? taskStore.projectProvenance(taskProvenance)
315
+ : null;
316
+ } catch {
317
+ projectedTaskProvenance = null;
318
+ }
319
+ if (!projectedTaskProvenance || typeof projectedTaskProvenance !== "object") {
320
+ // Closed fallback: a throwing or absent projector never alters dispatch,
321
+ // never surfaces raw task material, and never passes a status through.
322
+ projectedTaskProvenance = Object.freeze({
323
+ status: typeof taskProvenance?.status === "string" && PROVENANCE_CAPTURE_STATUSES.includes(taskProvenance.status)
324
+ ? taskProvenance.status
325
+ : "source-unavailable",
326
+ source: null,
327
+ tasks: [],
328
+ });
329
+ }
295
330
  const dispatched = new Set();
296
331
  const communicationOptions = options.communication ?? options;
297
332
  const replacementRole = options.replacementRole ?? role;
@@ -318,7 +353,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
318
353
  status = "failed";
319
354
  journey.status = "failed";
320
355
  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" });
356
+ journey.steps.push({ step: journey.steps.length + 1, taskDisplayId: null, status: "failed", error: consumed?.reason || "envelope consumption failed closed" });
322
357
  } else {
323
358
  envelopeConsumed = true;
324
359
  }
@@ -339,6 +374,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
339
374
  report: journeyReceipt(journey),
340
375
  reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
341
376
  handoff: journey.handoff,
377
+ taskProvenance: projectedTaskProvenance,
342
378
  nonAuthorizing: true,
343
379
  persisted: false,
344
380
  };
@@ -348,10 +384,10 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
348
384
  // worker for the same trusted repository/role through the existing
349
385
  // herdr-lifecycle spawn boundary (fixed argv, shell:false) and resume the
350
386
  // pending task sequence. Reuses the same task cards; never duplicates them.
351
- const handoffToReplacement = async (reason, taskId = null) => {
387
+ const handoffToReplacement = async (reason, taskDisplayId = null) => {
352
388
  journey.status = "worker-unresponsive";
353
389
  journey.code = "worker-unresponsive";
354
- journey.steps.push({ step: journey.steps.length + 1, taskId, status: "worker-unresponsive", error: reason });
390
+ journey.steps.push({ step: journey.steps.length + 1, taskDisplayId, status: "worker-unresponsive", error: reason });
355
391
  if (!spawnReplacement) {
356
392
  journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
357
393
  return finish("worker-unresponsive");
@@ -472,13 +508,13 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
472
508
  } catch (error) {
473
509
  journey.status = "failed";
474
510
  journey.code = error?.code || "envelope-invalid";
475
- journey.steps.push({ step: 0, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
511
+ journey.steps.push({ step: 0, taskDisplayId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
476
512
  return finish("failed");
477
513
  }
478
514
  if (!guard.ok) {
479
515
  journey.status = "failed";
480
516
  journey.code = guard.code || "envelope-invalid";
481
- journey.steps.push({ step: 0, taskId: null, status: "failed", error: guard.reason });
517
+ journey.steps.push({ step: 0, taskDisplayId: null, status: "failed", error: guard.reason });
482
518
  return finish("failed");
483
519
  }
484
520
  }
@@ -493,9 +529,18 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
493
529
  } catch (error) {
494
530
  journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
495
531
  journey.code = error?.code || "pulse-failed";
496
- journey.steps.push({ step: stepIndex, taskId: null, status: journey.status, error: String(error?.message || error).slice(0, 256) });
532
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: journey.status, error: String(error?.message || error).slice(0, 256) });
497
533
  return finish(journey.status);
498
534
  }
535
+ // The pre-task pulse has not selected a task. Confirmed replacement here
536
+ // 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"))) {
538
+ journey.status = "worker-unresponsive";
539
+ journey.code = "worker-unresponsive";
540
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "worker-unresponsive", error: "worker unavailable before task selection" });
541
+ journey.handoff = { attempted: false, reason: "no task selected for confirmed replacement" };
542
+ return finish("worker-unresponsive");
543
+ }
499
544
  if (!pulse.alive) {
500
545
  return handoffToReplacement("worker role is not alive");
501
546
  }
@@ -503,7 +548,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
503
548
  if (pulse.status === "blocked") {
504
549
  journey.status = "role-blocked";
505
550
  journey.code = "role_blocked";
506
- journey.steps.push({ step: stepIndex, taskId: null, status: "role-blocked", workerStatus: pulse.status });
551
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "role-blocked", workerStatus: pulse.status });
507
552
  return finish("role-blocked");
508
553
  }
509
554
  // Not idle within an observed exchange cycle: unresponsive for dispatch.
@@ -516,19 +561,23 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
516
561
  task = selectNextTask(taskStore, dispatched);
517
562
  } catch (error) {
518
563
  journey.status = "failed";
519
- journey.steps.push({ step: stepIndex, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
564
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
520
565
  return finish("failed");
521
566
  }
522
567
  if (!task) {
523
568
  journey.status = "exhausted";
524
- journey.steps.push({ step: stepIndex, taskId: null, status: "exhausted" });
569
+ journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "exhausted" });
525
570
  return finish("exhausted");
526
571
  }
572
+ // Model-visible evidence carries only the opaque display token; the raw
573
+ // unbounded session ID stays internal to the store, dispatched set, and
574
+ // provenance revalidation lookups.
575
+ const displayTaskId = sessionTaskDisplayId(task.id);
527
576
 
528
577
  // Interactive opt-in: stop after each step for explicit approval.
529
578
  if (mode === "turn-by-turn" && stepIndex > 1) {
530
579
  journey.status = "waiting-approval";
531
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "waiting-approval" });
580
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "waiting-approval" });
532
581
  return finish("waiting-approval");
533
582
  }
534
583
 
@@ -537,7 +586,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
537
586
  if (autonomy !== "autonomous") {
538
587
  if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
539
588
  journey.status = "failed";
540
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
589
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "failed", error: "native TUI confirmation unavailable" });
541
590
  return finish("failed");
542
591
  }
543
592
  let confirmed;
@@ -550,12 +599,12 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
550
599
  ].join("\n"));
551
600
  } catch (error) {
552
601
  journey.status = "failed";
553
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
602
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "failed", error: `confirmation failed: ${error.message}` });
554
603
  return finish("failed");
555
604
  }
556
605
  if (confirmed !== true) {
557
606
  journey.status = "cancelled";
558
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
607
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "cancelled", error: "native confirmation was not granted" });
559
608
  return finish("cancelled");
560
609
  }
561
610
  }
@@ -568,14 +617,16 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
568
617
  if (!guard.ok) {
569
618
  journey.status = "failed";
570
619
  journey.code = guard.code || "envelope-invalid";
571
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: guard.reason });
620
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "failed", error: guard.reason });
572
621
  return finish("failed");
573
622
  }
574
623
  }
575
624
 
576
625
  // One prompt exchange. Any failure is terminal for the journey; there is
577
- // no invisible retry or resend.
578
- const exchange = await executeHerdrCommunication(
626
+ // no invisible retry or resend. The legacy blocking exchange is used
627
+ // deliberately: a journey step is a single prompt→report round-trip, and
628
+ // the tool-facing prompt action is now async delivery-only.
629
+ const exchange = await executeHerdrPromptExchange(
579
630
  { action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
580
631
  context,
581
632
  communicationOptions,
@@ -589,7 +640,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
589
640
  // automatically through the guarded seam. The replacement's first
590
641
  // prompt includes the mandatory gap-analysis instruction.
591
642
  const replacementPrompt = `${stepPrompt}\n\nMANDATORY GAP-ANALYSIS PHASE: you are a replacement agent. Before resuming implementation work: (1) read the task spec; (2) inspect the repository state (code, tests, working tree) — not what prior reports claim; (3) consult the journey history for prior step reports, handoffs, and progress judgments; (4) produce a gap analysis: remaining work and the next concrete sub-step you will execute. You may not resume implementation until this phase is complete.`;
592
- const replacement = await executeHerdrCommunication(
643
+ const replacement = await executeHerdrPromptExchange(
593
644
  { action: "prompt", role, prompt: replacementPrompt, timeoutMs: 120000 },
594
645
  context,
595
646
  communicationOptions,
@@ -602,7 +653,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
602
653
  const progressCredited = replacement.ok === true && (!previousScope || gapAnalysis !== previousScope);
603
654
  journey.steps.push({
604
655
  step: journey.steps.length + 1,
605
- taskId: task.id,
656
+ taskDisplayId: displayTaskId,
606
657
  status: replacement.ok === true ? "replaced" : "worker-unresponsive",
607
658
  error: replacement.ok === true ? undefined : String(replacement.code),
608
659
  gapAnalysis,
@@ -626,22 +677,22 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
626
677
  return finish("worker-unresponsive");
627
678
  }
628
679
  }
629
- return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
680
+ return handoffToReplacement(`exchange ended with ${exchange.code}`, displayTaskId);
630
681
  }
631
682
  journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";
632
683
  journey.code = exchange.code || "exchange-failed";
633
- journey.steps.push({ step: stepIndex, taskId: task.id, status: journey.status, error: exchange.reason || exchange.error || exchange.code });
684
+ journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: journey.status, error: exchange.reason || exchange.error || exchange.code });
634
685
  return finish(journey.status);
635
686
  }
636
687
  journey.steps.push({
637
688
  step: stepIndex,
638
- taskId: task.id,
689
+ taskDisplayId: displayTaskId,
639
690
  status: "done",
640
691
  workerStatus: exchange.agentStatus,
641
692
  report: exchange.report,
642
693
  // §10.2 progress snapshot at the meaningful checkpoint (step completion).
643
694
  progress: progressSnapshot({
644
- taskId: task.id,
695
+ taskDisplayId: displayTaskId,
645
696
  envelope: board?.envelope ?? null,
646
697
  repository: options.repository ?? null,
647
698
  workerStatus: exchange.agentStatus ?? null,
@@ -670,11 +721,13 @@ export function registerWorkerDispatchInterface(pi, options = {}) {
670
721
  pi.registerTool({
671
722
  name: WORKER_DISPATCH_TOOL,
672
723
  label: "Worker Dispatch And Pulse",
673
- description: "Observe worker liveness (pulse) or run one bounded continuous worker journey over the existing task sequence. Continuous mode is the default; turn-by-turn is explicit opt-in. Journeys emit one collated marked report, never create task cards, never retry, and are bounded by step count, not wall-clock.",
674
- promptSnippet: "Use agentic_worker_dispatch to pulse a worker or run one bounded continuous journey over the existing task sequence; it observes dispatchable tasks without creating cards and grants no authority.",
724
+ description: "Observe worker liveness (pulse) or run a bounded journey over coordinator session tasks. Interactive dispatch requires native confirmation; autonomous journeys follow their existing session confirmation semantics. Canonical TASKS.md board cards are dispatched through Board Pulse or agentic_kanban_board_dispatch, not this journey.",
725
+ promptSnippet: "Use agentic_worker_dispatch for coordinator session tasks only; board cards belong to Board Pulse or agentic_kanban_board_dispatch.",
675
726
  promptGuidelines: [
676
- "agentic_worker_dispatch pulse observes liveness, state, and dispatch eligibility without granting authority.",
677
- "agentic_worker_dispatch dispatch runs at most maxSteps single-exchange steps; any exchange failure ends the journey explicitly with no retry or resend.",
727
+ "agentic_worker_dispatch pulse observes liveness and eligibility without granting authority.",
728
+ "agentic_worker_dispatch dispatch observes session tasks without claiming board cards; interactive steps require native confirmation, while autonomous steps follow the session's existing autonomy semantics.",
729
+ "Board Pulse and agentic_kanban_board_dispatch own TASKS.md claims and the full route evidence required for envelopes. A journey never claims board cards.",
730
+ "Dispatch runs at most maxSteps single-exchange steps; exchange failure ends the journey without retry or resend.",
678
731
  ],
679
732
  parameters: WORKER_DISPATCH_PARAMETERS,
680
733
  async execute(_id, params, signal, _update, context) {