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

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 (32) hide show
  1. package/dist/core/diagnostics/probe-runner.js +93 -0
  2. package/dist/core/diagnostics/probes/api.js +46 -0
  3. package/dist/core/diagnostics/probes/auth.js +86 -0
  4. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  5. package/dist/core/diagnostics/probes/config.js +72 -0
  6. package/dist/core/diagnostics/probes/disk.js +81 -0
  7. package/dist/core/diagnostics/probes/git.js +65 -0
  8. package/dist/core/diagnostics/probes/mcp.js +75 -0
  9. package/dist/core/diagnostics/probes/node.js +59 -0
  10. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  11. package/dist/core/diagnostics/probes/session.js +74 -0
  12. package/dist/core/diagnostics/probes/workspace.js +63 -0
  13. package/dist/core/diagnostics/types.js +70 -0
  14. package/dist/core/engine/strip-internal-fields.js +124 -0
  15. package/dist/core/engine/tool-bridge.js +100 -37
  16. package/dist/core/file-cache.js +113 -1
  17. package/dist/core/mcp/client.js +66 -6
  18. package/dist/core/mcp/registry.js +24 -2
  19. package/dist/core/repl/session.js +34 -0
  20. package/dist/core/repl/slash-commands.js +9 -0
  21. package/dist/runtime/cli.js +24 -58
  22. package/dist/runtime/commands/doctor.js +357 -0
  23. package/dist/runtime/commands/mcp.js +290 -3
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/tools/agent-tool.js +18 -4
  26. package/dist/tools/ask-user-question.js +213 -0
  27. package/dist/tools/file-tools.js +57 -14
  28. package/dist/tools/registry.js +7 -0
  29. package/dist/tui/ask-user-question-prompt.js +192 -0
  30. package/dist/tui/conversation-pane.js +68 -7
  31. package/dist/tui/doctor-table.js +31 -0
  32. package/package.json +2 -2
@@ -1,6 +1,45 @@
1
+ /**
2
+ * Per-session file-read cache + stale-read gate.
3
+ *
4
+ * Leak intel L1 (openclaude `FileEditTool.ts`, 2026-05-27 gap analysis
5
+ * §5.1): every FileEdit must validate the operator's last-known view of
6
+ * the file before mutating disk. The gate compares BOTH `mtimeMs` and
7
+ * `sha256(content)` of the file on disk against the record captured at
8
+ * read time:
9
+ *
10
+ * - mtimeMs is a cheap fast-path. If the inode mtime hasn't moved
11
+ * since the read, the content hash cannot have changed (barring a
12
+ * filesystem with hash-on-mtime-skew bugs) and we can short-circuit.
13
+ * - sha256 is the authoritative gate. A user editor that writes back
14
+ * identical content can leave mtime untouched on some filesystems
15
+ * (atomic-rename with preserved metadata), and conversely `touch`
16
+ * bumps mtime without changing content. Hash is the truth.
17
+ *
18
+ * Both signals must agree for the gate to PASS. Any divergence => STALE
19
+ * => refuse the edit, force the model to re-read.
20
+ *
21
+ * Cache lifetime: per-session. `FileReadCache.clear()` is called at
22
+ * session.end (see `core/session.ts`). The cache is intentionally NOT
23
+ * durable across sessions — a re-read after restart is cheap and stale
24
+ * cross-session entries would themselves be a soundness hazard.
25
+ *
26
+ * Exception: writeTool for create-new (path doesn't exist on disk) does
27
+ * not consult the cache. Creating a brand new file has no "last-known
28
+ * view" to invalidate.
29
+ */
1
30
  import { createHash } from 'node:crypto';
2
- import { statSync } from 'node:fs';
31
+ import { existsSync, statSync } from 'node:fs';
3
32
  import { resolve } from 'node:path';
33
+ export class StaleReadError extends Error {
34
+ reason;
35
+ path;
36
+ constructor(path, reason, detail) {
37
+ super(`stale_read: ${path} — ${detail}. Re-read the file before editing.`);
38
+ this.name = 'StaleReadError';
39
+ this.reason = reason;
40
+ this.path = path;
41
+ }
42
+ }
4
43
  export class FileReadCache {
5
44
  records = new Map();
6
45
  set(record) {
@@ -9,6 +48,70 @@ export class FileReadCache {
9
48
  get(root, path) {
10
49
  return this.records.get(resolve(root, path));
11
50
  }
51
+ /**
52
+ * Validate a candidate edit against the cached read record. Returns
53
+ * a tagged-union: `{ stale: false }` when the edit may proceed, or
54
+ * `{ stale: true, reason, detail }` when the gate must refuse.
55
+ *
56
+ * Pure function over the cache + supplied `currentMtimeMs` /
57
+ * `currentContent` — does NOT touch disk. Callers (editTool /
58
+ * writeTool) do their own `statSync` + `readFileSync` because they
59
+ * also need the content for the diff/edit itself.
60
+ *
61
+ * @param root workspace root (used to resolve relative path)
62
+ * @param path workspace-relative file path
63
+ * @param currentMtimeMs `fs.statSync().mtimeMs` of the on-disk file
64
+ * @param currentContent UTF-8 contents of the on-disk file
65
+ */
66
+ validate(root, path, currentMtimeMs, currentContent) {
67
+ const record = this.get(root, path);
68
+ if (!record) {
69
+ return {
70
+ stale: true,
71
+ reason: 'no_prior_read',
72
+ detail: 'file must be read first',
73
+ };
74
+ }
75
+ // Fast-path: mtime hasn't moved. Hash check is redundant in the
76
+ // common case but cheap, so we still verify below. Skipping hash
77
+ // when mtime matches would allow a subtle bug class (in-place
78
+ // writers that preserve mtime) to slip through.
79
+ if (currentMtimeMs > record.mtimeMs) {
80
+ // mtime advanced — confirm with hash before flagging. A bump
81
+ // without a content change (e.g. `touch`) shouldn't fire stale.
82
+ const currentHash = hashContent(currentContent);
83
+ if (currentHash !== record.sha256) {
84
+ return {
85
+ stale: true,
86
+ reason: 'mtime_drift',
87
+ detail: `mtime advanced (${record.mtimeMs} → ${currentMtimeMs}) and content hash diverged`,
88
+ };
89
+ }
90
+ // mtime bumped but content identical — treat as fresh. The cache
91
+ // entry's mtime is intentionally NOT refreshed here; the next
92
+ // edit will hit the same path and the gate will keep agreeing.
93
+ return { stale: false };
94
+ }
95
+ // mtime hasn't moved — hash MUST still match the record. A
96
+ // mismatch is a filesystem-level inconsistency or an in-place
97
+ // editor that preserves mtime; either way, refuse.
98
+ const currentHash = hashContent(currentContent);
99
+ if (currentHash !== record.sha256) {
100
+ return {
101
+ stale: true,
102
+ reason: 'hash_drift',
103
+ detail: 'content hash diverged from last read (mtime unchanged)',
104
+ };
105
+ }
106
+ return { stale: false };
107
+ }
108
+ /**
109
+ * Drop every cached record. Called by session.end so a fresh REPL
110
+ * session never inherits stale cross-session entries.
111
+ */
112
+ clear() {
113
+ this.records.clear();
114
+ }
12
115
  }
13
116
  export function hashContent(content) {
14
117
  return createHash('sha256').update(content).digest('hex');
@@ -26,4 +129,13 @@ export function createReadRecord(root, path, content, source) {
26
129
  source,
27
130
  };
28
131
  }
132
+ /**
133
+ * Convenience helper: does this absolute path exist on disk? Wraps the
134
+ * existsSync import so file-tools.ts can decide between create-new
135
+ * (skip stale gate) and update-existing (apply stale gate) without
136
+ * pulling in another fs import.
137
+ */
138
+ export function pathExists(absolutePath) {
139
+ return existsSync(absolutePath);
140
+ }
29
141
  //# sourceMappingURL=file-cache.js.map
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
2
3
  import { z } from 'zod';
3
4
  /**
4
5
  * Minimal JSON-RPC 2.0 over stdio MCP client for Pugi CLI M1.
@@ -79,6 +80,22 @@ export async function connect(serverName, config, options = {}) {
79
80
  env: { ...process.env, ...config.env },
80
81
  stdio: ['pipe', 'pipe', 'pipe'],
81
82
  });
83
+ // L13: optional stderr → log file. We open the stream lazily so a bad
84
+ // path surfaces synchronously to the caller instead of getting buried
85
+ // in the child's stderr handler. `mkdir -p` of the parent dir is the
86
+ // caller's responsibility (the registry does it once per session).
87
+ let logStream;
88
+ if (options.logFile) {
89
+ try {
90
+ logStream = createWriteStream(options.logFile, { flags: 'a' });
91
+ }
92
+ catch {
93
+ // Log directory missing or unwritable — degrade silently rather
94
+ // than refuse the handshake. The operator still gets the same
95
+ // surface they had pre-L13 (stderr dropped on the floor).
96
+ logStream = undefined;
97
+ }
98
+ }
82
99
  const connection = {
83
100
  serverName,
84
101
  child,
@@ -86,6 +103,8 @@ export async function connect(serverName, config, options = {}) {
86
103
  pending: new Map(),
87
104
  nextId: 1,
88
105
  closed: false,
106
+ ...(logStream ? { logStream } : {}),
107
+ startedAt: Date.now(),
89
108
  };
90
109
  // Attach the spawn-error handler BEFORE the reader: ENOENT and similar
91
110
  // are emitted asynchronously on the next tick, and without this
@@ -193,11 +212,25 @@ export async function callTool(connection, name, args, options = {}) {
193
212
  * SIGKILL if the child has not exited. Safe to call multiple times.
194
213
  */
195
214
  export async function disconnect(connection) {
196
- if (connection.closed)
215
+ const closeLogStream = () => {
216
+ const stream = connection.logStream;
217
+ if (!stream)
218
+ return;
219
+ try {
220
+ stream.end();
221
+ }
222
+ catch {
223
+ // Best-effort — stream may already be destroyed.
224
+ }
225
+ };
226
+ if (connection.closed) {
227
+ closeLogStream();
197
228
  return;
229
+ }
198
230
  const { child } = connection;
199
231
  if (child.exitCode !== null || child.killed) {
200
232
  connection.closed = true;
233
+ closeLogStream();
201
234
  return;
202
235
  }
203
236
  return new Promise((resolveDone) => {
@@ -207,6 +240,7 @@ export async function disconnect(connection) {
207
240
  return;
208
241
  settled = true;
209
242
  connection.closed = true;
243
+ closeLogStream();
210
244
  resolveDone();
211
245
  };
212
246
  child.once('exit', settle);
@@ -232,6 +266,22 @@ export async function disconnect(connection) {
232
266
  }, SHUTDOWN_GRACE_MS);
233
267
  });
234
268
  }
269
+ /**
270
+ * L13: cheap liveness probe for `pugi mcp doctor`. True when the child
271
+ * process is still running AND the connection has not been torn down.
272
+ * Does NOT issue a JSON-RPC call — that would race with in-flight tool
273
+ * dispatch and the doctor surface is read-only.
274
+ */
275
+ export function isAlive(connection) {
276
+ if (connection.closed)
277
+ return false;
278
+ const { child } = connection;
279
+ if (child.killed)
280
+ return false;
281
+ if (child.exitCode !== null)
282
+ return false;
283
+ return true;
284
+ }
235
285
  function writeFrame(connection, message) {
236
286
  if (connection.closed) {
237
287
  throw new Error(`mcp server "${connection.serverName}" connection is closed`);
@@ -283,11 +333,21 @@ function attachReader(connection) {
283
333
  newlineIndex = buffer.indexOf('\n');
284
334
  }
285
335
  });
286
- // stderr is captured but never thrown. Some MCP servers log diagnostics
287
- // there even on success; we route it to nowhere so the CLI stdout stays
288
- // pure JSON envelope output. A future iteration may forward this to the
289
- // audit log.
290
- connection.child.stderr.on('data', () => { });
336
+ // stderr is captured and (when `connect({ logFile })` was set) mirrored
337
+ // to a per-server log file under `.pugi/logs/`. Operators can tail the
338
+ // file via `pugi mcp logs <server>`. Default sink remains "drop on the
339
+ // floor" so the CLI stdout stays pure JSON envelope output.
340
+ connection.child.stderr.on('data', (chunk) => {
341
+ const stream = connection.logStream;
342
+ if (!stream || stream.destroyed)
343
+ return;
344
+ try {
345
+ stream.write(typeof chunk === 'string' ? chunk : chunk);
346
+ }
347
+ catch {
348
+ // Disk full / fd revoked — drop silently. Mirroring is best-effort.
349
+ }
350
+ });
291
351
  }
292
352
  function handleLine(connection, line) {
293
353
  let parsed;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { resolve } from 'node:path';
4
4
  import { z } from 'zod';
@@ -38,6 +38,13 @@ import { getMcpTrust } from './trust.js';
38
38
  const mcpFileSchema = z.object({
39
39
  servers: z.record(mcpServerConfigSchema).default({}),
40
40
  });
41
+ /**
42
+ * L13: workspace-relative path for per-server log files. Surfaces in
43
+ * `pugi mcp logs <name>` and is mkdir -p'd before the first connect.
44
+ */
45
+ export function mcpLogPath(workspaceRoot, serverName) {
46
+ return resolve(workspaceRoot, '.pugi/logs', `mcp-${serverName}.log`);
47
+ }
41
48
  /**
42
49
  * Load and (optionally) connect every approved MCP server defined in the
43
50
  * workspace + user configs. Pending and denied servers stay in the
@@ -45,6 +52,7 @@ const mcpFileSchema = z.object({
45
52
  */
46
53
  export async function loadMcpRegistry(workspaceRoot, options = {}) {
47
54
  const shouldConnect = options.connect !== false;
55
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5_000;
48
56
  const userConfig = readMcpFile(resolve(userHomeDir(), 'mcp.json'));
49
57
  const workspaceConfig = readMcpFile(resolve(workspaceRoot, '.pugi/mcp.json'));
50
58
  const merged = new Map();
@@ -52,6 +60,17 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
52
60
  merged.set(name, config);
53
61
  for (const [name, config] of Object.entries(workspaceConfig))
54
62
  merged.set(name, config);
63
+ // L13: ensure the log dir exists once per session so per-server log
64
+ // streams can `append` without each one having to mkdir -p.
65
+ if (shouldConnect && merged.size > 0) {
66
+ try {
67
+ mkdirSync(resolve(workspaceRoot, '.pugi/logs'), { recursive: true });
68
+ }
69
+ catch {
70
+ // Workspace may be read-only (CI sandbox). Log routing degrades
71
+ // silently in that case — see `client.ts::connect`.
72
+ }
73
+ }
55
74
  const servers = new Map();
56
75
  for (const [name, config] of merged) {
57
76
  const ledgerTrust = await getMcpTrust(name);
@@ -70,7 +89,10 @@ export async function loadMcpRegistry(workspaceRoot, options = {}) {
70
89
  };
71
90
  if (shouldConnect && trust === 'trusted') {
72
91
  try {
73
- const connection = await connect(name, config);
92
+ const connection = await connect(name, config, {
93
+ timeoutMs: handshakeTimeoutMs,
94
+ logFile: mcpLogPath(workspaceRoot, name),
95
+ });
74
96
  state.connection = connection;
75
97
  state.surfacedTools = await listTools(connection);
76
98
  }
@@ -765,6 +765,40 @@ export class ReplSession {
765
765
  }
766
766
  return verdict;
767
767
  }
768
+ case 'doctor': {
769
+ // L17 (2026-05-27): run the doctor probe sweep inline. We
770
+ // dynamic-import the runtime/commands/doctor module so the
771
+ // slash dispatcher does not pull the diagnostics graph
772
+ // (execFileSync + fs probes) into every keystroke. The
773
+ // module's output is captured into local lines so we can
774
+ // render it as system entries in the conversation pane;
775
+ // an Ink-rendered table inside the REPL frame is a follow-up.
776
+ try {
777
+ const { runDoctorCommand, defaultHome } = await import('../../runtime/commands/doctor.js');
778
+ const lines = [];
779
+ await runDoctorCommand({
780
+ cwd: process.cwd(),
781
+ home: defaultHome(),
782
+ env: process.env,
783
+ json: false,
784
+ writeOutput: (_payload, text) => {
785
+ const trimmed = text.replace(/\n+$/u, '');
786
+ if (trimmed.length > 0)
787
+ lines.push(trimmed);
788
+ },
789
+ });
790
+ for (const line of lines)
791
+ this.appendSystemLine(line);
792
+ if (lines.length === 0) {
793
+ this.appendSystemLine('/doctor: no output.');
794
+ }
795
+ }
796
+ catch (error) {
797
+ const message = error instanceof Error ? error.message : String(error);
798
+ this.appendSystemLine(`/doctor failed: ${message}`);
799
+ }
800
+ return verdict;
801
+ }
768
802
  case 'stub': {
769
803
  this.appendSystemLine(verdict.message);
770
804
  return verdict;
@@ -81,6 +81,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
81
81
  // Meta
82
82
  { name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
83
83
  { name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
84
+ { name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
84
85
  { name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
85
86
  ]);
86
87
  /**
@@ -271,6 +272,14 @@ export function parseSlashCommand(input) {
271
272
  const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
272
273
  return { kind: 'mcp', args: tokens };
273
274
  }
275
+ case 'doctor':
276
+ case 'health': {
277
+ // L17 (2026-05-27): run the probe sweep inline. Tail is ignored —
278
+ // the doctor command has no operator-facing arguments (every
279
+ // probe runs unconditionally; per-probe disable lives on the CLI
280
+ // shell surface, not the slash one).
281
+ return { kind: 'doctor' };
282
+ }
274
283
  case 'compact':
275
284
  case 'memory':
276
285
  case 'config':
@@ -1,13 +1,11 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { statSync } from 'node:fs';
5
5
  import { dirname, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
8
- import { NoopEngineAdapter } from '../core/engine/noop.js';
9
8
  import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
- import { decidePermission } from '../core/permission.js';
11
9
  import { loadMcpRegistry } from '../core/mcp/registry.js';
12
10
  import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
13
11
  import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
@@ -16,7 +14,6 @@ import { loadSettings } from '../core/settings.js';
16
14
  import { FileReadCache } from '../core/file-cache.js';
17
15
  import { resolveWorkspacePath } from '../core/path-security.js';
18
16
  import { globTool, grepTool, readTool } from '../tools/file-tools.js';
19
- import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
20
17
  import { webFetchTool } from '../tools/web-fetch.js';
21
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
22
19
  import { signatureForPlanReview } from '../core/repl/ask.js';
@@ -30,6 +27,7 @@ import { runJobsCommand } from '../commands/jobs.js';
30
27
  import { runConfigCommand } from './commands/config.js';
31
28
  import { runPrivacyCommand } from './commands/privacy.js';
32
29
  import { runReport } from './commands/report.js';
30
+ import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
33
31
  import { runUndoCommand } from './commands/undo.js';
34
32
  import { runBudgetCommand } from './commands/budget.js';
35
33
  import { runSkillsCommand } from './commands/skills.js';
@@ -1111,61 +1109,29 @@ async function help(args, flags, _session) {
1111
1109
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
1112
1110
  ].join('\n'));
1113
1111
  }
1112
+ /**
1113
+ * `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
1114
+ * probe runner in `runtime/commands/doctor.ts`. The handler stays
1115
+ * thin so the probe surface stays single-sourced between the CLI
1116
+ * shell command, the `pnpm run doctor --json` package script, and
1117
+ * the in-REPL `/doctor` slash command.
1118
+ *
1119
+ * Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
1120
+ * 2 = at least one error probe). The pre-L17 minimal doctor surface
1121
+ * (adapter capabilities + schema bundle hash) is preserved under
1122
+ * `payload.meta.legacy` so any operator scripts that grep the JSON
1123
+ * keep working through the transition; the field is marked for
1124
+ * removal in a follow-up sprint once the new shape is the
1125
+ * documented contract.
1126
+ */
1114
1127
  async function doctor(_args, flags, _session) {
1115
- const cwd = process.cwd();
1116
- const settings = loadSettings(cwd);
1117
- // `doctor` reports adapter capabilities only; we pass a no-op client
1118
- // so we do not require an Anvil endpoint to run `pugi doctor`. The
1119
- // adapter never invokes `client.send()` from inside `capabilities()`.
1120
- const inertClient = {
1121
- async send() {
1122
- return {
1123
- stop: 'error',
1124
- code: 'failed',
1125
- message: 'doctor: inert client',
1126
- };
1127
- },
1128
- };
1129
- const adapters = [
1130
- new NoopEngineAdapter(),
1131
- new NativePugiEngineAdapter({ client: inertClient }),
1132
- ];
1133
- const capabilities = await Promise.all(adapters.map(async (adapter) => ({
1134
- name: adapter.name,
1135
- capabilities: await adapter.capabilities(),
1136
- })));
1137
- const payload = {
1138
- cliVersion: PUGI_CLI_VERSION,
1139
- nodeVersion: process.version,
1140
- workspaceRoot: cwd,
1141
- pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
1142
- pugiDir: existsSync(resolve(cwd, '.pugi')),
1143
- eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
1144
- permissionMode: settings.permissions.mode,
1145
- approvals: settings.workflow.approvals,
1146
- notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
1147
- protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
1148
- protectedFileSafety: 'configured-in-m1',
1149
- mcpTrust: 'not-configured',
1150
- releaseGuard: 'scaffolded',
1151
- tools: toolRegistry,
1152
- engineAdapters: capabilities,
1153
- schemaBundleHash: createHash('sha256')
1154
- .update(toolSchemaBundleHashInput())
1155
- .digest('hex'),
1156
- };
1157
- writeOutput(flags, payload, [
1158
- 'Pugi doctor',
1159
- `CLI: ${payload.cliVersion}`,
1160
- `Node: ${payload.nodeVersion}`,
1161
- `Workspace: ${payload.workspaceRoot}`,
1162
- `Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
1163
- `Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
1164
- `Event log: ${payload.eventLog ? 'present' : 'missing'}`,
1165
- `Permission mode: ${payload.permissionMode}`,
1166
- `Approvals: ${payload.approvals}`,
1167
- `Release guard: ${payload.releaseGuard}`,
1168
- ].join('\n'));
1128
+ await runDoctorCommand({
1129
+ cwd: process.cwd(),
1130
+ home: defaultDoctorHome(),
1131
+ env: process.env,
1132
+ json: flags.json,
1133
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1134
+ });
1169
1135
  }
1170
1136
  /**
1171
1137
  * Programmatic init scaffolder. Idempotent — every helper call is a