@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
package/dist/src/pool.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, readdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
+ import { metaPath, readTaskMeta } from "./task.js";
6
+ import { classifyPathEntry, readRegularPathIfPresent } from "./fleet.js";
7
+ import { pathsNameSameObject } from "./path-identity.js";
5
8
  /**
6
9
  * Worktree pool over a project clone (kernel plan v1, M1-P3 step 3).
7
10
  * BUILD from the contract (plan decision D-1, FM-026): a clean disposable
@@ -44,16 +47,86 @@ export function taskBranchName(taskId) {
44
47
  }
45
48
  /** Task ids are single safe path segments. */
46
49
  export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
47
- function runGit(cwd, args) {
50
+ /**
51
+ * THE BOUND ON A NETWORK-REACHING GIT SUBPROCESS (M4-P19 fix round).
52
+ *
53
+ * `spawnSync` with no `timeout` waits for the child forever. For a local
54
+ * git command that is harmless: it either answers or fails. For one that
55
+ * opens a socket it is not, and the failure mode is not a slow command,
56
+ * it is a command that NEVER RETURNS. Measured by the clean-room
57
+ * reviewer against head abde402: a remote pointed at a TCP listener that
58
+ * accepts and never speaks made `tiphys pool list` and `tiphys doctor`
59
+ * run until killed (exit 124 under `timeout 25`), where the same fixture
60
+ * on the phase base exited 0 in about a second.
61
+ *
62
+ * This bound is applied to `ls-remote --symref <remote> HEAD` only, and
63
+ * the reason it is safe THERE and not elsewhere is a property of the
64
+ * command rather than a judgement about it: that invocation transfers a
65
+ * ref advertisement and nothing else, so a legitimate one is bounded by
66
+ * round-trip latency. `git fetch` (src/pool.ts, src/teardown.ts) and
67
+ * `git push` (src/teardown.ts) transfer objects, so their legitimate
68
+ * duration IS unbounded and a wall-clock cap on them would abort real
69
+ * work. They are left unbounded deliberately, and they are reached only
70
+ * from a command the operator invoked to do that work, never from a
71
+ * reporting path; keeping reporting paths off the network entirely is
72
+ * the other half of this fix (see `reconstructPoolRecord`).
73
+ *
74
+ * TIPHYS_GIT_NETWORK_TIMEOUT_MS is a TEST SEAM in the style of
75
+ * TIPHYS_WATCH_TEST_HOLD (src/watcher.ts) and TIPHYS_LOCK_TEST_HOLD
76
+ * (src/commands/lock.ts): a test cannot afford to wait out the shipped
77
+ * bound, and a shipped bound short enough for a test would abort a
78
+ * legitimate ls-remote over a slow link. A value that is not a positive
79
+ * integer is IGNORED rather than honoured, so a malformed environment
80
+ * cannot silently remove the bound.
81
+ */
82
+ export const NETWORK_TIMEOUT_MS = 20_000;
83
+ /**
84
+ * Exported as a PURE function so the validation has a witness that does
85
+ * not have to wait out a twenty-second bound to observe it. The
86
+ * end-to-end bound is witnessed separately, against a real remote that
87
+ * never answers; this is the arm that says a malformed environment
88
+ * cannot silently switch the bound off.
89
+ *
90
+ * `Number("")` is 0 and `Number("0x10")` is 16, so neither a blank value
91
+ * nor a hexadecimal one is passed through: the accepted set is exactly
92
+ * the positive integers, and everything else falls back.
93
+ */
94
+ export function resolveNetworkTimeoutMs(raw) {
95
+ if (raw === undefined) {
96
+ return NETWORK_TIMEOUT_MS;
97
+ }
98
+ const parsed = Number(raw);
99
+ if (!Number.isInteger(parsed) || parsed <= 0) {
100
+ return NETWORK_TIMEOUT_MS;
101
+ }
102
+ return parsed;
103
+ }
104
+ function networkTimeoutMs() {
105
+ return resolveNetworkTimeoutMs(process.env["TIPHYS_GIT_NETWORK_TIMEOUT_MS"]);
106
+ }
107
+ function runGit(cwd, args, timeoutMs) {
48
108
  const result = spawnSync("git", ["-C", cwd, ...args], {
49
109
  encoding: "utf8",
50
110
  // The transient-contention classification below reads git's English
51
111
  // message text, so the locale is pinned rather than inherited
52
112
  // (U-8). Without this a translated git silently stops retrying.
53
113
  env: { ...process.env, LC_ALL: "C", LANG: "C" },
114
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
54
115
  });
55
116
  if (result.error !== undefined) {
56
- return { status: null, stdout: "", stderr: String(result.error) };
117
+ // A killed-on-timeout child arrives here with an ETIMEDOUT error. It
118
+ // is named in terms rather than passed through as
119
+ // "Error: spawnSync git ETIMEDOUT", because the operator's remedy
120
+ // (an unreachable remote) is not readable from that string.
121
+ const timedOut = timeoutMs !== undefined &&
122
+ result.error.code === "ETIMEDOUT";
123
+ return {
124
+ status: null,
125
+ stdout: "",
126
+ stderr: timedOut
127
+ ? `git ${args.join(" ")} did not answer within ${String(timeoutMs)}ms and was killed`
128
+ : String(result.error),
129
+ };
57
130
  }
58
131
  return {
59
132
  status: result.status,
@@ -124,9 +197,30 @@ export function recordPath(fleet, taskId) {
124
197
  export function worktreePath(fleet, taskId) {
125
198
  return join(fleet.worktreesDir, taskId);
126
199
  }
200
+ /**
201
+ * THE RECORD'S ENTRY TYPE IS ESTABLISHED BEFORE IT IS OPENED. A bare read
202
+ * here hung `tiphys pool destroy` forever with zero output against a named
203
+ * pipe at `worktrees/<id>.pool.json`; measured before the fix, `pool list`
204
+ * (which reads only the NAME) returned in the same second while
205
+ * `pool destroy --task t-0001` was killed at ten seconds. The two commands
206
+ * differ by whether this function runs, which is what makes the mechanism
207
+ * the open and not the command.
208
+ *
209
+ * A non-regular record is a REFUSAL rather than `undefined`: `undefined`
210
+ * already means "there is no record", and a caller that cannot tell that
211
+ * apart from "the record could not be opened" would rebuild a worktree over
212
+ * a record it never read.
213
+ */
127
214
  export function readPoolRecord(fleet, taskId) {
215
+ const read = readRegularPathIfPresent(recordPath(fleet, taskId));
216
+ if (read.kind === "absent") {
217
+ return undefined;
218
+ }
219
+ if (read.kind === "refused") {
220
+ throw new Error(read.reason);
221
+ }
128
222
  try {
129
- return JSON.parse(readFileSync(recordPath(fleet, taskId), "utf8"));
223
+ return JSON.parse(read.body);
130
224
  }
131
225
  catch {
132
226
  return undefined;
@@ -161,8 +255,17 @@ function resolveRemote(project) {
161
255
  * set locally (no network); otherwise the remote's advertised default
162
256
  * via ls-remote --symref. The clone's own HEAD is never consulted, so a
163
257
  * detached HEAD in the clone is irrelevant.
258
+ *
259
+ * `network` IS A REQUIRED PARAMETER AND HAS NO DEFAULT (M4-P19 fix
260
+ * round). Every caller must state whether it is allowed to open a
261
+ * socket, because the two callers that exist have opposite answers and a
262
+ * default would hand the wrong one to whichever caller is added next
263
+ * without anybody having to decide. `false` stops at the local
264
+ * `origin/HEAD` lookup and refuses, naming the fact that the network was
265
+ * not consulted, so a caller reading the refusal can tell "this remote
266
+ * has no default branch" from "I was not permitted to ask".
164
267
  */
165
- function resolveDefaultBranch(project, remote) {
268
+ function resolveDefaultBranch(project, remote, network) {
166
269
  const local = runGit(project, [
167
270
  "symbolic-ref",
168
271
  "--quiet",
@@ -175,7 +278,15 @@ function resolveDefaultBranch(project, remote) {
175
278
  return { ok: true, value: ref.slice(prefix.length) };
176
279
  }
177
280
  }
178
- const advertised = runGit(project, ["ls-remote", "--symref", remote, "HEAD"]);
281
+ if (!network) {
282
+ return {
283
+ ok: false,
284
+ reason: `cannot resolve the default branch of remote ${remote} without the ` +
285
+ `network: ${remote}/HEAD is unset locally, and this caller reports ` +
286
+ `rather than writes, so it does not contact ${remote} to ask`,
287
+ };
288
+ }
289
+ const advertised = runGit(project, ["ls-remote", "--symref", remote, "HEAD"], networkTimeoutMs());
179
290
  if (advertised.status === 0) {
180
291
  const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(advertised.stdout);
181
292
  if (match !== null) {
@@ -192,6 +303,69 @@ function resolveDefaultBranch(project, remote) {
192
303
  `${remote}/HEAD is unset locally and ls-remote failed: ${advertised.stderr.trim()}`,
193
304
  };
194
305
  }
306
+ /**
307
+ * Rebuild a pool record for taskId from tasks/<id>/meta.json and git.
308
+ * Reads only; writes nothing anywhere, ever.
309
+ */
310
+ export function reconstructPoolRecord(fleet, taskId, options) {
311
+ if (!TASK_ID_PATTERN.test(taskId)) {
312
+ return { kind: "absent", reason: `task id "${taskId}" is not a safe path segment` };
313
+ }
314
+ const meta = readTaskMeta(fleet, taskId);
315
+ if (meta === undefined) {
316
+ return {
317
+ kind: "absent",
318
+ reason: `no readable task meta at ${metaPath(fleet, taskId)}, so there is ` +
319
+ `nothing to reconstruct a pool record from`,
320
+ };
321
+ }
322
+ const unresolved = [];
323
+ const details = [];
324
+ let remote;
325
+ let branch;
326
+ if (!existsSync(meta.project)) {
327
+ // Both fields live in the clone, so losing the clone loses both. They
328
+ // are reported together rather than one at a time, because a caller
329
+ // that repaired only the first would be told about the second on the
330
+ // next run and learn nothing it could not have been told now.
331
+ unresolved.push("remote", "branch");
332
+ details.push(`the project clone ${meta.project} recorded in meta.json is absent`);
333
+ }
334
+ else {
335
+ const resolvedRemote = resolveRemote(meta.project);
336
+ if (resolvedRemote.ok) {
337
+ remote = resolvedRemote.value;
338
+ const resolvedBranch = resolveDefaultBranch(meta.project, remote, options.network);
339
+ if (resolvedBranch.ok) {
340
+ branch = resolvedBranch.value;
341
+ }
342
+ else {
343
+ unresolved.push("branch");
344
+ details.push(resolvedBranch.reason);
345
+ }
346
+ }
347
+ else {
348
+ unresolved.push("remote", "branch");
349
+ details.push(resolvedRemote.reason);
350
+ }
351
+ }
352
+ if (remote === undefined || branch === undefined) {
353
+ return { kind: "incomplete", unresolved, detail: details.join("; ") };
354
+ }
355
+ return {
356
+ kind: "complete",
357
+ record: {
358
+ taskId,
359
+ project: meta.project,
360
+ remote,
361
+ branch,
362
+ baseSha: meta.baseSha,
363
+ branchName: meta.branch,
364
+ offline: meta.baseOffline,
365
+ createdAt: meta.createdAt,
366
+ },
367
+ };
368
+ }
195
369
  /**
196
370
  * pool create (EXT-F-03 five steps; see module doc). Returns the pool
197
371
  * record on success.
@@ -280,7 +454,9 @@ export async function poolCreate(fleet, options) {
280
454
  if (!remote.ok) {
281
455
  return remote;
282
456
  }
283
- const branch = resolveDefaultBranch(project, remote.value);
457
+ // poolCreate is a write the operator invoked and it is about to fetch
458
+ // from this remote anyway, so it may ask the remote for its default.
459
+ const branch = resolveDefaultBranch(project, remote.value, true);
284
460
  if (!branch.ok) {
285
461
  return branch;
286
462
  }
@@ -381,24 +557,98 @@ export async function poolCreate(fleet, options) {
381
557
  }
382
558
  return { ok: true, value: poolRecord };
383
559
  }
384
- /** One entry per pool record, with the worktree's current HEAD SHA. */
560
+ /** The worktree's current HEAD SHA, or "missing" when there is no worktree. */
561
+ function headShaOf(fleet, taskId) {
562
+ const worktree = worktreePath(fleet, taskId);
563
+ const head = existsSync(worktree)
564
+ ? runGit(worktree, ["rev-parse", "HEAD"])
565
+ : undefined;
566
+ return head !== undefined && head.status === 0 ? head.stdout.trim() : "missing";
567
+ }
568
+ /**
569
+ * One entry per pool record, with the worktree's current HEAD SHA, PLUS
570
+ * one per OPEN task that has no pool record beside it (M4-P19).
571
+ *
572
+ * The second group is what a reclaim leaves behind: tasks/ is tracked and
573
+ * survives, worktrees/ is gitignored and does not, so a task can be open
574
+ * with its record gone. Reporting only the first group makes those tasks
575
+ * invisible to `pool list` and to doctor, which is the state the plan
576
+ * calls a defect. Every such entry is marked, never silently blended in
577
+ * with the originals, and NOTHING here is written to disk.
578
+ *
579
+ * Closed tasks are excluded: a closed task is not in the pool, and
580
+ * listing every task this fleet ever finished as a missing worktree would
581
+ * make the report useless within a week.
582
+ *
583
+ * THIS FUNCTION OPENS NO SOCKET (M4-P19 fix round). It reads the
584
+ * filesystem and runs local git commands, and its reconstruction is
585
+ * asked for with `{ network: false }`. An entry whose default branch
586
+ * cannot be established from the clone alone is reported
587
+ * `unreconstructable (unresolved: branch)` rather than waited on. See
588
+ * `ReconstructOptions` for the measured hang that this closes.
589
+ */
385
590
  export function poolList(fleet) {
386
591
  const entries = [];
592
+ const seen = new Set();
387
593
  const names = readdirSync(fleet.worktreesDir)
388
594
  .filter((name) => name.endsWith(".pool.json"))
389
595
  .sort();
390
596
  for (const name of names) {
391
597
  const taskId = name.slice(0, -".pool.json".length);
392
- const worktree = worktreePath(fleet, taskId);
393
- const head = existsSync(worktree)
394
- ? runGit(worktree, ["rev-parse", "HEAD"])
395
- : undefined;
396
- entries.push({
397
- taskId,
398
- headSha: head !== undefined && head.status === 0
399
- ? head.stdout.trim()
400
- : "missing",
401
- });
598
+ seen.add(taskId);
599
+ entries.push({ taskId, headSha: headShaOf(fleet, taskId), origin: "record" });
600
+ }
601
+ /* AN UNLISTABLE tasks/ IS NOT AN ABSENCE OF TASKS (T-036's mechanism, and
602
+ src/commands/next.ts:40 states the standard: a category empty BY
603
+ CONSTRUCTION reported as empty BY OBSERVATION). This `catch` swallowed
604
+ it into `[]`, so `tiphys pool list` printed nothing and exited 0 and
605
+ doctor's CHECK worktrees printed the positive claim "no pool worktrees"
606
+ about a directory it had not read. It now reaches the caller through the
607
+ SAME channel the `readdirSync(fleet.worktreesDir)` above it already
608
+ uses, which src/commands/next.ts:334 already catches and reports into
609
+ its `unknown` list. */
610
+ const taskIds = readdirSync(fleet.tasksDir).sort();
611
+ for (const taskId of taskIds) {
612
+ if (seen.has(taskId) || !TASK_ID_PATTERN.test(taskId)) {
613
+ continue;
614
+ }
615
+ /* A task record that is PRESENT and did not READ is not a closed task.
616
+ `readTaskMeta` answers `undefined` for absent, unreadable and
617
+ unparseable alike, and dropping all three hid exactly the interrupted
618
+ spawn this listing exists to surface: with tasks/t-0001/meta.json
619
+ truncated mid-write, `pool list` printed NOTHING while doctor's CHECK
620
+ tasks reported the same task as open. The entry type is established
621
+ here rather than inferred from `readTaskMeta`'s one-word answer. */
622
+ const metaEntry = classifyPathEntry(metaPath(fleet, taskId));
623
+ const meta = readTaskMeta(fleet, taskId);
624
+ if (meta === undefined && metaEntry.kind !== "absent" && metaEntry.kind !== "dangling") {
625
+ entries.push({
626
+ taskId,
627
+ headSha: headShaOf(fleet, taskId),
628
+ origin: "unreconstructable",
629
+ unresolved: ["meta"],
630
+ });
631
+ continue;
632
+ }
633
+ if (meta === undefined || meta.status !== "open") {
634
+ continue;
635
+ }
636
+ // REPORTING, so NO NETWORK. `pool list` and doctor's CHECK worktrees
637
+ // both arrive here, and both must return. A task whose `branch`
638
+ // needs the network to resolve is reported `unreconstructable`,
639
+ // which is a true statement about what this path can establish.
640
+ const rebuilt = reconstructPoolRecord(fleet, taskId, { network: false });
641
+ if (rebuilt.kind === "absent") {
642
+ continue;
643
+ }
644
+ entries.push(rebuilt.kind === "complete"
645
+ ? { taskId, headSha: headShaOf(fleet, taskId), origin: "reconstructed" }
646
+ : {
647
+ taskId,
648
+ headSha: headShaOf(fleet, taskId),
649
+ origin: "unreconstructable",
650
+ unresolved: rebuilt.unresolved,
651
+ });
402
652
  }
403
653
  return entries;
404
654
  }
@@ -465,10 +715,15 @@ async function destroyGitStep(contextDir, args, worktree) {
465
715
  return result;
466
716
  }
467
717
  /** Stage 1: gather facts. Read only; mutates nothing. */
468
- async function resolveDestroy(fleet, taskId) {
718
+ async function resolveDestroy(fleet, taskId, reconstructed) {
469
719
  const worktree = worktreePath(fleet, taskId);
470
720
  const recordFile = recordPath(fleet, taskId);
471
- const record = readPoolRecord(fleet, taskId);
721
+ const onDisk = readPoolRecord(fleet, taskId);
722
+ // The on-disk record always wins. The reconstruction is a FALLBACK, so
723
+ // a present-but-different record can never be overridden by one.
724
+ const record = onDisk ?? reconstructed;
725
+ // Deliberately the FILE, not the record: this is what authorizes the
726
+ // unlink in stage 3, and a reconstruction has no file to unlink.
472
727
  const haveRecord = existsSync(recordFile);
473
728
  const haveWorktree = existsSync(worktree);
474
729
  if (!haveRecord && !haveWorktree) {
@@ -539,7 +794,19 @@ async function resolveDestroy(fleet, taskId) {
539
794
  currentPath = line.slice("worktree ".length).trim();
540
795
  }
541
796
  else if (line.trim() === `branch refs/heads/${branchName}`) {
542
- if (currentPath !== undefined && resolve(currentPath) !== resolve(worktree)) {
797
+ // IDENTITY, NOT STRING EQUALITY, and the difference is a shipped
798
+ // defect this comparison had until 2026-09-16 (macOS smoke job of
799
+ // pull request #155). `currentPath` is GIT'S spelling and `worktree`
800
+ // is THIS KERNEL'S: git canonicalizes every worktree path it records,
801
+ // so a fleet reached through a symlink makes the two sides different
802
+ // strings for one directory. `resolve` does not resolve symlinks, so
803
+ // the old comparison then reported the task's OWN worktree as a
804
+ // foreign worktree holding the branch, stage 2 refused the destroy
805
+ // that was never in danger, and spawn's rollback left the worktree,
806
+ // the pool record and the branch behind after a launch that never
807
+ // started. On macOS no unusual setup is needed to reach it: the
808
+ // platform puts `os.tmpdir()` behind `/var -> /private/var`.
809
+ if (currentPath !== undefined && !pathsNameSameObject(currentPath, worktree)) {
543
810
  branchCheckedOutAt = currentPath;
544
811
  }
545
812
  }
@@ -706,7 +973,7 @@ export async function poolDestroy(fleet, options) {
706
973
  return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
707
974
  }
708
975
  // Stage 1: read only.
709
- const resolved = await resolveDestroy(fleet, taskId);
976
+ const resolved = await resolveDestroy(fleet, taskId, options.reconstructed);
710
977
  if (!resolved.ok) {
711
978
  return resolved;
712
979
  }
@@ -46,6 +46,37 @@ import type { Diagnostic } from "./validate.ts";
46
46
  * three agree rather than trusting this comment.
47
47
  */
48
48
  export declare const ROLE_IDS: readonly string[];
49
+ /**
50
+ * WHERE EACH ROLE'S BRIEF ACTUALLY SHIPS, relative to the kernel root.
51
+ *
52
+ * The vocabulary above has six members and `roles/` holds FIVE briefs. The
53
+ * sixth, the orchestrator's, ships as `AGENTS.md` at the package root, with
54
+ * `role: orchestrator` frontmatter, which roles/README.md states and
55
+ * package.json's `files` list carries. Until this constant existed, the
56
+ * composer resolved every id to `roles/<id>.md`, so
57
+ * `tiphys brief compose --role orchestrator` answered, against the PUBLISHED
58
+ * TARBALL, "role brief .../roles/orchestrator.md: ... does not exist" for a
59
+ * brief that is in the same tarball at a path the resolver never looked in.
60
+ * A consumer reads that as a broken install.
61
+ *
62
+ * This is a composition defect no per-phase review could have caught: M3-P5
63
+ * built the six-member resolver over `roles/<id>.md`, M3-P6 added files to
64
+ * that directory, and M3-P9 put the sixth brief somewhere else. Each is
65
+ * correct alone.
66
+ *
67
+ * DECLARED RATHER THAN DERIVED ON PURPOSE. A resolver that fell back to
68
+ * "look for <id>.md anywhere" would answer for a file that happened to be
69
+ * there, and the property wanted is the opposite: the package states where
70
+ * each declared role's brief IS, and test/sweep-exclusion-sync.test.ts turns
71
+ * a role added without one into a red test rather than a failure at use.
72
+ */
73
+ export declare const ROLE_BRIEF_FILES: Readonly<Record<string, string>>;
74
+ /**
75
+ * The brief path for a role, relative to the kernel root, or undefined when
76
+ * the vocabulary declares a role the package does not ship. The second case
77
+ * is a REFUSAL the caller names, never a path that is then found missing.
78
+ */
79
+ export declare function roleBriefFile(roleId: string): string | undefined;
49
80
  /** The fence a role brief's YAML frontmatter block is delimited by. */
50
81
  export declare const FRONTMATTER_FENCE = "---";
51
82
  /** The include directive, on a line of its own. */
package/dist/src/roles.js CHANGED
@@ -56,6 +56,48 @@ export const ROLE_IDS = [
56
56
  "implementer",
57
57
  "clean-room-reviewer",
58
58
  ];
59
+ /**
60
+ * WHERE EACH ROLE'S BRIEF ACTUALLY SHIPS, relative to the kernel root.
61
+ *
62
+ * The vocabulary above has six members and `roles/` holds FIVE briefs. The
63
+ * sixth, the orchestrator's, ships as `AGENTS.md` at the package root, with
64
+ * `role: orchestrator` frontmatter, which roles/README.md states and
65
+ * package.json's `files` list carries. Until this constant existed, the
66
+ * composer resolved every id to `roles/<id>.md`, so
67
+ * `tiphys brief compose --role orchestrator` answered, against the PUBLISHED
68
+ * TARBALL, "role brief .../roles/orchestrator.md: ... does not exist" for a
69
+ * brief that is in the same tarball at a path the resolver never looked in.
70
+ * A consumer reads that as a broken install.
71
+ *
72
+ * This is a composition defect no per-phase review could have caught: M3-P5
73
+ * built the six-member resolver over `roles/<id>.md`, M3-P6 added files to
74
+ * that directory, and M3-P9 put the sixth brief somewhere else. Each is
75
+ * correct alone.
76
+ *
77
+ * DECLARED RATHER THAN DERIVED ON PURPOSE. A resolver that fell back to
78
+ * "look for <id>.md anywhere" would answer for a file that happened to be
79
+ * there, and the property wanted is the opposite: the package states where
80
+ * each declared role's brief IS, and test/sweep-exclusion-sync.test.ts turns
81
+ * a role added without one into a red test rather than a failure at use.
82
+ */
83
+ export const ROLE_BRIEF_FILES = {
84
+ orchestrator: "AGENTS.md",
85
+ investigator: "roles/investigator.md",
86
+ "plan-writer": "roles/plan-writer.md",
87
+ "adversarial-plan-reviewer": "roles/adversarial-plan-reviewer.md",
88
+ implementer: "roles/implementer.md",
89
+ "clean-room-reviewer": "roles/clean-room-reviewer.md",
90
+ };
91
+ /**
92
+ * The brief path for a role, relative to the kernel root, or undefined when
93
+ * the vocabulary declares a role the package does not ship. The second case
94
+ * is a REFUSAL the caller names, never a path that is then found missing.
95
+ */
96
+ export function roleBriefFile(roleId) {
97
+ return Object.prototype.hasOwnProperty.call(ROLE_BRIEF_FILES, roleId)
98
+ ? ROLE_BRIEF_FILES[roleId]
99
+ : undefined;
100
+ }
59
101
  /** The fence a role brief's YAML frontmatter block is delimited by. */
60
102
  export const FRONTMATTER_FENCE = "---";
61
103
  /** The include directive, on a line of its own. */