@pugi/cli 0.1.0-alpha.8 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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/engine/native-pugi.js +6 -1
  23. package/dist/core/engine/tool-bridge.js +33 -1
  24. package/dist/core/repl/ask.js +512 -0
  25. package/dist/core/repl/cancellation.js +98 -0
  26. package/dist/core/repl/dispatch-fsm.js +220 -0
  27. package/dist/core/repl/privacy-banner.js +71 -0
  28. package/dist/core/repl/session.js +1909 -13
  29. package/dist/core/repl/slash-commands.js +59 -32
  30. package/dist/core/repl/store/index.js +12 -0
  31. package/dist/core/repl/store/jsonl-log.js +321 -0
  32. package/dist/core/repl/store/lockfile.js +155 -0
  33. package/dist/core/repl/store/session-store.js +792 -0
  34. package/dist/core/repl/store/types.js +44 -0
  35. package/dist/core/repl/store/uuid-v7.js +68 -0
  36. package/dist/core/repl/workspace-context.js +184 -0
  37. package/dist/core/skills/loader.js +454 -0
  38. package/dist/core/skills/sources.js +480 -0
  39. package/dist/core/skills/trust.js +172 -0
  40. package/dist/runtime/cli.js +728 -10
  41. package/dist/runtime/commands/agents.js +385 -0
  42. package/dist/runtime/commands/config.js +338 -8
  43. package/dist/runtime/commands/review-consensus.js +399 -0
  44. package/dist/runtime/commands/skills.js +401 -0
  45. package/dist/tools/file-tools.js +90 -0
  46. package/dist/tools/web-fetch.js +1 -1
  47. package/dist/tui/agent-tree-pane.js +9 -0
  48. package/dist/tui/ask-cli.js +52 -0
  49. package/dist/tui/ask-modal.js +211 -0
  50. package/dist/tui/conversation-pane.js +48 -3
  51. package/dist/tui/input-box.js +48 -5
  52. package/dist/tui/markdown-render.js +266 -0
  53. package/dist/tui/repl-render.js +183 -4
  54. package/dist/tui/repl-splash-art.js +64 -0
  55. package/dist/tui/repl-splash-mascot.js +130 -0
  56. package/dist/tui/repl-splash.js +117 -0
  57. package/dist/tui/repl.js +108 -11
  58. package/dist/tui/slash-palette.js +47 -10
  59. package/dist/tui/status-bar.js +77 -4
  60. package/dist/tui/tool-stream-pane.js +91 -0
  61. package/dist/tui/workspace-context.js +105 -0
  62. package/package.json +11 -5
@@ -16,14 +16,23 @@ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
16
16
  import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
17
  import { webFetchTool } from '../tools/web-fetch.js';
18
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
19
+ import { signatureForPlanReview } from '../core/repl/ask.js';
19
20
  import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
20
21
  import { PUGI_TAGLINE } from '@pugi/personas';
21
22
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
23
+ import { runDeployCommand } from '../commands/deploy.js';
22
24
  import { runJobsCommand } from '../commands/jobs.js';
23
25
  import { runConfigCommand } from './commands/config.js';
24
26
  import { runPrivacyCommand } from './commands/privacy.js';
25
27
  import { runUndoCommand } from './commands/undo.js';
26
28
  import { runBudgetCommand } from './commands/budget.js';
29
+ import { runSkillsCommand } from './commands/skills.js';
30
+ import { runAgentsCommand } from './commands/agents.js';
31
+ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
32
+ import { runReviewConsensus } from './commands/review-consensus.js';
33
+ import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
34
+ import { slugForCwd } from '../core/repl/history.js';
35
+ import { dispatchEdit, } from '../core/edits/index.js';
27
36
  /**
28
37
  * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
29
38
  *
@@ -35,13 +44,16 @@ import { runBudgetCommand } from './commands/budget.js';
35
44
  * packages/pugi-sdk/package.json); the publish workflow validates the
36
45
  * three are in lockstep.
37
46
  */
38
- const PUGI_CLI_VERSION = '0.1.0-alpha.8';
47
+ const PUGI_CLI_VERSION = "0.1.0-beta.1";
39
48
  const handlers = {
40
49
  accounts,
50
+ agents: dispatchAgents,
51
+ ask: dispatchAsk,
41
52
  build: runEngineTask('build_task'),
42
53
  budget: dispatchBudget,
43
54
  code: runEngineTask('code'),
44
55
  config: dispatchConfig,
56
+ deploy: dispatchDeploy,
45
57
  doctor,
46
58
  explain: runEngineTask('explain'),
47
59
  fix: runEngineTask('fix'),
@@ -53,16 +65,174 @@ const handlers = {
53
65
  login,
54
66
  logout,
55
67
  plan: runEngineTask('plan'),
68
+ 'plan-review': dispatchPlanReview,
56
69
  privacy: dispatchPrivacy,
57
70
  review,
58
71
  resume,
59
72
  sessions,
73
+ skills: dispatchSkills,
60
74
  sync,
61
75
  undo: dispatchUndo,
62
76
  version,
63
77
  web: dispatchWeb,
64
78
  whoami,
65
79
  };
80
+ /**
81
+ * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
82
+ * modal manually. In an interactive TTY we mount a tiny Ink app, render
83
+ * the `<AskModal />`, and await the operator's verdict. In non-TTY
84
+ * (CI / pipes), we emit the structured ask JSON to stdout so scripted
85
+ * callers can pipe the response back without rendering a modal.
86
+ *
87
+ * The verdict is printed to stdout as either:
88
+ * - the chosen option `value` (one of the yes/no defaults)
89
+ * - `other:<text>` when the operator typed a custom answer
90
+ * - `cancelled` when the operator pressed Esc
91
+ *
92
+ * This is a CLI-side helper. The REPL slash `/ask` is wired separately
93
+ * through `slash-commands.ts`.
94
+ */
95
+ async function dispatchAsk(args, flags, _session) {
96
+ const question = args.join(' ').trim();
97
+ if (!question) {
98
+ writeOutput(flags, { ok: false, error: 'Usage: pugi ask "<question>"' }, 'Usage: pugi ask "<question>"');
99
+ process.exitCode = 2;
100
+ return;
101
+ }
102
+ const { synthesiseLocalAskTag } = await import('../core/repl/session.js');
103
+ const tag = synthesiseLocalAskTag(question);
104
+ if (!tag) {
105
+ writeOutput(flags, { ok: false, error: 'Question must be 1-80 chars.' }, 'pugi ask: question must be 1-80 chars.');
106
+ process.exitCode = 2;
107
+ return;
108
+ }
109
+ if (!isInteractive(flags)) {
110
+ // Non-TTY: emit the structured ask payload so scripted callers can
111
+ // forward it. The interactive modal is only meaningful with a real
112
+ // terminal, so the line-buffered fallback prints the question +
113
+ // options and exits 0 — callers parse the JSON.
114
+ const payload = {
115
+ ok: true,
116
+ ask: {
117
+ question: tag.question,
118
+ options: tag.options,
119
+ signature: tag.signature,
120
+ },
121
+ };
122
+ writeOutput(flags, payload, [
123
+ `Question: ${tag.question}`,
124
+ ...tag.options.map((o, i) => ` ${i + 1}. ${o.label}${o.desc ? ` - ${o.desc}` : ''}`),
125
+ ` ${tag.options.length + 1}. Other (custom)`,
126
+ '',
127
+ '(non-TTY: re-run in a real terminal to answer interactively, or pipe an answer to stdin)',
128
+ ].join('\n'));
129
+ return;
130
+ }
131
+ // Interactive: render the Ink modal and await resolution.
132
+ const { renderAskCli } = await import('../tui/ask-cli.js');
133
+ const verdict = await renderAskCli({ tag });
134
+ const encoded = verdict.cancelled
135
+ ? 'cancelled'
136
+ : verdict.value.length > 0
137
+ ? verdict.value
138
+ : `other:${verdict.customInput ?? ''}`;
139
+ writeOutput(flags, { ok: true, verdict: encoded }, encoded);
140
+ }
141
+ /**
142
+ * α6.3 `pugi plan-review <task>` — generate + present a plan WITHOUT
143
+ * executing. The legacy `pugi plan` surface (offline plan generator,
144
+ * see runEngineTask) stays intact; `plan-review` adds the office-hours
145
+ * Ink modal layer on top so the operator can approve/modify/cancel
146
+ * before the orchestrator dispatches Marcus.
147
+ *
148
+ * Phase 1 implementation: build a deterministic plan stub from the
149
+ * task description (the persona-driven planner ships in a follow-up
150
+ * sprint). The plan is presented through the standard
151
+ * `<pugi-plan-review>` modal in interactive mode; non-TTY emits the
152
+ * structured payload to stdout for scripted consumers.
153
+ *
154
+ * The exit code reflects the operator's verdict:
155
+ * - 0 PASS approved
156
+ * - 1 MODIFY modify (text printed)
157
+ * - 2 CANCEL cancel
158
+ */
159
+ async function dispatchPlanReview(args, flags, _session) {
160
+ const task = args.join(' ').trim();
161
+ if (!task) {
162
+ writeOutput(flags, { ok: false, error: 'Usage: pugi plan <task description>' }, 'Usage: pugi plan <task description>');
163
+ process.exitCode = 2;
164
+ return;
165
+ }
166
+ const planTag = synthesiseLocalPlanReview(task);
167
+ if (!isInteractive(flags)) {
168
+ const payload = {
169
+ ok: true,
170
+ plan: {
171
+ steps: planTag.steps,
172
+ risk: planTag.risk,
173
+ signature: planTag.signature,
174
+ },
175
+ };
176
+ writeOutput(flags, payload, [
177
+ 'Plan review (non-execution):',
178
+ ...planTag.steps.map((s, i) => ` ${i + 1}. ${s.text}`),
179
+ planTag.risk ? `Risk: ${planTag.risk}` : '',
180
+ '',
181
+ '(non-TTY: re-run in a real terminal to approve/modify/cancel interactively)',
182
+ ]
183
+ .filter(Boolean)
184
+ .join('\n'));
185
+ return;
186
+ }
187
+ const { renderPlanReviewCli } = await import('../tui/ask-cli.js');
188
+ const result = await renderPlanReviewCli({ tag: planTag });
189
+ switch (result.verdict) {
190
+ case 'approve':
191
+ writeOutput(flags, { ok: true, verdict: 'approve' }, 'approve');
192
+ return;
193
+ case 'modify':
194
+ writeOutput(flags, { ok: true, verdict: 'modify', modifyText: result.modifyText ?? '' }, `modify: ${result.modifyText ?? ''}`);
195
+ process.exitCode = 1;
196
+ return;
197
+ case 'cancel':
198
+ writeOutput(flags, { ok: true, verdict: 'cancel' }, 'cancel');
199
+ process.exitCode = 2;
200
+ return;
201
+ }
202
+ }
203
+ /**
204
+ * Local plan stub generator. Until the persona-side planner lands, we
205
+ * produce a deterministic 3-step skeleton anchored to the task text so
206
+ * the operator can dry-run the modal interaction. Real plan synthesis
207
+ * arrives in a follow-up sprint.
208
+ */
209
+ function synthesiseLocalPlanReview(task) {
210
+ const truncated = task.length > 80 ? task.slice(0, 77) + '...' : task;
211
+ const steps = [
212
+ { text: `1. Understand the task: ${truncated}` },
213
+ { text: '2. Identify scope, files touched, side effects.' },
214
+ { text: '3. Execute with verification gates per Pugi defaults.' },
215
+ ];
216
+ const risk = task.length > 200
217
+ ? 'Long task description - consider splitting into smaller briefs.'
218
+ : undefined;
219
+ // Route through the single-source signature helper from ask.ts so a
220
+ // parser-extracted plan-review with identical content collides
221
+ // deterministically with this synthesised one. Inlining the formula
222
+ // here (as the original implementation did) silently drifted from
223
+ // signatureForPlanReview when the helper added `.trim()` to each
224
+ // step. Codex triple-review P2 (PR #375).
225
+ const signature = signatureForPlanReview(steps, risk ?? null);
226
+ const tag = {
227
+ steps,
228
+ signature,
229
+ start: 0,
230
+ end: 0,
231
+ };
232
+ if (risk)
233
+ tag.risk = risk;
234
+ return tag;
235
+ }
66
236
  async function dispatchConfig(args, flags, _session) {
67
237
  await runConfigCommand(args, {
68
238
  workspaceRoot: process.cwd(),
@@ -89,6 +259,20 @@ async function dispatchBudget(args, flags, _session) {
89
259
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
90
260
  });
91
261
  }
262
+ async function dispatchSkills(args, flags, _session) {
263
+ await runSkillsCommand(args, {
264
+ workspaceRoot: process.cwd(),
265
+ json: flags.json,
266
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
267
+ });
268
+ }
269
+ async function dispatchAgents(args, flags, _session) {
270
+ await runAgentsCommand(args, {
271
+ workspaceRoot: process.cwd(),
272
+ json: flags.json,
273
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
274
+ });
275
+ }
92
276
  /**
93
277
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
94
278
  *
@@ -169,6 +353,8 @@ export async function runCli(argv) {
169
353
  workspaceLabel: workspaceLabel(process.cwd()),
170
354
  cliVersion: PUGI_CLI_VERSION,
171
355
  updateBanner,
356
+ skipSplash: flags.noSplash,
357
+ hideToolStream: flags.noToolStream,
172
358
  });
173
359
  return;
174
360
  }
@@ -200,10 +386,24 @@ function parseArgs(argv) {
200
386
  web: false,
201
387
  dryRun: false,
202
388
  triple: false,
389
+ consensus: false,
203
390
  offline: false,
204
391
  noTty: false,
205
392
  allowFetch: false,
206
393
  noUpdateCheck: false,
394
+ noSplash: process.env.PUGI_SKIP_SPLASH === '1',
395
+ // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
396
+ // until backend ships `tool.start`/`tool.result` SSE events. Current
397
+ // client-side synthesiser parses persona prose for `Read(...)` /
398
+ // `Bash(...)` patterns — never fires in production (admin-api emits
399
+ // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
400
+ // accidental `Verb(noun)` shapes producing stuck `running` rows.
401
+ // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
402
+ // for development/testing. Will flip к default ON when backend
403
+ // emits real tool events (filed as α6.13.X follow-up).
404
+ noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
405
+ ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
406
+ : true,
207
407
  };
208
408
  const args = [];
209
409
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -230,9 +430,16 @@ function parseArgs(argv) {
230
430
  else if (arg === '--dry-run') {
231
431
  flags.dryRun = true;
232
432
  }
233
- else if (arg === '--triple' || arg === '--consensus') {
433
+ else if (arg === '--triple') {
234
434
  flags.triple = true;
235
435
  }
436
+ else if (arg === '--consensus') {
437
+ // α6.7: customer-facing 3-model consensus review. Routes through
438
+ // the SSE-based runtime gate rather than the legacy artifact
439
+ // writer. The triple flag stays unset так the existing
440
+ // performRemoteTripleReview path is never accidentally entered.
441
+ flags.consensus = true;
442
+ }
236
443
  else if (arg === '--offline') {
237
444
  flags.offline = true;
238
445
  }
@@ -245,6 +452,17 @@ function parseArgs(argv) {
245
452
  else if (arg === '--no-update-check') {
246
453
  flags.noUpdateCheck = true;
247
454
  }
455
+ else if (arg === '--no-splash') {
456
+ flags.noSplash = true;
457
+ }
458
+ else if (arg === '--no-tool-stream') {
459
+ flags.noToolStream = true;
460
+ }
461
+ else if (arg === '--tool-stream') {
462
+ // Opt-in для α6.12 dev/testing — backend tool events not live yet,
463
+ // pane shows синтесайз heuristic OR empty placeholder
464
+ flags.noToolStream = false;
465
+ }
248
466
  else if (arg.startsWith('--privacy=')) {
249
467
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
250
468
  }
@@ -294,6 +512,30 @@ async function help(_args, flags, _session) {
294
512
  '',
295
513
  'Review gate:',
296
514
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
515
+ ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
516
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
517
+ ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
518
+ '',
519
+ 'Skills + agents marketplace:',
520
+ ' pugi skills list All installed skills.',
521
+ ' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
522
+ ' pugi skills info <name> Metadata + body preview.',
523
+ ' pugi agents list All installed sub-agents.',
524
+ ' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
525
+ '',
526
+ 'Office-hours forcing questions (α6.3):',
527
+ ' pugi ask "<question>" Surface a yes/no question modal locally.',
528
+ ' pugi plan-review <task> Generate + present a plan-review modal.',
529
+ '',
530
+ 'Deploy:',
531
+ ' pugi deploy --target vercel <vercelProject> --project <id>',
532
+ ' Trigger a Vercel deployment from the bound Git source.',
533
+ ' Optional: --target-env production|preview, --ref <ref>,',
534
+ ' --integration <id>.',
535
+ ' pugi deploy --target render <renderService> --project <id>',
536
+ ' Trigger a Render deployment (Sprint 2 — stub today).',
537
+ ' pugi deploy --status <id> Vendor-agnostic status snapshot.',
538
+ ' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
297
539
  '',
298
540
  'Sync safety:',
299
541
  ' pugi sync --dry-run --privacy metadata',
@@ -303,6 +545,10 @@ async function help(_args, flags, _session) {
303
545
  ' recording flows, dumb terminals).',
304
546
  ' --no-update-check Silence the REPL startup update banner. Pairs',
305
547
  ' with PUGI_SKIP_UPDATE_BANNER=1.',
548
+ ' --no-splash Skip the REPL boot splash. Pairs with',
549
+ ' PUGI_SKIP_SPLASH=1.',
550
+ ' --no-tool-stream Hide the live tool stream pane (α6.12).',
551
+ ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
306
552
  '',
307
553
  PUGI_TAGLINE,
308
554
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -767,6 +1013,26 @@ async function review(args, flags, session) {
767
1013
  const root = process.cwd();
768
1014
  ensureInitialized(root);
769
1015
  const prompt = args.join(' ').trim();
1016
+ // α6.7: customer-facing consensus review routes here. Distinct from
1017
+ // `--triple --remote` (legacy artifact-writer flow) so the new SSE
1018
+ // streaming UX and rubric-driven exit codes don't disturb the existing
1019
+ // pugi-cli surfaces that depend on the old shape.
1020
+ if (flags.consensus) {
1021
+ const exitCode = await runReviewConsensus(args, {
1022
+ cwd: root,
1023
+ config: resolveRuntimeConfig(),
1024
+ json: flags.json,
1025
+ emit: (line) => {
1026
+ if (!flags.json)
1027
+ process.stdout.write(line);
1028
+ },
1029
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1030
+ });
1031
+ // Caller owns `process.exitCode` so a REPL slash invocation
1032
+ // ('/consensus') cannot inherit a stale exit code from a previous run.
1033
+ process.exitCode = exitCode;
1034
+ return;
1035
+ }
770
1036
  if (flags.triple && flags.remote) {
771
1037
  await performRemoteTripleReview(root, session, flags, prompt);
772
1038
  return;
@@ -1330,6 +1596,14 @@ async function handoff(args, flags, session) {
1330
1596
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1331
1597
  }
1332
1598
  async function sessions(args, flags, _session) {
1599
+ // α6.4: `pugi sessions --local` / `--search "query"` route to the
1600
+ // local SessionStore. The default surface stays artifact-based for
1601
+ // backward compat — operators who relied on the index.json view get
1602
+ // the same shape.
1603
+ if (args.includes('--local') || args.includes('--search')) {
1604
+ await sessionsLocal(args, flags);
1605
+ return;
1606
+ }
1333
1607
  const root = process.cwd();
1334
1608
  ensureInitialized(root);
1335
1609
  const rebuild = args.includes('--rebuild');
@@ -1404,6 +1678,72 @@ async function sessions(args, flags, _session) {
1404
1678
  function hasStubSession(index) {
1405
1679
  return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
1406
1680
  }
1681
+ /**
1682
+ * α6.4: `pugi sessions --local` / `--search "query"` against the
1683
+ * SessionStore. The default `--local` mode lists the 10 most recent
1684
+ * sessions for the current project; `--search "query"` runs FTS5
1685
+ * against the title+body index.
1686
+ */
1687
+ async function sessionsLocal(args, flags) {
1688
+ const cwd = process.cwd();
1689
+ const projectSlug = slugForCwd(cwd);
1690
+ const projectDir = resolveProjectStoreDir(projectSlug);
1691
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
1692
+ writeOutput(flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
1693
+ return;
1694
+ }
1695
+ // Parse `--search "query"` or `--search query`.
1696
+ const searchIdx = args.indexOf('--search');
1697
+ const query = searchIdx >= 0 ? (args[searchIdx + 1] ?? '').trim() : '';
1698
+ if (query.length > 0) {
1699
+ let rows;
1700
+ try {
1701
+ rows = await searchLocalSessions(projectSlug, query);
1702
+ }
1703
+ catch (error) {
1704
+ // Surface FTS5 syntax errors as a clean one-line message + exit 2
1705
+ // so a stray `"` in the operator's input does not dump a stack
1706
+ // trace. Both the live-store path (FtsSyntaxError) and the
1707
+ // read-only fallback (SQLite error with code starting `SQLITE_`)
1708
+ // funnel here.
1709
+ const code = error?.code;
1710
+ if (error instanceof FtsSyntaxError
1711
+ || (typeof code === 'string' && (code === 'EFTS5_SYNTAX' || code.startsWith('SQLITE_')))) {
1712
+ writeOutput(flags, { status: 'error', error: 'invalid search query', query }, `Invalid search query: '${query}'. Try simpler text (no unbalanced quotes).`);
1713
+ process.exitCode = 2;
1714
+ return;
1715
+ }
1716
+ throw error;
1717
+ }
1718
+ writeOutput(flags, { projectSlug, query, sessions: rows }, rows.length === 0
1719
+ ? `No local sessions matched '${query}' for project '${projectSlug}'.`
1720
+ : `Search hits for '${query}' (${rows.length}):\n\n${rows
1721
+ .map((row) => ` ${row.id.slice(0, 13)} ${(row.title ?? '(untitled)').slice(0, 64)}`)
1722
+ .join('\n')}`);
1723
+ return;
1724
+ }
1725
+ const rows = await listLocalSessions(projectSlug);
1726
+ writeOutput(flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
1727
+ }
1728
+ /**
1729
+ * Run an FTS5 search against the local SessionStore. Opens the SQLite
1730
+ * file READ-ONLY via `SqliteSessionStore.openReadOnly` so the search
1731
+ * never takes the lockfile and never inserts a stub session row. Works
1732
+ * whether or not a live REPL holds the writer lock — SQLite supports
1733
+ * concurrent readers + a single writer.
1734
+ *
1735
+ * FTS syntax errors surface as `FtsSyntaxError` (code `EFTS5_SYNTAX`);
1736
+ * the dispatcher catches that + exits 2 with a clean message.
1737
+ */
1738
+ async function searchLocalSessions(projectSlug, query) {
1739
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1740
+ try {
1741
+ return await view.search(query, { limit: 20 });
1742
+ }
1743
+ finally {
1744
+ await view.close();
1745
+ }
1746
+ }
1407
1747
  function registerArtifact(root, artifact) {
1408
1748
  // Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
1409
1749
  // that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
@@ -1436,6 +1776,19 @@ function registerArtifact(root, artifact) {
1436
1776
  }
1437
1777
  async function resume(args, flags, session) {
1438
1778
  const root = process.cwd();
1779
+ // α6.4: `pugi resume [<local-session-id>]` and `pugi resume --list`
1780
+ // operate on the LOCAL SessionStore under `~/.pugi/projects/<slug>/`
1781
+ // before falling back to the legacy artifact-based resume. The
1782
+ // local-session path requires no `.pugi/` directory in the cwd
1783
+ // (the store lives under $HOME) so we run it BEFORE ensureInitialized.
1784
+ const wantsList = args.includes('--list');
1785
+ const arg0 = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
1786
+ const looksLikeSessionId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(arg0) : false;
1787
+ const looksLikeSessionShortId = arg0 ? /^[0-9a-f]{8}-[0-9a-f]{4}$/i.test(arg0) : false;
1788
+ if (wantsList || looksLikeSessionId || looksLikeSessionShortId) {
1789
+ await resumeLocalSession({ flags, arg0, wantsList });
1790
+ return;
1791
+ }
1439
1792
  ensureInitialized(root);
1440
1793
  const target = args[0];
1441
1794
  const artifacts = listArtifactSets(root);
@@ -1478,6 +1831,152 @@ async function resume(args, flags, session) {
1478
1831
  recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
1479
1832
  writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
1480
1833
  }
1834
+ /**
1835
+ * α6.4: resume a local SessionStore session. Two modes:
1836
+ *
1837
+ * - `pugi resume --list` → print the 10 most recent local sessions
1838
+ * for the current project slug and exit.
1839
+ * - `pugi resume <id>` → resolve the id (full or short prefix),
1840
+ * check it exists, then mount the REPL
1841
+ * with the localSessionId pre-bound so
1842
+ * the bootstrap restores the transcript.
1843
+ *
1844
+ * The list path is non-interactive — operators pick by id and re-run
1845
+ * with the chosen one. A future sprint can replace the print with an
1846
+ * Ink select prompt; today's CLI surface is scripting-friendly.
1847
+ */
1848
+ async function resumeLocalSession(input) {
1849
+ const cwd = process.cwd();
1850
+ const projectSlug = slugForCwd(cwd);
1851
+ // Resolve the project directory WITHOUT opening the store — when we
1852
+ // are only listing, taking the lock would block a live REPL.
1853
+ const projectDir = resolveProjectStoreDir(projectSlug);
1854
+ if (!existsSync(resolve(projectDir, 'session.db'))) {
1855
+ writeOutput(input.flags, { status: 'no-sessions', projectSlug, projectDir }, `No stored sessions for project '${projectSlug}' yet.`);
1856
+ return;
1857
+ }
1858
+ if (input.wantsList && !input.arg0) {
1859
+ // Read-only list. Open + close without writing to keep it cheap.
1860
+ const rows = await listLocalSessions(projectSlug);
1861
+ writeOutput(input.flags, { projectSlug, sessions: rows }, renderLocalSessionList(rows, projectSlug));
1862
+ return;
1863
+ }
1864
+ if (!input.arg0) {
1865
+ writeOutput(input.flags, { status: 'error', error: 'usage: pugi resume <session-id> | pugi resume --list' }, 'Usage: pugi resume <session-id> (run `pugi resume --list` to see ids).');
1866
+ process.exitCode = 2;
1867
+ return;
1868
+ }
1869
+ // Resolve the id. Accepts full uuid OR the 13-char prefix `pugi
1870
+ // resume` prints (`xxxxxxxx-xxxx`). Match on prefix because the
1871
+ // operator types from the human-friendly listing.
1872
+ const candidate = input.arg0;
1873
+ const target = await resolveLocalSessionId(projectSlug, candidate);
1874
+ if (!target) {
1875
+ writeOutput(input.flags, { status: 'not-found', id: candidate }, `No local session matches '${candidate}'. Run \`pugi resume --list\`.`);
1876
+ process.exitCode = 1;
1877
+ return;
1878
+ }
1879
+ // Hand off to the REPL bootstrap with the resolved id pre-bound so
1880
+ // the SessionStore opens the existing log + the bootstrap calls
1881
+ // restoreTranscript before the first user input.
1882
+ const runtimeConfig = resolveRuntimeConfig();
1883
+ if (!runtimeConfig) {
1884
+ writeOutput(input.flags, { status: 'auth-missing', id: target.id }, 'No credentials configured. Run `pugi login` first, then `pugi resume <id>`.');
1885
+ process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
1886
+ return;
1887
+ }
1888
+ const { renderRepl } = await import('../tui/repl-render.js');
1889
+ await renderRepl({
1890
+ apiUrl: runtimeConfig.apiUrl,
1891
+ apiKey: runtimeConfig.apiKey,
1892
+ workspaceLabel: workspaceLabel(cwd),
1893
+ cliVersion: PUGI_CLI_VERSION,
1894
+ skipSplash: input.flags.noSplash,
1895
+ hideToolStream: input.flags.noToolStream,
1896
+ resumeLocalSessionId: target.id,
1897
+ });
1898
+ }
1899
+ /**
1900
+ * List the most recent local sessions for a project. Uses the
1901
+ * READ-ONLY view (`SqliteSessionStore.openReadOnly`) so the call never
1902
+ * takes the lockfile and never inserts a stub session row. Safe to
1903
+ * call while a live REPL writes in the same project — SQLite supports
1904
+ * concurrent readers + a single writer.
1905
+ *
1906
+ * Previously this opened the full SqliteSessionStore (lockfile +
1907
+ * insert path), which polluted history with one empty session row per
1908
+ * `pugi resume --list` or `pugi sessions --local` invocation. Fixed in
1909
+ * the α6.4 review pass.
1910
+ */
1911
+ async function listLocalSessions(projectSlug) {
1912
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1913
+ try {
1914
+ return await view.list({ limit: 10 });
1915
+ }
1916
+ finally {
1917
+ await view.close();
1918
+ }
1919
+ }
1920
+ /** Canonical UUID v7 surface form: 8-4-4-4-12 hex with '7' at the version nibble. */
1921
+ const FULL_UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1922
+ /**
1923
+ * Resolve a session id from a partial input. Accepts:
1924
+ * - full uuid v7 (canonical form xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
1925
+ * - 13-char prefix `xxxxxxxx-xxxx` (the human-friendly form the
1926
+ * `/resume` list prints)
1927
+ * - short 8-char hex prefix `xxxxxxxx`
1928
+ *
1929
+ * For a FULL uuid we go direct-to-`get` so the lookup is not bounded
1930
+ * by the most-recent-N listing (operators paste an id from days ago).
1931
+ * For a prefix we fall back to scanning the first page; that matches
1932
+ * the renderer's listing window.
1933
+ *
1934
+ * Returns the matching SessionRow or null when no row matches.
1935
+ */
1936
+ async function resolveLocalSessionId(projectSlug, candidate) {
1937
+ const normalised = candidate.trim().toLowerCase();
1938
+ const view = await SqliteSessionStore.openReadOnly(resolveProjectStoreDir(projectSlug));
1939
+ try {
1940
+ if (FULL_UUID_V7_RE.test(normalised)) {
1941
+ // Direct lookup — never bounded by the listing window.
1942
+ const direct = await view.get(normalised);
1943
+ if (direct)
1944
+ return direct;
1945
+ return null;
1946
+ }
1947
+ // Prefix path: scan the most-recent 10 rows so a typed short prefix
1948
+ // resolves against what the renderer just printed.
1949
+ const rows = await view.list({ limit: 10 });
1950
+ const exact = rows.find((r) => r.id.toLowerCase() === normalised);
1951
+ if (exact)
1952
+ return exact;
1953
+ const byPrefix = rows.find((r) => r.id.toLowerCase().startsWith(normalised));
1954
+ return byPrefix ?? null;
1955
+ }
1956
+ finally {
1957
+ await view.close();
1958
+ }
1959
+ }
1960
+ function renderLocalSessionList(rows, projectSlug) {
1961
+ if (rows.length === 0) {
1962
+ return `No stored sessions for project '${projectSlug}' yet.`;
1963
+ }
1964
+ const lines = [
1965
+ `Recent local sessions for '${projectSlug}' (${rows.length}):`,
1966
+ '',
1967
+ ];
1968
+ for (let i = 0; i < rows.length; i += 1) {
1969
+ const row = rows[i];
1970
+ const title = (row.title ?? '(untitled)').slice(0, 64);
1971
+ const idShort = row.id.slice(0, 13);
1972
+ const branch = (row.branch ?? 'no-branch').padEnd(16);
1973
+ const turns = `${row.turnCount}t`.padStart(4);
1974
+ const events = `${row.eventCount}e`.padStart(5);
1975
+ lines.push(` ${idShort} ${branch} ${turns} ${events} ${title}`);
1976
+ }
1977
+ lines.push('', 'Resume with: pugi resume <id>');
1978
+ return lines.join('\n');
1979
+ }
1481
1980
  /**
1482
1981
  * Per-command exit code map. Surfaced to the operator so shell scripts
1483
1982
  * can branch on the engine outcome:
@@ -1652,6 +2151,45 @@ function runEngineTask(kind) {
1652
2151
  risks: ['adapter terminated without emitting a result event'],
1653
2152
  };
1654
2153
  }
2154
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
2155
+ //
2156
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
2157
+ // the final response rather than through tool calls (especially
2158
+ // Gemini and o1 family, which under-use tool schemas in long
2159
+ // reasoning chains). We run the dispatcher against the model's
2160
+ // final text so those markers still land on disk. Tool-call edits
2161
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
2162
+ // unaffected — the dispatcher only fires on prose blocks that
2163
+ // happen to contain markers.
2164
+ //
2165
+ // Scope: code / fix / build / explain only. `plan` is read-only
2166
+ // (the engine refuses write tools), so even a stray marker in plan
2167
+ // output gets ignored to honour the plan-mode contract.
2168
+ //
2169
+ // Dry-run + read-only short-circuits: when the flags forbid writes
2170
+ // we dispatch with `dryRun: true` so the operator still sees what
2171
+ // WOULD have been written, but nothing touches disk.
2172
+ let dispatchResults = [];
2173
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2174
+ dispatchResults = await runMarkerDispatch({
2175
+ root,
2176
+ result: {
2177
+ status: result.status,
2178
+ summary: result.summary,
2179
+ eventRefs: result.eventRefs,
2180
+ },
2181
+ dryRun: flags.dryRun,
2182
+ });
2183
+ // Merge dispatcher-touched files into `result.filesChanged` so the
2184
+ // operator-facing summary lists them alongside tool-driven edits.
2185
+ for (const dr of dispatchResults) {
2186
+ if (dr.ok && dr.absPath) {
2187
+ const rel = relative(root, dr.absPath);
2188
+ if (!result.filesChanged.includes(rel))
2189
+ result.filesChanged.push(rel);
2190
+ }
2191
+ }
2192
+ }
1655
2193
  // For `plan` we always write a plan.md artifact, regardless of
1656
2194
  // outcome. A blocked plan (budget exhausted, tool refusal) still
1657
2195
  // produces a reviewable artifact — the reason is recorded inline.
@@ -1714,6 +2252,16 @@ function runEngineTask(kind) {
1714
2252
  sessionEventsMirror: metrics.mirror,
1715
2253
  risks: result.risks,
1716
2254
  plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2255
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
2256
+ // markers were detected in the model's final response.
2257
+ diffEdits: dispatchResults.map((dr) => ({
2258
+ layer: dr.layer,
2259
+ file: dr.file,
2260
+ ok: dr.ok,
2261
+ bytesWritten: dr.bytesWritten,
2262
+ reason: dr.reason,
2263
+ detail: dr.detail,
2264
+ })),
1717
2265
  // The full event stream is useful for cabinet UI replay. We surface
1718
2266
  // it in JSON mode only — text mode operators want the summary, not
1719
2267
  // 30 turn-level lines.
@@ -1734,6 +2282,19 @@ function runEngineTask(kind) {
1734
2282
  textLines.push('Files modified: none');
1735
2283
  }
1736
2284
  textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2285
+ if (dispatchResults.length > 0) {
2286
+ const okCount = dispatchResults.filter((d) => d.ok).length;
2287
+ const failCount = dispatchResults.length - okCount;
2288
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2289
+ for (const dr of dispatchResults) {
2290
+ if (dr.ok) {
2291
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2292
+ }
2293
+ else {
2294
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2295
+ }
2296
+ }
2297
+ }
1737
2298
  if (result.risks.length > 0) {
1738
2299
  textLines.push(`Risks: ${result.risks.join('; ')}`);
1739
2300
  }
@@ -1743,6 +2304,96 @@ function runEngineTask(kind) {
1743
2304
  writeOutput(flags, payload, textLines.join('\n'));
1744
2305
  };
1745
2306
  }
2307
+ // Exported for the α6.6.1 triple-review remediation spec
2308
+ // (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
2309
+ // surface is not part of the public CLI API; this is a test seam.
2310
+ export async function runMarkerDispatch(input) {
2311
+ const { root, result, dryRun } = input;
2312
+ const dispatch = input.dispatchFn ?? dispatchEdit;
2313
+ // Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
2314
+ // engine's terminal status. A `blocked` (budget_exhausted, plan-mode
2315
+ // refusal) or `failed` result may still carry markers in the
2316
+ // partial `summary` text — applying them would mutate files the
2317
+ // CLI then exits non-zero on, leaving the workspace in an
2318
+ // unexpected state with no operator signal that "blocked but with
2319
+ // side effects" happened. Only `done` is allowed to write.
2320
+ if (result.status !== 'done')
2321
+ return [];
2322
+ // Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
2323
+ // from the body before scanning. The prefixes start with `[`; the
2324
+ // dispatcher tolerates leading prose but the cleaner the input the
2325
+ // less chance of accidental marker matches.
2326
+ const body = result.summary;
2327
+ if (!hasAnyMarkerSignal(body))
2328
+ return [];
2329
+ const modelTag = extractModelTag(result.eventRefs);
2330
+ try {
2331
+ return await dispatch(body, {
2332
+ modelTag,
2333
+ cwd: root,
2334
+ dryRun,
2335
+ });
2336
+ }
2337
+ catch (error) {
2338
+ // Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
2339
+ // swallowed parser/applicator crashes silently — the operator
2340
+ // saw a clean "0 applied" rather than the actual stack trace,
2341
+ // and the bug only surfaced when someone manually `pugi resume`-d
2342
+ // a session and noticed the missing edits. Surface the failure
2343
+ // both to stderr (so live operators see it) and as a synthetic
2344
+ // DispatchResult (so JSON consumers and the audit log record it).
2345
+ //
2346
+ // R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
2347
+ // returned `detail: message` — i.e. the raw `Error.message`. That
2348
+ // string is constructed by whatever code path threw (parser,
2349
+ // applicator, fs layer, etc.) and may contain absolute paths,
2350
+ // secret fragments echoed in `oldString` context, or other
2351
+ // stack-bearing internals. The audit log and any JSON consumer
2352
+ // that surfaces `detail` to the operator (or worse, to a remote
2353
+ // monitoring pipe) would leak them. Stack already goes to stderr
2354
+ // for live diagnosis; the returned result must carry a safe,
2355
+ // static string so consumers can still detect "dispatcher
2356
+ // crashed" without re-rendering the underlying exception.
2357
+ const message = error instanceof Error ? error.message : String(error);
2358
+ const stack = error instanceof Error && error.stack ? error.stack : message;
2359
+ process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
2360
+ return [
2361
+ {
2362
+ layer: 'layer-a',
2363
+ file: '',
2364
+ ok: false,
2365
+ bytesWritten: 0,
2366
+ reason: 'dispatcher_crash',
2367
+ detail: 'dispatcher crashed - see stderr for stack trace',
2368
+ },
2369
+ ];
2370
+ }
2371
+ }
2372
+ /**
2373
+ * Quick pre-filter: does the body contain ANY of the marker
2374
+ * signatures the dispatcher knows about? Saves a full parse on every
2375
+ * model response (most responses are pure prose and would otherwise
2376
+ * round-trip through the parser pointlessly).
2377
+ */
2378
+ function hasAnyMarkerSignal(body) {
2379
+ return (body.includes('+++ NEW') ||
2380
+ body.includes('<<<<<<< SEARCH') ||
2381
+ body.includes('@@@ REWRITE') ||
2382
+ body.includes('@@@ AST') ||
2383
+ /^--- a\//m.test(body));
2384
+ }
2385
+ /**
2386
+ * Extract `model=<tag>` from eventRefs if the adapter emitted it.
2387
+ * Returns undefined when missing; dispatchEdit then auto-detects from
2388
+ * the payload itself.
2389
+ */
2390
+ function extractModelTag(refs) {
2391
+ for (const ref of refs) {
2392
+ if (ref.startsWith('model='))
2393
+ return ref.slice('model='.length);
2394
+ }
2395
+ return undefined;
2396
+ }
1746
2397
  /**
1747
2398
  * Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
1748
2399
  * already emits the canonical strings (`tool_calls=N`, `turns=N`,
@@ -3016,6 +3667,33 @@ async function jobs(args, flags, session) {
3016
3667
  process.exitCode = exitCode;
3017
3668
  }
3018
3669
  }
3670
+ /**
3671
+ * `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
3672
+ * `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
3673
+ * to the deploy-specific flag set + exposes the credential store via
3674
+ * `resolveRuntimeConfig` so the deploy module stays decoupled from the
3675
+ * CLI's auth bootstrap.
3676
+ */
3677
+ async function dispatchDeploy(args, flags, _session) {
3678
+ // Triple-review #391 P2: the global `parseArgs` in this file consumes
3679
+ // `--json` before the per-command args reach us, so `runDeployCommand`'s
3680
+ // internal parser never sees it and the JSON envelope path is silently
3681
+ // skipped. Re-inject the flag so downstream parsing surfaces the JSON
3682
+ // output contract the operator asked for. Idempotent: if the user wrote
3683
+ // `pugi deploy --json ...` the global parser stripped it; if they wrote
3684
+ // `pugi --json deploy ...` ditto. Either way the global flag is the
3685
+ // single source of truth and we forward it verbatim.
3686
+ const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
3687
+ const exitCode = await runDeployCommand(forwardedArgs, {
3688
+ write: (text) => process.stdout.write(text),
3689
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
3690
+ }, {
3691
+ resolveConfig: () => resolveRuntimeConfig(),
3692
+ });
3693
+ if (exitCode !== 0) {
3694
+ process.exitCode = exitCode;
3695
+ }
3696
+ }
3019
3697
  function notImplemented(command) {
3020
3698
  return async (_args, flags) => {
3021
3699
  const payload = {
@@ -3051,13 +3729,18 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
3051
3729
  * REPL header in sync with `pwd` lets the operator orient at a glance.
3052
3730
  * Empty / pathological cwd values (a worktree resolved to `/`) fall
3053
3731
  * back to `workspace` so the header never collapses.
3732
+ *
3733
+ * α6.14.2 wave 5: when the cwd has no project markers (no .git, no
3734
+ * package.json, no PUGI.md), the resolver returns the explicit "not
3735
+ * bound" warning instead of a stray parent-dir basename. CEO 2026-05-25
3736
+ * dogfood surfaced the bug — launching `pugi` from `codeforge-io/`
3737
+ * (the parent of all checkouts) leaked `codeforge-io` into the splash
3738
+ * as if it were a real workspace. Mira/Pugi can NOT bind on that. The
3739
+ * decision lives in `core/repl/workspace-context.ts` so the splash +
3740
+ * status bar agree on a single label.
3054
3741
  */
3055
3742
  function workspaceLabel(cwd) {
3056
- const segments = cwd.split('/').filter((s) => s.length > 0);
3057
- const last = segments[segments.length - 1];
3058
- if (!last || last.length === 0)
3059
- return 'workspace';
3060
- return last;
3743
+ return resolveWorkspaceLabel(cwd);
3061
3744
  }
3062
3745
  function ensureDir(path, created, skipped) {
3063
3746
  if (existsSync(path)) {
@@ -3279,22 +3962,41 @@ const PROTECTED_DIFF_EXCLUDES = [
3279
3962
  // Basename excludes apply at the repo root AND in any subdirectory
3280
3963
  // (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
3281
3964
  // `**/` prefix, git's literal pathspec syntax would only match the
3282
- // repo root and silently let a subdir `.env` ship in the diff
3965
+ // repo root and silently let a subdir `.env` ship in the diff -
3283
3966
  // common pitfall in pnpm/turbo monorepos.
3967
+ //
3968
+ // Keep this list in sync with `PROTECTED_PATHSPEC_EXCLUDES` in
3969
+ // `apps/pugi-cli/src/core/consensus/diff-capture.ts`. Both surfaces
3970
+ // (legacy triple-review + consensus fan-out) enforce the same egress
3971
+ // contract; divergence creates an adversarial-PR leak window.
3284
3972
  ':(exclude,glob)**/.env',
3285
3973
  ':(exclude,glob)**/.env.*',
3286
3974
  ':(exclude,glob)**/.npmrc',
3287
3975
  ':(exclude,glob)**/.yarnrc',
3288
3976
  ':(exclude,glob)**/.pypirc',
3289
3977
  ':(exclude,glob)**/.gitconfig',
3978
+ ':(exclude,glob)**/.netrc',
3290
3979
  ':(exclude,glob)**/id_rsa',
3291
3980
  ':(exclude,glob)**/id_ed25519',
3981
+ ':(exclude,glob)**/id_ecdsa',
3982
+ ':(exclude,glob)**/id_dsa',
3292
3983
  ':(exclude,glob)**/*.pem',
3293
3984
  ':(exclude,glob)**/*.key',
3294
3985
  ':(exclude,glob)**/*.crt',
3986
+ ':(exclude,glob)**/*.cer',
3987
+ ':(exclude,glob)**/*.der',
3988
+ ':(exclude,glob)**/*.pfx',
3295
3989
  ':(exclude,glob)**/*.p12',
3296
3990
  ':(exclude,glob)**/*.dump',
3297
3991
  ':(exclude,glob)**/*.sql',
3992
+ ':(exclude,glob)**/*.secret',
3993
+ ':(exclude,glob)**/credentials',
3994
+ ':(exclude,glob)**/credentials.json',
3995
+ // Use `secrets/**` (not `secrets/*`) so nested credential paths
3996
+ // recurse - with glob pathspec magic a single `*` does not cross path
3997
+ // separators, so the non-recursive form would let `secrets/prod/x.key`
3998
+ // ship in the diff payload.
3999
+ ':(exclude,glob)**/secrets/**',
3298
4000
  ];
3299
4001
  function collectUntrackedSummary(root) {
3300
4002
  const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
@@ -3311,12 +4013,28 @@ function collectUntrackedSummary(root) {
3311
4013
  return { paths: visible.slice(0, 50), excludedProtected: excluded };
3312
4014
  }
3313
4015
  function isProtectedPath(path) {
4016
+ // Keep in sync with PROTECTED_DIFF_EXCLUDES above. This filter
4017
+ // applies to the untracked-files summary surfaced to operators; the
4018
+ // pathspec excludes apply at the egress / diff capture layer.
3314
4019
  const base = path.split('/').pop() ?? path;
3315
4020
  if (base === '.env' || base.startsWith('.env.'))
3316
4021
  return true;
3317
- if (['.npmrc', '.yarnrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(base))
4022
+ const exactNames = [
4023
+ '.npmrc',
4024
+ '.yarnrc',
4025
+ '.pypirc',
4026
+ '.gitconfig',
4027
+ '.netrc',
4028
+ 'id_rsa',
4029
+ 'id_ed25519',
4030
+ 'id_ecdsa',
4031
+ 'id_dsa',
4032
+ 'credentials',
4033
+ 'credentials.json',
4034
+ ];
4035
+ if (exactNames.includes(base))
3318
4036
  return true;
3319
- return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
4037
+ return /\.(pem|key|crt|cer|der|pfx|p12|dump|sql|secret)$/i.test(base);
3320
4038
  }
3321
4039
  function safeReadJson(path) {
3322
4040
  try {