@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,119 @@
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
+ import { mkdirSync, renameSync, writeFileSync, appendFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { refuseOpenForWrite, readRegularFileIfPresent } from "./task.js";
27
+ /** Where the status files live inside a fleet home. */
28
+ export const STATUS_DIR = join("state", "status");
29
+ export const STREAM_FILE = join(STATUS_DIR, "stream.jsonl");
30
+ export const CURRENT_FILE = join(STATUS_DIR, "current.json");
31
+ /**
32
+ * The five supervisor-actionable states, blueprint section 5. Duplicated
33
+ * from `schemas/status-line.schema.json` so the CLI can reject a bad state
34
+ * before it composes a record; `test/status.test.ts` asserts the two agree,
35
+ * because a duplicated vocabulary that nothing compares is a vocabulary that
36
+ * drifts.
37
+ */
38
+ export const STATUS_STATES = [
39
+ "blocked",
40
+ "done",
41
+ "failed",
42
+ "needs-decision",
43
+ "phase-change",
44
+ ];
45
+ /** RFC 3339 UTC to whole seconds, the form the schema's pattern accepts. */
46
+ export function nowIso() {
47
+ return `${new Date().toISOString().slice(0, 19)}Z`;
48
+ }
49
+ export function makeStatusRecord(input) {
50
+ return {
51
+ kind: "status-line",
52
+ at: input.at ?? nowIso(),
53
+ run: input.run,
54
+ project: input.project,
55
+ state: input.state,
56
+ detail: input.detail ?? "",
57
+ refs: input.refs ?? [],
58
+ };
59
+ }
60
+ /**
61
+ * Append ONE line to the stream, then rewrite `current.json` atomically.
62
+ *
63
+ * The order is deliberate and is the same ordering rule M2's gate runner
64
+ * learned: the durable history is written first, so a crash between the two
65
+ * leaves a history that is ahead of the pointer rather than a pointer that
66
+ * names an event no record supports.
67
+ */
68
+ export function emitStatus(fleetRoot, record) {
69
+ const directory = join(fleetRoot, STATUS_DIR);
70
+ mkdirSync(directory, { recursive: true });
71
+ const streamPath = join(fleetRoot, STREAM_FILE);
72
+ const currentPath = join(fleetRoot, CURRENT_FILE);
73
+ const temporaryPath = `${currentPath}.tmp`;
74
+ for (const path of [streamPath, currentPath, temporaryPath]) {
75
+ const refusal = refuseOpenForWrite(path);
76
+ if (refusal !== undefined) {
77
+ return { ok: false, reason: refusal };
78
+ }
79
+ }
80
+ appendFileSync(streamPath, `${JSON.stringify(record)}\n`, "utf8");
81
+ writeFileSync(temporaryPath, `${JSON.stringify(record, undefined, 2)}\n`, "utf8");
82
+ renameSync(temporaryPath, currentPath);
83
+ return { ok: true, record };
84
+ }
85
+ /**
86
+ * Read the current status. C-1: this function knows only `current.json`.
87
+ *
88
+ * It does not accept a stream path, does not fall back to the stream, and
89
+ * has no code path that opens it. That is what makes criterion 7 a
90
+ * demonstrable property rather than a convention: an implementation that
91
+ * reads the stream is a DIFFERENT function, and the criterion requires it to
92
+ * be written, shown red against corrupt stream bytes, and reverted.
93
+ */
94
+ export function readCurrent(fleetRoot) {
95
+ const path = join(fleetRoot, CURRENT_FILE);
96
+ const read = readRegularFileIfPresent(path);
97
+ if (read.kind === "absent") {
98
+ return { ok: false, reason: `no status has been emitted in ${fleetRoot}` };
99
+ }
100
+ if (read.kind === "refused") {
101
+ return { ok: false, reason: read.reason };
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(read.body);
106
+ }
107
+ catch (error) {
108
+ return {
109
+ ok: false,
110
+ reason: `${path} is not readable as a status record: ${error.message}`,
111
+ };
112
+ }
113
+ return { ok: true, record: parsed };
114
+ }
115
+ /** One line, the form `tiphys status show` prints. */
116
+ export function renderStatus(record) {
117
+ const refs = record.refs.length === 0 ? "" : ` refs=${record.refs.join(",")}`;
118
+ return `${record.at} ${record.state} run=${record.run} project=${record.project}${record.detail === "" ? "" : ` ${record.detail}`}${refs}`;
119
+ }
@@ -0,0 +1,264 @@
1
+ import type { Fleet } from "./fleet.ts";
2
+ /**
3
+ * Task state (kernel plan v1, M1-P4 step 1). A task's durable state is
4
+ * <fleet>/tasks/<id>/, holding plain JSON per plan decision D-3:
5
+ *
6
+ * meta.json the task record written by spawn and closed by teardown
7
+ * brief.md the assembled brief (src/brief.ts)
8
+ * turn-end the payload's completion record (src/hooks.ts)
9
+ * executor.json the launch record written by the executor adapter
10
+ * report.md a scout's report, the teardown gate for shape scout
11
+ *
12
+ * PLAN CONSTRAINT C-1 (FM-052, FM-049), binding on every consumer: the
13
+ * ONE current-state authority for a task is meta.json's status plus the
14
+ * turn-end file's recorded exit code. Currency is never derived from the
15
+ * tail of an event or status log, and the turn-end file is a completion
16
+ * NOTIFICATION, not the task's state: a missing turn-end never means
17
+ * success (tuition T-002).
18
+ *
19
+ * The task directory deliberately sits OUTSIDE the worktree (FM-059), so
20
+ * the pool's dirty check never needs an exemption list for the kernel's
21
+ * own injected files. That invariant is absolute: nothing this phase
22
+ * writes may ever land inside <fleet>/worktrees/<id>.
23
+ */
24
+ /**
25
+ * OPEN NOTHING WHOSE TYPE HAS NOT BEEN ESTABLISHED (fix round 4, CR-520).
26
+ *
27
+ * THE MECHANISM AND ITS RULE. Opening a path is not a total operation.
28
+ * open(2) on a named pipe with no peer BLOCKS IN THE KERNEL, for reading
29
+ * and for writing, until a peer appears. A block is not an exception, so
30
+ * no try/catch sees it, no "this function never raises" reasoning touches
31
+ * it, and no error classification reaches it. Every process that reaches
32
+ * such an open stops forever with no output at all.
33
+ *
34
+ * This project has now paid for that mechanism three times in one phase:
35
+ * once at tasks/<id>/meta.json (delta review NEW-2), then on six further
36
+ * paths after the first fix was applied at ONE CALL SITE instead of at the
37
+ * read (CR-520), then on four more this round found by deriving the
38
+ * inventory again rather than inheriting it. The lesson recorded in
39
+ * tuition T-005 is that a rule fixed at a call site does not travel; the
40
+ * rule has to be a property of the operation.
41
+ *
42
+ * So the probe lives HERE, in the readers and in one classifier, and every
43
+ * caller is protected by construction rather than by remembering:
44
+ *
45
+ * - lstat the path (the link itself), then stat (what it resolves to),
46
+ * and open ONLY when that is a regular file;
47
+ * - a directory, FIFO, socket, device node, or a symlink resolving to
48
+ * any of those is classified WITHOUT being opened;
49
+ * - nothing at the path is not an error, because absence is the normal
50
+ * transient shape of most of this kernel's state files.
51
+ *
52
+ * WHY THIS LIVES IN src/task.ts. It is a general filesystem rule and not a
53
+ * task rule, and a dedicated module would be its right home. This module
54
+ * is the lowest one in the import graph that the fix round authorized to
55
+ * touch (src/liveness.ts imports it, src/watcher.ts imports it, and it
56
+ * imports neither), and it already carries one cross-cutting helper for
57
+ * the same reason (runStep, below). Moving both to their own module is
58
+ * recorded as an M2 item rather than done here without authorization.
59
+ *
60
+ * RESIDUAL, stated rather than papered over: the probe and the open are
61
+ * two syscalls, so a path that changes type between them can still be
62
+ * opened as something other than a regular file. Closing that needs
63
+ * open(O_NONBLOCK) followed by fstat, which Node's synchronous fs API
64
+ * does not expose for reads. Nothing in this kernel writes that state, and
65
+ * the window is now the only way to reach the block rather than the
66
+ * default path to it.
67
+ */
68
+ export type EntryClass =
69
+ /** Nothing at the path. */
70
+ {
71
+ kind: "absent";
72
+ }
73
+ /** A link is there and resolves to nothing: it exists, and it is empty of evidence. */
74
+ | {
75
+ kind: "dangling";
76
+ }
77
+ /** Safe to open. */
78
+ | {
79
+ kind: "regular";
80
+ }
81
+ /** Present, and opening it is not safe: never opened, always named. */
82
+ | {
83
+ kind: "irregular";
84
+ reason: string;
85
+ }
86
+ /** Neither lstat nor stat could answer the question. */
87
+ | {
88
+ kind: "unexaminable";
89
+ reason: string;
90
+ };
91
+ /**
92
+ * THE ONE ANSWER TO "may this path be opened". Every reader and every
93
+ * writer of a path this kernel does not itself guarantee to be a regular
94
+ * file goes through this, so there is one implementation of the question
95
+ * and not one per call site.
96
+ */
97
+ export declare function classifyEntry(path: string): EntryClass;
98
+ /**
99
+ * Refuse an open-for-WRITE of a path that is not a regular file. The
100
+ * hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
101
+ * exactly as reading one with no writer does, so a staged write and an
102
+ * append are as dangerous as a read. Returns the reason, or undefined when
103
+ * the path may be opened (absent included: creating it is the point).
104
+ */
105
+ export declare function refuseOpenForWrite(path: string): string | undefined;
106
+ /** What a guarded read of a possibly-absent path produced. */
107
+ export type RegularRead = {
108
+ kind: "read";
109
+ body: string;
110
+ } | {
111
+ kind: "absent";
112
+ }
113
+ /** Present and not readable, with a reason naming the path. */
114
+ | {
115
+ kind: "refused";
116
+ reason: string;
117
+ };
118
+ /**
119
+ * THE ONE READ of a file that might not be there and might not be a file.
120
+ * src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
121
+ * all read fleet state through this, so "probe before open" is a property
122
+ * of the read and cannot be forgotten by a new caller.
123
+ */
124
+ export declare function readRegularFileIfPresent(path: string): RegularRead;
125
+ export type TaskShape = "ship" | "scout";
126
+ export type TaskStatus = "open" | "closed";
127
+ export declare const TASK_SHAPES: readonly TaskShape[];
128
+ /**
129
+ * Task meta (the plan's field set, M1-P4 step 1).
130
+ *
131
+ * - id: the task id, a safe path segment (pool's TASK_ID_PATTERN).
132
+ * - project: absolute path of the project clone the worktree came from.
133
+ * - shape: ship or scout; it selects teardown's refusal rules.
134
+ * - branch: the TASK branch (task/<id>), the branch teardown judges for
135
+ * landedness. Note the deliberate naming difference from the pool
136
+ * record, whose "branch" is the project's default branch and whose
137
+ * "branchName" is this one; meta records the branch the task works on.
138
+ * - worktree: absolute path of the task worktree.
139
+ * - baseSha: the fetched base SHA pool create emitted (EXT-F-03).
140
+ * - baseOffline: provenance, COPIED from the pool record's offline field
141
+ * and never recomputed (PR-212). It is true only when the fetch failed
142
+ * and --offline authorized falling back to the last fetched
143
+ * remote-tracking SHA. A spawn that recomputed it from its own flags
144
+ * would report a fetched base as offline whenever a contended fetch
145
+ * succeeded on retry, which is exactly the provenance inversion V-2
146
+ * produced in M1-P3.
147
+ * - status: open at spawn, closed by a successful teardown.
148
+ * - createdAt: ISO-8601 timestamp of the spawn.
149
+ */
150
+ export interface TaskMeta {
151
+ id: string;
152
+ project: string;
153
+ shape: TaskShape;
154
+ branch: string;
155
+ worktree: string;
156
+ baseSha: string;
157
+ baseOffline: boolean;
158
+ status: TaskStatus;
159
+ createdAt: string;
160
+ }
161
+ export declare function taskDir(fleet: Fleet, taskId: string): string;
162
+ export declare function metaPath(fleet: Fleet, taskId: string): string;
163
+ export declare function briefPath(fleet: Fleet, taskId: string): string;
164
+ export declare function turnEndPath(fleet: Fleet, taskId: string): string;
165
+ export declare function executorRecordPath(fleet: Fleet, taskId: string): string;
166
+ export declare function reportPath(fleet: Fleet, taskId: string): string;
167
+ /** Serialize meta the way every kernel JSON state file is written. */
168
+ export declare function renderTaskMeta(meta: TaskMeta): string;
169
+ export declare function writeTaskMeta(fleet: Fleet, meta: TaskMeta): void;
170
+ /**
171
+ * Read meta.json, or undefined when it is absent, is not a regular file,
172
+ * or does not parse. All three mean the same thing to every caller: this
173
+ * is not a readable record, and it is not evidence that the task finished.
174
+ *
175
+ * The type probe is INSIDE this function and not in front of one of its
176
+ * callers (CR-520, CR-521). There is exactly one implementation of "read a
177
+ * task record", every caller of it is protected, and adding a caller
178
+ * cannot reopen the hole: src/teardown.ts reaches this directly, without
179
+ * going through the liveness classifier, and a named pipe here used to
180
+ * hang it forever.
181
+ */
182
+ export declare function readTaskMeta(fleet: Fleet, taskId: string): TaskMeta | undefined;
183
+ /** Set meta.json status (teardown's last step; C-1's state authority). */
184
+ export declare function setTaskStatus(fleet: Fleet, meta: TaskMeta, status: TaskStatus): void;
185
+ export type GuardResult = {
186
+ ok: true;
187
+ } | {
188
+ ok: false;
189
+ reason: string;
190
+ };
191
+ /**
192
+ * Holdership guard for the task-mutating commands (PR-203), shared by
193
+ * spawn and teardown so there is exactly one implementation of the rule.
194
+ * It lives here rather than in a guard module of its own because this
195
+ * phase's files-to-touch list has no shared-guard file and the rule is a
196
+ * precondition of every task mutation.
197
+ *
198
+ * The transport is M1-P3's, unchanged and not re-invented: lock acquire
199
+ * PRINTS the opaque holderId, the operator carries it, and here it
200
+ * arrives as the TIPHYS_HOLDER_ID environment variable. No second
201
+ * identity mechanism exists, and nothing about the holder is derived
202
+ * from the running program (plan constraint C-2): this reads the lease
203
+ * FILE and nothing else.
204
+ *
205
+ * With no lease file present the command proceeds (the plan's M1 test
206
+ * contexts). A lease that exists but is unreadable, expired, or held by
207
+ * a different holder refuses: fail closed, because every one of those
208
+ * states means this caller cannot prove it is the one orchestrator.
209
+ */
210
+ export declare function checkHoldership(fleet: Fleet): GuardResult;
211
+ /** True when the task directory already exists (spawn's rollback scope). */
212
+ export declare function taskDirExists(fleet: Fleet, taskId: string): boolean;
213
+ /**
214
+ * True when tasks/<id>/ already holds a previous incarnation of the task
215
+ * id, which spawn refuses (CR-301).
216
+ *
217
+ * The task directory is the DURABLE record: teardown removes the worktree
218
+ * and the pool record but deliberately leaves tasks/<id>/ behind, so the
219
+ * id is free from the pool's point of view and occupied from the task
220
+ * state's. Spawning into it would overwrite the closed task's records,
221
+ * hand the launch-failure rollback files it did not create, and leave the
222
+ * previous incarnation's turn-end file readable while the new
223
+ * incarnation's meta says open, which is a completion that did not happen
224
+ * sitting under the C-1 state authority.
225
+ *
226
+ * A path that exists but is not a directory counts as occupied too: it is
227
+ * not a state this kernel may write into, and refusing costs the operator
228
+ * one rename while guessing could cost the record.
229
+ */
230
+ export declare function taskDirOccupied(fleet: Fleet, taskId: string): boolean;
231
+ /**
232
+ * Collapse captured git or error output to ONE line (CR-303). Plan step 5 ends
233
+ * "every refusal is exit nonzero plus a single reason line", and git's
234
+ * own stderr is routinely five lines, so any interpolation of it must be
235
+ * flattened rather than trusted to be short. The M1-P6 harness reads
236
+ * these reason lines as evidence.
237
+ */
238
+ export declare function singleLine(text: string): string;
239
+ export type StepResult<T> = {
240
+ ok: true;
241
+ value: T;
242
+ } | {
243
+ ok: false;
244
+ reason: string;
245
+ };
246
+ /**
247
+ * Run one step that may signal failure by THROWING, and fold a raised
248
+ * error into the same ok/reason shape every other step in spawn and
249
+ * teardown returns (F-1, F-2).
250
+ *
251
+ * This exists because the modules were written as a result type end to
252
+ * end while the Node fs calls underneath them are not: writeFileSync and
253
+ * mkdirSync raise. Every returned failure was handled correctly and every
254
+ * THROWN one walked straight past the handler, out of the command, and
255
+ * onto stderr as a stack trace, taking spawn's rollback and teardown's
256
+ * state update with it. Wrapping is therefore not defensive decoration
257
+ * for a state M1 never reaches: it is the difference between a rollback
258
+ * that runs and an orphaned worktree, and between a task marked closed
259
+ * and a meta.json that lies about a worktree that is already gone.
260
+ *
261
+ * It never swallows: the caller still gets a reason naming the step, and
262
+ * still decides whether to roll back, report a partial failure, or refuse.
263
+ */
264
+ export declare function runStep<T>(what: string, step: () => T): StepResult<T>;
@@ -0,0 +1,305 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { leaseStatus } from "./lock.js";
4
+ function describeType(stats) {
5
+ if (stats.isDirectory()) {
6
+ return "a directory";
7
+ }
8
+ if (stats.isFIFO()) {
9
+ return "a named pipe";
10
+ }
11
+ if (stats.isSocket()) {
12
+ return "a socket";
13
+ }
14
+ if (stats.isCharacterDevice()) {
15
+ return "a character device";
16
+ }
17
+ if (stats.isBlockDevice()) {
18
+ return "a block device";
19
+ }
20
+ return "an entry of an unrecognized type";
21
+ }
22
+ /**
23
+ * THE ONE ANSWER TO "may this path be opened". Every reader and every
24
+ * writer of a path this kernel does not itself guarantee to be a regular
25
+ * file goes through this, so there is one implementation of the question
26
+ * and not one per call site.
27
+ */
28
+ export function classifyEntry(path) {
29
+ try {
30
+ lstatSync(path);
31
+ }
32
+ catch (error) {
33
+ if (error.code === "ENOENT") {
34
+ return { kind: "absent" };
35
+ }
36
+ return {
37
+ kind: "unexaminable",
38
+ reason: `${path} could not be examined: ${String(error)}`,
39
+ };
40
+ }
41
+ let stats;
42
+ try {
43
+ stats = statSync(path);
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT") {
47
+ return { kind: "dangling" };
48
+ }
49
+ return {
50
+ kind: "unexaminable",
51
+ reason: `${path} could not be examined: ${String(error)}`,
52
+ };
53
+ }
54
+ if (stats.isFile()) {
55
+ return { kind: "regular" };
56
+ }
57
+ return {
58
+ kind: "irregular",
59
+ reason: `${path} is ${describeType(stats)}, not a regular file, so it was not opened`,
60
+ };
61
+ }
62
+ /**
63
+ * Refuse an open-for-WRITE of a path that is not a regular file. The
64
+ * hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
65
+ * exactly as reading one with no writer does, so a staged write and an
66
+ * append are as dangerous as a read. Returns the reason, or undefined when
67
+ * the path may be opened (absent included: creating it is the point).
68
+ */
69
+ export function refuseOpenForWrite(path) {
70
+ const entry = classifyEntry(path);
71
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
72
+ return entry.reason;
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * THE ONE READ of a file that might not be there and might not be a file.
78
+ * src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
79
+ * all read fleet state through this, so "probe before open" is a property
80
+ * of the read and cannot be forgotten by a new caller.
81
+ */
82
+ export function readRegularFileIfPresent(path) {
83
+ const entry = classifyEntry(path);
84
+ if (entry.kind === "absent" || entry.kind === "dangling") {
85
+ return { kind: "absent" };
86
+ }
87
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
88
+ return { kind: "refused", reason: entry.reason };
89
+ }
90
+ let body;
91
+ try {
92
+ body = readFileSync(path, "utf8");
93
+ }
94
+ catch (error) {
95
+ if (error.code === "ENOENT") {
96
+ // Removed between the probe and the read.
97
+ return { kind: "absent" };
98
+ }
99
+ return {
100
+ kind: "refused",
101
+ reason: `${path} could not be read: ${String(error)}`,
102
+ };
103
+ }
104
+ return { kind: "read", body };
105
+ }
106
+ export const TASK_SHAPES = ["ship", "scout"];
107
+ export function taskDir(fleet, taskId) {
108
+ return join(fleet.tasksDir, taskId);
109
+ }
110
+ export function metaPath(fleet, taskId) {
111
+ return join(taskDir(fleet, taskId), "meta.json");
112
+ }
113
+ export function briefPath(fleet, taskId) {
114
+ return join(taskDir(fleet, taskId), "brief.md");
115
+ }
116
+ export function turnEndPath(fleet, taskId) {
117
+ return join(taskDir(fleet, taskId), "turn-end");
118
+ }
119
+ export function executorRecordPath(fleet, taskId) {
120
+ return join(taskDir(fleet, taskId), "executor.json");
121
+ }
122
+ export function reportPath(fleet, taskId) {
123
+ return join(taskDir(fleet, taskId), "report.md");
124
+ }
125
+ /** Serialize meta the way every kernel JSON state file is written. */
126
+ export function renderTaskMeta(meta) {
127
+ return `${JSON.stringify(meta, null, 2)}\n`;
128
+ }
129
+ export function writeTaskMeta(fleet, meta) {
130
+ writeFileSync(metaPath(fleet, meta.id), renderTaskMeta(meta));
131
+ }
132
+ /**
133
+ * Read meta.json, or undefined when it is absent, is not a regular file,
134
+ * or does not parse. All three mean the same thing to every caller: this
135
+ * is not a readable record, and it is not evidence that the task finished.
136
+ *
137
+ * The type probe is INSIDE this function and not in front of one of its
138
+ * callers (CR-520, CR-521). There is exactly one implementation of "read a
139
+ * task record", every caller of it is protected, and adding a caller
140
+ * cannot reopen the hole: src/teardown.ts reaches this directly, without
141
+ * going through the liveness classifier, and a named pipe here used to
142
+ * hang it forever.
143
+ */
144
+ export function readTaskMeta(fleet, taskId) {
145
+ const read = readRegularFileIfPresent(metaPath(fleet, taskId));
146
+ if (read.kind !== "read") {
147
+ return undefined;
148
+ }
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(read.body);
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ const candidate = parsed;
157
+ if (typeof candidate.id !== "string" ||
158
+ typeof candidate.project !== "string" ||
159
+ (candidate.shape !== "ship" && candidate.shape !== "scout") ||
160
+ typeof candidate.branch !== "string" ||
161
+ typeof candidate.worktree !== "string" ||
162
+ typeof candidate.baseSha !== "string" ||
163
+ typeof candidate.baseOffline !== "boolean" ||
164
+ (candidate.status !== "open" && candidate.status !== "closed") ||
165
+ typeof candidate.createdAt !== "string") {
166
+ return undefined;
167
+ }
168
+ return candidate;
169
+ }
170
+ /** Set meta.json status (teardown's last step; C-1's state authority). */
171
+ export function setTaskStatus(fleet, meta, status) {
172
+ writeTaskMeta(fleet, { ...meta, status });
173
+ }
174
+ /**
175
+ * Holdership guard for the task-mutating commands (PR-203), shared by
176
+ * spawn and teardown so there is exactly one implementation of the rule.
177
+ * It lives here rather than in a guard module of its own because this
178
+ * phase's files-to-touch list has no shared-guard file and the rule is a
179
+ * precondition of every task mutation.
180
+ *
181
+ * The transport is M1-P3's, unchanged and not re-invented: lock acquire
182
+ * PRINTS the opaque holderId, the operator carries it, and here it
183
+ * arrives as the TIPHYS_HOLDER_ID environment variable. No second
184
+ * identity mechanism exists, and nothing about the holder is derived
185
+ * from the running program (plan constraint C-2): this reads the lease
186
+ * FILE and nothing else.
187
+ *
188
+ * With no lease file present the command proceeds (the plan's M1 test
189
+ * contexts). A lease that exists but is unreadable, expired, or held by
190
+ * a different holder refuses: fail closed, because every one of those
191
+ * states means this caller cannot prove it is the one orchestrator.
192
+ */
193
+ export function checkHoldership(fleet) {
194
+ const status = leaseStatus(fleet.lockPath);
195
+ if (status.state === "free") {
196
+ return { ok: true };
197
+ }
198
+ if (status.state === "corrupt") {
199
+ return {
200
+ ok: false,
201
+ reason: `lease file ${fleet.lockPath} exists but does not parse; refusing without provable holdership`,
202
+ };
203
+ }
204
+ const holder = process.env.TIPHYS_HOLDER_ID;
205
+ if (status.state === "expired") {
206
+ return {
207
+ ok: false,
208
+ reason: `lease ${fleet.lockPath} expired ${status.lease.expiresAt} (holder ` +
209
+ `${status.lease.holderId}); re-acquire or take over before mutating tasks`,
210
+ };
211
+ }
212
+ if (holder === undefined || holder === "") {
213
+ return {
214
+ ok: false,
215
+ reason: `lease ${fleet.lockPath} is held by ${status.lease.holderId} and ` +
216
+ `TIPHYS_HOLDER_ID is not set; set it to the holder id lock acquire printed`,
217
+ };
218
+ }
219
+ if (holder !== status.lease.holderId) {
220
+ return {
221
+ ok: false,
222
+ reason: `lease ${fleet.lockPath} is held by ${status.lease.holderId}, not by ` +
223
+ `TIPHYS_HOLDER_ID ${holder}`,
224
+ };
225
+ }
226
+ return { ok: true };
227
+ }
228
+ /** True when the task directory already exists (spawn's rollback scope). */
229
+ export function taskDirExists(fleet, taskId) {
230
+ return existsSync(taskDir(fleet, taskId));
231
+ }
232
+ /**
233
+ * True when tasks/<id>/ already holds a previous incarnation of the task
234
+ * id, which spawn refuses (CR-301).
235
+ *
236
+ * The task directory is the DURABLE record: teardown removes the worktree
237
+ * and the pool record but deliberately leaves tasks/<id>/ behind, so the
238
+ * id is free from the pool's point of view and occupied from the task
239
+ * state's. Spawning into it would overwrite the closed task's records,
240
+ * hand the launch-failure rollback files it did not create, and leave the
241
+ * previous incarnation's turn-end file readable while the new
242
+ * incarnation's meta says open, which is a completion that did not happen
243
+ * sitting under the C-1 state authority.
244
+ *
245
+ * A path that exists but is not a directory counts as occupied too: it is
246
+ * not a state this kernel may write into, and refusing costs the operator
247
+ * one rename while guessing could cost the record.
248
+ */
249
+ export function taskDirOccupied(fleet, taskId) {
250
+ const dir = taskDir(fleet, taskId);
251
+ if (!existsSync(dir)) {
252
+ return false;
253
+ }
254
+ try {
255
+ if (!statSync(dir).isDirectory()) {
256
+ return true;
257
+ }
258
+ return readdirSync(dir).length > 0;
259
+ }
260
+ catch {
261
+ // Unreadable is not empty: fail closed.
262
+ return true;
263
+ }
264
+ }
265
+ /**
266
+ * Collapse captured git or error output to ONE line (CR-303). Plan step 5 ends
267
+ * "every refusal is exit nonzero plus a single reason line", and git's
268
+ * own stderr is routinely five lines, so any interpolation of it must be
269
+ * flattened rather than trusted to be short. The M1-P6 harness reads
270
+ * these reason lines as evidence.
271
+ */
272
+ export function singleLine(text) {
273
+ return text
274
+ .split("\n")
275
+ .map((line) => line.trim())
276
+ .filter((line) => line !== "")
277
+ .join("; ");
278
+ }
279
+ /**
280
+ * Run one step that may signal failure by THROWING, and fold a raised
281
+ * error into the same ok/reason shape every other step in spawn and
282
+ * teardown returns (F-1, F-2).
283
+ *
284
+ * This exists because the modules were written as a result type end to
285
+ * end while the Node fs calls underneath them are not: writeFileSync and
286
+ * mkdirSync raise. Every returned failure was handled correctly and every
287
+ * THROWN one walked straight past the handler, out of the command, and
288
+ * onto stderr as a stack trace, taking spawn's rollback and teardown's
289
+ * state update with it. Wrapping is therefore not defensive decoration
290
+ * for a state M1 never reaches: it is the difference between a rollback
291
+ * that runs and an orphaned worktree, and between a task marked closed
292
+ * and a meta.json that lies about a worktree that is already gone.
293
+ *
294
+ * It never swallows: the caller still gets a reason naming the step, and
295
+ * still decides whether to roll back, report a partial failure, or refuse.
296
+ */
297
+ export function runStep(what, step) {
298
+ try {
299
+ return { ok: true, value: step() };
300
+ }
301
+ catch (error) {
302
+ const detail = error instanceof Error ? error.message : String(error);
303
+ return { ok: false, reason: `${what} failed: ${detail}` };
304
+ }
305
+ }