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

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/dist/core/diagnostics/probe-runner.js +93 -0
  2. package/dist/core/diagnostics/probes/api.js +46 -0
  3. package/dist/core/diagnostics/probes/auth.js +86 -0
  4. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  5. package/dist/core/diagnostics/probes/config.js +72 -0
  6. package/dist/core/diagnostics/probes/disk.js +81 -0
  7. package/dist/core/diagnostics/probes/git.js +65 -0
  8. package/dist/core/diagnostics/probes/mcp.js +75 -0
  9. package/dist/core/diagnostics/probes/node.js +59 -0
  10. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  11. package/dist/core/diagnostics/probes/session.js +74 -0
  12. package/dist/core/diagnostics/probes/workspace.js +63 -0
  13. package/dist/core/diagnostics/types.js +70 -0
  14. package/dist/core/engine/strip-internal-fields.js +124 -0
  15. package/dist/core/engine/tool-bridge.js +100 -37
  16. package/dist/core/file-cache.js +113 -1
  17. package/dist/core/mcp/client.js +66 -6
  18. package/dist/core/mcp/registry.js +24 -2
  19. package/dist/core/repl/session.js +34 -0
  20. package/dist/core/repl/slash-commands.js +9 -0
  21. package/dist/runtime/cli.js +24 -58
  22. package/dist/runtime/commands/doctor.js +357 -0
  23. package/dist/runtime/commands/mcp.js +290 -3
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/tools/agent-tool.js +18 -4
  26. package/dist/tools/ask-user-question.js +213 -0
  27. package/dist/tools/file-tools.js +57 -14
  28. package/dist/tools/registry.js +7 -0
  29. package/dist/tui/ask-user-question-prompt.js +192 -0
  30. package/dist/tui/conversation-pane.js +68 -7
  31. package/dist/tui/doctor-table.js +31 -0
  32. package/package.json +2 -2
@@ -0,0 +1,59 @@
1
+ /**
2
+ * NODE probe — verifies the running Node major version meets the
3
+ * `engines.node` floor declared in @pugi/cli's package.json.
4
+ *
5
+ * We bake the floor in as a constant rather than reading package.json
6
+ * at runtime because the published .tgz strips the file from a location
7
+ * the compiled bundle can reach (`--resolveJsonModule` is off for the
8
+ * CLI build). The lockstep is enforced by
9
+ * `scripts/check-version-lockstep.sh` in the publish pipeline.
10
+ */
11
+ /**
12
+ * Minimum supported Node major. Mirrors `engines.node` in
13
+ * apps/pugi-cli/package.json (`">=22.5.0"`).
14
+ */
15
+ export const MIN_NODE_MAJOR = 22;
16
+ export const MIN_NODE_MINOR = 5;
17
+ /**
18
+ * Parse a Node version string of the form `v<major>.<minor>.<patch>`.
19
+ * Returns null when the input doesn't match — the caller treats null
20
+ * as an error condition.
21
+ */
22
+ export function parseNodeVersion(version) {
23
+ const match = /^v(\d+)\.(\d+)\./.exec(version);
24
+ if (!match)
25
+ return null;
26
+ const major = Number(match[1]);
27
+ const minor = Number(match[2]);
28
+ if (!Number.isFinite(major) || !Number.isFinite(minor))
29
+ return null;
30
+ return { major, minor };
31
+ }
32
+ export function probeNode(input) {
33
+ const parsed = parseNodeVersion(input.version);
34
+ if (!parsed) {
35
+ return {
36
+ name: 'NODE',
37
+ status: 'error',
38
+ detail: `Unparseable Node version "${input.version}"`,
39
+ remediation: `Install Node >= ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.0 (current binary returns a non-semver version string)`,
40
+ };
41
+ }
42
+ const { major, minor } = parsed;
43
+ const passes = major > MIN_NODE_MAJOR ||
44
+ (major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR);
45
+ if (passes) {
46
+ return {
47
+ name: 'NODE',
48
+ status: 'ok',
49
+ detail: `${input.version} (>= ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.0 required)`,
50
+ };
51
+ }
52
+ return {
53
+ name: 'NODE',
54
+ status: 'error',
55
+ detail: `${input.version} below floor >= ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.0`,
56
+ remediation: `Upgrade Node: \`nvm install ${MIN_NODE_MAJOR}\` or download from nodejs.org`,
57
+ };
58
+ }
59
+ //# sourceMappingURL=node.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * PNPM probe — verifies pnpm is on PATH and reports its version. The
3
+ * customer-facing CLI doesn't strictly require pnpm at runtime
4
+ * (`@pugi/cli` is published as a regular npm package), but a missing
5
+ * pnpm means `pugi code` cannot run `pnpm test` / `pnpm typecheck`
6
+ * gates the agent loop emits. Surfacing this early prevents the
7
+ * agent from issuing a meaningless `command not found: pnpm` error
8
+ * three turns into a session.
9
+ */
10
+ export function probePnpm(deps) {
11
+ try {
12
+ const version = deps.resolveVersion();
13
+ if (!version) {
14
+ return {
15
+ name: 'PNPM',
16
+ status: 'warn',
17
+ detail: 'pnpm reported empty version string',
18
+ };
19
+ }
20
+ return {
21
+ name: 'PNPM',
22
+ status: 'ok',
23
+ detail: `pnpm ${version}`,
24
+ };
25
+ }
26
+ catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ return {
29
+ name: 'PNPM',
30
+ status: 'warn',
31
+ detail: 'pnpm not on PATH — agent quality gates will be skipped',
32
+ remediation: `Install pnpm: \`npm i -g pnpm\` (error: ${message})`,
33
+ };
34
+ }
35
+ }
36
+ //# sourceMappingURL=pnpm.js.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * SESSION probe — reports the active session id + age when the doctor
3
+ * runs from inside the REPL OR finds a recent NDJSON session log in
4
+ * the workspace.
5
+ *
6
+ * The CLI command path has no live session context (each `pugi <cmd>`
7
+ * invocation is a fresh process), so we read `.pugi/events.jsonl` if
8
+ * present and report the most-recent event's age + total line count.
9
+ * Inside the REPL we pass an explicit `sessionId` so the probe
10
+ * surfaces the live state without re-reading disk.
11
+ *
12
+ * Absence of `.pugi/events.jsonl` is `skipped`, not an error — the
13
+ * operator may simply be running `pugi doctor` in a workspace that
14
+ * has not yet seen a dispatch.
15
+ */
16
+ export function probeSession(ctx, fs, deps) {
17
+ if (deps.liveSessionId) {
18
+ return {
19
+ name: 'SESSION',
20
+ status: 'ok',
21
+ detail: `session=${deps.liveSessionId} (live, REPL active)`,
22
+ };
23
+ }
24
+ const eventsPath = `${ctx.cwd}/.pugi/events.jsonl`;
25
+ if (!fs.existsSync(eventsPath)) {
26
+ return {
27
+ name: 'SESSION',
28
+ status: 'skipped',
29
+ detail: 'No prior dispatch logged in this workspace',
30
+ };
31
+ }
32
+ let stats;
33
+ try {
34
+ stats = fs.statSync(eventsPath);
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ return {
39
+ name: 'SESSION',
40
+ status: 'warn',
41
+ detail: `.pugi/events.jsonl present but unreadable`,
42
+ remediation: `Inspect: ${message}`,
43
+ };
44
+ }
45
+ const ageMs = Math.max(0, deps.now() - stats.mtimeMs);
46
+ const ageLabel = formatAge(ageMs);
47
+ // Counting lines is cheap on a small NDJSON file; a "huge" Pugi
48
+ // session is single-digit MB. We avoid loading binary blobs by
49
+ // simply walking the buffer count.
50
+ let lineCount = 0;
51
+ try {
52
+ const raw = fs.readFileSync(eventsPath, 'utf8');
53
+ lineCount = raw.split('\n').filter((line) => line.trim().length > 0).length;
54
+ }
55
+ catch {
56
+ lineCount = -1;
57
+ }
58
+ const linePart = lineCount >= 0 ? `, ${lineCount} event(s)` : '';
59
+ return {
60
+ name: 'SESSION',
61
+ status: 'ok',
62
+ detail: `last event ${ageLabel} ago${linePart}`,
63
+ };
64
+ }
65
+ export function formatAge(ms) {
66
+ if (ms < 60_000)
67
+ return `${Math.round(ms / 1000)}s`;
68
+ if (ms < 3_600_000)
69
+ return `${Math.round(ms / 60_000)}m`;
70
+ if (ms < 86_400_000)
71
+ return `${Math.round(ms / 3_600_000)}h`;
72
+ return `${Math.round(ms / 86_400_000)}d`;
73
+ }
74
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * WORKSPACE probe — verifies `.pugi/` exists, is writable, and is not
3
+ * littered with stale lock files. Optional NDJSON session log presence
4
+ * is reported as additional context but never the basis for a verdict
5
+ * change (it is created on first dispatch, not at init time).
6
+ *
7
+ * The probe owns its fs surface so the spec can run in a tmp sandbox.
8
+ */
9
+ export function probeWorkspace(ctx, fs) {
10
+ const pugiDir = `${ctx.cwd}/.pugi`;
11
+ if (!fs.existsSync(pugiDir)) {
12
+ return {
13
+ name: 'WORKSPACE',
14
+ status: 'warn',
15
+ detail: `.pugi/ not initialised in ${ctx.cwd}`,
16
+ remediation: 'Run `pugi init` to scaffold the workspace',
17
+ };
18
+ }
19
+ let isDir = false;
20
+ try {
21
+ isDir = fs.statSync(pugiDir).isDirectory();
22
+ }
23
+ catch {
24
+ return {
25
+ name: 'WORKSPACE',
26
+ status: 'error',
27
+ detail: `.pugi/ stat failed in ${ctx.cwd}`,
28
+ remediation: 'Re-create the directory: `rm -rf .pugi && pugi init`',
29
+ };
30
+ }
31
+ if (!isDir) {
32
+ return {
33
+ name: 'WORKSPACE',
34
+ status: 'error',
35
+ detail: `${pugiDir} exists but is not a directory`,
36
+ remediation: 'Remove the file at .pugi and run `pugi init`',
37
+ };
38
+ }
39
+ try {
40
+ fs.accessSync(pugiDir, fs.W_OK);
41
+ }
42
+ catch {
43
+ return {
44
+ name: 'WORKSPACE',
45
+ status: 'error',
46
+ detail: `.pugi/ is not writable for the current user`,
47
+ remediation: `chown / chmod the directory so the current user can write it`,
48
+ };
49
+ }
50
+ // Best-effort: report session log presence as detail context. Absence
51
+ // is normal (events.jsonl is created lazily) so it never moves the
52
+ // verdict.
53
+ const eventLogPresent = fs.existsSync(`${pugiDir}/events.jsonl`);
54
+ const detail = eventLogPresent
55
+ ? `.pugi/ writable, events.jsonl present`
56
+ : `.pugi/ writable (events.jsonl created on first dispatch)`;
57
+ return {
58
+ name: 'WORKSPACE',
59
+ status: 'ok',
60
+ detail,
61
+ };
62
+ }
63
+ //# sourceMappingURL=workspace.js.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Leak L17 — `pugi doctor` diagnostics types.
3
+ *
4
+ * The doctor command probes the local environment + remote API +
5
+ * workspace state and produces a structured health report. Each probe
6
+ * runs independently; one probe's failure NEVER cascades to another.
7
+ *
8
+ * Status semantics:
9
+ * - `ok` : probe verified the expected state.
10
+ * - `warn` : non-blocking signal (stale CLI, low-but-not-empty disk,
11
+ * missing optional config, etc.). Overall verdict still
12
+ * passes the gate.
13
+ * - `error` : a real problem the operator must fix before Pugi will
14
+ * work end-to-end (auth missing, API unreachable, .pugi/
15
+ * unwritable, Node version below floor, disk full).
16
+ * - `skipped` : prerequisite for the probe is absent (e.g. MCP probe
17
+ * when no mcp.json exists). Does NOT count against the
18
+ * overall verdict.
19
+ *
20
+ * Layered design: this module owns NO I/O. Individual probe files own
21
+ * their I/O surface. The runner orchestrates them in parallel with a
22
+ * timeout + fail-isolation wrapper. The doctor command formats the
23
+ * results for human + JSON consumers.
24
+ */
25
+ /**
26
+ * Helper for the runner to compute the overall verdict from a probe
27
+ * set without leaking the algorithm into the doctor command. Any error
28
+ * → 'error'; any warn (no errors) → 'warning'; otherwise 'healthy'.
29
+ * Skipped probes do NOT influence the verdict.
30
+ */
31
+ export function computeOverall(probes) {
32
+ let hasError = false;
33
+ let hasWarn = false;
34
+ for (const probe of probes) {
35
+ if (probe.status === 'error')
36
+ hasError = true;
37
+ else if (probe.status === 'warn')
38
+ hasWarn = true;
39
+ }
40
+ if (hasError)
41
+ return 'error';
42
+ if (hasWarn)
43
+ return 'warning';
44
+ return 'healthy';
45
+ }
46
+ /**
47
+ * Compute the per-status counts in a single pass so renderers do not
48
+ * have to re-iterate. Surfaces in both the trailer line and the JSON
49
+ * envelope so downstream consumers can render a one-line summary
50
+ * without re-walking the probe array.
51
+ */
52
+ export function countProbes(probes) {
53
+ const counts = { ok: 0, warn: 0, error: 0, skipped: 0 };
54
+ for (const probe of probes)
55
+ counts[probe.status] += 1;
56
+ return counts;
57
+ }
58
+ /**
59
+ * Exit-code map. Exposed for both the CLI handler and the spec so the
60
+ * contract stays in one place.
61
+ * 0 — healthy OR warnings only.
62
+ * 1 — internal crash (unhandled throw in the runner itself).
63
+ * 2 — at least one probe reported `error`.
64
+ */
65
+ export function exitCodeFor(overall) {
66
+ if (overall === 'error')
67
+ return 2;
68
+ return 0;
69
+ }
70
+ //# sourceMappingURL=types.js.map
@@ -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,7 @@ 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 { stripInternalFields } from './strip-internal-fields.js';
11
13
  /**
12
14
  * Tool-bridge: turns the abstract tool registry into:
13
15
  * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
@@ -185,26 +187,18 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
185
187
  },
186
188
  },
187
189
  });
188
- // β1 T2: AskUserQuestion bridge. Returns picked answer in interactive
189
- // mode, `[user_input_required]` envelope otherwise.
190
+ // β1 T2 leak L5 (2026-05-27): structured AskUserQuestion bridge.
191
+ // Schema upgraded to openclaude's multi-choice form: header chip +
192
+ // {label, description} per option. Dispatcher accepts the structured
193
+ // form (preferred) AND the legacy string-array form so existing
194
+ // callers / tests keep working until the next major bump.
195
+ //
196
+ // Interactive TTY → returns the picked label(s).
197
+ // Non-TTY / no bridge → `[user_input_required]` envelope.
190
198
  toolDefs.push({
191
199
  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
- },
200
+ 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.',
201
+ parameters: askUserQuestionJsonSchema,
208
202
  });
209
203
  // β1 T3: Skill tool — discover + invoke locally-installed skills.
210
204
  toolDefs.push({
@@ -332,7 +326,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
332
326
  if (!planMode) {
333
327
  toolDefs.push({
334
328
  name: 'write',
335
- description: 'Create or overwrite a workspace file. Use for new files only — prefer edit for existing files. Workspace-scoped.',
329
+ description: 'Create or overwrite a workspace file. Prefer edit for existing files. ' +
330
+ 'For OVERWRITE of an existing file, you MUST read the file first in this session — ' +
331
+ 'write refuses with STALE_READ if the file changed since your last read, or if you ' +
332
+ 'never read it. New-file creation (path does not exist) skips that gate. Workspace-scoped.',
336
333
  parameters: {
337
334
  type: 'object',
338
335
  additionalProperties: false,
@@ -344,7 +341,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
344
341
  },
345
342
  }, {
346
343
  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.',
344
+ description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. ' +
345
+ 'Refuses with STALE_READ if the file was never read this session or the on-disk contents ' +
346
+ 'drifted since the read (mtime+sha gate). Recovery: re-read with the `read` tool, then ' +
347
+ 'retry the edit. Also fails if oldString is missing or duplicate.',
348
348
  parameters: {
349
349
  type: 'object',
350
350
  additionalProperties: false,
@@ -417,7 +417,21 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
417
417
  });
418
418
  }
419
419
  }
420
- return toolDefs;
420
+ // α7 L3 (2026-05-27): leak-parity underscore-prefix filter. Every
421
+ // tool's parameter schema is scrubbed of `_`-prefixed fields before
422
+ // the model ever sees it. Native tool schemas above currently declare
423
+ // no `_*` fields, but MCP tools surfaced through buildMcpToolDefs
424
+ // come from third-party servers whose authors may follow the same
425
+ // convention (an MCP tool can declare `_sessionId` knowing the CLI
426
+ // dispatcher will inject it before forwarding). The dispatcher
427
+ // (buildExecutor below) does NOT strip these from the args record at
428
+ // call time — `_internal*` keys still flow through to tool handlers
429
+ // when an upstream layer populates them.
430
+ return toolDefs.map((tool) => ({
431
+ name: tool.name,
432
+ description: tool.description,
433
+ parameters: stripInternalFields(tool.parameters),
434
+ }));
421
435
  }
422
436
  function parseArgs(raw) {
423
437
  if (!raw || raw.trim() === '')
@@ -433,19 +447,27 @@ function parseArgs(raw) {
433
447
  throw new Error(`invalid JSON in tool arguments: ${error.message}`);
434
448
  }
435
449
  }
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
- }
450
+ /**
451
+ * Strict canonical-only argument coercion (leak P0 L2, 2026-05-27).
452
+ *
453
+ * Reverts the beta.17 alias acceptance (`file` / `filename` / `filepath`
454
+ * / `file_path` → `path`). The alias shim was the wrong direction: it
455
+ * paved over a model-side prompt-drift bug at the runtime layer, weakened
456
+ * the strict JSON-Schema contract one layer up (`additionalProperties:
457
+ * false`), and drifted away from the openclaude reference (research memo
458
+ * §1.1 — `z.strictObject` rejects aliased fields).
459
+ *
460
+ * The compensating change ships in the persona prompts: Mira's system
461
+ * prompt and Hiroshi's persona body now declare canonical parameter
462
+ * names with few-shot wrong/right contrasts so the model learns the
463
+ * grammar upstream of the bridge.
464
+ */
465
+ function requireString(obj, key) {
466
+ const v = obj[key];
467
+ if (typeof v === 'string')
468
+ return v;
446
469
  throw new Error(`tool argument "${key}" must be a string`);
447
470
  }
448
- const PATH_ALIASES = ['file', 'filename', 'filepath', 'file_path'];
449
471
  export function buildExecutor(input) {
450
472
  const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
451
473
  const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
@@ -603,6 +625,32 @@ export function buildExecutor(input) {
603
625
  }
604
626
  throw new Error(`OPERATOR_ABORTED: ${name} aborted mid-execution.`);
605
627
  }
628
+ // Leak L1 (2026-05-27): re-shape StaleReadError into a
629
+ // deterministic STALE_READ:<reason> sentinel so the model's
630
+ // retry policy can pattern-match on a stable prefix instead of
631
+ // free-form prose. The model is expected to re-read the file and
632
+ // retry the edit — the message points it at exactly that recovery
633
+ // path. PostToolUseFailure hooks observe the typed error so an
634
+ // operator can build a "warn me when stale edits keep happening"
635
+ // hook (likely a concurrency / multi-agent indicator).
636
+ if (error instanceof StaleReadError) {
637
+ if (hooks && sessionId) {
638
+ const path = extractToolPath(name, argsRaw);
639
+ await hooks.fire({
640
+ sessionId,
641
+ event: 'PostToolUseFailure',
642
+ tool: name,
643
+ path,
644
+ payload: {
645
+ tool: name,
646
+ arguments: argsRaw,
647
+ ok: false,
648
+ error: `STALE_READ: ${error.reason} on ${error.path}`,
649
+ },
650
+ });
651
+ }
652
+ throw new Error(`STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
653
+ }
606
654
  if (hooks && sessionId) {
607
655
  const path = extractToolPath(name, argsRaw);
608
656
  await hooks.fire({
@@ -643,7 +691,7 @@ function extractToolPath(name, argsRaw) {
643
691
  function dispatchTool(name, args, ctx) {
644
692
  switch (name) {
645
693
  case 'read': {
646
- const { path } = { path: requireString(args, 'path', PATH_ALIASES) };
694
+ const { path } = { path: requireString(args, 'path') };
647
695
  const content = readTool(ctx, path);
648
696
  // Cap the content surfaced back to the model so a 10MB file
649
697
  // does not blow the context window. The model sees the head
@@ -656,7 +704,7 @@ function dispatchTool(name, args, ctx) {
656
704
  }
657
705
  case 'write': {
658
706
  const wargs = {
659
- path: requireString(args, 'path', PATH_ALIASES),
707
+ path: requireString(args, 'path'),
660
708
  content: requireString(args, 'content'),
661
709
  };
662
710
  writeTool(ctx, wargs.path, wargs.content);
@@ -664,7 +712,7 @@ function dispatchTool(name, args, ctx) {
664
712
  }
665
713
  case 'edit': {
666
714
  const eargs = {
667
- path: requireString(args, 'path', PATH_ALIASES),
715
+ path: requireString(args, 'path'),
668
716
  oldString: requireString(args, 'oldString'),
669
717
  newString: requireString(args, 'newString'),
670
718
  };
@@ -762,11 +810,26 @@ function dispatchTaskTool(name, args, opts) {
762
810
  }
763
811
  }
764
812
  async function dispatchAskUser(args, opts) {
765
- const question = requireString(args, 'question');
766
813
  const rawOptions = args['options'];
767
814
  if (!Array.isArray(rawOptions)) {
768
815
  throw new Error('ask_user_question: options must be an array');
769
816
  }
817
+ // Leak L5 (2026-05-27): detect structured vs legacy form. Structured
818
+ // entries are objects with {label, description}; legacy entries are
819
+ // plain strings. The structured path validates via Zod and emits the
820
+ // [ask_user_question:answered|cancelled|timeout] envelope. The legacy
821
+ // path stays for back-compat with the existing β1 T2 tests + the
822
+ // <pugi-ask> prompt envelope (which still feeds string options).
823
+ const looksStructured = rawOptions.length > 0
824
+ && typeof rawOptions[0] === 'object'
825
+ && rawOptions[0] !== null
826
+ && !Array.isArray(rawOptions[0]);
827
+ if (looksStructured) {
828
+ const result = await dispatchAskUserQuestion({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, args);
829
+ return result.envelope;
830
+ }
831
+ // Legacy string-array form.
832
+ const question = requireString(args, 'question');
770
833
  const options = rawOptions.map((o, i) => {
771
834
  if (typeof o !== 'string') {
772
835
  throw new Error(`ask_user_question: options[${i}] must be a string`);