@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19

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 (51) hide show
  1. package/dist/core/compact/auto-trigger.js +96 -0
  2. package/dist/core/compact/buffer-rewriter.js +115 -0
  3. package/dist/core/compact/summarizer.js +196 -0
  4. package/dist/core/compact/token-counter.js +108 -0
  5. package/dist/core/denial-tracking/index.js +8 -0
  6. package/dist/core/denial-tracking/state.js +264 -0
  7. package/dist/core/diagnostics/probe-runner.js +93 -0
  8. package/dist/core/diagnostics/probes/api.js +46 -0
  9. package/dist/core/diagnostics/probes/auth.js +86 -0
  10. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  11. package/dist/core/diagnostics/probes/config.js +72 -0
  12. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  13. package/dist/core/diagnostics/probes/disk.js +81 -0
  14. package/dist/core/diagnostics/probes/git.js +65 -0
  15. package/dist/core/diagnostics/probes/mcp.js +75 -0
  16. package/dist/core/diagnostics/probes/node.js +59 -0
  17. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  18. package/dist/core/diagnostics/probes/session.js +74 -0
  19. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  20. package/dist/core/diagnostics/probes/workspace.js +63 -0
  21. package/dist/core/diagnostics/types.js +70 -0
  22. package/dist/core/engine/native-pugi.js +20 -0
  23. package/dist/core/engine/strip-internal-fields.js +124 -0
  24. package/dist/core/engine/tool-bridge.js +251 -49
  25. package/dist/core/file-cache.js +113 -1
  26. package/dist/core/mcp/client.js +66 -6
  27. package/dist/core/mcp/registry.js +24 -2
  28. package/dist/core/permissions/gate.js +187 -0
  29. package/dist/core/permissions/index.js +18 -0
  30. package/dist/core/permissions/mode.js +102 -0
  31. package/dist/core/permissions/state.js +160 -0
  32. package/dist/core/permissions/tool-class.js +93 -0
  33. package/dist/core/repl/session.js +261 -9
  34. package/dist/core/repl/slash-commands.js +67 -4
  35. package/dist/runtime/cli.js +153 -58
  36. package/dist/runtime/commands/compact.js +296 -0
  37. package/dist/runtime/commands/doctor.js +369 -0
  38. package/dist/runtime/commands/mcp.js +290 -3
  39. package/dist/runtime/commands/permissions.js +87 -0
  40. package/dist/runtime/commands/status.js +178 -0
  41. package/dist/runtime/version.js +1 -1
  42. package/dist/tools/agent-tool.js +18 -4
  43. package/dist/tools/ask-user-question.js +213 -0
  44. package/dist/tools/file-tools.js +57 -14
  45. package/dist/tools/registry.js +7 -0
  46. package/dist/tui/ask-user-question-prompt.js +192 -0
  47. package/dist/tui/compact-banner.js +54 -0
  48. package/dist/tui/conversation-pane.js +68 -7
  49. package/dist/tui/doctor-table.js +31 -0
  50. package/dist/tui/status-table.js +7 -0
  51. package/package.json +2 -2
@@ -0,0 +1,124 @@
1
+ /**
2
+ * α7 L3 (2026-05-27) — leak-parity: underscore-prefix internal-fields filter.
3
+ *
4
+ * The convention (observed in the leaked Claude Code BashTool surface and
5
+ * codified in `docs/research/2026-05-27-pugi-gap-analysis-3-repos.md` §1)
6
+ * is that tool-argument fields whose names start with a leading underscore
7
+ * are INTERNAL — populated by the dispatcher at call time (sessionId,
8
+ * tenantId, correlation handles, hook context, ask-modal bridge handles)
9
+ * but never advertised to the model. The model schema MUST omit them so:
10
+ *
11
+ * 1. No token cost — internal context never burns model budget.
12
+ * 2. No fabrication risk — the model cannot hallucinate values for
13
+ * sessionId / tenantId / etc. because the field is invisible.
14
+ * 3. No leak surface — implementation detail stays implementation detail.
15
+ *
16
+ * The dispatcher (see `tool-bridge.ts::buildExecutor`) does NOT strip these
17
+ * fields at call time. It passes the full args record (including any
18
+ * `_internal*` keys an upstream layer injected) straight to the tool
19
+ * handler. Only the schema surface that the engine adapter ships to the
20
+ * model is filtered.
21
+ *
22
+ * This module is intentionally narrow: it accepts a JSON Schema fragment
23
+ * and returns a deep clone with `_`-prefixed keys removed from every
24
+ * `properties` map encountered while walking, and with `required` filtered
25
+ * to drop any references to those keys. It descends into nested object
26
+ * schemas and into the `items` schema of arrays. It is JSON-Schema-version
27
+ * agnostic (works on draft-07, 2019-09, 2020-12 alike) because it only
28
+ * inspects `properties`/`required`/`items` and leaves the rest of the
29
+ * fragment alone.
30
+ *
31
+ * Edge cases handled:
32
+ * - `_` alone (single underscore) is treated as internal and stripped.
33
+ * - Nested object schemas inside `properties` get the same treatment
34
+ * (a sub-property whose name starts with `_` is removed too).
35
+ * - Array `items` are walked. Tuple schemas (`items` as array) are
36
+ * walked element-by-element.
37
+ * - `oneOf`/`anyOf`/`allOf` branches are walked.
38
+ * - Non-object inputs (null, primitives, arrays passed as the root)
39
+ * are returned as-is — defensive no-op, never throws.
40
+ *
41
+ * Contract notes:
42
+ * - Pure function. Input is never mutated.
43
+ * - Output is a deep clone — every nested object/array is freshly
44
+ * allocated so callers can mutate safely.
45
+ * - JSON-only — does not preserve symbols, getters, or class instances
46
+ * because JSON Schema is plain-data by spec.
47
+ */
48
+ const INTERNAL_PREFIX = '_';
49
+ /**
50
+ * Returns true when the field name should be stripped from the model-
51
+ * facing schema. Leading underscore is the contract — single `_` is also
52
+ * stripped (no escape hatch). Empty-string keys (which are technically
53
+ * valid JSON) are left alone so we do not silently drop them.
54
+ */
55
+ export function isInternalFieldName(name) {
56
+ return name.length > 0 && name.startsWith(INTERNAL_PREFIX);
57
+ }
58
+ /**
59
+ * Strip `_`-prefixed properties from a JSON Schema fragment. Recursively
60
+ * walks nested object schemas and array `items`. Returns a deep clone;
61
+ * the input is never mutated.
62
+ *
63
+ * Pass-through behaviour:
64
+ * - Non-object / null / array inputs round-trip unchanged (as deep
65
+ * clones where applicable).
66
+ * - Fragments with no `properties` key are returned as deep clones
67
+ * after walking `items`/`oneOf`/`anyOf`/`allOf`.
68
+ */
69
+ export function stripInternalFields(schema) {
70
+ if (schema === null || typeof schema !== 'object')
71
+ return schema;
72
+ if (Array.isArray(schema)) {
73
+ return schema.map((item) => stripInternalFields(item));
74
+ }
75
+ return walkObject(schema);
76
+ }
77
+ function walkObject(node) {
78
+ const out = {};
79
+ for (const [key, value] of Object.entries(node)) {
80
+ if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
81
+ out[key] = walkProperties(value);
82
+ continue;
83
+ }
84
+ if (key === 'required' && Array.isArray(value)) {
85
+ out[key] = value.filter((entry) => typeof entry === 'string' && !isInternalFieldName(entry));
86
+ continue;
87
+ }
88
+ if (key === 'items') {
89
+ out[key] = stripInternalFields(value);
90
+ continue;
91
+ }
92
+ if (key === 'oneOf' || key === 'anyOf' || key === 'allOf') {
93
+ if (Array.isArray(value)) {
94
+ out[key] = value.map((branch) => stripInternalFields(branch));
95
+ continue;
96
+ }
97
+ }
98
+ // Default: deep-clone any nested objects/arrays so the caller can
99
+ // mutate freely without touching the input. Primitives pass through.
100
+ out[key] = cloneJson(value);
101
+ }
102
+ return out;
103
+ }
104
+ function walkProperties(props) {
105
+ const out = {};
106
+ for (const [propName, propSchema] of Object.entries(props)) {
107
+ if (isInternalFieldName(propName))
108
+ continue;
109
+ out[propName] = stripInternalFields(propSchema);
110
+ }
111
+ return out;
112
+ }
113
+ function cloneJson(value) {
114
+ if (value === null || typeof value !== 'object')
115
+ return value;
116
+ if (Array.isArray(value))
117
+ return value.map((item) => cloneJson(item));
118
+ const out = {};
119
+ for (const [key, val] of Object.entries(value)) {
120
+ out[key] = cloneJson(val);
121
+ }
122
+ return out;
123
+ }
124
+ //# sourceMappingURL=strip-internal-fields.js.map
@@ -1,6 +1,7 @@
1
- import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
1
+ import { editTool, globTool, grepTool, OperatorAbortedError, readTool, StaleReadError, writeTool, } from '../../tools/file-tools.js';
2
2
  import { bashToolSync } from '../../tools/bash.js';
3
3
  import { askUser } from '../../tools/ask-user.js';
4
+ import { askUserQuestionJsonSchema, dispatchAskUserQuestion, } from '../../tools/ask-user-question.js';
4
5
  import { skillInvoke, skillList } from '../../tools/skill-tool.js';
5
6
  import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
6
7
  import { webFetchTool } from '../../tools/web-fetch.js';
@@ -8,6 +9,9 @@ import { webSearchTool } from '../../tools/web-search.js';
8
9
  import { agentTool } from '../../tools/agent-tool.js';
9
10
  import { multiEdit } from '../../tools/multi-edit.js';
10
11
  import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
12
+ import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracking/state.js';
13
+ import { stripInternalFields } from './strip-internal-fields.js';
14
+ import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
11
15
  /**
12
16
  * Tool-bridge: turns the abstract tool registry into:
13
17
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -185,26 +189,18 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
185
189
  },
186
190
  },
187
191
  });
188
- // β1 T2: AskUserQuestion bridge. Returns picked answer in interactive
189
- // mode, `[user_input_required]` envelope otherwise.
192
+ // β1 T2 leak L5 (2026-05-27): structured AskUserQuestion bridge.
193
+ // Schema upgraded to openclaude's multi-choice form: header chip +
194
+ // {label, description} per option. Dispatcher accepts the structured
195
+ // form (preferred) AND the legacy string-array form so existing
196
+ // callers / tests keep working until the next major bump.
197
+ //
198
+ // Interactive TTY → returns the picked label(s).
199
+ // Non-TTY / no bridge → `[user_input_required]` envelope.
190
200
  toolDefs.push({
191
201
  name: 'ask_user_question',
192
- description: 'Surface a 2-4 option modal to the operator. Interactive TTY: returns the chosen label(s). Non-TTY: returns a [user_input_required] envelope.',
193
- parameters: {
194
- type: 'object',
195
- additionalProperties: false,
196
- required: ['question', 'options'],
197
- properties: {
198
- question: { type: 'string', description: 'Short, scannable question. Max 1000 chars.' },
199
- options: {
200
- type: 'array',
201
- items: { type: 'string', maxLength: 200 },
202
- minItems: 2,
203
- maxItems: 4,
204
- },
205
- multiSelect: { type: 'boolean' },
206
- },
207
- },
202
+ description: 'Clarifying multi-choice question to the operator. Use INSTEAD of asking in prose when one parameter is missing. Required: question (?-ended), header (≤12 chars), 2-4 options each with {label, description}. NEVER include "Other" — UI auto-adds. Budget: max 1 per turn.',
203
+ parameters: askUserQuestionJsonSchema,
208
204
  });
209
205
  // β1 T3: Skill tool — discover + invoke locally-installed skills.
210
206
  toolDefs.push({
@@ -332,7 +328,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
332
328
  if (!planMode) {
333
329
  toolDefs.push({
334
330
  name: 'write',
335
- description: 'Create or overwrite a workspace file. Use for new files only — prefer edit for existing files. Workspace-scoped.',
331
+ description: 'Create or overwrite a workspace file. Prefer edit for existing files. ' +
332
+ 'For OVERWRITE of an existing file, you MUST read the file first in this session — ' +
333
+ 'write refuses with STALE_READ if the file changed since your last read, or if you ' +
334
+ 'never read it. New-file creation (path does not exist) skips that gate. Workspace-scoped.',
336
335
  parameters: {
337
336
  type: 'object',
338
337
  additionalProperties: false,
@@ -344,7 +343,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
344
343
  },
345
344
  }, {
346
345
  name: 'edit',
347
- description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. Fails if the file changed since you read it or if oldString is missing/duplicate.',
346
+ description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. ' +
347
+ 'Refuses with STALE_READ if the file was never read this session or the on-disk contents ' +
348
+ 'drifted since the read (mtime+sha gate). Recovery: re-read with the `read` tool, then ' +
349
+ 'retry the edit. Also fails if oldString is missing or duplicate.',
348
350
  parameters: {
349
351
  type: 'object',
350
352
  additionalProperties: false,
@@ -417,7 +419,41 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
417
419
  });
418
420
  }
419
421
  }
420
- return toolDefs;
422
+ // α7 L3 (2026-05-27): leak-parity underscore-prefix filter. Every
423
+ // tool's parameter schema is scrubbed of `_`-prefixed fields before
424
+ // the model ever sees it. Native tool schemas above currently declare
425
+ // no `_*` fields, but MCP tools surfaced through buildMcpToolDefs
426
+ // come from third-party servers whose authors may follow the same
427
+ // convention (an MCP tool can declare `_sessionId` knowing the CLI
428
+ // dispatcher will inject it before forwarding). The dispatcher
429
+ // (buildExecutor below) does NOT strip these from the args record at
430
+ // call time — `_internal*` keys still flow through to tool handlers
431
+ // when an upstream layer populates them.
432
+ return toolDefs.map((tool) => ({
433
+ name: tool.name,
434
+ description: tool.description,
435
+ parameters: stripInternalFields(tool.parameters),
436
+ }));
437
+ }
438
+ /**
439
+ * α7 L11: tolerant args-parse for the denial fingerprint. Unlike
440
+ * `parseArgs` (which throws on malformed JSON so the model sees a
441
+ * parse error), this swallows failures and returns `{}` — the denial
442
+ * tracker needs SOME key even when the raw payload is unparseable,
443
+ * because malformed-args spam is itself a pattern operators want to
444
+ * see in `/permissions denials`.
445
+ */
446
+ function safeParseForTracking(raw) {
447
+ if (!raw || raw.trim() === '')
448
+ return {};
449
+ try {
450
+ return JSON.parse(raw);
451
+ }
452
+ catch {
453
+ // Use the raw string as the fingerprint payload so repeated
454
+ // identical malformed dispatches still cluster.
455
+ return { _rawArgs: raw.slice(0, 512) };
456
+ }
421
457
  }
422
458
  function parseArgs(raw) {
423
459
  if (!raw || raw.trim() === '')
@@ -433,24 +469,70 @@ function parseArgs(raw) {
433
469
  throw new Error(`invalid JSON in tool arguments: ${error.message}`);
434
470
  }
435
471
  }
436
- function requireString(obj, key, aliases = []) {
437
- // 2026-05-27 CEO live smoke: model passed `file: "index.html"` to write tool
438
- // but spec required `path:`. Tool rejected → silent no-op → file не созданы.
439
- // Accept common aliases so models trained на various tool schemas just work.
440
- const tryKeys = [key, ...aliases];
441
- for (const k of tryKeys) {
442
- const v = obj[k];
443
- if (typeof v === 'string')
444
- return v;
445
- }
472
+ /**
473
+ * Strict canonical-only argument coercion (leak P0 L2, 2026-05-27).
474
+ *
475
+ * Reverts the beta.17 alias acceptance (`file` / `filename` / `filepath`
476
+ * / `file_path` → `path`). The alias shim was the wrong direction: it
477
+ * paved over a model-side prompt-drift bug at the runtime layer, weakened
478
+ * the strict JSON-Schema contract one layer up (`additionalProperties:
479
+ * false`), and drifted away from the openclaude reference (research memo
480
+ * §1.1 — `z.strictObject` rejects aliased fields).
481
+ *
482
+ * The compensating change ships in the persona prompts: Mira's system
483
+ * prompt and Hiroshi's persona body now declare canonical parameter
484
+ * names with few-shot wrong/right contrasts so the model learns the
485
+ * grammar upstream of the bridge.
486
+ */
487
+ function requireString(obj, key) {
488
+ const v = obj[key];
489
+ if (typeof v === 'string')
490
+ return v;
446
491
  throw new Error(`tool argument "${key}" must be a string`);
447
492
  }
448
- const PATH_ALIASES = ['file', 'filename', 'filepath', 'file_path'];
449
493
  export function buildExecutor(input) {
450
- const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
494
+ const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
451
495
  const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
452
496
  const workspaceRoot = input.workspaceRoot ?? ctx.root;
453
497
  const planMode = kind === 'plan';
498
+ const denialTracking = input.denialTracking;
499
+ // α7 L11: helper that records a denial (when tracking is wired) and
500
+ // ALWAYS returns an Error whose message includes a compact
501
+ // `<denial-context>` reminder when the same (tool, args) pair has
502
+ // already been refused at least once before in this session.
503
+ //
504
+ // The reminder is appended to the THROWN message — the engine loop
505
+ // appends thrown messages to the transcript as tool-result strings,
506
+ // so the model sees the aggregate the next time it considers a
507
+ // dispatch. Without this every retry would only see the latest
508
+ // single-turn reason and could loop indefinitely.
509
+ //
510
+ // Best-effort: a hash/clone failure inside the tracker MUST NOT
511
+ // mask the original refusal. The catch path falls back to a bare
512
+ // Error with the reason text.
513
+ const recordDenial = (toolName, args, reason) => {
514
+ if (!denialTracking)
515
+ return new Error(reason);
516
+ try {
517
+ const record = denialTracking.recordDenial(toolName, args, reason);
518
+ // Only inject the reminder once the threshold is hit — the very
519
+ // first denial is the model's first chance to learn, no need to
520
+ // shout. From the 2nd repeat onwards the model has demonstrated
521
+ // it is not learning from the single-turn sentinel, so we splice
522
+ // the aggregate context.
523
+ if (record.count >= DENIAL_REMINDER_THRESHOLD) {
524
+ const reminder = buildDenialContext(denialTracking);
525
+ if (reminder.length > 0) {
526
+ return new Error(`${reason}\n\n${reminder}`);
527
+ }
528
+ }
529
+ }
530
+ catch {
531
+ // Tracking is best-effort. Fall through to the bare Error so
532
+ // the refusal still propagates.
533
+ }
534
+ return new Error(reason);
535
+ };
454
536
  return async ({ name, arguments: argsRaw }) => {
455
537
  // β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
456
538
  // validated lazily by the dispatcher (the registry knows which
@@ -458,15 +540,64 @@ export function buildExecutor(input) {
458
540
  // so a bad `mcp__bogus__foo` does not collide with the native
459
541
  // unknown-tool branch.
460
542
  const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
543
+ // α7 L11: parse-or-empty args once up-front so every deny path
544
+ // below can fingerprint the call against the denial tracker. We
545
+ // tolerate parse failure — `{}` keys still produce a stable hash
546
+ // (the model may have sent malformed JSON, but the refusal is
547
+ // semantic, not parse-driven).
548
+ const argsForTracking = safeParseForTracking(argsRaw);
461
549
  if (!isMcpName && !WIRED_TOOLS.has(name)) {
462
- throw new Error(`unknown tool: ${name}`);
550
+ throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
463
551
  }
464
- if (planMode) {
552
+ // Leak L6 — canonical 4-mode permission gate. Routes the dispatch
553
+ // decision BEFORE the legacy plan-mode-only enforcement so the new
554
+ // surface is the source of truth when the caller opted in. Absent
555
+ // `permissionMode` falls through to the legacy plan-mode branch
556
+ // (existing semantics preserved for callsites that have not
557
+ // migrated yet).
558
+ let hooksBypassed = false;
559
+ if (permissionMode) {
560
+ const decision = permissionGate(name, argsRaw, {
561
+ permissionMode,
562
+ ...(permissionAlwaysCache ? { alwaysCache: permissionAlwaysCache } : {}),
563
+ });
564
+ if (decision.decision === 'deny') {
565
+ throw new PermissionDenied(name, getToolClass(name), permissionMode, decision.reason);
566
+ }
567
+ if (decision.decision === 'ask') {
568
+ if (!permissionAsk) {
569
+ // Non-interactive caller (CI / pipes / agent-as-tool) cannot
570
+ // surface a prompt. Collapse to deny so the loop receives a
571
+ // deterministic refusal instead of hanging.
572
+ throw new PermissionDenied(name, decision.toolClass, permissionMode, `Ask mode: no operator prompt available for ${name} (non-interactive caller)`);
573
+ }
574
+ const answer = await permissionAsk({
575
+ toolName: name,
576
+ toolClass: decision.toolClass,
577
+ question: decision.question,
578
+ options: decision.options,
579
+ });
580
+ const verdict = permissionAlwaysCache
581
+ ? applyAskAnswer(permissionAlwaysCache, name, answer)
582
+ : applyAskAnswer({ alwaysAllowed: new Set(), alwaysDenied: new Set() }, name, answer);
583
+ if (verdict.decision === 'deny') {
584
+ throw new PermissionDenied(name, decision.toolClass, permissionMode, verdict.reason);
585
+ }
586
+ // verdict.decision === 'allow' falls through to dispatch.
587
+ }
588
+ else {
589
+ // allow — honour the bypass flag for the hook layer below.
590
+ hooksBypassed = decision.hooksBypassed === true;
591
+ }
592
+ }
593
+ else if (planMode) {
594
+ // Legacy plan-mode enforcement (kind === 'plan') stays in place
595
+ // for callers that have not opted into the canonical gate.
465
596
  // MCP tools are uniformly refused in plan mode (see schema-side
466
597
  // rationale in buildToolsSchema). Native tools split via
467
598
  // READ_ONLY_TOOLS as before.
468
599
  if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
469
- throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
600
+ throw recordDenial(name, argsForTracking, `PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
470
601
  }
471
602
  }
472
603
  // α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
@@ -475,13 +606,18 @@ export function buildExecutor(input) {
475
606
  // by `runEngineLoop` as a terminal-cancel signal so the loop
476
607
  // returns control to the caller rather than retrying the model.
477
608
  if (ctx.cancellation && ctx.cancellation.isAborted) {
478
- throw new Error(`OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
609
+ throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
479
610
  }
480
611
  // Fire PreToolUse hooks. The match grammar takes the tool name and
481
612
  // (when extractable) the target path. Each new tool dispatch starts a
482
613
  // fresh dedup batch so a hook fires once per dispatch, not once per
483
614
  // session.
484
- if (hooks && sessionId) {
615
+ //
616
+ // Leak L6 — bypass mode skips the entire hook layer (PreToolUse +
617
+ // PostToolUse + PostToolUseFailure). The gate's allow decision
618
+ // carries the `hooksBypassed` flag; we honour it here so the
619
+ // executor stays single-pass.
620
+ if (hooks && sessionId && !hooksBypassed) {
485
621
  hooks.resetBatch();
486
622
  const path = extractToolPath(name, argsRaw);
487
623
  const preCtx = {
@@ -501,7 +637,11 @@ export function buildExecutor(input) {
501
637
  const hook = matchingPreHooks[i];
502
638
  const result = preResults[i];
503
639
  if (hook && result && hook.onFailure === 'block' && !result.ok) {
504
- throw new Error(`HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
640
+ // α7 L11: record the PreToolUse hook denial so the model
641
+ // sees the pattern reminder on subsequent turns. Without
642
+ // this the model would re-issue the same refused call and
643
+ // burn a turn each time before noticing the loop.
644
+ throw recordDenial(name, argsForTracking, `HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
505
645
  }
506
646
  }
507
647
  }
@@ -558,7 +698,7 @@ export function buildExecutor(input) {
558
698
  // model spawn a write-capable child and break the read-only
559
699
  // contract.
560
700
  if (planMode) {
561
- throw new Error('PLAN_MODE_REFUSED: agent is not allowed in plan mode');
701
+ throw recordDenial(name, argsForTracking, 'PLAN_MODE_REFUSED: agent is not allowed in plan mode');
562
702
  }
563
703
  return dispatchAgent(args, agentDispatch);
564
704
  }
@@ -566,7 +706,7 @@ export function buildExecutor(input) {
566
706
  };
567
707
  try {
568
708
  const result = await dispatch();
569
- if (hooks && sessionId) {
709
+ if (hooks && sessionId && !hooksBypassed) {
570
710
  const path = extractToolPath(name, argsRaw);
571
711
  await hooks.fire({
572
712
  sessionId,
@@ -579,6 +719,27 @@ export function buildExecutor(input) {
579
719
  return result;
580
720
  }
581
721
  catch (error) {
722
+ // Leak L6 — surface the PermissionDenied sentinel as a model-
723
+ // readable message instead of leaking the raw Error type. The
724
+ // string format is stable so the engine adapter / spec layer
725
+ // can pattern-match against it.
726
+ if (error instanceof PermissionDenied) {
727
+ // PostToolUseFailure fires for visibility unless bypass is on.
728
+ if (hooks && sessionId && !hooksBypassed) {
729
+ await hooks.fire({
730
+ sessionId,
731
+ event: 'PostToolUseFailure',
732
+ tool: name,
733
+ payload: {
734
+ tool: name,
735
+ arguments: argsRaw,
736
+ ok: false,
737
+ error: error.toModelMessage(),
738
+ },
739
+ });
740
+ }
741
+ throw new Error(error.toModelMessage());
742
+ }
582
743
  // α6.9: re-shape OperatorAbortedError throws from the
583
744
  // file-tools layer into the same `OPERATOR_ABORTED:` sentinel
584
745
  // the upstream cancellation gate uses so `runEngineLoop` sees
@@ -586,7 +747,7 @@ export function buildExecutor(input) {
586
747
  // the abort landed pre-dispatch or mid-tool (e.g. inside the
587
748
  // grep file-loop).
588
749
  if (error instanceof OperatorAbortedError) {
589
- if (hooks && sessionId) {
750
+ if (hooks && sessionId && !hooksBypassed) {
590
751
  const path = extractToolPath(name, argsRaw);
591
752
  await hooks.fire({
592
753
  sessionId,
@@ -601,9 +762,35 @@ export function buildExecutor(input) {
601
762
  },
602
763
  });
603
764
  }
604
- throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
765
+ throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} aborted mid-execution.`);
766
+ }
767
+ // Leak L1 (2026-05-27): re-shape StaleReadError into a
768
+ // deterministic STALE_READ:<reason> sentinel so the model's
769
+ // retry policy can pattern-match on a stable prefix instead of
770
+ // free-form prose. The model is expected to re-read the file and
771
+ // retry the edit — the message points it at exactly that recovery
772
+ // path. PostToolUseFailure hooks observe the typed error so an
773
+ // operator can build a "warn me when stale edits keep happening"
774
+ // hook (likely a concurrency / multi-agent indicator).
775
+ if (error instanceof StaleReadError) {
776
+ if (hooks && sessionId && !hooksBypassed) {
777
+ const path = extractToolPath(name, argsRaw);
778
+ await hooks.fire({
779
+ sessionId,
780
+ event: 'PostToolUseFailure',
781
+ tool: name,
782
+ path,
783
+ payload: {
784
+ tool: name,
785
+ arguments: argsRaw,
786
+ ok: false,
787
+ error: `STALE_READ: ${error.reason} on ${error.path}`,
788
+ },
789
+ });
790
+ }
791
+ throw recordDenial(name, argsForTracking, `STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
605
792
  }
606
- if (hooks && sessionId) {
793
+ if (hooks && sessionId && !hooksBypassed) {
607
794
  const path = extractToolPath(name, argsRaw);
608
795
  await hooks.fire({
609
796
  sessionId,
@@ -643,7 +830,7 @@ function extractToolPath(name, argsRaw) {
643
830
  function dispatchTool(name, args, ctx) {
644
831
  switch (name) {
645
832
  case 'read': {
646
- const { path } = { path: requireString(args, 'path', PATH_ALIASES) };
833
+ const { path } = { path: requireString(args, 'path') };
647
834
  const content = readTool(ctx, path);
648
835
  // Cap the content surfaced back to the model so a 10MB file
649
836
  // does not blow the context window. The model sees the head
@@ -656,7 +843,7 @@ function dispatchTool(name, args, ctx) {
656
843
  }
657
844
  case 'write': {
658
845
  const wargs = {
659
- path: requireString(args, 'path', PATH_ALIASES),
846
+ path: requireString(args, 'path'),
660
847
  content: requireString(args, 'content'),
661
848
  };
662
849
  writeTool(ctx, wargs.path, wargs.content);
@@ -664,7 +851,7 @@ function dispatchTool(name, args, ctx) {
664
851
  }
665
852
  case 'edit': {
666
853
  const eargs = {
667
- path: requireString(args, 'path', PATH_ALIASES),
854
+ path: requireString(args, 'path'),
668
855
  oldString: requireString(args, 'oldString'),
669
856
  newString: requireString(args, 'newString'),
670
857
  };
@@ -762,11 +949,26 @@ function dispatchTaskTool(name, args, opts) {
762
949
  }
763
950
  }
764
951
  async function dispatchAskUser(args, opts) {
765
- const question = requireString(args, 'question');
766
952
  const rawOptions = args['options'];
767
953
  if (!Array.isArray(rawOptions)) {
768
954
  throw new Error('ask_user_question: options must be an array');
769
955
  }
956
+ // Leak L5 (2026-05-27): detect structured vs legacy form. Structured
957
+ // entries are objects with {label, description}; legacy entries are
958
+ // plain strings. The structured path validates via Zod and emits the
959
+ // [ask_user_question:answered|cancelled|timeout] envelope. The legacy
960
+ // path stays for back-compat with the existing β1 T2 tests + the
961
+ // <pugi-ask> prompt envelope (which still feeds string options).
962
+ const looksStructured = rawOptions.length > 0
963
+ && typeof rawOptions[0] === 'object'
964
+ && rawOptions[0] !== null
965
+ && !Array.isArray(rawOptions[0]);
966
+ if (looksStructured) {
967
+ const result = await dispatchAskUserQuestion({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, args);
968
+ return result.envelope;
969
+ }
970
+ // Legacy string-array form.
971
+ const question = requireString(args, 'question');
770
972
  const options = rawOptions.map((o, i) => {
771
973
  if (typeof o !== 'string') {
772
974
  throw new Error(`ask_user_question: options[${i}] must be a string`);