@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.
- package/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -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 +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -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 +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -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 +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -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 +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -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 +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -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 +390 -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 +566 -0
- package/dist/src/gates/run.js +1536 -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 +42 -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 +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -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 +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -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 +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -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/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 +289 -0
- package/schemas/work-history.schema.json +183 -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,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;
|