@on-par/factory-cli 0.9.3 → 0.9.5-next.1

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.
@@ -1,9 +1,17 @@
1
1
  // packages/cli/src/cli/doctor.ts — `factory doctor` environment preflight
2
+ import { existsSync } from 'node:fs';
2
3
  import { resolve } from 'node:path';
3
4
  import { detectSandboxRuntime } from '@on-par/factory-core';
5
+ import { findConfigSchemaErrors, isHarnessCliPresent, isTemplateToken, lintFactoryConfig, listBuiltInCheckers, } from '@on-par/factory-core/internal';
4
6
  import { MIN_NODE_MAJOR } from '../node-version.js';
5
- import { CLAUDE_CODE_URL, GITHUB_TOKENS_URL } from './first-run.js';
7
+ import { availableAgentClis, CLAUDE_CODE_URL, CODEX_CLI_URL, GITHUB_TOKENS_URL } from './first-run.js';
8
+ /** Section for rows about repo-configured command harnesses (#2691). */
9
+ export const COMMAND_HARNESS_SECTION = 'Command harness';
10
+ /** Repo wired-up rows, sectioned only inside a git repo (#2557). */
11
+ export const REPO_SECTION = 'Repo';
6
12
  export const NOT_IN_REPO_HINT = 'cd into your repo, then run factory doctor again';
13
+ /** Detail for every repo wired-up row when doctor runs outside a git repository (#2554). */
14
+ export const NOT_IN_REPO_SKIP_DETAIL = `skipped: not inside a git repository; ${NOT_IN_REPO_HINT}`;
7
15
  export function hasSandboxRuntime(platform, commandAvailable) {
8
16
  return detectSandboxRuntime(platform, commandAvailable) !== 'none';
9
17
  }
@@ -11,13 +19,298 @@ export function hasSandboxRuntime(platform, commandAvailable) {
11
19
  * or null when cmdDoctor should resolve the policy as before (#2383). */
12
20
  export function sandboxAuthSkipDetail(opts) {
13
21
  if (!opts.runtimeAvailable)
14
- return 'skipped — no sandbox runtime (sandbox-exec/firejail) on this host';
22
+ return 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
15
23
  if (!opts.hostOk)
16
- return 'skipped — host claude auth was not verified';
24
+ return 'skipped: host claude auth was not verified';
17
25
  if (!opts.inRepo)
18
- return `skipped — not inside a git repository; ${NOT_IN_REPO_HINT}`;
26
+ return NOT_IN_REPO_SKIP_DETAIL;
19
27
  return null;
20
28
  }
29
+ /** Sign-in method reported by `codex login status`, or null when it shows no login. Never returns
30
+ * any part of a credential: only a fixed label is derived from the status text. */
31
+ export function codexAuthMethod(statusOutput) {
32
+ if (statusOutput === null)
33
+ return null;
34
+ const text = statusOutput.toLowerCase();
35
+ if (!text.includes('logged in') || text.includes('not logged in'))
36
+ return null;
37
+ if (text.includes('chatgpt'))
38
+ return 'ChatGPT';
39
+ if (text.includes('api key'))
40
+ return 'API key';
41
+ if (text.includes('access token'))
42
+ return 'access token';
43
+ return 'unknown method';
44
+ }
45
+ const GIT_AUTH_CHECK = 'git auth';
46
+ /** Non-interactive read-only probe of origin. `credential.interactive=never` turns off Git
47
+ * Credential Manager UI and an empty `core.askPass` turns off configured askpass programs;
48
+ * stored credentials from any helper still work. */
49
+ export const GIT_AUTH_PROBE_CMD = 'git -c credential.interactive=never -c core.askPass= ls-remote --quiet origin HEAD';
50
+ /** GIT_TERMINAL_PROMPT=0 makes git fail instead of asking on the terminal; empty GIT_ASKPASS
51
+ * and SSH_ASKPASS turn off askpass programs and ssh's GUI askpass; GCM_INTERACTIVE=never
52
+ * turns off Git Credential Manager prompts. */
53
+ export const GIT_AUTH_PROBE_ENV = {
54
+ GIT_TERMINAL_PROMPT: '0',
55
+ GIT_ASKPASS: '',
56
+ SSH_ASKPASS: '',
57
+ GCM_INTERACTIVE: 'never',
58
+ };
59
+ /** Bounds the probe, e.g. an ssh key passphrase prompt that waits on /dev/tty. */
60
+ export const GIT_AUTH_PROBE_TIMEOUT_MS = 15_000;
61
+ /** Remedy shown only when the git-auth probe fails: wires git's credential helper to gh. */
62
+ export const GIT_AUTH_FIX = 'run `gh auth setup-git` so git uses your gh credentials, then re-run `factory doctor`';
63
+ /** Git-auth check (#2482, #2483): skipped, never failing, outside a git repository or without
64
+ * an origin remote. Otherwise runs a non-interactive `git ls-remote` against origin and fails
65
+ * when it cannot authenticate. The detail never echoes the origin URL, which may hold a token. */
66
+ export function gitAuthCheck(repoRoot, tryExec) {
67
+ if (repoRoot === null) {
68
+ return {
69
+ name: GIT_AUTH_CHECK,
70
+ ok: true,
71
+ optional: true,
72
+ detail: NOT_IN_REPO_SKIP_DETAIL,
73
+ };
74
+ }
75
+ const origin = tryExec('git remote get-url origin');
76
+ if (!origin) {
77
+ return { name: GIT_AUTH_CHECK, ok: true, optional: true, detail: 'skipped: no origin remote configured' };
78
+ }
79
+ // An empty string is exit 0 with no HEAD ref (an empty repo): still authenticated.
80
+ const probe = tryExec(GIT_AUTH_PROBE_CMD, { env: { ...GIT_AUTH_PROBE_ENV }, timeoutMs: GIT_AUTH_PROBE_TIMEOUT_MS });
81
+ if (probe === null) {
82
+ return {
83
+ name: GIT_AUTH_CHECK,
84
+ ok: false,
85
+ detail: 'git could not authenticate to origin non-interactively (prompting disabled)',
86
+ fix: GIT_AUTH_FIX,
87
+ next: 'gh auth setup-git',
88
+ };
89
+ }
90
+ return { name: GIT_AUTH_CHECK, ok: true, detail: 'git can authenticate to origin non-interactively' };
91
+ }
92
+ /** One doctor section per built-in harness (#2555), in display order. */
93
+ export const HARNESS_SETUP = [
94
+ {
95
+ section: 'Claude Code',
96
+ harnessId: 'claude-cli',
97
+ cli: 'claude',
98
+ install: 'npm install -g @anthropic-ai/claude-code',
99
+ login: 'claude /login',
100
+ envKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
101
+ },
102
+ {
103
+ section: 'Codex',
104
+ harnessId: 'codex-cli',
105
+ cli: 'codex',
106
+ install: 'npm install -g @openai/codex',
107
+ login: 'codex login',
108
+ envKeys: ['CODEX_API_KEY'],
109
+ },
110
+ {
111
+ section: 'OpenCode',
112
+ harnessId: 'opencode',
113
+ cli: 'opencode',
114
+ install: 'npm install -g opencode-ai',
115
+ login: 'opencode auth login',
116
+ envKeys: ['OPENCODE_API_KEY'],
117
+ },
118
+ ];
119
+ /** One optional `<cli> installed` row per harness, with the CLI version when found and the install command when not (#2555). */
120
+ export function harnessInstallChecks(commandAvailable, tryExec) {
121
+ return HARNESS_SETUP.map(({ section, cli, install }) => {
122
+ const name = `${cli} installed`;
123
+ if (!commandAvailable(cli)) {
124
+ return {
125
+ name,
126
+ section,
127
+ ok: false,
128
+ optional: true,
129
+ detail: `${cli} not found on PATH, ${cli} routes unavailable`,
130
+ fix: `install ${section}: ${install}`,
131
+ };
132
+ }
133
+ const version = tryExec(`${cli} --version`)?.trim().split('\n')[0]?.trim();
134
+ return {
135
+ name,
136
+ section,
137
+ ok: true,
138
+ optional: true,
139
+ detail: version ? `found on PATH (${version})` : 'found on PATH (version unknown)',
140
+ };
141
+ });
142
+ }
143
+ const SIGN_IN_EVIDENCE = {
144
+ claude: 'no ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN or stored login',
145
+ codex: 'no CODEX_API_KEY or codex login; API billing: codex login --with-api-key; headless: codex login --device-auth',
146
+ opencode: 'no OPENCODE_API_KEY or stored auth login',
147
+ };
148
+ function safe(fn) {
149
+ if (!fn)
150
+ return false;
151
+ try {
152
+ return fn();
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ /** One `<cli> signed in` row per installed harness CLI (#2556, #2631), judged only from local evidence: an env key,
159
+ * stored credentials, or `codex login status`. Never runs inference and never prints a secret. */
160
+ export function harnessSignInChecks(probes) {
161
+ const { commandAvailable, envPresent, tryExec } = probes;
162
+ const found = availableAgentClis(commandAvailable);
163
+ return HARNESS_SETUP.filter(({ harnessId }) => isHarnessCliPresent(harnessId, commandAvailable)).map(({ section, cli, login, envKeys }) => {
164
+ const name = `${cli} signed in`;
165
+ const fix = `sign in to ${section}: ${login}`;
166
+ if (cli === 'codex') {
167
+ const key = envKeys.find((k) => envPresent(k));
168
+ if (key)
169
+ return { name, section, ok: true, detail: `signed in (${key} set)` };
170
+ const method = codexAuthMethod(tryExec('codex login status 2>&1'));
171
+ const session = method === 'ChatGPT' ? (probes.codexSessionState?.() ?? 'unknown') : 'unknown';
172
+ if (session === 'unusable') {
173
+ return {
174
+ name,
175
+ section,
176
+ ok: false,
177
+ optional: found.length > 1,
178
+ detail: 'ChatGPT session in auth.json is unusable (unreadable, or no valid access token and no refresh token)',
179
+ fix,
180
+ next: login,
181
+ };
182
+ }
183
+ if (session === 'refresh-pending') {
184
+ return {
185
+ name,
186
+ section,
187
+ ok: true,
188
+ detail: 'logged in with ChatGPT; access token expired, codex refreshes it on the next run (run `codex login` if that run fails with 401)',
189
+ };
190
+ }
191
+ if (method) {
192
+ return {
193
+ name,
194
+ section,
195
+ ok: true,
196
+ detail: method === 'ChatGPT'
197
+ ? 'logged in with ChatGPT; runs draw on your ChatGPT plan limits (manage usage in ChatGPT settings)'
198
+ : `logged in with ${method}`,
199
+ };
200
+ }
201
+ }
202
+ else {
203
+ const key = envKeys.find((k) => envPresent(k));
204
+ if (key)
205
+ return { name, section, ok: true, detail: `signed in (${key} set)` };
206
+ if (cli === 'claude' && safe(probes.claudeLoggedIn)) {
207
+ return { name, section, ok: true, detail: 'signed in (stored Claude Code login found)' };
208
+ }
209
+ if (cli === 'opencode' && safe(probes.opencodeLoggedIn)) {
210
+ return { name, section, ok: true, detail: 'signed in (stored opencode auth login found)' };
211
+ }
212
+ }
213
+ return {
214
+ name,
215
+ section,
216
+ ok: false,
217
+ // Required only when this is the sole agent: then nothing else can run.
218
+ optional: found.length > 1,
219
+ detail: `unverified: no sign-in evidence (${SIGN_IN_EVIDENCE[cli]})`,
220
+ fix,
221
+ next: login,
222
+ };
223
+ });
224
+ }
225
+ function safeList(fn) {
226
+ if (!fn)
227
+ return [];
228
+ try {
229
+ return fn();
230
+ }
231
+ catch {
232
+ return [];
233
+ }
234
+ }
235
+ /** The executable (first token) of a command template, or null when it cannot be parsed safely (#2691).
236
+ * Guards `isCommandAvailable`, which interpolates into `command -v` unescaped. */
237
+ export function commandExecutable(command) {
238
+ const tokens = command.trim().split(/\s+/);
239
+ while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
240
+ tokens.shift();
241
+ let token = tokens[0];
242
+ if (!token)
243
+ return null;
244
+ const quoted = /^(['"])(.*)\1$/.exec(token);
245
+ if (quoted)
246
+ token = quoted[2];
247
+ return /^[\w./@+~-]+$/.test(token) ? token : null;
248
+ }
249
+ /** Declared, agentic command harnesses that at least one repo `models.registry` entry uses (#2691). */
250
+ export function repoCommandHarnesses(harnesses, registry) {
251
+ const used = new Set(Object.values(registry ?? {}).map((def) => def.harness));
252
+ return Object.entries(harnesses ?? {})
253
+ .filter(([id, decl]) => decl.agentic === true && used.has(id))
254
+ .map(([id, decl]) => ({ harness: id, command: decl.command, executable: commandExecutable(decl.command) }));
255
+ }
256
+ /** One optional `command harness <id>` row per configured command harness (#2691). */
257
+ export function commandHarnessChecks(harnesses, commandAvailable) {
258
+ return harnesses.map((h) => {
259
+ const name = `command harness ${h.harness}`;
260
+ if (h.executable !== null && commandAvailable(h.executable)) {
261
+ return {
262
+ name,
263
+ section: COMMAND_HARNESS_SECTION,
264
+ optional: true,
265
+ ok: true,
266
+ detail: `${h.executable} found on PATH (command: ${h.command})`,
267
+ };
268
+ }
269
+ return {
270
+ name,
271
+ section: COMMAND_HARNESS_SECTION,
272
+ optional: true,
273
+ ok: false,
274
+ detail: `configured command \`${h.command}\`: ${h.executable === null ? 'cannot parse an executable from it' : `${h.executable} not found on PATH`}`,
275
+ fix: installFix(h),
276
+ };
277
+ });
278
+ }
279
+ function installFix(h) {
280
+ return `install ${h.executable ?? 'the agent CLI'} on PATH or fix "command" for harness ${h.harness} in the models config harnesses section`;
281
+ }
282
+ /** Coding agent prerequisites: at least one agent CLI is required; each CLI is then reported
283
+ * on its own optional line, with sign-in reported separately from installation (#1269, #2422, #2555, #2556).
284
+ * A repo-configured command harness whose executable is on PATH also counts as a coding agent (#2691). */
285
+ export function agentCliChecks(probes) {
286
+ const { commandAvailable, tryExec } = probes;
287
+ const found = availableAgentClis(commandAvailable);
288
+ const configured = safeList(probes.commandHarnesses);
289
+ const runnable = configured.filter((h) => h.executable !== null && commandAvailable(h.executable));
290
+ const agents = [...found, ...runnable.map((h) => `${h.harness} (command harness)`)];
291
+ const describe = (h) => `command harness ${h.harness} runs \`${h.command}\`, but ${h.executable ?? 'its executable'} is not on PATH`;
292
+ const missingRow = configured.length === 0
293
+ ? {
294
+ name: 'coding agent',
295
+ ok: false,
296
+ detail: 'no coding agent CLI on PATH (claude, codex or opencode)',
297
+ fix: `install Claude Code (${CLAUDE_CODE_URL}) or the Codex CLI (${CODEX_CLI_URL})`,
298
+ next: HARNESS_SETUP[0].install,
299
+ }
300
+ : {
301
+ name: 'coding agent',
302
+ ok: false,
303
+ detail: `no coding agent CLI on PATH (claude, codex or opencode); ${configured.map(describe).join('; ')}`,
304
+ fix: installFix(configured[0]),
305
+ };
306
+ const checks = [
307
+ agents.length > 0 ? { name: 'coding agent', ok: true, detail: `found ${agents.join(', ')}` } : missingRow,
308
+ ];
309
+ checks.push(...harnessInstallChecks(commandAvailable, tryExec));
310
+ checks.push(...commandHarnessChecks(configured, commandAvailable));
311
+ checks.push(...harnessSignInChecks(probes));
312
+ return checks;
313
+ }
21
314
  export function runDoctorChecks(probes) {
22
315
  const { commandAvailable, envPresent, tryExec, pathExists, distFreshness } = probes;
23
316
  const checks = [];
@@ -28,37 +321,40 @@ export function runDoctorChecks(probes) {
28
321
  name: 'git repository',
29
322
  ok: false,
30
323
  optional: true,
31
- detail: 'not inside a git repository — repo checks skipped',
324
+ detail: 'not inside a git repository; repo checks skipped',
32
325
  fix: NOT_IN_REPO_HINT,
33
326
  });
34
- const userName = tryExec('git config user.name');
35
- const userEmail = tryExec('git config user.email');
36
- if (userName && userEmail) {
37
- checks.push({ name: 'git identity', ok: true, detail: `${userName} <${userEmail}>` });
327
+ const repoSection = repoRoot === null ? {} : { section: REPO_SECTION };
328
+ if (repoRoot === null) {
329
+ checks.push({ name: 'git identity', ok: false, optional: true, detail: NOT_IN_REPO_SKIP_DETAIL });
38
330
  }
39
331
  else {
40
- const missing = [!userName && 'user.name', !userEmail && 'user.email'].filter(Boolean).join(' and ');
41
- checks.push({
42
- name: 'git identity',
43
- ok: false,
44
- detail: `${missing} not set`,
45
- fix: 'git config --global user.name "Your Name" && git config --global user.email you@example.com',
46
- });
332
+ const userName = tryExec('git config user.name');
333
+ const userEmail = tryExec('git config user.email');
334
+ if (userName && userEmail) {
335
+ checks.push({ name: 'git identity', ok: true, detail: `${userName} <${userEmail}>`, ...repoSection });
336
+ }
337
+ else {
338
+ const missing = [!userName && 'user.name', !userEmail && 'user.email'].filter(Boolean).join(' and ');
339
+ checks.push({
340
+ name: 'git identity',
341
+ ok: false,
342
+ detail: `${missing} not set`,
343
+ next: !userName
344
+ ? 'git config --global user.name "Your Name"'
345
+ : 'git config --global user.email you@example.com',
346
+ ...repoSection,
347
+ });
348
+ }
47
349
  }
48
- checks.push(commandAvailable('claude')
49
- ? { name: 'claude CLI', ok: true, detail: 'found on PATH' }
50
- : {
51
- name: 'claude CLI',
52
- ok: false,
53
- detail: 'not found on PATH',
54
- fix: `install Claude Code first: ${CLAUDE_CODE_URL}`,
55
- });
350
+ checks.push(...agentCliChecks(probes));
56
351
  if (!commandAvailable('gh')) {
57
352
  checks.push({
58
353
  name: 'gh auth',
59
354
  ok: false,
60
355
  detail: 'gh CLI not found on PATH',
61
- fix: 'install GitHub CLI and run `gh auth login`',
356
+ fix: 'install GitHub CLI: https://cli.github.com',
357
+ next: 'gh auth login',
62
358
  });
63
359
  }
64
360
  else if (tryExec('gh auth status') === null) {
@@ -66,12 +362,13 @@ export function runDoctorChecks(probes) {
66
362
  name: 'gh auth',
67
363
  ok: false,
68
364
  detail: 'gh is not authenticated',
69
- fix: 'install GitHub CLI and run `gh auth login`',
365
+ next: 'gh auth login',
70
366
  });
71
367
  }
72
368
  else {
73
369
  checks.push({ name: 'gh auth', ok: true, detail: 'authenticated' });
74
370
  }
371
+ checks.push({ ...gitAuthCheck(repoRoot, tryExec), ...repoSection });
75
372
  if (envPresent('GITHUB_TOKEN')) {
76
373
  checks.push({ name: 'GitHub token', ok: true, detail: 'GITHUB_TOKEN set' });
77
374
  }
@@ -89,6 +386,7 @@ export function runDoctorChecks(probes) {
89
386
  ok: false,
90
387
  detail: 'no GITHUB_TOKEN, GH_TOKEN, or gh auth token',
91
388
  fix: `create a token at ${GITHUB_TOKENS_URL} and export it`,
389
+ next: 'gh auth login',
92
390
  });
93
391
  }
94
392
  }
@@ -105,11 +403,11 @@ export function runDoctorChecks(probes) {
105
403
  name: 'git worktree clean',
106
404
  ok: false,
107
405
  optional: true,
108
- detail: `skipped — not a git repository; ${NOT_IN_REPO_HINT}`,
406
+ detail: NOT_IN_REPO_SKIP_DETAIL,
109
407
  });
110
408
  }
111
409
  else if (tryExec('git status --porcelain') === '') {
112
- checks.push({ name: 'git worktree clean', ok: true, detail: 'clean' });
410
+ checks.push({ name: 'git worktree clean', ok: true, detail: 'clean', ...repoSection });
113
411
  }
114
412
  else {
115
413
  checks.push({
@@ -118,6 +416,7 @@ export function runDoctorChecks(probes) {
118
416
  optional: true,
119
417
  detail: 'uncommitted changes present',
120
418
  fix: 'commit or stash changes before running the factory',
419
+ ...repoSection,
121
420
  });
122
421
  }
123
422
  const sandboxRuntime = detectSandboxRuntime(probes.platform ?? process.platform, commandAvailable);
@@ -127,14 +426,14 @@ export function runDoctorChecks(probes) {
127
426
  ok: true,
128
427
  optional: true,
129
428
  detail: sandboxRuntime === 'none'
130
- ? 'codex CLI found — no sandbox runtime, so codex runs with danger-full-access'
429
+ ? 'codex CLI found; no sandbox runtime, so codex runs with danger-full-access'
131
430
  : 'codex CLI found (sandboxed builds available)',
132
431
  }
133
432
  : {
134
433
  name: 'codex sandbox',
135
434
  ok: false,
136
435
  optional: true,
137
- detail: 'codex CLI not found — codex build routes unavailable',
436
+ detail: 'codex CLI not found; codex build routes unavailable',
138
437
  fix: 'install the codex CLI, or rely on claude routes',
139
438
  });
140
439
  checks.push(sandboxRuntime !== 'none'
@@ -143,7 +442,7 @@ export function runDoctorChecks(probes) {
143
442
  name: 'sandbox runtime',
144
443
  ok: false,
145
444
  optional: true,
146
- detail: 'no sandbox-exec (macOS) or firejail (Linux) on PATH — agents will run uncontained',
445
+ detail: 'no sandbox-exec (macOS) or firejail (Linux) on PATH; agents will run uncontained',
147
446
  fix: 'install firejail (Linux) or run on macOS; see README Safety',
148
447
  });
149
448
  if (repoRoot === null) {
@@ -151,19 +450,19 @@ export function runDoctorChecks(probes) {
151
450
  name: '.factory initialized',
152
451
  ok: false,
153
452
  optional: true,
154
- detail: `skipped — not a git repository; ${NOT_IN_REPO_HINT}`,
453
+ detail: NOT_IN_REPO_SKIP_DETAIL,
155
454
  });
156
455
  }
157
456
  else if (pathExists(resolve(repoRoot, '.factory'))) {
158
- checks.push({ name: '.factory initialized', ok: true, optional: true, detail: '.factory/ present' });
457
+ checks.push({ name: '.factory initialized', ok: true, detail: '.factory/ present', ...repoSection });
159
458
  }
160
459
  else {
161
460
  checks.push({
162
461
  name: '.factory initialized',
163
462
  ok: false,
164
- optional: true,
165
463
  detail: '.factory/ missing',
166
- fix: 'run `factory init`',
464
+ next: 'factory init',
465
+ ...repoSection,
167
466
  });
168
467
  }
169
468
  if (distFreshness) {
@@ -175,16 +474,32 @@ export function runDoctorChecks(probes) {
175
474
  ok: false,
176
475
  detail,
177
476
  fix: "run 'npm run build' at the monorepo root",
477
+ next: 'npm run build',
178
478
  });
179
479
  }
180
480
  return checks;
181
481
  }
182
- export function formatDoctorChecks(checks) {
482
+ /** Row icon (#2550): ❌ only for a failure (counted in "N failed"), ➖ for a skipped row, ⚠️ for an optional warning. */
483
+ export function doctorCheckIcon(check) {
484
+ if (!check.ok && !check.optional)
485
+ return '❌';
486
+ if (/^skipped\b/.test(check.detail))
487
+ return '➖';
488
+ return check.ok ? '✅' : '⚠️';
489
+ }
490
+ /** Outside a repo the models hint is skipped because `factory models --doctor` needs a repo (#2472). */
491
+ export function formatDoctorChecks(checks, opts = {}) {
183
492
  const lines = ['== factory doctor =='];
184
493
  let passed = 0;
185
494
  let failed = 0;
186
495
  let warnings = 0;
187
- for (const check of checks) {
496
+ const sections = [...new Set(checks.flatMap((c) => (c.section === undefined ? [] : [c.section])))];
497
+ const ordered = [
498
+ ...checks.filter((c) => c.section === undefined),
499
+ ...sections.flatMap((s) => checks.filter((c) => c.section === s)),
500
+ ];
501
+ let currentSection;
502
+ for (const check of ordered) {
188
503
  if (check.ok) {
189
504
  passed++;
190
505
  }
@@ -194,15 +509,24 @@ export function formatDoctorChecks(checks) {
194
509
  else {
195
510
  failed++;
196
511
  }
197
- const icon = check.ok ? '✅' : '❌';
198
- lines.push(` ${icon} ${check.name} — ${check.detail}`);
512
+ if (check.section !== undefined && check.section !== currentSection) {
513
+ lines.push(`-- ${check.section} --`);
514
+ }
515
+ currentSection = check.section;
516
+ const icon = doctorCheckIcon(check);
517
+ lines.push(` ${icon} ${check.name} - ${check.detail}`);
199
518
  if (!check.ok && check.fix) {
200
519
  lines.push(` fix: ${check.fix}`);
201
520
  }
521
+ if (!check.ok && check.next) {
522
+ lines.push(` next: ${check.next}`);
523
+ }
202
524
  }
203
525
  lines.push(`${passed} passed, ${failed} failed, ${warnings} warnings`);
204
526
  lines.push('');
205
- lines.push('Model reachability: factory models --doctor');
527
+ lines.push(opts.inRepo === false
528
+ ? `Model reachability: ${NOT_IN_REPO_SKIP_DETAIL}`
529
+ : 'Model reachability: factory models --doctor');
206
530
  return lines.join('\n');
207
531
  }
208
532
  export function doctorFailed(checks) {
@@ -230,15 +554,15 @@ export function claudeKeychainCheck(probe) {
230
554
  return {
231
555
  name: 'claude keychain (macOS)',
232
556
  ok: false,
233
- detail: `security find-generic-password -s '${service}'${profile} failed inside tmux (SecKeychainSearchCopyNext) — Claude OAuth refresh will fail with local_auth`,
234
- fix: 'run the factory from a context with login-keychain access — a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal, not a raw tmux pane — see docs/runbooks/macos-keychain-launchagent.md',
557
+ detail: `security find-generic-password -s '${service}'${profile} failed inside tmux (SecKeychainSearchCopyNext); Claude OAuth refresh will fail with local_auth`,
558
+ fix: 'run the factory from a context with login-keychain access, such as a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal, not a raw tmux pane; see docs/runbooks/macos-keychain-launchagent.md',
235
559
  };
236
560
  }
237
561
  return {
238
562
  name: 'claude keychain (macOS)',
239
563
  ok: false,
240
564
  optional: true,
241
- detail: `security find-generic-password -s '${service}'${profile} failed — either never logged in, or this launch context cannot read the login keychain`,
565
+ detail: `security find-generic-password -s '${service}'${profile} failed; either never logged in, or this launch context cannot read the login keychain`,
242
566
  fix: `run ${loginFix(probe)} and complete /login (or export ANTHROPIC_API_KEY); if already logged in, see docs/runbooks/macos-keychain-launchagent.md`,
243
567
  };
244
568
  }
@@ -248,7 +572,7 @@ export function keychainPreflightError(probe) {
248
572
  if (probe.status !== 'unreadable' || !probe.inTmux)
249
573
  return null;
250
574
  const service = probe.service ?? DEFAULT_KEYCHAIN_SERVICE;
251
- return `Claude Code credentials are not readable from this tmux session (security find-generic-password -s '${service}' failed / SecKeychainSearchCopyNext). OAuth refresh will fail and every claimed issue would spuriously park as local_auth. Launch the factory from a context with login-keychain access — a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal — or export ANTHROPIC_API_KEY. See docs/runbooks/macos-keychain-launchagent.md.`;
575
+ return `Claude Code credentials are not readable from this tmux session (security find-generic-password -s '${service}' failed / SecKeychainSearchCopyNext). OAuth refresh will fail and every claimed issue would spuriously park as local_auth. Launch the factory from a context with login-keychain access, such as a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal, or export ANTHROPIC_API_KEY. See docs/runbooks/macos-keychain-launchagent.md.`;
252
576
  }
253
577
  const HOST_AUTH_CHECK = 'claude auth (host)';
254
578
  const SANDBOX_AUTH_CHECK = 'claude auth (sandboxed)';
@@ -277,7 +601,7 @@ export function sandboxClaudeAuthChecks(result) {
277
601
  sandboxed = {
278
602
  name: SANDBOX_AUTH_CHECK,
279
603
  ok: false,
280
- detail: `${result.sandboxDetail} — host claude auth is healthy, so the sandbox is blocking Claude credential refresh`,
604
+ detail: `${result.sandboxDetail}; host claude auth is healthy, so the sandbox is blocking Claude credential refresh`,
281
605
  fix: 'check the sandbox write allowlist (~/.claude.json*, $CLAUDE_CONFIG_DIR when set, ~/Library/Keychains); as a stopgap run with FACTORY_SANDBOX=0',
282
606
  };
283
607
  }
@@ -286,12 +610,27 @@ export function sandboxClaudeAuthChecks(result) {
286
610
  name: SANDBOX_AUTH_CHECK,
287
611
  ok: false,
288
612
  optional: true,
289
- detail: `${result.sandboxDetail} — host claude auth is not healthy either, so this is not attributable to the sandbox`,
613
+ detail: `${result.sandboxDetail}; host claude auth is not healthy either, so this is not attributable to the sandbox`,
290
614
  fix: 'fix host claude auth first, then re-run `factory doctor`',
291
615
  };
292
616
  }
293
617
  return [host, sandboxed];
294
618
  }
619
+ const AGENT_AUTH_CHECKS = new Set([...HARNESS_SETUP.map(({ cli }) => `${cli} signed in`), HOST_AUTH_CHECK]);
620
+ /** #2496: doctor fails on agent sign-in only when no installed agent is signed in. A logged-out
621
+ * agent is a warning while another one is signed in, and a hard failure when none is. Skipped
622
+ * probes (optional passes, e.g. a `<cli> signed in` row for a CLI not on PATH) never count as signed in. */
623
+ export function reconcileAgentAuthChecks(checks) {
624
+ const auth = checks.filter((c) => AGENT_AUTH_CHECKS.has(c.name));
625
+ const anySignedIn = auth.some((c) => c.ok && c.optional !== true);
626
+ return checks.map((c) => {
627
+ if (!AGENT_AUTH_CHECKS.has(c.name) || c.ok)
628
+ return c;
629
+ return anySignedIn
630
+ ? { ...c, optional: true, detail: `${c.detail} (another agent is signed in, so runs can still proceed)` }
631
+ : { ...c, optional: false };
632
+ });
633
+ }
295
634
  export function leaseChecks(rows) {
296
635
  if (rows.length === 0) {
297
636
  return [{ name: 'port leases', ok: true, optional: true, detail: 'no active leases' }];
@@ -302,7 +641,7 @@ export function leaseChecks(rows) {
302
641
  name: `port lease :${row.port}`,
303
642
  ok: true,
304
643
  optional: true,
305
- detail: `${row.worktreeId} — port ${row.port}, pid ${row.pid}, live`,
644
+ detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, live`,
306
645
  };
307
646
  }
308
647
  const squatted = row.portSquatted ? '; port still in use by another process' : '';
@@ -310,7 +649,7 @@ export function leaseChecks(rows) {
310
649
  name: `port lease :${row.port}`,
311
650
  ok: false,
312
651
  optional: true,
313
- detail: `${row.worktreeId} — port ${row.port}, pid ${row.pid}, stale (${row.reason})${squatted}`,
652
+ detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, stale (${row.reason})${squatted}`,
314
653
  fix: 'run `factory doctor --reconcile` to reclaim stale leases',
315
654
  };
316
655
  });
@@ -349,7 +688,7 @@ export function eventLogCheck(integrity) {
349
688
  ok: false,
350
689
  optional: true,
351
690
  detail: `${integrity.unparseable} of ${integrity.total} lines unparseable (${pct}%)`,
352
- fix: 'historical corruption from pre-lock parallel writes; appends are now serialized — metrics over this file carry this error bar',
691
+ fix: 'historical corruption from pre-lock parallel writes; appends are now serialized; metrics over this file carry this error bar',
353
692
  };
354
693
  }
355
694
  export function orphanContainerChecks(names) {
@@ -380,7 +719,7 @@ export function formatContainerReconcileReport(reaped) {
380
719
  return reaped
381
720
  .map((r) => r.removed
382
721
  ? `reconcile: removed container ${r.name}`
383
- : `reconcile: failed to remove container ${r.name} — ${r.detail}`)
722
+ : `reconcile: failed to remove container ${r.name}: ${r.detail}`)
384
723
  .join('\n');
385
724
  }
386
725
  export function formatMicroVmReconcileReport(reaped) {
@@ -389,7 +728,7 @@ export function formatMicroVmReconcileReport(reaped) {
389
728
  return reaped
390
729
  .map((r) => r.removed
391
730
  ? `reconcile: removed sbx VM ${r.name} (sbx rm --force)`
392
- : `reconcile: failed to remove sbx VM ${r.name} — ${r.detail}`)
731
+ : `reconcile: failed to remove sbx VM ${r.name}: ${r.detail}`)
393
732
  .join('\n');
394
733
  }
395
734
  export function formatReconcileReport(reaped) {
@@ -416,7 +755,7 @@ export function formatClaimReconcileReport(released) {
416
755
  return released
417
756
  .map((c) => c.released
418
757
  ? `reconcile: released issue #${c.issue} back to factory:queued (lease expired at ${c.expiresAt})`
419
- : `reconcile: failed to release issue #${c.issue} (lease expired at ${c.expiresAt}) — ${c.detail ?? 'unknown error'}`)
758
+ : `reconcile: failed to release issue #${c.issue} (lease expired at ${c.expiresAt}): ${c.detail ?? 'unknown error'}`)
420
759
  .join('\n');
421
760
  }
422
761
  const GREEN_PR_CHECK = 'green PRs awaiting merge';
@@ -430,7 +769,7 @@ export function unmergedGreenPrChecks(result) {
430
769
  name: GREEN_PR_CHECK,
431
770
  ok: false,
432
771
  optional: true,
433
- detail: `scan failed — ${result.detail}`,
772
+ detail: `scan failed: ${result.detail}`,
434
773
  fix: 'check `gh auth status` and network access, then re-run `factory doctor`',
435
774
  },
436
775
  ];
@@ -442,8 +781,8 @@ export function unmergedGreenPrChecks(result) {
442
781
  name: `green PR #${row.number}`,
443
782
  ok: false,
444
783
  optional: true,
445
- detail: `${row.issue === null ? 'owning issue unknown' : `issue #${row.issue}`} — green and ready on ${row.branch}, open ${row.ageHours}h, not merged`,
446
- fix: `review and merge #${row.number} (or close it) — the factory never auto-merges an orphaned PR`,
784
+ detail: `${row.issue === null ? 'owning issue unknown' : `issue #${row.issue}`}: green and ready on ${row.branch}, open ${row.ageHours}h, not merged`,
785
+ fix: `review and merge #${row.number} (or close it); the factory never auto-merges an orphaned PR`,
447
786
  }));
448
787
  }
449
788
  /** One doctor check per user-declared harness, from its `probe` result. */
@@ -456,7 +795,7 @@ export function declaredHarnessChecks(probes) {
456
795
  return {
457
796
  name,
458
797
  ok: false,
459
- detail: `unavailable — probe \`${p.probe}\` failed: ${p.detail}`,
798
+ detail: `unavailable: probe \`${p.probe}\` failed: ${p.detail}`,
460
799
  fix: 'install the agent CLI on PATH or fix the "probe" command in the models config harnesses section',
461
800
  };
462
801
  }
@@ -464,9 +803,107 @@ export function declaredHarnessChecks(probes) {
464
803
  name,
465
804
  ok: false,
466
805
  optional: true,
467
- detail: 'unverified — no probe configured',
806
+ detail: 'unverified: no probe configured',
468
807
  fix: 'add a "probe" command (exit 0 = reachable) to the harness in the models config',
469
808
  };
470
809
  });
471
810
  }
811
+ const REGISTRY_CHECK = 'models.registry';
812
+ /** One failed check per models.registry entry whose harness is missing or unknown;
813
+ * a single ok row when every entry is valid; nothing when there is no registry. */
814
+ export function modelsRegistryChecks(configPath, registry, validHarnesses) {
815
+ const entries = Object.entries(registry ?? {});
816
+ if (entries.length === 0)
817
+ return [];
818
+ const failed = [];
819
+ for (const [id, def] of entries) {
820
+ const field = `models.registry.${id}.harness`;
821
+ const name = `${REGISTRY_CHECK} ${id}`;
822
+ if (def.harness === undefined || def.harness.trim() === '') {
823
+ failed.push({
824
+ name,
825
+ ok: false,
826
+ detail: `${configPath}: ${field} is missing`,
827
+ fix: `set "harness" to one of: ${validHarnesses.join(', ')}`,
828
+ });
829
+ }
830
+ else if (!validHarnesses.includes(def.harness)) {
831
+ failed.push({
832
+ name,
833
+ ok: false,
834
+ detail: `${configPath}: ${field} '${def.harness}' is not a known harness (valid harnesses: ${validHarnesses.join(', ')})`,
835
+ fix: 'use a built-in harness id or declare it under "harnesses" in the models config',
836
+ });
837
+ }
838
+ }
839
+ if (failed.length > 0)
840
+ return failed;
841
+ const n = entries.length;
842
+ return [{ name: REGISTRY_CHECK, ok: true, detail: `${n} entr${n === 1 ? 'y' : 'ies'} valid (${configPath})` }];
843
+ }
844
+ /** `config` rows from the shared pre-PLAN lint (#2612): one failing row per finding, so doctor fails exactly when
845
+ * the run preflight would escalate or park. A single ok row when clean. Read-only. */
846
+ export function configLintChecks(input, loadError) {
847
+ const schemaRows = (input.configFile ? findConfigSchemaErrors(input.configFile) : []).map((f) => ({
848
+ name: 'config',
849
+ ok: false,
850
+ section: REPO_SECTION,
851
+ detail: `${f.file}${f.line ? `:${f.line}` : ''}: schema error at '${f.path}': ${f.message}`,
852
+ fix: f.fix,
853
+ }));
854
+ if (loadError !== undefined) {
855
+ return [
856
+ {
857
+ name: 'config',
858
+ ok: false,
859
+ section: REPO_SECTION,
860
+ detail: loadError,
861
+ fix: 'fix the constitution frontmatter YAML',
862
+ },
863
+ ...schemaRows,
864
+ ];
865
+ }
866
+ const { findings } = lintFactoryConfig(input);
867
+ if (findings.length > 0) {
868
+ const names = [...listBuiltInCheckers().map((c) => c.name), 'custom_*'].join(', ');
869
+ const lintRows = findings.map((f) => {
870
+ const where = `${f.file}${f.line ? `:${f.line}` : ''}`;
871
+ if (f.rule === 'unknown-checker') {
872
+ const kind = isTemplateToken(f.token) ? 'placeholder' : 'unknown';
873
+ return {
874
+ name: 'config',
875
+ ok: false,
876
+ section: REPO_SECTION,
877
+ detail: `${where}: ${kind} checker '${f.token}'`,
878
+ fix: `replace '${f.token}' with a real checker name (${names}) or delete the line`,
879
+ };
880
+ }
881
+ return {
882
+ name: 'config',
883
+ ok: false,
884
+ section: REPO_SECTION,
885
+ detail: `${where}: unfilled template token '${f.token}'`,
886
+ fix: `replace '${f.token}' with a real value`,
887
+ };
888
+ });
889
+ return [...lintRows, ...schemaRows];
890
+ }
891
+ if (schemaRows.length > 0)
892
+ return schemaRows;
893
+ const scanned = [];
894
+ if (input.constitution?.repoInstance)
895
+ scanned.push(input.constitution.repoInstance.relPath);
896
+ if (input.configFile && existsSync(input.configFile.absPath))
897
+ scanned.push(input.configFile.relPath);
898
+ return [
899
+ {
900
+ name: 'config',
901
+ ok: true,
902
+ section: REPO_SECTION,
903
+ detail: scanned.length > 0
904
+ ? `no unfilled placeholders, unknown checkers or schema errors in ${scanned.join(', ')}`
905
+ : 'no constitution or config file to scan',
906
+ },
907
+ ];
908
+ }
472
909
  //# sourceMappingURL=doctor.js.map