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

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 (63) hide show
  1. package/dist/core/auth/env-provider.js +238 -0
  2. package/dist/core/bare-mode/index.js +107 -0
  3. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  4. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  5. package/dist/core/engine/native-pugi.js +55 -11
  6. package/dist/core/engine/prompts.js +30 -2
  7. package/dist/core/engine/tool-bridge.js +32 -0
  8. package/dist/core/feedback/queue.js +177 -0
  9. package/dist/core/feedback/submitter.js +145 -0
  10. package/dist/core/onboarding/marker.js +111 -0
  11. package/dist/core/onboarding/telemetry-state.js +108 -0
  12. package/dist/core/output-style/presets.js +176 -0
  13. package/dist/core/output-style/state.js +185 -0
  14. package/dist/core/permissions/index.js +1 -1
  15. package/dist/core/permissions/state.js +55 -0
  16. package/dist/core/pugi-md/context-injector.js +76 -0
  17. package/dist/core/pugi-md/walk-up.js +207 -0
  18. package/dist/core/release-notes/parser.js +241 -0
  19. package/dist/core/release-notes/state.js +116 -0
  20. package/dist/core/repl/session.js +482 -12
  21. package/dist/core/repl/slash-commands.js +134 -1
  22. package/dist/core/repl/workspace-context.js +22 -0
  23. package/dist/core/share/formatter.js +271 -0
  24. package/dist/core/share/redactor.js +221 -0
  25. package/dist/core/share/uploader.js +267 -0
  26. package/dist/core/theme/context.js +91 -0
  27. package/dist/core/theme/presets.js +228 -0
  28. package/dist/core/theme/state.js +181 -0
  29. package/dist/core/todos/invariant.js +10 -0
  30. package/dist/core/todos/state.js +177 -0
  31. package/dist/core/vim/keymap.js +288 -0
  32. package/dist/core/vim/state.js +92 -0
  33. package/dist/runtime/cli.js +603 -15
  34. package/dist/runtime/commands/doctor.js +21 -0
  35. package/dist/runtime/commands/feedback.js +184 -0
  36. package/dist/runtime/commands/onboarding.js +275 -0
  37. package/dist/runtime/commands/plan.js +143 -0
  38. package/dist/runtime/commands/release-notes.js +229 -0
  39. package/dist/runtime/commands/share.js +316 -0
  40. package/dist/runtime/commands/stickers.js +82 -0
  41. package/dist/runtime/commands/style.js +194 -0
  42. package/dist/runtime/commands/theme.js +196 -0
  43. package/dist/runtime/commands/vim.js +140 -0
  44. package/dist/runtime/version.js +1 -1
  45. package/dist/tools/registry.js +8 -0
  46. package/dist/tools/todo-write.js +184 -0
  47. package/dist/tui/compact-banner.js +28 -1
  48. package/dist/tui/conversation-pane.js +13 -0
  49. package/dist/tui/doctor-table.js +32 -17
  50. package/dist/tui/feedback-prompt.js +156 -0
  51. package/dist/tui/onboarding-wizard.js +240 -0
  52. package/dist/tui/repl-render.js +26 -3
  53. package/dist/tui/repl.js +9 -1
  54. package/dist/tui/stickers-art.js +136 -0
  55. package/dist/tui/style-table.js +28 -0
  56. package/dist/tui/theme-table.js +29 -0
  57. package/dist/tui/vim-input.js +267 -0
  58. package/package.json +2 -2
  59. package/dist/core/engine/compaction-hook.js +0 -154
  60. package/dist/core/init/scaffold.js +0 -195
  61. package/dist/core/repl/codebase-survey.js +0 -308
  62. package/dist/core/repl/init-interview.js +0 -457
  63. package/dist/core/repl/onboarding-state.js +0 -297
@@ -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,161 @@ export class ReplSession {
768
788
  }
769
789
  return verdict;
770
790
  }
791
+ case 'theme': {
792
+ // Leak L30 (2026-05-27): /theme [name] [--persist|--reset|--list]
793
+ // forwards to the shared `runThemeCommand` runner. Same async
794
+ // buffer-then-flush pattern as `/style` so a future async
795
+ // write path inside the runner cannot drop a tail emission
796
+ // and so multi-line payloads (banner + preview table) land
797
+ // one row per visual line in the conversation pane.
798
+ try {
799
+ const { runThemeCommand } = await import('../../runtime/commands/theme.js');
800
+ const lines = [];
801
+ await runThemeCommand(verdict.args, {
802
+ workspaceRoot: process.cwd(),
803
+ writeOutput: (_payload, text) => {
804
+ for (const raw of text.split('\n')) {
805
+ const trimmed = raw.replace(/\s+$/u, '');
806
+ lines.push(trimmed);
807
+ }
808
+ },
809
+ });
810
+ if (lines.length === 0) {
811
+ this.appendSystemLine('/theme: no output.');
812
+ }
813
+ else {
814
+ for (const line of lines)
815
+ this.appendSystemLine(line);
816
+ }
817
+ }
818
+ catch (error) {
819
+ const message = error instanceof Error ? error.message : String(error);
820
+ this.appendSystemLine(`/theme failed: ${message}`);
821
+ }
822
+ return verdict;
823
+ }
824
+ case 'style': {
825
+ // Leak L18 (2026-05-27): /style [name] [--persist|--reset|--list]
826
+ // forwards to the shared `runStyleCommand` runner so the slash
827
+ // + top-level surfaces share one code path. Dynamic import
828
+ // keeps the dispatcher free of the output-style module graph
829
+ // until the operator first invokes the slash. The runner's
830
+ // exit code is captured but NOT propagated to process.exitCode
831
+ // — REPL session should not die because a bad preset slug was
832
+ // typed in the input box.
833
+ try {
834
+ const { runStyleCommand } = await import('../../runtime/commands/style.js');
835
+ // L18 P1 fix (2026-05-27): writeOutput is invoked SYNCHRONOUSLY
836
+ // by `runStyleCommand` for each emitted block. We buffer every
837
+ // emission into `lines` and flush after the await resolves so
838
+ // that:
839
+ // (1) any future async write path inside the runner cannot
840
+ // drop a tail emission (callback never references the
841
+ // Ink frame directly), and
842
+ // (2) multi-line payloads (e.g. the active-style banner +
843
+ // catalogue table) render one row per visual line in the
844
+ // conversation pane, matching the `/stickers` surface.
845
+ const lines = [];
846
+ await runStyleCommand(verdict.args, {
847
+ workspaceRoot: process.cwd(),
848
+ writeOutput: (_payload, text) => {
849
+ for (const raw of text.split('\n')) {
850
+ const trimmed = raw.replace(/\s+$/u, '');
851
+ lines.push(trimmed);
852
+ }
853
+ },
854
+ });
855
+ if (lines.length === 0) {
856
+ this.appendSystemLine('/style: no output.');
857
+ }
858
+ else {
859
+ for (const line of lines)
860
+ this.appendSystemLine(line);
861
+ }
862
+ }
863
+ catch (error) {
864
+ const message = error instanceof Error ? error.message : String(error);
865
+ this.appendSystemLine(`/style failed: ${message}`);
866
+ }
867
+ return verdict;
868
+ }
869
+ case 'onboarding': {
870
+ // Leak L25 (2026-05-27): /onboarding forwards to the shared
871
+ // `runOnboardingCommand` runner. From inside the REPL we ALWAYS
872
+ // route through the non-interactive snapshot path — the REPL
873
+ // already owns the Ink tree and mounting a second Ink wizard
874
+ // on top would conflict over stdin raw mode. Operators who
875
+ // want the interactive walk exit the REPL and run
876
+ // `pugi onboarding` from a fresh shell; the slash surface
877
+ // surfaces the recap card + hints inline so the operator
878
+ // sees current values without leaving the session.
879
+ try {
880
+ const { runOnboardingCommand } = await import('../../runtime/commands/onboarding.js');
881
+ const { resolveActiveCredential } = await import('../credentials.js');
882
+ const credential = resolveActiveCredential();
883
+ const lines = [];
884
+ await runOnboardingCommand(verdict.args, {
885
+ workspaceRoot: process.cwd(),
886
+ env: process.env,
887
+ authPresent: credential !== null,
888
+ interactive: false,
889
+ writeOutput: (_payload, text) => {
890
+ const trimmed = text.replace(/\n+$/u, '');
891
+ if (trimmed.length > 0)
892
+ lines.push(trimmed);
893
+ },
894
+ });
895
+ for (const line of lines)
896
+ this.appendSystemLine(line);
897
+ if (lines.length === 0) {
898
+ this.appendSystemLine('/onboarding: no output.');
899
+ }
900
+ }
901
+ catch (error) {
902
+ const message = error instanceof Error ? error.message : String(error);
903
+ this.appendSystemLine(`/onboarding failed: ${message}`);
904
+ }
905
+ return verdict;
906
+ }
907
+ case 'vim': {
908
+ // Leak L26 (2026-05-27): /vim forwards to the shared
909
+ // `runVimCommand` runner so the slash + top-level surfaces
910
+ // stay single-sourced. Dynamic import mirrors /style so the
911
+ // dispatcher does not drag the vim module graph into every
912
+ // keystroke.
913
+ //
914
+ // The runner mutates `~/.pugi/config.json::vimMode`; the
915
+ // active REPL session does NOT live-pick-up the flip (the
916
+ // VimInput wrapper is mounted once at REPL boot). Operators
917
+ // get a hint that the next session will reflect the change.
918
+ // A follow-up sprint can plumb a state-store subscriber so
919
+ // the flip takes effect mid-session.
920
+ try {
921
+ const { runVimCommand } = await import('../../runtime/commands/vim.js');
922
+ const lines = [];
923
+ await runVimCommand(verdict.args, {
924
+ env: process.env,
925
+ writeOutput: (_payload, text) => {
926
+ for (const raw of text.split('\n')) {
927
+ const trimmed = raw.replace(/\s+$/u, '');
928
+ lines.push(trimmed);
929
+ }
930
+ },
931
+ });
932
+ if (lines.length === 0) {
933
+ this.appendSystemLine('/vim: no output.');
934
+ }
935
+ else {
936
+ for (const line of lines)
937
+ this.appendSystemLine(line);
938
+ }
939
+ }
940
+ catch (error) {
941
+ const message = error instanceof Error ? error.message : String(error);
942
+ this.appendSystemLine(`/vim failed: ${message}`);
943
+ }
944
+ return verdict;
945
+ }
771
946
  case 'doctor': {
772
947
  // L17 (2026-05-27): run the doctor probe sweep inline. We
773
948
  // dynamic-import the runtime/commands/doctor module so the
@@ -839,12 +1014,231 @@ export class ReplSession {
839
1014
  await this.dispatchCompact('manual');
840
1015
  return verdict;
841
1016
  }
1017
+ case 'share': {
1018
+ // Leak L20 (2026-05-27): /share forwards to the same runner the
1019
+ // top-level `pugi share` command uses. The session module
1020
+ // wires writeOutput to appendSystemLine so the upload result +
1021
+ // privacy gate banner land in the REPL transcript inline.
1022
+ // Confirmation prompt + readline still use stdio because the
1023
+ // Ink frame is held by the input box; operators wanting fully
1024
+ // scripted shares pass `--yes` so no prompt fires.
1025
+ try {
1026
+ const { runShareCommand } = await import('../../runtime/commands/share.js');
1027
+ const lines = [];
1028
+ await runShareCommand(verdict.args, {
1029
+ workspaceRoot: process.cwd(),
1030
+ cliVersion: this.options.cliVersion,
1031
+ sessionId: this.localSessionId ?? undefined,
1032
+ writeOutput: (_payload, text) => {
1033
+ const trimmed = text.replace(/\n+$/u, '');
1034
+ if (trimmed.length > 0)
1035
+ lines.push(trimmed);
1036
+ },
1037
+ });
1038
+ for (const line of lines)
1039
+ this.appendSystemLine(line);
1040
+ if (lines.length === 0) {
1041
+ this.appendSystemLine('/share: no output.');
1042
+ }
1043
+ }
1044
+ catch (error) {
1045
+ const message = error instanceof Error ? error.message : String(error);
1046
+ this.appendSystemLine(`/share failed: ${message}`);
1047
+ }
1048
+ return verdict;
1049
+ }
1050
+ case 'plan': {
1051
+ // Leak L7: handle `/plan [--back | --persist] [<prompt>]`.
1052
+ // The session module forwards the mode-switch portion to the
1053
+ // shared runtime helper so the workspace + global-config writes
1054
+ // share one code path with `pugi plan`. When the operator
1055
+ // typed a prompt alongside (`/plan write me X`), the prompt is
1056
+ // forwarded through the dispatch FSM exactly as if they had
1057
+ // typed it directly — the only difference is the gate now
1058
+ // refuses write/dispatch tools because the workspace mode flipped
1059
+ // to plan first. Same dynamic-import trick as /permissions to
1060
+ // avoid pulling the engine adapter graph into the dispatcher.
1061
+ try {
1062
+ const { runPlanCommand } = await import('../../runtime/commands/plan.js');
1063
+ const lines = [];
1064
+ await runPlanCommand({ back: verdict.back, persist: verdict.persist }, {
1065
+ workspaceRoot: process.cwd(),
1066
+ writeOutput: (line) => {
1067
+ const trimmed = line.replace(/\n+$/u, '');
1068
+ if (trimmed.length > 0)
1069
+ lines.push(trimmed);
1070
+ },
1071
+ });
1072
+ for (const line of lines)
1073
+ this.appendSystemLine(line);
1074
+ // Optional one-shot engine dispatch: when the operator typed
1075
+ // a prompt alongside the slash, route it through the existing
1076
+ // dispatch path. We rewrite the verdict into a synthetic
1077
+ // `dispatch` result so the engine sees the user's prompt with
1078
+ // the plan-mode gate already in place. `--auto-back` is NOT
1079
+ // honoured in the slash surface today — operators stay in
1080
+ // plan mode and revert manually with `/plan --back`. The CLI
1081
+ // top-level `pugi plan --auto-back` exists for scripted use.
1082
+ if (verdict.prompt.length > 0 && !verdict.back) {
1083
+ return { kind: 'dispatch', brief: verdict.prompt };
1084
+ }
1085
+ }
1086
+ catch (error) {
1087
+ const message = error instanceof Error ? error.message : String(error);
1088
+ this.appendSystemLine(`/plan failed: ${message}`);
1089
+ }
1090
+ return verdict;
1091
+ }
1092
+ case 'release-notes': {
1093
+ // Leak L24 (2026-05-27): changelog diff between the operator's
1094
+ // last-seen + installed CLI versions. Delegate к the shared
1095
+ // `runReleaseNotesCommand` runner so the slash + top-level
1096
+ // paths stay single-sourced. The renderer collects each line
1097
+ // into the system pane via `appendSystemLine` — no fresh Ink
1098
+ // mount, no boxed render. `--reset` is honoured via the
1099
+ // `verdict.reset` field parsed in slash-commands.ts.
1100
+ try {
1101
+ const { runReleaseNotesCommand, defaultReleaseNotesHome } = await import('../../runtime/commands/release-notes.js');
1102
+ const lines = [];
1103
+ runReleaseNotesCommand({
1104
+ home: defaultReleaseNotesHome(),
1105
+ json: false,
1106
+ reset: verdict.reset,
1107
+ writeOutput: (_payload, text) => {
1108
+ for (const line of text.split('\n')) {
1109
+ lines.push(line.replace(/\s+$/u, ''));
1110
+ }
1111
+ },
1112
+ });
1113
+ if (lines.length === 0) {
1114
+ this.appendSystemLine('/release-notes: no output.');
1115
+ }
1116
+ else {
1117
+ for (const line of lines)
1118
+ this.appendSystemLine(line);
1119
+ }
1120
+ }
1121
+ catch (error) {
1122
+ const message = error instanceof Error ? error.message : String(error);
1123
+ this.appendSystemLine(`/release-notes failed: ${message}`);
1124
+ }
1125
+ return verdict;
1126
+ }
1127
+ case 'stickers': {
1128
+ // Leak L33 (2026-05-27): brand-personality gimmick. Delegate to
1129
+ // the shared `runStickersCommand` so the slash + top-level
1130
+ // paths stay single-sourced. The renderer routes the text
1131
+ // through the system pane line-buffer (ascii-only — no fresh
1132
+ // Ink mount) so the gimmick lands as a single contiguous
1133
+ // block в the conversation transcript.
1134
+ try {
1135
+ const { runStickersCommand } = await import('../../runtime/commands/stickers.js');
1136
+ // L33 P1 fix (2026-05-27): await the runner even though the
1137
+ // current implementation is synchronous. Two reasons:
1138
+ // (1) future-proofs the call site against the runner growing
1139
+ // an async path (e.g. remote stickerpack fetch) — without
1140
+ // this await, a returned promise would resolve AFTER we
1141
+ // flushed `lines` and the gimmick would render blank, and
1142
+ // (2) keeps the slash dispatcher uniform with the other
1143
+ // command runners (style, doctor, permissions, plan), all
1144
+ // of which are awaited.
1145
+ const lines = [];
1146
+ await runStickersCommand({
1147
+ json: false,
1148
+ asciiOnly: true,
1149
+ writeOutput: (_payload, text) => {
1150
+ for (const line of text.split('\n')) {
1151
+ const trimmed = line.replace(/\s+$/u, '');
1152
+ lines.push(trimmed);
1153
+ }
1154
+ },
1155
+ });
1156
+ if (lines.length === 0) {
1157
+ this.appendSystemLine('/stickers: no output.');
1158
+ }
1159
+ else {
1160
+ for (const line of lines)
1161
+ this.appendSystemLine(line);
1162
+ }
1163
+ }
1164
+ catch (error) {
1165
+ const message = error instanceof Error ? error.message : String(error);
1166
+ this.appendSystemLine(`/stickers failed: ${message}`);
1167
+ }
1168
+ return verdict;
1169
+ }
1170
+ case 'feedback': {
1171
+ // Leak L21 (2026-05-27): in-CLI feedback collector. The wizard
1172
+ // mounts a fresh Ink tree (renderFeedbackPrompt) outside the
1173
+ // live REPL input box so the operator can step through
1174
+ // category / rating / comment / context / confirm without
1175
+ // interleaving with persona output. The session module owns
1176
+ // the submit + queue wiring so the slash + top-level CLI
1177
+ // surfaces stay single-sourced through `runFeedbackCommand`.
1178
+ try {
1179
+ await this.runFeedbackSlash();
1180
+ }
1181
+ catch (error) {
1182
+ const message = error instanceof Error ? error.message : String(error);
1183
+ this.appendSystemLine(`/feedback failed: ${message}`);
1184
+ }
1185
+ return verdict;
1186
+ }
842
1187
  case 'stub': {
843
1188
  this.appendSystemLine(verdict.message);
844
1189
  return verdict;
845
1190
  }
846
1191
  }
847
1192
  }
1193
+ /**
1194
+ * Leak L21 (2026-05-27): drive the `/feedback` wizard from inside
1195
+ * the REPL. Mounts the Ink prompt, collects the draft, hands it to
1196
+ * `runFeedbackCommand` (which routes to submit-now or
1197
+ * queue-locally), then writes the operator-facing toast to the
1198
+ * conversation system pane.
1199
+ *
1200
+ * The session module owns the wiring (cwd, cliVersion, apiUrl,
1201
+ * apiKey, transcript provider) so the slash + top-level CLI paths
1202
+ * stay single-sourced through `runFeedbackCommand`.
1203
+ */
1204
+ async runFeedbackSlash() {
1205
+ const { renderFeedbackPrompt } = await import('../../tui/feedback-prompt.js');
1206
+ const { runFeedbackCommand, renderFeedbackToast } = await import('../../runtime/commands/feedback.js');
1207
+ const { submitFeedback, redactSessionContext } = await import('../feedback/submitter.js');
1208
+ const verdict = await renderFeedbackPrompt();
1209
+ if (verdict.cancelled || !verdict.draft) {
1210
+ this.appendSystemLine('Feedback cancelled. Nothing was sent.');
1211
+ return;
1212
+ }
1213
+ // Build a session-context provider that reads the LAST 5 turns
1214
+ // from the live transcript + applies the redactor. Only invoked
1215
+ // when the operator opted in on step 4.
1216
+ const sessionContextProvider = () => {
1217
+ const last5 = this.state.transcript
1218
+ .filter((row) => row.source !== 'system')
1219
+ .slice(-5)
1220
+ .map((row) => ({
1221
+ role: row.source === 'operator' ? 'user' : 'assistant',
1222
+ text: row.text,
1223
+ }));
1224
+ // The workspace context exposed to the session does not carry
1225
+ // a git branch field today, so we omit `gitBranch` here. When
1226
+ // `ReplWorkspaceContext` gains the field we can forward it via
1227
+ // an extra options entry without changing the redactor contract.
1228
+ return redactSessionContext(last5);
1229
+ };
1230
+ const result = await runFeedbackCommand({
1231
+ cwd: process.cwd(),
1232
+ cliVersion: this.options.cliVersion,
1233
+ submit: async (env) => submitFeedback(env, {
1234
+ apiUrl: this.options.apiUrl,
1235
+ apiKey: this.options.apiKey,
1236
+ }),
1237
+ draft: verdict.draft,
1238
+ sessionContext: sessionContextProvider,
1239
+ });
1240
+ this.appendSystemLine(renderFeedbackToast(result));
1241
+ }
848
1242
  /**
849
1243
  * Leak L8 (2026-05-27): drive the `/compact` flow from inside the
850
1244
  * REPL. Reuses the standalone runner so the wire shape + reason
@@ -874,10 +1268,25 @@ export class ReplSession {
874
1268
  },
875
1269
  });
876
1270
  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}) ───`);
1271
+ // L29 (2026-05-27): emit a structured `compact-boundary` row so
1272
+ // the conversation pane routes the marker through the dedicated
1273
+ // `<CompactBanner />` Ink component (gray, terminal-width
1274
+ // separator) instead of leaking the raw text into a `system`
1275
+ // row. The plain-text body is kept as a deterministic fallback
1276
+ // for non-Ink consumers (snapshot tests, JSON-mode exports).
1277
+ const turnsBefore = result.turnsBefore ?? 0;
1278
+ this.appendRow({
1279
+ source: 'compact-boundary',
1280
+ text: `─── context compacted (${turnsBefore} turns → 1 summary, ${trigger}) ───`,
1281
+ compaction: {
1282
+ turnsBefore,
1283
+ trigger,
1284
+ summaryTokenCount: result.tokensSummarised,
1285
+ // Fresh in-REPL compaction lands at the head of the
1286
+ // transcript — no turns have followed it yet.
1287
+ turnsAgo: 0,
1288
+ },
1289
+ });
881
1290
  }
882
1291
  }
883
1292
  catch (error) {
@@ -2399,13 +2808,14 @@ export class ReplSession {
2399
2808
  this.appendRow({ source: 'persona', text: stripped, personaSlug });
2400
2809
  }
2401
2810
  appendRow(input) {
2402
- if (input.text.length === 0)
2811
+ if (input.text.length === 0 && input.source !== 'compact-boundary')
2403
2812
  return;
2404
2813
  const row = {
2405
2814
  id: randomUUID(),
2406
2815
  source: input.source,
2407
2816
  text: input.text,
2408
2817
  personaSlug: input.personaSlug,
2818
+ compaction: input.compaction,
2409
2819
  timestampEpochMs: this.now(),
2410
2820
  };
2411
2821
  const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
@@ -2484,6 +2894,15 @@ export class ReplSession {
2484
2894
  persistRow(row) {
2485
2895
  if (!this.store)
2486
2896
  return;
2897
+ // L29 (2026-05-27): `compact-boundary` transcript rows are echoes of
2898
+ // the JSONL `compaction` event the compact runner already appended
2899
+ // via `appendCompactBoundary`. Persisting them here would double-
2900
+ // write the marker (and worse, with a stripped payload that lacks
2901
+ // `summary` / `coversUntilOffset`) — `isCompactBoundary` would
2902
+ // reject the duplicate but `applyCompactMask` would still index off
2903
+ // the wrong offset. Skip the write.
2904
+ if (row.source === 'compact-boundary')
2905
+ return;
2487
2906
  const kind = row.source === 'operator' ? 'user'
2488
2907
  : row.source === 'persona' ? 'persona'
2489
2908
  : 'system';
@@ -2536,6 +2955,13 @@ export class ReplSession {
2536
2955
  if (row)
2537
2956
  rows.push(row);
2538
2957
  }
2958
+ // L29 (2026-05-27): tag each compact-boundary row with the count of
2959
+ // operator + persona turns that landed AFTER it in the replay
2960
+ // window. The banner reads `turnsAgo` to render the "N turns ago"
2961
+ // suffix so a long session that resumes across multiple compactions
2962
+ // stays self-orienting. System rows + sibling boundaries are NOT
2963
+ // counted — they are chrome, not operator-visible turns.
2964
+ annotateBoundaryTurnsAgo(rows);
2539
2965
  // Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
2540
2966
  // window math stays consistent post-restore.
2541
2967
  const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
@@ -2720,26 +3146,70 @@ function eventToTranscriptRow(event) {
2720
3146
  };
2721
3147
  }
2722
3148
  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.
3149
+ // L8 + L29 (2026-05-27): render the marker as a structured
3150
+ // `compact-boundary` row so the renderer can route it to the
3151
+ // dedicated <CompactBanner /> Ink component. The full summary text
3152
+ // is intentionally NOT inlined here (a 2k-token summary in the
3153
+ // transcript would defeat the purpose of compacting); the operator
3154
+ // sees the "context compacted" banner and can run `/context` to
3155
+ // inspect the marker payload when they want the details. The plain
3156
+ // text fallback stays in place for non-Ink consumers (snapshot
3157
+ // tests, future JSON exports).
2729
3158
  const compactionPayload = (event.payload ?? null);
2730
3159
  const trigger = compactionPayload?.trigger === 'auto' ? 'auto' : 'manual';
2731
3160
  const turns = typeof compactionPayload?.summaryTurnsBefore === 'number'
2732
3161
  ? compactionPayload.summaryTurnsBefore
2733
3162
  : 0;
3163
+ const tokens = typeof compactionPayload?.summaryTokenCount === 'number'
3164
+ ? compactionPayload.summaryTokenCount
3165
+ : undefined;
2734
3166
  return {
2735
3167
  id: randomUUID(),
2736
- source: 'system',
3168
+ source: 'compact-boundary',
2737
3169
  text: `─── context compacted (${turns} turns → 1 summary, ${trigger}) ───`,
3170
+ compaction: {
3171
+ turnsBefore: turns,
3172
+ trigger,
3173
+ summaryTokenCount: tokens,
3174
+ },
2738
3175
  timestampEpochMs: event.t,
2739
3176
  };
2740
3177
  }
2741
3178
  return null;
2742
3179
  }
3180
+ /**
3181
+ * L29 (2026-05-27): walk a chronological transcript window and stamp
3182
+ * every `compact-boundary` row's `compaction.turnsAgo` with the count of
3183
+ * operator + persona rows that land AFTER it. The annotation runs in
3184
+ * place on the array — boundaries earlier in time get larger `turnsAgo`
3185
+ * values, the boundary at the head of the window gets zero. System rows
3186
+ * and sibling boundaries are excluded from the count (they are chrome,
3187
+ * not operator-visible turns).
3188
+ *
3189
+ * Exported so a future spec can lock the contract and so the in-REPL
3190
+ * `/compact` path can reuse the same counter on live appends if it ever
3191
+ * needs to. Pure function (mutates only the input slice).
3192
+ */
3193
+ export function annotateBoundaryTurnsAgo(rows) {
3194
+ let trailingTurns = 0;
3195
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
3196
+ const row = rows[i];
3197
+ if (row.source === 'operator' || row.source === 'persona') {
3198
+ trailingTurns += 1;
3199
+ continue;
3200
+ }
3201
+ if (row.source === 'compact-boundary') {
3202
+ // Re-assign with the live `turnsAgo`. Carry forward the existing
3203
+ // structured payload so we never lose the trigger / token-count
3204
+ // data the renderer needs.
3205
+ const compaction = row.compaction ?? { turnsBefore: 0, trigger: 'manual' };
3206
+ rows[i] = {
3207
+ ...row,
3208
+ compaction: { ...compaction, turnsAgo: trailingTurns },
3209
+ };
3210
+ }
3211
+ }
3212
+ }
2743
3213
  /**
2744
3214
  * Heuristic: does this text contain Markdown structures that benefit
2745
3215
  * from atomic grouping? Code fences, bullet lists, numbered lists,