@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.
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/engine/native-pugi.js +55 -11
- package/dist/core/engine/prompts.js +30 -2
- package/dist/core/engine/tool-bridge.js +32 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/index.js +1 -1
- package/dist/core/permissions/state.js +55 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/session.js +482 -12
- package/dist/core/repl/slash-commands.js +134 -1
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/runtime/cli.js +603 -15
- package/dist/runtime/commands/doctor.js +21 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/registry.js +8 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tui/compact-banner.js +28 -1
- package/dist/tui/conversation-pane.js +13 -0
- package/dist/tui/doctor-table.js +32 -17
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/repl-render.js +26 -3
- package/dist/tui/repl.js +9 -1
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/vim-input.js +267 -0
- package/package.json +2 -2
- package/dist/core/engine/compaction-hook.js +0 -154
- package/dist/core/init/scaffold.js +0 -195
- package/dist/core/repl/codebase-survey.js +0 -308
- package/dist/core/repl/init-interview.js +0 -457
- 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
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
|
|
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
|
-
//
|
|
2724
|
-
//
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
//
|
|
2728
|
-
//
|
|
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: '
|
|
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,
|