dorfl 0.13.2 → 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 (52) 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 +255 -1
  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 +357 -2
  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/package.json +1 -1
  42. package/src/claim-cas.ts +40 -0
  43. package/src/cli.ts +18 -1
  44. package/src/complete.ts +9 -2
  45. package/src/cwd-section.ts +151 -4
  46. package/src/format.ts +72 -0
  47. package/src/index.ts +2 -0
  48. package/src/item-lock.ts +369 -1
  49. package/src/needs-attention.ts +474 -0
  50. package/src/reconcile-terminal.ts +180 -0
  51. package/src/scan.ts +82 -5
  52. package/src/status.ts +131 -6
@@ -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';
@@ -2140,6 +2142,478 @@ export function prepareTreelessSurfaceCommit(params: {
2140
2142
  }
2141
2143
  }
2142
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
+
2143
2617
  export interface SurfaceStuckToNeedsAttentionOptions {
2144
2618
  /**
2145
2619
  * The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
@@ -0,0 +1,180 @@
1
+ import {
2
+ classifyTerminalItemLocks,
3
+ reconcileTerminalItemLocks,
4
+ refreshMainRef,
5
+ type TerminalLockClassification,
6
+ type TerminalReconcileReport,
7
+ } from './item-lock.js';
8
+ import {
9
+ classifyTerminalQuestionResidue,
10
+ reconcileTerminalQuestionResidue,
11
+ type TerminalQuestionReport,
12
+ type TerminalQuestionDrainResult,
13
+ } from './needs-attention.js';
14
+
15
+ /**
16
+ * **The ONE terminal-state reconciliation pass.**
17
+ *
18
+ * Two defects of the same shape motivated this, and they are deliberately fixed
19
+ * by ONE mechanism rather than two:
20
+ *
21
+ * 1. **The propose-path lock leak.** `complete --propose` keeps the per-item
22
+ * lock held across the open PR and defers its release to the merge, so every
23
+ * completed item leaked its `refs/dorfl/lock/<entry>` ref and reported
24
+ * in-progress for ever.
25
+ * 2. **The stranded question state.** A bounce atomically writes a sidecar plus
26
+ * `needsAnswers:true`; if the human disagrees, re-dispatches, and the rebuild
27
+ * SUCCEEDS, neither half is ever cleared. The item comes to rest in
28
+ * `tasks/done/` still carrying a question asking whether to CANCEL it, and a
29
+ * `needsAnswers` gate left armed over shipped work.
30
+ *
31
+ * They share a cause, a moment, and a blind spot. The cause is that both are
32
+ * cleared by a step that only runs on a path the item did not take. The moment
33
+ * both become moot is exactly the same one: the DONE-MOVE landing on the
34
+ * arbiter's `main`. And the blind spot is that each is detectable only from a
35
+ * loop the manual path never enters (`gc --ledger --reap-stale-locks` for the
36
+ * lock, the `advance` tick's `invariant-violation` classifier for the questions),
37
+ * while a human driving `do` and merging a PR enters neither.
38
+ *
39
+ * Dorfl cannot hook the merge: nobody runs a dorfl process when a human clicks
40
+ * merge on GitHub, and there is no daemon. So the shape has to be RECONCILE
41
+ * AGAINST `main` rather than react to the merge. The merge event is unobservable;
42
+ * its consequence on `main` is durable, so a late pass converges just as well as
43
+ * a timely one.
44
+ *
45
+ * ONE DISCRIMINATOR governs both halves: the item's POSITION on `<arbiter>/main`.
46
+ * Not a branch, not a PR, not the holder, not age, not the flag/sidecar
47
+ * disagreement on its own. An item that has reached a terminal resting folder is
48
+ * finished and cannot need either piece of state; an item resting anywhere else
49
+ * keeps everything it has, untouched. Both sub-passes resolve every uncertainty
50
+ * towards LEAVING STATE ALONE, because the failure modes are asymmetric and both
51
+ * severe: releasing a live lock would let two claimants build one item, and
52
+ * clearing a live `needsAnswers` would disarm a gate and hand gated work to
53
+ * agents.
54
+ *
55
+ * WHERE THIS RUNS. On the CLAIM path, which already writes to the arbiter and
56
+ * already runs on every unit of work, so the residue drains as a side effect of
57
+ * ordinary use. The read-only surfaces (`status`, `scan`) use the CLASSIFY twin
58
+ * ({@link classifyTerminalState}) to REPORT the same residue without writing, and
59
+ * perform this pass only under an explicit `--reconcile-locks`. Putting the
60
+ * automatic clear on a write path rather than behind a flag on a read command is
61
+ * what makes the fix real: an offer nobody is routed to is what let both defects
62
+ * accumulate in the first place.
63
+ */
64
+ export interface TerminalStateReport {
65
+ locks: TerminalReconcileReport;
66
+ questions: TerminalQuestionDrainResult;
67
+ }
68
+
69
+ /** The read-only twin of {@link TerminalStateReport}: what a reconcile WOULD do. */
70
+ export interface TerminalStateClassification {
71
+ locks: TerminalLockClassification;
72
+ questions: TerminalQuestionReport;
73
+ }
74
+
75
+ export interface TerminalStateOptions {
76
+ cwd: string;
77
+ arbiter?: string;
78
+ env?: NodeJS.ProcessEnv;
79
+ /**
80
+ * The ref holding the arbiter's authoritative `main`, for the READ side.
81
+ * Defaults to `<arbiter>/main` (the WORKING-CLONE shape); a BARE HUB MIRROR has
82
+ * no `refs/remotes/*` namespace at all and must pass `'main'`.
83
+ *
84
+ * NOTE: this governs the CLASSIFY/read side only. The question-drain's WRITE
85
+ * side publishes through `runTreelessLedgerMove`, which resolves its own CAS
86
+ * base from `<arbiter>/main`, so {@link reconcileTerminalState} is supported
87
+ * from a WORKING CLONE only. Classification is safe from either shape.
88
+ */
89
+ mainRef?: string;
90
+ note?: (message: string) => void;
91
+ }
92
+
93
+ /**
94
+ * READ-ONLY: classify every piece of terminal-state residue on the arbiter (stale
95
+ * locks + stranded question state) without touching anything. This is what the
96
+ * read commands render, so finished work stops being reported as in-flight or as
97
+ * blocked on open questions.
98
+ */
99
+ export async function classifyTerminalState(
100
+ opts: TerminalStateOptions,
101
+ ): Promise<TerminalStateClassification> {
102
+ const arbiter = opts.arbiter ?? 'origin';
103
+ const mainRef = opts.mainRef ?? `${arbiter}/main`;
104
+ const locks = await classifyTerminalItemLocks(opts.cwd, arbiter, opts.env, {
105
+ mainRef,
106
+ });
107
+ const questions = await classifyTerminalQuestionResidue({
108
+ cwd: opts.cwd,
109
+ arbiter,
110
+ mainRef,
111
+ env: opts.env,
112
+ });
113
+ return {locks, questions};
114
+ }
115
+
116
+ /**
117
+ * WRITE: settle both halves of an item's terminal residue in one pass. The lock
118
+ * sub-pass deletes stale lock refs; the question sub-pass publishes ONE tree-less
119
+ * commit to `main` removing stale sidecars and clearing stale `needsAnswers`
120
+ * flags.
121
+ *
122
+ * Order matters only for reporting, not correctness: the two touch disjoint state
123
+ * (a hidden ref namespace vs `main`'s tree) and neither depends on the other. It
124
+ * never throws; each sub-pass degrades independently, so a failure to reach the
125
+ * lock refs does not prevent the question drain, or vice versa.
126
+ */
127
+ export async function reconcileTerminalState(
128
+ opts: TerminalStateOptions,
129
+ ): Promise<TerminalStateReport> {
130
+ const arbiter = opts.arbiter ?? 'origin';
131
+ const mainRef = opts.mainRef ?? `${arbiter}/main`;
132
+ // ONE refresh of `main` for the WHOLE pass. Both sub-passes read the same
133
+ // durable record, so fetching it twice per claim is pure waste on a hot path.
134
+ // A failed refresh is not fatal: each sub-pass independently resolves every
135
+ // uncertainty towards leaving state alone.
136
+ await refreshMainRef(mainRef, arbiter, opts.cwd, opts.env);
137
+ // Each sub-pass is independently guarded so one cannot take the other down,
138
+ // and so a caller running this as opportunistic hygiene (the claim path) is
139
+ // never failed by unrelated residue. Both are documented as never throwing;
140
+ // this is the belt that makes that true even if a callee regresses.
141
+ let locks: TerminalReconcileReport = {
142
+ released: [],
143
+ kept: [],
144
+ stillHeld: [],
145
+ errors: [],
146
+ };
147
+ try {
148
+ locks = await reconcileTerminalItemLocks(opts.cwd, arbiter, opts.env, {
149
+ mainRef,
150
+ mainAlreadyFresh: true,
151
+ });
152
+ } catch (err) {
153
+ locks.errors.push({
154
+ entry: '(locks)',
155
+ message: err instanceof Error ? err.message : String(err),
156
+ });
157
+ }
158
+ let questions: TerminalQuestionDrainResult = {
159
+ drained: [],
160
+ unflagged: [],
161
+ answeredHeld: [],
162
+ errors: [],
163
+ };
164
+ try {
165
+ questions = await reconcileTerminalQuestionResidue({
166
+ cwd: opts.cwd,
167
+ arbiter,
168
+ mainRef,
169
+ env: opts.env,
170
+ mainAlreadyFresh: true,
171
+ note: opts.note,
172
+ });
173
+ } catch (err) {
174
+ questions.errors.push({
175
+ item: '(questions)',
176
+ message: err instanceof Error ? err.message : String(err),
177
+ });
178
+ }
179
+ return {locks, questions};
180
+ }