@pugi/cli 0.1.0-beta.21 → 0.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/core/bare-mode/index.js +107 -0
  2. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  3. package/dist/core/engine/native-pugi.js +21 -10
  4. package/dist/core/engine/prompts.js +30 -2
  5. package/dist/core/engine/tool-bridge.js +32 -0
  6. package/dist/core/feedback/queue.js +177 -0
  7. package/dist/core/feedback/submitter.js +145 -0
  8. package/dist/core/onboarding/marker.js +111 -0
  9. package/dist/core/onboarding/telemetry-state.js +108 -0
  10. package/dist/core/output-style/presets.js +176 -0
  11. package/dist/core/output-style/state.js +185 -0
  12. package/dist/core/permissions/index.js +1 -1
  13. package/dist/core/permissions/state.js +55 -0
  14. package/dist/core/repl/session.js +375 -12
  15. package/dist/core/repl/slash-commands.js +99 -1
  16. package/dist/core/repl/workspace-context.js +22 -0
  17. package/dist/core/share/formatter.js +271 -0
  18. package/dist/core/share/redactor.js +221 -0
  19. package/dist/core/share/uploader.js +267 -0
  20. package/dist/core/todos/invariant.js +10 -0
  21. package/dist/core/todos/state.js +177 -0
  22. package/dist/runtime/cli.js +386 -1
  23. package/dist/runtime/commands/doctor.js +8 -0
  24. package/dist/runtime/commands/feedback.js +184 -0
  25. package/dist/runtime/commands/onboarding.js +275 -0
  26. package/dist/runtime/commands/plan.js +143 -0
  27. package/dist/runtime/commands/share.js +316 -0
  28. package/dist/runtime/commands/stickers.js +82 -0
  29. package/dist/runtime/commands/style.js +194 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tools/registry.js +8 -0
  32. package/dist/tools/todo-write.js +184 -0
  33. package/dist/tui/compact-banner.js +28 -1
  34. package/dist/tui/conversation-pane.js +13 -0
  35. package/dist/tui/feedback-prompt.js +156 -0
  36. package/dist/tui/onboarding-wizard.js +240 -0
  37. package/dist/tui/repl-render.js +9 -1
  38. package/dist/tui/stickers-art.js +136 -0
  39. package/dist/tui/style-table.js +22 -0
  40. package/package.json +2 -2
@@ -390,6 +390,11 @@ export class ReplSession {
390
390
  // admin-api down) is silent - the operator can still type
391
391
  // `/privacy` to see the contract.
392
392
  void this.fetchAndAnnouncePrivacyMode().catch(() => undefined);
393
+ // Leak L21 (2026-05-27): silently drain any feedback envelopes
394
+ // that landed offline during a previous session. Best-effort —
395
+ // a failed flush leaves the queue intact for the next start.
396
+ // Never blocks bootstrap.
397
+ void this.flushFeedbackQueueOnBootstrap().catch(() => undefined);
393
398
  }
394
399
  catch (error) {
395
400
  this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
@@ -433,6 +438,21 @@ export class ReplSession {
433
438
  // Silent fail - offline / DNS / unauth all collapse to no banner.
434
439
  }
435
440
  }
441
+ /**
442
+ * Leak L21 (2026-05-27): on bootstrap, drain the local feedback
443
+ * queue silently. Operators who ran `pugi feedback` while offline
444
+ * see their envelopes flushed on the next online session without
445
+ * any extra command. The drain is best-effort and never blocks
446
+ * the REPL — a failed flush leaves the queue intact for the next
447
+ * bootstrap attempt.
448
+ */
449
+ async flushFeedbackQueueOnBootstrap() {
450
+ const { flushFeedbackQueueSilently } = await import('../../runtime/commands/feedback.js');
451
+ await flushFeedbackQueueSilently(process.cwd(), {
452
+ apiUrl: this.options.apiUrl,
453
+ apiKey: this.options.apiKey,
454
+ });
455
+ }
436
456
  /**
437
457
  * Tear down the SSE stream and stop the reconnect timer. The session
438
458
  * id stays valid server-side; `pugi resume <id>` reopens later.
@@ -768,6 +788,89 @@ export class ReplSession {
768
788
  }
769
789
  return verdict;
770
790
  }
791
+ case 'style': {
792
+ // Leak L18 (2026-05-27): /style [name] [--persist|--reset|--list]
793
+ // forwards to the shared `runStyleCommand` runner so the slash
794
+ // + top-level surfaces share one code path. Dynamic import
795
+ // keeps the dispatcher free of the output-style module graph
796
+ // until the operator first invokes the slash. The runner's
797
+ // exit code is captured but NOT propagated to process.exitCode
798
+ // — REPL session should not die because a bad preset slug was
799
+ // typed in the input box.
800
+ try {
801
+ const { runStyleCommand } = await import('../../runtime/commands/style.js');
802
+ // L18 P1 fix (2026-05-27): writeOutput is invoked SYNCHRONOUSLY
803
+ // by `runStyleCommand` for each emitted block. We buffer every
804
+ // emission into `lines` and flush after the await resolves so
805
+ // that:
806
+ // (1) any future async write path inside the runner cannot
807
+ // drop a tail emission (callback never references the
808
+ // Ink frame directly), and
809
+ // (2) multi-line payloads (e.g. the active-style banner +
810
+ // catalogue table) render one row per visual line in the
811
+ // conversation pane, matching the `/stickers` surface.
812
+ const lines = [];
813
+ await runStyleCommand(verdict.args, {
814
+ workspaceRoot: process.cwd(),
815
+ writeOutput: (_payload, text) => {
816
+ for (const raw of text.split('\n')) {
817
+ const trimmed = raw.replace(/\s+$/u, '');
818
+ lines.push(trimmed);
819
+ }
820
+ },
821
+ });
822
+ if (lines.length === 0) {
823
+ this.appendSystemLine('/style: no output.');
824
+ }
825
+ else {
826
+ for (const line of lines)
827
+ this.appendSystemLine(line);
828
+ }
829
+ }
830
+ catch (error) {
831
+ const message = error instanceof Error ? error.message : String(error);
832
+ this.appendSystemLine(`/style failed: ${message}`);
833
+ }
834
+ return verdict;
835
+ }
836
+ case 'onboarding': {
837
+ // Leak L25 (2026-05-27): /onboarding forwards to the shared
838
+ // `runOnboardingCommand` runner. From inside the REPL we ALWAYS
839
+ // route through the non-interactive snapshot path — the REPL
840
+ // already owns the Ink tree and mounting a second Ink wizard
841
+ // on top would conflict over stdin raw mode. Operators who
842
+ // want the interactive walk exit the REPL and run
843
+ // `pugi onboarding` from a fresh shell; the slash surface
844
+ // surfaces the recap card + hints inline so the operator
845
+ // sees current values without leaving the session.
846
+ try {
847
+ const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
848
+ const { resolveActiveCredential } = await import('../credentials.js');
849
+ const credential = resolveActiveCredential();
850
+ const lines = [];
851
+ await runOnboardingCommand(verdict.args, {
852
+ workspaceRoot: process.cwd(),
853
+ env: process.env,
854
+ authPresent: credential !== null,
855
+ interactive: false,
856
+ writeOutput: (_payload, text) => {
857
+ const trimmed = text.replace(/\n+$/u, '');
858
+ if (trimmed.length > 0)
859
+ lines.push(trimmed);
860
+ },
861
+ });
862
+ for (const line of lines)
863
+ this.appendSystemLine(line);
864
+ if (lines.length === 0) {
865
+ this.appendSystemLine('/onboarding: no output.');
866
+ }
867
+ }
868
+ catch (error) {
869
+ const message = error instanceof Error ? error.message : String(error);
870
+ this.appendSystemLine(`/onboarding failed: ${message}`);
871
+ }
872
+ return verdict;
873
+ }
771
874
  case 'doctor': {
772
875
  // L17 (2026-05-27): run the doctor probe sweep inline. We
773
876
  // dynamic-import the runtime/commands/doctor module so the
@@ -839,12 +942,196 @@ export class ReplSession {
839
942
  await this.dispatchCompact('manual');
840
943
  return verdict;
841
944
  }
945
+ case 'share': {
946
+ // Leak L20 (2026-05-27): /share forwards to the same runner the
947
+ // top-level `pugi share` command uses. The session module
948
+ // wires writeOutput to appendSystemLine so the upload result +
949
+ // privacy gate banner land in the REPL transcript inline.
950
+ // Confirmation prompt + readline still use stdio because the
951
+ // Ink frame is held by the input box; operators wanting fully
952
+ // scripted shares pass `--yes` so no prompt fires.
953
+ try {
954
+ const { runShareCommand } = await import('../../runtime/commands/share.js');
955
+ const lines = [];
956
+ await runShareCommand(verdict.args, {
957
+ workspaceRoot: process.cwd(),
958
+ cliVersion: this.options.cliVersion,
959
+ sessionId: this.localSessionId ?? undefined,
960
+ writeOutput: (_payload, text) => {
961
+ const trimmed = text.replace(/\n+$/u, '');
962
+ if (trimmed.length > 0)
963
+ lines.push(trimmed);
964
+ },
965
+ });
966
+ for (const line of lines)
967
+ this.appendSystemLine(line);
968
+ if (lines.length === 0) {
969
+ this.appendSystemLine('/share: no output.');
970
+ }
971
+ }
972
+ catch (error) {
973
+ const message = error instanceof Error ? error.message : String(error);
974
+ this.appendSystemLine(`/share failed: ${message}`);
975
+ }
976
+ return verdict;
977
+ }
978
+ case 'plan': {
979
+ // Leak L7: handle `/plan [--back | --persist] [<prompt>]`.
980
+ // The session module forwards the mode-switch portion to the
981
+ // shared runtime helper so the workspace + global-config writes
982
+ // share one code path with `pugi plan`. When the operator
983
+ // typed a prompt alongside (`/plan write me X`), the prompt is
984
+ // forwarded through the dispatch FSM exactly as if they had
985
+ // typed it directly — the only difference is the gate now
986
+ // refuses write/dispatch tools because the workspace mode flipped
987
+ // to plan first. Same dynamic-import trick as /permissions to
988
+ // avoid pulling the engine adapter graph into the dispatcher.
989
+ try {
990
+ const { runPlanCommand } = await import('../../runtime/commands/plan.js');
991
+ const lines = [];
992
+ await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
993
+ workspaceRoot: process.cwd(),
994
+ writeOutput: (line) => {
995
+ const trimmed = line.replace(/\n+$/u, '');
996
+ if (trimmed.length > 0)
997
+ lines.push(trimmed);
998
+ },
999
+ });
1000
+ for (const line of lines)
1001
+ this.appendSystemLine(line);
1002
+ // Optional one-shot engine dispatch: when the operator typed
1003
+ // a prompt alongside the slash, route it through the existing
1004
+ // dispatch path. We rewrite the verdict into a synthetic
1005
+ // `dispatch` result so the engine sees the user's prompt with
1006
+ // the plan-mode gate already in place. `--auto-back` is NOT
1007
+ // honoured in the slash surface today — operators stay in
1008
+ // plan mode and revert manually with `/plan --back`. The CLI
1009
+ // top-level `pugi plan --auto-back` exists for scripted use.
1010
+ if (verdict.prompt.length > 0 && !verdict.back) {
1011
+ return { kind: 'dispatch', brief: verdict.prompt };
1012
+ }
1013
+ }
1014
+ catch (error) {
1015
+ const message = error instanceof Error ? error.message : String(error);
1016
+ this.appendSystemLine(`/plan failed: ${message}`);
1017
+ }
1018
+ return verdict;
1019
+ }
1020
+ case 'stickers': {
1021
+ // Leak L33 (2026-05-27): brand-personality gimmick. Delegate to
1022
+ // the shared `runStickersCommand` so the slash + top-level
1023
+ // paths stay single-sourced. The renderer routes the text
1024
+ // through the system pane line-buffer (ascii-only — no fresh
1025
+ // Ink mount) so the gimmick lands as a single contiguous
1026
+ // block в the conversation transcript.
1027
+ try {
1028
+ const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
1029
+ // L33 P1 fix (2026-05-27): await the runner even though the
1030
+ // current implementation is synchronous. Two reasons:
1031
+ // (1) future-proofs the call site against the runner growing
1032
+ // an async path (e.g. remote stickerpack fetch) — without
1033
+ // this await, a returned promise would resolve AFTER we
1034
+ // flushed `lines` and the gimmick would render blank, and
1035
+ // (2) keeps the slash dispatcher uniform with the other
1036
+ // command runners (style, doctor, permissions, plan), all
1037
+ // of which are awaited.
1038
+ const lines = [];
1039
+ await runStickersCommand({
1040
+ json: false,
1041
+ asciiOnly: true,
1042
+ writeOutput: (_payload, text) => {
1043
+ for (const line of text.split('\n')) {
1044
+ const trimmed = line.replace(/\s+$/u, '');
1045
+ lines.push(trimmed);
1046
+ }
1047
+ },
1048
+ });
1049
+ if (lines.length === 0) {
1050
+ this.appendSystemLine('/stickers: no output.');
1051
+ }
1052
+ else {
1053
+ for (const line of lines)
1054
+ this.appendSystemLine(line);
1055
+ }
1056
+ }
1057
+ catch (error) {
1058
+ const message = error instanceof Error ? error.message : String(error);
1059
+ this.appendSystemLine(`/stickers failed: ${message}`);
1060
+ }
1061
+ return verdict;
1062
+ }
1063
+ case 'feedback': {
1064
+ // Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
1065
+ // mounts a fresh Ink tree (renderFeedbackPrompt) outside the
1066
+ // live REPL input box so the operator can step through
1067
+ // category / rating / comment / context / confirm without
1068
+ // interleaving with persona output. The session module owns
1069
+ // the submit + queue wiring so the slash + top-level CLI
1070
+ // surfaces stay single-sourced through `runFeedbackCommand`.
1071
+ try {
1072
+ await this.runFeedbackSlash();
1073
+ }
1074
+ catch (error) {
1075
+ const message = error instanceof Error ? error.message : String(error);
1076
+ this.appendSystemLine(`/feedback failed: ${message}`);
1077
+ }
1078
+ return verdict;
1079
+ }
842
1080
  case 'stub': {
843
1081
  this.appendSystemLine(verdict.message);
844
1082
  return verdict;
845
1083
  }
846
1084
  }
847
1085
  }
1086
+ /**
1087
+ * Leak L21 (2026-05-27): drive the `/feedback` wizard from inside
1088
+ * the REPL. Mounts the Ink prompt, collects the draft, hands it to
1089
+ * `runFeedbackCommand` (which routes to submit-now or
1090
+ * queue-locally), then writes the operator-facing toast to the
1091
+ * conversation system pane.
1092
+ *
1093
+ * The session module owns the wiring (cwd, cliVersion, apiUrl,
1094
+ * apiKey, transcript provider) so the slash + top-level CLI paths
1095
+ * stay single-sourced through `runFeedbackCommand`.
1096
+ */
1097
+ async runFeedbackSlash() {
1098
+ const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
1099
+ const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
1100
+ const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
1101
+ const verdict = await renderFeedbackPrompt();
1102
+ if (verdict.cancelled || !verdict.draft) {
1103
+ this.appendSystemLine('Feedback cancelled. Nothing was sent.');
1104
+ return;
1105
+ }
1106
+ // Build a session-context provider that reads the LAST 5 turns
1107
+ // from the live transcript + applies the redactor. Only invoked
1108
+ // when the operator opted in on step 4.
1109
+ const sessionContextProvider = () => {
1110
+ const last5 = this.state.transcript
1111
+ .filter((row) => row.source !== 'system')
1112
+ .slice(-5)
1113
+ .map((row) => ({
1114
+ role: row.source === 'operator' ? 'user' : 'assistant',
1115
+ text: row.text,
1116
+ }));
1117
+ // The workspace context exposed to the session does not carry
1118
+ // a git branch field today, so we omit `gitBranch` here. When
1119
+ // `ReplWorkspaceContext` gains the field we can forward it via
1120
+ // an extra options entry without changing the redactor contract.
1121
+ return redactSessionContext(last5);
1122
+ };
1123
+ const result = await runFeedbackCommand({
1124
+ cwd: process.cwd(),
1125
+ cliVersion: this.options.cliVersion,
1126
+ submit: async (env) => submitFeedback(env, {
1127
+ apiUrl: this.options.apiUrl,
1128
+ apiKey: this.options.apiKey,
1129
+ }),
1130
+ draft: verdict.draft,
1131
+ sessionContext: sessionContextProvider,
1132
+ });
1133
+ this.appendSystemLine(renderFeedbackToast(result));
1134
+ }
848
1135
  /**
849
1136
  * Leak L8 (2026-05-27): drive the `/compact` flow from inside the
850
1137
  * REPL. Reuses the standalone runner so the wire shape + reason
@@ -874,10 +1161,25 @@ export class ReplSession {
874
1161
  },
875
1162
  });
876
1163
  if (result.status === 'compacted') {
877
- // Echo a visible separator into the transcript so the operator
878
- // immediately sees where the compaction landed. The Ink banner
879
- // renders the row when the session reloads / resumes.
880
- this.appendSystemLine(`─── context compacted (${result.turnsBefore} turns 1 summary, ${trigger}) ───`);
1164
+ // L29 (2026-05-27): emit a structured `compact-boundary` row so
1165
+ // the conversation pane routes the marker through the dedicated
1166
+ // `<CompactBanner />` Ink component (gray, terminal-width
1167
+ // separator) instead of leaking the raw text into a `system`
1168
+ // row. The plain-text body is kept as a deterministic fallback
1169
+ // for non-Ink consumers (snapshot tests, JSON-mode exports).
1170
+ const turnsBefore = result.turnsBefore ?? 0;
1171
+ this.appendRow({
1172
+ source: 'compact-boundary',
1173
+ text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
1174
+ compaction: {
1175
+ turnsBefore,
1176
+ trigger,
1177
+ summaryTokenCount: result.tokensSummarised,
1178
+ // Fresh in-REPL compaction lands at the head of the
1179
+ // transcript — no turns have followed it yet.
1180
+ turnsAgo: 0,
1181
+ },
1182
+ });
881
1183
  }
882
1184
  }
883
1185
  catch (error) {
@@ -2399,13 +2701,14 @@ export class ReplSession {
2399
2701
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
2400
2702
  }
2401
2703
  appendRow(input) {
2402
- if (input.text.length === 0)
2704
+ if (input.text.length === 0 && input.source !== 'compact-boundary')
2403
2705
  return;
2404
2706
  const row = {
2405
2707
  id: randomUUID(),
2406
2708
  source: input.source,
2407
2709
  text: input.text,
2408
2710
  personaSlug: input.personaSlug,
2711
+ compaction: input.compaction,
2409
2712
  timestampEpochMs: this.now(),
2410
2713
  };
2411
2714
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
@@ -2484,6 +2787,15 @@ export class ReplSession {
2484
2787
  persistRow(row) {
2485
2788
  if (!this.store)
2486
2789
  return;
2790
+ // L29 (2026-05-27): `compact-boundary` transcript rows are echoes of
2791
+ // the JSONL `compaction` event the compact runner already appended
2792
+ // via `appendCompactBoundary`. Persisting them here would double-
2793
+ // write the marker (and worse, with a stripped payload that lacks
2794
+ // `summary` / `coversUntilOffset`) — `isCompactBoundary` would
2795
+ // reject the duplicate but `applyCompactMask` would still index off
2796
+ // the wrong offset. Skip the write.
2797
+ if (row.source === 'compact-boundary')
2798
+ return;
2487
2799
  const kind = row.source === 'operator' ? 'user'
2488
2800
  : row.source === 'persona' ? 'persona'
2489
2801
  : 'system';
@@ -2536,6 +2848,13 @@ export class ReplSession {
2536
2848
  if (row)
2537
2849
  rows.push(row);
2538
2850
  }
2851
+ // L29 (2026-05-27): tag each compact-boundary row with the count of
2852
+ // operator + persona turns that landed AFTER it in the replay
2853
+ // window. The banner reads `turnsAgo` to render the "N turns ago"
2854
+ // suffix so a long session that resumes across multiple compactions
2855
+ // stays self-orienting. System rows + sibling boundaries are NOT
2856
+ // counted — they are chrome, not operator-visible turns.
2857
+ annotateBoundaryTurnsAgo(rows);
2539
2858
  // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
2540
2859
  // window math stays consistent post-restore.
2541
2860
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
@@ -2720,26 +3039,70 @@ function eventToTranscriptRow(event) {
2720
3039
  };
2721
3040
  }
2722
3041
  if (event.kind === 'compaction') {
2723
- // Leak L8: render the marker as a system separator line on
2724
- // replay. The full summary text is intentionally NOT inlined here
2725
- // (a 2k-token summary in the transcript would defeat the purpose
2726
- // of compacting); the operator sees the "context compacted"
2727
- // banner and can run `/context` to inspect the marker payload
2728
- // when they want the details.
3042
+ // L8 + L29 (2026-05-27): render the marker as a structured
3043
+ // `compact-boundary` row so the renderer can route it to the
3044
+ // dedicated <CompactBanner /> Ink component. The full summary text
3045
+ // is intentionally NOT inlined here (a 2k-token summary in the
3046
+ // transcript would defeat the purpose of compacting); the operator
3047
+ // sees the "context compacted" banner and can run `/context` to
3048
+ // inspect the marker payload when they want the details. The plain
3049
+ // text fallback stays in place for non-Ink consumers (snapshot
3050
+ // tests, future JSON exports).
2729
3051
  const compactionPayload = (event.payload ?? null);
2730
3052
  const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
2731
3053
  const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
2732
3054
  ? compactionPayload.summaryTurnsBefore
2733
3055
  : 0;
3056
+ const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
3057
+ ? compactionPayload.summaryTokenCount
3058
+ : undefined;
2734
3059
  return {
2735
3060
  id: randomUUID(),
2736
- source: 'system',
3061
+ source: 'compact-boundary',
2737
3062
  text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
3063
+ compaction: {
3064
+ turnsBefore: turns,
3065
+ trigger,
3066
+ summaryTokenCount: tokens,
3067
+ },
2738
3068
  timestampEpochMs: event.t,
2739
3069
  };
2740
3070
  }
2741
3071
  return null;
2742
3072
  }
3073
+ /**
3074
+ * L29 (2026-05-27): walk a chronological transcript window and stamp
3075
+ * every `compact-boundary` row's `compaction.turnsAgo` with the count of
3076
+ * operator + persona rows that land AFTER it. The annotation runs in
3077
+ * place on the array — boundaries earlier in time get larger `turnsAgo`
3078
+ * values, the boundary at the head of the window gets zero. System rows
3079
+ * and sibling boundaries are excluded from the count (they are chrome,
3080
+ * not operator-visible turns).
3081
+ *
3082
+ * Exported so a future spec can lock the contract and so the in-REPL
3083
+ * `/compact` path can reuse the same counter on live appends if it ever
3084
+ * needs to. Pure function (mutates only the input slice).
3085
+ */
3086
+ export function annotateBoundaryTurnsAgo(rows) {
3087
+ let trailingTurns = 0;
3088
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
3089
+ const row = rows[i];
3090
+ if (row.source === 'operator' || row.source === 'persona') {
3091
+ trailingTurns += 1;
3092
+ continue;
3093
+ }
3094
+ if (row.source === 'compact-boundary') {
3095
+ // Re-assign with the live `turnsAgo`. Carry forward the existing
3096
+ // structured payload so we never lose the trigger / token-count
3097
+ // data the renderer needs.
3098
+ const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
3099
+ rows[i] = {
3100
+ ...row,
3101
+ compaction: { ...compaction, turnsAgo: trailingTurns },
3102
+ };
3103
+ }
3104
+ }
3105
+ }
2743
3106
  /**
2744
3107
  * Heuristic: does this text contain Markdown structures that benefit
2745
3108
  * from atomic grouping? Code fences, bullet lists, numbered lists,
@@ -78,14 +78,20 @@ export const SLASH_COMMAND_HELP = Object.freeze([
78
78
  // Settings
79
79
  { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
80
80
  { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
81
- { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass)', group: 'Settings' },
81
+ { name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass) (also: /plan)', group: 'Settings' },
82
+ { name: 'plan', args: '[--back | --persist] [<prompt>]', gloss: 'Switch to plan mode (read-only). Same as /permissions plan, slicker UX.', group: 'Settings' },
82
83
  { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
83
84
  { name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
85
+ { name: 'style', args: '[name] [--persist|--reset|--list]', gloss: 'Output-style preset (default / terse / explanatory / russian-formal / casual)', group: 'Settings' },
86
+ { name: 'onboarding', args: '[--reset|--non-interactive]', gloss: 'First-run wizard — auth / mode / style / MCP / telemetry (leak L25)', group: 'Settings' },
84
87
  { name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
85
88
  // Meta
86
89
  { name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
87
90
  { name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
88
91
  { name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
92
+ { name: 'stickers', args: '', gloss: 'show Pugi brand stickers (gimmick)', group: 'Meta' },
93
+ { name: 'feedback', args: '', gloss: 'file a bug / feature / general comment without leaving the REPL', group: 'Meta' },
94
+ { name: 'share', args: '[--gist|--pugi] [--redact] [--preview]', gloss: 'Export session transcript to gist / pugi.io (leak L20)', group: 'Meta' },
89
95
  { name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
90
96
  ]);
91
97
  /**
@@ -317,6 +323,54 @@ export function parseSlashCommand(input) {
317
323
  // skills.
318
324
  return { kind: 'init' };
319
325
  }
326
+ case 'plan': {
327
+ // Leak L7: `/plan [--back | --persist] [<prompt>]`.
328
+ //
329
+ // Argument grammar (single line, no quoting):
330
+ // /plan -> enter plan mode + banner
331
+ // /plan --back -> restore previous mode
332
+ // /plan --persist -> enter + write global config
333
+ // /plan <prompt...> -> enter + run one-shot engine
334
+ // /plan --auto-back <prompt...> -> enter + run + restore mode
335
+ //
336
+ // The parser pulls the flags off the head of the tail; whatever
337
+ // remains is the prompt. `--back` + a non-empty prompt and
338
+ // `--back` + `--auto-back` are both refused as `error` because
339
+ // they conflict at the verb level.
340
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
341
+ let back = false;
342
+ let persist = false;
343
+ let autoBack = false;
344
+ const promptTokens = [];
345
+ for (const token of tokens) {
346
+ if (token === '--back') {
347
+ back = true;
348
+ }
349
+ else if (token === '--persist') {
350
+ persist = true;
351
+ }
352
+ else if (token === '--auto-back') {
353
+ autoBack = true;
354
+ }
355
+ else {
356
+ promptTokens.push(token);
357
+ }
358
+ }
359
+ const prompt = promptTokens.join(' ');
360
+ if (back && prompt.length > 0) {
361
+ return {
362
+ kind: 'error',
363
+ message: '/plan --back does not accept a prompt; revert first, then dispatch.',
364
+ };
365
+ }
366
+ if (back && autoBack) {
367
+ return {
368
+ kind: 'error',
369
+ message: '/plan --back and --auto-back cannot be combined.',
370
+ };
371
+ }
372
+ return { kind: 'plan', back, persist, autoBack, prompt };
373
+ }
320
374
  case 'mcp': {
321
375
  // β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
322
376
  // Quoting / shell-escapes are NOT supported — the slash surface is
@@ -325,6 +379,26 @@ export function parseSlashCommand(input) {
325
379
  const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
326
380
  return { kind: 'mcp', args: tokens };
327
381
  }
382
+ case 'style':
383
+ case 'output-style': {
384
+ // Leak L18 (2026-05-27): forward the tokenized tail unchanged so
385
+ // the slash + top-level CLI surfaces share one parser inside
386
+ // `runStyleCommand`. Quoting / multi-word args are not used (the
387
+ // preset slugs are single tokens by contract).
388
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
389
+ return { kind: 'style', args: tokens };
390
+ }
391
+ case 'onboarding':
392
+ case 'onboard':
393
+ case 'setup': {
394
+ // Leak L25 (2026-05-27): forward the tokenized tail unchanged.
395
+ // The slash always routes through the non-interactive snapshot
396
+ // path (the REPL already owns the Ink tree); the runner picks
397
+ // it up from `ctx.interactive = false` in the session
398
+ // dispatcher.
399
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
400
+ return { kind: 'onboarding', args: tokens };
401
+ }
328
402
  case 'doctor':
329
403
  case 'health': {
330
404
  // L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
@@ -341,6 +415,30 @@ export function parseSlashCommand(input) {
341
415
  // fresh shell.
342
416
  return { kind: 'compact' };
343
417
  }
418
+ case 'stickers': {
419
+ // Leak L33 (2026-05-27): brand-personality gimmick. Tail args
420
+ // are ignored — the surface is intentionally parameterless. The
421
+ // session module delegates to the shared `runStickersCommand`
422
+ // so the slash + top-level paths stay single-sourced.
423
+ return { kind: 'stickers' };
424
+ }
425
+ case 'feedback': {
426
+ // Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
427
+ // collects category/rating/comment/context/confirm interactively
428
+ // so the slash surface is parameterless. Tail args are reserved
429
+ // for a future `--message=...` quick-path; today they are
430
+ // accepted but ignored so the operator-level UX matches
431
+ // Claude Code's `/feedback`.
432
+ return { kind: 'feedback' };
433
+ }
434
+ case 'share': {
435
+ // Leak L20 (2026-05-27): forward the tokenized arg list verbatim
436
+ // so the session module (which owns the network + readline
437
+ // affordances) can hand them to runShareCommand. Defaults: no
438
+ // tokens means "auto-pick target + prompt for confirmation".
439
+ const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
440
+ return { kind: 'share', args: tokens };
441
+ }
344
442
  case 'memory':
345
443
  case 'config':
346
444
  case 'budget':
@@ -27,6 +27,16 @@
27
27
  import { existsSync, readFileSync, statSync } from 'node:fs';
28
28
  import { basename, resolve as resolvePath } from 'node:path';
29
29
  import { slugForCwd } from './history.js';
30
+ import { isBareMode } from '../bare-mode/index.js';
31
+ /**
32
+ * Workspace summary shown when the operator launched with `--bare` (or
33
+ * `PUGI_BARE=1`). Leak L22: bare mode disables project auto-discovery
34
+ * across the CLI, so we never read `.pugi/PUGI.md` and never advertise
35
+ * a real workspace label to admin-api. Explicit string so the splash +
36
+ * status bar agree, and so operators triaging "why is Mira ignoring
37
+ * my repo" see a clear cause.
38
+ */
39
+ export const BARE_MODE_WORKSPACE_LABEL = '(bare mode - auto-discovery disabled)';
30
40
  /** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
31
41
  const PUGI_MD_HEAD_LIMIT = 200;
32
42
  /**
@@ -48,6 +58,18 @@ export const UNBOUND_WORKSPACE_LABEL = '(not bound - run /init OR cd into projec
48
58
  export function resolveWorkspaceContext(cwd) {
49
59
  const normalised = resolvePath(cwd);
50
60
  const slug = slugForCwd(normalised);
61
+ // Leak L22 (2026-05-27): `--bare` short-circuits BEFORE any PUGI.md
62
+ // / project-marker reads so the resolver never advertises a real
63
+ // workspace summary to admin-api. The cwd + slug still travel for
64
+ // telemetry, but the model + Mira treat the session as if launched
65
+ // from a fresh, unbound directory.
66
+ if (isBareMode()) {
67
+ return {
68
+ workspaceCwd: normalised,
69
+ workspaceSlug: slug,
70
+ workspaceSummary: BARE_MODE_WORKSPACE_LABEL,
71
+ };
72
+ }
51
73
  // α6.14.2 wave 5: when the cwd has no project markers, prefer the
52
74
  // explicit "not bound" summary so admin-api's prompt builder knows
53
75
  // not to fabricate a workspace context for Mira/Pugi. The cwd +