@tiphys/kernel 0.1.0 → 0.2.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.
Files changed (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
@@ -1,11 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { EX_USAGE } from "../cli.js";
6
6
  import { BEACON_FILE, LOCK_FILE, loadFleet, missingLayoutEntries } from "../fleet.js";
7
7
  import { judgeBeacon, warnIfWatcherStale } from "../liveness.js";
8
- import { readRegularFileIfPresent } from "../task.js";
8
+ import { expiryHasPassed } from "../lock.js";
9
+ import { poolList, resolveNetworkTimeoutMs } from "../pool.js";
10
+ import { classifyEntry, readRegularFileIfPresent, singleLine } from "../task.js";
11
+ import { sharedLockStatus } from "../exclusion.js";
9
12
  import { decodeDocument } from "../validate.js";
10
13
  import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
11
14
  /**
@@ -15,6 +18,13 @@ import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
15
18
  * and grows at M2/M3 with the gate registry.
16
19
  */
17
20
  export const PROFILES = {
21
+ /* THESE THREE PROMOTE NOTHING ABOUT A CHARTER, and M4-P30 left that alone
22
+ while changing `full`. Nothing below `full` resolves roles, checklists or
23
+ retention duties out of a charter, so a fleet that has none is not broken
24
+ in these modes and must keep exiting 0 there; `tiphys init` writes no
25
+ charter, so the first thing a new user does would otherwise fail. A later
26
+ profile that adds a retention condition here reddens a test that walks all
27
+ three. */
18
28
  generic: [],
19
29
  "local-only": [],
20
30
  "direct-pr": ["gh-missing"],
@@ -22,18 +32,77 @@ export const PROFILES = {
22
32
  whose charter declares no retention paths is not ready for full mode. The
23
33
  generic profile leaves it a WARN, which is the state a fleet legitimately
24
34
  sits in before its charter is written.
25
- NOT promoted, and deliberately: `retention-not-applicable`, the state of a
26
- fleet that has no charter document at all. See checkRetention's header for
27
- why the two are separate conditions rather than one. */
28
- full: ["gh-missing", "remote-missing", "retention-undeclared"],
35
+ M4-P30 PROMOTES `retention-not-applicable` HERE TOO, REVERSING THE LINE
36
+ THAT STOOD ABOVE IT UNTIL THIS PHASE. What that line said was that a fleet
37
+ with NO charter document at all is left a WARN under `full` as well, and
38
+ the reason given was the sentence immediately above: the generic profile
39
+ leaves it a WARN because that is the state a fleet legitimately sits in
40
+ before its charter is written. THAT REASON IS ABOUT THE GENERIC PROFILE.
41
+ Carried into `full` it makes `tiphys doctor --for full` exit 0 on a fleet
42
+ that has no charter, which is a guard that cannot go red against the state
43
+ it exists to detect: the fleet bring-up measured it on the kernel's own
44
+ fleet home, fully configured, and got exit 0 with ZERO FAIL lines and one
45
+ WARN line about retention (delivery/evidence/m4-fleet-bringup/bringup.md:1,
46
+ record `C5.1-arm5-before-charter-emptied`). `full` is the mode whose
47
+ pipeline resolves roles, checklists and retention duties out of a charter,
48
+ so a fleet with no charter is not ready for it, and unlike the branch check
49
+ the remedy IS reachable by the operator: write the charter.
50
+ THE TWO CONDITIONS STAY SEPARATE, which is the half a one-line fix loses.
51
+ `retention-not-applicable` (no charter document) and `retention-undeclared`
52
+ (a charter, or YAML in charter/, that declares no retention paths) keep
53
+ their own ids AND their own detail strings, so promoting both does not make
54
+ "nobody has written a charter yet" and "somebody configured this wrongly"
55
+ print the same sentence. See checkRetention's header. */
56
+ /* M3-P13: `kernel-artifacts-incomplete` is promoted here, so a fleet whose
57
+ installed kernel has lost roles/, schemas/, checklists/ or AGENTS.md is not
58
+ ready for full mode. It is NOT promoted below full, deliberately: the
59
+ commands that resolve those artifacts are full mode's, and promoting
60
+ everywhere is how a check fails a fleet that never needed it. */
61
+ /* M4-P17: `branches-unmerged`, `tasks-open`, `tasks-not-established`,
62
+ `branches-not-established`, `remote-diverged`, `remote-untracked` and
63
+ `remote-not-a-fleet` are NOT promoted here, and the branch one is the
64
+ case the plan argues at length (criterion 5). Deleting a remote ref is
65
+ refused in the container this kernel is built in, and the delete dry run
66
+ exits 0 either way (CLAUDE.md standing warning 14), so a promoted branch
67
+ check would make this profile unpassable on the kernel's own fleet with
68
+ no remedy its operator could reach, and an unpassable check is one that
69
+ gets switched off. The others are states a fleet legitimately sits in
70
+ between a spawn and a teardown, or immediately after a reclaim. A test
71
+ walks every profile and asserts none of them promotes any of these. */
72
+ /* M4-P22: `shared-lock-unreachable` is promoted here and nowhere else. A
73
+ fleet that has DECLARED the shared exclusion register and cannot read it
74
+ refuses every lease operation, every spawn and every teardown, so it is
75
+ not ready for full mode by any reading; and unlike the branch check the
76
+ remedy is reachable by the operator, who either repairs the remote or
77
+ removes the declaration. It is not promoted below `full` because the
78
+ layer is opt-in per fleet home (M4-P21 criterion 1) and a fleet using
79
+ `local-only` has nothing to reach. The other three statuses cannot be
80
+ promoted at all: `not-declared`, `free` and `held` are PASS and carry no
81
+ condition, deliberately, because a register held by another environment
82
+ is the layer WORKING and not a fault of this fleet. */
83
+ full: [
84
+ "gh-missing",
85
+ "remote-missing",
86
+ "retention-undeclared",
87
+ "retention-not-applicable",
88
+ "kernel-artifacts-incomplete",
89
+ "shared-lock-unreachable",
90
+ ],
29
91
  watch: ["beacon-absent", "beacon-stale"],
30
92
  };
31
- /** Locate the kernel's own package.json (same walk as src/version.ts). */
93
+ /**
94
+ * Locate the kernel's own package.json (same walk as src/version.ts).
95
+ *
96
+ * `classifyEntry`, not `existsSync`: this is the second of the three call
97
+ * sites of the mechanism `reading-a-path-whose-type-is-not-established`, and
98
+ * it is the one inside this phase's files-to-touch. The derivation over all
99
+ * three, and the one left open, is published in this phase's work history.
100
+ */
32
101
  function readKernelEnginesNode() {
33
102
  let dir = dirname(fileURLToPath(import.meta.url));
34
103
  for (;;) {
35
104
  const candidate = join(dir, "package.json");
36
- if (existsSync(candidate)) {
105
+ if (classifyEntry(candidate).kind === "regular") {
37
106
  const parsed = JSON.parse(readFileSync(candidate, "utf8"));
38
107
  const engines = parsed.engines;
39
108
  if (engines === undefined || typeof engines.node !== "string") {
@@ -143,6 +212,106 @@ function checkLayout(root) {
143
212
  }
144
213
  return { name: "layout", status: "PASS", detail: "all layout entries present" };
145
214
  }
215
+ /**
216
+ * A git invocation for the reporting paths in this file, with the two
217
+ * properties a REPORT needs and a write does not.
218
+ *
219
+ * `LC_ALL`/`LANG` are pinned so a translated git cannot change what this
220
+ * module reads back, the same reason src/pool.ts:143 pins them.
221
+ * `GIT_TERMINAL_PROMPT=0` turns a credential prompt into an error: doctor is
222
+ * run non-interactively (by a watcher, by an exit test, by CI), and a git that
223
+ * stops to ask for a password is a command that never returns.
224
+ */
225
+ function runGitHere(root, args, timeoutMs) {
226
+ const result = spawnSync("git", ["-C", root, ...args], {
227
+ encoding: "utf8",
228
+ env: { ...process.env, LC_ALL: "C", LANG: "C", GIT_TERMINAL_PROMPT: "0" },
229
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
230
+ });
231
+ if (result.error !== undefined) {
232
+ const timedOut = timeoutMs !== undefined &&
233
+ result.error.code === "ETIMEDOUT";
234
+ return {
235
+ status: null,
236
+ stdout: "",
237
+ stderr: timedOut
238
+ ? `git ${args.join(" ")} did not answer within ${String(timeoutMs)}ms and was killed`
239
+ : String(result.error),
240
+ timedOut,
241
+ };
242
+ }
243
+ return {
244
+ status: result.status,
245
+ stdout: result.stdout ?? "",
246
+ stderr: result.stderr ?? "",
247
+ timedOut: false,
248
+ };
249
+ }
250
+ /** The first non-empty line of git's own stderr, for a one-line detail. */
251
+ function firstStderrLine(stderr) {
252
+ const line = stderr
253
+ .split("\n")
254
+ .map((entry) => entry.trim())
255
+ .find((entry) => entry !== "");
256
+ return line ?? "no stderr";
257
+ }
258
+ /** git's answer, or undefined when it did not answer. */
259
+ function gitValue(root, args) {
260
+ const run = runGitHere(root, args);
261
+ if (run.status !== 0) {
262
+ return undefined;
263
+ }
264
+ const value = run.stdout.trim();
265
+ return value === "" ? undefined : value;
266
+ }
267
+ /**
268
+ * CHECK remote (M4-P17 criterion 6): FETCH, then COMPARE.
269
+ *
270
+ * WHAT IT USED TO DO, and why that was the H-B shape. It listed the
271
+ * configured remotes and printed `PASS remote configured (origin)` whenever
272
+ * the list was non-empty. That condition is TRUE OF THE DANGEROUS STATE in
273
+ * two separate ways, and both are ordinary rather than exotic: a fleet whose
274
+ * whole history has never been pushed, and a fleet whose remote URL points at
275
+ * something that is not there. Both printed PASS. Nothing the check did could
276
+ * have told them from a healthy fleet, because `git remote` reads a config
277
+ * file and never touches the remote.
278
+ *
279
+ * SO THE FETCH IS THE CHECK. Without it, "is the fleet's work somewhere other
280
+ * than this disk" is answered by reading a string the operator typed. With it,
281
+ * the answer is ahead/behind counts against a ref the remote actually
282
+ * advertised.
283
+ *
284
+ * THE FETCH IS BOUNDED, AND THIS IS A DELIBERATE DEPARTURE FROM THE
285
+ * CLASSIFICATION M4-P19 WROTE (test/pool.test.ts's
286
+ * `pool-network-calls-are-classified`). That classification leaves object
287
+ * transfers (`clone`, `fetch`, `pull`, `push`) unbounded, on the reasoning
288
+ * that a legitimate transfer's duration is set by how much data there is, so a
289
+ * wall-clock bound would kill real work. That reasoning is right where it was
290
+ * written, and it does not reach here: this fetch moves no work an operator
291
+ * asked for, it is a PROBE inside a REPORT. Killing it costs one diagnostic
292
+ * line; not killing it costs the whole diagnosis, because doctor is the
293
+ * command someone runs when a fleet is already misbehaving and a remote that
294
+ * accepts and never answers would hang it with zero output. The bound reads
295
+ * the same `TIPHYS_GIT_NETWORK_TIMEOUT_MS` override as the pool's ref probe,
296
+ * so a witness can shorten it instead of waiting out the shipped twenty
297
+ * seconds; a bound no test can drive is a bound no test will guard.
298
+ *
299
+ * A FETCH THAT DID NOT RUN IS NOT A PASS AND IS NOT A WARN. It is FAIL,
300
+ * naming git's own first stderr line. The alternative a reviewer should look
301
+ * for, and which this code deliberately does not do, is to swallow the failure
302
+ * and fall back to the old non-empty-list test: that is green in exactly the
303
+ * state the check exists to catch, which is the guard-that-cannot-go-red shape.
304
+ * FAIL is also what this command already does everywhere else an input could
305
+ * not be established (an unreadable lease, an undecodable charter), so the
306
+ * three states a reader might confuse (in sync, diverged, could not ask) never
307
+ * print the same word.
308
+ *
309
+ * OUTSIDE A FLEET HOME THERE IS NOTHING TO FETCH FOR. The subject of this
310
+ * check is the FLEET's push target (SC-002), so in a git repository that is
311
+ * not a fleet home it reports that and stops, exactly as CHECK worktrees does.
312
+ * That is a verdict with its reason, not a silent pass, and it means doctor
313
+ * run inside some unrelated checkout never reaches for that checkout's remote.
314
+ */
146
315
  function checkRemote(root) {
147
316
  if (!existsSync(join(root, ".git"))) {
148
317
  return {
@@ -152,9 +321,9 @@ function checkRemote(root) {
152
321
  condition: "remote-missing",
153
322
  };
154
323
  }
155
- const result = spawnSync("git", ["-C", root, "remote"], { encoding: "utf8" });
156
- const remotes = result.status === 0
157
- ? (result.stdout ?? "").split("\n").filter((line) => line !== "")
324
+ const listed = runGitHere(root, ["remote"]);
325
+ const remotes = listed.status === 0
326
+ ? listed.stdout.split("\n").filter((line) => line !== "")
158
327
  : [];
159
328
  if (remotes.length === 0) {
160
329
  return {
@@ -164,10 +333,87 @@ function checkRemote(root) {
164
333
  condition: "remote-missing",
165
334
  };
166
335
  }
336
+ try {
337
+ loadFleet(root);
338
+ }
339
+ catch {
340
+ return {
341
+ name: "remote",
342
+ status: "WARN",
343
+ detail: `${root} is a git repository with a remote (${remotes.join(", ")}) but it is not a ` +
344
+ "fleet home, so there is no fleet state to compare against it",
345
+ condition: "remote-not-a-fleet",
346
+ };
347
+ }
348
+ const branch = gitValue(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
349
+ const remote = (branch === undefined || branch === "HEAD"
350
+ ? undefined
351
+ : gitValue(root, ["config", "--get", `branch.${branch}.remote`])) ??
352
+ (remotes.includes("origin") ? "origin" : remotes[0]);
353
+ const fetched = runGitHere(root, ["fetch", "--quiet", remote], resolveNetworkTimeoutMs(process.env["TIPHYS_GIT_NETWORK_TIMEOUT_MS"]));
354
+ if (fetched.status !== 0) {
355
+ return {
356
+ name: "remote",
357
+ status: "FAIL",
358
+ detail: `git fetch ${remote} did not succeed, so this fleet's push target could not be ` +
359
+ `reached and nothing about it is established: ${firstStderrLine(fetched.stderr)}`,
360
+ };
361
+ }
362
+ if (branch === undefined || branch === "HEAD") {
363
+ return {
364
+ name: "remote",
365
+ status: "WARN",
366
+ detail: `fetched ${remote}, but HEAD is detached, so there is no tracked remote ` +
367
+ "ref to compare it against",
368
+ condition: "remote-untracked",
369
+ };
370
+ }
371
+ const tracked = gitValue(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) ??
372
+ (gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}`]) ===
373
+ undefined
374
+ ? undefined
375
+ : `${remote}/${branch}`);
376
+ if (tracked === undefined) {
377
+ return {
378
+ name: "remote",
379
+ status: "WARN",
380
+ detail: `fetched ${remote}, which carries no ref for branch ${branch}, so the whole of ` +
381
+ "this branch is unpushed",
382
+ condition: "remote-untracked",
383
+ };
384
+ }
385
+ const counted = runGitHere(root, ["rev-list", "--left-right", "--count", `${tracked}...HEAD`]);
386
+ if (counted.status !== 0) {
387
+ return {
388
+ name: "remote",
389
+ status: "FAIL",
390
+ detail: `git rev-list could not count HEAD against ${tracked}, so the ahead and behind ` +
391
+ `counts are not established: ${firstStderrLine(counted.stderr)}`,
392
+ };
393
+ }
394
+ const fields = counted.stdout.trim().split(/\s+/);
395
+ const behind = Number(fields[0]);
396
+ const ahead = Number(fields[1]);
397
+ if (fields.length !== 2 || !Number.isInteger(behind) || !Number.isInteger(ahead)) {
398
+ return {
399
+ name: "remote",
400
+ status: "FAIL",
401
+ detail: `git rev-list answered ${JSON.stringify(counted.stdout.trim())} for ` +
402
+ `${tracked}...HEAD, which is not two counts`,
403
+ };
404
+ }
405
+ if (ahead === 0 && behind === 0) {
406
+ return {
407
+ name: "remote",
408
+ status: "PASS",
409
+ detail: `in sync with ${tracked} (0 ahead, 0 behind) after fetching ${remote}`,
410
+ };
411
+ }
167
412
  return {
168
413
  name: "remote",
169
- status: "PASS",
170
- detail: `remote configured (${remotes.join(", ")})`,
414
+ status: "WARN",
415
+ detail: `${String(ahead)} unpushed, ${String(behind)} behind ${tracked}`,
416
+ condition: "remote-diverged",
171
417
  };
172
418
  }
173
419
  /**
@@ -207,19 +453,57 @@ function checkLock(root) {
207
453
  detail: "lease file is missing holderId or expiresAt",
208
454
  };
209
455
  }
210
- const expiresMs = Date.parse(lease.expiresAt);
211
- if (Number.isNaN(expiresMs)) {
456
+ return lockCheckFor(lease.holderId, lease.expiresAt, Date.now());
457
+ }
458
+ /**
459
+ * THE VERDICT OVER A LEASE THAT HAS BEEN READ (M4-P17 criteria 1 and 2).
460
+ *
461
+ * AN EXPIRED LEASE IS A FAIL. Until this phase it was a PASS carrying the
462
+ * word `(expired)` inside the detail, and that is the H-B shape this
463
+ * repository keeps paying for: the check's condition was TRUE OF THE
464
+ * DANGEROUS STATE, so a fleet whose orchestrator died holding the lease
465
+ * reported `CHECK lock PASS` and doctor exited 0. AGENTS.md's resume clause
466
+ * says doctor reports "which leases are expired and who last held them"; a
467
+ * green line with a parenthesis in it is not that report, and an operator
468
+ * scanning for FAIL lines never saw it.
469
+ *
470
+ * The holder id and the expiry are both in the detail because the remedy
471
+ * needs both: WHO to ask before breaking the lease, and WHEN it lapsed.
472
+ *
473
+ * `nowMs` IS A PARAMETER, and that is what makes criterion 2's boundary
474
+ * member testable at all. `Date.now()` cannot be driven to a chosen
475
+ * millisecond from outside the process, so a lease whose expiry is exactly
476
+ * the current instant is unreachable through the CLI: by the time doctor
477
+ * runs, the instant has passed and the case under test is the interior one
478
+ * again. The caller above passes the clock; the witness passes an instant.
479
+ *
480
+ * THE COMPARISON IS NOT MADE HERE. `expiryHasPassed` (src/lock.ts) owns it,
481
+ * so doctor and the lock module cannot return two verdicts about one lease.
482
+ * That is the same rule checkBeacon follows for `judgeBeacon`, and it was
483
+ * written down there after a delta review found doctor carrying its own copy
484
+ * of the beacon comparison and missing a floor the module had.
485
+ */
486
+ export function lockCheckFor(holderId, expiresAt, nowMs) {
487
+ if (Number.isNaN(Date.parse(expiresAt))) {
212
488
  return {
213
489
  name: "lock",
214
490
  status: "FAIL",
215
- detail: `lease expiresAt "${lease.expiresAt}" is not a parseable timestamp`,
491
+ detail: `lease expiresAt "${expiresAt}" is not a parseable timestamp`,
492
+ };
493
+ }
494
+ if (expiryHasPassed(expiresAt, nowMs)) {
495
+ return {
496
+ name: "lock",
497
+ status: "FAIL",
498
+ detail: `lease held by ${holderId} EXPIRED at ${expiresAt}; a lease that has ` +
499
+ "lapsed is no longer holding anything, so whatever it was protecting " +
500
+ "is unprotected",
216
501
  };
217
502
  }
218
- const expired = expiresMs <= Date.now();
219
503
  return {
220
504
  name: "lock",
221
505
  status: "PASS",
222
- detail: `lease held by ${lease.holderId}, expires ${lease.expiresAt}${expired ? " (expired)" : ""}`,
506
+ detail: `lease held by ${holderId}, expires ${expiresAt}`,
223
507
  };
224
508
  }
225
509
  /**
@@ -357,13 +641,25 @@ function checkIdentity(root) {
357
641
  * fields are project facts init does not hold. Folding that state into
358
642
  * `retention-undeclared` made `tiphys doctor --for full` exit nonzero on every
359
643
  * freshly initialized fleet, which is the first thing a new user does. So it
360
- * gets its own condition, `retention-not-applicable`, which the `full` profile
361
- * does NOT promote. It is still a WARN and still names its reason, so it never
362
- * prints the same word as "declared, present and tracked": the plan's hazard
363
- * row for this check permits exactly "FAIL or not-applicable-with-a-reason,
364
- * never a silent pass". The SC-011 arm the row is aimed at, a charter that
365
- * EXISTS and declares no retention paths, keeps `retention-undeclared` and
366
- * keeps its promotion.
644
+ * gets its own condition, `retention-not-applicable`. It is still a WARN and
645
+ * still names its reason, so it never prints the same word as "declared,
646
+ * present and tracked": the plan's hazard row for this check permits exactly
647
+ * "FAIL or not-applicable-with-a-reason, never a silent pass". The SC-011 arm
648
+ * the row is aimed at, a charter that EXISTS and declares no retention paths,
649
+ * keeps `retention-undeclared` and keeps its promotion.
650
+ *
651
+ * WHAT M4-P30 CHANGED, AND WHAT IT DID NOT. Until this phase the paragraph
652
+ * above ended "which the `full` profile does NOT promote", and that is now
653
+ * false: `full` promotes BOTH conditions (see the PROFILES table's own note).
654
+ * What survives unchanged is the separation the paragraph was written to
655
+ * protect. The two states keep separate condition ids and separate detail
656
+ * strings, so a reader of a FAIL line can still tell "nobody has written a
657
+ * charter here yet" from "somebody put YAML in charter/ that is not a
658
+ * charter", and the promotion is what M3-P8 fix round 2 could not have: a
659
+ * state is only promoted where the operator's remedy exists. Below `full`
660
+ * nothing resolves anything out of a charter, so nothing below `full`
661
+ * promotes it and a fresh `tiphys init` fleet still exits 0 under the
662
+ * generic profile.
367
663
  */
368
664
  function checkRetention(root) {
369
665
  const charterDir = join(root, "charter");
@@ -456,9 +752,21 @@ function checkRetention(root) {
456
752
  /* NOT APPLICABLE versus UNDECLARED, and the difference is whether anyone
457
753
  has written a charter yet. An empty charter/ is a fleet before
458
754
  realization; YAML that is present but carries no `kind: charter` is a
459
- fleet someone has configured wrongly, which stays the promoted
460
- condition. */
755
+ fleet someone has configured wrongly.
756
+ SINCE M4-P30 THE `full` PROFILE PROMOTES BOTH, AND THE SPLIT IS WHAT
757
+ SURVIVES THAT. It would be one line shorter to return one condition with
758
+ one sentence now that both arms exit nonzero under the same profile, and
759
+ that is the edit this comment exists to refuse: the two states have
760
+ DIFFERENT REMEDIES. The first is an owner writing the charter the fleet
761
+ has been waiting for; the second is somebody removing or fixing a
762
+ document that is in charter/ and is not a charter. A FAIL line that
763
+ cannot tell a reader which of those to do is a diagnosis that has lost
764
+ the thing it was for. The condition ids and the detail strings are both
765
+ kept distinct, and a test asserts it on one fleet with one variable
766
+ changed. */
461
767
  if (candidates === 0) {
768
+ /* NO YAML AT ALL in charter/, which is what `tiphys init` leaves. WARN
769
+ below `full`, promoted there. */
462
770
  return {
463
771
  name: "retention",
464
772
  status: "WARN",
@@ -466,6 +774,9 @@ function checkRetention(root) {
466
774
  condition: "retention-not-applicable",
467
775
  };
468
776
  }
777
+ /* YAML IS PRESENT AND NONE OF IT IS A CHARTER. The count is in the detail
778
+ because it is the difference between one stray file and a directory
779
+ somebody filled with the wrong documents. */
469
780
  return {
470
781
  name: "retention",
471
782
  status: "WARN",
@@ -538,6 +849,631 @@ function isGitIgnored(repository, relative) {
538
849
  const result = spawnSync("git", ["-C", repository, "check-ignore", "-q", "--", relative], { encoding: "utf8" });
539
850
  return result.error === undefined && result.status === 0;
540
851
  }
852
+ /**
853
+ * THE KERNEL ARTIFACTS THIS CHECK REQUIRES, pinned HERE and never read out of
854
+ * the install being audited.
855
+ *
856
+ * The mechanism index's `checking-a-generated-artifact-against-its-own-generator`
857
+ * row is about a check whose SUBJECT is selected by a value read from the
858
+ * artifact it audits: that check can be silently narrowed by editing the
859
+ * artifact. Reading this list out of the install's own `package.json` files
860
+ * array would be exactly that shape, because an install that dropped `roles/`
861
+ * from both the tree and the files list would report itself complete. So the
862
+ * list is a constant in the source, and `package.json` is consulted only to
863
+ * locate the package root, never to decide what must be in it.
864
+ *
865
+ * A DIRECTORY MUST BE NON-EMPTY, which is decision D-1 of the phase plan. The
866
+ * check's subject is whether the install can resolve a role, a schema or a
867
+ * checklist, and an empty `roles/` resolves none. An `existsSync` on a
868
+ * directory the packer created empty is the vacuous pass hazard H1 names.
869
+ */
870
+ /**
871
+ * WHAT "RESOLVES" MEANS, PER ARTIFACT, TAKEN FROM THE CONSUMER (fix round 1,
872
+ * clean-room finding CR-001 of the hazard contract).
873
+ *
874
+ * Round 0 tested that a required path was PRESENT and reported success as
875
+ * `carries roles/, schemas/, checklists/ and AGENTS.md`, which is a claim
876
+ * about RESOLVABILITY. Presence is a PROXY for it, and the proxy was reachable
877
+ * in four measured shapes, every one of them PASS with FAIL count zero: a
878
+ * directory holding one unrelated file, a directory holding only a
879
+ * subdirectory, a directory whose members are all zero bytes, and a zero-byte
880
+ * `AGENTS.md`.
881
+ *
882
+ * The suffix below is not invented here. It is the filter the CONSUMING
883
+ * command already applies, so the check cannot claim more than the consumer
884
+ * will deliver:
885
+ *
886
+ * roles/ src/roles.ts:335 `.md`
887
+ * schemas/ src/commands/validate.ts:156 `.schema.json`
888
+ * checklists/ src/checklists.ts:91 `.yaml`
889
+ *
890
+ * WHAT THE PREDICATE DOES NOT COVER, stated rather than left to be found. It
891
+ * asks whether at least ONE member would be selected and carries bytes. It
892
+ * does not PARSE a member, so a `.yaml` that does not decode, a `.schema.json`
893
+ * that is not a schema and a `.md` with no frontmatter all resolve. It does
894
+ * not ask WHICH members are present, so an install carrying one role resolves
895
+ * `roles/` even if the role a brief names is the missing one. Both are
896
+ * deliberate: doctor answers "is this install fit to run", and a per-document
897
+ * decode is the consuming command's own failure, reported by it, with the path
898
+ * it could not use.
899
+ */
900
+ const REQUIRED_KERNEL_DIRECTORIES = [
901
+ { name: "roles", suffix: ".md" },
902
+ { name: "schemas", suffix: ".schema.json" },
903
+ { name: "checklists", suffix: ".yaml" },
904
+ ];
905
+ const REQUIRED_KERNEL_FILES = ["AGENTS.md"];
906
+ /**
907
+ * A path that is a regular file AND carries bytes.
908
+ *
909
+ * `classifyEntry` first, `statSync` second: the type is established before the
910
+ * size is asked for, so a FIFO here is `false` in bounded time rather than a
911
+ * blocked open (mechanism index,
912
+ * `reading-a-path-whose-type-is-not-established`). The `statSync` cannot be
913
+ * folded into `classifyEntry`, which returns a kind and no size; it is a
914
+ * second stat of a path already established as a regular file, never an open.
915
+ */
916
+ function carriesContent(path) {
917
+ if (classifyEntry(path).kind !== "regular") {
918
+ return false;
919
+ }
920
+ try {
921
+ return statSync(path).size > 0;
922
+ }
923
+ catch {
924
+ return false;
925
+ }
926
+ }
927
+ /**
928
+ * The installed kernel's own package root: the first ancestor of THIS MODULE
929
+ * carrying a `package.json`.
930
+ *
931
+ * **This is deliberately NOT `kernelRoot()` from src/roles.ts, and the reason
932
+ * is the whole point of the check.** That function walks upward looking for a
933
+ * `roles/` directory containing a `.md` file, which is the very artifact this
934
+ * check exists to find missing: against an install with `roles/` removed it
935
+ * walks PAST the install and answers about an ancestor, and where no ancestor
936
+ * carries one it throws. A check built on it reports on the wrong tree or
937
+ * crashes on precisely the state its own criteria describe.
938
+ *
939
+ * Walking for `package.json` does not have that property. `package.json` is
940
+ * the package BOUNDARY rather than a member of the set under test, and it is
941
+ * present in both shipped layouts. Measured on this head: the published
942
+ * package puts this module at `dist/src/commands/doctor.js` with the artifacts
943
+ * three levels up at the package root, and the development checkout puts it at
944
+ * `src/commands/doctor.ts` with the artifacts two levels up, while
945
+ * `dist/package.json` does not exist in the pack listing. So a FIXED DEPTH
946
+ * from `import.meta.url` is wrong in one of the two layouts and the first
947
+ * `package.json` above the module is right in both.
948
+ *
949
+ * Returns the reason rather than throwing, because a guard whose correctness
950
+ * depends on a crash is not a guard (mechanism index,
951
+ * `a-guard-s-own-failure-path`).
952
+ */
953
+ export function resolveInstalledKernelRoot(from = dirname(fileURLToPath(import.meta.url))) {
954
+ let dir = from;
955
+ for (;;) {
956
+ const candidate = join(dir, "package.json");
957
+ if (classifyEntry(candidate).kind === "regular") {
958
+ return { ok: true, root: dir };
959
+ }
960
+ const parent = dirname(dir);
961
+ if (parent === dir) {
962
+ return {
963
+ ok: false,
964
+ reason: `no package.json above ${from}, so the installed kernel root cannot be resolved`,
965
+ };
966
+ }
967
+ dir = parent;
968
+ }
969
+ }
970
+ /**
971
+ * `kernel-artifacts`: the resolved kernel install carries every artifact M3
972
+ * made load-bearing (kernel plan M3 section 4, stage E0.4's designated subject;
973
+ * phase M3-P13).
974
+ *
975
+ * WHAT THIS CHECK IS FOR. The brief composer resolves `roles/`, the validator
976
+ * loads `schemas/`, the checklist command resolves `checklists/`, and
977
+ * `AGENTS.md` is the policy document every role brief points at. Until now an
978
+ * install that lost one of them reported nothing wrong: the loss surfaced later
979
+ * as one command's resolution failure, whose message names the path it could
980
+ * not open rather than the state of the install. doctor is the command whose
981
+ * whole job is answering "is this environment fit to run", and the kernel's own
982
+ * artifacts were the one input none of its checks looked at.
983
+ *
984
+ * EVERY missing artifact is named, not the first (decision D-2): a check that
985
+ * names one sends its reader round the loop once per missing item, and the loop
986
+ * here is a reinstall.
987
+ *
988
+ * The condition is `kernel-artifacts-incomplete`, promoted to FAIL under the
989
+ * `full` profile and left a WARN below it. Below `full` no command that needs
990
+ * these artifacts is necessarily in the pipeline, and promoting everywhere is
991
+ * how a check like this ends up failing a fleet that never needed it.
992
+ */
993
+ export function checkKernelArtifacts(resolution = resolveInstalledKernelRoot()) {
994
+ if (!resolution.ok) {
995
+ return {
996
+ name: "kernel-artifacts",
997
+ status: "FAIL",
998
+ detail: resolution.reason,
999
+ };
1000
+ }
1001
+ const root = resolution.root;
1002
+ const missing = [];
1003
+ for (const { name, suffix } of REQUIRED_KERNEL_DIRECTORIES) {
1004
+ const path = join(root, name);
1005
+ let entries;
1006
+ try {
1007
+ entries = readdirSync(path);
1008
+ }
1009
+ catch (error) {
1010
+ const code = error.code;
1011
+ missing.push(code === "ENOENT"
1012
+ ? `${name}/ (absent)`
1013
+ : `${name}/ (${code ?? "unreadable"})`);
1014
+ continue;
1015
+ }
1016
+ if (entries.length === 0) {
1017
+ missing.push(`${name}/ (present but empty, so it resolves nothing)`);
1018
+ continue;
1019
+ }
1020
+ if (!entries.some((entry) => entry.endsWith(suffix) && carriesContent(join(path, entry)))) {
1021
+ missing.push(`${name}/ (present, but no ${suffix} member resolves)`);
1022
+ }
1023
+ }
1024
+ for (const name of REQUIRED_KERNEL_FILES) {
1025
+ /* classifyEntry, not existsSync: it lstats the link, stats what it
1026
+ resolves to, and opens only a regular file, so a FIFO at this path is a
1027
+ reported refusal in bounded time rather than a doctor that hangs
1028
+ (mechanism index, `reading-a-path-whose-type-is-not-established`). */
1029
+ const path = join(root, name);
1030
+ const entry = classifyEntry(path);
1031
+ if (entry.kind !== "regular") {
1032
+ missing.push(entry.kind === "absent"
1033
+ ? `${name} (absent)`
1034
+ : `${name} (${entry.kind}${entry.kind === "dangling" ? "" : `: ${entry.reason}`})`);
1035
+ continue;
1036
+ }
1037
+ /* The FILE member of the class the emptiness reasoning was written for.
1038
+ The plan's words for the directory member, "an install that carries an
1039
+ empty roles/ resolves no role", are true word for word of a zero-byte
1040
+ AGENTS.md: it is the policy document every role brief points at, and an
1041
+ empty one states no policy. */
1042
+ if (!carriesContent(path)) {
1043
+ missing.push(`${name} (present but empty, so it states nothing)`);
1044
+ }
1045
+ }
1046
+ if (missing.length > 0) {
1047
+ return {
1048
+ name: "kernel-artifacts",
1049
+ status: "WARN",
1050
+ detail: `the kernel install at ${root} is missing ${missing.join(", ")}`,
1051
+ condition: "kernel-artifacts-incomplete",
1052
+ };
1053
+ }
1054
+ return {
1055
+ name: "kernel-artifacts",
1056
+ status: "PASS",
1057
+ detail: `the kernel install at ${root} carries roles/, schemas/, checklists/ and AGENTS.md`,
1058
+ };
1059
+ }
1060
+ /**
1061
+ * CHECK tasks (M4-P17 criterion 3): how many tasks are OPEN, and which.
1062
+ *
1063
+ * THE DEFINITION IS THE PLAN'S AND IT IS DELIBERATELY NARROW. A task is open
1064
+ * when `tasks/<id>/meta.json` is there and `tasks/<id>/turn-end` is not. Two
1065
+ * file existences, both under `tasks/`, and nothing else. No log is read
1066
+ * (constraint C-1: currency never comes off the tail of an append-only
1067
+ * stream), and nothing is probed for being alive (constraint C-2: liveness is
1068
+ * lease freshness, never a process). A test in test/doctor.test.ts greps this
1069
+ * function's own source for the four tokens that would mean either constraint
1070
+ * had been broken, because a violation of either is invisible in the output.
1071
+ *
1072
+ * ESTABLISHED, ABSENT, UNUSABLE, AND THEY NEVER PRINT THE SAME WORD. This is
1073
+ * the one thing this check must not get wrong. The kernel already carries a
1074
+ * live instance of the opposite, tracked at
1075
+ * delivery/verification/tracked-doctor-charter-selection.md:1: the retention
1076
+ * check selects charter documents by a raw `kind` read, so a document whose
1077
+ * `kind` cannot be read is SKIPPED, and skipping is indistinguishable from
1078
+ * absence, so the command reports PASS over a fleet it could not examine. A
1079
+ * fourth check with that shape would be worse than no check.
1080
+ *
1081
+ * So every candidate under `tasks/` lands in exactly one of three buckets and
1082
+ * the third is reported by name:
1083
+ *
1084
+ * - open meta.json is a regular file, turn-end is absent
1085
+ * - closed meta.json is a regular file, turn-end is a regular file
1086
+ * - UNUSABLE anything else: no meta.json at all, a meta.json or a turn-end
1087
+ * that is a directory, a named pipe, a dangling link, or a path
1088
+ * `lstat` itself could not answer about
1089
+ *
1090
+ * An UNUSABLE candidate is a WARN carrying its own condition, never folded
1091
+ * into "closed" and never dropped from the total. A directory under `tasks/`
1092
+ * with no `meta.json` is the common real shape of it, a task half created or
1093
+ * half removed, and the honest report is that the check could not establish
1094
+ * what it is.
1095
+ *
1096
+ * NO PATH HERE IS OPENED. `classifyEntry` (src/task.ts) lstats, stats and
1097
+ * answers a kind; a named pipe at `tasks/<id>/meta.json` is therefore a named
1098
+ * WARN in bounded time rather than a doctor that hangs with no output, which
1099
+ * is the defect CR-520 recorded at the lease path and which this check would
1100
+ * otherwise reintroduce one directory along.
1101
+ */
1102
+ export function checkTasks(root) {
1103
+ const tasksDir = join(root, "tasks");
1104
+ let entries;
1105
+ try {
1106
+ entries = readdirSync(tasksDir, { withFileTypes: true });
1107
+ }
1108
+ catch (error) {
1109
+ /* CHECK layout owns a missing tasks/ and FAILs on it (FLEET_DIRS in
1110
+ src/fleet.ts), so this arm names the condition and leaves the verdict
1111
+ to the check that owns it. */
1112
+ return {
1113
+ name: "tasks",
1114
+ status: "WARN",
1115
+ detail: `tasks/ under ${root} could not be listed (${String(error)}), so no task is established`,
1116
+ condition: "tasks-not-established",
1117
+ };
1118
+ }
1119
+ const open = [];
1120
+ const unusable = [];
1121
+ let total = 0;
1122
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : 1))) {
1123
+ if (entry.isFile()) {
1124
+ /* `tasks/.gitkeep` and anything else a plain file: a task is a
1125
+ DIRECTORY, so this is not a candidate rather than a broken one. */
1126
+ continue;
1127
+ }
1128
+ total += 1;
1129
+ if (!entry.isDirectory()) {
1130
+ unusable.push(`${entry.name} (not a directory)`);
1131
+ continue;
1132
+ }
1133
+ const metaFile = join(tasksDir, entry.name, "meta.json");
1134
+ const meta = classifyEntry(metaFile);
1135
+ if (meta.kind !== "regular") {
1136
+ unusable.push(`${entry.name} (meta.json ${unestablishedBecause(metaFile, meta)})`);
1137
+ continue;
1138
+ }
1139
+ const turnEndFile = join(tasksDir, entry.name, "turn-end");
1140
+ const turnEnd = classifyEntry(turnEndFile);
1141
+ if (turnEnd.kind === "absent") {
1142
+ open.push(entry.name);
1143
+ continue;
1144
+ }
1145
+ if (turnEnd.kind !== "regular") {
1146
+ unusable.push(`${entry.name} (turn-end ${unestablishedBecause(turnEndFile, turnEnd)})`);
1147
+ }
1148
+ }
1149
+ const counted = `${String(open.length)} open of ${String(total)}` +
1150
+ (open.length === 0 ? "" : ` (${open.join(", ")})`);
1151
+ if (unusable.length > 0) {
1152
+ return {
1153
+ name: "tasks",
1154
+ status: "WARN",
1155
+ detail: `${counted}; ${String(unusable.length)} not established: ${unusable.join(", ")}`,
1156
+ condition: "tasks-not-established",
1157
+ };
1158
+ }
1159
+ if (open.length > 0) {
1160
+ return { name: "tasks", status: "WARN", detail: counted, condition: "tasks-open" };
1161
+ }
1162
+ return { name: "tasks", status: "PASS", detail: counted };
1163
+ }
1164
+ /**
1165
+ * Why a path under `tasks/` could not be established, WITHOUT the absolute
1166
+ * path in it.
1167
+ *
1168
+ * `classifyEntry` prefixes its reason with the path it examined, which is
1169
+ * right for a diagnostic naming one file and wrong inside a line that has
1170
+ * already named the task. Stripping it also makes the line reproducible: a
1171
+ * detail carrying a temporary directory differs on every run, so no capture
1172
+ * could record it and no test could compare against one.
1173
+ */
1174
+ function unestablishedBecause(path, entry) {
1175
+ if (entry.kind === "absent") {
1176
+ return "is absent";
1177
+ }
1178
+ if (entry.kind === "dangling" || entry.kind === "regular") {
1179
+ return entry.kind === "dangling"
1180
+ ? "is a link that resolves to nothing"
1181
+ : "is a regular file";
1182
+ }
1183
+ const reason = entry.reason;
1184
+ return reason.startsWith(`${path} `) ? reason.slice(path.length + 1) : reason;
1185
+ }
1186
+ /**
1187
+ * CHECK branches (M4-P17 criterion 5): which branches are PUSHED and NOT YET
1188
+ * MERGED. AGENTS.md's resume clause names this as one of the three things
1189
+ * doctor reports after a reclaim, and it was the one with no implementation.
1190
+ *
1191
+ * PUSHED means a remote-tracking ref exists for it, which is the only
1192
+ * evidence available locally that the branch is somewhere other than this
1193
+ * disk. UNMERGED means the trunk does not already contain it.
1194
+ *
1195
+ * IT REPORTS EVERY PUSHED REF RATHER THAN FILTERING TO A NAMING PATTERN, and
1196
+ * that is a decision rather than an omission. At least two branch spellings
1197
+ * are in use across the repositories this kernel runs over: the pool names
1198
+ * task branches `task/<id>` (src/pool.ts:54), and a delivery process running
1199
+ * on this kernel names phase branches with its own harness prefix followed by
1200
+ * a milestone and phase segment. A filter written for either is blind to the
1201
+ * other, and a check that is blind to a branch is worse than one that names a
1202
+ * branch the reader already knew about. A superset prints rows a reader can
1203
+ * skip; a filter that misses a branch prints nothing at all, and nothing is
1204
+ * what a healthy fleet prints too.
1205
+ *
1206
+ * NO BRANCH PREFIX IS SPELLED OUT HERE, and that is not a style choice.
1207
+ * test/schemas.test.ts:800 asserts by name which shipped files carry the
1208
+ * harness-derived branch prefix and exists to stop that set GROWING; writing
1209
+ * the literal prefix into this comment added src/commands/doctor.ts to it and
1210
+ * reddened that test. The spelling belongs in the delivery process that uses
1211
+ * it, not in the kernel that reports over any of them.
1212
+ *
1213
+ * WARN, AND NO PROFILE PROMOTES IT TO FAIL. The reason is measured and is not
1214
+ * a preference. Deleting a remote ref is REFUSED in the container this kernel
1215
+ * is built in, and `git push --dry-run --delete` exits 0 whether deletion is
1216
+ * allowed or not, so the dry run cannot tell an operator whether the remedy is
1217
+ * even available (CLAUDE.md standing warning 14). A promotable branch check
1218
+ * would therefore make `tiphys doctor --for full` unpassable on the kernel's
1219
+ * own fleet, with no action its operator could take, and an unpassable check
1220
+ * is a check that gets switched off. The count is printed; the exit code does
1221
+ * not move. A test walks every profile in PROFILES and asserts that, so a
1222
+ * later profile cannot promote it by accident.
1223
+ *
1224
+ * AND IT NEVER PRINTS PASS FOR A QUESTION IT COULD NOT ASK. If git refuses to
1225
+ * list the refs, or the trunk cannot be resolved, the check says so under its
1226
+ * own condition instead of reporting an empty list as a clean bill of health.
1227
+ * An empty result from a query that failed is indistinguishable from an empty
1228
+ * result from a query that succeeded, which is the third way this repository
1229
+ * has shipped a guard that could not go red.
1230
+ */
1231
+ export function checkBranches(root) {
1232
+ if (!existsSync(join(root, ".git"))) {
1233
+ return {
1234
+ name: "branches",
1235
+ status: "WARN",
1236
+ detail: "fleet home is not a git repository, so no branch can be reported",
1237
+ condition: "branches-not-established",
1238
+ };
1239
+ }
1240
+ /* THE FORMAT ASKS FOR THE FULL REFNAME, AND THAT IS THE WHOLE POINT OF THIS
1241
+ LINE. `%(refname:short)` renders refs/remotes/origin/HEAD as `origin`, not
1242
+ as `origin/HEAD`, because git shortens a remote's HEAD to the remote's own
1243
+ name. A filter written as `endsWith("/HEAD")` over the SHORT name is
1244
+ therefore dead on exactly the ref it exists to drop, which is the shape
1245
+ this repository keeps paying for: a guard whose condition does not test
1246
+ the property that matters. The short name is recovered below by stripping
1247
+ the prefix, which is what `:short` does for every ref that is not a HEAD.
1248
+
1249
+ `%(symref)` is the second half and is not redundant. Since git 2.48.0,
1250
+ `git fetch` creates refs/remotes/<name>/HEAD when the remote advertises one
1251
+ and the local side has none: `remote.<name>.followRemoteHEAD` documents
1252
+ `create` as its default. It creates it as a SYMBOLIC ref, so dropping
1253
+ symbolic refs is the direct statement of "an alias is not a branch". A HEAD
1254
+ written as an ordinary ref carries no symref target and is caught by the
1255
+ name test instead; both members occur and each half catches one of them. */
1256
+ const listed = runGitHere(root, [
1257
+ "for-each-ref",
1258
+ "--format=%(refname)%09%(symref)",
1259
+ "refs/remotes",
1260
+ ]);
1261
+ if (listed.status !== 0) {
1262
+ return {
1263
+ name: "branches",
1264
+ status: "WARN",
1265
+ detail: "git could not list the remote-tracking refs, so no branch is established: " +
1266
+ firstStderrLine(listed.stderr),
1267
+ condition: "branches-not-established",
1268
+ };
1269
+ }
1270
+ const REMOTES_PREFIX = "refs/remotes/";
1271
+ const refs = [];
1272
+ for (const row of listed.stdout.split("\n")) {
1273
+ const [refname = "", symref = ""] = row.split("\t");
1274
+ if (!refname.startsWith(REMOTES_PREFIX)) {
1275
+ continue;
1276
+ }
1277
+ /* TWO TESTS, TWO STATEMENTS. They are not one condition with an `||`
1278
+ because they are two different properties with two different witnesses,
1279
+ and a witness member that defangs one of them must be distinguishable
1280
+ from one that defangs the other. */
1281
+ if (symref !== "") {
1282
+ continue;
1283
+ }
1284
+ if (refname.endsWith("/HEAD")) {
1285
+ continue;
1286
+ }
1287
+ refs.push(refname.slice(REMOTES_PREFIX.length));
1288
+ }
1289
+ if (refs.length === 0) {
1290
+ return { name: "branches", status: "PASS", detail: "no pushed branches" };
1291
+ }
1292
+ /* The trunk, in the order the evidence is strongest: what the remote
1293
+ itself advertises as its default, then what this branch tracks, and
1294
+ only then this checkout's own HEAD. */
1295
+ const trunk = gitValue(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]) ??
1296
+ gitValue(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) ??
1297
+ "HEAD";
1298
+ if (gitValue(root, ["rev-parse", "--verify", "--quiet", `${trunk}^{commit}`]) === undefined) {
1299
+ return {
1300
+ name: "branches",
1301
+ status: "WARN",
1302
+ detail: `${trunk} does not resolve to a commit, so no branch can be compared against it`,
1303
+ condition: "branches-not-established",
1304
+ };
1305
+ }
1306
+ const unmerged = [];
1307
+ for (const ref of refs) {
1308
+ if (ref === trunk) {
1309
+ continue;
1310
+ }
1311
+ const ancestor = runGitHere(root, ["merge-base", "--is-ancestor", ref, trunk]);
1312
+ if (ancestor.status === 0) {
1313
+ continue;
1314
+ }
1315
+ if (ancestor.status !== 1) {
1316
+ return {
1317
+ name: "branches",
1318
+ status: "WARN",
1319
+ detail: `git merge-base --is-ancestor ${ref} ${trunk} exited ` +
1320
+ `${String(ancestor.status)}, so whether ${ref} is merged is not established: ` +
1321
+ firstStderrLine(ancestor.stderr),
1322
+ condition: "branches-not-established",
1323
+ };
1324
+ }
1325
+ unmerged.push(ref);
1326
+ }
1327
+ if (unmerged.length === 0) {
1328
+ return {
1329
+ name: "branches",
1330
+ status: "PASS",
1331
+ detail: `${String(refs.length)} pushed branch(es), none unmerged into ${trunk}`,
1332
+ };
1333
+ }
1334
+ return {
1335
+ name: "branches",
1336
+ status: "WARN",
1337
+ detail: `${String(unmerged.length)} of ${String(refs.length)} pushed branch(es) unmerged ` +
1338
+ `into ${trunk}: ${unmerged.join(", ")}`,
1339
+ condition: "branches-unmerged",
1340
+ };
1341
+ }
1342
+ /**
1343
+ * CHECK worktrees (M4-P19): every entry in the worktree pool, and whether a
1344
+ * pool record still exists beside it.
1345
+ *
1346
+ * THE STATE THIS REPORTS IS THE POST-RECLAIM ONE. `worktrees/` is
1347
+ * gitignored (src/fleet.ts:28) and `tasks/` is tracked, so a reclaim takes
1348
+ * every worktrees/<id>.pool.json with it and leaves the task records
1349
+ * standing. Before this check, nothing in the kernel said so: `pool list`
1350
+ * enumerated records, and a task whose record was gone was invisible to
1351
+ * every reporting path.
1352
+ *
1353
+ * WARN AND NEVER FAIL, AND NOT PROMOTED BY ANY PROFILE. The condition is
1354
+ * named (`worktree-record-missing`) so a later profile CAN promote it, and
1355
+ * none does, deliberately: a fleet that has just been rehydrated from its
1356
+ * remote is EXPECTED to be in exactly this state, so promoting it would
1357
+ * make `doctor --for full` unpassable on the one fleet the remedy exists
1358
+ * for, and an unpassable check is a check that gets switched off (hazard
1359
+ * H-D). The number is printed; the exit code does not move.
1360
+ *
1361
+ * It reads tasks/<id>/meta.json and git, never a log tail (C-1), and
1362
+ * probes no process (C-2).
1363
+ */
1364
+ export function checkWorktrees(root) {
1365
+ let fleet;
1366
+ try {
1367
+ fleet = loadFleet(root);
1368
+ }
1369
+ catch {
1370
+ // Not a fleet home. CHECK layout is what reports that, and an
1371
+ // advisory must not be the thing that says so.
1372
+ return {
1373
+ name: "worktrees",
1374
+ status: "WARN",
1375
+ detail: `${root} is not a fleet home, so there is no worktree pool to report`,
1376
+ };
1377
+ }
1378
+ /* THE POOL LISTING IS ASKED FOR, NEVER ASSUMED TO ANSWER (DR-0047 sweep,
1379
+ round 2). `poolList` THROWS on a `tasks/` it cannot list, which is correct
1380
+ and is what round 1 made it do: a pool whose task directory is unreadable
1381
+ is not an empty pool, and reporting "no pool worktrees" over it was the
1382
+ false PASS the review found. What round 1 could not do from its own files
1383
+ is the other half. Uncaught here, the throw escapes `doctor`'s whole run,
1384
+ so a DIAGNOSTIC command ends with one stack-shaped line and reports none of
1385
+ the checks that had nothing wrong with them.
1386
+
1387
+ src/commands/next.ts:334 already catches the same throw and reports it into
1388
+ `unknown`, and the asymmetry was the finding: one consumer of one function
1389
+ degrades and the other aborts. A check that cannot look must say so and let
1390
+ the rest of the run report, which is the same could-not-determine rule
1391
+ `checkSharedLock` states one screen down, and it is FAIL rather than WARN
1392
+ because an unlistable pool is a broken fleet home and not a fleet that
1393
+ never opted in. */
1394
+ let entries;
1395
+ try {
1396
+ entries = poolList(fleet);
1397
+ }
1398
+ catch (error) {
1399
+ return {
1400
+ name: "worktrees",
1401
+ status: "FAIL",
1402
+ detail: `the worktree pool could not be listed, so whether any pool entry lacks a record is ` +
1403
+ `unknown: ${singleLine(String(error))}`,
1404
+ };
1405
+ }
1406
+ if (entries.length === 0) {
1407
+ return {
1408
+ name: "worktrees",
1409
+ status: "PASS",
1410
+ detail: "no pool worktrees",
1411
+ };
1412
+ }
1413
+ const withoutRecord = entries.filter((entry) => entry.origin !== "record");
1414
+ if (withoutRecord.length === 0) {
1415
+ return {
1416
+ name: "worktrees",
1417
+ status: "PASS",
1418
+ detail: `${String(entries.length)} pool entr(ies), each with a pool record beside it`,
1419
+ };
1420
+ }
1421
+ // BY ID, because "1 of 3" tells an operator nothing they can act on.
1422
+ const named = withoutRecord
1423
+ .map((entry) => entry.origin === "reconstructed"
1424
+ ? `${entry.taskId} (reconstructed)`
1425
+ : `${entry.taskId} (unreconstructable: ${(entry.unresolved ?? []).join(", ")})`)
1426
+ .join(", ");
1427
+ return {
1428
+ name: "worktrees",
1429
+ status: "WARN",
1430
+ detail: `${String(withoutRecord.length)} of ${String(entries.length)} pool ` +
1431
+ `entr(ies) have no pool record beside them: ${named}`,
1432
+ condition: "worktree-record-missing",
1433
+ };
1434
+ }
1435
+ /**
1436
+ * CHECK shared-lock (M4-P22 criterion 1): who holds this fleet ACROSS
1437
+ * environments, in exactly one of four statuses.
1438
+ *
1439
+ * CHECK lock above reports the lease on THIS filesystem, which is the only
1440
+ * thing it can report: src/lock.ts:63 states that domain honestly, and
1441
+ * M4-P20 measured two clones of one fleet remote both holding their own
1442
+ * lease at once. This check reports the second layer M4-P21 built, and the
1443
+ * two are separate lines on purpose, because they answer different questions
1444
+ * and an operator reading one of them is entitled to know the other was not
1445
+ * merged into it.
1446
+ *
1447
+ * THE VERDICT IS NOT MADE HERE. `sharedLockStatus` (src/exclusion.ts) owns
1448
+ * it, exactly as `expiryHasPassed` owns CHECK lock's comparison and
1449
+ * `judgeBeacon` owns CHECK beacon's. This check only decides how to present
1450
+ * a status, so doctor and the exclusion layer cannot return two verdicts
1451
+ * about one register.
1452
+ *
1453
+ * THE FOURTH STATUS IS NEVER PASS, which is this check's whole reason for
1454
+ * having four. An unreachable register absorbed into a green line is the
1455
+ * H-C shape: the bundle says fine and the one question that mattered was
1456
+ * never asked. It is a WARN under its own condition so an operator sees it
1457
+ * without doctor exiting nonzero on a fleet that never opted in, and `full`
1458
+ * promotes it (see the PROFILES table).
1459
+ *
1460
+ * IT SPAWNS NOTHING FOR A FLEET THAT HAS NOT OPTED IN. `sharedLockStatus`
1461
+ * reads the fleet home's own package.json first and returns `not-declared`
1462
+ * before any git call, so the cost of this check on every existing fleet is
1463
+ * one file read.
1464
+ */
1465
+ export function checkSharedLock(root) {
1466
+ const status = sharedLockStatus(root);
1467
+ if (status.token === "unreachable") {
1468
+ return {
1469
+ name: "shared-lock",
1470
+ status: "WARN",
1471
+ detail: status.text,
1472
+ condition: "shared-lock-unreachable",
1473
+ };
1474
+ }
1475
+ return { name: "shared-lock", status: "PASS", detail: status.text };
1476
+ }
541
1477
  export function runChecks(root) {
542
1478
  return [
543
1479
  checkNode(),
@@ -546,9 +1482,14 @@ export function runChecks(root) {
546
1482
  checkLayout(root),
547
1483
  checkRemote(root),
548
1484
  checkLock(root),
1485
+ checkSharedLock(root),
549
1486
  checkBeacon(root),
550
1487
  checkIdentity(root),
551
1488
  checkRetention(root),
1489
+ checkTasks(root),
1490
+ checkBranches(root),
1491
+ checkWorktrees(root),
1492
+ checkKernelArtifacts(),
552
1493
  ];
553
1494
  }
554
1495
  export function cmdDoctor(args) {