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
package/src/claim-cas.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {runAsync, type RunResult} from './git.js';
2
2
  import {acquireItemLock, releaseItemLock, heldTaskSlugs} from './item-lock.js';
3
+ import {reconcileTerminalState} from './reconcile-terminal.js';
3
4
  import {extractPromptSection} from './prompt.js';
4
5
  import {resolveReadiness} from './readiness.js';
5
6
  import {workBranchRef} from './slug-namespace.js';
@@ -386,6 +387,45 @@ async function runClaim(
386
387
  }
387
388
  }
388
389
 
390
+ // LAZY RECONCILIATION of TERMINAL-STATE RESIDUE: the stale per-item lock a
391
+ // propose PR leaves behind (observation
392
+ // `every-completed-task-leaves-its-lock-ref-reporting-in-progress`) AND the
393
+ // stranded sidecar + `needsAnswers` flag a bounced-then-rebuilt item leaves
394
+ // behind (observation
395
+ // `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
396
+ // ONE pass, because they are the SAME defect wearing two hats: both are cleared
397
+ // by a step that only runs on a path the item did not take, both become moot at
398
+ // exactly the same moment (the done-move landing on `main`), and both are
399
+ // otherwise detectable only from a loop the manual path never enters.
400
+ //
401
+ // `complete --propose` deliberately KEEPS its per-item lock held across the open
402
+ // PR and promises "It is released when the PR merges (reconciled against main)".
403
+ // That release could never fire: no dorfl process runs when a human clicks merge
404
+ // on GitHub, there is no merge hook, and there is no daemon, so every completed
405
+ // propose item leaked its lock ref permanently.
406
+ //
407
+ // THIS is where the release belongs, and why it is here rather than on `status`:
408
+ // the merge EVENT is unobservable, but its CONSEQUENCE on `main` is durable, so
409
+ // any later sweep converges just as well as a timely one. `claim` is the natural
410
+ // host, it ALREADY writes to the arbiter (it is about to acquire a lock), it
411
+ // ALREADY fetched `main` just above, and it runs on EVERY unit of work, so the
412
+ // leaked set drains continuously without a human ever being asked to run a
413
+ // clean-up verb. Keeping the release on a write path is what makes the fix real:
414
+ // `gc --ledger` had been reporting these very locks and printing the
415
+ // `release-lock` command all along, and that offer is precisely what nobody was
416
+ // routed to. The read-only surfaces (`status`, `scan`) therefore only CLASSIFY.
417
+ //
418
+ // It acts EXACTLY on items that have come to REST in a terminal folder on
419
+ // `<arbiter>/main`; an item on an OPEN PR still shows its body in the pool and
420
+ // keeps both its lock and its question state, as does a genuinely stuck one.
421
+ // Clearing a live `needsAnswers` would be as bad as releasing a live lock: it
422
+ // would disarm a gate and hand gated work to agents. Best-effort and never throws:
423
+ // any fault leaves every lock HELD (the safe direction) and never fails the
424
+ // claim, which is unrelated work, this sweep is opportunistic hygiene, not a
425
+ // precondition. It also cannot affect THIS claim's outcome: an item terminal on
426
+ // `main` is not in the claimable pool anyway.
427
+ await reconcileTerminalState({cwd, arbiter, env});
428
+
389
429
  // Acquire the lock FIRST. A lock `lost` (someone already holds this SAME item's
390
430
  // lock) makes claim lose DEFINITIVELY — exit 2, NO retry budget (a per-item ref
391
431
  // never falsely contends, so a rejection is a GENUINE same-item conflict the
package/src/cli.ts CHANGED
@@ -167,6 +167,7 @@ interface ScanFlags {
167
167
  autoBuild?: boolean;
168
168
  json?: boolean;
169
169
  here?: boolean;
170
+ reconcileLocks?: boolean;
170
171
  arbiterRemote?: string;
171
172
  arbiter?: string;
172
173
  }
@@ -1029,6 +1030,7 @@ interface StatusFlags {
1029
1030
  arbiter?: string;
1030
1031
  noArbiter?: boolean;
1031
1032
  here?: boolean;
1033
+ reconcileLocks?: boolean;
1032
1034
  json?: boolean;
1033
1035
  }
1034
1036
 
@@ -1385,6 +1387,10 @@ export function buildProgram(): Command {
1385
1387
  '--here',
1386
1388
  'report ONLY the current repo (the cwd working tree, fetch-first): skip the cross-repo registry loop entirely. The fast, focused path — no N-mirror fetches.',
1387
1389
  )
1390
+ .option(
1391
+ '--reconcile-locks',
1392
+ 'WRITE: release the per-item locks whose item is already at rest in a terminal folder on the arbiter’s main (e.g. a merged propose PR left its lock behind). Without this flag `scan` is strictly read-only. Not normally needed, the claim path releases them automatically on every unit of work. An item that is NOT terminal on main (a live build, an open PR, a stuck item) is NEVER released, with or without the flag.',
1393
+ )
1388
1394
  .option('--json', 'output the raw report as JSON')
1389
1395
  .action(async (flags: ScanFlags, command: Commander) => {
1390
1396
  const fileConfig = loadConfig(flags.config);
@@ -1403,6 +1409,7 @@ export function buildProgram(): Command {
1403
1409
  override,
1404
1410
  arbiterRemote: flags.arbiterRemote,
1405
1411
  lockArbiterRemote: flags.arbiter ?? 'origin',
1412
+ reconcileLocks: flags.reconcileLocks === true,
1406
1413
  warn,
1407
1414
  });
1408
1415
  // `--here`: report ONLY the cwd — skip the registry loop ENTIRELY (the fast,
@@ -1425,7 +1432,11 @@ export function buildProgram(): Command {
1425
1432
  }
1426
1433
  return;
1427
1434
  }
1428
- const report = await scan(config, {warn, override});
1435
+ const report = await scan(config, {
1436
+ warn,
1437
+ override,
1438
+ reconcileLocks: flags.reconcileLocks === true,
1439
+ });
1429
1440
  // The cwd-local section: resolve it ONLY when a participating cwd is NOT
1430
1441
  // already covered by a registered mirror. A FETCH-FREE pre-check
1431
1442
  // (`cwdSectionDisposition`) decides this with zero network I/O; an
@@ -3908,6 +3919,10 @@ export function buildProgram(): Command {
3908
3919
  '--here',
3909
3920
  'report ONLY the current repo (the cwd working tree, fetch-first): skip the jobs, registry-mirror, and arbiter sections entirely. "This repo, nothing else" — the fast, focused path.',
3910
3921
  )
3922
+ .option(
3923
+ '--reconcile-locks',
3924
+ 'WRITE: release the per-item locks reported as "Completed, lock not yet released" (item already at rest in a terminal folder on the arbiter’s main, e.g. a merged propose PR). Without this flag `status` is strictly read-only and only REPORTS them. Not normally needed, the claim path releases them automatically on every unit of work; this is the manual “drain them now” lever. An item that is NOT terminal on main (a live build, an open PR, a stuck item) is NEVER released, with or without the flag.',
3925
+ )
3911
3926
  .option('--json', 'output the raw report as JSON')
3912
3927
  .action(async (flags: StatusFlags) => {
3913
3928
  const config = resolveGlobalConfig(loadConfig(flags.config), {});
@@ -3923,6 +3938,7 @@ export function buildProgram(): Command {
3923
3938
  override,
3924
3939
  arbiterRemote: flags.arbiterRemote ?? DEFAULT_ARBITER_REMOTE,
3925
3940
  lockArbiterRemote: flags.arbiter ?? 'origin',
3941
+ reconcileLocks: flags.reconcileLocks === true,
3926
3942
  warn,
3927
3943
  });
3928
3944
  // `--here`: report ONLY the cwd — skip the jobs, registry-mirror, and arbiter
@@ -3976,6 +3992,7 @@ export function buildProgram(): Command {
3976
3992
  mirrorPaths,
3977
3993
  arbiter,
3978
3994
  cwd: cwdSection,
3995
+ reconcileLocks: flags.reconcileLocks === true,
3979
3996
  warn,
3980
3997
  });
3981
3998
  if (flags.json) {
package/src/complete.ts CHANGED
@@ -1260,10 +1260,17 @@ async function releaseClaimLockAfterDurableMove(
1260
1260
  if (!durablyOnMain) {
1261
1261
  // PROPOSE, PR open: the done-move is on the PR branch, not on `main`. KEEP the
1262
1262
  // lock held so the in-flight task is not re-claimed during the review window.
1263
+ // The wording used to promise "It is released when the PR merges (reconciled
1264
+ // against main)", which was FALSE: no dorfl process is present at merge time
1265
+ // (no hook, no daemon), so nothing could act on that event and every propose
1266
+ // build leaked its lock ref for ever. The release actually happens on the next
1267
+ // command that writes to the arbiter (the claim path sweeps stale terminal
1268
+ // locks), so the message now names a trigger that really fires.
1263
1269
  note(
1264
1270
  `'${slug}': keeping the per-item lock HELD (propose PR open; the work is ` +
1265
- 'not yet on main). It is released when the PR merges (reconciled against ' +
1266
- 'main).',
1271
+ 'not yet on main). Once the PR merges, the item comes to rest on main and ' +
1272
+ 'the lock is released by the next claim (or now, with `dorfl status ' +
1273
+ '--reconcile-locks`).',
1267
1274
  );
1268
1275
  return;
1269
1276
  }
@@ -8,7 +8,14 @@ import {
8
8
  heldTaskSlugsStrict,
9
9
  heldSpecSlugsStrict,
10
10
  listItemLockEntries,
11
+ classifyTerminalItemLocks,
12
+ reconcileTerminalItemLocks,
13
+ type LockEntry,
11
14
  } from './item-lock.js';
15
+ import {
16
+ classifyTerminalQuestionResidue,
17
+ reconcileTerminalQuestionResidue,
18
+ } from './needs-attention.js';
12
19
  import type {Config} from './config.js';
13
20
  import type {ConfigOverrideMap} from './config-override.js';
14
21
 
@@ -105,6 +112,36 @@ export interface CwdSection {
105
112
  registeredMirrorPath?: string;
106
113
  /** The cwd repo's arbiter + divergence (fetch-first). Absent when no arbiter. */
107
114
  arbiter?: CwdArbiter;
115
+ /**
116
+ * Lock `<entry>` names whose item is already at REST in a terminal folder on
117
+ * `<arbiter>/main`, finished work (typically a merged propose PR) whose lock
118
+ * has not been released yet. These are STALE, not in flight, and are reported
119
+ * separately from {@link RepoReport.lockHeld} so completed work never reads as
120
+ * in-progress. Purely a CLASSIFICATION: `status` releases nothing (it is
121
+ * read-only). They are released automatically by the next claim, or on demand
122
+ * via `--reconcile-locks` / `dorfl gc --ledger --reap-stale-locks`.
123
+ */
124
+ staleLocks?: string[];
125
+ /**
126
+ * Lock `<entry>` names this call actually RELEASED. Populated ONLY under the
127
+ * explicit `reconcileLocks` opt-in (`status --reconcile-locks`), which is the
128
+ * one way this otherwise read-only resolver writes to the arbiter. Empty/absent
129
+ * ⇒ nothing was written.
130
+ */
131
+ reconciledLocks?: string[];
132
+ /**
133
+ * Items resting in a TERMINAL folder on `main` that still carry question state
134
+ * (a stale bounce sidecar, and often a stranded `needsAnswers:true` gate over
135
+ * shipped work). Reported so the residue is visible; `status` clears nothing.
136
+ * These drain on the next claim.
137
+ */
138
+ staleQuestions?: string[];
139
+ /**
140
+ * Terminal items whose sidecar carries a human's ANSWER that was never applied.
141
+ * Deliberately NEVER auto-drained (the answer is prose the tool did not
142
+ * author), so it is surfaced for a human instead.
143
+ */
144
+ unappliedAnswers?: string[];
108
145
  }
109
146
 
110
147
  /** True iff the working repo has a git remote with name `remote`. The held-lock
@@ -204,6 +241,16 @@ export interface ResolveCwdSectionOptions {
204
241
  * arbiter even when the divergence remote is absent. Default `origin`.
205
242
  */
206
243
  lockArbiterRemote?: string;
244
+ /**
245
+ * OPT-IN WRITE (`status --reconcile-locks`). `false`/omitted (the DEFAULT)
246
+ * keeps this resolver strictly READ-ONLY: stale locks are classified and
247
+ * REPORTED via {@link CwdSection.staleLocks}, and nothing on the arbiter is
248
+ * touched. `true` additionally RELEASES the locks whose item is terminal on
249
+ * `<arbiter>/main`, reporting them via {@link CwdSection.reconciledLocks}.
250
+ * Not needed for routine convergence, the claim path already sweeps on every
251
+ * unit of work; this is the manual "drain them now" lever.
252
+ */
253
+ reconcileLocks?: boolean;
207
254
  /** Sink for the fetch-first fall-back warning (warn + last-known, never error). */
208
255
  warn?: (message: string) => void;
209
256
  env?: NodeJS.ProcessEnv;
@@ -261,6 +308,96 @@ export async function resolveCwdSection(
261
308
  // nothing to fail against — it keeps the empty set.
262
309
  const lockRemote = options.lockArbiterRemote ?? 'origin';
263
310
  const hasLockRemote = remoteExists(cwd, lockRemote, env);
311
+
312
+ // 2a. CLASSIFY the held locks against `main` (fix for the propose-path lock
313
+ // leak; observation
314
+ // `every-completed-task-leaves-its-lock-ref-reporting-in-progress`).
315
+ // `complete --propose` deliberately keeps the lock HELD across the open PR
316
+ // and promises it is "released when the PR merges (reconciled against
317
+ // main)", but NO dorfl process runs when a human clicks merge on GitHub,
318
+ // and there is no daemon, so that release could never fire. Every completed
319
+ // propose item therefore kept its lock ref and was reported in-progress for
320
+ // ever.
321
+ //
322
+ // `status` is READ-ONLY and stays that way: by DEFAULT this only
323
+ // CLASSIFIES, splitting the held locks into genuinely in-flight ones and
324
+ // ones whose item has come to REST in a terminal folder on
325
+ // `<arbiter>/main`. That alone fixes the MIS-REPORTING half of the bug
326
+ // finished work stops reading as in-progress, without this command
327
+ // mutating the arbiter. The REF is drained by the paths that already write
328
+ // on every unit of work (the claim path), or on demand here via the
329
+ // explicit `reconcileLocks` opt-in below.
330
+ //
331
+ // The terminal test is the item's POSITION ON MAIN, never a branch or a
332
+ // PR, so an item on an OPEN PR (body still in the pool on `main`) stays
333
+ // in-flight, as does a genuinely stuck one. Ordered BEFORE the held-slug
334
+ // reads below so, under the opt-in, the subtraction and the in-flight
335
+ // surface both see the reconciled truth in the SAME run.
336
+ //
337
+ // Best-effort by construction: neither call throws, and every uncertainty
338
+ // resolves to KEEP, so an offline or push-denied checkout degrades to the
339
+ // previous behaviour.
340
+ //
341
+ // The cwd is a WORKING CLONE, so the arbiter's main is the remote-tracking
342
+ // ref `<lockRemote>/main`, which is the default. (A bare hub mirror would
343
+ // need `mainRef: 'main'`; see the mirror callers in `status`/`scan`.)
344
+ let staleLocks: string[] = [];
345
+ let reconciledLocks: string[] = [];
346
+ // The in-flight entries, taken straight from the classification so the surface
347
+ // below needs no second `ls-remote` + fetch. `undefined` = not read at all.
348
+ let inFlightLocks: LockEntry[] | undefined;
349
+ if (hasLockRemote) {
350
+ if (options.reconcileLocks === true) {
351
+ // The ONLY write this resolver performs, and only when explicitly asked.
352
+ const swept = await reconcileTerminalItemLocks(cwd, lockRemote, env);
353
+ reconciledLocks = swept.released;
354
+ inFlightLocks = swept.stillHeld;
355
+ // Under an EXPLICIT drain request a refusal must be reported: the operator
356
+ // asked for these to go away, so silence would be a lie. (On the claim
357
+ // path the same errors are ignored on purpose: there the sweep is
358
+ // opportunistic hygiene, not something the operator asked for.)
359
+ for (const e of swept.errors) {
360
+ options.warn?.(`could not release lock '${e.entry}': ${e.message}`);
361
+ }
362
+ } else {
363
+ const classified = await classifyTerminalItemLocks(cwd, lockRemote, env);
364
+ staleLocks = classified.terminal.map((l) => l.entry);
365
+ inFlightLocks = classified.inFlight;
366
+ }
367
+ }
368
+
369
+ // 2b. The OTHER half of the same terminal residue: a bounced-then-rebuilt item
370
+ // whose sidecar + `needsAnswers` flag were never cleared (observation
371
+ // `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
372
+ // READ-ONLY here, exactly like the lock half: `status` reports it and the
373
+ // claim path drains it. Best-effort; a fault yields an empty report.
374
+ let staleQuestions: string[] = [];
375
+ let unappliedAnswers: string[] = [];
376
+ if (hasLockRemote && options.reconcileLocks !== true) {
377
+ const residue = await classifyTerminalQuestionResidue({
378
+ cwd,
379
+ arbiter: lockRemote,
380
+ mainRef: `${lockRemote}/main`,
381
+ env,
382
+ });
383
+ staleQuestions = residue.drainable.map((r) => r.item);
384
+ unappliedAnswers = residue.answeredHeld.map((r) => r.item);
385
+ } else if (hasLockRemote) {
386
+ // Under the explicit opt-in the question residue is DRAINED alongside the
387
+ // locks, through the one shared pass.
388
+ const drained = await reconcileTerminalQuestionResidue({
389
+ cwd,
390
+ arbiter: lockRemote,
391
+ mainRef: `${lockRemote}/main`,
392
+ env,
393
+ note: options.warn,
394
+ });
395
+ unappliedAnswers = drained.answeredHeld;
396
+ for (const e of drained.errors) {
397
+ options.warn?.(`could not drain questions for '${e.item}': ${e.message}`);
398
+ }
399
+ }
400
+
264
401
  const heldSlugs = hasLockRemote
265
402
  ? await heldTaskSlugsStrict(cwd, lockRemote, env)
266
403
  : new Set<string>();
@@ -307,10 +444,16 @@ export async function resolveCwdSection(
307
444
  // different fault models (selection fails closed; the surface degrades).
308
445
  // Read from the SAME coordination remote the subtraction used. A repo with no
309
446
  // coordination remote has no lock refs to read — empty surface.
310
- if (repo !== undefined && hasLockRemote) {
311
- const lockHeld = await listItemLockEntries(cwd, lockRemote, env);
312
- if (lockHeld.length > 0) {
313
- repo.lockHeld = lockHeld;
447
+ if (repo !== undefined && inFlightLocks !== undefined) {
448
+ // Taken STRAIGHT from the classification in 2a, so the stale ones are already
449
+ // excluded. This is the reporting half of the propose-lock-leak fix: a lock
450
+ // whose item is already at REST on `main` is finished work, NOT an in-flight
451
+ // hold, so it must not appear under "In progress" (the symptom being fixed:
452
+ // completed tasks reported in-progress for ever). It is surfaced under
453
+ // `staleLocks` instead. Reusing that result also avoids a second `ls-remote` +
454
+ // fetch and the TOCTOU window between two separate reads.
455
+ if (inFlightLocks.length > 0) {
456
+ repo.lockHeld = inFlightLocks;
314
457
  }
315
458
  }
316
459
 
@@ -326,6 +469,10 @@ export async function resolveCwdSection(
326
469
  path: cwd,
327
470
  participating: true,
328
471
  repo,
472
+ staleLocks,
473
+ reconciledLocks,
474
+ staleQuestions,
475
+ unappliedAnswers,
329
476
  totalItems: localReport.totalItems,
330
477
  totalEligible: localReport.totalEligible,
331
478
  alsoRegistered,
package/src/format.ts CHANGED
@@ -199,6 +199,78 @@ export function formatCwdSection(section: CwdSection): string[] {
199
199
  lines.push(...formatLockEntryLines(entry, ' '));
200
200
  }
201
201
  }
202
+
203
+ // STALE locks, finished work whose lock has not been released yet (fix for the
204
+ // propose-path lock leak). `complete --propose` keeps the lock held and defers
205
+ // its release to the PR merge, an event dorfl is never present for, so these
206
+ // accumulate. They are shown as their OWN block and NOT under "In progress":
207
+ // reporting completed work as in-progress for ever was the symptom. `status` is
208
+ // read-only, so it names the state and how it clears rather than clearing it.
209
+ const stale = section.staleLocks ?? [];
210
+ if (stale.length > 0) {
211
+ lines.push('');
212
+ lines.push(
213
+ ` Completed, lock not yet released (${stale.length}; item is at rest ` +
214
+ 'on main, so this is NOT in flight):',
215
+ );
216
+ for (const entry of stale) {
217
+ lines.push(` ${entry}`);
218
+ }
219
+ lines.push(
220
+ ' These clear automatically on the next claim. To drain them now: ' +
221
+ '`dorfl status --reconcile-locks`.',
222
+ );
223
+ }
224
+
225
+ // STRANDED QUESTION STATE on terminal items: a bounce wrote a sidecar +
226
+ // `needsAnswers:true` atomically, the human re-dispatched instead of answering,
227
+ // the rebuild succeeded, and neither half was ever cleared. The flag is the
228
+ // harmful part: it is a gate left armed over shipped work, which is why this is
229
+ // surfaced rather than left to be discovered in the questions folder.
230
+ const staleQuestions = section.staleQuestions ?? [];
231
+ if (staleQuestions.length > 0) {
232
+ lines.push('');
233
+ lines.push(
234
+ ` Completed, question state not yet cleared (${staleQuestions.length}; ` +
235
+ 'stale bounce sidecar and/or a needsAnswers gate on a finished item):',
236
+ );
237
+ for (const item of staleQuestions) {
238
+ lines.push(` ${item}`);
239
+ }
240
+ lines.push(
241
+ ' These clear automatically on the next claim. To drain them now: ' +
242
+ '`dorfl status --reconcile-locks`.',
243
+ );
244
+ }
245
+
246
+ // A human's ANSWER that nothing ever applied. Never auto-drained, because the
247
+ // prose is data the tool did not author; it needs a human, so it is named.
248
+ const unapplied = section.unappliedAnswers ?? [];
249
+ if (unapplied.length > 0) {
250
+ lines.push('');
251
+ lines.push(
252
+ ` Answered but never applied (${unapplied.length}; the sidecar carries ` +
253
+ 'your answer and the item is already finished, kept, never auto-deleted):',
254
+ );
255
+ for (const item of unapplied) {
256
+ lines.push(` ${item}`);
257
+ }
258
+ }
259
+
260
+ // Locks this run actually RELEASED (only under the explicit
261
+ // `--reconcile-locks` opt-in, the one way `status` writes). Always reported,
262
+ // never silent.
263
+ const reconciled = section.reconciledLocks ?? [];
264
+ if (reconciled.length > 0) {
265
+ lines.push('');
266
+ lines.push(
267
+ ` Released ${reconciled.length} completed lock(s) (item is terminal on ` +
268
+ 'main, the propose PR merged out-of-band):',
269
+ );
270
+ for (const entry of reconciled) {
271
+ lines.push(` ${entry}`);
272
+ }
273
+ }
202
274
  lines.push('');
203
275
  lines.push(
204
276
  ` Local total: ${total} ${pluralItems(total)} ` +
package/src/index.ts CHANGED
@@ -341,6 +341,8 @@ export {
341
341
  releaseHeldItemLock,
342
342
  readItemLock,
343
343
  reconcileItemLockAgainstMain,
344
+ reconcileTerminalItemLocks,
345
+ classifyTerminalItemLocks,
344
346
  classifyItemLockAgainstMain,
345
347
  reportItemLocks,
346
348
  formatItemLockReport,