@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.
Files changed (32) 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 +607 -97
  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 +23 -5
  31. package/skills/pulse/SKILL.md +6 -2
  32. package/skills/tasks/SKILL.md +4 -0
@@ -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
  }
@@ -434,7 +434,7 @@ function boardHasCapabilities(boardPath) {
434
434
  }
435
435
  }
436
436
 
437
- function workspaceRegistriesFor(boardPath, requestedCapabilities) {
437
+ export function workspaceRegistriesFor(boardPath, requestedCapabilities) {
438
438
  try {
439
439
  return { capabilities: loadWorkspaceCapabilityNames(boardPath) };
440
440
  } catch (error) {
@@ -1777,7 +1777,11 @@ const PULSE_FIELDS = Object.freeze([
1777
1777
  ]);
1778
1778
  const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
1779
1779
  const PULSE_MODES = Object.freeze(["interactive", "automated"]);
1780
- 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;
1781
1785
 
1782
1786
  function pulseCapacityOk(value) {
1783
1787
  return Number.isInteger(value) && value >= 1 && value <= 32;
@@ -1788,8 +1792,9 @@ function checkPulseRouteEntry(entry, { seen }) {
1788
1792
  || !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
1789
1793
  return "each routing entry must have exactly {model, maxConcurrent}";
1790
1794
  }
1791
- if (typeof entry.model !== "string" || !PULSE_MODEL_RE.test(entry.model)) {
1792
- 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`;
1793
1798
  }
1794
1799
  if (seen.has(entry.model)) {
1795
1800
  return `model "${entry.model}" appears more than once for this role`;
@@ -2238,7 +2243,20 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
2238
2243
  if (activeClaims.length >= concurrency) {
2239
2244
  return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
2240
2245
  }
2241
- 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);
2242
2260
  if (!validatedBoard.ok) {
2243
2261
  return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
2244
2262
  }
@@ -1,5 +1,9 @@
1
- // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-only
1
+ ---
2
+ description: Use Board Pulse to check Kanban scheduling capacity or work through ready cards.
3
+ ---
4
+
5
+ <!-- SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com> -->
6
+ <!-- SPDX-License-Identifier: AGPL-3.0-only -->
3
7
 
4
8
  # Pulse
5
9
 
@@ -1,3 +1,7 @@
1
+ ---
2
+ description: Use the session task list to track work and promote owner-authorized tasks to the durable board.
3
+ ---
4
+
1
5
  # tasks — unified session task list + board promotion (v1)
2
6
 
3
7
  Owner-facing skill for the pi-agentic-driver session task list and