@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,258 @@
1
+ /**
2
+ * READING THE SHIPPED ASSURANCE MODE DEFINITIONS (kernel plan M3, M3-P3
3
+ * step 5).
4
+ *
5
+ * `assurance-modes.yaml` ships at the package root, beside `gate-registry.yaml`
6
+ * and the `schemas/` directory. This module locates it, decodes it, and
7
+ * answers one question: what does a declared mode require. Nothing here
8
+ * RESOLVES a mode into behaviour and nothing here enforces one. M3 never
9
+ * executes `direct-pr` or `local-only`, and building an enforcement engine for
10
+ * a mode this milestone never enters is the M1-P3 failure the plan is trying
11
+ * not to repeat.
12
+ *
13
+ * NO VALIDATION HAPPENS HERE, deliberately. `tiphys validate --type
14
+ * assurance-modes` is the command that says whether the document is
15
+ * well-formed, and duplicating its rules in a reader would produce a second
16
+ * opinion to keep in sync. This module reads what is there and reports what it
17
+ * cannot find.
18
+ */
19
+ import { readdirSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join } from "node:path";
22
+ import { decodeDocument, readOperatorPath } from "./validate.js";
23
+ /** The shipped document's basename, at the package root. */
24
+ export const MODES_FILENAME = "assurance-modes.yaml";
25
+ /**
26
+ * Locate the package root by walking UP from this module and testing for the
27
+ * shipped document.
28
+ *
29
+ * The depth differs between the two layouts this code runs in: from source it
30
+ * is `src/` and the root is one level up, and from the built entry it is
31
+ * `dist/src/` and the root is two levels up. Counting `..` would be right in
32
+ * exactly one of them, which is the layout-dependent break
33
+ * `schemasDirectory()` already documents. Walking up and TESTING is right in
34
+ * both, and in a relocated copy as well.
35
+ */
36
+ export function packageRoot() {
37
+ let directory = dirname(fileURLToPath(import.meta.url));
38
+ for (let depth = 0; depth < 8; depth += 1) {
39
+ try {
40
+ if (readdirSync(directory).includes(MODES_FILENAME)) {
41
+ return directory;
42
+ }
43
+ }
44
+ catch {
45
+ /* not readable here; keep walking */
46
+ }
47
+ const parent = dirname(directory);
48
+ if (parent === directory) {
49
+ break;
50
+ }
51
+ directory = parent;
52
+ }
53
+ throw new Error(`the shipped ${MODES_FILENAME} was not found above this module; the installation is incomplete`);
54
+ }
55
+ function asRecord(value) {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value)
57
+ ? value
58
+ : undefined;
59
+ }
60
+ function strings(record, key) {
61
+ const value = record[key];
62
+ return Array.isArray(value)
63
+ ? value.filter((entry) => typeof entry === "string")
64
+ : [];
65
+ }
66
+ function optionalStrings(record, key) {
67
+ return record[key] === undefined ? undefined : strings(record, key);
68
+ }
69
+ /** Read and decode the shipped document, or say why it could not be read. */
70
+ export function readModes(path = join(packageRoot(), MODES_FILENAME)) {
71
+ const read = readOperatorPath(path);
72
+ if (!read.ok) {
73
+ return { ok: false, reason: read.reason };
74
+ }
75
+ const decoded = decodeDocument(read.body, path);
76
+ if (!decoded.ok) {
77
+ return { ok: false, reason: decoded.reason };
78
+ }
79
+ const document = asRecord(decoded.value);
80
+ if (document === undefined) {
81
+ return { ok: false, reason: `${path} does not decode to a mapping` };
82
+ }
83
+ const raw = document["modes"];
84
+ if (!Array.isArray(raw)) {
85
+ return { ok: false, reason: `${path} declares no modes list` };
86
+ }
87
+ const modes = [];
88
+ for (const entry of raw) {
89
+ const record = asRecord(entry);
90
+ if (record === undefined) {
91
+ continue;
92
+ }
93
+ modes.push({
94
+ id: String(record["id"] ?? ""),
95
+ declaredBy: String(record["declared-by"] ?? ""),
96
+ pipeline: strings(record, "pipeline"),
97
+ skips: strings(record, "skips"),
98
+ gateSets: strings(record, "gate-sets"),
99
+ mergeAuthority: String(record["merge-authority"] ?? ""),
100
+ grantedBy: typeof record["granted-by"] === "string" ? record["granted-by"] : undefined,
101
+ conditions: optionalStrings(record, "conditions"),
102
+ reviewContracts: optionalStrings(record, "review-contracts"),
103
+ escalationBounds: asRecord(record["escalation-bounds"]),
104
+ });
105
+ }
106
+ return { ok: true, path, raw: decoded.value, modes };
107
+ }
108
+ /**
109
+ * The id blueprint section 8 gives the un-downgraded process BY NAME: "The
110
+ * current proven process is the definition of `full`."
111
+ *
112
+ * SIBLING LITERAL, named so neither site can drift unnoticed:
113
+ * `REFERENCE_MODE_ID` at src/checks.ts:275 is the same string, used as the
114
+ * reference pipeline every other mode's omissions are measured against. The two
115
+ * are pinned from opposite directions by registered tests: the checks-side
116
+ * literal by the assertion that deleting `full` produces `no mode declares id
117
+ * full` (test/assurance-modes.test.ts:314), and this one by the assertion that
118
+ * the mode `mode show` annotates as un-downgraded is the mode named `full`.
119
+ */
120
+ const UNDOWNGRADED_MODE_ID = "full";
121
+ /**
122
+ * The execution status of one mode, DERIVED rather than looked up in a list of
123
+ * ids (CR-004 item 2, DR-0020).
124
+ *
125
+ * Two facts are available and both are checkable by the reader: whether this is
126
+ * the kernel's own document, and whether this mode IS the one blueprint section
127
+ * 8 names as the un-downgraded process, "The current proven process is the
128
+ * definition of `full`. Downgrades are declared, never improvised."
129
+ *
130
+ * WHY THE NAME AND NOT THE SKIP COUNT (CR-002, round 9). This function used
131
+ * `mode.skips.length === 0` as its proxy for "this is the un-downgraded mode".
132
+ * The proxy held only because `full` happened to be the only mode with an empty
133
+ * list, nothing enforced that, and `skips[]` is shipped DATA. Two measured
134
+ * consequences, both at exit 0 with every registry gate green: giving `full` one
135
+ * bogus `skips[]` entry made this function say that no phase of the tiphys
136
+ * project had ever been delivered under `full`, which is false about the mode
137
+ * this project has delivered every phase under; and giving `direct-pr` an empty
138
+ * `skips[]` made a mode nobody has ever entered claim to be the one the project
139
+ * follows, with `merge-authority: owner` printed beneath it, which is not the
140
+ * regime in force (DR-0015). Blueprint section 8 defines `full` BY NAME, so the
141
+ * name is the primary fact and the skip count is a consequence of it.
142
+ *
143
+ * THE COUNT IS STILL REPORTED, as a fact about the mode rather than as the
144
+ * ground of the claim. That distinction is the whole finding: a number may be
145
+ * shown without being believed.
146
+ *
147
+ * WHAT MAKES THE `full` SENTENCE TRUE IS DATA, AND IT IS GUARDED IN TWO PLACES
148
+ * THAT COVER DIFFERENT DOCUMENTS. Keying off the name moves the burden: the
149
+ * claim is only as good as the `full` in the document being served genuinely
150
+ * being un-downgraded.
151
+ *
152
+ * The VALIDATOR carries it for EVERY document (round 10, CRB9-02).
153
+ * `mode-no-undeclared-downgrade` rejects a `skips[]` entry that the same mode's
154
+ * pipeline runs, and also one that the reference does not run; on the reference
155
+ * those two exhaust the cases, so a `full` declaring any skipped stage at all is
156
+ * refused and this sentence is never printed over it. Until that second part
157
+ * existed, a `full` whose stage had MOVED from `pipeline` into `skips` was an
158
+ * HONEST downgrade that validated at exit 0, and `tiphys mode show --mode full`
159
+ * printed this sentence about fifteen lines above a `skips: deploy-verify` row.
160
+ *
161
+ * A REGISTERED TEST carries it for THIS repository's own document, asserting the
162
+ * shipped `full` declares no skipped stage. It is kept rather than superseded:
163
+ * a test guards the document, the check guards every other document, and the
164
+ * failure this pair exists to prevent is a property asserted where it is stated
165
+ * and not enforced where it is consumed.
166
+ *
167
+ * WHAT THIS DELIBERATELY DOES NOT SAY. It does not say that tiphys runs
168
+ * anything: nothing runs on tiphys before M4. The un-downgraded mode of the
169
+ * kernel's own document is the process the tiphys PROJECT follows for its own
170
+ * delivery; the downgraded ones have never been entered at all. And for a
171
+ * document that is not the kernel's own, the answer is that tiphys does not
172
+ * know, because it does not.
173
+ */
174
+ export function executionStatus(mode, context) {
175
+ if (!context.shippedDocument) {
176
+ return ("not determinable here. This is not the kernel's own assurance-modes.yaml, " +
177
+ "so nothing tiphys ships records whether any phase has been delivered under " +
178
+ "this mode (DR-0020).");
179
+ }
180
+ if (mode.id === UNDOWNGRADED_MODE_ID) {
181
+ return (`this mode is ${UNDOWNGRADED_MODE_ID}, which blueprint section 8 defines by name as the ` +
182
+ "un-downgraded process, and it is the one the tiphys project follows for its own delivery.");
183
+ }
184
+ return (`DECLARED AND VALIDATED, NEVER EXERCISED. This mode is not ${UNDOWNGRADED_MODE_ID}, which ` +
185
+ "blueprint section 8 defines by name as the un-downgraded process, so no phase of the " +
186
+ `tiphys project has ever been delivered under it. It declares ${String(mode.skips.length)} ` +
187
+ "skipped stage(s). Its pipeline and its gate selection are checked by validation only " +
188
+ "(DR-0020).");
189
+ }
190
+ /**
191
+ * The standing limits of this release, printed on every invocation.
192
+ *
193
+ * IT SAYS ONLY WHAT THE SHIPPED SCHEMAS DO. The vocabularies really are closed
194
+ * enums, so "a document naming any other id is rejected" is the enum's own
195
+ * behaviour and not a claim about intent. The M4 sentence is attributed to
196
+ * DR-0020 rather than stated as a property of the code.
197
+ */
198
+ export const RELEASE_LIMITS = "limits: the mode, stage and role vocabularies in the shipped schemas are this " +
199
+ "repository's own closed enums, so a document naming any other id is rejected and a " +
200
+ "consuming project cannot extend them at v0.1.0; whether to open them is an M4 question " +
201
+ "(DR-0020). This command SHOWS a declared mode: nothing in this release resolves a " +
202
+ "project into a mode, enforces one, or runs one.";
203
+ /**
204
+ * Render one mode for a human or for a brief.
205
+ *
206
+ * THE SHAPE IS PART OF THE CONTRACT, because criterion 2 asserts over it: a
207
+ * section is a line ending in a colon at column zero, and its items are the
208
+ * lines indented by exactly two spaces beneath it. That makes "prints exactly
209
+ * the twelve stage ids in order" something a test can extract rather than
210
+ * something a reader has to eyeball.
211
+ */
212
+ export function renderMode(mode, context) {
213
+ const lines = [`mode: ${mode.id}`];
214
+ /* SECOND LINE, not a footnote. CR-004 measured that `mode show` printed a
215
+ never-exercised mode with exactly the confidence of the exercised one, and
216
+ that the only disclosure lived in `delivery/`, which the package excludes. */
217
+ lines.push(`execution-status: ${executionStatus(mode, context)}`);
218
+ lines.push(`merge-authority: ${mode.mergeAuthority}`);
219
+ if (mode.grantedBy !== undefined) {
220
+ lines.push(`granted-by: ${mode.grantedBy}`);
221
+ }
222
+ const section = (name, items) => {
223
+ if (items === undefined) {
224
+ return;
225
+ }
226
+ lines.push(`${name}:`);
227
+ if (items.length === 0) {
228
+ lines.push(" (none)");
229
+ return;
230
+ }
231
+ for (const item of items) {
232
+ lines.push(` ${item}`);
233
+ }
234
+ };
235
+ section("pipeline", mode.pipeline);
236
+ section("skips", mode.skips);
237
+ section("gate-sets", mode.gateSets);
238
+ section("review-contracts", mode.reviewContracts);
239
+ if (mode.escalationBounds !== undefined) {
240
+ /* CR-004 item 3. The bounds are DATA an orchestrator brief cites. Nothing
241
+ in this release counts a fix round or detects a recurrence, so a bare
242
+ `escalation-bounds:` header invites the reader to assume an enforcement
243
+ engine that does not exist. */
244
+ lines.push("escalation-bounds (data an orchestrator brief cites; nothing in this release counts fix rounds, detects recurrence, or enforces these):");
245
+ for (const key of Object.keys(mode.escalationBounds).sort()) {
246
+ lines.push(` ${key}: ${String(mode.escalationBounds[key])}`);
247
+ }
248
+ }
249
+ if (mode.conditions !== undefined) {
250
+ lines.push("conditions:");
251
+ for (const condition of mode.conditions) {
252
+ lines.push(` ${condition.replace(/\s+/g, " ").trim()}`);
253
+ }
254
+ }
255
+ lines.push(`declared-by: ${mode.declaredBy.replace(/\s+/g, " ").trim()}`);
256
+ lines.push(RELEASE_LIMITS);
257
+ return lines;
258
+ }
@@ -0,0 +1,34 @@
1
+ /** Compare two existing paths by their canonical filesystem identity. */
2
+ export declare function pathsIdentifySameObject(left: string, right: string): boolean;
3
+ /**
4
+ * Decide whether two paths NAME THE SAME filesystem object when at least
5
+ * one of them was produced by ANOTHER PROGRAM rather than composed here.
6
+ *
7
+ * THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
8
+ * job of pull request #155. `path.resolve` normalizes `.`, `..` and
9
+ * relative segments and does NOT resolve symlinks, so two spellings of one
10
+ * directory stay unequal as strings. Programs the kernel shells out to do
11
+ * not preserve the caller's spelling: git canonicalizes every worktree
12
+ * path it records (`git worktree add /link/wt` then `git worktree list
13
+ * --porcelain` reports `/real/wt`, measured on Linux), and node reports
14
+ * the canonical path of a test file in its reporter's `file` field. So a
15
+ * kernel-composed path compared by string against such a value answers
16
+ * "different object" for the same object, silently, and every decision
17
+ * taken on that answer is wrong in the direction that does nothing.
18
+ *
19
+ * On macOS the symlink is supplied by the platform and needs no unusual
20
+ * setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
21
+ * path under `/var/folders`, where `/var` is a symlink to `/private/var`.
22
+ * The CLI is accidentally immune because `process.cwd()` is already
23
+ * canonical, so only a caller that hands a path in makes this reachable,
24
+ * which is exactly what a library consumer does.
25
+ *
26
+ * The string comparison is tried FIRST and kept, rather than replaced, for
27
+ * two reasons: it answers without touching the filesystem in the common
28
+ * case, and it still gives the right answer for two paths that do not
29
+ * exist, where `realpathSync` can only raise. This function therefore says
30
+ * "same" strictly more often than `resolve(left) === resolve(right)` does,
31
+ * and never says "same" about two objects that are genuinely different:
32
+ * equal canonical paths ARE one object.
33
+ */
34
+ export declare function pathsNameSameObject(left: string, right: string): boolean;
@@ -0,0 +1,48 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ /** Compare two existing paths by their canonical filesystem identity. */
4
+ export function pathsIdentifySameObject(left, right) {
5
+ try {
6
+ return realpathSync(left) === realpathSync(right);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ /**
13
+ * Decide whether two paths NAME THE SAME filesystem object when at least
14
+ * one of them was produced by ANOTHER PROGRAM rather than composed here.
15
+ *
16
+ * THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
17
+ * job of pull request #155. `path.resolve` normalizes `.`, `..` and
18
+ * relative segments and does NOT resolve symlinks, so two spellings of one
19
+ * directory stay unequal as strings. Programs the kernel shells out to do
20
+ * not preserve the caller's spelling: git canonicalizes every worktree
21
+ * path it records (`git worktree add /link/wt` then `git worktree list
22
+ * --porcelain` reports `/real/wt`, measured on Linux), and node reports
23
+ * the canonical path of a test file in its reporter's `file` field. So a
24
+ * kernel-composed path compared by string against such a value answers
25
+ * "different object" for the same object, silently, and every decision
26
+ * taken on that answer is wrong in the direction that does nothing.
27
+ *
28
+ * On macOS the symlink is supplied by the platform and needs no unusual
29
+ * setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
30
+ * path under `/var/folders`, where `/var` is a symlink to `/private/var`.
31
+ * The CLI is accidentally immune because `process.cwd()` is already
32
+ * canonical, so only a caller that hands a path in makes this reachable,
33
+ * which is exactly what a library consumer does.
34
+ *
35
+ * The string comparison is tried FIRST and kept, rather than replaced, for
36
+ * two reasons: it answers without touching the filesystem in the common
37
+ * case, and it still gives the right answer for two paths that do not
38
+ * exist, where `realpathSync` can only raise. This function therefore says
39
+ * "same" strictly more often than `resolve(left) === resolve(right)` does,
40
+ * and never says "same" about two objects that are genuinely different:
41
+ * equal canonical paths ARE one object.
42
+ */
43
+ export function pathsNameSameObject(left, right) {
44
+ if (resolve(left) === resolve(right)) {
45
+ return true;
46
+ }
47
+ return pathsIdentifySameObject(left, right);
48
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * THE PHASE-DECLARATION PROJECTION (kernel plan M3, M3-P1 step 2; D-M3-18).
3
+ *
4
+ * The M2-P4 scope auditor reads one document per phase from
5
+ * `delivery/plan/phase-declarations/<phase-id-lowercased>.json`, out of the
6
+ * MERGE BASE of the audited branch. Until now that document was a SECOND
7
+ * HAND-AUTHORED SOURCE beside the plan, and two hand-authored sources of the
8
+ * same fact drift. This module makes it a GENERATED VIEW of the plan, so the
9
+ * plan is the one source and the auditor's input is derived from it.
10
+ *
11
+ * WHAT THE PROJECTION EMITS, taken from the DELIVERED
12
+ * `src/gates/schemas/phase-declaration.schema.json` rather than from a
13
+ * description of it. The delivered schema sets `additionalProperties: false`,
14
+ * so an extra property is a REJECTION and not an ignored field, and the
15
+ * emitted key set is exactly these five, camelCase:
16
+ *
17
+ * id, branch, filesToTouch, declaredExtras, citations
18
+ *
19
+ * TWO VOCABULARIES MEET HERE AND NOWHERE ELSE. The plan spells the same
20
+ * fields `files-to-touch` and `extras`, because the plan is a document
21
+ * authored for people. This projector is the single translation point, and
22
+ * that is D-M3-18's whole value.
23
+ *
24
+ * THE GLOSS IS STRIPPED HERE OR NOWHERE (M2R-016). The auditor matches a
25
+ * declared path as a literal string or as a directory prefix and interprets
26
+ * nothing, so a plan entry written as
27
+ *
28
+ * `src/cli.ts` (edit only if step 4 requires it)
29
+ *
30
+ * must project to `src/cli.ts`. A projector that passed the glossed form
31
+ * through would emit a declaration under which every real change is
32
+ * undeclared, and the auditor would be right to reject it.
33
+ */
34
+ /** The five properties the delivered phase-declaration schema requires. */
35
+ export interface PhaseDeclaration {
36
+ id: string;
37
+ branch: string;
38
+ filesToTouch: string[];
39
+ declaredExtras: string[];
40
+ citations: string[];
41
+ }
42
+ export declare function stripGloss(entry: string): string;
43
+ /**
44
+ * Is this a literal path the scope auditor can match?
45
+ *
46
+ * The auditor compares strings exactly or as a directory prefix and
47
+ * interprets nothing, so an entry still carrying whitespace after the gloss
48
+ * strip is prose. The projector REFUSES rather than emitting it: an emitted
49
+ * prose entry is a declaration nobody can satisfy, and the failure would
50
+ * surface as an unrelated scope-gate red on some later branch.
51
+ */
52
+ export declare function isLiteralPath(entry: string): boolean;
53
+ export interface ProjectionResult {
54
+ ok: true;
55
+ declaration: PhaseDeclaration;
56
+ /** `m3-p1.json`, the filename the auditor looks for. */
57
+ filename: string;
58
+ }
59
+ export interface ProjectionFailure {
60
+ ok: false;
61
+ reason: string;
62
+ }
63
+ /**
64
+ * Project one phase of a decoded plan into its declaration.
65
+ *
66
+ * The filename is the phase id LOWERCASED, because CI derives `--phase` from
67
+ * the branch with a lowercase regex and the auditor looks the declaration up
68
+ * by that name. An uppercase filename is a declaration the auditor never
69
+ * finds, which reads exactly like a phase with no declaration at all.
70
+ */
71
+ export declare function projectPhase(plan: unknown, phaseId: string): ProjectionResult | ProjectionFailure;
72
+ /** The declaration as the auditor reads it: JSON, one trailing newline. */
73
+ export declare function renderDeclaration(declaration: PhaseDeclaration): string;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * THE PHASE-DECLARATION PROJECTION (kernel plan M3, M3-P1 step 2; D-M3-18).
3
+ *
4
+ * The M2-P4 scope auditor reads one document per phase from
5
+ * `delivery/plan/phase-declarations/<phase-id-lowercased>.json`, out of the
6
+ * MERGE BASE of the audited branch. Until now that document was a SECOND
7
+ * HAND-AUTHORED SOURCE beside the plan, and two hand-authored sources of the
8
+ * same fact drift. This module makes it a GENERATED VIEW of the plan, so the
9
+ * plan is the one source and the auditor's input is derived from it.
10
+ *
11
+ * WHAT THE PROJECTION EMITS, taken from the DELIVERED
12
+ * `src/gates/schemas/phase-declaration.schema.json` rather than from a
13
+ * description of it. The delivered schema sets `additionalProperties: false`,
14
+ * so an extra property is a REJECTION and not an ignored field, and the
15
+ * emitted key set is exactly these five, camelCase:
16
+ *
17
+ * id, branch, filesToTouch, declaredExtras, citations
18
+ *
19
+ * TWO VOCABULARIES MEET HERE AND NOWHERE ELSE. The plan spells the same
20
+ * fields `files-to-touch` and `extras`, because the plan is a document
21
+ * authored for people. This projector is the single translation point, and
22
+ * that is D-M3-18's whole value.
23
+ *
24
+ * THE GLOSS IS STRIPPED HERE OR NOWHERE (M2R-016). The auditor matches a
25
+ * declared path as a literal string or as a directory prefix and interprets
26
+ * nothing, so a plan entry written as
27
+ *
28
+ * `src/cli.ts` (edit only if step 4 requires it)
29
+ *
30
+ * must project to `src/cli.ts`. A projector that passed the glossed form
31
+ * through would emit a declaration under which every real change is
32
+ * undeclared, and the auditor would be right to reject it.
33
+ */
34
+ /**
35
+ * Reduce one plan `files-to-touch` entry to the bare path.
36
+ *
37
+ * THIS FUNCTION FEEDS THE GATE THAT EXISTS TO PREVENT SCOPE WIDENING, so an
38
+ * over-eager strip here is worse than no strip at all.
39
+ *
40
+ * MEASURED DEFECT, fix round 1 (B-001, high). The first version truncated at
41
+ * the FIRST `(` and produced:
42
+ *
43
+ * "src/app/(marketing)/page.tsx" -> "src/app/"
44
+ * "src/(lib)/util.ts" -> "src/"
45
+ *
46
+ * `src/gates/scope.ts` treats a trailing slash as a DIRECTORY PREFIX GRANT, so
47
+ * each of those turned one declared file into an entire tree. Parenthesised
48
+ * path segments are ordinary in real projects (Next.js route groups are the
49
+ * obvious case), so this is not a corner.
50
+ *
51
+ * THE RULE NOW, and each clause is load-bearing:
52
+ *
53
+ * 1. A gloss is stripped only when it is TRAILING and the whole of it is
54
+ * parenthesised: `<path> (anything)` with the closing paren at the end.
55
+ * 2. It is stripped only when WHITESPACE separates it from the path. A path
56
+ * whose own last segment is parenthesised, `src/app/(marketing)`, has no
57
+ * such whitespace and is left alone.
58
+ * 3. What precedes the gloss must be ONE non-whitespace token, which is what
59
+ * a literal path is. That is the "plausible path" test, and it is why an
60
+ * interior `(` can never trigger a strip: the interior case never matches
61
+ * the trailing form at all.
62
+ *
63
+ * Anything the rule does not recognise is returned UNCHANGED and rejected
64
+ * loudly by `projectPhase`, because a prose entry that reaches the auditor as
65
+ * a literal string fails visibly, while a silently truncated one grants a
66
+ * tree.
67
+ */
68
+ const TRAILING_GLOSS = /^(\S+)\s+\([\s\S]*\)$/;
69
+ export function stripGloss(entry) {
70
+ let text = entry.trim();
71
+ const trailing = TRAILING_GLOSS.exec(text);
72
+ if (trailing !== null) {
73
+ text = trailing[1].trim();
74
+ }
75
+ /* Backticks are markdown, never part of a path. Stripped AFTER the gloss,
76
+ because the plan writes `` `path` (gloss) `` and the backticks close
77
+ before the gloss opens. */
78
+ text = text.replace(/^`+/, "").replace(/`+$/, "").trim();
79
+ /* A trailing comma survives a list written inline. */
80
+ text = text.replace(/,+$/, "").trim();
81
+ return text;
82
+ }
83
+ /**
84
+ * Is this a literal path the scope auditor can match?
85
+ *
86
+ * The auditor compares strings exactly or as a directory prefix and
87
+ * interprets nothing, so an entry still carrying whitespace after the gloss
88
+ * strip is prose. The projector REFUSES rather than emitting it: an emitted
89
+ * prose entry is a declaration nobody can satisfy, and the failure would
90
+ * surface as an unrelated scope-gate red on some later branch.
91
+ */
92
+ export function isLiteralPath(entry) {
93
+ return entry !== "" && !/\s/.test(entry);
94
+ }
95
+ function asRecord(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value)
97
+ ? value
98
+ : undefined;
99
+ }
100
+ function stringArray(value) {
101
+ return Array.isArray(value)
102
+ ? value.filter((item) => typeof item === "string")
103
+ : [];
104
+ }
105
+ /**
106
+ * Project one phase of a decoded plan into its declaration.
107
+ *
108
+ * The filename is the phase id LOWERCASED, because CI derives `--phase` from
109
+ * the branch with a lowercase regex and the auditor looks the declaration up
110
+ * by that name. An uppercase filename is a declaration the auditor never
111
+ * finds, which reads exactly like a phase with no declaration at all.
112
+ */
113
+ export function projectPhase(plan, phaseId) {
114
+ const document = asRecord(plan);
115
+ if (document === undefined) {
116
+ return { ok: false, reason: "the plan document is not a mapping" };
117
+ }
118
+ const phases = Array.isArray(document["phases"]) ? document["phases"] : [];
119
+ const phase = asRecord(phases.find((candidate) => asRecord(candidate)?.["id"] === phaseId));
120
+ if (phase === undefined) {
121
+ return { ok: false, reason: `the plan contains no phase ${phaseId}` };
122
+ }
123
+ const filesToTouch = stringArray(phase["files-to-touch"])
124
+ .map(stripGloss)
125
+ .filter((path) => path !== "");
126
+ const declaredExtras = stringArray(phase["extras"])
127
+ .map(stripGloss)
128
+ .filter((path) => path !== "");
129
+ const prose = [...filesToTouch, ...declaredExtras].filter((path) => !isLiteralPath(path));
130
+ if (prose.length > 0) {
131
+ return {
132
+ ok: false,
133
+ reason: `phase ${phaseId} declares an entry that is not a literal path and ` +
134
+ `cannot be projected: ${prose.map((p) => JSON.stringify(p)).join(", ")}`,
135
+ };
136
+ }
137
+ const declaration = {
138
+ id: String(phase["id"]),
139
+ branch: String(phase["branch"]),
140
+ filesToTouch,
141
+ declaredExtras,
142
+ citations: stringArray(phase["citations"]),
143
+ };
144
+ return {
145
+ ok: true,
146
+ declaration,
147
+ filename: `${phaseId.toLowerCase()}.json`,
148
+ };
149
+ }
150
+ /** The declaration as the auditor reads it: JSON, one trailing newline. */
151
+ export function renderDeclaration(declaration) {
152
+ return `${JSON.stringify(declaration, undefined, 2)}\n`;
153
+ }