@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,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
|
-
|
|
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" ||
|
|
1792
|
-
|
|
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
|
-
|
|
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
|
}
|
package/skills/pulse/SKILL.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
package/skills/tasks/SKILL.md
CHANGED