@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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi login --provider env` — env-var auth path (Leak L35).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code, Codex CLI, and gh CLI all ship a way to authenticate via
|
|
5
|
+
* an environment variable so CI / container / scripted contexts can
|
|
6
|
+
* skip the device flow entirely. This module backs that path:
|
|
7
|
+
*
|
|
8
|
+
* 1. Resolve the candidate token (explicit `--key` flag beats
|
|
9
|
+
* `PUGI_API_KEY` env — same precedence as `gh auth login --token`).
|
|
10
|
+
* 2. Run a cheap local format check so an obviously malformed key
|
|
11
|
+
* (empty, whitespace, suspiciously short) fails fast WITHOUT
|
|
12
|
+
* shipping it to the server (no observability leak into the
|
|
13
|
+
* Anvil access log).
|
|
14
|
+
* 3. Call `GET /api/pugi/health` with `Authorization: Bearer <key>`
|
|
15
|
+
* so an expired / revoked / typo'd token surfaces immediately
|
|
16
|
+
* and the credential file never lands on disk for a dead key.
|
|
17
|
+
* 4. Map response to typed outcome the CLI dispatcher can render.
|
|
18
|
+
*
|
|
19
|
+
* The module is intentionally pure — fetch + reading env are injected,
|
|
20
|
+
* the writer is a separate concern. The CLI dispatcher composes
|
|
21
|
+
* `resolveEnvCandidateToken` + `assertTokenFormat` + `validateTokenAgainstHealth`
|
|
22
|
+
* and then writes the credential via `storeApiKey` on success.
|
|
23
|
+
*
|
|
24
|
+
* Failure modes are explicit so the dispatcher can pick the user-facing
|
|
25
|
+
* remediation string without re-parsing strings:
|
|
26
|
+
*
|
|
27
|
+
* - `missing` → no token in env or --key, halt with hint
|
|
28
|
+
* - `invalid-format` → token failed local format check, halt
|
|
29
|
+
* - `unauthorized` → server rejected the token (401 / 403)
|
|
30
|
+
* - `network-error` → fetch threw (DNS, refused, TLS)
|
|
31
|
+
* - `server-error` → server returned 5xx (transient — operator
|
|
32
|
+
* may want to retry once)
|
|
33
|
+
* - `unexpected-status`→ anything else non-2xx (treat as failure)
|
|
34
|
+
*
|
|
35
|
+
* NEVER log the raw token. Memory hits
|
|
36
|
+
* `feedback_no_claude_attribution_anywhere_hard_rule` plus the
|
|
37
|
+
* CSO bearer-leak sweep apply here. Use `maskApiKey` from
|
|
38
|
+
* `core/credentials.ts` when the dispatcher needs to surface the key
|
|
39
|
+
* to the operator.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The minimum length below which we refuse to even ship the token to
|
|
43
|
+
* the server. Pugi-issued PATs are 48+ chars (`pugi_<32 base32>`), JWTs
|
|
44
|
+
* issued by the device flow are ~250 chars, legacy `sk-*` PATs we
|
|
45
|
+
* accept for compatibility are 32+. 16 is well below all three real
|
|
46
|
+
* shapes so it only catches obvious paste mistakes.
|
|
47
|
+
*/
|
|
48
|
+
export const MIN_TOKEN_LENGTH = 16;
|
|
49
|
+
/**
|
|
50
|
+
* The set of prefixes we recognise as plausibly-real Pugi-shaped
|
|
51
|
+
* tokens. Loose by design — the real validator is the server-side
|
|
52
|
+
* health probe. We just want to catch an operator who pasted the
|
|
53
|
+
* wrong string entirely (a username, a URL, a placeholder like
|
|
54
|
+
* "<your-key>") before it reaches the network.
|
|
55
|
+
*
|
|
56
|
+
* Three-segment JWTs are also accepted via the `looksLikeJwt`
|
|
57
|
+
* predicate so device-flow tokens copied out of `~/.pugi/credentials.json`
|
|
58
|
+
* on a different machine work.
|
|
59
|
+
*/
|
|
60
|
+
export const RECOGNISED_TOKEN_PREFIXES = ['pugi_', 'sk_', 'sk-', 'pat_'];
|
|
61
|
+
/**
|
|
62
|
+
* Returns the trimmed candidate token, or `null` when neither path
|
|
63
|
+
* produced one. Precedence: explicit flag arg beats env var (matches
|
|
64
|
+
* `gh auth login --with-token`, `aws configure set`, and `pugi config`
|
|
65
|
+
* which all prefer the most-specific operator intent over the ambient
|
|
66
|
+
* env).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveEnvCandidateToken(input) {
|
|
69
|
+
const explicit = input.explicitKey?.trim();
|
|
70
|
+
if (explicit)
|
|
71
|
+
return explicit;
|
|
72
|
+
const env = input.env ?? process.env;
|
|
73
|
+
const fromEnv = env.PUGI_API_KEY?.trim();
|
|
74
|
+
if (fromEnv)
|
|
75
|
+
return fromEnv;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Local-only format check. Returns `null` on accept, a human-readable
|
|
80
|
+
* error string on reject. Deliberately lenient — the server-side
|
|
81
|
+
* health probe is the source of truth. We only catch obvious paste
|
|
82
|
+
* mistakes (empty, whitespace-laden, too short, looks like a URL or
|
|
83
|
+
* a placeholder).
|
|
84
|
+
*/
|
|
85
|
+
export function assertTokenFormat(token) {
|
|
86
|
+
if (!token)
|
|
87
|
+
return 'Token is empty';
|
|
88
|
+
if (/\s/.test(token)) {
|
|
89
|
+
return 'Token contains whitespace — check for shell quoting issues or a stray newline';
|
|
90
|
+
}
|
|
91
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
92
|
+
return `Token too short (${token.length} chars; Pugi tokens are >= ${MIN_TOKEN_LENGTH})`;
|
|
93
|
+
}
|
|
94
|
+
if (token.startsWith('<') && token.endsWith('>')) {
|
|
95
|
+
return 'Token looks like a placeholder (`<your-key>`) — replace with the actual key';
|
|
96
|
+
}
|
|
97
|
+
if (/^https?:\/\//i.test(token)) {
|
|
98
|
+
return 'Token looks like a URL — did you mean --api-url?';
|
|
99
|
+
}
|
|
100
|
+
// Accept either a recognised prefix OR a JWT three-segment shape.
|
|
101
|
+
// Anything else still passes — the server probe will catch genuinely
|
|
102
|
+
// unknown keys. We just want to surface an obvious mistake.
|
|
103
|
+
const hasKnownPrefix = RECOGNISED_TOKEN_PREFIXES.some((p) => token.startsWith(p));
|
|
104
|
+
if (!hasKnownPrefix && !looksLikeJwt(token)) {
|
|
105
|
+
// Soft-fail: warn the operator but proceed. Returning null here
|
|
106
|
+
// would mask the case where the operator pasted something
|
|
107
|
+
// genuinely wrong but the server happens to accept it (impossible
|
|
108
|
+
// for real keys but defence-in-depth). Returning the warning
|
|
109
|
+
// string would block legacy keys. We choose to proceed — the
|
|
110
|
+
// server is the source of truth — and let the CLI dispatcher
|
|
111
|
+
// decide whether to surface a note. Tracked via a separate
|
|
112
|
+
// `warnUnknownPrefix` return on a future revision.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* JWT three-segment check. Does NOT verify the signature — we just
|
|
119
|
+
* want to recognise the shape so device-flow tokens copied from one
|
|
120
|
+
* machine to another pass the format gate.
|
|
121
|
+
*/
|
|
122
|
+
export function looksLikeJwt(token) {
|
|
123
|
+
const parts = token.split('.');
|
|
124
|
+
if (parts.length !== 3)
|
|
125
|
+
return false;
|
|
126
|
+
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 0);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Call `GET /api/pugi/health` with the candidate token. Returns a
|
|
130
|
+
* typed outcome that the CLI dispatcher can map directly to an exit
|
|
131
|
+
* code + remediation string.
|
|
132
|
+
*
|
|
133
|
+
* Health endpoint conventions (see apps/admin-api):
|
|
134
|
+
* - 200 → token is valid, account is active
|
|
135
|
+
* - 401 → token unknown / malformed at the server boundary
|
|
136
|
+
* - 403 → token recognised but the account is suspended / paused
|
|
137
|
+
* - 5xx → server-side issue, operator can retry
|
|
138
|
+
* - network throw → DNS, refused, TLS — operator's connectivity issue
|
|
139
|
+
*
|
|
140
|
+
* We do not parse the body — the health endpoint's contract is the
|
|
141
|
+
* status code. Any future field (latency, region, build sha) can be
|
|
142
|
+
* surfaced by a separate `pugi doctor` probe without touching the
|
|
143
|
+
* login path.
|
|
144
|
+
*/
|
|
145
|
+
export async function validateTokenAgainstHealth(input) {
|
|
146
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
147
|
+
const now = input.now ?? Date.now;
|
|
148
|
+
const url = `${stripTrailingSlash(input.apiUrl)}/api/pugi/health`;
|
|
149
|
+
const started = now();
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetchImpl(url, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
156
|
+
Accept: 'application/json',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
// DNS failure, ECONNREFUSED, TLS handshake — anything that makes
|
|
162
|
+
// fetch throw before a status code is observable. We deliberately
|
|
163
|
+
// do NOT echo the URL host in the message body if it could leak a
|
|
164
|
+
// self-hosted Anvil hostname into a public CI log; the dispatcher
|
|
165
|
+
// composes the user-facing remediation.
|
|
166
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
167
|
+
return {
|
|
168
|
+
kind: 'network-error',
|
|
169
|
+
message: `Cannot reach ${input.apiUrl}; check your connection`,
|
|
170
|
+
cause,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const latencyMs = now() - started;
|
|
174
|
+
const { status } = response;
|
|
175
|
+
if (status === 200) {
|
|
176
|
+
return { kind: 'ok', latencyMs };
|
|
177
|
+
}
|
|
178
|
+
if (status === 401 || status === 403) {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'unauthorized',
|
|
181
|
+
status,
|
|
182
|
+
message: status === 401
|
|
183
|
+
? 'Token invalid or expired — run `pugi login --provider device` to get a fresh one'
|
|
184
|
+
: 'Token recognised but the account is suspended — check `pugi whoami` on a working machine or contact support',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (status >= 500) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'server-error',
|
|
190
|
+
status,
|
|
191
|
+
message: `${input.apiUrl} returned ${status}; retry in a moment`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
kind: 'unexpected-status',
|
|
196
|
+
status,
|
|
197
|
+
message: `Unexpected ${status} from /api/pugi/health; treat as login failure`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function resolveAndValidateEnvLogin(input) {
|
|
201
|
+
const token = resolveEnvCandidateToken({
|
|
202
|
+
explicitKey: input.explicitKey,
|
|
203
|
+
env: input.env,
|
|
204
|
+
});
|
|
205
|
+
if (!token) {
|
|
206
|
+
return {
|
|
207
|
+
kind: 'missing',
|
|
208
|
+
message: 'pugi login --provider env requires a token. Export PUGI_API_KEY in the current shell or pass --key <value>.',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const formatError = assertTokenFormat(token);
|
|
212
|
+
if (formatError) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'invalid-format',
|
|
215
|
+
message: `pugi login --provider env: ${formatError}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (input.skipValidate) {
|
|
219
|
+
// Used by the existing `login-variants.spec.ts` regression suite
|
|
220
|
+
// so the test plane does not require a live network. Production
|
|
221
|
+
// path always validates.
|
|
222
|
+
return { kind: 'ok', token, latencyMs: 0 };
|
|
223
|
+
}
|
|
224
|
+
const probe = await validateTokenAgainstHealth({
|
|
225
|
+
apiUrl: input.apiUrl,
|
|
226
|
+
apiKey: token,
|
|
227
|
+
fetchImpl: input.fetchImpl,
|
|
228
|
+
now: input.now,
|
|
229
|
+
});
|
|
230
|
+
if (probe.kind === 'ok') {
|
|
231
|
+
return { kind: 'ok', token, latencyMs: probe.latencyMs };
|
|
232
|
+
}
|
|
233
|
+
return probe;
|
|
234
|
+
}
|
|
235
|
+
function stripTrailingSlash(url) {
|
|
236
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=env-provider.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leak L22 (2026-05-27) — `--bare` mode predicate.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of Claude Code's `--bare` flag: when active the CLI behaves
|
|
5
|
+
* like a plain LLM frontend with NO project auto-discovery. Useful for:
|
|
6
|
+
*
|
|
7
|
+
* - headless scripting where the operator wants deterministic, repo-
|
|
8
|
+
* independent behavior (`pugi --bare --print "..."`),
|
|
9
|
+
* - dropping into a workspace without auto-creating `.pugi/`,
|
|
10
|
+
* - REPL sessions that should NOT inject ambient `PUGI.md` / `CLAUDE.md`
|
|
11
|
+
* into the model prompt,
|
|
12
|
+
* - support / triage flows where the engineer needs the CLI to act
|
|
13
|
+
* like a fresh install regardless of where it's invoked.
|
|
14
|
+
*
|
|
15
|
+
* Discovery surfaces gated by `isBareMode()`:
|
|
16
|
+
*
|
|
17
|
+
* 1. `PUGI.md` / `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` parent-dir
|
|
18
|
+
* walk-up (`loadTraversedMarkdown` in `core/context/markdown-traverse.ts`).
|
|
19
|
+
* 2. Workspace-root markdown context (`loadMarkdownContext` consumers).
|
|
20
|
+
* 3. Auto-init `.pugi/` scaffold on REPL boot in untouched dirs.
|
|
21
|
+
* 4. Persona / skill auto-load from `.pugi/skills/`.
|
|
22
|
+
* 5. Workspace summary (`readPugiSummary`) read on REPL session start.
|
|
23
|
+
*
|
|
24
|
+
* Activation precedence — the bare bit is "sticky" once set so any
|
|
25
|
+
* subprocess the CLI spawns inherits it without re-passing the flag:
|
|
26
|
+
*
|
|
27
|
+
* 1. Top-level `--bare` arg parsed by `parseArgs` in `runtime/cli.ts`.
|
|
28
|
+
* The parser sets `process.env.PUGI_BARE='1'` BEFORE the dispatch
|
|
29
|
+
* flows so callsites checking the env see the activated state.
|
|
30
|
+
* 2. `PUGI_BARE=1` env var (any value matching `/^(1|true|yes|on)$/i`).
|
|
31
|
+
* 3. Default: bare mode OFF — full auto-discovery as before.
|
|
32
|
+
*
|
|
33
|
+
* This mirrors the existing `PUGI_SKIP_SPLASH` / `PUGI_NO_AUTO_INIT`
|
|
34
|
+
* env-flag pattern so the bare module fits the rest of the runtime
|
|
35
|
+
* configuration grammar without inventing a new wire.
|
|
36
|
+
*
|
|
37
|
+
* Test surface: `apps/pugi-cli/test/bare-mode.spec.ts` exercises the
|
|
38
|
+
* env precedence, value parsing, and the explicit-set / clear helpers.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Env var consulted by `isBareMode()`. Kept as an export so the spec
|
|
42
|
+
* + the runtime CLI can use the same constant — no string-typing of
|
|
43
|
+
* the wire name across modules.
|
|
44
|
+
*/
|
|
45
|
+
export const PUGI_BARE_ENV = 'PUGI_BARE';
|
|
46
|
+
/**
|
|
47
|
+
* Truthy values recognised on the `PUGI_BARE` env. Anything else
|
|
48
|
+
* (empty string, `0`, `false`, `no`, `off`, `disabled`, undefined) is
|
|
49
|
+
* treated as bare-mode OFF. The list is intentionally short — the
|
|
50
|
+
* value is set by the CLI parser and is not customer-typed prose, so
|
|
51
|
+
* we do not need a permissive boolean coercion.
|
|
52
|
+
*/
|
|
53
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
54
|
+
/**
|
|
55
|
+
* Return true when bare mode is active for the current process. Reads
|
|
56
|
+
* `process.env[PUGI_BARE_ENV]` and applies the truthy-value match.
|
|
57
|
+
*
|
|
58
|
+
* Safe to call from any module (no FS, no side-effects). The runtime
|
|
59
|
+
* cost is a single env-var lookup + lower-case + set membership, so
|
|
60
|
+
* gating hot-path callsites with `if (isBareMode()) return ...` adds
|
|
61
|
+
* effectively zero overhead in the default (non-bare) case.
|
|
62
|
+
*/
|
|
63
|
+
export function isBareMode(env = process.env) {
|
|
64
|
+
const raw = env[PUGI_BARE_ENV];
|
|
65
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
return TRUTHY.has(raw.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Explicitly activate bare mode for the current process. Called by
|
|
71
|
+
* `parseArgs` in `runtime/cli.ts` when `--bare` is seen on the command
|
|
72
|
+
* line so downstream modules (engine, REPL bootstrap, doctor probe)
|
|
73
|
+
* see a consistent activated state via `isBareMode()` regardless of
|
|
74
|
+
* whether the operator set the env var manually or used the flag.
|
|
75
|
+
*
|
|
76
|
+
* Subprocess inheritance is the reason we mutate `process.env` rather
|
|
77
|
+
* than threading a `bare: boolean` field through every call signature
|
|
78
|
+
* — every Node child_process spawn inherits `process.env` by default,
|
|
79
|
+
* so the bare bit propagates to MCP servers / hook scripts / git
|
|
80
|
+
* subprocesses without ceremony.
|
|
81
|
+
*/
|
|
82
|
+
export function setBareMode(env = process.env) {
|
|
83
|
+
env[PUGI_BARE_ENV] = '1';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Clear bare mode for the current process. Provided primarily for the
|
|
87
|
+
* spec so adjacent tests do not leak state between cases. Production
|
|
88
|
+
* code does NOT call this — bare mode is a one-shot per process.
|
|
89
|
+
*/
|
|
90
|
+
export function clearBareMode(env = process.env) {
|
|
91
|
+
delete env[PUGI_BARE_ENV];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Human-readable one-line banner printed by the dispatcher when bare
|
|
95
|
+
* mode is active and the invocation is NOT JSON-only. Kept as a single
|
|
96
|
+
* constant so the spec can assert the exact wording and downstream
|
|
97
|
+
* tools (status bars, doctor row, REPL header) stay in lockstep.
|
|
98
|
+
*/
|
|
99
|
+
export const BARE_MODE_BANNER = 'Pugi --bare mode: project auto-discovery disabled.';
|
|
100
|
+
/**
|
|
101
|
+
* Short label rendered inside the `pugi doctor` table when bare mode
|
|
102
|
+
* is active. The doctor probe surfaces `BARE MODE` as a separate row
|
|
103
|
+
* so operators triaging "why is Pugi ignoring my PUGI.md" see the
|
|
104
|
+
* cause without grep'ing the env.
|
|
105
|
+
*/
|
|
106
|
+
export const BARE_MODE_DOCTOR_LABEL = 'BARE MODE';
|
|
107
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BARE MODE probe — Leak L22 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the `--bare` activation state inside `pugi doctor`. The row is
|
|
5
|
+
* informational: bare mode is an opt-in operator choice, never an error.
|
|
6
|
+
* Operators triaging "why is Pugi ignoring my PUGI.md / why was the
|
|
7
|
+
* `.pugi/` scaffold skipped" see the cause without grep'ing the env.
|
|
8
|
+
*
|
|
9
|
+
* Status semantics:
|
|
10
|
+
* - `skipped` when bare mode is OFF (default). The probe stays silent
|
|
11
|
+
* in the table since there is nothing to report; the row still
|
|
12
|
+
* renders so the JSON consumer can read a stable schema.
|
|
13
|
+
* - `ok` when bare mode is ON via `--bare` or `PUGI_BARE=1`. The detail
|
|
14
|
+
* enumerates the surfaces that are currently bypassed.
|
|
15
|
+
*
|
|
16
|
+
* No I/O — pure env probe. Wired into `buildDefaultProbes` in
|
|
17
|
+
* `runtime/commands/doctor.ts`.
|
|
18
|
+
*/
|
|
19
|
+
import { isBareMode, BARE_MODE_DOCTOR_LABEL } from '../../bare-mode/index.js';
|
|
20
|
+
/**
|
|
21
|
+
* One-line summary printed in the `--bare` row when bare mode is on.
|
|
22
|
+
* Exported so the spec can assert the exact wording — operators reading
|
|
23
|
+
* `pugi doctor` should see the list of surfaces that the flag disables.
|
|
24
|
+
*/
|
|
25
|
+
export const BARE_MODE_ACTIVE_DETAIL = 'bare mode active: PUGI.md walk-up + auto-init + persona auto-load disabled';
|
|
26
|
+
export const BARE_MODE_INACTIVE_DETAIL = 'bare mode off (default auto-discovery)';
|
|
27
|
+
export function probeBareMode(input = {}) {
|
|
28
|
+
const env = input.env ?? process.env;
|
|
29
|
+
if (isBareMode(env)) {
|
|
30
|
+
return {
|
|
31
|
+
name: BARE_MODE_DOCTOR_LABEL,
|
|
32
|
+
status: 'ok',
|
|
33
|
+
detail: BARE_MODE_ACTIVE_DETAIL,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
name: BARE_MODE_DOCTOR_LABEL,
|
|
38
|
+
status: 'skipped',
|
|
39
|
+
detail: BARE_MODE_INACTIVE_DETAIL,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=bare-mode.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUGI.md hierarchy probe — Leak L32 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces how many ambient `PUGI.md` / `CLAUDE.md` files were
|
|
5
|
+
* discovered by the cwd → homedir walk-up. Operators triaging "why
|
|
6
|
+
* is the model not following my project conventions" can run
|
|
7
|
+
* `pugi doctor` and immediately see whether the hierarchy walk
|
|
8
|
+
* loaded the file they expected.
|
|
9
|
+
*
|
|
10
|
+
* Status semantics:
|
|
11
|
+
* - `skipped` when bare mode is active (the walk is deliberately
|
|
12
|
+
* disabled). The row still renders so the JSON schema stays
|
|
13
|
+
* stable for downstream consumers.
|
|
14
|
+
* - `skipped` when zero files were found. This is the default
|
|
15
|
+
* state on a clean machine and is NOT an error — most operators
|
|
16
|
+
* do not maintain a `~/PUGI.md`.
|
|
17
|
+
* - `ok` when one or more files were discovered. The detail names
|
|
18
|
+
* the closest file and the total count.
|
|
19
|
+
*
|
|
20
|
+
* Side effects:
|
|
21
|
+
* - One filesystem walk from cwd to homedir (bounded by the walker's
|
|
22
|
+
* depth cap). Each level performs at most 2 `existsSync` calls.
|
|
23
|
+
* Cost is single-digit ms even on cold cache; well inside the
|
|
24
|
+
* doctor probe wall-clock budget.
|
|
25
|
+
*
|
|
26
|
+
* Wired into `buildDefaultProbes` in `runtime/commands/doctor.ts`.
|
|
27
|
+
*/
|
|
28
|
+
import { isBareMode } from '../../bare-mode/index.js';
|
|
29
|
+
import { walkUpPugiMd } from '../../pugi-md/walk-up.js';
|
|
30
|
+
export const PUGI_MD_DOCTOR_LABEL = 'PUGI.md HIERARCHY';
|
|
31
|
+
/** Detail string emitted when bare mode disables the walk. */
|
|
32
|
+
export const PUGI_MD_BARE_SKIP_DETAIL = 'skipped (--bare)';
|
|
33
|
+
/** Detail string emitted when the walk ran but found nothing. */
|
|
34
|
+
export const PUGI_MD_EMPTY_DETAIL = 'no PUGI.md / CLAUDE.md found in cwd → homedir';
|
|
35
|
+
export function probePugiMdHierarchy(input = {}) {
|
|
36
|
+
const env = input.env ?? process.env;
|
|
37
|
+
if (isBareMode(env)) {
|
|
38
|
+
return {
|
|
39
|
+
name: PUGI_MD_DOCTOR_LABEL,
|
|
40
|
+
status: 'skipped',
|
|
41
|
+
detail: PUGI_MD_BARE_SKIP_DETAIL,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const cwd = input.cwd ?? process.cwd();
|
|
45
|
+
const walk = input.walkImpl ?? ((c, o) => walkUpPugiMd(c, o));
|
|
46
|
+
let files;
|
|
47
|
+
try {
|
|
48
|
+
files = walk(cwd, input.homedir !== undefined ? { homedir: input.homedir } : {});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Defensive: walker is wrapped in per-file try/catch already, so
|
|
52
|
+
// a throw here means a programmer error (bad input). Degrade to
|
|
53
|
+
// a `warn` row rather than crashing the doctor sweep.
|
|
54
|
+
return {
|
|
55
|
+
name: PUGI_MD_DOCTOR_LABEL,
|
|
56
|
+
status: 'warn',
|
|
57
|
+
detail: 'walk-up failed (see logs)',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (files.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
name: PUGI_MD_DOCTOR_LABEL,
|
|
63
|
+
status: 'skipped',
|
|
64
|
+
detail: PUGI_MD_EMPTY_DETAIL,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// `files` is shallow-to-deep; the first entry is the closest to cwd.
|
|
68
|
+
// Operators reading the table care most about that one — it is the
|
|
69
|
+
// file that will most directly influence model behaviour. The
|
|
70
|
+
// additional count gives a quick "is the homedir / parent picked up
|
|
71
|
+
// too?" signal without listing every path.
|
|
72
|
+
const closest = files[0];
|
|
73
|
+
// closest is guaranteed non-undefined: files.length > 0 enforced
|
|
74
|
+
// by the early return above.
|
|
75
|
+
if (!closest) {
|
|
76
|
+
return {
|
|
77
|
+
name: PUGI_MD_DOCTOR_LABEL,
|
|
78
|
+
status: 'skipped',
|
|
79
|
+
detail: PUGI_MD_EMPTY_DETAIL,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const suffix = files.length === 1 ? '' : ` (+${files.length - 1} more)`;
|
|
83
|
+
return {
|
|
84
|
+
name: PUGI_MD_DOCTOR_LABEL,
|
|
85
|
+
status: 'ok',
|
|
86
|
+
detail: `${files.length} file${files.length === 1 ? '' : 's'}: ${closest.path}${suffix}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=pugi-md.js.map
|
|
@@ -17,6 +17,9 @@ import { CancellationToken } from '../repl/cancellation.js';
|
|
|
17
17
|
import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
|
|
18
18
|
import { applyIntentMarker, classifyIntent } from './intent.js';
|
|
19
19
|
import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
|
|
20
|
+
import { isBareMode } from '../bare-mode/index.js';
|
|
21
|
+
import { walkUpPugiMd } from '../pugi-md/walk-up.js';
|
|
22
|
+
import { renderAmbientContext } from '../pugi-md/context-injector.js';
|
|
20
23
|
// α7 L11 (2026-05-27): per-session DenialTrackingState. One instance
|
|
21
24
|
// per `run()` so denials cluster by (tool, args) within the same
|
|
22
25
|
// command but do NOT leak across CLI invocations.
|
|
@@ -233,19 +236,53 @@ export class NativePugiEngineAdapter {
|
|
|
233
236
|
// accurate; the REPL session retains the launch cwd for the
|
|
234
237
|
// lifetime of the session which is what the operator expects.
|
|
235
238
|
const cwdForTraverse = process.cwd();
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
239
|
+
// Leak L32 (2026-05-27): cwd → homedir walk-up that picks up every
|
|
240
|
+
// ambient `PUGI.md` (or `CLAUDE.md` as a fallback) the operator
|
|
241
|
+
// has placed above their workspace. This is the cross-project
|
|
242
|
+
// hierarchy walk — distinct from the workspace-bounded
|
|
243
|
+
// `loadTraversedMarkdown` below which only sees files INSIDE the
|
|
244
|
+
// workspace root. Render the concatenation once at session boot
|
|
245
|
+
// and prepend to the system prompt so the model treats the
|
|
246
|
+
// operator's personal guidance as ambient context for the whole
|
|
247
|
+
// session. `--bare` (Leak L22) skips this walk entirely.
|
|
248
|
+
let ambientContextBlock = '';
|
|
249
|
+
if (!isBareMode()) {
|
|
250
|
+
try {
|
|
251
|
+
const hierarchy = walkUpPugiMd(cwdForTraverse);
|
|
252
|
+
ambientContextBlock = renderAmbientContext(hierarchy);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Pure FS surface — if it throws (programmer error in the
|
|
256
|
+
// walker, not a per-file fs error which is already swallowed
|
|
257
|
+
// inside) we drop ambient context for this session rather
|
|
258
|
+
// than crashing the engine loop. Doctor probe still surfaces
|
|
259
|
+
// the hierarchy state for operator triage.
|
|
260
|
+
ambientContextBlock = '';
|
|
261
|
+
}
|
|
242
262
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
263
|
+
let traverseResult;
|
|
264
|
+
// Leak L22 (2026-05-27): `--bare` skips the parent-dir PUGI.md /
|
|
265
|
+
// AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
|
|
266
|
+
// the operator's prompt + working-set + intent marker, with no
|
|
267
|
+
// ambient project context injection. Mirrors Claude Code's
|
|
268
|
+
// --bare semantics.
|
|
269
|
+
if (isBareMode()) {
|
|
247
270
|
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
248
271
|
}
|
|
272
|
+
else {
|
|
273
|
+
try {
|
|
274
|
+
traverseResult = await loadTraversedMarkdown({
|
|
275
|
+
cwd: cwdForTraverse,
|
|
276
|
+
workspaceRoot: root,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Per-dir markdown is a NICE-TO-HAVE; a fs error here must
|
|
281
|
+
// never break the engine loop. Fall back to an empty result
|
|
282
|
+
// so the prefix block still surfaces cwd + working set.
|
|
283
|
+
traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
249
286
|
const intentClassification = classifyIntent(task.prompt);
|
|
250
287
|
const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
|
|
251
288
|
const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
|
|
@@ -537,7 +574,14 @@ export class NativePugiEngineAdapter {
|
|
|
537
574
|
// pattern instead of re-issuing the same refused call.
|
|
538
575
|
denialTracking,
|
|
539
576
|
}),
|
|
540
|
-
|
|
577
|
+
// Leak L32 (2026-05-27): ambient `PUGI.md` hierarchy block
|
|
578
|
+
// prepended once at session boot. When the walk found
|
|
579
|
+
// nothing OR bare mode is on, `ambientContextBlock === ''`
|
|
580
|
+
// and the system prompt is unchanged — no leading blank
|
|
581
|
+
// line, no empty wrapper tag.
|
|
582
|
+
systemPrompt: ambientContextBlock
|
|
583
|
+
? `${ambientContextBlock}\n\n${systemPromptFor(kind)}`
|
|
584
|
+
: systemPromptFor(kind),
|
|
541
585
|
// β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
|
|
542
586
|
// applied above. Falls back to verbatim `task.prompt` when
|
|
543
587
|
// both the prefix block is empty AND the intent classifier
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getJobRegistry, summarizeJobsForPrompt, } from '../jobs/registry.js';
|
|
2
|
+
import { compileStyleBlock } from '../output-style/presets.js';
|
|
3
|
+
import { resolveOutputStyle } from '../output-style/state.js';
|
|
2
4
|
/**
|
|
3
5
|
* System prompts for each engine command. Each prompt:
|
|
4
6
|
* - Anchors the model in Pugi's local-first contract (ADR-0037).
|
|
@@ -92,10 +94,36 @@ export function systemPromptFor(kind) {
|
|
|
92
94
|
// voice gate are command-agnostic — a definitional question lands
|
|
93
95
|
// the same way under `pugi explain` and `pugi code`.
|
|
94
96
|
const withV2 = `${base}\n\n${PROMPT_V2_APPENDIX}`;
|
|
97
|
+
// Leak L18 (2026-05-27): output-style preset block. Compiled from
|
|
98
|
+
// workspace > user > default precedence. The `default` preset
|
|
99
|
+
// returns an empty block (no override over the base voice), so the
|
|
100
|
+
// injection is a no-op for the most-common case. Wrapped in
|
|
101
|
+
// try/catch — every IO failure inside `resolveOutputStyle` already
|
|
102
|
+
// degrades to the default slug, but defence in depth keeps the
|
|
103
|
+
// engine prompt assembly from ever crashing on a hand-edited
|
|
104
|
+
// config.json.
|
|
105
|
+
const styleBlock = formatOutputStyleBlock();
|
|
106
|
+
const withStyle = styleBlock ? `${withV2}\n\n${styleBlock}` : withV2;
|
|
95
107
|
const snapshot = formatBackgroundJobsSnapshot(getJobRegistrySafely());
|
|
96
108
|
if (!snapshot)
|
|
97
|
-
return
|
|
98
|
-
return `${
|
|
109
|
+
return withStyle;
|
|
110
|
+
return `${withStyle}\n\n${snapshot}`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the active output-style preset for the current process
|
|
114
|
+
* and compile it into a prompt block. Returns empty string for the
|
|
115
|
+
* default preset OR for any unexpected IO failure so the engine
|
|
116
|
+
* prompt assembly path is unaffected by a missing / malformed
|
|
117
|
+
* config.json.
|
|
118
|
+
*/
|
|
119
|
+
function formatOutputStyleBlock() {
|
|
120
|
+
try {
|
|
121
|
+
const resolved = resolveOutputStyle({ workspaceRoot: process.cwd() });
|
|
122
|
+
return compileStyleBlock(resolved.slug);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
99
127
|
}
|
|
100
128
|
function baseSystemPromptFor(kind) {
|
|
101
129
|
switch (kind) {
|