@pugi/cli 0.1.0-beta.15 → 0.1.0-beta.17

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.
@@ -2604,7 +2604,7 @@ export function synthesiseToolCall(input) {
2604
2604
  // Pattern: ToolName(args) optionally suffixed with a result hint.
2605
2605
  // We allow the canonical Claude Code casing AND the snake_case
2606
2606
  // alias `web_fetch` so the synthesiser matches what personas write.
2607
- const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2607
+ const match = /^(Read|Write|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
2608
2608
  .exec(detail);
2609
2609
  if (!match)
2610
2610
  return null;
@@ -2628,6 +2628,8 @@ function normaliseToolName(raw) {
2628
2628
  return 'web_fetch';
2629
2629
  if (lower === 'read')
2630
2630
  return 'read';
2631
+ if (lower === 'write')
2632
+ return 'write';
2631
2633
  if (lower === 'edit')
2632
2634
  return 'edit';
2633
2635
  if (lower === 'bash')
@@ -2853,7 +2855,22 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2853
2855
  // Escape regex specials in the display name even though THE_TEN
2854
2856
  // names are alpha-only today (forward-defense).
2855
2857
  const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2858
+ // Match `<DisplayName>` (case-insensitive) followed by EITHER:
2859
+ // - an end-of-string, OR
2860
+ // - a separator (whitespace / comma / colon / dash / period+space).
2861
+ // The `i` flag is needed so a model writing "PUGI:" or "pugi," still
2862
+ // strips. After this match the post-fix `noSepUppercaseRe` handles
2863
+ // the "PugiПринял" / "PugiHello" no-separator emission pattern
2864
+ // (CEO red-alert 2026-05-27) using a SEPARATE regex without the `i`
2865
+ // flag so the lookahead is case-strict (Pugineous must NOT strip).
2856
2866
  const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
2867
+ // No-separator case-strict matcher. Display name in either of its
2868
+ // canonical casings ("Pugi" / "PUGI") immediately followed by an
2869
+ // uppercase Cyrillic or Latin letter. The strip is intentionally
2870
+ // narrower than the case-insensitive `re` above because a lowercase
2871
+ // continuation ("Pugineous") is a single word, not a display-name
2872
+ // echo - we must not eat real content.
2873
+ const noSepUppercaseRe = new RegExp(`^(?:${escaped}|${escaped.toUpperCase()})(?=[А-ЯЁA-Z])`);
2857
2874
  // Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
2858
2875
  // collapse to a single name. The model occasionally emits the display
2859
2876
  // name two or three times back-to-back when the pane prefix also
@@ -2865,10 +2882,18 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
2865
2882
  // matches an empty string (defence-in-depth even though the current
2866
2883
  // pattern guarantees at least one consumed char).
2867
2884
  for (let i = 0; i < 3; i += 1) {
2868
- const m = re.exec(working);
2869
- if (!m || m[0].length === 0)
2870
- break;
2871
- working = working.slice(m[0].length).trimStart();
2885
+ let m = re.exec(working);
2886
+ if (m && m[0].length > 0) {
2887
+ working = working.slice(m[0].length).trimStart();
2888
+ continue;
2889
+ }
2890
+ // Fallback: no-separator match for "PugiПринял" / "PugiHello" shape.
2891
+ m = noSepUppercaseRe.exec(working);
2892
+ if (m && m[0].length > 0) {
2893
+ working = working.slice(m[0].length);
2894
+ continue;
2895
+ }
2896
+ break;
2872
2897
  }
2873
2898
  return working;
2874
2899
  }
@@ -29,6 +29,7 @@ import { runDeployCommand } from '../commands/deploy.js';
29
29
  import { runJobsCommand } from '../commands/jobs.js';
30
30
  import { runConfigCommand } from './commands/config.js';
31
31
  import { runPrivacyCommand } from './commands/privacy.js';
32
+ import { runReport } from './commands/report.js';
32
33
  import { runUndoCommand } from './commands/undo.js';
33
34
  import { runBudgetCommand } from './commands/budget.js';
34
35
  import { runSkillsCommand } from './commands/skills.js';
@@ -90,6 +91,10 @@ const handlers = {
90
91
  plan: runEngineTask('plan'),
91
92
  'plan-review': dispatchPlanReview,
92
93
  privacy: dispatchPrivacy,
94
+ // PAVF-7 (2026-05-27): `pugi report --from-error` captures the
95
+ // most-recent failed session as a redacted bundle so operators can
96
+ // file clean bug reports without manual log-grepping.
97
+ report: dispatchReport,
93
98
  review,
94
99
  resume,
95
100
  roster: dispatchRoster,
@@ -271,6 +276,25 @@ async function dispatchPrivacy(args, flags, _session) {
271
276
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
272
277
  });
273
278
  }
279
+ /**
280
+ * PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
281
+ * recent failed session into a redacted local report so operators can
282
+ * file clean bug tickets without manual log-grepping. v1 is local-only
283
+ * (no auto-upload — see commands/report.ts header for the rationale).
284
+ */
285
+ async function dispatchReport(args, flags, _session) {
286
+ const rc = runReport(args, {
287
+ cwd: process.cwd(),
288
+ json: flags.json,
289
+ emit: (line) => {
290
+ if (!flags.json)
291
+ process.stdout.write(line);
292
+ },
293
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
294
+ });
295
+ if (rc !== 0)
296
+ process.exitCode = rc;
297
+ }
274
298
  /**
275
299
  * `pugi roster` - α7.5 Phase 1.
276
300
  *
@@ -973,6 +997,16 @@ const COMMAND_HELP_BODIES = {
973
997
  'event log, settings), permission mode, and the capability matrix per',
974
998
  'engine adapter. Safe to run anywhere; no network calls.',
975
999
  ],
1000
+ report: [
1001
+ 'pugi report — capture a bug report from the most-recent session.',
1002
+ '',
1003
+ ' --from-error Bundle the most-recent failed session as a',
1004
+ ' redacted local report (default + only mode in v1).',
1005
+ '',
1006
+ 'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
1007
+ 'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
1008
+ 'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
1009
+ ],
976
1010
  ask: [
977
1011
  'pugi ask "<question>" — surface a yes/no question modal locally.',
978
1012
  '',
@@ -2140,12 +2174,45 @@ async function performTripleProviderReview(root, session, flags, prompt) {
2140
2174
  `Refusing to submit an empty diff for review.`);
2141
2175
  }
2142
2176
  const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
2143
- const mergeBase = safeGit(root, ['merge-base', baseRef, commitRef]).trim() || '';
2177
+ // merge-base is intentionally a PROBE: an empty result is a valid
2178
+ // signal (orphan branch, shallow clone, moved tag) that the dispatch
2179
+ // path handles by falling back к range-notation. Use the legacy
2180
+ // `safeGit` (probe semantics) explicitly rather than the strict
2181
+ // variant.
2182
+ const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
2183
+ // 2026-05-27 (Claude review followup #489): when merge-base returns empty
2184
+ // (orphan branch, shallow clone, moved tag), we MUST NOT pass the
2185
+ // `<range> <commitRef>` two-arg form to `git diff` — that combo is
2186
+ // invalid syntax, git exits 129, `safeGit` swallows the error, and the
2187
+ // diff payload ships empty. An empty diff is then classified as
2188
+ // `'code'` server-side, dispatched to reviewers who emit a trivial
2189
+ // `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
2190
+ // nobody actually examined. Branch on `mergeBase` так что:
2191
+ // - mergeBase present → `git diff <mergeBase> <commitRef> --`
2192
+ // (both endpoints explicit, only-uncommitted-against-base ignored
2193
+ // because commitRef is a SHA, not HEAD).
2194
+ // - mergeBase empty → `git diff <baseRef>..<commitRef> --`
2195
+ // (range form encodes both endpoints; do NOT append commitRef
2196
+ // again or git rejects the args).
2144
2197
  const diffRange = mergeBase || `${baseRef}..${commitRef}`;
2145
- const diffArgs = ['diff', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2146
- const diffStatArgs = ['diff', '--shortstat', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2147
- const diffPatch = safeGit(root, diffArgs);
2148
- const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
2198
+ const diffArgs = mergeBase
2199
+ ? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
2200
+ : ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2201
+ const diffStatArgs = mergeBase
2202
+ ? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
2203
+ : ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2204
+ // Use the strict variant — a non-empty diffPatch is load-bearing for
2205
+ // the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
2206
+ // permission), we'd rather surface a hard error than ship a green
2207
+ // review on nothing. The `--shortstat` companion uses the same
2208
+ // helper so the throw is symmetric.
2209
+ const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
2210
+ const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
2211
+ if (diffPatch.trim() === '') {
2212
+ throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
2213
+ `Refusing to dispatch a review for zero changes — check the refs ` +
2214
+ `or commit your changes before running.`);
2215
+ }
2149
2216
  const requestBody = pugiTripleReviewRequestSchema.parse({
2150
2217
  schema: 1,
2151
2218
  workspace: {
@@ -5039,7 +5106,31 @@ function fileBytes(path) {
5039
5106
  return 0;
5040
5107
  }
5041
5108
  }
5042
- function safeGit(root, args) {
5109
+ /**
5110
+ * Git invocation helpers — probe vs required semantics.
5111
+ *
5112
+ * 2026-05-27 (Claude review followup #489): the historical `safeGit`
5113
+ * collapsed BOTH "tell me the branch name if you can" probes AND
5114
+ * "give me the diff or fail" hard requirements into a single helper
5115
+ * that swallowed every error as an empty string. That's the correct
5116
+ * shape for the probe case (branch / status / dirty flag — empty
5117
+ * result is a valid signal) but catastrophically wrong for the diff
5118
+ * case (empty result === false PASS on a commit nobody reviewed).
5119
+ *
5120
+ * The split:
5121
+ * - `safeGitProbe` — best-effort. Returns '' on any error. Use for
5122
+ * branch name lookups, status probes, opt-in dirty detection.
5123
+ * - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
5124
+ * Use for diff, merge-base resolution, anything whose empty
5125
+ * output would silently corrupt downstream behaviour.
5126
+ *
5127
+ * Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
5128
+ * so existing call-sites (branch detection, status, etc.) keep their
5129
+ * tolerant semantics until they are individually migrated. Diff /
5130
+ * merge-base / rev-parse-verify call-sites are migrated к
5131
+ * `safeGitRequired` in this same patch.
5132
+ */
5133
+ export function safeGitProbe(root, args) {
5043
5134
  try {
5044
5135
  return execFileSync('git', args, {
5045
5136
  cwd: root,
@@ -5057,6 +5148,38 @@ function safeGit(root, args) {
5057
5148
  return '';
5058
5149
  }
5059
5150
  }
5151
+ /**
5152
+ * Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
5153
+ * failure. The thrown error carries the operation context so the
5154
+ * caller (triple-review dispatch, etc.) can fail loud rather than
5155
+ * ship an empty diff to a remote reviewer.
5156
+ */
5157
+ export function safeGitRequired(root, args, context) {
5158
+ try {
5159
+ return execFileSync('git', args, {
5160
+ cwd: root,
5161
+ encoding: 'utf8',
5162
+ stdio: ['ignore', 'pipe', 'pipe'],
5163
+ maxBuffer: 64 * 1024 * 1024,
5164
+ });
5165
+ }
5166
+ catch (err) {
5167
+ const cause = err instanceof Error ? err.message : String(err);
5168
+ throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
5169
+ `Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
5170
+ }
5171
+ }
5172
+ /**
5173
+ * Deprecated alias preserved for diff / status / branch probes that
5174
+ * legitimately want a tolerant empty-string-on-error shape. New call
5175
+ * sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
5176
+ *
5177
+ * @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
5178
+ * `safeGitRequired` (strict, throws).
5179
+ */
5180
+ function safeGit(root, args) {
5181
+ return safeGitProbe(root, args);
5182
+ }
5060
5183
  /**
5061
5184
  * Glob patterns excluded from triple-review `diffPatch` before egress.
5062
5185
  *
@@ -0,0 +1,299 @@
1
+ /**
2
+ * PAVF-7 — `pugi report --from-error` field-bug capture.
3
+ *
4
+ * Operator hit a CLI failure ("pugi explain: failed [auth_missing]...")
5
+ * and wants to file a clean report без manual log-grepping. This command:
6
+ *
7
+ * 1. Locates the most-recently-modified session under .pugi/sessions/
8
+ * (the engine adapter mirrors EVERY dispatch's events to a fresh
9
+ * session dir; the latest one is always the failure that just
10
+ * surprised the operator).
11
+ * 2. Reads events.jsonl + extracts the terminal-state event +
12
+ * the last 50 frames before it (enough context to triage; small
13
+ * enough for a GH issue body or email paste).
14
+ * 3. Captures workspace metadata (CLI version, Node version, OS,
15
+ * tenant id from credentials, current dir, .pugi/PUGI.md presence).
16
+ * 4. Strips secrets — auth tokens, env values, JWT signatures —
17
+ * before the report ever touches disk OR network.
18
+ * 5. Writes the bundle к .pugi/reports/<ISO-timestamp>-<session-id>/
19
+ * with both a machine-readable report.json and a human-readable
20
+ * report.md the operator can paste into a GH issue / email.
21
+ * 6. Prints the path + the canonical share command the operator can
22
+ * run when ready to upload (the upload endpoint is deferred to a
23
+ * follow-up; v1 keeps everything LOCAL so an operator working
24
+ * offline / behind a corporate firewall can still file a clean
25
+ * report).
26
+ *
27
+ * Why not auto-upload in v1:
28
+ * The CEO HARD rule `feedback_no_fake_dispatch_promises` says we do
29
+ * not invent dispatch we cannot deliver. Without a live
30
+ * /api/pugi/report endpoint, an auto-upload would either silently
31
+ * no-op or claim shipped и lie. v1 emits the artifacts + a clear
32
+ * "upload pending" status; v2 (separate PR) wires the endpoint и
33
+ * flips the default к upload-on-success.
34
+ *
35
+ * Exit codes (match the existing PAVF-1 stage_code table):
36
+ * 0 = report written successfully
37
+ * 8 = no sessions found (operator ran in a workspace без .pugi/)
38
+ * 9 = session events.jsonl unreadable / corrupted
39
+ * 20 = output path not writable (disk full / perms)
40
+ *
41
+ * Secret-redaction posture: PII / tokens / env values are stripped at
42
+ * the report-generation layer, NOT at upload time. Even if the operator
43
+ * never uploads, the report dir on disk MUST NOT carry plaintext
44
+ * secrets — a colleague who later runs `cat .pugi/reports/.../report.md`
45
+ * over the shoulder sees the bug context but not the bearer token.
46
+ */
47
+ import { existsSync, readdirSync, readFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
48
+ import { join, resolve as resolvePath } from 'node:path';
49
+ import { homedir, platform, release } from 'node:os';
50
+ import { PUGI_CLI_VERSION } from '../version.js';
51
+ const MAX_TAIL_FRAMES = 50;
52
+ const MAX_DETAIL_CHARS = 400;
53
+ const TERMINAL_TYPES = new Set([
54
+ 'agent.completed',
55
+ 'agent.failed',
56
+ 'agent.blocked',
57
+ 'subagent.outcome',
58
+ 'result',
59
+ ]);
60
+ /**
61
+ * Bearer / JWT / env-secret patterns. We do NOT try to be exhaustive
62
+ * (cat-and-mouse with custom secret formats is unwinnable); we cover
63
+ * the shapes that actually appear in Pugi sessions:
64
+ *
65
+ * - `Authorization: Bearer eyJ...` (JWT header.payload.signature)
66
+ * - `apiKey: eyJ...` inside captured JSON envelopes
67
+ * - any long base64-ish token (>= 20 chars, [A-Za-z0-9_-]) following
68
+ * `token`, `password`, `secret`, or `key` field names
69
+ *
70
+ * Replacement is a length-preserving `[REDACTED:<n>]` marker so the
71
+ * operator can still verify the report at-a-glance ("yes, a 32-char
72
+ * token was here") без leaking the value.
73
+ */
74
+ function redact(text) {
75
+ if (!text)
76
+ return text;
77
+ // Bearer + JWT shape.
78
+ text = text.replace(/(Bearer\s+)([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/gi, (_m, prefix, tok) => `${prefix}[REDACTED:${tok.length}]`);
79
+ // Bare JWTs (no Bearer prefix) inside JSON / log lines.
80
+ text = text.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, (tok) => `[REDACTED:${tok.length}]`);
81
+ // `"token": "..."` / `"apiKey": "..."` / `"password": "..."` shapes.
82
+ text = text.replace(/("(?:apiKey|api_key|token|access_token|refresh_token|password|secret|bearer)"\s*:\s*")([^"]{10,})(")/gi, (_m, before, val, after) => `${before}[REDACTED:${val.length}]${after}`);
83
+ // Bare env-style KEY=VALUE на длинных значениях.
84
+ text = text.replace(/\b((?:PUGI_API_KEY|GITHUB_TOKEN|NPM_TOKEN|ANVIL_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY)=)([^\s"']{10,})/g, (_m, prefix, val) => `${prefix}[REDACTED:${val.length}]`);
85
+ return text;
86
+ }
87
+ function clampDetail(value) {
88
+ if (typeof value !== 'string')
89
+ return undefined;
90
+ const redacted = redact(value);
91
+ return redacted.length > MAX_DETAIL_CHARS
92
+ ? `${redacted.slice(0, MAX_DETAIL_CHARS)}…`
93
+ : redacted;
94
+ }
95
+ function findLatestSession(cwd) {
96
+ const dir = resolvePath(cwd, '.pugi/sessions');
97
+ if (!existsSync(dir))
98
+ return null;
99
+ const entries = readdirSync(dir, { withFileTypes: true })
100
+ .filter((e) => e.isDirectory())
101
+ .map((e) => {
102
+ const path = join(dir, e.name);
103
+ let mtime = 0;
104
+ try {
105
+ mtime = statSync(join(path, 'events.jsonl')).mtimeMs;
106
+ }
107
+ catch {
108
+ // Session dir without events.jsonl yet — never opened. Skip.
109
+ return null;
110
+ }
111
+ return { name: e.name, path, mtime };
112
+ })
113
+ .filter((x) => x !== null)
114
+ .sort((a, b) => b.mtime - a.mtime);
115
+ return entries[0]?.path ?? null;
116
+ }
117
+ function readTenantIdSafely() {
118
+ const credPath = resolvePath(homedir(), '.pugi/credentials.json');
119
+ if (!existsSync(credPath))
120
+ return undefined;
121
+ try {
122
+ const raw = JSON.parse(readFileSync(credPath, 'utf8'));
123
+ const first = raw.tokens?.[0]?.apiKey;
124
+ if (!first || typeof first !== 'string')
125
+ return undefined;
126
+ // JWT payload is the middle segment; base64-decode + parse for the
127
+ // `customerId` claim. Failure here returns undefined (the report
128
+ // still emits useful context without it).
129
+ const parts = first.split('.');
130
+ if (parts.length !== 3)
131
+ return undefined;
132
+ const payload = JSON.parse(Buffer.from(parts[1] ?? '', 'base64').toString('utf8'));
133
+ return typeof payload.customerId === 'string' ? payload.customerId : undefined;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ function captureFrames(eventsPath) {
140
+ const lines = readFileSync(eventsPath, 'utf8')
141
+ .split('\n')
142
+ .filter((l) => l.trim().length > 0);
143
+ const parsed = lines
144
+ .map((line) => {
145
+ try {
146
+ return JSON.parse(line);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ })
152
+ .filter((f) => f !== null);
153
+ // Keep the LAST MAX_TAIL_FRAMES frames — failures cluster at the
154
+ // end, and the tail is where the operator's context actually lives.
155
+ const tail = parsed.slice(-MAX_TAIL_FRAMES);
156
+ return tail.map((f) => {
157
+ const out = {
158
+ type: typeof f.type === 'string' ? f.type : 'unknown',
159
+ };
160
+ if (typeof f.taskId === 'string')
161
+ out.taskId = f.taskId;
162
+ if (typeof f.timestamp === 'string')
163
+ out.timestamp = f.timestamp;
164
+ if (typeof f.outcome === 'string')
165
+ out.outcome = f.outcome;
166
+ // Keep detail / error ONLY on terminal frames (full reply text on
167
+ // every agent.message would blow the report past the GH issue cap).
168
+ if (TERMINAL_TYPES.has(out.type)) {
169
+ const detail = clampDetail(f.detail) ?? clampDetail(f.error);
170
+ if (detail)
171
+ out.detail = detail;
172
+ if (typeof f.error === 'string')
173
+ out.error = clampDetail(f.error);
174
+ }
175
+ return out;
176
+ });
177
+ }
178
+ export function runReport(args, ctx) {
179
+ const fromError = args.includes('--from-error');
180
+ if (!fromError) {
181
+ ctx.writeOutput({
182
+ command: 'report',
183
+ status: 'no_sessions',
184
+ message: 'pugi report — capture a bug report from the most-recent session.\n\n' +
185
+ 'Usage:\n' +
186
+ ' pugi report --from-error Bundle the most-recent failed session as a report.\n\n' +
187
+ 'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.\n' +
188
+ 'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
189
+ }, 'pugi report — see `pugi report --help`');
190
+ return 0;
191
+ }
192
+ const sessionPath = findLatestSession(ctx.cwd);
193
+ if (!sessionPath) {
194
+ ctx.writeOutput({
195
+ command: 'report',
196
+ status: 'no_sessions',
197
+ message: 'No sessions found under .pugi/sessions/. Run a `pugi` command first.',
198
+ }, 'pugi report: no sessions found under .pugi/sessions/ — run a `pugi` command first.');
199
+ return 8;
200
+ }
201
+ const eventsPath = join(sessionPath, 'events.jsonl');
202
+ let frames;
203
+ try {
204
+ frames = captureFrames(eventsPath);
205
+ }
206
+ catch (err) {
207
+ const message = err instanceof Error ? err.message : String(err);
208
+ ctx.writeOutput({
209
+ command: 'report',
210
+ status: 'unreadable',
211
+ message: `Failed to read ${eventsPath}: ${message}`,
212
+ }, `pugi report: cannot read session events (${message})`);
213
+ return 9;
214
+ }
215
+ const sessionId = sessionPath.split('/').pop() ?? 'unknown';
216
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
217
+ const reportDir = resolvePath(ctx.cwd, '.pugi/reports', `${timestamp}-${sessionId}`);
218
+ let reportJson;
219
+ let reportMd;
220
+ try {
221
+ mkdirSync(reportDir, { recursive: true });
222
+ reportJson = join(reportDir, 'report.json');
223
+ reportMd = join(reportDir, 'report.md');
224
+ const meta = {
225
+ schema: 1,
226
+ generatedAt: new Date().toISOString(),
227
+ cliVersion: PUGI_CLI_VERSION,
228
+ nodeVersion: process.version,
229
+ os: `${platform()} ${release()}`,
230
+ cwd: ctx.cwd,
231
+ sessionId,
232
+ tenantId: readTenantIdSafely() ?? '(not resolvable)',
233
+ pugiMd: existsSync(resolvePath(ctx.cwd, '.pugi/PUGI.md')),
234
+ frames,
235
+ };
236
+ writeFileSync(reportJson, JSON.stringify(meta, null, 2), 'utf8');
237
+ const mdLines = [
238
+ `# Pugi bug report — ${sessionId}`,
239
+ '',
240
+ `Generated: \`${meta.generatedAt}\``,
241
+ `CLI version: \`${meta.cliVersion}\``,
242
+ `Node: \`${meta.nodeVersion}\` · OS: \`${meta.os}\``,
243
+ `Workspace: \`${meta.cwd}\` (PUGI.md present: ${meta.pugiMd ? 'yes' : 'no'})`,
244
+ `Tenant: \`${meta.tenantId}\``,
245
+ '',
246
+ `## Last ${frames.length} frames`,
247
+ '',
248
+ '```jsonl',
249
+ ...frames.map((f) => JSON.stringify(f)),
250
+ '```',
251
+ '',
252
+ '## How to share',
253
+ '',
254
+ '1. Review `report.md` for accidental PII or sensitive paths.',
255
+ '2. Paste the contents into a GH issue at https://github.com/pugi-io/pugi/issues',
256
+ ' OR attach the `report.json` as a file.',
257
+ '',
258
+ 'Auto-upload to api.pugi.io is planned (`pugi report --upload`) but',
259
+ 'NOT shipped in this build — v1 keeps everything local so an operator',
260
+ 'behind a firewall can still file a clean report.',
261
+ ];
262
+ writeFileSync(reportMd, mdLines.join('\n'), 'utf8');
263
+ }
264
+ catch (err) {
265
+ const message = err instanceof Error ? err.message : String(err);
266
+ ctx.writeOutput({
267
+ command: 'report',
268
+ status: 'output_not_writable',
269
+ message: `Failed to write report bundle to ${reportDir}: ${message}`,
270
+ }, `pugi report: cannot write report dir (${message})`);
271
+ return 20;
272
+ }
273
+ ctx.writeOutput({
274
+ command: 'report',
275
+ status: 'written',
276
+ reportDir,
277
+ reportJson,
278
+ reportMd,
279
+ sessionId,
280
+ message: `Report written: ${reportDir}`,
281
+ }, [
282
+ `pugi report: bundle written`,
283
+ ` Session: ${sessionId}`,
284
+ ` Frames captured: ${frames.length}`,
285
+ ` Files:`,
286
+ ` ${reportJson}`,
287
+ ` ${reportMd}`,
288
+ ``,
289
+ `Review report.md for accidental PII, then paste into a GH issue OR`,
290
+ `attach report.json. Auto-upload is planned for a follow-up build.`,
291
+ ].join('\n'));
292
+ return 0;
293
+ }
294
+ // Test seam — the redactor is the most-tested piece (false negatives
295
+ // leak secrets; false positives garble bug context). Exported so
296
+ // apps/pugi-cli/test/report.spec.ts can assert the regex behaviour
297
+ // без spinning up a full session.
298
+ export const __INTERNAL_FOR_TESTS = { redact, clampDetail };
299
+ //# sourceMappingURL=report.js.map
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.15');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.17');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -1,3 +1,31 @@
1
+ /**
2
+ * file-tools - Pugi CLI file/bash/glob/grep tool surface.
3
+ *
4
+ * Workspace-binding contract (CEO red-alert 2026-05-27 follow-up):
5
+ *
6
+ * Every tool dispatch path threads `ctx.root` from the operator's
7
+ * `process.cwd()` through `EngineTask.workspaceRoot` ->
8
+ * `native-pugi.run()` -> `toolCtx.root` -> here. Tools call
9
+ * `resolveWorkspacePath(ctx.root, path)` for every on-disk operation
10
+ * so a dispatched specialist (e.g. Hiroshi writing tic-tac-toe HTML)
11
+ * produces files in the OPERATOR'S cwd, never in a server-side temp
12
+ * space. The path-security gate refuses traversal (`../etc/passwd`,
13
+ * URL-encoded variants, symlink escapes at the target).
14
+ *
15
+ * Wiring chain:
16
+ * 1. runtime/cli.ts: workspaceRoot = process.cwd()
17
+ * 2. EngineTask.workspaceRoot threads through to native-pugi.run().
18
+ * 3. native-pugi: const root = task.workspaceRoot
19
+ * 4. tool-bridge: passes ctx.root to file-tools / bash.
20
+ * 5. file-tools: resolveWorkspacePath(ctx.root, path).
21
+ *
22
+ * The contract is locked by `test/tools-write-to-workspace.spec.ts`
23
+ * (6 cases covering relative + nested + absolute paths + traversal
24
+ * refusal). If any layer of the chain regressed silently, dispatched
25
+ * files would land in `/tmp` instead of the operator's repo, which
26
+ * is the same failure surface as the menu-mode anti-pattern the
27
+ * sibling commits close.
28
+ */
1
29
  import { spawnSync } from 'node:child_process';
2
30
  import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
3
31
  import { dirname, isAbsolute, relative } from 'node:path';