@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.10

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.
Files changed (74) hide show
  1. package/README.md +33 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/assets/pugi-mascot.ansi +16 -0
  4. package/dist/commands/deploy.js +439 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +1 -1
  7. package/dist/core/consensus/anvil-fanout.js +276 -0
  8. package/dist/core/consensus/diff-capture.js +382 -0
  9. package/dist/core/consensus/rubric.js +233 -0
  10. package/dist/core/context/index.js +21 -0
  11. package/dist/core/context/pugiignore.js +316 -0
  12. package/dist/core/context/repo-skeleton.js +533 -0
  13. package/dist/core/context/watcher.js +342 -0
  14. package/dist/core/context/working-set.js +165 -0
  15. package/dist/core/edits/dispatch.js +185 -0
  16. package/dist/core/edits/index.js +15 -0
  17. package/dist/core/edits/layer-a-apply.js +217 -0
  18. package/dist/core/edits/layer-b-apply.js +211 -0
  19. package/dist/core/edits/layer-c-apply.js +160 -0
  20. package/dist/core/edits/layer-d-ast.js +29 -0
  21. package/dist/core/edits/marker-parser.js +401 -0
  22. package/dist/core/edits/security-gate.js +223 -0
  23. package/dist/core/edits/worktree.js +322 -0
  24. package/dist/core/engine/native-pugi.js +6 -1
  25. package/dist/core/engine/prompts.js +8 -0
  26. package/dist/core/engine/tool-bridge.js +33 -1
  27. package/dist/core/lsp/client.js +719 -0
  28. package/dist/core/repl/ask.js +512 -0
  29. package/dist/core/repl/cancellation.js +98 -0
  30. package/dist/core/repl/dispatch-fsm.js +220 -0
  31. package/dist/core/repl/privacy-banner.js +71 -0
  32. package/dist/core/repl/session.js +1908 -13
  33. package/dist/core/repl/slash-commands.js +92 -32
  34. package/dist/core/repl/store/index.js +12 -0
  35. package/dist/core/repl/store/jsonl-log.js +321 -0
  36. package/dist/core/repl/store/lockfile.js +155 -0
  37. package/dist/core/repl/store/session-store.js +792 -0
  38. package/dist/core/repl/store/types.js +44 -0
  39. package/dist/core/repl/store/uuid-v7.js +68 -0
  40. package/dist/core/repl/workspace-context.js +72 -1
  41. package/dist/core/skills/defaults.js +457 -0
  42. package/dist/core/skills/loader.js +454 -0
  43. package/dist/core/skills/sources.js +480 -0
  44. package/dist/core/skills/trust.js +172 -0
  45. package/dist/runtime/cli.js +998 -12
  46. package/dist/runtime/commands/agents.js +385 -0
  47. package/dist/runtime/commands/config.js +338 -8
  48. package/dist/runtime/commands/delegate.js +289 -0
  49. package/dist/runtime/commands/lsp.js +206 -0
  50. package/dist/runtime/commands/patch.js +128 -0
  51. package/dist/runtime/commands/review-consensus.js +399 -0
  52. package/dist/runtime/commands/roster.js +117 -0
  53. package/dist/runtime/commands/skills.js +401 -0
  54. package/dist/runtime/commands/worktree.js +177 -0
  55. package/dist/runtime/plan-decompose.js +531 -0
  56. package/dist/tools/apply-patch.js +495 -0
  57. package/dist/tools/file-tools.js +90 -0
  58. package/dist/tools/lsp-tools.js +189 -0
  59. package/dist/tools/registry.js +26 -0
  60. package/dist/tools/web-fetch.js +1 -1
  61. package/dist/tui/agent-tree-pane.js +9 -0
  62. package/dist/tui/ask-cli.js +52 -0
  63. package/dist/tui/ask-modal.js +211 -0
  64. package/dist/tui/conversation-pane.js +48 -3
  65. package/dist/tui/input-box.js +48 -5
  66. package/dist/tui/markdown-render.js +266 -0
  67. package/dist/tui/repl-render.js +319 -3
  68. package/dist/tui/repl-splash-mascot.js +130 -0
  69. package/dist/tui/repl-splash.js +7 -1
  70. package/dist/tui/repl.js +96 -12
  71. package/dist/tui/status-bar.js +63 -3
  72. package/dist/tui/tool-stream-pane.js +91 -0
  73. package/docs/examples/codegraph.mcp.json +10 -0
  74. package/package.json +14 -6
@@ -16,14 +16,30 @@ 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 { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
19
+ import { signatureForPlanReview } from '../core/repl/ask.js';
20
+ import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSession, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitDelegate, submitSync, submitTripleReview, } from '@pugi/sdk';
20
21
  import { PUGI_TAGLINE } from '@pugi/personas';
22
+ import { resolveRoster, renderRosterTable } from './commands/roster.js';
23
+ import { runDelegateCommand } from './commands/delegate.js';
21
24
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
25
+ import { runDeployCommand } from '../commands/deploy.js';
22
26
  import { runJobsCommand } from '../commands/jobs.js';
23
27
  import { runConfigCommand } from './commands/config.js';
24
28
  import { runPrivacyCommand } from './commands/privacy.js';
25
29
  import { runUndoCommand } from './commands/undo.js';
26
30
  import { runBudgetCommand } from './commands/budget.js';
31
+ import { runSkillsCommand } from './commands/skills.js';
32
+ import { installDefaultSkills } from '../core/skills/defaults.js';
33
+ import { runAgentsCommand } from './commands/agents.js';
34
+ import { runLspCommand } from './commands/lsp.js';
35
+ import { runPatchCommand } from './commands/patch.js';
36
+ import { runWorktreeCommand } from './commands/worktree.js';
37
+ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
38
+ import { runReviewConsensus } from './commands/review-consensus.js';
39
+ import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
40
+ import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
41
+ import { slugForCwd } from '../core/repl/history.js';
42
+ import { dispatchEdit, } from '../core/edits/index.js';
27
43
  /**
28
44
  * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
29
45
  *
@@ -35,13 +51,17 @@ import { runBudgetCommand } from './commands/budget.js';
35
51
  * packages/pugi-sdk/package.json); the publish workflow validates the
36
52
  * three are in lockstep.
37
53
  */
38
- const PUGI_CLI_VERSION = '0.1.0-alpha.9';
54
+ const PUGI_CLI_VERSION = "0.1.0-beta.10";
39
55
  const handlers = {
40
56
  accounts,
57
+ agents: dispatchAgents,
58
+ ask: dispatchAsk,
41
59
  build: runEngineTask('build_task'),
42
60
  budget: dispatchBudget,
43
61
  code: runEngineTask('code'),
44
62
  config: dispatchConfig,
63
+ delegate: dispatchDelegate,
64
+ deploy: dispatchDeploy,
45
65
  doctor,
46
66
  explain: runEngineTask('explain'),
47
67
  fix: runEngineTask('fix'),
@@ -52,17 +72,179 @@ const handlers = {
52
72
  jobs,
53
73
  login,
54
74
  logout,
75
+ lsp: dispatchLsp,
76
+ patch: dispatchPatch,
55
77
  plan: runEngineTask('plan'),
78
+ 'plan-review': dispatchPlanReview,
56
79
  privacy: dispatchPrivacy,
57
80
  review,
58
81
  resume,
82
+ roster: dispatchRoster,
59
83
  sessions,
84
+ skills: dispatchSkills,
60
85
  sync,
61
86
  undo: dispatchUndo,
62
87
  version,
63
88
  web: dispatchWeb,
64
89
  whoami,
90
+ worktree: dispatchWorktree,
65
91
  };
92
+ /**
93
+ * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
94
+ * modal manually. In an interactive TTY we mount a tiny Ink app, render
95
+ * the `<AskModal />`, and await the operator's verdict. In non-TTY
96
+ * (CI / pipes), we emit the structured ask JSON to stdout so scripted
97
+ * callers can pipe the response back without rendering a modal.
98
+ *
99
+ * The verdict is printed to stdout as either:
100
+ * - the chosen option `value` (one of the yes/no defaults)
101
+ * - `other:<text>` when the operator typed a custom answer
102
+ * - `cancelled` when the operator pressed Esc
103
+ *
104
+ * This is a CLI-side helper. The REPL slash `/ask` is wired separately
105
+ * through `slash-commands.ts`.
106
+ */
107
+ async function dispatchAsk(args, flags, _session) {
108
+ const question = args.join(' ').trim();
109
+ if (!question) {
110
+ writeOutput(flags, { ok: false, error: 'Usage: pugi ask "<question>"' }, 'Usage: pugi ask "<question>"');
111
+ process.exitCode = 2;
112
+ return;
113
+ }
114
+ const { synthesiseLocalAskTag } = await import('../core/repl/session.js');
115
+ const tag = synthesiseLocalAskTag(question);
116
+ if (!tag) {
117
+ writeOutput(flags, { ok: false, error: 'Question must be 1-80 chars.' }, 'pugi ask: question must be 1-80 chars.');
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+ if (!isInteractive(flags)) {
122
+ // Non-TTY: emit the structured ask payload so scripted callers can
123
+ // forward it. The interactive modal is only meaningful with a real
124
+ // terminal, so the line-buffered fallback prints the question +
125
+ // options and exits 0 — callers parse the JSON.
126
+ const payload = {
127
+ ok: true,
128
+ ask: {
129
+ question: tag.question,
130
+ options: tag.options,
131
+ signature: tag.signature,
132
+ },
133
+ };
134
+ writeOutput(flags, payload, [
135
+ `Question: ${tag.question}`,
136
+ ...tag.options.map((o, i) => ` ${i + 1}. ${o.label}${o.desc ? ` - ${o.desc}` : ''}`),
137
+ ` ${tag.options.length + 1}. Other (custom)`,
138
+ '',
139
+ '(non-TTY: re-run in a real terminal to answer interactively, or pipe an answer to stdin)',
140
+ ].join('\n'));
141
+ return;
142
+ }
143
+ // Interactive: render the Ink modal and await resolution.
144
+ const { renderAskCli } = await import('../tui/ask-cli.js');
145
+ const verdict = await renderAskCli({ tag });
146
+ const encoded = verdict.cancelled
147
+ ? 'cancelled'
148
+ : verdict.value.length > 0
149
+ ? verdict.value
150
+ : `other:${verdict.customInput ?? ''}`;
151
+ writeOutput(flags, { ok: true, verdict: encoded }, encoded);
152
+ }
153
+ /**
154
+ * α6.3 `pugi plan-review <task>` — generate + present a plan WITHOUT
155
+ * executing. The legacy `pugi plan` surface (offline plan generator,
156
+ * see runEngineTask) stays intact; `plan-review` adds the office-hours
157
+ * Ink modal layer on top so the operator can approve/modify/cancel
158
+ * before the orchestrator dispatches Marcus.
159
+ *
160
+ * Phase 1 implementation: build a deterministic plan stub from the
161
+ * task description (the persona-driven planner ships in a follow-up
162
+ * sprint). The plan is presented through the standard
163
+ * `<pugi-plan-review>` modal in interactive mode; non-TTY emits the
164
+ * structured payload to stdout for scripted consumers.
165
+ *
166
+ * The exit code reflects the operator's verdict:
167
+ * - 0 PASS approved
168
+ * - 1 MODIFY modify (text printed)
169
+ * - 2 CANCEL cancel
170
+ */
171
+ async function dispatchPlanReview(args, flags, _session) {
172
+ const task = args.join(' ').trim();
173
+ if (!task) {
174
+ writeOutput(flags, { ok: false, error: 'Usage: pugi plan <task description>' }, 'Usage: pugi plan <task description>');
175
+ process.exitCode = 2;
176
+ return;
177
+ }
178
+ const planTag = synthesiseLocalPlanReview(task);
179
+ if (!isInteractive(flags)) {
180
+ const payload = {
181
+ ok: true,
182
+ plan: {
183
+ steps: planTag.steps,
184
+ risk: planTag.risk,
185
+ signature: planTag.signature,
186
+ },
187
+ };
188
+ writeOutput(flags, payload, [
189
+ 'Plan review (non-execution):',
190
+ ...planTag.steps.map((s, i) => ` ${i + 1}. ${s.text}`),
191
+ planTag.risk ? `Risk: ${planTag.risk}` : '',
192
+ '',
193
+ '(non-TTY: re-run in a real terminal to approve/modify/cancel interactively)',
194
+ ]
195
+ .filter(Boolean)
196
+ .join('\n'));
197
+ return;
198
+ }
199
+ const { renderPlanReviewCli } = await import('../tui/ask-cli.js');
200
+ const result = await renderPlanReviewCli({ tag: planTag });
201
+ switch (result.verdict) {
202
+ case 'approve':
203
+ writeOutput(flags, { ok: true, verdict: 'approve' }, 'approve');
204
+ return;
205
+ case 'modify':
206
+ writeOutput(flags, { ok: true, verdict: 'modify', modifyText: result.modifyText ?? '' }, `modify: ${result.modifyText ?? ''}`);
207
+ process.exitCode = 1;
208
+ return;
209
+ case 'cancel':
210
+ writeOutput(flags, { ok: true, verdict: 'cancel' }, 'cancel');
211
+ process.exitCode = 2;
212
+ return;
213
+ }
214
+ }
215
+ /**
216
+ * Local plan stub generator. Until the persona-side planner lands, we
217
+ * produce a deterministic 3-step skeleton anchored to the task text so
218
+ * the operator can dry-run the modal interaction. Real plan synthesis
219
+ * arrives in a follow-up sprint.
220
+ */
221
+ function synthesiseLocalPlanReview(task) {
222
+ const truncated = task.length > 80 ? task.slice(0, 77) + '...' : task;
223
+ const steps = [
224
+ { text: `1. Understand the task: ${truncated}` },
225
+ { text: '2. Identify scope, files touched, side effects.' },
226
+ { text: '3. Execute with verification gates per Pugi defaults.' },
227
+ ];
228
+ const risk = task.length > 200
229
+ ? 'Long task description - consider splitting into smaller briefs.'
230
+ : undefined;
231
+ // Route through the single-source signature helper from ask.ts so a
232
+ // parser-extracted plan-review with identical content collides
233
+ // deterministically with this synthesised one. Inlining the formula
234
+ // here (as the original implementation did) silently drifted from
235
+ // signatureForPlanReview when the helper added `.trim()` to each
236
+ // step. Codex triple-review P2 (PR #375).
237
+ const signature = signatureForPlanReview(steps, risk ?? null);
238
+ const tag = {
239
+ steps,
240
+ signature,
241
+ start: 0,
242
+ end: 0,
243
+ };
244
+ if (risk)
245
+ tag.risk = risk;
246
+ return tag;
247
+ }
66
248
  async function dispatchConfig(args, flags, _session) {
67
249
  await runConfigCommand(args, {
68
250
  workspaceRoot: process.cwd(),
@@ -76,6 +258,59 @@ async function dispatchPrivacy(args, flags, _session) {
76
258
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
77
259
  });
78
260
  }
261
+ /**
262
+ * `pugi roster` - α7.5 Phase 1.
263
+ *
264
+ * List the live Tier 1 personas with display name, role, and routing
265
+ * tag. Walks the remote /api/pugi/sessions/roster endpoint when a
266
+ * credential is available; falls back to the local @pugi/personas
267
+ * roster when offline so the operator can still see who is on the team.
268
+ */
269
+ async function dispatchRoster(_args, flags, _session) {
270
+ const credential = resolveActiveCredential();
271
+ const config = credential
272
+ ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
273
+ : null;
274
+ const { rows, warning } = await resolveRoster(config);
275
+ const payload = {
276
+ ok: true,
277
+ personas: rows,
278
+ warning,
279
+ };
280
+ const text = (warning ? `# warning: ${warning}\n\n` : '') +
281
+ renderRosterTable(rows);
282
+ writeOutput(flags, payload, text);
283
+ }
284
+ /**
285
+ * `pugi delegate <slug> "<brief>"` - α7.5 Phase 1.
286
+ *
287
+ * Open a fresh REPL session and POST the brief to one Tier 1 persona,
288
+ * bypassing Mira's coordinator pass. Non-interactive: the CLI prints
289
+ * the dispatch id on success and exits; the operator (or a script) can
290
+ * subscribe to the session stream separately if they want the live
291
+ * lifecycle. Interactive operators use `/delegate` from inside the REPL
292
+ * instead so the dispatch lifecycle surfaces inline.
293
+ */
294
+ async function dispatchDelegate(args, flags, _session) {
295
+ await runDelegateCommand(args, {
296
+ workspaceCwd: process.cwd(),
297
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
298
+ resolveConfig: () => {
299
+ const credential = resolveActiveCredential();
300
+ if (!credential)
301
+ return null;
302
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
303
+ },
304
+ fetchRoster: fetchPersonaRoster,
305
+ submitDelegate,
306
+ openSession: async (config, workspaceCwd) => {
307
+ const result = await openPugiSession(config, { workspaceCwd });
308
+ if (result.status === 'ok')
309
+ return { sessionId: result.response.sessionId };
310
+ return { error: `${result.status}: ${result.message}` };
311
+ },
312
+ });
313
+ }
79
314
  async function dispatchUndo(args, flags, session) {
80
315
  await runUndoCommand(args, {
81
316
  workspaceRoot: process.cwd(),
@@ -89,6 +324,20 @@ async function dispatchBudget(args, flags, _session) {
89
324
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
90
325
  });
91
326
  }
327
+ async function dispatchSkills(args, flags, _session) {
328
+ await runSkillsCommand(args, {
329
+ workspaceRoot: process.cwd(),
330
+ json: flags.json,
331
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
332
+ });
333
+ }
334
+ async function dispatchAgents(args, flags, _session) {
335
+ await runAgentsCommand(args, {
336
+ workspaceRoot: process.cwd(),
337
+ json: flags.json,
338
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
339
+ });
340
+ }
92
341
  /**
93
342
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
94
343
  *
@@ -130,6 +379,59 @@ async function dispatchWeb(args, flags, _session) {
130
379
  }
131
380
  writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
132
381
  }
382
+ /**
383
+ * α7.7: `pugi lsp <op> <file> [args]` — direct LSP queries. Delegated
384
+ * to the standalone runner in `./commands/lsp.ts` so the giant cli.ts
385
+ * dispatch table stays narrow. The runner spawns + tears down the LSP
386
+ * server per invocation (no daemon yet — that ships in α7.7b).
387
+ */
388
+ async function dispatchLsp(args, flags, _session) {
389
+ const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
390
+ console.log(result.text);
391
+ if (result.exitCode !== 0)
392
+ process.exitCode = result.exitCode;
393
+ }
394
+ /**
395
+ * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
396
+ * Routes through the same security gate as the Layer A/B/C applicators
397
+ * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
398
+ * security taxonomy so CI loops can alert on hostile patches without
399
+ * confusing them with operator typos.
400
+ *
401
+ * R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
402
+ * top-level parser's consumption of `--dry-run` does not silently
403
+ * disable dry-run mode on `pugi patch --dry-run < diff.patch`.
404
+ */
405
+ async function dispatchPatch(args, flags, _session) {
406
+ const result = await runPatchCommand(args, {
407
+ cwd: process.cwd(),
408
+ json: flags.json,
409
+ dryRun: flags.dryRun,
410
+ });
411
+ console.log(result.text);
412
+ if (result.exitCode !== 0)
413
+ process.exitCode = result.exitCode;
414
+ }
415
+ /**
416
+ * α7.7: `pugi worktree <op>` — manual scratch worktree management.
417
+ * The `pugi build` and `pugi review --consensus` paths use the same
418
+ * primitives internally (`createWorktree` / `promoteWorktree`); this
419
+ * surface is the operator escape hatch for debug + experiment flows.
420
+ *
421
+ * R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
422
+ * top-level parser's consumption of `--dry-run` does not silently
423
+ * disable dry-run mode on `pugi worktree promote --dry-run <path>`.
424
+ */
425
+ async function dispatchWorktree(args, flags, _session) {
426
+ const result = await runWorktreeCommand(args, {
427
+ cwd: process.cwd(),
428
+ json: flags.json,
429
+ dryRun: flags.dryRun,
430
+ });
431
+ console.log(result.text);
432
+ if (result.exitCode !== 0)
433
+ process.exitCode = result.exitCode;
434
+ }
133
435
  export async function runCli(argv) {
134
436
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
135
437
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
@@ -170,6 +472,7 @@ export async function runCli(argv) {
170
472
  cliVersion: PUGI_CLI_VERSION,
171
473
  updateBanner,
172
474
  skipSplash: flags.noSplash,
475
+ hideToolStream: flags.noToolStream,
173
476
  });
174
477
  return;
175
478
  }
@@ -201,11 +504,26 @@ function parseArgs(argv) {
201
504
  web: false,
202
505
  dryRun: false,
203
506
  triple: false,
507
+ consensus: false,
204
508
  offline: false,
205
509
  noTty: false,
206
510
  allowFetch: false,
207
511
  noUpdateCheck: false,
208
512
  noSplash: process.env.PUGI_SKIP_SPLASH === '1',
513
+ // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
514
+ // until backend ships `tool.start`/`tool.result` SSE events. Current
515
+ // client-side synthesiser parses persona prose for `Read(...)` /
516
+ // `Bash(...)` patterns — never fires in production (admin-api emits
517
+ // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
518
+ // accidental `Verb(noun)` shapes producing stuck `running` rows.
519
+ // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
520
+ // for development/testing. Will flip to default ON when backend
521
+ // emits real tool events (filed as α6.13.X follow-up).
522
+ noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
523
+ ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
524
+ : true,
525
+ noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
526
+ decompose: false,
209
527
  };
210
528
  const args = [];
211
529
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -232,9 +550,16 @@ function parseArgs(argv) {
232
550
  else if (arg === '--dry-run') {
233
551
  flags.dryRun = true;
234
552
  }
235
- else if (arg === '--triple' || arg === '--consensus') {
553
+ else if (arg === '--triple') {
236
554
  flags.triple = true;
237
555
  }
556
+ else if (arg === '--consensus') {
557
+ // α6.7: customer-facing 3-model consensus review. Routes through
558
+ // the SSE-based runtime gate rather than the legacy artifact
559
+ // writer. The triple flag stays unset so the existing
560
+ // performRemoteTripleReview path is never accidentally entered.
561
+ flags.consensus = true;
562
+ }
238
563
  else if (arg === '--offline') {
239
564
  flags.offline = true;
240
565
  }
@@ -250,6 +575,25 @@ function parseArgs(argv) {
250
575
  else if (arg === '--no-splash') {
251
576
  flags.noSplash = true;
252
577
  }
578
+ else if (arg === '--no-tool-stream') {
579
+ flags.noToolStream = true;
580
+ }
581
+ else if (arg === '--tool-stream') {
582
+ // Opt-in for α6.12 dev/testing — backend tool events not live yet,
583
+ // pane shows synthesized heuristic OR empty placeholder
584
+ flags.noToolStream = false;
585
+ }
586
+ else if (arg === '--no-defaults') {
587
+ // Init-only flag: skip the bundled default-skills install. Parsed
588
+ // at the global level for consistency with --no-splash / --no-tool-stream.
589
+ flags.noDefaults = true;
590
+ }
591
+ else if (arg === '--decompose') {
592
+ // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
593
+ // it. Parsed globally for symmetry with the rest of the flag
594
+ // grammar; `runEngineTask('plan')` is the single consumer.
595
+ flags.decompose = true;
596
+ }
253
597
  else if (arg.startsWith('--privacy=')) {
254
598
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
255
599
  }
@@ -265,8 +609,20 @@ function parseArgs(argv) {
265
609
  }
266
610
  }
267
611
  const isBareInvocation = args.length === 0;
612
+ const command = args.shift() ?? 'help';
613
+ // Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
614
+ // / `-h` on ANY sub-command must route to the help printer rather
615
+ // than dispatching the real engine. Before this guard `pugi build
616
+ // --help` burned 86k tokens running the actual build loop because
617
+ // the dispatcher saw `--help` as an opaque arg and forwarded it
618
+ // through to the engine. Re-routing here means `pugi <cmd> --help`
619
+ // becomes `pugi help <cmd>` deterministically across the entire
620
+ // command tree.
621
+ if (args.includes('--help') || args.includes('-h')) {
622
+ return { command: 'help', args: [command], flags, isBareInvocation: false };
623
+ }
268
624
  return {
269
- command: args.shift() ?? 'help',
625
+ command,
270
626
  args,
271
627
  flags,
272
628
  isBareInvocation,
@@ -299,6 +655,39 @@ async function help(_args, flags, _session) {
299
655
  '',
300
656
  'Review gate:',
301
657
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
658
+ ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
659
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
660
+ ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
661
+ '',
662
+ 'Skills + agents marketplace:',
663
+ ' pugi skills list All installed skills.',
664
+ ' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
665
+ ' pugi skills info <name> Metadata + body preview.',
666
+ ' pugi agents list All installed sub-agents.',
667
+ ' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
668
+ '',
669
+ 'Office-hours forcing questions (α6.3):',
670
+ ' pugi ask "<question>" Surface a yes/no question modal locally.',
671
+ ' pugi plan-review <task> Generate + present a plan-review modal.',
672
+ '',
673
+ 'Persona dispatch (α7.5):',
674
+ ' pugi roster List the live Tier 1 personas + roles.',
675
+ ' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
676
+ '',
677
+ 'Plan decomposition (α6.8):',
678
+ ' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
679
+ ' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
680
+ ' plus manifest.md with the dependency DAG.',
681
+ '',
682
+ 'Deploy:',
683
+ ' pugi deploy --target vercel <vercelProject> --project <id>',
684
+ ' Trigger a Vercel deployment from the bound Git source.',
685
+ ' Optional: --target-env production|preview, --ref <ref>,',
686
+ ' --integration <id>.',
687
+ ' pugi deploy --target render <renderService> --project <id>',
688
+ ' Trigger a Render deployment (Sprint 2 — stub today).',
689
+ ' pugi deploy --status <id> Vendor-agnostic status snapshot.',
690
+ ' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
302
691
  '',
303
692
  'Sync safety:',
304
693
  ' pugi sync --dry-run --privacy metadata',
@@ -310,6 +699,10 @@ async function help(_args, flags, _session) {
310
699
  ' with PUGI_SKIP_UPDATE_BANNER=1.',
311
700
  ' --no-splash Skip the REPL boot splash. Pairs with',
312
701
  ' PUGI_SKIP_SPLASH=1.',
702
+ ' --no-tool-stream Hide the live tool stream pane (α6.12).',
703
+ ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
704
+ ' --no-defaults Skip bundled default-skills install on',
705
+ ' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
313
706
  '',
314
707
  PUGI_TAGLINE,
315
708
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -379,6 +772,7 @@ async function init(_args, flags, _session) {
379
772
  ensureDir(pugiDir, created, skipped);
380
773
  ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
381
774
  ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
775
+ ensureDir(resolve(pugiDir, 'skills'), created, skipped);
382
776
  writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
383
777
  schema: 1,
384
778
  workflow: {
@@ -448,17 +842,50 @@ async function init(_args, flags, _session) {
448
842
  // Ensure `.pugi/` is git-ignored so users do not accidentally commit
449
843
  // local audit logs, artifacts, or triple-review request payloads.
450
844
  ensurePugiGitIgnore(cwd, created, skipped);
845
+ // Bundled default skills (brand-voice, endpoint-probe, readme-sync).
846
+ // Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
847
+ // Idempotent: a skill whose target directory already exists is left
848
+ // alone so re-running `pugi init` after the operator customised one of
849
+ // the defaults does not clobber their edits.
850
+ let defaultSkills = [];
851
+ if (!flags.noDefaults) {
852
+ try {
853
+ defaultSkills = await installDefaultSkills({
854
+ workspaceRoot: cwd,
855
+ log: (line) => process.stderr.write(line),
856
+ });
857
+ }
858
+ catch (error) {
859
+ // Default-skills install is a convenience layer. A failure here
860
+ // (bad sha256 hashing, permission error on .pugi/skills/) must not
861
+ // leave `pugi init` in a half-state where settings.json exists but
862
+ // the operator sees an unexplained crash. Log the error to stderr
863
+ // and continue — the operator can still install skills manually.
864
+ const message = error instanceof Error ? error.message : String(error);
865
+ process.stderr.write(`[pugi init] default-skills install failed: ${message}\n`);
866
+ }
867
+ }
451
868
  const payload = {
452
869
  status: 'initialized',
453
870
  root: cwd,
454
871
  created,
455
872
  skipped,
873
+ defaultSkills,
456
874
  };
875
+ const defaultSkillLines = flags.noDefaults
876
+ ? ['Default skills: skipped (--no-defaults)']
877
+ : defaultSkills.length === 0
878
+ ? ['Default skills: none installed']
879
+ : [
880
+ 'Default skills:',
881
+ ...defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
882
+ ];
457
883
  writeOutput(flags, payload, [
458
884
  'Pugi initialized',
459
885
  `Root: ${cwd}`,
460
886
  created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
461
887
  skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
888
+ ...defaultSkillLines,
462
889
  ].join('\n'));
463
890
  }
464
891
  async function idea(args, flags, session) {
@@ -774,6 +1201,26 @@ async function review(args, flags, session) {
774
1201
  const root = process.cwd();
775
1202
  ensureInitialized(root);
776
1203
  const prompt = args.join(' ').trim();
1204
+ // α6.7: customer-facing consensus review routes here. Distinct from
1205
+ // `--triple --remote` (legacy artifact-writer flow) so the new SSE
1206
+ // streaming UX and rubric-driven exit codes don't disturb the existing
1207
+ // pugi-cli surfaces that depend on the old shape.
1208
+ if (flags.consensus) {
1209
+ const exitCode = await runReviewConsensus(args, {
1210
+ cwd: root,
1211
+ config: resolveRuntimeConfig(),
1212
+ json: flags.json,
1213
+ emit: (line) => {
1214
+ if (!flags.json)
1215
+ process.stdout.write(line);
1216
+ },
1217
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1218
+ });
1219
+ // Caller owns `process.exitCode` so a REPL slash invocation
1220
+ // ('/consensus') cannot inherit a stale exit code from a previous run.
1221
+ process.exitCode = exitCode;
1222
+ return;
1223
+ }
777
1224
  if (flags.triple && flags.remote) {
778
1225
  await performRemoteTripleReview(root, session, flags, prompt);
779
1226
  return;
@@ -1337,6 +1784,14 @@ async function handoff(args, flags, session) {
1337
1784
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1338
1785
  }
1339
1786
  async function sessions(args, flags, _session) {
1787
+ // α6.4: `pugi sessions --local` / `--search "query"` route to the
1788
+ // local SessionStore. The default surface stays artifact-based for
1789
+ // backward compat — operators who relied on the index.json view get
1790
+ // the same shape.
1791
+ if (args.includes('--local') || args.includes('--search')) {
1792
+ await sessionsLocal(args, flags);
1793
+ return;
1794
+ }
1340
1795
  const root = process.cwd();
1341
1796
  ensureInitialized(root);
1342
1797
  const rebuild = args.includes('--rebuild');
@@ -1411,6 +1866,72 @@ async function sessions(args, flags, _session) {
1411
1866
  function hasStubSession(index) {
1412
1867
  return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
1413
1868
  }
1869
+ /**
1870
+ * α6.4: `pugi sessions --local` / `--search "query"` against the
1871
+ * SessionStore. The default `--local` mode lists the 10 most recent
1872
+ * sessions for the current project; `--search "query"` runs FTS5
1873
+ * against the title+body index.
1874
+ */
1875
+ async function sessionsLocal(args, flags) {
1876
+ const cwd = process.cwd();
1877
+ const projectSlug = slugForCwd(cwd);
1878
+ const projectDir = resolveProjectStoreDir(projectSlug);
1879
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
1880
+ writeOutput(flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
1881
+ return;
1882
+ }
1883
+ // Parse `--search "query"` or `--search query`.
1884
+ const searchIdx = args.indexOf('--search');
1885
+ const query = searchIdx >= 0 ? (args[searchIdx + 1] ?? '').trim() : '';
1886
+ if (query.length > 0) {
1887
+ let rows;
1888
+ try {
1889
+ rows = await searchLocalSessions(projectSlug, query);
1890
+ }
1891
+ catch (error) {
1892
+ // Surface FTS5 syntax errors as a clean one-line message + exit 2
1893
+ // so a stray `"` in the operator's input does not dump a stack
1894
+ // trace. Both the live-store path (FtsSyntaxError) and the
1895
+ // read-only fallback (SQLite error with code starting `SQLITE_`)
1896
+ // funnel here.
1897
+ const code = error?.code;
1898
+ if (error instanceof FtsSyntaxError
1899
+ || (typeof code === 'string' && (code === 'EFTS5_SYNTAX' || code.startsWith('SQLITE_')))) {
1900
+ writeOutput(flags, { status: 'error', error: 'invalid search query', query }, `Invalid search query: '${query}'. Try simpler text (no unbalanced quotes).`);
1901
+ process.exitCode = 2;
1902
+ return;
1903
+ }
1904
+ throw error;
1905
+ }
1906
+ writeOutput(flags, { projectSlug, query, sessions: rows }, rows.length === 0
1907
+ ? `No local sessions matched '${query}' for project '${projectSlug}'.`
1908
+ : `Search hits for '${query}' (${rows.length}):\n\n${rows
1909
+ .map((row) => ` ${row.id.slice(0, 13)} ${(row.title ?? '(untitled)').slice(0, 64)}`)
1910
+ .join('\n')}`);
1911
+ return;
1912
+ }
1913
+ const rows = await listLocalSessions(projectSlug);
1914
+ writeOutput(flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
1915
+ }
1916
+ /**
1917
+ * Run an FTS5 search against the local SessionStore. Opens the SQLite
1918
+ * file READ-ONLY via `SqliteSessionStore.openReadOnly` so the search
1919
+ * never takes the lockfile and never inserts a stub session row. Works
1920
+ * whether or not a live REPL holds the writer lock — SQLite supports
1921
+ * concurrent readers + a single writer.
1922
+ *
1923
+ * FTS syntax errors surface as `FtsSyntaxError` (code `EFTS5_SYNTAX`);
1924
+ * the dispatcher catches that + exits 2 with a clean message.
1925
+ */
1926
+ async function searchLocalSessions(projectSlug, query) {
1927
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1928
+ try {
1929
+ return await view.search(query, { limit: 20 });
1930
+ }
1931
+ finally {
1932
+ await view.close();
1933
+ }
1934
+ }
1414
1935
  function registerArtifact(root, artifact) {
1415
1936
  // Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
1416
1937
  // that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
@@ -1443,6 +1964,19 @@ function registerArtifact(root, artifact) {
1443
1964
  }
1444
1965
  async function resume(args, flags, session) {
1445
1966
  const root = process.cwd();
1967
+ // α6.4: `pugi resume [<local-session-id>]` and `pugi resume --list`
1968
+ // operate on the LOCAL SessionStore under `~/.pugi/projects/<slug>/`
1969
+ // before falling back to the legacy artifact-based resume. The
1970
+ // local-session path requires no `.pugi/` directory in the cwd
1971
+ // (the store lives under $HOME) so we run it BEFORE ensureInitialized.
1972
+ const wantsList = args.includes('--list');
1973
+ const arg0 = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
1974
+ 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;
1975
+ const looksLikeSessionShortId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}$/i.test(arg0) : false;
1976
+ if (wantsList || looksLikeSessionId || looksLikeSessionShortId) {
1977
+ await resumeLocalSession({ flags, arg0, wantsList });
1978
+ return;
1979
+ }
1446
1980
  ensureInitialized(root);
1447
1981
  const target = args[0];
1448
1982
  const artifacts = listArtifactSets(root);
@@ -1485,6 +2019,152 @@ async function resume(args, flags, session) {
1485
2019
  recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
1486
2020
  writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
1487
2021
  }
2022
+ /**
2023
+ * α6.4: resume a local SessionStore session. Two modes:
2024
+ *
2025
+ * - `pugi resume --list` → print the 10 most recent local sessions
2026
+ * for the current project slug and exit.
2027
+ * - `pugi resume <id>` → resolve the id (full or short prefix),
2028
+ * check it exists, then mount the REPL
2029
+ * with the localSessionId pre-bound so
2030
+ * the bootstrap restores the transcript.
2031
+ *
2032
+ * The list path is non-interactive — operators pick by id and re-run
2033
+ * with the chosen one. A future sprint can replace the print with an
2034
+ * Ink select prompt; today's CLI surface is scripting-friendly.
2035
+ */
2036
+ async function resumeLocalSession(input) {
2037
+ const cwd = process.cwd();
2038
+ const projectSlug = slugForCwd(cwd);
2039
+ // Resolve the project directory WITHOUT opening the store — when we
2040
+ // are only listing, taking the lock would block a live REPL.
2041
+ const projectDir = resolveProjectStoreDir(projectSlug);
2042
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
2043
+ writeOutput(input.flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
2044
+ return;
2045
+ }
2046
+ if (input.wantsList && !input.arg0) {
2047
+ // Read-only list. Open + close without writing to keep it cheap.
2048
+ const rows = await listLocalSessions(projectSlug);
2049
+ writeOutput(input.flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
2050
+ return;
2051
+ }
2052
+ if (!input.arg0) {
2053
+ 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).');
2054
+ process.exitCode = 2;
2055
+ return;
2056
+ }
2057
+ // Resolve the id. Accepts full uuid OR the 13-char prefix `pugi
2058
+ // resume` prints (`xxxxxxxx-xxxx`). Match on prefix because the
2059
+ // operator types from the human-friendly listing.
2060
+ const candidate = input.arg0;
2061
+ const target = await resolveLocalSessionId(projectSlug, candidate);
2062
+ if (!target) {
2063
+ writeOutput(input.flags, { status: 'not-found', id: candidate }, `No local session matches '${candidate}'. Run \`pugi resume --list\`.`);
2064
+ process.exitCode = 1;
2065
+ return;
2066
+ }
2067
+ // Hand off to the REPL bootstrap with the resolved id pre-bound so
2068
+ // the SessionStore opens the existing log + the bootstrap calls
2069
+ // restoreTranscript before the first user input.
2070
+ const runtimeConfig = resolveRuntimeConfig();
2071
+ if (!runtimeConfig) {
2072
+ writeOutput(input.flags, { status: 'auth-missing', id: target.id }, 'No credentials configured. Run `pugi login` first, then `pugi resume <id>`.');
2073
+ process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
2074
+ return;
2075
+ }
2076
+ const { renderRepl } = await import('../tui/repl-render.js');
2077
+ await renderRepl({
2078
+ apiUrl: runtimeConfig.apiUrl,
2079
+ apiKey: runtimeConfig.apiKey,
2080
+ workspaceLabel: workspaceLabel(cwd),
2081
+ cliVersion: PUGI_CLI_VERSION,
2082
+ skipSplash: input.flags.noSplash,
2083
+ hideToolStream: input.flags.noToolStream,
2084
+ resumeLocalSessionId: target.id,
2085
+ });
2086
+ }
2087
+ /**
2088
+ * List the most recent local sessions for a project. Uses the
2089
+ * READ-ONLY view (`SqliteSessionStore.openReadOnly`) so the call never
2090
+ * takes the lockfile and never inserts a stub session row. Safe to
2091
+ * call while a live REPL writes in the same project — SQLite supports
2092
+ * concurrent readers + a single writer.
2093
+ *
2094
+ * Previously this opened the full SqliteSessionStore (lockfile +
2095
+ * insert path), which polluted history with one empty session row per
2096
+ * `pugi resume --list` or `pugi sessions --local` invocation. Fixed in
2097
+ * the α6.4 review pass.
2098
+ */
2099
+ async function listLocalSessions(projectSlug) {
2100
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
2101
+ try {
2102
+ return await view.list({ limit: 10 });
2103
+ }
2104
+ finally {
2105
+ await view.close();
2106
+ }
2107
+ }
2108
+ /** Canonical UUID v7 surface form: 8-4-4-4-12 hex with '7' at the version nibble. */
2109
+ 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;
2110
+ /**
2111
+ * Resolve a session id from a partial input. Accepts:
2112
+ * - full uuid v7 (canonical form xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
2113
+ * - 13-char prefix `xxxxxxxx-xxxx` (the human-friendly form the
2114
+ * `/resume` list prints)
2115
+ * - short 8-char hex prefix `xxxxxxxx`
2116
+ *
2117
+ * For a FULL uuid we go direct-to-`get` so the lookup is not bounded
2118
+ * by the most-recent-N listing (operators paste an id from days ago).
2119
+ * For a prefix we fall back to scanning the first page; that matches
2120
+ * the renderer's listing window.
2121
+ *
2122
+ * Returns the matching SessionRow or null when no row matches.
2123
+ */
2124
+ async function resolveLocalSessionId(projectSlug, candidate) {
2125
+ const normalised = candidate.trim().toLowerCase();
2126
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
2127
+ try {
2128
+ if (FULL_UUID_V7_RE.test(normalised)) {
2129
+ // Direct lookup — never bounded by the listing window.
2130
+ const direct = await view.get(normalised);
2131
+ if (direct)
2132
+ return direct;
2133
+ return null;
2134
+ }
2135
+ // Prefix path: scan the most-recent 10 rows so a typed short prefix
2136
+ // resolves against what the renderer just printed.
2137
+ const rows = await view.list({ limit: 10 });
2138
+ const exact = rows.find((r) => r.id.toLowerCase() === normalised);
2139
+ if (exact)
2140
+ return exact;
2141
+ const byPrefix = rows.find((r) => r.id.toLowerCase().startsWith(normalised));
2142
+ return byPrefix ?? null;
2143
+ }
2144
+ finally {
2145
+ await view.close();
2146
+ }
2147
+ }
2148
+ function renderLocalSessionList(rows, projectSlug) {
2149
+ if (rows.length === 0) {
2150
+ return `No stored sessions for project '${projectSlug}' yet.`;
2151
+ }
2152
+ const lines = [
2153
+ `Recent local sessions for '${projectSlug}' (${rows.length}):`,
2154
+ '',
2155
+ ];
2156
+ for (let i = 0; i < rows.length; i += 1) {
2157
+ const row = rows[i];
2158
+ const title = (row.title ?? '(untitled)').slice(0, 64);
2159
+ const idShort = row.id.slice(0, 13);
2160
+ const branch = (row.branch ?? 'no-branch').padEnd(16);
2161
+ const turns = `${row.turnCount}t`.padStart(4);
2162
+ const events = `${row.eventCount}e`.padStart(5);
2163
+ lines.push(` ${idShort} ${branch} ${turns} ${events} ${title}`);
2164
+ }
2165
+ lines.push('', 'Resume with: pugi resume <id>');
2166
+ return lines.join('\n');
2167
+ }
1488
2168
  /**
1489
2169
  * Per-command exit code map. Surfaced to the operator so shell scripts
1490
2170
  * can branch on the engine outcome:
@@ -1550,6 +2230,26 @@ function runEngineTask(kind) {
1550
2230
  const config = credential
1551
2231
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
1552
2232
  : envConfig;
2233
+ // α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
2234
+ // fallback. Two reasons:
2235
+ // 1. The flag is plan-only — surfacing the rejection for
2236
+ // `pugi build --decompose` before we drop into `offlineBuild`
2237
+ // means the operator gets a deterministic error instead of a
2238
+ // silent no-op stub.
2239
+ // 2. The decompose post-processor depends on the engine's final
2240
+ // text. The offline plan stub does not invoke the engine, so
2241
+ // `pugi plan --decompose --offline` would silently skip the
2242
+ // decomposition step. Refusing the combination up front is the
2243
+ // cheapest way to keep the contract honest.
2244
+ if (flags.decompose && kind !== 'plan') {
2245
+ throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
2246
+ }
2247
+ if (flags.decompose && flags.offline) {
2248
+ throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
2249
+ }
2250
+ if (flags.decompose && !config) {
2251
+ throw new Error('--decompose requires the engine — run `pugi login` or set PUGI_API_KEY (decomposition needs the model to emit a fenced JSON block)');
2252
+ }
1553
2253
  // Offline fallback: preserves the local-first invariant. `plan` /
1554
2254
  // `build` / `explain` drop back to their pre-Sprint-2 stub
1555
2255
  // behaviour so an operator without an API key (or with --offline)
@@ -1602,6 +2302,17 @@ function runEngineTask(kind) {
1602
2302
  throw new Error(`pugi ${label} requires a prompt`);
1603
2303
  }
1604
2304
  }
2305
+ // α6.8 EXTEND PR1: when `--decompose` is set, augment the user
2306
+ // prompt with the decomposition-request suffix BEFORE the adapter
2307
+ // run. The system prompt for `plan` already constrains the model
2308
+ // to read-only tools + a plan deliverable; the suffix layers the
2309
+ // JSON-emission contract on top so the post-run parser can lift
2310
+ // the structured payload out of the final answer. The plan-only /
2311
+ // engine-required gates fired before the offline fallback above,
2312
+ // so by here we know we are on the engine path with a plan task.
2313
+ if (flags.decompose && kind === 'plan') {
2314
+ prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
2315
+ }
1605
2316
  // Narrow `config` for the type checker — the offline branches above
1606
2317
  // return whenever `config` is null, so by this point it must be set.
1607
2318
  if (!config) {
@@ -1659,6 +2370,45 @@ function runEngineTask(kind) {
1659
2370
  risks: ['adapter terminated without emitting a result event'],
1660
2371
  };
1661
2372
  }
2373
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
2374
+ //
2375
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
2376
+ // the final response rather than through tool calls (especially
2377
+ // Gemini and o1 family, which under-use tool schemas in long
2378
+ // reasoning chains). We run the dispatcher against the model's
2379
+ // final text so those markers still land on disk. Tool-call edits
2380
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
2381
+ // unaffected — the dispatcher only fires on prose blocks that
2382
+ // happen to contain markers.
2383
+ //
2384
+ // Scope: code / fix / build / explain only. `plan` is read-only
2385
+ // (the engine refuses write tools), so even a stray marker in plan
2386
+ // output gets ignored to honour the plan-mode contract.
2387
+ //
2388
+ // Dry-run + read-only short-circuits: when the flags forbid writes
2389
+ // we dispatch with `dryRun: true` so the operator still sees what
2390
+ // WOULD have been written, but nothing touches disk.
2391
+ let dispatchResults = [];
2392
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2393
+ dispatchResults = await runMarkerDispatch({
2394
+ root,
2395
+ result: {
2396
+ status: result.status,
2397
+ summary: result.summary,
2398
+ eventRefs: result.eventRefs,
2399
+ },
2400
+ dryRun: flags.dryRun,
2401
+ });
2402
+ // Merge dispatcher-touched files into `result.filesChanged` so the
2403
+ // operator-facing summary lists them alongside tool-driven edits.
2404
+ for (const dr of dispatchResults) {
2405
+ if (dr.ok && dr.absPath) {
2406
+ const rel = relative(root, dr.absPath);
2407
+ if (!result.filesChanged.includes(rel))
2408
+ result.filesChanged.push(rel);
2409
+ }
2410
+ }
2411
+ }
1662
2412
  // For `plan` we always write a plan.md artifact, regardless of
1663
2413
  // outcome. A blocked plan (budget exhausted, tool refusal) still
1664
2414
  // produces a reviewable artifact — the reason is recorded inline.
@@ -1672,6 +2422,41 @@ function runEngineTask(kind) {
1672
2422
  statusEvents,
1673
2423
  });
1674
2424
  }
2425
+ // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
2426
+ // the parse on a `done` plan (a blocked/failed plan is already
2427
+ // captured in plan.md with its reason; no JSON to extract). The
2428
+ // model's final answer arrives via `result.summary` — on success
2429
+ // the adapter prefix is empty so it is the raw final text. We
2430
+ // strip any leading/trailing whitespace then run the parser
2431
+ // against the contents. On parse failure we surface a non-fatal
2432
+ // structured error in the payload — the operator still gets the
2433
+ // plan.md artifact and can re-run.
2434
+ //
2435
+ // TODO(α7.x): `result.summary` is currently a string contract that
2436
+ // doubles as both "human-readable headline" and "raw final model
2437
+ // text". Split into `{ summary, finalText }` on the adapter so the
2438
+ // parser does not have to assume the prefix is empty. Tracked in
2439
+ // PR #423 v2 retro (P2.6, Claude review).
2440
+ let decomposeArtifact = null;
2441
+ let decomposeError = null;
2442
+ if (flags.decompose && kind === 'plan' && result.status === 'done') {
2443
+ const parsed = parseDecompositionFromText(result.summary);
2444
+ if (parsed.ok) {
2445
+ decomposeArtifact = writeDecomposition({
2446
+ root,
2447
+ sessionId: session.id,
2448
+ // Persist the OPERATOR's original prompt, not the prompt+suffix
2449
+ // we sent to the engine. The suffix is plumbing; the manifest
2450
+ // header reads naturally only with the operator text.
2451
+ prompt: args.join(' ').trim() || prompt,
2452
+ decomposition: parsed.decomposition,
2453
+ rationale: parsed.rationale,
2454
+ });
2455
+ }
2456
+ else {
2457
+ decomposeError = { reason: parsed.reason, detail: parsed.detail };
2458
+ }
2459
+ }
1675
2460
  // Pull the headline metrics out of `eventRefs` so the summary and
1676
2461
  // JSON envelope match without re-parsing strings in two places.
1677
2462
  const metrics = parseEventRefs(result.eventRefs);
@@ -1721,6 +2506,30 @@ function runEngineTask(kind) {
1721
2506
  sessionEventsMirror: metrics.mirror,
1722
2507
  risks: result.risks,
1723
2508
  plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2509
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
2510
+ // markers were detected in the model's final response.
2511
+ diffEdits: dispatchResults.map((dr) => ({
2512
+ layer: dr.layer,
2513
+ file: dr.file,
2514
+ ok: dr.ok,
2515
+ bytesWritten: dr.bytesWritten,
2516
+ reason: dr.reason,
2517
+ detail: dr.detail,
2518
+ })),
2519
+ // α6.8 EXTEND PR1: decompose artifacts (only present when
2520
+ // `--decompose` was passed AND the model emitted a parseable
2521
+ // JSON block). The `error` shape lands when the model returned
2522
+ // unparseable output; the operator can re-run with a tighter
2523
+ // prompt without losing the plain plan.md artifact.
2524
+ decompose: decomposeArtifact !== null
2525
+ ? {
2526
+ manifest: relative(root, decomposeArtifact.manifestPath),
2527
+ planDir: relative(root, decomposeArtifact.planDir),
2528
+ splits: decomposeArtifact.splitPaths,
2529
+ }
2530
+ : decomposeError !== null
2531
+ ? { error: decomposeError }
2532
+ : undefined,
1724
2533
  // The full event stream is useful for cabinet UI replay. We surface
1725
2534
  // it in JSON mode only — text mode operators want the summary, not
1726
2535
  // 30 turn-level lines.
@@ -1730,6 +2539,13 @@ function runEngineTask(kind) {
1730
2539
  if (kind === 'plan' && planArtifact) {
1731
2540
  textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
1732
2541
  }
2542
+ if (decomposeArtifact !== null) {
2543
+ textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
2544
+ textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
2545
+ }
2546
+ else if (decomposeError !== null) {
2547
+ textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
2548
+ }
1733
2549
  textLines.push(`Pugi ${label}: ${result.status}`);
1734
2550
  textLines.push(`Summary: ${result.summary}`);
1735
2551
  if (result.filesChanged.length > 0) {
@@ -1741,6 +2557,19 @@ function runEngineTask(kind) {
1741
2557
  textLines.push('Files modified: none');
1742
2558
  }
1743
2559
  textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2560
+ if (dispatchResults.length > 0) {
2561
+ const okCount = dispatchResults.filter((d) => d.ok).length;
2562
+ const failCount = dispatchResults.length - okCount;
2563
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2564
+ for (const dr of dispatchResults) {
2565
+ if (dr.ok) {
2566
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2567
+ }
2568
+ else {
2569
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2570
+ }
2571
+ }
2572
+ }
1744
2573
  if (result.risks.length > 0) {
1745
2574
  textLines.push(`Risks: ${result.risks.join('; ')}`);
1746
2575
  }
@@ -1750,6 +2579,96 @@ function runEngineTask(kind) {
1750
2579
  writeOutput(flags, payload, textLines.join('\n'));
1751
2580
  };
1752
2581
  }
2582
+ // Exported for the α6.6.1 triple-review remediation spec
2583
+ // (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
2584
+ // surface is not part of the public CLI API; this is a test seam.
2585
+ export async function runMarkerDispatch(input) {
2586
+ const { root, result, dryRun } = input;
2587
+ const dispatch = input.dispatchFn ?? dispatchEdit;
2588
+ // Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
2589
+ // engine's terminal status. A `blocked` (budget_exhausted, plan-mode
2590
+ // refusal) or `failed` result may still carry markers in the
2591
+ // partial `summary` text — applying them would mutate files the
2592
+ // CLI then exits non-zero on, leaving the workspace in an
2593
+ // unexpected state with no operator signal that "blocked but with
2594
+ // side effects" happened. Only `done` is allowed to write.
2595
+ if (result.status !== 'done')
2596
+ return [];
2597
+ // Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
2598
+ // from the body before scanning. The prefixes start with `[`; the
2599
+ // dispatcher tolerates leading prose but the cleaner the input the
2600
+ // less chance of accidental marker matches.
2601
+ const body = result.summary;
2602
+ if (!hasAnyMarkerSignal(body))
2603
+ return [];
2604
+ const modelTag = extractModelTag(result.eventRefs);
2605
+ try {
2606
+ return await dispatch(body, {
2607
+ modelTag,
2608
+ cwd: root,
2609
+ dryRun,
2610
+ });
2611
+ }
2612
+ catch (error) {
2613
+ // Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
2614
+ // swallowed parser/applicator crashes silently — the operator
2615
+ // saw a clean "0 applied" rather than the actual stack trace,
2616
+ // and the bug only surfaced when someone manually `pugi resume`-d
2617
+ // a session and noticed the missing edits. Surface the failure
2618
+ // both to stderr (so live operators see it) and as a synthetic
2619
+ // DispatchResult (so JSON consumers and the audit log record it).
2620
+ //
2621
+ // R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
2622
+ // returned `detail: message` — i.e. the raw `Error.message`. That
2623
+ // string is constructed by whatever code path threw (parser,
2624
+ // applicator, fs layer, etc.) and may contain absolute paths,
2625
+ // secret fragments echoed in `oldString` context, or other
2626
+ // stack-bearing internals. The audit log and any JSON consumer
2627
+ // that surfaces `detail` to the operator (or worse, to a remote
2628
+ // monitoring pipe) would leak them. Stack already goes to stderr
2629
+ // for live diagnosis; the returned result must carry a safe,
2630
+ // static string so consumers can still detect "dispatcher
2631
+ // crashed" without re-rendering the underlying exception.
2632
+ const message = error instanceof Error ? error.message : String(error);
2633
+ const stack = error instanceof Error && error.stack ? error.stack : message;
2634
+ process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
2635
+ return [
2636
+ {
2637
+ layer: 'layer-a',
2638
+ file: '',
2639
+ ok: false,
2640
+ bytesWritten: 0,
2641
+ reason: 'dispatcher_crash',
2642
+ detail: 'dispatcher crashed - see stderr for stack trace',
2643
+ },
2644
+ ];
2645
+ }
2646
+ }
2647
+ /**
2648
+ * Quick pre-filter: does the body contain ANY of the marker
2649
+ * signatures the dispatcher knows about? Saves a full parse on every
2650
+ * model response (most responses are pure prose and would otherwise
2651
+ * round-trip through the parser pointlessly).
2652
+ */
2653
+ function hasAnyMarkerSignal(body) {
2654
+ return (body.includes('+++ NEW') ||
2655
+ body.includes('<<<<<<< SEARCH') ||
2656
+ body.includes('@@@ REWRITE') ||
2657
+ body.includes('@@@ AST') ||
2658
+ /^--- a\//m.test(body));
2659
+ }
2660
+ /**
2661
+ * Extract `model=<tag>` from eventRefs if the adapter emitted it.
2662
+ * Returns undefined when missing; dispatchEdit then auto-detects from
2663
+ * the payload itself.
2664
+ */
2665
+ function extractModelTag(refs) {
2666
+ for (const ref of refs) {
2667
+ if (ref.startsWith('model='))
2668
+ return ref.slice('model='.length);
2669
+ }
2670
+ return undefined;
2671
+ }
1753
2672
  /**
1754
2673
  * Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
1755
2674
  * already emits the canonical strings (`tool_calls=N`, `turns=N`,
@@ -3023,6 +3942,33 @@ async function jobs(args, flags, session) {
3023
3942
  process.exitCode = exitCode;
3024
3943
  }
3025
3944
  }
3945
+ /**
3946
+ * `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
3947
+ * `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
3948
+ * to the deploy-specific flag set + exposes the credential store via
3949
+ * `resolveRuntimeConfig` so the deploy module stays decoupled from the
3950
+ * CLI's auth bootstrap.
3951
+ */
3952
+ async function dispatchDeploy(args, flags, _session) {
3953
+ // Triple-review #391 P2: the global `parseArgs` in this file consumes
3954
+ // `--json` before the per-command args reach us, so `runDeployCommand`'s
3955
+ // internal parser never sees it and the JSON envelope path is silently
3956
+ // skipped. Re-inject the flag so downstream parsing surfaces the JSON
3957
+ // output contract the operator asked for. Idempotent: if the user wrote
3958
+ // `pugi deploy --json ...` the global parser stripped it; if they wrote
3959
+ // `pugi --json deploy ...` ditto. Either way the global flag is the
3960
+ // single source of truth and we forward it verbatim.
3961
+ const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
3962
+ const exitCode = await runDeployCommand(forwardedArgs, {
3963
+ write: (text) => process.stdout.write(text),
3964
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
3965
+ }, {
3966
+ resolveConfig: () => resolveRuntimeConfig(),
3967
+ });
3968
+ if (exitCode !== 0) {
3969
+ process.exitCode = exitCode;
3970
+ }
3971
+ }
3026
3972
  function notImplemented(command) {
3027
3973
  return async (_args, flags) => {
3028
3974
  const payload = {
@@ -3058,13 +4004,18 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
3058
4004
  * REPL header in sync with `pwd` lets the operator orient at a glance.
3059
4005
  * Empty / pathological cwd values (a worktree resolved to `/`) fall
3060
4006
  * back to `workspace` so the header never collapses.
4007
+ *
4008
+ * α6.14.2 wave 5: when the cwd has no project markers (no .git, no
4009
+ * package.json, no PUGI.md), the resolver returns the explicit "not
4010
+ * bound" warning instead of a stray parent-dir basename. CEO 2026-05-25
4011
+ * dogfood surfaced the bug — launching `pugi` from `codeforge-io/`
4012
+ * (the parent of all checkouts) leaked `codeforge-io` into the splash
4013
+ * as if it were a real workspace. Mira/Pugi can NOT bind on that. The
4014
+ * decision lives in `core/repl/workspace-context.ts` so the splash +
4015
+ * status bar agree on a single label.
3061
4016
  */
3062
4017
  function workspaceLabel(cwd) {
3063
- const segments = cwd.split('/').filter((s) => s.length > 0);
3064
- const last = segments[segments.length - 1];
3065
- if (!last || last.length === 0)
3066
- return 'workspace';
3067
- return last;
4018
+ return resolveWorkspaceLabel(cwd);
3068
4019
  }
3069
4020
  function ensureDir(path, created, skipped) {
3070
4021
  if (existsSync(path)) {
@@ -3286,22 +4237,41 @@ const PROTECTED_DIFF_EXCLUDES = [
3286
4237
  // Basename excludes apply at the repo root AND in any subdirectory
3287
4238
  // (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
3288
4239
  // `**/` prefix, git's literal pathspec syntax would only match the
3289
- // repo root and silently let a subdir `.env` ship in the diff
4240
+ // repo root and silently let a subdir `.env` ship in the diff -
3290
4241
  // common pitfall in pnpm/turbo monorepos.
4242
+ //
4243
+ // Keep this list in sync with `PROTECTED_PATHSPEC_EXCLUDES` in
4244
+ // `apps/pugi-cli/src/core/consensus/diff-capture.ts`. Both surfaces
4245
+ // (legacy triple-review + consensus fan-out) enforce the same egress
4246
+ // contract; divergence creates an adversarial-PR leak window.
3291
4247
  ':(exclude,glob)**/.env',
3292
4248
  ':(exclude,glob)**/.env.*',
3293
4249
  ':(exclude,glob)**/.npmrc',
3294
4250
  ':(exclude,glob)**/.yarnrc',
3295
4251
  ':(exclude,glob)**/.pypirc',
3296
4252
  ':(exclude,glob)**/.gitconfig',
4253
+ ':(exclude,glob)**/.netrc',
3297
4254
  ':(exclude,glob)**/id_rsa',
3298
4255
  ':(exclude,glob)**/id_ed25519',
4256
+ ':(exclude,glob)**/id_ecdsa',
4257
+ ':(exclude,glob)**/id_dsa',
3299
4258
  ':(exclude,glob)**/*.pem',
3300
4259
  ':(exclude,glob)**/*.key',
3301
4260
  ':(exclude,glob)**/*.crt',
4261
+ ':(exclude,glob)**/*.cer',
4262
+ ':(exclude,glob)**/*.der',
4263
+ ':(exclude,glob)**/*.pfx',
3302
4264
  ':(exclude,glob)**/*.p12',
3303
4265
  ':(exclude,glob)**/*.dump',
3304
4266
  ':(exclude,glob)**/*.sql',
4267
+ ':(exclude,glob)**/*.secret',
4268
+ ':(exclude,glob)**/credentials',
4269
+ ':(exclude,glob)**/credentials.json',
4270
+ // Use `secrets/**` (not `secrets/*`) so nested credential paths
4271
+ // recurse - with glob pathspec magic a single `*` does not cross path
4272
+ // separators, so the non-recursive form would let `secrets/prod/x.key`
4273
+ // ship in the diff payload.
4274
+ ':(exclude,glob)**/secrets/**',
3305
4275
  ];
3306
4276
  function collectUntrackedSummary(root) {
3307
4277
  const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
@@ -3318,12 +4288,28 @@ function collectUntrackedSummary(root) {
3318
4288
  return { paths: visible.slice(0, 50), excludedProtected: excluded };
3319
4289
  }
3320
4290
  function isProtectedPath(path) {
4291
+ // Keep in sync with PROTECTED_DIFF_EXCLUDES above. This filter
4292
+ // applies to the untracked-files summary surfaced to operators; the
4293
+ // pathspec excludes apply at the egress / diff capture layer.
3321
4294
  const base = path.split('/').pop() ?? path;
3322
4295
  if (base === '.env' || base.startsWith('.env.'))
3323
4296
  return true;
3324
- if (['.npmrc', '.yarnrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(base))
4297
+ const exactNames = [
4298
+ '.npmrc',
4299
+ '.yarnrc',
4300
+ '.pypirc',
4301
+ '.gitconfig',
4302
+ '.netrc',
4303
+ 'id_rsa',
4304
+ 'id_ed25519',
4305
+ 'id_ecdsa',
4306
+ 'id_dsa',
4307
+ 'credentials',
4308
+ 'credentials.json',
4309
+ ];
4310
+ if (exactNames.includes(base))
3325
4311
  return true;
3326
- return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
4312
+ return /\.(pem|key|crt|cer|der|pfx|p12|dump|sql|secret)$/i.test(base);
3327
4313
  }
3328
4314
  function safeReadJson(path) {
3329
4315
  try {