@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.15

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