@pugi/cli 0.1.0-beta.95 → 0.1.0-beta.97
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/engine/tool-bridge.js +60 -0
- package/dist/core/permissions/tool-class.js +14 -0
- package/dist/core/repl/engine-bridge.js +104 -0
- package/dist/core/repl/session.js +65 -7
- package/dist/runtime/cli.js +27 -42
- package/dist/runtime/engine-exit-code.js +50 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/http-request.js +336 -0
- package/dist/tools/registry.js +21 -1
- package/dist/tools/server-tools.js +892 -0
- package/dist/tui/repl-render.js +10 -0
- package/package.json +2 -2
|
@@ -19,6 +19,12 @@ import { dispatchEnterWorktree, enterWorktreeJsonSchema, } from '../../tools/ent
|
|
|
19
19
|
import { dispatchExitWorktree, exitWorktreeJsonSchema, } from '../../tools/exit-worktree.js';
|
|
20
20
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
21
21
|
import { webSearchTool } from '../../tools/web-search.js';
|
|
22
|
+
// Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request
|
|
23
|
+
// dispatchers + JSON schema fragments. Every primitive returns a
|
|
24
|
+
// sentinel string on validation failure (sleep/brief convention) so
|
|
25
|
+
// the engine adapter surfaces it as a recoverable tool result.
|
|
26
|
+
import { dispatchHttpRequest, httpRequestJsonSchema, } from '../../tools/http-request.js';
|
|
27
|
+
import { dispatchServerHealth, dispatchServerLogs, dispatchServerStart, dispatchServerStop, serverHealthJsonSchema, serverLogsJsonSchema, serverStartJsonSchema, serverStopJsonSchema, } from '../../tools/server-tools.js';
|
|
22
28
|
import { agentTool } from '../../tools/agent-tool.js';
|
|
23
29
|
import { multiEdit } from '../../tools/multi-edit.js';
|
|
24
30
|
import { recordVerificationCall } from '../session.js';
|
|
@@ -217,6 +223,15 @@ const WIRED_TOOLS = new Set([
|
|
|
217
223
|
'symbols_signature',
|
|
218
224
|
'symbols_type_definition',
|
|
219
225
|
'symbols_workspace_symbols',
|
|
226
|
+
// Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request.
|
|
227
|
+
// Off by default in plan mode (mutation surface for start/stop; even
|
|
228
|
+
// health/logs/http_request cross the plan-mode read-only contract
|
|
229
|
+
// because they are runtime evidence primitives, not source reads).
|
|
230
|
+
'http_request',
|
|
231
|
+
'server_health',
|
|
232
|
+
'server_logs',
|
|
233
|
+
'server_start',
|
|
234
|
+
'server_stop',
|
|
220
235
|
]);
|
|
221
236
|
export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
|
|
222
237
|
const planMode = kind === 'plan';
|
|
@@ -831,6 +846,31 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
831
846
|
},
|
|
832
847
|
},
|
|
833
848
|
},
|
|
849
|
+
},
|
|
850
|
+
// Phase 1 runtime evidence pack (PUGI-291..295) - server lifecycle
|
|
851
|
+
// primitives + generic HTTP request. Off in plan mode because each
|
|
852
|
+
// tool produces side effects (a spawned process, an outbound HTTP
|
|
853
|
+
// call) the plan-mode read-only contract refuses.
|
|
854
|
+
{
|
|
855
|
+
name: 'server_start',
|
|
856
|
+
description: 'Spawn a server via /bin/sh -c <command> in the workspace and poll the health URL until it returns the expected status (default 200) or the timeout elapses. Persists pid + meta to `.pugi/runs/<runId>/server.json` and a stdout/stderr tail to `server.log`. Returns {ok, pid, port, healthStatus, logPath, durationMs, error?}. Use to materialise concrete pid + health 200 evidence rather than asserting a server is up in prose.',
|
|
857
|
+
parameters: serverStartJsonSchema,
|
|
858
|
+
}, {
|
|
859
|
+
name: 'server_stop',
|
|
860
|
+
description: 'Send SIGTERM to the supplied pid, wait graceMs (default 5000ms), then escalate to SIGKILL. Idempotent - a pid that already exited returns ok=true with signal=undefined. Returns {ok, pid, exitCode?, signal?, durationMs, error?}.',
|
|
861
|
+
parameters: serverStopJsonSchema,
|
|
862
|
+
}, {
|
|
863
|
+
name: 'server_health',
|
|
864
|
+
description: 'One-shot HTTP GET against the supplied URL with a short timeout (default 5000ms, max 60000ms). Returns {ok, status, durationMs, body?, error?} where ok=true only when the response status equals expectStatus (default 200). Body is capped at 1 KB so the envelope stays compact.',
|
|
865
|
+
parameters: serverHealthJsonSchema,
|
|
866
|
+
}, {
|
|
867
|
+
name: 'server_logs',
|
|
868
|
+
description: 'Read the trailing N lines of `.pugi/runs/<runId>/server.log`. Defaults to the most recent run when runId is omitted; pid-based lookup walks every run dir. Returns {ok, logPath, lines, totalLines, error?}.',
|
|
869
|
+
parameters: serverLogsJsonSchema,
|
|
870
|
+
}, {
|
|
871
|
+
name: 'http_request',
|
|
872
|
+
description: 'Issue an HTTP request (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS) against a URL. Loopback hosts (localhost / 127.0.0.0/8 / ::1) always pass; non-loopback hosts require allowExternal: true so the default posture stays private. Body objects/arrays are JSON-serialised. Returns {ok, status, headers, body, json?, durationMs, error?, truncated?}.',
|
|
873
|
+
parameters: httpRequestJsonSchema,
|
|
834
874
|
});
|
|
835
875
|
}
|
|
836
876
|
// β4 M1/M3: append MCP tools last. Plan mode skips them because every
|
|
@@ -1235,6 +1275,26 @@ export function buildExecutor(input) {
|
|
|
1235
1275
|
sessionId,
|
|
1236
1276
|
});
|
|
1237
1277
|
}
|
|
1278
|
+
// Phase 1 runtime evidence pack (PUGI-291..295). Each tool returns
|
|
1279
|
+
// a JSON-string envelope or a sentinel string on validation
|
|
1280
|
+
// failure - both paths are recoverable from the model's POV, so
|
|
1281
|
+
// the engine adapter routes them as plain tool results and the
|
|
1282
|
+
// model can self-correct.
|
|
1283
|
+
if (name === 'server_start') {
|
|
1284
|
+
return dispatchServerStart({ workspaceRoot }, args);
|
|
1285
|
+
}
|
|
1286
|
+
if (name === 'server_stop') {
|
|
1287
|
+
return dispatchServerStop({ workspaceRoot }, args);
|
|
1288
|
+
}
|
|
1289
|
+
if (name === 'server_health') {
|
|
1290
|
+
return dispatchServerHealth({ workspaceRoot }, args);
|
|
1291
|
+
}
|
|
1292
|
+
if (name === 'server_logs') {
|
|
1293
|
+
return dispatchServerLogs({ workspaceRoot }, args);
|
|
1294
|
+
}
|
|
1295
|
+
if (name === 'http_request') {
|
|
1296
|
+
return dispatchHttpRequest({}, args);
|
|
1297
|
+
}
|
|
1238
1298
|
if (name === 'multi_edit') {
|
|
1239
1299
|
return dispatchMultiEdit(args, ctx);
|
|
1240
1300
|
}
|
|
@@ -47,11 +47,25 @@ const BUILT_IN_TOOL_CLASSES = Object.freeze({
|
|
|
47
47
|
skill: 'read',
|
|
48
48
|
task_get: 'read',
|
|
49
49
|
task_list: 'read',
|
|
50
|
+
// Phase 1 runtime evidence pack (PUGI-291..295): server_health and
|
|
51
|
+
// server_logs are read-only probes (HTTP GET + tail of a log file
|
|
52
|
+
// already on disk). Classifying them as `read` keeps the ask-mode
|
|
53
|
+
// prompts honest; the dispatcher still applies the network-perm gate
|
|
54
|
+
// for server_health and the workspace-read gate for server_logs.
|
|
55
|
+
server_health: 'read',
|
|
56
|
+
server_logs: 'read',
|
|
50
57
|
// Mutating actions.
|
|
51
58
|
write: 'write',
|
|
52
59
|
edit: 'write',
|
|
53
60
|
multi_edit: 'write',
|
|
54
61
|
bash: 'write',
|
|
62
|
+
// Phase 1 runtime evidence pack: server_start spawns a process,
|
|
63
|
+
// server_stop terminates one, http_request can POST/PUT/DELETE
|
|
64
|
+
// arbitrary data against a localhost service. All three are write
|
|
65
|
+
// class so plan mode refuses and ask mode prompts.
|
|
66
|
+
server_start: 'write',
|
|
67
|
+
server_stop: 'write',
|
|
68
|
+
http_request: 'write',
|
|
55
69
|
task_create: 'write',
|
|
56
70
|
task_update: 'write',
|
|
57
71
|
todo_write: 'write',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AnvilEngineLoopClient } from '../engine/anvil-client.js';
|
|
2
2
|
import { NativePugiEngineAdapter } from '../engine/native-pugi.js';
|
|
3
|
+
import { loadMcpRegistry as defaultLoadMcpRegistry } from '../mcp/registry.js';
|
|
3
4
|
import { openSession } from '../session.js';
|
|
5
|
+
import { loadHookRegistryOrExit as defaultLoadHookRegistry } from '../../runtime/load-hooks-or-exit.js';
|
|
4
6
|
/**
|
|
5
7
|
* Translate `pugi-tool-route command="..."` into the SDK's
|
|
6
8
|
* `EngineTaskKind`. `code` and `fix` pass through verbatim; `build`
|
|
@@ -83,13 +85,115 @@ function translateStreamEvent(event, names) {
|
|
|
83
85
|
*/
|
|
84
86
|
export function createEngineBridge(deps) {
|
|
85
87
|
const buildClient = deps.clientFactory ?? ((config) => new AnvilEngineLoopClient(config));
|
|
88
|
+
const loadMcp = deps.loadMcpRegistry ?? defaultLoadMcpRegistry;
|
|
89
|
+
const loadHooks = deps.loadHookRegistry ??
|
|
90
|
+
((opts) => defaultLoadHookRegistry(opts));
|
|
91
|
+
// PR A — per-bridge-instance caches. Spawning MCP child processes
|
|
92
|
+
// (multi-second startup for some servers) and parsing hook configuration
|
|
93
|
+
// are too expensive to repeat per turn AND have no per-turn state. We
|
|
94
|
+
// load once on the first call, reuse across turns. The MCP registry
|
|
95
|
+
// shutdown happens at process exit; a future PR will wire it to the
|
|
96
|
+
// REPL's exit hook so trusted child processes are reaped before the
|
|
97
|
+
// parent terminates.
|
|
98
|
+
//
|
|
99
|
+
// /triple-review P1 round 2: cache stored as in-flight Promise<T>
|
|
100
|
+
// instead of value + boolean flag. Two concurrent first-turn calls
|
|
101
|
+
// would otherwise both observe `mcpLoaded === false`, both spawn the
|
|
102
|
+
// expensive loader, and the second result would overwrite the first —
|
|
103
|
+
// leaking the first registry's child processes. Storing the promise
|
|
104
|
+
// means the second caller awaits the same in-flight load, gets the
|
|
105
|
+
// same instance, and there is no double-spawn. On loader failure the
|
|
106
|
+
// promise resolves to `undefined` (mirrors the value-cache fallback);
|
|
107
|
+
// a future retry on transient ENOENT would mint a NEW promise after
|
|
108
|
+
// explicit invalidation (out of scope for this PR — tracked as P2 fu).
|
|
109
|
+
let mcpRegistryPromise = null;
|
|
110
|
+
let hooksPromise = null;
|
|
111
|
+
const securityHooksParseFailed = { value: false };
|
|
112
|
+
async function resolveMcpRegistry(root) {
|
|
113
|
+
if (mcpRegistryPromise === null) {
|
|
114
|
+
mcpRegistryPromise = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
return await loadMcp(root);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// PR A — mirror cli.ts:6394 failure mode. A bad `.pugi/mcp.json`
|
|
120
|
+
// must not crash the REPL; the operator sees a stderr warning
|
|
121
|
+
// and continues without MCP tools. They can fix the file and
|
|
122
|
+
// the next REPL launch picks up the new registry.
|
|
123
|
+
const msg = error.message;
|
|
124
|
+
process.stderr.write(`pugi REPL engine bridge: MCP registry load failed — ${msg}. ` +
|
|
125
|
+
`Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
}
|
|
130
|
+
return mcpRegistryPromise;
|
|
131
|
+
}
|
|
132
|
+
async function resolveHooks(root, session) {
|
|
133
|
+
if (hooksPromise === null) {
|
|
134
|
+
hooksPromise = (async () => {
|
|
135
|
+
try {
|
|
136
|
+
const outcome = await loadHooks({
|
|
137
|
+
workspaceRoot: root,
|
|
138
|
+
session,
|
|
139
|
+
label: 'repl',
|
|
140
|
+
});
|
|
141
|
+
if (outcome.kind === 'parse-failure-refused') {
|
|
142
|
+
// /triple-review P1 round 2: hooks fail-open is a security
|
|
143
|
+
// regression vs cli.ts:6440 (hard-exit on parse failure).
|
|
144
|
+
// SECURITY: a workspace operator who configured a `PreToolUse
|
|
145
|
+
// onFailure: 'block'` rule that refuses bash containing `rm`
|
|
146
|
+
// loses that protection the moment the JSON file gets a
|
|
147
|
+
// typo. Stderr-only warning scrolls off the TUI.
|
|
148
|
+
//
|
|
149
|
+
// Mitigation: do NOT load hooks for this REPL session (so
|
|
150
|
+
// the operator's mental model — "I have hooks configured" —
|
|
151
|
+
// does not silently break), AND mark the bridge as "hooks
|
|
152
|
+
// parse failed" so every turn surfaces the warning until
|
|
153
|
+
// the file is fixed. The PUGI_HOOKS_BYPASS=1 env var
|
|
154
|
+
// (loadHookRegistryOrExit:97-107) is still the explicit
|
|
155
|
+
// escape hatch when the operator is mid-edit and acknowledges
|
|
156
|
+
// the risk.
|
|
157
|
+
securityHooksParseFailed.value = true;
|
|
158
|
+
process.stderr.write('pugi REPL engine bridge: SECURITY — hooks parse failed. ' +
|
|
159
|
+
'PreToolUse/PostToolUse rules are NOT active for this REPL ' +
|
|
160
|
+
'session. Fix .pugi/hooks.json and restart REPL, OR set ' +
|
|
161
|
+
'PUGI_HOOKS_BYPASS=1 to acknowledge and continue.\n');
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return outcome.hooks;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const msg = error.message;
|
|
168
|
+
process.stderr.write(`pugi REPL engine bridge: hooks loader threw — ${msg}. ` +
|
|
169
|
+
`Continuing without hooks.\n`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
}
|
|
174
|
+
return hooksPromise;
|
|
175
|
+
}
|
|
86
176
|
return async (input) => {
|
|
87
177
|
const root = deps.cwd();
|
|
88
178
|
const session = openSession(root);
|
|
89
179
|
const client = buildClient(deps.config);
|
|
180
|
+
const [cachedMcpRegistry, cachedHooks] = await Promise.all([
|
|
181
|
+
resolveMcpRegistry(root),
|
|
182
|
+
resolveHooks(root, session),
|
|
183
|
+
]);
|
|
184
|
+
if (securityHooksParseFailed.value) {
|
|
185
|
+
// /triple-review P1 round 2: re-emit the security warning on every
|
|
186
|
+
// bridged turn so the operator does not miss it after scrollback.
|
|
187
|
+
// Cheap one-liner, hard to miss in TUI status pane.
|
|
188
|
+
process.stderr.write('pugi REPL engine bridge: SECURITY reminder — hooks still NOT ' +
|
|
189
|
+
'loaded for this session (.pugi/hooks.json parse failure).\n');
|
|
190
|
+
}
|
|
90
191
|
const adapter = new NativePugiEngineAdapter({
|
|
91
192
|
client,
|
|
92
193
|
session,
|
|
194
|
+
...(cachedMcpRegistry ? { mcpRegistry: cachedMcpRegistry } : {}),
|
|
195
|
+
...(cachedHooks ? { hooks: cachedHooks } : {}),
|
|
196
|
+
...(deps.intensityProfile ? { intensityProfile: deps.intensityProfile } : {}),
|
|
93
197
|
});
|
|
94
198
|
// Per-call name map for matching `tool.end` -> `tool.start`. New
|
|
95
199
|
// every invocation so concurrent bridges never cross-pollute.
|
|
@@ -40,7 +40,8 @@ import { applyRewindMask } from '../checkpoint/rewinder.js';
|
|
|
40
40
|
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
41
41
|
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
42
42
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
43
|
-
import { extractToolRouteTags, } from './tool-route.js';
|
|
43
|
+
import { extractToolRouteTags, signatureForToolRoute, } from './tool-route.js';
|
|
44
|
+
import { personaSlugFor } from '../engine/prompts.js';
|
|
44
45
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
45
46
|
import { resolve as resolvePath } from 'node:path';
|
|
46
47
|
import { CancellationToken } from './cancellation.js';
|
|
@@ -2788,13 +2789,70 @@ export class ReplSession {
|
|
|
2788
2789
|
if (!this.streamHandle && !this.closed) {
|
|
2789
2790
|
this.openStream();
|
|
2790
2791
|
}
|
|
2792
|
+
// PR A (PUGI-538-FU) — REPL becomes a first-class engine
|
|
2793
|
+
// path. When the CLI REPL has an engine bridge wired the brief is
|
|
2794
|
+
// dispatched DIRECTLY to the inproc engine adapter via
|
|
2795
|
+
// `runEngineBridge` instead of POSTing к admin-api `/sessions/:id/brief`.
|
|
2796
|
+
//
|
|
2797
|
+
// Why this matters:
|
|
2798
|
+
// - The server-side bypass () had to fabricate a synthetic
|
|
2799
|
+
// `<pugi-tool-route>` envelope SSE event so the CLI parser would
|
|
2800
|
+
// fire `runEngineBridge`. That worked but cost one full HTTP
|
|
2801
|
+
// round-trip + SSE latency per turn — and required `cliVersion`
|
|
2802
|
+
// to thread correctly через the session-create + header pipe
|
|
2803
|
+
// (which broke in production: CEO smoke 2026-06-05 showed
|
|
2804
|
+
// `envelope=delegate` instead of `tool-route` because the version
|
|
2805
|
+
// header was missing on his customer-installed beta.95 client,
|
|
2806
|
+
// so the bypass branch never matched и the coordinator chat
|
|
2807
|
+
// ceremony ran anyway).
|
|
2808
|
+
// - Going direct removes that whole class of bug: the CLI knows
|
|
2809
|
+
// it is the CLI, it has the engine bridge in hand, it skips the
|
|
2810
|
+
// server entirely и calls the adapter inproc. Matches Claude
|
|
2811
|
+
// Code / Codex / Aider tools-first loop architecture.
|
|
2812
|
+
//
|
|
2813
|
+
// Personas survive: `personaSlugFor('code')` returns 'dev' (Hiroshi),
|
|
2814
|
+
// the engine adapter renders the persona system prompt + memory
|
|
2815
|
+
// recall just like `pugi code` direct CLI. The synthetic agent-tree
|
|
2816
|
+
// node inside `runEngineBridge` carries `personaName` so the TUI
|
|
2817
|
+
// shows "Hiroshi" the same way it did before.
|
|
2818
|
+
//
|
|
2819
|
+
// Server-side bypass от remains в place для non-CLI surfaces
|
|
2820
|
+
// (cabinet BFF, telegram bot) — they have no engine adapter wired,
|
|
2821
|
+
// so the server still needs to fabricate the dispatch on their behalf.
|
|
2822
|
+
//
|
|
2823
|
+
// Env opt-out: `PUGI_REPL_DIRECT_ENGINE=0` falls back к the HTTP
|
|
2824
|
+
// path for regression debugging. cliVersion presence is the CLI
|
|
2825
|
+
// signal — REPL embedded inside cabinet BFF mounts without that
|
|
2826
|
+
// field и continues к hit the server route.
|
|
2827
|
+
const useDirectEngine = this.options.engineBridge !== undefined &&
|
|
2828
|
+
typeof this.options.cliVersion === 'string' &&
|
|
2829
|
+
this.options.cliVersion.length > 0 &&
|
|
2830
|
+
(this.options.env ?? process.env).PUGI_REPL_DIRECT_ENGINE !== '0';
|
|
2791
2831
|
try {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2832
|
+
if (useDirectEngine) {
|
|
2833
|
+
const persona = personaSlugFor('code');
|
|
2834
|
+
const tag = {
|
|
2835
|
+
command: 'code',
|
|
2836
|
+
brief,
|
|
2837
|
+
persona,
|
|
2838
|
+
// Direct-dispatch tags do not flow through the parser, so the
|
|
2839
|
+
// start/end byte offsets are inapplicable. Keep `signatureForToolRoute`
|
|
2840
|
+
// so the seen-tag rolling set still de-dupes a brief that the
|
|
2841
|
+
// operator submits twice in a row by accident.
|
|
2842
|
+
signature: signatureForToolRoute('code', persona, brief),
|
|
2843
|
+
start: 0,
|
|
2844
|
+
end: 0,
|
|
2845
|
+
};
|
|
2846
|
+
await this.runEngineBridge(tag);
|
|
2847
|
+
}
|
|
2848
|
+
else {
|
|
2849
|
+
await this.options.transport.postBrief({
|
|
2850
|
+
apiUrl: this.options.apiUrl,
|
|
2851
|
+
apiKey: this.options.apiKey,
|
|
2852
|
+
sessionId,
|
|
2853
|
+
brief,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2798
2856
|
}
|
|
2799
2857
|
catch (error) {
|
|
2800
2858
|
this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
|
package/dist/runtime/cli.js
CHANGED
|
@@ -5021,48 +5021,26 @@ function renderLocalSessionList(rows, projectSlug) {
|
|
|
5021
5021
|
* 1 is reserved for the credential gate (engine_unavailable) so
|
|
5022
5022
|
* existing shell wrappers that branch on "any non-zero" still work.
|
|
5023
5023
|
*/
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
*
|
|
5045
|
-
* The override fires when `result.status` carries the new
|
|
5046
|
-
* `needs_verification` literal OR when `result.unverifiedReason`
|
|
5047
|
-
* is set to `verification_command_failed` — the latter pins exit
|
|
5048
|
-
* 1 even if a future producer left `status: 'failed'` while
|
|
5049
|
-
* inferring the cause from the reason field.
|
|
5050
|
-
*/
|
|
5051
|
-
function resolveEngineExitCode(result) {
|
|
5052
|
-
if (result.status === 'needs_verification') {
|
|
5053
|
-
return ENGINE_EXIT_CODES.needs_verification;
|
|
5054
|
-
}
|
|
5055
|
-
if (result.unverifiedReason === 'verification_command_failed') {
|
|
5056
|
-
// Spec: verified=false because a verification command failed
|
|
5057
|
-
// maps to CLI exit 1 (clear "this is broken" signal).
|
|
5058
|
-
return 1;
|
|
5059
|
-
}
|
|
5060
|
-
if (result.status === 'done')
|
|
5061
|
-
return ENGINE_EXIT_CODES.done;
|
|
5062
|
-
if (result.status === 'failed')
|
|
5063
|
-
return ENGINE_EXIT_CODES.failed;
|
|
5064
|
-
return ENGINE_EXIT_CODES.blocked;
|
|
5065
|
-
}
|
|
5024
|
+
// PUGI-VERIFY-GATE - Codex dogfood 2026-06-04 surfaced a P0 where
|
|
5025
|
+
// `pugi code` returned exit 0 while npm test failed. The spec locks
|
|
5026
|
+
// the contract to exit 1 on a verification failure AND exit 2 on
|
|
5027
|
+
// `needs_verification`. Legacy callers reading exit 8/9 still see
|
|
5028
|
+
// "any non-zero = failure" but the new codes (1/2) are the
|
|
5029
|
+
// authoritative signal for the verification gate.
|
|
5030
|
+
//
|
|
5031
|
+
// Phase 1 (PUGI-299) - runtime invariant additions:
|
|
5032
|
+
// 3. `verificationFailures` non-empty array → exit 1
|
|
5033
|
+
// 4. `verified === false && status === 'done'` → exit 2
|
|
5034
|
+
//
|
|
5035
|
+
// The two new rules are defensive: today `computeVerificationOutcome`
|
|
5036
|
+
// always populates `unverifiedReason` (rule 2) or downgrades the
|
|
5037
|
+
// status (rule 1) so the new branches never fire, but the invariant
|
|
5038
|
+
// locks the contract for future engine adapters.
|
|
5039
|
+
//
|
|
5040
|
+
// Implementation lives in `engine-exit-code.ts` so the runtime spec
|
|
5041
|
+
// (`test/engine-exit-code.spec.ts`) can exercise it without mounting
|
|
5042
|
+
// this 9700-LOC entry module.
|
|
5043
|
+
import { ENGINE_EXIT_CODES, resolveEngineExitCode, } from './engine-exit-code.js';
|
|
5066
5044
|
function commandLabel(kind) {
|
|
5067
5045
|
return kind === 'build_task' ? 'build' : kind;
|
|
5068
5046
|
}
|
|
@@ -8132,5 +8110,12 @@ export const __test__ = {
|
|
|
8132
8110
|
// env-driven $PUGI_HOME redirect) without going через the full
|
|
8133
8111
|
// engine-task dispatch path.
|
|
8134
8112
|
readPersistedContextTier,
|
|
8113
|
+
// Phase 1 (PUGI-299) - runtime verification gate exit-code resolver.
|
|
8114
|
+
// Re-exported from `engine-exit-code.ts` so the invariant spec can
|
|
8115
|
+
// assert that a `verificationFailures` non-empty array OR a
|
|
8116
|
+
// `verified=false` outcome MUST surface as a non-zero CLI exit
|
|
8117
|
+
// even when a malformed adapter forgets to flip the status.
|
|
8118
|
+
resolveEngineExitCode,
|
|
8119
|
+
ENGINE_EXIT_CODES,
|
|
8135
8120
|
};
|
|
8136
8121
|
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-exit-code - Phase 1 verification gate invariant (PUGI-299).
|
|
3
|
+
*
|
|
4
|
+
* The runtime resolves the CLI exit code from the engine outcome via a
|
|
5
|
+
* deterministic ladder. Extracted from `cli.ts` so the invariant is
|
|
6
|
+
* exercisable from a focused spec without mounting the 9700-LOC entry
|
|
7
|
+
* module.
|
|
8
|
+
*
|
|
9
|
+
* Contract (in priority order):
|
|
10
|
+
* 1. `status === 'needs_verification'` → exit 2
|
|
11
|
+
* 2. `unverifiedReason === 'verification_command_failed'` → exit 1
|
|
12
|
+
* 3. `verificationFailures` is a non-empty array → exit 1
|
|
13
|
+
* 4. `verified === false && status === 'done'` → exit 2
|
|
14
|
+
* 5. `status === 'done'` → exit 0
|
|
15
|
+
* 6. `status === 'failed'` → exit 8
|
|
16
|
+
* 7. `status === 'blocked'` (catch-all) → exit 9
|
|
17
|
+
*
|
|
18
|
+
* The rule fires defensively: rules 3 + 4 cover producer bugs where a
|
|
19
|
+
* future engine adapter forgets to flip `unverifiedReason` or
|
|
20
|
+
* downgrade the status to `needs_verification`. Today
|
|
21
|
+
* `computeVerificationOutcome` always synthesises one of the two,
|
|
22
|
+
* but the invariant locks the contract.
|
|
23
|
+
*/
|
|
24
|
+
export const ENGINE_EXIT_CODES = {
|
|
25
|
+
done: 0,
|
|
26
|
+
failed: 8,
|
|
27
|
+
blocked: 9,
|
|
28
|
+
engine_unavailable: 1,
|
|
29
|
+
needs_verification: 2,
|
|
30
|
+
};
|
|
31
|
+
export function resolveEngineExitCode(result) {
|
|
32
|
+
if (result.status === 'needs_verification') {
|
|
33
|
+
return ENGINE_EXIT_CODES.needs_verification;
|
|
34
|
+
}
|
|
35
|
+
if (result.unverifiedReason === 'verification_command_failed') {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(result.verificationFailures) && result.verificationFailures.length > 0) {
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
if (result.verified === false && result.status === 'done') {
|
|
42
|
+
return ENGINE_EXIT_CODES.needs_verification;
|
|
43
|
+
}
|
|
44
|
+
if (result.status === 'done')
|
|
45
|
+
return ENGINE_EXIT_CODES.done;
|
|
46
|
+
if (result.status === 'failed')
|
|
47
|
+
return ENGINE_EXIT_CODES.failed;
|
|
48
|
+
return ENGINE_EXIT_CODES.blocked;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=engine-exit-code.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.97');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|