@tiphys/kernel 0.0.0 → 0.2.0

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