@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/task.js CHANGED
@@ -1,108 +1,12 @@
1
- import { existsSync, lstatSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ /* IMPORTED UNDER THE OLD NAMES rather than re-exported straight through,
4
+ because this module CALLS them as well as publishing them. A bare
5
+ `export ... from` publishes without binding, so the internal call sites below
6
+ would not resolve and the only signal would be a build error. */
7
+ import { classifyPathEntry as classifyEntry, readRegularPathIfPresent as readRegularFileIfPresent, refuseOpenPathForWrite as refuseOpenForWrite, } from "./fleet.js";
3
8
  import { leaseStatus } from "./lock.js";
4
- function describeType(stats) {
5
- if (stats.isDirectory()) {
6
- return "a directory";
7
- }
8
- if (stats.isFIFO()) {
9
- return "a named pipe";
10
- }
11
- if (stats.isSocket()) {
12
- return "a socket";
13
- }
14
- if (stats.isCharacterDevice()) {
15
- return "a character device";
16
- }
17
- if (stats.isBlockDevice()) {
18
- return "a block device";
19
- }
20
- return "an entry of an unrecognized type";
21
- }
22
- /**
23
- * THE ONE ANSWER TO "may this path be opened". Every reader and every
24
- * writer of a path this kernel does not itself guarantee to be a regular
25
- * file goes through this, so there is one implementation of the question
26
- * and not one per call site.
27
- */
28
- export function classifyEntry(path) {
29
- try {
30
- lstatSync(path);
31
- }
32
- catch (error) {
33
- if (error.code === "ENOENT") {
34
- return { kind: "absent" };
35
- }
36
- return {
37
- kind: "unexaminable",
38
- reason: `${path} could not be examined: ${String(error)}`,
39
- };
40
- }
41
- let stats;
42
- try {
43
- stats = statSync(path);
44
- }
45
- catch (error) {
46
- if (error.code === "ENOENT") {
47
- return { kind: "dangling" };
48
- }
49
- return {
50
- kind: "unexaminable",
51
- reason: `${path} could not be examined: ${String(error)}`,
52
- };
53
- }
54
- if (stats.isFile()) {
55
- return { kind: "regular" };
56
- }
57
- return {
58
- kind: "irregular",
59
- reason: `${path} is ${describeType(stats)}, not a regular file, so it was not opened`,
60
- };
61
- }
62
- /**
63
- * Refuse an open-for-WRITE of a path that is not a regular file. The
64
- * hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
65
- * exactly as reading one with no writer does, so a staged write and an
66
- * append are as dangerous as a read. Returns the reason, or undefined when
67
- * the path may be opened (absent included: creating it is the point).
68
- */
69
- export function refuseOpenForWrite(path) {
70
- const entry = classifyEntry(path);
71
- if (entry.kind === "irregular" || entry.kind === "unexaminable") {
72
- return entry.reason;
73
- }
74
- return undefined;
75
- }
76
- /**
77
- * THE ONE READ of a file that might not be there and might not be a file.
78
- * src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
79
- * all read fleet state through this, so "probe before open" is a property
80
- * of the read and cannot be forgotten by a new caller.
81
- */
82
- export function readRegularFileIfPresent(path) {
83
- const entry = classifyEntry(path);
84
- if (entry.kind === "absent" || entry.kind === "dangling") {
85
- return { kind: "absent" };
86
- }
87
- if (entry.kind === "irregular" || entry.kind === "unexaminable") {
88
- return { kind: "refused", reason: entry.reason };
89
- }
90
- let body;
91
- try {
92
- body = readFileSync(path, "utf8");
93
- }
94
- catch (error) {
95
- if (error.code === "ENOENT") {
96
- // Removed between the probe and the read.
97
- return { kind: "absent" };
98
- }
99
- return {
100
- kind: "refused",
101
- reason: `${path} could not be read: ${String(error)}`,
102
- };
103
- }
104
- return { kind: "read", body };
105
- }
9
+ export { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite };
106
10
  export const TASK_SHAPES = ["ship", "scout"];
107
11
  export function taskDir(fleet, taskId) {
108
12
  return join(fleet.tasksDir, taskId);
@@ -129,10 +33,33 @@ export function renderTaskMeta(meta) {
129
33
  export function writeTaskMeta(fleet, meta) {
130
34
  writeFileSync(metaPath(fleet, meta.id), renderTaskMeta(meta));
131
35
  }
36
+ /** Which required field first failed, or undefined when all of them hold. */
37
+ function firstBadMetaField(candidate) {
38
+ if (typeof candidate.id !== "string")
39
+ return "id (a string)";
40
+ if (typeof candidate.project !== "string")
41
+ return "project (a string)";
42
+ if (candidate.shape !== "ship" && candidate.shape !== "scout") {
43
+ return 'shape (either "ship" or "scout")';
44
+ }
45
+ if (typeof candidate.branch !== "string")
46
+ return "branch (a string)";
47
+ if (typeof candidate.worktree !== "string")
48
+ return "worktree (a string)";
49
+ if (typeof candidate.baseSha !== "string")
50
+ return "baseSha (a string)";
51
+ if (typeof candidate.baseOffline !== "boolean")
52
+ return "baseOffline (a boolean)";
53
+ if (candidate.status !== "open" && candidate.status !== "closed") {
54
+ return 'status (either "open" or "closed")';
55
+ }
56
+ if (typeof candidate.createdAt !== "string")
57
+ return "createdAt (a string)";
58
+ return undefined;
59
+ }
132
60
  /**
133
- * Read meta.json, or undefined when it is absent, is not a regular file,
134
- * or does not parse. All three mean the same thing to every caller: this
135
- * is not a readable record, and it is not evidence that the task finished.
61
+ * THE ONE READ of a task record, and the one that says WHICH of the four
62
+ * outcomes happened.
136
63
  *
137
64
  * The type probe is INSIDE this function and not in front of one of its
138
65
  * callers (CR-520, CR-521). There is exactly one implementation of "read a
@@ -140,32 +67,71 @@ export function writeTaskMeta(fleet, meta) {
140
67
  * cannot reopen the hole: src/teardown.ts reaches this directly, without
141
68
  * going through the liveness classifier, and a named pipe here used to
142
69
  * hang it forever.
70
+ *
71
+ * WHY THIS IS A SEPARATE FUNCTION FROM `readTaskMeta` RATHER THAN ITS NEW
72
+ * SIGNATURE, declared rather than left to be inferred. Widening the return
73
+ * type of `readTaskMeta` is the shape this repair wants, and it is a
74
+ * compile-time break in four modules that this fix round's file list does not
75
+ * own (src/pool.ts, src/liveness.ts, src/teardown.ts, src/commands/next.ts).
76
+ * The distinction is therefore made AVAILABLE upstream of all of them here,
77
+ * `readTaskMeta` is documented as the deliberate NARROWING of it, and the
78
+ * sites that still collapse are named in the fix round's work history rather
79
+ * than quietly left.
143
80
  */
144
- export function readTaskMeta(fleet, taskId) {
145
- const read = readRegularFileIfPresent(metaPath(fleet, taskId));
146
- if (read.kind !== "read") {
147
- return undefined;
81
+ export function classifyTaskMeta(fleet, taskId) {
82
+ const path = metaPath(fleet, taskId);
83
+ const read = readRegularFileIfPresent(path);
84
+ if (read.kind === "absent") {
85
+ return { kind: "absent" };
86
+ }
87
+ if (read.kind === "refused") {
88
+ return { kind: "unreadable", reason: read.reason };
148
89
  }
149
90
  let parsed;
150
91
  try {
151
92
  parsed = JSON.parse(read.body);
152
93
  }
153
- catch {
154
- return undefined;
94
+ catch (error) {
95
+ const detail = error instanceof Error ? error.message : String(error);
96
+ return {
97
+ kind: "unparsable",
98
+ reason: `${path} is present (${read.body.length} byte(s)) and does not parse as ` +
99
+ `JSON (${detail}), which is what a task record killed mid-write looks ` +
100
+ `like; it is not evidence that there is no task here`,
101
+ };
102
+ }
103
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
104
+ return {
105
+ kind: "malformed",
106
+ reason: `${path} parses as JSON but not as an object, so it is not a task record`,
107
+ };
155
108
  }
156
109
  const candidate = parsed;
157
- if (typeof candidate.id !== "string" ||
158
- typeof candidate.project !== "string" ||
159
- (candidate.shape !== "ship" && candidate.shape !== "scout") ||
160
- typeof candidate.branch !== "string" ||
161
- typeof candidate.worktree !== "string" ||
162
- typeof candidate.baseSha !== "string" ||
163
- typeof candidate.baseOffline !== "boolean" ||
164
- (candidate.status !== "open" && candidate.status !== "closed") ||
165
- typeof candidate.createdAt !== "string") {
166
- return undefined;
110
+ const bad = firstBadMetaField(candidate);
111
+ if (bad !== undefined) {
112
+ return {
113
+ kind: "malformed",
114
+ reason: `${path} parses as JSON but is not a task record: it needs ${bad}; it is ` +
115
+ `not evidence that there is no task here`,
116
+ };
167
117
  }
168
- return candidate;
118
+ return { kind: "read", meta: candidate };
119
+ }
120
+ /**
121
+ * Read meta.json, or undefined when it is absent, is not a regular file,
122
+ * does not parse, or parses and is not a task record.
123
+ *
124
+ * THIS IS A DELIBERATE NARROWING OF `classifyTaskMeta` AND THE COLLAPSE IS THE
125
+ * WHOLE OF CR-F02. `undefined` answers "is there a readable record" and it
126
+ * cannot answer "is there a task here", because it is returned both when the
127
+ * kernel looked and found nothing and when it found something it could not
128
+ * read. A caller that reports an absence, skips an entry, or decides nothing
129
+ * is in flight must call `classifyTaskMeta` instead; a caller that REFUSES on
130
+ * every one of the four (teardown does) loses nothing by using this.
131
+ */
132
+ export function readTaskMeta(fleet, taskId) {
133
+ const read = classifyTaskMeta(fleet, taskId);
134
+ return read.kind === "read" ? read.meta : undefined;
169
135
  }
170
136
  /** Set meta.json status (teardown's last step; C-1's state authority). */
171
137
  export function setTaskStatus(fleet, meta, status) {
@@ -303,3 +269,39 @@ export function runStep(what, step) {
303
269
  return { ok: false, reason: `${what} failed: ${detail}` };
304
270
  }
305
271
  }
272
+ /**
273
+ * runStep's shape for a step that returns a PROMISE (M4-P2 step 2).
274
+ *
275
+ * This is a PROMOTION, not a new function. It lived module-private in
276
+ * src/watcher.ts, where its own comment said "src/task.ts covers the sync
277
+ * one"; spawn's launch call site needs the same shape now that
278
+ * `ExecutorAdapter.launch` returns a promise, and T-005's one-mechanism
279
+ * rule makes copying it a third time the wrong answer. The promotion was
280
+ * measured before it was made: the two functions were run side by side over
281
+ * six `what`/thrown-error pairs covering an ordinary Error, an Error with an
282
+ * empty message, a thrown string, a thrown object with a custom toString, an
283
+ * Error subclass and a multi-line pair, and their reason strings were
284
+ * compared as buffers. All six were byte-identical, so no watcher message
285
+ * changes on any of those six. The capture is in the M4-P2 work history
286
+ * under `delivery/work-history/m4-p2.md`.
287
+ *
288
+ * WHY A SEPARATE FUNCTION RATHER THAN WIDENING runStep. `runStep` returns
289
+ * `{ok: true, value}` the instant its callback returns, and a callback that
290
+ * returns a promise returns one IMMEDIATELY. So `runStep` over an async
291
+ * step is not merely imprecise: its `ok: true` is a claim that the step
292
+ * SUCCEEDED when nothing has run yet, and a later rejection escapes the
293
+ * result type entirely as an unhandled rejection. That is the exact defect
294
+ * this phase's criterion 4 witness reddens against.
295
+ *
296
+ * It never swallows, on the same terms as the sync one: the caller gets a
297
+ * reason naming the step and still decides what to do about it.
298
+ */
299
+ export async function runStepAsync(what, step) {
300
+ try {
301
+ return { ok: true, value: await step() };
302
+ }
303
+ catch (error) {
304
+ const detail = error instanceof Error ? error.message : String(error);
305
+ return { ok: false, reason: `${what} failed: ${detail}` };
306
+ }
307
+ }
@@ -28,5 +28,12 @@ export declare function landedness(contextDir: string, branchRef: string, defaul
28
28
  export interface TeardownOptions {
29
29
  taskId: string;
30
30
  salvage: boolean;
31
+ /**
32
+ * Proceed on a pool record RECONSTRUCTED from tasks/<id>/meta.json and
33
+ * git, when worktrees/<id>.pool.json did not survive a reclaim
34
+ * (M4-P19, M4-D-12). See the FROM-RECONSTRUCTED header below for what
35
+ * this flag does and, more importantly, what it does NOT do.
36
+ */
37
+ fromReconstructed: boolean;
31
38
  }
32
39
  export declare function teardownTask(fleet: Fleet, options: TeardownOptions): Promise<TeardownResult>;
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME } from "./commands/init.js";
4
- import { poolDestroy, readPoolRecord, worktreePath } from "./pool.js";
4
+ import { guardSharedRegister } from "./exclusion.js";
5
+ import { poolDestroy, readPoolRecord, reconstructPoolRecord, worktreePath, } from "./pool.js";
5
6
  import { checkHoldership, metaPath, readTaskMeta, reportPath, runStep, setTaskStatus, singleLine, } from "./task.js";
6
7
  /**
7
8
  * Local git runner. It is deliberately not the pool's: src/pool.ts is
@@ -80,7 +81,7 @@ export function landedness(contextDir, branchRef, defaultRef) {
80
81
  * Read-only with respect to the task: the fetch updates only the
81
82
  * project's remote-tracking ref.
82
83
  */
83
- function resolveContext(fleet, taskId) {
84
+ function resolveContext(fleet, taskId, fromReconstructed) {
84
85
  const meta = readTaskMeta(fleet, taskId);
85
86
  if (meta === undefined) {
86
87
  return {
@@ -88,13 +89,45 @@ function resolveContext(fleet, taskId) {
88
89
  reason: `no readable task meta for task id ${taskId}; teardown needs tasks/${taskId}/meta.json`,
89
90
  };
90
91
  }
91
- const record = readPoolRecord(fleet, taskId);
92
+ let record = readPoolRecord(fleet, taskId);
93
+ let reconstructed = false;
92
94
  if (record === undefined) {
93
- return {
94
- ok: false,
95
- reason: `no readable pool record for task id ${taskId}; teardown needs it for the ` +
96
- `project remote and default branch, and refuses rather than guessing them`,
97
- };
95
+ if (!fromReconstructed) {
96
+ return {
97
+ ok: false,
98
+ reason: `no readable pool record for task id ${taskId}; teardown needs it for the ` +
99
+ `project remote and default branch, and refuses rather than guessing them; ` +
100
+ `pass --from-reconstructed to rebuild them from tasks/${taskId}/meta.json ` +
101
+ `and git, which keeps every other refusal in force`,
102
+ };
103
+ }
104
+ // NETWORK ALLOWED here and nowhere else in this kernel's
105
+ // reconstruction (M4-P19 fix round). Teardown is a command the
106
+ // operator invoked in order to destroy something, it is about to
107
+ // fetch from this remote on the next line regardless, and it is
108
+ // allowed to take as long as that fetch takes. `pool list` and
109
+ // doctor are not, and they pass `{ network: false }`.
110
+ const rebuilt = reconstructPoolRecord(fleet, taskId, { network: true });
111
+ if (rebuilt.kind === "absent") {
112
+ return {
113
+ ok: false,
114
+ reason: `cannot reconstruct the pool record for task id ${taskId}: ${rebuilt.reason}`,
115
+ };
116
+ }
117
+ if (rebuilt.kind === "incomplete") {
118
+ // The missing FIELD is named, not merely the failure, because the
119
+ // remedy differs per field: `remote` is a git configuration repair
120
+ // in the clone, `branch` is usually an origin/HEAD that was never
121
+ // set or a remote that cannot be reached to advertise it.
122
+ return {
123
+ ok: false,
124
+ reason: `cannot reconstruct the pool record for task id ${taskId}: unresolved ` +
125
+ `field(s) ${rebuilt.unresolved.join(", ")} (${rebuilt.detail}); teardown ` +
126
+ `refuses rather than guessing them`,
127
+ };
128
+ }
129
+ record = rebuilt.record;
130
+ reconstructed = true;
98
131
  }
99
132
  const worktree = worktreePath(fleet, taskId);
100
133
  const defaultRef = `refs/remotes/${record.remote}/${record.branch}`;
@@ -111,7 +144,7 @@ function resolveContext(fleet, taskId) {
111
144
  `be judged against fresh remote state: ${singleLine(fetched.stderr)}`,
112
145
  };
113
146
  }
114
- return { ok: true, value: { meta, record, worktree, defaultRef } };
147
+ return { ok: true, value: { meta, record, reconstructed, worktree, defaultRef } };
115
148
  }
116
149
  /** Uncommitted changes or untracked files in the task worktree. */
117
150
  function worktreeDirty(worktree) {
@@ -171,6 +204,11 @@ async function finish(fleet, context, options) {
171
204
  taskId: context.meta.id,
172
205
  discard: options.discard,
173
206
  deleteBranchForce: options.deleteBranchForce,
207
+ // M4-P19: in memory only, and only when the record was rebuilt. This
208
+ // makes the destroy's own base-sha gate ABLE to judge instead of
209
+ // abstaining with its "pool record missing" refusal; it does not
210
+ // create a file and it does not relax a gate.
211
+ ...(context.reconstructed ? { reconstructed: context.record } : {}),
174
212
  });
175
213
  if (!destroyed.ok) {
176
214
  // The destroy's own reason distinguishes a stage-2 refusal (a true
@@ -213,13 +251,69 @@ export async function teardownTask(fleet, options) {
213
251
  if (!holdership.ok) {
214
252
  return { ok: false, reason: holdership.reason };
215
253
  }
216
- const resolved = resolveContext(fleet, options.taskId);
254
+ /* THE CROSS-ENVIRONMENT HALF (M4-P22 criterion 3), rule 0's second limb.
255
+ `checkHoldership` above returns OK when this environment holds the local
256
+ lease, and the local lease is evidence about this filesystem alone
257
+ (src/lock.ts:63). With the shared register naming another environment,
258
+ the old guard is green and teardown would remove a worktree the other
259
+ orchestrator is working in. One reason line, nonzero exit, and NOTHING
260
+ REMOVED: it runs before `resolveContext`, so no pool record is read, no
261
+ worktree is probed and no branch is deleted. A teardown that refuses
262
+ after deleting something has failed in the way that matters. */
263
+ const sharedGuard = guardSharedRegister(fleet.root, "teardown");
264
+ if (sharedGuard.kind === "refused") {
265
+ return { ok: false, reason: sharedGuard.reason };
266
+ }
267
+ const resolved = resolveContext(fleet, options.taskId, options.fromReconstructed);
217
268
  if (!resolved.ok) {
218
269
  return resolved;
219
270
  }
220
271
  const context = resolved.value;
221
272
  const { meta, record, worktree } = context;
222
273
  if (meta.shape === "scout") {
274
+ // M4-P19 FIX ROUND, finding F-5. THE RECONSTRUCTED PATH IS STRICTER
275
+ // THAN THE WITH-RECORD PATH FOR A SCOUT, DELIBERATELY.
276
+ //
277
+ // The scout arm below discards a dirty scratch tree by design
278
+ // (PR-010: a scout is judged by its report and never pushes), and it
279
+ // reaches `finish` without ever probing cleanliness. That was
280
+ // reachable only by an operator who had the pool record in front of
281
+ // them. This phase made it reachable from a RECLAIM, where the
282
+ // record is the one thing that did not survive, and measured at head
283
+ // abde402: `teardown --task s1 --from-reconstructed` against a scout
284
+ // worktree holding ` M readme.md` and `?? important.md` exited 0 and
285
+ // removed the worktree, where the same fixture on the phase base
286
+ // exited 1 and left it standing. Plan criterion 4 states the refusal
287
+ // with no shape qualifier, so the plan is what is followed here.
288
+ //
289
+ // WHY NOT INSTEAD MAKE THE WITH-RECORD SCOUT PATH REFUSE TOO. That
290
+ // is a change to a decided scout policy (PR-010) which this phase
291
+ // does not own and which the plan does not ask for. The asymmetry is
292
+ // therefore REAL and is stated rather than smoothed over: the
293
+ // difference in force is the difference in what the operator knows.
294
+ // With the record present they are tearing down a scout they are
295
+ // tracking; arriving here from a reclaim they are recovering a fleet
296
+ // whose bookkeeping is already known to be incomplete, and the
297
+ // leavings in that tree may be the only copy.
298
+ //
299
+ // --salvage is NOT the escape, because salvage pushes (PR-010: a
300
+ // scout never pushes), so the remedy named is the one that works.
301
+ if (context.reconstructed) {
302
+ const scoutDirty = worktreeDirty(worktree);
303
+ if (!scoutDirty.ok) {
304
+ return { ok: false, reason: scoutDirty.reason };
305
+ }
306
+ if (scoutDirty.dirty) {
307
+ return {
308
+ ok: false,
309
+ reason: `scout worktree ${worktree} has uncommitted changes or untracked ` +
310
+ `files and its pool record did not survive, so teardown is running ` +
311
+ `on a reconstruction and refuses to discard them: copy anything ` +
312
+ `worth keeping out of ${worktree}, then re-run once ` +
313
+ `"git -C ${worktree} status --porcelain" is empty`,
314
+ };
315
+ }
316
+ }
223
317
  // (a) A scout is judged by its report, never by its scratch tree.
224
318
  if (!existsSync(reportPath(fleet, options.taskId))) {
225
319
  return {
@@ -287,10 +381,24 @@ export async function teardownTask(fleet, options) {
287
381
  };
288
382
  }
289
383
  if (landed.kind === "unlanded") {
384
+ // M4-P19 criterion 5: the refusal NAMES THE TIP. That sha is the
385
+ // operator's recovery handle, exactly as the deleted-branch sha is on
386
+ // the success path (V-1), and it is the one fact that makes this
387
+ // refusal actionable: it says which commit is at risk, not merely
388
+ // that something is. It is named on every unlanded refusal rather
389
+ // than only the reconstructed one, so there is one message and not
390
+ // two that can drift apart.
391
+ const tip = runGit(record.project, [
392
+ "rev-parse",
393
+ "--verify",
394
+ "--quiet",
395
+ `${branchRef}^{commit}`,
396
+ ]);
397
+ const tipSha = tip.status === 0 ? tip.stdout.trim() : "unresolvable";
290
398
  return {
291
399
  ok: false,
292
- reason: `branch ${record.branchName} is not landed on ${record.remote}/${record.branch}; ` +
293
- `land it before tearing the task down` +
400
+ reason: `branch ${record.branchName} (tip ${tipSha}) is not landed on ` +
401
+ `${record.remote}/${record.branch}; land it before tearing the task down` +
294
402
  (options.salvage ? " (--salvage rescues leavings, it never lands work)" : ""),
295
403
  };
296
404
  }
@@ -23,11 +23,22 @@
23
23
  * deliberately identical: the two modules now share this one engine.
24
24
  *
25
25
  * THE MESSAGE TABLE IS THE CONTRACT, not Ajv's `message` field. A keyword
26
- * whose Ajv error reaches `renderAjvError` without an entry in
27
- * `MESSAGE_BY_KEYWORD` is a Tiphys DEFECT and is reported as one, naming the
28
- * keyword, rather than being papered over with Ajv's own sentence. That
29
- * refusal is what stops Ajv wording leaking into a public contract by
30
- * omission (DR-0013 criterion 8).
26
+ * whose Ajv error reaches `renderAjvError` and matches no `case` of that
27
+ * function's switch falls to its `default:` arm, which is a Tiphys DEFECT and
28
+ * is reported as one, naming the keyword, rather than being papered over with
29
+ * Ajv's own sentence. That refusal is what stops Ajv wording leaking into a
30
+ * public contract by omission (DR-0013 criterion 8).
31
+ *
32
+ * THIS PARAGRAPH NAMED THE WRONG MECHANISM UNTIL THE DR-0047 SWEEP (CR-VS-004).
33
+ * It said the refusal came from a `MESSAGE_BY_KEYWORD` set. That set existed,
34
+ * nothing ever read it (three references: its own declaration and two comments
35
+ * saying it was the contract), and its contents disagreed with
36
+ * `AUTHORING_VOCABULARY` in BOTH directions: `$ref`, `items`, `properties` and
37
+ * `then` were in the vocabulary and not in the set, `minimum`, `maximum` and
38
+ * `maxItems` were in the set and not in the vocabulary. A dead constant that a
39
+ * file's own documentation calls the contract is how the next reader edits the
40
+ * thing that does nothing, so it is deleted and this sentence now names the
41
+ * switch, which is what actually refuses.
31
42
  *
32
43
  * YAML IS INPUT DECODING AND IS A SEPARATE STAGE (DR-0013 YAML clause 3).
33
44
  * `decodeDocument` decodes; `validateInstance` validates an already-decoded
@@ -50,12 +61,34 @@ export interface Diagnostic {
50
61
  message: string;
51
62
  }
52
63
  /**
53
- * THE DECLARED AUTHORING VOCABULARY (DR-0013 clause 7), documented in
54
- * `schemas/README.md` and asserted by `test/schemas.test.ts`. Ajv supplies
55
- * Draft 2020-12 entire; this list is what Tiphys schemas are ALLOWED to use,
56
- * so a keyword outside it is a deliberate expansion rather than an accident.
57
- * Every entry has both a positive and a negative test (validator criterion 2)
58
- * and therefore also has an entry in `MESSAGE_BY_KEYWORD`.
64
+ * THE DECLARED AUTHORING VOCABULARY (DR-0013 clause 7). THIS LIST IS THE SOURCE
65
+ * OF TRUTH. Ajv supplies Draft 2020-12 entire; this is what Tiphys schemas are
66
+ * ALLOWED to use, so a keyword outside it is a deliberate expansion rather than
67
+ * an accident. Every entry has both a positive and a negative test (validator
68
+ * criterion 2), asserted by `test/schemas.test.ts`, which DERIVES its cases
69
+ * from this array rather than from a hand-written list.
70
+ *
71
+ * `schemas/README.md` RENDERS THIS LIST FOR A HUMAN READER AND IS NOT ITSELF
72
+ * THE CONTRACT, and the distinction was paid for (CR-VS-004, FIND-02 of the
73
+ * DR-0047 sweep). The sentence here used to say the vocabulary IS "documented
74
+ * in `schemas/README.md`", which reads as a guarantee that the two agree.
75
+ * Measured at the swept head they did not: this array holds sixteen keywords
76
+ * and that document's table declared fifteen, `uniqueItems` being the missing
77
+ * row, since the day M3-P1 wrote both. Nothing asserted the relation, so no gate
78
+ * could see it.
79
+ *
80
+ * THE DRIFT IS NOW CLOSED, AND BY THE TEST RATHER THAN BY THE ROW (DR-0047
81
+ * sweep round 2). The row was added to `schemas/README.md`, which closes the one
82
+ * instance; what closes the MECHANISM is that test/schemas.test.ts:340 parses
83
+ * that document's keyword column and compares it to this array, so the two
84
+ * halves can no longer disagree silently. The document still does not DECIDE
85
+ * anything: this array is the contract and that one renders it, which is why
86
+ * the comparison is written as an assertion about the document rather than as a
87
+ * generator.
88
+ *
89
+ * AND THE SECOND HALF OF THAT SENTENCE IS GONE. It said every entry "therefore
90
+ * also has an entry in `MESSAGE_BY_KEYWORD`", which was false in both
91
+ * directions and about a constant nothing read; see the module header.
59
92
  */
60
93
  export declare const AUTHORING_VOCABULARY: readonly string[];
61
94
  /** Annotations that carry no constraint and are permitted everywhere. */
@@ -23,11 +23,22 @@
23
23
  * deliberately identical: the two modules now share this one engine.
24
24
  *
25
25
  * THE MESSAGE TABLE IS THE CONTRACT, not Ajv's `message` field. A keyword
26
- * whose Ajv error reaches `renderAjvError` without an entry in
27
- * `MESSAGE_BY_KEYWORD` is a Tiphys DEFECT and is reported as one, naming the
28
- * keyword, rather than being papered over with Ajv's own sentence. That
29
- * refusal is what stops Ajv wording leaking into a public contract by
30
- * omission (DR-0013 criterion 8).
26
+ * whose Ajv error reaches `renderAjvError` and matches no `case` of that
27
+ * function's switch falls to its `default:` arm, which is a Tiphys DEFECT and
28
+ * is reported as one, naming the keyword, rather than being papered over with
29
+ * Ajv's own sentence. That refusal is what stops Ajv wording leaking into a
30
+ * public contract by omission (DR-0013 criterion 8).
31
+ *
32
+ * THIS PARAGRAPH NAMED THE WRONG MECHANISM UNTIL THE DR-0047 SWEEP (CR-VS-004).
33
+ * It said the refusal came from a `MESSAGE_BY_KEYWORD` set. That set existed,
34
+ * nothing ever read it (three references: its own declaration and two comments
35
+ * saying it was the contract), and its contents disagreed with
36
+ * `AUTHORING_VOCABULARY` in BOTH directions: `$ref`, `items`, `properties` and
37
+ * `then` were in the vocabulary and not in the set, `minimum`, `maximum` and
38
+ * `maxItems` were in the set and not in the vocabulary. A dead constant that a
39
+ * file's own documentation calls the contract is how the next reader edits the
40
+ * thing that does nothing, so it is deleted and this sentence now names the
41
+ * switch, which is what actually refuses.
31
42
  *
32
43
  * YAML IS INPUT DECODING AND IS A SEPARATE STAGE (DR-0013 YAML clause 3).
33
44
  * `decodeDocument` decodes; `validateInstance` validates an already-decoded
@@ -76,12 +87,34 @@ export const TIPHYS_DIALECT = "https://json-schema.org/draft/2020-12/schema";
76
87
  /** The document root, in RFC 6901 URI-fragment form. */
77
88
  export const ROOT_POINTER = "#";
78
89
  /**
79
- * THE DECLARED AUTHORING VOCABULARY (DR-0013 clause 7), documented in
80
- * `schemas/README.md` and asserted by `test/schemas.test.ts`. Ajv supplies
81
- * Draft 2020-12 entire; this list is what Tiphys schemas are ALLOWED to use,
82
- * so a keyword outside it is a deliberate expansion rather than an accident.
83
- * Every entry has both a positive and a negative test (validator criterion 2)
84
- * and therefore also has an entry in `MESSAGE_BY_KEYWORD`.
90
+ * THE DECLARED AUTHORING VOCABULARY (DR-0013 clause 7). THIS LIST IS THE SOURCE
91
+ * OF TRUTH. Ajv supplies Draft 2020-12 entire; this is what Tiphys schemas are
92
+ * ALLOWED to use, so a keyword outside it is a deliberate expansion rather than
93
+ * an accident. Every entry has both a positive and a negative test (validator
94
+ * criterion 2), asserted by `test/schemas.test.ts`, which DERIVES its cases
95
+ * from this array rather than from a hand-written list.
96
+ *
97
+ * `schemas/README.md` RENDERS THIS LIST FOR A HUMAN READER AND IS NOT ITSELF
98
+ * THE CONTRACT, and the distinction was paid for (CR-VS-004, FIND-02 of the
99
+ * DR-0047 sweep). The sentence here used to say the vocabulary IS "documented
100
+ * in `schemas/README.md`", which reads as a guarantee that the two agree.
101
+ * Measured at the swept head they did not: this array holds sixteen keywords
102
+ * and that document's table declared fifteen, `uniqueItems` being the missing
103
+ * row, since the day M3-P1 wrote both. Nothing asserted the relation, so no gate
104
+ * could see it.
105
+ *
106
+ * THE DRIFT IS NOW CLOSED, AND BY THE TEST RATHER THAN BY THE ROW (DR-0047
107
+ * sweep round 2). The row was added to `schemas/README.md`, which closes the one
108
+ * instance; what closes the MECHANISM is that test/schemas.test.ts:340 parses
109
+ * that document's keyword column and compares it to this array, so the two
110
+ * halves can no longer disagree silently. The document still does not DECIDE
111
+ * anything: this array is the contract and that one renders it, which is why
112
+ * the comparison is written as an assertion about the document rather than as a
113
+ * generator.
114
+ *
115
+ * AND THE SECOND HALF OF THAT SENTENCE IS GONE. It said every entry "therefore
116
+ * also has an entry in `MESSAGE_BY_KEYWORD`", which was false in both
117
+ * directions and about a constant nothing read; see the module header.
85
118
  */
86
119
  export const AUTHORING_VOCABULARY = [
87
120
  "$ref",
@@ -252,29 +285,6 @@ function valueAt(instance, instancePath) {
252
285
  }
253
286
  return node;
254
287
  }
255
- /**
256
- * Ajv keywords this engine translates. A keyword absent from this map is a
257
- * defect and says so: see the module header. The map's KEY SET is also the
258
- * mechanical half of "every keyword in the declared vocabulary has a
259
- * diagnostic", asserted in `test/schemas.test.ts`.
260
- */
261
- const MESSAGE_BY_KEYWORD = new Set([
262
- "type",
263
- "required",
264
- "additionalProperties",
265
- "enum",
266
- "const",
267
- "minimum",
268
- "maximum",
269
- "minItems",
270
- "maxItems",
271
- "minLength",
272
- "pattern",
273
- "uniqueItems",
274
- "contains",
275
- "oneOf",
276
- "if",
277
- ]);
278
288
  /**
279
289
  * Translate ONE Ajv error into the Tiphys contract. `undefined` means the
280
290
  * error carries no independent information for a reader (Ajv reports a