@evoclock/pi-agentic-driver 0.6.0 → 0.8.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-agentic-driver v0.6.0
1
+ # pi-agentic-driver v0.8.0
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
@@ -7,7 +7,7 @@
7
7
  <p align="center">
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
9
9
  <a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
10
- <img src="https://img.shields.io/badge/version-0.6.0-blue?style=flat" alt="Version 0.6.0"/>
10
+ <img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat" alt="Version 0.8.0"/>
11
11
  <img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
12
12
  <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
13
13
  <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
@@ -49,6 +49,8 @@ proofs for agentic workflows.
49
49
  | `agentic_aidr` | A remedy for AI;DR. Reviews writing for clarity, simplicity, brevity, and humanity. | shipped |
50
50
  | `agentic_linux_microvm_cutover` | Runs one job in a throwaway QEMU/KVM virtual machine on a Linux host, with a severity-tiered killswitch that stops escape attempts. | user-enabled, native confirmation |
51
51
  | `agentic_worker_dispatch` | Runs controlled worker journeys and observes worker liveness. | shipped |
52
+ | `agentic_kanban_board` | Shows the workspace task board: lanes, flags, priorities, dependencies, and which cards can run. | shipped |
53
+ | `agentic_kanban_board_write` | Adds cards to the board through the trusted writer, which records who authorized the work. | shipped |
52
54
 
53
55
  **Status: active development and testing.** Each extension ships only after
54
56
  it passes fixture-based acceptance, native tests, live-session checks, and
@@ -187,6 +189,34 @@ silently, and return results as untrusted evidence.
187
189
  </details>
188
190
 
189
191
 
192
+ <details>
193
+ <summary><strong>task board, planned work you can see</strong> <em>(released, 0.8.0)</em></summary>
194
+
195
+ Keep planned work on a Kanban board. You read and edit the board in
196
+ Obsidian or in the Vogelkop Task Board pane (our upcoming Scientific and
197
+ Research Workbench). Agents read the same board and add cards to it.
198
+
199
+ The board appears only when a `board.md` or `TASKS.md` file exists in the
200
+ workspace. With no board file, the tools do not appear and nothing changes.
201
+
202
+ When an agent adds a card, a trusted writer does the bookkeeping: it
203
+ assigns the card ID, computes the integrity hash, checks the card, and
204
+ records who authorized the work. The record comes from you: your
205
+ instruction, or your approval of an agent's proposal. A card without that
206
+ record cannot be dispatched. A hand-edited card cannot fake the record, and
207
+ a tampered card refuses to run.
208
+
209
+ Only you complete a card. Move it to done in your board UI, or tell an
210
+ agent to close it. An agent report that says the work is finished is
211
+ evidence for your review. It is never the completion itself.
212
+
213
+ The card format is shared. The same board renders in Obsidian, with
214
+ optional Tasks-plugin emoji, and in the Vogelkop Task Board pane. The
215
+ machine-readable fields are the single source of truth, so there is no
216
+ second copy to keep in sync.
217
+
218
+ </details>
219
+
190
220
  - **project status and state review.** Read-only projections of workspace Git
191
221
  state, formal records, and task-state health.
192
222
  - **role-lane routing and warm sessions.** Separate lanes handle
@@ -345,18 +375,72 @@ Small models on your own desk are worth taking seriously as reviewers.
345
375
 
346
376
  </details>
347
377
 
378
+ <details>
379
+ <summary><strong>autonomous journeys, overnight operation</strong> <em>(released, 0.7.0)</em></summary>
380
+
381
+ Dispatch a worker journey that runs end to end while you are away. The
382
+ journey proceeds through steps without pausing for confirmation, replaces
383
+ agents that get stuck, and records everything in one report you read when
384
+ you come back.
385
+
386
+ Autonomy is entered by your words alone. Say "run these overnight end to
387
+ end" or "don't wait for me" and the journey runs in autonomous mode. Say
388
+ "do these two things" and it runs in the normal mode, pausing for you
389
+ between steps. The mode is recorded in the report either way.
390
+
391
+ The journeys run work you dispatch in natural language: work that is real
392
+ and authorized but not yet formally placed on a kanban board. A task board
393
+ integration is planned, and once it ships, planned journeys will execute
394
+ board-managed work with the same containment and safety guarantees.
395
+
396
+ ### The cast
397
+
398
+ You name the agents for the journey, and that list becomes the standing
399
+ permission. If the implementer gets stuck at 3am, a replacement spawns
400
+ automatically: same role, same model, both in the list you authorized.
401
+ If a task needs an agent you didn't name, the attempt is recorded and that
402
+ thread stops. Nothing outside your list can appear while you sleep.
403
+
404
+ The cast is frozen at dispatch and never grows. It can only shrink in
405
+ practice (roles finishing their work), never widen.
406
+
407
+ ### When agents get stuck
408
+
409
+ A stuck agent doesn't block the journey. The journey detects the stall,
410
+ spawns a replacement from the cast, and the replacement starts with a
411
+ mandatory gap analysis: read the task spec, inspect the repository, consult
412
+ the journey history, and state what remains before resuming. The replacement
413
+ must show that the remaining work is smaller than what its predecessor left.
414
+ If it can't, the journey stops that role and continues with the others.
415
+
416
+ ### The morning report
417
+
418
+ One block tells you what happened: the terminal state, each step's outcome,
419
+ every replacement with its gap analysis and progress judgment, and any
420
+ denials. Every claim is tied to a receipt or a report excerpt. No prose
421
+ narrative to reconstruct; you read the report and know exactly where things
422
+ stand.
423
+
424
+ </details>
425
+
426
+ <details>
427
+ <summary><strong>attended-authority guard</strong> <em>(released, 0.7.0)</em></summary>
428
+
429
+ The safety net between an agent and your shell. When a model tries to
430
+ delete, overwrite, or push, the guard stops it and asks you. Safe commands
431
+ pass through untouched. If you deny, you get a clear reason and the session
432
+ continues, and the agent does not retry behind your back. In headless runs
433
+ where no human can confirm, destructive commands are refused rather than
434
+ silently allowed.
435
+
436
+ </details>
437
+
348
438
  **Under development in this theme:**
349
439
 
350
440
  - **native macOS container proof.** The native Apple Container runtime has
351
441
  passed a fixed local isolation qualification: read-only repository mount,
352
442
  no network, automatic removal. The native Pi adapter is not yet part of the
353
443
  released package.
354
- - **attended-authority guard.** The safety net between an agent and your
355
- shell. When a model tries to delete, overwrite, or push, the guard stops it
356
- and asks you. Safe commands pass through untouched. If you deny, you get a
357
- clear reason and the session continues, and the agent does not retry behind
358
- your back. In headless runs where no human can confirm, destructive
359
- commands are refused rather than silently allowed.
360
444
 
361
445
  ## Session continuity
362
446
 
@@ -436,7 +520,7 @@ pi install npm:@evoclock/pi-agentic-driver
436
520
  Or from Git at a pinned tag:
437
521
 
438
522
  ```sh
439
- pi install git:github.com/evoclock/pi-agentic-driver@v0.6.0
523
+ pi install git:github.com/evoclock/pi-agentic-driver@v0.8.0
440
524
  ```
441
525
 
442
526
  Released extensions load standalone; neither requires the other.
@@ -450,7 +534,7 @@ extensions you want with the object form in your Pi settings:
450
534
  {
451
535
  "packages": [
452
536
  {
453
- "source": "npm:@evoclock/pi-agentic-driver@0.6.0",
537
+ "source": "npm:@evoclock/pi-agentic-driver@0.8.0",
454
538
  "extensions": [
455
539
  "extensions/aidr.ts",
456
540
  "extensions/code-phage.js"
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ export default async function attendedAuthorityGuard(pi) {
5
+ const module = await import(new URL("../scripts/enforcement/attended_authority_guard.js", import.meta.url).href);
6
+ return module.registerAttendedAuthorityGuard(pi);
7
+ }
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // BOARD-1 provider extension (§5 reversibility): the board surface registers
5
+ // only when a real board file is observed in the workspace. No board file,
6
+ // no behavior change and no new tool.
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ // Documented board locations (design §2): workspace-level Obsidian Kanban
12
+ // Markdown, or vogelkop's simpler TASKS.md shape.
13
+ const BOARD_FILENAMES = ["board.md", "TASKS.md"];
14
+
15
+ export function resolveBoardPath(cwd) {
16
+ if (typeof cwd !== "string" || cwd === "") return null;
17
+ for (const name of BOARD_FILENAMES) {
18
+ const candidate = join(cwd, name);
19
+ if (existsSync(candidate)) return candidate;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export default async function taskBoardPi(pi) {
25
+ const module = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
26
+ const boardPath = resolveBoardPath(pi?.ctx?.cwd);
27
+ if (boardPath === null) {
28
+ return { registered: [], observation: { present: false, boardPath: null } };
29
+ }
30
+ return module.registerKanbanBoardTools(pi, { boardPath });
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -34,9 +34,12 @@
34
34
  "governance"
35
35
  ],
36
36
  "files": [
37
+ "extensions/attended-authority-guard.ts",
38
+ "scripts/enforcement/attended_authority_guard.js",
37
39
  "extensions/code-phage.js",
38
40
  "extensions/herdr-communication.ts",
39
41
  "extensions/herdr-dispatch.ts",
42
+ "extensions/task-board.ts",
40
43
  "lib/adapters/diff-scope.mjs",
41
44
  "lib/adapters/evidence.mjs",
42
45
  "lib/adapters/narrative.mjs",
@@ -62,7 +65,8 @@
62
65
  "PROVENANCE.md",
63
66
  "extensions/aidr.ts",
64
67
  "scripts/aidr_writing_review.js",
65
- "templates/AGENTS.md"
68
+ "templates/AGENTS.md",
69
+ "scripts/enforcement/task_board_core_pi.js"
66
70
  ],
67
71
  "pi": {
68
72
  "extensions": [
@@ -40,6 +40,18 @@ const STE_PHRASAL_GUIDANCE = Object.freeze({
40
40
  "look at": "examine",
41
41
  "set up": "configure or install",
42
42
  });
43
+ // Project terminology rules (established in session guidance, encoded here so
44
+ // AI;DR enforces them advisorially):
45
+ // - "limited" for numeric limits; "controlled" or "authorised" for authority
46
+ // and behavior; "approved" or "authorised" for tasks.
47
+ // - "fundamental" or "crucial" instead of the metaphor "load-bearing".
48
+ // - Avoid "bounded" in prose entirely; use "limited" for numeric limits.
49
+ // (The boundedText identifier is a code name, not prose, and is flagged
50
+ // separately for a future rename.)
51
+ const TERMINOLOGY_GUIDANCE = Object.freeze({
52
+ bounded: "use limited for numeric limits; avoid bounded in prose",
53
+ "load-bearing": "use fundamental or crucial",
54
+ });
43
55
  const STE_MODAL_GUIDANCE = Object.freeze({
44
56
  should: "use must for a requirement, or state the recommendation directly",
45
57
  may: "use can for ability or must have permission language when needed",
@@ -154,6 +166,13 @@ function steFindings(prose, sentences, documentType) {
154
166
  message: "State requirement, ability, permission, or condition precisely; do not leave the modal meaning implicit.",
155
167
  examples: modalExamples,
156
168
  });
169
+ const terminologyExamples = steTermExamples(prose, TERMINOLOGY_GUIDANCE);
170
+ if (terminologyExamples.length) findings.push({
171
+ rule: "STE-T1",
172
+ kind: "ste-project-terminology",
173
+ message: "Use the project terminology: limited for numeric limits, controlled or authorised for authority and behavior, approved or authorised for tasks, fundamental or crucial instead of load-bearing, and avoid bounded in prose.",
174
+ examples: terminologyExamples,
175
+ });
157
176
  if (/\band\/or\b/i.test(prose)) findings.push({
158
177
  rule: "STE-C1",
159
178
  kind: "ste-conjunction",
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Attended-authority destructive-command guard for Pi.
5
+ //
6
+ // Ported observable behavior (private reference: scripts/enforcement/
7
+ // pi_attended_authority.js + cc_destructive_command_guard_hook.py):
8
+ // - Intercept tool calls before execution via the `tool_call` event.
9
+ // - Destructive shell/Git operations require native confirmation.
10
+ // - Safe reads/builds/tests pass through untouched.
11
+ // - An explicit denial returns a clear reason and the session continues;
12
+ // no hidden retry and no automatic re-ask.
13
+ // - Headless / no-confirmation contexts refuse destructive operations
14
+ // fail-closed.
15
+ // - No bypasses, no model-supplied authority, no silent escalation. The
16
+ // model cannot mark a call safe; classification is structural only.
17
+
18
+ import { isNativeTuiContext } from "./native_tui_context.js";
19
+
20
+ export const GUARD_SCHEMA = "pi-agentic-driver.attended-authority.v1";
21
+
22
+ // Tool names that can mutate the workspace.
23
+ const MUTATING_TOOLS = new Set(["bash", "write", "edit"]);
24
+
25
+ // Read-only / build / test commands that always pass through.
26
+ const SAFE_COMMAND_PREFIXES = [
27
+ "cat", "ls", "head", "tail", "grep", "rg", "find", "sed -n", "awk",
28
+ "wc", "file", "stat", "which", "echo", "pwd", "date", "env",
29
+ "node --check", "node --test", "npm test", "npm run",
30
+ "python3 -m pytest", "pytest", "make", "cmake",
31
+ "git status", "git log", "git diff", "git show", "git branch",
32
+ "git remote", "git rev-parse", "git blame", "git describe",
33
+ "git config --get", "git ls-files",
34
+ ];
35
+
36
+ // Arbitrary-code interpreter execution: any code payload can perform a
37
+ // destructive operation, so these forms are classified fail-closed.
38
+ const DESTRUCTIVE_SHELL_PATTERNS = [
39
+ { pattern: /\brm\b[^|;&]*\s(-[a-z]*[rf][a-z]*\s|--recursive|--force)/, kind: "recursive or forced file deletion" },
40
+ { pattern: /\bsudo\s+rm\b/, kind: "privileged file deletion" },
41
+ { pattern: /\brmdir\b|\bunlink\b/, kind: "file or directory deletion" },
42
+ { pattern: /\bmkfs\b|\bshred\b|\bdd\b\s+if=/, kind: "irreversible disk or file operation" },
43
+ { pattern: /\btruncate\s+-s\s*0\b/, kind: "file truncation" },
44
+ { pattern: /\bkill\b\s+-9\b|\bpkill\b/, kind: "forced process termination" },
45
+ { pattern: /\bchmod\s+-R\b|\bchown\s+-R\b/, kind: "recursive permission change" },
46
+ { pattern: /\bnode\s+(-e|--eval)\b/, kind: "arbitrary JavaScript execution via node -e" },
47
+ { pattern: /\bpython3?\s+-c\b/, kind: "arbitrary Python execution via python -c" },
48
+ { pattern: /\bnpx\b/, kind: "arbitrary package execution via npx" },
49
+ ];
50
+
51
+ // Scope note: plain `rm file` (no -r/-f) is intentionally NOT classified
52
+ // destructive here; the guard targets recursive/forced deletion and
53
+ // irreversible operations. Any `git push`, forced or not, is treated
54
+ // conservatively as destructive because pushes publish history to remotes.
55
+
56
+ // Destructive Git operations.
57
+ const DESTRUCTIVE_GIT_PATTERNS = [
58
+ { pattern: /\bgit\s+push\b[^|;&]*(--force|-f\b)/, kind: "forced Git push" },
59
+ { pattern: /\bgit\s+push\b/, kind: "Git push" },
60
+ { pattern: /\bgit\s+reset\s+--hard\b/, kind: "hard Git reset" },
61
+ { pattern: /\bgit\s+reset\b/, kind: "Git reset" },
62
+ { pattern: /\bgit\s+clean\b/, kind: "Git clean" },
63
+ { pattern: /\bgit\s+checkout\s+--\s/, kind: "Git working-tree discard" },
64
+ { pattern: /\bgit\s+restore\b/, kind: "Git working-tree discard" },
65
+ { pattern: /\bgit\s+stash\s+(drop|clear|pop)\b/, kind: "Git stash mutation" },
66
+ { pattern: /\bgit\s+stash\b/, kind: "Git stash mutation" },
67
+ { pattern: /\bgit\s+rebase\b/, kind: "Git history rewrite" },
68
+ { pattern: /\bgit\s+filter-(branch|repo)\b/, kind: "Git history rewrite" },
69
+ { pattern: /\bgit\s+commit\b[^|;&]*--amend\b/, kind: "Git history rewrite" },
70
+ { pattern: /\bgit\s+branch\s+(-D|-d)\b/, kind: "protected branch deletion" },
71
+ { pattern: /\bgit\s+tag\s+-d\b/, kind: "protected tag deletion" },
72
+ { pattern: /\bgit\s+cherry-pick\b|\bgit\s+revert\b/, kind: "Git history mutation" },
73
+ ];
74
+
75
+ // Paths whose deletion or overwrite is always treated as destructive.
76
+ const PROTECTED_PATHS = [
77
+ ".env", ".ssh", ".gnupg", "node_modules", ".git",
78
+ "package-lock.json", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock",
79
+ ];
80
+
81
+ // Quote-aware tokenization: normalizes shell quoting so `rm '-rf'`,
82
+ // `git "push"`, and split/embedded quoting such as `r'm' '-rf'` still
83
+ // expose their destructive tokens to the patterns. Escapes are also
84
+ // stripped (`\rm` -> `rm`) so an escaped destructive token cannot dodge
85
+ // classification. Normalization is only used for the destructive
86
+ // patterns, never to widen the safe-prefix list.
87
+ function unquoteTokens(text) {
88
+ return String(text ?? "")
89
+ .split(/[\s\n]+/)
90
+ .map((token) => token
91
+ .replace(/^"/, "").replace(/"$/, "")
92
+ .replace(/^'/, "").replace(/'$/, "")
93
+ .replace(/["']/g, "")
94
+ .replace(/\\(.)/g, "$1"))
95
+ .join(" ");
96
+ }
97
+
98
+ export function classifyBashCommand(command) {
99
+ const text = String(command ?? "").trim();
100
+ if (!text) return { destructive: false };
101
+ // A safe prefix wins only when the whole command is that single simple
102
+ // command (no chaining, redirection, or command substitution), so a
103
+ // destructive payload cannot hide behind a safe-looking prefix.
104
+ const compound = /[;&|>`]|\$\(|\n/.test(text);
105
+ const redirection = /(^|\s)(>{1,2}|<)/.test(text);
106
+ // Structural destructive verdicts win over safe-looking prefixes, so a
107
+ // destructive form such as `git branch -D` cannot hide behind a safe
108
+ // prefix such as `git branch`.
109
+ for (const { pattern, kind } of DESTRUCTIVE_SHELL_PATTERNS) {
110
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
111
+ }
112
+ for (const { pattern, kind } of DESTRUCTIVE_GIT_PATTERNS) {
113
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
114
+ }
115
+ if (!compound && !redirection) {
116
+ for (const prefix of SAFE_COMMAND_PREFIXES) {
117
+ if (text === prefix || text.startsWith(`${prefix} `) || text.startsWith(`${prefix}\t`)) {
118
+ return { destructive: false };
119
+ }
120
+ }
121
+ }
122
+ // Output redirection overwrites an existing file in place.
123
+ const redirect = text.match(/(?:^|\s)>{1,2}\s*([^\s;&|]+)\s*$/);
124
+ if (redirect) {
125
+ const target = redirect[1].replace(/^["']|["']$/g, "");
126
+ return {
127
+ destructive: true,
128
+ kind: `file overwrite via redirection to ${target}`,
129
+ };
130
+ }
131
+ // Unrecognized non-safe commands are not destructive by default; the
132
+ // guard only intercepts structurally destructive operations.
133
+ return { destructive: false };
134
+ }
135
+
136
+ function touchesProtectedPath(path) {
137
+ const normalized = String(path ?? "").replace(/\\/g, "/");
138
+ return PROTECTED_PATHS.some((entry) =>
139
+ normalized === entry
140
+ || normalized.endsWith(`/${entry}`)
141
+ || normalized.includes(`/${entry}/`)
142
+ || normalized.startsWith(`./${entry}`),
143
+ );
144
+ }
145
+
146
+ // Existing-file overwrite detection is injected so tests stay filesystem-free.
147
+ export function classifyWriteCall(toolName, input, existsSync) {
148
+ if (toolName !== "write" && toolName !== "edit") {
149
+ return { destructive: false };
150
+ }
151
+ const path = input?.path ?? input?.file_path ?? "";
152
+ if (touchesProtectedPath(path)) {
153
+ return { destructive: true, kind: `protected path write to ${path}` };
154
+ }
155
+ if (toolName === "write" && typeof existsSync === "function" && path && existsSync(path)) {
156
+ return { destructive: true, kind: `overwrite of existing file ${path}` };
157
+ }
158
+ return { destructive: false };
159
+ }
160
+
161
+ export function confirmationBody(toolName, input, kind) {
162
+ return [
163
+ `Attended-authority guard: ${kind}.`,
164
+ `Tool: ${toolName}`,
165
+ toolName === "bash" ? `Command: ${input?.command}` : `Path: ${input?.path ?? input?.file_path ?? ""}`,
166
+ "",
167
+ "Allow this destructive operation?",
168
+ ].join("\n");
169
+ }
170
+
171
+ export function denialReason(toolName, kind, context) {
172
+ if (!isNativeTuiContext(context)) {
173
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) refused fail-closed: no native confirmation surface in this headless context`;
174
+ }
175
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) denied by user; the session continues and this call is not retried`;
176
+ }
177
+
178
+ /**
179
+ * Core guard for one tool call. Returns undefined to allow the call, or
180
+ * `{ block: true, reason }` to refuse it. Confirmation is native-only:
181
+ * `ctx.ui.confirm` in an interactive TUI. Headless contexts never confirm.
182
+ * Model-supplied fields on the event can never grant authority.
183
+ */
184
+ export async function guardToolCall(event, ctx, options = {}) {
185
+ const toolName = String(event?.toolName ?? "").toLowerCase();
186
+ if (!MUTATING_TOOLS.has(toolName)) return undefined;
187
+ const input = event?.input && typeof event.input === "object" ? event.input : {};
188
+
189
+ const bash = toolName === "bash"
190
+ ? classifyBashCommand(input.command)
191
+ : { destructive: false };
192
+ const write = toolName === "bash"
193
+ ? { destructive: false }
194
+ : classifyWriteCall(toolName, input, options.existsSync);
195
+ const verdict = bash.destructive ? bash : write;
196
+ if (!verdict.destructive) return undefined;
197
+
198
+ const context = options.context ?? ctx;
199
+ if (!isNativeTuiContext(context)) {
200
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
201
+ }
202
+ const confirmed = await context.ui.confirm(
203
+ "Destructive operation",
204
+ confirmationBody(toolName, input, verdict.kind),
205
+ );
206
+ if (confirmed === true) return undefined;
207
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
208
+ }
209
+
210
+ /**
211
+ * Register the guard on a Pi host instance. Duplicate registration on the
212
+ * same instance is ignored.
213
+ */
214
+ const REGISTERED = new WeakSet();
215
+
216
+ export function registerAttendedAuthorityGuard(pi, options = {}) {
217
+ if (!pi || REGISTERED.has(pi)) return undefined;
218
+ REGISTERED.add(pi);
219
+ pi.on?.("tool_call", (event, ctx) => guardToolCall(event, ctx, options));
220
+ return { registered: true, schema: GUARD_SCHEMA };
221
+ }
222
+
223
+ export default guardToolCall;