@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,170 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Owner-local, non-authorizing recovery evidence. Nothing in the journey calls
5
+ // this module yet. The host chooses a private root; the store never chooses an
6
+ // implicit repository or app-data path and never evicts evidence on its own.
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { createHash } from "node:crypto";
9
+ import { constants, closeSync, existsSync, lstatSync, mkdirSync, openSync, realpathSync } from "node:fs";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+ import { appendTerminalResult, nudgeCanonicalJsonString, validateLedgerEntry } from "./nudge_ledger_pi.js";
12
+
13
+ export const RECOVERY_EVIDENCE_SCHEMA = "agentic-driver.recovery-evidence.v1";
14
+ const FILE = "recovery-evidence.v1.sqlite";
15
+ const SHA256 = /^[a-f0-9]{64}$/;
16
+
17
+ function refuse(code, message) {
18
+ return Object.assign(new Error(message), { code });
19
+ }
20
+
21
+ function positiveBound(value, name) {
22
+ if (!Number.isSafeInteger(value) || value < 1) throw refuse("invalid-bound", `${name} must be a positive safe integer`);
23
+ return value;
24
+ }
25
+
26
+ function privateRoot(root, create) {
27
+ if (typeof root !== "string" || !isAbsolute(root) || resolve(root) !== root) {
28
+ throw refuse("invalid-root", "a canonical absolute owner-provided root is required");
29
+ }
30
+ if (create && !existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
31
+ const stat = lstatSync(root);
32
+ if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || realpathSync(root) !== root) {
33
+ throw refuse("untrusted-root", "evidence root must be a private, non-symlink directory");
34
+ }
35
+ return root;
36
+ }
37
+
38
+ function evidenceFile(root, create) {
39
+ const path = join(root, FILE);
40
+ if (create && !existsSync(path)) {
41
+ try {
42
+ const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
43
+ closeSync(fd);
44
+ } catch (error) {
45
+ if (error.code !== "EEXIST") throw error;
46
+ }
47
+ }
48
+ const stat = lstatSync(path);
49
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || realpathSync(path) !== path) {
50
+ throw refuse("untrusted-file", "evidence file must be private and not a symlink");
51
+ }
52
+ return path;
53
+ }
54
+
55
+ /** Explicit host root; readOnly observation never creates a directory or file. */
56
+ export function openRecoveryEvidenceStore({ root, readOnly = false, maxRecordBytes = 128 * 1024, maxTotalBytes = 16 * 1024 * 1024, maxRecords = 1000 } = {}) {
57
+ positiveBound(maxRecordBytes, "maxRecordBytes");
58
+ positiveBound(maxTotalBytes, "maxTotalBytes");
59
+ positiveBound(maxRecords, "maxRecords");
60
+ const path = evidenceFile(privateRoot(root, !readOnly), !readOnly);
61
+ const db = new DatabaseSync(path, { readOnly });
62
+ try {
63
+ db.exec("PRAGMA busy_timeout = 5000;");
64
+ if (!readOnly) db.exec(`
65
+ CREATE TABLE IF NOT EXISTS evidence (
66
+ journey_id TEXT NOT NULL, entry_id TEXT NOT NULL,
67
+ phase TEXT NOT NULL CHECK(phase IN ('requested','terminal')),
68
+ body TEXT NOT NULL, digest TEXT NOT NULL, previous_digest TEXT,
69
+ bytes INTEGER NOT NULL, created_at TEXT NOT NULL,
70
+ PRIMARY KEY (journey_id, entry_id, phase)
71
+ );
72
+ `);
73
+ } catch (error) { db.close(); throw error; }
74
+ return { db, path, readOnly, maxRecordBytes, maxTotalBytes, maxRecords };
75
+ }
76
+
77
+ export function closeRecoveryEvidenceStore(store) {
78
+ store.db.close();
79
+ }
80
+
81
+ function digest(text) { return createHash("sha256").update(text, "utf8").digest("hex"); }
82
+
83
+ function reference(row) {
84
+ return Object.freeze({ schema: RECOVERY_EVIDENCE_SCHEMA, journeyId: row.journey_id,
85
+ entryId: row.entry_id, phase: row.phase, sha256: row.digest, bytes: row.bytes });
86
+ }
87
+
88
+ function fetchRow(store, { journeyId, entryId, phase }) {
89
+ return store.db.prepare("SELECT * FROM evidence WHERE journey_id=? AND entry_id=? AND phase=?")
90
+ .get(journeyId, entryId, phase);
91
+ }
92
+
93
+ /** Bounded, read-only listing of closed retrieval references; never bodies. */
94
+ export function listRecoveryEvidenceReferences(store, { limit = 50 } = {}) {
95
+ if (!Number.isSafeInteger(limit) || limit < 1) throw refuse("invalid-bound", "limit must be a positive safe integer");
96
+ return store.db.prepare(
97
+ "SELECT journey_id, entry_id, phase, digest, bytes FROM evidence ORDER BY created_at DESC, journey_id, entry_id, phase LIMIT ?",
98
+ ).all(limit).map((row) => reference(row));
99
+ }
100
+
101
+ /** Returns exact bytes only when the caller's reference and its predecessor verify. */
102
+ export function readRecoveryEvidence(store, ref) {
103
+ if (!ref || ref.schema !== RECOVERY_EVIDENCE_SCHEMA || !["requested", "terminal"].includes(ref.phase)
104
+ || typeof ref.journeyId !== "string" || !ref.journeyId || typeof ref.entryId !== "string" || !ref.entryId
105
+ || !SHA256.test(ref.sha256 ?? "") || !Number.isSafeInteger(ref.bytes) || ref.bytes < 1) {
106
+ throw refuse("invalid-reference", "a complete evidence reference is required");
107
+ }
108
+ const row = fetchRow(store, ref);
109
+ if (!row) throw refuse("evidence-missing", "referenced recovery evidence is unavailable");
110
+ if (row.digest !== ref.sha256 || row.bytes !== ref.bytes || digest(row.body) !== row.digest
111
+ || Buffer.byteLength(row.body, "utf8") !== row.bytes) {
112
+ throw refuse("evidence-corrupt", "recovery evidence digest or size mismatch");
113
+ }
114
+ let entry;
115
+ try { entry = JSON.parse(row.body); } catch { throw refuse("evidence-corrupt", "recovery evidence is not valid JSON"); }
116
+ if (entry.journeyId !== ref.journeyId || entry.entryId !== ref.entryId || !validateLedgerEntry(entry).ok
117
+ || (ref.phase === "requested" && entry.result !== "requested")
118
+ || (ref.phase === "terminal" && entry.result === "requested")) {
119
+ throw refuse("evidence-corrupt", "recovery evidence does not match its identity and phase");
120
+ }
121
+ if (ref.phase === "terminal") {
122
+ const parent = fetchRow(store, { ...ref, phase: "requested" });
123
+ if (!parent || row.previous_digest !== parent.digest || digest(parent.body) !== parent.digest) {
124
+ throw refuse("evidence-corrupt", "terminal evidence has no intact requested predecessor");
125
+ }
126
+ }
127
+ return { reference: reference(row), entry };
128
+ }
129
+
130
+ function append(store, entry, phase, previousDigest = null) {
131
+ if (store.readOnly) throw refuse("read-only", "evidence store is read-only");
132
+ const body = nudgeCanonicalJsonString(entry);
133
+ const bytes = Buffer.byteLength(body, "utf8");
134
+ if (bytes > store.maxRecordBytes) throw refuse("evidence-too-large", "recovery record exceeds per-record bound");
135
+ store.db.exec("BEGIN IMMEDIATE;");
136
+ try {
137
+ const usage = store.db.prepare("SELECT COUNT(*) AS count, COALESCE(SUM(bytes),0) AS bytes FROM evidence").get();
138
+ if (usage.count >= store.maxRecords || usage.bytes + bytes > store.maxTotalBytes) {
139
+ throw refuse("evidence-capacity", "owner review or increased capacity is required; unreviewed evidence is never evicted");
140
+ }
141
+ store.db.prepare("INSERT INTO evidence VALUES (?,?,?,?,?,?,?,?)").run(
142
+ entry.journeyId, entry.entryId, phase, body, digest(body), previousDigest, bytes, new Date().toISOString());
143
+ store.db.exec("COMMIT;");
144
+ } catch (error) {
145
+ store.db.exec("ROLLBACK;");
146
+ if (String(error.message).startsWith("UNIQUE constraint failed: evidence.")) {
147
+ throw refuse("evidence-duplicate", "recovery phase already recorded");
148
+ }
149
+ throw error;
150
+ }
151
+ return reference({ journey_id: entry.journeyId, entry_id: entry.entryId, phase, digest: digest(body), bytes });
152
+ }
153
+
154
+ /** Must succeed before the caller attempts any nudge or replacement. */
155
+ export function recordRecoveryRequest(store, entry) {
156
+ const valid = validateLedgerEntry(entry);
157
+ if (!valid.ok || entry.result !== "requested") throw refuse("invalid-entry", "a valid requested ledger entry is required");
158
+ return append(store, entry, "requested");
159
+ }
160
+
161
+ /** A failed post-action write is ambiguous; callers must retain their identity lock. */
162
+ export function recordRecoveryResult(store, requestedRef, terminal) {
163
+ if (!requestedRef || requestedRef.phase !== "requested") throw refuse("invalid-reference", "requested evidence reference required");
164
+ const requested = readRecoveryEvidence(store, requestedRef);
165
+ if (fetchRow(store, { ...requestedRef, phase: "terminal" })) {
166
+ throw refuse("evidence-duplicate", "terminal evidence already recorded; never replay an action");
167
+ }
168
+ const [entry] = appendTerminalResult([requested.entry], requested.entry.entryId, terminal);
169
+ return append(store, entry, "terminal", requestedRef.sha256);
170
+ }
@@ -36,6 +36,7 @@ export const TASK_PROMOTION_SCHEMA = "agentic-driver.task-promotion.v1";
36
36
  export const TASK_STATE_ENTRY = "picc-tasks-state";
37
37
  export const SESSION_TASK_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
38
38
  export const TASK_LIST_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
39
+ export const SESSION_TASK_PRIORITIES = Object.freeze(["P0", "P1", "P2", "P3"]);
39
40
  export const ORIGIN_VALUES = Object.freeze(["coordinator", "worker"]);
40
41
  export const LEGACY_UNKNOWN_ORIGIN = "legacy-unknown";
41
42
  export const PROMOTED_TASK_STATUSES = Object.freeze(["pending", "in_progress"]);
@@ -43,14 +44,27 @@ export const PROMOTION_LANES = Object.freeze(["backlog", "in-progress"]);
43
44
  export const COMPLETED_TASK_POLICY = Object.freeze(["skip", "promote-as-done"]);
44
45
  export const BATCH_END_POLICY = Object.freeze(["offered", "off"]);
45
46
  export const TRUSTED_HERDR_EXECUTABLE = "/opt/homebrew/bin/herdr";
47
+ export const PROVENANCE_CAPTURE_STATUSES = Object.freeze([
48
+ "captured", "host-session-unavailable", "task-list-mismatch", "source-unavailable",
49
+ ]);
46
50
  export const DEFAULT_PROMOTION_LANE = "backlog";
47
51
 
52
+ // Deterministic opaque display token for model-visible evidence. Session task
53
+ // IDs are unbounded caller-controlled strings that can carry secret-like text;
54
+ // only this full 64-hex SHA-256 token (no lossy truncation, no fabricated
55
+ // identity) is ever displayed. The raw ID stays inside the task store for
56
+ // selection, dispatch, and provenance lookups.
57
+ export function sessionTaskDisplayId(id) {
58
+ return createHash("sha256").update(`agentic-driver.session-task-display.v1:${String(id ?? "")}`, "utf8").digest("hex");
59
+ }
60
+
48
61
  /** picc-tasks task shape: the closed snapshot shape replay and migration read. */
49
62
  export function validateSessionTask(value) {
50
63
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
51
64
  if (typeof value.id !== "string" || value.id === "") return false;
52
65
  if (!SESSION_TASK_STATUSES.includes(value.status)) return false;
53
66
  if (typeof value.subject !== "string" || typeof value.description !== "string") return false;
67
+ if (value.priority !== undefined && !SESSION_TASK_PRIORITIES.includes(value.priority)) return false;
54
68
  for (const field of ["blocks", "blockedBy"]) {
55
69
  if (!Array.isArray(value[field]) || value[field].some((entry) => typeof entry !== "string")) return false;
56
70
  }
@@ -379,6 +379,75 @@ function safeJsonParse(text) {
379
379
  }
380
380
  }
381
381
 
382
+ const CAPABILITY_REGISTRY_PATH = ".vogelkop/capabilities.json";
383
+
384
+ function capabilityRegistryError(registryPath, reason) {
385
+ return Object.assign(new Error(`capability registry is invalid at ${registryPath}: ${reason}`), {
386
+ code: "capability-registry-invalid",
387
+ });
388
+ }
389
+
390
+ // The workspace registry is deliberately loaded at tool-call time. A missing
391
+ // registry is an empty declaration; an unreadable or malformed present file is
392
+ // not silently downgraded to empty. Capability-less boards are independent of
393
+ // the registry, so they retain the writer's existing behavior even while an
394
+ // unused registry file is malformed.
395
+ function loadWorkspaceCapabilityNames(boardPath) {
396
+ const registryPath = join(dirname(boardPath), CAPABILITY_REGISTRY_PATH);
397
+ let text;
398
+ try {
399
+ text = readFileSync(registryPath, "utf8");
400
+ } catch (error) {
401
+ if (error?.code === "ENOENT") return [];
402
+ throw capabilityRegistryError(registryPath, String(error?.message || error).slice(0, 256));
403
+ }
404
+ let entries;
405
+ try {
406
+ entries = JSON.parse(text);
407
+ } catch (error) {
408
+ throw capabilityRegistryError(registryPath, `invalid JSON: ${String(error?.message || error).slice(0, 256)}`);
409
+ }
410
+ if (!Array.isArray(entries)) {
411
+ throw capabilityRegistryError(registryPath, "expected a JSON array");
412
+ }
413
+ const names = [];
414
+ for (const [index, entry] of entries.entries()) {
415
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)
416
+ || typeof entry.name !== "string" || entry.name.trim() === ""
417
+ || (entry.description !== undefined && typeof entry.description !== "string")) {
418
+ throw capabilityRegistryError(registryPath, `entry ${index} must contain a non-empty string name and an optional string description`);
419
+ }
420
+ names.push(entry.name);
421
+ }
422
+ return names;
423
+ }
424
+
425
+ function boardHasCapabilities(boardPath) {
426
+ if (!existsSync(boardPath)) return false;
427
+ try {
428
+ const parsed = parseBoard(readFileSync(boardPath, "utf8"));
429
+ return parsed.cards.some((card) => Array.isArray(card.capabilities) && card.capabilities.length > 0);
430
+ } catch {
431
+ // Let the writer report the board error; do not use an unreadable board to
432
+ // justify ignoring a malformed capability registry.
433
+ return true;
434
+ }
435
+ }
436
+
437
+ export function workspaceRegistriesFor(boardPath, requestedCapabilities) {
438
+ try {
439
+ return { capabilities: loadWorkspaceCapabilityNames(boardPath) };
440
+ } catch (error) {
441
+ const capabilityRequested = requestedCapabilities !== undefined
442
+ && requestedCapabilities !== null
443
+ && (!Array.isArray(requestedCapabilities) || requestedCapabilities.length > 0);
444
+ if (error?.code === "capability-registry-invalid" && !capabilityRequested && !boardHasCapabilities(boardPath)) {
445
+ return { capabilities: [] };
446
+ }
447
+ throw error;
448
+ }
449
+ }
450
+
382
451
  // ---------------------------------------------------------------------------
383
452
  // Closed validator (§1, §6 gate 2). Validate before persist; decline on
384
453
  // violation; duplicate cardId is a validation error; decorative use of the
@@ -1708,7 +1777,11 @@ const PULSE_FIELDS = Object.freeze([
1708
1777
  ]);
1709
1778
  const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
1710
1779
  const PULSE_MODES = Object.freeze(["interactive", "automated"]);
1711
- const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
1780
+ // Pi model IDs are provider/model-id. The provider is one bounded segment;
1781
+ // model-id may contain bounded nested segments and Pi's documented colon
1782
+ // punctuation. The separate total-length check keeps the complete ID bounded.
1783
+ const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127})+$/;
1784
+ const PULSE_MODEL_MAX_LENGTH = 192;
1712
1785
 
1713
1786
  function pulseCapacityOk(value) {
1714
1787
  return Number.isInteger(value) && value >= 1 && value <= 32;
@@ -1719,8 +1792,9 @@ function checkPulseRouteEntry(entry, { seen }) {
1719
1792
  || !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
1720
1793
  return "each routing entry must have exactly {model, maxConcurrent}";
1721
1794
  }
1722
- if (typeof entry.model !== "string" || !PULSE_MODEL_RE.test(entry.model)) {
1723
- return `route model "${entry.model}" is not an exact provider/model identifier`;
1795
+ if (typeof entry.model !== "string" || entry.model.length > PULSE_MODEL_MAX_LENGTH
1796
+ || !PULSE_MODEL_RE.test(entry.model)) {
1797
+ return `route model "${entry.model}" is not an exact bounded provider/model identifier`;
1724
1798
  }
1725
1799
  if (seen.has(entry.model)) {
1726
1800
  return `model "${entry.model}" appears more than once for this role`;
@@ -2169,7 +2243,20 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
2169
2243
  if (activeClaims.length >= concurrency) {
2170
2244
  return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
2171
2245
  }
2172
- const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
2246
+ // Pulse policies also carry a role-wide ceiling. Enforce it here, under the
2247
+ // writer lock and after expired claims are released, so concurrent claimers
2248
+ // cannot race past the route's pre-scan capacity check.
2249
+ const roleConcurrency = policyCheck.policy.pulse?.routing?.[role]?.maxConcurrent;
2250
+ if (Number.isInteger(roleConcurrency)) {
2251
+ const activeForRole = activeClaims.filter((claim) => claim.role === role).length;
2252
+ if (activeForRole >= roleConcurrency) {
2253
+ return { ok: false, code: "policy-role-concurrency-refused", reason: `the automation policy allows role "${role}" at most ${roleConcurrency} concurrent claim(s); ${activeForRole} are active` };
2254
+ }
2255
+ }
2256
+ let registries;
2257
+ try { registries = workspaceRegistriesFor(boardPath); }
2258
+ catch (error) { return { ok: false, code: error?.code ?? "capability-registry-invalid", reason: error?.message ?? String(error) }; }
2259
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
2173
2260
  if (!validatedBoard.ok) {
2174
2261
  return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
2175
2262
  }
@@ -2898,11 +2985,12 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
2898
2985
  // reason), never raw throws — the model sees the governance reason.
2899
2986
  let result;
2900
2987
  try {
2988
+ const registries = workspaceRegistriesFor(activeBoardPath, input?.capabilities);
2901
2989
  result = writeCard({
2902
2990
  boardPath: activeBoardPath,
2903
2991
  input: writerInput,
2904
2992
  authority: input?.authority,
2905
- registries: {},
2993
+ registries,
2906
2994
  surface: "tasks",
2907
2995
  // No requireExistingBoard: the write tool bootstraps a fresh
2908
2996
  // board when none exists. Recreation is safe — fresh content only,
@@ -3028,9 +3116,10 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
3028
3116
  }
3029
3117
  let result;
3030
3118
  try {
3119
+ const registries = workspaceRegistriesFor(activeBoardPath, operation === "update" ? input?.capabilities : undefined);
3031
3120
  result = operation === "update"
3032
- ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries: {}, surface: "tasks" })
3033
- : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries: {}, surface: "tasks" });
3121
+ ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries, surface: "tasks" })
3122
+ : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries, surface: "tasks" });
3034
3123
  } catch (error) {
3035
3124
  const code = typeof error?.code === "string" ? error.code : "writer-error";
3036
3125
  const value = { ok: false, persisted: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };