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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/core/bare-mode/index.js +107 -0
  2. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  3. package/dist/core/engine/native-pugi.js +21 -10
  4. package/dist/core/engine/prompts.js +30 -2
  5. package/dist/core/engine/tool-bridge.js +32 -0
  6. package/dist/core/feedback/queue.js +177 -0
  7. package/dist/core/feedback/submitter.js +145 -0
  8. package/dist/core/onboarding/marker.js +111 -0
  9. package/dist/core/onboarding/telemetry-state.js +108 -0
  10. package/dist/core/output-style/presets.js +176 -0
  11. package/dist/core/output-style/state.js +185 -0
  12. package/dist/core/permissions/index.js +1 -1
  13. package/dist/core/permissions/state.js +55 -0
  14. package/dist/core/repl/session.js +375 -12
  15. package/dist/core/repl/slash-commands.js +99 -1
  16. package/dist/core/repl/workspace-context.js +22 -0
  17. package/dist/core/share/formatter.js +271 -0
  18. package/dist/core/share/redactor.js +221 -0
  19. package/dist/core/share/uploader.js +267 -0
  20. package/dist/core/todos/invariant.js +10 -0
  21. package/dist/core/todos/state.js +177 -0
  22. package/dist/runtime/cli.js +386 -1
  23. package/dist/runtime/commands/doctor.js +8 -0
  24. package/dist/runtime/commands/feedback.js +184 -0
  25. package/dist/runtime/commands/onboarding.js +275 -0
  26. package/dist/runtime/commands/plan.js +143 -0
  27. package/dist/runtime/commands/share.js +316 -0
  28. package/dist/runtime/commands/stickers.js +82 -0
  29. package/dist/runtime/commands/style.js +194 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tools/registry.js +8 -0
  32. package/dist/tools/todo-write.js +184 -0
  33. package/dist/tui/compact-banner.js +28 -1
  34. package/dist/tui/conversation-pane.js +13 -0
  35. package/dist/tui/feedback-prompt.js +156 -0
  36. package/dist/tui/onboarding-wizard.js +240 -0
  37. package/dist/tui/repl-render.js +9 -1
  38. package/dist/tui/stickers-art.js +136 -0
  39. package/dist/tui/style-table.js +22 -0
  40. package/package.json +2 -2
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Leak L25 (2026-05-27) — Onboarding marker file.
3
+ *
4
+ * `~/.pugi/.onboarded` is a single, contentless marker. Its existence
5
+ * tells the bare-invocation hint check that the operator has already
6
+ * walked the `/onboarding` wizard at least once, so we no longer print
7
+ * the "Tip: run `pugi onboarding` to configure defaults" line on
8
+ * cold-start. The wizard re-runs cleanly — idempotency lives in the
9
+ * wizard itself, not in the marker.
10
+ *
11
+ * Why a marker file (and not just `~/.pugi/config.json`'s existence)?
12
+ *
13
+ * - The config file is touched the moment ANY surface writes a
14
+ * default — `pugi style terse --persist`, `pugi permissions ask`,
15
+ * `pugi config set …`. Using "config exists" as the proxy for
16
+ * "operator has onboarded" would silence the first-run hint for
17
+ * operators who never saw the wizard.
18
+ *
19
+ * - The marker is explicit: it is written ONLY by the wizard's exit
20
+ * step (or `pugi onboarding --mark-only` for the upgrade-path
21
+ * where we want to suppress the hint without forcing a re-walk).
22
+ *
23
+ * - Removing the marker (`rm ~/.pugi/.onboarded`) re-arms the hint
24
+ * without nuking the operator's accumulated config — useful for
25
+ * QA, support flows, and demo-machine resets.
26
+ *
27
+ * Path resolution mirrors the L6/L18 convention: `PUGI_HOME` env wins,
28
+ * else `~/.pugi`. The marker is touched as an empty file (no JSON, no
29
+ * timestamp payload) — readers MUST treat existence as the only signal
30
+ * so a future change to mtime semantics does not break us.
31
+ *
32
+ * IO contract:
33
+ * - `isOnboarded(env)` — pure read; never throws, returns false on
34
+ * any fs error so a corrupted home dir cannot hide the hint.
35
+ * - `markOnboarded(env)` — best-effort write; creates `<home>/.pugi/`
36
+ * if missing, mode 0o600 on the marker so it never lands in a
37
+ * world-readable backup.
38
+ * - `clearOnboarded(env)` — best-effort delete; absent file is a
39
+ * no-op (not an error). Used by `pugi onboarding --reset` and the
40
+ * spec teardown.
41
+ */
42
+ import { existsSync, mkdirSync, rmSync, writeFileSync, } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import { resolve } from 'node:path';
45
+ /**
46
+ * Env override for `~/.pugi`. Same convention as L6 / L18 — spec
47
+ * fixtures point this at a temp dir so a real developer machine never
48
+ * lands a stray marker.
49
+ */
50
+ export const PUGI_HOME_ENV = 'PUGI_HOME';
51
+ /**
52
+ * Marker basename. Hidden (leading dot) so it does not clutter `ls`
53
+ * inside `~/.pugi/` next to `config.json` / `session.json`.
54
+ */
55
+ const MARKER_BASENAME = '.onboarded';
56
+ /**
57
+ * Resolve the absolute path to the onboarding marker. Exported for the
58
+ * spec; production callers go through `isOnboarded` / `markOnboarded`.
59
+ */
60
+ export function onboardingMarkerPath(env = process.env) {
61
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
62
+ return resolve(home, MARKER_BASENAME);
63
+ }
64
+ /**
65
+ * True when the marker exists. Pure read. Defensive: any fs error
66
+ * (race with deletion, permission flip) degrades to `false` — printing
67
+ * the hint twice is harmless, silently swallowing the wizard would
68
+ * surprise the operator.
69
+ */
70
+ export function isOnboarded(env = process.env) {
71
+ try {
72
+ return existsSync(onboardingMarkerPath(env));
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * Touch the marker. Creates `<home>/.pugi/` if missing. Idempotent —
80
+ * re-touching an existing marker is a no-op for the consumer (the file
81
+ * was already there; the hint was already suppressed).
82
+ *
83
+ * Best-effort: a write failure is swallowed because the wizard already
84
+ * completed its real work (mode + style + telemetry were persisted).
85
+ * The worst case is a redundant hint on the next `pugi` invocation —
86
+ * preferable to crashing the freshly-completed wizard with a stat EIO.
87
+ */
88
+ export function markOnboarded(env = process.env) {
89
+ const path = onboardingMarkerPath(env);
90
+ try {
91
+ mkdirSync(resolve(path, '..'), { recursive: true });
92
+ writeFileSync(path, '', { encoding: 'utf8', mode: 0o600 });
93
+ }
94
+ catch {
95
+ // intentionally swallowed — see header
96
+ }
97
+ }
98
+ /**
99
+ * Remove the marker. Used by `pugi onboarding --reset` (and the spec
100
+ * teardown). Absent file is a no-op; any other fs error is swallowed
101
+ * so a permission glitch never leaks out of the reset surface.
102
+ */
103
+ export function clearOnboarded(env = process.env) {
104
+ try {
105
+ rmSync(onboardingMarkerPath(env), { force: true });
106
+ }
107
+ catch {
108
+ // intentionally swallowed — see header
109
+ }
110
+ }
111
+ //# sourceMappingURL=marker.js.map
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Leak L25 (2026-05-27) — Telemetry consent state.
3
+ *
4
+ * The onboarding wizard's Step 5 asks the operator for telemetry
5
+ * consent. We persist the verdict in the user-tier
6
+ * `~/.pugi/config.json::telemetry` field so a future REPL boot can
7
+ * read it without re-asking. Choices mirror `core/settings.ts`'s
8
+ * `privacy.telemetry` enum:
9
+ *
10
+ * - `off` — no telemetry of any kind (default).
11
+ * - `anonymous` — counts + error categories only, no payloads.
12
+ * - `community` — anonymous + opt-in skill/usage panels.
13
+ *
14
+ * This module is intentionally narrow: it only owns the `telemetry`
15
+ * key inside `~/.pugi/config.json`. The full settings parsing lives in
16
+ * `core/settings.ts` (workspace-tier `.pugi/settings.json`); we do NOT
17
+ * route through it here because:
18
+ *
19
+ * 1. The settings schema is workspace-scoped — its file path is
20
+ * `<root>/.pugi/settings.json`, not `~/.pugi/config.json`.
21
+ * 2. The wizard records a user-level default that workspace settings
22
+ * can later override. Mixing the two would conflate scope.
23
+ * 3. Read-modify-write on a partial JSON file is the same pattern
24
+ * L6 / L18 use for adjacent keys — keeping it self-contained
25
+ * preserves the "one module, one key" invariant.
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { dirname, resolve } from 'node:path';
30
+ import { PUGI_HOME_ENV } from './marker.js';
31
+ export const TELEMETRY_CHOICES = Object.freeze([
32
+ 'off',
33
+ 'anonymous',
34
+ 'community',
35
+ ]);
36
+ export const DEFAULT_TELEMETRY = 'off';
37
+ /**
38
+ * Path to the user-tier config. Mirrors `userConfigPath()` from L18
39
+ * `output-style/state.ts` — duplicated here (not imported) to keep the
40
+ * marker + telemetry module self-contained. Any future drift between
41
+ * the two would surface a spec failure: both modules read the same
42
+ * file in the spec sandbox.
43
+ */
44
+ export function telemetryConfigPath(env = process.env) {
45
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
46
+ return resolve(home, 'config.json');
47
+ }
48
+ /**
49
+ * Type guard for arbitrary string input (CLI argv, config.json
50
+ * deserialisation). Returns false for any non-string or out-of-set
51
+ * value so a malformed config degrades to the default verdict.
52
+ */
53
+ export function isTelemetryChoice(value) {
54
+ return (typeof value === 'string'
55
+ && TELEMETRY_CHOICES.includes(value));
56
+ }
57
+ /**
58
+ * Read the persisted telemetry verdict. Returns the default (`'off'`)
59
+ * when the file is absent, empty, malformed, or holds an unknown
60
+ * value. Never throws — the wizard re-asks every time it runs, so a
61
+ * defensive read is the right posture.
62
+ */
63
+ export function readTelemetryChoice(io = {}) {
64
+ const config = readConfigFile(telemetryConfigPath(io.env ?? process.env));
65
+ return isTelemetryChoice(config.telemetry) ? config.telemetry : DEFAULT_TELEMETRY;
66
+ }
67
+ /**
68
+ * Persist the telemetry verdict. Read-modify-write preserves any
69
+ * neighbouring keys (`outputStyle`, `defaultPermissionMode`, …) the
70
+ * other tier-state modules own.
71
+ */
72
+ export function writeTelemetryChoice(choice, io = {}) {
73
+ const path = telemetryConfigPath(io.env ?? process.env);
74
+ const config = readConfigFile(path);
75
+ config.telemetry = choice;
76
+ writeConfigFile(path, config);
77
+ }
78
+ function readConfigFile(path) {
79
+ if (!existsSync(path))
80
+ return {};
81
+ let raw;
82
+ try {
83
+ raw = readFileSync(path, 'utf8');
84
+ }
85
+ catch {
86
+ return {};
87
+ }
88
+ if (raw.trim().length === 0)
89
+ return {};
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(raw);
93
+ }
94
+ catch {
95
+ return {};
96
+ }
97
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
98
+ return {};
99
+ return parsed;
100
+ }
101
+ function writeConfigFile(path, config) {
102
+ mkdirSync(dirname(path), { recursive: true });
103
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
104
+ encoding: 'utf8',
105
+ mode: 0o600,
106
+ });
107
+ }
108
+ //# sourceMappingURL=telemetry-state.js.map
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Leak L18 (2026-05-27) — Output-style presets.
3
+ *
4
+ * Mirror of Claude Code's `/output-style` surface: a small closed set of
5
+ * named voice presets the operator can flip between at session start so
6
+ * the model's prose lands in the register they prefer. The preset
7
+ * compiles into an `<output-style>` rule block appended to the engine
8
+ * system prompt; tool-use / code-block formatting / file edits are NOT
9
+ * affected — the preset only steers prose register.
10
+ *
11
+ * Design contract:
12
+ *
13
+ * - The catalogue is intentionally tiny (5 entries) so the operator
14
+ * can hold the full surface in working memory. Adding entries means
15
+ * adding a row in `OUTPUT_STYLES` plus a spec assertion; there is
16
+ * no plugin surface today.
17
+ *
18
+ * - `default` is the only preset that emits NO rule block. The
19
+ * "current Pugi voice" already lives in the base engine prompt
20
+ * (jargon ban, brand voice, terse register), so re-stating it
21
+ * under `<output-style>` would double the model's instruction load
22
+ * for the most-common case. Other presets emit the block.
23
+ *
24
+ * - Rule-block prose stays terse and operator-grade (brandbook §08).
25
+ * No friendly hedging, no AI-assistant framing. The bullets are
26
+ * the model's contract; the section title carries the preset name
27
+ * so the model can self-correct mid-turn if it drifts ("I am in
28
+ * terse mode → drop articles").
29
+ *
30
+ * - The Russian-formal preset uses вы-form explicitly. Russian/
31
+ * Ukrainian chat is permitted by the base voice contract; this
32
+ * preset hardens the register for B2B / enterprise demo flows
33
+ * where ты-form reads as too casual.
34
+ *
35
+ * - The Casual preset RELAXES the jargon ban — contractions, jokes,
36
+ * informal phrasing are allowed. It does NOT lift the brand-voice
37
+ * em-dash / emoji ban; those are typographic, not register, and
38
+ * remain off across every preset.
39
+ *
40
+ * Test surface: `test/commands/output-style-presets.spec.ts` exercises
41
+ * the catalogue invariants (5 entries, unique slugs, every non-default
42
+ * preset emits a non-empty block, the block starts with the expected
43
+ * marker so the engine prompt appender can locate it for stripping).
44
+ */
45
+ /**
46
+ * The closed list of preset slugs in catalogue order. Mirror used by
47
+ * the CLI surface (`/style` table, `pugi style --list`) so the
48
+ * operator sees presets in a stable order regardless of catalogue
49
+ * iteration order.
50
+ */
51
+ export const OUTPUT_STYLE_SLUGS = Object.freeze([
52
+ 'default',
53
+ 'terse',
54
+ 'explanatory',
55
+ 'russian-formal',
56
+ 'casual',
57
+ ]);
58
+ /**
59
+ * Default slug used when no workspace-/user-level preference is set.
60
+ * Exported so `state.ts` and the CLI handler share one constant.
61
+ */
62
+ export const DEFAULT_OUTPUT_STYLE = 'default';
63
+ /**
64
+ * Catalogue keyed by slug. Frozen so callers cannot mutate the
65
+ * shared rows; the CLI handler returns slugs, not preset references,
66
+ * to keep the boundary clean.
67
+ */
68
+ export const OUTPUT_STYLES = Object.freeze({
69
+ default: Object.freeze({
70
+ slug: 'default',
71
+ title: 'Default',
72
+ gloss: 'Current Pugi voice (no override). Base engine prompt rules apply unchanged.',
73
+ rules: Object.freeze([]),
74
+ }),
75
+ terse: Object.freeze({
76
+ slug: 'terse',
77
+ title: 'Terse',
78
+ gloss: 'Fragments, dropped articles, one short sentence per turn.',
79
+ rules: Object.freeze([
80
+ 'Drop articles, fillers, hedging',
81
+ '1 short sentence per turn for prose answers',
82
+ 'Code blocks unchanged — never abbreviate code',
83
+ 'Quote errors verbatim with no paraphrase',
84
+ ]),
85
+ }),
86
+ explanatory: Object.freeze({
87
+ slug: 'explanatory',
88
+ title: 'Explanatory',
89
+ gloss: 'Verbose, walks reasoning step by step, links concepts.',
90
+ rules: Object.freeze([
91
+ 'Explain reasoning, not just the conclusion',
92
+ 'Cite relevant files + line numbers when grounding claims',
93
+ 'Link adjacent concepts the operator may want to chase',
94
+ 'Code blocks unchanged — annotate around, not inside',
95
+ ]),
96
+ }),
97
+ 'russian-formal': Object.freeze({
98
+ slug: 'russian-formal',
99
+ title: 'Russian formal',
100
+ gloss: 'Russian вы-form, professional register, no slang.',
101
+ rules: Object.freeze([
102
+ 'Pisat\' otvety po-russki (Russian prose; ASCII transliteration permitted in this rule block only)',
103
+ 'Address the operator using вы-form, never ты',
104
+ 'No slang, no contractions of Russian forms',
105
+ 'Code blocks + identifiers stay in English unchanged',
106
+ 'Error messages quoted verbatim in the original language',
107
+ ]),
108
+ }),
109
+ casual: Object.freeze({
110
+ slug: 'casual',
111
+ title: 'Casual',
112
+ gloss: 'Informal register, contractions OK, dry jokes welcome.',
113
+ rules: Object.freeze([
114
+ 'Contractions allowed (it\'s, don\'t, you\'re)',
115
+ 'Dry, deadpan jokes welcome when they do not displace signal',
116
+ 'No em-dashes, no emoji — typographic rules unchanged',
117
+ 'Stay terse — casual is register, not verbosity',
118
+ ]),
119
+ }),
120
+ });
121
+ /**
122
+ * Type-narrowing predicate. Used by the slash-command parser + state
123
+ * loader so an unknown string from operator input or a stale config
124
+ * file degrades to the default preset instead of crashing.
125
+ */
126
+ export function isOutputStyleSlug(value) {
127
+ return (typeof value === 'string'
128
+ && OUTPUT_STYLE_SLUGS.includes(value));
129
+ }
130
+ /**
131
+ * Compile a preset into the `<output-style>` rule block injected at
132
+ * the tail of the engine system prompt.
133
+ *
134
+ * Returns empty string when the preset is `default` (or any preset
135
+ * with an empty rules array). Empty string is a load-bearing signal —
136
+ * the engine prompt appender uses it to skip injection entirely so
137
+ * the model sees a clean prompt for the default register.
138
+ *
139
+ * The block opens with `<output-style>` and closes with `</output-style>`
140
+ * (XML-shaped marker, matching the engine prompt's existing `<intent>`
141
+ * grammar). The `Active style:` line gives the model a self-correction
142
+ * anchor when it drifts mid-turn.
143
+ */
144
+ export function compileStyleBlock(slug) {
145
+ const preset = OUTPUT_STYLES[slug];
146
+ if (preset.rules.length === 0)
147
+ return '';
148
+ const lines = [];
149
+ lines.push('<output-style>');
150
+ lines.push(` Active style: ${preset.slug}`);
151
+ for (const rule of preset.rules) {
152
+ lines.push(` - ${rule}`);
153
+ }
154
+ lines.push('</output-style>');
155
+ return lines.join('\n');
156
+ }
157
+ /**
158
+ * Render the preset catalogue as a plain-text table for the `/style`
159
+ * + `pugi style` surfaces. Marks the active slug with `*` so the
160
+ * operator can see at a glance which preset is in effect.
161
+ *
162
+ * Pure renderer (no fs, no env). Identical text is emitted from both
163
+ * the slash dispatcher and the top-level CLI command so operators
164
+ * trained on one surface read the same table on the other.
165
+ */
166
+ export function renderStyleTable(active) {
167
+ const slugWidth = Math.max('NAME'.length, ...OUTPUT_STYLE_SLUGS.map((slug) => slug.length));
168
+ const header = `${'NAME'.padEnd(slugWidth)} GLOSS`;
169
+ const rows = OUTPUT_STYLE_SLUGS.map((slug) => {
170
+ const preset = OUTPUT_STYLES[slug];
171
+ const marker = slug === active ? '*' : ' ';
172
+ return `${marker} ${slug.padEnd(slugWidth)} ${preset.gloss}`;
173
+ });
174
+ return [header, ...rows].join('\n');
175
+ }
176
+ //# sourceMappingURL=presets.js.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Leak L18 (2026-05-27) — Output-style state persistence.
3
+ *
4
+ * Two-tier storage:
5
+ *
6
+ * 1. **Workspace** — `<workspaceRoot>/.pugi/config.json`. Set by
7
+ * `/style <name>` from inside the REPL or `pugi style <name>`
8
+ * without `--persist`. Overrides the user default for the
9
+ * current workspace only. Survives sessions because the same
10
+ * `.pugi/` survives sessions.
11
+ *
12
+ * 2. **User default** — `~/.pugi/config.json` (PUGI_HOME-aware).
13
+ * Set by `pugi style <name> --persist` or
14
+ * `/style <name> --persist`. Applies to every workspace that
15
+ * has no workspace-level override.
16
+ *
17
+ * Precedence (highest → lowest):
18
+ *
19
+ * workspace value > user value > DEFAULT_OUTPUT_STYLE ('default')
20
+ *
21
+ * Both files live under the same `pugi-config-v1` JSON envelope as
22
+ * other settings (permissionMode, privacy, model, preferredEndpoint).
23
+ * The schema is intentionally NOT shared with `runtime/commands/config.ts`'s
24
+ * strict Zod schema — `outputStyle` is read/written ONLY through this
25
+ * module so `pugi config set outputStyle=…` is NOT a supported path
26
+ * (it would silently bypass the slug validator). Operators get a
27
+ * single surface: `/style` + `pugi style`.
28
+ *
29
+ * File layout (one config.json, multiple keys; this module owns the
30
+ * `outputStyle` key only):
31
+ *
32
+ * {
33
+ * "permissionMode": "ask",
34
+ * "outputStyle": "terse",
35
+ * ...
36
+ * }
37
+ *
38
+ * The reader tolerates:
39
+ * - missing file (returns the default slug),
40
+ * - empty file (returns the default slug),
41
+ * - malformed JSON (returns the default slug — DO NOT crash REPL
42
+ * boot because of a hand-edited config),
43
+ * - unknown slug (returns the default slug + emits no error; the
44
+ * operator can `/style` to see the table and re-set).
45
+ *
46
+ * The writer is a read-modify-write to preserve neighbouring keys
47
+ * (permissionMode etc.) — overwriting the whole file would clobber
48
+ * the other tier's settings.
49
+ *
50
+ * Test surface: `test/commands/output-style-state.spec.ts` exercises
51
+ * precedence, malformed-config tolerance, persistence across reads,
52
+ * the `--persist` (user-default) path, and reset semantics.
53
+ */
54
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
55
+ import { homedir } from 'node:os';
56
+ import { dirname, resolve } from 'node:path';
57
+ import { DEFAULT_OUTPUT_STYLE, isOutputStyleSlug, } from './presets.js';
58
+ /**
59
+ * Env override for `~/.pugi` so the spec can sandbox both tiers
60
+ * without touching the developer's real config. Matches the existing
61
+ * `runtime/commands/config.ts` convention.
62
+ */
63
+ export const PUGI_HOME_ENV = 'PUGI_HOME';
64
+ /**
65
+ * Resolve the active output style for the workspace, applying the
66
+ * precedence ladder (workspace > user > default).
67
+ *
68
+ * Pure read. Never writes, never throws — every IO failure degrades
69
+ * to the default slug. The function returns the source label too so
70
+ * the CLI surface can show the operator where the value came from.
71
+ */
72
+ export function resolveOutputStyle(io) {
73
+ const workspaceSlug = readSlugFromFile(workspaceConfigPath(io.workspaceRoot));
74
+ if (workspaceSlug)
75
+ return { slug: workspaceSlug, source: 'workspace' };
76
+ const userSlug = readSlugFromFile(userConfigPath(io.env ?? process.env));
77
+ if (userSlug)
78
+ return { slug: userSlug, source: 'user' };
79
+ return { slug: DEFAULT_OUTPUT_STYLE, source: 'default' };
80
+ }
81
+ /**
82
+ * Write `slug` to the workspace tier. Creates `<workspaceRoot>/.pugi/`
83
+ * if missing. Preserves neighbouring config keys via read-modify-write.
84
+ */
85
+ export function setWorkspaceOutputStyle(slug, io) {
86
+ writeSlugToFile(workspaceConfigPath(io.workspaceRoot), slug);
87
+ }
88
+ /**
89
+ * Write `slug` to the user tier (`~/.pugi/config.json`).
90
+ *
91
+ * Mirrors the workspace writer's read-modify-write so the user's
92
+ * `permissionMode` / `privacy` / `model` keys survive a style flip.
93
+ */
94
+ export function setUserOutputStyle(slug, io) {
95
+ writeSlugToFile(userConfigPath(io.env ?? process.env), slug);
96
+ }
97
+ /**
98
+ * Clear the workspace tier's `outputStyle` key. The user tier (and
99
+ * therefore the eventual resolved style) is left untouched.
100
+ *
101
+ * Used by `/style --reset` so the operator can revert a workspace
102
+ * override without nuking the rest of their workspace config.
103
+ */
104
+ export function clearWorkspaceOutputStyle(io) {
105
+ clearSlugInFile(workspaceConfigPath(io.workspaceRoot));
106
+ }
107
+ /**
108
+ * Clear the user tier's `outputStyle` key. Lower-blast-radius reset
109
+ * for operators who want every workspace to fall back to `default`
110
+ * unless an explicit workspace value is set.
111
+ */
112
+ export function clearUserOutputStyle(io) {
113
+ clearSlugInFile(userConfigPath(io.env ?? process.env));
114
+ }
115
+ /**
116
+ * Workspace config path. Exported for the spec; production callers
117
+ * should use the `setWorkspace…` / `resolveOutputStyle` helpers.
118
+ */
119
+ export function workspaceConfigPath(workspaceRoot) {
120
+ return resolve(workspaceRoot, '.pugi', 'config.json');
121
+ }
122
+ /**
123
+ * User config path resolved against `PUGI_HOME` (or `~/.pugi`).
124
+ * Exported for the spec.
125
+ */
126
+ export function userConfigPath(env = process.env) {
127
+ const home = env[PUGI_HOME_ENV] ?? resolve(homedir(), '.pugi');
128
+ return resolve(home, 'config.json');
129
+ }
130
+ /**
131
+ * Read + parse a config file. Returns an empty object on any IO or
132
+ * parse error. Caller-provided JSON must be a plain object; arrays /
133
+ * scalars / null are treated as "no config" so a hand-edited file
134
+ * never crashes the REPL.
135
+ */
136
+ function readConfigFile(path) {
137
+ if (!existsSync(path))
138
+ return {};
139
+ let raw;
140
+ try {
141
+ raw = readFileSync(path, 'utf8');
142
+ }
143
+ catch {
144
+ return {};
145
+ }
146
+ if (raw.trim().length === 0)
147
+ return {};
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(raw);
151
+ }
152
+ catch {
153
+ return {};
154
+ }
155
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
156
+ return {};
157
+ return parsed;
158
+ }
159
+ function writeConfigFile(path, config) {
160
+ mkdirSync(dirname(path), { recursive: true });
161
+ // 0o600 mirrors `runtime/commands/config.ts` — the config file may
162
+ // hold `preferredEndpoint` URLs that should not be world-readable.
163
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, {
164
+ encoding: 'utf8',
165
+ mode: 0o600,
166
+ });
167
+ }
168
+ function readSlugFromFile(path) {
169
+ const config = readConfigFile(path);
170
+ const candidate = config.outputStyle;
171
+ return isOutputStyleSlug(candidate) ? candidate : null;
172
+ }
173
+ function writeSlugToFile(path, slug) {
174
+ const config = readConfigFile(path);
175
+ config.outputStyle = slug;
176
+ writeConfigFile(path, config);
177
+ }
178
+ function clearSlugInFile(path) {
179
+ const config = readConfigFile(path);
180
+ if (!('outputStyle' in config))
181
+ return;
182
+ delete config.outputStyle;
183
+ writeConfigFile(path, config);
184
+ }
185
+ //# sourceMappingURL=state.js.map
@@ -14,5 +14,5 @@
14
14
  export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
15
15
  export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
16
16
  export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
17
- export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
17
+ export { getCurrentMode, getGlobalDefaultMode, getPreviousMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, setPreviousMode, } from './state.js';
18
18
  //# sourceMappingURL=index.js.map
@@ -27,6 +27,13 @@ const permissionModeEnum = z.enum(['plan', 'ask', 'allow', 'bypass']);
27
27
  const sessionStateSchema = z
28
28
  .object({
29
29
  permissionMode: permissionModeEnum.optional(),
30
+ /**
31
+ * Leak L7: snapshot of the mode that was active immediately BEFORE
32
+ * the operator typed `/plan` (or `/plan <prompt>`). `/plan --back`
33
+ * pops this snapshot and restores it. Cleared after a successful
34
+ * pop so a second `/plan --back` does not double-revert.
35
+ */
36
+ previousPermissionMode: permissionModeEnum.optional(),
30
37
  })
31
38
  .partial()
32
39
  .passthrough();
@@ -90,6 +97,54 @@ export function setCurrentMode(workspaceRoot, mode) {
90
97
  writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
91
98
  renameSync(tmpPath, path);
92
99
  }
100
+ /**
101
+ * Leak L7 — read the snapshot of the mode that was active before the
102
+ * most-recent `/plan` (or `pugi plan`) entry. Returns null when the
103
+ * file is absent OR the field is unset. Same defensive behaviour as
104
+ * `getCurrentMode`: a malformed session file never breaks the slash
105
+ * command — the worst case is `/plan --back` reports "no previous
106
+ * mode to restore" and the operator picks the target mode explicitly.
107
+ */
108
+ export function getPreviousMode(workspaceRoot) {
109
+ const path = sessionStatePath(workspaceRoot);
110
+ if (!existsSync(path))
111
+ return null;
112
+ try {
113
+ const raw = readFileSync(path, 'utf8');
114
+ const parsed = sessionStateSchema.parse(JSON.parse(raw));
115
+ return isPermissionMode(parsed.previousPermissionMode)
116
+ ? parsed.previousPermissionMode
117
+ : null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /**
124
+ * Leak L7 — record the mode that was active immediately before the
125
+ * operator switched to plan. The runtime calls this AT `/plan` entry
126
+ * with the current mode (whatever `resolveMode` returned). Atomic
127
+ * tmp+rename keeps the snapshot consistent if the process is killed
128
+ * mid-write. Pass `null` to clear the snapshot (used after a
129
+ * successful `/plan --back` so a second `--back` does not loop).
130
+ */
131
+ export function setPreviousMode(workspaceRoot, mode) {
132
+ const path = sessionStatePath(workspaceRoot);
133
+ mkdirSync(dirname(path), { recursive: true });
134
+ const existing = existsSync(path)
135
+ ? safeParseObject(readFileSync(path, 'utf8'))
136
+ : {};
137
+ const next = { ...existing };
138
+ if (mode === null) {
139
+ delete next.previousPermissionMode;
140
+ }
141
+ else {
142
+ next.previousPermissionMode = mode;
143
+ }
144
+ const tmpPath = `${path}.tmp`;
145
+ writeFileSync(tmpPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
146
+ renameSync(tmpPath, path);
147
+ }
93
148
  /**
94
149
  * Read `~/.pugi/config.json::defaultPermissionMode`. Returns null when
95
150
  * the file is absent / the field is unset; same defensive behaviour