@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.14
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/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +99 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +859 -269
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/conversation-pane.js +1 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +105 -15
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +10 -4
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify hook — β1b Pl10 (2026-05-26).
|
|
3
|
+
*
|
|
4
|
+
* After the edit-dispatcher writes a multi-file change to the
|
|
5
|
+
* workspace, this hook fires three lightweight checks against the
|
|
6
|
+
* post-state and reports each result back to the engine loop as a
|
|
7
|
+
* status event:
|
|
8
|
+
*
|
|
9
|
+
* 1. tsc — if `tsconfig.json` is present in the workspace root, run
|
|
10
|
+
* `tsc --noEmit` to catch compile-time breakage. Pass/fail tracked
|
|
11
|
+
* per file is overkill at this stage; we surface the exit code +
|
|
12
|
+
* first ~40 lines of stderr.
|
|
13
|
+
* 2. tests — if package.json has a `test` script AND a test runner
|
|
14
|
+
* is available (jest / vitest / node --test), run `pnpm test
|
|
15
|
+
* --bail` (or `npm test --bail`). Same exit-code + tail of output
|
|
16
|
+
* contract.
|
|
17
|
+
* 3. URL probes — extract every `https?://...` literal from the diff
|
|
18
|
+
* (README + code) and HEAD-probe each. A response < 400 counts as
|
|
19
|
+
* live; >=400 surfaces as a warning. Capped at 8 unique URLs per
|
|
20
|
+
* hook to avoid spending the budget on doc rot.
|
|
21
|
+
*
|
|
22
|
+
* Retry contract (β1b r1 rescope): the hook itself is stateless and
|
|
23
|
+
* runs ONCE per invocation, returning a structured report. The
|
|
24
|
+
* "feedback → model → re-edit" retry orchestrator does NOT exist yet
|
|
25
|
+
* in the engine loop — it was promised as part of β1b Pl10 but never
|
|
26
|
+
* shipped because the engine refactor that hosts it is bigger than
|
|
27
|
+
* the verify-hook itself can absorb. Retry orchestration is deferred
|
|
28
|
+
* to β6 plan-mode integration where the loop already needs a
|
|
29
|
+
* model→hook feedback channel for plan replay. Today the operator
|
|
30
|
+
* re-runs `pugi code` to re-drive verification after a fail. The
|
|
31
|
+
* stateless hook ships unchanged so the β6 driver can wrap it.
|
|
32
|
+
*
|
|
33
|
+
* Why HEAD and not GET for URL probes:
|
|
34
|
+
* - HEAD avoids the body fetch; cheaper + faster.
|
|
35
|
+
* - SSRF guard from `web-fetch.ts::validateHostnameForFetch` runs
|
|
36
|
+
* before every probe so private IPs / localhost cannot ride.
|
|
37
|
+
* - Some servers reject HEAD (rare but real); on 4xx-from-HEAD we
|
|
38
|
+
* do NOT escalate to GET — that would burn the budget. We surface
|
|
39
|
+
* a `head_rejected` warning so the operator decides.
|
|
40
|
+
*
|
|
41
|
+
* Skip cases (return early with `skipped: true` on the relevant
|
|
42
|
+
* check):
|
|
43
|
+
* - tsc: no `tsconfig.json` at workspace root.
|
|
44
|
+
* - tests: no `package.json` OR no `test` script.
|
|
45
|
+
* - urls: no `https?://...` literals in the diff.
|
|
46
|
+
*
|
|
47
|
+
* Best-effort: every failure mode degrades to a structured report; the
|
|
48
|
+
* hook itself NEVER throws. The engine loop decides whether to
|
|
49
|
+
* surface as a hard fail vs a model-correctable warning.
|
|
50
|
+
*
|
|
51
|
+
* Brand voice: ASCII only, no banned words.
|
|
52
|
+
*/
|
|
53
|
+
import { spawnSync } from 'node:child_process';
|
|
54
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
55
|
+
import { resolve } from 'node:path';
|
|
56
|
+
import { validateHostnameForFetch } from '../../tools/web-fetch.js';
|
|
57
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
58
|
+
const DEFAULT_MAX_URL_PROBES = 8;
|
|
59
|
+
const URL_LITERAL_RE = /(https?:\/\/[^\s"'<>()`\\\]]+)/g;
|
|
60
|
+
/**
|
|
61
|
+
* Drive one verify pass. Synchronous tsc + test child processes,
|
|
62
|
+
* concurrent URL probes (up to `maxUrlProbes`). Returns once every
|
|
63
|
+
* check has completed (no streaming events — the caller wraps the
|
|
64
|
+
* report into its own status event format).
|
|
65
|
+
*/
|
|
66
|
+
export async function runVerifyHook(input) {
|
|
67
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
68
|
+
const runProc = input.runProc ?? defaultRunProc;
|
|
69
|
+
return {
|
|
70
|
+
tsc: runTscCheck(input.workspaceRoot, runProc, timeoutMs),
|
|
71
|
+
tests: runTestsCheck(input.workspaceRoot, runProc, timeoutMs),
|
|
72
|
+
urls: await runUrlChecks(input),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/* ----------------------- tsc check ---------------------- */
|
|
76
|
+
function runTscCheck(workspaceRoot, runProc, timeoutMs) {
|
|
77
|
+
const tsconfig = resolve(workspaceRoot, 'tsconfig.json');
|
|
78
|
+
if (!existsSync(tsconfig)) {
|
|
79
|
+
return { ok: true, skipped: true, reason: 'no_tsconfig' };
|
|
80
|
+
}
|
|
81
|
+
// Prefer `pnpm exec tsc` because pnpm-aware monorepos hoist tsc into
|
|
82
|
+
// `node_modules/.bin`; fallback to bare `tsc` for global installs.
|
|
83
|
+
// We try `pnpm exec tsc` first only if a `pnpm-lock.yaml` is at the
|
|
84
|
+
// workspace root; otherwise we go straight to `tsc`.
|
|
85
|
+
const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
|
|
86
|
+
const cmd = pnpmLock ? 'pnpm' : 'tsc';
|
|
87
|
+
const args = pnpmLock ? ['exec', 'tsc', '--noEmit'] : ['--noEmit'];
|
|
88
|
+
const result = runProc(cmd, args, workspaceRoot, timeoutMs);
|
|
89
|
+
if (result.exitCode === 0)
|
|
90
|
+
return { ok: true };
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: `tsc_exit_${result.exitCode}`,
|
|
94
|
+
detail: tailOutput(result.stdout, result.stderr, 40),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/* ----------------------- tests check ---------------------- */
|
|
98
|
+
function runTestsCheck(workspaceRoot, runProc, timeoutMs) {
|
|
99
|
+
const pkgPath = resolve(workspaceRoot, 'package.json');
|
|
100
|
+
if (!existsSync(pkgPath)) {
|
|
101
|
+
return { ok: true, skipped: true, reason: 'no_package_json' };
|
|
102
|
+
}
|
|
103
|
+
let pkg;
|
|
104
|
+
try {
|
|
105
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { ok: true, skipped: true, reason: 'malformed_package_json' };
|
|
109
|
+
}
|
|
110
|
+
if (!pkg.scripts || typeof pkg.scripts.test !== 'string') {
|
|
111
|
+
return { ok: true, skipped: true, reason: 'no_test_script' };
|
|
112
|
+
}
|
|
113
|
+
const pnpmLock = existsSync(resolve(workspaceRoot, 'pnpm-lock.yaml'));
|
|
114
|
+
// Prefer pnpm test --bail; some test runners reject the flag (node
|
|
115
|
+
// --test ignores it), so we surface non-zero exits clearly but do
|
|
116
|
+
// not retry without --bail.
|
|
117
|
+
// Both pnpm + npm accept `<cmd> test -- --bail`; the runner-side
|
|
118
|
+
// flag-forwarding contract is identical, so the ternary collapses to
|
|
119
|
+
// a single args literal.
|
|
120
|
+
const cmd = pnpmLock ? 'pnpm' : 'npm';
|
|
121
|
+
const args = ['test', '--', '--bail'];
|
|
122
|
+
const result = runProc(cmd, args, workspaceRoot, timeoutMs);
|
|
123
|
+
if (result.exitCode === 0)
|
|
124
|
+
return { ok: true };
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: `tests_exit_${result.exitCode}`,
|
|
128
|
+
detail: tailOutput(result.stdout, result.stderr, 60),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/* ----------------------- url probes ---------------------- */
|
|
132
|
+
async function runUrlChecks(input) {
|
|
133
|
+
const diffText = input.diffText ?? '';
|
|
134
|
+
if (diffText.length === 0) {
|
|
135
|
+
return { ok: true, skipped: true, reason: 'no_diff_text' };
|
|
136
|
+
}
|
|
137
|
+
const urls = extractUrls(diffText);
|
|
138
|
+
if (urls.length === 0) {
|
|
139
|
+
return { ok: true, skipped: true, reason: 'no_urls' };
|
|
140
|
+
}
|
|
141
|
+
const cap = input.maxUrlProbes ?? DEFAULT_MAX_URL_PROBES;
|
|
142
|
+
const probed = urls.slice(0, cap);
|
|
143
|
+
const probeFn = input.probeFn ?? defaultProbeFn;
|
|
144
|
+
const failures = [];
|
|
145
|
+
for (const url of probed) {
|
|
146
|
+
// Hostname SSRF guard — never probe a localhost / private IP even
|
|
147
|
+
// when a literal in the diff points there.
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(url);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
failures.push({ url, error: 'invalid_url' });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
157
|
+
failures.push({ url, error: `unsupported_scheme_${parsed.protocol}` });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
161
|
+
const guard = await validateHostnameForFetch(hostname);
|
|
162
|
+
if (guard) {
|
|
163
|
+
failures.push({ url, error: `ssrf_refused: ${guard}` });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const r = await probeFn(url);
|
|
168
|
+
if ('error' in r) {
|
|
169
|
+
failures.push({ url, error: r.error });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (r.status >= 400) {
|
|
173
|
+
failures.push({ url, error: `http_${r.status}` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
failures.push({
|
|
178
|
+
url,
|
|
179
|
+
error: error instanceof Error ? error.message : String(error),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (failures.length === 0) {
|
|
184
|
+
return { ok: true, probedCount: probed.length };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
reason: `url_probe_failed`,
|
|
189
|
+
probedCount: probed.length,
|
|
190
|
+
failures,
|
|
191
|
+
detail: failures.map((f) => `${f.url} → ${f.error}`).join('; '),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Extract unique http(s) URLs from a diff/text blob. Order preserved
|
|
196
|
+
* (first-seen) so the cap picks the earliest-mentioned ones, which
|
|
197
|
+
* intuitively matches the operator's expectation.
|
|
198
|
+
*/
|
|
199
|
+
export function extractUrls(text) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const out = [];
|
|
202
|
+
let match;
|
|
203
|
+
// Reset the regex's lastIndex; URL_LITERAL_RE is module-scoped and
|
|
204
|
+
// /g means stateful exec calls.
|
|
205
|
+
URL_LITERAL_RE.lastIndex = 0;
|
|
206
|
+
while ((match = URL_LITERAL_RE.exec(text)) !== null) {
|
|
207
|
+
const raw = match[1];
|
|
208
|
+
if (!raw)
|
|
209
|
+
continue;
|
|
210
|
+
// Strip a trailing punctuation that the regex tolerates inside
|
|
211
|
+
// the match (sentences in Markdown often end `... https://x).`).
|
|
212
|
+
// We also strip `!` and `?` so prose like "see https://x!" lands
|
|
213
|
+
// as `https://x`.
|
|
214
|
+
const cleaned = raw.replace(/[.,;:!?)\]>]+$/, '');
|
|
215
|
+
if (cleaned.length === 0)
|
|
216
|
+
continue;
|
|
217
|
+
if (seen.has(cleaned))
|
|
218
|
+
continue;
|
|
219
|
+
seen.add(cleaned);
|
|
220
|
+
out.push(cleaned);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/* ----------------------- defaults ---------------------- */
|
|
225
|
+
function defaultRunProc(cmd, args, cwd, timeoutMs) {
|
|
226
|
+
const result = spawnSync(cmd, [...args], {
|
|
227
|
+
cwd,
|
|
228
|
+
encoding: 'utf8',
|
|
229
|
+
timeout: timeoutMs,
|
|
230
|
+
// Inherit a minimal env — every check is read-only against the
|
|
231
|
+
// workspace and we do not want to leak PUGI_API_KEY into a
|
|
232
|
+
// sub-process accidentally.
|
|
233
|
+
env: { ...process.env, PUGI_API_KEY: undefined, PUGI_LOGIN_TOKEN: undefined },
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
exitCode: typeof result.status === 'number' ? result.status : -1,
|
|
237
|
+
stdout: result.stdout ?? '',
|
|
238
|
+
stderr: result.stderr ?? '',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function defaultProbeFn(url) {
|
|
242
|
+
// Lazy-import undici so the verify-hook module stays cheap when
|
|
243
|
+
// url probes are skipped.
|
|
244
|
+
const { request } = await import('undici');
|
|
245
|
+
try {
|
|
246
|
+
const response = await request(url, {
|
|
247
|
+
method: 'HEAD',
|
|
248
|
+
bodyTimeout: 5_000,
|
|
249
|
+
headersTimeout: 5_000,
|
|
250
|
+
});
|
|
251
|
+
// Drain so the connection releases promptly.
|
|
252
|
+
try {
|
|
253
|
+
await response.body.dump();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* swallow */
|
|
257
|
+
}
|
|
258
|
+
return { status: response.statusCode };
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function tailOutput(stdout, stderr, maxLines) {
|
|
265
|
+
const merged = `${stdout}\n${stderr}`.trim();
|
|
266
|
+
if (merged.length === 0)
|
|
267
|
+
return '';
|
|
268
|
+
const lines = merged.split('\n');
|
|
269
|
+
if (lines.length <= maxLines)
|
|
270
|
+
return merged;
|
|
271
|
+
return `... (${lines.length - maxLines} earlier lines elided)\n${lines.slice(-maxLines).join('\n')}`;
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=verify-hook.js.map
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// PR-CLI-SERVER-VERSION-HANDSHAKE (#225). The interceptor stamps the
|
|
2
|
+
// outbound X-Pugi-Cli-Version header, inspects the inbound recommended/
|
|
3
|
+
// server-version headers, and throws PugiCliUpgradeRequiredError on a
|
|
4
|
+
// 426 server response. The top-level catch in `runtime/cli.ts` /
|
|
5
|
+
// `index.ts` renders the upgrade message and exits 1.
|
|
6
|
+
import { assertNotUpgradeRequired, injectClientVersionHeader, inspectVersionResponse, } from '../transport/version-interceptor.js';
|
|
7
|
+
import { PUGI_CLI_VERSION } from '../../runtime/version.js';
|
|
1
8
|
/**
|
|
2
9
|
* Anvil-backed engine loop client.
|
|
3
10
|
*
|
|
@@ -54,13 +61,17 @@ export class AnvilEngineLoopClient {
|
|
|
54
61
|
options.signal.addEventListener('abort', onAbort);
|
|
55
62
|
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
56
63
|
try {
|
|
64
|
+
// PR-CLI-SERVER-VERSION-HANDSHAKE (#225). Stamp the outbound
|
|
65
|
+
// X-Pugi-Cli-Version header so the admin-api middleware can
|
|
66
|
+
// decide whether to honour, soft-warn, or 426 this request.
|
|
67
|
+
const outboundHeaders = injectClientVersionHeader({
|
|
68
|
+
'content-type': 'application/json',
|
|
69
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
70
|
+
'user-agent': 'pugi-cli/0.0.1',
|
|
71
|
+
}, PUGI_CLI_VERSION);
|
|
57
72
|
const res = await fetch(url, {
|
|
58
73
|
method: 'POST',
|
|
59
|
-
headers:
|
|
60
|
-
'content-type': 'application/json',
|
|
61
|
-
authorization: `Bearer ${this.config.apiKey}`,
|
|
62
|
-
'user-agent': 'pugi-cli/0.0.1',
|
|
63
|
-
},
|
|
74
|
+
headers: outboundHeaders,
|
|
64
75
|
body: JSON.stringify({
|
|
65
76
|
personaSlug: options.personaSlug,
|
|
66
77
|
messages,
|
|
@@ -87,6 +98,40 @@ export class AnvilEngineLoopClient {
|
|
|
87
98
|
signal: controller.signal,
|
|
88
99
|
});
|
|
89
100
|
const text = await res.text();
|
|
101
|
+
// PR-CLI-SERVER-VERSION-HANDSHAKE: cache server-recommended +
|
|
102
|
+
// server-version headers so UpdateBanner / `pugi doctor` can
|
|
103
|
+
// surface them, then short-circuit on 426 by throwing
|
|
104
|
+
// PugiCliUpgradeRequiredError. The throw bubbles to the
|
|
105
|
+
// top-level catch in index.ts which renders the upgrade banner.
|
|
106
|
+
// The getter shim handles both real `Response` (`.headers.get`)
|
|
107
|
+
// and minimal fixture/stub responses (`.headers?.[name]`) so
|
|
108
|
+
// existing transport tests that mock `fetch` with `{status, text}`
|
|
109
|
+
// don't need to grow a Headers polyfill just to keep passing.
|
|
110
|
+
//
|
|
111
|
+
// Cache poison guard: skip the inspect step on 426. A hostile
|
|
112
|
+
// upstream (proxy with a compromised cert pin, or a transient MITM
|
|
113
|
+
// on a coffee-shop network) could otherwise forge an
|
|
114
|
+
// `X-Pugi-Cli-Upgrade-Recommended` header alongside a 426 status
|
|
115
|
+
// and poison `cachedServerRecommendation` for the rest of the REPL
|
|
116
|
+
// session — `UpdateBanner` would then surface attacker-chosen
|
|
117
|
+
// version strings to the operator. The 426 body still carries the
|
|
118
|
+
// legitimate `recommendedVersion` field, which assertNotUpgrade-
|
|
119
|
+
// Required parses + throws with, so the operator-facing banner
|
|
120
|
+
// remains accurate via the error path.
|
|
121
|
+
if (res.status !== 426) {
|
|
122
|
+
inspectVersionResponse((name) => {
|
|
123
|
+
const h = res.headers;
|
|
124
|
+
if (h && typeof h.get === 'function') {
|
|
125
|
+
return h.get(name);
|
|
126
|
+
}
|
|
127
|
+
if (h && typeof h === 'object') {
|
|
128
|
+
const lowered = h[name.toLowerCase()];
|
|
129
|
+
return lowered ?? null;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
assertNotUpgradeRequired(res.status, text, PUGI_CLI_VERSION);
|
|
90
135
|
if (res.status === 200) {
|
|
91
136
|
try {
|
|
92
137
|
const json = JSON.parse(text);
|
|
@@ -135,6 +180,55 @@ export class AnvilEngineLoopClient {
|
|
|
135
180
|
};
|
|
136
181
|
}
|
|
137
182
|
if (res.status === 401 || res.status === 403) {
|
|
183
|
+
// 403 has two distinct causes:
|
|
184
|
+
// 1. genuinely invalid / expired token (auth_missing) — the
|
|
185
|
+
// old default.
|
|
186
|
+
// 2. tenant authenticated successfully but the privacy mode
|
|
187
|
+
// (strict / balanced policy) refused upstream LLM dispatch.
|
|
188
|
+
// The admin-api returns
|
|
189
|
+
// `{ code: 'privacy_strict_upstream_blocked', mode, model,
|
|
190
|
+
// message: '...switch via pugi config set privacy=...' }`.
|
|
191
|
+
// Reported as `auth_missing` the user runs `pugi login`
|
|
192
|
+
// again, which does nothing — the actual fix is a privacy-
|
|
193
|
+
// mode change. Parse the body and route accordingly.
|
|
194
|
+
// (2026-05-27 P0.3 — dogfood surfaced this on /api/pugi/engine
|
|
195
|
+
// for a strict-mode tenant; see memory feedback_no_fake_dispatch_promises
|
|
196
|
+
// for the broader "misleading error" pattern.)
|
|
197
|
+
try {
|
|
198
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
199
|
+
// 2026-05-27 dogfood cycle 2: distinct error code for the
|
|
200
|
+
// infra-side "PII scrubber down" case. Previously the engine
|
|
201
|
+
// server returned `privacy_strict_upstream_blocked` here even
|
|
202
|
+
// when the tenant was on BALANCED (the scrubber crash forced
|
|
203
|
+
// a fail-closed). Operators chased the wrong fix ("switch
|
|
204
|
+
// privacy") for hours. Server now emits
|
|
205
|
+
// `pii_scrubber_unavailable` — surface a distinct remediation
|
|
206
|
+
// that points at the infra side, not the operator's privacy
|
|
207
|
+
// posture.
|
|
208
|
+
if (parsed?.code === 'pii_scrubber_unavailable') {
|
|
209
|
+
return {
|
|
210
|
+
stop: 'error',
|
|
211
|
+
code: 'privacy_blocked',
|
|
212
|
+
message: parsed.message ?? 'PII scrubber unavailable; privacy filter refused dispatch.',
|
|
213
|
+
remediation: 'Infra-side issue (not your tenant privacy mode). Wait for ops to restore ' +
|
|
214
|
+
'the PiiScrubberService, OR temporarily switch your tenant to permissive via ' +
|
|
215
|
+
'`pugi config set privacy=permissive`.',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
|
|
219
|
+
return {
|
|
220
|
+
stop: 'error',
|
|
221
|
+
code: 'privacy_blocked',
|
|
222
|
+
message: parsed.message ?? 'Tenant privacy mode forbids upstream LLM dispatch.',
|
|
223
|
+
remediation: 'pugi config set privacy=balanced — OR configure a self-hosted Anvil model.',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Body not JSON — fall through to the generic auth_missing
|
|
229
|
+
// branch below; the 200-char text echo on `failed` will at
|
|
230
|
+
// least give the operator the raw response to triage.
|
|
231
|
+
}
|
|
138
232
|
return {
|
|
139
233
|
stop: 'error',
|
|
140
234
|
code: 'auth_missing',
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Hard cap on the rendered `<context>` block, bytes. */
|
|
2
|
+
export const CONTEXT_PREFIX_MAX_BYTES = 5 * 1024;
|
|
3
|
+
/** Hard cap on working-set entries surfaced in the prefix. */
|
|
4
|
+
export const CONTEXT_PREFIX_MAX_WORKING_SET = 50;
|
|
5
|
+
/** Hard cap on per-dir markdown files surfaced inline. */
|
|
6
|
+
export const CONTEXT_PREFIX_MAX_MARKDOWN = 3;
|
|
7
|
+
/** Per-markdown-file inline excerpt cap, bytes. Keeps any one file from dominating. */
|
|
8
|
+
export const CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES = 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Build the `<context>` block. Always returns a result — when there
|
|
11
|
+
* is nothing to surface (no cwd hint, no working set, no per-dir
|
|
12
|
+
* files), returns `{ block: '', bytes: 0, truncated: false, counts: ... }`
|
|
13
|
+
* so the caller can short-circuit the splice cleanly.
|
|
14
|
+
*
|
|
15
|
+
* Determinism: same input always produces byte-identical output. The
|
|
16
|
+
* working-set order comes from the caller's summary; we preserve it
|
|
17
|
+
* verbatim. Per-dir files are sorted by `distanceFromCwd` ascending
|
|
18
|
+
* (closest first) so two equal `TraversedMarkdown` arrays produce
|
|
19
|
+
* identical blocks.
|
|
20
|
+
*/
|
|
21
|
+
export function buildContextPrefix(input) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
let truncated = false;
|
|
25
|
+
// Walk a write-then-measure loop so we never overflow the byte cap.
|
|
26
|
+
// Each push checks budget; when full, set `truncated = true` and
|
|
27
|
+
// stop. The opener / closer tags always fit (small) — we reserve
|
|
28
|
+
// their byte cost up-front from the budget.
|
|
29
|
+
const opener = '<context>';
|
|
30
|
+
const closer = '</context>';
|
|
31
|
+
const reservedTagBytes = Buffer.byteLength(opener, 'utf8') + 1 + Buffer.byteLength(closer, 'utf8') + 1;
|
|
32
|
+
let budget = CONTEXT_PREFIX_MAX_BYTES - reservedTagBytes;
|
|
33
|
+
if (budget < 0) {
|
|
34
|
+
return {
|
|
35
|
+
block: '',
|
|
36
|
+
bytes: 0,
|
|
37
|
+
truncated: false,
|
|
38
|
+
counts: {
|
|
39
|
+
workingSetIncluded: 0,
|
|
40
|
+
workingSetTotal: input.workingSet?.length ?? 0,
|
|
41
|
+
markdownIncluded: 0,
|
|
42
|
+
markdownTotal: input.traversedMarkdown?.length ?? 0,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const pushLine = (line) => {
|
|
47
|
+
const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // newline
|
|
48
|
+
if (lineBytes > budget) {
|
|
49
|
+
truncated = true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
lines.push(line);
|
|
53
|
+
budget -= lineBytes;
|
|
54
|
+
bytes += lineBytes;
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
// cwd — always cheap, always first.
|
|
58
|
+
pushLine(`cwd: ${input.cwdRelative || '.'}`);
|
|
59
|
+
// intent hint, if provided.
|
|
60
|
+
if (input.intentHint) {
|
|
61
|
+
pushLine(`intent: ${input.intentHint}`);
|
|
62
|
+
}
|
|
63
|
+
// Working set — render `open-files:` only when there is at least one entry.
|
|
64
|
+
const wsTotal = input.workingSet?.length ?? 0;
|
|
65
|
+
let wsIncluded = 0;
|
|
66
|
+
if (wsTotal > 0 && input.workingSet) {
|
|
67
|
+
const wsCap = Math.min(CONTEXT_PREFIX_MAX_WORKING_SET, wsTotal);
|
|
68
|
+
pushLine('open-files:');
|
|
69
|
+
for (let i = 0; i < wsCap; i += 1) {
|
|
70
|
+
const entry = input.workingSet[i];
|
|
71
|
+
if (!entry)
|
|
72
|
+
continue;
|
|
73
|
+
const ok = pushLine(` - ${entry.absPath}`);
|
|
74
|
+
if (!ok)
|
|
75
|
+
break;
|
|
76
|
+
wsIncluded += 1;
|
|
77
|
+
}
|
|
78
|
+
if (wsTotal > wsCap) {
|
|
79
|
+
pushLine(` ... (+${wsTotal - wsCap} more)`);
|
|
80
|
+
truncated = truncated || true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Per-dir markdown — closest-first, max 3, each capped to 1 KB excerpt.
|
|
84
|
+
const mdTotal = input.traversedMarkdown?.length ?? 0;
|
|
85
|
+
let mdIncluded = 0;
|
|
86
|
+
if (mdTotal > 0 && input.traversedMarkdown) {
|
|
87
|
+
const sorted = [...input.traversedMarkdown].sort((a, b) => a.distanceFromCwd - b.distanceFromCwd);
|
|
88
|
+
const top = sorted.slice(0, CONTEXT_PREFIX_MAX_MARKDOWN);
|
|
89
|
+
if (top.length > 0) {
|
|
90
|
+
pushLine('per-dir-conventions:');
|
|
91
|
+
for (const md of top) {
|
|
92
|
+
// Header line names the source file for traceability.
|
|
93
|
+
const header = ` [${md.resolvedPath}]`;
|
|
94
|
+
if (!pushLine(header))
|
|
95
|
+
break;
|
|
96
|
+
// Excerpt: cap to 1 KB, single-line collapse (replace newlines
|
|
97
|
+
// with " | " so the YAML-ish block stays parseable by humans).
|
|
98
|
+
const excerpt = excerpt1KB(md.content);
|
|
99
|
+
// Indent two spaces so it nests under the file header.
|
|
100
|
+
const indented = excerpt.split('\n').map((l) => ` ${l}`).join('\n');
|
|
101
|
+
if (!pushLine(indented))
|
|
102
|
+
break;
|
|
103
|
+
mdIncluded += 1;
|
|
104
|
+
}
|
|
105
|
+
if (mdTotal > top.length) {
|
|
106
|
+
pushLine(` ... (+${mdTotal - top.length} more files; closest-3 shown)`);
|
|
107
|
+
truncated = truncated || true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (lines.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
block: '',
|
|
114
|
+
bytes: 0,
|
|
115
|
+
truncated: false,
|
|
116
|
+
counts: {
|
|
117
|
+
workingSetIncluded: 0,
|
|
118
|
+
workingSetTotal: wsTotal,
|
|
119
|
+
markdownIncluded: 0,
|
|
120
|
+
markdownTotal: mdTotal,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const block = [opener, ...lines, closer].join('\n');
|
|
125
|
+
const totalBytes = Buffer.byteLength(block, 'utf8');
|
|
126
|
+
return {
|
|
127
|
+
block,
|
|
128
|
+
bytes: totalBytes,
|
|
129
|
+
truncated,
|
|
130
|
+
counts: {
|
|
131
|
+
workingSetIncluded: wsIncluded,
|
|
132
|
+
workingSetTotal: wsTotal,
|
|
133
|
+
markdownIncluded: mdIncluded,
|
|
134
|
+
markdownTotal: mdTotal,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Splice a built context block onto the front of a user message.
|
|
140
|
+
* Empty block → message returned verbatim (no leading blank line, no
|
|
141
|
+
* empty `<context>` tag).
|
|
142
|
+
*/
|
|
143
|
+
export function spliceContextPrefix(block, userMessage) {
|
|
144
|
+
if (block.length === 0)
|
|
145
|
+
return userMessage;
|
|
146
|
+
return `${block}\n\n${userMessage}`;
|
|
147
|
+
}
|
|
148
|
+
function excerpt1KB(content) {
|
|
149
|
+
const capped = content.length <= CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES
|
|
150
|
+
? content
|
|
151
|
+
: content.slice(0, CONTEXT_PREFIX_MAX_PER_MARKDOWN_BYTES);
|
|
152
|
+
// Trim trailing newlines so the YAML-ish render stays tight.
|
|
153
|
+
return capped.replace(/\s+$/g, '');
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=context-prefix.js.map
|