@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
@@ -23,8 +23,8 @@ export declare const KERNEL_PACKAGE_NAME = "@tiphys/kernel";
23
23
  export declare const MACHINE_IDENTITY_NAME = "Tiphys Fleet";
24
24
  export declare const MACHINE_IDENTITY_EMAIL = "fleet@tiphys.invalid";
25
25
  /**
26
- * tiphys init <dir>: create a fleet home in an empty or absent directory
27
- * (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure filesystem and
28
- * git (DR-0007).
26
+ * tiphys init <dir> [--shared-exclusion]: create a fleet home in an empty or
27
+ * absent directory (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure
28
+ * filesystem and git (DR-0007).
29
29
  */
30
30
  export declare function cmdInit(args: string[]): number;
@@ -2,7 +2,9 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { EX_USAGE } from "../cli.js";
5
+ import { DEFAULT_SHARED_REF, DEFAULT_SHARED_REMOTE, SHARED_EXCLUSION_FIELD, } from "../exclusion.js";
5
6
  import { FLEET_DIRS, FLEET_IGNORED } from "../fleet.js";
7
+ import { DURABLE_STATUS_DIR } from "../status.js";
6
8
  import { readOwnVersion } from "../version.js";
7
9
  /**
8
10
  * THE PUBLISHED KERNEL NAME (DR-0008, decided 2026-08-05: public npmjs under
@@ -34,8 +36,17 @@ export const MACHINE_IDENTITY_EMAIL = "fleet@tiphys.invalid";
34
36
  * the durable layout (SC-002) would not survive a clone of the fleet repo.
35
37
  * The ignored ephemera (state/, worktrees/, projects/) get no keep file:
36
38
  * they are recreated locally and are deliberately not repository content.
39
+ *
40
+ * `status` JOINED THIS LIST AT M4-P18 AND IT IS THE HALF OF M4-D-13 THAT IS
41
+ * VISIBLE IN THE LAYOUT. The status pointer used to live beside its stream
42
+ * under the ignored `state/` prefix, where the sentence that says where the
43
+ * pipeline stands could be neither committed nor pushed; the split moved it
44
+ * here. It is created at init rather than on first emit so that a fleet home
45
+ * carries the durable directory from its bootstrap commit, and so a CLONE of
46
+ * one carries it too: `tiphys resume` rebuilds the EPHEMERAL three and does
47
+ * not, and must not, fabricate durable content (src/commands/resume.ts).
37
48
  */
38
- const DURABLE_KEEP_DIRS = ["charter", "decisions", "tasks"];
49
+ const DURABLE_KEEP_DIRS = ["charter", "decisions", "tasks", DURABLE_STATUS_DIR];
39
50
  function runGit(cwd, args, extraEnv) {
40
51
  const result = spawnSync("git", ["-C", cwd, ...args], {
41
52
  encoding: "utf8",
@@ -44,14 +55,29 @@ function runGit(cwd, args, extraEnv) {
44
55
  return { status: result.status, stderr: result.stderr ?? "" };
45
56
  }
46
57
  /**
47
- * tiphys init <dir>: create a fleet home in an empty or absent directory
48
- * (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure filesystem and
49
- * git (DR-0007).
58
+ * THE OPT-IN FLAG FOR THE SHARED EXCLUSION REGISTER (M4-P21 criterion 1).
59
+ *
60
+ * The layer is DECLARED, never inferred, and the declaration lives in the
61
+ * fleet home's own `package.json` because that file is already the fleet's
62
+ * owner-controlled pin file. Without this flag `init` writes exactly the
63
+ * package.json it wrote before, with no `tiphys` section at all, so a fleet
64
+ * created today behaves exactly as it did and `src/exclusion.ts` returns
65
+ * before spawning anything. That default is the H-D carve-out in the plan's
66
+ * hazard table: a fleet that cannot reach a remote is not forced to switch
67
+ * the layer off globally, because it was never on.
68
+ */
69
+ const SHARED_EXCLUSION_FLAG = "--shared-exclusion";
70
+ /**
71
+ * tiphys init <dir> [--shared-exclusion]: create a fleet home in an empty or
72
+ * absent directory (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure
73
+ * filesystem and git (DR-0007).
50
74
  */
51
75
  export function cmdInit(args) {
52
- const [dir, ...extra] = args;
76
+ const rest = args.filter((arg) => arg !== SHARED_EXCLUSION_FLAG);
77
+ const sharedExclusion = args.length !== rest.length;
78
+ const [dir, ...extra] = rest;
53
79
  if (dir === undefined || extra.length > 0) {
54
- process.stderr.write("usage: tiphys init <dir>\n");
80
+ process.stderr.write(`usage: tiphys init <dir> [${SHARED_EXCLUSION_FLAG}]\n`);
55
81
  return EX_USAGE;
56
82
  }
57
83
  const root = resolve(dir);
@@ -65,7 +91,14 @@ export function cmdInit(args) {
65
91
  const fleetMarkers = new Set([...FLEET_DIRS, "backlog.md", ".git"]);
66
92
  const looksInitialized = entries.some((entry) => fleetMarkers.has(entry));
67
93
  if (looksInitialized) {
68
- process.stderr.write(`tiphys init: ${root} is already initialized\n`);
94
+ /* THE REMEDY TOKEN IS THE POINT OF THIS LINE (M4-P16 criterion 6).
95
+ The exit code was already 1 and already correct, so a reader who
96
+ got here learned only that init refused. A CLONE of a fleet home
97
+ lands here too, because the marker set contains `.git`, and that
98
+ reader's actual next step is `tiphys resume`, which rebuilds the
99
+ three gitignored directories the clone does not carry. Naming it
100
+ here is the difference between a refusal and an instruction. */
101
+ process.stderr.write(`tiphys init: ${root} is already initialized; run tiphys resume to rebuild the ephemeral directories a clone does not carry\n`);
69
102
  }
70
103
  else {
71
104
  process.stderr.write(`tiphys init: ${root} is not empty and not a fleet home, refusing\n`);
@@ -76,7 +109,7 @@ export function cmdInit(args) {
76
109
  else {
77
110
  mkdirSync(root, { recursive: true });
78
111
  }
79
- for (const name of FLEET_DIRS) {
112
+ for (const name of [...FLEET_DIRS, DURABLE_STATUS_DIR]) {
80
113
  mkdirSync(join(root, name), { recursive: true });
81
114
  }
82
115
  for (const name of DURABLE_KEEP_DIRS) {
@@ -101,6 +134,19 @@ export function cmdInit(args) {
101
134
  [KERNEL_PACKAGE_NAME]: readOwnVersion(),
102
135
  },
103
136
  };
137
+ if (sharedExclusion) {
138
+ /* The declared form is written out in full rather than as `true` so the
139
+ owner can see, and edit, the remote and the ref this fleet's shared
140
+ lease lives on. The ref is a BRANCH because only refs/heads/* is
141
+ pushable against the remote this kernel is built for (CLAUDE.md
142
+ standing warning 14, re-measured for M4-P21). */
143
+ fleetPackageJson["tiphys"] = {
144
+ sharedExclusion: {
145
+ remote: DEFAULT_SHARED_REMOTE,
146
+ ref: DEFAULT_SHARED_REF,
147
+ },
148
+ };
149
+ }
104
150
  writeFileSync(join(root, "package.json"), `${JSON.stringify(fleetPackageJson, null, 2)}\n`);
105
151
  writeFileSync(join(root, ".gitignore"), `${FLEET_IGNORED.join("\n")}\n`);
106
152
  const steps = [
@@ -124,5 +170,8 @@ export function cmdInit(args) {
124
170
  }
125
171
  }
126
172
  process.stdout.write(`initialized fleet home at ${root}\n`);
173
+ if (sharedExclusion) {
174
+ process.stdout.write(`declared ${SHARED_EXCLUSION_FIELD} on ${DEFAULT_SHARED_REMOTE} at ${DEFAULT_SHARED_REF}\n`);
175
+ }
127
176
  return 0;
128
177
  }
@@ -1 +1,34 @@
1
+ /**
2
+ * THE DECISION-CLOCK SEAM (M4-P21 criteria 4 and 5). When
3
+ * TIPHYS_LOCK_TEST_NOW_MS carries a finite number of milliseconds, every
4
+ * mutating subcommand decides against that instant instead of `Date.now()`,
5
+ * by feeding the EXISTING `nowMs` option the lease library already takes
6
+ * (src/lock.ts:568). It exists because the two clock-skew witnesses must
7
+ * move ONE environment's clock ten minutes without touching a system clock,
8
+ * which the plan requires by name, and because criterion 6 is about what the
9
+ * COMMAND prints, so the skew has to reach the command rather than only the
10
+ * library. Inert unless the variable is set, exactly like the hold point
11
+ * above, and a value that does not parse is a loud refusal rather than a
12
+ * silent fall back to the real clock: a seam that quietly ignores its input
13
+ * would make a skew witness green while measuring no skew at all.
14
+ *
15
+ * AND IT IS GATED, BECAUSE IT SHIPS. `judgeByCounter` (src/exclusion.ts:553)
16
+ * computes `unchangedForMs = nowMs - previous.firstSeenMs`, so the module's
17
+ * promised "two readings of ONE clock" become one reading of a clock the
18
+ * CALLER supplies. Measured in the published CLI before this gate existed:
19
+ * the honest challenger was refused with "fencing counter 1 has stood still
20
+ * for 0ms of the 900000ms this environment requires", and the SAME command
21
+ * with this one variable set an hour ahead took a live lease over instantly
22
+ * and still printed `signal=counter`. A captured witness of that run was
23
+ * indistinguishable from an honest one.
24
+ *
25
+ * TWO CHANGES, and they are different in kind. The ALLOWANCE
26
+ * (TIPHYS_ALLOW_TEST_CLOCK=1) makes reaching the seam a declared act rather
27
+ * than a side effect of one environment variable. The LABEL makes the run
28
+ * SAY SO: `injectedClockSuffix` below is appended to every verdict line the
29
+ * shared layer prints, so the output of a measurement can never be quoted as
30
+ * the output of a real judgement. An allowance alone would leave the second
31
+ * hole open, which is why both are here.
32
+ */
33
+ export declare const TEST_CLOCK_ALLOWANCE = "TIPHYS_ALLOW_TEST_CLOCK";
1
34
  export declare function cmdLock(args: string[]): Promise<number>;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, writeFileSync } from "node:fs";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { EX_USAGE } from "../cli.js";
4
- import { loadFleet } from "../fleet.js";
4
+ import { loadFleet, refuseOpenPathForWrite } from "../fleet.js";
5
5
  import { acquireLease, leaseStatus, observeLease, releaseLease, renewLease, } from "../lock.js";
6
6
  /**
7
7
  * tiphys lock <acquire [--take-over] [--duration <seconds>] | renew
@@ -49,6 +49,16 @@ async function maybeHoldForTest(lockPath) {
49
49
  }
50
50
  const observed = observeLease(lockPath);
51
51
  const nowMs = Date.now();
52
+ /* THE MARKER PATHS ARE DERIVED FROM A CALLER-SUPPLIED PATH, so their entry
53
+ type is established before either is opened for writing. A named pipe at
54
+ `<barrier>.observed` blocked this seam forever with zero output, which is
55
+ the same class this round closed in the lease and brief readers; the seam
56
+ is inert unless the variable is set, which changes how it is REACHED and
57
+ not what it does once reached. */
58
+ const observedRefusal = refuseOpenPathForWrite(`${barrier}.observed`);
59
+ if (observedRefusal !== undefined) {
60
+ throw new Error(`lock test hold point: ${observedRefusal}`);
61
+ }
52
62
  writeFileSync(`${barrier}.observed`, "");
53
63
  const startNs = process.hrtime.bigint();
54
64
  const limitNs = BigInt(HOLD_WAIT_LIMIT_MS) * 1000000n;
@@ -72,9 +82,102 @@ async function maybeHoldForTest(lockPath) {
72
82
  }
73
83
  // Record that the hold really held, and why the wait ended, so the
74
84
  // witness can assert the interleave rather than assume it.
85
+ /* Re-established immediately before this second write rather than once
86
+ per call: the two writes are separated by a wait of up to thirty seconds
87
+ and the path is derived from one the CALLER supplied. */
88
+ const releasedRefusal = refuseOpenPathForWrite(`${barrier}.released`);
89
+ if (releasedRefusal !== undefined) {
90
+ throw new Error(`lock test hold point: ${releasedRefusal}`);
91
+ }
75
92
  writeFileSync(`${barrier}.released`, `held after ${String(waitedMs)}ms (monotonic), barrier observed\n`);
76
93
  return { observed, nowMs };
77
94
  }
95
+ /**
96
+ * THE DECISION-CLOCK SEAM (M4-P21 criteria 4 and 5). When
97
+ * TIPHYS_LOCK_TEST_NOW_MS carries a finite number of milliseconds, every
98
+ * mutating subcommand decides against that instant instead of `Date.now()`,
99
+ * by feeding the EXISTING `nowMs` option the lease library already takes
100
+ * (src/lock.ts:568). It exists because the two clock-skew witnesses must
101
+ * move ONE environment's clock ten minutes without touching a system clock,
102
+ * which the plan requires by name, and because criterion 6 is about what the
103
+ * COMMAND prints, so the skew has to reach the command rather than only the
104
+ * library. Inert unless the variable is set, exactly like the hold point
105
+ * above, and a value that does not parse is a loud refusal rather than a
106
+ * silent fall back to the real clock: a seam that quietly ignores its input
107
+ * would make a skew witness green while measuring no skew at all.
108
+ *
109
+ * AND IT IS GATED, BECAUSE IT SHIPS. `judgeByCounter` (src/exclusion.ts:553)
110
+ * computes `unchangedForMs = nowMs - previous.firstSeenMs`, so the module's
111
+ * promised "two readings of ONE clock" become one reading of a clock the
112
+ * CALLER supplies. Measured in the published CLI before this gate existed:
113
+ * the honest challenger was refused with "fencing counter 1 has stood still
114
+ * for 0ms of the 900000ms this environment requires", and the SAME command
115
+ * with this one variable set an hour ahead took a live lease over instantly
116
+ * and still printed `signal=counter`. A captured witness of that run was
117
+ * indistinguishable from an honest one.
118
+ *
119
+ * TWO CHANGES, and they are different in kind. The ALLOWANCE
120
+ * (TIPHYS_ALLOW_TEST_CLOCK=1) makes reaching the seam a declared act rather
121
+ * than a side effect of one environment variable. The LABEL makes the run
122
+ * SAY SO: `injectedClockSuffix` below is appended to every verdict line the
123
+ * shared layer prints, so the output of a measurement can never be quoted as
124
+ * the output of a real judgement. An allowance alone would leave the second
125
+ * hole open, which is why both are here.
126
+ */
127
+ export const TEST_CLOCK_ALLOWANCE = "TIPHYS_ALLOW_TEST_CLOCK";
128
+ function testClockMs() {
129
+ const raw = process.env.TIPHYS_LOCK_TEST_NOW_MS;
130
+ if (raw === undefined || raw === "") {
131
+ return undefined;
132
+ }
133
+ if (process.env[TEST_CLOCK_ALLOWANCE] !== "1") {
134
+ throw new Error(`lock test clock: TIPHYS_LOCK_TEST_NOW_MS is set and ` +
135
+ `${TEST_CLOCK_ALLOWANCE}=1 is not, so this run would have decided ` +
136
+ `cross-environment exclusion against a clock its caller supplied ` +
137
+ `while printing the same verdict an honest run prints; set ` +
138
+ `${TEST_CLOCK_ALLOWANCE}=1 to declare that this run is a measurement, ` +
139
+ `or unset TIPHYS_LOCK_TEST_NOW_MS`);
140
+ }
141
+ const parsed = Number(raw);
142
+ if (!Number.isFinite(parsed)) {
143
+ throw new Error(`lock test clock: TIPHYS_LOCK_TEST_NOW_MS=${raw} does not parse as a ` +
144
+ `number of milliseconds; this run would have measured the real clock ` +
145
+ `and is not evidence`);
146
+ }
147
+ return parsed;
148
+ }
149
+ /**
150
+ * What a verdict line gains when the decision clock was injected. Empty on
151
+ * every honest run, so no shipped output changes for anyone who does not set
152
+ * the variable.
153
+ */
154
+ function injectedClockSuffix() {
155
+ return process.env.TIPHYS_LOCK_TEST_NOW_MS === undefined ||
156
+ process.env.TIPHYS_LOCK_TEST_NOW_MS === ""
157
+ ? ""
158
+ : "(injected-clock)";
159
+ }
160
+ /**
161
+ * Emit the shared exclusion layer's verdict line. One line, and it always
162
+ * names the SIGNAL that reached the verdict (criterion 6): `signal=counter`
163
+ * where the register was reachable and its fencing counter decided, or
164
+ * `signal=clock` where it was not and the command therefore refused rather
165
+ * than deciding cross-environment exclusion on a local clock.
166
+ */
167
+ function reportShared(outcome) {
168
+ if (outcome.shared !== undefined) {
169
+ process.stdout.write(`${labelClock(outcome.shared.line)}\n`);
170
+ }
171
+ }
172
+ /**
173
+ * Mark every `signal=<basis>` token in a verdict line when this run's
174
+ * decision clock was injected. The token is what criterion 6 asks the
175
+ * command to print, so it is the token that has to carry the caveat.
176
+ */
177
+ function labelClock(line) {
178
+ const suffix = injectedClockSuffix();
179
+ return suffix === "" ? line : line.replace(/signal=(counter|clock)/g, `signal=$1${suffix}`);
180
+ }
78
181
  function usageError(message) {
79
182
  if (message !== undefined) {
80
183
  process.stderr.write(`tiphys lock: ${message}\n`);
@@ -99,7 +202,7 @@ function failure(outcome) {
99
202
  const remedy = outcome.claimTimeout === true
100
203
  ? "; a crashed mutation can leave this file behind, but deleting it while a mutation is genuinely in flight can produce two lock holders, so confirm no tiphys process is running against this fleet before removing it"
101
204
  : "";
102
- process.stderr.write(`tiphys lock: ${outcome.reason}${remedy}\n`);
205
+ process.stderr.write(`tiphys lock: ${labelClock(outcome.reason)}${remedy}\n`);
103
206
  return 1;
104
207
  }
105
208
  function parseFlags(rest) {
@@ -158,7 +261,7 @@ export async function cmdLock(args) {
158
261
  takeover: flags.takeover,
159
262
  durationSeconds: flags.durationSeconds,
160
263
  observed: held?.observed,
161
- nowMs: held?.nowMs,
264
+ nowMs: testClockMs() ?? held?.nowMs,
162
265
  });
163
266
  if (!outcome.ok) {
164
267
  return failure(outcome);
@@ -168,6 +271,7 @@ export async function cmdLock(args) {
168
271
  throw new Error("unreachable: acquire produced no lease");
169
272
  }
170
273
  process.stdout.write(`acquired ${lease.holderId} expires ${lease.expiresAt}\n`);
274
+ reportShared(outcome);
171
275
  return 0;
172
276
  }
173
277
  case "renew": {
@@ -178,7 +282,7 @@ export async function cmdLock(args) {
178
282
  const outcome = await renewLease(lockPath, flags.holder, {
179
283
  durationSeconds: flags.durationSeconds,
180
284
  observed: held?.observed,
181
- nowMs: held?.nowMs,
285
+ nowMs: testClockMs() ?? held?.nowMs,
182
286
  });
183
287
  if (!outcome.ok) {
184
288
  return failure(outcome);
@@ -188,6 +292,7 @@ export async function cmdLock(args) {
188
292
  throw new Error("unreachable: renew produced no lease");
189
293
  }
190
294
  process.stdout.write(`renewed ${lease.holderId} expires ${lease.expiresAt}\n`);
295
+ reportShared(outcome);
191
296
  return 0;
192
297
  }
193
298
  case "release": {
@@ -195,13 +300,19 @@ export async function cmdLock(args) {
195
300
  return usageError("release requires --holder <id> and no other flags");
196
301
  }
197
302
  const held = await maybeHoldForTest(lockPath);
198
- const outcome = await releaseLease(lockPath, flags.holder, {
303
+ const releaseOptions = {
199
304
  observed: held?.observed,
200
- });
305
+ };
306
+ const releaseClock = testClockMs();
307
+ if (releaseClock !== undefined) {
308
+ releaseOptions.nowMs = releaseClock;
309
+ }
310
+ const outcome = await releaseLease(lockPath, flags.holder, releaseOptions);
201
311
  if (!outcome.ok) {
202
312
  return failure(outcome);
203
313
  }
204
314
  process.stdout.write(`released ${flags.holder}\n`);
315
+ reportShared(outcome);
205
316
  return 0;
206
317
  }
207
318
  case "status": {
@@ -0,0 +1,130 @@
1
+ import type { Fleet } from "../fleet.ts";
2
+ /**
3
+ * Work remains. DISTINCT FROM 1, which is the command failing, and from 0,
4
+ * which is every in-flight category empty (criterion 1).
5
+ */
6
+ export declare const EXIT_WORK_REMAINS = 3;
7
+ /**
8
+ * WHAT THIS COMMAND CANNOT SEE, as a NAMED LIST (criterion 4).
9
+ *
10
+ * Printed unconditionally. It is not derived from a probe and it does not
11
+ * shorten when something is unreachable, because a list that shrinks when the
12
+ * network is gone tells the reader that fewer things are unknown at exactly
13
+ * the moment more of them are.
14
+ */
15
+ export declare const CANNOT_SEE: readonly string[];
16
+ /** The heading the cannot-see block is printed under. */
17
+ export declare const CANNOT_SEE_HEADING = "cannot see (this command has no network; an absence here is not evidence of absence):";
18
+ /** How a branch turned out to have been delivered, or why that is not settled. */
19
+ export type Delivery = {
20
+ kind: "delivered";
21
+ how: "ancestor" | "squash" | "patch-equivalent";
22
+ } | {
23
+ kind: "open";
24
+ } | {
25
+ kind: "unknown";
26
+ detail: string;
27
+ };
28
+ /**
29
+ * THE DELIVERED-ELSEWHERE PREDICATE (criterion 3).
30
+ *
31
+ * `git branch --merged` reports a squash-merged branch as UNMERGED, because
32
+ * the squash commit's sha is not the branch tip and the branch tip is not an
33
+ * ancestor of the base. This process squash-merges every phase, so under the
34
+ * naive implementation EVERY delivered branch reads as open forever and the
35
+ * stop condition never goes green.
36
+ *
37
+ * THE CITATION THAT STOOD HERE UNTIL ROUND 1 WAS WRONG, AND IT WAS WRONG IN
38
+ * THE SILENT DIRECTION. It read `delivery/STATE.md:54`, which resolves, which
39
+ * is why no gate reddened; the line it resolves to is about two merge blockers
40
+ * and says nothing about `git branch --merged`. Measured:
41
+ * `grep -n 'branch --merged' delivery/STATE.md` returns nothing, at this head
42
+ * and at the merge base, so the sentence is absent rather than moved. The
43
+ * claim itself is measured in this repository and the measurement is the
44
+ * citation now: witness/captures/next-delivered-elsewhere-git.txt records
45
+ * `git branch --merged main` listing neither the squash-merged branch nor the
46
+ * one delivered through another pull request. The same wrong citation is in
47
+ * the plan at delivery/plan/kernel-plan-m4.md:3239 and in this phase's work
48
+ * history; the work history is corrected, and the plan is an owner-approved
49
+ * document this phase does not edit, so it is raised rather than changed.
50
+ *
51
+ * THREE ARMS, AND THE THIRD IS WHY THIS IS NOT JUST A CALL TO `landedness`.
52
+ *
53
+ * 1. ANCESTOR. An ordinary merge or a fast-forward. `landedness` arm one.
54
+ * 2. CONTENT. Merging the branch into the base changes no tree, so the base
55
+ * already carries everything the branch did however it got there. This is
56
+ * MEMBER A, the squash merge, and it is `landedness` arm two.
57
+ * 3. PATCH EQUIVALENCE. Every commit the branch carries has an equivalent
58
+ * patch already upstream, found by patch id rather than by sha. This is
59
+ * MEMBER B: commits that landed inside ANOTHER branch's pull request, so
60
+ * they appear on the base interleaved with commits the branch never had.
61
+ *
62
+ * ARM 3 IS NOT REDUNDANT WITH ARM 2, and the case that separates them is the
63
+ * ordinary one. Once the base moves on and edits the same region again, a
64
+ * three-way merge of the delivered branch CONFLICTS, so arm 2 answers `open`
65
+ * for a branch whose every commit is demonstrably already upstream. Arm 3
66
+ * answers it by patch id, which survives the later edit.
67
+ *
68
+ * ARM 2 IS NOT REDUNDANT WITH ARM 3 EITHER. A squash collapses N commits into
69
+ * one, whose patch id is the id of the COMBINED diff and therefore equals no
70
+ * individual commit's. For any branch of more than one commit, arm 3 sees only
71
+ * unmatched commits and answers `open`.
72
+ *
73
+ * REUSE, NOT A SECOND IMPLEMENTATION (T-005). Arms 1 and 2 are
74
+ * `landedness` at src/teardown.ts:123, the predicate `tiphys teardown` already
75
+ * refuses on. A second copy here would be a second thing to keep true.
76
+ *
77
+ * INCONCLUSIVE IS NOT `open` AND IT IS NOT `delivered`. A git failure returns
78
+ * `unknown`, which the caller counts as work remaining, because the one answer
79
+ * this predicate must never invent is a confident one.
80
+ */
81
+ export declare function deliveredElsewhere(contextDir: string, branchRef: string, baseRef: string): Delivery;
82
+ /** One thing that is in flight, in the category that found it. */
83
+ export interface InFlightItem {
84
+ category: "task" | "worktree" | "branch";
85
+ what: string;
86
+ }
87
+ /**
88
+ * Everything in flight in a fleet home, plus every candidate left unread, plus
89
+ * the disclosures a reader needs to check the judgement rather than trust it.
90
+ *
91
+ * `notes` DOES NOT COUNT TOWARD THE EXIT CODE and that is deliberate. It
92
+ * carries facts about HOW a judgement was made (which base ref each project's
93
+ * branches were compared against, and by which rule that base was chosen), not
94
+ * facts about whether work remains. Putting them in `unknown` would hold the
95
+ * exit at `EXIT_WORK_REMAINS` forever; leaving them out is what let a clone
96
+ * whose `origin/HEAD` points at a feature branch judge every branch against
97
+ * that branch with nothing in the output saying so.
98
+ */
99
+ export interface InFlight {
100
+ items: InFlightItem[];
101
+ unknown: string[];
102
+ notes: string[];
103
+ }
104
+ export declare function baseRefOf(projectDir: string): {
105
+ ok: true;
106
+ ref: string;
107
+ how: string;
108
+ doubt?: string;
109
+ } | {
110
+ ok: false;
111
+ reason: string;
112
+ };
113
+ /**
114
+ * Every in-flight category in a fleet home, in dependency order: the task
115
+ * record, the checkout it owns, and the branch that outlives both.
116
+ */
117
+ export declare function collectInFlight(fleet: Fleet): InFlight;
118
+ /**
119
+ * THE ONE NEXT ACTION (criterion 1). Exactly one, chosen by the first
120
+ * non-empty category in the order above, so the answer is derived from the
121
+ * report rather than picked.
122
+ */
123
+ export declare function nextAction(report: InFlight): string;
124
+ /** The cannot-see block. Unconditional, and it probes nothing to build. */
125
+ export declare function cannotSeeBlock(): string[];
126
+ /** The whole report, as lines, with exactly one `next action:` line. */
127
+ export declare function renderReport(fleet: Fleet, report: InFlight): string[];
128
+ /** Work remains whenever anything is in flight OR anything could not be read. */
129
+ export declare function exitCodeFor(report: InFlight): number;
130
+ export declare function cmdNext(args: string[]): number;