@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.
Files changed (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. 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