@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,422 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME } from "./commands/init.js";
4
+ import { guardSharedRegister } from "./exclusion.js";
5
+ import { poolDestroy, readPoolRecord, reconstructPoolRecord, worktreePath, } from "./pool.js";
6
+ import { checkHoldership, metaPath, readTaskMeta, reportPath, runStep, setTaskStatus, singleLine, } from "./task.js";
7
+ /**
8
+ * Local git runner. It is deliberately not the pool's: src/pool.ts is
9
+ * explicitly out of this phase's edit scope, and teardown needs no
10
+ * contention retry, because nothing in M1 runs teardown concurrently
11
+ * with anything else (parallelism is off until M5). The locale is pinned
12
+ * for the same reason the pool pins it: reproducible output.
13
+ */
14
+ function runGit(cwd, args, extraEnv) {
15
+ const result = spawnSync("git", ["-C", cwd, ...args], {
16
+ encoding: "utf8",
17
+ env: { ...process.env, LC_ALL: "C", LANG: "C", ...extraEnv },
18
+ });
19
+ if (result.error !== undefined) {
20
+ return { status: null, stdout: "", stderr: String(result.error) };
21
+ }
22
+ return {
23
+ status: result.status,
24
+ stdout: result.stdout ?? "",
25
+ stderr: result.stderr ?? "",
26
+ };
27
+ }
28
+ /** The commit-message prefix every salvaged commit carries (R-081a). */
29
+ export const SALVAGE_PREFIX = "WIP-UNREVIEWED (do not treat as reviewed):";
30
+ /**
31
+ * Is branchRef landed on defaultRef? Both are resolved in contextDir,
32
+ * and defaultRef is always the just-fetched remote-tracking ref.
33
+ */
34
+ export function landedness(contextDir, branchRef, defaultRef) {
35
+ const ancestor = runGit(contextDir, [
36
+ "merge-base",
37
+ "--is-ancestor",
38
+ branchRef,
39
+ defaultRef,
40
+ ]);
41
+ if (ancestor.status === 0) {
42
+ return { kind: "landed", how: "ancestor" };
43
+ }
44
+ if (ancestor.status !== 1) {
45
+ return {
46
+ kind: "inconclusive",
47
+ detail: `git merge-base --is-ancestor exited ${String(ancestor.status)}: ${singleLine(ancestor.stderr)}`,
48
+ };
49
+ }
50
+ const defaultTree = runGit(contextDir, ["rev-parse", "--verify", `${defaultRef}^{tree}`]);
51
+ if (defaultTree.status !== 0) {
52
+ return {
53
+ kind: "inconclusive",
54
+ detail: `cannot resolve the tree of ${defaultRef}: ${singleLine(defaultTree.stderr)}`,
55
+ };
56
+ }
57
+ const merged = runGit(contextDir, ["merge-tree", "--write-tree", defaultRef, branchRef]);
58
+ if (merged.status === 1) {
59
+ // Conflicts: merging is not a no-op, so the branch is not landed.
60
+ return { kind: "unlanded" };
61
+ }
62
+ if (merged.status !== 0) {
63
+ return {
64
+ kind: "inconclusive",
65
+ detail: `git merge-tree --write-tree exited ${String(merged.status)}: ${singleLine(merged.stderr)}`,
66
+ };
67
+ }
68
+ const mergedTree = merged.stdout.split("\n")[0]?.trim() ?? "";
69
+ if (mergedTree === "") {
70
+ return {
71
+ kind: "inconclusive",
72
+ detail: "git merge-tree --write-tree produced no tree id",
73
+ };
74
+ }
75
+ return mergedTree === defaultTree.stdout.trim()
76
+ ? { kind: "landed", how: "squash" }
77
+ : { kind: "unlanded" };
78
+ }
79
+ /**
80
+ * Resolve everything teardown needs, and perform the mandatory fetch.
81
+ * Read-only with respect to the task: the fetch updates only the
82
+ * project's remote-tracking ref.
83
+ */
84
+ function resolveContext(fleet, taskId, fromReconstructed) {
85
+ const meta = readTaskMeta(fleet, taskId);
86
+ if (meta === undefined) {
87
+ return {
88
+ ok: false,
89
+ reason: `no readable task meta for task id ${taskId}; teardown needs tasks/${taskId}/meta.json`,
90
+ };
91
+ }
92
+ let record = readPoolRecord(fleet, taskId);
93
+ let reconstructed = false;
94
+ if (record === undefined) {
95
+ if (!fromReconstructed) {
96
+ return {
97
+ ok: false,
98
+ reason: `no readable pool record for task id ${taskId}; teardown needs it for the ` +
99
+ `project remote and default branch, and refuses rather than guessing them; ` +
100
+ `pass --from-reconstructed to rebuild them from tasks/${taskId}/meta.json ` +
101
+ `and git, which keeps every other refusal in force`,
102
+ };
103
+ }
104
+ // NETWORK ALLOWED here and nowhere else in this kernel's
105
+ // reconstruction (M4-P19 fix round). Teardown is a command the
106
+ // operator invoked in order to destroy something, it is about to
107
+ // fetch from this remote on the next line regardless, and it is
108
+ // allowed to take as long as that fetch takes. `pool list` and
109
+ // doctor are not, and they pass `{ network: false }`.
110
+ const rebuilt = reconstructPoolRecord(fleet, taskId, { network: true });
111
+ if (rebuilt.kind === "absent") {
112
+ return {
113
+ ok: false,
114
+ reason: `cannot reconstruct the pool record for task id ${taskId}: ${rebuilt.reason}`,
115
+ };
116
+ }
117
+ if (rebuilt.kind === "incomplete") {
118
+ // The missing FIELD is named, not merely the failure, because the
119
+ // remedy differs per field: `remote` is a git configuration repair
120
+ // in the clone, `branch` is usually an origin/HEAD that was never
121
+ // set or a remote that cannot be reached to advertise it.
122
+ return {
123
+ ok: false,
124
+ reason: `cannot reconstruct the pool record for task id ${taskId}: unresolved ` +
125
+ `field(s) ${rebuilt.unresolved.join(", ")} (${rebuilt.detail}); teardown ` +
126
+ `refuses rather than guessing them`,
127
+ };
128
+ }
129
+ record = rebuilt.record;
130
+ reconstructed = true;
131
+ }
132
+ const worktree = worktreePath(fleet, taskId);
133
+ const defaultRef = `refs/remotes/${record.remote}/${record.branch}`;
134
+ // PR-001: fetch first, always, and judge landed-ness only against this.
135
+ const fetched = runGit(record.project, [
136
+ "fetch",
137
+ record.remote,
138
+ `+refs/heads/${record.branch}:${defaultRef}`,
139
+ ]);
140
+ if (fetched.status !== 0) {
141
+ return {
142
+ ok: false,
143
+ reason: `fetch of ${record.remote}/${record.branch} failed, so landed-ness cannot ` +
144
+ `be judged against fresh remote state: ${singleLine(fetched.stderr)}`,
145
+ };
146
+ }
147
+ return { ok: true, value: { meta, record, reconstructed, worktree, defaultRef } };
148
+ }
149
+ /** Uncommitted changes or untracked files in the task worktree. */
150
+ function worktreeDirty(worktree) {
151
+ const status = runGit(worktree, ["status", "--porcelain"]);
152
+ if (status.status !== 0) {
153
+ return {
154
+ ok: false,
155
+ reason: `cannot verify worktree cleanliness at ${worktree}: ${singleLine(status.stderr)}`,
156
+ };
157
+ }
158
+ return { ok: true, dirty: status.stdout.trim() !== "" };
159
+ }
160
+ /** Commit the leavings under the WIP label and push the branch (R-081a). */
161
+ function salvageLeavings(context) {
162
+ const { worktree, record } = context;
163
+ const added = runGit(worktree, ["add", "-A"]);
164
+ if (added.status !== 0) {
165
+ return { ok: false, reason: `salvage failed at git add: ${singleLine(added.stderr)}` };
166
+ }
167
+ const message = `${SALVAGE_PREFIX} leavings salvaged by tiphys teardown for task ${context.meta.id}`;
168
+ // CI runners have no git identity, and the fleet never reads or writes
169
+ // user or global git configuration (EXT-F-02 option B): the machine
170
+ // identity is set command-scoped, exactly as init's bootstrap commit
171
+ // does it.
172
+ const committed = runGit(worktree, ["commit", "-m", message], {
173
+ GIT_AUTHOR_NAME: MACHINE_IDENTITY_NAME,
174
+ GIT_AUTHOR_EMAIL: MACHINE_IDENTITY_EMAIL,
175
+ GIT_COMMITTER_NAME: MACHINE_IDENTITY_NAME,
176
+ GIT_COMMITTER_EMAIL: MACHINE_IDENTITY_EMAIL,
177
+ });
178
+ if (committed.status !== 0) {
179
+ return {
180
+ ok: false,
181
+ reason: `salvage failed at git commit: ${singleLine(committed.stderr) || singleLine(committed.stdout)}`,
182
+ };
183
+ }
184
+ const pushed = runGit(record.project, [
185
+ "push",
186
+ record.remote,
187
+ `refs/heads/${record.branchName}:refs/heads/${record.branchName}`,
188
+ ]);
189
+ if (pushed.status !== 0) {
190
+ return {
191
+ ok: false,
192
+ reason: `salvage committed the leavings as "${SALVAGE_PREFIX} ..." but the push of ` +
193
+ `${record.branchName} failed: ${singleLine(pushed.stderr)}; the commit is local only`,
194
+ };
195
+ }
196
+ return { ok: true };
197
+ }
198
+ /**
199
+ * Remove the worktree and close the task. Never called before every
200
+ * refusal rule has passed.
201
+ */
202
+ async function finish(fleet, context, options) {
203
+ const destroyed = await poolDestroy(fleet, {
204
+ taskId: context.meta.id,
205
+ discard: options.discard,
206
+ deleteBranchForce: options.deleteBranchForce,
207
+ // M4-P19: in memory only, and only when the record was rebuilt. This
208
+ // makes the destroy's own base-sha gate ABLE to judge instead of
209
+ // abstaining with its "pool record missing" refusal; it does not
210
+ // create a file and it does not relax a gate.
211
+ ...(context.reconstructed ? { reconstructed: context.record } : {}),
212
+ });
213
+ if (!destroyed.ok) {
214
+ // The destroy's own reason distinguishes a stage-2 refusal (a true
215
+ // no-op) from a stage-3 partial failure (worktree already removed,
216
+ // survivors enumerated). It is passed through verbatim, and the only
217
+ // thing added is what this layer knows for certain.
218
+ return {
219
+ ok: false,
220
+ reason: `pool destroy did not complete: ${destroyed.reason}; task ${context.meta.id} stays open`,
221
+ };
222
+ }
223
+ // F-1. The worktree is GONE by this point, so a raised write here is
224
+ // not a refusal and must never crash the command: an uncaught throw
225
+ // left meta.json reading "open" beside a worktree that no longer
226
+ // exists, which is the single state authority (C-1) telling a later
227
+ // reader, and the M1-P5 watcher, something false. It is the same
228
+ // partial-failure shape M1-P3 defined for destroy, reported in the
229
+ // same vocabulary, with the manual remedy named.
230
+ const closed = runStep(`marking task ${context.meta.id} closed`, () => {
231
+ setTaskStatus(fleet, context.meta, "closed");
232
+ });
233
+ if (!closed.ok) {
234
+ const removed = destroyed.value.deletedBranch === undefined
235
+ ? `worktree ${context.worktree} HAS BEEN REMOVED`
236
+ : `worktree ${context.worktree} HAS BEEN REMOVED and branch ` +
237
+ `${destroyed.value.deletedBranch} was deleted (it was ` +
238
+ `${destroyed.value.deletedSha ?? "unknown"})`;
239
+ return {
240
+ ok: false,
241
+ reason: `partial teardown of task id ${context.meta.id}: ${removed}, but ` +
242
+ `${metaPath(fleet, context.meta.id)} could not be marked closed ` +
243
+ `(${closed.reason}); the task record still reads status open although its ` +
244
+ `worktree is gone, so repair that file and set "status": "closed" by hand`,
245
+ };
246
+ }
247
+ return { ok: true, value: { taskId: context.meta.id, salvaged: options.salvaged } };
248
+ }
249
+ export async function teardownTask(fleet, options) {
250
+ const holdership = checkHoldership(fleet);
251
+ if (!holdership.ok) {
252
+ return { ok: false, reason: holdership.reason };
253
+ }
254
+ /* THE CROSS-ENVIRONMENT HALF (M4-P22 criterion 3), rule 0's second limb.
255
+ `checkHoldership` above returns OK when this environment holds the local
256
+ lease, and the local lease is evidence about this filesystem alone
257
+ (src/lock.ts:63). With the shared register naming another environment,
258
+ the old guard is green and teardown would remove a worktree the other
259
+ orchestrator is working in. One reason line, nonzero exit, and NOTHING
260
+ REMOVED: it runs before `resolveContext`, so no pool record is read, no
261
+ worktree is probed and no branch is deleted. A teardown that refuses
262
+ after deleting something has failed in the way that matters. */
263
+ const sharedGuard = guardSharedRegister(fleet.root, "teardown");
264
+ if (sharedGuard.kind === "refused") {
265
+ return { ok: false, reason: sharedGuard.reason };
266
+ }
267
+ const resolved = resolveContext(fleet, options.taskId, options.fromReconstructed);
268
+ if (!resolved.ok) {
269
+ return resolved;
270
+ }
271
+ const context = resolved.value;
272
+ const { meta, record, worktree } = context;
273
+ if (meta.shape === "scout") {
274
+ // M4-P19 FIX ROUND, finding F-5. THE RECONSTRUCTED PATH IS STRICTER
275
+ // THAN THE WITH-RECORD PATH FOR A SCOUT, DELIBERATELY.
276
+ //
277
+ // The scout arm below discards a dirty scratch tree by design
278
+ // (PR-010: a scout is judged by its report and never pushes), and it
279
+ // reaches `finish` without ever probing cleanliness. That was
280
+ // reachable only by an operator who had the pool record in front of
281
+ // them. This phase made it reachable from a RECLAIM, where the
282
+ // record is the one thing that did not survive, and measured at head
283
+ // abde402: `teardown --task s1 --from-reconstructed` against a scout
284
+ // worktree holding ` M readme.md` and `?? important.md` exited 0 and
285
+ // removed the worktree, where the same fixture on the phase base
286
+ // exited 1 and left it standing. Plan criterion 4 states the refusal
287
+ // with no shape qualifier, so the plan is what is followed here.
288
+ //
289
+ // WHY NOT INSTEAD MAKE THE WITH-RECORD SCOUT PATH REFUSE TOO. That
290
+ // is a change to a decided scout policy (PR-010) which this phase
291
+ // does not own and which the plan does not ask for. The asymmetry is
292
+ // therefore REAL and is stated rather than smoothed over: the
293
+ // difference in force is the difference in what the operator knows.
294
+ // With the record present they are tearing down a scout they are
295
+ // tracking; arriving here from a reclaim they are recovering a fleet
296
+ // whose bookkeeping is already known to be incomplete, and the
297
+ // leavings in that tree may be the only copy.
298
+ //
299
+ // --salvage is NOT the escape, because salvage pushes (PR-010: a
300
+ // scout never pushes), so the remedy named is the one that works.
301
+ if (context.reconstructed) {
302
+ const scoutDirty = worktreeDirty(worktree);
303
+ if (!scoutDirty.ok) {
304
+ return { ok: false, reason: scoutDirty.reason };
305
+ }
306
+ if (scoutDirty.dirty) {
307
+ return {
308
+ ok: false,
309
+ reason: `scout worktree ${worktree} has uncommitted changes or untracked ` +
310
+ `files and its pool record did not survive, so teardown is running ` +
311
+ `on a reconstruction and refuses to discard them: copy anything ` +
312
+ `worth keeping out of ${worktree}, then re-run once ` +
313
+ `"git -C ${worktree} status --porcelain" is empty`,
314
+ };
315
+ }
316
+ }
317
+ // (a) A scout is judged by its report, never by its scratch tree.
318
+ if (!existsSync(reportPath(fleet, options.taskId))) {
319
+ return {
320
+ ok: false,
321
+ reason: `scout task ${options.taskId} has no report: ${reportPath(fleet, options.taskId)} ` +
322
+ `is absent, and a scout is torn down only once it has reported`,
323
+ };
324
+ }
325
+ // Scout worktrees are scratch and scouts never push (PR-010), so the
326
+ // dirty tree is discarded. --delete-branch-force is deliberately NOT
327
+ // passed: --discard's plan-defined meaning is the dirty-tree
328
+ // override only, and a scout that committed to its scratch branch is
329
+ // refused rather than having those commits deleted silently, which is
330
+ // exactly the M1-P3 V-1 defect.
331
+ //
332
+ // CR-304: teardown answers that question ITSELF, before calling
333
+ // destroy, because the pool's own refusal advises passing
334
+ // --delete-branch-force, a flag teardown does not accept, so the
335
+ // operator was told to do something impossible through the command
336
+ // that printed it and the task could never reach closed. This
337
+ // refusal is a true no-op and names a route that works; the pool's
338
+ // gate stays behind it as the enforcer.
339
+ const tip = runGit(record.project, [
340
+ "rev-parse",
341
+ "--verify",
342
+ "--quiet",
343
+ `refs/heads/${record.branchName}^{commit}`,
344
+ ]);
345
+ if (tip.status === 0 && tip.stdout.trim() !== record.baseSha) {
346
+ return {
347
+ ok: false,
348
+ reason: `scout task ${options.taskId} has commits on its scratch branch ` +
349
+ `${record.branchName} (tip ${tip.stdout.trim()}, base ${record.baseSha}) and ` +
350
+ `teardown never deletes committed work: copy or push them somewhere ` +
351
+ `durable, then release the branch with "git -C ${record.project} update-ref ` +
352
+ `refs/heads/${record.branchName} ${record.baseSha}" and re-run teardown`,
353
+ };
354
+ }
355
+ return finish(fleet, context, {
356
+ discard: true,
357
+ deleteBranchForce: false,
358
+ salvaged: false,
359
+ });
360
+ }
361
+ // (b) ship.
362
+ const dirty = worktreeDirty(worktree);
363
+ if (!dirty.ok) {
364
+ return { ok: false, reason: dirty.reason };
365
+ }
366
+ if (dirty.dirty && !options.salvage) {
367
+ return {
368
+ ok: false,
369
+ reason: `worktree ${worktree} has uncommitted changes or untracked files; commit ` +
370
+ `or land them first, or pass --salvage to commit them as ` +
371
+ `"${SALVAGE_PREFIX} ..." and push`,
372
+ };
373
+ }
374
+ const branchRef = `refs/heads/${record.branchName}`;
375
+ const landed = landedness(record.project, branchRef, context.defaultRef);
376
+ if (landed.kind === "inconclusive") {
377
+ return {
378
+ ok: false,
379
+ reason: `cannot determine whether ${record.branchName} is landed on ` +
380
+ `${record.remote}/${record.branch} (${landed.detail}); refusing`,
381
+ };
382
+ }
383
+ if (landed.kind === "unlanded") {
384
+ // M4-P19 criterion 5: the refusal NAMES THE TIP. That sha is the
385
+ // operator's recovery handle, exactly as the deleted-branch sha is on
386
+ // the success path (V-1), and it is the one fact that makes this
387
+ // refusal actionable: it says which commit is at risk, not merely
388
+ // that something is. It is named on every unlanded refusal rather
389
+ // than only the reconstructed one, so there is one message and not
390
+ // two that can drift apart.
391
+ const tip = runGit(record.project, [
392
+ "rev-parse",
393
+ "--verify",
394
+ "--quiet",
395
+ `${branchRef}^{commit}`,
396
+ ]);
397
+ const tipSha = tip.status === 0 ? tip.stdout.trim() : "unresolvable";
398
+ return {
399
+ ok: false,
400
+ reason: `branch ${record.branchName} (tip ${tipSha}) is not landed on ` +
401
+ `${record.remote}/${record.branch}; land it before tearing the task down` +
402
+ (options.salvage ? " (--salvage rescues leavings, it never lands work)" : ""),
403
+ };
404
+ }
405
+ let salvaged = false;
406
+ if (dirty.dirty) {
407
+ const rescue = salvageLeavings(context);
408
+ if (!rescue.ok) {
409
+ return rescue;
410
+ }
411
+ salvaged = true;
412
+ }
413
+ // The landed judgement is what authorizes deleting the task branch
414
+ // (V-1's explicit flag, which src/pool.ts documents as coming from
415
+ // exactly here). After a salvage the branch carries the WIP commit,
416
+ // which is on the remote, so the local branch is safe to remove.
417
+ return finish(fleet, context, {
418
+ discard: false,
419
+ deleteBranchForce: true,
420
+ salvaged,
421
+ });
422
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * THE TUITION FEED AND THE MECHANISM INDEX (kernel plan M3, M3-P8; R-091).
3
+ *
4
+ * A tuition entry records one failure mode a delivery paid for. The MECHANISM
5
+ * INDEX is a PROJECTION of the `mechanisms[]` field of every entry in the
6
+ * feed, keyed by mechanism, and it is generated rather than authored. T-005 is
7
+ * why both halves exist and why they are one artifact rather than two: a rule
8
+ * M1-P3 paid for did not reach M1-P5, which reimplemented the same claim-file
9
+ * mechanism silently and produced the most severe defect found in M1, and a
10
+ * second hand-maintained copy of a rule is the state that produced it.
11
+ *
12
+ * THE TWO LAYERS ARE STRUCTURALLY DISTINCT, which is the plan's compaction
13
+ * model (step 2c). The index is the READ layer: dense, consulted at every
14
+ * dispatch under the `mechanism-lookup` obligation. The entries are the
15
+ * ARCHIVE layer: longer, read when a rule is disputed. `tuition index --check`
16
+ * is what keeps the first honest about the second.
17
+ *
18
+ * PATHS THIS MODULE READS ARE NOT ITS OWN (D-M3-27, the mechanism index's own
19
+ * row "Reading a path whose type is not established"). Every read goes through
20
+ * `readOperatorPath`, so a named pipe at an entry path is refused with the
21
+ * observed entry type instead of blocking the command forever.
22
+ */
23
+ /** The generated index, relative to a tuition directory. */
24
+ export declare const MECHANISM_INDEX_FILE = "mechanism-index.yaml";
25
+ /** One mechanism as a tuition entry declares it. */
26
+ export interface MechanismDeclaration {
27
+ mechanism: string;
28
+ rule: string;
29
+ siblings?: string[];
30
+ "machine-readable-form"?: {
31
+ path: string;
32
+ key: string;
33
+ };
34
+ evidence: string[];
35
+ }
36
+ /** One tuition entry, after schema validation. */
37
+ export interface TuitionEntry {
38
+ kind: "tuition";
39
+ version: number;
40
+ id: string;
41
+ project: string;
42
+ date: string;
43
+ stage: string;
44
+ "kernel-relevant": boolean;
45
+ "what-happened": string;
46
+ lesson: string[];
47
+ mechanisms?: MechanismDeclaration[];
48
+ "structural-consequence"?: {
49
+ target: string;
50
+ status: "proposed" | "applied" | "ticketed";
51
+ change: string;
52
+ record?: string;
53
+ }[];
54
+ evidence: string[];
55
+ }
56
+ /** One row of the generated index, plus the entry it was projected from. */
57
+ export interface IndexRow {
58
+ key: string;
59
+ name: string;
60
+ rule: string;
61
+ siblings?: string[];
62
+ "machine-readable-form"?: {
63
+ path: string;
64
+ key: string;
65
+ };
66
+ evidence: string[];
67
+ /** The tuition id this row came from. Not part of the rendered document. */
68
+ source: string;
69
+ }
70
+ /**
71
+ * THE KEY IS DERIVED FROM THE NAME, NEVER INVENTED: lowercase, every run of
72
+ * characters outside [a-z0-9] collapsed to one hyphen, ends trimmed. The same
73
+ * derivation `schemas/mechanism-index.schema.json` documents and M3-P6's
74
+ * registered test applies, stated once in code so the generator and the check
75
+ * cannot disagree about it.
76
+ */
77
+ export declare function mechanismKey(name: string): string;
78
+ export type EntryFileListing = {
79
+ ok: true;
80
+ paths: string[];
81
+ } | {
82
+ ok: false;
83
+ reason: string;
84
+ };
85
+ /**
86
+ * Every tuition ENTRY file in a directory, sorted by name.
87
+ *
88
+ * The generated index lives in the same directory and is EXCLUDED BY NAME: it
89
+ * is the projection's output, and a projection that reads its own output is a
90
+ * loop rather than a check. Anything that is not a `.yaml` file is ignored, so
91
+ * a README beside the feed is not an entry.
92
+ */
93
+ export declare function listEntryFiles(directory: string): EntryFileListing;
94
+ export type EntryLoad = {
95
+ ok: true;
96
+ entry: TuitionEntry;
97
+ body: string;
98
+ } | {
99
+ ok: false;
100
+ reason: string;
101
+ diagnostics: string[];
102
+ };
103
+ /**
104
+ * Read, decode and schema-validate one tuition entry.
105
+ *
106
+ * THE RAW BYTES COME BACK WITH THE ENTRY, and that is not a convenience. An
107
+ * earlier version had `tuition add` call this and then read the same path a
108
+ * second time for the bytes to write. Two reads meant two independent
109
+ * refusals of a non-regular path, and a refusal that another refusal shadows
110
+ * cannot be witnessed: mutating either left the other rejecting the same
111
+ * input, which is exactly the shape T-018 records. One read, one
112
+ * classification, one guard.
113
+ */
114
+ export declare function loadEntry(path: string): EntryLoad;
115
+ export type Projection = {
116
+ ok: true;
117
+ rows: IndexRow[];
118
+ } | {
119
+ ok: false;
120
+ reason: string;
121
+ };
122
+ /**
123
+ * Project the mechanism index out of a set of entries, sorted by key.
124
+ *
125
+ * A KEY CLAIMED BY TWO ENTRIES IS AN ERROR NAMING BOTH, never a silent
126
+ * first-wins. Two entries stating the same mechanism differently is exactly
127
+ * the divergence this document exists to prevent, and the projection cannot
128
+ * decide which of the two rules the project actually learned. The resolution
129
+ * is an editorial one: merge the two entries' rules by hand into whichever one
130
+ * owns the mechanism.
131
+ */
132
+ export declare function projectIndex(entries: readonly TuitionEntry[]): Projection;
133
+ /**
134
+ * Emit a YAML scalar, quoting only when a plain one would not round trip.
135
+ *
136
+ * THE CONDITION IS THE POINT: this generator's output is re-read by
137
+ * `--check`, so a scalar that YAML would decode as something other than the
138
+ * string handed in makes the projection disagree with itself. The reserved
139
+ * leading indicators, an embedded `: ` or ` #`, and a trailing colon are the
140
+ * cases; everything else is emitted plain, which is what keeps the document
141
+ * readable.
142
+ */
143
+ export declare function yamlScalar(value: string): string;
144
+ /** Render the index document. The bytes are what `--check` compares. */
145
+ export declare function renderIndex(rows: readonly IndexRow[]): string;
146
+ /**
147
+ * Compare a committed index document against a fresh projection.
148
+ *
149
+ * NAMES THE MECHANISM AND THE ENTRY IT CAME FROM, which is the criterion's
150
+ * letter (4): a reader of a red `--check` must be able to go straight to the
151
+ * file that changed. Both directions are reported, because a row DELETED from
152
+ * the feed and a row ADDED to it are different faults with the same symptom.
153
+ *
154
+ * The field comparison is over the DECODED values rather than the bytes, so a
155
+ * rewrap of a folded scalar is not reported as a rule change; a byte
156
+ * comparison is done by the caller afterwards and reported as formatting,
157
+ * which keeps "the rule changed" and "the file was hand-edited" apart.
158
+ */
159
+ export declare function driftLines(committed: unknown, rows: readonly IndexRow[]): string[];