@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- 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/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
|
@@ -36,12 +36,20 @@ const SECURITY_REASONS = new Set(['path_outside_workspace', 'protected_file', 's
|
|
|
36
36
|
export async function runPatchCommand(args, opts) {
|
|
37
37
|
const positional = [];
|
|
38
38
|
const applyOpts = {};
|
|
39
|
+
// Seed from caller-supplied options first; arg-flag parsing below
|
|
40
|
+
// overrides when present.
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
applyOpts.dryRun = true;
|
|
43
|
+
if (opts.baseSha)
|
|
44
|
+
applyOpts.baseSha = opts.baseSha;
|
|
45
|
+
let threeWaySeen = false;
|
|
39
46
|
for (let i = 0; i < args.length; i += 1) {
|
|
40
47
|
const arg = args[i] ?? '';
|
|
41
48
|
if (arg === '--dry-run')
|
|
42
49
|
applyOpts.dryRun = true;
|
|
43
50
|
else if (arg === '--3way') {
|
|
44
51
|
// honored only when --base is also supplied
|
|
52
|
+
threeWaySeen = true;
|
|
45
53
|
}
|
|
46
54
|
else if (arg === '--base') {
|
|
47
55
|
const next = args[i + 1];
|
|
@@ -59,6 +67,15 @@ export async function runPatchCommand(args, opts) {
|
|
|
59
67
|
positional.push(arg);
|
|
60
68
|
}
|
|
61
69
|
}
|
|
70
|
+
// R1 fix (2026-05-26, PR #413 r1, P2 #14): `--3way` without `--base`
|
|
71
|
+
// is meaningless because `git apply --3way` falls back to the index,
|
|
72
|
+
// which a CLI-side `pugi patch` invocation does not have populated
|
|
73
|
+
// with the patch's pre-image. Warn the operator instead of dropping
|
|
74
|
+
// the flag silently.
|
|
75
|
+
if (threeWaySeen && !applyOpts.baseSha) {
|
|
76
|
+
const warn = opts.warn ?? ((m) => console.warn(m));
|
|
77
|
+
warn('warning: --3way ignored without --base=<sha>; pass --base or drop --3way');
|
|
78
|
+
}
|
|
62
79
|
let patch;
|
|
63
80
|
try {
|
|
64
81
|
patch = await readPatchSource(positional[0], opts);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi permissions` / `/permissions` — Leak L6 4-mode gate control.
|
|
3
|
+
*
|
|
4
|
+
* Two entry points share one runtime helper:
|
|
5
|
+
* 1. `/permissions` in the REPL — forwarded by `core/repl/session.ts`.
|
|
6
|
+
* 2. `pugi permissions ...` top-level CLI command (handler in
|
|
7
|
+
* `runtime/cli.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Both pass a `PermissionsCommand` payload describing the operator
|
|
10
|
+
* intent (show / flip / persist) and a `writeOutput` callback that
|
|
11
|
+
* lets the caller route the rendered lines into the right surface
|
|
12
|
+
* (REPL transcript vs. stdout). The helper is intentionally I/O-free
|
|
13
|
+
* itself — it produces lines and lets the caller stream them.
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_PERMISSION_MODE, PERMISSION_MODES, PERMISSION_MODE_GLOSS, getCurrentMode, getGlobalDefaultMode, setCurrentMode, setGlobalDefaultMode, } from '../../core/permissions/index.js';
|
|
16
|
+
/**
|
|
17
|
+
* Run the `/permissions` or `pugi permissions` flow. Side effects:
|
|
18
|
+
* - When `command.mode` is undefined: prints the current mode + the
|
|
19
|
+
* 4-mode table (no writes).
|
|
20
|
+
* - When `command.mode === 'bypass'` without `confirmBypass`: prints
|
|
21
|
+
* a refusal + the safety copy, no writes.
|
|
22
|
+
* - When `command.mode` is set + valid: writes workspace session
|
|
23
|
+
* state; optionally writes global default when `persist` is true.
|
|
24
|
+
* - Always prints the new effective mode + a one-line confirmation.
|
|
25
|
+
*/
|
|
26
|
+
export async function runPermissionsCommand(command, ctx) {
|
|
27
|
+
if (!command.mode) {
|
|
28
|
+
renderCurrentMode(ctx);
|
|
29
|
+
renderModeTable(ctx);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command.mode === 'bypass' && !command.confirmBypass) {
|
|
33
|
+
ctx.writeOutput('Bypass mode disables policy hooks (skill steering, denial tracking).');
|
|
34
|
+
ctx.writeOutput('Run `/permissions bypass --confirm` to acknowledge before flipping.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setCurrentMode(ctx.workspaceRoot, command.mode);
|
|
38
|
+
if (command.persist) {
|
|
39
|
+
setGlobalDefaultMode(command.mode, ctx.homeDir);
|
|
40
|
+
}
|
|
41
|
+
const persistedHint = command.persist
|
|
42
|
+
? ' Persisted to ~/.pugi/config.json for future sessions.'
|
|
43
|
+
: '';
|
|
44
|
+
ctx.writeOutput(`Permission mode set to '${command.mode}'.${persistedHint} ${PERMISSION_MODE_GLOSS[command.mode]}`);
|
|
45
|
+
if (command.mode === 'bypass') {
|
|
46
|
+
ctx.writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Print the resolved current mode + the layered source. The merge
|
|
51
|
+
* order mirrors `resolveMode()`: workspace > global > default.
|
|
52
|
+
*/
|
|
53
|
+
function renderCurrentMode(ctx) {
|
|
54
|
+
const workspace = getCurrentMode(ctx.workspaceRoot);
|
|
55
|
+
const global = getGlobalDefaultMode(ctx.homeDir);
|
|
56
|
+
const effective = workspace ?? global ?? DEFAULT_PERMISSION_MODE;
|
|
57
|
+
const source = workspace
|
|
58
|
+
? 'workspace session.json'
|
|
59
|
+
: global
|
|
60
|
+
? 'global ~/.pugi/config.json'
|
|
61
|
+
: 'default (no override)';
|
|
62
|
+
ctx.writeOutput(`Current permission mode: ${effective} (source: ${source})`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Print the 4-mode reference table. Keeps the gloss + the side-effect
|
|
66
|
+
* matrix in one place so the operator can see the contract while they
|
|
67
|
+
* decide which mode to switch to.
|
|
68
|
+
*/
|
|
69
|
+
function renderModeTable(ctx) {
|
|
70
|
+
ctx.writeOutput('');
|
|
71
|
+
ctx.writeOutput('Permission modes:');
|
|
72
|
+
for (const mode of PERMISSION_MODES) {
|
|
73
|
+
ctx.writeOutput(` ${mode.padEnd(7)} ${PERMISSION_MODE_GLOSS[mode]}`);
|
|
74
|
+
}
|
|
75
|
+
ctx.writeOutput('');
|
|
76
|
+
ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. Bypass requires `--confirm`.');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render the one-shot banner shown on session boot when the effective
|
|
80
|
+
* mode is `bypass`. The caller (engine adapter / REPL bootstrap) calls
|
|
81
|
+
* this once per session — repeated invocations are idempotent in copy
|
|
82
|
+
* but the caller is responsible for the once-only semantics.
|
|
83
|
+
*/
|
|
84
|
+
export function renderBypassBanner(writeOutput) {
|
|
85
|
+
writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -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
|
|
@@ -40,8 +40,23 @@ import { aggregate, exitCodeFor, reviewerVerdictFromRaw, } from '../../core/cons
|
|
|
40
40
|
* `--branch <name>` / `--branch=<name>`
|
|
41
41
|
* `--base <ref>` / `--base=<ref>` (override default origin/main)
|
|
42
42
|
*/
|
|
43
|
-
export function parseConsensusArgs(args
|
|
43
|
+
export function parseConsensusArgs(args,
|
|
44
|
+
/**
|
|
45
|
+
* 2026-05-27 (Codex r0 P1 on PR #489): cli.ts now parses --commit /
|
|
46
|
+
* --base in the GLOBAL flag pass for the new triple-provider path.
|
|
47
|
+
* Those tokens are consumed BEFORE this function sees `args`, so
|
|
48
|
+
* `pugi review --consensus --commit X` would silently fall back to
|
|
49
|
+
* the default diff and review the wrong changes. Pass the global
|
|
50
|
+
* flags through here so consensus picks them up when present.
|
|
51
|
+
* Inline `--commit`/`--base` tokens in args still win — explicit
|
|
52
|
+
* caller intent is preserved.
|
|
53
|
+
*/
|
|
54
|
+
fallback) {
|
|
44
55
|
const spec = {};
|
|
56
|
+
if (fallback?.commit)
|
|
57
|
+
spec.commit = fallback.commit;
|
|
58
|
+
if (fallback?.base)
|
|
59
|
+
spec.baseRef = fallback.base;
|
|
45
60
|
for (let i = 0; i < args.length; i += 1) {
|
|
46
61
|
const arg = args[i] ?? '';
|
|
47
62
|
const equalsIdx = arg.indexOf('=');
|
|
@@ -119,7 +134,7 @@ export async function runReviewConsensus(args, ctx) {
|
|
|
119
134
|
// exit 2 — same as BLOCK because the gate could not even run.
|
|
120
135
|
let captured;
|
|
121
136
|
try {
|
|
122
|
-
const spec = parseConsensusArgs(args);
|
|
137
|
+
const spec = parseConsensusArgs(args, ctx.flagsFallback);
|
|
123
138
|
captured = captureDiff({ ...spec, cwd: ctx.cwd });
|
|
124
139
|
}
|
|
125
140
|
catch (error) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi roster` command - α7.5 Tier 1 instantiation Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* Lists the live Tier 1 personas with display name, role, and routing
|
|
5
|
+
* tag. The CLI walks two sources in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. The local @pugi/personas roster (THE_TEN). Always succeeds; the
|
|
8
|
+
* ten brand-canonical personas are baked into the SDK.
|
|
9
|
+
* 2. The remote `GET /api/pugi/sessions/roster` endpoint when the
|
|
10
|
+
* operator has a valid credential. The remote response carries the
|
|
11
|
+
* server-side dispatch role + dispatchTag for each slug so the
|
|
12
|
+
* operator sees the actual routing decision the dispatcher will
|
|
13
|
+
* apply on a `pugi delegate <slug>` call.
|
|
14
|
+
*
|
|
15
|
+
* The command never fails if the network is unreachable - it falls back
|
|
16
|
+
* to local-only output with a one-line warning. This matches the
|
|
17
|
+
* local-first contract (ADR-0037): the operator can still see who is on
|
|
18
|
+
* the team without an API key.
|
|
19
|
+
*
|
|
20
|
+
* Output:
|
|
21
|
+
* - text default: a 3-column table (slug | name | role).
|
|
22
|
+
* - --json: a structured array of { slug, name, role, totem,
|
|
23
|
+
* dispatchTag } records, used by scripted callers.
|
|
24
|
+
*/
|
|
25
|
+
import { THE_TEN } from '@pugi/personas';
|
|
26
|
+
import { fetchPersonaRoster, } from '@pugi/sdk';
|
|
27
|
+
/**
|
|
28
|
+
* Fallback role + tag table the CLI uses when the runtime is unreachable
|
|
29
|
+
* (no credentials, network error, older runtime without the
|
|
30
|
+
* /sessions/roster endpoint). Mirrors the server-side
|
|
31
|
+
* persona-dispatch.ts PERSONA_REGISTRY so a CLI that ran without
|
|
32
|
+
* credentials still shows the operator the right routing intent.
|
|
33
|
+
*/
|
|
34
|
+
const FALLBACK_ROLE_BY_SLUG = Object.freeze({
|
|
35
|
+
main: { role: 'orchestrator', dispatchTag: 'reason' },
|
|
36
|
+
architect: { role: 'architect', dispatchTag: 'reason' },
|
|
37
|
+
dev: { role: 'coder', dispatchTag: 'codegen' },
|
|
38
|
+
qa: { role: 'verifier', dispatchTag: 'reason' },
|
|
39
|
+
pm: { role: 'release', dispatchTag: 'reason' },
|
|
40
|
+
devops: { role: 'devops', dispatchTag: 'reason' },
|
|
41
|
+
researcher: { role: 'researcher', dispatchTag: 'reason' },
|
|
42
|
+
analyst: { role: 'analyst', dispatchTag: 'summarize' },
|
|
43
|
+
designer: { role: 'design_qa', dispatchTag: 'reason' },
|
|
44
|
+
frontend: { role: 'frontend', dispatchTag: 'codegen' },
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Build the roster rows by merging the local @pugi/personas brand
|
|
48
|
+
* roster with the remote dispatch metadata when a credential is
|
|
49
|
+
* available. Pure function so the runtime CLI command can unit-test it
|
|
50
|
+
* without standing up an Anvil endpoint.
|
|
51
|
+
*/
|
|
52
|
+
export function mergeRoster(brandRoster, remote) {
|
|
53
|
+
const remoteIndex = new Map((remote ?? []).map((entry) => [entry.slug, entry]));
|
|
54
|
+
return brandRoster.map((persona) => {
|
|
55
|
+
const fromRemote = remoteIndex.get(persona.slug);
|
|
56
|
+
const fallback = FALLBACK_ROLE_BY_SLUG[persona.slug] ?? {
|
|
57
|
+
role: persona.role,
|
|
58
|
+
dispatchTag: 'reason',
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
slug: persona.slug,
|
|
62
|
+
name: persona.name,
|
|
63
|
+
totem: persona.animal,
|
|
64
|
+
role: fromRemote?.role ?? fallback.role,
|
|
65
|
+
dispatchTag: fromRemote?.dispatchTag ?? fallback.dispatchTag,
|
|
66
|
+
oneLiner: persona.oneLiner,
|
|
67
|
+
source: fromRemote ? 'remote' : 'local-fallback',
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render a roster as a plain-text 3-column table the operator reads in
|
|
73
|
+
* the terminal. The column widths grow to fit the longest cell so a
|
|
74
|
+
* future displayName drift does not truncate silently.
|
|
75
|
+
*/
|
|
76
|
+
export function renderRosterTable(rows) {
|
|
77
|
+
if (rows.length === 0)
|
|
78
|
+
return 'Roster is empty.';
|
|
79
|
+
const head = { slug: 'slug', name: 'name', totem: 'totem', role: 'role', dispatchTag: 'tag' };
|
|
80
|
+
const widths = {
|
|
81
|
+
slug: Math.max(head.slug.length, ...rows.map((r) => r.slug.length)),
|
|
82
|
+
name: Math.max(head.name.length, ...rows.map((r) => r.name.length)),
|
|
83
|
+
totem: Math.max(head.totem.length, ...rows.map((r) => r.totem.length)),
|
|
84
|
+
role: Math.max(head.role.length, ...rows.map((r) => r.role.length)),
|
|
85
|
+
dispatchTag: Math.max(head.dispatchTag.length, ...rows.map((r) => r.dispatchTag.length)),
|
|
86
|
+
};
|
|
87
|
+
const pad = (s, width) => s + ' '.repeat(Math.max(0, width - s.length));
|
|
88
|
+
const line = (r) => [pad(r.slug, widths.slug), pad(r.name, widths.name), pad(r.totem, widths.totem), pad(r.role, widths.role), pad(r.dispatchTag, widths.dispatchTag)].join(' ');
|
|
89
|
+
const header = line(head);
|
|
90
|
+
const sep = '-'.repeat(header.length);
|
|
91
|
+
return [header, sep, ...rows.map((r) => line(r))].join('\n');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the roster by walking remote + local sources. The CLI command
|
|
95
|
+
* is a thin wrapper around this function so unit tests can exercise the
|
|
96
|
+
* merge logic without hitting the runtime.
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveRoster(config) {
|
|
99
|
+
if (!config) {
|
|
100
|
+
return {
|
|
101
|
+
rows: mergeRoster(THE_TEN, null),
|
|
102
|
+
warning: 'no credential configured; showing local @pugi/personas roster only',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const result = await fetchPersonaRoster(config);
|
|
106
|
+
if (result.status === 'ok') {
|
|
107
|
+
return { rows: mergeRoster(THE_TEN, result.response.personas), warning: null };
|
|
108
|
+
}
|
|
109
|
+
const reason = result.status === 'endpoint_missing'
|
|
110
|
+
? 'runtime does not expose /api/pugi/sessions/roster (upgrade admin-api to α7.5+)'
|
|
111
|
+
: result.message;
|
|
112
|
+
return {
|
|
113
|
+
rows: mergeRoster(THE_TEN, null),
|
|
114
|
+
warning: `roster fetch failed (${result.status}): ${reason}; showing local roster only`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=roster.js.map
|