@pugi/cli 0.1.0-beta.46 → 0.1.0-beta.48

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.
@@ -0,0 +1,9 @@
1
+ [?25l 
2
+ 
3
+ ▄▄▄▄▄▄▄▄ 
4
+ ▄▄▄▄▄▄ 
5
+ ▄▄▄▄▄▄ 
6
+ ▄▄ 
7
+ ▄ 
8
+ 
9
+ [?25h
@@ -346,6 +346,93 @@ const BUILD_TEST_PREFIXES = [
346
346
  'tsc -p',
347
347
  'eslint',
348
348
  'prettier --check',
349
+ // P0 fix 2026-05-29 (#37 CRITICAL): customer-blocking gap surfaced
350
+ // during dogfood. Engine emitted `chmod +x build.sh`, `node script.js`,
351
+ // `python3 -m pytest`, `git status`, `pnpm build`, `docker ps`, etc.
352
+ // and the classifier returned `unknown` → permission matrix denied
353
+ // в bypassPermissions mode (which the customer expected to auto-allow
354
+ // basic dev tools). Customers could not run ANY real build/test/git
355
+ // workflow through Pugi.
356
+ //
357
+ // The prefixes below cover three classes of developer tooling that
358
+ // are always allowed in `auto`/`dontAsk`/`bypassPermissions` modes,
359
+ // `ask` in interactive modes, and `deny` in `plan` (read-only) mode:
360
+ // - Language runtimes: `node`, `python`, `python3`, `ruby`, etc.
361
+ // - Native build chains: `gcc`, `clang`, `cmake`, `rustc`, etc.
362
+ // - Container/k8s read-class: `docker ps/inspect/logs`, `kubectl get`.
363
+ //
364
+ // Destructive variants are already gated upstream by DESTRUCTIVE_PATTERNS
365
+ // (e.g. `docker system prune`, `kubectl delete --all`). The first-token
366
+ // gate in classifyComponent runs THIS list before the unknown fallback.
367
+ //
368
+ // Language runtime invocations (first-token match, with or without args).
369
+ 'node',
370
+ 'python',
371
+ 'python3',
372
+ 'ruby',
373
+ 'perl',
374
+ 'php',
375
+ 'deno',
376
+ 'bun',
377
+ 'tsx',
378
+ 'ts-node',
379
+ // Native build chains.
380
+ 'gcc',
381
+ 'g++',
382
+ 'clang',
383
+ 'clang++',
384
+ 'cmake',
385
+ 'rustc',
386
+ 'javac',
387
+ 'java',
388
+ // Container/k8s read-class (the destructive subcommands are pre-empted
389
+ // by DESTRUCTIVE_PATTERNS: `docker system prune`, `kubectl delete --all`,
390
+ // `kubectl delete namespace`).
391
+ 'docker ps',
392
+ 'docker images',
393
+ 'docker inspect',
394
+ 'docker logs',
395
+ 'docker version',
396
+ 'docker info',
397
+ 'docker exec',
398
+ 'docker run',
399
+ 'docker stop',
400
+ 'docker start',
401
+ 'docker restart',
402
+ 'docker rm',
403
+ 'docker rmi',
404
+ 'docker build',
405
+ 'docker tag',
406
+ 'docker compose',
407
+ 'docker-compose',
408
+ 'kubectl get',
409
+ 'kubectl describe',
410
+ 'kubectl logs',
411
+ 'kubectl exec',
412
+ 'kubectl apply',
413
+ 'kubectl create',
414
+ 'kubectl rollout',
415
+ 'kubectl port-forward',
416
+ 'kubectl config',
417
+ // Git read+write surface (network ops already handled by NETWORK_PREFIXES;
418
+ // destructive ops `reset --hard`/`clean -fdx`/`push --force` blocked above).
419
+ // Note: WRITE_WORKSPACE_PREFIXES already covers `git commit/add/checkout/...`.
420
+ // These entries handle plain `git rev-list`, `git cherry-pick`, `git worktree`,
421
+ // `git submodule`, etc that customer scripts commonly invoke.
422
+ 'git rev-list',
423
+ 'git cherry-pick',
424
+ 'git worktree',
425
+ 'git submodule',
426
+ 'git blame',
427
+ 'git describe',
428
+ 'git tag --list',
429
+ 'git tag -l',
430
+ 'git for-each-ref',
431
+ 'git ls-remote',
432
+ // gh CLI (GitHub). `gh repo delete` / `gh release delete` reach into
433
+ // network operations but are non-destructive for the local workspace.
434
+ // Permission matrix asks before allowing in auto.
435
+ 'gh',
349
436
  ];
350
437
  /** Single-token read-only commands. Argument-free entries match exact. */
351
438
  const READ_TOKENS = new Set([
@@ -384,6 +471,16 @@ const READ_TOKENS = new Set([
384
471
  'cut',
385
472
  'sort',
386
473
  'uniq',
474
+ // P0 fix 2026-05-29 (#37 CRITICAL): structured-data inspection tools
475
+ // are pure stdin/stdout transformers (no FS write, no network) when
476
+ // не paired с `>` redirection (the redirection branch above promotes
477
+ // к write_workspace independently). Common в dev scripts for parsing
478
+ // package.json, tsconfig.json, Helm values.yaml, etc.
479
+ // `tee` is INTENTIONALLY excluded — it writes by definition, even
480
+ // в protected paths (`tee /etc/...` is already in DESTRUCTIVE_PATTERNS).
481
+ 'jq',
482
+ 'yq',
483
+ 'column',
387
484
  ]);
388
485
  const READ_PREFIXES = [
389
486
  'git status',
@@ -418,6 +515,16 @@ const WRITE_WORKSPACE_PREFIXES = [
418
515
  'git tag',
419
516
  'git rebase',
420
517
  'git merge',
518
+ // P0 fix 2026-05-29 (#37 CRITICAL): file-permission ops are common
519
+ // в build scripts (`chmod +x build.sh`, `chown $USER file`). The
520
+ // destructive variants (`chmod 777 /`, `chmod -R 777 /`, `chmod -R
521
+ // 777 ~`, `chown -R root /`, `chown -R / ...`) are pre-empted by
522
+ // DESTRUCTIVE_PATTERNS which runs BEFORE this list — safe to add
523
+ // here for the non-destructive path. detectProtectedWrite's `\bchmod\b`
524
+ // / `\bchown\b` regex also catches writes into protected paths
525
+ // regardless of this list.
526
+ 'chmod ',
527
+ 'chown ',
421
528
  ];
422
529
  /**
423
530
  * Protected-write triggers. If a command writes to any of these paths
@@ -636,6 +743,25 @@ function classifyComponent(cmd, ctx) {
636
743
  if (trimmed === 'make' || trimmed.startsWith('make ')) {
637
744
  return { class: 'build_test', reason: 'make runner', matched: 'make' };
638
745
  }
746
+ // 7c. Operator-override safe tokens (P0 fix 2026-05-29 #37).
747
+ // `PUGI_CLASSIFIER_EXTRA_SAFE=tool1,tool2,...` extends the BUILD_TEST
748
+ // first-token list at runtime. This is a security-sensitive escape
749
+ // hatch — operators can add their custom build tools without a
750
+ // recompile. Destructive patterns ALREADY ran above (step 1) so this
751
+ // cannot whitelist `rm`, `mkfs`, `git push --force`, etc. The match
752
+ // is strict first-token equality — not substring — and the env var
753
+ // is read fresh on every classify call so tests can mutate it.
754
+ const extraSafe = readExtraSafeTokens();
755
+ if (extraSafe.size > 0) {
756
+ const firstTokenForExtraSafe = trimmed.split(/\s+/)[0] ?? '';
757
+ if (extraSafe.has(firstTokenForExtraSafe)) {
758
+ return {
759
+ class: 'build_test',
760
+ reason: `PUGI_CLASSIFIER_EXTRA_SAFE override: ${firstTokenForExtraSafe}`,
761
+ matched: firstTokenForExtraSafe,
762
+ };
763
+ }
764
+ }
639
765
  // 7c. Bare `cd <path>` (inside workspace — the cwd-escape detector
640
766
  // upgrades the class to write_protected when the target is
641
767
  // outside). Standalone `cd` (HOME) is escape, also handled by the
@@ -756,6 +882,40 @@ function nestingDepth(cmd, open, close) {
756
882
  function escapeRegex(s) {
757
883
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
758
884
  }
885
+ /**
886
+ * Operator-override safe tokens. Read from `PUGI_CLASSIFIER_EXTRA_SAFE`
887
+ * (comma-separated). Allows operators to extend the BUILD_TEST first-
888
+ * token list at runtime for site-specific tooling без recompile.
889
+ *
890
+ * Security note: destructive substring patterns run BEFORE this gate
891
+ * (step 1 in classifyComponent), so this cannot whitelist `rm`, `mkfs`,
892
+ * `git push --force`, etc. The env var only adds tools to the benign
893
+ * build_test class. Invalid entries (empty strings, тokens containing
894
+ * shell metas) are silently dropped to avoid surprising classifications.
895
+ *
896
+ * Read fresh on every call so per-test mutations work и so operators
897
+ * can update without restarting the agent loop. The cost (one env var
898
+ * read + Set construction per call) is negligible for the classifier's
899
+ * call frequency.
900
+ */
901
+ function readExtraSafeTokens() {
902
+ const raw = process.env.PUGI_CLASSIFIER_EXTRA_SAFE;
903
+ if (!raw || raw.trim() === '')
904
+ return new Set();
905
+ const tokens = new Set();
906
+ for (const candidate of raw.split(',')) {
907
+ const trimmed = candidate.trim();
908
+ if (trimmed === '')
909
+ continue;
910
+ // Reject anything containing shell metas or whitespace — only bare
911
+ // tool names allowed. Defends against accidental
912
+ // `PUGI_CLASSIFIER_EXTRA_SAFE='rm -rf /'` smuggling.
913
+ if (/[\s;|&<>$`(){}\[\]'"\\]/.test(trimmed))
914
+ continue;
915
+ tokens.add(trimmed);
916
+ }
917
+ return tokens;
918
+ }
759
919
  function detectProtectedWrite(cmd, ctx) {
760
920
  // Surface every write target this command produces so we can both
761
921
  // protected-path-check and outside-workspace-check them uniformly.
@@ -32,7 +32,6 @@ import { runStyleCommand } from './commands/style.js';
32
32
  import { runThemeCommand } from './commands/theme.js';
33
33
  import { runOnboardingCommand } from './commands/onboarding.js';
34
34
  import { runVimCommand } from './commands/vim.js';
35
- import { isOnboarded } from '../core/onboarding/marker.js';
36
35
  import { ensureInitialized as ensureInitializedHelper } from '../core/onboarding/ensure-initialized.js';
37
36
  import { ensureAuthenticated as ensureAuthenticatedHelper } from '../core/auth/ensure-authenticated.js';
38
37
  import { runPrivacyCommand } from './commands/privacy.js';
@@ -1140,19 +1139,22 @@ export async function runCli(argv) {
1140
1139
  process.exitCode = exitCode;
1141
1140
  return;
1142
1141
  }
1143
- // Leak L25 (2026-05-27): first-run hint. When the operator types a
1144
- // bare `pugi` on a real TTY AND the onboarding marker is absent, drop
1145
- // a one-line hint on stderr BEFORE the REPL splash mounts. Stderr so
1146
- // the line never lands in a `--json` envelope or a scripted stdout
1147
- // pipe; suppressed when --json is set or the operator already walked
1148
- // the wizard. The marker check is best-effort a fs glitch returns
1149
- // false and we print the hint, which is harmless.
1150
- if (isBareInvocation
1151
- && isInteractive(flags)
1152
- && !flags.json
1153
- && !isOnboarded(process.env)) {
1154
- process.stderr.write('Tip: run `pugi onboarding` to configure defaults.\n');
1155
- }
1142
+ // CEO P0 escalation #2 (2026-05-29) boot polish.
1143
+ //
1144
+ // Leak L25 (2026-05-27) used к drop a one-line stderr hint
1145
+ // ("Tip: run `pugi onboarding` to configure defaults.") BEFORE the
1146
+ // REPL mounted. Combined with beta.46's surfaced "(Y/n)" prompt the
1147
+ // boot read as a noisy three-line vault door instead of Claude
1148
+ // Code's silent, friendly welcome card. The new `WelcomeBanner` in
1149
+ // the REPL surfaces the "/init" tip inline (right column) so this
1150
+ // pre-Ink stderr write is redundant — suppress it on the bare REPL
1151
+ // path. The hint stays available for any future non-REPL bare entry
1152
+ // (no current callers, but keep the helper for symmetry).
1153
+ //
1154
+ // Leaving the suppression here as a hard branch (no env override)
1155
+ // because the welcome banner is the authoritative surface — a stray
1156
+ // stderr line above the alt-screen flicker would race against the
1157
+ // banner paint on slow terminals.
1156
1158
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
1157
1159
  // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
1158
1160
  // that brings Pugi to parity with Claude Code / Codex CLI. When the
@@ -1168,25 +1170,32 @@ export async function runCli(argv) {
1168
1170
  // Propagating via env keeps the session module transport-free.
1169
1171
  if (flags.allowFetch)
1170
1172
  process.env.PUGI_ALLOW_FETCH = '1';
1171
- // CEO P0 (2026-05-28): auto-init pre-flight on the bare REPL
1172
- // boot path. PR #628 wired `runAutoInitPreflight` into every
1173
- // engine command (`pugi code/fix/build/...`) but the bare
1174
- // `pugi` REPL entry boots straight into the workspace label
1175
- // resolver which surfaces the "(not bound — run /init OR cd
1176
- // into project)" banner without ever asking the operator
1177
- // whether к initialise the workspace. CEO escalation: closed
1178
- // multiple times wrong; the operator hits the banner and walks
1179
- // away thinking Pugi is broken. We use the REPL-tuned variant
1180
- // that NEVER throws `n`, `--bare`, `--no-init`, non-TTY all
1181
- // fall through к the legacy "not bound" banner so the existing
1182
- // contract (booting REPL still works without `.pugi/`) holds.
1183
- // The prompt fires ONLY on the happy path: interactive TTY,
1184
- // no `.pugi/`, no opt-out flag. Wrapped in a defensive try/
1185
- // catch the scaffold may legitimately fail (read-only fs,
1186
- // permission denied) but the REPL still needs to boot so the
1187
- // operator gets a usable surface к diagnose the failure.
1173
+ // CEO P0 escalation #2 (2026-05-29) — silent auto-init.
1174
+ //
1175
+ // PR #628 + the first P0 fix wired `runReplAutoInitPreflight`
1176
+ // into the bare REPL boot path. That helper PROMPTED
1177
+ // "Initialize a new Pugi workspace here? (Y/n)" before mounting
1178
+ // Ink, which on a TTY in a fresh dir read as a noisy gate the
1179
+ // operator had to consciously accept just к see Pugi. The CEO
1180
+ // dogfood transcript called this out as visually broken vs
1181
+ // Claude Code, which silently inits and surfaces "/init to
1182
+ // create CLAUDE.md" as an inline tip in the welcome banner.
1183
+ //
1184
+ // The fix swaps the prompt variant for `runReplSilentInitPreflight`
1185
+ // which scaffolds inline без prompt on the happy path
1186
+ // (interactive TTY, project root, no opt-out flag). The
1187
+ // welcome banner (rendered by Ink immediately after) surfaces
1188
+ // a one-line "Initialised Pugi workspace" toast so the
1189
+ // operator still sees the side-effect just без a Y/N stop.
1190
+ //
1191
+ // Opt-outs (`--bare`, `--no-init`, `PUGI_BARE`, `PUGI_NO_AUTO_INIT`)
1192
+ // and non-TTY fall-through preserved verbatim from the prompt
1193
+ // variant. Wrapped in try/catch — a scaffold failure (read-only
1194
+ // fs, perms) still lets the REPL boot so the operator can
1195
+ // diagnose.
1196
+ let silentInitOutcome = null;
1188
1197
  try {
1189
- await runReplAutoInitPreflight(process.cwd(), flags);
1198
+ silentInitOutcome = await runReplSilentInitPreflight(process.cwd(), flags);
1190
1199
  }
1191
1200
  catch (error) {
1192
1201
  // Surface the scaffold error on stderr but proceed to mount
@@ -1225,6 +1234,11 @@ export async function runCli(argv) {
1225
1234
  updateBanner,
1226
1235
  skipSplash: flags.noSplash,
1227
1236
  hideToolStream: flags.noToolStream,
1237
+ // CEO P0 #2 (2026-05-29): forward the silent-init outcome so
1238
+ // the welcome banner can surface a one-line toast on the
1239
+ // "initialized" branch ("Pugi workspace initialised at .pugi/.")
1240
+ // and skip the toast on the "already" / "declined" branches.
1241
+ autoInitStatus: silentInitOutcome?.status ?? null,
1228
1242
  });
1229
1243
  return;
1230
1244
  }
@@ -6520,6 +6534,51 @@ export async function runReplAutoInitPreflight(root, flags, overrides = {}) {
6520
6534
  }),
6521
6535
  });
6522
6536
  }
6537
+ export async function runReplSilentInitPreflight(root, flags, overrides = {}) {
6538
+ const interactive = overrides.interactive ?? isInteractive(flags);
6539
+ return ensureInitializedHelper({
6540
+ cwd: root,
6541
+ interactive,
6542
+ skip: flags.noInit
6543
+ || flags.bare
6544
+ || process.env.PUGI_NO_AUTO_INIT === '1'
6545
+ || process.env.PUGI_BARE === '1',
6546
+ // CC-style silent init: the prompt callback returns 'y' immediately
6547
+ // so the helper proceeds straight to scaffold. The helper's
6548
+ // contract treats empty / 'y' / 'yes' as the default-Y answer, so
6549
+ // returning 'y' is the canonical way to drive the happy path
6550
+ // without surfacing the (Y/n) string on stderr. The
6551
+ // `write` callback is silenced (no-op) so the helper does not
6552
+ // print the legacy "No Pugi workspace found at ..." line either —
6553
+ // the welcome banner surfaces the post-scaffold success toast
6554
+ // instead. Note: the helper's `write` ALSO swallows the
6555
+ // "Initialization declined" footer, but that branch never fires
6556
+ // here because our prompt always returns 'y'.
6557
+ prompt: async () => 'y',
6558
+ write: () => {
6559
+ /* silent — banner owns the operator-visible signal */
6560
+ },
6561
+ scaffold: overrides.scaffold
6562
+ ?? (async (input) => {
6563
+ // CEO P0 #2 (2026-05-29): forward а no-op `log` callback so
6564
+ // the default-skills installer ("[pugi init] installed default
6565
+ // skill …") does not leak к stderr above the Ink welcome
6566
+ // banner. The welcome banner already surfaces the one-line
6567
+ // "Pugi workspace initialised at .pugi/." toast — the per-
6568
+ // skill detail is noise on the cold-start path и kills the
6569
+ // CC-style silent boot the operator expects. The non-silent
6570
+ // `pugi init` command remains noisy by passing the default
6571
+ // stderr writer.
6572
+ await scaffoldPugiWorkspace({
6573
+ cwd: input.cwd,
6574
+ noDefaults: flags.noDefaults,
6575
+ log: () => {
6576
+ /* silent — banner owns the operator-visible signal */
6577
+ },
6578
+ });
6579
+ }),
6580
+ });
6581
+ }
6523
6582
  /**
6524
6583
  * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6525
6584
  * `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.46');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.48');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -83,6 +83,13 @@ export function InputBox(props) {
83
83
  const [history, setHistory] = useState(seededHistory);
84
84
  const [historyIndex, setHistoryIndex] = useState(-1);
85
85
  const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
86
+ // CEO P0 #2 (2026-05-29): Claude Code parity — surface а visible
87
+ // "Press Ctrl+C again to exit" toast on the first Ctrl+C press so
88
+ // the operator knows the second press will terminate the REPL.
89
+ // Auto-clears after CTRL_C_DOUBLE_TAP_MS so it never lingers past
90
+ // the double-tap window.
91
+ const [ctrlCToast, setCtrlCToast] = useState(null);
92
+ const ctrlCToastTimerRef = useRef(null);
86
93
  // Wave 6 BT 8: Esc-Esc walkback double-tap window. Tracks the epoch
87
94
  // ms of the most recent Esc press so the next Esc within
88
95
  // ESCAPE_DOUBLE_TAP_MS triggers the walkback handler instead of
@@ -191,6 +198,28 @@ export function InputBox(props) {
191
198
  return;
192
199
  }
193
200
  setLastCtrlCAt(t);
201
+ // CEO P0 #2 (2026-05-29): surface the "Press Ctrl+C again to
202
+ // exit" toast on the first press so the operator sees the
203
+ // double-tap semantics in the UI, not just в the bottom hint
204
+ // line. Mirrors Claude Code's exit affordance verbatim. The
205
+ // toast string varies by which branch fired (cancel vs idle
206
+ // clear) so the operator learns what the press just did:
207
+ //
208
+ // - cancelResult === true → "Aborted. Press Ctrl+C again to exit."
209
+ // - cancelResult === false → "Press Ctrl+C again to exit."
210
+ //
211
+ // (The undefined branch already returned above — а modal owns
212
+ // input и the toast is suppressed.)
213
+ const toastCopy = cancelResult === true
214
+ ? 'Aborted. Press Ctrl+C again to exit.'
215
+ : 'Press Ctrl+C again to exit.';
216
+ setCtrlCToast(toastCopy);
217
+ if (ctrlCToastTimerRef.current)
218
+ clearTimeout(ctrlCToastTimerRef.current);
219
+ ctrlCToastTimerRef.current = setTimeout(() => {
220
+ setCtrlCToast(null);
221
+ ctrlCToastTimerRef.current = null;
222
+ }, CTRL_C_DOUBLE_TAP_MS);
194
223
  // Legacy behaviour: on idle (or no onCancel wired), clear the
195
224
  // buffer + reset search so the operator's screen is calm before
196
225
  // they confirm exit. When we DID cancel a live dispatch, keep
@@ -685,7 +714,7 @@ export function InputBox(props) {
685
714
  : Math.min(paletteIndex, paletteView.rows.length - 1);
686
715
  const divider = '─'.repeat(innerWidth);
687
716
  const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
688
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", 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: "#3da9fc", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), modeCycleToast ? (_jsx(Box, { children: _jsx(Text, { color: "#3da9fc", bold: true, children: ` ${modeCycleToast}` }) })) : null, 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 · Shift+Tab mode · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
717
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", 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: "#3da9fc", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), modeCycleToast ? (_jsx(Box, { children: _jsx(Text, { color: "#3da9fc", bold: true, children: ` ${modeCycleToast}` }) })) : null, ctrlCToast ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", bold: true, children: ` ${ctrlCToast}` }) })) : null, 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 · Shift+Tab mode · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
689
718
  }
690
719
  /**
691
720
  * Render the line with the cursor glyph inserted at `cursor`. The cursor
@@ -22,6 +22,7 @@ import React from 'react';
22
22
  import { render } from 'ink';
23
23
  import { Repl } from './repl.js';
24
24
  import { printPugMascotPreInk } from './repl-splash-mascot.js';
25
+ import { collectWelcomeData } from './welcome-data.js';
25
26
  import { ThemeProvider } from '../core/theme/context.js';
26
27
  import { resolveTheme } from '../core/theme/state.js';
27
28
  import { ReplSession, } from '../core/repl/session.js';
@@ -196,12 +197,31 @@ export async function renderRepl(options) {
196
197
  workspaceRoot: process.cwd(),
197
198
  env: process.env,
198
199
  });
200
+ // CEO P0 #2 (2026-05-29): collect welcome banner data BEFORE Ink
201
+ // mounts so the banner paints on the first frame instead of swapping
202
+ // in mid-render. The collector swallows every IO error so а missing
203
+ // CHANGELOG / unreadable credential / malformed settings never
204
+ // blocks the boot.
205
+ let welcomeData;
206
+ if (options.skipSplash !== true) {
207
+ try {
208
+ welcomeData = collectWelcomeData({
209
+ cliVersion: options.cliVersion,
210
+ cwd: process.cwd(),
211
+ });
212
+ }
213
+ catch {
214
+ welcomeData = undefined;
215
+ }
216
+ }
199
217
  const instance = render(React.createElement(ThemeProvider, { slug: resolvedTheme.slug }, React.createElement(Repl, {
200
218
  session,
201
219
  updateBanner: options.updateBanner ?? null,
202
220
  skipSplash: options.skipSplash === true,
203
221
  hideToolStream: options.hideToolStream === true,
204
222
  mascotPrePrinted,
223
+ welcomeData,
224
+ autoInitStatus: options.autoInitStatus ?? null,
205
225
  })));
206
226
  // Make sure we leave the alt screen on abrupt exits too. Without
207
227
  // this the operator's shell stays "frozen" on the Pugi splash.
@@ -48,9 +48,21 @@ import { fileURLToPath } from 'node:url';
48
48
  * — two directory hops up from this file. In a local `pnpm dev`
49
49
  * checkout the structure is the same (`src/tui/` ⇒ `../../assets/`)
50
50
  * because tsx re-resolves the same relative tree.
51
+ *
52
+ * CEO P0 #2 (2026-05-29) — banner mascot bake. The prozr2 portrait is
53
+ * the canonical brand-pug glyph from `apps/console-web/public/brand/
54
+ * Pugi-prozr2.png`, baked к а 16x8 vhalf truecolor render. We prefer
55
+ * the prozr2 bake when it ships alongside the CLI (small — ~900 bytes,
56
+ * shaped for the compact welcome-banner left column) and fall back к
57
+ * the legacy 40KB `pugi-mascot.ansi` (hero-pug 80x40) when prozr2 is
58
+ * missing — preserves the splash-mascot install surface for any
59
+ * tarball that predates the bake.
51
60
  */
52
61
  export function pugMascotAssetPath() {
53
62
  const here = dirname(fileURLToPath(import.meta.url));
63
+ const prozr2 = resolvePath(here, '..', '..', 'assets', 'pugi-prozr2-mascot.ansi');
64
+ if (existsSync(prozr2))
65
+ return prozr2;
54
66
  return resolvePath(here, '..', '..', 'assets', 'pugi-mascot.ansi');
55
67
  }
56
68
  /**
package/dist/tui/repl.js CHANGED
@@ -26,8 +26,10 @@ import { ConversationPane } from './conversation-pane.js';
26
26
  import { InputBox } from './input-box.js';
27
27
  import { ReplSplash } from './repl-splash.js';
28
28
  import { StatusBar } from './status-bar.js';
29
+ import { ThinkingSpinner } from './thinking-spinner.js';
29
30
  import { ToolStreamPane } from './tool-stream-pane.js';
30
31
  import { UpdateBanner } from './update-banner.js';
32
+ import { WelcomeBanner } from './welcome-banner.js';
31
33
  import { collectWorkspaceContext } from './workspace-context.js';
32
34
  import { useTheme } from '../core/theme/context.js';
33
35
  import { slugForCwd } from '../core/repl/history.js';
@@ -57,6 +59,12 @@ export function Repl(props) {
57
59
  // Tenant block crowding the top.
58
60
  const [splashVisible, setSplashVisible] = useState(false);
59
61
  const dismissSplash = useCallback(() => setSplashVisible(false), []);
62
+ // CEO P0 #2 (2026-05-29): CC-style welcome banner. Visible from boot
63
+ // until the operator submits the first brief OR the session emits
64
+ // its first agent event. The host owns dismissal lifecycle (kept
65
+ // symmetric with the splash) so the welcome card never lingers
66
+ // behind а live transcript.
67
+ const [welcomeVisible, setWelcomeVisible] = useState(Boolean(props.welcomeData));
60
68
  // α6.14 wave 3: workspace context snapshot for the status bar. We
61
69
  // read once at mount and freeze; a brand-new PUGI.md or skill is
62
70
  // surfaced on the next REPL boot rather than via a watcher.
@@ -100,6 +108,17 @@ export function Repl(props) {
100
108
  setSplashVisible(false);
101
109
  }
102
110
  }, [splashVisible, state.agents.length, state.transcript.length]);
111
+ // CEO P0 #2 (2026-05-29): same dismissal contract for the welcome
112
+ // banner. The banner is the "boot card" — once any agent fires or
113
+ // the transcript gains а row, the banner clears так the conversation
114
+ // pane owns the vertical real estate.
115
+ useEffect(() => {
116
+ if (!welcomeVisible)
117
+ return;
118
+ if (state.agents.length > 0 || state.transcript.length > 0) {
119
+ setWelcomeVisible(false);
120
+ }
121
+ }, [welcomeVisible, state.agents.length, state.transcript.length]);
103
122
  const personaNames = useMemo(() => buildPersonaNameMap(), []);
104
123
  const { exit } = useApp();
105
124
  const handleSubmit = useCallback((line) => {
@@ -107,6 +126,10 @@ export function Repl(props) {
107
126
  // `setSplashVisible(false)` is a no-op once the state already
108
127
  // settled to false (timer fired or `agent.spawned` arrived).
109
128
  setSplashVisible(false);
129
+ // CEO P0 #2 (2026-05-29): same dismissal for the welcome banner
130
+ // — the operator engaging the input box is the cleanest signal
131
+ // they have finished reading the boot card.
132
+ setWelcomeVisible(false);
110
133
  // Run async without awaiting - the session module owns the
111
134
  // network call, errors land in the transcript automatically.
112
135
  void props.session.handleInput(line).then((verdict) => {
@@ -232,11 +255,11 @@ export function Repl(props) {
232
255
  // input, and the input stays the sole focusable surface adjacent
233
256
  // to the cursor row, so all keystrokes route through it.
234
257
  const altScreenRows = process.stdout.rows ?? 24;
235
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, 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, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
258
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, welcomeVisible && props.welcomeData ? (_jsx(WelcomeBanner, { data: props.welcomeData, mascotPrePrinted: props.mascotPrePrinted === true, autoInitStatus: props.autoInitStatus ?? null })) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
236
259
  // Slug from process.cwd() (full path) so two workspaces with
237
260
  // the same basename do not share history. state.workspaceLabel
238
261
  // is the basename only. Codex review P2.
239
- 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, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
262
+ workspaceSlug: slugForCwd(process.cwd()) })), _jsx(ThinkingSpinner, { dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel }), _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, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
240
263
  // α7 cost-meter sprint — surface accumulated session totals
241
264
  // + per-turn delta flash on the status bar's top row. The
242
265
  // session module owns accumulation; the bar is a pure render.
@@ -0,0 +1,123 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Thinking spinner — animated dispatch indicator (CEO P0 #2, 2026-05-29).
4
+ *
5
+ * Replaces the static `dispatching` / `tool: <name>` strings the bottom
6
+ * status row used к paint during active brief dispatch with а Claude-
7
+ * Code-style rotating verb + glyph pair:
8
+ *
9
+ * ✽ Briefing… (awaiting_response, before the first tool call)
10
+ * ◆ Dispatching… (awaiting_response, после the first tool call)
11
+ * ◇ Reviewing… (tool_running)
12
+ * ✦ Synthesizing… (awaiting_response, late in turn)
13
+ * ❋ Shipping… (tool_running, edit/write/build family tool)
14
+ *
15
+ * The rotation cadence is ~800 ms per step so the verb feels alive but
16
+ * never strobes. The component owns its own timer (cleared on unmount)
17
+ * и takes the active dispatch state + the optional tool label so the
18
+ * "Shipping…" branch can light up specifically для write-class tools.
19
+ *
20
+ * Mount lifecycle: the REPL renders `<ThinkingSpinner />` only while
21
+ * `dispatchState` ∈ {`awaiting_response`, `tool_running`}. The
22
+ * status-bar's legacy `composeStatusLabel` row stays mounted too — they
23
+ * are not mutually exclusive — but the spinner row sits above the
24
+ * status row так the operator's eye lands on the live signal first.
25
+ *
26
+ * Brand discipline:
27
+ * - Verbs from the approved power-words list: `Briefing`,
28
+ * `Dispatching`, `Reviewing`, `Synthesizing`, `Shipping`. No
29
+ * `Thinking…` (forbidden cool word per DESIGN.md §3.2 voice gate).
30
+ * - Glyphs from the Pugi mascot brand glyph kit (`✽ ◆ ◇ ✦ ❋`). No
31
+ * emoji — the spinner must paint in а narrow Bash / WSL terminal
32
+ * где emoji fall back к `?`.
33
+ * - Cyan accent (`#3da9fc`) on the glyph; verb stays default-tone so
34
+ * the rotation does not compete with the cost-meter row above.
35
+ * - Trailing ellipsis is а single Unicode `…` (DESIGN.md §4 — three-
36
+ * dot ellipsis is the ONLY allowed truncation glyph).
37
+ *
38
+ * Test surface: `tickSpinnerFrame` is exported so the spec can drive
39
+ * the frame index without а real-clock timer.
40
+ */
41
+ import { useEffect, useState } from 'react';
42
+ import { Box, Text } from 'ink';
43
+ /* ------------------------------------------------------------------ */
44
+ /* Constants */
45
+ /* ------------------------------------------------------------------ */
46
+ const ACCENT = '#3da9fc';
47
+ const FRAME_INTERVAL_MS = 800;
48
+ const FRAMES = [
49
+ { glyph: '✽', verb: 'Briefing' },
50
+ { glyph: '◆', verb: 'Dispatching' },
51
+ { glyph: '◇', verb: 'Reviewing' },
52
+ { glyph: '✦', verb: 'Synthesizing' },
53
+ { glyph: '❋', verb: 'Shipping' },
54
+ ];
55
+ /**
56
+ * Tool labels that anchor к the `Shipping…` frame regardless of
57
+ * rotation index. These are the write-class tools — the operator sees
58
+ * "shipping" the moment а real file mutation fires так the indicator
59
+ * matches the visible filesystem effect.
60
+ */
61
+ const SHIPPING_TOOLS = new Set([
62
+ 'edit',
63
+ 'write',
64
+ 'build',
65
+ 'multi_edit',
66
+ 'apply_patch',
67
+ ]);
68
+ /* ------------------------------------------------------------------ */
69
+ /* Helpers */
70
+ /* ------------------------------------------------------------------ */
71
+ /**
72
+ * Compute the visible frame для the current tick. The base index is
73
+ * derived from а monotonically-incrementing counter (one increment per
74
+ * `FRAME_INTERVAL_MS` window), и а ship-class tool label overrides the
75
+ * base so write-tools light up `Shipping…` immediately.
76
+ *
77
+ * Exported for the spec so the frame selection is testable without
78
+ * mounting Ink.
79
+ */
80
+ export function tickSpinnerFrame(baseIndex, toolLabel) {
81
+ if (toolLabel && SHIPPING_TOOLS.has(toolLabel.toLowerCase())) {
82
+ return FRAMES[FRAMES.length - 1] ?? FRAMES[0];
83
+ }
84
+ const wrapped = ((baseIndex % FRAMES.length) + FRAMES.length) % FRAMES.length;
85
+ return FRAMES[wrapped] ?? FRAMES[0];
86
+ }
87
+ /**
88
+ * Decide whether the spinner should be visible for а dispatch state.
89
+ * `awaiting_response` и `tool_running` are the only active states; all
90
+ * other states (`idle`, `aborting`, `aborted`, `completed`, `failed`)
91
+ * suppress the spinner так the operator does not see а phantom verb
92
+ * after the dispatch settles.
93
+ */
94
+ export function isSpinnerActive(dispatchState) {
95
+ return dispatchState === 'awaiting_response' || dispatchState === 'tool_running';
96
+ }
97
+ /* ------------------------------------------------------------------ */
98
+ /* Component */
99
+ /* ------------------------------------------------------------------ */
100
+ export function ThinkingSpinner(props) {
101
+ const active = isSpinnerActive(props.dispatchState);
102
+ const [frameIndex, setFrameIndex] = useState(0);
103
+ const intervalMs = props.frameIntervalMs ?? FRAME_INTERVAL_MS;
104
+ useEffect(() => {
105
+ if (!active) {
106
+ // Reset так the next dispatch starts on `Briefing` instead of
107
+ // mid-rotation. Without this the spinner would resume one verb
108
+ // later on every turn и quickly drift to `Synthesizing` even на
109
+ // а fresh brief.
110
+ setFrameIndex(0);
111
+ return;
112
+ }
113
+ const timer = setInterval(() => {
114
+ setFrameIndex((previous) => (previous + 1) % FRAMES.length);
115
+ }, intervalMs);
116
+ return () => clearInterval(timer);
117
+ }, [active, intervalMs]);
118
+ if (!active)
119
+ return null;
120
+ const frame = tickSpinnerFrame(frameIndex, props.dispatchToolLabel ?? null);
121
+ return (_jsxs(Box, { children: [_jsx(Text, { color: ACCENT, children: `${frame.glyph} ` }), _jsx(Text, { children: `${frame.verb}…` })] }));
122
+ }
123
+ //# sourceMappingURL=thinking-spinner.js.map
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { PUG_MASCOT, PUG_MASCOT_CYAN_MASK, PUG_MASCOT_MAX_WIDTH, } from './repl-splash-art.js';
4
+ /* ------------------------------------------------------------------ */
5
+ /* Layout constants */
6
+ /* ------------------------------------------------------------------ */
7
+ /** Default left-column width when the parent box doesn't pin one. */
8
+ const LEFT_COLUMN_WIDTH = 44;
9
+ /** Default right-column width. */
10
+ const RIGHT_COLUMN_WIDTH = 44;
11
+ const ACCENT = '#3da9fc';
12
+ const BULLET = '·';
13
+ /* ------------------------------------------------------------------ */
14
+ /* Component */
15
+ /* ------------------------------------------------------------------ */
16
+ export function WelcomeBanner(props) {
17
+ const { data } = props;
18
+ const showHandCraftedMascot = props.mascotPrePrinted !== true;
19
+ const initToast = props.autoInitStatus === 'initialized'
20
+ ? 'Pugi workspace initialised at .pugi/.'
21
+ : null;
22
+ const accountLine = formatAccountLine(data);
23
+ const modelLine = formatModelLine(data);
24
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { borderStyle: "round", borderColor: ACCENT, paddingX: 1, flexDirection: "row", children: [_jsx(LeftColumn, { greetingName: data.greetingName, cwd: data.cwd, modelLine: modelLine, accountLine: accountLine, cliVersion: data.cliVersion, showHandCraftedMascot: showHandCraftedMascot, initToast: initToast }), _jsx(Box, { width: 2 }), _jsx(RightColumn, { whatsNew: data.whatsNew })] }) }));
25
+ }
26
+ /* ------------------------------------------------------------------ */
27
+ /* Left column — greeting + mascot + status */
28
+ /* ------------------------------------------------------------------ */
29
+ function LeftColumn({ greetingName, cwd, modelLine, accountLine, cliVersion, showHandCraftedMascot, initToast, }) {
30
+ return (_jsxs(Box, { flexDirection: "column", width: LEFT_COLUMN_WIDTH, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: ACCENT, children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${cliVersion}` })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: `Welcome back ${greetingName}!` }) }), showHandCraftedMascot ? (_jsx(Box, { marginTop: 1, children: _jsx(MascotColumn, {}) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { children: modelLine }), _jsx(Text, { dimColor: true, children: accountLine }), _jsx(Text, { dimColor: true, children: truncatePath(cwd, LEFT_COLUMN_WIDTH - 2) })] }), initToast ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: ACCENT, children: initToast }) })) : null] }));
31
+ }
32
+ /* ------------------------------------------------------------------ */
33
+ /* Right column — tips + what's new */
34
+ /* ------------------------------------------------------------------ */
35
+ function RightColumn({ whatsNew, }) {
36
+ return (_jsxs(Box, { flexDirection: "column", width: RIGHT_COLUMN_WIDTH, children: [_jsx(Text, { dimColor: true, children: "Tips for getting started" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(TipRow, { text: "Run /init to scaffold PUGI.md instructions" }), _jsx(TipRow, { text: "Brief Pugi \u2014 the workforce dispatches" }), _jsx(TipRow, { text: "Triple-review gate before push: /review --triple" }), _jsx(TipRow, { text: "/help for every slash command" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(RIGHT_COLUMN_WIDTH - 2, 18)) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "What's new" }) }), _jsx(Box, { flexDirection: "column", children: whatsNew.length > 0 ? (whatsNew.map((line, index) => (_jsx(TipRow, { text: line }, `whatsnew-${index}`)))) : (_jsx(Text, { dimColor: true, children: ` ${BULLET} /release-notes for the full changelog` })) })] }));
37
+ }
38
+ function TipRow({ text }) {
39
+ return (_jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: ` ${BULLET} ` }), _jsx(Text, { children: text })] }));
40
+ }
41
+ /* ------------------------------------------------------------------ */
42
+ /* Mascot column (hand-crafted ASCII fallback path) */
43
+ /* ------------------------------------------------------------------ */
44
+ function MascotColumn() {
45
+ 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] ?? [] }, `mascot-row-${rowIndex}`))) }));
46
+ }
47
+ function MascotRow({ row, mask, }) {
48
+ // Split into contiguous same-color runs so we emit one <Text> per
49
+ // run instead of one per character (keeps the Ink tree shallow и
50
+ // the snapshot diff readable).
51
+ const runs = [];
52
+ let buffer = '';
53
+ let bufferCyan = false;
54
+ for (let column = 0; column < row.length; column += 1) {
55
+ const ch = row.charAt(column);
56
+ const cyan = mask[column] === true;
57
+ if (buffer.length === 0) {
58
+ buffer = ch;
59
+ bufferCyan = cyan;
60
+ continue;
61
+ }
62
+ if (cyan === bufferCyan) {
63
+ buffer += ch;
64
+ }
65
+ else {
66
+ runs.push({ text: buffer, cyan: bufferCyan });
67
+ buffer = ch;
68
+ bufferCyan = cyan;
69
+ }
70
+ }
71
+ if (buffer.length > 0)
72
+ runs.push({ text: buffer, cyan: bufferCyan });
73
+ return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: ACCENT, children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
74
+ }
75
+ /* ------------------------------------------------------------------ */
76
+ /* Formatters */
77
+ /* ------------------------------------------------------------------ */
78
+ function formatModelLine(data) {
79
+ const tierLabel = data.plan ? ` ${BULLET} ${capitalize(data.plan)} Tier` : '';
80
+ return `${data.model}${tierLabel}`;
81
+ }
82
+ function formatAccountLine(data) {
83
+ if (!data.email) {
84
+ return 'Anonymous (run /login to authenticate)';
85
+ }
86
+ const tenant = data.tenant ? ` ${BULLET} ${data.tenant} Org` : '';
87
+ return `${data.email}${tenant}`;
88
+ }
89
+ function capitalize(value) {
90
+ if (value.length === 0)
91
+ return value;
92
+ return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
93
+ }
94
+ /**
95
+ * Trim the cwd from the LEFT (preserving the basename) so the operator
96
+ * always sees which project they're in even when the column is narrow.
97
+ * Mirrors the Claude Code boot card convention: long paths get an
98
+ * ellipsis on the head, never on the tail.
99
+ */
100
+ export function truncatePath(path, max) {
101
+ if (path.length <= max)
102
+ return path;
103
+ if (max <= 3)
104
+ return path.slice(-max);
105
+ return `…${path.slice(-(max - 1))}`;
106
+ }
107
+ //# sourceMappingURL=welcome-banner.js.map
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Pure data layer for the `<WelcomeBanner />` component (CEO P0 #2,
3
+ * 2026-05-29). Lives in its own module so it can be unit-tested without
4
+ * rendering Ink, and so the banner component itself contains zero IO.
5
+ *
6
+ * The banner is the CC-style 2-column boxed greeting that replaces the
7
+ * α6.14 wave-3 `<ReplSplash />` on the bare REPL boot path. It mirrors
8
+ * Claude Code's boot layout:
9
+ *
10
+ * ╭────── Pugi v0.1.0-beta.46 ──────╮
11
+ * │ │ Tips for getting started
12
+ * │ Welcome back Yurii! │ Run /init to create PUGI.md...
13
+ * │ │ ───────────
14
+ * │ ▗ ▗ ▖ ▖ │ What's new
15
+ * │ ▘▘ ▝▝ │ * 0.1.0-beta.26 — Wave 6 RAG ...
16
+ * │ Sonnet 4.6 (1M context) · Founder
17
+ * │ yuriy.bulah@gmail.com · pugi-io Org
18
+ * │ /Volumes/T9/Web/.../TestRepos2 │
19
+ * ╰──────────────────────────────────╯
20
+ *
21
+ * Data sources, in priority order:
22
+ *
23
+ * - Account email + tenant + plan: JWT principal decoded from the
24
+ * active credential. Falls back to anonymous label when no
25
+ * credential is on disk (operator boots `pugi` before login).
26
+ * - Greeting first-name: best-effort split of the local part of the
27
+ * email. Falls back к "operator" when unauthenticated.
28
+ * - Model: env override `PUGI_ENGINE_MODEL_CODE`, then the operator's
29
+ * workspace settings `defaultModel`, then "Sonnet 4.6 (1M context)"
30
+ * as the locked α7 default.
31
+ * - Cwd: absolute path of `process.cwd()` — banner left column shows
32
+ * the full path so the operator confirms они are в the right repo.
33
+ * - What's new: top 3 release-note titles from `apps/pugi-cli/CHANGELOG.md`
34
+ * newer than `~/.pugi/.last-seen-version`. Falls back к the top 3
35
+ * overall when last-seen marker is missing. Each entry is a one-line
36
+ * headline ("0.1.0-beta.26 — Wave 6 RAG consumer middleware") trimmed
37
+ * к 60 chars so the right column does not wrap on a 100-col terminal.
38
+ *
39
+ * Every resolver swallows its IO error and returns the documented
40
+ * fallback so a partial environment (missing CHANGELOG, malformed JWT,
41
+ * unreadable settings) never blocks the banner.
42
+ */
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+ import { homedir } from 'node:os';
45
+ import { dirname, resolve as resolvePath } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { DEFAULT_API_URL, normalizeApiUrl, readCredentialsFile, resolveActiveCredential, } from '../core/credentials.js';
48
+ import { parseChangelog } from '../core/release-notes/parser.js';
49
+ function decodeJwtPayload(token) {
50
+ try {
51
+ const parts = token.split('.');
52
+ if (parts.length < 2)
53
+ return null;
54
+ const payload = parts[1];
55
+ if (!payload)
56
+ return null;
57
+ const padded = payload
58
+ .replace(/-/g, '+')
59
+ .replace(/_/g, '/')
60
+ .padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
61
+ const json = Buffer.from(padded, 'base64').toString('utf8');
62
+ const obj = JSON.parse(json);
63
+ if (!obj || typeof obj !== 'object')
64
+ return null;
65
+ return {
66
+ ...(typeof obj.sub === 'string' && { sub: obj.sub }),
67
+ ...(typeof obj.email === 'string' && { email: obj.email }),
68
+ ...(typeof obj.customerId === 'string' && { customerId: obj.customerId }),
69
+ ...(typeof obj.plan === 'string' && { plan: obj.plan }),
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /* ------------------------------------------------------------------ */
77
+ /* Resolvers */
78
+ /* ------------------------------------------------------------------ */
79
+ /**
80
+ * Derive a greeting first-name from an email's local part. The split
81
+ * is intentionally aggressive: dot-separated, hyphen-separated, and
82
+ * digit-stripped so `yuriy.bulah@gmail.com` resolves к "Yuriy" instead
83
+ * of "yuriy.bulah". Title-cases the first segment. Falls back к
84
+ * "operator" when no email is available.
85
+ *
86
+ * Export so the spec can lock the heuristic against edge cases (numeric
87
+ * local part, plus-tag aliases, single-letter local part).
88
+ */
89
+ export function deriveGreetingName(email) {
90
+ if (!email || typeof email !== 'string')
91
+ return 'operator';
92
+ const at = email.indexOf('@');
93
+ if (at <= 0)
94
+ return 'operator';
95
+ const local = email.slice(0, at);
96
+ // Strip plus-tag aliases (`name+tag@host` → `name`).
97
+ const noTag = local.split('+')[0] ?? local;
98
+ // First dot / hyphen / underscore segment wins; this is the
99
+ // colloquial "given name" surface for the vast majority of work
100
+ // emails ("first.last@..." / "first-last@...").
101
+ const firstSegment = noTag.split(/[._-]/)[0] ?? noTag;
102
+ // Strip trailing digits some signup flows append (`yurii2@`) so the
103
+ // greeting reads as a name, not a username.
104
+ const stripped = firstSegment.replace(/[0-9]+$/u, '');
105
+ if (stripped.length === 0)
106
+ return 'operator';
107
+ return stripped.charAt(0).toUpperCase() + stripped.slice(1);
108
+ }
109
+ /**
110
+ * Resolve the model display string. Priority:
111
+ *
112
+ * 1. `PUGI_ENGINE_MODEL_CODE` env override (operator-set in shell).
113
+ * 2. `.pugi/settings.json` → `defaultModel` field (per-workspace).
114
+ * 3. Locked α7 default `"Sonnet 4.6 (1M context)"`.
115
+ *
116
+ * Returns the value verbatim — the banner is responsible for trimming
117
+ * if the string is too long for the column.
118
+ */
119
+ export function resolveModelDisplay(env, settingsOverride) {
120
+ const envModel = env.PUGI_ENGINE_MODEL_CODE;
121
+ if (typeof envModel === 'string' && envModel.length > 0)
122
+ return envModel;
123
+ const settingsModel = settingsOverride?.defaultModel;
124
+ if (typeof settingsModel === 'string' && settingsModel.length > 0) {
125
+ return settingsModel;
126
+ }
127
+ return 'Sonnet 4.6 (1M context)';
128
+ }
129
+ /**
130
+ * Locate the bundled CHANGELOG.md. The CLI ships к
131
+ * `node_modules/@pugi/cli/dist/tui/welcome-data.js` so the changelog
132
+ * lives at `node_modules/@pugi/cli/CHANGELOG.md` — two directory hops
133
+ * up from this file. In a local pnpm dev checkout the structure is the
134
+ * same (`src/tui/` ⇒ `../../CHANGELOG.md`) because tsx re-resolves the
135
+ * same relative tree.
136
+ */
137
+ function defaultChangelogPath() {
138
+ const here = dirname(fileURLToPath(import.meta.url));
139
+ return resolvePath(here, '..', '..', 'CHANGELOG.md');
140
+ }
141
+ /**
142
+ * Read top-3 "what's new" headlines from the CLI CHANGELOG.md. Each
143
+ * headline is `<version> — <first non-empty section body line>` trimmed
144
+ * к 60 chars so the right column does not wrap on a 100-col terminal.
145
+ * Returns an empty array when the file is missing / unparseable.
146
+ *
147
+ * The body-line heuristic walks к the first non-empty, non-section-
148
+ * header line below the version header. For Keep-a-Changelog entries
149
+ * the second line is usually `### Added` / `### Fixed`; we skip those
150
+ * headers и land on the first bullet, which is the operator-visible
151
+ * one-liner ("- L30 `pugi theme` ...").
152
+ */
153
+ export function readWhatsNew(changelogPath) {
154
+ const path = changelogPath ?? defaultChangelogPath();
155
+ try {
156
+ if (!existsSync(path))
157
+ return [];
158
+ const raw = readFileSync(path, 'utf8');
159
+ if (!raw || raw.length === 0)
160
+ return [];
161
+ const sections = parseChangelog(raw);
162
+ const headlines = [];
163
+ for (const section of sections) {
164
+ if (headlines.length >= 3)
165
+ break;
166
+ // Skip the "[Unreleased]" / "[Unreleased] - YYYY-MM-DD" section —
167
+ // the banner is meant к surface SHIPPED notes only. The parser
168
+ // captures Unreleased identically к а tagged version, so we
169
+ // filter here.
170
+ if (/^unreleased$/i.test(section.version))
171
+ continue;
172
+ const firstBullet = pickFirstBullet(section.body);
173
+ if (!firstBullet)
174
+ continue;
175
+ const headline = `${section.version} — ${firstBullet}`;
176
+ headlines.push(truncate(headline, 60));
177
+ }
178
+ return headlines;
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
184
+ /**
185
+ * Pull the first bullet text out of a Keep-a-Changelog section body.
186
+ * Skips `### <subsection>` headers, blank lines, и non-bullet prose
187
+ * (release-note footers like "### Notes"). Returns undefined when no
188
+ * bullet is present.
189
+ *
190
+ * Body lines arrive verbatim from the parser; bullets follow the
191
+ * `- ` / `* ` Markdown convention. We strip the leading bullet glyph
192
+ * + the first whitespace run so the rendered string is the bullet
193
+ * content only.
194
+ */
195
+ function pickFirstBullet(body) {
196
+ const lines = body.split('\n');
197
+ for (const line of lines) {
198
+ const trimmed = line.trim();
199
+ if (trimmed.length === 0)
200
+ continue;
201
+ if (trimmed.startsWith('### '))
202
+ continue;
203
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
204
+ // Strip leading bullet glyph + ws and collapse internal MD ticks.
205
+ return trimmed
206
+ .slice(2)
207
+ .replace(/`/g, '')
208
+ .replace(/\*\*/g, '')
209
+ .trim();
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+ function truncate(text, max) {
215
+ if (text.length <= max)
216
+ return text;
217
+ // Reserve 1 char for the ellipsis so we land on EXACTLY `max`
218
+ // visible chars including the dot. Single Unicode ellipsis would be
219
+ // narrower but some terminals render it as а full-width glyph in
220
+ // CJK locales — ASCII three-dot stays predictable.
221
+ return `${text.slice(0, max - 1).trimEnd()}…`;
222
+ }
223
+ /**
224
+ * Resolve the active credential and decode the JWT principal. Returns
225
+ * the email / tenant / plan triple when authenticated, null fields when
226
+ * anonymous. Encapsulates the credential + JWT IO so the spec can drive
227
+ * the resolver via an env override.
228
+ */
229
+ function resolvePrincipal(env, home) {
230
+ const credential = resolveActiveCredential(env, home);
231
+ if (!credential) {
232
+ const file = readCredentialsFile(home);
233
+ return {
234
+ apiUrl: normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL),
235
+ };
236
+ }
237
+ const principal = decodeJwtPayload(credential.apiKey);
238
+ return {
239
+ apiUrl: credential.apiUrl,
240
+ ...(principal?.email && { email: principal.email }),
241
+ ...(principal?.customerId && { tenant: principal.customerId }),
242
+ ...(principal?.plan && { plan: principal.plan }),
243
+ };
244
+ }
245
+ /**
246
+ * Best-effort read of `.pugi/settings.json`. Returns null when the file
247
+ * is missing / unreadable / malformed so the model resolver can fall
248
+ * back to the env override + locked default.
249
+ */
250
+ function readSettingsBlob(cwd) {
251
+ try {
252
+ const path = resolvePath(cwd, '.pugi', 'settings.json');
253
+ if (!existsSync(path))
254
+ return null;
255
+ const raw = readFileSync(path, 'utf8');
256
+ if (!raw || raw.length === 0)
257
+ return null;
258
+ const obj = JSON.parse(raw);
259
+ if (typeof obj?.defaultModel === 'string') {
260
+ return { defaultModel: obj.defaultModel };
261
+ }
262
+ return {};
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ /* ------------------------------------------------------------------ */
269
+ /* Entry point */
270
+ /* ------------------------------------------------------------------ */
271
+ export function collectWelcomeData(input) {
272
+ const env = input.env ?? process.env;
273
+ const home = input.home ?? homedir();
274
+ const cwd = input.cwd ?? process.cwd();
275
+ const principal = resolvePrincipal(env, home);
276
+ const settings = input.settingsOverride !== undefined
277
+ ? input.settingsOverride
278
+ : readSettingsBlob(cwd);
279
+ const greetingName = deriveGreetingName(principal.email);
280
+ const model = resolveModelDisplay(env, settings);
281
+ const whatsNew = readWhatsNew(input.changelogPath);
282
+ return {
283
+ greetingName,
284
+ ...(principal.email && { email: principal.email }),
285
+ ...(principal.tenant && { tenant: principal.tenant }),
286
+ ...(principal.plan && { plan: principal.plan }),
287
+ model,
288
+ cwd,
289
+ cliVersion: input.cliVersion,
290
+ whatsNew,
291
+ };
292
+ }
293
+ //# sourceMappingURL=welcome-data.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.46",
3
+ "version": "0.1.0-beta.48",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.46"
58
+ "@pugi/sdk": "0.1.0-beta.48"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",