@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
|
@@ -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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate byte cap on the full rendered block. 96 KB = 3 files at
|
|
3
|
+
* the per-file cap, which is enough for cwd + parent + homedir while
|
|
4
|
+
* leaving plenty of prompt budget for the rest of the system prompt.
|
|
5
|
+
* Anything beyond is replaced with a truncation marker.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_INJECT_BYTES = 96 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Marker line emitted when the aggregate cap is hit. Visible to the
|
|
10
|
+
* model so it knows ambient context was clipped; visible to the
|
|
11
|
+
* operator via the doctor probe so they can decide whether to trim
|
|
12
|
+
* their `PUGI.md` hierarchy.
|
|
13
|
+
*/
|
|
14
|
+
export const TRUNCATION_MARKER = '<ambient-context-truncated reason="aggregate-cap" />';
|
|
15
|
+
/**
|
|
16
|
+
* Render a HierarchyFile array into the system-prompt block. Returns
|
|
17
|
+
* `''` when `files` is empty. Each file becomes one
|
|
18
|
+
* `<ambient-context source="..." level="...">...</ambient-context>`
|
|
19
|
+
* stanza separated by a single newline.
|
|
20
|
+
*
|
|
21
|
+
* Determinism: same input always produces byte-identical output.
|
|
22
|
+
*/
|
|
23
|
+
export function renderAmbientContext(files) {
|
|
24
|
+
if (files.length === 0)
|
|
25
|
+
return '';
|
|
26
|
+
const stanzas = [];
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
let truncated = false;
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const stanza = renderStanza(file);
|
|
31
|
+
const stanzaBytes = Buffer.byteLength(stanza, 'utf8') + 1; // newline join cost
|
|
32
|
+
if (bytes + stanzaBytes > MAX_INJECT_BYTES) {
|
|
33
|
+
truncated = true;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
stanzas.push(stanza);
|
|
37
|
+
bytes += stanzaBytes;
|
|
38
|
+
}
|
|
39
|
+
if (truncated)
|
|
40
|
+
stanzas.push(TRUNCATION_MARKER);
|
|
41
|
+
return stanzas.join('\n');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build a single `<ambient-context>` stanza for one HierarchyFile.
|
|
45
|
+
* The `source` attribute carries the absolute path (after realpath)
|
|
46
|
+
* so the model can cite which file a piece of guidance came from
|
|
47
|
+
* when it explains its decisions to the operator.
|
|
48
|
+
*/
|
|
49
|
+
function renderStanza(file) {
|
|
50
|
+
const sourceAttr = escapeAttr(file.path);
|
|
51
|
+
const levelAttr = String(file.level);
|
|
52
|
+
// No trailing newline inside `content` — the join adds one between
|
|
53
|
+
// stanzas. Trimming the file's trailing whitespace keeps the tag
|
|
54
|
+
// close to the content for readability when an engineer dumps the
|
|
55
|
+
// assembled prompt for debugging.
|
|
56
|
+
const trimmed = file.content.replace(/\s+$/g, '');
|
|
57
|
+
return [
|
|
58
|
+
`<ambient-context source="${sourceAttr}" level="${levelAttr}">`,
|
|
59
|
+
trimmed,
|
|
60
|
+
`</ambient-context>`,
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Escape an XML attribute value. We expect operator-controlled paths
|
|
65
|
+
* (not adversarial input) but `&`, `"` and `<` are still possible in
|
|
66
|
+
* symlinked / unicode paths so we escape them defensively. The model
|
|
67
|
+
* has been trained to read this attribute as opaque metadata.
|
|
68
|
+
*/
|
|
69
|
+
function escapeAttr(value) {
|
|
70
|
+
return value
|
|
71
|
+
.replace(/&/g, '&')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/>/g, '>');
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=context-injector.js.map
|