@tiphys/kernel 0.0.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.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,1546 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { EX_USAGE } from "../cli.js";
|
|
6
|
+
import { BEACON_FILE, LOCK_FILE, loadFleet, missingLayoutEntries } from "../fleet.js";
|
|
7
|
+
import { judgeBeacon, warnIfWatcherStale } from "../liveness.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";
|
|
12
|
+
import { decodeDocument } from "../validate.js";
|
|
13
|
+
import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
|
|
14
|
+
/**
|
|
15
|
+
* Readiness profiles (EXT-F-08): a profile promotes its required WARN
|
|
16
|
+
* conditions to FAIL, so exit 0 under a profile means ready for that mode
|
|
17
|
+
* (SC-011: never green by omission). The M1 table is deliberately small
|
|
18
|
+
* and grows at M2/M3 with the gate registry.
|
|
19
|
+
*/
|
|
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. */
|
|
28
|
+
generic: [],
|
|
29
|
+
"local-only": [],
|
|
30
|
+
"direct-pr": ["gh-missing"],
|
|
31
|
+
/* M3-P8 step 7 (R-098): `retention-undeclared` is promoted here, so a fleet
|
|
32
|
+
whose charter declares no retention paths is not ready for full mode. The
|
|
33
|
+
generic profile leaves it a WARN, which is the state a fleet legitimately
|
|
34
|
+
sits in before its charter is written.
|
|
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
|
+
],
|
|
91
|
+
watch: ["beacon-absent", "beacon-stale"],
|
|
92
|
+
};
|
|
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
|
+
*/
|
|
101
|
+
function readKernelEnginesNode() {
|
|
102
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
103
|
+
for (;;) {
|
|
104
|
+
const candidate = join(dir, "package.json");
|
|
105
|
+
if (classifyEntry(candidate).kind === "regular") {
|
|
106
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
107
|
+
const engines = parsed.engines;
|
|
108
|
+
if (engines === undefined || typeof engines.node !== "string") {
|
|
109
|
+
throw new Error(`no engines.node string in ${candidate}`);
|
|
110
|
+
}
|
|
111
|
+
return engines.node;
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir) {
|
|
115
|
+
throw new Error("package.json not found above " + import.meta.url);
|
|
116
|
+
}
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Evaluate a running node version against the kernel's engines.node range.
|
|
122
|
+
* Fails closed (CR-102): only the exact ">=<major>[.<minor>[.<patch>]]"
|
|
123
|
+
* form is interpreted, compared over the full version tuple; any other
|
|
124
|
+
* range shape, and any unparseable version, is FAIL with a reason line,
|
|
125
|
+
* never a silent truncation.
|
|
126
|
+
*/
|
|
127
|
+
export function nodeCheckFor(range, version) {
|
|
128
|
+
const match = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
|
|
129
|
+
if (match === null) {
|
|
130
|
+
return {
|
|
131
|
+
name: "node",
|
|
132
|
+
status: "FAIL",
|
|
133
|
+
detail: `cannot interpret kernel engines.node range "${range}"`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const floor = [
|
|
137
|
+
Number(match[1]),
|
|
138
|
+
Number(match[2] ?? "0"),
|
|
139
|
+
Number(match[3] ?? "0"),
|
|
140
|
+
];
|
|
141
|
+
const parts = version.replace(/^v/, "").split(".").map(Number);
|
|
142
|
+
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
|
|
143
|
+
return {
|
|
144
|
+
name: "node",
|
|
145
|
+
status: "FAIL",
|
|
146
|
+
detail: `cannot interpret running node version "${version}"`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
let satisfied = true;
|
|
150
|
+
for (let i = 0; i < 3; i += 1) {
|
|
151
|
+
const have = parts[i];
|
|
152
|
+
const need = floor[i];
|
|
153
|
+
if (have > need) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (have < need) {
|
|
157
|
+
satisfied = false;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (satisfied) {
|
|
162
|
+
return {
|
|
163
|
+
name: "node",
|
|
164
|
+
status: "PASS",
|
|
165
|
+
detail: `${version} satisfies kernel engines "${range}"`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
name: "node",
|
|
170
|
+
status: "FAIL",
|
|
171
|
+
detail: `${version} does not satisfy kernel engines "${range}"`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function checkNode() {
|
|
175
|
+
return nodeCheckFor(readKernelEnginesNode(), process.version);
|
|
176
|
+
}
|
|
177
|
+
function toolVersion(cmd) {
|
|
178
|
+
const result = spawnSync(cmd, ["--version"], { encoding: "utf8" });
|
|
179
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const firstLine = (result.stdout ?? "").split("\n")[0] ?? "";
|
|
183
|
+
return firstLine.trim();
|
|
184
|
+
}
|
|
185
|
+
function checkGit() {
|
|
186
|
+
const version = toolVersion("git");
|
|
187
|
+
if (version === undefined) {
|
|
188
|
+
return { name: "git", status: "FAIL", detail: "git not found on PATH" };
|
|
189
|
+
}
|
|
190
|
+
return { name: "git", status: "PASS", detail: version };
|
|
191
|
+
}
|
|
192
|
+
function checkGh() {
|
|
193
|
+
const version = toolVersion("gh");
|
|
194
|
+
if (version === undefined) {
|
|
195
|
+
return {
|
|
196
|
+
name: "gh",
|
|
197
|
+
status: "WARN",
|
|
198
|
+
detail: "gh not found on PATH, PR modes unavailable",
|
|
199
|
+
condition: "gh-missing",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return { name: "gh", status: "PASS", detail: version };
|
|
203
|
+
}
|
|
204
|
+
function checkLayout(root) {
|
|
205
|
+
const missing = missingLayoutEntries(root);
|
|
206
|
+
if (missing.length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
name: "layout",
|
|
209
|
+
status: "FAIL",
|
|
210
|
+
detail: `missing ${missing.join(", ")}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { name: "layout", status: "PASS", detail: "all layout entries present" };
|
|
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
|
+
*/
|
|
315
|
+
function checkRemote(root) {
|
|
316
|
+
if (!existsSync(join(root, ".git"))) {
|
|
317
|
+
return {
|
|
318
|
+
name: "remote",
|
|
319
|
+
status: "WARN",
|
|
320
|
+
detail: "fleet home is not a git repository",
|
|
321
|
+
condition: "remote-missing",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const listed = runGitHere(root, ["remote"]);
|
|
325
|
+
const remotes = listed.status === 0
|
|
326
|
+
? listed.stdout.split("\n").filter((line) => line !== "")
|
|
327
|
+
: [];
|
|
328
|
+
if (remotes.length === 0) {
|
|
329
|
+
return {
|
|
330
|
+
name: "remote",
|
|
331
|
+
status: "WARN",
|
|
332
|
+
detail: "no remote configured, fleet state has no push target (SC-002)",
|
|
333
|
+
condition: "remote-missing",
|
|
334
|
+
};
|
|
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
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
name: "remote",
|
|
414
|
+
status: "WARN",
|
|
415
|
+
detail: `${String(ahead)} unpushed, ${String(behind)} behind ${tracked}`,
|
|
416
|
+
condition: "remote-diverged",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Lease presence and shape. The read is guarded (fix round 4, CR-520's
|
|
421
|
+
* class): doctor is the command an operator runs when a fleet is
|
|
422
|
+
* misbehaving, and a named pipe at state/orchestrator.lock blocked this
|
|
423
|
+
* check in the kernel, so doctor produced no diagnosis at all. This check
|
|
424
|
+
* now classifies such an entry instead of opening it.
|
|
425
|
+
*/
|
|
426
|
+
function checkLock(root) {
|
|
427
|
+
const lockPath = join(root, LOCK_FILE);
|
|
428
|
+
const read = readRegularFileIfPresent(lockPath);
|
|
429
|
+
if (read.kind === "absent") {
|
|
430
|
+
return { name: "lock", status: "PASS", detail: "no lease present" };
|
|
431
|
+
}
|
|
432
|
+
if (read.kind === "refused") {
|
|
433
|
+
return { name: "lock", status: "FAIL", detail: read.reason };
|
|
434
|
+
}
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = JSON.parse(read.body);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
return {
|
|
441
|
+
name: "lock",
|
|
442
|
+
status: "FAIL",
|
|
443
|
+
detail: `lease file is not valid JSON (${String(error)})`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const lease = parsed;
|
|
447
|
+
if (typeof lease.holderId !== "string" ||
|
|
448
|
+
lease.holderId === "" ||
|
|
449
|
+
typeof lease.expiresAt !== "string") {
|
|
450
|
+
return {
|
|
451
|
+
name: "lock",
|
|
452
|
+
status: "FAIL",
|
|
453
|
+
detail: "lease file is missing holderId or expiresAt",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
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))) {
|
|
488
|
+
return {
|
|
489
|
+
name: "lock",
|
|
490
|
+
status: "FAIL",
|
|
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",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
name: "lock",
|
|
505
|
+
status: "PASS",
|
|
506
|
+
detail: `lease held by ${holderId}, expires ${expiresAt}`,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Beacon freshness (R-095, completed by M1-P5). THE JUDGEMENT IS NOT MADE
|
|
511
|
+
* HERE: judgeBeacon in src/liveness.ts decides what the beacon is
|
|
512
|
+
* evidence of, and this check only decides how to present it. That is
|
|
513
|
+
* why doctor and the liveness guard can never return two verdicts about
|
|
514
|
+
* one file in one run, which they did while this check carried its own
|
|
515
|
+
* copy of the comparison and missed the declared-cadence floor (delta
|
|
516
|
+
* review CR-508).
|
|
517
|
+
*
|
|
518
|
+
* This check is about the beacon alone. The separate "watcher stale"
|
|
519
|
+
* warning line this command also emits is the GUARD, whose predicate
|
|
520
|
+
* additionally requires work in flight: a fleet with nothing in flight
|
|
521
|
+
* and no watcher is untidy, not dangerous.
|
|
522
|
+
*/
|
|
523
|
+
function checkBeacon(root) {
|
|
524
|
+
const beaconPath = join(root, BEACON_FILE);
|
|
525
|
+
const verdict = judgeBeacon(beaconPath);
|
|
526
|
+
if (verdict.kind === "absent") {
|
|
527
|
+
return {
|
|
528
|
+
name: "beacon",
|
|
529
|
+
status: "WARN",
|
|
530
|
+
detail: "watcher not running or not scheduled",
|
|
531
|
+
condition: "beacon-absent",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (verdict.kind === "unreadable") {
|
|
535
|
+
return {
|
|
536
|
+
name: "beacon",
|
|
537
|
+
status: "FAIL",
|
|
538
|
+
detail: `beacon file ${beaconPath} does not parse as a beacon record`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const thresholdSeconds = String(Math.round(verdict.thresholdMs / 1000));
|
|
542
|
+
if (verdict.kind === "ahead") {
|
|
543
|
+
return {
|
|
544
|
+
name: "beacon",
|
|
545
|
+
status: "WARN",
|
|
546
|
+
detail: `beacon present but dated ${String(Math.round(verdict.aheadMs / 1000))}s in ` +
|
|
547
|
+
`the future, so it is no evidence that supervision ran`,
|
|
548
|
+
condition: "beacon-stale",
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const rounded = String(Math.max(0, Math.round(verdict.ageMs / 1000)));
|
|
552
|
+
if (verdict.kind === "stale") {
|
|
553
|
+
return {
|
|
554
|
+
name: "beacon",
|
|
555
|
+
status: "WARN",
|
|
556
|
+
detail: `beacon present but ${rounded}s old, past the ${thresholdSeconds}s ` +
|
|
557
|
+
`freshness threshold`,
|
|
558
|
+
condition: "beacon-stale",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
name: "beacon",
|
|
563
|
+
status: "PASS",
|
|
564
|
+
detail: `beacon present, age ${rounded}s (freshness threshold ${thresholdSeconds}s)`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function gitConfigGet(root, key) {
|
|
568
|
+
const result = spawnSync("git", ["-C", root, "config", "--get", key], {
|
|
569
|
+
encoding: "utf8",
|
|
570
|
+
});
|
|
571
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
const value = (result.stdout ?? "").trim();
|
|
575
|
+
return value === "" ? undefined : value;
|
|
576
|
+
}
|
|
577
|
+
function checkIdentity(root) {
|
|
578
|
+
const name = gitConfigGet(root, "user.name");
|
|
579
|
+
const email = gitConfigGet(root, "user.email");
|
|
580
|
+
if (name === undefined || email === undefined) {
|
|
581
|
+
return {
|
|
582
|
+
name: "identity",
|
|
583
|
+
status: "WARN",
|
|
584
|
+
detail: `git user.name or user.email unset; fleet-scoped commits use init's machine identity (${MACHINE_IDENTITY_NAME} <${MACHINE_IDENTITY_EMAIL}>) and do not require it`,
|
|
585
|
+
condition: "identity-unset",
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
name: "identity",
|
|
590
|
+
status: "PASS",
|
|
591
|
+
detail: `git commit identity configured (${name} <${email}>)`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* THE RETENTION CHECK (M3-P8 step 7, R-098).
|
|
596
|
+
*
|
|
597
|
+
* A charter declares `retention` paths for its work histories, its evidence
|
|
598
|
+
* and its tuition. This check reads them and FAILs when a declared path is
|
|
599
|
+
* absent, or is git-ignored in the repository it lives in, because evidence
|
|
600
|
+
* that is ignored is evidence that does not survive the next clone. That is
|
|
601
|
+
* the duty made checkable rather than stated.
|
|
602
|
+
*
|
|
603
|
+
* A CHARTER THAT DECLARES NOTHING IS NOT A PASS. It is a WARN carrying the
|
|
604
|
+
* condition `retention-undeclared`, promoted to FAIL under the `full` profile.
|
|
605
|
+
* A check that is vacuously satisfied by an absent declaration is the SC-011
|
|
606
|
+
* shape this milestone exists to police, so the two states a reader might
|
|
607
|
+
* confuse (nothing declared, everything declared and present) never print the
|
|
608
|
+
* same word.
|
|
609
|
+
*
|
|
610
|
+
* "DECLARES NOTHING" IS DECIDED BY THE COUNT OF PATHS, NOT BY THE TYPE OF THE
|
|
611
|
+
* FIELD (CR-1 and HRB-6, fix round 3). Until then the sentence above was a
|
|
612
|
+
* promise the code did not keep: the guard tested `typeof retention !==
|
|
613
|
+
* "object"`, and `{}` and `[]` are objects, so both printed `PASS 0 declared
|
|
614
|
+
* retention path(s) present and tracked` under BOTH profiles. Round 2 recorded
|
|
615
|
+
* `{}` as an open item; measured on a real `tiphys init` fleet it is a family of
|
|
616
|
+
* five, `{}`, `[]`, nested-map values, empty-string values and non-string
|
|
617
|
+
* values, and an ABSENT key correctly FAILs, so two characters defeated the
|
|
618
|
+
* promotion. Two arms now close it and they close different halves: a value that
|
|
619
|
+
* is not a non-empty string is its own FAIL naming the key, and a charter that
|
|
620
|
+
* yields zero paths by any route takes `retention-undeclared`.
|
|
621
|
+
*
|
|
622
|
+
* THIS CHECK DOES NOT VALIDATE THE CHARTER AGAINST ITS SCHEMA, and that is why
|
|
623
|
+
* the above is reachable by a real user rather than only by a fixture.
|
|
624
|
+
* `schemas/charter.schema.json` does forbid every shape above, but nothing makes
|
|
625
|
+
* anyone run `tiphys validate --type charter` before `tiphys doctor --for full`,
|
|
626
|
+
* and charters are owner-authored by design, so a hand-written charter that does
|
|
627
|
+
* not match its schema is the ordinary case. Wiring schema validation in here is
|
|
628
|
+
* a larger change than this round is scoped for; the two arms make doctor's own
|
|
629
|
+
* verdict correct without it.
|
|
630
|
+
*
|
|
631
|
+
* TWO ROOTS, because a retention path is written from the PROJECT's point of
|
|
632
|
+
* view. `delivery/work-history/` lives in the project repository, and the
|
|
633
|
+
* charter that names it lives in the fleet home, so each path is resolved
|
|
634
|
+
* against the fleet root and against `projects/<identity name>` when that
|
|
635
|
+
* clone is present. A path found unignored under either is satisfied.
|
|
636
|
+
*
|
|
637
|
+
* NO CHARTER AT ALL IS A THIRD STATE, AND IT IS NOT THE ONE ABOVE (fix round
|
|
638
|
+
* 2). `tiphys init` writes `charter/.gitkeep` and no charter document, because
|
|
639
|
+
* the charter is owner-authored (delivery/intake/orchestrated-delivery-v1.md:224
|
|
640
|
+
* lists charter authorship among the owner's standing duties) and its required
|
|
641
|
+
* fields are project facts init does not hold. Folding that state into
|
|
642
|
+
* `retention-undeclared` made `tiphys doctor --for full` exit nonzero on every
|
|
643
|
+
* freshly initialized fleet, which is the first thing a new user does. So it
|
|
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.
|
|
663
|
+
*/
|
|
664
|
+
function checkRetention(root) {
|
|
665
|
+
const charterDir = join(root, "charter");
|
|
666
|
+
let names;
|
|
667
|
+
try {
|
|
668
|
+
names = readdirSync(charterDir).sort();
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
/* The `layout` check owns a missing charter/ and FAILs on it (FLEET_DIRS in
|
|
672
|
+
src/fleet.ts), so this arm never has to carry that verdict itself. */
|
|
673
|
+
return {
|
|
674
|
+
name: "retention",
|
|
675
|
+
status: "WARN",
|
|
676
|
+
detail: `no charter/ directory under ${root}, so retention is not applicable; the layout check owns that condition`,
|
|
677
|
+
condition: "retention-not-applicable",
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const declarations = [];
|
|
681
|
+
let candidates = 0;
|
|
682
|
+
for (const name of names) {
|
|
683
|
+
if (!name.endsWith(".yaml") && !name.endsWith(".yml")) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
candidates += 1;
|
|
687
|
+
const path = join(charterDir, name);
|
|
688
|
+
const read = readRegularFileIfPresent(path);
|
|
689
|
+
if (read.kind === "refused") {
|
|
690
|
+
return { name: "retention", status: "FAIL", detail: read.reason };
|
|
691
|
+
}
|
|
692
|
+
if (read.kind === "absent") {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
let document;
|
|
696
|
+
try {
|
|
697
|
+
const decoded = decodeDocument(read.body, path);
|
|
698
|
+
if (!decoded.ok) {
|
|
699
|
+
return { name: "retention", status: "FAIL", detail: decoded.reason };
|
|
700
|
+
}
|
|
701
|
+
document = (decoded.value ?? {});
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
return {
|
|
705
|
+
name: "retention",
|
|
706
|
+
status: "FAIL",
|
|
707
|
+
detail: `${path} could not be decoded: ${String(error)}`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (document["kind"] !== "charter") {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const retention = document["retention"];
|
|
714
|
+
if (typeof retention !== "object" || retention === null) {
|
|
715
|
+
return {
|
|
716
|
+
name: "retention",
|
|
717
|
+
status: "WARN",
|
|
718
|
+
detail: `${path} declares no retention paths`,
|
|
719
|
+
condition: "retention-undeclared",
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/* A NON-STRING VALUE IS ITS OWN FAIL, NEVER A SILENT DROP (CR-1, HRB-6, fix
|
|
723
|
+
round 3). The earlier form filtered them away, so a charter declaring
|
|
724
|
+
three retention paths with the wrong types reported the same green as one
|
|
725
|
+
declaring none. Naming the key is what makes the verdict actionable. */
|
|
726
|
+
const paths = [];
|
|
727
|
+
for (const [key, value] of Object.entries(retention)) {
|
|
728
|
+
if (typeof value === "string" && value !== "") {
|
|
729
|
+
paths.push(value);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
name: "retention",
|
|
734
|
+
status: "FAIL",
|
|
735
|
+
detail: `${path} declares retention key ${key} as ` +
|
|
736
|
+
`${value === "" ? "an empty string" : describeRetentionValue(value)}, ` +
|
|
737
|
+
`which names no path`,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const identity = document["identity"];
|
|
741
|
+
const projectName = typeof identity === "object" && identity !== null
|
|
742
|
+
? identity["name"]
|
|
743
|
+
: undefined;
|
|
744
|
+
const projectRoot = typeof projectName === "string"
|
|
745
|
+
? join(root, "projects", projectName)
|
|
746
|
+
: undefined;
|
|
747
|
+
declarations.push(projectRoot !== undefined && existsSync(projectRoot)
|
|
748
|
+
? { charter: path, paths, projectRoot }
|
|
749
|
+
: { charter: path, paths });
|
|
750
|
+
}
|
|
751
|
+
if (declarations.length === 0) {
|
|
752
|
+
/* NOT APPLICABLE versus UNDECLARED, and the difference is whether anyone
|
|
753
|
+
has written a charter yet. An empty charter/ is a fleet before
|
|
754
|
+
realization; YAML that is present but carries no `kind: charter` is a
|
|
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. */
|
|
767
|
+
if (candidates === 0) {
|
|
768
|
+
/* NO YAML AT ALL in charter/, which is what `tiphys init` leaves. WARN
|
|
769
|
+
below `full`, promoted there. */
|
|
770
|
+
return {
|
|
771
|
+
name: "retention",
|
|
772
|
+
status: "WARN",
|
|
773
|
+
detail: `no charter document in ${charterDir}, so no project is realized here yet and retention is not applicable`,
|
|
774
|
+
condition: "retention-not-applicable",
|
|
775
|
+
};
|
|
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. */
|
|
780
|
+
return {
|
|
781
|
+
name: "retention",
|
|
782
|
+
status: "WARN",
|
|
783
|
+
detail: `${String(candidates)} YAML document(s) in ${charterDir}, none with kind: charter, so no retention paths are declared`,
|
|
784
|
+
condition: "retention-undeclared",
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/* THE VERDICT COMES FROM THE COUNT, NOT FROM THE TYPE (CR-1, HRB-6, fix
|
|
788
|
+
round 3). The type test above decides PRESENCE OF AN OBJECT, and `{}` and
|
|
789
|
+
`[]` are both objects, so two characters in a charter defeated the promoted
|
|
790
|
+
`retention-undeclared` condition and printed `PASS 0 declared retention
|
|
791
|
+
path(s) present and tracked`: the same word as a charter with three paths
|
|
792
|
+
present and tracked, which is the exact thing this check's header forbids
|
|
793
|
+
and the plan's hazard row at delivery/plan/kernel-plan-m3.md:4042 polices.
|
|
794
|
+
Whatever shape `retention` had, a charter that yields NO path has declared
|
|
795
|
+
nothing, and that is one condition rather than a family of them. */
|
|
796
|
+
const empty = declarations.filter((declaration) => declaration.paths.length === 0);
|
|
797
|
+
if (empty.length > 0) {
|
|
798
|
+
return {
|
|
799
|
+
name: "retention",
|
|
800
|
+
status: "WARN",
|
|
801
|
+
detail: `${empty[0].charter} declares no retention paths`,
|
|
802
|
+
condition: "retention-undeclared",
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
let checked = 0;
|
|
806
|
+
for (const declaration of declarations) {
|
|
807
|
+
const roots = [root, ...(declaration.projectRoot === undefined ? [] : [declaration.projectRoot])];
|
|
808
|
+
for (const relative of declaration.paths) {
|
|
809
|
+
checked += 1;
|
|
810
|
+
const present = roots.filter((base) => existsSync(join(base, relative)));
|
|
811
|
+
if (present.length === 0) {
|
|
812
|
+
return {
|
|
813
|
+
name: "retention",
|
|
814
|
+
status: "FAIL",
|
|
815
|
+
detail: `${declaration.charter} declares retention path ${relative}, which does not exist`,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
const kept = present.filter((base) => !isGitIgnored(base, relative));
|
|
819
|
+
if (kept.length === 0) {
|
|
820
|
+
return {
|
|
821
|
+
name: "retention",
|
|
822
|
+
status: "FAIL",
|
|
823
|
+
detail: `${declaration.charter} declares retention path ${relative}, which is git-ignored and will not survive a clone`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
name: "retention",
|
|
830
|
+
status: "PASS",
|
|
831
|
+
detail: `${String(checked)} declared retention path(s) present and tracked`,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/** Name a non-string retention value in a diagnostic, without printing it. */
|
|
835
|
+
function describeRetentionValue(value) {
|
|
836
|
+
if (value === null) {
|
|
837
|
+
return "null";
|
|
838
|
+
}
|
|
839
|
+
if (Array.isArray(value)) {
|
|
840
|
+
return "a list";
|
|
841
|
+
}
|
|
842
|
+
if (typeof value === "object") {
|
|
843
|
+
return "a map";
|
|
844
|
+
}
|
|
845
|
+
return `a ${typeof value}`;
|
|
846
|
+
}
|
|
847
|
+
/** True when git reports the path ignored in that repository. */
|
|
848
|
+
function isGitIgnored(repository, relative) {
|
|
849
|
+
const result = spawnSync("git", ["-C", repository, "check-ignore", "-q", "--", relative], { encoding: "utf8" });
|
|
850
|
+
return result.error === undefined && result.status === 0;
|
|
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
|
+
}
|
|
1477
|
+
export function runChecks(root) {
|
|
1478
|
+
return [
|
|
1479
|
+
checkNode(),
|
|
1480
|
+
checkGit(),
|
|
1481
|
+
checkGh(),
|
|
1482
|
+
checkLayout(root),
|
|
1483
|
+
checkRemote(root),
|
|
1484
|
+
checkLock(root),
|
|
1485
|
+
checkSharedLock(root),
|
|
1486
|
+
checkBeacon(root),
|
|
1487
|
+
checkIdentity(root),
|
|
1488
|
+
checkRetention(root),
|
|
1489
|
+
checkTasks(root),
|
|
1490
|
+
checkBranches(root),
|
|
1491
|
+
checkWorktrees(root),
|
|
1492
|
+
checkKernelArtifacts(),
|
|
1493
|
+
];
|
|
1494
|
+
}
|
|
1495
|
+
export function cmdDoctor(args) {
|
|
1496
|
+
let profile = "generic";
|
|
1497
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1498
|
+
if (args[i] === "--for" && i + 1 < args.length) {
|
|
1499
|
+
profile = args[i + 1];
|
|
1500
|
+
i += 1;
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
process.stderr.write("usage: tiphys doctor [--for <profile>]\n");
|
|
1504
|
+
return EX_USAGE;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
const promoted = PROFILES[profile];
|
|
1508
|
+
if (promoted === undefined) {
|
|
1509
|
+
process.stderr.write(`tiphys doctor: unknown profile "${profile}" (profiles: ${Object.keys(PROFILES).join(", ")})\n`);
|
|
1510
|
+
return EX_USAGE;
|
|
1511
|
+
}
|
|
1512
|
+
let failed = false;
|
|
1513
|
+
for (const result of runChecks(process.cwd())) {
|
|
1514
|
+
let status = result.status;
|
|
1515
|
+
let detail = result.detail;
|
|
1516
|
+
if (status === "WARN" &&
|
|
1517
|
+
result.condition !== undefined &&
|
|
1518
|
+
promoted.includes(result.condition)) {
|
|
1519
|
+
status = "FAIL";
|
|
1520
|
+
detail = `${detail} (required for profile ${profile})`;
|
|
1521
|
+
}
|
|
1522
|
+
if (status === "FAIL") {
|
|
1523
|
+
failed = true;
|
|
1524
|
+
}
|
|
1525
|
+
process.stdout.write(`CHECK ${result.name} ${status} ${detail}\n`);
|
|
1526
|
+
}
|
|
1527
|
+
// Liveness guard (M1-P5 step 2). It warns and never blocks: doctor's
|
|
1528
|
+
// exit code is decided by its checks exactly as before. Outside a fleet
|
|
1529
|
+
// home there is no guard to run, and the layout check is what reports
|
|
1530
|
+
// that; an advisory must not be the thing that says so.
|
|
1531
|
+
//
|
|
1532
|
+
// THE ADVISORY RUNS LAST, AFTER THE DIAGNOSIS IS PRINTED (CR-523). It
|
|
1533
|
+
// used to run first, so anything wrong with the guard silenced the whole
|
|
1534
|
+
// command: with a named pipe at the beacon, the one tool an operator
|
|
1535
|
+
// runs on a misbehaving fleet produced zero CHECK lines. The guard is
|
|
1536
|
+
// now safe on that path, but the ordering is what made a single defect
|
|
1537
|
+
// in an advisory cost the entire diagnosis, and an advisory belongs
|
|
1538
|
+
// beside a diagnosis rather than in front of it.
|
|
1539
|
+
try {
|
|
1540
|
+
warnIfWatcherStale(loadFleet(process.cwd()));
|
|
1541
|
+
}
|
|
1542
|
+
catch {
|
|
1543
|
+
// Not a fleet home: reported by CHECK layout above.
|
|
1544
|
+
}
|
|
1545
|
+
return failed ? 1 : 0;
|
|
1546
|
+
}
|