@codedology/code-forge 0.7.7 → 0.9.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 (69) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +68 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +390 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +45 -7
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +25 -5
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +19 -2
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +427 -41
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +324 -18
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/index.mjs +5 -4
  28. package/src/doctor/local.mjs +91 -3
  29. package/src/engines/sentinel.mjs +27 -6
  30. package/src/gates/detect.mjs +21 -8
  31. package/src/gates/expand.mjs +202 -0
  32. package/src/gates/owned-count.mjs +66 -0
  33. package/src/gates/run.mjs +49 -12
  34. package/src/install/wizard/answers.mjs +34 -5
  35. package/src/install/wizard/flags.mjs +8 -1
  36. package/src/install/wizard/project-file.mjs +2 -0
  37. package/src/install/wizard/run.mjs +6 -0
  38. package/src/install/wizard/steps.mjs +24 -4
  39. package/src/install/wizard/summary.mjs +13 -5
  40. package/src/ledger/export.mjs +1 -1
  41. package/src/ledger/outside-review.mjs +164 -0
  42. package/src/ledger/report.mjs +17 -5
  43. package/src/ledger/write.mjs +7 -1
  44. package/src/proof/export.mjs +93 -10
  45. package/src/proof/lock.mjs +28 -5
  46. package/src/proof/worktree.mjs +482 -0
  47. package/src/review/answers.mjs +445 -0
  48. package/src/review/deletion.mjs +106 -0
  49. package/src/review/engine.mjs +36 -2
  50. package/src/review/fixloop.mjs +25 -15
  51. package/src/review/gate-check.mjs +384 -83
  52. package/src/review/open-findings.mjs +59 -0
  53. package/src/review/reopen.mjs +258 -0
  54. package/src/review/stop-exits.mjs +15 -0
  55. package/src/state/block-end.mjs +290 -0
  56. package/src/state/block.mjs +103 -39
  57. package/src/state/brief-status.mjs +131 -0
  58. package/src/state/config-snapshot.mjs +51 -0
  59. package/src/state/registry.mjs +35 -4
  60. package/src/state/run.mjs +10 -1
  61. package/src/util/breadcrumbs.mjs +1 -0
  62. package/src/util/forbidden.mjs +46 -1
  63. package/src/util/gate-token.mjs +8 -0
  64. package/src/util/tmp.mjs +16 -4
  65. package/src/worker/engine.mjs +230 -24
  66. package/src/worker/loop.mjs +29 -8
  67. package/src/worker/queue.mjs +296 -15
  68. package/src/worker/review-state.mjs +346 -51
  69. package/src/worker/ticket.mjs +46 -6
@@ -9,10 +9,26 @@
9
9
  *
10
10
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
11
11
  * the MAC makes an edit visible anyway (T1/T2). Writes are atomic (temp file + rename).
12
+ *
13
+ * B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
14
+ * worktree still queues there; only the reviewed content is read in the block's tree.
15
+ *
16
+ * B71: an ANSWERS ticket (`kind: 'answers'`, from `review-file <answering file> --answers <ids>
17
+ * --of <source>`) asks the worker whether `by_file` resolves the open findings `answers` of
18
+ * `file` (the source). Its id adds `answers`, `by_file`, `by_hash` and the sorted ids to the
19
+ * normal ticket's parts, so it never collides with a review ticket of the same source content.
20
+ *
21
+ * B75: `review-file --fresh` drops a done marker it classified in place as re-runnable through
22
+ * `dropDoneIf` (an atomic claim by rename, then a byte compare), so two concurrent callers never
23
+ * both drop it. A counted marker the read saw is never moved; only a newer marker the worker wrote
24
+ * between that read and the claim is moved for a moment, then linked straight back (`changed`).
25
+ * B83: a claim that could not be linked back leaves a refused `fresh-restore-failed` marker in its
26
+ * place (or, when that write fails too, the claim itself, which `pendingTickets` treats as done),
27
+ * so a failed restore never hands the worker a ticket to run again; `clearDone` clears both.
12
28
  */
13
29
 
14
- import { randomBytes } from 'node:crypto';
15
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { createHash, randomBytes } from 'node:crypto';
31
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
32
  import path from 'node:path';
17
33
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
18
34
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -54,26 +70,121 @@ function readJSON(file) {
54
70
  }
55
71
 
56
72
  /**
57
- * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string}} Ticket
73
+ * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
74
+ * `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
75
+ * and the worker only checks it against the tree it takes from the run record (a ticket whose
76
+ * tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
58
77
  */
59
78
 
60
79
  /**
61
- * Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
62
- * content returns the same ticket and never rewrites it.
63
- * @param {{repoRoot: string, run: string, block: string, file: string, now?: Date}} opts
64
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string}}
80
+ * The review ticket for `file`'s CURRENT content: the one rule `enqueue` and `clearDone` (B72)
81
+ * share — the repo-relative path, the realpath'd tree (or null), the content hash taken under the
82
+ * tree when given (else under `repoRoot`) and the ticket id over all of them.
83
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
84
+ * @returns {{rel: string, root: string | null, content_hash: string, ticket: string}}
85
+ * @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
65
86
  */
66
- export function enqueue({ repoRoot, run, block, file, now = new Date() }) {
87
+ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
67
88
  const rel = assertRowPath(file);
68
- const content_hash = contentHash(repoRoot, rel);
69
- const ticket = ticketId({ run, block, file: rel, content_hash });
89
+ const root = tree === null ? null : realTree(tree);
90
+ const content_hash = contentHash(root ?? repoRoot, rel);
91
+ return { rel, root, content_hash, ticket: ticketId({ run, block, file: rel, content_hash, tree: root }) };
92
+ }
93
+
94
+ /**
95
+ * Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
96
+ * content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
97
+ * content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
98
+ * under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
99
+ * the same bytes differ, and the answer names the tree given here, never a stored value.
100
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
101
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
102
+ * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
103
+ */
104
+ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
105
+ const { rel, root, content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
70
106
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
107
+ const named = root === null ? {} : { tree: root };
71
108
  if (!existsSync(target)) {
72
109
  /** @type {Ticket} */
73
- const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString() };
110
+ const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
74
111
  writeAtomic(target, `${JSON.stringify(body)}\n`);
75
112
  }
76
- return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash };
113
+ return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
114
+ }
115
+
116
+ /** B71: the most finding ids one answers ticket may name. */
117
+ export const MAX_ANSWERS = 10;
118
+
119
+ /** B71: the finding-id shape (the one `block waive` and the review results use). */
120
+ const ANSWER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
121
+
122
+ /**
123
+ * B71: the id of an answers ticket — the normal ticket's parts (run, block, source, source hash,
124
+ * tree) plus `answers`, the answering file, its hash and the sorted ids, so it differs from the
125
+ * normal ticket of the same source content and from an answer of other ids or another file.
126
+ * @param {{run: string, block: string, file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string | null}} t
127
+ * @returns {string}
128
+ */
129
+ export function answersTicketId({ run, block, file, content_hash, by_file, by_hash, answers, tree = null }) {
130
+ const parts = ['answers', run, block, file, content_hash, by_file, by_hash, [...answers].sort().join(','), ...(tree === null ? [] : [tree])];
131
+ return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
132
+ }
133
+
134
+ /**
135
+ * B71: refuse an id list that is not 1..MAX_ANSWERS finding ids; returns them sorted and unique.
136
+ * @param {unknown} answers @returns {string[]}
137
+ * @throws {WorkerError} `usage`
138
+ */
139
+ export function assertAnswers(answers) {
140
+ if (!Array.isArray(answers) || answers.length === 0) throw new WorkerError('usage', '--answers needs at least one finding id');
141
+ if (answers.some((a) => typeof a !== 'string' || !ANSWER_ID.test(a))) throw new WorkerError('usage', 'a finding id is letters, digits, . _ : - (at most 64)');
142
+ const ids = [...new Set(/** @type {string[]} */ (answers))].sort();
143
+ if (ids.length > MAX_ANSWERS) throw new WorkerError('usage', `--answers takes at most ${MAX_ANSWERS} finding ids`);
144
+ return ids;
145
+ }
146
+
147
+ /**
148
+ * @typedef {Ticket & {kind: 'answers', by_file: string, by_hash: string, answers: string[]}} AnswersTicket
149
+ */
150
+
151
+ /**
152
+ * B71: enqueue an answers ticket: `byFile` answers the findings `answers` of `file` (both
153
+ * repo-root-relative). Both files are hashed now (under `tree` when given); idempotent like
154
+ * {@link enqueue}. The caller has already refused the same file and the id list.
155
+ * @param {{repoRoot: string, run: string, block: string, file: string, byFile: string, answers: string[], tree?: string | null, now?: Date}} opts
156
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, by_file: string, by_hash: string, answers: string[], tree?: string}}
157
+ * @throws {WorkerError} `bad-path`, `usage`
158
+ */
159
+ export function enqueueAnswers({ repoRoot, run, block, file, byFile, answers, tree = null, now = new Date() }) {
160
+ const rel = assertRowPath(file);
161
+ const by_file = assertRowPath(byFile);
162
+ const ids = assertAnswers(answers);
163
+ const root = tree === null ? null : realTree(tree);
164
+ const content_hash = contentHash(root ?? repoRoot, rel);
165
+ const by_hash = contentHash(root ?? repoRoot, by_file);
166
+ const ticket = answersTicketId({ run, block, file: rel, content_hash, by_file, by_hash, answers: ids, tree: root });
167
+ const target = path.join(queueDir(repoRoot), `${ticket}.json`);
168
+ const named = root === null ? {} : { tree: root };
169
+ if (!existsSync(target)) {
170
+ /** @type {AnswersTicket} */
171
+ const body = { ticket, kind: 'answers', run, block, file: rel, content_hash, by_file, by_hash, answers: ids, enqueued_at: now.toISOString(), ...named };
172
+ writeAtomic(target, `${JSON.stringify(body)}\n`);
173
+ }
174
+ return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, by_file, by_hash, answers: ids, ...named };
175
+ }
176
+
177
+ /**
178
+ * @param {string} tree @returns {string} `tree` realpath'd.
179
+ * @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
180
+ */
181
+ function realTree(tree) {
182
+ if (!path.isAbsolute(tree)) throw new WorkerError('bad-path', `the content tree ${tree} is not an absolute path`);
183
+ try {
184
+ return realpathSync(tree);
185
+ } catch {
186
+ throw new WorkerError('bad-path', `the content tree ${tree} cannot be resolved`);
187
+ }
77
188
  }
78
189
 
79
190
  /** @param {string} repoRoot @param {string} ticket */
@@ -81,9 +192,163 @@ export function isDone(repoRoot, ticket) {
81
192
  return existsSync(path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`));
82
193
  }
83
194
 
195
+ /** B83: a `dropDoneIf` claim of a done marker (`<ticket>.done.<pid>.<hex>.fresh`). */
196
+ const CLAIM = /^([0-9a-f]{24})\.done\.\d+\.[0-9a-f]{8}\.fresh$/;
197
+
198
+ /**
199
+ * B83: an fs error's code when it is a plain code word, else `unknown` (a message names the code
200
+ * only, never a path). @param {unknown} err @returns {string}
201
+ */
202
+ function codeOf(err) {
203
+ const code = /** @type {{code?: unknown}} */ (err)?.code;
204
+ return typeof code === 'string' && /^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(code) ? code : 'unknown';
205
+ }
206
+
207
+ /**
208
+ * B72 (`block reopen`, C11): drop the done marker of the review ticket for the file's CURRENT
209
+ * content, so the next `review-file` on it runs instead of returning the cached result. The ticket
210
+ * id comes from `resolveTicket`, the rule `enqueue` uses; no ticket is written. Synchronous.
211
+ * B83: it also removes the ticket's leftover `dropDoneIf` claims (`<ticket>.done.*.fresh`), which
212
+ * would otherwise keep the ticket off the worker for good; `removed` still names the marker only.
213
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null}} opts
214
+ * @returns {{ticket: string, content_hash: string, removed: boolean}}
215
+ * @throws {WorkerError} `bad-path` for a bad file path or a tree that cannot be resolved.
216
+ */
217
+ export function clearDone({ repoRoot, run, block, file, tree = null }) {
218
+ const { content_hash, ticket } = resolveTicket({ repoRoot, run, block, file, tree });
219
+ const marker = path.join(queueDir(repoRoot), `${ticket}.done`);
220
+ const removed = existsSync(marker);
221
+ rmSync(marker, { force: true });
222
+ let names;
223
+ try {
224
+ names = readdirSync(queueDir(repoRoot));
225
+ } catch {
226
+ names = [];
227
+ }
228
+ // accepted race: a claim a concurrent dropDoneIf still uses may go here, which can leave a refused
229
+ // fresh-restore-failed marker (rare, fail closed; a second `block reopen` clears it)
230
+ for (const name of names) if (CLAIM.exec(name)?.[1] === ticket) rmSync(path.join(queueDir(repoRoot), name), { force: true });
231
+ return { ticket, content_hash, removed };
232
+ }
233
+
234
+ /**
235
+ * B75 (`review-file --fresh`): drop the done marker of `ticket` only while it still holds `seen`,
236
+ * the bytes the caller already read IN PLACE and classified as re-runnable (a marker classified
237
+ * as counted, tampered or unknown is never passed here, so it is never moved). The queue has no
238
+ * lock file; the order is the guarantee:
239
+ * 1. the caller reads the marker in place and classifies it (a refusal touches nothing);
240
+ * 2. only a re-runnable marker is claimed by an atomic rename to a name of this process — of
241
+ * two concurrent callers only one takes it, the other gets `gone`;
242
+ * 3. the claim's bytes are compared with `seen`: equal, the claim is removed (`dropped`) and the
243
+ * caller enqueues; different (a newer result arrived), the marker is restored with
244
+ * `linkSync` only — on EEXIST a newer marker is there and wins, the claim is removed
245
+ * (`changed`); on any other link error `fresh-restore-failed` is thrown (below).
246
+ * B83, fail closed — no failure leaves a ticket the worker runs again by itself:
247
+ * - a link-back that fails with other than EEXIST writes a refused marker in place,
248
+ * `{ticket, status: 'refused', reason: 'fresh-restore-failed'}` (no-overwrite open `wx`, mode
249
+ * 0o600; it names no result, so `--fresh` counts it and refuses, and `block reopen` clears it).
250
+ * EEXIST on that write: a newer marker is in place. Any other write error keeps the claim; a
251
+ * claim (`<ticket>.done.<pid>.<hex>.fresh`) counts as done for `pendingTickets`, and
252
+ * `clearDone` (`block reopen`) removes it;
253
+ * - a claim that cannot be removed after a `dropped` compare is `fresh-claim-failed` (it would
254
+ * hold the re-queued ticket off the worker; `block reopen` clears it); after a good link-back
255
+ * the marker is back, so a claim that cannot be removed is left for `clearDone`.
256
+ * A crash between the drop and the caller's enqueue leaves a re-runnable ticket with no marker:
257
+ * harmless — its ticket file is pending, and the next `review-file` queues it anyway. Synchronous.
258
+ * @param {string} repoRoot @param {string} ticket @param {Buffer} seen
259
+ * @param {{link?: (from: string, to: string) => void, read?: (file: string) => Buffer, rename?: (from: string, to: string) => void, remove?: (file: string) => void, write?: (file: string, text: string) => void}} [io] - for
260
+ * tests (default `linkSync`, `readFileSync`, `renameSync`, `rmSync` with force, `writeFileSync`
261
+ * with flag `wx` and mode 0o600). A claim that cannot be read is restored the same way and
262
+ * refused `fresh-restore-failed`.
263
+ * @returns {'dropped' | 'gone' | 'changed'}
264
+ * @throws {WorkerError} `fresh-claim-failed` (the claim could not be taken, or not removed after
265
+ * the drop; nothing was queued) or `fresh-restore-failed` (the message says which state was left:
266
+ * put back, a newer marker, a refused marker, the claim kept, or nothing). A message names an
267
+ * error code, never a path.
268
+ */
269
+ export function dropDoneIf(repoRoot, ticket, seen, io = {}) {
270
+ const marker = path.join(queueDir(repoRoot), `${assertTicketId(ticket)}.done`);
271
+ const claim = `${marker}.${process.pid}.${randomBytes(4).toString('hex')}.fresh`;
272
+ const remove = io.remove ?? ((/** @type {string} */ file) => rmSync(file, { force: true }));
273
+ try {
274
+ (io.rename ?? renameSync)(marker, claim);
275
+ } catch (err) {
276
+ if (/** @type {{code?: unknown}} */ (err)?.code === 'ENOENT') return 'gone';
277
+ throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} could not be claimed (${codeOf(err)}); nothing was queued`);
278
+ }
279
+ /** @type {string | null} */
280
+ let readFailed = null;
281
+ /** @type {boolean} */
282
+ let same = false;
283
+ try {
284
+ same = (io.read ?? readFileSync)(claim).equals(seen);
285
+ } catch (err) {
286
+ readFailed = codeOf(err);
287
+ }
288
+ if (same) {
289
+ try {
290
+ remove(claim);
291
+ } catch (err) {
292
+ throw new WorkerError('fresh-claim-failed', `the done marker of ticket ${ticket} was dropped but its claim could not be removed (${codeOf(err)}); block reopen clears it; nothing was queued`);
293
+ }
294
+ return 'dropped';
295
+ }
296
+ /** @type {string | null} */
297
+ let linkFailed = null;
298
+ try {
299
+ (io.link ?? linkSync)(claim, marker);
300
+ } catch (err) {
301
+ linkFailed = codeOf(err);
302
+ }
303
+ const unread = `the claimed done marker of ticket ${ticket} could not be read (${readFailed})`;
304
+ if (linkFailed !== null && linkFailed !== 'EEXIST') {
305
+ const cause = readFailed !== null ? `${unread} and could not be restored (${linkFailed})` : `the done marker of ticket ${ticket} changed and could not be restored (${linkFailed})`;
306
+ throw new WorkerError('fresh-restore-failed', `${cause}; ${refuseInPlace(marker, claim, ticket, io, remove)}; nothing was queued`);
307
+ }
308
+ try {
309
+ remove(claim);
310
+ } catch {
311
+ // the marker is back (or a newer one won), so the ticket is done; `clearDone` removes the claim
312
+ }
313
+ if (readFailed !== null) throw new WorkerError('fresh-restore-failed', `${unread}; ${linkFailed === 'EEXIST' ? 'a newer marker is in place' : 'it was put back'}; nothing was queued`);
314
+ return 'changed';
315
+ }
316
+
317
+ /**
318
+ * B83: after a failed link-back, put a refused `fresh-restore-failed` marker where the done marker
319
+ * was (never over one), so the worker does not run the ticket; the claim is then removed when it
320
+ * can be (`clearDone` removes a leftover one). When the write fails the claim is kept instead.
321
+ * @param {string} marker @param {string} claim @param {string} ticket
322
+ * @param {{write?: (file: string, text: string) => void}} io @param {(file: string) => void} remove
323
+ * @returns {string} which state was left, for the message (codes only, no path).
324
+ */
325
+ function refuseInPlace(marker, claim, ticket, io, remove) {
326
+ const text = `${JSON.stringify({ ticket, status: 'refused', reason: 'fresh-restore-failed' })}\n`;
327
+ /** @type {string | null} */
328
+ let writeFailed = null;
329
+ try {
330
+ (io.write ?? ((/** @type {string} */ file, /** @type {string} */ body) => writeFileSync(file, body, { flag: 'wx', mode: 0o600 })))(marker, text);
331
+ } catch (err) {
332
+ writeFailed = codeOf(err);
333
+ }
334
+ if (writeFailed === null || writeFailed === 'EEXIST') {
335
+ try {
336
+ remove(claim);
337
+ } catch {
338
+ // the marker holds the ticket off the worker; `clearDone` removes the leftover claim
339
+ }
340
+ return writeFailed === null ? 'a refused fresh-restore-failed marker was written in its place, so the worker does not run the ticket' : 'a newer marker is in place';
341
+ }
342
+ // the claim holds the ticket off the worker (`pendingTickets`); a claim that is gone cannot
343
+ return existsSync(claim)
344
+ ? `the refused marker could not be written (${writeFailed}); its claim was kept, so the worker does not run the ticket; block reopen clears it`
345
+ : `the refused marker could not be written (${writeFailed}) and no claim exists`;
346
+ }
347
+
84
348
  /**
85
349
  * Tickets with no done marker, oldest first (enqueue time, then id). Unparseable files are
86
- * skipped here and refused by the worker when it reads them.
350
+ * skipped here and refused by the worker when it reads them. B83: a ticket whose marker is held
351
+ * by a `dropDoneIf` claim is not pending either (a claim kept after a failed restore fails closed).
87
352
  * @param {string} repoRoot
88
353
  * @returns {string[]} ticket ids
89
354
  */
@@ -94,8 +359,9 @@ export function pendingTickets(repoRoot) {
94
359
  } catch {
95
360
  return [];
96
361
  }
362
+ const claimed = new Set(names.map((n) => CLAIM.exec(n)?.[1]));
97
363
  const ids = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => n.slice(0, 24));
98
- const pending = ids.filter((id) => !names.includes(`${id}.done`));
364
+ const pending = ids.filter((id) => !names.includes(`${id}.done`) && !claimed.has(id));
99
365
  const at = (/** @type {string} */ id) => {
100
366
  const t = readJSON(path.join(queueDir(repoRoot), `${id}.json`));
101
367
  return typeof t?.enqueued_at === 'string' ? t.enqueued_at : '';
@@ -113,9 +379,24 @@ export function readTicket(repoRoot, id) {
113
379
  const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
114
380
  if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
115
381
  assertRowPath(t.file);
116
- if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string' || ticketId(t) !== id) {
382
+ if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
383
+ if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string') {
117
384
  throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
118
385
  }
386
+ if (t.kind !== undefined) {
387
+ // B71: the only other kind is an answers ticket; its id covers the answering file and the ids
388
+ if (t.kind !== 'answers') throw new WorkerError('bad-ticket', `ticket ${id} has an unknown kind`);
389
+ assertRowPath(t.by_file);
390
+ let ids;
391
+ try {
392
+ ids = assertAnswers(t.answers);
393
+ } catch {
394
+ throw new WorkerError('bad-ticket', `ticket ${id} names no valid finding ids`);
395
+ }
396
+ if (typeof t.by_hash !== 'string' || ids.join(',') !== t.answers.join(',') || answersTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
397
+ return /** @type {Ticket} */ (t);
398
+ }
399
+ if (ticketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
119
400
  return /** @type {Ticket} */ (t);
120
401
  }
121
402