@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.
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/engine/tool-bridge.js +14 -8
- package/dist/core/repl/session.js +30 -5
- package/dist/runtime/cli.js +129 -6
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/file-tools.js +28 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/repl-render.js +5 -5
- package/dist/tui/tool-stream-pane.js +7 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
2869
|
-
if (
|
|
2870
|
-
|
|
2871
|
-
|
|
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
|
}
|
package/dist/runtime/cli.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
const
|
|
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
|
-
|
|
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
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
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.
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -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';
|