@tiphys/kernel 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,32 @@
1
+ import type { Fleet } from "./fleet.ts";
2
+ /** The commit-message prefix every salvaged commit carries (R-081a). */
3
+ export declare const SALVAGE_PREFIX = "WIP-UNREVIEWED (do not treat as reviewed):";
4
+ export type TeardownResult = {
5
+ ok: true;
6
+ value: {
7
+ taskId: string;
8
+ salvaged: boolean;
9
+ };
10
+ } | {
11
+ ok: false;
12
+ reason: string;
13
+ };
14
+ export type Landedness = {
15
+ kind: "landed";
16
+ how: "ancestor" | "squash";
17
+ } | {
18
+ kind: "unlanded";
19
+ } | {
20
+ kind: "inconclusive";
21
+ detail: string;
22
+ };
23
+ /**
24
+ * Is branchRef landed on defaultRef? Both are resolved in contextDir,
25
+ * and defaultRef is always the just-fetched remote-tracking ref.
26
+ */
27
+ export declare function landedness(contextDir: string, branchRef: string, defaultRef: string): Landedness;
28
+ export interface TeardownOptions {
29
+ taskId: string;
30
+ salvage: boolean;
31
+ }
32
+ export declare function teardownTask(fleet: Fleet, options: TeardownOptions): Promise<TeardownResult>;
@@ -0,0 +1,314 @@
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 { poolDestroy, readPoolRecord, worktreePath } from "./pool.js";
5
+ import { checkHoldership, metaPath, readTaskMeta, reportPath, runStep, setTaskStatus, singleLine, } from "./task.js";
6
+ /**
7
+ * Local git runner. It is deliberately not the pool's: src/pool.ts is
8
+ * explicitly out of this phase's edit scope, and teardown needs no
9
+ * contention retry, because nothing in M1 runs teardown concurrently
10
+ * with anything else (parallelism is off until M5). The locale is pinned
11
+ * for the same reason the pool pins it: reproducible output.
12
+ */
13
+ function runGit(cwd, args, extraEnv) {
14
+ const result = spawnSync("git", ["-C", cwd, ...args], {
15
+ encoding: "utf8",
16
+ env: { ...process.env, LC_ALL: "C", LANG: "C", ...extraEnv },
17
+ });
18
+ if (result.error !== undefined) {
19
+ return { status: null, stdout: "", stderr: String(result.error) };
20
+ }
21
+ return {
22
+ status: result.status,
23
+ stdout: result.stdout ?? "",
24
+ stderr: result.stderr ?? "",
25
+ };
26
+ }
27
+ /** The commit-message prefix every salvaged commit carries (R-081a). */
28
+ export const SALVAGE_PREFIX = "WIP-UNREVIEWED (do not treat as reviewed):";
29
+ /**
30
+ * Is branchRef landed on defaultRef? Both are resolved in contextDir,
31
+ * and defaultRef is always the just-fetched remote-tracking ref.
32
+ */
33
+ export function landedness(contextDir, branchRef, defaultRef) {
34
+ const ancestor = runGit(contextDir, [
35
+ "merge-base",
36
+ "--is-ancestor",
37
+ branchRef,
38
+ defaultRef,
39
+ ]);
40
+ if (ancestor.status === 0) {
41
+ return { kind: "landed", how: "ancestor" };
42
+ }
43
+ if (ancestor.status !== 1) {
44
+ return {
45
+ kind: "inconclusive",
46
+ detail: `git merge-base --is-ancestor exited ${String(ancestor.status)}: ${singleLine(ancestor.stderr)}`,
47
+ };
48
+ }
49
+ const defaultTree = runGit(contextDir, ["rev-parse", "--verify", `${defaultRef}^{tree}`]);
50
+ if (defaultTree.status !== 0) {
51
+ return {
52
+ kind: "inconclusive",
53
+ detail: `cannot resolve the tree of ${defaultRef}: ${singleLine(defaultTree.stderr)}`,
54
+ };
55
+ }
56
+ const merged = runGit(contextDir, ["merge-tree", "--write-tree", defaultRef, branchRef]);
57
+ if (merged.status === 1) {
58
+ // Conflicts: merging is not a no-op, so the branch is not landed.
59
+ return { kind: "unlanded" };
60
+ }
61
+ if (merged.status !== 0) {
62
+ return {
63
+ kind: "inconclusive",
64
+ detail: `git merge-tree --write-tree exited ${String(merged.status)}: ${singleLine(merged.stderr)}`,
65
+ };
66
+ }
67
+ const mergedTree = merged.stdout.split("\n")[0]?.trim() ?? "";
68
+ if (mergedTree === "") {
69
+ return {
70
+ kind: "inconclusive",
71
+ detail: "git merge-tree --write-tree produced no tree id",
72
+ };
73
+ }
74
+ return mergedTree === defaultTree.stdout.trim()
75
+ ? { kind: "landed", how: "squash" }
76
+ : { kind: "unlanded" };
77
+ }
78
+ /**
79
+ * Resolve everything teardown needs, and perform the mandatory fetch.
80
+ * Read-only with respect to the task: the fetch updates only the
81
+ * project's remote-tracking ref.
82
+ */
83
+ function resolveContext(fleet, taskId) {
84
+ const meta = readTaskMeta(fleet, taskId);
85
+ if (meta === undefined) {
86
+ return {
87
+ ok: false,
88
+ reason: `no readable task meta for task id ${taskId}; teardown needs tasks/${taskId}/meta.json`,
89
+ };
90
+ }
91
+ const record = readPoolRecord(fleet, taskId);
92
+ if (record === undefined) {
93
+ return {
94
+ ok: false,
95
+ reason: `no readable pool record for task id ${taskId}; teardown needs it for the ` +
96
+ `project remote and default branch, and refuses rather than guessing them`,
97
+ };
98
+ }
99
+ const worktree = worktreePath(fleet, taskId);
100
+ const defaultRef = `refs/remotes/${record.remote}/${record.branch}`;
101
+ // PR-001: fetch first, always, and judge landed-ness only against this.
102
+ const fetched = runGit(record.project, [
103
+ "fetch",
104
+ record.remote,
105
+ `+refs/heads/${record.branch}:${defaultRef}`,
106
+ ]);
107
+ if (fetched.status !== 0) {
108
+ return {
109
+ ok: false,
110
+ reason: `fetch of ${record.remote}/${record.branch} failed, so landed-ness cannot ` +
111
+ `be judged against fresh remote state: ${singleLine(fetched.stderr)}`,
112
+ };
113
+ }
114
+ return { ok: true, value: { meta, record, worktree, defaultRef } };
115
+ }
116
+ /** Uncommitted changes or untracked files in the task worktree. */
117
+ function worktreeDirty(worktree) {
118
+ const status = runGit(worktree, ["status", "--porcelain"]);
119
+ if (status.status !== 0) {
120
+ return {
121
+ ok: false,
122
+ reason: `cannot verify worktree cleanliness at ${worktree}: ${singleLine(status.stderr)}`,
123
+ };
124
+ }
125
+ return { ok: true, dirty: status.stdout.trim() !== "" };
126
+ }
127
+ /** Commit the leavings under the WIP label and push the branch (R-081a). */
128
+ function salvageLeavings(context) {
129
+ const { worktree, record } = context;
130
+ const added = runGit(worktree, ["add", "-A"]);
131
+ if (added.status !== 0) {
132
+ return { ok: false, reason: `salvage failed at git add: ${singleLine(added.stderr)}` };
133
+ }
134
+ const message = `${SALVAGE_PREFIX} leavings salvaged by tiphys teardown for task ${context.meta.id}`;
135
+ // CI runners have no git identity, and the fleet never reads or writes
136
+ // user or global git configuration (EXT-F-02 option B): the machine
137
+ // identity is set command-scoped, exactly as init's bootstrap commit
138
+ // does it.
139
+ const committed = runGit(worktree, ["commit", "-m", message], {
140
+ GIT_AUTHOR_NAME: MACHINE_IDENTITY_NAME,
141
+ GIT_AUTHOR_EMAIL: MACHINE_IDENTITY_EMAIL,
142
+ GIT_COMMITTER_NAME: MACHINE_IDENTITY_NAME,
143
+ GIT_COMMITTER_EMAIL: MACHINE_IDENTITY_EMAIL,
144
+ });
145
+ if (committed.status !== 0) {
146
+ return {
147
+ ok: false,
148
+ reason: `salvage failed at git commit: ${singleLine(committed.stderr) || singleLine(committed.stdout)}`,
149
+ };
150
+ }
151
+ const pushed = runGit(record.project, [
152
+ "push",
153
+ record.remote,
154
+ `refs/heads/${record.branchName}:refs/heads/${record.branchName}`,
155
+ ]);
156
+ if (pushed.status !== 0) {
157
+ return {
158
+ ok: false,
159
+ reason: `salvage committed the leavings as "${SALVAGE_PREFIX} ..." but the push of ` +
160
+ `${record.branchName} failed: ${singleLine(pushed.stderr)}; the commit is local only`,
161
+ };
162
+ }
163
+ return { ok: true };
164
+ }
165
+ /**
166
+ * Remove the worktree and close the task. Never called before every
167
+ * refusal rule has passed.
168
+ */
169
+ async function finish(fleet, context, options) {
170
+ const destroyed = await poolDestroy(fleet, {
171
+ taskId: context.meta.id,
172
+ discard: options.discard,
173
+ deleteBranchForce: options.deleteBranchForce,
174
+ });
175
+ if (!destroyed.ok) {
176
+ // The destroy's own reason distinguishes a stage-2 refusal (a true
177
+ // no-op) from a stage-3 partial failure (worktree already removed,
178
+ // survivors enumerated). It is passed through verbatim, and the only
179
+ // thing added is what this layer knows for certain.
180
+ return {
181
+ ok: false,
182
+ reason: `pool destroy did not complete: ${destroyed.reason}; task ${context.meta.id} stays open`,
183
+ };
184
+ }
185
+ // F-1. The worktree is GONE by this point, so a raised write here is
186
+ // not a refusal and must never crash the command: an uncaught throw
187
+ // left meta.json reading "open" beside a worktree that no longer
188
+ // exists, which is the single state authority (C-1) telling a later
189
+ // reader, and the M1-P5 watcher, something false. It is the same
190
+ // partial-failure shape M1-P3 defined for destroy, reported in the
191
+ // same vocabulary, with the manual remedy named.
192
+ const closed = runStep(`marking task ${context.meta.id} closed`, () => {
193
+ setTaskStatus(fleet, context.meta, "closed");
194
+ });
195
+ if (!closed.ok) {
196
+ const removed = destroyed.value.deletedBranch === undefined
197
+ ? `worktree ${context.worktree} HAS BEEN REMOVED`
198
+ : `worktree ${context.worktree} HAS BEEN REMOVED and branch ` +
199
+ `${destroyed.value.deletedBranch} was deleted (it was ` +
200
+ `${destroyed.value.deletedSha ?? "unknown"})`;
201
+ return {
202
+ ok: false,
203
+ reason: `partial teardown of task id ${context.meta.id}: ${removed}, but ` +
204
+ `${metaPath(fleet, context.meta.id)} could not be marked closed ` +
205
+ `(${closed.reason}); the task record still reads status open although its ` +
206
+ `worktree is gone, so repair that file and set "status": "closed" by hand`,
207
+ };
208
+ }
209
+ return { ok: true, value: { taskId: context.meta.id, salvaged: options.salvaged } };
210
+ }
211
+ export async function teardownTask(fleet, options) {
212
+ const holdership = checkHoldership(fleet);
213
+ if (!holdership.ok) {
214
+ return { ok: false, reason: holdership.reason };
215
+ }
216
+ const resolved = resolveContext(fleet, options.taskId);
217
+ if (!resolved.ok) {
218
+ return resolved;
219
+ }
220
+ const context = resolved.value;
221
+ const { meta, record, worktree } = context;
222
+ if (meta.shape === "scout") {
223
+ // (a) A scout is judged by its report, never by its scratch tree.
224
+ if (!existsSync(reportPath(fleet, options.taskId))) {
225
+ return {
226
+ ok: false,
227
+ reason: `scout task ${options.taskId} has no report: ${reportPath(fleet, options.taskId)} ` +
228
+ `is absent, and a scout is torn down only once it has reported`,
229
+ };
230
+ }
231
+ // Scout worktrees are scratch and scouts never push (PR-010), so the
232
+ // dirty tree is discarded. --delete-branch-force is deliberately NOT
233
+ // passed: --discard's plan-defined meaning is the dirty-tree
234
+ // override only, and a scout that committed to its scratch branch is
235
+ // refused rather than having those commits deleted silently, which is
236
+ // exactly the M1-P3 V-1 defect.
237
+ //
238
+ // CR-304: teardown answers that question ITSELF, before calling
239
+ // destroy, because the pool's own refusal advises passing
240
+ // --delete-branch-force, a flag teardown does not accept, so the
241
+ // operator was told to do something impossible through the command
242
+ // that printed it and the task could never reach closed. This
243
+ // refusal is a true no-op and names a route that works; the pool's
244
+ // gate stays behind it as the enforcer.
245
+ const tip = runGit(record.project, [
246
+ "rev-parse",
247
+ "--verify",
248
+ "--quiet",
249
+ `refs/heads/${record.branchName}^{commit}`,
250
+ ]);
251
+ if (tip.status === 0 && tip.stdout.trim() !== record.baseSha) {
252
+ return {
253
+ ok: false,
254
+ reason: `scout task ${options.taskId} has commits on its scratch branch ` +
255
+ `${record.branchName} (tip ${tip.stdout.trim()}, base ${record.baseSha}) and ` +
256
+ `teardown never deletes committed work: copy or push them somewhere ` +
257
+ `durable, then release the branch with "git -C ${record.project} update-ref ` +
258
+ `refs/heads/${record.branchName} ${record.baseSha}" and re-run teardown`,
259
+ };
260
+ }
261
+ return finish(fleet, context, {
262
+ discard: true,
263
+ deleteBranchForce: false,
264
+ salvaged: false,
265
+ });
266
+ }
267
+ // (b) ship.
268
+ const dirty = worktreeDirty(worktree);
269
+ if (!dirty.ok) {
270
+ return { ok: false, reason: dirty.reason };
271
+ }
272
+ if (dirty.dirty && !options.salvage) {
273
+ return {
274
+ ok: false,
275
+ reason: `worktree ${worktree} has uncommitted changes or untracked files; commit ` +
276
+ `or land them first, or pass --salvage to commit them as ` +
277
+ `"${SALVAGE_PREFIX} ..." and push`,
278
+ };
279
+ }
280
+ const branchRef = `refs/heads/${record.branchName}`;
281
+ const landed = landedness(record.project, branchRef, context.defaultRef);
282
+ if (landed.kind === "inconclusive") {
283
+ return {
284
+ ok: false,
285
+ reason: `cannot determine whether ${record.branchName} is landed on ` +
286
+ `${record.remote}/${record.branch} (${landed.detail}); refusing`,
287
+ };
288
+ }
289
+ if (landed.kind === "unlanded") {
290
+ return {
291
+ ok: false,
292
+ reason: `branch ${record.branchName} is not landed on ${record.remote}/${record.branch}; ` +
293
+ `land it before tearing the task down` +
294
+ (options.salvage ? " (--salvage rescues leavings, it never lands work)" : ""),
295
+ };
296
+ }
297
+ let salvaged = false;
298
+ if (dirty.dirty) {
299
+ const rescue = salvageLeavings(context);
300
+ if (!rescue.ok) {
301
+ return rescue;
302
+ }
303
+ salvaged = true;
304
+ }
305
+ // The landed judgement is what authorizes deleting the task branch
306
+ // (V-1's explicit flag, which src/pool.ts documents as coming from
307
+ // exactly here). After a salvage the branch carries the WIP commit,
308
+ // which is on the remote, so the local branch is safe to remove.
309
+ return finish(fleet, context, {
310
+ discard: false,
311
+ deleteBranchForce: true,
312
+ salvaged,
313
+ });
314
+ }
@@ -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[];