@tiphys/kernel 0.0.0 → 0.1.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 (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,605 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } 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 { readRegularFileIfPresent } from "../task.js";
9
+ import { decodeDocument } from "../validate.js";
10
+ import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME, } from "./init.js";
11
+ /**
12
+ * Readiness profiles (EXT-F-08): a profile promotes its required WARN
13
+ * conditions to FAIL, so exit 0 under a profile means ready for that mode
14
+ * (SC-011: never green by omission). The M1 table is deliberately small
15
+ * and grows at M2/M3 with the gate registry.
16
+ */
17
+ export const PROFILES = {
18
+ generic: [],
19
+ "local-only": [],
20
+ "direct-pr": ["gh-missing"],
21
+ /* M3-P8 step 7 (R-098): `retention-undeclared` is promoted here, so a fleet
22
+ whose charter declares no retention paths is not ready for full mode. The
23
+ generic profile leaves it a WARN, which is the state a fleet legitimately
24
+ 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"],
29
+ watch: ["beacon-absent", "beacon-stale"],
30
+ };
31
+ /** Locate the kernel's own package.json (same walk as src/version.ts). */
32
+ function readKernelEnginesNode() {
33
+ let dir = dirname(fileURLToPath(import.meta.url));
34
+ for (;;) {
35
+ const candidate = join(dir, "package.json");
36
+ if (existsSync(candidate)) {
37
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
38
+ const engines = parsed.engines;
39
+ if (engines === undefined || typeof engines.node !== "string") {
40
+ throw new Error(`no engines.node string in ${candidate}`);
41
+ }
42
+ return engines.node;
43
+ }
44
+ const parent = dirname(dir);
45
+ if (parent === dir) {
46
+ throw new Error("package.json not found above " + import.meta.url);
47
+ }
48
+ dir = parent;
49
+ }
50
+ }
51
+ /**
52
+ * Evaluate a running node version against the kernel's engines.node range.
53
+ * Fails closed (CR-102): only the exact ">=<major>[.<minor>[.<patch>]]"
54
+ * form is interpreted, compared over the full version tuple; any other
55
+ * range shape, and any unparseable version, is FAIL with a reason line,
56
+ * never a silent truncation.
57
+ */
58
+ export function nodeCheckFor(range, version) {
59
+ const match = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
60
+ if (match === null) {
61
+ return {
62
+ name: "node",
63
+ status: "FAIL",
64
+ detail: `cannot interpret kernel engines.node range "${range}"`,
65
+ };
66
+ }
67
+ const floor = [
68
+ Number(match[1]),
69
+ Number(match[2] ?? "0"),
70
+ Number(match[3] ?? "0"),
71
+ ];
72
+ const parts = version.replace(/^v/, "").split(".").map(Number);
73
+ if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
74
+ return {
75
+ name: "node",
76
+ status: "FAIL",
77
+ detail: `cannot interpret running node version "${version}"`,
78
+ };
79
+ }
80
+ let satisfied = true;
81
+ for (let i = 0; i < 3; i += 1) {
82
+ const have = parts[i];
83
+ const need = floor[i];
84
+ if (have > need) {
85
+ break;
86
+ }
87
+ if (have < need) {
88
+ satisfied = false;
89
+ break;
90
+ }
91
+ }
92
+ if (satisfied) {
93
+ return {
94
+ name: "node",
95
+ status: "PASS",
96
+ detail: `${version} satisfies kernel engines "${range}"`,
97
+ };
98
+ }
99
+ return {
100
+ name: "node",
101
+ status: "FAIL",
102
+ detail: `${version} does not satisfy kernel engines "${range}"`,
103
+ };
104
+ }
105
+ function checkNode() {
106
+ return nodeCheckFor(readKernelEnginesNode(), process.version);
107
+ }
108
+ function toolVersion(cmd) {
109
+ const result = spawnSync(cmd, ["--version"], { encoding: "utf8" });
110
+ if (result.error !== undefined || result.status !== 0) {
111
+ return undefined;
112
+ }
113
+ const firstLine = (result.stdout ?? "").split("\n")[0] ?? "";
114
+ return firstLine.trim();
115
+ }
116
+ function checkGit() {
117
+ const version = toolVersion("git");
118
+ if (version === undefined) {
119
+ return { name: "git", status: "FAIL", detail: "git not found on PATH" };
120
+ }
121
+ return { name: "git", status: "PASS", detail: version };
122
+ }
123
+ function checkGh() {
124
+ const version = toolVersion("gh");
125
+ if (version === undefined) {
126
+ return {
127
+ name: "gh",
128
+ status: "WARN",
129
+ detail: "gh not found on PATH, PR modes unavailable",
130
+ condition: "gh-missing",
131
+ };
132
+ }
133
+ return { name: "gh", status: "PASS", detail: version };
134
+ }
135
+ function checkLayout(root) {
136
+ const missing = missingLayoutEntries(root);
137
+ if (missing.length > 0) {
138
+ return {
139
+ name: "layout",
140
+ status: "FAIL",
141
+ detail: `missing ${missing.join(", ")}`,
142
+ };
143
+ }
144
+ return { name: "layout", status: "PASS", detail: "all layout entries present" };
145
+ }
146
+ function checkRemote(root) {
147
+ if (!existsSync(join(root, ".git"))) {
148
+ return {
149
+ name: "remote",
150
+ status: "WARN",
151
+ detail: "fleet home is not a git repository",
152
+ condition: "remote-missing",
153
+ };
154
+ }
155
+ const result = spawnSync("git", ["-C", root, "remote"], { encoding: "utf8" });
156
+ const remotes = result.status === 0
157
+ ? (result.stdout ?? "").split("\n").filter((line) => line !== "")
158
+ : [];
159
+ if (remotes.length === 0) {
160
+ return {
161
+ name: "remote",
162
+ status: "WARN",
163
+ detail: "no remote configured, fleet state has no push target (SC-002)",
164
+ condition: "remote-missing",
165
+ };
166
+ }
167
+ return {
168
+ name: "remote",
169
+ status: "PASS",
170
+ detail: `remote configured (${remotes.join(", ")})`,
171
+ };
172
+ }
173
+ /**
174
+ * Lease presence and shape. The read is guarded (fix round 4, CR-520's
175
+ * class): doctor is the command an operator runs when a fleet is
176
+ * misbehaving, and a named pipe at state/orchestrator.lock blocked this
177
+ * check in the kernel, so doctor produced no diagnosis at all. This check
178
+ * now classifies such an entry instead of opening it.
179
+ */
180
+ function checkLock(root) {
181
+ const lockPath = join(root, LOCK_FILE);
182
+ const read = readRegularFileIfPresent(lockPath);
183
+ if (read.kind === "absent") {
184
+ return { name: "lock", status: "PASS", detail: "no lease present" };
185
+ }
186
+ if (read.kind === "refused") {
187
+ return { name: "lock", status: "FAIL", detail: read.reason };
188
+ }
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(read.body);
192
+ }
193
+ catch (error) {
194
+ return {
195
+ name: "lock",
196
+ status: "FAIL",
197
+ detail: `lease file is not valid JSON (${String(error)})`,
198
+ };
199
+ }
200
+ const lease = parsed;
201
+ if (typeof lease.holderId !== "string" ||
202
+ lease.holderId === "" ||
203
+ typeof lease.expiresAt !== "string") {
204
+ return {
205
+ name: "lock",
206
+ status: "FAIL",
207
+ detail: "lease file is missing holderId or expiresAt",
208
+ };
209
+ }
210
+ const expiresMs = Date.parse(lease.expiresAt);
211
+ if (Number.isNaN(expiresMs)) {
212
+ return {
213
+ name: "lock",
214
+ status: "FAIL",
215
+ detail: `lease expiresAt "${lease.expiresAt}" is not a parseable timestamp`,
216
+ };
217
+ }
218
+ const expired = expiresMs <= Date.now();
219
+ return {
220
+ name: "lock",
221
+ status: "PASS",
222
+ detail: `lease held by ${lease.holderId}, expires ${lease.expiresAt}${expired ? " (expired)" : ""}`,
223
+ };
224
+ }
225
+ /**
226
+ * Beacon freshness (R-095, completed by M1-P5). THE JUDGEMENT IS NOT MADE
227
+ * HERE: judgeBeacon in src/liveness.ts decides what the beacon is
228
+ * evidence of, and this check only decides how to present it. That is
229
+ * why doctor and the liveness guard can never return two verdicts about
230
+ * one file in one run, which they did while this check carried its own
231
+ * copy of the comparison and missed the declared-cadence floor (delta
232
+ * review CR-508).
233
+ *
234
+ * This check is about the beacon alone. The separate "watcher stale"
235
+ * warning line this command also emits is the GUARD, whose predicate
236
+ * additionally requires work in flight: a fleet with nothing in flight
237
+ * and no watcher is untidy, not dangerous.
238
+ */
239
+ function checkBeacon(root) {
240
+ const beaconPath = join(root, BEACON_FILE);
241
+ const verdict = judgeBeacon(beaconPath);
242
+ if (verdict.kind === "absent") {
243
+ return {
244
+ name: "beacon",
245
+ status: "WARN",
246
+ detail: "watcher not running or not scheduled",
247
+ condition: "beacon-absent",
248
+ };
249
+ }
250
+ if (verdict.kind === "unreadable") {
251
+ return {
252
+ name: "beacon",
253
+ status: "FAIL",
254
+ detail: `beacon file ${beaconPath} does not parse as a beacon record`,
255
+ };
256
+ }
257
+ const thresholdSeconds = String(Math.round(verdict.thresholdMs / 1000));
258
+ if (verdict.kind === "ahead") {
259
+ return {
260
+ name: "beacon",
261
+ status: "WARN",
262
+ detail: `beacon present but dated ${String(Math.round(verdict.aheadMs / 1000))}s in ` +
263
+ `the future, so it is no evidence that supervision ran`,
264
+ condition: "beacon-stale",
265
+ };
266
+ }
267
+ const rounded = String(Math.max(0, Math.round(verdict.ageMs / 1000)));
268
+ if (verdict.kind === "stale") {
269
+ return {
270
+ name: "beacon",
271
+ status: "WARN",
272
+ detail: `beacon present but ${rounded}s old, past the ${thresholdSeconds}s ` +
273
+ `freshness threshold`,
274
+ condition: "beacon-stale",
275
+ };
276
+ }
277
+ return {
278
+ name: "beacon",
279
+ status: "PASS",
280
+ detail: `beacon present, age ${rounded}s (freshness threshold ${thresholdSeconds}s)`,
281
+ };
282
+ }
283
+ function gitConfigGet(root, key) {
284
+ const result = spawnSync("git", ["-C", root, "config", "--get", key], {
285
+ encoding: "utf8",
286
+ });
287
+ if (result.error !== undefined || result.status !== 0) {
288
+ return undefined;
289
+ }
290
+ const value = (result.stdout ?? "").trim();
291
+ return value === "" ? undefined : value;
292
+ }
293
+ function checkIdentity(root) {
294
+ const name = gitConfigGet(root, "user.name");
295
+ const email = gitConfigGet(root, "user.email");
296
+ if (name === undefined || email === undefined) {
297
+ return {
298
+ name: "identity",
299
+ status: "WARN",
300
+ 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`,
301
+ condition: "identity-unset",
302
+ };
303
+ }
304
+ return {
305
+ name: "identity",
306
+ status: "PASS",
307
+ detail: `git commit identity configured (${name} <${email}>)`,
308
+ };
309
+ }
310
+ /**
311
+ * THE RETENTION CHECK (M3-P8 step 7, R-098).
312
+ *
313
+ * A charter declares `retention` paths for its work histories, its evidence
314
+ * and its tuition. This check reads them and FAILs when a declared path is
315
+ * absent, or is git-ignored in the repository it lives in, because evidence
316
+ * that is ignored is evidence that does not survive the next clone. That is
317
+ * the duty made checkable rather than stated.
318
+ *
319
+ * A CHARTER THAT DECLARES NOTHING IS NOT A PASS. It is a WARN carrying the
320
+ * condition `retention-undeclared`, promoted to FAIL under the `full` profile.
321
+ * A check that is vacuously satisfied by an absent declaration is the SC-011
322
+ * shape this milestone exists to police, so the two states a reader might
323
+ * confuse (nothing declared, everything declared and present) never print the
324
+ * same word.
325
+ *
326
+ * "DECLARES NOTHING" IS DECIDED BY THE COUNT OF PATHS, NOT BY THE TYPE OF THE
327
+ * FIELD (CR-1 and HRB-6, fix round 3). Until then the sentence above was a
328
+ * promise the code did not keep: the guard tested `typeof retention !==
329
+ * "object"`, and `{}` and `[]` are objects, so both printed `PASS 0 declared
330
+ * retention path(s) present and tracked` under BOTH profiles. Round 2 recorded
331
+ * `{}` as an open item; measured on a real `tiphys init` fleet it is a family of
332
+ * five, `{}`, `[]`, nested-map values, empty-string values and non-string
333
+ * values, and an ABSENT key correctly FAILs, so two characters defeated the
334
+ * promotion. Two arms now close it and they close different halves: a value that
335
+ * is not a non-empty string is its own FAIL naming the key, and a charter that
336
+ * yields zero paths by any route takes `retention-undeclared`.
337
+ *
338
+ * THIS CHECK DOES NOT VALIDATE THE CHARTER AGAINST ITS SCHEMA, and that is why
339
+ * the above is reachable by a real user rather than only by a fixture.
340
+ * `schemas/charter.schema.json` does forbid every shape above, but nothing makes
341
+ * anyone run `tiphys validate --type charter` before `tiphys doctor --for full`,
342
+ * and charters are owner-authored by design, so a hand-written charter that does
343
+ * not match its schema is the ordinary case. Wiring schema validation in here is
344
+ * a larger change than this round is scoped for; the two arms make doctor's own
345
+ * verdict correct without it.
346
+ *
347
+ * TWO ROOTS, because a retention path is written from the PROJECT's point of
348
+ * view. `delivery/work-history/` lives in the project repository, and the
349
+ * charter that names it lives in the fleet home, so each path is resolved
350
+ * against the fleet root and against `projects/<identity name>` when that
351
+ * clone is present. A path found unignored under either is satisfied.
352
+ *
353
+ * NO CHARTER AT ALL IS A THIRD STATE, AND IT IS NOT THE ONE ABOVE (fix round
354
+ * 2). `tiphys init` writes `charter/.gitkeep` and no charter document, because
355
+ * the charter is owner-authored (delivery/intake/orchestrated-delivery-v1.md:224
356
+ * lists charter authorship among the owner's standing duties) and its required
357
+ * fields are project facts init does not hold. Folding that state into
358
+ * `retention-undeclared` made `tiphys doctor --for full` exit nonzero on every
359
+ * 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.
367
+ */
368
+ function checkRetention(root) {
369
+ const charterDir = join(root, "charter");
370
+ let names;
371
+ try {
372
+ names = readdirSync(charterDir).sort();
373
+ }
374
+ catch {
375
+ /* The `layout` check owns a missing charter/ and FAILs on it (FLEET_DIRS in
376
+ src/fleet.ts), so this arm never has to carry that verdict itself. */
377
+ return {
378
+ name: "retention",
379
+ status: "WARN",
380
+ detail: `no charter/ directory under ${root}, so retention is not applicable; the layout check owns that condition`,
381
+ condition: "retention-not-applicable",
382
+ };
383
+ }
384
+ const declarations = [];
385
+ let candidates = 0;
386
+ for (const name of names) {
387
+ if (!name.endsWith(".yaml") && !name.endsWith(".yml")) {
388
+ continue;
389
+ }
390
+ candidates += 1;
391
+ const path = join(charterDir, name);
392
+ const read = readRegularFileIfPresent(path);
393
+ if (read.kind === "refused") {
394
+ return { name: "retention", status: "FAIL", detail: read.reason };
395
+ }
396
+ if (read.kind === "absent") {
397
+ continue;
398
+ }
399
+ let document;
400
+ try {
401
+ const decoded = decodeDocument(read.body, path);
402
+ if (!decoded.ok) {
403
+ return { name: "retention", status: "FAIL", detail: decoded.reason };
404
+ }
405
+ document = (decoded.value ?? {});
406
+ }
407
+ catch (error) {
408
+ return {
409
+ name: "retention",
410
+ status: "FAIL",
411
+ detail: `${path} could not be decoded: ${String(error)}`,
412
+ };
413
+ }
414
+ if (document["kind"] !== "charter") {
415
+ continue;
416
+ }
417
+ const retention = document["retention"];
418
+ if (typeof retention !== "object" || retention === null) {
419
+ return {
420
+ name: "retention",
421
+ status: "WARN",
422
+ detail: `${path} declares no retention paths`,
423
+ condition: "retention-undeclared",
424
+ };
425
+ }
426
+ /* A NON-STRING VALUE IS ITS OWN FAIL, NEVER A SILENT DROP (CR-1, HRB-6, fix
427
+ round 3). The earlier form filtered them away, so a charter declaring
428
+ three retention paths with the wrong types reported the same green as one
429
+ declaring none. Naming the key is what makes the verdict actionable. */
430
+ const paths = [];
431
+ for (const [key, value] of Object.entries(retention)) {
432
+ if (typeof value === "string" && value !== "") {
433
+ paths.push(value);
434
+ continue;
435
+ }
436
+ return {
437
+ name: "retention",
438
+ status: "FAIL",
439
+ detail: `${path} declares retention key ${key} as ` +
440
+ `${value === "" ? "an empty string" : describeRetentionValue(value)}, ` +
441
+ `which names no path`,
442
+ };
443
+ }
444
+ const identity = document["identity"];
445
+ const projectName = typeof identity === "object" && identity !== null
446
+ ? identity["name"]
447
+ : undefined;
448
+ const projectRoot = typeof projectName === "string"
449
+ ? join(root, "projects", projectName)
450
+ : undefined;
451
+ declarations.push(projectRoot !== undefined && existsSync(projectRoot)
452
+ ? { charter: path, paths, projectRoot }
453
+ : { charter: path, paths });
454
+ }
455
+ if (declarations.length === 0) {
456
+ /* NOT APPLICABLE versus UNDECLARED, and the difference is whether anyone
457
+ has written a charter yet. An empty charter/ is a fleet before
458
+ realization; YAML that is present but carries no `kind: charter` is a
459
+ fleet someone has configured wrongly, which stays the promoted
460
+ condition. */
461
+ if (candidates === 0) {
462
+ return {
463
+ name: "retention",
464
+ status: "WARN",
465
+ detail: `no charter document in ${charterDir}, so no project is realized here yet and retention is not applicable`,
466
+ condition: "retention-not-applicable",
467
+ };
468
+ }
469
+ return {
470
+ name: "retention",
471
+ status: "WARN",
472
+ detail: `${String(candidates)} YAML document(s) in ${charterDir}, none with kind: charter, so no retention paths are declared`,
473
+ condition: "retention-undeclared",
474
+ };
475
+ }
476
+ /* THE VERDICT COMES FROM THE COUNT, NOT FROM THE TYPE (CR-1, HRB-6, fix
477
+ round 3). The type test above decides PRESENCE OF AN OBJECT, and `{}` and
478
+ `[]` are both objects, so two characters in a charter defeated the promoted
479
+ `retention-undeclared` condition and printed `PASS 0 declared retention
480
+ path(s) present and tracked`: the same word as a charter with three paths
481
+ present and tracked, which is the exact thing this check's header forbids
482
+ and the plan's hazard row at delivery/plan/kernel-plan-m3.md:4042 polices.
483
+ Whatever shape `retention` had, a charter that yields NO path has declared
484
+ nothing, and that is one condition rather than a family of them. */
485
+ const empty = declarations.filter((declaration) => declaration.paths.length === 0);
486
+ if (empty.length > 0) {
487
+ return {
488
+ name: "retention",
489
+ status: "WARN",
490
+ detail: `${empty[0].charter} declares no retention paths`,
491
+ condition: "retention-undeclared",
492
+ };
493
+ }
494
+ let checked = 0;
495
+ for (const declaration of declarations) {
496
+ const roots = [root, ...(declaration.projectRoot === undefined ? [] : [declaration.projectRoot])];
497
+ for (const relative of declaration.paths) {
498
+ checked += 1;
499
+ const present = roots.filter((base) => existsSync(join(base, relative)));
500
+ if (present.length === 0) {
501
+ return {
502
+ name: "retention",
503
+ status: "FAIL",
504
+ detail: `${declaration.charter} declares retention path ${relative}, which does not exist`,
505
+ };
506
+ }
507
+ const kept = present.filter((base) => !isGitIgnored(base, relative));
508
+ if (kept.length === 0) {
509
+ return {
510
+ name: "retention",
511
+ status: "FAIL",
512
+ detail: `${declaration.charter} declares retention path ${relative}, which is git-ignored and will not survive a clone`,
513
+ };
514
+ }
515
+ }
516
+ }
517
+ return {
518
+ name: "retention",
519
+ status: "PASS",
520
+ detail: `${String(checked)} declared retention path(s) present and tracked`,
521
+ };
522
+ }
523
+ /** Name a non-string retention value in a diagnostic, without printing it. */
524
+ function describeRetentionValue(value) {
525
+ if (value === null) {
526
+ return "null";
527
+ }
528
+ if (Array.isArray(value)) {
529
+ return "a list";
530
+ }
531
+ if (typeof value === "object") {
532
+ return "a map";
533
+ }
534
+ return `a ${typeof value}`;
535
+ }
536
+ /** True when git reports the path ignored in that repository. */
537
+ function isGitIgnored(repository, relative) {
538
+ const result = spawnSync("git", ["-C", repository, "check-ignore", "-q", "--", relative], { encoding: "utf8" });
539
+ return result.error === undefined && result.status === 0;
540
+ }
541
+ export function runChecks(root) {
542
+ return [
543
+ checkNode(),
544
+ checkGit(),
545
+ checkGh(),
546
+ checkLayout(root),
547
+ checkRemote(root),
548
+ checkLock(root),
549
+ checkBeacon(root),
550
+ checkIdentity(root),
551
+ checkRetention(root),
552
+ ];
553
+ }
554
+ export function cmdDoctor(args) {
555
+ let profile = "generic";
556
+ for (let i = 0; i < args.length; i += 1) {
557
+ if (args[i] === "--for" && i + 1 < args.length) {
558
+ profile = args[i + 1];
559
+ i += 1;
560
+ }
561
+ else {
562
+ process.stderr.write("usage: tiphys doctor [--for <profile>]\n");
563
+ return EX_USAGE;
564
+ }
565
+ }
566
+ const promoted = PROFILES[profile];
567
+ if (promoted === undefined) {
568
+ process.stderr.write(`tiphys doctor: unknown profile "${profile}" (profiles: ${Object.keys(PROFILES).join(", ")})\n`);
569
+ return EX_USAGE;
570
+ }
571
+ let failed = false;
572
+ for (const result of runChecks(process.cwd())) {
573
+ let status = result.status;
574
+ let detail = result.detail;
575
+ if (status === "WARN" &&
576
+ result.condition !== undefined &&
577
+ promoted.includes(result.condition)) {
578
+ status = "FAIL";
579
+ detail = `${detail} (required for profile ${profile})`;
580
+ }
581
+ if (status === "FAIL") {
582
+ failed = true;
583
+ }
584
+ process.stdout.write(`CHECK ${result.name} ${status} ${detail}\n`);
585
+ }
586
+ // Liveness guard (M1-P5 step 2). It warns and never blocks: doctor's
587
+ // exit code is decided by its checks exactly as before. Outside a fleet
588
+ // home there is no guard to run, and the layout check is what reports
589
+ // that; an advisory must not be the thing that says so.
590
+ //
591
+ // THE ADVISORY RUNS LAST, AFTER THE DIAGNOSIS IS PRINTED (CR-523). It
592
+ // used to run first, so anything wrong with the guard silenced the whole
593
+ // command: with a named pipe at the beacon, the one tool an operator
594
+ // runs on a misbehaving fleet produced zero CHECK lines. The guard is
595
+ // now safe on that path, but the ordering is what made a single defect
596
+ // in an advisory cost the entire diagnosis, and an advisory belongs
597
+ // beside a diagnosis rather than in front of it.
598
+ try {
599
+ warnIfWatcherStale(loadFleet(process.cwd()));
600
+ }
601
+ catch {
602
+ // Not a fleet home: reported by CHECK layout above.
603
+ }
604
+ return failed ? 1 : 0;
605
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The outer backstop for CR-801. Node's uncaught-exception exit code is 1,
3
+ * which is this phase's own EXIT_RED, so a throw escaping anywhere under
4
+ * `gates` used to be indistinguishable to a consumer from a gate reporting
5
+ * red. `runGates` folds its own throws; this catches everything else the
6
+ * subcommand can reach, including the schema loads that `self-check`
7
+ * performs outside the runner.
8
+ */
9
+ export declare function cmdGates(args: string[]): number;