run402 4.38.1 → 4.39.0

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.
@@ -68,12 +68,29 @@
68
68
  * no per-ref object selection, so one batch = one full restore. That is a
69
69
  * superset of what git asked for (git writes the refs itself from `list`),
70
70
  * never a subset — but it is not incremental.
71
- * - `push` never changes the vault's HEAD target; the SDK carries it forward.
72
- * A fresh vault defaults to `refs/heads/main`, so a first push of some
73
- * other branch leaves HEAD naming a ref that does not exist yet. Use
74
- * `run402 gitvault push`, which sets the HEAD target from the local HEAD.
75
- * - `option dry-run` is `unsupported`: this helper cannot rehearse a
76
- * publication, and reporting a fake success would be worse than refusing.
71
+ * - `push` REPAIRS a DANGLING vault HEAD and otherwise never moves it
72
+ * (kychee-com/run402#568). A fresh vault defaults its HEAD symref to
73
+ * `refs/heads/main`; before this fix, a first push of any OTHER branch
74
+ * left that symref naming a ref that would never exist, and the first
75
+ * `git clone` warned "remote HEAD refers to nonexistent ref" and checked
76
+ * out an EMPTY tree publishing landed, but nothing was reachable from
77
+ * it. Now: when the vault's current HEAD target is unset, or is a
78
+ * symref naming a ref this push's own batch does not leave present, the
79
+ * helper points it at one of the branches THIS push is publishing — the
80
+ * local repository's own HEAD branch when it is among them, else the
81
+ * first branch in the batch — and prints a one-line stderr note saying
82
+ * which and why (see `chooseGitvaultHeadTargetForPush`). A HEALTHY HEAD
83
+ * (one that already names a ref this push leaves present) is NEVER
84
+ * touched — push moving history never means push moving HEAD.
85
+ * - `option dry-run true` (kychee-com/run402#565) runs the REAL local
86
+ * pipeline — pack building, encryption sizing, via `vault.planPush` — and
87
+ * reports the per-ref `ok` lines a real push would, plus a stderr summary
88
+ * (objects, encrypted bytes, refs, the generation it would admit as,
89
+ * whether allocation would be needed). It never uploads or admits, and a
90
+ * push-to-create dry run never allocates. Still honestly refuses
91
+ * anything git's own dry-run negotiation would also refuse (e.g. a
92
+ * non-fast-forward update) — reporting a fake `ok` would be worse than
93
+ * refusing, which is why this was `unsupported` until it could be real.
77
94
  * - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
78
95
  * helper against a repository, so running this binary by hand from a shell
79
96
  * is refused rather than silently pointed at the current directory. Only
@@ -82,6 +99,7 @@
82
99
  */
83
100
 
84
101
  import { createInterface } from "node:readline";
102
+ import { pathToFileURL } from "node:url";
85
103
  import { getSdk } from "./lib/sdk.mjs";
86
104
  import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
87
105
  import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
@@ -214,6 +232,62 @@ function parsePushSpec(spec) {
214
232
  return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
215
233
  }
216
234
 
235
+ /**
236
+ * Decide whether THIS push must repair a DANGLING vault HEAD, and to what
237
+ * (kychee-com/run402#568 — the first-clone empty-tree hazard). The rule:
238
+ *
239
+ * WHEN the vault's current materialized HEAD target is absent, OR is a
240
+ * symref naming a ref this push's own batch does not leave present, set
241
+ * `head_target` to one of the branches THIS push is publishing — this
242
+ * repository's own HEAD branch when it is among them, else the first
243
+ * branch in the batch (git's own order) — and say which, and why, in a
244
+ * one-line note. No silent magic.
245
+ *
246
+ * WHEN HEAD is already set and healthy (a symref naming a ref this push
247
+ * leaves present, or a detached target), it is NEVER touched — push
248
+ * moving history never means push moving HEAD. That stays the documented
249
+ * rule (`vault.push`'s own `head_target ?? base.head_target` carry-forward
250
+ * already guarantees this at the SDK layer; this function just decides
251
+ * WHEN to override that default).
252
+ *
253
+ * Pure — no I/O, no git, no network — so it is unit-testable directly.
254
+ * `updates` is this push's own ref-transaction updates (`{ ref, new_oid }`,
255
+ * `new_oid: null` for a deletion); `baseRefs`/`baseHeadTarget` are what the
256
+ * vault materialized BEFORE this push; `localHeadRef` is this repository's
257
+ * own HEAD branch (`refs/heads/<name>`), or `null` when detached/unknown.
258
+ *
259
+ * Returns `{ head_target: undefined }` (never publish an override — the SDK
260
+ * carries the base forward) when HEAD needs no repair, or when this batch
261
+ * has no branch update to repair it WITH (a tags-only or deletion-only
262
+ * batch cannot fix a dangling HEAD by itself).
263
+ */
264
+ export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, updates, localHeadRef }) {
265
+ const postPushRefs = { ...(baseRefs ?? {}) };
266
+ for (const u of updates) {
267
+ if (u.new_oid === null) delete postPushRefs[u.ref];
268
+ else postPushRefs[u.ref] = u.new_oid;
269
+ }
270
+
271
+ const dangling =
272
+ !baseHeadTarget ||
273
+ (baseHeadTarget.kind === "symref" && !Object.prototype.hasOwnProperty.call(postPushRefs, baseHeadTarget.ref));
274
+ if (!dangling) return { head_target: undefined, note: null };
275
+
276
+ const pushedBranches = updates.filter((u) => u.new_oid !== null && u.ref.startsWith("refs/heads/")).map((u) => u.ref);
277
+ if (pushedBranches.length === 0) return { head_target: undefined, note: null };
278
+
279
+ const localIsPushed = Boolean(localHeadRef) && pushedBranches.includes(localHeadRef);
280
+ const chosen = localIsPushed ? localHeadRef : pushedBranches[0];
281
+ const why = localIsPushed
282
+ ? "this repository's own HEAD branch"
283
+ : pushedBranches.length > 1
284
+ ? `the first of ${pushedBranches.length} branches pushed in this batch`
285
+ : "the branch this push publishes";
286
+ const priorState = baseHeadTarget ? `dangling (named '${baseHeadTarget.ref}', which this push does not publish)` : "unset";
287
+ const note = `vault HEAD was ${priorState} — setting it to '${chosen}' (${why}). A healthy HEAD is never moved by push.`;
288
+ return { head_target: { kind: "symref", ref: chosen }, note };
289
+ }
290
+
217
291
  async function main(argv) {
218
292
  const address = resolveRemoteAddress(argv);
219
293
  if (!address) {
@@ -230,6 +304,11 @@ async function main(argv) {
230
304
  const addressForm = gitvaultRemoteAddressForm(address);
231
305
  const target = { project_id: address.project_id, org_id: address.org_id };
232
306
  let verbosity = 1;
307
+ // kychee-com/run402#565: `option dry-run true` used to be honestly
308
+ // `unsupported` (this helper could not rehearse a publication, and
309
+ // reporting a fake success would be worse than refusing). It now IS
310
+ // real — see `handleOption`'s `dry-run` case and `runPush` below.
311
+ let dryRun = false;
233
312
 
234
313
  /**
235
314
  * The repository git invoked us for, resolved once and PROVEN.
@@ -245,6 +324,16 @@ async function main(argv) {
245
324
  return resolvedRepo.repo_dir;
246
325
  }
247
326
 
327
+ /** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
328
+ async function localHeadBranchRef(repoDir) {
329
+ try {
330
+ const out = (await hardenedGit(repoDir, ["symbolic-ref", "--quiet", "HEAD"])).text().trim();
331
+ return out.length > 0 ? out : null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
248
337
  /**
249
338
  * What to tell a human when we refuse. `git clone` is the case that used to
250
339
  * fail; naming the working alternative beats a bare error.
@@ -380,6 +469,52 @@ async function main(argv) {
380
469
  ? null
381
470
  : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
382
471
  }
472
+
473
+ if (dryRun) {
474
+ // kychee-com/run402#565: READ-ONLY resolution — `openVault`, never
475
+ // `openOrCreateVault` — so a push-to-create dry run allocates
476
+ // NOTHING. An unresolved vault means there is nothing to preview a
477
+ // push against yet (no repo_id ⇒ no encryption key ⇒ sizing is
478
+ // genuinely unknowable, not merely unreported); still report success
479
+ // per-ref, since a real push here WOULD succeed (it would allocate
480
+ // first) — only the sizing is unavailable.
481
+ let vault;
482
+ try {
483
+ vault = await openVault(repoDir);
484
+ } catch (err) {
485
+ if (!isVaultNotFound(err)) throw err;
486
+ note("dry-run: no vault allocated for this project yet — a real push would allocate one (push-to-create) before publishing; object/byte sizing is not knowable until then");
487
+ for (const spec of specs) out(`ok ${spec.dst}`);
488
+ endBlock();
489
+ return 0;
490
+ }
491
+ const base = await vault.materialize();
492
+ const updates = [];
493
+ for (const spec of specs) {
494
+ const expectedOld = base.refs?.[spec.dst] ?? null;
495
+ updates.push({
496
+ ref: spec.dst,
497
+ expected_old_oid: expectedOld,
498
+ new_oid: newOids.get(spec),
499
+ force: spec.force && expectedOld !== null,
500
+ });
501
+ }
502
+ // Same evaluation, pack building, and sealing/encryption a real push
503
+ // runs — stops before the two network mutations (upload, admit). A
504
+ // refusal here (non-fast-forward, tag immutability, ...) throws the
505
+ // SAME way a real push's would, caught below and reported as
506
+ // `error`, never a fake `ok`.
507
+ const plan = await vault.planPush({ transaction: { updates } });
508
+ note(
509
+ `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
510
+ `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw), ` +
511
+ `${Object.keys(plan.refs).length} ref(s); no allocation needed`,
512
+ );
513
+ for (const spec of specs) out(`ok ${spec.dst}`);
514
+ endBlock();
515
+ return 0;
516
+ }
517
+
383
518
  const vault = await openOrCreateVault(repoDir);
384
519
  const base = await vault.materialize();
385
520
  const updates = [];
@@ -394,10 +529,26 @@ async function main(argv) {
394
529
  force: spec.force && expectedOld !== null,
395
530
  });
396
531
  }
532
+ // Repair a DANGLING HEAD from this batch's own branches (#568) — see
533
+ // `chooseGitvaultHeadTargetForPush`'s own doc comment for the exact
534
+ // rule. `localHeadRef` is read from THIS repository (never cwd, same
535
+ // fail-closed resolution as everything else in this function).
536
+ const headFix = chooseGitvaultHeadTargetForPush({
537
+ baseHeadTarget: base.head_target,
538
+ baseRefs: base.refs,
539
+ updates,
540
+ localHeadRef: await localHeadBranchRef(repoDir),
541
+ });
542
+ if (headFix.note) note(headFix.note);
397
543
  // ONE transaction for the whole batch: the SDK evaluates fast-forward,
398
544
  // tag immutability, protocol-ref refusal and retention roots, builds the
399
- // packs, and publishes — all or nothing.
400
- const published = await vault.push({ transaction: { updates } });
545
+ // packs, and publishes — all or nothing. `head_target` is included ONLY
546
+ // when a repair is called for; omitted, `vault.push` carries the base
547
+ // forward unchanged — a healthy HEAD is never moved.
548
+ const published = await vault.push({
549
+ transaction: { updates },
550
+ ...(headFix.head_target ? { head_target: headFix.head_target } : {}),
551
+ });
401
552
  if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
402
553
  for (const spec of specs) out(`ok ${spec.dst}`);
403
554
  } catch (err) {
@@ -429,8 +580,17 @@ async function main(argv) {
429
580
  // whichever way git asked for it.
430
581
  out("ok");
431
582
  return;
583
+ case "dry-run":
584
+ // kychee-com/run402#565: a REAL dry run — `runPush` runs the actual
585
+ // local pipeline (pack building, encryption sizing) and stops before
586
+ // the two network mutations. `value` is git's own boolean spelling
587
+ // ("true"/"false"); anything else is refused rather than guessed.
588
+ if (value === "true") { dryRun = true; out("ok"); return; }
589
+ if (value === "false") { dryRun = false; out("ok"); return; }
590
+ out("unsupported");
591
+ return;
432
592
  default:
433
- // Includes dry-run, object-format, depth, cloning, check-connectivity,
593
+ // Includes object-format, depth, cloning, check-connectivity,
434
594
  // followtags, pushcert: honestly unsupported rather than acknowledged.
435
595
  out("unsupported");
436
596
  }
@@ -501,10 +661,23 @@ async function main(argv) {
501
661
  }
502
662
  }
503
663
 
664
+ // Only run the protocol loop when git (or a human) actually invoked this file
665
+ // as a binary — never on `import` (tests import `chooseGitvaultHeadTargetForPush`
666
+ // directly; without this guard that import would block on stdin forever).
667
+ const invokedDirectly = (() => {
668
+ try {
669
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
670
+ } catch {
671
+ return false;
672
+ }
673
+ })();
674
+
504
675
  // Never `process.exit()` mid-stream: that can truncate a pending stdout write
505
676
  // on a pipe, which git reads as a protocol violation. Set the code and let Node
506
677
  // flush and exit on its own.
507
- main(process.argv.slice(2)).then(
508
- (code) => { process.exitCode = code; },
509
- (err) => { note(describeError(err)); process.exitCode = 1; },
510
- );
678
+ if (invokedDirectly) {
679
+ main(process.argv.slice(2)).then(
680
+ (code) => { process.exitCode = code; },
681
+ (err) => { note(describeError(err)); process.exitCode = 1; },
682
+ );
683
+ }
@@ -433,7 +433,11 @@ export const COMMAND_MANIFEST = [
433
433
  { path: ["service", "health"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
434
434
  { path: ["cache", "inspect"], positionals: [p("url")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["https://example.com/"] },
435
435
  { path: ["cache", "invalidate"], positionals: [p("url", { required: false })], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--all", "--host", "example.com"] },
436
- { path: ["doctor"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
436
+ // projectScoped (kychee-com/run402#566): --project targets the gitvault
437
+ // check only (see doctor.mjs's own HELP) — every other check stays
438
+ // wallet/machine-wide, but the gate's contract is "accepts --project
439
+ // without rejecting it," which this satisfies.
440
+ { path: ["doctor"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
437
441
  { path: ["webhook-secret", "rotate"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
438
442
  { path: ["logs"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--request-id", "req_gate123"], runStyle: "merged" },
439
443
  ];
package/lib/doctor.mjs CHANGED
@@ -24,11 +24,56 @@ import { doctorUpdateCheck } from "./update-check.mjs";
24
24
  import { buildBuzzDoctorReport, parseBuzzDoctorArgs } from "./buzz-doctor.mjs";
25
25
  import { queueBuzzDoctorTelemetry } from "./diagnostic-telemetry.mjs";
26
26
  import { fail } from "./sdk-errors.mjs";
27
+ import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
28
+
29
+ /** Value-taking flags (kychee-com/run402#566 — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored). */
30
+ const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--buzz-agent", "--project", "--only"];
31
+
32
+ /**
33
+ * The stable, complete registry of ordinary-mode check names (kychee-com/run402#566,
34
+ * the remaining half). One entry per `checks.push({ name: ... })` call below,
35
+ * in the order each check normally runs. This is the ONE place `--only`
36
+ * validates its argument against and the ONE place its help text is derived
37
+ * from, so a check can never be selectable-but-undocumented or
38
+ * documented-but-unselectable.
39
+ *
40
+ * Deliberately excludes buzz mode's own check names (`session_shell`,
41
+ * `node_runtime`, …) — buzz mode is a wholly separate report shape that
42
+ * returns before this array is ever consulted; see the `--only`/`--buzz`
43
+ * mutual-exclusion check in `run()`.
44
+ */
45
+ const DOCTOR_CHECK_NAMES = [
46
+ "config_dir",
47
+ "cli_update",
48
+ "allowance",
49
+ "projects",
50
+ "api_reachable",
51
+ "tier",
52
+ "operator_health",
53
+ "runtime_staleness",
54
+ "gitvault",
55
+ "source_scan",
56
+ ];
57
+
58
+ /** Every value passed to a repeatable flag, in argv order (mirrors the pattern in buzz-notifications.mjs). */
59
+ function collectRepeatableFlag(args, flag) {
60
+ const values = [];
61
+ for (let i = 0; i < args.length; i++) {
62
+ if (args[i] !== flag) continue;
63
+ if (i + 1 >= args.length || (typeof args[i + 1] === "string" && args[i + 1].startsWith("--"))) {
64
+ fail({ code: "BAD_FLAG", message: `${flag} requires a value`, details: { flag } });
65
+ }
66
+ values.push(args[i + 1]);
67
+ i += 1;
68
+ }
69
+ return values;
70
+ }
27
71
 
28
72
  const HELP = `run402 doctor — Health and config diagnostics
29
73
 
30
74
  Usage:
31
- run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>]
75
+ run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>] [--project <id>]
76
+ [--only <check> ...]
32
77
  run402 --wallet <profile> doctor --buzz --buzz-agent <npub-or-hex>
33
78
 
34
79
  Output:
@@ -38,12 +83,40 @@ Output:
38
83
 
39
84
  Options:
40
85
  --verbose Include extra detail (timing, error messages)
41
- --refresh Wait for a bounded live npm version check for the run402 CLI
42
- --no-scan Skip the source-tree scan (config / health checks only)
86
+ --refresh Force a bounded live npm version check for the run402 CLI, even
87
+ if the cache is still within its 24h TTL. The cache self-heals
88
+ without this flag too: a MISSING or EXPIRED cache gets exactly
89
+ one bounded live attempt automatically on a plain \`doctor\` call.
90
+ A failed live check (offline) falls back to the last known-good
91
+ value, clearly labeled with its age — never a silent weeks-old
92
+ "latest" (kychee-com/run402#561). cli_update.value.cache always
93
+ reports fresh/age_ms/refresh_attempted/refresh_failed.
94
+ --no-scan Skip the source-tree scan (config / health checks only). Implied
95
+ by any --only that omits source_scan.
43
96
  --scan-dir D Scan a custom directory instead of \`<cwd>/src\`
97
+ --project <id> Target THIS project's gitvault check instead of the repo-standing
98
+ default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
99
+ project, in that order — see \`gitvault-target.mjs\`). Scoped to the
100
+ gitvault check only; every other check is wallet/machine-wide, not
101
+ per-project, and is unaffected by this flag. Composes with --only.
102
+ --only <check> Run ONLY the named check (repeatable — pass it more than once
103
+ to run several). Every other check, INCLUDING the monorepo
104
+ source-tree scan, is suppressed rather than merely hidden: a
105
+ skipped check's network/filesystem work never runs at all, so
106
+ \`doctor --only gitvault\` costs one gitvault read, not a
107
+ config/tier/operator/scan sweep. An unknown check name is
108
+ BAD_USAGE listing the valid names below. Not used with --buzz
109
+ (buzz mode is its own separate, always-complete check set).
44
110
  --buzz Run only the zero-mutation Buzz setup preflight
45
111
  --buzz-agent P Bind Buzz mode to the intended public agent npub or hex key
46
112
 
113
+ --only check names (ordinary mode; see "Checks performed" below for what each
114
+ one reports):
115
+ ${DOCTOR_CHECK_NAMES.join(", ")}
116
+
117
+ Any flag not listed above is rejected (BAD_USAGE / UNKNOWN_FLAG), never
118
+ silently ignored.
119
+
47
120
  Telemetry:
48
121
  Buzz preflight sends only anonymous allowlisted start/pass/block counters.
49
122
  No identity, wallet, relay, domain, path, command output, or installation id
@@ -109,16 +182,57 @@ function describeCheckFailure(label, err) {
109
182
  }
110
183
 
111
184
  export async function run(sub, args = []) {
112
- const all = [sub, ...args].filter(Boolean);
185
+ const all = normalizeArgv([sub, ...args].filter(Boolean));
113
186
  if (all.includes("--help") || all.includes("-h")) {
114
187
  console.log(HELP);
115
188
  return;
116
189
  }
190
+ // kychee-com/run402#566 (--project half): doctor used to accept ANY flag
191
+ // silently — an unrecognized one (a typo, or --project before this fix)
192
+ // was simply never looked at. Any flag doctor actually parses is listed
193
+ // here; anything else is now a structured BAD_USAGE/UNKNOWN_FLAG rejection
194
+ // instead of quietly doing nothing.
195
+ assertKnownFlags(all, ["--verbose", "--refresh", "--no-scan", "--buzz", ...DOCTOR_VALUE_FLAGS], DOCTOR_VALUE_FLAGS);
117
196
  const verbose = all.includes("--verbose");
118
197
  const refresh = all.includes("--refresh");
119
198
  const skipScan = all.includes("--no-scan");
120
199
  const scanDirArgIdx = all.indexOf("--scan-dir");
121
200
  const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
201
+ // Scoped to the gitvault check (see HELP): every other check is
202
+ // wallet/machine-wide, not per-project.
203
+ const projectOverride = flagValue(all, "--project");
204
+
205
+ // kychee-com/run402#566 (the remaining half): --only <check>, repeatable.
206
+ // Validated against the stable registry ABOVE the buzz early-return, so an
207
+ // unknown name is BAD_USAGE regardless of which mode was also requested —
208
+ // the same "every accepted flag must work or BAD_USAGE" bar #569 named for
209
+ // doctor's own --human bug.
210
+ const onlyChecks = collectRepeatableFlag(all, "--only");
211
+ for (const name of onlyChecks) {
212
+ if (!DOCTOR_CHECK_NAMES.includes(name)) {
213
+ fail({
214
+ code: "BAD_USAGE",
215
+ message: `Unknown doctor check: '${name}'.`,
216
+ hint: `Valid check names: ${DOCTOR_CHECK_NAMES.join(", ")}.`,
217
+ details: { check: name, known_checks: DOCTOR_CHECK_NAMES },
218
+ });
219
+ }
220
+ }
221
+ // Buzz mode is a wholly separate, always-complete report shape — an --only
222
+ // that named ordinary-mode checks would be silently ignored under --buzz,
223
+ // exactly the class of bug #569 flagged for --human. Reject the
224
+ // combination instead.
225
+ if (onlyChecks.length > 0 && all.includes("--buzz")) {
226
+ fail({
227
+ code: "BAD_USAGE",
228
+ message: "--only is not used with --buzz — buzz mode runs its own fixed, always-complete check set.",
229
+ hint: "Drop --buzz to scope the ordinary check set with --only, or drop --only to run every buzz check.",
230
+ details: { only: onlyChecks },
231
+ });
232
+ }
233
+ const only = new Set(onlyChecks);
234
+ /** `true` when `name` should run — every check when --only was not passed, otherwise exactly the named ones. */
235
+ const wanted = (name) => only.size === 0 || only.has(name);
122
236
 
123
237
  const buzzArgs = parseBuzzDoctorArgs(all);
124
238
  if (buzzArgs.error) fail(buzzArgs.error);
@@ -135,7 +249,7 @@ export async function run(sub, args = []) {
135
249
  const CONFIG_DIR = configDir();
136
250
 
137
251
  // 1. Config directory.
138
- try {
252
+ if (wanted("config_dir")) try {
139
253
  if (existsSync(CONFIG_DIR) && statSync(CONFIG_DIR).isDirectory()) {
140
254
  checks.push({ name: "config_dir", status: "ok", value: CONFIG_DIR });
141
255
  } else {
@@ -156,7 +270,7 @@ export async function run(sub, args = []) {
156
270
 
157
271
  // 1b. CLI version/update state. This is advisory: stale or unknown version
158
272
  // state should help the user, not hide the rest of doctor.
159
- try {
273
+ if (wanted("cli_update")) try {
160
274
  checks.push(await doctorUpdateCheck({ refresh }));
161
275
  } catch (err) {
162
276
  checks.push({
@@ -168,7 +282,7 @@ export async function run(sub, args = []) {
168
282
 
169
283
  // 2. Allowance.
170
284
  let allowanceConfigured = false;
171
- try {
285
+ if (wanted("allowance")) try {
172
286
  const allowance = readAllowance();
173
287
  if (allowance) {
174
288
  allowanceConfigured = true;
@@ -203,7 +317,7 @@ export async function run(sub, args = []) {
203
317
  // service_key) that `run402 projects provision` writes. An empty store is
204
318
  // normal for fresh installs that haven't provisioned a project yet, so
205
319
  // report informationally as `ok` rather than warning.
206
- try {
320
+ if (wanted("projects")) try {
207
321
  const keystore = loadKeyStore();
208
322
  const projectCount = Object.keys(keystore?.projects ?? {}).length;
209
323
  checks.push({
@@ -228,7 +342,7 @@ export async function run(sub, args = []) {
228
342
  }
229
343
 
230
344
  // 4. API base reachability.
231
- try {
345
+ if (wanted("api_reachable")) try {
232
346
  const sdk = getSdk();
233
347
  // Use the service.status endpoint (read-only, unauthenticated).
234
348
  const t0 = Date.now();
@@ -249,7 +363,7 @@ export async function run(sub, args = []) {
249
363
  }
250
364
 
251
365
  // 5. Active tier.
252
- try {
366
+ if (wanted("tier")) try {
253
367
  const sdk = getSdk();
254
368
  const tier = await sdk.tier.status();
255
369
  const tierName = tier?.tier ?? null;
@@ -292,7 +406,12 @@ export async function run(sub, args = []) {
292
406
  }
293
407
 
294
408
  // 6. Operator health snapshot (v1.55 + v1.56 verification attempt detail).
295
- try {
409
+ // Both checks below ride the SAME operator-status read (runtime_staleness
410
+ // reuses the response operator_health already pulled), so the whole block
411
+ // is gated on wanting EITHER — --only runtime_staleness alone still needs
412
+ // this read, but --only-ing neither skips it entirely, same "don't do the work of a
413
+ // check nobody asked for" discipline the rest of --only follows.
414
+ if (wanted("operator_health") || wanted("runtime_staleness")) try {
296
415
  const sdk = getSdk();
297
416
  const status = await sdk.admin.getOperatorStatus();
298
417
  const gaps = [];
@@ -334,15 +453,17 @@ export async function run(sub, args = []) {
334
453
  gaps.push(`${item.kind}: ${item.detail}`);
335
454
  }
336
455
  }
337
- if (gaps.length > 0) {
338
- checks.push({
339
- name: "operator_health",
340
- status: "warning",
341
- value: { gaps },
342
- hint: "Address the above gaps; they're what 'run402 notifications' is designed to surface.",
343
- });
344
- } else {
345
- checks.push({ name: "operator_health", status: "ok" });
456
+ if (wanted("operator_health")) {
457
+ if (gaps.length > 0) {
458
+ checks.push({
459
+ name: "operator_health",
460
+ status: "warning",
461
+ value: { gaps },
462
+ hint: "Address the above gaps; they're what 'run402 notifications' is designed to surface.",
463
+ });
464
+ } else {
465
+ checks.push({ name: "operator_health", status: "ok" });
466
+ }
346
467
  }
347
468
 
348
469
  // 6b. Function runtime staleness (v1.69, capability
@@ -352,45 +473,47 @@ export async function run(sub, args = []) {
352
473
  // NOT refresh it (apply's release diff keys on the source code_hash, not
353
474
  // the wrapper). Read-only signal; refreshing is strictly opt-in. Reuses
354
475
  // the operator status fetched above to avoid a second round-trip.
355
- const runtime = status.runtime;
356
- if (runtime && typeof runtime.stale_function_count === "number") {
357
- if (runtime.stale_function_count > 0) {
358
- checks.push({
359
- name: "runtime_staleness",
360
- status: "warning",
361
- value: {
362
- stale_function_count: runtime.stale_function_count,
363
- stale_functions: runtime.stale_functions ?? [],
364
- },
365
- hint: `${runtime.stale_function_count} function(s) are running an older platform runtime. Run 'run402 functions rebuild --all' to refresh (re-bundles from your stored source; no source change).`,
366
- });
476
+ if (wanted("runtime_staleness")) {
477
+ const runtime = status.runtime;
478
+ if (runtime && typeof runtime.stale_function_count === "number") {
479
+ if (runtime.stale_function_count > 0) {
480
+ checks.push({
481
+ name: "runtime_staleness",
482
+ status: "warning",
483
+ value: {
484
+ stale_function_count: runtime.stale_function_count,
485
+ stale_functions: runtime.stale_functions ?? [],
486
+ },
487
+ hint: `${runtime.stale_function_count} function(s) are running an older platform runtime. Run 'run402 functions rebuild --all' to refresh (re-bundles from your stored source; no source change).`,
488
+ });
489
+ } else {
490
+ checks.push({
491
+ name: "runtime_staleness",
492
+ status: "ok",
493
+ value: { stale_function_count: 0 },
494
+ });
495
+ }
367
496
  } else {
497
+ // Gateway older than v1.69 doesn't surface the runtime block.
368
498
  checks.push({
369
499
  name: "runtime_staleness",
370
- status: "ok",
371
- value: { stale_function_count: 0 },
500
+ status: "skipped",
501
+ ...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
372
502
  });
373
503
  }
374
- } else {
375
- // Gateway older than v1.69 doesn't surface the runtime block.
376
- checks.push({
377
- name: "runtime_staleness",
378
- status: "skipped",
379
- ...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
380
- });
381
504
  }
382
505
  } catch (err) {
383
506
  // Operator status endpoint may not be reachable if the operator-binding
384
507
  // substrate isn't deployed yet on the target API. Don't fail the whole
385
508
  // doctor over it — emit as a soft warning. The runtime-staleness check
386
509
  // rides on the same fetch, so skip it for the same reason.
387
- checks.push({
510
+ if (wanted("operator_health")) checks.push({
388
511
  name: "operator_health",
389
512
  status: "skipped",
390
513
  message: describeCheckFailure("operator status check", err),
391
514
  ...(verbose && { hint: "GET /agent/v1/operator/status not reachable; requires v1.55+ gateway." }),
392
515
  });
393
- checks.push({
516
+ if (wanted("runtime_staleness")) checks.push({
394
517
  name: "runtime_staleness",
395
518
  status: "skipped",
396
519
  message: describeCheckFailure("operator status check", err),
@@ -410,14 +533,16 @@ export async function run(sub, args = []) {
410
533
  // deployed is a first-class shape (protocol D183), so its mere absence of a
411
534
  // deploy raises nothing.
412
535
  //
413
- // TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d): when
414
- // cwd is a repository with its own pinned repo id or run402/origin remote,
415
- // doctor now checks THAT vault, not the profile's active project the
416
- // same pin > remote > RUN402_PROJECT_ID env > active-project order every
417
- // other gitvault verb follows (`gitvault-target.mjs`). Doctor has no
418
- // `--project`/`--repo` flag of its own, so there is no explicit tier here.
419
- {
420
- const target = await resolveGitvaultTarget({ repoDir: process.cwd() });
536
+ // TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d, extended
537
+ // by kychee-com/run402#566's --project half): when cwd is a repository
538
+ // with its own pinned repo id or run402/origin remote, doctor checks THAT
539
+ // vault, not the profile's active project the same pin > remote >
540
+ // RUN402_PROJECT_ID env > active-project order every other gitvault verb
541
+ // follows (`gitvault-target.mjs`). An explicit `--project <id>` outranks
542
+ // all of that (the resolver's own top tier), same as every other gitvault
543
+ // verb's `--project`.
544
+ if (wanted("gitvault")) {
545
+ const target = await resolveGitvaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
421
546
  const projectId = target.project_id ?? null;
422
547
  const repoId = target.repo_id ?? null;
423
548
  if (!projectId && !repoId) {
@@ -458,7 +583,11 @@ export async function run(sub, args = []) {
458
583
  if (gv.pending_overrides > 0) {
459
584
  gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
460
585
  }
461
- if (gv.remote && !gv.remote.matches) {
586
+ // `matches` is a TRI-STATE (kychee-com/run402#562): `false` alone is
587
+ // a real mismatch. `null` (a slug-form remote not yet resolved on
588
+ // this machine) is not evidence of anything wrong — `!gv.remote.matches`
589
+ // used to treat null the same as false and would have warned here.
590
+ if (gv.remote && gv.remote.matches === false) {
462
591
  gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
463
592
  }
464
593
  // Echoed exactly as the SDK reported them — including the
@@ -488,8 +617,10 @@ export async function run(sub, args = []) {
488
617
  // SDK names, state-changing GETs, auth.* in prerendered pages, and
489
618
  // direct mutation of internal.sessions.authz_version. Hits with severity
490
619
  // `error` block deploy (`run402 deploy` wraps doctor and respects exit
491
- // code). Skipped via --no-scan when the user wants config-only checks.
492
- if (!skipScan) {
620
+ // code). Skipped via --no-scan when the user wants config-only checks, and
621
+ // by any --only that omits it (kychee-com/run402#566 — this is the check
622
+ // that used to bury the gitvault diagnosis under ~1,800 monorepo findings).
623
+ if (!skipScan && wanted("source_scan")) {
493
624
  try {
494
625
  const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
495
626
  const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });