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

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 (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -16,14 +16,26 @@ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
16
16
  import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
17
  import { webFetchTool } from '../tools/web-fetch.js';
18
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
19
+ import { signatureForPlanReview } from '../core/repl/ask.js';
19
20
  import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
20
21
  import { PUGI_TAGLINE } from '@pugi/personas';
21
22
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
23
+ import { runDeployCommand } from '../commands/deploy.js';
22
24
  import { runJobsCommand } from '../commands/jobs.js';
23
25
  import { runConfigCommand } from './commands/config.js';
24
26
  import { runPrivacyCommand } from './commands/privacy.js';
25
27
  import { runUndoCommand } from './commands/undo.js';
26
28
  import { runBudgetCommand } from './commands/budget.js';
29
+ import { runSkillsCommand } from './commands/skills.js';
30
+ import { runAgentsCommand } from './commands/agents.js';
31
+ import { runLspCommand } from './commands/lsp.js';
32
+ import { runPatchCommand } from './commands/patch.js';
33
+ import { runWorktreeCommand } from './commands/worktree.js';
34
+ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
35
+ import { runReviewConsensus } from './commands/review-consensus.js';
36
+ import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
37
+ import { slugForCwd } from '../core/repl/history.js';
38
+ import { dispatchEdit, } from '../core/edits/index.js';
27
39
  /**
28
40
  * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
29
41
  *
@@ -35,13 +47,16 @@ import { runBudgetCommand } from './commands/budget.js';
35
47
  * packages/pugi-sdk/package.json); the publish workflow validates the
36
48
  * three are in lockstep.
37
49
  */
38
- const PUGI_CLI_VERSION = '0.1.0-alpha.9';
50
+ const PUGI_CLI_VERSION = "0.1.0-beta.2";
39
51
  const handlers = {
40
52
  accounts,
53
+ agents: dispatchAgents,
54
+ ask: dispatchAsk,
41
55
  build: runEngineTask('build_task'),
42
56
  budget: dispatchBudget,
43
57
  code: runEngineTask('code'),
44
58
  config: dispatchConfig,
59
+ deploy: dispatchDeploy,
45
60
  doctor,
46
61
  explain: runEngineTask('explain'),
47
62
  fix: runEngineTask('fix'),
@@ -52,17 +67,178 @@ const handlers = {
52
67
  jobs,
53
68
  login,
54
69
  logout,
70
+ lsp: dispatchLsp,
71
+ patch: dispatchPatch,
55
72
  plan: runEngineTask('plan'),
73
+ 'plan-review': dispatchPlanReview,
56
74
  privacy: dispatchPrivacy,
57
75
  review,
58
76
  resume,
59
77
  sessions,
78
+ skills: dispatchSkills,
60
79
  sync,
61
80
  undo: dispatchUndo,
62
81
  version,
63
82
  web: dispatchWeb,
64
83
  whoami,
84
+ worktree: dispatchWorktree,
65
85
  };
86
+ /**
87
+ * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
88
+ * modal manually. In an interactive TTY we mount a tiny Ink app, render
89
+ * the `<AskModal />`, and await the operator's verdict. In non-TTY
90
+ * (CI / pipes), we emit the structured ask JSON to stdout so scripted
91
+ * callers can pipe the response back without rendering a modal.
92
+ *
93
+ * The verdict is printed to stdout as either:
94
+ * - the chosen option `value` (one of the yes/no defaults)
95
+ * - `other:<text>` when the operator typed a custom answer
96
+ * - `cancelled` when the operator pressed Esc
97
+ *
98
+ * This is a CLI-side helper. The REPL slash `/ask` is wired separately
99
+ * through `slash-commands.ts`.
100
+ */
101
+ async function dispatchAsk(args, flags, _session) {
102
+ const question = args.join(' ').trim();
103
+ if (!question) {
104
+ writeOutput(flags, { ok: false, error: 'Usage: pugi ask "<question>"' }, 'Usage: pugi ask "<question>"');
105
+ process.exitCode = 2;
106
+ return;
107
+ }
108
+ const { synthesiseLocalAskTag } = await import('../core/repl/session.js');
109
+ const tag = synthesiseLocalAskTag(question);
110
+ if (!tag) {
111
+ writeOutput(flags, { ok: false, error: 'Question must be 1-80 chars.' }, 'pugi ask: question must be 1-80 chars.');
112
+ process.exitCode = 2;
113
+ return;
114
+ }
115
+ if (!isInteractive(flags)) {
116
+ // Non-TTY: emit the structured ask payload so scripted callers can
117
+ // forward it. The interactive modal is only meaningful with a real
118
+ // terminal, so the line-buffered fallback prints the question +
119
+ // options and exits 0 — callers parse the JSON.
120
+ const payload = {
121
+ ok: true,
122
+ ask: {
123
+ question: tag.question,
124
+ options: tag.options,
125
+ signature: tag.signature,
126
+ },
127
+ };
128
+ writeOutput(flags, payload, [
129
+ `Question: ${tag.question}`,
130
+ ...tag.options.map((o, i) => ` ${i + 1}. ${o.label}${o.desc ? ` - ${o.desc}` : ''}`),
131
+ ` ${tag.options.length + 1}. Other (custom)`,
132
+ '',
133
+ '(non-TTY: re-run in a real terminal to answer interactively, or pipe an answer to stdin)',
134
+ ].join('\n'));
135
+ return;
136
+ }
137
+ // Interactive: render the Ink modal and await resolution.
138
+ const { renderAskCli } = await import('../tui/ask-cli.js');
139
+ const verdict = await renderAskCli({ tag });
140
+ const encoded = verdict.cancelled
141
+ ? 'cancelled'
142
+ : verdict.value.length > 0
143
+ ? verdict.value
144
+ : `other:${verdict.customInput ?? ''}`;
145
+ writeOutput(flags, { ok: true, verdict: encoded }, encoded);
146
+ }
147
+ /**
148
+ * α6.3 `pugi plan-review <task>` — generate + present a plan WITHOUT
149
+ * executing. The legacy `pugi plan` surface (offline plan generator,
150
+ * see runEngineTask) stays intact; `plan-review` adds the office-hours
151
+ * Ink modal layer on top so the operator can approve/modify/cancel
152
+ * before the orchestrator dispatches Marcus.
153
+ *
154
+ * Phase 1 implementation: build a deterministic plan stub from the
155
+ * task description (the persona-driven planner ships in a follow-up
156
+ * sprint). The plan is presented through the standard
157
+ * `<pugi-plan-review>` modal in interactive mode; non-TTY emits the
158
+ * structured payload to stdout for scripted consumers.
159
+ *
160
+ * The exit code reflects the operator's verdict:
161
+ * - 0 PASS approved
162
+ * - 1 MODIFY modify (text printed)
163
+ * - 2 CANCEL cancel
164
+ */
165
+ async function dispatchPlanReview(args, flags, _session) {
166
+ const task = args.join(' ').trim();
167
+ if (!task) {
168
+ writeOutput(flags, { ok: false, error: 'Usage: pugi plan <task description>' }, 'Usage: pugi plan <task description>');
169
+ process.exitCode = 2;
170
+ return;
171
+ }
172
+ const planTag = synthesiseLocalPlanReview(task);
173
+ if (!isInteractive(flags)) {
174
+ const payload = {
175
+ ok: true,
176
+ plan: {
177
+ steps: planTag.steps,
178
+ risk: planTag.risk,
179
+ signature: planTag.signature,
180
+ },
181
+ };
182
+ writeOutput(flags, payload, [
183
+ 'Plan review (non-execution):',
184
+ ...planTag.steps.map((s, i) => ` ${i + 1}. ${s.text}`),
185
+ planTag.risk ? `Risk: ${planTag.risk}` : '',
186
+ '',
187
+ '(non-TTY: re-run in a real terminal to approve/modify/cancel interactively)',
188
+ ]
189
+ .filter(Boolean)
190
+ .join('\n'));
191
+ return;
192
+ }
193
+ const { renderPlanReviewCli } = await import('../tui/ask-cli.js');
194
+ const result = await renderPlanReviewCli({ tag: planTag });
195
+ switch (result.verdict) {
196
+ case 'approve':
197
+ writeOutput(flags, { ok: true, verdict: 'approve' }, 'approve');
198
+ return;
199
+ case 'modify':
200
+ writeOutput(flags, { ok: true, verdict: 'modify', modifyText: result.modifyText ?? '' }, `modify: ${result.modifyText ?? ''}`);
201
+ process.exitCode = 1;
202
+ return;
203
+ case 'cancel':
204
+ writeOutput(flags, { ok: true, verdict: 'cancel' }, 'cancel');
205
+ process.exitCode = 2;
206
+ return;
207
+ }
208
+ }
209
+ /**
210
+ * Local plan stub generator. Until the persona-side planner lands, we
211
+ * produce a deterministic 3-step skeleton anchored to the task text so
212
+ * the operator can dry-run the modal interaction. Real plan synthesis
213
+ * arrives in a follow-up sprint.
214
+ */
215
+ function synthesiseLocalPlanReview(task) {
216
+ const truncated = task.length > 80 ? task.slice(0, 77) + '...' : task;
217
+ const steps = [
218
+ { text: `1. Understand the task: ${truncated}` },
219
+ { text: '2. Identify scope, files touched, side effects.' },
220
+ { text: '3. Execute with verification gates per Pugi defaults.' },
221
+ ];
222
+ const risk = task.length > 200
223
+ ? 'Long task description - consider splitting into smaller briefs.'
224
+ : undefined;
225
+ // Route through the single-source signature helper from ask.ts so a
226
+ // parser-extracted plan-review with identical content collides
227
+ // deterministically with this synthesised one. Inlining the formula
228
+ // here (as the original implementation did) silently drifted from
229
+ // signatureForPlanReview when the helper added `.trim()` to each
230
+ // step. Codex triple-review P2 (PR #375).
231
+ const signature = signatureForPlanReview(steps, risk ?? null);
232
+ const tag = {
233
+ steps,
234
+ signature,
235
+ start: 0,
236
+ end: 0,
237
+ };
238
+ if (risk)
239
+ tag.risk = risk;
240
+ return tag;
241
+ }
66
242
  async function dispatchConfig(args, flags, _session) {
67
243
  await runConfigCommand(args, {
68
244
  workspaceRoot: process.cwd(),
@@ -89,6 +265,20 @@ async function dispatchBudget(args, flags, _session) {
89
265
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
90
266
  });
91
267
  }
268
+ async function dispatchSkills(args, flags, _session) {
269
+ await runSkillsCommand(args, {
270
+ workspaceRoot: process.cwd(),
271
+ json: flags.json,
272
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
273
+ });
274
+ }
275
+ async function dispatchAgents(args, flags, _session) {
276
+ await runAgentsCommand(args, {
277
+ workspaceRoot: process.cwd(),
278
+ json: flags.json,
279
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
280
+ });
281
+ }
92
282
  /**
93
283
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
94
284
  *
@@ -130,6 +320,46 @@ async function dispatchWeb(args, flags, _session) {
130
320
  }
131
321
  writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
132
322
  }
323
+ /**
324
+ * α7.7: `pugi lsp <op> <file> [args]` — direct LSP queries. Delegated
325
+ * to the standalone runner in `./commands/lsp.ts` so the giant cli.ts
326
+ * dispatch table stays narrow. The runner spawns + tears down the LSP
327
+ * server per invocation (no daemon yet — that ships in α7.7b).
328
+ */
329
+ async function dispatchLsp(args, flags, _session) {
330
+ const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
331
+ if (flags.json)
332
+ console.log(result.text);
333
+ else
334
+ console.log(result.text);
335
+ if (result.exitCode !== 0)
336
+ process.exitCode = result.exitCode;
337
+ }
338
+ /**
339
+ * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
340
+ * Routes through the same security gate as the Layer A/B/C applicators
341
+ * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
342
+ * security taxonomy so CI loops can alert on hostile patches without
343
+ * confusing them with operator typos.
344
+ */
345
+ async function dispatchPatch(args, flags, _session) {
346
+ const result = await runPatchCommand(args, { cwd: process.cwd(), json: flags.json });
347
+ console.log(result.text);
348
+ if (result.exitCode !== 0)
349
+ process.exitCode = result.exitCode;
350
+ }
351
+ /**
352
+ * α7.7: `pugi worktree <op>` — manual scratch worktree management.
353
+ * The `pugi build` and `pugi review --consensus` paths use the same
354
+ * primitives internally (`createWorktree` / `promoteWorktree`); this
355
+ * surface is the operator escape hatch for debug + experiment flows.
356
+ */
357
+ async function dispatchWorktree(args, flags, _session) {
358
+ const result = await runWorktreeCommand(args, { cwd: process.cwd(), json: flags.json });
359
+ console.log(result.text);
360
+ if (result.exitCode !== 0)
361
+ process.exitCode = result.exitCode;
362
+ }
133
363
  export async function runCli(argv) {
134
364
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
135
365
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
@@ -170,6 +400,7 @@ export async function runCli(argv) {
170
400
  cliVersion: PUGI_CLI_VERSION,
171
401
  updateBanner,
172
402
  skipSplash: flags.noSplash,
403
+ hideToolStream: flags.noToolStream,
173
404
  });
174
405
  return;
175
406
  }
@@ -201,11 +432,24 @@ function parseArgs(argv) {
201
432
  web: false,
202
433
  dryRun: false,
203
434
  triple: false,
435
+ consensus: false,
204
436
  offline: false,
205
437
  noTty: false,
206
438
  allowFetch: false,
207
439
  noUpdateCheck: false,
208
440
  noSplash: process.env.PUGI_SKIP_SPLASH === '1',
441
+ // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
442
+ // until backend ships `tool.start`/`tool.result` SSE events. Current
443
+ // client-side synthesiser parses persona prose for `Read(...)` /
444
+ // `Bash(...)` patterns — never fires in production (admin-api emits
445
+ // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
446
+ // accidental `Verb(noun)` shapes producing stuck `running` rows.
447
+ // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
448
+ // for development/testing. Will flip к default ON when backend
449
+ // emits real tool events (filed as α6.13.X follow-up).
450
+ noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
451
+ ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
452
+ : true,
209
453
  };
210
454
  const args = [];
211
455
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -232,9 +476,16 @@ function parseArgs(argv) {
232
476
  else if (arg === '--dry-run') {
233
477
  flags.dryRun = true;
234
478
  }
235
- else if (arg === '--triple' || arg === '--consensus') {
479
+ else if (arg === '--triple') {
236
480
  flags.triple = true;
237
481
  }
482
+ else if (arg === '--consensus') {
483
+ // α6.7: customer-facing 3-model consensus review. Routes through
484
+ // the SSE-based runtime gate rather than the legacy artifact
485
+ // writer. The triple flag stays unset так the existing
486
+ // performRemoteTripleReview path is never accidentally entered.
487
+ flags.consensus = true;
488
+ }
238
489
  else if (arg === '--offline') {
239
490
  flags.offline = true;
240
491
  }
@@ -250,6 +501,14 @@ function parseArgs(argv) {
250
501
  else if (arg === '--no-splash') {
251
502
  flags.noSplash = true;
252
503
  }
504
+ else if (arg === '--no-tool-stream') {
505
+ flags.noToolStream = true;
506
+ }
507
+ else if (arg === '--tool-stream') {
508
+ // Opt-in для α6.12 dev/testing — backend tool events not live yet,
509
+ // pane shows синтесайз heuristic OR empty placeholder
510
+ flags.noToolStream = false;
511
+ }
253
512
  else if (arg.startsWith('--privacy=')) {
254
513
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
255
514
  }
@@ -299,6 +558,30 @@ async function help(_args, flags, _session) {
299
558
  '',
300
559
  'Review gate:',
301
560
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
561
+ ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
562
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
563
+ ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
564
+ '',
565
+ 'Skills + agents marketplace:',
566
+ ' pugi skills list All installed skills.',
567
+ ' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
568
+ ' pugi skills info <name> Metadata + body preview.',
569
+ ' pugi agents list All installed sub-agents.',
570
+ ' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
571
+ '',
572
+ 'Office-hours forcing questions (α6.3):',
573
+ ' pugi ask "<question>" Surface a yes/no question modal locally.',
574
+ ' pugi plan-review <task> Generate + present a plan-review modal.',
575
+ '',
576
+ 'Deploy:',
577
+ ' pugi deploy --target vercel <vercelProject> --project <id>',
578
+ ' Trigger a Vercel deployment from the bound Git source.',
579
+ ' Optional: --target-env production|preview, --ref <ref>,',
580
+ ' --integration <id>.',
581
+ ' pugi deploy --target render <renderService> --project <id>',
582
+ ' Trigger a Render deployment (Sprint 2 — stub today).',
583
+ ' pugi deploy --status <id> Vendor-agnostic status snapshot.',
584
+ ' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
302
585
  '',
303
586
  'Sync safety:',
304
587
  ' pugi sync --dry-run --privacy metadata',
@@ -310,6 +593,8 @@ async function help(_args, flags, _session) {
310
593
  ' with PUGI_SKIP_UPDATE_BANNER=1.',
311
594
  ' --no-splash Skip the REPL boot splash. Pairs with',
312
595
  ' PUGI_SKIP_SPLASH=1.',
596
+ ' --no-tool-stream Hide the live tool stream pane (α6.12).',
597
+ ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
313
598
  '',
314
599
  PUGI_TAGLINE,
315
600
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -774,6 +1059,26 @@ async function review(args, flags, session) {
774
1059
  const root = process.cwd();
775
1060
  ensureInitialized(root);
776
1061
  const prompt = args.join(' ').trim();
1062
+ // α6.7: customer-facing consensus review routes here. Distinct from
1063
+ // `--triple --remote` (legacy artifact-writer flow) so the new SSE
1064
+ // streaming UX and rubric-driven exit codes don't disturb the existing
1065
+ // pugi-cli surfaces that depend on the old shape.
1066
+ if (flags.consensus) {
1067
+ const exitCode = await runReviewConsensus(args, {
1068
+ cwd: root,
1069
+ config: resolveRuntimeConfig(),
1070
+ json: flags.json,
1071
+ emit: (line) => {
1072
+ if (!flags.json)
1073
+ process.stdout.write(line);
1074
+ },
1075
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1076
+ });
1077
+ // Caller owns `process.exitCode` so a REPL slash invocation
1078
+ // ('/consensus') cannot inherit a stale exit code from a previous run.
1079
+ process.exitCode = exitCode;
1080
+ return;
1081
+ }
777
1082
  if (flags.triple && flags.remote) {
778
1083
  await performRemoteTripleReview(root, session, flags, prompt);
779
1084
  return;
@@ -1337,6 +1642,14 @@ async function handoff(args, flags, session) {
1337
1642
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1338
1643
  }
1339
1644
  async function sessions(args, flags, _session) {
1645
+ // α6.4: `pugi sessions --local` / `--search "query"` route to the
1646
+ // local SessionStore. The default surface stays artifact-based for
1647
+ // backward compat — operators who relied on the index.json view get
1648
+ // the same shape.
1649
+ if (args.includes('--local') || args.includes('--search')) {
1650
+ await sessionsLocal(args, flags);
1651
+ return;
1652
+ }
1340
1653
  const root = process.cwd();
1341
1654
  ensureInitialized(root);
1342
1655
  const rebuild = args.includes('--rebuild');
@@ -1411,6 +1724,72 @@ async function sessions(args, flags, _session) {
1411
1724
  function hasStubSession(index) {
1412
1725
  return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
1413
1726
  }
1727
+ /**
1728
+ * α6.4: `pugi sessions --local` / `--search "query"` against the
1729
+ * SessionStore. The default `--local` mode lists the 10 most recent
1730
+ * sessions for the current project; `--search "query"` runs FTS5
1731
+ * against the title+body index.
1732
+ */
1733
+ async function sessionsLocal(args, flags) {
1734
+ const cwd = process.cwd();
1735
+ const projectSlug = slugForCwd(cwd);
1736
+ const projectDir = resolveProjectStoreDir(projectSlug);
1737
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
1738
+ writeOutput(flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
1739
+ return;
1740
+ }
1741
+ // Parse `--search "query"` or `--search query`.
1742
+ const searchIdx = args.indexOf('--search');
1743
+ const query = searchIdx >= 0 ? (args[searchIdx + 1] ?? '').trim() : '';
1744
+ if (query.length > 0) {
1745
+ let rows;
1746
+ try {
1747
+ rows = await searchLocalSessions(projectSlug, query);
1748
+ }
1749
+ catch (error) {
1750
+ // Surface FTS5 syntax errors as a clean one-line message + exit 2
1751
+ // so a stray `"` in the operator's input does not dump a stack
1752
+ // trace. Both the live-store path (FtsSyntaxError) and the
1753
+ // read-only fallback (SQLite error with code starting `SQLITE_`)
1754
+ // funnel here.
1755
+ const code = error?.code;
1756
+ if (error instanceof FtsSyntaxError
1757
+ || (typeof code === 'string' && (code === 'EFTS5_SYNTAX' || code.startsWith('SQLITE_')))) {
1758
+ writeOutput(flags, { status: 'error', error: 'invalid search query', query }, `Invalid search query: '${query}'. Try simpler text (no unbalanced quotes).`);
1759
+ process.exitCode = 2;
1760
+ return;
1761
+ }
1762
+ throw error;
1763
+ }
1764
+ writeOutput(flags, { projectSlug, query, sessions: rows }, rows.length === 0
1765
+ ? `No local sessions matched '${query}' for project '${projectSlug}'.`
1766
+ : `Search hits for '${query}' (${rows.length}):\n\n${rows
1767
+ .map((row) => ` ${row.id.slice(0, 13)} ${(row.title ?? '(untitled)').slice(0, 64)}`)
1768
+ .join('\n')}`);
1769
+ return;
1770
+ }
1771
+ const rows = await listLocalSessions(projectSlug);
1772
+ writeOutput(flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
1773
+ }
1774
+ /**
1775
+ * Run an FTS5 search against the local SessionStore. Opens the SQLite
1776
+ * file READ-ONLY via `SqliteSessionStore.openReadOnly` so the search
1777
+ * never takes the lockfile and never inserts a stub session row. Works
1778
+ * whether or not a live REPL holds the writer lock — SQLite supports
1779
+ * concurrent readers + a single writer.
1780
+ *
1781
+ * FTS syntax errors surface as `FtsSyntaxError` (code `EFTS5_SYNTAX`);
1782
+ * the dispatcher catches that + exits 2 with a clean message.
1783
+ */
1784
+ async function searchLocalSessions(projectSlug, query) {
1785
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1786
+ try {
1787
+ return await view.search(query, { limit: 20 });
1788
+ }
1789
+ finally {
1790
+ await view.close();
1791
+ }
1792
+ }
1414
1793
  function registerArtifact(root, artifact) {
1415
1794
  // Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
1416
1795
  // that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
@@ -1443,6 +1822,19 @@ function registerArtifact(root, artifact) {
1443
1822
  }
1444
1823
  async function resume(args, flags, session) {
1445
1824
  const root = process.cwd();
1825
+ // α6.4: `pugi resume [<local-session-id>]` and `pugi resume --list`
1826
+ // operate on the LOCAL SessionStore under `~/.pugi/projects/<slug>/`
1827
+ // before falling back to the legacy artifact-based resume. The
1828
+ // local-session path requires no `.pugi/` directory in the cwd
1829
+ // (the store lives under $HOME) so we run it BEFORE ensureInitialized.
1830
+ const wantsList = args.includes('--list');
1831
+ const arg0 = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
1832
+ 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;
1833
+ const looksLikeSessionShortId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}$/i.test(arg0) : false;
1834
+ if (wantsList || looksLikeSessionId || looksLikeSessionShortId) {
1835
+ await resumeLocalSession({ flags, arg0, wantsList });
1836
+ return;
1837
+ }
1446
1838
  ensureInitialized(root);
1447
1839
  const target = args[0];
1448
1840
  const artifacts = listArtifactSets(root);
@@ -1485,6 +1877,152 @@ async function resume(args, flags, session) {
1485
1877
  recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
1486
1878
  writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
1487
1879
  }
1880
+ /**
1881
+ * α6.4: resume a local SessionStore session. Two modes:
1882
+ *
1883
+ * - `pugi resume --list` → print the 10 most recent local sessions
1884
+ * for the current project slug and exit.
1885
+ * - `pugi resume <id>` → resolve the id (full or short prefix),
1886
+ * check it exists, then mount the REPL
1887
+ * with the localSessionId pre-bound so
1888
+ * the bootstrap restores the transcript.
1889
+ *
1890
+ * The list path is non-interactive — operators pick by id and re-run
1891
+ * with the chosen one. A future sprint can replace the print with an
1892
+ * Ink select prompt; today's CLI surface is scripting-friendly.
1893
+ */
1894
+ async function resumeLocalSession(input) {
1895
+ const cwd = process.cwd();
1896
+ const projectSlug = slugForCwd(cwd);
1897
+ // Resolve the project directory WITHOUT opening the store — when we
1898
+ // are only listing, taking the lock would block a live REPL.
1899
+ const projectDir = resolveProjectStoreDir(projectSlug);
1900
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
1901
+ writeOutput(input.flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
1902
+ return;
1903
+ }
1904
+ if (input.wantsList && !input.arg0) {
1905
+ // Read-only list. Open + close without writing to keep it cheap.
1906
+ const rows = await listLocalSessions(projectSlug);
1907
+ writeOutput(input.flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
1908
+ return;
1909
+ }
1910
+ if (!input.arg0) {
1911
+ 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).');
1912
+ process.exitCode = 2;
1913
+ return;
1914
+ }
1915
+ // Resolve the id. Accepts full uuid OR the 13-char prefix `pugi
1916
+ // resume` prints (`xxxxxxxx-xxxx`). Match on prefix because the
1917
+ // operator types from the human-friendly listing.
1918
+ const candidate = input.arg0;
1919
+ const target = await resolveLocalSessionId(projectSlug, candidate);
1920
+ if (!target) {
1921
+ writeOutput(input.flags, { status: 'not-found', id: candidate }, `No local session matches '${candidate}'. Run \`pugi resume --list\`.`);
1922
+ process.exitCode = 1;
1923
+ return;
1924
+ }
1925
+ // Hand off to the REPL bootstrap with the resolved id pre-bound so
1926
+ // the SessionStore opens the existing log + the bootstrap calls
1927
+ // restoreTranscript before the first user input.
1928
+ const runtimeConfig = resolveRuntimeConfig();
1929
+ if (!runtimeConfig) {
1930
+ writeOutput(input.flags, { status: 'auth-missing', id: target.id }, 'No credentials configured. Run `pugi login` first, then `pugi resume <id>`.');
1931
+ process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
1932
+ return;
1933
+ }
1934
+ const { renderRepl } = await import('../tui/repl-render.js');
1935
+ await renderRepl({
1936
+ apiUrl: runtimeConfig.apiUrl,
1937
+ apiKey: runtimeConfig.apiKey,
1938
+ workspaceLabel: workspaceLabel(cwd),
1939
+ cliVersion: PUGI_CLI_VERSION,
1940
+ skipSplash: input.flags.noSplash,
1941
+ hideToolStream: input.flags.noToolStream,
1942
+ resumeLocalSessionId: target.id,
1943
+ });
1944
+ }
1945
+ /**
1946
+ * List the most recent local sessions for a project. Uses the
1947
+ * READ-ONLY view (`SqliteSessionStore.openReadOnly`) so the call never
1948
+ * takes the lockfile and never inserts a stub session row. Safe to
1949
+ * call while a live REPL writes in the same project — SQLite supports
1950
+ * concurrent readers + a single writer.
1951
+ *
1952
+ * Previously this opened the full SqliteSessionStore (lockfile +
1953
+ * insert path), which polluted history with one empty session row per
1954
+ * `pugi resume --list` or `pugi sessions --local` invocation. Fixed in
1955
+ * the α6.4 review pass.
1956
+ */
1957
+ async function listLocalSessions(projectSlug) {
1958
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1959
+ try {
1960
+ return await view.list({ limit: 10 });
1961
+ }
1962
+ finally {
1963
+ await view.close();
1964
+ }
1965
+ }
1966
+ /** Canonical UUID v7 surface form: 8-4-4-4-12 hex with '7' at the version nibble. */
1967
+ 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;
1968
+ /**
1969
+ * Resolve a session id from a partial input. Accepts:
1970
+ * - full uuid v7 (canonical form xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
1971
+ * - 13-char prefix `xxxxxxxx-xxxx` (the human-friendly form the
1972
+ * `/resume` list prints)
1973
+ * - short 8-char hex prefix `xxxxxxxx`
1974
+ *
1975
+ * For a FULL uuid we go direct-to-`get` so the lookup is not bounded
1976
+ * by the most-recent-N listing (operators paste an id from days ago).
1977
+ * For a prefix we fall back to scanning the first page; that matches
1978
+ * the renderer's listing window.
1979
+ *
1980
+ * Returns the matching SessionRow or null when no row matches.
1981
+ */
1982
+ async function resolveLocalSessionId(projectSlug, candidate) {
1983
+ const normalised = candidate.trim().toLowerCase();
1984
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1985
+ try {
1986
+ if (FULL_UUID_V7_RE.test(normalised)) {
1987
+ // Direct lookup — never bounded by the listing window.
1988
+ const direct = await view.get(normalised);
1989
+ if (direct)
1990
+ return direct;
1991
+ return null;
1992
+ }
1993
+ // Prefix path: scan the most-recent 10 rows so a typed short prefix
1994
+ // resolves against what the renderer just printed.
1995
+ const rows = await view.list({ limit: 10 });
1996
+ const exact = rows.find((r) => r.id.toLowerCase() === normalised);
1997
+ if (exact)
1998
+ return exact;
1999
+ const byPrefix = rows.find((r) => r.id.toLowerCase().startsWith(normalised));
2000
+ return byPrefix ?? null;
2001
+ }
2002
+ finally {
2003
+ await view.close();
2004
+ }
2005
+ }
2006
+ function renderLocalSessionList(rows, projectSlug) {
2007
+ if (rows.length === 0) {
2008
+ return `No stored sessions for project '${projectSlug}' yet.`;
2009
+ }
2010
+ const lines = [
2011
+ `Recent local sessions for '${projectSlug}' (${rows.length}):`,
2012
+ '',
2013
+ ];
2014
+ for (let i = 0; i < rows.length; i += 1) {
2015
+ const row = rows[i];
2016
+ const title = (row.title ?? '(untitled)').slice(0, 64);
2017
+ const idShort = row.id.slice(0, 13);
2018
+ const branch = (row.branch ?? 'no-branch').padEnd(16);
2019
+ const turns = `${row.turnCount}t`.padStart(4);
2020
+ const events = `${row.eventCount}e`.padStart(5);
2021
+ lines.push(` ${idShort} ${branch} ${turns} ${events} ${title}`);
2022
+ }
2023
+ lines.push('', 'Resume with: pugi resume <id>');
2024
+ return lines.join('\n');
2025
+ }
1488
2026
  /**
1489
2027
  * Per-command exit code map. Surfaced to the operator so shell scripts
1490
2028
  * can branch on the engine outcome:
@@ -1659,6 +2197,45 @@ function runEngineTask(kind) {
1659
2197
  risks: ['adapter terminated without emitting a result event'],
1660
2198
  };
1661
2199
  }
2200
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
2201
+ //
2202
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
2203
+ // the final response rather than through tool calls (especially
2204
+ // Gemini and o1 family, which under-use tool schemas in long
2205
+ // reasoning chains). We run the dispatcher against the model's
2206
+ // final text so those markers still land on disk. Tool-call edits
2207
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
2208
+ // unaffected — the dispatcher only fires on prose blocks that
2209
+ // happen to contain markers.
2210
+ //
2211
+ // Scope: code / fix / build / explain only. `plan` is read-only
2212
+ // (the engine refuses write tools), so even a stray marker in plan
2213
+ // output gets ignored to honour the plan-mode contract.
2214
+ //
2215
+ // Dry-run + read-only short-circuits: when the flags forbid writes
2216
+ // we dispatch with `dryRun: true` so the operator still sees what
2217
+ // WOULD have been written, but nothing touches disk.
2218
+ let dispatchResults = [];
2219
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2220
+ dispatchResults = await runMarkerDispatch({
2221
+ root,
2222
+ result: {
2223
+ status: result.status,
2224
+ summary: result.summary,
2225
+ eventRefs: result.eventRefs,
2226
+ },
2227
+ dryRun: flags.dryRun,
2228
+ });
2229
+ // Merge dispatcher-touched files into `result.filesChanged` so the
2230
+ // operator-facing summary lists them alongside tool-driven edits.
2231
+ for (const dr of dispatchResults) {
2232
+ if (dr.ok && dr.absPath) {
2233
+ const rel = relative(root, dr.absPath);
2234
+ if (!result.filesChanged.includes(rel))
2235
+ result.filesChanged.push(rel);
2236
+ }
2237
+ }
2238
+ }
1662
2239
  // For `plan` we always write a plan.md artifact, regardless of
1663
2240
  // outcome. A blocked plan (budget exhausted, tool refusal) still
1664
2241
  // produces a reviewable artifact — the reason is recorded inline.
@@ -1721,6 +2298,16 @@ function runEngineTask(kind) {
1721
2298
  sessionEventsMirror: metrics.mirror,
1722
2299
  risks: result.risks,
1723
2300
  plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2301
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
2302
+ // markers were detected in the model's final response.
2303
+ diffEdits: dispatchResults.map((dr) => ({
2304
+ layer: dr.layer,
2305
+ file: dr.file,
2306
+ ok: dr.ok,
2307
+ bytesWritten: dr.bytesWritten,
2308
+ reason: dr.reason,
2309
+ detail: dr.detail,
2310
+ })),
1724
2311
  // The full event stream is useful for cabinet UI replay. We surface
1725
2312
  // it in JSON mode only — text mode operators want the summary, not
1726
2313
  // 30 turn-level lines.
@@ -1741,6 +2328,19 @@ function runEngineTask(kind) {
1741
2328
  textLines.push('Files modified: none');
1742
2329
  }
1743
2330
  textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2331
+ if (dispatchResults.length > 0) {
2332
+ const okCount = dispatchResults.filter((d) => d.ok).length;
2333
+ const failCount = dispatchResults.length - okCount;
2334
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2335
+ for (const dr of dispatchResults) {
2336
+ if (dr.ok) {
2337
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2338
+ }
2339
+ else {
2340
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2341
+ }
2342
+ }
2343
+ }
1744
2344
  if (result.risks.length > 0) {
1745
2345
  textLines.push(`Risks: ${result.risks.join('; ')}`);
1746
2346
  }
@@ -1750,6 +2350,96 @@ function runEngineTask(kind) {
1750
2350
  writeOutput(flags, payload, textLines.join('\n'));
1751
2351
  };
1752
2352
  }
2353
+ // Exported for the α6.6.1 triple-review remediation spec
2354
+ // (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
2355
+ // surface is not part of the public CLI API; this is a test seam.
2356
+ export async function runMarkerDispatch(input) {
2357
+ const { root, result, dryRun } = input;
2358
+ const dispatch = input.dispatchFn ?? dispatchEdit;
2359
+ // Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
2360
+ // engine's terminal status. A `blocked` (budget_exhausted, plan-mode
2361
+ // refusal) or `failed` result may still carry markers in the
2362
+ // partial `summary` text — applying them would mutate files the
2363
+ // CLI then exits non-zero on, leaving the workspace in an
2364
+ // unexpected state with no operator signal that "blocked but with
2365
+ // side effects" happened. Only `done` is allowed to write.
2366
+ if (result.status !== 'done')
2367
+ return [];
2368
+ // Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
2369
+ // from the body before scanning. The prefixes start with `[`; the
2370
+ // dispatcher tolerates leading prose but the cleaner the input the
2371
+ // less chance of accidental marker matches.
2372
+ const body = result.summary;
2373
+ if (!hasAnyMarkerSignal(body))
2374
+ return [];
2375
+ const modelTag = extractModelTag(result.eventRefs);
2376
+ try {
2377
+ return await dispatch(body, {
2378
+ modelTag,
2379
+ cwd: root,
2380
+ dryRun,
2381
+ });
2382
+ }
2383
+ catch (error) {
2384
+ // Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
2385
+ // swallowed parser/applicator crashes silently — the operator
2386
+ // saw a clean "0 applied" rather than the actual stack trace,
2387
+ // and the bug only surfaced when someone manually `pugi resume`-d
2388
+ // a session and noticed the missing edits. Surface the failure
2389
+ // both to stderr (so live operators see it) and as a synthetic
2390
+ // DispatchResult (so JSON consumers and the audit log record it).
2391
+ //
2392
+ // R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
2393
+ // returned `detail: message` — i.e. the raw `Error.message`. That
2394
+ // string is constructed by whatever code path threw (parser,
2395
+ // applicator, fs layer, etc.) and may contain absolute paths,
2396
+ // secret fragments echoed in `oldString` context, or other
2397
+ // stack-bearing internals. The audit log and any JSON consumer
2398
+ // that surfaces `detail` to the operator (or worse, to a remote
2399
+ // monitoring pipe) would leak them. Stack already goes to stderr
2400
+ // for live diagnosis; the returned result must carry a safe,
2401
+ // static string so consumers can still detect "dispatcher
2402
+ // crashed" without re-rendering the underlying exception.
2403
+ const message = error instanceof Error ? error.message : String(error);
2404
+ const stack = error instanceof Error && error.stack ? error.stack : message;
2405
+ process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
2406
+ return [
2407
+ {
2408
+ layer: 'layer-a',
2409
+ file: '',
2410
+ ok: false,
2411
+ bytesWritten: 0,
2412
+ reason: 'dispatcher_crash',
2413
+ detail: 'dispatcher crashed - see stderr for stack trace',
2414
+ },
2415
+ ];
2416
+ }
2417
+ }
2418
+ /**
2419
+ * Quick pre-filter: does the body contain ANY of the marker
2420
+ * signatures the dispatcher knows about? Saves a full parse on every
2421
+ * model response (most responses are pure prose and would otherwise
2422
+ * round-trip through the parser pointlessly).
2423
+ */
2424
+ function hasAnyMarkerSignal(body) {
2425
+ return (body.includes('+++ NEW') ||
2426
+ body.includes('<<<<<<< SEARCH') ||
2427
+ body.includes('@@@ REWRITE') ||
2428
+ body.includes('@@@ AST') ||
2429
+ /^--- a\//m.test(body));
2430
+ }
2431
+ /**
2432
+ * Extract `model=<tag>` from eventRefs if the adapter emitted it.
2433
+ * Returns undefined when missing; dispatchEdit then auto-detects from
2434
+ * the payload itself.
2435
+ */
2436
+ function extractModelTag(refs) {
2437
+ for (const ref of refs) {
2438
+ if (ref.startsWith('model='))
2439
+ return ref.slice('model='.length);
2440
+ }
2441
+ return undefined;
2442
+ }
1753
2443
  /**
1754
2444
  * Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
1755
2445
  * already emits the canonical strings (`tool_calls=N`, `turns=N`,
@@ -3023,6 +3713,33 @@ async function jobs(args, flags, session) {
3023
3713
  process.exitCode = exitCode;
3024
3714
  }
3025
3715
  }
3716
+ /**
3717
+ * `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
3718
+ * `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
3719
+ * to the deploy-specific flag set + exposes the credential store via
3720
+ * `resolveRuntimeConfig` so the deploy module stays decoupled from the
3721
+ * CLI's auth bootstrap.
3722
+ */
3723
+ async function dispatchDeploy(args, flags, _session) {
3724
+ // Triple-review #391 P2: the global `parseArgs` in this file consumes
3725
+ // `--json` before the per-command args reach us, so `runDeployCommand`'s
3726
+ // internal parser never sees it and the JSON envelope path is silently
3727
+ // skipped. Re-inject the flag so downstream parsing surfaces the JSON
3728
+ // output contract the operator asked for. Idempotent: if the user wrote
3729
+ // `pugi deploy --json ...` the global parser stripped it; if they wrote
3730
+ // `pugi --json deploy ...` ditto. Either way the global flag is the
3731
+ // single source of truth and we forward it verbatim.
3732
+ const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
3733
+ const exitCode = await runDeployCommand(forwardedArgs, {
3734
+ write: (text) => process.stdout.write(text),
3735
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
3736
+ }, {
3737
+ resolveConfig: () => resolveRuntimeConfig(),
3738
+ });
3739
+ if (exitCode !== 0) {
3740
+ process.exitCode = exitCode;
3741
+ }
3742
+ }
3026
3743
  function notImplemented(command) {
3027
3744
  return async (_args, flags) => {
3028
3745
  const payload = {
@@ -3058,13 +3775,18 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
3058
3775
  * REPL header in sync with `pwd` lets the operator orient at a glance.
3059
3776
  * Empty / pathological cwd values (a worktree resolved to `/`) fall
3060
3777
  * back to `workspace` so the header never collapses.
3778
+ *
3779
+ * α6.14.2 wave 5: when the cwd has no project markers (no .git, no
3780
+ * package.json, no PUGI.md), the resolver returns the explicit "not
3781
+ * bound" warning instead of a stray parent-dir basename. CEO 2026-05-25
3782
+ * dogfood surfaced the bug — launching `pugi` from `codeforge-io/`
3783
+ * (the parent of all checkouts) leaked `codeforge-io` into the splash
3784
+ * as if it were a real workspace. Mira/Pugi can NOT bind on that. The
3785
+ * decision lives in `core/repl/workspace-context.ts` so the splash +
3786
+ * status bar agree on a single label.
3061
3787
  */
3062
3788
  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;
3789
+ return resolveWorkspaceLabel(cwd);
3068
3790
  }
3069
3791
  function ensureDir(path, created, skipped) {
3070
3792
  if (existsSync(path)) {
@@ -3286,22 +4008,41 @@ const PROTECTED_DIFF_EXCLUDES = [
3286
4008
  // Basename excludes apply at the repo root AND in any subdirectory
3287
4009
  // (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
3288
4010
  // `**/` prefix, git's literal pathspec syntax would only match the
3289
- // repo root and silently let a subdir `.env` ship in the diff
4011
+ // repo root and silently let a subdir `.env` ship in the diff -
3290
4012
  // common pitfall in pnpm/turbo monorepos.
4013
+ //
4014
+ // Keep this list in sync with `PROTECTED_PATHSPEC_EXCLUDES` in
4015
+ // `apps/pugi-cli/src/core/consensus/diff-capture.ts`. Both surfaces
4016
+ // (legacy triple-review + consensus fan-out) enforce the same egress
4017
+ // contract; divergence creates an adversarial-PR leak window.
3291
4018
  ':(exclude,glob)**/.env',
3292
4019
  ':(exclude,glob)**/.env.*',
3293
4020
  ':(exclude,glob)**/.npmrc',
3294
4021
  ':(exclude,glob)**/.yarnrc',
3295
4022
  ':(exclude,glob)**/.pypirc',
3296
4023
  ':(exclude,glob)**/.gitconfig',
4024
+ ':(exclude,glob)**/.netrc',
3297
4025
  ':(exclude,glob)**/id_rsa',
3298
4026
  ':(exclude,glob)**/id_ed25519',
4027
+ ':(exclude,glob)**/id_ecdsa',
4028
+ ':(exclude,glob)**/id_dsa',
3299
4029
  ':(exclude,glob)**/*.pem',
3300
4030
  ':(exclude,glob)**/*.key',
3301
4031
  ':(exclude,glob)**/*.crt',
4032
+ ':(exclude,glob)**/*.cer',
4033
+ ':(exclude,glob)**/*.der',
4034
+ ':(exclude,glob)**/*.pfx',
3302
4035
  ':(exclude,glob)**/*.p12',
3303
4036
  ':(exclude,glob)**/*.dump',
3304
4037
  ':(exclude,glob)**/*.sql',
4038
+ ':(exclude,glob)**/*.secret',
4039
+ ':(exclude,glob)**/credentials',
4040
+ ':(exclude,glob)**/credentials.json',
4041
+ // Use `secrets/**` (not `secrets/*`) so nested credential paths
4042
+ // recurse - with glob pathspec magic a single `*` does not cross path
4043
+ // separators, so the non-recursive form would let `secrets/prod/x.key`
4044
+ // ship in the diff payload.
4045
+ ':(exclude,glob)**/secrets/**',
3305
4046
  ];
3306
4047
  function collectUntrackedSummary(root) {
3307
4048
  const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
@@ -3318,12 +4059,28 @@ function collectUntrackedSummary(root) {
3318
4059
  return { paths: visible.slice(0, 50), excludedProtected: excluded };
3319
4060
  }
3320
4061
  function isProtectedPath(path) {
4062
+ // Keep in sync with PROTECTED_DIFF_EXCLUDES above. This filter
4063
+ // applies to the untracked-files summary surfaced to operators; the
4064
+ // pathspec excludes apply at the egress / diff capture layer.
3321
4065
  const base = path.split('/').pop() ?? path;
3322
4066
  if (base === '.env' || base.startsWith('.env.'))
3323
4067
  return true;
3324
- if (['.npmrc', '.yarnrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(base))
4068
+ const exactNames = [
4069
+ '.npmrc',
4070
+ '.yarnrc',
4071
+ '.pypirc',
4072
+ '.gitconfig',
4073
+ '.netrc',
4074
+ 'id_rsa',
4075
+ 'id_ed25519',
4076
+ 'id_ecdsa',
4077
+ 'id_dsa',
4078
+ 'credentials',
4079
+ 'credentials.json',
4080
+ ];
4081
+ if (exactNames.includes(base))
3325
4082
  return true;
3326
- return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
4083
+ return /\.(pem|key|crt|cer|der|pfx|p12|dump|sql|secret)$/i.test(base);
3327
4084
  }
3328
4085
  function safeReadJson(path) {
3329
4086
  try {