@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,177 @@
1
+ import type { Fleet } from "./fleet.ts";
2
+ import type { GuardResult, TaskMeta, TaskShape } from "./task.ts";
3
+ /**
4
+ * tiphys spawn (kernel plan v1, M1-P4 step 4): worktree, brief, turn-end
5
+ * hook, task meta and the executor launch in ONE command.
6
+ *
7
+ * Order, exactly as the plan states it: liveness-guard check (M1-P5; a
8
+ * documented no-op seam in this phase), holdership check, pool create,
9
+ * brief assembly, meta write, executor launch. The holdership check runs
10
+ * before pool create because its refusal must create nothing
11
+ * (criterion 12).
12
+ *
13
+ * Rollback contract, stated narrowly because the broad version would not
14
+ * be honest (the lesson M1-P3's destroy paid for four times over):
15
+ *
16
+ * - A failure BEFORE pool create removes nothing, because nothing was
17
+ * created. In particular a duplicate task id leaves any pre-existing
18
+ * tasks/<id>/ byte-identical: this command writes nothing under
19
+ * tasks/ until pool create has succeeded (criterion 4, PR-005).
20
+ * - A failure AFTER pool create and BEFORE the payload starts removes
21
+ * exactly what this invocation created (the files it wrote, the task
22
+ * directory when it created it, and the pool worktree) and nothing
23
+ * else (criterion 5). This holds for a step that RETURNS a failure
24
+ * and for one that THROWS: every write in that window goes through
25
+ * runStep, because an unwrapped raise walked past the rollback
26
+ * entirely and orphaned the worktree, the branch and the pool record
27
+ * (F-2). The worktree is untouched at that point, so
28
+ * the removal passes the ordinary pool destroy gates and needs no
29
+ * force flag: --discard and --delete-branch-force are deliberately
30
+ * NOT passed, so a worktree that somehow is not pristine refuses and
31
+ * survives rather than being destroyed by a cleanup path.
32
+ * - Once the PAYLOAD HAS STARTED, nothing is ever rolled back. The
33
+ * worktree may hold real work by then, and a cleanup that removed it
34
+ * would be M1-P3's V-1 data-loss defect with a different name. A
35
+ * failure after that point reports what happened and changes
36
+ * nothing.
37
+ *
38
+ * Substrate: everything here is substrate-neutral except the executor,
39
+ * which sits behind ExecutorAdapter (DR-0007). M1 ships exactly one
40
+ * adapter, the local subprocess one.
41
+ */
42
+ /** What the adapter is asked to do. Everything crosses as files and exit codes. */
43
+ export interface ExecutorRequest {
44
+ taskId: string;
45
+ /** cwd for the payload: the task worktree. */
46
+ worktree: string;
47
+ /** argv of the payload, argv[0] being the program. */
48
+ command: string[];
49
+ /** Script the adapter invokes with the payload exit code when it exits. */
50
+ hookPath: string;
51
+ /** Where the adapter writes its launch record. */
52
+ recordPath: string;
53
+ /** Optional non-completion deadline in seconds (PR-207). */
54
+ deadlineSeconds: number | undefined;
55
+ /**
56
+ * The EXACT environment for BOTH children this launch produces: the
57
+ * payload and the turn-end hook (M2-P8, M2R-004 edit 4). Built by
58
+ * `buildChildEnv` (src/exec/env.ts): allowlisted names only, with the
59
+ * five credential-store pointers redirected to harness-owned paths.
60
+ * `undefined` means the children inherit the parent's environment
61
+ * UNCHANGED, and is only ever passed under `allowPrCredentials`, the
62
+ * declared escape hatch; an adapter must never widen it on its own.
63
+ */
64
+ env: Record<string, string> | undefined;
65
+ }
66
+ /**
67
+ * Launch outcomes. The distinction between a payload that never started
68
+ * and one that did is load-bearing: only the first authorizes rollback.
69
+ */
70
+ export type LaunchOutcome = {
71
+ kind: "completed";
72
+ exitCode: number;
73
+ } | {
74
+ kind: "launch-failed";
75
+ reason: string;
76
+ } | {
77
+ kind: "incomplete";
78
+ reason: string;
79
+ };
80
+ /**
81
+ * The ExecutorAdapter interface (DR-0007, M1-P4 grounding). The ENTIRE
82
+ * contract is: write the launch record tasks/<id>/executor.json at
83
+ * launch, launch the payload in the task worktree, and ensure the
84
+ * turn-end file is written with the payload exit code on completion. All
85
+ * state crosses this boundary through files and exit codes, never
86
+ * through terminal inspection (FM-055: pane scraping is a race farm;
87
+ * FM-060: every toolbelt boundary is a subprocess with an exit code).
88
+ * A multiplexer-window adapter and a cloud-session adapter are further
89
+ * adapters against this same interface, not kernel changes (M4 era).
90
+ */
91
+ export interface ExecutorAdapter {
92
+ readonly name: string;
93
+ launch(request: ExecutorRequest): LaunchOutcome;
94
+ }
95
+ /** The launch record (JSON per DR-0006, shape per PR-207). */
96
+ export interface ExecutorRecord {
97
+ adapter: string;
98
+ launchedAt: string;
99
+ /**
100
+ * Absolute ISO-8601 instant by which the task must have completed,
101
+ * present only when --deadline was passed. Recorded as an instant
102
+ * rather than as the raw seconds so the M1-P5 watcher compares it
103
+ * against the clock directly, the way it already compares lease
104
+ * expiry, instead of re-deriving it from launchedAt. The plan fixes
105
+ * the field name and its optionality, not its units; this is the
106
+ * choice, recorded rather than assumed.
107
+ */
108
+ deadline?: string;
109
+ }
110
+ /**
111
+ * The local subprocess adapter, the one M1 ships (and the one the exit
112
+ * test's stub payload uses).
113
+ *
114
+ * The payload runs to COMPLETION before this returns: the kernel never
115
+ * auto-backgrounds anything (plan constraint C-3, FM-054), so there is
116
+ * no daemonize path here to forget to guard.
117
+ *
118
+ * It runs without a shell on purpose. Under a shell a missing payload
119
+ * binary arrives as an ordinary exit code 127, indistinguishable from a
120
+ * payload that ran and failed, and spawn's rollback rule turns on
121
+ * exactly that distinction.
122
+ */
123
+ export declare const subprocessAdapter: ExecutorAdapter;
124
+ /**
125
+ * Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
126
+ * this phase it is a documented no-op, so that P5 adds a body here
127
+ * rather than a new call site in the middle of the spawn sequence.
128
+ */
129
+ export declare function livenessGuard(fleet: Fleet): GuardResult;
130
+ /**
131
+ * Tokenise --exec. M1 supports a program plus plain arguments separated
132
+ * by whitespace; there is no shell and no quoting. Anything richer is an
133
+ * adapter's business (M4 era), not a string this kernel re-parses.
134
+ */
135
+ export declare function parseExecCommand(command: string): string[];
136
+ export interface SpawnOptions {
137
+ taskId: string;
138
+ project: string;
139
+ briefFile: string;
140
+ shape: TaskShape;
141
+ exec: string;
142
+ deadlineSeconds: number | undefined;
143
+ /** Passed straight through to pool create (EXT-F-03); see spawnTask. */
144
+ offline: boolean;
145
+ /**
146
+ * DECLARED ESCAPE HATCH from the credential scrub (M2-P8 criterion 1).
147
+ * When true, both children inherit the parent environment unchanged,
148
+ * including any pull-request-capable credential the parent holds. This
149
+ * exists for the orchestrator's own spawns, never for an implementer
150
+ * payload; default is false and the scrub is on.
151
+ */
152
+ allowPrCredentials?: boolean;
153
+ adapter?: ExecutorAdapter;
154
+ }
155
+ export interface SpawnSuccess {
156
+ meta: TaskMeta;
157
+ exitCode: number;
158
+ }
159
+ export type SpawnResult = {
160
+ ok: true;
161
+ value: SpawnSuccess;
162
+ } | {
163
+ ok: false;
164
+ reason: string;
165
+ };
166
+ /**
167
+ * One spawn. See the module docs for the rollback contract.
168
+ *
169
+ * --offline is passed straight through to pool create and is the only
170
+ * way a fleet can reach an offline base. meta.baseOffline is then COPIED
171
+ * from the pool record the create returned, never recomputed from this
172
+ * flag: pool create attempts the fetch even under --offline and records
173
+ * offline false when it succeeds, so a spawn that trusted its own flag
174
+ * would mark a perfectly fetched base as offline (PR-212, and the
175
+ * provenance inversion measured as V-2 in M1-P3).
176
+ */
177
+ export declare function spawnTask(fleet: Fleet, options: SpawnOptions): Promise<SpawnResult>;
@@ -0,0 +1,332 @@
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 { assembleBrief } from "./brief.js";
5
+ import { buildChildEnv, scrubRoot } from "./exec/env.js";
6
+ import { writeTurnEndHook } from "./hooks.js";
7
+ import { poolCreate, poolDestroy, worktreePath } from "./pool.js";
8
+ import { checkHoldership, executorRecordPath, metaPath, runStep, taskDir, taskDirExists, taskDirOccupied, writeTaskMeta, } from "./task.js";
9
+ /**
10
+ * A payload terminated by a signal has no exit code. The shell's
11
+ * convention (128 + signal number) is used so the turn-end record always
12
+ * carries a number. M1 never drives this path; it exists so that the
13
+ * type is honest rather than filled with a guess.
14
+ */
15
+ function payloadExitCode(status, signal) {
16
+ if (status !== null) {
17
+ return status;
18
+ }
19
+ const signals = constants.signals;
20
+ const number = signal === null ? undefined : signals[signal];
21
+ return 128 + (number ?? 0);
22
+ }
23
+ /**
24
+ * The local subprocess adapter, the one M1 ships (and the one the exit
25
+ * test's stub payload uses).
26
+ *
27
+ * The payload runs to COMPLETION before this returns: the kernel never
28
+ * auto-backgrounds anything (plan constraint C-3, FM-054), so there is
29
+ * no daemonize path here to forget to guard.
30
+ *
31
+ * It runs without a shell on purpose. Under a shell a missing payload
32
+ * binary arrives as an ordinary exit code 127, indistinguishable from a
33
+ * payload that ran and failed, and spawn's rollback rule turns on
34
+ * exactly that distinction.
35
+ */
36
+ export const subprocessAdapter = {
37
+ name: "subprocess",
38
+ launch(request) {
39
+ const launchedAt = new Date();
40
+ const record = {
41
+ adapter: "subprocess",
42
+ launchedAt: launchedAt.toISOString(),
43
+ };
44
+ if (request.deadlineSeconds !== undefined) {
45
+ record.deadline = new Date(launchedAt.getTime() + request.deadlineSeconds * 1000).toISOString();
46
+ }
47
+ // The record write happens BEFORE the payload, so a failure here is
48
+ // provably a launch failure and is safe to roll back. Everything
49
+ // after the payload starts is reported as incomplete instead, which
50
+ // never rolls anything back (F-2's fix must not become V-1's defect).
51
+ const written = runStep(`writing the launch record ${request.recordPath}`, () => {
52
+ writeFileSync(request.recordPath, `${JSON.stringify(record, null, 2)}\n`);
53
+ });
54
+ if (!written.ok) {
55
+ return { kind: "launch-failed", reason: written.reason };
56
+ }
57
+ const [program, ...args] = request.command;
58
+ if (program === undefined) {
59
+ return { kind: "launch-failed", reason: "empty --exec command" };
60
+ }
61
+ const result = spawnSync(program, args, {
62
+ cwd: request.worktree,
63
+ stdio: "inherit",
64
+ // The scrubbed environment (M2-P8). Spread rather than `env:
65
+ // request.env` so an undefined request.env means "no env option at
66
+ // all", which is Node's documented full-inheritance form.
67
+ ...(request.env === undefined ? {} : { env: request.env }),
68
+ });
69
+ if (result.error !== undefined) {
70
+ return {
71
+ kind: "launch-failed",
72
+ reason: `cannot launch ${program}: ${String(result.error)}`,
73
+ };
74
+ }
75
+ const exitCode = payloadExitCode(result.status, result.signal);
76
+ // The payload has run. Every failure below, raised or returned, is
77
+ // reported as incomplete: the worktree may hold real work now, so
78
+ // nothing here may lead to a rollback.
79
+ const hooked = runStep(`invoking the turn-end hook ${request.hookPath}`, () => spawnSync(process.execPath, [request.hookPath, String(exitCode)], {
80
+ stdio: "inherit",
81
+ // The hook child gets the SAME scrubbed environment as the
82
+ // payload (M2R-004 edit 4): a second launch nobody scrubbed is
83
+ // exactly the leak the finding names. The generated hook script
84
+ // itself reads no environment at all (src/hooks.ts), so the
85
+ // scrub cannot break it.
86
+ ...(request.env === undefined ? {} : { env: request.env }),
87
+ }));
88
+ if (!hooked.ok) {
89
+ return {
90
+ kind: "incomplete",
91
+ reason: `the payload exited ${String(exitCode)} but the turn-end record could not ` +
92
+ `be written (${hooked.reason}); the worktree and the task directory are ` +
93
+ `left in place`,
94
+ };
95
+ }
96
+ const hook = hooked.value;
97
+ if (hook.error !== undefined || hook.status !== 0) {
98
+ const detail = hook.error === undefined ? `exit ${String(hook.status)}` : String(hook.error);
99
+ return {
100
+ kind: "incomplete",
101
+ reason: `the payload exited ${String(exitCode)} but the turn-end hook ` +
102
+ `${request.hookPath} failed (${detail}); the worktree and the task ` +
103
+ `directory are left in place`,
104
+ };
105
+ }
106
+ return { kind: "completed", exitCode };
107
+ },
108
+ };
109
+ /**
110
+ * Liveness-guard seam (plan step 4). The guard itself is M1-P5 work; in
111
+ * this phase it is a documented no-op, so that P5 adds a body here
112
+ * rather than a new call site in the middle of the spawn sequence.
113
+ */
114
+ export function livenessGuard(fleet) {
115
+ void fleet;
116
+ return { ok: true };
117
+ }
118
+ /**
119
+ * Tokenise --exec. M1 supports a program plus plain arguments separated
120
+ * by whitespace; there is no shell and no quoting. Anything richer is an
121
+ * adapter's business (M4 era), not a string this kernel re-parses.
122
+ */
123
+ export function parseExecCommand(command) {
124
+ return command.split(/\s+/u).filter((token) => token !== "");
125
+ }
126
+ /**
127
+ * One spawn. See the module docs for the rollback contract.
128
+ *
129
+ * --offline is passed straight through to pool create and is the only
130
+ * way a fleet can reach an offline base. meta.baseOffline is then COPIED
131
+ * from the pool record the create returned, never recomputed from this
132
+ * flag: pool create attempts the fetch even under --offline and records
133
+ * offline false when it succeeds, so a spawn that trusted its own flag
134
+ * would mark a perfectly fetched base as offline (PR-212, and the
135
+ * provenance inversion measured as V-2 in M1-P3).
136
+ */
137
+ export async function spawnTask(fleet, options) {
138
+ const { taskId } = options;
139
+ const liveness = livenessGuard(fleet);
140
+ if (!liveness.ok) {
141
+ return { ok: false, reason: liveness.reason };
142
+ }
143
+ const holdership = checkHoldership(fleet);
144
+ if (!holdership.ok) {
145
+ return { ok: false, reason: holdership.reason };
146
+ }
147
+ const command = parseExecCommand(options.exec);
148
+ if (command.length === 0) {
149
+ return { ok: false, reason: "--exec is empty" };
150
+ }
151
+ // CR-301, checked before pool create so the refusal creates nothing and
152
+ // destroys nothing. tasks/<id>/ survives every teardown by design, so a
153
+ // reused id would otherwise overwrite a closed task's records, hand the
154
+ // rollback files it did not create, and leave the previous
155
+ // incarnation's turn-end readable beside a meta that says open.
156
+ if (taskDirOccupied(fleet, taskId)) {
157
+ return {
158
+ ok: false,
159
+ reason: `task directory ${taskDir(fleet, taskId)} already holds records for task ` +
160
+ `id ${taskId}; a task id is spawned once, so choose a fresh id or move ` +
161
+ `that directory aside before re-using this one`,
162
+ };
163
+ }
164
+ const created = await poolCreate(fleet, {
165
+ taskId,
166
+ project: options.project,
167
+ offline: options.offline,
168
+ });
169
+ if (!created.ok) {
170
+ return { ok: false, reason: created.reason };
171
+ }
172
+ const poolRecord = created.value;
173
+ const worktree = worktreePath(fleet, taskId);
174
+ // Everything below is rollback-scoped: this list holds exactly what
175
+ // THIS invocation created, in creation order.
176
+ const createdFiles = [];
177
+ const dir = taskDir(fleet, taskId);
178
+ const createdTaskDir = !taskDirExists(fleet, taskId);
179
+ const rollback = async (reason) => {
180
+ for (const file of [...createdFiles].reverse()) {
181
+ try {
182
+ unlinkSync(file);
183
+ }
184
+ catch {
185
+ // Never written, or already gone.
186
+ }
187
+ }
188
+ // The scrub root (harness-owned redirect targets, M2-P8) is created
189
+ // by THIS invocation strictly before the launch, and this rollback
190
+ // only ever runs before the payload has started, so removing it
191
+ // recursively removes only what this invocation staged. It sits
192
+ // inside the task directory, never inside the worktree.
193
+ try {
194
+ rmSync(scrubRoot(dir), { recursive: true, force: true });
195
+ }
196
+ catch {
197
+ // Never created, or already gone.
198
+ }
199
+ if (createdTaskDir) {
200
+ try {
201
+ rmdirSync(dir);
202
+ }
203
+ catch {
204
+ // Left in place when it is not empty: whatever is in it was not
205
+ // created by this invocation, and this rollback does not own it.
206
+ }
207
+ }
208
+ const destroyed = await poolDestroy(fleet, {
209
+ taskId,
210
+ discard: false,
211
+ deleteBranchForce: false,
212
+ });
213
+ if (!destroyed.ok) {
214
+ return {
215
+ ok: false,
216
+ reason: `${reason}; rollback of the worktree did not complete: ${destroyed.reason}`,
217
+ };
218
+ }
219
+ return { ok: false, reason };
220
+ };
221
+ // From here to the launch, EVERY step goes through runStep: a raised
222
+ // fs error is folded into the same ok/reason shape a returned failure
223
+ // uses, so one handler covers both and the rollback cannot be walked
224
+ // past (F-2).
225
+ if (createdTaskDir) {
226
+ const made = runStep(`creating the task directory ${dir}`, () => {
227
+ mkdirSync(dir, { recursive: true });
228
+ });
229
+ if (!made.ok) {
230
+ return rollback(made.reason);
231
+ }
232
+ }
233
+ const brief = runStep(`assembling the brief for task ${taskId}`, () => assembleBrief(fleet, taskId, options.briefFile));
234
+ if (!brief.ok) {
235
+ return rollback(brief.reason);
236
+ }
237
+ if (!brief.value.ok) {
238
+ return rollback(brief.value.reason);
239
+ }
240
+ createdFiles.push(brief.value.value);
241
+ const meta = {
242
+ id: taskId,
243
+ project: poolRecord.project,
244
+ shape: options.shape,
245
+ branch: poolRecord.branchName,
246
+ worktree,
247
+ baseSha: poolRecord.baseSha,
248
+ baseOffline: poolRecord.offline,
249
+ status: "open",
250
+ createdAt: new Date().toISOString(),
251
+ };
252
+ const wroteMeta = runStep(`writing ${metaPath(fleet, taskId)}`, () => {
253
+ writeTaskMeta(fleet, meta);
254
+ });
255
+ if (!wroteMeta.ok) {
256
+ return rollback(wroteMeta.reason);
257
+ }
258
+ createdFiles.push(metaPath(fleet, taskId));
259
+ const hook = runStep(`writing the turn-end hook for task ${taskId}`, () => writeTurnEndHook(fleet, taskId));
260
+ if (!hook.ok) {
261
+ return rollback(hook.reason);
262
+ }
263
+ const hookPath = hook.value;
264
+ createdFiles.push(hookPath);
265
+ const recordPath = executorRecordPath(fleet, taskId);
266
+ createdFiles.push(recordPath);
267
+ // The child environment (M2-P8): built from the allowlist with the
268
+ // credential-store pointers redirected into this task's directory,
269
+ // unless the caller passed the declared escape hatch. Built BEFORE the
270
+ // launch so a staging failure is a rollback, never a half-scrubbed
271
+ // child.
272
+ let childEnv;
273
+ if (options.allowPrCredentials !== true) {
274
+ const built = runStep(`constructing the scrubbed child environment for task ${taskId}`, () => buildChildEnv({ parentEnv: process.env, scrubDir: scrubRoot(dir) }));
275
+ if (!built.ok) {
276
+ return rollback(built.reason);
277
+ }
278
+ if (!built.value.ok) {
279
+ return rollback(built.value.reason);
280
+ }
281
+ childEnv = built.value.env;
282
+ }
283
+ const adapter = options.adapter ?? subprocessAdapter;
284
+ const launched = runStep(`launching the payload through the ${adapter.name} adapter`, () => adapter.launch({
285
+ taskId,
286
+ worktree,
287
+ command,
288
+ hookPath,
289
+ recordPath,
290
+ deadlineSeconds: options.deadlineSeconds,
291
+ env: childEnv,
292
+ }));
293
+ if (!launched.ok) {
294
+ // An adapter that THREW rather than returning an outcome cannot tell
295
+ // us whether the payload started, and this rollback destroys a
296
+ // worktree. Refusing to guess is the whole lesson of V-1: the state
297
+ // is left in place and enumerated instead.
298
+ return {
299
+ ok: false,
300
+ reason: `${launched.reason}; the ${adapter.name} adapter did not report whether the ` +
301
+ `payload started, so nothing was rolled back: the worktree ${worktree}, its ` +
302
+ `task directory and the pool record are left in place for inspection; when ` +
303
+ `you have inspected them, close the task with "tiphys teardown --task ${taskId}"`,
304
+ };
305
+ }
306
+ const outcome = launched.value;
307
+ if (outcome.kind === "launch-failed") {
308
+ return rollback(`executor launch failed: ${outcome.reason}`);
309
+ }
310
+ if (outcome.kind === "incomplete") {
311
+ // The payload ran, so nothing is rolled back, and the reason says so.
312
+ // The scrub root is deliberately LEFT in place here: the hook child
313
+ // failed, and whatever the children left under the redirected paths
314
+ // is part of the state an operator inspects.
315
+ return { ok: false, reason: outcome.reason };
316
+ }
317
+ // The scrub root is ephemeral. Both children have exited (the launch is
318
+ // synchronous, C-3), so the harness-owned redirect targets have no
319
+ // further reader; removing them returns the task directory to its
320
+ // documented records-only shape. This removal touches ONLY the scrub
321
+ // root, never the worktree, so it cannot be a V-1-shaped loss.
322
+ if (childEnv !== undefined) {
323
+ try {
324
+ rmSync(scrubRoot(dir), { recursive: true, force: true });
325
+ }
326
+ catch {
327
+ // A leftover empty scrub directory is benign; failing a completed
328
+ // spawn over its cleanup would not be.
329
+ }
330
+ }
331
+ return { ok: true, value: { meta, exitCode: outcome.exitCode } };
332
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * THE STATUS LINE (kernel plan M3, M3-P1 steps 5 and 6; R-084).
3
+ *
4
+ * The orchestrator narrates to the owner at MILESTONES ONLY: a phase merged,
5
+ * an incident, a decision needed. Routine noise gets a one-line ack and
6
+ * nothing else. That sparseness is expressed STRUCTURALLY in
7
+ * `schemas/status-line.schema.json` rather than as an instruction: the state
8
+ * vocabulary has exactly five members and none of them means "still working",
9
+ * so a heartbeat is not a thing this contract can say.
10
+ *
11
+ * CONSTRAINT C-1, NAMED BECAUSE IT DECIDES THE SHAPE OF THIS MODULE.
12
+ * "Never read current state from the tail of an append-only log." The stream
13
+ * at `state/status/stream.jsonl` is the HISTORY and is append-only. The
14
+ * current state is `state/status/current.json`, a whole document rewritten
15
+ * atomically on every emit. `readCurrent` opens `current.json` and nothing
16
+ * else; it does not know the stream's path. A truncated, corrupted or
17
+ * half-written stream therefore cannot change what `tiphys status show`
18
+ * reports, and that is the property criterion 7 witnesses in both directions.
19
+ *
20
+ * ATOMIC REWRITE means write a temp file beside the target and rename. A
21
+ * rename within one directory is atomic on POSIX, so a reader either sees the
22
+ * whole previous document or the whole new one and never a partial write.
23
+ */
24
+ /** Where the status files live inside a fleet home. */
25
+ export declare const STATUS_DIR: string;
26
+ export declare const STREAM_FILE: string;
27
+ export declare const CURRENT_FILE: string;
28
+ /**
29
+ * The five supervisor-actionable states, blueprint section 5. Duplicated
30
+ * from `schemas/status-line.schema.json` so the CLI can reject a bad state
31
+ * before it composes a record; `test/status.test.ts` asserts the two agree,
32
+ * because a duplicated vocabulary that nothing compares is a vocabulary that
33
+ * drifts.
34
+ */
35
+ export declare const STATUS_STATES: readonly string[];
36
+ export interface StatusRecord {
37
+ kind: "status-line";
38
+ at: string;
39
+ run: string;
40
+ project: string;
41
+ state: string;
42
+ detail: string;
43
+ refs: string[];
44
+ }
45
+ export interface EmitInput {
46
+ run: string;
47
+ project: string;
48
+ state: string;
49
+ detail?: string;
50
+ refs?: string[];
51
+ /** Injected so a test can assert an exact record rather than a shape. */
52
+ at?: string;
53
+ }
54
+ /** RFC 3339 UTC to whole seconds, the form the schema's pattern accepts. */
55
+ export declare function nowIso(): string;
56
+ export declare function makeStatusRecord(input: EmitInput): StatusRecord;
57
+ export type EmitOutcome = {
58
+ ok: true;
59
+ record: StatusRecord;
60
+ } | {
61
+ ok: false;
62
+ reason: string;
63
+ };
64
+ /**
65
+ * Append ONE line to the stream, then rewrite `current.json` atomically.
66
+ *
67
+ * The order is deliberate and is the same ordering rule M2's gate runner
68
+ * learned: the durable history is written first, so a crash between the two
69
+ * leaves a history that is ahead of the pointer rather than a pointer that
70
+ * names an event no record supports.
71
+ */
72
+ export declare function emitStatus(fleetRoot: string, record: StatusRecord): EmitOutcome;
73
+ export type CurrentRead = {
74
+ ok: true;
75
+ record: StatusRecord;
76
+ } | {
77
+ ok: false;
78
+ reason: string;
79
+ };
80
+ /**
81
+ * Read the current status. C-1: this function knows only `current.json`.
82
+ *
83
+ * It does not accept a stream path, does not fall back to the stream, and
84
+ * has no code path that opens it. That is what makes criterion 7 a
85
+ * demonstrable property rather than a convention: an implementation that
86
+ * reads the stream is a DIFFERENT function, and the criterion requires it to
87
+ * be written, shown red against corrupt stream bytes, and reverted.
88
+ */
89
+ export declare function readCurrent(fleetRoot: string): CurrentRead;
90
+ /** One line, the form `tiphys status show` prints. */
91
+ export declare function renderStatus(record: StatusRecord): string;