@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Versioned, user-scoped, atomically-written checkpoint store for the
3
+ * `implement-epic` CLI (BAPI-803).
4
+ *
5
+ * The checkpoint is the loop's only durable memory. `/conduct-epic` (BAPI-804)
6
+ * derives every decision from it plus a freshly probed world, so three
7
+ * properties are load-bearing and the rest of this module exists to hold them:
8
+ *
9
+ * - **A corrupt checkpoint is a hard error, never a fresh start.** Re-creating
10
+ * an unreadable file would silently discard the record of which tickets are
11
+ * merged and how many attempts they have consumed, and the loop would redo
12
+ * work it already paid for. Only `init` may create a checkpoint, and only when
13
+ * none exists.
14
+ * - **A write is all-or-nothing.** The complete next document is validated
15
+ * BEFORE a single byte is written, then written to a same-directory temporary
16
+ * file and `rename`d over the primary. A crash mid-write leaves the previous
17
+ * valid document, never a truncated one — and the previous document is also
18
+ * retained at `<EPIC>.json.prev` so an operator has something to fall back to.
19
+ * - **It lives outside the repository.** `~/.config/bridge/conduct/<repo>/`
20
+ * (honoring `XDG_CONFIG_HOME`, mode `0700`, files `0600`) resolves identically
21
+ * from the main checkout and from every worktree, needs no `.gitignore` rule,
22
+ * and cannot be committed by an agent running `git add`.
23
+ *
24
+ * The directory derivation is COPIED from `credential-store.ts`'s
25
+ * `getPrimaryCredentialStorePath` rather than imported, because it is driven
26
+ * here by injected `env`/`homedir` seams so tests can exercise both the XDG and
27
+ * home-relative branches without touching a real home directory.
28
+ */
29
+ import path from "node:path";
30
+ /** The only checkpoint schema version this module reads or writes. */
31
+ export const CONDUCT_EPIC_CHECKPOINT_VERSION = 1;
32
+ /** Default soft deadline (seconds) written at `init`; operator-editable. */
33
+ export const CONDUCT_EPIC_DEFAULT_SOFT_SECONDS = 3600;
34
+ /** Default hard deadline (seconds) written at `init`; operator-editable. */
35
+ export const CONDUCT_EPIC_DEFAULT_HARD_SECONDS = 10800;
36
+ /** Retained journal lines per ticket. The oldest are dropped first. */
37
+ export const CONDUCT_EPIC_MAX_JOURNAL_LINES = 50;
38
+ /**
39
+ * BAPI-915 — how many verdictless review observations, at ONE head, the pilot
40
+ * makes before it commits a terminal disposition.
41
+ *
42
+ * A fixed CONSTANT, deliberately: not a policy key, not an operator-editable
43
+ * checkpoint field, and not a `deadlines` entry. The pilot has no `RunPolicy`
44
+ * — that is the whole reason v2's `review_policy.verdictless_disposition` is
45
+ * unreachable from here — and R14 rule 2's spirit is that a new knob needs a
46
+ * declared home. This has none, so it is not a knob. What IS operator-settable
47
+ * is the disposition itself, on the `review_state` done-gate condition, which
48
+ * already has a declared home that both the pilot and the merge path read.
49
+ *
50
+ * Six observations at the documented five-minute tick cadence is roughly thirty
51
+ * minutes — comfortably inside `deadlines.hard_seconds` (10800), so the ceiling
52
+ * is what an operator actually sees rather than a three-hour `stalled`.
53
+ *
54
+ * It is EXPORTED because `cli.ts` publishes it in the status snapshot: the
55
+ * command compares two numbers it read from that snapshot instead of carrying a
56
+ * bound in prose, matching "every value is absolute, computed from the Stage 2
57
+ * snapshot".
58
+ */
59
+ export const CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING = 6;
60
+ /** The closed per-ticket status vocabulary. */
61
+ export const CONDUCT_EPIC_TICKET_STATUSES = [
62
+ "pending",
63
+ "in_progress",
64
+ "merged",
65
+ "done",
66
+ "needs_human",
67
+ ];
68
+ /** Monotonic suffix source for atomic temp files (avoids same-tick collisions). */
69
+ let atomicWriteCounter = 0;
70
+ /**
71
+ * `~/.config/bridge/conduct/<repo>/` — the per-repository implement-epic state
72
+ * directory. Copies `getPrimaryCredentialStorePath`'s two-branch derivation so
73
+ * conduct state sits beside the credential store and honors `XDG_CONFIG_HOME`
74
+ * identically.
75
+ */
76
+ export function resolveConductEpicStateDirectory(repoName, deps) {
77
+ const xdg = deps.env.XDG_CONFIG_HOME;
78
+ const bridgeDir = xdg && xdg.trim().length > 0
79
+ ? path.join(xdg, "bridge")
80
+ : path.join(deps.homedir(), ".config", "bridge");
81
+ return path.join(bridgeDir, "conduct", repoName);
82
+ }
83
+ /** `<state dir>/<EPIC>.json` — the default checkpoint path. */
84
+ export function resolveConductEpicCheckpointPath(repoName, epicKey, deps) {
85
+ return path.join(resolveConductEpicStateDirectory(repoName, deps), `${epicKey}.json`);
86
+ }
87
+ /**
88
+ * `<EPIC>.lock` beside the checkpoint. Derived from the RESOLVED checkpoint path
89
+ * rather than re-derived from the repo/epic, so `--checkpoint-path` moves the
90
+ * lock with it and two invocations pointed at one checkpoint always contend on
91
+ * one lock.
92
+ */
93
+ export function resolveConductEpicLockPath(checkpointPath) {
94
+ const dir = path.dirname(checkpointPath);
95
+ const base = path.basename(checkpointPath).replace(/\.json$/, "");
96
+ return path.join(dir, `${base}.lock`);
97
+ }
98
+ /** `<EPIC>.json.prev` — the retained previous document. */
99
+ export function resolveConductEpicBackupPath(checkpointPath) {
100
+ return `${checkpointPath}.prev`;
101
+ }
102
+ /** `<state dir>/<EPIC>/prompts/` — where the caller writes spawn prompt files. */
103
+ export function resolveConductEpicPromptsDirectory(repoName, epicKey, deps) {
104
+ return path.join(resolveConductEpicStateDirectory(repoName, deps), epicKey, "prompts");
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Validation
108
+ // ---------------------------------------------------------------------------
109
+ function isRecord(value) {
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ }
112
+ /**
113
+ * Supply defaults for per-ticket fields an older version-1 ticket does not
114
+ * carry, returning a NEW document rather than mutating the input.
115
+ *
116
+ * Two generations of additive fields go through here: the pre-BAPI-825
117
+ * parse-request pair, and the BAPI-915 verdictless-observation pair
118
+ * (`review_verdictless_observations` → `0`, `review_verdictless_for_sha` →
119
+ * `null`). Both were added without incrementing
120
+ * {@link CONDUCT_EPIC_CHECKPOINT_VERSION}, because bumping the version would
121
+ * make every checkpoint written by an in-flight pilot run instantly
122
+ * `unsupported-version` — a hard error whose documented recovery is a human
123
+ * editing the file. Normalizing an absent field to `null` is the compatible
124
+ * alternative: an old document stays valid, and every downstream TypeScript
125
+ * reader sees a real `null` instead of `undefined`.
126
+ *
127
+ * Only ABSENT keys are filled. A key that is present but malformed is left
128
+ * exactly as it is, so validation still rejects it rather than having it
129
+ * quietly repaired into a legal value. That distinction is the point: filling an
130
+ * absent key is reading an old document, while repairing a present bad one would
131
+ * be inventing state an operator never wrote and then acting on it.
132
+ */
133
+ export function normalizeConductEpicCheckpoint(value) {
134
+ if (!isRecord(value) || !Array.isArray(value.tickets))
135
+ return value;
136
+ return {
137
+ ...value,
138
+ // BAPI-844: a checkpoint written before the scope field existed reads as an
139
+ // unscoped epic rather than as invalid state.
140
+ index_scope_id: "index_scope_id" in value ? value.index_scope_id : null,
141
+ // BAPI-846: a checkpoint written before the lease field existed reads as
142
+ // "no lease held", not as invalid state.
143
+ index_scope_lease_epoch: "index_scope_lease_epoch" in value ? value.index_scope_lease_epoch : null,
144
+ tickets: value.tickets.map((ticket) => {
145
+ if (!isRecord(ticket))
146
+ return ticket;
147
+ const normalized = { ...ticket };
148
+ if (!("parse_requested_at" in normalized))
149
+ normalized.parse_requested_at = null;
150
+ if (!("parse_requested_for_sha" in normalized))
151
+ normalized.parse_requested_for_sha = null;
152
+ // BAPI-915: a checkpoint written before the verdictless ceiling existed
153
+ // reads as "nothing observed yet, bound to no head", not as invalid state.
154
+ if (!("review_verdictless_observations" in normalized)) {
155
+ normalized.review_verdictless_observations = 0;
156
+ }
157
+ if (!("review_verdictless_for_sha" in normalized)) {
158
+ normalized.review_verdictless_for_sha = null;
159
+ }
160
+ return normalized;
161
+ }),
162
+ };
163
+ }
164
+ /** A non-empty string. Blank-only values are rejected everywhere. */
165
+ function isText(value) {
166
+ return typeof value === "string" && value.trim().length > 0;
167
+ }
168
+ /** A non-negative safe integer. */
169
+ function isCount(value) {
170
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
171
+ }
172
+ /** A non-empty string or an explicit `null` — the shape every nullable field has. */
173
+ function isNullableText(value) {
174
+ return value === null || isText(value);
175
+ }
176
+ function fail(error) {
177
+ return { ok: false, error };
178
+ }
179
+ function validateCounters(value, where) {
180
+ if (!isRecord(value))
181
+ return fail(`${where}.counters must be an object`);
182
+ for (const key of ["sessions_spawned", "plan_generations_observed", "merge_attempts"]) {
183
+ if (!isCount(value[key])) {
184
+ return fail(`${where}.counters.${key} must be a non-negative integer`);
185
+ }
186
+ }
187
+ if (Object.keys(value).length !== 3) {
188
+ return fail(`${where}.counters carries unexpected fields`);
189
+ }
190
+ return { ok: true };
191
+ }
192
+ function validateTicket(value, index) {
193
+ const where = `tickets[${index}]`;
194
+ if (!isRecord(value))
195
+ return fail(`${where} must be an object`);
196
+ if (!isText(value.key))
197
+ return fail(`${where}.key must be a non-empty string`);
198
+ if (typeof value.status !== "string" ||
199
+ !CONDUCT_EPIC_TICKET_STATUSES.includes(value.status)) {
200
+ return fail(`${where}.status must be one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`);
201
+ }
202
+ if (!isNullableText(value.branch))
203
+ return fail(`${where}.branch must be a non-empty string or null`);
204
+ if (value.pr_number !== null && !(isCount(value.pr_number) && value.pr_number > 0)) {
205
+ return fail(`${where}.pr_number must be a positive integer or null`);
206
+ }
207
+ if (!isNullableText(value.last_seen_head)) {
208
+ return fail(`${where}.last_seen_head must be a non-empty string or null`);
209
+ }
210
+ if (!isNullableText(value.last_state_change_at)) {
211
+ return fail(`${where}.last_state_change_at must be a timestamp string or null`);
212
+ }
213
+ if (!isNullableText(value.spawned_at)) {
214
+ return fail(`${where}.spawned_at must be a timestamp string or null`);
215
+ }
216
+ if (!isCount(value.respawns))
217
+ return fail(`${where}.respawns must be a non-negative integer`);
218
+ if (!isCount(value.conflict_attempts)) {
219
+ return fail(`${where}.conflict_attempts must be a non-negative integer`);
220
+ }
221
+ const counters = validateCounters(value.counters, where);
222
+ if (!counters.ok)
223
+ return counters;
224
+ // Present-but-malformed is rejected; ABSENT is impossible here, because
225
+ // `normalizeConductEpicCheckpoint` runs before every validation and fills a
226
+ // missing parse-request field with `null` (BAPI-825/A2).
227
+ for (const key of ["parse_requested_at", "parse_requested_for_sha"]) {
228
+ if (!isNullableText(value[key])) {
229
+ return fail(`${where}.${key} must be a non-empty string or null`);
230
+ }
231
+ }
232
+ // BAPI-915: same posture — ABSENT is impossible here because
233
+ // `normalizeConductEpicCheckpoint` filled it, so anything reaching this check
234
+ // is present, and a present malformed value is REJECTED rather than repaired.
235
+ if (!isCount(value.review_verdictless_observations)) {
236
+ return fail(`${where}.review_verdictless_observations must be a non-negative integer`);
237
+ }
238
+ if (!isNullableText(value.review_verdictless_for_sha)) {
239
+ return fail(`${where}.review_verdictless_for_sha must be a non-empty string or null`);
240
+ }
241
+ if (!Array.isArray(value.journal) || value.journal.some((line) => typeof line !== "string")) {
242
+ return fail(`${where}.journal must be an array of strings`);
243
+ }
244
+ if (value.journal.length > CONDUCT_EPIC_MAX_JOURNAL_LINES) {
245
+ return fail(`${where}.journal must hold at most ${CONDUCT_EPIC_MAX_JOURNAL_LINES} entries`);
246
+ }
247
+ return { ok: true };
248
+ }
249
+ function validateCiLastPoll(value) {
250
+ if (value === null)
251
+ return { ok: true };
252
+ if (!isRecord(value))
253
+ return fail("ci_last_poll must be an object or null");
254
+ if (!isText(value.head_sha))
255
+ return fail("ci_last_poll.head_sha must be a non-empty string");
256
+ if (!Array.isArray(value.required) || value.required.some((n) => typeof n !== "string")) {
257
+ return fail("ci_last_poll.required must be an array of strings");
258
+ }
259
+ if (typeof value.results_fingerprint !== "string") {
260
+ return fail("ci_last_poll.results_fingerprint must be a string");
261
+ }
262
+ if (!isText(value.at))
263
+ return fail("ci_last_poll.at must be a timestamp string");
264
+ return { ok: true };
265
+ }
266
+ function validateNeedsHuman(value) {
267
+ if (value === null)
268
+ return { ok: true };
269
+ if (!isRecord(value))
270
+ return fail("needs_human must be an object or null");
271
+ for (const key of ["reason", "evidence", "at"]) {
272
+ if (typeof value[key] !== "string")
273
+ return fail(`needs_human.${key} must be a string`);
274
+ }
275
+ return { ok: true };
276
+ }
277
+ function validateLock(value) {
278
+ if (!isRecord(value))
279
+ return fail("lock must be an object");
280
+ if (!isCount(value.owner_pid))
281
+ return fail("lock.owner_pid must be a non-negative integer");
282
+ if (typeof value.host !== "string")
283
+ return fail("lock.host must be a string");
284
+ if (typeof value.acquired_at !== "string")
285
+ return fail("lock.acquired_at must be a string");
286
+ if (Object.keys(value).length !== 3)
287
+ return fail("lock carries unexpected fields");
288
+ return { ok: true };
289
+ }
290
+ /**
291
+ * Validate a complete candidate document against the version-1 schema.
292
+ *
293
+ * Deliberately exhaustive rather than a top-level presence check: the failure
294
+ * this guards against is a partially-hand-edited checkpoint (an operator
295
+ * unparking a run edits this file by hand) being accepted because its top-level
296
+ * keys still exist, and then driving the loop from a nonsense value.
297
+ *
298
+ * The candidate is passed through {@link normalizeConductEpicCheckpoint} first,
299
+ * so a version-1 document written before the parse-request fields existed
300
+ * validates unchanged. Normalization only fills ABSENT keys, so it can never
301
+ * launder a malformed present value past the rules below.
302
+ */
303
+ export function validateConductEpicCheckpoint(candidate) {
304
+ const value = normalizeConductEpicCheckpoint(candidate);
305
+ if (!isRecord(value))
306
+ return fail("checkpoint must be a JSON object");
307
+ if (value.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
308
+ return fail(`checkpoint version must be ${CONDUCT_EPIC_CHECKPOINT_VERSION}`);
309
+ }
310
+ for (const key of ["epic_key", "repo_name", "epic_branch", "base_branch_original"]) {
311
+ if (!isText(value[key]))
312
+ return fail(`${key} must be a non-empty string`);
313
+ }
314
+ for (const key of ["created_at", "updated_at"]) {
315
+ if (!isText(value[key]))
316
+ return fail(`${key} must be a timestamp string`);
317
+ }
318
+ if (!isNullableText(value.index_scope_id)) {
319
+ return fail("index_scope_id must be a non-empty string or null");
320
+ }
321
+ if (value.index_scope_lease_epoch !== null &&
322
+ (typeof value.index_scope_lease_epoch !== "number" ||
323
+ !Number.isInteger(value.index_scope_lease_epoch) ||
324
+ value.index_scope_lease_epoch < 0)) {
325
+ return fail("index_scope_lease_epoch must be a non-negative integer or null");
326
+ }
327
+ const deadlines = value.deadlines;
328
+ if (!isRecord(deadlines))
329
+ return fail("deadlines must be an object");
330
+ if (!isCount(deadlines.soft_seconds))
331
+ return fail("deadlines.soft_seconds must be a non-negative integer");
332
+ if (!isCount(deadlines.hard_seconds))
333
+ return fail("deadlines.hard_seconds must be a non-negative integer");
334
+ if (!Array.isArray(value.tickets) || value.tickets.length === 0) {
335
+ return fail("tickets must be a non-empty array");
336
+ }
337
+ const seen = new Set();
338
+ for (let i = 0; i < value.tickets.length; i += 1) {
339
+ const result = validateTicket(value.tickets[i], i);
340
+ if (!result.ok)
341
+ return result;
342
+ const key = value.tickets[i].key;
343
+ if (seen.has(key))
344
+ return fail(`tickets contains a duplicate key: ${key}`);
345
+ seen.add(key);
346
+ }
347
+ const counters = value.counters;
348
+ if (!isRecord(counters))
349
+ return fail("counters must be an object");
350
+ if (!isCount(counters.iterations))
351
+ return fail("counters.iterations must be a non-negative integer");
352
+ if (!isCount(counters.merges))
353
+ return fail("counters.merges must be a non-negative integer");
354
+ const needsHuman = validateNeedsHuman(value.needs_human);
355
+ if (!needsHuman.ok)
356
+ return needsHuman;
357
+ const ciLastPoll = validateCiLastPoll(value.ci_last_poll);
358
+ if (!ciLastPoll.ok)
359
+ return ciLastPoll;
360
+ const lock = validateLock(value.lock);
361
+ if (!lock.ok)
362
+ return lock;
363
+ return { ok: true };
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Read / create
367
+ // ---------------------------------------------------------------------------
368
+ function errorCode(err) {
369
+ const code = err?.code;
370
+ return typeof code === "string" ? code : undefined;
371
+ }
372
+ /**
373
+ * Read and validate the checkpoint at `checkpointPath`.
374
+ *
375
+ * `ENOENT` is the ONLY input that produces `missing`. An unreadable file
376
+ * (permissions, an I/O error) is `unreadable`, not `missing` — reporting
377
+ * "no checkpoint" for a file that exists but cannot be read would let `init`
378
+ * conclude the epic was never started and re-provision over live state.
379
+ */
380
+ export async function readConductEpicCheckpoint(checkpointPath, fs) {
381
+ let raw;
382
+ try {
383
+ raw = await fs.readFile(checkpointPath);
384
+ }
385
+ catch (err) {
386
+ if (errorCode(err) === "ENOENT")
387
+ return { kind: "missing" };
388
+ return { kind: "unreadable", error: `checkpoint at ${checkpointPath} could not be read` };
389
+ }
390
+ let parsed;
391
+ try {
392
+ parsed = JSON.parse(raw);
393
+ }
394
+ catch {
395
+ return { kind: "corrupt", error: `checkpoint at ${checkpointPath} is not valid JSON` };
396
+ }
397
+ // Version is checked before the schema so a future version reports as such
398
+ // rather than as a pile of unrelated field errors.
399
+ if (isRecord(parsed) && parsed.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
400
+ return {
401
+ kind: "unsupported-version",
402
+ error: `checkpoint at ${checkpointPath} has version ${JSON.stringify(parsed.version)}; ` +
403
+ `this CLI only understands version ${CONDUCT_EPIC_CHECKPOINT_VERSION}`,
404
+ };
405
+ }
406
+ const validation = validateConductEpicCheckpoint(parsed);
407
+ if (!validation.ok) {
408
+ return { kind: "invalid", error: `checkpoint at ${checkpointPath} is invalid: ${validation.error}` };
409
+ }
410
+ // Return the NORMALIZED document, not the parsed one. Validation normalizes a
411
+ // private copy, so handing back `parsed` would give every caller `undefined`
412
+ // where the interface promises `string | null` — and `undefined` is exactly
413
+ // the value a `!== null` causal check reads as "a request was recorded".
414
+ return {
415
+ kind: "ok",
416
+ checkpoint: normalizeConductEpicCheckpoint(parsed),
417
+ };
418
+ }
419
+ /** Build the initial document: every ticket `pending`, every counter zero. */
420
+ export function createInitialConductEpicCheckpoint(input) {
421
+ return {
422
+ version: CONDUCT_EPIC_CHECKPOINT_VERSION,
423
+ epic_key: input.epicKey,
424
+ repo_name: input.repoName,
425
+ epic_branch: input.epicBranch,
426
+ base_branch_original: input.baseBranchOriginal,
427
+ index_scope_id: input.indexScopeId ?? null,
428
+ index_scope_lease_epoch: input.indexScopeLeaseEpoch ?? null,
429
+ created_at: input.now,
430
+ updated_at: input.now,
431
+ deadlines: input.deadlines ?? {
432
+ soft_seconds: CONDUCT_EPIC_DEFAULT_SOFT_SECONDS,
433
+ hard_seconds: CONDUCT_EPIC_DEFAULT_HARD_SECONDS,
434
+ },
435
+ tickets: input.ticketKeys.map((key) => ({
436
+ key,
437
+ status: "pending",
438
+ branch: null,
439
+ pr_number: null,
440
+ last_seen_head: null,
441
+ last_state_change_at: null,
442
+ spawned_at: null,
443
+ respawns: 0,
444
+ conflict_attempts: 0,
445
+ counters: { sessions_spawned: 0, plan_generations_observed: 0, merge_attempts: 0 },
446
+ parse_requested_at: null,
447
+ parse_requested_for_sha: null,
448
+ review_verdictless_observations: 0,
449
+ review_verdictless_for_sha: null,
450
+ journal: [],
451
+ })),
452
+ counters: { iterations: 0, merges: 0 },
453
+ needs_human: null,
454
+ ci_last_poll: null,
455
+ lock: input.lock,
456
+ };
457
+ }
458
+ /**
459
+ * Append one journal line to `ticket`, keeping only the newest
460
+ * {@link CONDUCT_EPIC_MAX_JOURNAL_LINES}. Returns a new ticket object; the input
461
+ * is not mutated, so a caller that fails validation later cannot leave a
462
+ * half-updated document behind.
463
+ */
464
+ export function appendTicketJournal(ticket, line) {
465
+ const journal = [...ticket.journal, line];
466
+ return {
467
+ ...ticket,
468
+ journal: journal.length > CONDUCT_EPIC_MAX_JOURNAL_LINES
469
+ ? journal.slice(journal.length - CONDUCT_EPIC_MAX_JOURNAL_LINES)
470
+ : journal,
471
+ };
472
+ }
473
+ /**
474
+ * Validate, then write `checkpoint` to `checkpointPath` atomically.
475
+ *
476
+ * Order matters and is asserted by tests:
477
+ *
478
+ * 1. validate the COMPLETE next document — an invalid document never reaches
479
+ * the filesystem, so a rejected write cannot damage the existing state;
480
+ * 2. create the parent directory `0700`;
481
+ * 3. write `<path>.tmp-<pid>-<n>` `0600` in the SAME directory (a cross-device
482
+ * temp file would make step 5 a copy, which is not atomic);
483
+ * 4. copy the previous VALID primary to `<path>.prev`;
484
+ * 5. `rename` the temp file over the primary.
485
+ *
486
+ * On failure only the temporary artifact is removed. The pre-existing primary is
487
+ * never deleted, truncated, or recreated — including when it is itself corrupt,
488
+ * because "repair by overwriting" is exactly how the operator's record of a
489
+ * half-finished epic would disappear.
490
+ */
491
+ export async function writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, fs, options = {}) {
492
+ const validation = validateConductEpicCheckpoint(checkpoint);
493
+ if (!validation.ok) {
494
+ return { ok: false, error: `refusing to write an invalid checkpoint: ${validation.error}` };
495
+ }
496
+ const dir = path.dirname(checkpointPath);
497
+ try {
498
+ await fs.mkdir(dir, { recursive: true });
499
+ if (!options.skipChmod)
500
+ await fs.chmod(dir, 0o700);
501
+ }
502
+ catch {
503
+ return { ok: false, error: `could not prepare the checkpoint directory ${dir}` };
504
+ }
505
+ const content = `${JSON.stringify(checkpoint, null, 2)}\n`;
506
+ const tmpPath = path.join(dir, `${path.basename(checkpointPath)}.tmp-${process.pid}-${atomicWriteCounter++}`);
507
+ try {
508
+ await fs.writeFile(tmpPath, content, { mode: 0o600 });
509
+ if (!options.skipChmod)
510
+ await fs.chmod(tmpPath, 0o600);
511
+ // Retain the previous document — but only if it is a document worth
512
+ // retaining. Copying corrupt bytes over a good `.prev` would destroy the
513
+ // last recoverable state at precisely the moment it is needed.
514
+ let previous = null;
515
+ try {
516
+ previous = await fs.readFile(checkpointPath);
517
+ }
518
+ catch {
519
+ previous = null;
520
+ }
521
+ if (previous !== null) {
522
+ let previousIsValid = false;
523
+ try {
524
+ previousIsValid = validateConductEpicCheckpoint(JSON.parse(previous)).ok;
525
+ }
526
+ catch {
527
+ previousIsValid = false;
528
+ }
529
+ if (previousIsValid) {
530
+ await fs.writeFile(resolveConductEpicBackupPath(checkpointPath), previous, { mode: 0o600 });
531
+ if (!options.skipChmod)
532
+ await fs.chmod(resolveConductEpicBackupPath(checkpointPath), 0o600);
533
+ }
534
+ }
535
+ await fs.rename(tmpPath, checkpointPath);
536
+ return { ok: true, path: checkpointPath };
537
+ }
538
+ catch {
539
+ await fs.unlink(tmpPath).catch(() => undefined);
540
+ return { ok: false, error: `could not write the checkpoint at ${checkpointPath}` };
541
+ }
542
+ }