@pugi/cli 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
@@ -32,6 +32,9 @@ import { evaluateCap, describeVerdict } from './cap-warning.js';
32
32
  import { parseSlashCommand } from './slash-commands.js';
33
33
  import { webFetchTool } from '../../tools/web-fetch.js';
34
34
  import { loadSettings } from '../settings.js';
35
+ import { getJobRegistry } from '../jobs/registry.js';
36
+ import { existsSync, readdirSync, statSync } from 'node:fs';
37
+ import { resolve as resolvePath } from 'node:path';
35
38
  const MAX_TRANSCRIPT_ROWS = 500;
36
39
  const MAX_RECONNECT_ATTEMPTS = 10;
37
40
  const RECONNECT_BASE_MS = 250;
@@ -45,6 +48,18 @@ export class ReplSession {
45
48
  reconnectAttempt = 0;
46
49
  reconnectTimer;
47
50
  closed = false;
51
+ /**
52
+ * Last non-trivial step.detail recorded per taskId. The server streams
53
+ * the persona reply incrementally via `agent.step` events whose
54
+ * `detail` field carries the cumulative model output. `agent.completed`
55
+ * arrives last and previously overwrote the visible detail to the
56
+ * literal string `'shipped'` while the transcript line said only
57
+ * `shipped.` — the actual reply text was lost. By caching the last
58
+ * non-trivial detail here, we can flush it into the transcript when
59
+ * the agent completes so the operator sees what the persona actually
60
+ * said. CEO wave-2 fix 2026-05-25.
61
+ */
62
+ lastStepDetail = new Map();
48
63
  constructor(options) {
49
64
  this.options = options;
50
65
  this.state = {
@@ -81,6 +96,7 @@ export class ReplSession {
81
96
  const { sessionId } = await this.options.transport.createSession({
82
97
  apiUrl: this.options.apiUrl,
83
98
  apiKey: this.options.apiKey,
99
+ workspace: this.options.workspace,
84
100
  });
85
101
  this.patch({ sessionId, connection: 'connecting' });
86
102
  this.openStream();
@@ -122,7 +138,11 @@ export class ReplSession {
122
138
  // UI overlays - no transport interaction.
123
139
  return verdict;
124
140
  case 'quit':
125
- this.appendSystemLine('Brief it. It ships.');
141
+ // UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
142
+ // for identity intro + landing per wave-4 prompt rule. Drop the
143
+ // tagline drift here; tell the operator what happened and how to
144
+ // resume.
145
+ this.appendSystemLine('On watch ended. pugi resume to come back.');
126
146
  return verdict;
127
147
  case 'error':
128
148
  this.appendSystemLine(verdict.message);
@@ -139,8 +159,112 @@ export class ReplSession {
139
159
  await this.dispatchWebFetch(verdict.url);
140
160
  return verdict;
141
161
  }
162
+ case 'clear': {
163
+ this.clearTranscript();
164
+ return verdict;
165
+ }
166
+ case 'version': {
167
+ this.appendSystemLine(`pugi ${this.options.cliVersion}`);
168
+ return verdict;
169
+ }
170
+ case 'jobs': {
171
+ await this.dispatchJobs();
172
+ return verdict;
173
+ }
174
+ case 'diff': {
175
+ this.dispatchDiff();
176
+ return verdict;
177
+ }
178
+ case 'cost': {
179
+ this.dispatchCost();
180
+ return verdict;
181
+ }
182
+ case 'status': {
183
+ this.dispatchStatus();
184
+ return verdict;
185
+ }
186
+ case 'stub': {
187
+ this.appendSystemLine(verdict.message);
188
+ return verdict;
189
+ }
142
190
  }
143
191
  }
192
+ /**
193
+ * Reset the conversation transcript. The agent registry stays intact
194
+ * so the operator can `/clear` to declutter the chat pane without
195
+ * losing visibility into running dispatches.
196
+ */
197
+ clearTranscript() {
198
+ this.patch({ transcript: [] });
199
+ }
200
+ /* ------------- Tier 1 / Tier 2 wired handlers -------------- */
201
+ async dispatchJobs() {
202
+ try {
203
+ const registry = getJobRegistry();
204
+ const entries = await registry.list();
205
+ if (entries.length === 0) {
206
+ this.appendSystemLine('No background jobs tracked.');
207
+ return;
208
+ }
209
+ this.appendSystemLine(`Background jobs (${entries.length}):`);
210
+ for (const entry of entries) {
211
+ const id = entry.id.replace(/^pj-/, '').slice(0, 8);
212
+ const status = entry.status;
213
+ const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
214
+ this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
215
+ }
216
+ }
217
+ catch (error) {
218
+ this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
219
+ }
220
+ }
221
+ dispatchDiff() {
222
+ try {
223
+ const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
224
+ if (!existsSync(artifactsRoot)) {
225
+ this.appendSystemLine('No pending diffs (.pugi/artifacts/ not found).');
226
+ return;
227
+ }
228
+ const subdirs = readdirSync(artifactsRoot, { withFileTypes: true })
229
+ .filter((d) => d.isDirectory())
230
+ .map((d) => d.name);
231
+ const diffs = [];
232
+ for (const name of subdirs) {
233
+ const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
234
+ if (existsSync(candidate)) {
235
+ const size = statSync(candidate).size;
236
+ diffs.push(` ${name}/diff.patch (${size} bytes)`);
237
+ }
238
+ }
239
+ if (diffs.length === 0) {
240
+ this.appendSystemLine('No pending diffs.');
241
+ return;
242
+ }
243
+ this.appendSystemLine(`Pending diffs (${diffs.length}):`);
244
+ for (const line of diffs)
245
+ this.appendSystemLine(line);
246
+ }
247
+ catch (error) {
248
+ this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
249
+ }
250
+ }
251
+ dispatchCost() {
252
+ const { tokensDownstreamTotal, agents } = this.state;
253
+ const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
254
+ const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
255
+ const lineAgents = `Active dispatches: ${active} of cap.`;
256
+ this.appendSystemLine(lineTokens);
257
+ this.appendSystemLine(lineAgents);
258
+ this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
259
+ }
260
+ dispatchStatus() {
261
+ const sessionId = this.state.sessionId ?? '(unbound)';
262
+ const reach = this.state.connection;
263
+ this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
264
+ this.appendSystemLine(`Session: ${sessionId}.`);
265
+ this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
266
+ this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
267
+ }
144
268
  /**
145
269
  * Fetch one URL via the web_fetch tool and inject the resulting
146
270
  * Markdown into the transcript as an operator-attributed brief. The
@@ -284,6 +408,20 @@ export class ReplSession {
284
408
  switch (event.type) {
285
409
  case 'agent.spawned': {
286
410
  const persona = safePersonaName(event.role);
411
+ // Wave 4 fix 2026-05-25: the roster collapses to one row per
412
+ // persona slug. The α5.7 reducer pushed a fresh row on every
413
+ // spawn, so after three turns the bottom panel stacked
414
+ // "Mira orchestrator shipped" three times. The new contract:
415
+ // - If a row already exists for this personaSlug, REUSE it.
416
+ // Replace its taskId, reset status to 'queued', clear the
417
+ // detail line, restart the duration clock, zero the token
418
+ // counters. The persona name + slug + role stay stable
419
+ // (they are the row identity).
420
+ // - If no row exists yet, push a new one.
421
+ // Per-task lifecycle (step/tokens/completed/blocked/failed) is
422
+ // keyed off `taskId` everywhere, so the reused row still folds
423
+ // the latest task's events correctly.
424
+ const existing = this.state.agents.find((a) => a.personaSlug === event.personaSlug);
287
425
  const node = {
288
426
  taskId: event.taskId,
289
427
  role: event.role,
@@ -295,7 +433,14 @@ export class ReplSession {
295
433
  tokensIn: 0,
296
434
  tokensOut: 0,
297
435
  };
298
- this.patch({ agents: [node, ...this.state.agents] });
436
+ if (existing) {
437
+ this.patch({
438
+ agents: this.state.agents.map((a) => a.personaSlug === event.personaSlug ? node : a),
439
+ });
440
+ }
441
+ else {
442
+ this.patch({ agents: [node, ...this.state.agents] });
443
+ }
299
444
  // The conversation pane already prefixes persona rows with the
300
445
  // persona name in the persona's hue colour. Skip embedding the
301
446
  // name in the body text to avoid the `Marcus Marcus dispatched`
@@ -307,6 +452,12 @@ export class ReplSession {
307
452
  return;
308
453
  }
309
454
  case 'agent.step': {
455
+ // Cache the running detail per task so we can surface the
456
+ // model's actual reply when agent.completed lands (otherwise
457
+ // the reply disappears under the literal 'shipped' patch).
458
+ if (event.detail && event.detail.trim().length > 0) {
459
+ this.lastStepDetail.set(event.taskId, event.detail);
460
+ }
310
461
  this.patch({
311
462
  agents: this.state.agents.map((a) => a.taskId === event.taskId
312
463
  ? { ...a, status: 'thinking', detail: event.detail }
@@ -330,18 +481,41 @@ export class ReplSession {
330
481
  }
331
482
  case 'agent.completed': {
332
483
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
484
+ const finalDetail = this.lastStepDetail.get(event.taskId);
485
+ this.lastStepDetail.delete(event.taskId);
333
486
  this.patch({
334
487
  agents: this.state.agents.map((a) => a.taskId === event.taskId
335
488
  ? { ...a, status: 'shipped', detail: 'shipped' }
336
489
  : a),
337
490
  });
338
491
  if (target) {
339
- this.appendPersonaLine(target.personaSlug, 'shipped.');
492
+ // If the persona actually produced a reply via incremental
493
+ // agent.step events, render that reply in the transcript so
494
+ // the operator sees the full answer. The threshold (>4 chars
495
+ // + not the queued placeholder) filters out the no-op
496
+ // "shipped"/"queued for dispatch" placeholders the dispatcher
497
+ // emits before the model speaks. Multi-line replies are
498
+ // emitted line by line so the conversation pane wraps each
499
+ // sentence on its own row.
500
+ if (finalDetail
501
+ && finalDetail !== 'queued for dispatch'
502
+ && finalDetail.trim().length > 4) {
503
+ for (const line of finalDetail.split('\n')) {
504
+ const trimmed = line.trim();
505
+ if (trimmed.length > 0) {
506
+ this.appendPersonaLine(target.personaSlug, trimmed);
507
+ }
508
+ }
509
+ }
510
+ else {
511
+ this.appendPersonaLine(target.personaSlug, 'shipped.');
512
+ }
340
513
  }
341
514
  return;
342
515
  }
343
516
  case 'agent.blocked': {
344
517
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
518
+ this.lastStepDetail.delete(event.taskId);
345
519
  this.patch({
346
520
  agents: this.state.agents.map((a) => a.taskId === event.taskId
347
521
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -354,6 +528,7 @@ export class ReplSession {
354
528
  }
355
529
  case 'agent.failed': {
356
530
  const target = this.state.agents.find((a) => a.taskId === event.taskId);
531
+ this.lastStepDetail.delete(event.taskId);
357
532
  this.patch({
358
533
  agents: this.state.agents.map((a) => a.taskId === event.taskId
359
534
  ? { ...a, status: 'failed', detail: event.error }
@@ -1,32 +1,99 @@
1
1
  /**
2
- * REPL slash command registry - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
2
+ * REPL slash command registry Sprint α5.7, expanded α6.14 wave 2.
3
3
  *
4
- * The REPL input box surfaces a small palette of slash commands that the
5
- * operator can run from inside a persistent session: dispatch a brief,
6
- * inspect the agent roster, stop a running persona, open the help
7
- * overlay, or quit. The registry is intentionally narrow at M1 -
8
- * complementary surfaces (`/sync`, `/handoff`, `/budget`) ship as proper
9
- * subcommands in α5.8+ and stay reachable from a non-REPL `pugi
10
- * <command>` invocation.
4
+ * The REPL input box surfaces a palette of slash commands the operator
5
+ * can run from inside a persistent session. The wave-2 expansion (CEO
6
+ * 2026-05-25) grows the surface from 6 to 20 commands so the `/help`
7
+ * overlay matches the breadth Claude Code / Codex CLI operators expect.
11
8
  *
12
- * The registry is pure: each entry returns a `SlashCommandResult`
13
- * describing what the REPL session should do next. The session module
14
- * owns the side effects (network calls, dispatcher invocation, exit).
15
- * Keeping the surface pure lets the unit test exercise every shape
16
- * without standing up an Ink runtime or an Anvil endpoint.
9
+ * The registry is pure: each `parseSlashCommand` call returns a
10
+ * `SlashCommandResult` describing what the REPL session should do next.
11
+ * The session module owns the side effects (network calls, dispatcher
12
+ * invocation, exit, transcript clear). Keeping the surface pure lets
13
+ * the unit test exercise every shape without standing up an Ink runtime
14
+ * or an Anvil endpoint.
15
+ *
16
+ * Tiering (per CEO wave-2 spec):
17
+ *
18
+ * Tier 1 — wired against real state (3 + existing 6 = 9 wired):
19
+ * brief, agents, stop, help, quit, web, clear, version, jobs.
20
+ *
21
+ * Tier 2 — best-effort wiring against existing surfaces (3):
22
+ * diff, cost, status.
23
+ *
24
+ * Tier 3 — deterministic stubs ("coming in αX.Y") (8):
25
+ * compact, resume, memory, config, privacy, budget, mcp, undo.
17
26
  *
18
27
  * Brand voice (brandbook §08): power words `brief / dispatch / stop /
19
- * agents / quit`. Tagline `Brief it. It ships.` reserved for `/quit`
20
- * confirmation and `/help` footer - never inline.
28
+ * agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
29
+ * `/quit` confirmation and `/help` footer never inline.
21
30
  */
22
31
  import { listRoles } from '../agents/registry.js';
32
+ /**
33
+ * Deterministic stub copy returned by the Tier 3 commands. Spec'd
34
+ * inline so the unit test can pin the exact text without poking at
35
+ * the help overlay. The version tag at the end maps to the sprint we
36
+ * intend to land the real wiring in.
37
+ */
38
+ export const SLASH_STUB_MESSAGES = Object.freeze({
39
+ brief: '',
40
+ agents: '',
41
+ stop: '',
42
+ help: '',
43
+ quit: '',
44
+ web: '',
45
+ clear: '',
46
+ version: '',
47
+ jobs: '',
48
+ diff: '',
49
+ cost: '',
50
+ status: '',
51
+ compact: 'Manual context compaction lands in α6.5.',
52
+ resume: 'Resume last session lands in α6.4 once SQLite session.db lands.',
53
+ memory: 'Session memory editor lands in α6.5.',
54
+ config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
55
+ privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
56
+ budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
57
+ mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
58
+ undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
59
+ });
23
60
  export const SLASH_COMMAND_HELP = Object.freeze([
24
- { name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce' },
25
- { name: 'agents', args: '', gloss: 'List the on-watch agent roster' },
26
- { name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug' },
27
- { name: 'help', args: '', gloss: 'Show this help overlay' },
28
- { name: 'quit', args: '', gloss: 'Exit the REPL (session resumes via pugi resume)' },
29
- { name: 'web', args: '<url>', gloss: 'Fetch a URL and brief Pugi on the page' },
61
+ // Workforce dispatch
62
+ { name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce', group: 'Workforce dispatch' },
63
+ { name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
64
+ { name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
65
+ { name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
66
+ // Session
67
+ { name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
68
+ { name: 'resume', args: '', gloss: 'Resume last session (α6.4)', group: 'Session', stub: true },
69
+ { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5)', group: 'Session', stub: true },
70
+ { name: 'memory', args: '', gloss: 'Session memory editor (α6.5)', group: 'Session', stub: true },
71
+ // Pugi tools
72
+ { name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
73
+ { name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
74
+ { name: 'cost', args: '', gloss: 'Token usage + budget', group: 'Pugi tools' },
75
+ { name: 'status', args: '', gloss: 'Backend + tenant status', group: 'Pugi tools' },
76
+ // Settings
77
+ { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
78
+ { name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings', stub: true },
79
+ { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
80
+ { name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
81
+ { name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
82
+ // Meta
83
+ { name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
84
+ { name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
85
+ { name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
86
+ ]);
87
+ /**
88
+ * Ordered list of groups. Drives the `/help` overlay sectioning so the
89
+ * operator reads commands by intent (dispatch first, meta last).
90
+ */
91
+ export const SLASH_COMMAND_GROUPS = Object.freeze([
92
+ 'Workforce dispatch',
93
+ 'Session',
94
+ 'Pugi tools',
95
+ 'Settings',
96
+ 'Meta',
30
97
  ]);
31
98
  /**
32
99
  * Parse one line of input from the REPL. The contract:
@@ -34,13 +101,15 @@ export const SLASH_COMMAND_HELP = Object.freeze([
34
101
  * - Empty / whitespace-only input returns `noop` with the original
35
102
  * text so the REPL can ignore it without printing anything.
36
103
  * - Input that does not start with `/` is treated as an implicit
37
- * `/brief <text>` - the most-common operator action.
104
+ * `/brief <text>` the most-common operator action.
38
105
  * - `/<name> [args]` resolves the name against the registry; unknown
39
106
  * names return `error` so the REPL can render a one-line tip
40
107
  * instead of silently dropping the input.
108
+ * - Tier 3 stubs return `{ kind: 'stub', name, message }` so the REPL
109
+ * can render the deterministic "coming in αX.Y" copy uniformly.
41
110
  *
42
111
  * The function never throws. Bad input maps to a structured result the
43
- * REPL can render - the alternative (throwing from a keystroke handler)
112
+ * REPL can render the alternative (throwing from a keystroke handler)
44
113
  * would unmount Ink mid-frame.
45
114
  */
46
115
  export function parseSlashCommand(input) {
@@ -98,6 +167,40 @@ export function parseSlashCommand(input) {
98
167
  }
99
168
  return { kind: 'web', url: tail };
100
169
  }
170
+ case 'clear':
171
+ case 'cls': {
172
+ return { kind: 'clear' };
173
+ }
174
+ case 'version':
175
+ case 'v': {
176
+ return { kind: 'version' };
177
+ }
178
+ case 'jobs': {
179
+ return { kind: 'jobs' };
180
+ }
181
+ case 'diff': {
182
+ return { kind: 'diff' };
183
+ }
184
+ case 'cost': {
185
+ return { kind: 'cost' };
186
+ }
187
+ case 'status': {
188
+ return { kind: 'status' };
189
+ }
190
+ case 'compact':
191
+ case 'resume':
192
+ case 'memory':
193
+ case 'config':
194
+ case 'privacy':
195
+ case 'budget':
196
+ case 'mcp':
197
+ case 'undo': {
198
+ return {
199
+ kind: 'stub',
200
+ name: name,
201
+ message: SLASH_STUB_MESSAGES[name],
202
+ };
203
+ }
101
204
  default: {
102
205
  return {
103
206
  kind: 'error',
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workspace context resolver — Sprint α6.14 wave 4.
3
+ *
4
+ * Reads the operator's cwd and synthesises the workspace bundle the CLI
5
+ * forwards to admin-api on POST /api/pugi/sessions. Mira's prompt v1.1
6
+ * consumes the bundle so "what repo is this?" / "а изучи репо…" answers
7
+ * from the live cwd instead of bouncing back "репо не привязано" (CEO
8
+ * dogfood 2026-05-25).
9
+ *
10
+ * Three fields:
11
+ *
12
+ * - `workspaceCwd` — absolute path the CLI was launched from.
13
+ * - `workspaceSlug` — a stable short identifier (slugForCwd).
14
+ * - `workspaceSummary` — first ~200 chars of `.pugi/PUGI.md` if the
15
+ * repo has one, else the directory basename.
16
+ *
17
+ * The helper is pure-ish (reads the filesystem but does not mutate it)
18
+ * so the production caller in `runtime/cli.ts` can call it eagerly at
19
+ * REPL launch without touching the network. Tests pass an explicit cwd
20
+ * + `fs` stub so the resolver stays deterministic.
21
+ *
22
+ * Failure mode: any FS error (permission denied, missing PUGI.md,
23
+ * symlink loop) returns the basename fallback. The CLI never bubbles
24
+ * the error to the operator — workspace context is a best-effort hint,
25
+ * not a precondition for opening the session.
26
+ */
27
+ import { existsSync, readFileSync, statSync } from 'node:fs';
28
+ import { basename, resolve as resolvePath } from 'node:path';
29
+ import { slugForCwd } from './history.js';
30
+ /** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
31
+ const PUGI_MD_HEAD_LIMIT = 200;
32
+ /**
33
+ * Resolve a `ReplWorkspaceContext` from the operator's working directory.
34
+ * Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
35
+ * `workspaceSummary` populated. The summary is the PUGI.md head when
36
+ * available, else the directory basename.
37
+ */
38
+ export function resolveWorkspaceContext(cwd) {
39
+ const normalised = resolvePath(cwd);
40
+ const slug = slugForCwd(normalised);
41
+ const summary = readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace';
42
+ return {
43
+ workspaceCwd: normalised,
44
+ workspaceSlug: slug,
45
+ workspaceSummary: summary,
46
+ };
47
+ }
48
+ /**
49
+ * Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
50
+ * project's own description is the highest-signal one-line summary we
51
+ * can hand to Mira — `pugi init` writes it on workspace creation, and
52
+ * the operator may have edited it since.
53
+ *
54
+ * Returns null on any FS error so the caller falls back to the
55
+ * basename. We never throw — workspace context is best-effort.
56
+ */
57
+ function readPugiSummary(cwd) {
58
+ const candidate = resolvePath(cwd, '.pugi', 'PUGI.md');
59
+ try {
60
+ if (!existsSync(candidate))
61
+ return null;
62
+ const st = statSync(candidate);
63
+ if (!st.isFile())
64
+ return null;
65
+ // Cap the read at 2 KB — even a malformed PUGI.md will not be
66
+ // bigger than that's worth on a one-line summary, and capping
67
+ // bounds the slice cost on a giant file.
68
+ const raw = readFileSync(candidate, 'utf8').slice(0, 2048);
69
+ return summariseMarkdown(raw);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Reduce a PUGI.md head to one short summary line: strip the front-
77
+ * matter and the leading H1 marker, take the first non-empty line,
78
+ * cap at PUGI_MD_HEAD_LIMIT. Whitespace collapses to single spaces so
79
+ * the summary survives the admin-api clamp without weird wrap.
80
+ */
81
+ export function summariseMarkdown(raw) {
82
+ if (!raw || raw.trim().length === 0)
83
+ return null;
84
+ const body = stripFrontmatter(raw);
85
+ const lines = body.split(/\r?\n/);
86
+ for (const line of lines) {
87
+ // Strip leading `#` markers + trim whitespace. A heading like
88
+ // "# My Project" becomes "My Project".
89
+ const stripped = line.replace(/^#+\s*/, '').trim();
90
+ if (stripped.length === 0)
91
+ continue;
92
+ const oneLine = stripped.replace(/\s+/g, ' ');
93
+ return oneLine.length > PUGI_MD_HEAD_LIMIT
94
+ ? oneLine.slice(0, PUGI_MD_HEAD_LIMIT)
95
+ : oneLine;
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Drop a YAML front-matter block (`---\n…\n---`) from the head of a
101
+ * Markdown file. Mira does not need to see the metadata; the prose body
102
+ * carries the project description.
103
+ */
104
+ function stripFrontmatter(raw) {
105
+ if (!raw.startsWith('---'))
106
+ return raw;
107
+ const end = raw.indexOf('\n---', 3);
108
+ if (end === -1)
109
+ return raw;
110
+ const afterFrontmatter = raw.slice(end + 4);
111
+ return afterFrontmatter.replace(/^\r?\n/, '');
112
+ }
113
+ //# sourceMappingURL=workspace-context.js.map
@@ -35,7 +35,7 @@ import { runBudgetCommand } from './commands/budget.js';
35
35
  * packages/pugi-sdk/package.json); the publish workflow validates the
36
36
  * three are in lockstep.
37
37
  */
38
- const PUGI_CLI_VERSION = '0.1.0-alpha.7';
38
+ const PUGI_CLI_VERSION = '0.1.0-alpha.9';
39
39
  const handlers = {
40
40
  accounts,
41
41
  build: runEngineTask('build_task'),
@@ -169,6 +169,7 @@ export async function runCli(argv) {
169
169
  workspaceLabel: workspaceLabel(process.cwd()),
170
170
  cliVersion: PUGI_CLI_VERSION,
171
171
  updateBanner,
172
+ skipSplash: flags.noSplash,
172
173
  });
173
174
  return;
174
175
  }
@@ -204,6 +205,7 @@ function parseArgs(argv) {
204
205
  noTty: false,
205
206
  allowFetch: false,
206
207
  noUpdateCheck: false,
208
+ noSplash: process.env.PUGI_SKIP_SPLASH === '1',
207
209
  };
208
210
  const args = [];
209
211
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -245,6 +247,9 @@ function parseArgs(argv) {
245
247
  else if (arg === '--no-update-check') {
246
248
  flags.noUpdateCheck = true;
247
249
  }
250
+ else if (arg === '--no-splash') {
251
+ flags.noSplash = true;
252
+ }
248
253
  else if (arg.startsWith('--privacy=')) {
249
254
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
250
255
  }
@@ -303,6 +308,8 @@ async function help(_args, flags, _session) {
303
308
  ' recording flows, dumb terminals).',
304
309
  ' --no-update-check Silence the REPL startup update banner. Pairs',
305
310
  ' with PUGI_SKIP_UPDATE_BANNER=1.',
311
+ ' --no-splash Skip the REPL boot splash. Pairs with',
312
+ ' PUGI_SKIP_SPLASH=1.',
306
313
  '',
307
314
  PUGI_TAGLINE,
308
315
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -456,7 +456,7 @@ export function InputBox(props) {
456
456
  : Math.min(paletteIndex, paletteView.rows.length - 1);
457
457
  const divider = '─'.repeat(innerWidth);
458
458
  const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
459
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
459
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
460
460
  }
461
461
  /**
462
462
  * Render the line with the cursor glyph inserted at `cursor`. The cursor
@@ -19,7 +19,8 @@
19
19
  import React from 'react';
20
20
  import { render } from 'ink';
21
21
  import { Repl } from './repl.js';
22
- import { ReplSession } from '../core/repl/session.js';
22
+ import { ReplSession, } from '../core/repl/session.js';
23
+ import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
23
24
  /**
24
25
  * Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
25
26
  * `/quit`. The session is closed (server-side stays alive; resume via
@@ -27,17 +28,27 @@ import { ReplSession } from '../core/repl/session.js';
27
28
  */
28
29
  export async function renderRepl(options) {
29
30
  const transport = createProductionTransport();
31
+ // Auto-bind the workspace context from process.cwd() so Mira knows
32
+ // which repo the operator launched the CLI in. The resolver is
33
+ // best-effort — any FS error falls back to a basename-only summary,
34
+ // never blocks REPL launch. Wave 4 fix 2026-05-25.
35
+ const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
30
36
  const session = new ReplSession({
31
37
  apiUrl: options.apiUrl,
32
38
  apiKey: options.apiKey,
33
39
  workspaceLabel: options.workspaceLabel,
34
40
  cliVersion: options.cliVersion,
35
41
  transport,
42
+ workspace,
36
43
  });
37
44
  // Kick off the connect; the Repl renders the connecting state until
38
45
  // the session pushes `connection: 'on_watch'` from the SSE onOpen.
39
46
  void session.start();
40
- const instance = render(React.createElement(Repl, { session, updateBanner: options.updateBanner ?? null }));
47
+ const instance = render(React.createElement(Repl, {
48
+ session,
49
+ updateBanner: options.updateBanner ?? null,
50
+ skipSplash: options.skipSplash === true,
51
+ }));
41
52
  try {
42
53
  await instance.waitUntilExit();
43
54
  }
@@ -50,11 +61,22 @@ export async function renderRepl(options) {
50
61
  /* ------------------------------------------------------------------ */
51
62
  function createProductionTransport() {
52
63
  return {
53
- async createSession({ apiUrl, apiKey }) {
64
+ async createSession({ apiUrl, apiKey, workspace }) {
65
+ // Forward the workspace bundle in the POST body so admin-api can
66
+ // surface `<workspace-context>` in Mira's prompt. Older admin-api
67
+ // builds ignore unknown fields, so this stays forward-compatible.
68
+ // Wave 4 fix 2026-05-25.
69
+ const body = {};
70
+ if (workspace?.workspaceCwd)
71
+ body.workspaceCwd = workspace.workspaceCwd;
72
+ if (workspace?.workspaceSlug)
73
+ body.workspaceSlug = workspace.workspaceSlug;
74
+ if (workspace?.workspaceSummary)
75
+ body.workspaceSummary = workspace.workspaceSummary;
54
76
  const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
55
77
  method: 'POST',
56
78
  headers: jsonHeaders(apiKey),
57
- body: JSON.stringify({}),
79
+ body: JSON.stringify(body),
58
80
  });
59
81
  const json = await readJson(response);
60
82
  const sessionId = json.sessionId;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ASCII pug mascot for the REPL boot splash (α6.14 wave 3).
3
+ *
4
+ * Hand-crafted at 9 rows × 20 columns to read as a pug at a single
5
+ * glance — references the cyber-zoo hero glyph in
6
+ * `apps/clawhost-web/public/brand/hero-pug.png`: blocky pug face with
7
+ * angular ear flaps on either side of the head, forehead crease,
8
+ * angular cyan eyes (`◉`), smushed snout, undershot jaw, and a small
9
+ * cyan circuit chip (`▐■▌`) on the lower-right cheek.
10
+ *
11
+ * Separation of art + cyan mask lets the unit test assert structure
12
+ * (row count, line widths, mask shape, at-least-one cyan pixel per
13
+ * eye row) without coupling to the Ink renderer. The renderer in
14
+ * `repl-splash.tsx` splits each row into runs and colors the masked
15
+ * columns cyan (#3DA9FC, brandbook §05).
16
+ *
17
+ * Convention:
18
+ * - PUG_MASCOT[i] = one row of the silhouette
19
+ * - PUG_MASCOT_CYAN_MASK[i] = parallel boolean array, true => that
20
+ * column renders cyan instead of gray
21
+ *
22
+ * Both arrays MUST stay the same length and each mask row MUST be the
23
+ * same length as the corresponding art row. A unit test enforces this.
24
+ */
25
+ /* eslint-disable no-irregular-whitespace */
26
+ export const PUG_MASCOT = [
27
+ ' ▄▀▀▀▄▄▄▀▀▀▄ ',
28
+ ' █▄▄ ▄▄█ ',
29
+ ' █ ▀▄▄▄▄▄▀ █ ',
30
+ ' █ ◉ ◉ █ ',
31
+ ' ▀▄ ▀█▀ ▄▀ ',
32
+ ' █▀▀▀▀▀█ ',
33
+ ' █▒▒▒▒▒█ ▐■▌ ',
34
+ ' ▀▄▄▄▀ ',
35
+ ' ▀ ',
36
+ ];
37
+ /**
38
+ * Cyan accents are derived from the source characters so the art file
39
+ * stays the single source of truth. Two glyph classes get colored:
40
+ * - `◉` -> the two cyan eyes on row 3
41
+ * - `▐■▌` -> the cyan chip cluster on row 6 (right cheek)
42
+ *
43
+ * Everything else renders gray. The derivation runs at module load,
44
+ * which keeps the mask trivially auditable from the source array.
45
+ */
46
+ export const PUG_MASCOT_CYAN_MASK = PUG_MASCOT.map((row) => {
47
+ const mask = new Array(row.length).fill(false);
48
+ for (let column = 0; column < row.length; column += 1) {
49
+ const ch = row.charAt(column);
50
+ if (ch === '◉' || ch === '▐' || ch === '■' || ch === '▌') {
51
+ mask[column] = true;
52
+ }
53
+ }
54
+ return mask;
55
+ });
56
+ /**
57
+ * Pre-computed silhouette dimensions for layout math in the splash
58
+ * component. The unit test asserts these stay inside the documented
59
+ * envelope (≤22 chars wide, 9 ≤ rows ≤ 14) so a future edit can not
60
+ * silently bloat the terminal real estate.
61
+ */
62
+ export const PUG_MASCOT_MAX_WIDTH = PUG_MASCOT.reduce((max, row) => Math.max(max, row.length), 0);
63
+ export const PUG_MASCOT_HEIGHT = PUG_MASCOT.length;
64
+ //# sourceMappingURL=repl-splash-art.js.map
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * REPL boot splash (α6.14 wave 3).
4
+ *
5
+ * Rendered on REPL first paint — before the conversation pane, before
6
+ * any operator input lands. Mirrors the Claude Code / Codex / Gemini
7
+ * CLI boot-screen aesthetic while staying Pugi-brand-pure:
8
+ *
9
+ * [PUG ASCII] Pugi.io v0.1.0-alphaN
10
+ * Plan: <plan>
11
+ * Model: <model>
12
+ * Tenant: <customerId>
13
+ * Workspace: <basename>
14
+ *
15
+ * ─────────────────────────────────────
16
+ * Tips for getting started:
17
+ * 1. Type a brief, the workforce dispatches
18
+ * 2. /help for slash commands, /web <url> to pull a page
19
+ * 3. /skills install <name> for Anthropic / OpenClaw skills
20
+ *
21
+ * The splash auto-dismisses on:
22
+ * - first operator keystroke (the REPL `<Repl />` host owns this and
23
+ * calls the `onInteract` callback we expose),
24
+ * - 10s idle timeout (built-in, configurable via `skipSplash`),
25
+ * - `--no-splash` CLI flag or PUGI_SKIP_SPLASH=1 env (host gates the
26
+ * mount entirely; we still respect the `skipSplash` prop as a belt
27
+ * so a stray render in a test environment produces nothing).
28
+ *
29
+ * Brand voice gate: every visible string here is reviewed against the
30
+ * forbidden list (`journey / explore / delight / magical / friendly /
31
+ * AI-powered / pug-tastic`). Power words used: `brief / dispatch /
32
+ * ship / workforce / sentinel / skills`. No em-dashes; box-drawing
33
+ * `─` is OK (matches existing REPL header conventions).
34
+ */
35
+ import { useEffect } from 'react';
36
+ import { Box, Text } from 'ink';
37
+ import { PUG_MASCOT, PUG_MASCOT_CYAN_MASK, PUG_MASCOT_MAX_WIDTH, } from './repl-splash-art.js';
38
+ const DEFAULT_AUTO_DISMISS_MS = 10_000;
39
+ const PLACEHOLDER = '—';
40
+ export function ReplSplash(props) {
41
+ // Hooks MUST run unconditionally so the React reconciler can keep
42
+ // its hook order. We branch on `skipSplash` AFTER the effect
43
+ // declaration; the effect itself bails early when the splash is
44
+ // suppressed so no stray timer fires in the skip path.
45
+ useEffect(() => {
46
+ if (props.skipSplash)
47
+ return undefined;
48
+ const ms = props.autoDismissMs ?? DEFAULT_AUTO_DISMISS_MS;
49
+ const handle = setTimeout(() => {
50
+ props.onDismiss?.();
51
+ }, ms);
52
+ return () => clearTimeout(handle);
53
+ // Dependency on the onDismiss callback would re-arm the timer on
54
+ // every parent rerender; the host wraps it in useCallback so
55
+ // identity is stable for the splash's lifetime.
56
+ }, [props.autoDismissMs, props.onDismiss, props.skipSplash]);
57
+ // Belt for stray test renders: when the host already knows the
58
+ // operator opted out, we still want a render call to produce nothing
59
+ // visible. The host is the source of truth for mount-or-not; this is
60
+ // the no-op fallback.
61
+ if (props.skipSplash) {
62
+ return null;
63
+ }
64
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(MascotColumn, {}), _jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
65
+ }
66
+ /**
67
+ * Renders the multi-line ASCII pug. Each row is split into colored
68
+ * runs based on `PUG_MASCOT_CYAN_MASK` so the eyes + chip come out
69
+ * cyan and the body stays gray. Pure render of the static art array;
70
+ * no IO, no state.
71
+ */
72
+ function MascotColumn() {
73
+ return (_jsx(Box, { flexDirection: "column", minWidth: PUG_MASCOT_MAX_WIDTH, children: PUG_MASCOT.map((row, rowIndex) => (_jsx(MascotRow, { row: row, mask: PUG_MASCOT_CYAN_MASK[rowIndex] ?? [] }, rowIndex))) }));
74
+ }
75
+ function MascotRow({ row, mask, }) {
76
+ // Split the row into contiguous runs of same-color cells so we emit
77
+ // one <Text> per run instead of one per character. Keeps the Ink
78
+ // render tree shallow and the snapshot diff readable.
79
+ const runs = [];
80
+ let buffer = '';
81
+ let bufferCyan = false;
82
+ for (let column = 0; column < row.length; column += 1) {
83
+ const ch = row.charAt(column);
84
+ const cyan = mask[column] === true;
85
+ if (buffer.length === 0) {
86
+ buffer = ch;
87
+ bufferCyan = cyan;
88
+ continue;
89
+ }
90
+ if (cyan === bufferCyan) {
91
+ buffer += ch;
92
+ }
93
+ else {
94
+ runs.push({ text: buffer, cyan: bufferCyan });
95
+ buffer = ch;
96
+ bufferCyan = cyan;
97
+ }
98
+ }
99
+ if (buffer.length > 0) {
100
+ runs.push({ text: buffer, cyan: bufferCyan });
101
+ }
102
+ return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: "cyan", children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
103
+ }
104
+ function HeaderRow({ label, value }) {
105
+ const padded = `${label}:`.padEnd(11, ' ');
106
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: padded }), _jsx(Text, { children: value })] }));
107
+ }
108
+ function TipRow({ index, text }) {
109
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${index}. ` }), _jsx(Text, { children: text })] }));
110
+ }
111
+ //# sourceMappingURL=repl-splash.js.map
package/dist/tui/repl.js CHANGED
@@ -23,10 +23,12 @@ import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
23
23
  import { AgentTree } from './agent-tree.js';
24
24
  import { ConversationPane } from './conversation-pane.js';
25
25
  import { InputBox } from './input-box.js';
26
+ import { ReplSplash } from './repl-splash.js';
26
27
  import { StatusBar } from './status-bar.js';
27
28
  import { UpdateBanner } from './update-banner.js';
29
+ import { collectWorkspaceContext } from './workspace-context.js';
28
30
  import { slugForCwd } from '../core/repl/history.js';
29
- import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
31
+ import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
30
32
  const TICK_INTERVAL_MS = 200;
31
33
  const PULSE_INTERVAL_MS = 700;
32
34
  export function Repl(props) {
@@ -34,6 +36,15 @@ export function Repl(props) {
34
36
  const [overlay, setOverlay] = useState('none');
35
37
  const [pulsePhase, setPulsePhase] = useState(0);
36
38
  const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
39
+ // α6.14 wave 3: boot splash visible until first input, first
40
+ // `agent.spawned` event, or 10s idle. The host gates the initial
41
+ // visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
42
+ const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
43
+ const dismissSplash = useCallback(() => setSplashVisible(false), []);
44
+ // α6.14 wave 3: workspace context snapshot for the status bar. We
45
+ // read once at mount and freeze; a brand-new PUGI.md or skill is
46
+ // surfaced on the next REPL boot rather than via a watcher.
47
+ const workspaceContext = useMemo(() => props.workspaceContext ?? collectWorkspaceContext(process.cwd()), [props.workspaceContext]);
37
48
  // Subscribe to session state updates. The session module fires the
38
49
  // callback synchronously inside `patch` so we mirror without a
39
50
  // batching layer.
@@ -62,9 +73,24 @@ export function Repl(props) {
62
73
  useEffect(() => {
63
74
  props.onOverlayChange?.(overlay);
64
75
  }, [overlay, props]);
76
+ // α6.14 wave 3: dismiss the boot splash once the first agent spawns
77
+ // (the operator has clearly engaged the system) or the transcript
78
+ // gains a row. Mirrors the natural attention shift Claude Code /
79
+ // Codex / Gemini CLI all do on their boot screens.
80
+ useEffect(() => {
81
+ if (!splashVisible)
82
+ return;
83
+ if (state.agents.length > 0 || state.transcript.length > 0) {
84
+ setSplashVisible(false);
85
+ }
86
+ }, [splashVisible, state.agents.length, state.transcript.length]);
65
87
  const personaNames = useMemo(() => buildPersonaNameMap(), []);
66
88
  const { exit } = useApp();
67
89
  const handleSubmit = useCallback((line) => {
90
+ // Dismiss the boot splash on first operator input. Idempotent —
91
+ // `setSplashVisible(false)` is a no-op once the state already
92
+ // settled to false (timer fired or `agent.spawned` arrived).
93
+ setSplashVisible(false);
68
94
  // Run async without awaiting - the session module owns the
69
95
  // network call, errors land in the transcript automatically.
70
96
  void props.session.handleInput(line).then((verdict) => {
@@ -96,11 +122,11 @@ export function Repl(props) {
96
122
  setOverlay('none');
97
123
  }
98
124
  }, { isActive: overlay === 'help' || overlay === 'roster' });
99
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
125
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
100
126
  // Slug from process.cwd() (full path) so two workspaces with
101
127
  // the same basename do not share history. state.workspaceLabel
102
128
  // is the basename only. Codex review P2.
103
- workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
129
+ workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct })] })] }));
104
130
  }
105
131
  function Header({ state }) {
106
132
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
@@ -114,7 +140,27 @@ function MainArea({ state, personaNames, nowEpochMs, }) {
114
140
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), _jsx(Box, { marginTop: 1, children: _jsx(AgentTree, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
115
141
  }
116
142
  function HelpOverlay() {
117
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: SLASH_COMMAND_HELP.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
143
+ // Group commands by their `group` field so the operator scans the
144
+ // palette by intent (dispatch → session → tools → settings → meta).
145
+ // The α6.14 wave-2 expansion grew the surface from 6 to 20 commands;
146
+ // a flat list would force the operator to read 20 rows top-to-bottom
147
+ // every time. Grouping cuts perceived complexity dramatically.
148
+ const grouped = new Map();
149
+ for (const row of SLASH_COMMAND_HELP) {
150
+ const list = grouped.get(row.group);
151
+ if (list) {
152
+ grouped.set(row.group, [...list, row]);
153
+ }
154
+ else {
155
+ grouped.set(row.group, [row]);
156
+ }
157
+ }
158
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), SLASH_COMMAND_GROUPS.map((group) => {
159
+ const rows = grouped.get(group);
160
+ if (!rows || rows.length === 0)
161
+ return null;
162
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
163
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
118
164
  }
119
165
  function RosterOverlay() {
120
166
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "On-watch roster" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: THE_TEN.map((persona) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: ` ${persona.name.padEnd(10, ' ')}` }), _jsx(Text, { dimColor: true, children: `${persona.role.padEnd(20, ' ')}` }), _jsx(Text, { children: persona.oneLiner })] }, persona.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
@@ -138,6 +184,15 @@ function applyVerdictSideEffects(verdict, handlers) {
138
184
  case 'web':
139
185
  case 'error':
140
186
  case 'noop':
187
+ case 'clear':
188
+ case 'version':
189
+ case 'jobs':
190
+ case 'diff':
191
+ case 'cost':
192
+ case 'status':
193
+ case 'stub':
194
+ // All non-overlay verdicts: the session module already appended
195
+ // any operator-visible system lines. No further UI side effect.
141
196
  return;
142
197
  }
143
198
  }
@@ -3,16 +3,21 @@ import { Box, Text } from 'ink';
3
3
  import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
4
4
  export const PALETTE_ROW_LIMIT = 8;
5
5
  /**
6
- * Compute the visible palette window for a given input buffer.
6
+ * Compute the FULL filtered candidate list for a given input buffer.
7
7
  * Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
8
8
  * logic so the input box and the unit test agree on the shape.
9
9
  *
10
+ * Wave 4 fix 2026-05-25: returns the FULL filtered set, not just the
11
+ * first PALETTE_ROW_LIMIT rows. The palette renderer now windows the
12
+ * visible slice internally based on `focusedIndex`, so the operator
13
+ * can scroll past row 7 via ↑/↓ on a long list (e.g. 20 commands when
14
+ * the buffer is `/`). `totalBeforeLimit` is preserved on the return
15
+ * shape for backward compatibility but always equals `rows.length`.
16
+ *
10
17
  * Behaviour:
11
18
  * - Empty / non-slash buffer → empty result; palette stays hidden.
12
19
  * - `/` alone → all registry rows (the operator wants to browse).
13
20
  * - `/he` → rows whose name starts with `he` (case-insensitive).
14
- * - Capped at PALETTE_ROW_LIMIT; the input box renders a hint when
15
- * `totalBeforeLimit > rows.length`.
16
21
  */
17
22
  export function filterPalette(buffer) {
18
23
  if (!buffer.startsWith('/')) {
@@ -31,9 +36,33 @@ export function filterPalette(buffer) {
31
36
  const all = prefix.length === 0
32
37
  ? SLASH_COMMAND_HELP
33
38
  : SLASH_COMMAND_HELP.filter((row) => row.name.toLowerCase().startsWith(prefix));
39
+ // Defensive copy via spread so callers cannot mutate the registry
40
+ // through the returned readonly array (TS-only enforcement, but
41
+ // future refactors might assume the contract).
42
+ const rows = [...all];
43
+ return {
44
+ rows,
45
+ totalBeforeLimit: rows.length,
46
+ };
47
+ }
48
+ export function computePaletteWindow(rows, focusedIndex) {
49
+ const total = rows.length;
50
+ if (total <= PALETTE_ROW_LIMIT) {
51
+ return { visible: rows, startIndex: 0, total };
52
+ }
53
+ // Sliding window: anchor the start so the focused row stays inside the
54
+ // PALETTE_ROW_LIMIT span. Clamp at both ends so we never render fewer
55
+ // than PALETTE_ROW_LIMIT rows when the list is long enough to fill them.
56
+ let start = focusedIndex - Math.floor(PALETTE_ROW_LIMIT / 2);
57
+ if (start < 0)
58
+ start = 0;
59
+ const maxStart = total - PALETTE_ROW_LIMIT;
60
+ if (start > maxStart)
61
+ start = maxStart;
34
62
  return {
35
- rows: all.slice(0, PALETTE_ROW_LIMIT),
36
- totalBeforeLimit: all.length,
63
+ visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
64
+ startIndex: start,
65
+ total,
37
66
  };
38
67
  }
39
68
  /**
@@ -57,13 +86,21 @@ export function completePalette(buffer, rows, focusedIndex) {
57
86
  export function SlashPalette(props) {
58
87
  if (props.rows.length === 0)
59
88
  return null;
60
- const total = props.totalBeforeLimit ?? props.rows.length;
61
- const overflow = total - props.rows.length;
62
- return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [props.rows.map((row, idx) => {
63
- const focused = idx === props.focusedIndex;
89
+ // Wave 4 fix 2026-05-25: compute the visible window so the operator
90
+ // can scroll past row 7 on long lists. Focus indexes the full rows
91
+ // array; the window slides to keep the focused row visible.
92
+ const window = computePaletteWindow(props.rows, props.focusedIndex);
93
+ const overflow = window.total > PALETTE_ROW_LIMIT;
94
+ // Indicator value: focused row is 1-based for human display
95
+ // ("→ 9/20" reads better than "→ 8/20" when the operator is on
96
+ // the ninth entry).
97
+ const focusedDisplayIndex = Math.min(window.total, Math.max(1, props.focusedIndex + 1));
98
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [window.visible.map((row, visibleIdx) => {
99
+ const absoluteIdx = window.startIndex + visibleIdx;
100
+ const focused = absoluteIdx === props.focusedIndex;
64
101
  const glyph = focused ? '▸' : '·';
65
102
  const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
66
103
  return (_jsxs(Box, { children: [_jsx(Text, { color: focused ? 'cyan' : 'gray', children: `${glyph} ` }), _jsx(Text, { bold: focused, color: focused ? 'cyan' : undefined, dimColor: !focused, children: cmd }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name));
67
- }), overflow > 0 ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` · ${overflow} more (keep typing to narrow)` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
104
+ }), overflow ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` ${focusedDisplayIndex}/${window.total}` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
68
105
  }
69
106
  //# sourceMappingURL=slash-palette.js.map
@@ -13,7 +13,20 @@ export function StatusBar(props) {
13
13
  const phase = clampPhase(props.pulsePhase);
14
14
  const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
15
15
  const status = connectionLabel(props.connection);
16
- return (_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }));
16
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
17
+ }
18
+ /**
19
+ * Render a count badge — number if defined, `—` placeholder otherwise.
20
+ * The placeholder mirrors the splash header convention so the operator
21
+ * recognises "not yet known" vs "zero" at a glance.
22
+ */
23
+ function formatCount(value) {
24
+ return typeof value === 'number' ? value.toString() : '—';
25
+ }
26
+ function formatQuota(pct) {
27
+ if (typeof pct !== 'number' || Number.isNaN(pct))
28
+ return '—';
29
+ return `${Math.round(pct)}%`;
17
30
  }
18
31
  function connectionLabel(connection) {
19
32
  switch (connection) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Workspace-context badges for the REPL bottom status bar (α6.14
3
+ * wave 3). Mirrors the Gemini CLI pattern where the operator sees
4
+ * `N GEMINI.md · N MCP · N skills · N% quota` at a glance.
5
+ *
6
+ * Pure-IO helpers: each function reads disk once, swallows every error
7
+ * (a missing directory is the common case for a fresh workspace), and
8
+ * returns a count. The REPL host calls these at mount, caches the
9
+ * result in component state, and passes them to `<StatusBar />`. We
10
+ * intentionally do NOT refresh on every keystroke — a brand-new
11
+ * PUGI.md does not appear mid-session often enough to warrant a
12
+ * watcher.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, resolve } from 'node:path';
17
+ /**
18
+ * Count PUGI.md files in the workspace root. We do NOT walk
19
+ * subdirectories — a deep grep would burn IO on every REPL boot and
20
+ * the convention is one root file. Mirrors how Gemini CLI counts
21
+ * `GEMINI.md` at the project root only.
22
+ */
23
+ export function countPugiMdFiles(cwd) {
24
+ try {
25
+ const entries = readdirSync(cwd, { withFileTypes: true });
26
+ let count = 0;
27
+ for (const entry of entries) {
28
+ if (!entry.isFile())
29
+ continue;
30
+ // Case-insensitive so PUGI.md, Pugi.md, pugi.md all count.
31
+ if (entry.name.toLowerCase() === 'pugi.md')
32
+ count += 1;
33
+ }
34
+ return count;
35
+ }
36
+ catch {
37
+ return 0;
38
+ }
39
+ }
40
+ /**
41
+ * Count MCP servers wired into `.pugi/mcp.json` at the workspace root.
42
+ * Reads the file, parses JSON, returns the `servers` array length when
43
+ * present. Returns 0 on any failure (file missing, malformed JSON,
44
+ * wrong shape) — the status bar treats 0 and "error" the same.
45
+ */
46
+ export function countMcpServers(cwd) {
47
+ const path = join(cwd, '.pugi', 'mcp.json');
48
+ if (!existsSync(path))
49
+ return 0;
50
+ try {
51
+ const raw = readFileSync(path, 'utf8');
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed && typeof parsed === 'object') {
54
+ const servers = parsed.servers;
55
+ if (Array.isArray(servers))
56
+ return servers.length;
57
+ // Also support the `{ "<name>": { ... } }` map shape used by
58
+ // the Anthropic / Claude Code mcp config convention.
59
+ const entries = Object.keys(parsed);
60
+ return entries.length;
61
+ }
62
+ return 0;
63
+ }
64
+ catch {
65
+ return 0;
66
+ }
67
+ }
68
+ /**
69
+ * Count installed skills across the project-local + user-global
70
+ * directories: `.pugi/skills/` (per-workspace) + `~/.pugi/skills/`
71
+ * (per-machine). Each immediate subdirectory counts as one skill;
72
+ * matches the `skill-creator` convention.
73
+ */
74
+ export function countSkills(cwd, home = homedir()) {
75
+ const projectDir = resolve(cwd, '.pugi', 'skills');
76
+ const userDir = resolve(home, '.pugi', 'skills');
77
+ return countSubdirs(projectDir) + countSubdirs(userDir);
78
+ }
79
+ function countSubdirs(dir) {
80
+ try {
81
+ if (!existsSync(dir))
82
+ return 0;
83
+ const stat = statSync(dir);
84
+ if (!stat.isDirectory())
85
+ return 0;
86
+ const entries = readdirSync(dir, { withFileTypes: true });
87
+ let count = 0;
88
+ for (const entry of entries) {
89
+ if (entry.isDirectory())
90
+ count += 1;
91
+ }
92
+ return count;
93
+ }
94
+ catch {
95
+ return 0;
96
+ }
97
+ }
98
+ export function collectWorkspaceContext(cwd, home = homedir()) {
99
+ return {
100
+ pugiMdCount: countPugiMdFiles(cwd),
101
+ mcpServerCount: countMcpServers(cwd),
102
+ skillCount: countSkills(cwd, home),
103
+ };
104
+ }
105
+ //# sourceMappingURL=workspace-context.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Pugi CLI — terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "undici": "^8.3.0",
48
48
  "zod": "^3.23.0",
49
49
  "@pugi/personas": "0.1.0",
50
- "@pugi/sdk": "0.1.0-alpha.7"
50
+ "@pugi/sdk": "0.1.0-alpha.9"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.0.0",