@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.1
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/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -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/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1882 -12
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +721 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +157 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
package/dist/runtime/cli.js
CHANGED
|
@@ -16,14 +16,23 @@ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
|
16
16
|
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
17
17
|
import { webFetchTool } from '../tools/web-fetch.js';
|
|
18
18
|
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
19
|
+
import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
19
20
|
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
20
21
|
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
21
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';
|
|
22
24
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
23
25
|
import { runConfigCommand } from './commands/config.js';
|
|
24
26
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
25
27
|
import { runUndoCommand } from './commands/undo.js';
|
|
26
28
|
import { runBudgetCommand } from './commands/budget.js';
|
|
29
|
+
import { runSkillsCommand } from './commands/skills.js';
|
|
30
|
+
import { runAgentsCommand } from './commands/agents.js';
|
|
31
|
+
import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
32
|
+
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
33
|
+
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
34
|
+
import { slugForCwd } from '../core/repl/history.js';
|
|
35
|
+
import { dispatchEdit, } from '../core/edits/index.js';
|
|
27
36
|
/**
|
|
28
37
|
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
29
38
|
*
|
|
@@ -35,13 +44,16 @@ import { runBudgetCommand } from './commands/budget.js';
|
|
|
35
44
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
36
45
|
* three are in lockstep.
|
|
37
46
|
*/
|
|
38
|
-
const PUGI_CLI_VERSION =
|
|
47
|
+
const PUGI_CLI_VERSION = "0.1.0-beta.1";
|
|
39
48
|
const handlers = {
|
|
40
49
|
accounts,
|
|
50
|
+
agents: dispatchAgents,
|
|
51
|
+
ask: dispatchAsk,
|
|
41
52
|
build: runEngineTask('build_task'),
|
|
42
53
|
budget: dispatchBudget,
|
|
43
54
|
code: runEngineTask('code'),
|
|
44
55
|
config: dispatchConfig,
|
|
56
|
+
deploy: dispatchDeploy,
|
|
45
57
|
doctor,
|
|
46
58
|
explain: runEngineTask('explain'),
|
|
47
59
|
fix: runEngineTask('fix'),
|
|
@@ -53,16 +65,174 @@ const handlers = {
|
|
|
53
65
|
login,
|
|
54
66
|
logout,
|
|
55
67
|
plan: runEngineTask('plan'),
|
|
68
|
+
'plan-review': dispatchPlanReview,
|
|
56
69
|
privacy: dispatchPrivacy,
|
|
57
70
|
review,
|
|
58
71
|
resume,
|
|
59
72
|
sessions,
|
|
73
|
+
skills: dispatchSkills,
|
|
60
74
|
sync,
|
|
61
75
|
undo: dispatchUndo,
|
|
62
76
|
version,
|
|
63
77
|
web: dispatchWeb,
|
|
64
78
|
whoami,
|
|
65
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
|
|
82
|
+
* modal manually. In an interactive TTY we mount a tiny Ink app, render
|
|
83
|
+
* the `<AskModal />`, and await the operator's verdict. In non-TTY
|
|
84
|
+
* (CI / pipes), we emit the structured ask JSON to stdout so scripted
|
|
85
|
+
* callers can pipe the response back without rendering a modal.
|
|
86
|
+
*
|
|
87
|
+
* The verdict is printed to stdout as either:
|
|
88
|
+
* - the chosen option `value` (one of the yes/no defaults)
|
|
89
|
+
* - `other:<text>` when the operator typed a custom answer
|
|
90
|
+
* - `cancelled` when the operator pressed Esc
|
|
91
|
+
*
|
|
92
|
+
* This is a CLI-side helper. The REPL slash `/ask` is wired separately
|
|
93
|
+
* through `slash-commands.ts`.
|
|
94
|
+
*/
|
|
95
|
+
async function dispatchAsk(args, flags, _session) {
|
|
96
|
+
const question = args.join(' ').trim();
|
|
97
|
+
if (!question) {
|
|
98
|
+
writeOutput(flags, { ok: false, error: 'Usage: pugi ask "<question>"' }, 'Usage: pugi ask "<question>"');
|
|
99
|
+
process.exitCode = 2;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const { synthesiseLocalAskTag } = await import('../core/repl/session.js');
|
|
103
|
+
const tag = synthesiseLocalAskTag(question);
|
|
104
|
+
if (!tag) {
|
|
105
|
+
writeOutput(flags, { ok: false, error: 'Question must be 1-80 chars.' }, 'pugi ask: question must be 1-80 chars.');
|
|
106
|
+
process.exitCode = 2;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!isInteractive(flags)) {
|
|
110
|
+
// Non-TTY: emit the structured ask payload so scripted callers can
|
|
111
|
+
// forward it. The interactive modal is only meaningful with a real
|
|
112
|
+
// terminal, so the line-buffered fallback prints the question +
|
|
113
|
+
// options and exits 0 — callers parse the JSON.
|
|
114
|
+
const payload = {
|
|
115
|
+
ok: true,
|
|
116
|
+
ask: {
|
|
117
|
+
question: tag.question,
|
|
118
|
+
options: tag.options,
|
|
119
|
+
signature: tag.signature,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
writeOutput(flags, payload, [
|
|
123
|
+
`Question: ${tag.question}`,
|
|
124
|
+
...tag.options.map((o, i) => ` ${i + 1}. ${o.label}${o.desc ? ` - ${o.desc}` : ''}`),
|
|
125
|
+
` ${tag.options.length + 1}. Other (custom)`,
|
|
126
|
+
'',
|
|
127
|
+
'(non-TTY: re-run in a real terminal to answer interactively, or pipe an answer to stdin)',
|
|
128
|
+
].join('\n'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Interactive: render the Ink modal and await resolution.
|
|
132
|
+
const { renderAskCli } = await import('../tui/ask-cli.js');
|
|
133
|
+
const verdict = await renderAskCli({ tag });
|
|
134
|
+
const encoded = verdict.cancelled
|
|
135
|
+
? 'cancelled'
|
|
136
|
+
: verdict.value.length > 0
|
|
137
|
+
? verdict.value
|
|
138
|
+
: `other:${verdict.customInput ?? ''}`;
|
|
139
|
+
writeOutput(flags, { ok: true, verdict: encoded }, encoded);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* α6.3 `pugi plan-review <task>` — generate + present a plan WITHOUT
|
|
143
|
+
* executing. The legacy `pugi plan` surface (offline plan generator,
|
|
144
|
+
* see runEngineTask) stays intact; `plan-review` adds the office-hours
|
|
145
|
+
* Ink modal layer on top so the operator can approve/modify/cancel
|
|
146
|
+
* before the orchestrator dispatches Marcus.
|
|
147
|
+
*
|
|
148
|
+
* Phase 1 implementation: build a deterministic plan stub from the
|
|
149
|
+
* task description (the persona-driven planner ships in a follow-up
|
|
150
|
+
* sprint). The plan is presented through the standard
|
|
151
|
+
* `<pugi-plan-review>` modal in interactive mode; non-TTY emits the
|
|
152
|
+
* structured payload to stdout for scripted consumers.
|
|
153
|
+
*
|
|
154
|
+
* The exit code reflects the operator's verdict:
|
|
155
|
+
* - 0 PASS approved
|
|
156
|
+
* - 1 MODIFY modify (text printed)
|
|
157
|
+
* - 2 CANCEL cancel
|
|
158
|
+
*/
|
|
159
|
+
async function dispatchPlanReview(args, flags, _session) {
|
|
160
|
+
const task = args.join(' ').trim();
|
|
161
|
+
if (!task) {
|
|
162
|
+
writeOutput(flags, { ok: false, error: 'Usage: pugi plan <task description>' }, 'Usage: pugi plan <task description>');
|
|
163
|
+
process.exitCode = 2;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const planTag = synthesiseLocalPlanReview(task);
|
|
167
|
+
if (!isInteractive(flags)) {
|
|
168
|
+
const payload = {
|
|
169
|
+
ok: true,
|
|
170
|
+
plan: {
|
|
171
|
+
steps: planTag.steps,
|
|
172
|
+
risk: planTag.risk,
|
|
173
|
+
signature: planTag.signature,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
writeOutput(flags, payload, [
|
|
177
|
+
'Plan review (non-execution):',
|
|
178
|
+
...planTag.steps.map((s, i) => ` ${i + 1}. ${s.text}`),
|
|
179
|
+
planTag.risk ? `Risk: ${planTag.risk}` : '',
|
|
180
|
+
'',
|
|
181
|
+
'(non-TTY: re-run in a real terminal to approve/modify/cancel interactively)',
|
|
182
|
+
]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join('\n'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { renderPlanReviewCli } = await import('../tui/ask-cli.js');
|
|
188
|
+
const result = await renderPlanReviewCli({ tag: planTag });
|
|
189
|
+
switch (result.verdict) {
|
|
190
|
+
case 'approve':
|
|
191
|
+
writeOutput(flags, { ok: true, verdict: 'approve' }, 'approve');
|
|
192
|
+
return;
|
|
193
|
+
case 'modify':
|
|
194
|
+
writeOutput(flags, { ok: true, verdict: 'modify', modifyText: result.modifyText ?? '' }, `modify: ${result.modifyText ?? ''}`);
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
return;
|
|
197
|
+
case 'cancel':
|
|
198
|
+
writeOutput(flags, { ok: true, verdict: 'cancel' }, 'cancel');
|
|
199
|
+
process.exitCode = 2;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Local plan stub generator. Until the persona-side planner lands, we
|
|
205
|
+
* produce a deterministic 3-step skeleton anchored to the task text so
|
|
206
|
+
* the operator can dry-run the modal interaction. Real plan synthesis
|
|
207
|
+
* arrives in a follow-up sprint.
|
|
208
|
+
*/
|
|
209
|
+
function synthesiseLocalPlanReview(task) {
|
|
210
|
+
const truncated = task.length > 80 ? task.slice(0, 77) + '...' : task;
|
|
211
|
+
const steps = [
|
|
212
|
+
{ text: `1. Understand the task: ${truncated}` },
|
|
213
|
+
{ text: '2. Identify scope, files touched, side effects.' },
|
|
214
|
+
{ text: '3. Execute with verification gates per Pugi defaults.' },
|
|
215
|
+
];
|
|
216
|
+
const risk = task.length > 200
|
|
217
|
+
? 'Long task description - consider splitting into smaller briefs.'
|
|
218
|
+
: undefined;
|
|
219
|
+
// Route through the single-source signature helper from ask.ts so a
|
|
220
|
+
// parser-extracted plan-review with identical content collides
|
|
221
|
+
// deterministically with this synthesised one. Inlining the formula
|
|
222
|
+
// here (as the original implementation did) silently drifted from
|
|
223
|
+
// signatureForPlanReview when the helper added `.trim()` to each
|
|
224
|
+
// step. Codex triple-review P2 (PR #375).
|
|
225
|
+
const signature = signatureForPlanReview(steps, risk ?? null);
|
|
226
|
+
const tag = {
|
|
227
|
+
steps,
|
|
228
|
+
signature,
|
|
229
|
+
start: 0,
|
|
230
|
+
end: 0,
|
|
231
|
+
};
|
|
232
|
+
if (risk)
|
|
233
|
+
tag.risk = risk;
|
|
234
|
+
return tag;
|
|
235
|
+
}
|
|
66
236
|
async function dispatchConfig(args, flags, _session) {
|
|
67
237
|
await runConfigCommand(args, {
|
|
68
238
|
workspaceRoot: process.cwd(),
|
|
@@ -89,6 +259,20 @@ async function dispatchBudget(args, flags, _session) {
|
|
|
89
259
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
90
260
|
});
|
|
91
261
|
}
|
|
262
|
+
async function dispatchSkills(args, flags, _session) {
|
|
263
|
+
await runSkillsCommand(args, {
|
|
264
|
+
workspaceRoot: process.cwd(),
|
|
265
|
+
json: flags.json,
|
|
266
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function dispatchAgents(args, flags, _session) {
|
|
270
|
+
await runAgentsCommand(args, {
|
|
271
|
+
workspaceRoot: process.cwd(),
|
|
272
|
+
json: flags.json,
|
|
273
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
92
276
|
/**
|
|
93
277
|
* `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
|
|
94
278
|
*
|
|
@@ -170,6 +354,7 @@ export async function runCli(argv) {
|
|
|
170
354
|
cliVersion: PUGI_CLI_VERSION,
|
|
171
355
|
updateBanner,
|
|
172
356
|
skipSplash: flags.noSplash,
|
|
357
|
+
hideToolStream: flags.noToolStream,
|
|
173
358
|
});
|
|
174
359
|
return;
|
|
175
360
|
}
|
|
@@ -201,11 +386,24 @@ function parseArgs(argv) {
|
|
|
201
386
|
web: false,
|
|
202
387
|
dryRun: false,
|
|
203
388
|
triple: false,
|
|
389
|
+
consensus: false,
|
|
204
390
|
offline: false,
|
|
205
391
|
noTty: false,
|
|
206
392
|
allowFetch: false,
|
|
207
393
|
noUpdateCheck: false,
|
|
208
394
|
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
395
|
+
// Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
|
|
396
|
+
// until backend ships `tool.start`/`tool.result` SSE events. Current
|
|
397
|
+
// client-side synthesiser parses persona prose for `Read(...)` /
|
|
398
|
+
// `Bash(...)` patterns — never fires in production (admin-api emits
|
|
399
|
+
// "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
|
|
400
|
+
// accidental `Verb(noun)` shapes producing stuck `running` rows.
|
|
401
|
+
// Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
|
|
402
|
+
// for development/testing. Will flip к default ON when backend
|
|
403
|
+
// emits real tool events (filed as α6.13.X follow-up).
|
|
404
|
+
noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
405
|
+
? process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
406
|
+
: true,
|
|
209
407
|
};
|
|
210
408
|
const args = [];
|
|
211
409
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -232,9 +430,16 @@ function parseArgs(argv) {
|
|
|
232
430
|
else if (arg === '--dry-run') {
|
|
233
431
|
flags.dryRun = true;
|
|
234
432
|
}
|
|
235
|
-
else if (arg === '--triple'
|
|
433
|
+
else if (arg === '--triple') {
|
|
236
434
|
flags.triple = true;
|
|
237
435
|
}
|
|
436
|
+
else if (arg === '--consensus') {
|
|
437
|
+
// α6.7: customer-facing 3-model consensus review. Routes through
|
|
438
|
+
// the SSE-based runtime gate rather than the legacy artifact
|
|
439
|
+
// writer. The triple flag stays unset так the existing
|
|
440
|
+
// performRemoteTripleReview path is never accidentally entered.
|
|
441
|
+
flags.consensus = true;
|
|
442
|
+
}
|
|
238
443
|
else if (arg === '--offline') {
|
|
239
444
|
flags.offline = true;
|
|
240
445
|
}
|
|
@@ -250,6 +455,14 @@ function parseArgs(argv) {
|
|
|
250
455
|
else if (arg === '--no-splash') {
|
|
251
456
|
flags.noSplash = true;
|
|
252
457
|
}
|
|
458
|
+
else if (arg === '--no-tool-stream') {
|
|
459
|
+
flags.noToolStream = true;
|
|
460
|
+
}
|
|
461
|
+
else if (arg === '--tool-stream') {
|
|
462
|
+
// Opt-in для α6.12 dev/testing — backend tool events not live yet,
|
|
463
|
+
// pane shows синтесайз heuristic OR empty placeholder
|
|
464
|
+
flags.noToolStream = false;
|
|
465
|
+
}
|
|
253
466
|
else if (arg.startsWith('--privacy=')) {
|
|
254
467
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
255
468
|
}
|
|
@@ -299,6 +512,30 @@ async function help(_args, flags, _session) {
|
|
|
299
512
|
'',
|
|
300
513
|
'Review gate:',
|
|
301
514
|
' pugi review --triple Prepare the Anvil-backed triple-review gate.',
|
|
515
|
+
' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
|
|
516
|
+
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
517
|
+
' Exits 0 PASS · 1 WARN · 2 BLOCK.',
|
|
518
|
+
'',
|
|
519
|
+
'Skills + agents marketplace:',
|
|
520
|
+
' pugi skills list All installed skills.',
|
|
521
|
+
' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
|
|
522
|
+
' pugi skills info <name> Metadata + body preview.',
|
|
523
|
+
' pugi agents list All installed sub-agents.',
|
|
524
|
+
' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
|
|
525
|
+
'',
|
|
526
|
+
'Office-hours forcing questions (α6.3):',
|
|
527
|
+
' pugi ask "<question>" Surface a yes/no question modal locally.',
|
|
528
|
+
' pugi plan-review <task> Generate + present a plan-review modal.',
|
|
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.',
|
|
302
539
|
'',
|
|
303
540
|
'Sync safety:',
|
|
304
541
|
' pugi sync --dry-run --privacy metadata',
|
|
@@ -310,6 +547,8 @@ async function help(_args, flags, _session) {
|
|
|
310
547
|
' with PUGI_SKIP_UPDATE_BANNER=1.',
|
|
311
548
|
' --no-splash Skip the REPL boot splash. Pairs with',
|
|
312
549
|
' PUGI_SKIP_SPLASH=1.',
|
|
550
|
+
' --no-tool-stream Hide the live tool stream pane (α6.12).',
|
|
551
|
+
' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
|
|
313
552
|
'',
|
|
314
553
|
PUGI_TAGLINE,
|
|
315
554
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
@@ -774,6 +1013,26 @@ async function review(args, flags, session) {
|
|
|
774
1013
|
const root = process.cwd();
|
|
775
1014
|
ensureInitialized(root);
|
|
776
1015
|
const prompt = args.join(' ').trim();
|
|
1016
|
+
// α6.7: customer-facing consensus review routes here. Distinct from
|
|
1017
|
+
// `--triple --remote` (legacy artifact-writer flow) so the new SSE
|
|
1018
|
+
// streaming UX and rubric-driven exit codes don't disturb the existing
|
|
1019
|
+
// pugi-cli surfaces that depend on the old shape.
|
|
1020
|
+
if (flags.consensus) {
|
|
1021
|
+
const exitCode = await runReviewConsensus(args, {
|
|
1022
|
+
cwd: root,
|
|
1023
|
+
config: resolveRuntimeConfig(),
|
|
1024
|
+
json: flags.json,
|
|
1025
|
+
emit: (line) => {
|
|
1026
|
+
if (!flags.json)
|
|
1027
|
+
process.stdout.write(line);
|
|
1028
|
+
},
|
|
1029
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1030
|
+
});
|
|
1031
|
+
// Caller owns `process.exitCode` so a REPL slash invocation
|
|
1032
|
+
// ('/consensus') cannot inherit a stale exit code from a previous run.
|
|
1033
|
+
process.exitCode = exitCode;
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
777
1036
|
if (flags.triple && flags.remote) {
|
|
778
1037
|
await performRemoteTripleReview(root, session, flags, prompt);
|
|
779
1038
|
return;
|
|
@@ -1337,6 +1596,14 @@ async function handoff(args, flags, session) {
|
|
|
1337
1596
|
writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
|
|
1338
1597
|
}
|
|
1339
1598
|
async function sessions(args, flags, _session) {
|
|
1599
|
+
// α6.4: `pugi sessions --local` / `--search "query"` route to the
|
|
1600
|
+
// local SessionStore. The default surface stays artifact-based for
|
|
1601
|
+
// backward compat — operators who relied on the index.json view get
|
|
1602
|
+
// the same shape.
|
|
1603
|
+
if (args.includes('--local') || args.includes('--search')) {
|
|
1604
|
+
await sessionsLocal(args, flags);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1340
1607
|
const root = process.cwd();
|
|
1341
1608
|
ensureInitialized(root);
|
|
1342
1609
|
const rebuild = args.includes('--rebuild');
|
|
@@ -1411,6 +1678,72 @@ async function sessions(args, flags, _session) {
|
|
|
1411
1678
|
function hasStubSession(index) {
|
|
1412
1679
|
return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
|
|
1413
1680
|
}
|
|
1681
|
+
/**
|
|
1682
|
+
* α6.4: `pugi sessions --local` / `--search "query"` against the
|
|
1683
|
+
* SessionStore. The default `--local` mode lists the 10 most recent
|
|
1684
|
+
* sessions for the current project; `--search "query"` runs FTS5
|
|
1685
|
+
* against the title+body index.
|
|
1686
|
+
*/
|
|
1687
|
+
async function sessionsLocal(args, flags) {
|
|
1688
|
+
const cwd = process.cwd();
|
|
1689
|
+
const projectSlug = slugForCwd(cwd);
|
|
1690
|
+
const projectDir = resolveProjectStoreDir(projectSlug);
|
|
1691
|
+
if (!existsSync(resolve(projectDir, 'session.db'))) {
|
|
1692
|
+
writeOutput(flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
// Parse `--search "query"` or `--search query`.
|
|
1696
|
+
const searchIdx = args.indexOf('--search');
|
|
1697
|
+
const query = searchIdx >= 0 ? (args[searchIdx + 1] ?? '').trim() : '';
|
|
1698
|
+
if (query.length > 0) {
|
|
1699
|
+
let rows;
|
|
1700
|
+
try {
|
|
1701
|
+
rows = await searchLocalSessions(projectSlug, query);
|
|
1702
|
+
}
|
|
1703
|
+
catch (error) {
|
|
1704
|
+
// Surface FTS5 syntax errors as a clean one-line message + exit 2
|
|
1705
|
+
// so a stray `"` in the operator's input does not dump a stack
|
|
1706
|
+
// trace. Both the live-store path (FtsSyntaxError) and the
|
|
1707
|
+
// read-only fallback (SQLite error with code starting `SQLITE_`)
|
|
1708
|
+
// funnel here.
|
|
1709
|
+
const code = error?.code;
|
|
1710
|
+
if (error instanceof FtsSyntaxError
|
|
1711
|
+
|| (typeof code === 'string' && (code === 'EFTS5_SYNTAX' || code.startsWith('SQLITE_')))) {
|
|
1712
|
+
writeOutput(flags, { status: 'error', error: 'invalid search query', query }, `Invalid search query: '${query}'. Try simpler text (no unbalanced quotes).`);
|
|
1713
|
+
process.exitCode = 2;
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
throw error;
|
|
1717
|
+
}
|
|
1718
|
+
writeOutput(flags, { projectSlug, query, sessions: rows }, rows.length === 0
|
|
1719
|
+
? `No local sessions matched '${query}' for project '${projectSlug}'.`
|
|
1720
|
+
: `Search hits for '${query}' (${rows.length}):\n\n${rows
|
|
1721
|
+
.map((row) => ` ${row.id.slice(0, 13)} ${(row.title ?? '(untitled)').slice(0, 64)}`)
|
|
1722
|
+
.join('\n')}`);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const rows = await listLocalSessions(projectSlug);
|
|
1726
|
+
writeOutput(flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Run an FTS5 search against the local SessionStore. Opens the SQLite
|
|
1730
|
+
* file READ-ONLY via `SqliteSessionStore.openReadOnly` so the search
|
|
1731
|
+
* never takes the lockfile and never inserts a stub session row. Works
|
|
1732
|
+
* whether or not a live REPL holds the writer lock — SQLite supports
|
|
1733
|
+
* concurrent readers + a single writer.
|
|
1734
|
+
*
|
|
1735
|
+
* FTS syntax errors surface as `FtsSyntaxError` (code `EFTS5_SYNTAX`);
|
|
1736
|
+
* the dispatcher catches that + exits 2 with a clean message.
|
|
1737
|
+
*/
|
|
1738
|
+
async function searchLocalSessions(projectSlug, query) {
|
|
1739
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1740
|
+
try {
|
|
1741
|
+
return await view.search(query, { limit: 20 });
|
|
1742
|
+
}
|
|
1743
|
+
finally {
|
|
1744
|
+
await view.close();
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1414
1747
|
function registerArtifact(root, artifact) {
|
|
1415
1748
|
// Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
|
|
1416
1749
|
// that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
|
|
@@ -1443,6 +1776,19 @@ function registerArtifact(root, artifact) {
|
|
|
1443
1776
|
}
|
|
1444
1777
|
async function resume(args, flags, session) {
|
|
1445
1778
|
const root = process.cwd();
|
|
1779
|
+
// α6.4: `pugi resume [<local-session-id>]` and `pugi resume --list`
|
|
1780
|
+
// operate on the LOCAL SessionStore under `~/.pugi/projects/<slug>/`
|
|
1781
|
+
// before falling back to the legacy artifact-based resume. The
|
|
1782
|
+
// local-session path requires no `.pugi/` directory in the cwd
|
|
1783
|
+
// (the store lives under $HOME) so we run it BEFORE ensureInitialized.
|
|
1784
|
+
const wantsList = args.includes('--list');
|
|
1785
|
+
const arg0 = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
|
|
1786
|
+
const looksLikeSessionId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(arg0) : false;
|
|
1787
|
+
const looksLikeSessionShortId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}$/i.test(arg0) : false;
|
|
1788
|
+
if (wantsList || looksLikeSessionId || looksLikeSessionShortId) {
|
|
1789
|
+
await resumeLocalSession({ flags, arg0, wantsList });
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1446
1792
|
ensureInitialized(root);
|
|
1447
1793
|
const target = args[0];
|
|
1448
1794
|
const artifacts = listArtifactSets(root);
|
|
@@ -1485,6 +1831,152 @@ async function resume(args, flags, session) {
|
|
|
1485
1831
|
recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
|
|
1486
1832
|
writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
|
|
1487
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* α6.4: resume a local SessionStore session. Two modes:
|
|
1836
|
+
*
|
|
1837
|
+
* - `pugi resume --list` → print the 10 most recent local sessions
|
|
1838
|
+
* for the current project slug and exit.
|
|
1839
|
+
* - `pugi resume <id>` → resolve the id (full or short prefix),
|
|
1840
|
+
* check it exists, then mount the REPL
|
|
1841
|
+
* with the localSessionId pre-bound so
|
|
1842
|
+
* the bootstrap restores the transcript.
|
|
1843
|
+
*
|
|
1844
|
+
* The list path is non-interactive — operators pick by id and re-run
|
|
1845
|
+
* with the chosen one. A future sprint can replace the print with an
|
|
1846
|
+
* Ink select prompt; today's CLI surface is scripting-friendly.
|
|
1847
|
+
*/
|
|
1848
|
+
async function resumeLocalSession(input) {
|
|
1849
|
+
const cwd = process.cwd();
|
|
1850
|
+
const projectSlug = slugForCwd(cwd);
|
|
1851
|
+
// Resolve the project directory WITHOUT opening the store — when we
|
|
1852
|
+
// are only listing, taking the lock would block a live REPL.
|
|
1853
|
+
const projectDir = resolveProjectStoreDir(projectSlug);
|
|
1854
|
+
if (!existsSync(resolve(projectDir, 'session.db'))) {
|
|
1855
|
+
writeOutput(input.flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
if (input.wantsList && !input.arg0) {
|
|
1859
|
+
// Read-only list. Open + close without writing to keep it cheap.
|
|
1860
|
+
const rows = await listLocalSessions(projectSlug);
|
|
1861
|
+
writeOutput(input.flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (!input.arg0) {
|
|
1865
|
+
writeOutput(input.flags, { status: 'error', error: 'usage: pugi resume <session-id> | pugi resume --list' }, 'Usage: pugi resume <session-id> (run `pugi resume --list` to see ids).');
|
|
1866
|
+
process.exitCode = 2;
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
// Resolve the id. Accepts full uuid OR the 13-char prefix `pugi
|
|
1870
|
+
// resume` prints (`xxxxxxxx-xxxx`). Match on prefix because the
|
|
1871
|
+
// operator types from the human-friendly listing.
|
|
1872
|
+
const candidate = input.arg0;
|
|
1873
|
+
const target = await resolveLocalSessionId(projectSlug, candidate);
|
|
1874
|
+
if (!target) {
|
|
1875
|
+
writeOutput(input.flags, { status: 'not-found', id: candidate }, `No local session matches '${candidate}'. Run \`pugi resume --list\`.`);
|
|
1876
|
+
process.exitCode = 1;
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
// Hand off to the REPL bootstrap with the resolved id pre-bound so
|
|
1880
|
+
// the SessionStore opens the existing log + the bootstrap calls
|
|
1881
|
+
// restoreTranscript before the first user input.
|
|
1882
|
+
const runtimeConfig = resolveRuntimeConfig();
|
|
1883
|
+
if (!runtimeConfig) {
|
|
1884
|
+
writeOutput(input.flags, { status: 'auth-missing', id: target.id }, 'No credentials configured. Run `pugi login` first, then `pugi resume <id>`.');
|
|
1885
|
+
process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const { renderRepl } = await import('../tui/repl-render.js');
|
|
1889
|
+
await renderRepl({
|
|
1890
|
+
apiUrl: runtimeConfig.apiUrl,
|
|
1891
|
+
apiKey: runtimeConfig.apiKey,
|
|
1892
|
+
workspaceLabel: workspaceLabel(cwd),
|
|
1893
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
1894
|
+
skipSplash: input.flags.noSplash,
|
|
1895
|
+
hideToolStream: input.flags.noToolStream,
|
|
1896
|
+
resumeLocalSessionId: target.id,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* List the most recent local sessions for a project. Uses the
|
|
1901
|
+
* READ-ONLY view (`SqliteSessionStore.openReadOnly`) so the call never
|
|
1902
|
+
* takes the lockfile and never inserts a stub session row. Safe to
|
|
1903
|
+
* call while a live REPL writes in the same project — SQLite supports
|
|
1904
|
+
* concurrent readers + a single writer.
|
|
1905
|
+
*
|
|
1906
|
+
* Previously this opened the full SqliteSessionStore (lockfile +
|
|
1907
|
+
* insert path), which polluted history with one empty session row per
|
|
1908
|
+
* `pugi resume --list` or `pugi sessions --local` invocation. Fixed in
|
|
1909
|
+
* the α6.4 review pass.
|
|
1910
|
+
*/
|
|
1911
|
+
async function listLocalSessions(projectSlug) {
|
|
1912
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1913
|
+
try {
|
|
1914
|
+
return await view.list({ limit: 10 });
|
|
1915
|
+
}
|
|
1916
|
+
finally {
|
|
1917
|
+
await view.close();
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
/** Canonical UUID v7 surface form: 8-4-4-4-12 hex with '7' at the version nibble. */
|
|
1921
|
+
const FULL_UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1922
|
+
/**
|
|
1923
|
+
* Resolve a session id from a partial input. Accepts:
|
|
1924
|
+
* - full uuid v7 (canonical form xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
|
|
1925
|
+
* - 13-char prefix `xxxxxxxx-xxxx` (the human-friendly form the
|
|
1926
|
+
* `/resume` list prints)
|
|
1927
|
+
* - short 8-char hex prefix `xxxxxxxx`
|
|
1928
|
+
*
|
|
1929
|
+
* For a FULL uuid we go direct-to-`get` so the lookup is not bounded
|
|
1930
|
+
* by the most-recent-N listing (operators paste an id from days ago).
|
|
1931
|
+
* For a prefix we fall back to scanning the first page; that matches
|
|
1932
|
+
* the renderer's listing window.
|
|
1933
|
+
*
|
|
1934
|
+
* Returns the matching SessionRow or null when no row matches.
|
|
1935
|
+
*/
|
|
1936
|
+
async function resolveLocalSessionId(projectSlug, candidate) {
|
|
1937
|
+
const normalised = candidate.trim().toLowerCase();
|
|
1938
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1939
|
+
try {
|
|
1940
|
+
if (FULL_UUID_V7_RE.test(normalised)) {
|
|
1941
|
+
// Direct lookup — never bounded by the listing window.
|
|
1942
|
+
const direct = await view.get(normalised);
|
|
1943
|
+
if (direct)
|
|
1944
|
+
return direct;
|
|
1945
|
+
return null;
|
|
1946
|
+
}
|
|
1947
|
+
// Prefix path: scan the most-recent 10 rows so a typed short prefix
|
|
1948
|
+
// resolves against what the renderer just printed.
|
|
1949
|
+
const rows = await view.list({ limit: 10 });
|
|
1950
|
+
const exact = rows.find((r) => r.id.toLowerCase() === normalised);
|
|
1951
|
+
if (exact)
|
|
1952
|
+
return exact;
|
|
1953
|
+
const byPrefix = rows.find((r) => r.id.toLowerCase().startsWith(normalised));
|
|
1954
|
+
return byPrefix ?? null;
|
|
1955
|
+
}
|
|
1956
|
+
finally {
|
|
1957
|
+
await view.close();
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
function renderLocalSessionList(rows, projectSlug) {
|
|
1961
|
+
if (rows.length === 0) {
|
|
1962
|
+
return `No stored sessions for project '${projectSlug}' yet.`;
|
|
1963
|
+
}
|
|
1964
|
+
const lines = [
|
|
1965
|
+
`Recent local sessions for '${projectSlug}' (${rows.length}):`,
|
|
1966
|
+
'',
|
|
1967
|
+
];
|
|
1968
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1969
|
+
const row = rows[i];
|
|
1970
|
+
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
1971
|
+
const idShort = row.id.slice(0, 13);
|
|
1972
|
+
const branch = (row.branch ?? 'no-branch').padEnd(16);
|
|
1973
|
+
const turns = `${row.turnCount}t`.padStart(4);
|
|
1974
|
+
const events = `${row.eventCount}e`.padStart(5);
|
|
1975
|
+
lines.push(` ${idShort} ${branch} ${turns} ${events} ${title}`);
|
|
1976
|
+
}
|
|
1977
|
+
lines.push('', 'Resume with: pugi resume <id>');
|
|
1978
|
+
return lines.join('\n');
|
|
1979
|
+
}
|
|
1488
1980
|
/**
|
|
1489
1981
|
* Per-command exit code map. Surfaced to the operator so shell scripts
|
|
1490
1982
|
* can branch on the engine outcome:
|
|
@@ -1659,6 +2151,45 @@ function runEngineTask(kind) {
|
|
|
1659
2151
|
risks: ['adapter terminated without emitting a result event'],
|
|
1660
2152
|
};
|
|
1661
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
|
+
}
|
|
1662
2193
|
// For `plan` we always write a plan.md artifact, regardless of
|
|
1663
2194
|
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
1664
2195
|
// produces a reviewable artifact — the reason is recorded inline.
|
|
@@ -1721,6 +2252,16 @@ function runEngineTask(kind) {
|
|
|
1721
2252
|
sessionEventsMirror: metrics.mirror,
|
|
1722
2253
|
risks: result.risks,
|
|
1723
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
|
+
})),
|
|
1724
2265
|
// The full event stream is useful for cabinet UI replay. We surface
|
|
1725
2266
|
// it in JSON mode only — text mode operators want the summary, not
|
|
1726
2267
|
// 30 turn-level lines.
|
|
@@ -1741,6 +2282,19 @@ function runEngineTask(kind) {
|
|
|
1741
2282
|
textLines.push('Files modified: none');
|
|
1742
2283
|
}
|
|
1743
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
|
+
}
|
|
1744
2298
|
if (result.risks.length > 0) {
|
|
1745
2299
|
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
1746
2300
|
}
|
|
@@ -1750,6 +2304,96 @@ function runEngineTask(kind) {
|
|
|
1750
2304
|
writeOutput(flags, payload, textLines.join('\n'));
|
|
1751
2305
|
};
|
|
1752
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
|
+
}
|
|
1753
2397
|
/**
|
|
1754
2398
|
* Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
|
|
1755
2399
|
* already emits the canonical strings (`tool_calls=N`, `turns=N`,
|
|
@@ -3023,6 +3667,33 @@ async function jobs(args, flags, session) {
|
|
|
3023
3667
|
process.exitCode = exitCode;
|
|
3024
3668
|
}
|
|
3025
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
|
+
}
|
|
3026
3697
|
function notImplemented(command) {
|
|
3027
3698
|
return async (_args, flags) => {
|
|
3028
3699
|
const payload = {
|
|
@@ -3058,13 +3729,18 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
|
3058
3729
|
* REPL header in sync with `pwd` lets the operator orient at a glance.
|
|
3059
3730
|
* Empty / pathological cwd values (a worktree resolved to `/`) fall
|
|
3060
3731
|
* back to `workspace` so the header never collapses.
|
|
3732
|
+
*
|
|
3733
|
+
* α6.14.2 wave 5: when the cwd has no project markers (no .git, no
|
|
3734
|
+
* package.json, no PUGI.md), the resolver returns the explicit "not
|
|
3735
|
+
* bound" warning instead of a stray parent-dir basename. CEO 2026-05-25
|
|
3736
|
+
* dogfood surfaced the bug — launching `pugi` from `codeforge-io/`
|
|
3737
|
+
* (the parent of all checkouts) leaked `codeforge-io` into the splash
|
|
3738
|
+
* as if it were a real workspace. Mira/Pugi can NOT bind on that. The
|
|
3739
|
+
* decision lives in `core/repl/workspace-context.ts` so the splash +
|
|
3740
|
+
* status bar agree on a single label.
|
|
3061
3741
|
*/
|
|
3062
3742
|
function workspaceLabel(cwd) {
|
|
3063
|
-
|
|
3064
|
-
const last = segments[segments.length - 1];
|
|
3065
|
-
if (!last || last.length === 0)
|
|
3066
|
-
return 'workspace';
|
|
3067
|
-
return last;
|
|
3743
|
+
return resolveWorkspaceLabel(cwd);
|
|
3068
3744
|
}
|
|
3069
3745
|
function ensureDir(path, created, skipped) {
|
|
3070
3746
|
if (existsSync(path)) {
|
|
@@ -3286,22 +3962,41 @@ const PROTECTED_DIFF_EXCLUDES = [
|
|
|
3286
3962
|
// Basename excludes apply at the repo root AND in any subdirectory
|
|
3287
3963
|
// (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
|
|
3288
3964
|
// `**/` prefix, git's literal pathspec syntax would only match the
|
|
3289
|
-
// repo root and silently let a subdir `.env` ship in the diff
|
|
3965
|
+
// repo root and silently let a subdir `.env` ship in the diff -
|
|
3290
3966
|
// common pitfall in pnpm/turbo monorepos.
|
|
3967
|
+
//
|
|
3968
|
+
// Keep this list in sync with `PROTECTED_PATHSPEC_EXCLUDES` in
|
|
3969
|
+
// `apps/pugi-cli/src/core/consensus/diff-capture.ts`. Both surfaces
|
|
3970
|
+
// (legacy triple-review + consensus fan-out) enforce the same egress
|
|
3971
|
+
// contract; divergence creates an adversarial-PR leak window.
|
|
3291
3972
|
':(exclude,glob)**/.env',
|
|
3292
3973
|
':(exclude,glob)**/.env.*',
|
|
3293
3974
|
':(exclude,glob)**/.npmrc',
|
|
3294
3975
|
':(exclude,glob)**/.yarnrc',
|
|
3295
3976
|
':(exclude,glob)**/.pypirc',
|
|
3296
3977
|
':(exclude,glob)**/.gitconfig',
|
|
3978
|
+
':(exclude,glob)**/.netrc',
|
|
3297
3979
|
':(exclude,glob)**/id_rsa',
|
|
3298
3980
|
':(exclude,glob)**/id_ed25519',
|
|
3981
|
+
':(exclude,glob)**/id_ecdsa',
|
|
3982
|
+
':(exclude,glob)**/id_dsa',
|
|
3299
3983
|
':(exclude,glob)**/*.pem',
|
|
3300
3984
|
':(exclude,glob)**/*.key',
|
|
3301
3985
|
':(exclude,glob)**/*.crt',
|
|
3986
|
+
':(exclude,glob)**/*.cer',
|
|
3987
|
+
':(exclude,glob)**/*.der',
|
|
3988
|
+
':(exclude,glob)**/*.pfx',
|
|
3302
3989
|
':(exclude,glob)**/*.p12',
|
|
3303
3990
|
':(exclude,glob)**/*.dump',
|
|
3304
3991
|
':(exclude,glob)**/*.sql',
|
|
3992
|
+
':(exclude,glob)**/*.secret',
|
|
3993
|
+
':(exclude,glob)**/credentials',
|
|
3994
|
+
':(exclude,glob)**/credentials.json',
|
|
3995
|
+
// Use `secrets/**` (not `secrets/*`) so nested credential paths
|
|
3996
|
+
// recurse - with glob pathspec magic a single `*` does not cross path
|
|
3997
|
+
// separators, so the non-recursive form would let `secrets/prod/x.key`
|
|
3998
|
+
// ship in the diff payload.
|
|
3999
|
+
':(exclude,glob)**/secrets/**',
|
|
3305
4000
|
];
|
|
3306
4001
|
function collectUntrackedSummary(root) {
|
|
3307
4002
|
const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
|
|
@@ -3318,12 +4013,28 @@ function collectUntrackedSummary(root) {
|
|
|
3318
4013
|
return { paths: visible.slice(0, 50), excludedProtected: excluded };
|
|
3319
4014
|
}
|
|
3320
4015
|
function isProtectedPath(path) {
|
|
4016
|
+
// Keep in sync with PROTECTED_DIFF_EXCLUDES above. This filter
|
|
4017
|
+
// applies to the untracked-files summary surfaced to operators; the
|
|
4018
|
+
// pathspec excludes apply at the egress / diff capture layer.
|
|
3321
4019
|
const base = path.split('/').pop() ?? path;
|
|
3322
4020
|
if (base === '.env' || base.startsWith('.env.'))
|
|
3323
4021
|
return true;
|
|
3324
|
-
|
|
4022
|
+
const exactNames = [
|
|
4023
|
+
'.npmrc',
|
|
4024
|
+
'.yarnrc',
|
|
4025
|
+
'.pypirc',
|
|
4026
|
+
'.gitconfig',
|
|
4027
|
+
'.netrc',
|
|
4028
|
+
'id_rsa',
|
|
4029
|
+
'id_ed25519',
|
|
4030
|
+
'id_ecdsa',
|
|
4031
|
+
'id_dsa',
|
|
4032
|
+
'credentials',
|
|
4033
|
+
'credentials.json',
|
|
4034
|
+
];
|
|
4035
|
+
if (exactNames.includes(base))
|
|
3325
4036
|
return true;
|
|
3326
|
-
return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
|
|
4037
|
+
return /\.(pem|key|crt|cer|der|pfx|p12|dump|sql|secret)$/i.test(base);
|
|
3327
4038
|
}
|
|
3328
4039
|
function safeReadJson(path) {
|
|
3329
4040
|
try {
|