@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,734 @@
1
+ /**
2
+ * ROLE BRIEFS: frontmatter, the shared clause include, the clause round trip,
3
+ * and the phase projection `tiphys brief compose` emits (kernel plan M3,
4
+ * M3-P5 step 6).
5
+ *
6
+ * A role brief is markdown with YAML frontmatter, which section 1.5 of the
7
+ * plan grants as a JUSTIFIED EXCEPTION to the structured-artifact rule: a
8
+ * brief's effect comes from argument, ordering and emphasis, and splitting it
9
+ * into fields produces either one giant string or a set of fragments no agent
10
+ * reads as an argument. The frontmatter carries everything enumerable and is
11
+ * schema-validated; this module is what makes the BODY checkable in the one
12
+ * respect a machine can reach.
13
+ *
14
+ * THREE MECHANISMS LIVE HERE AND THEY ARE DELIBERATELY SEPARATE.
15
+ *
16
+ * 1. Frontmatter split. A brief is decoded before it is validated, exactly
17
+ * as `tiphys validate` decodes before it validates: a malformed
18
+ * frontmatter block names the decode, a failing contract names the
19
+ * pointer, and neither produces a stack trace.
20
+ * 2. The include. `$include: <name>` on a line of its own is resolved
21
+ * against the brief's own directory. It exists because M3-P5's three
22
+ * briefs and M3-P6's two carry the SAME dispatch-contract clause text,
23
+ * and five independently editable copies of a rule drift into five
24
+ * different rules. There is one copy, `roles/_shared-dispatch-contract.md`.
25
+ * 3. The clause round trip. Every id in `clauses[]` occurs exactly once as a
26
+ * body heading anchor of the include-expanded body, and every anchor
27
+ * occurs in `clauses[]`. This is what stops a clause id being a label
28
+ * with nothing behind it, which would make the clause map a rubber stamp.
29
+ * It proves PRESENCE and never content: whether the text under a heading
30
+ * says the opposite of the row it discharges is judgment, and the phase's
31
+ * own hazard-class table records that no criterion reaches it.
32
+ *
33
+ * D-M3-27 BINDS EVERY PATH THIS MODULE TOUCHES. Composition's whole job is
34
+ * resolving and reading paths it did not create, so every one of them goes
35
+ * through `classifyEntry` and `refuseOpenForWrite` in src/task.ts. A named
36
+ * pipe at a mandated-reading path is a reported refusal naming the path and
37
+ * the observed entry type, never a blocked open. This module adds no bare
38
+ * `readFileSync` and does not patch `src/brief.ts`, which is M1-P4's and
39
+ * carries its own open instance of that class.
40
+ */
41
+ import { readdirSync } from "node:fs";
42
+ import { fileURLToPath } from "node:url";
43
+ import { dirname, join, posix as posixPath } from "node:path";
44
+ import { classifyEntry, readRegularFileIfPresent } from "./task.js";
45
+ /**
46
+ * The six roles of blueprint section 6 and of the process document's role
47
+ * table. Identical to `schemas/role-brief.schema.json`'s `role` enum and to
48
+ * `role-model-config.yaml`'s `role` values; test/roles.test.ts asserts the
49
+ * three agree rather than trusting this comment.
50
+ */
51
+ export const ROLE_IDS = [
52
+ "orchestrator",
53
+ "investigator",
54
+ "plan-writer",
55
+ "adversarial-plan-reviewer",
56
+ "implementer",
57
+ "clean-room-reviewer",
58
+ ];
59
+ /** The fence a role brief's YAML frontmatter block is delimited by. */
60
+ export const FRONTMATTER_FENCE = "---";
61
+ /** The include directive, on a line of its own. */
62
+ export const INCLUDE_PATTERN = /^\$include:[ \t]+(\S+)[ \t]*$/;
63
+ /**
64
+ * A body heading anchor: `## clause <id>` with an optional `: title`.
65
+ *
66
+ * DELIBERATELY EXPLICIT. An anchor form that guessed (say, "a heading whose
67
+ * text looks like an identifier") would classify ordinary headings such as
68
+ * `## Scope` as anchors and redden every brief that has one. The word
69
+ * `clause` is the marker, and a heading without it is prose.
70
+ */
71
+ export const CLAUSE_ANCHOR_PATTERN = /^#{1,6}[ \t]+clause[ \t]+([A-Za-z][A-Za-z0-9-]*)[ \t]*(?::[^\n]*)?$/;
72
+ /**
73
+ * Split a role brief into its frontmatter text and its body.
74
+ *
75
+ * The document must OPEN with the fence. A markdown file with a `---` rule
76
+ * somewhere in the middle is not a role brief with frontmatter, and reading
77
+ * it as one is how a horizontal rule becomes a contract.
78
+ */
79
+ export function splitFrontmatter(text, label) {
80
+ const lines = text.split("\n");
81
+ if (lines[0]?.trim() !== FRONTMATTER_FENCE) {
82
+ return {
83
+ ok: false,
84
+ reason: `${label} does not open with a ${FRONTMATTER_FENCE} frontmatter fence, so it has no role-brief frontmatter to validate`,
85
+ };
86
+ }
87
+ for (let index = 1; index < lines.length; index += 1) {
88
+ if (lines[index].trim() === FRONTMATTER_FENCE) {
89
+ return {
90
+ ok: true,
91
+ frontmatter: lines.slice(1, index).join("\n"),
92
+ body: lines.slice(index + 1).join("\n"),
93
+ };
94
+ }
95
+ }
96
+ return {
97
+ ok: false,
98
+ reason: `${label} opens a ${FRONTMATTER_FENCE} frontmatter fence that is never closed`,
99
+ };
100
+ }
101
+ /**
102
+ * Resolve every `$include:` directive in a body against `baseDirectory`.
103
+ *
104
+ * ONE LEVEL, NOT RECURSIVE, and that is a decision rather than an omission: a
105
+ * nested include is a second place the shared text could come from, and the
106
+ * whole point of the shared block is that there is exactly one. An include
107
+ * inside an included file is refused by name.
108
+ */
109
+ export function expandIncludes(body, baseDirectory, label) {
110
+ const included = [];
111
+ const out = [];
112
+ for (const line of body.split("\n")) {
113
+ const match = INCLUDE_PATTERN.exec(line);
114
+ if (match === null) {
115
+ out.push(line);
116
+ continue;
117
+ }
118
+ const name = match[1];
119
+ const path = join(baseDirectory, name);
120
+ const read = readRegularFileIfPresent(path);
121
+ if (read.kind === "absent") {
122
+ return {
123
+ ok: false,
124
+ reason: `${label} includes ${name}, and ${path} does not exist`,
125
+ };
126
+ }
127
+ if (read.kind === "refused") {
128
+ return { ok: false, reason: `${label} includes ${name}: ${read.reason}` };
129
+ }
130
+ if (read.body.split("\n").some((candidate) => INCLUDE_PATTERN.test(candidate))) {
131
+ return {
132
+ ok: false,
133
+ reason: `${label} includes ${name}, which itself carries an include directive; includes are one level deep so the shared text has exactly one source`,
134
+ };
135
+ }
136
+ included.push(name);
137
+ out.push(read.body.replace(/\n$/, ""));
138
+ }
139
+ return { ok: true, text: out.join("\n"), included };
140
+ }
141
+ /** Every clause anchor in a body, in the order they occur, duplicates kept. */
142
+ export function clauseAnchors(body) {
143
+ const found = [];
144
+ for (const line of body.split("\n")) {
145
+ const match = CLAUSE_ANCHOR_PATTERN.exec(line);
146
+ if (match !== null) {
147
+ found.push(match[1]);
148
+ }
149
+ }
150
+ return found;
151
+ }
152
+ /**
153
+ * The round trip, in both directions, as diagnostics in the same
154
+ * `INVALID <pointer> <message>` contract the schema validator emits.
155
+ *
156
+ * A frontmatter clause with no anchor is ORPHANED: the id is declared, the
157
+ * clause map resolves it because the id occurs somewhere in the file, and
158
+ * there is no text behind it. A stray anchor is the mirror failure: text
159
+ * exists under a clause id nothing declared, so nothing tracks it.
160
+ */
161
+ export function clauseRoundTripDiagnostics(clauses, body) {
162
+ const anchors = clauseAnchors(body);
163
+ const counts = new Map();
164
+ for (const anchor of anchors) {
165
+ counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
166
+ }
167
+ const diagnostics = [];
168
+ const declared = new Set();
169
+ for (let index = 0; index < clauses.length; index += 1) {
170
+ const clause = clauses[index];
171
+ declared.add(clause);
172
+ const count = counts.get(clause) ?? 0;
173
+ if (count === 0) {
174
+ diagnostics.push({
175
+ pointer: `#/clauses/${String(index)}`,
176
+ message: `clause id ${clause} is declared in frontmatter and has no body heading anchor, so the clause is orphaned`,
177
+ });
178
+ continue;
179
+ }
180
+ if (count > 1) {
181
+ diagnostics.push({
182
+ pointer: `#/clauses/${String(index)}`,
183
+ message: `clause id ${clause} has ${String(count)} body heading anchors and must have exactly one`,
184
+ });
185
+ }
186
+ }
187
+ for (const anchor of [...counts.keys()].sort()) {
188
+ if (!declared.has(anchor)) {
189
+ diagnostics.push({
190
+ pointer: "#/clauses",
191
+ message: `body heading anchor ${anchor} is not declared in frontmatter`,
192
+ });
193
+ }
194
+ }
195
+ return diagnostics;
196
+ }
197
+ /**
198
+ * THE OUTPUT CONTRACT CHECK (M3-P5 fix round 1, clean-room finding 1).
199
+ *
200
+ * Every artifact type a brief declares in `outputs[]` has a schema document
201
+ * that governs it, and the brief must put that document on its
202
+ * `mandated-reading` list. A brief that does not is a brief whose agent is
203
+ * never told where the shape of its own deliverable is written.
204
+ *
205
+ * THE MECHANISM, NOT THE INSTANCE. The instance found in review was
206
+ * `roles/investigator.md` declaring `outputs: [report]` while its reading list
207
+ * carried `schemas/finding.schema.json`, the contract of a DIFFERENT role's
208
+ * artifact. Repairing that one list leaves the method that produced it, and
209
+ * three correct lists that can drift are worth less than one check that cannot
210
+ * be forgotten. M3-P6 ships two more briefs; this runs on them the day they
211
+ * land, with nobody having to remember it.
212
+ *
213
+ * WHY A CHECK AND NOT A DERIVATION, which was the alternative considered.
214
+ * `resolveMandatedReading` could have INJECTED `schemas/<type>.schema.json`
215
+ * into the list from `outputs[]`, making the omission impossible rather than
216
+ * merely refused. Rejected, for three reasons that are properties of the
217
+ * artifact rather than preferences. (1) It would split the truth in two: the
218
+ * brief file on disk would say one thing and the composed brief another, so a
219
+ * reader of `roles/investigator.md` could no longer see what its agent reads,
220
+ * and `tiphys validate --type role-brief` (which reads the file) and
221
+ * `tiphys brief compose` (which would read the file plus an injection) would
222
+ * hold two different opinions about one property. (2) The list is ORDERED and
223
+ * the order is authored; an injected entry has no authored position, and
224
+ * criterion 3 asserts the composed output's ordering. (3) Injection makes the
225
+ * defect invisible instead of absent: the wrong entry that pointed the
226
+ * investigator at the wrong document would still be sitting on the list,
227
+ * silently, with the right one bolted on beside it. The check makes the author
228
+ * fix the file, which is the artifact a consumer of the kernel reads.
229
+ *
230
+ * WHAT IT DOES NOT REACH, stated at the definition site. An `outputs` entry
231
+ * naming a type NO schema is registered for is SKIPPED rather than refused:
232
+ * there is no document to mandate, and schemas/role-brief.schema.json declares
233
+ * that residue deliberately (an enum there would serialise M3-P6, M3-P7 and
234
+ * M3-P8 against one file). It reaches the FRONTMATTER only: a brief whose body
235
+ * prose describes the wrong output shape passes this, and that is the judgment
236
+ * case the plan's hazard table hands to M3-P7's `clause-text-matches-row`
237
+ * probe. And it is one sub-case of the hazard row at
238
+ * delivery/plan/kernel-plan-m3.md:3021 ("nothing can compute which document a
239
+ * role NEEDS"); the output contract is the part of that which IS computable,
240
+ * and the rest of the row stands.
241
+ */
242
+ /**
243
+ * THE ONE CANONICAL FORM OF A MANDATED-READING ENTRY (fix round 2, D-3).
244
+ *
245
+ * Entries are kernel-root-relative by definition
246
+ * (schemas/role-brief.schema.json:60 says resolution is "against the kernel
247
+ * root"), so `schemas/report.schema.json`, `./schemas/report.schema.json` and
248
+ * `schemas/../schemas/report.schema.json` are three spellings of one document.
249
+ *
250
+ * IT EXISTS BECAUSE TWO COMMANDS WERE COMPARING THE SAME ENTRY DIFFERENTLY.
251
+ * `outputContractDiagnostics` tested raw string membership while
252
+ * `resolveMandatedReading` resolved with `join`, which normalises; so a brief
253
+ * writing `./schemas/report.schema.json` COMPOSED cleanly and was REFUSED by
254
+ * `tiphys validate --type role-brief`. That divergence is fail-safe (the
255
+ * refusal is the strict side) and it is still the shape this round's own
256
+ * argument against injecting the entry rejected: two commands holding two
257
+ * opinions about one property. Both now ask this function.
258
+ *
259
+ * A LEADING `/` IS STRIPPED rather than treated as an absolute path, because
260
+ * `join(root, "/schemas/x")` already resolves to `<root>/schemas/x`: stripping
261
+ * makes the comparison agree with the resolution that was always happening,
262
+ * instead of introducing a form the two commands read differently. This
263
+ * function changes what is COMPARED; it changes nothing about what is OPENED.
264
+ */
265
+ export function canonicalReadingEntry(entry) {
266
+ return posixPath.normalize(entry).replace(/^\/+/, "");
267
+ }
268
+ export function outputContractDiagnostics(outputs, reading, schemaFileForType) {
269
+ const diagnostics = [];
270
+ const declared = new Set(reading.map(canonicalReadingEntry));
271
+ for (let index = 0; index < outputs.length; index += 1) {
272
+ const output = outputs[index];
273
+ const file = schemaFileForType(output);
274
+ if (file === undefined) {
275
+ continue;
276
+ }
277
+ const wanted = `schemas/${file}`;
278
+ if (!declared.has(wanted)) {
279
+ diagnostics.push({
280
+ pointer: `#/outputs/${String(index)}`,
281
+ message: `output type ${output} is governed by ${wanted}, which is not on mandated-reading, so this brief never tells its agent where the contract for its own output is written`,
282
+ });
283
+ }
284
+ }
285
+ return diagnostics;
286
+ }
287
+ /**
288
+ * Locate the installed kernel root by walking UP from this module and testing
289
+ * for a `roles/` directory holding at least one brief.
290
+ *
291
+ * Counting `..` would be right in exactly one of the two layouts this code
292
+ * runs in (`src/` from source, `dist/src/` from the built entry), which is
293
+ * the layout-dependent break `schemasDirectory` in src/commands/validate.ts
294
+ * already documents. Walking up and TESTING is right in both, and in a
295
+ * relocated copy as well.
296
+ */
297
+ export function kernelRoot() {
298
+ let directory = dirname(fileURLToPath(import.meta.url));
299
+ for (let depth = 0; depth < 8; depth += 1) {
300
+ try {
301
+ const entries = readdirSync(join(directory, "roles"));
302
+ if (entries.some((name) => name.endsWith(".md"))) {
303
+ return directory;
304
+ }
305
+ }
306
+ catch {
307
+ /* not here; keep walking */
308
+ }
309
+ const parent = dirname(directory);
310
+ if (parent === directory) {
311
+ break;
312
+ }
313
+ directory = parent;
314
+ }
315
+ throw new Error("the shipped roles/ directory was not found above this module; the installation is incomplete");
316
+ }
317
+ /**
318
+ * Resolve every mandated-reading path against the kernel root, IN ORDER, and
319
+ * stop at the first one that is not a regular file.
320
+ *
321
+ * THE PATH IS NEVER OPENED. Existence and type are established by
322
+ * `classifyEntry`, so a named pipe at a mandated-reading path is refused with
323
+ * its observed type in bounded time instead of blocking this command forever.
324
+ * A MISSING path and a NON-REGULAR path are different states with different
325
+ * failure modes and they are reported differently on purpose (criteria 2 and
326
+ * 6c, which the plan is explicit are not the same criterion).
327
+ */
328
+ export function resolveMandatedReading(reading, root) {
329
+ const resolved = [];
330
+ for (const declared of reading) {
331
+ /* THE SAME CANONICAL FORM THE OUTPUT-CONTRACT CHECK COMPARES (D-3). It is
332
+ a no-op for resolution, because `join` already normalises and already
333
+ treats a leading `/` as root-relative; asking the shared function here
334
+ is what makes "one opinion, two commands" a property of the code rather
335
+ than a claim in a comment. The AUTHORED string is what is reported and
336
+ what `brief compose` renders, so the brief on disk stays readable as
337
+ written. */
338
+ const path = join(root, canonicalReadingEntry(declared));
339
+ const entry = classifyEntry(path);
340
+ if (entry.kind === "absent" || entry.kind === "dangling") {
341
+ return {
342
+ ok: false,
343
+ reason: `mandated-reading path ${declared} does not exist (looked for ${path})`,
344
+ };
345
+ }
346
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
347
+ return {
348
+ ok: false,
349
+ reason: `mandated-reading path ${declared}: ${entry.reason}`,
350
+ };
351
+ }
352
+ resolved.push(declared);
353
+ }
354
+ return { ok: true, paths: resolved };
355
+ }
356
+ /* ------------------------------------------------------------------ */
357
+ /* M3-P6: the six required sections of the implementer brief */
358
+ /* ------------------------------------------------------------------ */
359
+ /**
360
+ * A body SECTION anchor: `## section <id>` with an optional `: title`.
361
+ *
362
+ * A SECOND MARKER RATHER THAN A REUSE OF THE CLAUSE ONE, and the separation is
363
+ * the point. A clause discharges a requirement row and round-trips against
364
+ * `clauses[]`; a section is a structural part of the brief that R-033a
365
+ * enumerates, and it has no frontmatter list to round-trip against. Marking
366
+ * both with `clause` would mean either declaring six section ids in `clauses[]`
367
+ * (where the clause map would then try to resolve them as rows) or exempting
368
+ * six anchors from the round trip, which is a hole in the check that exists to
369
+ * stop labels with nothing behind them.
370
+ */
371
+ export const SECTION_ANCHOR_PATTERN = /^#{1,6}[ \t]+section[ \t]+([A-Za-z][A-Za-z0-9-]*)[ \t]*(?::[^\n]*)?$/;
372
+ /**
373
+ * The six sections R-033a enumerates, IN THE ORDER THE ROW GIVES THEM.
374
+ *
375
+ * HAND-WRITTEN, and there is nowhere to derive it from: R-033a is a row of a
376
+ * markdown table in a plan document, and parsing a requirement row's prose to
377
+ * recover six section names would be the "deciding what another program will
378
+ * do by pattern-matching the text of a file" mechanism, applied to a file that
379
+ * is not even machine-readable. The list is short, closed, and named in the
380
+ * plan; a phase that changes it changes this line and the criterion that
381
+ * witnesses it.
382
+ */
383
+ export const R033A_SECTIONS = [
384
+ "mandated-reading",
385
+ "phase-scope",
386
+ "push-protocol",
387
+ "gate-list",
388
+ "environment-warnings",
389
+ "reporting-contract",
390
+ ];
391
+ /** Every section anchor in a body, in order, duplicates kept. */
392
+ export function sectionAnchors(body) {
393
+ const found = [];
394
+ for (const line of body.split("\n")) {
395
+ const match = SECTION_ANCHOR_PATTERN.exec(line);
396
+ if (match !== null) {
397
+ found.push(match[1]);
398
+ }
399
+ }
400
+ return found;
401
+ }
402
+ /**
403
+ * The text under one section anchor: everything from the line after the anchor
404
+ * up to the next heading of any level, or the end of the body.
405
+ */
406
+ export function sectionBody(body, section) {
407
+ const lines = body.split("\n");
408
+ for (let index = 0; index < lines.length; index += 1) {
409
+ const match = SECTION_ANCHOR_PATTERN.exec(lines[index]);
410
+ if (match === null || match[1] !== section) {
411
+ continue;
412
+ }
413
+ const rest = lines.slice(index + 1);
414
+ const end = rest.findIndex((line) => /^#{1,6}[ \t]/.test(line));
415
+ return (end === -1 ? rest : rest.slice(0, end)).join("\n");
416
+ }
417
+ return undefined;
418
+ }
419
+ /**
420
+ * Why an include-expanded implementer body does not satisfy R-033a, or the
421
+ * empty list when it does.
422
+ *
423
+ * NON-EMPTY IS CHECKED, NOT ONLY PRESENT, and criterion 2 says so in as many
424
+ * words. A section reduced to its heading is the dangerous state here rather
425
+ * than a deleted one: the brief still has six anchors, still composes, still
426
+ * looks complete, and instructs nobody. A check that only counted anchors
427
+ * would be green against exactly that.
428
+ */
429
+ export function missingRequiredSections(body) {
430
+ const problems = [];
431
+ const seen = sectionAnchors(body);
432
+ for (const section of R033A_SECTIONS) {
433
+ if (!seen.includes(section)) {
434
+ problems.push(`required section ${section} is missing from the brief body, and R-033a requires all six of ${R033A_SECTIONS.join(", ")}`);
435
+ continue;
436
+ }
437
+ if ((sectionBody(body, section) ?? "").trim() === "") {
438
+ problems.push(`required section ${section} is present and empty, so the brief carries the heading and none of the instruction`);
439
+ }
440
+ }
441
+ return problems;
442
+ }
443
+ /* ------------------------------------------------------------------ */
444
+ /* M3-P6: the generated gate-list block */
445
+ /* ------------------------------------------------------------------ */
446
+ /**
447
+ * The markers delimiting the generated gate list inside a brief.
448
+ *
449
+ * THE MODE IS IN THE BEGIN MARKER, which is where the brief DECLARES which
450
+ * mode's gate set it carries. It cannot go in the frontmatter: the frontmatter
451
+ * schema is closed (`additionalProperties: false`) and belongs to M3-P5, and a
452
+ * phase that needed a new frontmatter key would be editing another phase's
453
+ * merged contract. The marker is body text, it is visible to a reader of the
454
+ * brief, and `scripts/check-brief-drift.mjs` reads the mode back out of it, so
455
+ * the declaration and the rendering cannot disagree about which mode was meant.
456
+ *
457
+ * HTML comments, so they are invisible in rendered markdown and unambiguous to
458
+ * a line scanner, and they name the producing script so the next person to edit
459
+ * the block by hand is told what to edit instead. The same shape M3-P2 used for
460
+ * CLAUDE.md, deliberately: two drift checks that look different are two things
461
+ * to learn.
462
+ */
463
+ export function briefGateBlockBeginMarker(mode) {
464
+ return (`<!-- BEGIN GENERATED GATE LIST (mode: ${mode}): rendered from gate-registry.yaml ` +
465
+ "by scripts/check-brief-drift.mjs. Do not edit by hand; edit the registry. -->");
466
+ }
467
+ export const BRIEF_GATE_BLOCK_END_MARKER = "<!-- END GENERATED GATE LIST -->";
468
+ /**
469
+ * THE MODE THE SHIPPED BRIEF'S GATE BLOCK MUST DECLARE, pinned HERE and not in
470
+ * the brief (M3-P6 fix round 1, CV-1).
471
+ *
472
+ * The mechanism this closes, stated as a mechanism rather than as the instance
473
+ * that exposed it: A CHECK WHOSE SUBJECT IS SELECTED BY A VALUE READ FROM THE
474
+ * ARTIFACT IT AUDITS CAN BE SILENTLY NARROWED BY EDITING THAT ARTIFACT. The
475
+ * mode above is read out of the brief's own begin marker, deliberately, so that
476
+ * no CALLER can point the comparison at a mode the brief never claimed. That
477
+ * left the EDITOR of the brief holding the same power: switching the marker to
478
+ * a narrower mode and re-rendering produces a brief advertising five gates
479
+ * instead of fifteen with the drift check green, which is an instruction-surface
480
+ * defect every future implementer reads.
481
+ *
482
+ * Two clean-room contracts reached this from different directions on the same
483
+ * head, one by forcing the narrowing and one by deriving it from the unit
484
+ * arithmetic below, and neither was pointed at it.
485
+ *
486
+ * WHY IT IS A CONSTANT HERE AND NOT A REGISTRY KEY. `gate-registry.yaml` is
487
+ * closed (`additionalProperties: false`) and its schema belongs to M3-P2, so a
488
+ * registry key would be this phase editing another phase's merged contract, the
489
+ * same reasoning that put the mode in the marker rather than in the frontmatter.
490
+ * WHY `full` IS THE RIGHT VALUE is not asserted here as a bare literal: the
491
+ * registered test derives from the registry that this mode selects every gate
492
+ * any mode selects, so narrowing is the only direction the value can move.
493
+ */
494
+ export const BRIEF_GATE_BLOCK_MODE = "full";
495
+ /** The begin marker's shape, with the mode captured. */
496
+ const BEGIN_MARKER_PATTERN = /<!-- BEGIN GENERATED GATE LIST \(mode: ([a-z][a-z0-9-]*)\): rendered from gate-registry\.yaml by scripts\/check-brief-drift\.mjs\. Do not edit by hand; edit the registry\. -->/;
497
+ /**
498
+ * Locate the generated block in a brief, or say why it cannot be located.
499
+ *
500
+ * A MISSING MARKER IS A REFUSAL AND NEVER A SILENT "NO DRIFT". A check that
501
+ * reports clean because it could not find the thing it compares is the
502
+ * guard-condition failure this repository has recorded twice: the watchdog that
503
+ * tested existence instead of freshness, and the byte check that could not see
504
+ * the one byte it existed to catch.
505
+ */
506
+ export function locateGateBlock(text, path) {
507
+ const match = BEGIN_MARKER_PATTERN.exec(text);
508
+ if (match === null) {
509
+ return {
510
+ ok: false,
511
+ reason: `${path} carries no generated gate-list begin marker naming a mode`,
512
+ };
513
+ }
514
+ const mode = match[1];
515
+ const begin = match.index;
516
+ if (BEGIN_MARKER_PATTERN.exec(text.slice(begin + 1)) !== null) {
517
+ return { ok: false, reason: `${path} carries more than one gate-list begin marker` };
518
+ }
519
+ const end = text.indexOf(BRIEF_GATE_BLOCK_END_MARKER, begin);
520
+ if (end === -1) {
521
+ return {
522
+ ok: false,
523
+ reason: `${path} carries a gate-list begin marker with no matching ${BRIEF_GATE_BLOCK_END_MARKER}`,
524
+ };
525
+ }
526
+ return {
527
+ ok: true,
528
+ mode,
529
+ block: text.slice(begin, end + BRIEF_GATE_BLOCK_END_MARKER.length),
530
+ begin,
531
+ end,
532
+ };
533
+ }
534
+ /**
535
+ * Render the brief's gate-list block from the registry ALONE, for one mode.
536
+ *
537
+ * IT DERIVES, IT DOES NOT READ THE BLOCK. The hazard the plan names for this
538
+ * criterion by name is "a generated gate-list block whose drift check compares
539
+ * the block TO ITSELF rather than to the registry", and that check is green
540
+ * forever. This function takes the decoded registry and a mode string, and
541
+ * nothing else; the brief file is opened only to compare against or write into.
542
+ *
543
+ * ONE RENDERER, TWO CALLERS. `scripts/check-brief-drift.mjs` calls it to
544
+ * compare and to write, and the registered test calls it to assert the composed
545
+ * brief is byte-identical to the registry's rendering. A second copy of this
546
+ * table in the test would be the test asserting agreement with itself.
547
+ */
548
+ export function renderBriefGateBlock(registry, mode) {
549
+ const selected = registry.gates.filter((gate) => (gate.modes ?? []).includes(mode));
550
+ const lines = [];
551
+ lines.push(briefGateBlockBeginMarker(mode));
552
+ lines.push("");
553
+ lines.push("Every change must pass these, in order:");
554
+ lines.push("");
555
+ let step = 0;
556
+ for (const entry of registry.preflight) {
557
+ step += 1;
558
+ lines.push(`${String(step)}. \`${entry.command.join(" ")}\` (${entry.note})`);
559
+ }
560
+ lines.push("");
561
+ lines.push(`Then the gates \`${mode}\` mode selects, run by ` +
562
+ `\`tiphys gates run --registry gate-registry.yaml --mode ${mode}\`:`);
563
+ lines.push("");
564
+ lines.push("| Gate | Verified by | Applicability | One unit is |");
565
+ lines.push("|---|---|---|---|");
566
+ for (const gate of selected) {
567
+ lines.push(`| \`${gate.id}\` | ${gate["verified-by"]}` +
568
+ `${gate.probe === undefined ? "" : ` (probe \`${gate.probe}\`)`}` +
569
+ ` | ${gate.applicability} | ${gate.unitLabel} |`);
570
+ }
571
+ lines.push("");
572
+ lines.push(BRIEF_GATE_BLOCK_END_MARKER);
573
+ return { text: lines.join("\n"), units: selected.length };
574
+ }
575
+ /* ------------------------------------------------------------------ */
576
+ /* M3-P6: the clean-room reviewer's two review contracts (T-007) */
577
+ /* ------------------------------------------------------------------ */
578
+ /**
579
+ * The two review contracts of `assurance-modes.yaml`'s `review-contracts`.
580
+ *
581
+ * TWO CONTRACTS, NOT TWO REVIEWERS, and the two are different axes that full
582
+ * mode requires both of. The measured evidence is that both reviews of one
583
+ * phase walked all fifteen acceptance criteria and agreed on every mechanical
584
+ * fact, and the one briefed on hazards found a high-severity defect the other's
585
+ * report does not name.
586
+ */
587
+ export const REVIEW_CONTRACTS = ["criteria", "hazard"];
588
+ /** The clause id carrying one contract's instructions. */
589
+ export function reviewContractClause(contract) {
590
+ return `review-contract-${contract}`;
591
+ }
592
+ /**
593
+ * The role whose brief carries a contract per value. Named rather than assumed,
594
+ * because `--review-contract` on any other role is a usage error and the check
595
+ * that says so needs something to compare against.
596
+ */
597
+ export const REVIEW_CONTRACT_ROLE = "clean-room-reviewer";
598
+ /**
599
+ * Keep the selected contract's clause block and DROP the others.
600
+ *
601
+ * The composed brief is what a dispatched reviewer reads, and a brief carrying
602
+ * both contracts has told the reviewer to start from the criteria and not to
603
+ * start from the criteria. Dropping happens at COMPOSE time and never in the
604
+ * file: the file declares both clause ids and carries both blocks, so the
605
+ * clause round trip still sees a complete brief and `tiphys validate` still
606
+ * checks both texts. A design that split the two into two files would have put
607
+ * the shared four-fifths of the brief in two places.
608
+ */
609
+ export function selectReviewContract(body, contract) {
610
+ const keep = reviewContractClause(contract);
611
+ const anchors = clauseAnchors(body);
612
+ if (!anchors.includes(keep)) {
613
+ return {
614
+ ok: false,
615
+ reason: `review contract ${contract} selects clause ${keep}, which the brief body does not carry`,
616
+ };
617
+ }
618
+ const drop = new Set(REVIEW_CONTRACTS.filter((other) => other !== contract).map(reviewContractClause));
619
+ const lines = body.split("\n");
620
+ const out = [];
621
+ let dropping = false;
622
+ for (const line of lines) {
623
+ const match = CLAUSE_ANCHOR_PATTERN.exec(line);
624
+ if (match !== null) {
625
+ dropping = drop.has(match[1]);
626
+ }
627
+ else if (dropping && /^#{1,6}[ \t]/.test(line)) {
628
+ dropping = false;
629
+ }
630
+ if (!dropping) {
631
+ out.push(line);
632
+ }
633
+ }
634
+ return { ok: true, text: out.join("\n") };
635
+ }
636
+ /* ------------------------------------------------------------------ */
637
+ /* The phase projection */
638
+ /* ------------------------------------------------------------------ */
639
+ /**
640
+ * THE FIELDS OF A PLAN PHASE THAT `brief compose` RENDERS, in order.
641
+ *
642
+ * HAND-WRITTEN ON PURPOSE, AND THE TEST THAT GUARDS IT IS NOT. Criterion 3b
643
+ * requires the rendered phase text to be a COMPLETE projection of the phase
644
+ * object, and requires the assertion to be driven FROM
645
+ * `schemas/plan.schema.json`'s phase `required` array rather than from a
646
+ * hand-written list. If this list were also derived from the schema the two
647
+ * would move together and the test could never redden, which is the shape of
648
+ * a guard whose condition does not test the property that matters.
649
+ *
650
+ * So: the SCHEMA is the test's source and this list is the renderer's, they
651
+ * are independent, and a later phase adding a required phase field reddens
652
+ * `brief compose renders every required field of the plan schema's phase` until
653
+ * this list is extended. The dangerous state the criterion names is the
654
+ * realistic one, a renderer that silently drops a field while the composed
655
+ * output still contains a mandated-reading list, a body and SOME phase text,
656
+ * and deleting an entry from this list is exactly that state.
657
+ */
658
+ export const PHASE_FIELD_ORDER = [
659
+ "id",
660
+ "branch",
661
+ "intent",
662
+ "grounding",
663
+ "severity",
664
+ "verified-root-cause",
665
+ "steps",
666
+ "files-to-touch",
667
+ "extras",
668
+ "acceptance",
669
+ "hazard-classes",
670
+ "migrations",
671
+ "conflicts-with",
672
+ "parallelizable",
673
+ "citations",
674
+ "fill-in",
675
+ ];
676
+ function renderScalar(value) {
677
+ if (typeof value === "string") {
678
+ return value.replace(/\n+$/, "");
679
+ }
680
+ return JSON.stringify(value) ?? String(value);
681
+ }
682
+ /** Render one field value as markdown lines. */
683
+ export function renderFieldValue(value, indent) {
684
+ if (Array.isArray(value)) {
685
+ if (value.length === 0) {
686
+ return [`${indent}(none)`];
687
+ }
688
+ const lines = [];
689
+ for (const element of value) {
690
+ if (element !== null && typeof element === "object" && !Array.isArray(element)) {
691
+ const entries = Object.entries(element);
692
+ const [firstKey, firstValue] = entries[0];
693
+ lines.push(`${indent}- ${firstKey}: ${renderScalar(firstValue)}`);
694
+ for (const [key, nested] of entries.slice(1)) {
695
+ if (Array.isArray(nested) || (nested !== null && typeof nested === "object")) {
696
+ lines.push(`${indent} ${key}:`);
697
+ lines.push(...renderFieldValue(nested, `${indent} `));
698
+ continue;
699
+ }
700
+ lines.push(`${indent} ${key}: ${renderScalar(nested)}`);
701
+ }
702
+ continue;
703
+ }
704
+ lines.push(`${indent}- ${renderScalar(element)}`);
705
+ }
706
+ return lines;
707
+ }
708
+ if (value !== null && typeof value === "object") {
709
+ const lines = [];
710
+ for (const [key, nested] of Object.entries(value)) {
711
+ if (Array.isArray(nested) || (nested !== null && typeof nested === "object")) {
712
+ lines.push(`${indent}${key}:`);
713
+ lines.push(...renderFieldValue(nested, `${indent} `));
714
+ continue;
715
+ }
716
+ lines.push(`${indent}${key}: ${renderScalar(nested)}`);
717
+ }
718
+ return lines.length === 0 ? [`${indent}(none)`] : lines;
719
+ }
720
+ return [`${indent}${renderScalar(value)}`];
721
+ }
722
+ /** Render one plan phase as the brief's phase section. */
723
+ export function renderPhase(phase) {
724
+ const lines = [`# Phase ${String(phase["id"] ?? "(unnamed)")}`, ""];
725
+ for (const field of PHASE_FIELD_ORDER) {
726
+ if (!(field in phase)) {
727
+ continue;
728
+ }
729
+ lines.push(`### ${field}`);
730
+ lines.push(...renderFieldValue(phase[field], ""));
731
+ lines.push("");
732
+ }
733
+ return lines;
734
+ }