dorfl 0.13.1 → 0.13.3

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 (56) hide show
  1. package/dist/claim-cas.d.ts.map +1 -1
  2. package/dist/claim-cas.js +39 -0
  3. package/dist/claim-cas.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +10 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/complete.d.ts.map +1 -1
  8. package/dist/complete.js +9 -2
  9. package/dist/complete.js.map +1 -1
  10. package/dist/cwd-section.d.ts +40 -0
  11. package/dist/cwd-section.d.ts.map +1 -1
  12. package/dist/cwd-section.js +105 -5
  13. package/dist/cwd-section.js.map +1 -1
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +56 -0
  16. package/dist/format.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/item-lock.d.ts +167 -0
  22. package/dist/item-lock.d.ts.map +1 -1
  23. package/dist/item-lock.js +283 -2
  24. package/dist/item-lock.js.map +1 -1
  25. package/dist/needs-attention.d.ts +153 -1
  26. package/dist/needs-attention.d.ts.map +1 -1
  27. package/dist/needs-attention.js +399 -3
  28. package/dist/needs-attention.js.map +1 -1
  29. package/dist/reconcile-terminal.d.ts +97 -0
  30. package/dist/reconcile-terminal.d.ts.map +1 -0
  31. package/dist/reconcile-terminal.js +88 -0
  32. package/dist/reconcile-terminal.js.map +1 -0
  33. package/dist/scan.d.ts +17 -0
  34. package/dist/scan.d.ts.map +1 -1
  35. package/dist/scan.js +51 -2
  36. package/dist/scan.js.map +1 -1
  37. package/dist/status.d.ts +28 -0
  38. package/dist/status.d.ts.map +1 -1
  39. package/dist/status.js +79 -2
  40. package/dist/status.js.map +1 -1
  41. package/dist/tasking.d.ts.map +1 -1
  42. package/dist/tasking.js +52 -3
  43. package/dist/tasking.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/claim-cas.ts +40 -0
  46. package/src/cli.ts +18 -1
  47. package/src/complete.ts +9 -2
  48. package/src/cwd-section.ts +151 -4
  49. package/src/format.ts +72 -0
  50. package/src/index.ts +2 -0
  51. package/src/item-lock.ts +398 -2
  52. package/src/needs-attention.ts +515 -1
  53. package/src/reconcile-terminal.ts +180 -0
  54. package/src/scan.ts +82 -5
  55. package/src/status.ts +131 -6
  56. package/src/tasking.ts +52 -3
@@ -19,6 +19,7 @@ import {
19
19
  readItemLock,
20
20
  itemLockRef,
21
21
  lockEntryFor,
22
+ refreshMainRef,
22
23
  parseLockEntry,
23
24
  type LockEntry,
24
25
  } from './item-lock.js';
@@ -33,6 +34,7 @@ import {
33
34
  resolveSidecarIdentity,
34
35
  serialiseSidecar,
35
36
  sidecarPathFor,
37
+ sidecarPathCandidates,
36
38
  type NewQuestion,
37
39
  type SidecarType,
38
40
  } from './sidecar.js';
@@ -675,13 +677,53 @@ export async function returnToBacklog(
675
677
  held = await readLocalItemLock(slug, cwd, env);
676
678
  }
677
679
  if (!held) {
680
+ // CROSS-NAMESPACE HINT (observation
681
+ // `crashed-do-spec-strands-a-tasking-lock-no-verb-releases`). `requeue` is a
682
+ // TASK-only verb, so a bare `<slug>` resolves to `task:<slug>` and finds no
683
+ // lock when what is ACTUALLY stranded is the SPEC lock a crashed `do
684
+ // spec:<slug>` left behind. The blunt "wrong slug, or already at rest?"
685
+ // refusal then actively MISLEADS: it asserts nothing is held while
686
+ // `refs/dorfl/lock/spec-<slug>` sits right there on the arbiter, sending the
687
+ // operator to look for a typo instead of at the lock they are holding.
688
+ //
689
+ // So before refusing, probe the SPEC namespace for the same slug and, on a
690
+ // hit, name the verb that DOES own that lock. We do NOT release it here:
691
+ // `requeue`'s contract is keep/continue/rebase/reset/reconcile of a WORK
692
+ // BRANCH, and a tasking run has no work branch to continue — releasing a
693
+ // spec lock from a task verb would fork a second release mechanism for the
694
+ // ref `release-lock` already owns. A pointer, not a second implementation.
695
+ //
696
+ // Best-effort and non-fatal: the probe is one extra ref read on a path that
697
+ // is already terminal, and any fault leaves the original refusal intact.
698
+ let specHint = '';
699
+ try {
700
+ const specHeld = await readItemLock({
701
+ item: `spec:${slug}`,
702
+ cwd,
703
+ arbiter,
704
+ env,
705
+ });
706
+ if (specHeld) {
707
+ specHint =
708
+ ` NOTE: a SPEC lock IS held for this slug ` +
709
+ `(refs/dorfl/lock/spec-${slug}, ${specHeld.action}/${specHeld.state}` +
710
+ `${specHeld.holder ? `, holder: ${specHeld.holder}` : ''}` +
711
+ `${specHeld.since ? `, since: ${specHeld.since}` : ''}) — left by a ` +
712
+ `\`do spec:${slug}\` run. requeue does not act on specs; if that run ` +
713
+ `is DEAD, clear it with \`dorfl release-lock spec:${slug}\` (a crashed ` +
714
+ `tasking run publishes no work branch, so releasing discards nothing).`;
715
+ }
716
+ } catch {
717
+ // Best-effort hint only: fall through to the plain refusal.
718
+ }
678
719
  return {
679
720
  moved: false,
680
721
  reasonNotMoved:
681
722
  `'${slug}' has no held per-item lock on ${arbiter} — nothing to requeue ` +
682
723
  '(wrong slug, or already at rest in backlog/done?). requeue recovers a ' +
683
724
  'task whose lock is held stuck (needs-attention) or active (a killed ' +
684
- 'in-progress run).',
725
+ 'in-progress run).' +
726
+ specHint,
685
727
  };
686
728
  }
687
729
 
@@ -2100,6 +2142,478 @@ export function prepareTreelessSurfaceCommit(params: {
2100
2142
  }
2101
2143
  }
2102
2144
 
2145
+ /**
2146
+ * The kind of TERMINAL resting place an item has reached on `main`, which
2147
+ * decides how much of its question state is residue.
2148
+ * - `completed`, the work HAPPENED (`tasks/done/`, `specs/tasked/`). Any
2149
+ * surviving question state is pure residue: the questions were about how to
2150
+ * proceed, and the item proceeded.
2151
+ * - `wont-proceed`, the item was ABANDONED (`tasks/cancelled/`,
2152
+ * `specs/dropped/`). Here `needsAnswers:true` may be ACCURATE HISTORY: an
2153
+ * item can be cancelled precisely BECAUSE its questions were never answered,
2154
+ * and the body may carry a real `## Open questions` section recording that.
2155
+ */
2156
+ export type TerminalKind = 'completed' | 'wont-proceed';
2157
+
2158
+ /** The terminal `work/` paths for an item, tagged by {@link TerminalKind}, so a
2159
+ * reader can tell "the work happened" from "the item was abandoned". Mirrors
2160
+ * `terminalMainPaths` in `item-lock.ts` (same folders, same per-regime split);
2161
+ * this variant carries the KIND the question-state drain branches on. */
2162
+ export function terminalMainPathsByKind(
2163
+ type: SidecarType,
2164
+ slug: string,
2165
+ ): {path: string; kind: TerminalKind}[] {
2166
+ const file = `${slug}.md`;
2167
+ switch (type) {
2168
+ case 'task':
2169
+ return [
2170
+ {path: workItemRel('done', file), kind: 'completed'},
2171
+ {path: workItemRel('cancelled', file), kind: 'wont-proceed'},
2172
+ ];
2173
+ case 'spec':
2174
+ return [
2175
+ {path: workItemRel('specs-tasked', file), kind: 'completed'},
2176
+ {path: workItemRel('specs-dropped', file), kind: 'wont-proceed'},
2177
+ ];
2178
+ case 'observation':
2179
+ // A note has no durable terminal folder: it leaves by DELETION, so there
2180
+ // is no resting record to reconcile against.
2181
+ return [];
2182
+ }
2183
+ }
2184
+
2185
+ /** One item whose question state survived into a terminal resting place. */
2186
+ export interface TerminalQuestionResidue {
2187
+ /** The namespaced identity (`task:<slug>`). */
2188
+ item: string;
2189
+ /** The sidecar's path on `main` (`work/questions/<type>-<slug>.md`). */
2190
+ sidecarPath: string;
2191
+ /** The item body's terminal path on `main`. */
2192
+ itemPath: string;
2193
+ /** Which terminal the body rests in. */
2194
+ terminal: TerminalKind;
2195
+ /** Does the body still carry `needsAnswers: true`? */
2196
+ flagged: boolean;
2197
+ /**
2198
+ * Does the sidecar carry at least one ANSWERED entry? Such a sidecar holds
2199
+ * human-written prose that was never consumed by the apply rung, so the drain
2200
+ * refuses to touch it (see {@link classifyTerminalQuestionResidue}).
2201
+ */
2202
+ answered: boolean;
2203
+ }
2204
+
2205
+ /** The read-only classification of the arbiter's stranded question state. */
2206
+ export interface TerminalQuestionReport {
2207
+ /** Residue the drain WILL clear: terminal + no answered entry. */
2208
+ drainable: TerminalQuestionResidue[];
2209
+ /**
2210
+ * Residue the drain deliberately LEAVES: a terminal item whose sidecar carries
2211
+ * a human's ANSWER that was never applied. Reported for a human, never
2212
+ * silently deleted (the answer is data the tool did not author).
2213
+ */
2214
+ answeredHeld: TerminalQuestionResidue[];
2215
+ errors: {item: string; message: string}[];
2216
+ }
2217
+
2218
+ /**
2219
+ * Classify the arbiter's STRANDED QUESTION STATE, read-only (observation
2220
+ * `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
2221
+ *
2222
+ * THE BUG THIS EXISTS FOR. When a build bounces, the surface path atomically
2223
+ * writes BOTH halves of the question state in ONE commit: the sidecar
2224
+ * `work/questions/<type>-<slug>.md` AND `needsAnswers: true` on the item body.
2225
+ * That is correct, and the atomicity is what makes this reconciliation decidable
2226
+ * at all. But if the human DISAGREES with the bounce and simply re-dispatches,
2227
+ * and the rebuild SUCCEEDS (PR opened, gate green, merged, body done-moved),
2228
+ * NEITHER half is ever cleared. The item comes to rest in `tasks/done/` still
2229
+ * carrying a question asking whether to CANCEL it, with a destructive default.
2230
+ *
2231
+ * The flag is the harmful half. A stranded sidecar is a stale question in a
2232
+ * folder a human scans; a stranded `needsAnswers` is a GATE LEFT ARMED, and it
2233
+ * makes `status` report shipped (sometimes released) work under "open questions
2234
+ * block autonomous work".
2235
+ *
2236
+ * Dorfl ALREADY knows this state is illegal: `advance-classify.ts` refuses it as
2237
+ * `invariant-violation` / `sidecar-without-needsAnswers`. The defect is purely
2238
+ * that the detector lives in the `advance` tick's classifier, and a human driving
2239
+ * `do` and merging a PR never enters that loop. So this is the same shape as the
2240
+ * propose-path lock leak, settled by the same reconcile pass at the same moment
2241
+ * (the done-move), rather than by a second mechanism.
2242
+ *
2243
+ * THE TRAP, and why the TERMINAL POSITION is the discriminator rather than the
2244
+ * flag/sidecar disagreement: the MIRROR state (`needsAnswers:true` with NO
2245
+ * sidecar) is LEGAL and COMMON. An item authored with open questions carries the
2246
+ * flag and has no sidecar until `surface` runs, and that flagged-but-unsurfaced
2247
+ * item is precisely the `surface` rung's INPUT. Clearing the flag there would
2248
+ * silently disarm every un-surfaced item in the repo and hand gated work to
2249
+ * agents. So this only ever considers items whose body has reached a TERMINAL
2250
+ * folder on `main`; an item resting in a pool or staging folder keeps whatever
2251
+ * state it has, untouched.
2252
+ *
2253
+ * The enumeration is anchored on the SIDECAR SET (`work/questions/` on `main`),
2254
+ * which is small, cheap to list, and is the half that makes the residue
2255
+ * discoverable unambiguously. A terminal item carrying a bare flag and no sidecar
2256
+ * is deliberately NOT swept: that shape is the legal one above, and there is no
2257
+ * second signal to distinguish residue from a hand-authored declaration.
2258
+ *
2259
+ * Best-effort and never throws.
2260
+ */
2261
+ export async function classifyTerminalQuestionResidue(params: {
2262
+ cwd: string;
2263
+ arbiter: string;
2264
+ /** The ref holding the arbiter's authoritative `main`. */
2265
+ mainRef: string;
2266
+ env?: NodeJS.ProcessEnv;
2267
+ /** Skip the `mainRef` refresh because the CALLER just did it (the combined
2268
+ * pass refreshes once and runs both sub-passes against that ONE snapshot). */
2269
+ mainAlreadyFresh?: boolean;
2270
+ }): Promise<TerminalQuestionReport> {
2271
+ const {cwd, mainRef, env} = params;
2272
+ // REFRESH `mainRef` FIRST, with an explicit refspec that writes exactly the ref
2273
+ // we are about to read. Without this the pass reads a STALE view: the caller
2274
+ // may not have fetched, and the lock sub-pass of the combined reconciliation
2275
+ // early-returns (so does not refresh) when no locks are held. A failed refresh
2276
+ // is NOT fatal, but it does mean the view may be stale in EITHER direction (an
2277
+ // item may have left a terminal folder, or acquired an answer, since we last
2278
+ // looked), which is exactly why the WRITE path re-derives this same
2279
+ // classification against its own freshly-resolved base rather than trusting
2280
+ // this snapshot.
2281
+ await refreshMainRef(mainRef, params.arbiter, cwd, env);
2282
+ return deriveTerminalQuestionResidue(mainRef, cwd, env);
2283
+ }
2284
+
2285
+ /**
2286
+ * The SYNC, PURE-of-network derivation of the question residue AT ONE COMMIT.
2287
+ *
2288
+ * Split out of {@link classifyTerminalQuestionResidue} so the WRITE path can
2289
+ * re-derive the SAME classification against the base it is actually about to
2290
+ * commit on, per contention attempt. That matters for correctness, not tidiness:
2291
+ * a classification taken before a contention retry can be stale in two ways that
2292
+ * both break a documented guarantee. An item may have LEFT its terminal folder
2293
+ * (re-opened), in which case its sidecar is live again and must not be deleted;
2294
+ * and a human may have written an ANSWER into a sidecar in the window, which must
2295
+ * never be auto-deleted. Re-deriving against `base` closes both, because the
2296
+ * commit is built on exactly that base.
2297
+ */
2298
+ function deriveTerminalQuestionResidue(
2299
+ base: string,
2300
+ cwd: string,
2301
+ env: NodeJS.ProcessEnv | undefined,
2302
+ ): TerminalQuestionReport {
2303
+ const mainRef = base;
2304
+ const out: TerminalQuestionReport = {
2305
+ drainable: [],
2306
+ answeredHeld: [],
2307
+ errors: [],
2308
+ };
2309
+ const questionsDir = workFolderRel('questions');
2310
+ const ls = run(
2311
+ 'git',
2312
+ ['ls-tree', '--name-only', `${mainRef}:${questionsDir}`],
2313
+ cwd,
2314
+ {env},
2315
+ );
2316
+ if (ls.status !== 0) {
2317
+ // No `work/questions/` on main at all: nothing surfaced, nothing to drain.
2318
+ return out;
2319
+ }
2320
+ for (const name of ls.stdout.split('\n').map((l) => l.trim())) {
2321
+ if (name === '' || !isWorkItemFile(name)) {
2322
+ continue;
2323
+ }
2324
+ const sidecarPath = `${questionsDir}/${name}`;
2325
+ try {
2326
+ // `<type>-<slug>.md` → `<type>:<slug>`. Only the CURRENT namespaces are
2327
+ // addressable; a legacy `prd-` file has no current item-form and is left
2328
+ // for the migration command.
2329
+ const stem = name.replace(/\.md$/, '');
2330
+ const dash = stem.indexOf('-');
2331
+ const type = stem.slice(0, dash) as SidecarType;
2332
+ const slug = stem.slice(dash + 1);
2333
+ if (!['task', 'spec', 'observation'].includes(type) || slug === '') {
2334
+ continue;
2335
+ }
2336
+ const item = `${type}:${slug}`;
2337
+ // Is the body at rest in a terminal folder on `main`?
2338
+ const terminalHit = terminalMainPathsByKind(type, slug).find((c) =>
2339
+ pathInCommit(mainRef, c.path, cwd, env),
2340
+ );
2341
+ if (terminalHit === undefined) {
2342
+ // NOT terminal: a live item. Its question state is its own business
2343
+ // a pending sidecar is a human's outstanding decision, and clearing a
2344
+ // flag here is the trap above. Untouched.
2345
+ continue;
2346
+ }
2347
+ // N5 GUARD: a mid-migration spec can have BOTH `spec-<slug>.md` and the
2348
+ // legacy `prd-<slug>.md` on main (`sidecarPathCandidates` still resolves
2349
+ // the legacy name for readers). Draining only the canonical one while
2350
+ // clearing the flag would leave the legacy sidecar live against
2351
+ // `needsAnswers:false`, which is precisely the
2352
+ // `sidecar-without-needsAnswers` invariant violation this change exists
2353
+ // to remove. If any OTHER candidate for this item still exists, leave the
2354
+ // whole item to `dorfl prd-to-spec`, which renames the DATA.
2355
+ const hasLegacyAlias = sidecarPathCandidates(item).some(
2356
+ (c) => c !== sidecarPath && pathInCommit(mainRef, c, cwd, env),
2357
+ );
2358
+ if (hasLegacyAlias) {
2359
+ continue;
2360
+ }
2361
+ const model = parseSidecar(
2362
+ catBlob(`${mainRef}:${sidecarPath}`, cwd, env),
2363
+ );
2364
+ const answered = model.entries.some((e) => isEntryAnswered(e));
2365
+ const body = catBlob(`${mainRef}:${terminalHit.path}`, cwd, env);
2366
+ const flagged = parseFrontmatter(body).needsAnswers === true;
2367
+ const residue: TerminalQuestionResidue = {
2368
+ item,
2369
+ sidecarPath,
2370
+ itemPath: terminalHit.path,
2371
+ terminal: terminalHit.kind,
2372
+ flagged,
2373
+ answered,
2374
+ };
2375
+ if (answered) {
2376
+ // A human WROTE an answer here and the apply rung never consumed it.
2377
+ // Deleting it would discard prose the tool did not author, so this is
2378
+ // surfaced for a human instead. (That the drain never runs on the
2379
+ // human-answer path either is a SEPARATE defect; this pass must not
2380
+ // paper over it by destroying the evidence.)
2381
+ out.answeredHeld.push(residue);
2382
+ } else {
2383
+ out.drainable.push(residue);
2384
+ }
2385
+ } catch (err) {
2386
+ out.errors.push({
2387
+ item: sidecarPath,
2388
+ message: err instanceof Error ? err.message : String(err),
2389
+ });
2390
+ }
2391
+ }
2392
+ return out;
2393
+ }
2394
+
2395
+ /** What a {@link reconcileTerminalQuestionResidue} pass did. */
2396
+ export interface TerminalQuestionDrainResult {
2397
+ /** Items whose sidecar was deleted. */
2398
+ drained: string[];
2399
+ /** Items whose `needsAnswers` flag was additionally cleared. */
2400
+ unflagged: string[];
2401
+ /** Terminal items left alone because a human's answer is unapplied. */
2402
+ answeredHeld: string[];
2403
+ errors: {item: string; message: string}[];
2404
+ }
2405
+
2406
+ /**
2407
+ * Drain the stranded question state {@link classifyTerminalQuestionResidue}
2408
+ * finds, in ONE tree-less commit CAS-published to the arbiter's `main` through
2409
+ * the SAME {@link runTreelessLedgerMove} core the surface path uses (same
2410
+ * contention-retry, same lease, same write seam; there is no second mechanism).
2411
+ *
2412
+ * WHAT IT CLEARS, and the deliberate asymmetry between the two terminals:
2413
+ * - the SIDECAR is deleted for EITHER terminal. A question asking whether to
2414
+ * cancel an item that has already come to rest is stale in both cases, and it
2415
+ * sits in a folder a human scans carrying a destructive default.
2416
+ * - the `needsAnswers` FLAG is cleared ONLY for a `completed` terminal
2417
+ * (`tasks/done/`, `specs/tasked/`). On a `wont-proceed` terminal
2418
+ * (`tasks/cancelled/`, `specs/dropped/`) the flag is KEPT, because an item
2419
+ * can be cancelled precisely BECAUSE its questions were never answered: there
2420
+ * the flag is accurate history, not residue, and the body may carry a real
2421
+ * `## Open questions` section saying so. Keeping it is harmless, a terminal
2422
+ * item is in no pool, so the flag gates nothing.
2423
+ *
2424
+ * A sidecar with ANY answered entry is never touched (see the classifier).
2425
+ *
2426
+ * Best-effort: it never throws, and any fault leaves the state exactly as it was.
2427
+ */
2428
+ export async function reconcileTerminalQuestionResidue(params: {
2429
+ cwd: string;
2430
+ arbiter: string;
2431
+ mainRef: string;
2432
+ env?: NodeJS.ProcessEnv;
2433
+ /** Skip the `mainRef` refresh because the CALLER just did it (the combined
2434
+ * pass refreshes once and runs both sub-passes against that ONE snapshot). */
2435
+ mainAlreadyFresh?: boolean;
2436
+ note?: (message: string) => void;
2437
+ }): Promise<TerminalQuestionDrainResult> {
2438
+ const {cwd, arbiter, mainRef, env} = params;
2439
+ const note = params.note ?? (() => {});
2440
+ const result: TerminalQuestionDrainResult = {
2441
+ drained: [],
2442
+ unflagged: [],
2443
+ answeredHeld: [],
2444
+ errors: [],
2445
+ };
2446
+ let report: TerminalQuestionReport;
2447
+ try {
2448
+ report = await classifyTerminalQuestionResidue({
2449
+ cwd,
2450
+ arbiter,
2451
+ mainRef,
2452
+ env,
2453
+ mainAlreadyFresh: params.mainAlreadyFresh,
2454
+ });
2455
+ } catch (err) {
2456
+ result.errors.push({
2457
+ item: '(classify)',
2458
+ message: err instanceof Error ? err.message : String(err),
2459
+ });
2460
+ return result;
2461
+ }
2462
+ result.answeredHeld = report.answeredHeld.map((r) => r.item);
2463
+ result.errors.push(...report.errors);
2464
+ if (report.drainable.length === 0) {
2465
+ return result;
2466
+ }
2467
+ // What the LANDED commit ACTUALLY did, filled in by the plan against the base
2468
+ // it committed on. The pre-plan `report` above is only a fast "is there
2469
+ // anything to do?" probe; reporting from it would claim a gate was disarmed
2470
+ // when a contention retry re-derived the residue and skipped the item.
2471
+ let applied: TerminalQuestionResidue[] = [];
2472
+ // NEVER THROW. `runTreelessLedgerMove` and the git plumbing inside the plan
2473
+ // both throw on any non-zero git, and this pass runs from the CLAIM path as
2474
+ // OPPORTUNISTIC HYGIENE on unrelated items. A fault here (a stale scratch ref,
2475
+ // a protected `main`, a permission refusal) must degrade to "left it alone",
2476
+ // never fail the caller's actual work.
2477
+ let landed = false;
2478
+ try {
2479
+ landed = await runTreelessLedgerMove({
2480
+ cwd,
2481
+ // The ref name only has to be unique for the scratch ref; this pass is
2482
+ // batch (many items, one commit), so it is not keyed to a single slug.
2483
+ slug: 'terminal-question-drain',
2484
+ arbiter,
2485
+ kind: 'needs-attention',
2486
+ onContended: 'drain stranded questions',
2487
+ explicitMainRefspec: true,
2488
+ env,
2489
+ note,
2490
+ // RE-PLANNED per attempt against the freshly-fetched base: the residue is
2491
+ // RE-DERIVED from that base, never reused from the probe above, so an item
2492
+ // re-opened out of its terminal folder, or a sidecar a human answered, in the
2493
+ // contention window is correctly left alone.
2494
+ plan: (base) => {
2495
+ const fresh = deriveTerminalQuestionResidue(base, cwd, env);
2496
+ applied = fresh.drainable;
2497
+ result.answeredHeld = fresh.answeredHeld.map((r) => r.item);
2498
+ return prepareTerminalQuestionDrainCommit({
2499
+ cwd,
2500
+ base,
2501
+ residue: fresh.drainable,
2502
+ env,
2503
+ });
2504
+ },
2505
+ });
2506
+ } catch (err) {
2507
+ result.errors.push({
2508
+ item: '(publish)',
2509
+ message: err instanceof Error ? err.message : String(err),
2510
+ });
2511
+ return result;
2512
+ }
2513
+ if (!landed) {
2514
+ result.errors.push({
2515
+ item: '(publish)',
2516
+ message:
2517
+ 'the stranded-question drain did not land on the arbiter’s main ' +
2518
+ '(contention exhausted, or nothing to do); state left untouched.',
2519
+ });
2520
+ return result;
2521
+ }
2522
+ for (const r of applied) {
2523
+ result.drained.push(r.item);
2524
+ if (r.terminal === 'completed' && r.flagged) {
2525
+ result.unflagged.push(r.item);
2526
+ }
2527
+ }
2528
+ return result;
2529
+ }
2530
+
2531
+ /**
2532
+ * Build the ONE tree-less commit that removes every drainable sidecar and clears
2533
+ * the `needsAnswers` flag on every `completed`-terminal body, using PLUMBING on a
2534
+ * SCRATCH INDEX (the caller's index/HEAD/working tree are never touched)
2535
+ * exactly as {@link prepareTreelessSurfaceCommit} does in the opposite direction.
2536
+ *
2537
+ * Batched into a single commit on purpose: the residue is a SET, one commit is
2538
+ * one CAS against `main` instead of N, and the whole drain then lands or does not
2539
+ * land atomically.
2540
+ */
2541
+ function prepareTerminalQuestionDrainCommit(params: {
2542
+ cwd: string;
2543
+ base: string;
2544
+ residue: TerminalQuestionResidue[];
2545
+ env: NodeJS.ProcessEnv | undefined;
2546
+ }): TreelessAttemptPlan {
2547
+ const {cwd, base, residue, env} = params;
2548
+ // RE-DERIVE against THIS base: anything already gone is not our business.
2549
+ const live = residue.filter((r) =>
2550
+ pathInCommit(base, r.sidecarPath, cwd, env),
2551
+ );
2552
+ if (live.length === 0) {
2553
+ return 'already-done';
2554
+ }
2555
+ const scratchIndex = join(
2556
+ tmpdir(),
2557
+ `dorfl-question-drain-${process.pid}-${Date.now()}.index`,
2558
+ );
2559
+ const withIndex: NodeJS.ProcessEnv = {
2560
+ ...(env ?? process.env),
2561
+ GIT_INDEX_FILE: scratchIndex,
2562
+ };
2563
+ try {
2564
+ gitHard(['read-tree', base], cwd, withIndex);
2565
+ for (const r of live) {
2566
+ // Remove the stale sidecar.
2567
+ gitHard(
2568
+ ['update-index', '--force-remove', r.sidecarPath],
2569
+ cwd,
2570
+ withIndex,
2571
+ );
2572
+ // Clear the flag ONLY on a `completed` terminal (see the doc above).
2573
+ if (r.terminal !== 'completed' || !r.flagged) {
2574
+ continue;
2575
+ }
2576
+ if (!pathInCommit(base, r.itemPath, cwd, env)) {
2577
+ continue;
2578
+ }
2579
+ const body = catBlob(`${base}:${r.itemPath}`, cwd, env);
2580
+ const cleared = setNeedsAnswersMarker(body, false);
2581
+ // Defense-in-depth, mirroring the surface path's guard: if the marker did
2582
+ // not parse back as `false`, leave the body ALONE rather than write a
2583
+ // body we cannot vouch for.
2584
+ if (parseFrontmatter(cleared).needsAnswers !== false) {
2585
+ continue;
2586
+ }
2587
+ const blob = hashObject(cleared, cwd, env);
2588
+ gitHard(
2589
+ [
2590
+ 'update-index',
2591
+ '--add',
2592
+ '--cacheinfo',
2593
+ `100644,${blob},${r.itemPath}`,
2594
+ ],
2595
+ cwd,
2596
+ withIndex,
2597
+ );
2598
+ }
2599
+ const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
2600
+ const subject =
2601
+ live.length === 1
2602
+ ? `drain stranded question state for ${live[0].item} (terminal on main)`
2603
+ : `drain stranded question state for ${live.length} terminal items`;
2604
+ const commit = runHard(
2605
+ ['commit-tree', tree, '-p', base, '-m', subject],
2606
+ cwd,
2607
+ env,
2608
+ ).stdout.trim();
2609
+ const ref = 'refs/dorfl/question-drain/batch';
2610
+ gitHard(['update-ref', ref, commit], cwd, env);
2611
+ return {ref, commit};
2612
+ } finally {
2613
+ rmSync(scratchIndex, {force: true});
2614
+ }
2615
+ }
2616
+
2103
2617
  export interface SurfaceStuckToNeedsAttentionOptions {
2104
2618
  /**
2105
2619
  * The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE