@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16
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/assets/pugi-mascot.ansi +17 -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/repl/ask.js +512 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1080 -11
- package/dist/core/repl/slash-commands.js +25 -3
- 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/runtime/cli.js +504 -10
- package/dist/runtime/commands/config.js +202 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- 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/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +85 -0
- package/dist/tui/repl-splash-mascot.js +118 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +59 -10
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +5 -4
package/dist/runtime/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ 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';
|
|
@@ -26,6 +27,10 @@ import { runUndoCommand } from './commands/undo.js';
|
|
|
26
27
|
import { runBudgetCommand } from './commands/budget.js';
|
|
27
28
|
import { runSkillsCommand } from './commands/skills.js';
|
|
28
29
|
import { runAgentsCommand } from './commands/agents.js';
|
|
30
|
+
import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
31
|
+
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
32
|
+
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
33
|
+
import { slugForCwd } from '../core/repl/history.js';
|
|
29
34
|
/**
|
|
30
35
|
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
31
36
|
*
|
|
@@ -37,10 +42,11 @@ import { runAgentsCommand } from './commands/agents.js';
|
|
|
37
42
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
38
43
|
* three are in lockstep.
|
|
39
44
|
*/
|
|
40
|
-
const PUGI_CLI_VERSION =
|
|
45
|
+
const PUGI_CLI_VERSION = "0.1.0-alpha.16";
|
|
41
46
|
const handlers = {
|
|
42
47
|
accounts,
|
|
43
48
|
agents: dispatchAgents,
|
|
49
|
+
ask: dispatchAsk,
|
|
44
50
|
build: runEngineTask('build_task'),
|
|
45
51
|
budget: dispatchBudget,
|
|
46
52
|
code: runEngineTask('code'),
|
|
@@ -56,6 +62,7 @@ const handlers = {
|
|
|
56
62
|
login,
|
|
57
63
|
logout,
|
|
58
64
|
plan: runEngineTask('plan'),
|
|
65
|
+
'plan-review': dispatchPlanReview,
|
|
59
66
|
privacy: dispatchPrivacy,
|
|
60
67
|
review,
|
|
61
68
|
resume,
|
|
@@ -67,6 +74,162 @@ const handlers = {
|
|
|
67
74
|
web: dispatchWeb,
|
|
68
75
|
whoami,
|
|
69
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
|
|
79
|
+
* modal manually. In an interactive TTY we mount a tiny Ink app, render
|
|
80
|
+
* the `<AskModal />`, and await the operator's verdict. In non-TTY
|
|
81
|
+
* (CI / pipes), we emit the structured ask JSON to stdout so scripted
|
|
82
|
+
* callers can pipe the response back without rendering a modal.
|
|
83
|
+
*
|
|
84
|
+
* The verdict is printed to stdout as either:
|
|
85
|
+
* - the chosen option `value` (one of the yes/no defaults)
|
|
86
|
+
* - `other:<text>` when the operator typed a custom answer
|
|
87
|
+
* - `cancelled` when the operator pressed Esc
|
|
88
|
+
*
|
|
89
|
+
* This is a CLI-side helper. The REPL slash `/ask` is wired separately
|
|
90
|
+
* through `slash-commands.ts`.
|
|
91
|
+
*/
|
|
92
|
+
async function dispatchAsk(args, flags, _session) {
|
|
93
|
+
const question = args.join(' ').trim();
|
|
94
|
+
if (!question) {
|
|
95
|
+
writeOutput(flags, { ok: false, error: 'Usage: pugi ask "<question>"' }, 'Usage: pugi ask "<question>"');
|
|
96
|
+
process.exitCode = 2;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { synthesiseLocalAskTag } = await import('../core/repl/session.js');
|
|
100
|
+
const tag = synthesiseLocalAskTag(question);
|
|
101
|
+
if (!tag) {
|
|
102
|
+
writeOutput(flags, { ok: false, error: 'Question must be 1-80 chars.' }, 'pugi ask: question must be 1-80 chars.');
|
|
103
|
+
process.exitCode = 2;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!isInteractive(flags)) {
|
|
107
|
+
// Non-TTY: emit the structured ask payload so scripted callers can
|
|
108
|
+
// forward it. The interactive modal is only meaningful with a real
|
|
109
|
+
// terminal, so the line-buffered fallback prints the question +
|
|
110
|
+
// options and exits 0 — callers parse the JSON.
|
|
111
|
+
const payload = {
|
|
112
|
+
ok: true,
|
|
113
|
+
ask: {
|
|
114
|
+
question: tag.question,
|
|
115
|
+
options: tag.options,
|
|
116
|
+
signature: tag.signature,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
writeOutput(flags, payload, [
|
|
120
|
+
`Question: ${tag.question}`,
|
|
121
|
+
...tag.options.map((o, i) => ` ${i + 1}. ${o.label}${o.desc ? ` - ${o.desc}` : ''}`),
|
|
122
|
+
` ${tag.options.length + 1}. Other (custom)`,
|
|
123
|
+
'',
|
|
124
|
+
'(non-TTY: re-run in a real terminal to answer interactively, or pipe an answer to stdin)',
|
|
125
|
+
].join('\n'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Interactive: render the Ink modal and await resolution.
|
|
129
|
+
const { renderAskCli } = await import('../tui/ask-cli.js');
|
|
130
|
+
const verdict = await renderAskCli({ tag });
|
|
131
|
+
const encoded = verdict.cancelled
|
|
132
|
+
? 'cancelled'
|
|
133
|
+
: verdict.value.length > 0
|
|
134
|
+
? verdict.value
|
|
135
|
+
: `other:${verdict.customInput ?? ''}`;
|
|
136
|
+
writeOutput(flags, { ok: true, verdict: encoded }, encoded);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* α6.3 `pugi plan-review <task>` — generate + present a plan WITHOUT
|
|
140
|
+
* executing. The legacy `pugi plan` surface (offline plan generator,
|
|
141
|
+
* see runEngineTask) stays intact; `plan-review` adds the office-hours
|
|
142
|
+
* Ink modal layer on top so the operator can approve/modify/cancel
|
|
143
|
+
* before the orchestrator dispatches Marcus.
|
|
144
|
+
*
|
|
145
|
+
* Phase 1 implementation: build a deterministic plan stub from the
|
|
146
|
+
* task description (the persona-driven planner ships in a follow-up
|
|
147
|
+
* sprint). The plan is presented through the standard
|
|
148
|
+
* `<pugi-plan-review>` modal in interactive mode; non-TTY emits the
|
|
149
|
+
* structured payload to stdout for scripted consumers.
|
|
150
|
+
*
|
|
151
|
+
* The exit code reflects the operator's verdict:
|
|
152
|
+
* - 0 PASS approved
|
|
153
|
+
* - 1 MODIFY modify (text printed)
|
|
154
|
+
* - 2 CANCEL cancel
|
|
155
|
+
*/
|
|
156
|
+
async function dispatchPlanReview(args, flags, _session) {
|
|
157
|
+
const task = args.join(' ').trim();
|
|
158
|
+
if (!task) {
|
|
159
|
+
writeOutput(flags, { ok: false, error: 'Usage: pugi plan <task description>' }, 'Usage: pugi plan <task description>');
|
|
160
|
+
process.exitCode = 2;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const planTag = synthesiseLocalPlanReview(task);
|
|
164
|
+
if (!isInteractive(flags)) {
|
|
165
|
+
const payload = {
|
|
166
|
+
ok: true,
|
|
167
|
+
plan: {
|
|
168
|
+
steps: planTag.steps,
|
|
169
|
+
risk: planTag.risk,
|
|
170
|
+
signature: planTag.signature,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
writeOutput(flags, payload, [
|
|
174
|
+
'Plan review (non-execution):',
|
|
175
|
+
...planTag.steps.map((s, i) => ` ${i + 1}. ${s.text}`),
|
|
176
|
+
planTag.risk ? `Risk: ${planTag.risk}` : '',
|
|
177
|
+
'',
|
|
178
|
+
'(non-TTY: re-run in a real terminal to approve/modify/cancel interactively)',
|
|
179
|
+
]
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join('\n'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const { renderPlanReviewCli } = await import('../tui/ask-cli.js');
|
|
185
|
+
const result = await renderPlanReviewCli({ tag: planTag });
|
|
186
|
+
switch (result.verdict) {
|
|
187
|
+
case 'approve':
|
|
188
|
+
writeOutput(flags, { ok: true, verdict: 'approve' }, 'approve');
|
|
189
|
+
return;
|
|
190
|
+
case 'modify':
|
|
191
|
+
writeOutput(flags, { ok: true, verdict: 'modify', modifyText: result.modifyText ?? '' }, `modify: ${result.modifyText ?? ''}`);
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
case 'cancel':
|
|
195
|
+
writeOutput(flags, { ok: true, verdict: 'cancel' }, 'cancel');
|
|
196
|
+
process.exitCode = 2;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Local plan stub generator. Until the persona-side planner lands, we
|
|
202
|
+
* produce a deterministic 3-step skeleton anchored to the task text so
|
|
203
|
+
* the operator can dry-run the modal interaction. Real plan synthesis
|
|
204
|
+
* arrives in a follow-up sprint.
|
|
205
|
+
*/
|
|
206
|
+
function synthesiseLocalPlanReview(task) {
|
|
207
|
+
const truncated = task.length > 80 ? task.slice(0, 77) + '...' : task;
|
|
208
|
+
const steps = [
|
|
209
|
+
{ text: `1. Understand the task: ${truncated}` },
|
|
210
|
+
{ text: '2. Identify scope, files touched, side effects.' },
|
|
211
|
+
{ text: '3. Execute with verification gates per Pugi defaults.' },
|
|
212
|
+
];
|
|
213
|
+
const risk = task.length > 200
|
|
214
|
+
? 'Long task description - consider splitting into smaller briefs.'
|
|
215
|
+
: undefined;
|
|
216
|
+
// Route through the single-source signature helper from ask.ts so a
|
|
217
|
+
// parser-extracted plan-review with identical content collides
|
|
218
|
+
// deterministically with this synthesised one. Inlining the formula
|
|
219
|
+
// here (as the original implementation did) silently drifted from
|
|
220
|
+
// signatureForPlanReview when the helper added `.trim()` to each
|
|
221
|
+
// step. Codex triple-review P2 (PR #375).
|
|
222
|
+
const signature = signatureForPlanReview(steps, risk ?? null);
|
|
223
|
+
const tag = {
|
|
224
|
+
steps,
|
|
225
|
+
signature,
|
|
226
|
+
start: 0,
|
|
227
|
+
end: 0,
|
|
228
|
+
};
|
|
229
|
+
if (risk)
|
|
230
|
+
tag.risk = risk;
|
|
231
|
+
return tag;
|
|
232
|
+
}
|
|
70
233
|
async function dispatchConfig(args, flags, _session) {
|
|
71
234
|
await runConfigCommand(args, {
|
|
72
235
|
workspaceRoot: process.cwd(),
|
|
@@ -188,6 +351,7 @@ export async function runCli(argv) {
|
|
|
188
351
|
cliVersion: PUGI_CLI_VERSION,
|
|
189
352
|
updateBanner,
|
|
190
353
|
skipSplash: flags.noSplash,
|
|
354
|
+
hideToolStream: flags.noToolStream,
|
|
191
355
|
});
|
|
192
356
|
return;
|
|
193
357
|
}
|
|
@@ -219,11 +383,24 @@ function parseArgs(argv) {
|
|
|
219
383
|
web: false,
|
|
220
384
|
dryRun: false,
|
|
221
385
|
triple: false,
|
|
386
|
+
consensus: false,
|
|
222
387
|
offline: false,
|
|
223
388
|
noTty: false,
|
|
224
389
|
allowFetch: false,
|
|
225
390
|
noUpdateCheck: false,
|
|
226
391
|
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
392
|
+
// Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
|
|
393
|
+
// until backend ships `tool.start`/`tool.result` SSE events. Current
|
|
394
|
+
// client-side synthesiser parses persona prose for `Read(...)` /
|
|
395
|
+
// `Bash(...)` patterns — never fires in production (admin-api emits
|
|
396
|
+
// "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
|
|
397
|
+
// accidental `Verb(noun)` shapes producing stuck `running` rows.
|
|
398
|
+
// Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
|
|
399
|
+
// for development/testing. Will flip к default ON when backend
|
|
400
|
+
// emits real tool events (filed as α6.13.X follow-up).
|
|
401
|
+
noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
402
|
+
? process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
403
|
+
: true,
|
|
227
404
|
};
|
|
228
405
|
const args = [];
|
|
229
406
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -250,9 +427,16 @@ function parseArgs(argv) {
|
|
|
250
427
|
else if (arg === '--dry-run') {
|
|
251
428
|
flags.dryRun = true;
|
|
252
429
|
}
|
|
253
|
-
else if (arg === '--triple'
|
|
430
|
+
else if (arg === '--triple') {
|
|
254
431
|
flags.triple = true;
|
|
255
432
|
}
|
|
433
|
+
else if (arg === '--consensus') {
|
|
434
|
+
// α6.7: customer-facing 3-model consensus review. Routes through
|
|
435
|
+
// the SSE-based runtime gate rather than the legacy artifact
|
|
436
|
+
// writer. The triple flag stays unset так the existing
|
|
437
|
+
// performRemoteTripleReview path is never accidentally entered.
|
|
438
|
+
flags.consensus = true;
|
|
439
|
+
}
|
|
256
440
|
else if (arg === '--offline') {
|
|
257
441
|
flags.offline = true;
|
|
258
442
|
}
|
|
@@ -268,6 +452,14 @@ function parseArgs(argv) {
|
|
|
268
452
|
else if (arg === '--no-splash') {
|
|
269
453
|
flags.noSplash = true;
|
|
270
454
|
}
|
|
455
|
+
else if (arg === '--no-tool-stream') {
|
|
456
|
+
flags.noToolStream = true;
|
|
457
|
+
}
|
|
458
|
+
else if (arg === '--tool-stream') {
|
|
459
|
+
// Opt-in для α6.12 dev/testing — backend tool events not live yet,
|
|
460
|
+
// pane shows синтесайз heuristic OR empty placeholder
|
|
461
|
+
flags.noToolStream = false;
|
|
462
|
+
}
|
|
271
463
|
else if (arg.startsWith('--privacy=')) {
|
|
272
464
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
273
465
|
}
|
|
@@ -317,6 +509,9 @@ async function help(_args, flags, _session) {
|
|
|
317
509
|
'',
|
|
318
510
|
'Review gate:',
|
|
319
511
|
' pugi review --triple Prepare the Anvil-backed triple-review gate.',
|
|
512
|
+
' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
|
|
513
|
+
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
514
|
+
' Exits 0 PASS · 1 WARN · 2 BLOCK.',
|
|
320
515
|
'',
|
|
321
516
|
'Skills + agents marketplace:',
|
|
322
517
|
' pugi skills list All installed skills.',
|
|
@@ -325,6 +520,10 @@ async function help(_args, flags, _session) {
|
|
|
325
520
|
' pugi agents list All installed sub-agents.',
|
|
326
521
|
' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
|
|
327
522
|
'',
|
|
523
|
+
'Office-hours forcing questions (α6.3):',
|
|
524
|
+
' pugi ask "<question>" Surface a yes/no question modal locally.',
|
|
525
|
+
' pugi plan-review <task> Generate + present a plan-review modal.',
|
|
526
|
+
'',
|
|
328
527
|
'Sync safety:',
|
|
329
528
|
' pugi sync --dry-run --privacy metadata',
|
|
330
529
|
'',
|
|
@@ -335,6 +534,8 @@ async function help(_args, flags, _session) {
|
|
|
335
534
|
' with PUGI_SKIP_UPDATE_BANNER=1.',
|
|
336
535
|
' --no-splash Skip the REPL boot splash. Pairs with',
|
|
337
536
|
' PUGI_SKIP_SPLASH=1.',
|
|
537
|
+
' --no-tool-stream Hide the live tool stream pane (α6.12).',
|
|
538
|
+
' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
|
|
338
539
|
'',
|
|
339
540
|
PUGI_TAGLINE,
|
|
340
541
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
@@ -799,6 +1000,26 @@ async function review(args, flags, session) {
|
|
|
799
1000
|
const root = process.cwd();
|
|
800
1001
|
ensureInitialized(root);
|
|
801
1002
|
const prompt = args.join(' ').trim();
|
|
1003
|
+
// α6.7: customer-facing consensus review routes here. Distinct from
|
|
1004
|
+
// `--triple --remote` (legacy artifact-writer flow) so the new SSE
|
|
1005
|
+
// streaming UX and rubric-driven exit codes don't disturb the existing
|
|
1006
|
+
// pugi-cli surfaces that depend on the old shape.
|
|
1007
|
+
if (flags.consensus) {
|
|
1008
|
+
const exitCode = await runReviewConsensus(args, {
|
|
1009
|
+
cwd: root,
|
|
1010
|
+
config: resolveRuntimeConfig(),
|
|
1011
|
+
json: flags.json,
|
|
1012
|
+
emit: (line) => {
|
|
1013
|
+
if (!flags.json)
|
|
1014
|
+
process.stdout.write(line);
|
|
1015
|
+
},
|
|
1016
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1017
|
+
});
|
|
1018
|
+
// Caller owns `process.exitCode` so a REPL slash invocation
|
|
1019
|
+
// ('/consensus') cannot inherit a stale exit code from a previous run.
|
|
1020
|
+
process.exitCode = exitCode;
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
802
1023
|
if (flags.triple && flags.remote) {
|
|
803
1024
|
await performRemoteTripleReview(root, session, flags, prompt);
|
|
804
1025
|
return;
|
|
@@ -1362,6 +1583,14 @@ async function handoff(args, flags, session) {
|
|
|
1362
1583
|
writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
|
|
1363
1584
|
}
|
|
1364
1585
|
async function sessions(args, flags, _session) {
|
|
1586
|
+
// α6.4: `pugi sessions --local` / `--search "query"` route to the
|
|
1587
|
+
// local SessionStore. The default surface stays artifact-based for
|
|
1588
|
+
// backward compat — operators who relied on the index.json view get
|
|
1589
|
+
// the same shape.
|
|
1590
|
+
if (args.includes('--local') || args.includes('--search')) {
|
|
1591
|
+
await sessionsLocal(args, flags);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1365
1594
|
const root = process.cwd();
|
|
1366
1595
|
ensureInitialized(root);
|
|
1367
1596
|
const rebuild = args.includes('--rebuild');
|
|
@@ -1436,6 +1665,72 @@ async function sessions(args, flags, _session) {
|
|
|
1436
1665
|
function hasStubSession(index) {
|
|
1437
1666
|
return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
|
|
1438
1667
|
}
|
|
1668
|
+
/**
|
|
1669
|
+
* α6.4: `pugi sessions --local` / `--search "query"` against the
|
|
1670
|
+
* SessionStore. The default `--local` mode lists the 10 most recent
|
|
1671
|
+
* sessions for the current project; `--search "query"` runs FTS5
|
|
1672
|
+
* against the title+body index.
|
|
1673
|
+
*/
|
|
1674
|
+
async function sessionsLocal(args, flags) {
|
|
1675
|
+
const cwd = process.cwd();
|
|
1676
|
+
const projectSlug = slugForCwd(cwd);
|
|
1677
|
+
const projectDir = resolveProjectStoreDir(projectSlug);
|
|
1678
|
+
if (!existsSync(resolve(projectDir, 'session.db'))) {
|
|
1679
|
+
writeOutput(flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
// Parse `--search "query"` or `--search query`.
|
|
1683
|
+
const searchIdx = args.indexOf('--search');
|
|
1684
|
+
const query = searchIdx >= 0 ? (args[searchIdx + 1] ?? '').trim() : '';
|
|
1685
|
+
if (query.length > 0) {
|
|
1686
|
+
let rows;
|
|
1687
|
+
try {
|
|
1688
|
+
rows = await searchLocalSessions(projectSlug, query);
|
|
1689
|
+
}
|
|
1690
|
+
catch (error) {
|
|
1691
|
+
// Surface FTS5 syntax errors as a clean one-line message + exit 2
|
|
1692
|
+
// so a stray `"` in the operator's input does not dump a stack
|
|
1693
|
+
// trace. Both the live-store path (FtsSyntaxError) and the
|
|
1694
|
+
// read-only fallback (SQLite error with code starting `SQLITE_`)
|
|
1695
|
+
// funnel here.
|
|
1696
|
+
const code = error?.code;
|
|
1697
|
+
if (error instanceof FtsSyntaxError
|
|
1698
|
+
|| (typeof code === 'string' && (code === 'EFTS5_SYNTAX' || code.startsWith('SQLITE_')))) {
|
|
1699
|
+
writeOutput(flags, { status: 'error', error: 'invalid search query', query }, `Invalid search query: '${query}'. Try simpler text (no unbalanced quotes).`);
|
|
1700
|
+
process.exitCode = 2;
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
throw error;
|
|
1704
|
+
}
|
|
1705
|
+
writeOutput(flags, { projectSlug, query, sessions: rows }, rows.length === 0
|
|
1706
|
+
? `No local sessions matched '${query}' for project '${projectSlug}'.`
|
|
1707
|
+
: `Search hits for '${query}' (${rows.length}):\n\n${rows
|
|
1708
|
+
.map((row) => ` ${row.id.slice(0, 13)} ${(row.title ?? '(untitled)').slice(0, 64)}`)
|
|
1709
|
+
.join('\n')}`);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const rows = await listLocalSessions(projectSlug);
|
|
1713
|
+
writeOutput(flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Run an FTS5 search against the local SessionStore. Opens the SQLite
|
|
1717
|
+
* file READ-ONLY via `SqliteSessionStore.openReadOnly` so the search
|
|
1718
|
+
* never takes the lockfile and never inserts a stub session row. Works
|
|
1719
|
+
* whether or not a live REPL holds the writer lock — SQLite supports
|
|
1720
|
+
* concurrent readers + a single writer.
|
|
1721
|
+
*
|
|
1722
|
+
* FTS syntax errors surface as `FtsSyntaxError` (code `EFTS5_SYNTAX`);
|
|
1723
|
+
* the dispatcher catches that + exits 2 with a clean message.
|
|
1724
|
+
*/
|
|
1725
|
+
async function searchLocalSessions(projectSlug, query) {
|
|
1726
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1727
|
+
try {
|
|
1728
|
+
return await view.search(query, { limit: 20 });
|
|
1729
|
+
}
|
|
1730
|
+
finally {
|
|
1731
|
+
await view.close();
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1439
1734
|
function registerArtifact(root, artifact) {
|
|
1440
1735
|
// Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
|
|
1441
1736
|
// that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
|
|
@@ -1468,6 +1763,19 @@ function registerArtifact(root, artifact) {
|
|
|
1468
1763
|
}
|
|
1469
1764
|
async function resume(args, flags, session) {
|
|
1470
1765
|
const root = process.cwd();
|
|
1766
|
+
// α6.4: `pugi resume [<local-session-id>]` and `pugi resume --list`
|
|
1767
|
+
// operate on the LOCAL SessionStore under `~/.pugi/projects/<slug>/`
|
|
1768
|
+
// before falling back to the legacy artifact-based resume. The
|
|
1769
|
+
// local-session path requires no `.pugi/` directory in the cwd
|
|
1770
|
+
// (the store lives under $HOME) so we run it BEFORE ensureInitialized.
|
|
1771
|
+
const wantsList = args.includes('--list');
|
|
1772
|
+
const arg0 = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
|
|
1773
|
+
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;
|
|
1774
|
+
const looksLikeSessionShortId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}$/i.test(arg0) : false;
|
|
1775
|
+
if (wantsList || looksLikeSessionId || looksLikeSessionShortId) {
|
|
1776
|
+
await resumeLocalSession({ flags, arg0, wantsList });
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1471
1779
|
ensureInitialized(root);
|
|
1472
1780
|
const target = args[0];
|
|
1473
1781
|
const artifacts = listArtifactSets(root);
|
|
@@ -1510,6 +1818,152 @@ async function resume(args, flags, session) {
|
|
|
1510
1818
|
recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
|
|
1511
1819
|
writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
|
|
1512
1820
|
}
|
|
1821
|
+
/**
|
|
1822
|
+
* α6.4: resume a local SessionStore session. Two modes:
|
|
1823
|
+
*
|
|
1824
|
+
* - `pugi resume --list` → print the 10 most recent local sessions
|
|
1825
|
+
* for the current project slug and exit.
|
|
1826
|
+
* - `pugi resume <id>` → resolve the id (full or short prefix),
|
|
1827
|
+
* check it exists, then mount the REPL
|
|
1828
|
+
* with the localSessionId pre-bound so
|
|
1829
|
+
* the bootstrap restores the transcript.
|
|
1830
|
+
*
|
|
1831
|
+
* The list path is non-interactive — operators pick by id and re-run
|
|
1832
|
+
* with the chosen one. A future sprint can replace the print with an
|
|
1833
|
+
* Ink select prompt; today's CLI surface is scripting-friendly.
|
|
1834
|
+
*/
|
|
1835
|
+
async function resumeLocalSession(input) {
|
|
1836
|
+
const cwd = process.cwd();
|
|
1837
|
+
const projectSlug = slugForCwd(cwd);
|
|
1838
|
+
// Resolve the project directory WITHOUT opening the store — when we
|
|
1839
|
+
// are only listing, taking the lock would block a live REPL.
|
|
1840
|
+
const projectDir = resolveProjectStoreDir(projectSlug);
|
|
1841
|
+
if (!existsSync(resolve(projectDir, 'session.db'))) {
|
|
1842
|
+
writeOutput(input.flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (input.wantsList && !input.arg0) {
|
|
1846
|
+
// Read-only list. Open + close without writing to keep it cheap.
|
|
1847
|
+
const rows = await listLocalSessions(projectSlug);
|
|
1848
|
+
writeOutput(input.flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (!input.arg0) {
|
|
1852
|
+
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).');
|
|
1853
|
+
process.exitCode = 2;
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
// Resolve the id. Accepts full uuid OR the 13-char prefix `pugi
|
|
1857
|
+
// resume` prints (`xxxxxxxx-xxxx`). Match on prefix because the
|
|
1858
|
+
// operator types from the human-friendly listing.
|
|
1859
|
+
const candidate = input.arg0;
|
|
1860
|
+
const target = await resolveLocalSessionId(projectSlug, candidate);
|
|
1861
|
+
if (!target) {
|
|
1862
|
+
writeOutput(input.flags, { status: 'not-found', id: candidate }, `No local session matches '${candidate}'. Run \`pugi resume --list\`.`);
|
|
1863
|
+
process.exitCode = 1;
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
// Hand off to the REPL bootstrap with the resolved id pre-bound so
|
|
1867
|
+
// the SessionStore opens the existing log + the bootstrap calls
|
|
1868
|
+
// restoreTranscript before the first user input.
|
|
1869
|
+
const runtimeConfig = resolveRuntimeConfig();
|
|
1870
|
+
if (!runtimeConfig) {
|
|
1871
|
+
writeOutput(input.flags, { status: 'auth-missing', id: target.id }, 'No credentials configured. Run `pugi login` first, then `pugi resume <id>`.');
|
|
1872
|
+
process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const { renderRepl } = await import('../tui/repl-render.js');
|
|
1876
|
+
await renderRepl({
|
|
1877
|
+
apiUrl: runtimeConfig.apiUrl,
|
|
1878
|
+
apiKey: runtimeConfig.apiKey,
|
|
1879
|
+
workspaceLabel: workspaceLabel(cwd),
|
|
1880
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
1881
|
+
skipSplash: input.flags.noSplash,
|
|
1882
|
+
hideToolStream: input.flags.noToolStream,
|
|
1883
|
+
resumeLocalSessionId: target.id,
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* List the most recent local sessions for a project. Uses the
|
|
1888
|
+
* READ-ONLY view (`SqliteSessionStore.openReadOnly`) so the call never
|
|
1889
|
+
* takes the lockfile and never inserts a stub session row. Safe to
|
|
1890
|
+
* call while a live REPL writes in the same project — SQLite supports
|
|
1891
|
+
* concurrent readers + a single writer.
|
|
1892
|
+
*
|
|
1893
|
+
* Previously this opened the full SqliteSessionStore (lockfile +
|
|
1894
|
+
* insert path), which polluted history with one empty session row per
|
|
1895
|
+
* `pugi resume --list` or `pugi sessions --local` invocation. Fixed in
|
|
1896
|
+
* the α6.4 review pass.
|
|
1897
|
+
*/
|
|
1898
|
+
async function listLocalSessions(projectSlug) {
|
|
1899
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1900
|
+
try {
|
|
1901
|
+
return await view.list({ limit: 10 });
|
|
1902
|
+
}
|
|
1903
|
+
finally {
|
|
1904
|
+
await view.close();
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/** Canonical UUID v7 surface form: 8-4-4-4-12 hex with '7' at the version nibble. */
|
|
1908
|
+
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;
|
|
1909
|
+
/**
|
|
1910
|
+
* Resolve a session id from a partial input. Accepts:
|
|
1911
|
+
* - full uuid v7 (canonical form xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
|
|
1912
|
+
* - 13-char prefix `xxxxxxxx-xxxx` (the human-friendly form the
|
|
1913
|
+
* `/resume` list prints)
|
|
1914
|
+
* - short 8-char hex prefix `xxxxxxxx`
|
|
1915
|
+
*
|
|
1916
|
+
* For a FULL uuid we go direct-to-`get` so the lookup is not bounded
|
|
1917
|
+
* by the most-recent-N listing (operators paste an id from days ago).
|
|
1918
|
+
* For a prefix we fall back to scanning the first page; that matches
|
|
1919
|
+
* the renderer's listing window.
|
|
1920
|
+
*
|
|
1921
|
+
* Returns the matching SessionRow or null when no row matches.
|
|
1922
|
+
*/
|
|
1923
|
+
async function resolveLocalSessionId(projectSlug, candidate) {
|
|
1924
|
+
const normalised = candidate.trim().toLowerCase();
|
|
1925
|
+
const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
|
|
1926
|
+
try {
|
|
1927
|
+
if (FULL_UUID_V7_RE.test(normalised)) {
|
|
1928
|
+
// Direct lookup — never bounded by the listing window.
|
|
1929
|
+
const direct = await view.get(normalised);
|
|
1930
|
+
if (direct)
|
|
1931
|
+
return direct;
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
// Prefix path: scan the most-recent 10 rows so a typed short prefix
|
|
1935
|
+
// resolves against what the renderer just printed.
|
|
1936
|
+
const rows = await view.list({ limit: 10 });
|
|
1937
|
+
const exact = rows.find((r) => r.id.toLowerCase() === normalised);
|
|
1938
|
+
if (exact)
|
|
1939
|
+
return exact;
|
|
1940
|
+
const byPrefix = rows.find((r) => r.id.toLowerCase().startsWith(normalised));
|
|
1941
|
+
return byPrefix ?? null;
|
|
1942
|
+
}
|
|
1943
|
+
finally {
|
|
1944
|
+
await view.close();
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
function renderLocalSessionList(rows, projectSlug) {
|
|
1948
|
+
if (rows.length === 0) {
|
|
1949
|
+
return `No stored sessions for project '${projectSlug}' yet.`;
|
|
1950
|
+
}
|
|
1951
|
+
const lines = [
|
|
1952
|
+
`Recent local sessions for '${projectSlug}' (${rows.length}):`,
|
|
1953
|
+
'',
|
|
1954
|
+
];
|
|
1955
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1956
|
+
const row = rows[i];
|
|
1957
|
+
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
1958
|
+
const idShort = row.id.slice(0, 13);
|
|
1959
|
+
const branch = (row.branch ?? 'no-branch').padEnd(16);
|
|
1960
|
+
const turns = `${row.turnCount}t`.padStart(4);
|
|
1961
|
+
const events = `${row.eventCount}e`.padStart(5);
|
|
1962
|
+
lines.push(` ${idShort} ${branch} ${turns} ${events} ${title}`);
|
|
1963
|
+
}
|
|
1964
|
+
lines.push('', 'Resume with: pugi resume <id>');
|
|
1965
|
+
return lines.join('\n');
|
|
1966
|
+
}
|
|
1513
1967
|
/**
|
|
1514
1968
|
* Per-command exit code map. Surfaced to the operator so shell scripts
|
|
1515
1969
|
* can branch on the engine outcome:
|
|
@@ -3083,13 +3537,18 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
|
3083
3537
|
* REPL header in sync with `pwd` lets the operator orient at a glance.
|
|
3084
3538
|
* Empty / pathological cwd values (a worktree resolved to `/`) fall
|
|
3085
3539
|
* back to `workspace` so the header never collapses.
|
|
3540
|
+
*
|
|
3541
|
+
* α6.14.2 wave 5: when the cwd has no project markers (no .git, no
|
|
3542
|
+
* package.json, no PUGI.md), the resolver returns the explicit "not
|
|
3543
|
+
* bound" warning instead of a stray parent-dir basename. CEO 2026-05-25
|
|
3544
|
+
* dogfood surfaced the bug — launching `pugi` from `codeforge-io/`
|
|
3545
|
+
* (the parent of all checkouts) leaked `codeforge-io` into the splash
|
|
3546
|
+
* as if it were a real workspace. Mira/Pugi can NOT bind on that. The
|
|
3547
|
+
* decision lives in `core/repl/workspace-context.ts` so the splash +
|
|
3548
|
+
* status bar agree on a single label.
|
|
3086
3549
|
*/
|
|
3087
3550
|
function workspaceLabel(cwd) {
|
|
3088
|
-
|
|
3089
|
-
const last = segments[segments.length - 1];
|
|
3090
|
-
if (!last || last.length === 0)
|
|
3091
|
-
return 'workspace';
|
|
3092
|
-
return last;
|
|
3551
|
+
return resolveWorkspaceLabel(cwd);
|
|
3093
3552
|
}
|
|
3094
3553
|
function ensureDir(path, created, skipped) {
|
|
3095
3554
|
if (existsSync(path)) {
|
|
@@ -3311,22 +3770,41 @@ const PROTECTED_DIFF_EXCLUDES = [
|
|
|
3311
3770
|
// Basename excludes apply at the repo root AND in any subdirectory
|
|
3312
3771
|
// (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
|
|
3313
3772
|
// `**/` prefix, git's literal pathspec syntax would only match the
|
|
3314
|
-
// repo root and silently let a subdir `.env` ship in the diff
|
|
3773
|
+
// repo root and silently let a subdir `.env` ship in the diff -
|
|
3315
3774
|
// common pitfall in pnpm/turbo monorepos.
|
|
3775
|
+
//
|
|
3776
|
+
// Keep this list in sync with `PROTECTED_PATHSPEC_EXCLUDES` in
|
|
3777
|
+
// `apps/pugi-cli/src/core/consensus/diff-capture.ts`. Both surfaces
|
|
3778
|
+
// (legacy triple-review + consensus fan-out) enforce the same egress
|
|
3779
|
+
// contract; divergence creates an adversarial-PR leak window.
|
|
3316
3780
|
':(exclude,glob)**/.env',
|
|
3317
3781
|
':(exclude,glob)**/.env.*',
|
|
3318
3782
|
':(exclude,glob)**/.npmrc',
|
|
3319
3783
|
':(exclude,glob)**/.yarnrc',
|
|
3320
3784
|
':(exclude,glob)**/.pypirc',
|
|
3321
3785
|
':(exclude,glob)**/.gitconfig',
|
|
3786
|
+
':(exclude,glob)**/.netrc',
|
|
3322
3787
|
':(exclude,glob)**/id_rsa',
|
|
3323
3788
|
':(exclude,glob)**/id_ed25519',
|
|
3789
|
+
':(exclude,glob)**/id_ecdsa',
|
|
3790
|
+
':(exclude,glob)**/id_dsa',
|
|
3324
3791
|
':(exclude,glob)**/*.pem',
|
|
3325
3792
|
':(exclude,glob)**/*.key',
|
|
3326
3793
|
':(exclude,glob)**/*.crt',
|
|
3794
|
+
':(exclude,glob)**/*.cer',
|
|
3795
|
+
':(exclude,glob)**/*.der',
|
|
3796
|
+
':(exclude,glob)**/*.pfx',
|
|
3327
3797
|
':(exclude,glob)**/*.p12',
|
|
3328
3798
|
':(exclude,glob)**/*.dump',
|
|
3329
3799
|
':(exclude,glob)**/*.sql',
|
|
3800
|
+
':(exclude,glob)**/*.secret',
|
|
3801
|
+
':(exclude,glob)**/credentials',
|
|
3802
|
+
':(exclude,glob)**/credentials.json',
|
|
3803
|
+
// Use `secrets/**` (not `secrets/*`) so nested credential paths
|
|
3804
|
+
// recurse - with glob pathspec magic a single `*` does not cross path
|
|
3805
|
+
// separators, so the non-recursive form would let `secrets/prod/x.key`
|
|
3806
|
+
// ship in the diff payload.
|
|
3807
|
+
':(exclude,glob)**/secrets/**',
|
|
3330
3808
|
];
|
|
3331
3809
|
function collectUntrackedSummary(root) {
|
|
3332
3810
|
const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
|
|
@@ -3343,12 +3821,28 @@ function collectUntrackedSummary(root) {
|
|
|
3343
3821
|
return { paths: visible.slice(0, 50), excludedProtected: excluded };
|
|
3344
3822
|
}
|
|
3345
3823
|
function isProtectedPath(path) {
|
|
3824
|
+
// Keep in sync with PROTECTED_DIFF_EXCLUDES above. This filter
|
|
3825
|
+
// applies to the untracked-files summary surfaced to operators; the
|
|
3826
|
+
// pathspec excludes apply at the egress / diff capture layer.
|
|
3346
3827
|
const base = path.split('/').pop() ?? path;
|
|
3347
3828
|
if (base === '.env' || base.startsWith('.env.'))
|
|
3348
3829
|
return true;
|
|
3349
|
-
|
|
3830
|
+
const exactNames = [
|
|
3831
|
+
'.npmrc',
|
|
3832
|
+
'.yarnrc',
|
|
3833
|
+
'.pypirc',
|
|
3834
|
+
'.gitconfig',
|
|
3835
|
+
'.netrc',
|
|
3836
|
+
'id_rsa',
|
|
3837
|
+
'id_ed25519',
|
|
3838
|
+
'id_ecdsa',
|
|
3839
|
+
'id_dsa',
|
|
3840
|
+
'credentials',
|
|
3841
|
+
'credentials.json',
|
|
3842
|
+
];
|
|
3843
|
+
if (exactNames.includes(base))
|
|
3350
3844
|
return true;
|
|
3351
|
-
return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
|
|
3845
|
+
return /\.(pem|key|crt|cer|der|pfx|p12|dump|sql|secret)$/i.test(base);
|
|
3352
3846
|
}
|
|
3353
3847
|
function safeReadJson(path) {
|
|
3354
3848
|
try {
|