@evoclock/pi-agentic-driver 0.10.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentic-driver/nudge.defaults.json +11 -0
- package/.githooks/pre-commit +4 -0
- package/README.md +43 -31
- package/config/codebase-inventory.schema.json +23 -0
- package/docs/codebase-inventory.md +15 -0
- package/docs/recovery-evidence-store.md +9 -0
- package/extensions/herdr-dispatch.ts +15 -2
- package/extensions/recovery-evidence.ts +14 -0
- package/extensions/task-store-adapter.ts +101 -0
- package/extensions/tasks.ts +34 -11
- package/package.json +19 -1
- package/scripts/codebase_inventory.py +105 -0
- package/scripts/codebase_inventory_gate.py +48 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +204 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +83 -30
- package/scripts/enforcement/herdr_async_seam_pi.js +347 -0
- package/scripts/enforcement/herdr_communication_pi.js +607 -97
- package/scripts/enforcement/herdr_lifecycle_pi.js +266 -13
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +6 -6
- package/scripts/enforcement/native_tui_context.js +16 -0
- package/scripts/enforcement/nudge_classifier_pi.js +176 -0
- package/scripts/enforcement/nudge_config_pi.js +234 -0
- package/scripts/enforcement/nudge_ledger_pi.js +514 -0
- package/scripts/enforcement/nudge_reason_state_pi.js +135 -0
- package/scripts/enforcement/nudge_validate_pi.js +224 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +15 -3
- package/scripts/enforcement/recovery_evidence_review_pi.js +269 -0
- package/scripts/enforcement/recovery_evidence_store_pi.js +170 -0
- package/scripts/enforcement/session_tasks_core_pi.js +14 -0
- package/scripts/enforcement/task_board_core_pi.js +23 -5
- package/skills/pulse/SKILL.md +6 -2
- package/skills/tasks/SKILL.md +4 -0
|
@@ -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}:
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}`,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
674
|
-
promptSnippet: "Use agentic_worker_dispatch
|
|
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
|
|
677
|
-
"agentic_worker_dispatch dispatch
|
|
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) {
|