@pugi/cli 0.1.0-alpha.17 → 0.1.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +204 -0
- package/dist/core/repl/privacy-banner.js +4 -4
- package/dist/core/repl/session.js +366 -31
- package/dist/core/repl/slash-commands.js +37 -32
- package/dist/runtime/cli.js +193 -1
- package/dist/runtime/commands/config.js +136 -0
- package/dist/tui/repl-render.js +72 -0
- package/package.json +6 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* REPL slash command registry
|
|
2
|
+
* REPL slash command registry - Sprint α5.7, expanded α6.14 wave 2.
|
|
3
3
|
*
|
|
4
4
|
* The REPL input box surfaces a palette of slash commands the operator
|
|
5
5
|
* can run from inside a persistent session. The wave-2 expansion (CEO
|
|
@@ -15,46 +15,35 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Tiering (per CEO wave-2 spec):
|
|
17
17
|
*
|
|
18
|
-
* Tier 1
|
|
18
|
+
* Tier 1 - wired against real state (3 + existing 6 = 9 wired):
|
|
19
19
|
* brief, agents, stop, help, quit, web, clear, version, jobs.
|
|
20
20
|
*
|
|
21
|
-
* Tier 2
|
|
21
|
+
* Tier 2 - best-effort wiring against existing surfaces (3):
|
|
22
22
|
* diff, cost, status.
|
|
23
23
|
*
|
|
24
|
-
* Tier 3
|
|
24
|
+
* Tier 3 - deterministic stubs ("coming in αX.Y") (8):
|
|
25
25
|
* compact, resume, memory, config, privacy, budget, mcp, undo.
|
|
26
26
|
*
|
|
27
27
|
* Brand voice (brandbook §08): power words `brief / dispatch / stop /
|
|
28
28
|
* agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
|
|
29
|
-
* `/quit` confirmation and `/help` footer
|
|
29
|
+
* `/quit` confirmation and `/help` footer - never inline.
|
|
30
30
|
*/
|
|
31
31
|
import { listRoles } from '../agents/registry.js';
|
|
32
32
|
/**
|
|
33
33
|
* Deterministic stub copy returned by the Tier 3 commands. Spec'd
|
|
34
34
|
* inline so the unit test can pin the exact text without poking at
|
|
35
35
|
* the help overlay. The version tag at the end maps to the sprint we
|
|
36
|
-
* intend to land the real wiring in.
|
|
36
|
+
* intend to land the real wiring in. Keyed by StubSlashCommandName
|
|
37
|
+
* (not the full SlashCommandName union) so wired commands cannot
|
|
38
|
+
* silently appear here with empty placeholders.
|
|
37
39
|
*/
|
|
38
40
|
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
stop: '',
|
|
42
|
-
help: '',
|
|
43
|
-
quit: '',
|
|
44
|
-
web: '',
|
|
45
|
-
clear: '',
|
|
46
|
-
version: '',
|
|
47
|
-
jobs: '',
|
|
48
|
-
ask: '',
|
|
49
|
-
diff: '',
|
|
50
|
-
cost: '',
|
|
51
|
-
status: '',
|
|
52
|
-
consensus: '',
|
|
53
|
-
compact: 'Manual context compaction lands in α6.5.',
|
|
54
|
-
resume: '',
|
|
55
|
-
memory: 'Session memory editor lands in α6.5.',
|
|
41
|
+
compact: 'Manual context compaction lands in α6.5b.',
|
|
42
|
+
memory: 'Session memory editor lands in α6.5b.',
|
|
56
43
|
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
57
|
-
|
|
44
|
+
// alpha 6.13: /privacy graduated from stub; nothing reads this at
|
|
45
|
+
// runtime but the type record stays exhaustive.
|
|
46
|
+
privacy: '',
|
|
58
47
|
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
59
48
|
mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
|
|
60
49
|
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
@@ -69,8 +58,9 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
69
58
|
// Session
|
|
70
59
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
71
60
|
{ name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
|
|
72
|
-
{ name: '
|
|
73
|
-
{ name: '
|
|
61
|
+
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
62
|
+
{ name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
|
|
63
|
+
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
74
64
|
// Pugi tools
|
|
75
65
|
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
76
66
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
@@ -79,7 +69,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
79
69
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
80
70
|
// Settings
|
|
81
71
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
82
|
-
{ name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings'
|
|
72
|
+
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
83
73
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
84
74
|
{ name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
|
|
85
75
|
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
@@ -105,7 +95,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
|
105
95
|
* - Empty / whitespace-only input returns `noop` with the original
|
|
106
96
|
* text so the REPL can ignore it without printing anything.
|
|
107
97
|
* - Input that does not start with `/` is treated as an implicit
|
|
108
|
-
* `/brief <text>`
|
|
98
|
+
* `/brief <text>` - the most-common operator action.
|
|
109
99
|
* - `/<name> [args]` resolves the name against the registry; unknown
|
|
110
100
|
* names return `error` so the REPL can render a one-line tip
|
|
111
101
|
* instead of silently dropping the input.
|
|
@@ -113,7 +103,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
|
113
103
|
* can render the deterministic "coming in αX.Y" copy uniformly.
|
|
114
104
|
*
|
|
115
105
|
* The function never throws. Bad input maps to a structured result the
|
|
116
|
-
* REPL can render
|
|
106
|
+
* REPL can render - the alternative (throwing from a keystroke handler)
|
|
117
107
|
* would unmount Ink mid-frame.
|
|
118
108
|
*/
|
|
119
109
|
export function parseSlashCommand(input) {
|
|
@@ -210,17 +200,32 @@ export function parseSlashCommand(input) {
|
|
|
210
200
|
// so the slash-command layer stays UI-agnostic.
|
|
211
201
|
return { kind: 'resume' };
|
|
212
202
|
}
|
|
203
|
+
case 'context':
|
|
204
|
+
case 'ctx': {
|
|
205
|
+
// α6.5: surface Tier 0 + Tier 1 status. The session module
|
|
206
|
+
// renders the summary as system lines so the operator can see
|
|
207
|
+
// skeleton size + working-set utilisation at a glance.
|
|
208
|
+
return { kind: 'context' };
|
|
209
|
+
}
|
|
210
|
+
case 'privacy': {
|
|
211
|
+
// alpha 6.13: real handler - the session module prints the
|
|
212
|
+
// contract doc + the current mode banner. Tail is ignored (no
|
|
213
|
+
// sub-commands today; mode flips go through
|
|
214
|
+
// `pugi config set privacy=<mode>` from a fresh shell so the
|
|
215
|
+
// device flow + audit identity are wired correctly).
|
|
216
|
+
return { kind: 'privacy' };
|
|
217
|
+
}
|
|
213
218
|
case 'compact':
|
|
214
219
|
case 'memory':
|
|
215
220
|
case 'config':
|
|
216
|
-
case 'privacy':
|
|
217
221
|
case 'budget':
|
|
218
222
|
case 'mcp':
|
|
219
223
|
case 'undo': {
|
|
224
|
+
const stubName = name;
|
|
220
225
|
return {
|
|
221
226
|
kind: 'stub',
|
|
222
|
-
name:
|
|
223
|
-
message: SLASH_STUB_MESSAGES[
|
|
227
|
+
name: stubName,
|
|
228
|
+
message: SLASH_STUB_MESSAGES[stubName],
|
|
224
229
|
};
|
|
225
230
|
}
|
|
226
231
|
default: {
|
package/dist/runtime/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
|
20
20
|
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
21
21
|
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
22
22
|
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
23
|
+
import { runDeployCommand } from '../commands/deploy.js';
|
|
23
24
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
24
25
|
import { runConfigCommand } from './commands/config.js';
|
|
25
26
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
@@ -31,6 +32,7 @@ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
|
31
32
|
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
32
33
|
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
33
34
|
import { slugForCwd } from '../core/repl/history.js';
|
|
35
|
+
import { dispatchEdit, } from '../core/edits/index.js';
|
|
34
36
|
/**
|
|
35
37
|
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
36
38
|
*
|
|
@@ -42,7 +44,7 @@ import { slugForCwd } from '../core/repl/history.js';
|
|
|
42
44
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
43
45
|
* three are in lockstep.
|
|
44
46
|
*/
|
|
45
|
-
const PUGI_CLI_VERSION = "0.1.0-alpha.
|
|
47
|
+
const PUGI_CLI_VERSION = "0.1.0-alpha.19";
|
|
46
48
|
const handlers = {
|
|
47
49
|
accounts,
|
|
48
50
|
agents: dispatchAgents,
|
|
@@ -51,6 +53,7 @@ const handlers = {
|
|
|
51
53
|
budget: dispatchBudget,
|
|
52
54
|
code: runEngineTask('code'),
|
|
53
55
|
config: dispatchConfig,
|
|
56
|
+
deploy: dispatchDeploy,
|
|
54
57
|
doctor,
|
|
55
58
|
explain: runEngineTask('explain'),
|
|
56
59
|
fix: runEngineTask('fix'),
|
|
@@ -524,6 +527,16 @@ async function help(_args, flags, _session) {
|
|
|
524
527
|
' pugi ask "<question>" Surface a yes/no question modal locally.',
|
|
525
528
|
' pugi plan-review <task> Generate + present a plan-review modal.',
|
|
526
529
|
'',
|
|
530
|
+
'Deploy:',
|
|
531
|
+
' pugi deploy --target vercel <vercelProject> --project <id>',
|
|
532
|
+
' Trigger a Vercel deployment from the bound Git source.',
|
|
533
|
+
' Optional: --target-env production|preview, --ref <ref>,',
|
|
534
|
+
' --integration <id>.',
|
|
535
|
+
' pugi deploy --target render <renderService> --project <id>',
|
|
536
|
+
' Trigger a Render deployment (Sprint 2 — stub today).',
|
|
537
|
+
' pugi deploy --status <id> Vendor-agnostic status snapshot.',
|
|
538
|
+
' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
|
|
539
|
+
'',
|
|
527
540
|
'Sync safety:',
|
|
528
541
|
' pugi sync --dry-run --privacy metadata',
|
|
529
542
|
'',
|
|
@@ -2138,6 +2151,45 @@ function runEngineTask(kind) {
|
|
|
2138
2151
|
risks: ['adapter terminated without emitting a result event'],
|
|
2139
2152
|
};
|
|
2140
2153
|
}
|
|
2154
|
+
// α6.6 diff escalation — Layer A/B/C dispatcher.
|
|
2155
|
+
//
|
|
2156
|
+
// Some models emit file edits as inline SEARCH/REPLACE markers in
|
|
2157
|
+
// the final response rather than through tool calls (especially
|
|
2158
|
+
// Gemini and o1 family, which under-use tool schemas in long
|
|
2159
|
+
// reasoning chains). We run the dispatcher against the model's
|
|
2160
|
+
// final text so those markers still land on disk. Tool-call edits
|
|
2161
|
+
// (Layer-A equivalent already handled by `edit`/`write` tools) are
|
|
2162
|
+
// unaffected — the dispatcher only fires on prose blocks that
|
|
2163
|
+
// happen to contain markers.
|
|
2164
|
+
//
|
|
2165
|
+
// Scope: code / fix / build / explain only. `plan` is read-only
|
|
2166
|
+
// (the engine refuses write tools), so even a stray marker in plan
|
|
2167
|
+
// output gets ignored to honour the plan-mode contract.
|
|
2168
|
+
//
|
|
2169
|
+
// Dry-run + read-only short-circuits: when the flags forbid writes
|
|
2170
|
+
// we dispatch with `dryRun: true` so the operator still sees what
|
|
2171
|
+
// WOULD have been written, but nothing touches disk.
|
|
2172
|
+
let dispatchResults = [];
|
|
2173
|
+
if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
|
|
2174
|
+
dispatchResults = await runMarkerDispatch({
|
|
2175
|
+
root,
|
|
2176
|
+
result: {
|
|
2177
|
+
status: result.status,
|
|
2178
|
+
summary: result.summary,
|
|
2179
|
+
eventRefs: result.eventRefs,
|
|
2180
|
+
},
|
|
2181
|
+
dryRun: flags.dryRun,
|
|
2182
|
+
});
|
|
2183
|
+
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
2184
|
+
// operator-facing summary lists them alongside tool-driven edits.
|
|
2185
|
+
for (const dr of dispatchResults) {
|
|
2186
|
+
if (dr.ok && dr.absPath) {
|
|
2187
|
+
const rel = relative(root, dr.absPath);
|
|
2188
|
+
if (!result.filesChanged.includes(rel))
|
|
2189
|
+
result.filesChanged.push(rel);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2141
2193
|
// For `plan` we always write a plan.md artifact, regardless of
|
|
2142
2194
|
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
2143
2195
|
// produces a reviewable artifact — the reason is recorded inline.
|
|
@@ -2200,6 +2252,16 @@ function runEngineTask(kind) {
|
|
|
2200
2252
|
sessionEventsMirror: metrics.mirror,
|
|
2201
2253
|
risks: result.risks,
|
|
2202
2254
|
plan: planArtifact ? { path: planArtifact.relPath } : undefined,
|
|
2255
|
+
// α6.6 — per-edit dispatcher trace. Empty array when no inline
|
|
2256
|
+
// markers were detected in the model's final response.
|
|
2257
|
+
diffEdits: dispatchResults.map((dr) => ({
|
|
2258
|
+
layer: dr.layer,
|
|
2259
|
+
file: dr.file,
|
|
2260
|
+
ok: dr.ok,
|
|
2261
|
+
bytesWritten: dr.bytesWritten,
|
|
2262
|
+
reason: dr.reason,
|
|
2263
|
+
detail: dr.detail,
|
|
2264
|
+
})),
|
|
2203
2265
|
// The full event stream is useful for cabinet UI replay. We surface
|
|
2204
2266
|
// it in JSON mode only — text mode operators want the summary, not
|
|
2205
2267
|
// 30 turn-level lines.
|
|
@@ -2220,6 +2282,19 @@ function runEngineTask(kind) {
|
|
|
2220
2282
|
textLines.push('Files modified: none');
|
|
2221
2283
|
}
|
|
2222
2284
|
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
2285
|
+
if (dispatchResults.length > 0) {
|
|
2286
|
+
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
2287
|
+
const failCount = dispatchResults.length - okCount;
|
|
2288
|
+
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
2289
|
+
for (const dr of dispatchResults) {
|
|
2290
|
+
if (dr.ok) {
|
|
2291
|
+
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
2292
|
+
}
|
|
2293
|
+
else {
|
|
2294
|
+
textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2223
2298
|
if (result.risks.length > 0) {
|
|
2224
2299
|
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
2225
2300
|
}
|
|
@@ -2229,6 +2304,96 @@ function runEngineTask(kind) {
|
|
|
2229
2304
|
writeOutput(flags, payload, textLines.join('\n'));
|
|
2230
2305
|
};
|
|
2231
2306
|
}
|
|
2307
|
+
// Exported for the α6.6.1 triple-review remediation spec
|
|
2308
|
+
// (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
|
|
2309
|
+
// surface is not part of the public CLI API; this is a test seam.
|
|
2310
|
+
export async function runMarkerDispatch(input) {
|
|
2311
|
+
const { root, result, dryRun } = input;
|
|
2312
|
+
const dispatch = input.dispatchFn ?? dispatchEdit;
|
|
2313
|
+
// Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
|
|
2314
|
+
// engine's terminal status. A `blocked` (budget_exhausted, plan-mode
|
|
2315
|
+
// refusal) or `failed` result may still carry markers in the
|
|
2316
|
+
// partial `summary` text — applying them would mutate files the
|
|
2317
|
+
// CLI then exits non-zero on, leaving the workspace in an
|
|
2318
|
+
// unexpected state with no operator signal that "blocked but with
|
|
2319
|
+
// side effects" happened. Only `done` is allowed to write.
|
|
2320
|
+
if (result.status !== 'done')
|
|
2321
|
+
return [];
|
|
2322
|
+
// Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
|
|
2323
|
+
// from the body before scanning. The prefixes start with `[`; the
|
|
2324
|
+
// dispatcher tolerates leading prose but the cleaner the input the
|
|
2325
|
+
// less chance of accidental marker matches.
|
|
2326
|
+
const body = result.summary;
|
|
2327
|
+
if (!hasAnyMarkerSignal(body))
|
|
2328
|
+
return [];
|
|
2329
|
+
const modelTag = extractModelTag(result.eventRefs);
|
|
2330
|
+
try {
|
|
2331
|
+
return await dispatch(body, {
|
|
2332
|
+
modelTag,
|
|
2333
|
+
cwd: root,
|
|
2334
|
+
dryRun,
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
catch (error) {
|
|
2338
|
+
// Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
|
|
2339
|
+
// swallowed parser/applicator crashes silently — the operator
|
|
2340
|
+
// saw a clean "0 applied" rather than the actual stack trace,
|
|
2341
|
+
// and the bug only surfaced when someone manually `pugi resume`-d
|
|
2342
|
+
// a session and noticed the missing edits. Surface the failure
|
|
2343
|
+
// both to stderr (so live operators see it) and as a synthetic
|
|
2344
|
+
// DispatchResult (so JSON consumers and the audit log record it).
|
|
2345
|
+
//
|
|
2346
|
+
// R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
|
|
2347
|
+
// returned `detail: message` — i.e. the raw `Error.message`. That
|
|
2348
|
+
// string is constructed by whatever code path threw (parser,
|
|
2349
|
+
// applicator, fs layer, etc.) and may contain absolute paths,
|
|
2350
|
+
// secret fragments echoed in `oldString` context, or other
|
|
2351
|
+
// stack-bearing internals. The audit log and any JSON consumer
|
|
2352
|
+
// that surfaces `detail` to the operator (or worse, to a remote
|
|
2353
|
+
// monitoring pipe) would leak them. Stack already goes to stderr
|
|
2354
|
+
// for live diagnosis; the returned result must carry a safe,
|
|
2355
|
+
// static string so consumers can still detect "dispatcher
|
|
2356
|
+
// crashed" without re-rendering the underlying exception.
|
|
2357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2358
|
+
const stack = error instanceof Error && error.stack ? error.stack : message;
|
|
2359
|
+
process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
|
|
2360
|
+
return [
|
|
2361
|
+
{
|
|
2362
|
+
layer: 'layer-a',
|
|
2363
|
+
file: '',
|
|
2364
|
+
ok: false,
|
|
2365
|
+
bytesWritten: 0,
|
|
2366
|
+
reason: 'dispatcher_crash',
|
|
2367
|
+
detail: 'dispatcher crashed - see stderr for stack trace',
|
|
2368
|
+
},
|
|
2369
|
+
];
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Quick pre-filter: does the body contain ANY of the marker
|
|
2374
|
+
* signatures the dispatcher knows about? Saves a full parse on every
|
|
2375
|
+
* model response (most responses are pure prose and would otherwise
|
|
2376
|
+
* round-trip through the parser pointlessly).
|
|
2377
|
+
*/
|
|
2378
|
+
function hasAnyMarkerSignal(body) {
|
|
2379
|
+
return (body.includes('+++ NEW') ||
|
|
2380
|
+
body.includes('<<<<<<< SEARCH') ||
|
|
2381
|
+
body.includes('@@@ REWRITE') ||
|
|
2382
|
+
body.includes('@@@ AST') ||
|
|
2383
|
+
/^--- a\//m.test(body));
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Extract `model=<tag>` from eventRefs if the adapter emitted it.
|
|
2387
|
+
* Returns undefined when missing; dispatchEdit then auto-detects from
|
|
2388
|
+
* the payload itself.
|
|
2389
|
+
*/
|
|
2390
|
+
function extractModelTag(refs) {
|
|
2391
|
+
for (const ref of refs) {
|
|
2392
|
+
if (ref.startsWith('model='))
|
|
2393
|
+
return ref.slice('model='.length);
|
|
2394
|
+
}
|
|
2395
|
+
return undefined;
|
|
2396
|
+
}
|
|
2232
2397
|
/**
|
|
2233
2398
|
* Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
|
|
2234
2399
|
* already emits the canonical strings (`tool_calls=N`, `turns=N`,
|
|
@@ -3502,6 +3667,33 @@ async function jobs(args, flags, session) {
|
|
|
3502
3667
|
process.exitCode = exitCode;
|
|
3503
3668
|
}
|
|
3504
3669
|
}
|
|
3670
|
+
/**
|
|
3671
|
+
* `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
|
|
3672
|
+
* `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
|
|
3673
|
+
* to the deploy-specific flag set + exposes the credential store via
|
|
3674
|
+
* `resolveRuntimeConfig` so the deploy module stays decoupled from the
|
|
3675
|
+
* CLI's auth bootstrap.
|
|
3676
|
+
*/
|
|
3677
|
+
async function dispatchDeploy(args, flags, _session) {
|
|
3678
|
+
// Triple-review #391 P2: the global `parseArgs` in this file consumes
|
|
3679
|
+
// `--json` before the per-command args reach us, so `runDeployCommand`'s
|
|
3680
|
+
// internal parser never sees it and the JSON envelope path is silently
|
|
3681
|
+
// skipped. Re-inject the flag so downstream parsing surfaces the JSON
|
|
3682
|
+
// output contract the operator asked for. Idempotent: if the user wrote
|
|
3683
|
+
// `pugi deploy --json ...` the global parser stripped it; if they wrote
|
|
3684
|
+
// `pugi --json deploy ...` ditto. Either way the global flag is the
|
|
3685
|
+
// single source of truth and we forward it verbatim.
|
|
3686
|
+
const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
|
|
3687
|
+
const exitCode = await runDeployCommand(forwardedArgs, {
|
|
3688
|
+
write: (text) => process.stdout.write(text),
|
|
3689
|
+
writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
3690
|
+
}, {
|
|
3691
|
+
resolveConfig: () => resolveRuntimeConfig(),
|
|
3692
|
+
});
|
|
3693
|
+
if (exitCode !== 0) {
|
|
3694
|
+
process.exitCode = exitCode;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3505
3697
|
function notImplemented(command) {
|
|
3506
3698
|
return async (_args, flags) => {
|
|
3507
3699
|
const payload = {
|
|
@@ -58,6 +58,8 @@ export async function runConfigCommand(args, ctx) {
|
|
|
58
58
|
'pugi config get routing',
|
|
59
59
|
'pugi config set routing.<tag>.<budget>=<model>',
|
|
60
60
|
'pugi config unset routing.<tag>.<budget>',
|
|
61
|
+
'pugi config get privacy',
|
|
62
|
+
'pugi config set privacy=strict|balanced|permissive',
|
|
61
63
|
],
|
|
62
64
|
}, [
|
|
63
65
|
'Usage:',
|
|
@@ -71,6 +73,8 @@ export async function runConfigCommand(args, ctx) {
|
|
|
71
73
|
' pugi config get routing Show effective routing table (defaults + tenant overrides).',
|
|
72
74
|
' pugi config set routing.<tag>.<budget>=<model> Override the model for one (tag, budget) lane.',
|
|
73
75
|
' pugi config unset routing.<tag>.<budget> Remove a routing override (revert to default).',
|
|
76
|
+
' pugi config get privacy Show current tenant privacy mode + last-flip metadata.',
|
|
77
|
+
' pugi config set privacy=<mode> Flip privacy mode (strict | balanced | permissive).',
|
|
74
78
|
].join('\n'));
|
|
75
79
|
return;
|
|
76
80
|
}
|
|
@@ -81,6 +85,13 @@ export async function runConfigCommand(args, ctx) {
|
|
|
81
85
|
if (args[1] === 'routing') {
|
|
82
86
|
return runRoutingGet(ctx);
|
|
83
87
|
}
|
|
88
|
+
// alpha 6.13: `pugi config get privacy` hits the admin-api
|
|
89
|
+
// /api/admin/privacy/mode surface. The privacy mode is a tenant-
|
|
90
|
+
// scoped server-side setting (so the Anvil filter can enforce it),
|
|
91
|
+
// not a local-only preference.
|
|
92
|
+
if (args[1] === 'privacy') {
|
|
93
|
+
return runPrivacyGet(ctx);
|
|
94
|
+
}
|
|
84
95
|
return runConfigGet(args.slice(1), ctx);
|
|
85
96
|
case 'set':
|
|
86
97
|
// Special form: `pugi config set routing.<tag>.<budget>=<model>` hits
|
|
@@ -88,6 +99,40 @@ export async function runConfigCommand(args, ctx) {
|
|
|
88
99
|
if (args[1] && args[1].startsWith('routing.')) {
|
|
89
100
|
return runRoutingSet(args.slice(1), ctx);
|
|
90
101
|
}
|
|
102
|
+
// alpha 6.13: `pugi config set privacy=<mode>` (or `privacy <mode>`)
|
|
103
|
+
// flips the tenant-scoped server-side mode IFF <mode> is one of
|
|
104
|
+
// the new closed set (strict | balanced | permissive). Legacy
|
|
105
|
+
// values (local-only | metadata | full) still hit the local
|
|
106
|
+
// config schema via runConfigSet so existing operators do not
|
|
107
|
+
// get a surprise 4xx when their playbook still uses the old
|
|
108
|
+
// names. The unit spec for config.ts has a regression test for
|
|
109
|
+
// both code paths.
|
|
110
|
+
//
|
|
111
|
+
// Triple-review P2 fix (2026-05-25): the prior disambiguation
|
|
112
|
+
// only excluded the bare form (`privacy local-only`) - the `=`
|
|
113
|
+
// form (`privacy=local-only`) still routed to runPrivacySet and
|
|
114
|
+
// 4xx'd. We now check the value AFTER `=` and route legacy local
|
|
115
|
+
// values to runConfigSet; new-style values + unknown values
|
|
116
|
+
// continue to runPrivacySet so its client-side validator surfaces
|
|
117
|
+
// a structured "Unknown privacy mode" error (preserves the
|
|
118
|
+
// existing UX for typos like `privacy=paranoid`).
|
|
119
|
+
if (args[1]) {
|
|
120
|
+
const equalsForm = args[1].startsWith('privacy=');
|
|
121
|
+
const bareForm = args[1] === 'privacy';
|
|
122
|
+
if (equalsForm) {
|
|
123
|
+
const valueAfterEquals = args[1].slice('privacy='.length);
|
|
124
|
+
if (isLegacyLocalPrivacyValue(valueAfterEquals)) {
|
|
125
|
+
// Legacy local form (`privacy=local-only|metadata|full`) -
|
|
126
|
+
// split into `['privacy', '<value>']` so runConfigSet sees
|
|
127
|
+
// the same shape it would for the bare form.
|
|
128
|
+
return runConfigSet(['privacy', valueAfterEquals], ctx);
|
|
129
|
+
}
|
|
130
|
+
return runPrivacySet(args.slice(1), ctx);
|
|
131
|
+
}
|
|
132
|
+
if (bareForm && isNewPrivacyModeValue(args[2])) {
|
|
133
|
+
return runPrivacySet(args.slice(1), ctx);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
91
136
|
return runConfigSet(args.slice(1), ctx);
|
|
92
137
|
case 'unset':
|
|
93
138
|
if (args[1] && args[1].startsWith('routing.')) {
|
|
@@ -393,6 +438,97 @@ async function runRoutingUnset(args, ctx) {
|
|
|
393
438
|
? `routing.${tag}.${budget} reverted to default.`
|
|
394
439
|
: `routing.${tag}.${budget} had no override (nothing to remove).`);
|
|
395
440
|
}
|
|
441
|
+
/* ------------------------------------------------------------------ */
|
|
442
|
+
/* alpha 6.13 privacy 3-mode - config.privacy.* subcommands */
|
|
443
|
+
/* ------------------------------------------------------------------ */
|
|
444
|
+
/**
|
|
445
|
+
* Closed mirror of the server-side PRIVACY_MODES enum
|
|
446
|
+
* (apps/admin-api/src/privacy/privacy-mode.ts). Pinning the literal
|
|
447
|
+
* set here lets the CLI reject typos client-side before the round-
|
|
448
|
+
* trip to admin-api (better UX, smaller blast radius on a flaky
|
|
449
|
+
* network).
|
|
450
|
+
*/
|
|
451
|
+
const PRIVACY_MODES = ['strict', 'balanced', 'permissive'];
|
|
452
|
+
function isNewPrivacyModeValue(value) {
|
|
453
|
+
return typeof value === 'string' && PRIVACY_MODES.includes(value);
|
|
454
|
+
}
|
|
455
|
+
function isPrivacyMode(value) {
|
|
456
|
+
return PRIVACY_MODES.includes(value);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Legacy local-config privacy values from before alpha 6.13. Kept so
|
|
460
|
+
* `pugi config set privacy=local-only` continues to write to the local
|
|
461
|
+
* config file (matching the bare-form behaviour). Triple-review P2 fix
|
|
462
|
+
* (2026-05-25): the prior disambiguation only excluded the bare form;
|
|
463
|
+
* the `=` form routed to runPrivacySet and 4xx'd on the unknown mode.
|
|
464
|
+
*/
|
|
465
|
+
const LEGACY_LOCAL_PRIVACY_VALUES = [
|
|
466
|
+
'local-only',
|
|
467
|
+
'metadata',
|
|
468
|
+
'full',
|
|
469
|
+
];
|
|
470
|
+
function isLegacyLocalPrivacyValue(value) {
|
|
471
|
+
return (typeof value === 'string' &&
|
|
472
|
+
LEGACY_LOCAL_PRIVACY_VALUES.includes(value));
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* `pugi config get privacy` - fetch the current privacy mode snapshot
|
|
476
|
+
* from /api/admin/privacy/mode + render it in human-readable form.
|
|
477
|
+
*/
|
|
478
|
+
async function runPrivacyGet(ctx) {
|
|
479
|
+
const { apiUrl, apiKey } = resolveAdminApi();
|
|
480
|
+
const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey);
|
|
481
|
+
const lastUpdatedLine = snapshot.lastUpdated
|
|
482
|
+
? `(last set by ${snapshot.lastUpdatedBy ?? 'unknown'} on ${snapshot.lastUpdated})`
|
|
483
|
+
: '(no flips recorded; on implicit default)';
|
|
484
|
+
const text = [
|
|
485
|
+
`privacy.mode = ${snapshot.mode}`,
|
|
486
|
+
`privacy.defaultMode = ${snapshot.defaultMode}`,
|
|
487
|
+
lastUpdatedLine,
|
|
488
|
+
].join('\n');
|
|
489
|
+
ctx.writeOutput({
|
|
490
|
+
command: 'config.privacy.get',
|
|
491
|
+
apiUrl,
|
|
492
|
+
snapshot,
|
|
493
|
+
}, text);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* `pugi config set privacy=<mode>` - PUT to /api/admin/privacy/mode.
|
|
497
|
+
* Validates the mode client-side against the closed set before
|
|
498
|
+
* round-tripping.
|
|
499
|
+
*
|
|
500
|
+
* Accepts both `privacy <mode>` and `privacy=<mode>` argument forms so
|
|
501
|
+
* the operator can type it either way.
|
|
502
|
+
*/
|
|
503
|
+
async function runPrivacySet(args, ctx) {
|
|
504
|
+
const raw = args.join(' ').trim();
|
|
505
|
+
let mode;
|
|
506
|
+
if (raw.startsWith('privacy=')) {
|
|
507
|
+
mode = raw.slice('privacy='.length).trim();
|
|
508
|
+
}
|
|
509
|
+
else if (raw === 'privacy') {
|
|
510
|
+
throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
|
|
511
|
+
}
|
|
512
|
+
else if (raw.startsWith('privacy ')) {
|
|
513
|
+
mode = raw.slice('privacy '.length).trim();
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
throw new Error(`pugi config set privacy: unrecognised argument "${raw}". Try: pugi config set privacy=balanced`);
|
|
517
|
+
}
|
|
518
|
+
if (mode.length === 0) {
|
|
519
|
+
throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
|
|
520
|
+
}
|
|
521
|
+
if (!isPrivacyMode(mode)) {
|
|
522
|
+
throw new Error(`Unknown privacy mode "${mode}". Allowed: ${PRIVACY_MODES.join(', ')}.`);
|
|
523
|
+
}
|
|
524
|
+
const { apiUrl, apiKey } = resolveAdminApi();
|
|
525
|
+
const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey, { method: 'PUT', body: { mode } });
|
|
526
|
+
ctx.writeOutput({
|
|
527
|
+
command: 'config.privacy.set',
|
|
528
|
+
mode: snapshot.mode,
|
|
529
|
+
snapshot,
|
|
530
|
+
}, `privacy.mode = ${snapshot.mode}`);
|
|
531
|
+
}
|
|
396
532
|
/**
|
|
397
533
|
* Thin authenticated fetch helper. Adds the bearer token + accepts JSON +
|
|
398
534
|
* surfaces structured errors. Uses undici `request` (not native `fetch`)
|