@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.
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/engine/native-pugi.js +21 -10
- 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/repl/session.js +375 -12
- package/dist/core/repl/slash-commands.js +99 -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/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/runtime/cli.js +386 -1
- package/dist/runtime/commands/doctor.js +8 -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/share.js +316 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -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/feedback-prompt.js +156 -0
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/repl-render.js +9 -1
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +22 -0
- 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
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
|
|
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
|
-
//
|
|
2724
|
-
//
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
//
|
|
2728
|
-
//
|
|
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: '
|
|
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 +
|