@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,224 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // `nudge validate` (NUDGE §7, rule 30): a read-only setup-time validator in
5
+ // the shape of router_validate_pi.js. It loads the merged nudge config
6
+ // exactly as the runtime would (repo defaults + optional profile overlay,
7
+ // same merge semantics: profile sections replace wholesale), then reports
8
+ // findings with paths. Exit 0 only when the config is valid AND operational
9
+ // — a valid schema with a missing required N1 capability (the upstream herdr
10
+ // nudge verb has not shipped) is reported non-operational and exits nonzero.
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { pathToFileURL } from "node:url";
16
+ import {
17
+ validateNudgeConfig, NUDGE_CONFIG_SCHEMA, NUDGE_REMOVED_KEYS,
18
+ probeNudgeUpstreamCapability,
19
+ } from "./nudge_config_pi.js";
20
+ import { TRUSTED_HERDR_EXECUTABLE } from "./herdr_communication_pi.js";
21
+
22
+ export const NUDGE_DEFAULTS_RELATIVE_PATH = ".agentic-driver/nudge.defaults.json";
23
+ export const NUDGE_PROFILE_DEFAULT_PATH = join(homedir(), ".config", "agentic-driver", "nudge", "profile.json");
24
+
25
+ // Optional cross-surface inputs: the dispatch config's poll bound and
26
+ // stallPolls. When a dispatch config exists, the validator cross-checks
27
+ // (SEAM §7); when it does not, those checks are skipped and reported as
28
+ // such — the runtime re-checks at enablement.
29
+ export const DISPATCH_DEFAULTS_RELATIVE_PATH = ".agentic-driver/dispatch.defaults.json";
30
+
31
+ function readJsonIfExists(path) {
32
+ if (!existsSync(path)) return null;
33
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; }
34
+ }
35
+
36
+ function mergeConfigs(defaults, profile) {
37
+ if (profile === null || profile === undefined) return defaults;
38
+ if (defaults === null || defaults === undefined) return profile;
39
+ return {
40
+ ...defaults,
41
+ ...profile,
42
+ nudge: profile.nudge !== undefined ? profile.nudge : defaults.nudge,
43
+ };
44
+ }
45
+
46
+ export function validateNudgeSetup({
47
+ repoRoot = process.cwd(),
48
+ defaultsPath = null,
49
+ profilePath = null,
50
+ dispatchPath = null,
51
+ upstreamCapabilityVerified = false,
52
+ capabilityProbe = null,
53
+ } = {}) {
54
+ // Part 2.11: `upstreamCapabilityVerified` may be asserted by the caller, or
55
+ // resolved through the probe seam when one is SUPPLIED. When a probe is
56
+ // given it must be a synchronous verdict provider (or an already-resolved
57
+ // {verified}); async probing is the caller's job — see
58
+ // resolveNudgeSetupWithProbe below. An unverified capability remains the
59
+ // expected fail-closed state until the upstream herdr nudge verb ships.
60
+ // The manual assertion is never a substitute for the probe: it only lets a
61
+ // caller that has already run the probe fold the verdict into this report.
62
+ let capabilityVerified = upstreamCapabilityVerified === true;
63
+ let capabilityProbeReason = null;
64
+ if (!capabilityVerified && capabilityProbe !== null && typeof capabilityProbe === "object" && typeof capabilityProbe.then !== "function") {
65
+ capabilityVerified = capabilityProbe.verified === true;
66
+ capabilityProbeReason = capabilityProbe.reason ?? null;
67
+ } else if (!capabilityVerified && capabilityProbe !== null) {
68
+ throw Object.assign(new Error("an async capabilityProbe must be resolved before validateNudgeSetup — use resolveNudgeSetupWithProbe"), { code: "invalid_parameters" });
69
+ }
70
+ const resolvedDefaults = resolve(defaultsPath ?? join(repoRoot, NUDGE_DEFAULTS_RELATIVE_PATH));
71
+ const resolvedProfile = resolve(profilePath ?? process.env.AGENTIC_DRIVER_NUDGE_PROFILE ?? NUDGE_PROFILE_DEFAULT_PATH);
72
+ const resolvedDispatch = resolve(dispatchPath ?? join(repoRoot, DISPATCH_DEFAULTS_RELATIVE_PATH));
73
+ const errors = [];
74
+ const warnings = [];
75
+
76
+ const defaults = readJsonIfExists(resolvedDefaults);
77
+ if (defaults === null) {
78
+ errors.push(`defaults: nudge defaults file is missing: ${resolvedDefaults}`);
79
+ } else if (defaults === undefined) {
80
+ errors.push(`defaults: nudge defaults file is not valid JSON: ${resolvedDefaults}`);
81
+ }
82
+ let profile = null;
83
+ if (existsSync(resolvedProfile)) {
84
+ profile = readJsonIfExists(resolvedProfile);
85
+ if (profile === undefined) errors.push(`profile: nudge profile file is not valid JSON: ${resolvedProfile}`);
86
+ else if (profile !== null) warnings.push(`profile loaded: ${resolvedProfile}`);
87
+ } else {
88
+ warnings.push(`no profile at ${resolvedProfile} — defaults only`);
89
+ }
90
+
91
+ const config = mergeConfigs(defaults, profile);
92
+ if (config !== null && config !== undefined) {
93
+ // Cross-surface inputs from the dispatch config when present.
94
+ let maxPollsPerSubmit = null;
95
+ let stallPolls = null;
96
+ const dispatch = readJsonIfExists(resolvedDispatch);
97
+ if (dispatch !== null && dispatch !== undefined) {
98
+ const poll = dispatch.poll;
99
+ if (poll !== null && typeof poll === "object" && !Array.isArray(poll)) {
100
+ maxPollsPerSubmit = typeof poll.maxPollsPerSubmit === "number" ? poll.maxPollsPerSubmit : null;
101
+ stallPolls = typeof poll.stallPolls === "number" ? poll.stallPolls : null;
102
+ }
103
+ if (poll === null || typeof poll !== "object" || Array.isArray(poll)) {
104
+ warnings.push(`dispatch: no poll section found at ${resolvedDispatch} — cross-surface window checks skipped (the runtime re-checks at enablement)`);
105
+ }
106
+ // Single-owner check for stallPolls (§7): if the nudge config somehow
107
+ // carries it, the closed validator already rejects the unknown key;
108
+ // here we name the ownership rule explicitly.
109
+ if (config.nudge && Object.prototype.hasOwnProperty.call(config.nudge, "stallPolls")) {
110
+ errors.push("nudge.stallPolls: stallPolls is owned solely by the dispatch config — duplicate ownership is invalid");
111
+ }
112
+ } else if (defaults !== null) {
113
+ warnings.push(`no dispatch config at ${resolvedDispatch} — cross-surface window checks skipped (the runtime re-checks at enablement)`);
114
+ }
115
+
116
+ const validation = validateNudgeConfig(config, { upstreamCapabilityVerified: capabilityVerified, maxPollsPerSubmit, stallPolls });
117
+ for (const error of validation.errors) errors.push(`config: ${error}`);
118
+ if (validation.ok && !validation.operational) {
119
+ if (capabilityVerified !== true) {
120
+ // §7: a valid schema with a missing required N1 capability is
121
+ // reported non-operational and exits nonzero — but it is a finding,
122
+ // not a config error, so it lands in warnings when the config itself
123
+ // is valid.
124
+ warnings.push("operational: the required N1 upstream capability (herdr agent nudge with modal-isolation fixtures) is not verified — the ladder is configured but non-operational (§7)");
125
+ } else if (config.nudge?.enabled !== true) {
126
+ warnings.push("operational: nudge.enabled is false — the ladder is configured but not activated");
127
+ }
128
+ }
129
+ }
130
+
131
+ return {
132
+ valid: errors.length === 0,
133
+ operational: errors.length === 0 && capabilityVerified === true && config?.nudge?.enabled === true,
134
+ capabilityVerified: capabilityVerified === true,
135
+ ...(capabilityProbeReason !== null ? { capabilityProbeReason } : {}),
136
+ defaultsPath: resolvedDefaults,
137
+ profilePath: resolvedProfile,
138
+ dispatchPath: resolvedDispatch,
139
+ errors,
140
+ warnings,
141
+ removedKeysRejected: NUDGE_REMOVED_KEYS,
142
+ schema: NUDGE_CONFIG_SCHEMA,
143
+ };
144
+ }
145
+
146
+ // Part 2.11: async-capable setup resolution. Runs the supplied probe (or the
147
+ // default trusted-executable probe) and folds its verdict into the setup
148
+ // report. The probe is the seam: when the upstream herdr nudge verb ships and
149
+ // its modal-isolation fixtures pass, the probe verifies and `nudge validate`
150
+ // reports operational (with enabled:true). Until then every probe outcome is
151
+ // verified:false and the validator stays non-operational — fail closed.
152
+ export async function resolveNudgeSetupWithProbe({
153
+ repoRoot = process.cwd(),
154
+ defaultsPath = null,
155
+ profilePath = null,
156
+ dispatchPath = null,
157
+ upstreamCapabilityVerified = false,
158
+ probe = null,
159
+ } = {}) {
160
+ let capabilityProbeReason = null;
161
+ let capabilityVerified = upstreamCapabilityVerified === true;
162
+ if (!capabilityVerified) {
163
+ const runProbe = typeof probe === "function" ? probe : () => probeNudgeUpstreamCapability({ executable: TRUSTED_HERDR_EXECUTABLE });
164
+ try {
165
+ const verdict = await runProbe();
166
+ capabilityVerified = verdict?.verified === true;
167
+ capabilityProbeReason = verdict?.reason ?? null;
168
+ } catch (error) {
169
+ capabilityVerified = false;
170
+ capabilityProbeReason = `the nudge-capability probe failed: ${String(error?.message || error).slice(0, 200)}`;
171
+ }
172
+ }
173
+ const report = validateNudgeSetup({
174
+ repoRoot,
175
+ defaultsPath,
176
+ profilePath,
177
+ dispatchPath,
178
+ upstreamCapabilityVerified: capabilityVerified,
179
+ ...(capabilityProbeReason !== null ? { capabilityProbe: { verified: capabilityVerified, reason: capabilityProbeReason } } : {}),
180
+ });
181
+ return report;
182
+ }
183
+
184
+ export function formatNudgeValidateReport(report) {
185
+ const lines = [];
186
+ lines.push(report.valid ? "nudge config is valid" : "nudge config is NOT valid");
187
+ lines.push(report.operational ? "nudge ladder is operational" : "nudge ladder is NOT operational");
188
+ lines.push(`defaults: ${report.defaultsPath}`);
189
+ lines.push(`profile: ${report.profilePath}`);
190
+ lines.push(`dispatch cross-check: ${report.dispatchPath}`);
191
+ for (const error of report.errors) lines.push(`ERROR: ${error}`);
192
+ for (const warning of report.warnings) lines.push(`warn: ${warning}`);
193
+ return lines.join("\n");
194
+ }
195
+
196
+ function isEntrypoint() {
197
+ if (typeof process === "undefined" || !Array.isArray(process.argv) || process.argv[1] === undefined) return false;
198
+ return pathToFileURL(process.argv[1]).href === import.meta.url;
199
+ }
200
+
201
+ if (isEntrypoint()) {
202
+ const args = process.argv.slice(2);
203
+ const take = (flag) => {
204
+ const idx = args.indexOf(flag);
205
+ return idx >= 0 ? args[idx + 1] : null;
206
+ };
207
+ const capabilityFlag = args.includes("--upstream-capability-verified");
208
+ const report = await resolveNudgeSetupWithProbe({
209
+ repoRoot: process.cwd(),
210
+ defaultsPath: take("--defaults"),
211
+ profilePath: take("--profile"),
212
+ dispatchPath: take("--dispatch"),
213
+ upstreamCapabilityVerified: capabilityFlag,
214
+ });
215
+ process.stdout.write(`${formatNudgeValidateReport(report)}\n`);
216
+ if (!report.valid || !report.operational) {
217
+ process.stderr.write(`${JSON.stringify({
218
+ valid: report.valid, operational: report.operational,
219
+ errors: report.errors, warnings: report.warnings,
220
+ }, null, 2)}\n`);
221
+ process.exit(1);
222
+ }
223
+ process.exit(0);
224
+ }
@@ -24,7 +24,7 @@ import {
24
24
  import { scanBoard } from "./pulse_core_pi.js";
25
25
  import { routeDecision } from "./router_engine_pi.js";
26
26
  import { createReservation, claimReservation, renewReservationLease, consumeReservation, releaseReservation, insertRouteDecision } from "./router_store_pi.js";
27
- import { isNativeTuiContext } from "./native_tui_context.js";
27
+ import { isAttendedElectronContext, isNativeTuiContext } from "./native_tui_context.js";
28
28
 
29
29
  // ---------------------------------------------------------------------------
30
30
  // Board resolution and observation
@@ -571,7 +571,7 @@ export function validateSpawnMatchesEnvelope({ envelope, model, provider = null,
571
571
  return { ok: true, code: null, reason: null };
572
572
  }
573
573
 
574
- export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker } = {}) {
574
+ export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker, executeHerdrSpawnWorkerAttendedElectron = null, spawnOptions = {} } = {}) {
575
575
  if (typeof executeHerdrSpawnWorker !== "function") {
576
576
  throw new Error("pulseWorkerSpawnSeam requires the guarded herdr-lifecycle executeHerdrSpawnWorker");
577
577
  }
@@ -595,7 +595,19 @@ export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker } = {}) {
595
595
  || resolve(repository) !== resolve(coordinator, "..", repositoryName)) {
596
596
  return { ok: false, code: "repository-mismatch", reason: "the assignment repository is not a canonical sibling of the coordinator repository" };
597
597
  }
598
- return executeHerdrSpawnWorker({ placement: "tab", role, model, repository: repositoryName }, context, {}, signal);
598
+ // Explicit context dispatch. A native Pi TUI context keeps the existing
599
+ // guarded lifecycle unchanged. An explicit attended-Electron context (a
600
+ // separate class that never satisfies the TUI predicate) routes to the
601
+ // attended Electron boundary when the trusted host bound it; without a
602
+ // bound boundary the spawn fails closed here. No other context class is
603
+ // dispatched: the TUI predicate itself is not weakened for any caller.
604
+ if (isAttendedElectronContext(context)) {
605
+ if (typeof executeHerdrSpawnWorkerAttendedElectron !== "function") {
606
+ return { ok: false, code: "attended-electron-seam-required", reason: "an attended Electron context was supplied but the attended Electron spawn boundary was not bound (fails closed)" };
607
+ }
608
+ return executeHerdrSpawnWorkerAttendedElectron({ placement: "tab", role, model, repository: repositoryName }, context, spawnOptions, signal);
609
+ }
610
+ return executeHerdrSpawnWorker({ placement: "tab", role, model, repository: repositoryName }, context, spawnOptions, signal);
599
611
  };
600
612
  }
601
613
 
@@ -0,0 +1,269 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Owner-only review access to recorded recovery evidence (first host
5
+ // binding/review-access slice). The trusted Pi host derives the explicit
6
+ // owner-private storage root itself — never from model or worker input — and
7
+ // the single owner-initiated command is native-TUI-only. The TUI modality is
8
+ // a privacy-confinement boundary, not authentication: it confines the full
9
+ // evidence to a transient terminal overlay so it never reaches the model
10
+ // transcript, tool content, or any public output; it does not verify who the
11
+ // viewer is or add owner identity to the evidence store's own checks. RPC,
12
+ // JSON, and print modes are refused fail-closed for that confinement reason.
13
+ // Read-only review never creates storage, and no model-callable raw-evidence
14
+ // tool exists here or anywhere else.
15
+ import { homedir } from "node:os";
16
+ import { isAbsolute, join, resolve } from "node:path";
17
+ import {
18
+ RECOVERY_EVIDENCE_SCHEMA,
19
+ openRecoveryEvidenceStore,
20
+ closeRecoveryEvidenceStore,
21
+ readRecoveryEvidence,
22
+ listRecoveryEvidenceReferences,
23
+ } from "./recovery_evidence_store_pi.js";
24
+ import { isNativeTuiContext } from "./native_tui_context.js";
25
+
26
+ export const RECOVERY_EVIDENCE_REVIEW_SCHEMA = "agentic-driver.recovery-evidence-review.v1";
27
+ export const RECOVERY_EVIDENCE_REVIEW_COMMAND = "agentic-recovery-evidence";
28
+ // Matches the store's default per-record bound; a reference claiming more
29
+ // bytes than this is refused before any store is opened.
30
+ export const MAX_REVIEW_BYTES = 128 * 1024;
31
+ export const REVIEW_LIST_LIMIT = 50;
32
+ const MAX_ID_LENGTH = 256;
33
+ const SHA256 = /^[a-f0-9]{64}$/;
34
+ const CLOSED_REFERENCE_FIELDS = Object.freeze(["bytes", "entryId", "journeyId", "phase", "schema", "sha256"]);
35
+ const REGISTRATIONS = new WeakSet();
36
+ // Width-safe rendering comes from Pi's own terminal-width helpers when the
37
+ // installed runtime exposes them; the offline test environment falls back to
38
+ // a plain-length approximation so the review logic stays testable.
39
+ let widthHelpers = null;
40
+ async function loadWidthHelpers() {
41
+ if (widthHelpers) return widthHelpers;
42
+ try {
43
+ const tui = await import("@earendil-works/pi-tui");
44
+ if (typeof tui.visibleWidth === "function" && typeof tui.truncateToWidth === "function") {
45
+ widthHelpers = { visibleWidth: tui.visibleWidth, truncateToWidth: tui.truncateToWidth };
46
+ }
47
+ } catch { /* offline test environment: fallback below */ }
48
+ if (!widthHelpers) {
49
+ widthHelpers = {
50
+ visibleWidth: (text) => text.length,
51
+ truncateToWidth: (text, width) => (text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`),
52
+ };
53
+ }
54
+ return widthHelpers;
55
+ }
56
+
57
+ function refuse(code, message) {
58
+ return Object.assign(new Error(message), { code });
59
+ }
60
+
61
+ function result(code, reason) {
62
+ return { ok: false, schema: RECOVERY_EVIDENCE_REVIEW_SCHEMA, code, reason };
63
+ }
64
+
65
+ function commandArgumentsPresent(args) {
66
+ if (args === undefined || args === null) return false;
67
+ if (typeof args === "string") return args.trim().length > 0;
68
+ if (Array.isArray(args)) return args.some((value) => String(value).trim().length > 0);
69
+ return Object.keys(args).length > 0;
70
+ }
71
+
72
+ /**
73
+ * Host-derived owner-private root. The explicit root comes from trusted host
74
+ * environment configuration and must be an absolute path; it is canonicalized
75
+ * (trailing slashes and redundant separators removed) but never silently
76
+ * expanded: a leading `~` is rejected because expanding it would guess at the
77
+ * owner's intent instead of following an explicit host contract. The fallback
78
+ * is the Pi coding-agent config directory. Command arguments and model/worker
79
+ * input can never supply the root.
80
+ */
81
+ export function resolveReviewEvidenceRoot(env = process.env) {
82
+ const explicit = typeof env.AGENTIC_DRIVER_RECOVERY_EVIDENCE_ROOT === "string"
83
+ ? env.AGENTIC_DRIVER_RECOVERY_EVIDENCE_ROOT.trim()
84
+ : "";
85
+ if (explicit) {
86
+ if (!isAbsolute(explicit) || explicit.startsWith("~")) {
87
+ throw refuse("invalid-root", "AGENTIC_DRIVER_RECOVERY_EVIDENCE_ROOT must be an absolute path from the trusted host; relative and ~ paths are refused");
88
+ }
89
+ return resolve(explicit);
90
+ }
91
+ const agentDir = typeof env.PI_CODING_AGENT_DIR === "string" && env.PI_CODING_AGENT_DIR.trim()
92
+ ? env.PI_CODING_AGENT_DIR.trim()
93
+ : join(homedir(), ".pi", "agent");
94
+ return join(agentDir, "recovery-evidence");
95
+ }
96
+
97
+ /** Closed reference fields only; an oversized reference is refused unread. */
98
+ export function parseReviewReference(input, { maxBytes = MAX_REVIEW_BYTES } = {}) {
99
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw refuse("invalid-bound", "maxBytes must be a positive safe integer");
100
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
101
+ throw refuse("invalid-reference", "a recovery evidence reference object is required");
102
+ }
103
+ const keys = Object.keys(input).sort();
104
+ if (keys.length !== CLOSED_REFERENCE_FIELDS.length || keys.some((key, index) => key !== CLOSED_REFERENCE_FIELDS[index])) {
105
+ throw refuse("invalid-reference", `reference must contain exactly the closed fields: ${CLOSED_REFERENCE_FIELDS.join(", ")}`);
106
+ }
107
+ if (input.schema !== RECOVERY_EVIDENCE_SCHEMA) throw refuse("invalid-reference", "reference schema does not match the recovery evidence schema");
108
+ if (typeof input.journeyId !== "string" || !input.journeyId || input.journeyId.length > MAX_ID_LENGTH
109
+ || typeof input.entryId !== "string" || !input.entryId || input.entryId.length > MAX_ID_LENGTH) {
110
+ throw refuse("invalid-reference", "reference journeyId and entryId must be bounded non-empty strings");
111
+ }
112
+ if (!["requested", "terminal"].includes(input.phase)) throw refuse("invalid-reference", "reference phase must be 'requested' or 'terminal'");
113
+ if (typeof input.sha256 !== "string" || !SHA256.test(input.sha256)) throw refuse("invalid-reference", "reference sha256 must be lowercase 64-hex");
114
+ if (!Number.isSafeInteger(input.bytes) || input.bytes < 1) throw refuse("invalid-reference", "reference bytes must be a positive safe integer");
115
+ if (input.bytes > maxBytes) throw refuse("reference-too-large", `reference bytes exceed the ${maxBytes}-byte review bound`);
116
+ return Object.freeze({ ...input });
117
+ }
118
+
119
+ function openReadOnly(root) {
120
+ try {
121
+ return { store: openRecoveryEvidenceStore({ root, readOnly: true }) };
122
+ } catch (error) {
123
+ if (error?.code === "ENOENT") return { failure: result("evidence-store-missing", "no recovery evidence store exists at the host-derived owner-private root; nothing was created") };
124
+ if (typeof error?.code === "string" && error.code !== "") return { failure: result(error.code, `the recovery evidence store refused read-only review (${error.code})`) };
125
+ return { failure: result("evidence-store-unavailable", "the recovery evidence store could not be opened read-only") };
126
+ }
127
+ }
128
+
129
+ /** Bounded, read-only listing of closed retrieval references; never bodies. */
130
+ export function listReviewableReferences({ root, limit = REVIEW_LIST_LIMIT } = {}) {
131
+ const opened = openReadOnly(root);
132
+ if (opened.failure) return opened.failure;
133
+ try {
134
+ return { ok: true, schema: RECOVERY_EVIDENCE_REVIEW_SCHEMA, references: listRecoveryEvidenceReferences(opened.store, { limit }) };
135
+ } catch (error) {
136
+ const code = typeof error?.code === "string" && error.code !== "" ? error.code : "invalid-bound";
137
+ return result(code, String(error?.message || error));
138
+ } finally {
139
+ closeRecoveryEvidenceStore(opened.store);
140
+ }
141
+ }
142
+
143
+ /** Retrieve and verify one reference read-only; returns the full entry for owner display only. */
144
+ export function reviewRecoveryEvidence({ root, reference, maxBytes = MAX_REVIEW_BYTES } = {}) {
145
+ let ref;
146
+ try {
147
+ ref = parseReviewReference(reference, { maxBytes });
148
+ } catch (error) {
149
+ return result(error?.code ?? "invalid-reference", String(error?.message || error));
150
+ }
151
+ const opened = openReadOnly(root);
152
+ if (opened.failure) return opened.failure;
153
+ try {
154
+ const verified = readRecoveryEvidence(opened.store, ref);
155
+ return { ok: true, schema: RECOVERY_EVIDENCE_REVIEW_SCHEMA, reference: verified.reference, entry: verified.entry };
156
+ } catch (error) {
157
+ return result(error?.code ?? "evidence-corrupt", String(error?.message || error));
158
+ } finally {
159
+ closeRecoveryEvidenceStore(opened.store);
160
+ }
161
+ }
162
+
163
+ /** Full evidence lines for the owner-only overlay; never sent through notify or any model surface. */
164
+ export function evidenceDisplayLines(reviewed) {
165
+ const { reference, entry } = reviewed;
166
+ return [
167
+ `schema: ${reference.schema}`,
168
+ `journey: ${reference.journeyId}`,
169
+ `entry: ${reference.entryId}`,
170
+ `phase: ${reference.phase}`,
171
+ `sha256: ${reference.sha256}`,
172
+ `bytes: ${reference.bytes} (verified against the store)`,
173
+ "",
174
+ "--- recorded entry (exact owner-private content) ---",
175
+ ...JSON.stringify(entry, null, 2).split("\n"),
176
+ ];
177
+ }
178
+
179
+ const VIEW_LINES = 24;
180
+
181
+ /** Minimal terminal-only overlay component for the interactive TUI. */
182
+ export async function createEvidenceOverlay({ done, lines, tui }) {
183
+ const { visibleWidth, truncateToWidth } = await loadWidthHelpers();
184
+ let offset = 0;
185
+ const maxOffset = () => Math.max(0, lines.length - VIEW_LINES);
186
+ return {
187
+ render(width) {
188
+ const w = Math.max(20, Number(width) || 80);
189
+ const fit = (line) => (visibleWidth(line) <= w ? line : truncateToWidth(line, w));
190
+ const body = lines.slice(offset, offset + VIEW_LINES).map(fit);
191
+ while (body.length < VIEW_LINES) body.push("");
192
+ return [
193
+ fit(`Recovery evidence (owner-only) — ${lines.length} lines; up/down or space scrolls; Enter/Esc closes`),
194
+ "",
195
+ ...body,
196
+ ];
197
+ },
198
+ handleInput(data) {
199
+ const previous = offset;
200
+ if (data === "\x1b[A") offset = Math.max(0, offset - 1);
201
+ else if (data === "\x1b[B") offset = Math.min(maxOffset(), offset + 1);
202
+ else if (data === " ") offset = Math.min(maxOffset(), offset + VIEW_LINES);
203
+ else if (data === "\x1b" || data === "q" || data === "\r" || data === "\n") done(undefined);
204
+ // Only an actual scroll change redraws; unrelated keys request nothing.
205
+ if (offset !== previous && typeof tui?.requestRender === "function") tui.requestRender();
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Registers ONLY the owner-initiated slash command. No model-callable tool is
212
+ * registered here, and no raw-evidence tool may be added later: the full
213
+ * evidence travels exclusively through the native TUI overlay. The trusted
214
+ * root is snapshotted once at registration so a later in-process environment
215
+ * mutation cannot redirect review to a different store mid-session; the
216
+ * snapshot still honors the trusted host context that existed at load time.
217
+ */
218
+ export async function registerRecoveryEvidenceReview(pi, _options = {}) {
219
+ if (typeof pi?.registerCommand !== "function" || REGISTRATIONS.has(pi)) return;
220
+ REGISTRATIONS.add(pi);
221
+ const registeredRoot = resolveReviewEvidenceRoot();
222
+ const notify = (context, message, type) => {
223
+ if (typeof context?.ui?.notify === "function") context.ui.notify(message, type);
224
+ };
225
+ pi.registerCommand(RECOVERY_EVIDENCE_REVIEW_COMMAND, {
226
+ description: "Review recorded recovery evidence (owner-only, interactive TUI; evidence is never shown to the model)",
227
+ argumentHint: "",
228
+ handler: async (args, context) => {
229
+ // The handler always returns undefined: nothing it does may become
230
+ // model-visible content or transcript output.
231
+ if (commandArgumentsPresent(args)) {
232
+ notify(context, "Recovery evidence review accepts no command arguments; the trusted host derives the owner-private root.", "warning");
233
+ return undefined;
234
+ }
235
+ // Fail closed outside the interactive native TUI: RPC forwards dialogs
236
+ // but custom() is undefined there, and JSON/print have no UI at all.
237
+ if (!isNativeTuiContext(context) || typeof context?.ui?.custom !== "function" || typeof context?.ui?.select !== "function") {
238
+ notify(context, "Recovery evidence review requires the interactive native Pi TUI; RPC, JSON, and print modes are refused so evidence can never enter a transcript.", "warning");
239
+ return undefined;
240
+ }
241
+ const root = registeredRoot;
242
+ const listing = listReviewableReferences({ root, limit: REVIEW_LIST_LIMIT });
243
+ if (!listing.ok) {
244
+ notify(context, `Recovery evidence review: ${listing.code} — ${listing.reason}`, listing.code === "evidence-store-missing" ? "info" : "error");
245
+ return undefined;
246
+ }
247
+ if (listing.references.length === 0) {
248
+ notify(context, "No recovery evidence is recorded yet.", "info");
249
+ return undefined;
250
+ }
251
+ const labels = listing.references.map((ref) =>
252
+ `${ref.phase} · ${ref.journeyId} · ${ref.entryId} · ${ref.sha256.slice(0, 12)} · ${ref.bytes} B`);
253
+ const selected = await context.ui.select("Recovery evidence (owner-only review)", labels);
254
+ const selectedIndex = typeof selected === "string" ? labels.indexOf(selected) : -1;
255
+ if (selectedIndex < 0) return undefined;
256
+ const reviewed = reviewRecoveryEvidence({ root, reference: listing.references[selectedIndex] });
257
+ if (!reviewed.ok) {
258
+ notify(context, `Recovery evidence review: ${reviewed.code} — ${reviewed.reason}`, "error");
259
+ return undefined;
260
+ }
261
+ // The only full-evidence surface: a transient terminal overlay that
262
+ // never reaches the session transcript, tool results, or the model.
263
+ // The injected tui handle is captured so scrolling can request renders.
264
+ await context.ui.custom((tui, _theme, _keybindings, done) =>
265
+ createEvidenceOverlay({ done, lines: evidenceDisplayLines(reviewed), tui }));
266
+ return undefined;
267
+ },
268
+ });
269
+ }