@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/cli.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { cmdBrief } from "./commands/brief.js";
2
2
  import { cmdChecklist } from "./commands/checklist.js";
3
+ import { cmdCutover } from "./commands/cutover.js";
3
4
  import { cmdDoctor } from "./commands/doctor.js";
4
5
  import { cmdGates } from "./commands/gates.js";
5
6
  import { cmdInit } from "./commands/init.js";
6
7
  import { cmdLock } from "./commands/lock.js";
7
8
  import { cmdMode } from "./commands/mode.js";
9
+ import { cmdNext } from "./commands/next.js";
8
10
  import { cmdPlan } from "./commands/plan.js";
9
11
  import { cmdPool } from "./commands/pool.js";
12
+ import { cmdResume } from "./commands/resume.js";
10
13
  import { cmdSpawn } from "./commands/spawn.js";
11
14
  import { cmdStatus } from "./commands/status.js";
15
+ import { cmdSync } from "./commands/sync.js";
12
16
  import { cmdTeardown } from "./commands/teardown.js";
13
17
  import { cmdTuition } from "./commands/tuition.js";
14
18
  import { cmdValidate } from "./commands/validate.js";
@@ -23,15 +27,22 @@ const commands = new Map([
23
27
  ["version", printVersion],
24
28
  ["brief", cmdBrief],
25
29
  ["checklist", cmdChecklist],
30
+ /* M4-P25. The rollback handlers M4-P26 shipped were reachable only by
31
+ import until this row existed, so the rollback document printed command
32
+ lines that did not run. */
33
+ ["cutover", cmdCutover],
26
34
  ["init", cmdInit],
27
35
  ["doctor", cmdDoctor],
28
36
  ["gates", cmdGates],
29
37
  ["lock", cmdLock],
30
38
  ["mode", cmdMode],
39
+ ["next", cmdNext],
31
40
  ["plan", cmdPlan],
32
41
  ["pool", cmdPool],
42
+ ["resume", cmdResume],
33
43
  ["spawn", cmdSpawn],
34
44
  ["status", cmdStatus],
45
+ ["sync", cmdSync],
35
46
  ["teardown", cmdTeardown],
36
47
  ["tuition", cmdTuition],
37
48
  ["validate", cmdValidate],
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import { writeFileSync } from "node:fs";
35
35
  import { join } from "node:path";
36
- import { REVIEW_CONTRACTS, REVIEW_CONTRACT_ROLE, ROLE_IDS, clauseRoundTripDiagnostics, expandIncludes, kernelRoot, missingRequiredSections, renderPhase, resolveMandatedReading, selectReviewContract, splitFrontmatter, } from "../roles.js";
36
+ import { REVIEW_CONTRACTS, REVIEW_CONTRACT_ROLE, ROLE_BRIEF_FILES, ROLE_IDS, clauseRoundTripDiagnostics, expandIncludes, kernelRoot, missingRequiredSections, renderPhase, resolveMandatedReading, roleBriefFile, selectReviewContract, splitFrontmatter, } from "../roles.js";
37
37
  import { refuseOpenForWrite, readRegularFileIfPresent } from "../task.js";
38
38
  import { decodeDocument, formatDiagnostics, readOperatorPath } from "../validate.js";
39
39
  /** Exit code for usage errors, per BSD sysexits EX_USAGE. */
@@ -68,8 +68,25 @@ export function composeBrief(options) {
68
68
  reason: `unknown role ${options.roleId}; the roles are ${ROLE_IDS.join(", ")}`,
69
69
  };
70
70
  }
71
- const rolesDirectory = join(options.root, "roles");
72
- const rolePath = join(rolesDirectory, `${options.roleId}.md`);
71
+ /* THE BRIEF'S LOCATION IS DECLARED, NOT COMPUTED FROM THE ID. Five of the
72
+ six roles ship as `roles/<id>.md` and the orchestrator's ships as
73
+ `AGENTS.md` at the package root, so `join(root, "roles", id + ".md")`
74
+ was a path that does not exist for one advertised role. A role the
75
+ vocabulary declares and the package does not place is refused BY NAME
76
+ here, before anything is read, rather than surfacing as a missing file
77
+ the operator reads as a broken install (src/roles.ts:54). Includes are
78
+ still resolved against `roles/`, which is where `$include:` targets such
79
+ as `_shared-dispatch-contract.md` live for every role. */
80
+ const relative = roleBriefFile(options.roleId);
81
+ if (relative === undefined) {
82
+ return {
83
+ ok: false,
84
+ reason: `role ${options.roleId} is declared in the role vocabulary and this ` +
85
+ `package does not say where its brief ships; the placed roles are ` +
86
+ `${Object.keys(ROLE_BRIEF_FILES).join(", ")}`,
87
+ };
88
+ }
89
+ const rolePath = join(options.root, relative);
73
90
  const roleRead = readOperatorPath(rolePath);
74
91
  if (!roleRead.ok) {
75
92
  return { ok: false, reason: `role brief ${rolePath}: ${roleRead.reason}` };
@@ -94,7 +111,13 @@ export function composeBrief(options) {
94
111
  if (!resolution.ok) {
95
112
  return { ok: false, reason: resolution.reason };
96
113
  }
97
- const expanded = expandIncludes(split.body, rolesDirectory, rolePath);
114
+ /* INCLUDES RESOLVE AGAINST THE BRIEF'S OWN DIRECTORY, not against `roles/`.
115
+ Every brief under `roles/` writes `$include: _shared-dispatch-contract.md`
116
+ and AGENTS.md, which is the orchestrator's brief at the package root,
117
+ writes `$include: roles/_shared-dispatch-contract.md`. Both are correct
118
+ relative to the document they are in, and a fixed `roles/` base turned the
119
+ second into `roles/roles/_shared-dispatch-contract.md`. */
120
+ const expanded = expandIncludes(split.body, dirnameOf(rolePath), rolePath);
98
121
  if (!expanded.ok) {
99
122
  return { ok: false, reason: expanded.reason };
100
123
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `tiphys cutover status|rollback|restore-files|restore-request` (kernel plan
3
+ * M4, M4-P26 and M4-P25). The executable half of D-19's second limb, plus the
4
+ * freeze-point status surface.
5
+ *
6
+ * REGISTERED IN `src/cli.ts` BY M4-P25. The header that stood here said this
7
+ * module was deliberately unregistered because `src/cli.ts` belonged to
8
+ * M4-P16 for the duration of wave 1 of the M4 conflict pre-pass
9
+ * (delivery/plan/m4-conflict-pre-pass.md:60), and that wave is closed: the
10
+ * `cutover` verb is on this phase's files-to-touch list and is now wired, so
11
+ * the rollback document's command lines resolve.
12
+ *
13
+ * Exit codes:
14
+ * 0 the step completed and its observation is printed
15
+ * 1 the step refused, with one reason line naming what was not done
16
+ * 3 `status` only: the question was answered and the answer is that WORK
17
+ * REMAINS (a switch still reads `current`, drain is not clean, or a
18
+ * retirement row is unported)
19
+ * 64 usage error (BSD sysexits EX_USAGE)
20
+ *
21
+ * A REFUSAL IS 1 AND NOT 64 even when the input file is malformed, because a
22
+ * malformed pre-freeze capture is a well-formed question with a negative
23
+ * answer. A caller that cannot tell those apart cannot script a rollback.
24
+ *
25
+ * 3 IS SEPARATE FROM 1 FOR THE SAME REASON, one level along, and it is the
26
+ * code `tiphys next` already uses for the same meaning
27
+ * (delivery/plan/kernel-plan-m4.md:3234, criterion 1: "distinct from 0 and
28
+ * from 1 so a caller can tell work remains from the command failed"). A
29
+ * cutover that has not completed is not a failure of this command.
30
+ */
31
+ /** Exit code for usage errors, per BSD sysexits EX_USAGE. */
32
+ export declare const EX_USAGE = 64;
33
+ /** Exit code for "the question was answered and work remains". */
34
+ export declare const EX_WORK_REMAINS = 3;
35
+ export declare function cmdCutover(argv: string[]): number;
@@ -0,0 +1,448 @@
1
+ /**
2
+ * `tiphys cutover status|rollback|restore-files|restore-request` (kernel plan
3
+ * M4, M4-P26 and M4-P25). The executable half of D-19's second limb, plus the
4
+ * freeze-point status surface.
5
+ *
6
+ * REGISTERED IN `src/cli.ts` BY M4-P25. The header that stood here said this
7
+ * module was deliberately unregistered because `src/cli.ts` belonged to
8
+ * M4-P16 for the duration of wave 1 of the M4 conflict pre-pass
9
+ * (delivery/plan/m4-conflict-pre-pass.md:60), and that wave is closed: the
10
+ * `cutover` verb is on this phase's files-to-touch list and is now wired, so
11
+ * the rollback document's command lines resolve.
12
+ *
13
+ * Exit codes:
14
+ * 0 the step completed and its observation is printed
15
+ * 1 the step refused, with one reason line naming what was not done
16
+ * 3 `status` only: the question was answered and the answer is that WORK
17
+ * REMAINS (a switch still reads `current`, drain is not clean, or a
18
+ * retirement row is unported)
19
+ * 64 usage error (BSD sysexits EX_USAGE)
20
+ *
21
+ * A REFUSAL IS 1 AND NOT 64 even when the input file is malformed, because a
22
+ * malformed pre-freeze capture is a well-formed question with a negative
23
+ * answer. A caller that cannot tell those apart cannot script a rollback.
24
+ *
25
+ * 3 IS SEPARATE FROM 1 FOR THE SAME REASON, one level along, and it is the
26
+ * code `tiphys next` already uses for the same meaning
27
+ * (delivery/plan/kernel-plan-m4.md:3234, criterion 1: "distinct from 0 and
28
+ * from 1 so a caller can tell work remains from the command failed"). A
29
+ * cutover that has not completed is not a failure of this command.
30
+ */
31
+ import { writeFileSync } from "node:fs";
32
+ import { join, resolve } from "node:path";
33
+ import { loadFleet } from "../fleet.js";
34
+ import { CANNOT_SEE, CUTOVER_SWITCHES, PRE_FREEZE_RULESET_PATH, RETIREMENT_INVENTORY_PATH, ROLLBACK_TRIGGERS, UNREHEARSABLE_REASON, applyRollback, cutoverStatePath, evaluateRetirementInventory, generateRestoreRequest, inFlightItems, preFreezeGuard, readCutoverState, restoreRetirementRoots, syncFleetState, unmergedBranchCount, } from "../cutover.js";
35
+ import { readRegularFileIfPresent, refuseOpenForWrite } from "../task.js";
36
+ /** Exit code for usage errors, per BSD sysexits EX_USAGE. */
37
+ export const EX_USAGE = 64;
38
+ function usage() {
39
+ return [
40
+ "usage: tiphys cutover status --fleet <dir> [--repo <dir>] [--json]",
41
+ " tiphys cutover status --retirement [--repo <dir>] [--inventory <path>] [--json]",
42
+ " tiphys cutover rollback --trigger <drain-reversal|retirement-unmet> --fleet <dir> [--reason <text>] [--allow-no-remote] [--json]",
43
+ " tiphys cutover restore-files --repo <dir> --from <sha> --root <path> [--root <path>]",
44
+ " tiphys cutover restore-request --ruleset <path> [--out <path>]",
45
+ ].join("\n");
46
+ }
47
+ function fail(reason, code) {
48
+ process.stderr.write(`tiphys cutover: ${reason}\n`);
49
+ if (code === EX_USAGE) {
50
+ process.stderr.write(`${usage()}\n`);
51
+ }
52
+ return code;
53
+ }
54
+ const VALUE_ARGS = new Set([
55
+ "--trigger",
56
+ "--fleet",
57
+ "--reason",
58
+ "--repo",
59
+ "--from",
60
+ "--root",
61
+ "--ruleset",
62
+ "--out",
63
+ "--inventory",
64
+ ]);
65
+ const FLAG_ARGS = new Set(["--json", "--allow-no-remote", "--retirement"]);
66
+ function parseArgs(argv) {
67
+ const flags = new Set();
68
+ const values = new Map();
69
+ const repeated = new Map();
70
+ for (let index = 0; index < argv.length; index += 1) {
71
+ const argument = argv[index];
72
+ if (FLAG_ARGS.has(argument)) {
73
+ flags.add(argument);
74
+ continue;
75
+ }
76
+ if (VALUE_ARGS.has(argument)) {
77
+ const value = argv[index + 1];
78
+ if (value === undefined || value.startsWith("--")) {
79
+ return { flags, values, repeated, usageError: `${argument} requires a value` };
80
+ }
81
+ values.set(argument, value);
82
+ const list = repeated.get(argument) ?? [];
83
+ list.push(value);
84
+ repeated.set(argument, list);
85
+ index += 1;
86
+ continue;
87
+ }
88
+ return { flags, values, repeated, usageError: `unknown argument ${argument}` };
89
+ }
90
+ return { flags, values, repeated };
91
+ }
92
+ function isRollbackTrigger(value) {
93
+ return ROLLBACK_TRIGGERS.includes(value);
94
+ }
95
+ /** Exit code for "the question was answered and work remains". */
96
+ export const EX_WORK_REMAINS = 3;
97
+ /**
98
+ * The five switch lines, ALWAYS FIVE AND ALWAYS IN THE CLOSED ORDER.
99
+ *
100
+ * Iterating `CUTOVER_SWITCHES` rather than the document's own keys is the
101
+ * contract: `Object.keys` would print whatever happened to be in the file, in
102
+ * whatever order it happened to be written, and criterion 1 pins the shape.
103
+ */
104
+ function switchLines(state) {
105
+ return CUTOVER_SWITCHES.map((name) => `SWITCH ${name} ${state.switches[name].state}`);
106
+ }
107
+ /**
108
+ * The state a fleet with no `cutover.json` is in.
109
+ *
110
+ * ABSENT IS NOT UNDECIDABLE HERE, and it is worth saying why this differs from
111
+ * `inFlightItems`, where an absent directory IS undecidable. A fleet that was
112
+ * loaded had its `worktrees/` and `tasks/` when it was loaded, so one that is
113
+ * gone now was removed since and says nothing about what was in it. Nothing
114
+ * creates `cutover.json` at init: its absence is the ORIGINAL state, in which
115
+ * no switch has ever been written and every authority is still the current
116
+ * process's. Reporting five `current` lines and exiting 3 is therefore the
117
+ * measured answer and not a fallthrough, and `flippedAt` is the epoch so that
118
+ * the criterion-5 comparison has an instant to compare against rather than a
119
+ * special case.
120
+ */
121
+ function unflippedState() {
122
+ const switches = {};
123
+ for (const name of CUTOVER_SWITCHES) {
124
+ switches[name] = {
125
+ state: "current",
126
+ flippedAt: new Date(0).toISOString(),
127
+ flippedBy: "(never written)",
128
+ reason: "cutover.json is absent, so no switch has ever been flipped",
129
+ restoreTo: "current",
130
+ };
131
+ }
132
+ return { switches };
133
+ }
134
+ /**
135
+ * `tiphys cutover status --retirement`: one line per PORT row of the M4-P23
136
+ * inventory.
137
+ *
138
+ * THE VACUOUS VERDICT IS WHAT THIS EXISTS AGAINST. `ported` requires the named
139
+ * kernel artifact to exist AND that row's negative witness to be RED under it.
140
+ * A verdict derived from the file existing alone is green and worthless: a
141
+ * file can exist and say nothing, and the negative witness is what turns
142
+ * "verify not weaker" from a phrase into a command.
143
+ */
144
+ function cmdRetirementStatus(parsed) {
145
+ const repo = resolve(parsed.values.get("--repo") ?? process.cwd());
146
+ const inventory = parsed.values.get("--inventory");
147
+ const inventoryPath = inventory === undefined ? join(repo, RETIREMENT_INVENTORY_PATH) : resolve(inventory);
148
+ const read = evaluateRetirementInventory(inventoryPath, repo);
149
+ if (read.kind === "refused") {
150
+ return fail(read.reason, 1);
151
+ }
152
+ const { results, unported } = read.report;
153
+ const lines = results.map((result) => `PORT ${result.id} ${result.verdict} ${result.reason}`);
154
+ lines.push(unported === 0
155
+ ? `RETIREMENT complete ${String(results.length)} PORT row(s)`
156
+ : `RETIREMENT ${String(unported)} of ${String(results.length)} PORT row(s) unported`);
157
+ if (parsed.flags.has("--json")) {
158
+ process.stdout.write(`${JSON.stringify({ inventory: inventoryPath, results, unported }, null, 2)}\n`);
159
+ }
160
+ else {
161
+ process.stdout.write(`${lines.join("\n")}\n`);
162
+ }
163
+ /* A ZERO-ROW INVENTORY IS NOT A COMPLETE RETIREMENT. `unported === 0` over an
164
+ empty list is the vacuous green one level up from the one the row verdict
165
+ guards, so the row count is required to be positive before this reports
166
+ complete. */
167
+ if (results.length === 0) {
168
+ return fail(`${inventoryPath} holds no PORT rows, so a complete verdict would assert nothing`, 1);
169
+ }
170
+ return unported === 0 ? 0 : EX_WORK_REMAINS;
171
+ }
172
+ /**
173
+ * `tiphys cutover status`: the five switches, the drain predicate, and the
174
+ * pre-freeze precondition.
175
+ *
176
+ * WHAT MAKES THIS EXIT 0. All five switches read `kernel`, drain is clean, and
177
+ * the pre-freeze capture is present and no older than the most recent switch
178
+ * write. Any other combination is nonzero, and the code says WHICH: 1 when the
179
+ * command refused to answer, 3 when it answered and work remains.
180
+ *
181
+ * THE BRANCH COUNT IS PRINTED AND DOES NOT VOTE (M4-D-15,
182
+ * delivery/plan/kernel-plan-m4.md:3279). It is on its own line, after the
183
+ * DRAIN verdict rather than beside it, because a reader who mistakes it for a
184
+ * blocker has the definition of drain that can never read clean.
185
+ */
186
+ function cmdStatus(parsed) {
187
+ if (parsed.flags.has("--retirement")) {
188
+ return cmdRetirementStatus(parsed);
189
+ }
190
+ const fleetDir = parsed.values.get("--fleet");
191
+ if (fleetDir === undefined) {
192
+ return fail("--fleet is required", EX_USAGE);
193
+ }
194
+ let fleet;
195
+ try {
196
+ fleet = loadFleet(fleetDir);
197
+ }
198
+ catch (error) {
199
+ return fail(error instanceof Error ? error.message : String(error), 1);
200
+ }
201
+ const repo = resolve(parsed.values.get("--repo") ?? process.cwd());
202
+ const read = readCutoverState(fleet);
203
+ if (read.kind === "refused") {
204
+ return fail(read.reason, 1);
205
+ }
206
+ const state = read.kind === "absent" ? unflippedState() : read.state;
207
+ const items = inFlightItems(fleet);
208
+ const branches = unmergedBranchCount(fleet.root);
209
+ const guard = preFreezeGuard(repo, state);
210
+ const lines = switchLines(state);
211
+ lines.push(items.length === 0 ? "DRAIN clean" : `DRAIN ${String(items.length)} in flight`);
212
+ lines.push(branches.kind === "counted"
213
+ ? `BRANCHES ${String(branches.count)} pushed and unmerged, informational: drain does not count branches (M4-D-15)`
214
+ : `BRANCHES unexaminable ${branches.reason}`);
215
+ for (const item of items) {
216
+ lines.push(`IN-FLIGHT ${item.kind} ${item.id} ${item.detail}`);
217
+ }
218
+ /* ONE REFUSAL LINE PER SWITCH THAT READS `kernel`, not one for the table.
219
+ The criterion says no SWITCH may report `kernel` while the capture is
220
+ absent or stale, so the refusal is attached to each switch that is making
221
+ the claim. A single table-level line would let a reader who greps for a
222
+ switch name see `kernel` and nothing else. */
223
+ const frozen = guard.kind === "refused"
224
+ ? CUTOVER_SWITCHES.filter((name) => state.switches[name].state === "kernel")
225
+ : [];
226
+ for (const name of frozen) {
227
+ lines.push(`REFUSED ${name} reports kernel but ${guard.kind === "refused" ? guard.reason : ""}`);
228
+ }
229
+ if (guard.kind === "satisfied") {
230
+ lines.push(`PRE-FREEZE captured ${guard.capturedAt} ${PRE_FREEZE_RULESET_PATH}`);
231
+ }
232
+ else if (guard.kind === "not-required") {
233
+ lines.push(`PRE-FREEZE not-required no switch reads kernel, so there is no freeze to have captured`);
234
+ }
235
+ /* NOT ABBREVIATED WHEN THE ANSWER IS SHORT. A command that prints a shorter
236
+ answer when it has less to say is indistinguishable from one reporting a
237
+ quiet system (standing warning 6). */
238
+ for (const entry of CANNOT_SEE) {
239
+ lines.push(`CANNOT-SEE ${entry}`);
240
+ }
241
+ if (parsed.flags.has("--json")) {
242
+ process.stdout.write(`${JSON.stringify({
243
+ switches: state.switches,
244
+ drain: { clean: items.length === 0, inFlight: items },
245
+ branches,
246
+ preFreeze: guard,
247
+ cannotSee: CANNOT_SEE,
248
+ }, null, 2)}\n`);
249
+ }
250
+ else {
251
+ process.stdout.write(`${lines.join("\n")}\n`);
252
+ }
253
+ if (guard.kind === "refused") {
254
+ return fail(`${PRE_FREEZE_RULESET_PATH}: ${guard.arm}; ${String(frozen.length)} switch(es) report kernel without a usable pre-freeze capture`, 1);
255
+ }
256
+ const allKernel = CUTOVER_SWITCHES.every((name) => state.switches[name].state === "kernel");
257
+ return allKernel && items.length === 0 ? 0 : EX_WORK_REMAINS;
258
+ }
259
+ /**
260
+ * Trigger 1 and the flip-back half of trigger 3.
261
+ *
262
+ * `freeze-point-restore` is REFUSED here on purpose. Trigger 2 is not one
263
+ * command: its file half is `restore-files`, its authority half is
264
+ * `restore-request` plus an owner, and collapsing them into one verb would
265
+ * let a green exit code stand for a restoration that has not happened. The
266
+ * refusal names the unrehearsable property in the same sentence.
267
+ */
268
+ function cmdRollback(parsed) {
269
+ const triggerValue = parsed.values.get("--trigger");
270
+ if (triggerValue === undefined) {
271
+ return fail("--trigger is required", EX_USAGE);
272
+ }
273
+ if (!isRollbackTrigger(triggerValue)) {
274
+ return fail(`unknown trigger ${triggerValue}, expected one of ${ROLLBACK_TRIGGERS.join(", ")}`, EX_USAGE);
275
+ }
276
+ if (triggerValue === "freeze-point-restore") {
277
+ return fail("freeze-point-restore is not one command: run restore-files for the file half, then " +
278
+ `restore-request for the authority half, which is an OWNER action because ${UNREHEARSABLE_REASON}`, 1);
279
+ }
280
+ const fleetDir = parsed.values.get("--fleet");
281
+ if (fleetDir === undefined) {
282
+ return fail("--fleet is required", EX_USAGE);
283
+ }
284
+ let fleet;
285
+ try {
286
+ fleet = loadFleet(fleetDir);
287
+ }
288
+ catch (error) {
289
+ return fail(error instanceof Error ? error.message : String(error), 1);
290
+ }
291
+ /* Step 2 of trigger 1 runs BEFORE step 1's switch write in this command,
292
+ because the in-flight enumeration is the thing that would be lost if the
293
+ rollback crashed, and enumerating costs nothing. The document orders the
294
+ write first; the observation is the same either way and this order cannot
295
+ lose the list. */
296
+ const items = inFlightItems(fleet);
297
+ const before = readCutoverState(fleet);
298
+ if (before.kind !== "read") {
299
+ return fail(before.kind === "absent"
300
+ ? `${cutoverStatePath(fleet)} is absent, so there is no cutover to roll back`
301
+ : before.reason, 1);
302
+ }
303
+ const outcome = applyRollback(fleet, triggerValue, {
304
+ now: new Date().toISOString(),
305
+ by: "tiphys cutover rollback",
306
+ reason: parsed.values.get("--reason") ?? `rollback trigger ${triggerValue}`,
307
+ });
308
+ if (!outcome.ok) {
309
+ return fail(outcome.reason, 1);
310
+ }
311
+ const sync = syncFleetState(fleet.root, {
312
+ allowNoRemote: parsed.flags.has("--allow-no-remote"),
313
+ message: `cutover rollback: ${triggerValue}`,
314
+ /* The ONE file this command changed. Trigger 1 fires precisely when
315
+ in-flight work exists, so the fleet is dirty by construction and a
316
+ rollback that staged everything would commit somebody else's half-done
317
+ work under the rollback's message. */
318
+ paths: ["cutover.json"],
319
+ });
320
+ const lines = [];
321
+ /* Iterate the CLOSED list rather than the object's keys: the five names and
322
+ their order are the contract, and `Object.keys` would print whatever
323
+ happened to be in the file. */
324
+ for (const name of CUTOVER_SWITCHES) {
325
+ lines.push(`SWITCH ${name} ${outcome.next.switches[name].state}`);
326
+ }
327
+ lines.push(items.length === 0 ? "DRAIN clean" : `DRAIN ${String(items.length)} in flight`);
328
+ for (const item of items) {
329
+ lines.push(`IN-FLIGHT ${item.kind} ${item.id} ${item.detail}`);
330
+ }
331
+ for (const entry of CANNOT_SEE) {
332
+ lines.push(`CANNOT-SEE ${entry}`);
333
+ }
334
+ if (sync.ok && sync.pushed) {
335
+ lines.push(`SYNC pushed ${sync.head}`);
336
+ }
337
+ else if (sync.ok) {
338
+ lines.push(`SYNC not-pushed ${sync.reason}`);
339
+ }
340
+ if (parsed.flags.has("--json")) {
341
+ process.stdout.write(`${JSON.stringify({
342
+ trigger: triggerValue,
343
+ changes: outcome.changes,
344
+ switches: outcome.next.switches,
345
+ inFlight: items,
346
+ cannotSee: CANNOT_SEE,
347
+ sync,
348
+ }, null, 2)}\n`);
349
+ }
350
+ else {
351
+ process.stdout.write(`${lines.join("\n")}\n`);
352
+ }
353
+ if (!sync.ok) {
354
+ return fail(sync.reason, 1);
355
+ }
356
+ return 0;
357
+ }
358
+ /** Trigger 2 step 2: the cheap half, guarded by the dirty-tree refusal. */
359
+ function cmdRestoreFiles(parsed) {
360
+ const repo = parsed.values.get("--repo");
361
+ const from = parsed.values.get("--from");
362
+ const roots = parsed.repeated.get("--root") ?? [];
363
+ if (repo === undefined || from === undefined || roots.length === 0) {
364
+ return fail("--repo, --from and at least one --root are required", EX_USAGE);
365
+ }
366
+ const outcome = restoreRetirementRoots(resolve(repo), from, roots);
367
+ if (!outcome.ok) {
368
+ return fail(outcome.reason, 1);
369
+ }
370
+ /* The removals are printed rather than folded into RESTORED. A restore that
371
+ deleted files is a different event from one that only rewrote them, and a
372
+ reader who cannot tell them apart cannot check the result. */
373
+ for (const path of outcome.removed) {
374
+ process.stdout.write(`REMOVED-AFTER-FREEZE ${path}\n`);
375
+ }
376
+ process.stdout.write(`RESTORED ${from} ${outcome.roots.join(" ")} (${String(outcome.removed.length)} post-freeze addition(s) removed)\n`);
377
+ return 0;
378
+ }
379
+ /** Trigger 2 step 3: prepare the owner request. It does not perform it. */
380
+ function cmdRestoreRequest(parsed) {
381
+ const rulesetPath = parsed.values.get("--ruleset");
382
+ if (rulesetPath === undefined) {
383
+ return fail("--ruleset is required", EX_USAGE);
384
+ }
385
+ const read = readRegularFileIfPresent(resolve(rulesetPath));
386
+ if (read.kind === "absent") {
387
+ return fail(`${rulesetPath} is absent, so no pre-freeze value can be restored`, 1);
388
+ }
389
+ if (read.kind === "refused") {
390
+ return fail(read.reason, 1);
391
+ }
392
+ let parsedDocument;
393
+ try {
394
+ parsedDocument = JSON.parse(read.body);
395
+ }
396
+ catch (error) {
397
+ return fail(`${rulesetPath} is not valid JSON: ${String(error)}`, 1);
398
+ }
399
+ const outcome = generateRestoreRequest(parsedDocument);
400
+ if (!outcome.ok) {
401
+ for (const reason of outcome.reasons) {
402
+ process.stderr.write(`tiphys cutover: ${reason}\n`);
403
+ }
404
+ process.stderr.write(`tiphys cutover: the owner request was NOT generated, because an incomplete request looks complete\n`);
405
+ return 1;
406
+ }
407
+ const out = parsed.values.get("--out");
408
+ if (out === undefined) {
409
+ process.stdout.write(outcome.text);
410
+ }
411
+ else {
412
+ const target = resolve(out);
413
+ /* THE ONE ANSWER TO "may this path be opened" applies to this write too.
414
+ `--out` is an operator-supplied path, and opening a named pipe for
415
+ writing blocks exactly as reading one does (src/task.ts:152). This was
416
+ the only write in the phase that went straight to an unprobed path. */
417
+ const refusal = refuseOpenForWrite(target);
418
+ if (refusal !== undefined) {
419
+ return fail(refusal, 1);
420
+ }
421
+ writeFileSync(target, outcome.text);
422
+ process.stdout.write(`REQUEST ${out} ${String(outcome.fields)} field(s)\n`);
423
+ }
424
+ return 0;
425
+ }
426
+ export function cmdCutover(argv) {
427
+ const [subcommand, ...rest] = argv;
428
+ if (subcommand === undefined) {
429
+ return fail("a subcommand is required", EX_USAGE);
430
+ }
431
+ const parsed = parseArgs(rest);
432
+ if (parsed.usageError !== undefined) {
433
+ return fail(parsed.usageError, EX_USAGE);
434
+ }
435
+ if (subcommand === "status") {
436
+ return cmdStatus(parsed);
437
+ }
438
+ if (subcommand === "rollback") {
439
+ return cmdRollback(parsed);
440
+ }
441
+ if (subcommand === "restore-files") {
442
+ return cmdRestoreFiles(parsed);
443
+ }
444
+ if (subcommand === "restore-request") {
445
+ return cmdRestoreRequest(parsed);
446
+ }
447
+ return fail(`unknown subcommand ${subcommand}`, EX_USAGE);
448
+ }