@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,1003 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { constants } from "node:os";
4
+ import { BUILT_IN_ADAPTER_NAME, selectAdapter } from "./adapters/load.js";
5
+ import { assembleBrief } from "./brief.js";
6
+ import { CREDENTIAL_STORE_REDIRECTIONS, buildChildEnv, extensionName, extensionReason, refuseExtraAllowlist, scrubRoot, } from "./exec/env.js";
7
+ import { guardSharedRegister } from "./exclusion.js";
8
+ import { writeTurnEndHook } from "./hooks.js";
9
+ import { poolCreate, poolDestroy, worktreePath } from "./pool.js";
10
+ import { checkHoldership, executorRecordPath, metaPath, readRegularFileIfPresent, runStep, runStepAsync, taskDir, taskDirExists, taskDirOccupied, turnEndPath, writeTaskMeta, } from "./task.js";
11
+ /**
12
+ * THE FIELDS AN ADAPTER MAY NAME IN `requires`, AND WHETHER EACH ONE CAN BE
13
+ * ABSENT (M4-P3 criteria 2 and 3).
14
+ *
15
+ * This is the ONE source for both halves of the requirement check, and it is
16
+ * one source on purpose: a list of legal names maintained beside a separate
17
+ * list of presence tests is two things that drift, and the drift is silent in
18
+ * exactly the direction that matters (a new request field nobody can require,
19
+ * or a requirable name nothing can satisfy). `requirableRequestFields` reads
20
+ * its answer back out of this function, so there is nothing to keep in step.
21
+ *
22
+ * The six names set to `true` unconditionally are the ones the KERNEL
23
+ * produces: it has a task id because it was given one, a worktree and a
24
+ * record path because it computed them, a command because it refused an empty
25
+ * one, a hook path because it wrote the hook, and a brief path because
26
+ * `assembleBrief` returned one. None of them can be absent by the time the
27
+ * request is built, and `test/spawn.test.ts` checks that claim against the
28
+ * request an adapter is actually handed rather than leaving it asserted here.
29
+ */
30
+ function requestFieldPresence(options) {
31
+ const presence = new Map();
32
+ for (const name of ["taskId", "worktree", "command", "hookPath", "recordPath", "briefPath"]) {
33
+ presence.set(name, true);
34
+ }
35
+ presence.set("deadlineSeconds", options.deadlineSeconds !== undefined);
36
+ presence.set("env", options.allowPrCredentials !== true);
37
+ presence.set("role", options.role !== undefined);
38
+ presence.set("declaredTier", options.declaredTier !== undefined);
39
+ presence.set("phaseId", options.phaseId !== undefined);
40
+ return presence;
41
+ }
42
+ /** The closed set of names an adapter may name in `requires`, in field order. */
43
+ export function requirableRequestFields() {
44
+ return [
45
+ ...requestFieldPresence({
46
+ taskId: "",
47
+ project: "",
48
+ briefFile: "",
49
+ shape: "ship",
50
+ exec: "",
51
+ deadlineSeconds: undefined,
52
+ offline: false,
53
+ // M4-P8: a REQUIRED SpawnOptions field, and deliberately NOT a
54
+ // requirable request field. `requestFieldPresence` maps names an
55
+ // adapter may declare in `requires`, and every one of them is a field
56
+ // of `ExecutorRequest`; the payload class never crosses that seam, so
57
+ // it appears here only because the literal must typecheck.
58
+ payloadClass: "project",
59
+ role: undefined,
60
+ declaredTier: undefined,
61
+ phaseId: undefined,
62
+ }).keys(),
63
+ ];
64
+ }
65
+ /**
66
+ * THE ADAPTER CONTRACT CHECK (M4-P3 criterion 3): a requirement naming a
67
+ * field that does not exist is a DEFECT IN THE ADAPTER, refused when the
68
+ * adapter is taken up and before anything is created.
69
+ *
70
+ * IT IS A DIFFERENT QUESTION FROM `checkAdapterRequirements` BELOW, AND THE
71
+ * ORDER IS LOAD-BEARING. The presence test there answers "is this field
72
+ * absent", and an unknown key is absent too, so a single check written that
73
+ * way would answer a defect in the adapter with a message about a missing
74
+ * flag: an operator would go looking for a `--modelName` that the kernel has
75
+ * no field for and could never accept. Running this one FIRST is what keeps
76
+ * the two answers distinct, and `test/spawn.test.ts` asserts that they say
77
+ * structurally different things rather than merely both refusing.
78
+ */
79
+ export function checkAdapterContract(adapter) {
80
+ const known = requirableRequestFields();
81
+ // THE DECLARATION IS FOREIGN INPUT, not a value this module produced. From
82
+ // M4-P4 an adapter is resolved from the fleet home, so `requires` arrives
83
+ // from a module the kernel did not write and the TYPE IS A PROMISE, not a
84
+ // guarantee. A missing or non-array declaration reaching `.filter` would be
85
+ // a TypeError raised out of `spawnTask` with no reason line, which is the
86
+ // same "refusal that arrives as a crash" shape the rest of this check
87
+ // exists to prevent, one level lower.
88
+ if (!Array.isArray(adapter.requires)) {
89
+ return {
90
+ ok: false,
91
+ reason: `the ${adapter.name} adapter does not declare requires as an array, so the ` +
92
+ `kernel cannot tell what it needs; an adapter that needs nothing declares []`,
93
+ };
94
+ }
95
+ const unknown = adapter.requires.filter((name) => !known.includes(name));
96
+ if (unknown.length === 0) {
97
+ return { ok: true };
98
+ }
99
+ return {
100
+ ok: false,
101
+ reason: `the ${adapter.name} adapter declares a requirement on ${unknown.join(", ")}, ` +
102
+ `which the executor request contract has no field for; the requirable ` +
103
+ `fields are ${known.join(", ")}`,
104
+ };
105
+ }
106
+ /**
107
+ * THE DECLARED-REQUIREMENT CHECK (M4-P3 criterion 2): a field the adapter
108
+ * declared it needs, and the spawn was not given, is a USAGE ERROR, and a
109
+ * usage error creates nothing.
110
+ *
111
+ * THE DANGEROUS STATE THIS EXISTS FOR is not "an adapter gets undefined". It
112
+ * is an adapter DISCOVERING that it got undefined, and raising, after pool
113
+ * create has already made a worktree, a branch and a pool record. That is the
114
+ * measured `--deadline` defect at src/commands/spawn.ts:81 with a new field:
115
+ * a value the kernel could not represent used to raise inside the adapter,
116
+ * after the creation, and the repair was to refuse at parse time. This is the
117
+ * same repair one field along, and it is the reason the check runs at the top
118
+ * of `spawnTask` rather than beside the launch where the request is built.
119
+ */
120
+ export function checkAdapterRequirements(adapter, options) {
121
+ const presence = requestFieldPresence(options);
122
+ const unmet = adapter.requires.filter((name) => presence.get(name) !== true);
123
+ if (unmet.length === 0) {
124
+ return { ok: true };
125
+ }
126
+ return {
127
+ ok: false,
128
+ reason: `the ${adapter.name} adapter requires ${unmet.join(", ")}, and this spawn ` +
129
+ `supplied no value for ${unmet.length === 1 ? "it" : "them"}; nothing was created`,
130
+ };
131
+ }
132
+ /**
133
+ * THE CREDENTIAL POLICY CHECK (M4-P8 steps 2, 3 and 5), and it runs before
134
+ * ANYTHING is resolved, loaded or created.
135
+ *
136
+ * Three refusals, in this order, and the order is the fail-closed one:
137
+ *
138
+ * 1. an absent or unrecognised `payloadClass`. Checked first so that
139
+ * refusal 2 never has to reason about an unknown value: without this,
140
+ * `payloadClass !== "project"` would read an omission as permission.
141
+ * 2. the declared escape hatch asked for on a PROJECT payload. This is
142
+ * the pairing the phase exists to refuse: `allowPrCredentials` hands
143
+ * the parent environment over UNCHANGED (see ExecutorRequest.env),
144
+ * credentials and all, and M2-P8 criterion 1 wrote it for the
145
+ * orchestrator's own spawns only. Until this phase the option was
146
+ * reachable from the library seam with nothing between it and a
147
+ * project payload.
148
+ * 3. an extension entry the child must not carry, which is the same
149
+ * vocabulary check `buildChildEnv` makes, made EARLIER. The
150
+ * duplication is deliberate and is not two implementations: both call
151
+ * `refuseExtraAllowlist`. Making it here as well is what keeps a
152
+ * rejected widening from costing a worktree, a branch and a pool
153
+ * record, because `buildChildEnv` does not run until after pool
154
+ * create.
155
+ *
156
+ * It returns a reason rather than throwing, because every refusal on this
157
+ * path must be able to say "nothing was created" in the same sentence.
158
+ */
159
+ export function checkCredentialPolicy(options) {
160
+ const declared = ["orchestrator", "project"];
161
+ if (!declared.includes(options.payloadClass)) {
162
+ return {
163
+ ok: false,
164
+ reason: `this spawn declares no payload class (payloadClass was ` +
165
+ `${JSON.stringify(options.payloadClass)}); it is required and has no ` +
166
+ `default, because an omitted class would otherwise take the ` +
167
+ `orchestrator's authority by default: pass one of ` +
168
+ `${declared.join(", ")}; nothing was created`,
169
+ };
170
+ }
171
+ if (options.allowPrCredentials === true && options.payloadClass === "project") {
172
+ return {
173
+ ok: false,
174
+ reason: `allowPrCredentials is the declared escape hatch from the credential ` +
175
+ `scrub and hands the parent environment over unchanged, so it may not ` +
176
+ `be combined with payloadClass "project": a project payload never ` +
177
+ `receives the orchestrator's credentials; nothing was created`,
178
+ };
179
+ }
180
+ // `reason-required`: THIS is the audited route (DR-0039 condition 2, M4-P8
181
+ // criterion 4), and the argument is passed explicitly rather than
182
+ // defaulted, because a default would be the same "absent takes the
183
+ // permissive arm" shape CR-B-002 is.
184
+ const extensionRefusal = refuseExtraAllowlist(options.extraAllowlist ?? [], "reason-required");
185
+ if (extensionRefusal !== undefined) {
186
+ return { ok: false, reason: `${extensionRefusal}; nothing was created` };
187
+ }
188
+ return { ok: true };
189
+ }
190
+ /**
191
+ * Render `redirectionSource` into the refusal sentence. It names the ARTIFACT
192
+ * the values were read from, never a party that observed them: the turn-end
193
+ * record is adapter-reachable (src/hooks.ts), so a phrase like "observed
194
+ * child-side", which is what this said until the DR-0047 sweep fix round,
195
+ * asserts more than the kernel checked (CR-F-CRED-001).
196
+ */
197
+ function redirectionSourcePhrase(source) {
198
+ if (source === "turn-end-record") {
199
+ return "read from the turn-end record, which the kernel generates the hook for and an adapter can also write";
200
+ }
201
+ if (source === "adapter") {
202
+ return "read from the adapter's own launch report";
203
+ }
204
+ return "read from no pointer evidence at all";
205
+ }
206
+ /**
207
+ * COMPARE THE HANDOVER (M4-P8 criterion 6, repaired for CR-B-001).
208
+ *
209
+ * `handed` is what the kernel built and passed, `reported` is the name set
210
+ * the adapter says it launched with, `pointers` is what the five
211
+ * CREDENTIAL_STORE_REDIRECTIONS actually were where the payload ran.
212
+ *
213
+ * TWO PROPERTIES, AND THE STATUS SAYS WHICH WERE CHECKED. The name-set
214
+ * comparison alone used to be written down as `compared`, which an operator
215
+ * reads as "the handover was verified"; an adapter that keeps the name set
216
+ * byte-identical and puts `HOME` and `XDG_CONFIG_HOME` back to their real
217
+ * paths defeats the M2R-004 defense entirely and was recorded as clean. See
218
+ * `CredentialHandoverRecord` for the five status values and for why the
219
+ * VALUES are still never written into the record.
220
+ *
221
+ * The pointer comparison is by value and the values are DISCARDED: only the
222
+ * names that differ survive into `changedRedirections`. A name the kernel
223
+ * never handed over is not compared, because there is no handed value to
224
+ * compare it against, and the name-set arms are what speak to that case.
225
+ */
226
+ export function compareHandover(handed, reported, pointers) {
227
+ if (handed === undefined) {
228
+ return {
229
+ status: "not-applicable",
230
+ added: [],
231
+ removed: [],
232
+ changedRedirections: [],
233
+ };
234
+ }
235
+ const changedRedirections = pointers === undefined
236
+ ? []
237
+ : CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)
238
+ .filter((name) => {
239
+ const handedValue = handed[name];
240
+ if (handedValue === undefined) {
241
+ return false;
242
+ }
243
+ return pointers.values[name] !== handedValue;
244
+ })
245
+ .sort();
246
+ const pointerPart = pointers === undefined ? {} : { redirectionSource: pointers.source };
247
+ if (reported === undefined) {
248
+ return {
249
+ status: pointers === undefined ? "unreported" : "pointers-compared",
250
+ added: [],
251
+ removed: [],
252
+ changedRedirections,
253
+ ...pointerPart,
254
+ };
255
+ }
256
+ const handedNames = new Set(Object.keys(handed));
257
+ const reportedNames = new Set(reported);
258
+ return {
259
+ status: pointers === undefined ? "names-compared" : "compared",
260
+ added: [...reportedNames].filter((name) => !handedNames.has(name)).sort(),
261
+ removed: [...handedNames].filter((name) => !reportedNames.has(name)).sort(),
262
+ changedRedirections,
263
+ ...pointerPart,
264
+ };
265
+ }
266
+ /**
267
+ * A payload terminated by a signal has no exit code. The shell's
268
+ * convention (128 + signal number) is used so the turn-end record always
269
+ * carries a number. M1 never drives this path; it exists so that the
270
+ * type is honest rather than filled with a guess.
271
+ */
272
+ function payloadExitCode(status, signal) {
273
+ if (status !== null) {
274
+ return status;
275
+ }
276
+ const signals = constants.signals;
277
+ const number = signal === null ? undefined : signals[signal];
278
+ return 128 + (number ?? 0);
279
+ }
280
+ /**
281
+ * The local subprocess adapter, the one M1 ships (and the one the exit
282
+ * test's stub payload uses).
283
+ *
284
+ * The payload runs to COMPLETION before this returns: the kernel never
285
+ * auto-backgrounds anything (plan constraint C-3, FM-054), so there is
286
+ * no daemonize path here to forget to guard.
287
+ *
288
+ * `async` since M4-P2, and its BODY IS UNCHANGED: every statement below
289
+ * is still synchronous, `spawnSync` is still what runs both children, and
290
+ * the promise this now returns is already settled by the time the first
291
+ * `await` on it runs. The keyword is there because the INTERFACE is async
292
+ * for the adapters that need it, not because this adapter gained a
293
+ * concurrency path to get wrong.
294
+ *
295
+ * It runs without a shell on purpose. Under a shell a missing payload
296
+ * binary arrives as an ordinary exit code 127, indistinguishable from a
297
+ * payload that ran and failed, and spawn's rollback rule turns on
298
+ * exactly that distinction.
299
+ */
300
+ export const subprocessAdapter = {
301
+ /*
302
+ * THE NAME IS THE CONSTANT, not a second copy of the string (M4-P4
303
+ * criterion 5). The loader refuses a loaded adapter that claims this name,
304
+ * and a refusal compared against a literal spelled out in another file is
305
+ * a guard that goes quiet the day one of the two is edited.
306
+ */
307
+ name: BUILT_IN_ADAPTER_NAME,
308
+ /*
309
+ * NOTHING, and that is a statement rather than a default (M4-P3). This
310
+ * adapter runs a command in a directory; it reads no brief, plays no role
311
+ * and asks for no tier, so declaring anything here would be a requirement
312
+ * the adapter does not have. The empty declaration is what makes the
313
+ * refusal path exercisable ONLY by an adapter that genuinely needs
314
+ * something, which is the state the check exists for.
315
+ */
316
+ requires: [],
317
+ async launch(request) {
318
+ const launchedAt = new Date();
319
+ const record = {
320
+ adapter: BUILT_IN_ADAPTER_NAME,
321
+ launchedAt: launchedAt.toISOString(),
322
+ };
323
+ if (request.deadlineSeconds !== undefined) {
324
+ record.deadline = new Date(launchedAt.getTime() + request.deadlineSeconds * 1000).toISOString();
325
+ }
326
+ // VERBATIM (M4-P3 criterion 6): the value the caller supplied, byte for
327
+ // byte, with no normalisation, no lowercasing and no vocabulary check.
328
+ // The kernel holds no tier vocabulary and no role vocabulary, so there is
329
+ // nothing here it could legitimately validate against; a kernel that
330
+ // "tidied" either value would be holding one.
331
+ if (request.declaredTier !== undefined) {
332
+ record.requestedTier = request.declaredTier;
333
+ }
334
+ if (request.role !== undefined) {
335
+ record.requestedRole = request.role;
336
+ }
337
+ // The record write happens BEFORE the payload, so a failure here is
338
+ // provably a launch failure and is safe to roll back. Everything
339
+ // after the payload starts is reported as incomplete instead, which
340
+ // never rolls anything back (F-2's fix must not become V-1's defect).
341
+ const written = runStep(`writing the launch record ${request.recordPath}`, () => {
342
+ writeFileSync(request.recordPath, `${JSON.stringify(record, null, 2)}\n`);
343
+ });
344
+ if (!written.ok) {
345
+ return { kind: "launch-failed", reason: written.reason };
346
+ }
347
+ const [program, ...args] = request.command;
348
+ if (program === undefined) {
349
+ return { kind: "launch-failed", reason: "empty --exec command" };
350
+ }
351
+ const result = spawnSync(program, args, {
352
+ cwd: request.worktree,
353
+ stdio: "inherit",
354
+ // The scrubbed environment (M2-P8). Spread rather than `env:
355
+ // request.env` so an undefined request.env means "no env option at
356
+ // all", which is Node's documented full-inheritance form.
357
+ ...(request.env === undefined ? {} : { env: request.env }),
358
+ });
359
+ if (result.error !== undefined) {
360
+ return {
361
+ kind: "launch-failed",
362
+ reason: `cannot launch ${program}: ${String(result.error)}`,
363
+ };
364
+ }
365
+ const exitCode = payloadExitCode(result.status, result.signal);
366
+ // The payload has run. Every failure below, raised or returned, is
367
+ // reported as incomplete: the worktree may hold real work now, so
368
+ // nothing here may lead to a rollback.
369
+ const hooked = runStep(`invoking the turn-end hook ${request.hookPath}`, () => spawnSync(process.execPath, [request.hookPath, String(exitCode)], {
370
+ stdio: "inherit",
371
+ // The hook child gets the SAME scrubbed environment as the
372
+ // payload (M2R-004 edit 4): a second launch nobody scrubbed is
373
+ // exactly the leak the finding names. The generated hook script
374
+ // itself reads no environment at all (src/hooks.ts), so the
375
+ // scrub cannot break it.
376
+ ...(request.env === undefined ? {} : { env: request.env }),
377
+ }));
378
+ if (!hooked.ok) {
379
+ return {
380
+ kind: "incomplete",
381
+ reason: `the payload exited ${String(exitCode)} but the turn-end record could not ` +
382
+ `be written (${hooked.reason}); the worktree and the task directory are ` +
383
+ `left in place`,
384
+ };
385
+ }
386
+ const hook = hooked.value;
387
+ if (hook.error !== undefined || hook.status !== 0) {
388
+ const detail = hook.error === undefined ? `exit ${String(hook.status)}` : String(hook.error);
389
+ return {
390
+ kind: "incomplete",
391
+ reason: `the payload exited ${String(exitCode)} but the turn-end hook ` +
392
+ `${request.hookPath} failed (${detail}); the worktree and the task ` +
393
+ `directory are left in place`,
394
+ };
395
+ }
396
+ // WHAT THIS ADAPTER ACTUALLY LAUNCHED WITH (M4-P8 criterion 6), read
397
+ // off `request.env` at the point of report rather than recomputed from
398
+ // the option object: the two spawnSync calls above spread that same
399
+ // value, so a mutation between the handover and the launch shows up
400
+ // here. Sorted so the comparison is over a set, not an insertion order.
401
+ return {
402
+ kind: "completed",
403
+ exitCode,
404
+ ...(request.env === undefined
405
+ ? {}
406
+ : {
407
+ launchedEnvNames: Object.keys(request.env).sort(),
408
+ // The five pointers as this adapter launched them, read off the
409
+ // same `request.env` the two spawnSync calls above spread
410
+ // (CR-B-001). The kernel treats this as the WEAKER of its two
411
+ // pointer sources and prefers the turn-end hook's child-written
412
+ // observation; reporting it anyway means an adapter that cannot
413
+ // run the hook still has something to be compared against.
414
+ launchedRedirections: Object.fromEntries(CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => [
415
+ redirection.name,
416
+ request.env?.[redirection.name] ?? null,
417
+ ])),
418
+ }),
419
+ };
420
+ },
421
+ };
422
+ /**
423
+ * THE COMPLETION PRECONDITION (M4-P2 step 5).
424
+ *
425
+ * `completed` used to be believed because the only adapter that could
426
+ * return it was the one three lines above, which invokes the turn-end hook
427
+ * itself before returning. Once `launch` is a promise that is no longer
428
+ * true: any adapter may resolve `completed` while its agent is still
429
+ * running, and `spawnTask` would then delete the scrub root out from under
430
+ * a LIVE child's HOME and report success.
431
+ *
432
+ * So the kernel stops taking the adapter's word and reads the artifact the
433
+ * payload's own exit produces. tasks/<id>/turn-end is written by the
434
+ * generated hook (src/hooks.ts:57) with the payload's exit code; it is the
435
+ * same file the watcher wakes on. A `completed` with no readable turn-end
436
+ * record is refused.
437
+ *
438
+ * FOUR distinct refusals, not one, and the distinction is the point. A
439
+ * check written as `existsSync` is green on a present-but-corrupt record,
440
+ * which is a guard whose condition does not test the property that matters.
441
+ * Absent, unreadable, unparseable and wrongly-shaped are all "this is not
442
+ * evidence that the payload ended", and each says which one it was.
443
+ *
444
+ * The read goes through `readRegularFileIfPresent` rather than
445
+ * `readFileSync` so a FIFO at the turn-end path is a refusal and not a
446
+ * hang: this is the same hazard CR-520 records for meta.json, one path
447
+ * along.
448
+ *
449
+ * WHAT THIS DOES NOT DO: it never rolls anything back and it never removes
450
+ * anything. A refusal here is reported with the residue enumerated, exactly
451
+ * like the `incomplete` arm, because the payload demonstrably ran far
452
+ * enough for an adapter to claim it finished.
453
+ */
454
+ function turnEndEvidence(fleet, taskId) {
455
+ const path = turnEndPath(fleet, taskId);
456
+ const read = readRegularFileIfPresent(path);
457
+ if (read.kind === "absent") {
458
+ return { ok: false, reason: `the turn-end record ${path} was never written` };
459
+ }
460
+ if (read.kind === "refused") {
461
+ return {
462
+ ok: false,
463
+ reason: `the turn-end record ${path} could not be read (${read.reason})`,
464
+ };
465
+ }
466
+ let parsed;
467
+ try {
468
+ parsed = JSON.parse(read.body);
469
+ }
470
+ catch (error) {
471
+ const detail = error instanceof Error ? error.message : String(error);
472
+ return {
473
+ ok: false,
474
+ reason: `the turn-end record ${path} does not parse as JSON (${detail})`,
475
+ };
476
+ }
477
+ if (typeof parsed !== "object" || parsed === null) {
478
+ return {
479
+ ok: false,
480
+ reason: `the turn-end record ${path} does not parse as a turn-end record`,
481
+ };
482
+ }
483
+ const candidate = parsed;
484
+ if (typeof candidate.endedAt !== "string" || !Number.isInteger(candidate.exitCode)) {
485
+ return {
486
+ ok: false,
487
+ reason: `the turn-end record ${path} does not parse as a turn-end record ` +
488
+ `(it needs a string endedAt and an integer exitCode)`,
489
+ };
490
+ }
491
+ // THE CHILD-WRITTEN POINTER OBSERVATION (CR-B-001). Absent on a record
492
+ // written by an older hook, and the ABSENCE IS NOT A PASS: the caller turns
493
+ // it into a weaker status word (`names-compared`) rather than into silence,
494
+ // which is the whole lesson of the finding one file over. A malformed or
495
+ // partially-typed `env` is read entry by entry and anything that is neither
496
+ // a string nor null is dropped, so a hostile record cannot smuggle an
497
+ // object into the comparison.
498
+ let observed;
499
+ if (typeof candidate.env === "object" && candidate.env !== null) {
500
+ observed = {};
501
+ for (const [name, value] of Object.entries(candidate.env)) {
502
+ if (typeof value === "string" || value === null) {
503
+ observed[name] = value;
504
+ }
505
+ }
506
+ }
507
+ return observed === undefined ? { ok: true } : { ok: true, observed };
508
+ }
509
+ /**
510
+ * Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
511
+ * this phase it is a documented no-op, so that P5 adds a body here
512
+ * rather than a new call site in the middle of the spawn sequence.
513
+ */
514
+ export function livenessGuard(fleet) {
515
+ void fleet;
516
+ return { ok: true };
517
+ }
518
+ /**
519
+ * Tokenise --exec. M1 supports a program plus plain arguments separated
520
+ * by whitespace; there is no shell and no quoting. Anything richer is an
521
+ * adapter's business (M4 era), not a string this kernel re-parses.
522
+ */
523
+ export function parseExecCommand(command) {
524
+ return command.split(/\s+/u).filter((token) => token !== "");
525
+ }
526
+ /**
527
+ * One spawn. See the module docs for the rollback contract.
528
+ *
529
+ * --offline is passed straight through to pool create and is the only
530
+ * way a fleet can reach an offline base. meta.baseOffline is then COPIED
531
+ * from the pool record the create returned, never recomputed from this
532
+ * flag: pool create attempts the fetch even under --offline and records
533
+ * offline false when it succeeds, so a spawn that trusted its own flag
534
+ * would mark a perfectly fetched base as offline (PR-212, and the
535
+ * provenance inversion measured as V-2 in M1-P3).
536
+ */
537
+ export async function spawnTask(fleet, options) {
538
+ const { taskId } = options;
539
+ // THE CREDENTIAL POLICY IS CHECKED BEFORE THE ADAPTER IS EVEN RESOLVED
540
+ // (M4-P8 step 5). Adapter selection reads the fleet home and may EVALUATE
541
+ // a module the kernel did not write (M4-P4), so a spawn whose credential
542
+ // shape is already refused must not get that far: the refusal creates
543
+ // nothing, loads nothing and runs nothing.
544
+ const credentials = checkCredentialPolicy(options);
545
+ if (!credentials.ok) {
546
+ return { ok: false, reason: credentials.reason };
547
+ }
548
+ // THE ADAPTER IS RESOLVED FIRST (M4-P3), earlier than it used to be, and
549
+ // the move is the point rather than a tidy-up: both checks below must
550
+ // refuse before ANYTHING is created, and until this phase the adapter was
551
+ // not named until the launch call site, which is after pool create has made
552
+ // a worktree, a branch and a pool record.
553
+ //
554
+ // M4-P4 PUTS THE LOAD IN THAT SAME WINDOW, and for the same reason one
555
+ // level out. Resolving, evaluating and shape-checking a module the kernel
556
+ // did not write are three more ways to discover that this spawn cannot
557
+ // happen, and every one of them must happen while there is still nothing to
558
+ // roll back: a malformed adapter found after pool create is criterion 4's
559
+ // dangerous state, which is M4-P3's refusal-after-creation shape with a
560
+ // different cause.
561
+ //
562
+ // A DIRECTLY SUPPLIED ADAPTER SHORT-CIRCUITS THE WHOLE SELECTION, including
563
+ // the fleet-home read. The kernel's own tests hand an object across this
564
+ // seam, and a test fleet that happened to declare a default would otherwise
565
+ // silently change which adapter those tests exercised.
566
+ let adapter;
567
+ if (options.adapter !== undefined) {
568
+ adapter = options.adapter;
569
+ }
570
+ else {
571
+ const selection = await selectAdapter(fleet, options.adapterSpecifier);
572
+ if (!selection.ok) {
573
+ return { ok: false, reason: selection.reason };
574
+ }
575
+ adapter = selection.adapter ?? subprocessAdapter;
576
+ }
577
+ // A requirement naming a field the contract does not have is a defect in
578
+ // the adapter, refused as the adapter is taken up (criterion 3). It runs
579
+ // BEFORE the presence check because an unknown name is also an absent one,
580
+ // and answering a contract defect with a message about a missing value
581
+ // sends the operator looking for a flag that cannot exist.
582
+ const contract = checkAdapterContract(adapter);
583
+ if (!contract.ok) {
584
+ return { ok: false, reason: contract.reason };
585
+ }
586
+ // A declared requirement the spawn cannot meet is a usage error, and a
587
+ // usage error creates nothing (criterion 2).
588
+ const requirements = checkAdapterRequirements(adapter, options);
589
+ if (!requirements.ok) {
590
+ return { ok: false, reason: requirements.reason };
591
+ }
592
+ const liveness = livenessGuard(fleet);
593
+ if (!liveness.ok) {
594
+ return { ok: false, reason: liveness.reason };
595
+ }
596
+ const holdership = checkHoldership(fleet);
597
+ if (!holdership.ok) {
598
+ return { ok: false, reason: holdership.reason };
599
+ }
600
+ /* THE CROSS-ENVIRONMENT HALF OF THE SAME GUARD (M4-P22 criterion 2), and
601
+ it runs here rather than inside `checkHoldership` because the two answer
602
+ different questions and one of them spawns git. `checkHoldership` above
603
+ has ALREADY RETURNED OK in the state this refuses: the local lease is
604
+ held by this environment, with TIPHYS_HOLDER_ID matching, while the
605
+ shared register names another environment. That is precisely M4-P20's
606
+ measured dangerous state, two clones of one fleet remote each holding a
607
+ live lease, and the local check has no evidence of it because `state/`
608
+ is gitignored so the lease artifact never travels (src/fleet.ts:29).
609
+
610
+ IT IS BEFORE pool create, before the task directory, before the brief and
611
+ before any executor, so the refusal creates nothing, which is the same
612
+ ordering rule the adapter and id-reuse refusals above follow. A fleet
613
+ with no declaration gets `off` before any subprocess is spawned. */
614
+ const sharedGuard = guardSharedRegister(fleet.root, "spawn");
615
+ if (sharedGuard.kind === "refused") {
616
+ return { ok: false, reason: sharedGuard.reason };
617
+ }
618
+ const command = parseExecCommand(options.exec);
619
+ if (command.length === 0) {
620
+ return { ok: false, reason: "--exec is empty" };
621
+ }
622
+ // CR-301, checked before pool create so the refusal creates nothing and
623
+ // destroys nothing. tasks/<id>/ survives every teardown by design, so a
624
+ // reused id would otherwise overwrite a closed task's records, hand the
625
+ // rollback files it did not create, and leave the previous
626
+ // incarnation's turn-end readable beside a meta that says open.
627
+ if (taskDirOccupied(fleet, taskId)) {
628
+ return {
629
+ ok: false,
630
+ reason: `task directory ${taskDir(fleet, taskId)} already holds records for task ` +
631
+ `id ${taskId}; a task id is spawned once, so choose a fresh id or move ` +
632
+ `that directory aside before re-using this one`,
633
+ };
634
+ }
635
+ const created = await poolCreate(fleet, {
636
+ taskId,
637
+ project: options.project,
638
+ offline: options.offline,
639
+ });
640
+ if (!created.ok) {
641
+ return { ok: false, reason: created.reason };
642
+ }
643
+ const poolRecord = created.value;
644
+ const worktree = worktreePath(fleet, taskId);
645
+ // Everything below is rollback-scoped: this list holds exactly what
646
+ // THIS invocation created, in creation order.
647
+ const createdFiles = [];
648
+ const dir = taskDir(fleet, taskId);
649
+ const createdTaskDir = !taskDirExists(fleet, taskId);
650
+ const rollback = async (reason) => {
651
+ for (const file of [...createdFiles].reverse()) {
652
+ try {
653
+ unlinkSync(file);
654
+ }
655
+ catch {
656
+ // Never written, or already gone.
657
+ }
658
+ }
659
+ // The scrub root (harness-owned redirect targets, M2-P8) is created
660
+ // by THIS invocation strictly before the launch, and this rollback
661
+ // only ever runs before the payload has started, so removing it
662
+ // recursively removes only what this invocation staged. It sits
663
+ // inside the task directory, never inside the worktree.
664
+ try {
665
+ rmSync(scrubRoot(dir), { recursive: true, force: true });
666
+ }
667
+ catch {
668
+ // Never created, or already gone.
669
+ }
670
+ if (createdTaskDir) {
671
+ try {
672
+ rmdirSync(dir);
673
+ }
674
+ catch {
675
+ // Left in place when it is not empty: whatever is in it was not
676
+ // created by this invocation, and this rollback does not own it.
677
+ }
678
+ }
679
+ const destroyed = await poolDestroy(fleet, {
680
+ taskId,
681
+ discard: false,
682
+ deleteBranchForce: false,
683
+ });
684
+ if (!destroyed.ok) {
685
+ return {
686
+ ok: false,
687
+ reason: `${reason}; rollback of the worktree did not complete: ${destroyed.reason}`,
688
+ };
689
+ }
690
+ return { ok: false, reason };
691
+ };
692
+ // From here to the launch, EVERY step goes through runStep: a raised
693
+ // fs error is folded into the same ok/reason shape a returned failure
694
+ // uses, so one handler covers both and the rollback cannot be walked
695
+ // past (F-2).
696
+ if (createdTaskDir) {
697
+ const made = runStep(`creating the task directory ${dir}`, () => {
698
+ mkdirSync(dir, { recursive: true });
699
+ });
700
+ if (!made.ok) {
701
+ return rollback(made.reason);
702
+ }
703
+ }
704
+ const brief = runStep(`assembling the brief for task ${taskId}`, () => assembleBrief(fleet, taskId, options.briefFile));
705
+ if (!brief.ok) {
706
+ return rollback(brief.reason);
707
+ }
708
+ if (!brief.value.ok) {
709
+ return rollback(brief.value.reason);
710
+ }
711
+ const briefPath = brief.value.value;
712
+ createdFiles.push(briefPath);
713
+ // THE CREDENTIAL DECISION, WRITTEN DOWN (M4-P8 step 6). The hazard this
714
+ // phase names is a credential reaching a project payload with NO ARTIFACT
715
+ // SAYING SO, so the record is written on every spawn, including the
716
+ // boring one where nothing was widened and the scrub ran. The extensions
717
+ // are copied verbatim, reason and all: a widening whose justification
718
+ // exists only in the caller's source is not auditable from the task
719
+ // directory an operator opens.
720
+ const credentialRecord = {
721
+ payloadClass: options.payloadClass,
722
+ scrubMode: options.allowPrCredentials === true ? "inherited" : "scrubbed",
723
+ // BUILT THROUGH THE ACCESSORS (CR-B-002, the record half). Reading
724
+ // `entry.name` directly produced the literal record `{}` for a
725
+ // bare-string entry, so meta.json said a widening happened and not WHICH
726
+ // name was widened. `extensionReason` returning undefined is recorded as
727
+ // an ABSENT key rather than defaulted to "", because a blank reason and a
728
+ // missing one are different facts and the audited route refuses both.
729
+ extensions: (options.extraAllowlist ?? []).map((entry) => {
730
+ const reason = extensionReason(entry);
731
+ return {
732
+ name: extensionName(entry),
733
+ ...(typeof reason === "string" ? { reason } : {}),
734
+ };
735
+ }),
736
+ };
737
+ const meta = {
738
+ id: taskId,
739
+ project: poolRecord.project,
740
+ shape: options.shape,
741
+ branch: poolRecord.branchName,
742
+ worktree,
743
+ baseSha: poolRecord.baseSha,
744
+ baseOffline: poolRecord.offline,
745
+ status: "open",
746
+ createdAt: new Date().toISOString(),
747
+ credentials: credentialRecord,
748
+ };
749
+ const wroteMeta = runStep(`writing ${metaPath(fleet, taskId)}`, () => {
750
+ writeTaskMeta(fleet, meta);
751
+ });
752
+ if (!wroteMeta.ok) {
753
+ return rollback(wroteMeta.reason);
754
+ }
755
+ createdFiles.push(metaPath(fleet, taskId));
756
+ // THE HOOK RECORDS THE FIVE POINTERS FROM INSIDE THE CHILD (CR-B-001).
757
+ // Passed on every spawn, including under the declared escape hatch, where
758
+ // the kernel handed no environment over and so compares nothing: a hook
759
+ // whose shape depended on the escape hatch would be one more thing that
760
+ // differs between the two arms, and T-009's lesson is that the arm nobody
761
+ // witnesses is the one that breaks.
762
+ const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId, CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name)));
763
+ if (!hook.ok) {
764
+ return rollback(hook.reason);
765
+ }
766
+ const hookPath = hook.value;
767
+ createdFiles.push(hookPath);
768
+ const recordPath = executorRecordPath(fleet, taskId);
769
+ createdFiles.push(recordPath);
770
+ // The child environment (M2-P8): built from the allowlist with the
771
+ // credential-store pointers redirected into this task's directory,
772
+ // unless the caller passed the declared escape hatch. Built BEFORE the
773
+ // launch so a staging failure is a rollback, never a half-scrubbed
774
+ // child.
775
+ let childEnv;
776
+ if (options.allowPrCredentials !== true) {
777
+ const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({
778
+ parentEnv: process.env,
779
+ scrubDir: scrubRoot(dir),
780
+ // M4-P8 step 3: the per-invocation extension finally has a way to
781
+ // reach this call. Before this phase `extraAllowlist` existed as
782
+ // data and NOTHING passed one, so the field was unreachable from
783
+ // every production path.
784
+ extraAllowlist: options.extraAllowlist ?? [],
785
+ }));
786
+ if (!built.ok) {
787
+ return rollback(built.reason);
788
+ }
789
+ if (!built.value.ok) {
790
+ return rollback(built.value.reason);
791
+ }
792
+ childEnv = built.value.env;
793
+ }
794
+ // AWAITED (M4-P2 step 4), and `runStepAsync` rather than `runStep` is
795
+ // load-bearing rather than cosmetic. `runStep` over a promise-returning
796
+ // callback returns {ok: true, value: <a pending promise>} before the
797
+ // adapter has done anything: the launch-failed arm below would never be
798
+ // reached, `outcome.kind` would be undefined on every launch, and a
799
+ // rejection would leave the result type entirely as an unhandled
800
+ // rejection with no handler to roll back or refuse.
801
+ const launched = await runStepAsync(`launching the payload through the ${adapter.name} adapter`, async () => adapter.launch({
802
+ taskId,
803
+ worktree,
804
+ command,
805
+ hookPath,
806
+ recordPath,
807
+ deadlineSeconds: options.deadlineSeconds,
808
+ env: childEnv,
809
+ // THE BRIEF THE ADAPTER LAUNCHES AGAINST (M4-P3 criterion 1). The
810
+ // path `assembleBrief` returned, which is already on `createdFiles`
811
+ // and so is already inside the rollback window.
812
+ briefPath,
813
+ role: options.role,
814
+ declaredTier: options.declaredTier,
815
+ phaseId: options.phaseId,
816
+ }));
817
+ if (!launched.ok) {
818
+ // An adapter that THREW rather than returning an outcome cannot tell
819
+ // us whether the payload started, and this rollback destroys a
820
+ // worktree. Refusing to guess is the whole lesson of V-1: the state
821
+ // is left in place and enumerated instead.
822
+ //
823
+ // Since M4-P2 this arm also covers a REJECTED promise, and it covers
824
+ // it for the same reason and with the same words: a rejection is an
825
+ // adapter failing to report, and WHEN it rejects tells us nothing,
826
+ // because an adapter that rejects before the payload starts and one
827
+ // that rejects after it dies are indistinguishable from here. An
828
+ // adapter that actually knows the payload never started says so, by
829
+ // RETURNING launch-failed, and that arm rolls back.
830
+ return {
831
+ ok: false,
832
+ reason: `${launched.reason}; the ${adapter.name} adapter did not report whether the ` +
833
+ `payload started, so nothing was rolled back: the worktree ${worktree}, its ` +
834
+ `task directory and the pool record are left in place for inspection; when ` +
835
+ `you have inspected them, close the task with "tiphys teardown --task ${taskId}"`,
836
+ };
837
+ }
838
+ const outcome = launched.value;
839
+ if (outcome.kind === "launch-failed") {
840
+ return rollback(`executor launch failed: ${outcome.reason}`);
841
+ }
842
+ // FROM HERE THE PAYLOAD HAS RUN, ON BOTH REMAINING ARMS (CR-B-003).
843
+ //
844
+ // `launch-failed` returned above and is the only arm where nothing ran.
845
+ // `completed` and `incomplete` both mean a child was launched with an
846
+ // environment, so "which environment did it actually get" is a real
847
+ // question on both, and until this round it was asked on neither the
848
+ // `incomplete` arm nor the arm where the completion precondition fails:
849
+ // the comparison sat below all three returns and `meta.json` carried no
850
+ // `handover` key at all. A widening on those arms was recorded NOWHERE
851
+ // while the same widening on the `completed` arm was recorded and refused.
852
+ // So the comparison moves up to here, ahead of every arm-specific return,
853
+ // and `meta.json` is rewritten before any of them.
854
+ //
855
+ // The turn-end record is read first because it carries the child-written
856
+ // pointer observation, and it is read on the `incomplete` arm too: an
857
+ // adapter that reports incomplete because the HOOK failed leaves no record
858
+ // and the read simply finds nothing, which is a weaker status word and not
859
+ // a pass.
860
+ const evidence = turnEndEvidence(fleet, taskId);
861
+ const pointers = (() => {
862
+ if (childEnv === undefined) {
863
+ return undefined;
864
+ }
865
+ if (evidence.ok && evidence.observed !== undefined) {
866
+ return { source: "turn-end-record", values: evidence.observed };
867
+ }
868
+ if (outcome.launchedRedirections !== undefined) {
869
+ return { source: "adapter", values: outcome.launchedRedirections };
870
+ }
871
+ return undefined;
872
+ })();
873
+ // THE HANDOVER CHECK (M4-P8 criterion 6, repaired by CR-B-001).
874
+ // src/spawn.ts's request contract has always FORBIDDEN an adapter widening
875
+ // `env` on its own, in prose, and nothing checked it. The adapter reports
876
+ // the name set it launched with and the kernel compares it; SINCE THIS
877
+ // ROUND the kernel also compares the five credential-store pointers, which
878
+ // is the half a name-set comparison structurally cannot see, because the
879
+ // M2R-004 defense works by redirecting those names rather than by dropping
880
+ // them.
881
+ //
882
+ // IT RUNS AFTER THE PAYLOAD HAS RUN, so it never rolls anything back: the
883
+ // worktree may hold real work by now (the V-1 rule), and the refusal's
884
+ // job is to make the widening impossible to miss, not to destroy
885
+ // evidence of it. The comparison is recorded in meta.json whichever way
886
+ // it goes, so a clean handover is an artifact too.
887
+ const handover = compareHandover(childEnv, outcome.launchedEnvNames, pointers);
888
+ credentialRecord.handover = handover;
889
+ const widened = handover.added.length > 0 ||
890
+ handover.removed.length > 0 ||
891
+ handover.changedRedirections.length > 0;
892
+ if (widened) {
893
+ credentialRecord.refusal =
894
+ `the ${adapter.name} adapter launched with an environment that differs ` +
895
+ `from the one the kernel handed it` +
896
+ (handover.added.length === 0 ? "" : `; added ${handover.added.join(", ")}`) +
897
+ (handover.removed.length === 0 ? "" : `; removed ${handover.removed.join(", ")}`) +
898
+ (handover.changedRedirections.length === 0
899
+ ? ""
900
+ : `; the credential-store pointer(s) ` +
901
+ `${handover.changedRedirections.join(", ")} did not have the ` +
902
+ `harness-owned value the kernel handed over, ` +
903
+ `${redirectionSourcePhrase(handover.redirectionSource)}`);
904
+ }
905
+ const rewroteMeta = runStep(`updating ${metaPath(fleet, taskId)}`, () => {
906
+ writeTaskMeta(fleet, meta);
907
+ });
908
+ if (!rewroteMeta.ok) {
909
+ // The payload ran, so nothing is rolled back here either; the record
910
+ // simply could not be completed and says so rather than being silently
911
+ // left at its pre-launch contents.
912
+ return {
913
+ ok: false,
914
+ reason: `the payload ran (the ${adapter.name} adapter reported ${outcome.kind}) ` +
915
+ `but the credential record could not be completed ` +
916
+ `(${rewroteMeta.reason}); the worktree ${worktree}, its task directory ` +
917
+ `and the pool record are left in place`,
918
+ };
919
+ }
920
+ if (outcome.kind === "incomplete") {
921
+ // The payload ran, so nothing is rolled back, and the reason says so.
922
+ // The scrub root is deliberately LEFT in place here: the hook child
923
+ // failed, and whatever the children left under the redirected paths
924
+ // is part of the state an operator inspects. The handover verdict is
925
+ // now part of the reason as well as of the record, because this arm's
926
+ // reason is the only thing many callers read.
927
+ return {
928
+ ok: false,
929
+ reason: widened
930
+ ? `${outcome.reason}; and ${credentialRecord.refusal ?? ""}, recorded in ` +
931
+ `${metaPath(fleet, taskId)}`
932
+ : outcome.reason,
933
+ };
934
+ }
935
+ // THE COMPLETION PRECONDITION (M4-P2 step 5). The only arm left is
936
+ // `completed`, and it is the only arm that DESTROYS something (the
937
+ // scrub root, which is a live child's redirected HOME while that child
938
+ // lives). Before M4-P2 the destruction was safe because `launch` was
939
+ // synchronous and the sole adapter wrote the turn-end record itself; an
940
+ // async `launch` lets any adapter resolve `completed` early, so the
941
+ // kernel checks the payload's own artifact instead of believing the
942
+ // report. See turnEndEvidence above for the four refusals.
943
+ //
944
+ // THE READ ITSELF MOVED UP (CR-B-003): `evidence` is computed before the
945
+ // handover comparison, because the turn-end record is where the child's
946
+ // own pointer observation lives. Only the REFUSAL is here, so this arm
947
+ // still refuses exactly what it refused, and it now does so with the
948
+ // handover already written down.
949
+ if (!evidence.ok) {
950
+ // The scrub root is named only when there IS one. Under the declared
951
+ // escape hatch childEnv is undefined and nothing was ever staged under
952
+ // scrubRoot(dir), so naming it would enumerate a path that does not
953
+ // exist, in the one message an operator uses to find the residue.
954
+ const residue = childEnv === undefined
955
+ ? `the worktree ${worktree}, its task directory and the pool record are all`
956
+ : `the worktree ${worktree}, its task directory, the pool record and the ` +
957
+ `harness-owned redirect targets under ${scrubRoot(dir)} are all`;
958
+ return {
959
+ ok: false,
960
+ reason: `the ${adapter.name} adapter reported the payload completed with exit code ` +
961
+ `${String(outcome.exitCode)}, but ${evidence.reason}, so the kernel does not ` +
962
+ `accept that the payload ended; nothing was rolled back and nothing was ` +
963
+ `removed: ${residue} left in place for inspection; when you have inspected ` +
964
+ `them, close the task with "tiphys teardown --task ${taskId}"` +
965
+ // THE WIDENING IS NAMED ON THIS ARM TOO (CR-B-003). The record already
966
+ // carries it, and this reason is the only thing many callers read, so
967
+ // leaving it out here would make the arm's refusal say less than the
968
+ // artifact beside it.
969
+ (widened
970
+ ? `; and ${credentialRecord.refusal ?? ""}, recorded in ${metaPath(fleet, taskId)}`
971
+ : ""),
972
+ };
973
+ }
974
+ if (widened) {
975
+ return {
976
+ ok: false,
977
+ reason: `${credentialRecord.refusal ?? ""}; an adapter never widens the child ` +
978
+ `environment it was given (see ExecutorRequest.env), and the difference ` +
979
+ `is recorded in ${metaPath(fleet, taskId)}; nothing was rolled back ` +
980
+ `because the payload had already run`,
981
+ };
982
+ }
983
+ // The scrub root is ephemeral. Both children have exited: the turn-end
984
+ // record exists and parses, which is the payload's own exit writing
985
+ // itself down, and the launch promise has been awaited, so the harness-
986
+ // owned redirect targets have no further reader. That sentence used to
987
+ // read "the launch is synchronous, C-3", and it stopped being true the
988
+ // moment `launch` returned a promise; a comment asserting a dead
989
+ // invariant is how the next reader re-derives the defect, so the
990
+ // reasoning is restated rather than left. C-3 is still satisfied, by
991
+ // the await rather than by the signature. This removal touches ONLY the
992
+ // scrub root, never the worktree, so it cannot be a V-1-shaped loss.
993
+ if (childEnv !== undefined) {
994
+ try {
995
+ rmSync(scrubRoot(dir), { recursive: true, force: true });
996
+ }
997
+ catch {
998
+ // A leftover empty scrub directory is benign; failing a completed
999
+ // spawn over its cleanup would not be.
1000
+ }
1001
+ }
1002
+ return { ok: true, value: { meta, exitCode: outcome.exitCode } };
1003
+ }