@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.
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +100 -37
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/repl/session.js +34 -0
- package/dist/core/repl/slash-commands.js +9 -0
- package/dist/runtime/cli.js +24 -58
- package/dist/runtime/commands/doctor.js +357 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- 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
|
|
189
|
-
//
|
|
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: '
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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'
|
|
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'
|
|
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'
|
|
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`);
|