@tiphys/kernel 0.1.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 (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
@@ -0,0 +1,307 @@
1
+ import { join } from "node:path";
2
+ import { readRegularFileIfPresent, taskDir } from "./task.js";
3
+ /**
4
+ * THE MODEL-RESOLUTION RECORD, KERNEL SIDE (kernel plan M4, M4-P7;
5
+ * delivery/plan/kernel-plan-m4.md:1018, discharging M4-D-06's four parts at
6
+ * delivery/plan/m4-intake.md:732).
7
+ *
8
+ * WHAT THIS MODULE IS AND, MORE IMPORTANTLY, WHAT IT IS NOT. It READS a
9
+ * record a harness adapter wrote. It holds NO vocabulary: no tier-to-model
10
+ * table, no model-to-family table, and no vendor name of any kind. The whole
11
+ * vocabulary lives in the plugin (plugin/src/vocabulary.ts) and this module
12
+ * dereferences a vocabulary's IDENTITY and never its CONTENT. That single
13
+ * property is what makes a second harness plugin possible at all: a mapping
14
+ * here would close off every harness that is not the one it names
15
+ * (delivery/plan/m4-intake.md:377), and `test/schemas.test.ts` already asserts
16
+ * the absence over the whole shipped surface rather than trusting this
17
+ * paragraph.
18
+ *
19
+ * THE ORDER OF THE CHECKS IS PART OF THE CONTRACT, and it is the release
20
+ * record's order (src/gates/release.ts): the SUBJECT ECHO is compared field by
21
+ * field BEFORE anything reads the resolved identity. A record about another
22
+ * task is not a record with a wrong answer, it is a record about something
23
+ * else, and reading its answer first is how a misattributed family token
24
+ * reaches a decorrelation check as though it belonged there.
25
+ *
26
+ * WHY `observed` IS NOT `attested`, stated here because the vocabulary is the
27
+ * thing most likely to be widened later by someone who did not read M4-P1.
28
+ * The probe measured that the observation channel is harness-written, that it
29
+ * can be EMPTY at hook time under load, and that a process at the agent's own
30
+ * uid can rewrite it in place so the result is byte-shaped exactly like the
31
+ * truth (delivery/verification/m4-prototype-probes.md:410). So `observed`
32
+ * ranks above `self-reported`, which costs nothing to forge, and below any
33
+ * notion of attestation, which would need a signer this environment has not
34
+ * been shown to have.
35
+ */
36
+ /** The record's file name inside the task directory. */
37
+ export const MODEL_RESOLUTION_RECORD_NAME = "model-resolution.json";
38
+ /** The contract versions this kernel accepts. */
39
+ export const MODEL_RESOLUTION_CONTRACT_VERSIONS = ["1"];
40
+ /**
41
+ * Where the record lives: `tasks/<id>/model-resolution.json`, NEVER inside the
42
+ * worktree (M4-P7 criterion 5, src/task.ts:31 and FM-059). The task directory
43
+ * sits outside the worktree precisely so the pool's dirty check needs no
44
+ * exemption list for the kernel's own injected files, and a record written
45
+ * into the worktree would be indistinguishable from the agent's own work.
46
+ */
47
+ export function modelResolutionPath(fleet, taskId) {
48
+ return join(taskDir(fleet, taskId), MODEL_RESOLUTION_RECORD_NAME);
49
+ }
50
+ function asRecord(value) {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value)
52
+ ? value
53
+ : undefined;
54
+ }
55
+ function stringAt(container, field) {
56
+ const value = container?.[field];
57
+ return typeof value === "string" ? value : undefined;
58
+ }
59
+ /**
60
+ * Read the record for a task.
61
+ *
62
+ * AN ABSENT RECORD IS AN ERROR AND IS NEVER GREEN AND NEVER NOT-APPLICABLE
63
+ * (M4-P7 criterion 5). This is not a new rule and deliberately not a second
64
+ * implementation of one: src/gates/release.ts:609 already produces the
65
+ * sentence for the same hazard on the release seam, and the wording is kept
66
+ * in the same shape so that a reader who has met one meets the other.
67
+ * `test/model-resolution.test.ts` drives the real release gate to capture that
68
+ * sentence and derives its assertion from the capture, rather than pinning a
69
+ * hand-written copy of it.
70
+ *
71
+ * THE READ GOES THROUGH `readRegularFileIfPresent`, which is M2-C-6 and the
72
+ * mechanism-index row `reading-a-path-whose-type-is-not-established`: a named
73
+ * pipe at this path is a REPORTED refusal, never a blocked open.
74
+ */
75
+ export function readModelResolutionRecord(path, writerExitCode) {
76
+ const read = readRegularFileIfPresent(path);
77
+ if (read.kind === "absent") {
78
+ return {
79
+ kind: "error",
80
+ reason: `fail-closed rule 1: adapter exited ${String(writerExitCode)} without ` +
81
+ `writing a model-resolution record at ${path}; exit 0 with no ` +
82
+ `record is error, not success`,
83
+ };
84
+ }
85
+ if (read.kind === "refused") {
86
+ return { kind: "error", reason: read.reason };
87
+ }
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(read.body);
91
+ }
92
+ catch (error) {
93
+ return {
94
+ kind: "error",
95
+ reason: `${path} is not readable as JSON: ${String(error).replace(/\s+/g, " ").trim()}`,
96
+ };
97
+ }
98
+ const record = asRecord(parsed);
99
+ if (record === undefined) {
100
+ return { kind: "error", reason: `${path} does not hold a JSON object` };
101
+ }
102
+ return { kind: "read", record };
103
+ }
104
+ /**
105
+ * The vocabulary IDENTITY of a record, and nothing else about the vocabulary.
106
+ *
107
+ * TWO FIELDS ARE READ AND THEY ARE NAMED HERE RATHER THAN SPREAD, which is
108
+ * the whole of criterion 4's mechanical half. A spread, an `Object.keys`, a
109
+ * `JSON.stringify` or a `structuredClone` of the vocabulary object would
110
+ * ENUMERATE it, and enumeration is dereferencing the content whatever the
111
+ * intent. `test/model-resolution.test.ts` hands this function a Proxy that
112
+ * records every property read and asserts the recorded set against these two
113
+ * names, so a later edit that reaches for a third reddens.
114
+ */
115
+ export function vocabularyIdentity(record) {
116
+ const resolved = asRecord(record["resolved"]);
117
+ const vocabulary = asRecord(resolved?.["vocabulary"]);
118
+ if (vocabulary === undefined) {
119
+ return undefined;
120
+ }
121
+ const id = vocabulary["id"];
122
+ const version = vocabulary["version"];
123
+ if (typeof id !== "string" || typeof version !== "number") {
124
+ return undefined;
125
+ }
126
+ return { id, version };
127
+ }
128
+ /**
129
+ * Accept or refuse one record against the launch request the kernel holds.
130
+ *
131
+ * FOUR REFUSALS, IN THIS ORDER, and the order is the point.
132
+ *
133
+ * 1. THE CONTRACT VERSION, because a record written to a contract this kernel
134
+ * does not know is not a record this kernel can read fields out of.
135
+ * 2. THE SUBJECT ECHO, FIELD BY FIELD, BEFORE THE OUTCOME IS READ. This is
136
+ * src/gates/schemas/release-record.schema.json:26's misattribution guard.
137
+ * 3. THE VOCABULARY IDENTITY, because a family token with no vocabulary is a
138
+ * token nobody can say the meaning of.
139
+ * 4. THE PROVENANCE, which is M4-P7 criterion 6 member TWO. Member one (a
140
+ * record claiming `observed` with no observation at all) is the schema's
141
+ * and is a missing field. THIS is the present-but-inconsistent half, and a
142
+ * schema-only guard passes it green: an observation about another task, or
143
+ * naming a model other than the one the record claims, is a self-report
144
+ * with a decoration on it.
145
+ */
146
+ export function acceptModelResolution(record, subject) {
147
+ const contractVersion = stringAt(record, "contractVersion");
148
+ if (contractVersion === undefined || !MODEL_RESOLUTION_CONTRACT_VERSIONS.includes(contractVersion)) {
149
+ return {
150
+ kind: "refused",
151
+ reason: `the record declares contract version ${String(contractVersion)}, and this ` +
152
+ `kernel accepts ${MODEL_RESOLUTION_CONTRACT_VERSIONS.join(", ")}`,
153
+ };
154
+ }
155
+ const echo = asRecord(record["subject"]);
156
+ if (echo === undefined) {
157
+ return {
158
+ kind: "refused",
159
+ reason: "the record carries no subject echo, so what it is about could not be established",
160
+ };
161
+ }
162
+ const echoed = [
163
+ ["taskId", stringAt(echo, "taskId")],
164
+ ["role", stringAt(echo, "role")],
165
+ ["requestedTier", stringAt(echo, "requestedTier")],
166
+ ];
167
+ for (const [field, value] of echoed) {
168
+ if (value !== subject[field]) {
169
+ return {
170
+ kind: "refused",
171
+ reason: `the record's subject echo says ${field} is ${String(value)} and the ` +
172
+ `launch request says ${subject[field]}, so this record is about a ` +
173
+ `different subject`,
174
+ };
175
+ }
176
+ }
177
+ const identity = vocabularyIdentity(record);
178
+ if (identity === undefined) {
179
+ return {
180
+ kind: "refused",
181
+ reason: "the record carries no resolved.vocabulary identity, so its family token " +
182
+ "belongs to no stated vocabulary and cannot be compared with any other",
183
+ };
184
+ }
185
+ const resolved = asRecord(record["resolved"]);
186
+ const provenance = stringAt(resolved, "provenance");
187
+ const family = stringAt(resolved, "family");
188
+ const model = stringAt(resolved, "model");
189
+ if (provenance === "observed") {
190
+ const observation = asRecord(resolved?.["observation"]);
191
+ if (observation === undefined) {
192
+ return {
193
+ kind: "refused",
194
+ reason: "the record claims provenance observed and carries no observation, so " +
195
+ "the claim is a self-report wearing the word observed",
196
+ };
197
+ }
198
+ const observedTask = stringAt(observation, "taskId");
199
+ if (observedTask !== subject.taskId) {
200
+ return {
201
+ kind: "refused",
202
+ reason: `the record claims provenance observed and its observation is about ` +
203
+ `task ${String(observedTask)}, while the echoed request is about task ` +
204
+ `${subject.taskId}, so the observation contradicts the subject it is ` +
205
+ `offered as evidence for`,
206
+ };
207
+ }
208
+ const observedModel = stringAt(observation, "model");
209
+ if (observedModel !== model) {
210
+ return {
211
+ kind: "refused",
212
+ reason: `the record claims model ${String(model)} with provenance observed and ` +
213
+ `its observation saw ${String(observedModel)}, so the claimed identity ` +
214
+ `is not the observed one`,
215
+ };
216
+ }
217
+ }
218
+ else if (provenance === "self-reported") {
219
+ if (resolved?.["observation"] !== undefined) {
220
+ return {
221
+ kind: "refused",
222
+ reason: "the record ranks itself self-reported and carries an observation, so " +
223
+ "it claims evidence it also says it does not have",
224
+ };
225
+ }
226
+ }
227
+ else if (provenance === "unresolved") {
228
+ if (family !== undefined || model !== undefined) {
229
+ return {
230
+ kind: "refused",
231
+ reason: "the record reports the identity unresolved and still names a family " +
232
+ "or a model, which is the quiet fallback to the forgeable value that " +
233
+ "an unresolved observation exists to prevent",
234
+ };
235
+ }
236
+ }
237
+ else {
238
+ return {
239
+ kind: "refused",
240
+ reason: `the record declares provenance ${String(provenance)}, which this kernel has no rule for`,
241
+ };
242
+ }
243
+ return { kind: "accepted", family, provenance: provenance };
244
+ }
245
+ /**
246
+ * Compare two accepted records' family tokens (M4-P7 criterion 3).
247
+ *
248
+ * TWO VOCABULARIES ARE NOT COMPARABLE AND THE REFUSAL NAMES BOTH IDS. A family
249
+ * token means whatever the vocabulary that minted it says it means, so
250
+ * comparing a token from one against a token from another is comparing two
251
+ * strings and calling the result a fact about models. That comparison is what
252
+ * `check-dual-review`'s decorrelation assertion rests on, and DR-0038 exists
253
+ * for exactly the environment where the two reviews come from different
254
+ * places, so a silent cross-vocabulary compare would make the assertion
255
+ * meaningless precisely where it is load-bearing.
256
+ *
257
+ * ONLY `id` IS READ HERE. The version is part of the identity a reader may
258
+ * print and is deliberately NOT part of the comparison: a vocabulary that
259
+ * added a family in v2 did not change what its v1 tokens mean, and refusing
260
+ * on version would turn a routine vocabulary revision into an outage.
261
+ */
262
+ export function compareResolvedFamilies(left, right) {
263
+ const leftIdentity = vocabularyIdentity(left);
264
+ const rightIdentity = vocabularyIdentity(right);
265
+ if (leftIdentity === undefined || rightIdentity === undefined) {
266
+ return {
267
+ kind: "refused",
268
+ reason: "one of the two records carries no resolved.vocabulary identity, so the " +
269
+ "two family tokens belong to no stated common vocabulary",
270
+ };
271
+ }
272
+ if (leftIdentity.id !== rightIdentity.id) {
273
+ return {
274
+ kind: "refused",
275
+ reason: `the two records name different vocabularies, ${leftIdentity.id} and ` +
276
+ `${rightIdentity.id}, so their family tokens are not comparable and no ` +
277
+ `decorrelation claim can be made from them`,
278
+ };
279
+ }
280
+ const leftFamily = stringAt(asRecord(left["resolved"]), "family");
281
+ const rightFamily = stringAt(asRecord(right["resolved"]), "family");
282
+ if (leftFamily === undefined || rightFamily === undefined) {
283
+ return {
284
+ kind: "refused",
285
+ reason: "one of the two records names no resolved family, so there is nothing to " +
286
+ "compare; an unresolved identity is not a difference and not a match",
287
+ };
288
+ }
289
+ return {
290
+ kind: "compared",
291
+ differ: leftFamily !== rightFamily,
292
+ families: [leftFamily, rightFamily],
293
+ };
294
+ }
295
+ /**
296
+ * The value closeout copies into a verdict's `produced-by` (M4-P7 criterion 8).
297
+ *
298
+ * VERBATIM, AND THE FUNCTION EXISTS SO THAT "VERBATIM" IS TESTABLE. DR-0031
299
+ * requires a pull request to carry all its own evidence, so the family token
300
+ * has to reach the verdict document rather than being left in a task
301
+ * directory that no reviewer of the pull request can see. A copy that
302
+ * normalised, lowercased or prefixed the token would satisfy every reading of
303
+ * that sentence and would break the one comparison the token exists for.
304
+ */
305
+ export function producedByFromRecord(record) {
306
+ return stringAt(asRecord(record["resolved"]), "family");
307
+ }
@@ -1,2 +1,34 @@
1
1
  /** Compare two existing paths by their canonical filesystem identity. */
2
2
  export declare function pathsIdentifySameObject(left: string, right: string): boolean;
3
+ /**
4
+ * Decide whether two paths NAME THE SAME filesystem object when at least
5
+ * one of them was produced by ANOTHER PROGRAM rather than composed here.
6
+ *
7
+ * THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
8
+ * job of pull request #155. `path.resolve` normalizes `.`, `..` and
9
+ * relative segments and does NOT resolve symlinks, so two spellings of one
10
+ * directory stay unequal as strings. Programs the kernel shells out to do
11
+ * not preserve the caller's spelling: git canonicalizes every worktree
12
+ * path it records (`git worktree add /link/wt` then `git worktree list
13
+ * --porcelain` reports `/real/wt`, measured on Linux), and node reports
14
+ * the canonical path of a test file in its reporter's `file` field. So a
15
+ * kernel-composed path compared by string against such a value answers
16
+ * "different object" for the same object, silently, and every decision
17
+ * taken on that answer is wrong in the direction that does nothing.
18
+ *
19
+ * On macOS the symlink is supplied by the platform and needs no unusual
20
+ * setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
21
+ * path under `/var/folders`, where `/var` is a symlink to `/private/var`.
22
+ * The CLI is accidentally immune because `process.cwd()` is already
23
+ * canonical, so only a caller that hands a path in makes this reachable,
24
+ * which is exactly what a library consumer does.
25
+ *
26
+ * The string comparison is tried FIRST and kept, rather than replaced, for
27
+ * two reasons: it answers without touching the filesystem in the common
28
+ * case, and it still gives the right answer for two paths that do not
29
+ * exist, where `realpathSync` can only raise. This function therefore says
30
+ * "same" strictly more often than `resolve(left) === resolve(right)` does,
31
+ * and never says "same" about two objects that are genuinely different:
32
+ * equal canonical paths ARE one object.
33
+ */
34
+ export declare function pathsNameSameObject(left: string, right: string): boolean;
@@ -1,4 +1,5 @@
1
1
  import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
2
3
  /** Compare two existing paths by their canonical filesystem identity. */
3
4
  export function pathsIdentifySameObject(left, right) {
4
5
  try {
@@ -8,3 +9,40 @@ export function pathsIdentifySameObject(left, right) {
8
9
  return false;
9
10
  }
10
11
  }
12
+ /**
13
+ * Decide whether two paths NAME THE SAME filesystem object when at least
14
+ * one of them was produced by ANOTHER PROGRAM rather than composed here.
15
+ *
16
+ * THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
17
+ * job of pull request #155. `path.resolve` normalizes `.`, `..` and
18
+ * relative segments and does NOT resolve symlinks, so two spellings of one
19
+ * directory stay unequal as strings. Programs the kernel shells out to do
20
+ * not preserve the caller's spelling: git canonicalizes every worktree
21
+ * path it records (`git worktree add /link/wt` then `git worktree list
22
+ * --porcelain` reports `/real/wt`, measured on Linux), and node reports
23
+ * the canonical path of a test file in its reporter's `file` field. So a
24
+ * kernel-composed path compared by string against such a value answers
25
+ * "different object" for the same object, silently, and every decision
26
+ * taken on that answer is wrong in the direction that does nothing.
27
+ *
28
+ * On macOS the symlink is supplied by the platform and needs no unusual
29
+ * setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
30
+ * path under `/var/folders`, where `/var` is a symlink to `/private/var`.
31
+ * The CLI is accidentally immune because `process.cwd()` is already
32
+ * canonical, so only a caller that hands a path in makes this reachable,
33
+ * which is exactly what a library consumer does.
34
+ *
35
+ * The string comparison is tried FIRST and kept, rather than replaced, for
36
+ * two reasons: it answers without touching the filesystem in the common
37
+ * case, and it still gives the right answer for two paths that do not
38
+ * exist, where `realpathSync` can only raise. This function therefore says
39
+ * "same" strictly more often than `resolve(left) === resolve(right)` does,
40
+ * and never says "same" about two objects that are genuinely different:
41
+ * equal canonical paths ARE one object.
42
+ */
43
+ export function pathsNameSameObject(left, right) {
44
+ if (resolve(left) === resolve(right)) {
45
+ return true;
46
+ }
47
+ return pathsIdentifySameObject(left, right);
48
+ }
@@ -56,11 +56,154 @@ export type PoolResult<T> = {
56
56
  ok: false;
57
57
  reason: string;
58
58
  };
59
+ /**
60
+ * THE BOUND ON A NETWORK-REACHING GIT SUBPROCESS (M4-P19 fix round).
61
+ *
62
+ * `spawnSync` with no `timeout` waits for the child forever. For a local
63
+ * git command that is harmless: it either answers or fails. For one that
64
+ * opens a socket it is not, and the failure mode is not a slow command,
65
+ * it is a command that NEVER RETURNS. Measured by the clean-room
66
+ * reviewer against head abde402: a remote pointed at a TCP listener that
67
+ * accepts and never speaks made `tiphys pool list` and `tiphys doctor`
68
+ * run until killed (exit 124 under `timeout 25`), where the same fixture
69
+ * on the phase base exited 0 in about a second.
70
+ *
71
+ * This bound is applied to `ls-remote --symref <remote> HEAD` only, and
72
+ * the reason it is safe THERE and not elsewhere is a property of the
73
+ * command rather than a judgement about it: that invocation transfers a
74
+ * ref advertisement and nothing else, so a legitimate one is bounded by
75
+ * round-trip latency. `git fetch` (src/pool.ts, src/teardown.ts) and
76
+ * `git push` (src/teardown.ts) transfer objects, so their legitimate
77
+ * duration IS unbounded and a wall-clock cap on them would abort real
78
+ * work. They are left unbounded deliberately, and they are reached only
79
+ * from a command the operator invoked to do that work, never from a
80
+ * reporting path; keeping reporting paths off the network entirely is
81
+ * the other half of this fix (see `reconstructPoolRecord`).
82
+ *
83
+ * TIPHYS_GIT_NETWORK_TIMEOUT_MS is a TEST SEAM in the style of
84
+ * TIPHYS_WATCH_TEST_HOLD (src/watcher.ts) and TIPHYS_LOCK_TEST_HOLD
85
+ * (src/commands/lock.ts): a test cannot afford to wait out the shipped
86
+ * bound, and a shipped bound short enough for a test would abort a
87
+ * legitimate ls-remote over a slow link. A value that is not a positive
88
+ * integer is IGNORED rather than honoured, so a malformed environment
89
+ * cannot silently remove the bound.
90
+ */
91
+ export declare const NETWORK_TIMEOUT_MS = 20000;
92
+ /**
93
+ * Exported as a PURE function so the validation has a witness that does
94
+ * not have to wait out a twenty-second bound to observe it. The
95
+ * end-to-end bound is witnessed separately, against a real remote that
96
+ * never answers; this is the arm that says a malformed environment
97
+ * cannot silently switch the bound off.
98
+ *
99
+ * `Number("")` is 0 and `Number("0x10")` is 16, so neither a blank value
100
+ * nor a hexadecimal one is passed through: the accepted set is exactly
101
+ * the positive integers, and everything else falls back.
102
+ */
103
+ export declare function resolveNetworkTimeoutMs(raw: string | undefined): number;
59
104
  /** Exported for the contention-classification test. */
60
105
  export declare function isTransientGitLockError(stderr: string): boolean;
61
106
  export declare function recordPath(fleet: Fleet, taskId: string): string;
62
107
  export declare function worktreePath(fleet: Fleet, taskId: string): string;
108
+ /**
109
+ * THE RECORD'S ENTRY TYPE IS ESTABLISHED BEFORE IT IS OPENED. A bare read
110
+ * here hung `tiphys pool destroy` forever with zero output against a named
111
+ * pipe at `worktrees/<id>.pool.json`; measured before the fix, `pool list`
112
+ * (which reads only the NAME) returned in the same second while
113
+ * `pool destroy --task t-0001` was killed at ten seconds. The two commands
114
+ * differ by whether this function runs, which is what makes the mechanism
115
+ * the open and not the command.
116
+ *
117
+ * A non-regular record is a REFUSAL rather than `undefined`: `undefined`
118
+ * already means "there is no record", and a caller that cannot tell that
119
+ * apart from "the record could not be opened" would rebuild a worktree over
120
+ * a record it never read.
121
+ */
63
122
  export declare function readPoolRecord(fleet: Fleet, taskId: string): PoolRecord | undefined;
123
+ /**
124
+ * POST-RECLAIM RECONSTRUCTION (M4-D-12, kernel plan M4 section M4-P19).
125
+ *
126
+ * THE RULE, and it is the whole design: RECONSTRUCT FOR REPORTING, NEVER
127
+ * FOR DESTRUCTION. A reconstructed record exists in memory for the life
128
+ * of one command and is NEVER written to worktrees/<id>.pool.json, so a
129
+ * later reader can never mistake a reconstruction for an original.
130
+ *
131
+ * THE DEFECT IT CLOSES, measured rather than assumed. tasks/<id>/meta.json
132
+ * is TRACKED and survives a reclaim; worktrees/<id>.pool.json cannot,
133
+ * because it sits beside the worktree BY DESIGN so it can never dirty the
134
+ * destroy-time cleanliness check (see the module header above, FM-059).
135
+ * Teardown then refuses without a pool record and says so in terms
136
+ * (src/teardown.ts), so the plan's stated fallback of "recovery is manual
137
+ * teardown" does not work post-reclaim: the manual path is itself blocked.
138
+ *
139
+ * WHAT IS DERIVED AND WHAT IS NOT. meta.json carries six of the eight
140
+ * PoolRecord fields directly (taskId, project, baseSha, branchName,
141
+ * offline, createdAt). It carries NEITHER `remote` NOR `branch`, which are
142
+ * the project's configured remote and that remote's default branch. Those
143
+ * two are re-derived from git through the SAME two resolvers poolCreate
144
+ * uses, so a reconstruction is a repeat of the original derivation and
145
+ * never a remembered value.
146
+ *
147
+ * WHEN EITHER OF THOSE TWO CANNOT BE DERIVED THE RESULT IS INCOMPLETE AND
148
+ * NAMES THE FIELD. It is never filled with a plausible default. "origin"
149
+ * and "main" are right often enough to look harmless and wrong often
150
+ * enough to destroy work: a guessed default branch sends the landed-ness
151
+ * judgement at a ref that is not the project's default, and teardown's
152
+ * authorization to delete a task branch comes from exactly that judgement
153
+ * (V-1, the defect src/spawn.ts was rewritten to prevent). An unresolvable
154
+ * field is therefore a refusal, not a gap to fill.
155
+ */
156
+ export type ReconstructResult =
157
+ /** Every field derived. Safe to report, and safe to pass to a GATED path. */
158
+ {
159
+ kind: "complete";
160
+ record: PoolRecord;
161
+ }
162
+ /** meta.json read, but git could not answer for the named fields. */
163
+ | {
164
+ kind: "incomplete";
165
+ unresolved: string[];
166
+ detail: string;
167
+ }
168
+ /** No readable task meta, so there is nothing to reconstruct from. */
169
+ | {
170
+ kind: "absent";
171
+ reason: string;
172
+ };
173
+ /**
174
+ * What a caller of `reconstructPoolRecord` is permitted to do to answer.
175
+ *
176
+ * THE FIELD IS REQUIRED AND THE TYPE IS WHY (M4-P19 fix round). The
177
+ * reconstruction rebuilds `branch` through `resolveDefaultBranch`, which
178
+ * falls back to `git ls-remote` when `<remote>/HEAD` is unset locally,
179
+ * and that fallback opens a socket. `<remote>/HEAD` unset is not an
180
+ * exotic state: it is the NORMAL state of a clone made by `git init` +
181
+ * `git remote add` + `git fetch`, which is how this kernel's own tests
182
+ * and fixtures build one. So the fallback is reached on ordinary fleets,
183
+ * and a caller that must return, such as `pool list` or `doctor`, must
184
+ * not reach it.
185
+ *
186
+ * Measured against head abde402, where this option did not exist and
187
+ * every caller got the network: with the remote pointed at a TCP
188
+ * listener that accepts and never speaks, `tiphys pool list` and
189
+ * `tiphys doctor` ran until killed. Making the decision a required field
190
+ * rather than a defaulted one is the part of the fix that survives the
191
+ * next caller: adding one without choosing does not compile.
192
+ */
193
+ export interface ReconstructOptions {
194
+ /**
195
+ * True only for a caller the operator invoked to CHANGE something and
196
+ * which may therefore wait on a remote. False for every reporting
197
+ * path, which then reports `unreconstructable (unresolved: branch)`
198
+ * rather than blocking.
199
+ */
200
+ network: boolean;
201
+ }
202
+ /**
203
+ * Rebuild a pool record for taskId from tasks/<id>/meta.json and git.
204
+ * Reads only; writes nothing anywhere, ever.
205
+ */
206
+ export declare function reconstructPoolRecord(fleet: Fleet, taskId: string, options: ReconstructOptions): ReconstructResult;
64
207
  export interface CreateOptions {
65
208
  taskId: string;
66
209
  project: string;
@@ -81,11 +224,46 @@ export interface CreateOptions {
81
224
  * chosen contract, not an oversight.
82
225
  */
83
226
  export declare function poolCreate(fleet: Fleet, options: CreateOptions): Promise<PoolResult<PoolRecord>>;
227
+ /**
228
+ * Where an entry's pool record came from. `record` is an original read
229
+ * from worktrees/<id>.pool.json; the other two exist only after a reclaim
230
+ * has taken worktrees/ with it, and are computed fresh on every call.
231
+ */
232
+ export type PoolEntryOrigin = "record" | "reconstructed" | "unreconstructable";
84
233
  export interface PoolListEntry {
85
234
  taskId: string;
86
235
  headSha: string;
236
+ origin: PoolEntryOrigin;
237
+ /**
238
+ * Set only for `unreconstructable`: what this listing could not establish.
239
+ * Usually the PoolRecord fields git could not answer for; `meta` means the
240
+ * task record itself was present and did not read, which is a different
241
+ * state from a task that is closed and from one that is not there.
242
+ */
243
+ unresolved?: string[];
87
244
  }
88
- /** One entry per pool record, with the worktree's current HEAD SHA. */
245
+ /**
246
+ * One entry per pool record, with the worktree's current HEAD SHA, PLUS
247
+ * one per OPEN task that has no pool record beside it (M4-P19).
248
+ *
249
+ * The second group is what a reclaim leaves behind: tasks/ is tracked and
250
+ * survives, worktrees/ is gitignored and does not, so a task can be open
251
+ * with its record gone. Reporting only the first group makes those tasks
252
+ * invisible to `pool list` and to doctor, which is the state the plan
253
+ * calls a defect. Every such entry is marked, never silently blended in
254
+ * with the originals, and NOTHING here is written to disk.
255
+ *
256
+ * Closed tasks are excluded: a closed task is not in the pool, and
257
+ * listing every task this fleet ever finished as a missing worktree would
258
+ * make the report useless within a week.
259
+ *
260
+ * THIS FUNCTION OPENS NO SOCKET (M4-P19 fix round). It reads the
261
+ * filesystem and runs local git commands, and its reconstruction is
262
+ * asked for with `{ network: false }`. An entry whose default branch
263
+ * cannot be established from the clone alone is reported
264
+ * `unreconstructable (unresolved: branch)` rather than waited on. See
265
+ * `ReconstructOptions` for the measured hang that this closes.
266
+ */
89
267
  export declare function poolList(fleet: Fleet): PoolListEntry[];
90
268
  export interface LsofProbe {
91
269
  available: boolean;
@@ -120,6 +298,24 @@ export interface DestroyOptions {
120
298
  * than assumed.
121
299
  */
122
300
  deleteBranchForce: boolean;
301
+ /**
302
+ * An IN-MEMORY reconstructed record (M4-P19), used ONLY when no record
303
+ * exists on disk. It is never written: `haveRecord` still comes from
304
+ * the file, so nothing here creates worktrees/<id>.pool.json and
305
+ * nothing unlinks a file that is not there.
306
+ *
307
+ * WHY THIS EXISTS AT ALL, since the whole design is "never for
308
+ * destruction". Without it, stage 2's base-sha gate has no base to
309
+ * compare against and returns its "pool record missing or unreadable"
310
+ * refusal, so the post-reclaim path would be blocked at the one gate
311
+ * that was ALREADY going to do the right thing. Passing the
312
+ * reconstruction in makes that gate WORK rather than abstain, which is
313
+ * strictly safer than the alternative of relaxing it. The caller is
314
+ * responsible for having derived it (src/teardown.ts's
315
+ * --from-reconstructed path is the only one), and `pool destroy` on
316
+ * the command line never sets it.
317
+ */
318
+ reconstructed?: PoolRecord;
123
319
  }
124
320
  /** What a completed destroy removed, for the operator's record. */
125
321
  export interface DestroyOutcome {