dorfl 0.13.3 → 0.14.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.
@@ -2145,20 +2145,32 @@ export function prepareTreelessSurfaceCommit(params: {
2145
2145
  /**
2146
2146
  * The kind of TERMINAL resting place an item has reached on `main`, which
2147
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.
2148
+ * - `completed`, the work HAPPENED (`tasks/done/`). Any surviving question
2149
+ * state is pure residue: the questions were about how to proceed, and the
2150
+ * item proceeded.
2151
2151
  * - `wont-proceed`, the item was ABANDONED (`tasks/cancelled/`,
2152
2152
  * `specs/dropped/`). Here `needsAnswers:true` may be ACCURATE HISTORY: an
2153
2153
  * item can be cancelled precisely BECAUSE its questions were never answered,
2154
2154
  * and the body may carry a real `## Open questions` section recording that.
2155
+ *
2156
+ * NOTE what is ABSENT: `specs/tasked/`. It is a terminal RESIDENCE, but this map
2157
+ * is keyed to "is the question loop CLOSED here?", not "has the item stopped
2158
+ * moving?", and on a tasked spec the loop is explicitly still open (see the
2159
+ * `case 'spec'` comment below).
2155
2160
  */
2156
2161
  export type TerminalKind = 'completed' | 'wont-proceed';
2157
2162
 
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. */
2163
+ /**
2164
+ * The terminal `work/` paths for an item, tagged by {@link TerminalKind}, so a
2165
+ * reader can tell "the work happened" from "the item was abandoned".
2166
+ *
2167
+ * Same SHAPE as `terminalMainPaths` in `item-lock.ts`, but deliberately NOT the
2168
+ * same folder set, and the difference must not be "tidied" away: locks treat
2169
+ * `specs/tasked/` as terminal (correctly, a tasked spec must release its lock),
2170
+ * whereas QUESTION state there is still live. This map is keyed to "is the
2171
+ * question loop CLOSED at this resting place?", not "has the item stopped
2172
+ * moving?". See the `case 'spec'` comment below.
2173
+ */
2162
2174
  export function terminalMainPathsByKind(
2163
2175
  type: SidecarType,
2164
2176
  slug: string,
@@ -2171,10 +2183,26 @@ export function terminalMainPathsByKind(
2171
2183
  {path: workItemRel('cancelled', file), kind: 'wont-proceed'},
2172
2184
  ];
2173
2185
  case 'spec':
2174
- return [
2175
- {path: workItemRel('specs-tasked', file), kind: 'completed'},
2176
- {path: workItemRel('specs-dropped', file), kind: 'wont-proceed'},
2177
- ];
2186
+ // `specs/tasked/` is deliberately NOT listed. WORK-CONTRACT ("A SPEC that
2187
+ // has drifted AFTER it was TASKED") makes a bare `needsAnswers:true` on a
2188
+ // tasked spec LEGAL and load-bearing: it means "tasked, but the spec has
2189
+ // drifted, do not RE-task or rely on it until reconciled", and the
2190
+ // contract says to set it *while the spec stays in `specs/tasked/`*
2191
+ // (moving it back would falsely un-record a tasking that really happened
2192
+ // and orphan the tasks it already emitted).
2193
+ //
2194
+ // So the reasoning that makes a task's question state moot at its terminal
2195
+ // does NOT transfer: a tasked spec is still IN the question loop.
2196
+ // `lifecycle-gather.ts` enumerates tasked resting specs UNCONDITIONALLY,
2197
+ // routing a bare flag to the SURFACE rung and an answered sidecar to the
2198
+ // APPLY rung, so BOTH halves are live inputs to a rung that WILL run.
2199
+ // Draining either would disarm a live drift gate and let a stale spec be
2200
+ // re-tasked. That is precisely the "clearing a live needsAnswers hands gated work
2201
+ // to agents" harm this pass exists to avoid.
2202
+ //
2203
+ // `specs/dropped/` needs no such carve-out: a dropped spec is abandoned,
2204
+ // and no rung enumerates it.
2205
+ return [{path: workItemRel('specs-dropped', file), kind: 'wont-proceed'}];
2178
2206
  case 'observation':
2179
2207
  // A note has no durable terminal folder: it leaves by DELETION, so there
2180
2208
  // is no resting record to reconcile against.
@@ -2182,6 +2210,56 @@ export function terminalMainPathsByKind(
2182
2210
  }
2183
2211
  }
2184
2212
 
2213
+ /** Every SUCCESS-terminal folder that can hold a stranded `needsAnswers` flag,
2214
+ * paired with the item type that rests there. Derived from
2215
+ * {@link terminalMainPathsByKind} with a sentinel slug so the folder set stays
2216
+ * SINGLE-SOURCED: adding a regime there adds it here, and the `wont-proceed`
2217
+ * terminals are excluded by the SAME `kind` split the drain already branches on
2218
+ * (a cancelled item's flag is accurate history, not residue). `observation`
2219
+ * contributes nothing, having no durable terminal. */
2220
+ function successTerminalFolders(): {folder: string; type: SidecarType}[] {
2221
+ const out: {folder: string; type: SidecarType}[] = [];
2222
+ for (const type of ['task', 'spec', 'observation'] as const) {
2223
+ for (const candidate of terminalMainPathsByKind(type, '__slug__')) {
2224
+ if (candidate.kind !== 'completed') {
2225
+ continue;
2226
+ }
2227
+ out.push({
2228
+ folder: candidate.path.slice(0, candidate.path.lastIndexOf('/')),
2229
+ type,
2230
+ });
2231
+ }
2232
+ }
2233
+ return out;
2234
+ }
2235
+
2236
+ /**
2237
+ * A SUCCESS-terminal item carrying a STRANDED `needsAnswers:true` flag with NO
2238
+ * sidecar beside it: the residue's harmful half, on its own.
2239
+ *
2240
+ * This is NOT the mirror state the classifier calls legal. `needsAnswers:true`
2241
+ * with no sidecar IS normal on a POOL or STAGING item (it is precisely the
2242
+ * `surface` rung's input, and clearing it there would disarm every un-surfaced
2243
+ * gated item in the repo). What makes THIS shape residue is the POSITION: the
2244
+ * item has already SHIPPED, so there is no question left to surface and no
2245
+ * answer that could still be typed, because `surface` will never run on it again.
2246
+ *
2247
+ * It is reached whenever the two halves are separated in the one order the
2248
+ * sidecar-anchored sweep cannot follow: the SIDECAR goes first and the FLAG is
2249
+ * left behind. A human tidying `work/questions/` by hand does exactly that (the
2250
+ * obvious manual clean-up, and the sidecar is the visible half), which is how
2251
+ * the fix
2252
+ * for the paired residue can report success while any gate it cannot see stays
2253
+ * armed. Anchoring only on the sidecar set makes hand-cleanup permanently strand
2254
+ * the half that actually gates work.
2255
+ */
2256
+ export interface TerminalFlagResidue {
2257
+ /** The namespaced identity (`task:<slug>`). */
2258
+ item: string;
2259
+ /** The item body's SUCCESS-terminal path on `main`. */
2260
+ itemPath: string;
2261
+ }
2262
+
2185
2263
  /** One item whose question state survived into a terminal resting place. */
2186
2264
  export interface TerminalQuestionResidue {
2187
2265
  /** The namespaced identity (`task:<slug>`). */
@@ -2212,6 +2290,12 @@ export interface TerminalQuestionReport {
2212
2290
  * silently deleted (the answer is data the tool did not author).
2213
2291
  */
2214
2292
  answeredHeld: TerminalQuestionResidue[];
2293
+ /**
2294
+ * SUCCESS-terminal items whose `needsAnswers` gate is armed with NO sidecar
2295
+ * beside it. Cleared by the drain (there is no sidecar, so nothing a human
2296
+ * wrote can be discarded). See {@link TerminalFlagResidue}.
2297
+ */
2298
+ staleFlags: TerminalFlagResidue[];
2215
2299
  errors: {item: string; message: string}[];
2216
2300
  }
2217
2301
 
@@ -2250,11 +2334,21 @@ export interface TerminalQuestionReport {
2250
2334
  * folder on `main`; an item resting in a pool or staging folder keeps whatever
2251
2335
  * state it has, untouched.
2252
2336
  *
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.
2337
+ * TWO ENUMERATIONS, because the two halves can be separated in either order and
2338
+ * a sweep anchored on one is blind to the other:
2339
+ * 1. the SIDECAR SET (`work/questions/` on `main`), small and cheap to list,
2340
+ * which finds a stale sidecar and the flag paired with it; and
2341
+ * 2. the SUCCESS-TERMINAL BODIES that are flagged with NO sidecar beside them
2342
+ * ({@link collectStrandedTerminalFlags}), which finds the armed gate ALONE.
2343
+ *
2344
+ * (2) is not optional tidiness. It is the half that actually gates work, and a
2345
+ * sweep anchored only on (1) reports success while any gate it cannot see stays
2346
+ * armed. The sidecar is the
2347
+ * half a human deletes by hand (it is the visible one, in a folder they scan),
2348
+ * and deleting it REMOVES the only handle (1) has, stranding the flag for good.
2349
+ * The discriminator that keeps (2) safe is POSITION, exactly as for (1): a bare
2350
+ * flag is LEGAL on a pool/staging item (the `surface` rung's input) and residue
2351
+ * only once the item has shipped, where `surface` can never run again.
2258
2352
  *
2259
2353
  * Best-effort and never throws.
2260
2354
  */
@@ -2304,8 +2398,16 @@ function deriveTerminalQuestionResidue(
2304
2398
  const out: TerminalQuestionReport = {
2305
2399
  drainable: [],
2306
2400
  answeredHeld: [],
2401
+ staleFlags: [],
2307
2402
  errors: [],
2308
2403
  };
2404
+ // The SECOND half of the residue, enumerated from the OTHER side. The sidecar
2405
+ // sweep below can only ever see items that still HAVE a sidecar; this one finds
2406
+ // the SUCCESS-terminal bodies whose gate is armed with no sidecar left to point
2407
+ // at them. Both must run: they are the same defect observed through the two
2408
+ // halves the surface path writes atomically, and either half can outlive the
2409
+ // other.
2410
+ collectStrandedTerminalFlags(mainRef, cwd, env, out);
2309
2411
  const questionsDir = workFolderRel('questions');
2310
2412
  const ls = run(
2311
2413
  'git',
@@ -2392,6 +2494,144 @@ function deriveTerminalQuestionResidue(
2392
2494
  return out;
2393
2495
  }
2394
2496
 
2497
+ /**
2498
+ * Find every SUCCESS-terminal body on `base` carrying `needsAnswers:true` with NO
2499
+ * sidecar beside it, appending them to `out.staleFlags`.
2500
+ *
2501
+ * ENUMERATION COST is why this is a `git grep` and not a walk. The terminal
2502
+ * folders are the repo's largest and most monotonically growing (this repo holds
2503
+ * 404 done tasks), and this runs on the CLAIM path, so reading every terminal
2504
+ * body per claim would be a real tax on a hot path. One `git grep -l` returns
2505
+ * only the candidates, and the frontmatter parse runs over that short list.
2506
+ *
2507
+ * The pattern is ANCHORED to match the PARSER rather than the word.
2508
+ * `parseFrontmatter` reads keys with `/^([A-Za-z0-9_.]+)\s*:\s*(.*)$/`, so a key
2509
+ * it will honour is always at column 0; an unanchored needle instead matches
2510
+ * every body that merely DISCUSSES the flag, which in `work/tasks/done/` here is
2511
+ * 77 files against 18 anchored, and the truthy form narrows it to 1.
2512
+ *
2513
+ * The value part is matched LOOSELY on purpose (optional quote, any case),
2514
+ * because `toBoolean` unquotes and lower-cases before comparing, so
2515
+ * `needsAnswers: 'True'` is a real armed gate. A needle of `:\s*true` would read
2516
+ * tighter and be WRONG: it would silently skip those bodies for ever, which is
2517
+ * the blind-spot class this function exists to remove. A superset is the safe
2518
+ * direction for a shortlist; a subset is not.
2519
+ *
2520
+ * The grep is still only a CANDIDATE FILTER, never the decision: prose can sit
2521
+ * at column 0 too (`needsAnswers: true?` appears in this repo's own bodies), so
2522
+ * every hit is confirmed by actually PARSING the frontmatter.
2523
+ *
2524
+ * Two git-isms are pinned rather than left to the environment:
2525
+ * - `core.quotePath=false`, or git C-quotes any non-ASCII path
2526
+ * (`"work/.../caf\303\251.md"`). A quoted line still starts with the
2527
+ * `<base>:` prefix but then fails the folder-prefix test, so such a body
2528
+ * would be SILENTLY skipped for ever, a permanent blind spot of exactly the
2529
+ * class this function exists to remove.
2530
+ * - `--full-name` + `:(top,literal)` pathspecs, because `git grep`'s pathspecs
2531
+ * are CWD-RELATIVE (unlike the `ls-tree`/`cat-file` probes elsewhere here,
2532
+ * which are tree-relative) and are globs. Without these, running any dorfl
2533
+ * command from a SUBDIRECTORY makes this half a silent no-op while the
2534
+ * sidecar half keeps working.
2535
+ *
2536
+ * Never throws; a failed grep yields no candidates, which leaves state alone.
2537
+ */
2538
+ function collectStrandedTerminalFlags(
2539
+ base: string,
2540
+ cwd: string,
2541
+ env: NodeJS.ProcessEnv | undefined,
2542
+ out: TerminalQuestionReport,
2543
+ ): void {
2544
+ const folders = successTerminalFolders();
2545
+ if (folders.length === 0) {
2546
+ return;
2547
+ }
2548
+ // `-l` names files only, `-I` skips binaries. Exit 1 means NO MATCH, which
2549
+ // ALSO covers "the folder does not exist on this base yet" (verified: an
2550
+ // absent pathspec folder exits 1 with no stderr), and an absent lifecycle
2551
+ // folder is legal per WORK-CONTRACT rule 3. Any OTHER non-zero is a genuine
2552
+ // fault and is REPORTED rather than swallowed: degrading silently to "no
2553
+ // candidates" would leave this half a no-op while the sidecar half keeps
2554
+ // reporting success, which is the very "reports success while the gate stays
2555
+ // armed" shape this change exists to correct.
2556
+ const grep = run(
2557
+ 'git',
2558
+ [
2559
+ '-c',
2560
+ 'core.quotePath=false',
2561
+ 'grep',
2562
+ '-l',
2563
+ '-I',
2564
+ '--full-name',
2565
+ '-E',
2566
+ '^needsAnswers:[[:space:]]*[\'"]?[Tt][Rr][Uu][Ee]',
2567
+ base,
2568
+ '--',
2569
+ ...folders.map((f) => `:(top,literal)${f.folder}`),
2570
+ ],
2571
+ cwd,
2572
+ {env},
2573
+ );
2574
+ if (grep.status !== 0) {
2575
+ if (grep.status !== 1) {
2576
+ out.errors.push({
2577
+ item: '(stranded-flag scan)',
2578
+ message:
2579
+ `git grep over the terminal folders failed (exit ${grep.status}): ` +
2580
+ `${grep.stderr.trim() || 'no stderr'}; stranded gates were NOT scanned.`,
2581
+ });
2582
+ }
2583
+ return;
2584
+ }
2585
+ const prefix = `${base}:`;
2586
+ for (const line of grep.stdout.split('\n')) {
2587
+ const raw = line.trim();
2588
+ if (raw === '' || !raw.startsWith(prefix)) {
2589
+ continue;
2590
+ }
2591
+ const path = raw.slice(prefix.length);
2592
+ try {
2593
+ const home = folders.find((f) => path.startsWith(`${f.folder}/`));
2594
+ if (home === undefined) {
2595
+ continue;
2596
+ }
2597
+ const name = path.slice(home.folder.length + 1);
2598
+ // Direct children only: a nested path is not an item body.
2599
+ if (name.includes('/') || !isWorkItemFile(name)) {
2600
+ continue;
2601
+ }
2602
+ // Case-INSENSITIVE to match `isWorkItemFile` above: a `Foo.MD` body must
2603
+ // yield the slug `Foo`, or the sidecar-existence guard below would probe
2604
+ // the wrong path and could clear a gate whose sidecar holds an answer.
2605
+ const slug = name.replace(/\.md$/i, '');
2606
+ if (slug === '') {
2607
+ continue;
2608
+ }
2609
+ const item = `${home.type}:${slug}`;
2610
+ // A sidecar STILL EXISTS for this item (canonical or legacy alias) ⇒ this
2611
+ // is the sidecar-anchored sweep's business, not ours. Skipping keeps the
2612
+ // two enumerations DISJOINT, so an item is never planned twice in one
2613
+ // commit and the answered-sidecar carve-out cannot be bypassed through
2614
+ // this path (an item held for an unapplied human answer keeps its flag).
2615
+ if (
2616
+ sidecarPathCandidates(item).some((c) => pathInCommit(base, c, cwd, env))
2617
+ ) {
2618
+ continue;
2619
+ }
2620
+ // CONFIRM against the parsed frontmatter: the grep only shortlisted.
2621
+ const body = catBlob(`${base}:${path}`, cwd, env);
2622
+ if (parseFrontmatter(body).needsAnswers !== true) {
2623
+ continue;
2624
+ }
2625
+ out.staleFlags.push({item, itemPath: path});
2626
+ } catch (err) {
2627
+ out.errors.push({
2628
+ item: path,
2629
+ message: err instanceof Error ? err.message : String(err),
2630
+ });
2631
+ }
2632
+ }
2633
+ }
2634
+
2395
2635
  /** What a {@link reconcileTerminalQuestionResidue} pass did. */
2396
2636
  export interface TerminalQuestionDrainResult {
2397
2637
  /** Items whose sidecar was deleted. */
@@ -2409,19 +2649,28 @@ export interface TerminalQuestionDrainResult {
2409
2649
  * the SAME {@link runTreelessLedgerMove} core the surface path uses (same
2410
2650
  * contention-retry, same lease, same write seam; there is no second mechanism).
2411
2651
  *
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.
2652
+ * WHAT IT CLEARS, and the deliberate asymmetry between the two terminals. Note
2653
+ * the terminal SET first: `specs/tasked/` is deliberately NOT in this map at all
2654
+ * (see {@link terminalMainPathsByKind}), so nothing below applies to a tasked
2655
+ * spec, whose question state stays untouched in both halves.
2656
+ * - the SIDECAR is deleted for EITHER terminal in the map. A question asking
2657
+ * whether to cancel an item that has already come to rest is stale in both
2658
+ * cases, and it sits in a folder a human scans carrying a destructive
2659
+ * default.
2416
2660
  * - the `needsAnswers` FLAG is cleared ONLY for a `completed` terminal
2417
- * (`tasks/done/`, `specs/tasked/`). On a `wont-proceed` terminal
2661
+ * (`tasks/done/`). On a `wont-proceed` terminal
2418
2662
  * (`tasks/cancelled/`, `specs/dropped/`) the flag is KEPT, because an item
2419
2663
  * can be cancelled precisely BECAUSE its questions were never answered: there
2420
2664
  * the flag is accurate history, not residue, and the body may carry a real
2421
2665
  * `## Open questions` section saying so. Keeping it is harmless, a terminal
2422
2666
  * item is in no pool, so the flag gates nothing.
2667
+ * - a SUCCESS-terminal item whose gate is armed with NO sidecar left beside it
2668
+ * has that FLAG cleared and nothing deleted (there is nothing to delete).
2669
+ * Restricted to the `completed` terminal by the same asymmetry above.
2423
2670
  *
2424
- * A sidecar with ANY answered entry is never touched (see the classifier).
2671
+ * A sidecar with ANY answered entry is never touched (see the classifier), and
2672
+ * an item still holding such a sidecar is excluded from the flag-only half too,
2673
+ * so the carve-out cannot be bypassed by clearing its gate.
2425
2674
  *
2426
2675
  * Best-effort: it never throws, and any fault leaves the state exactly as it was.
2427
2676
  */
@@ -2461,7 +2710,7 @@ export async function reconcileTerminalQuestionResidue(params: {
2461
2710
  }
2462
2711
  result.answeredHeld = report.answeredHeld.map((r) => r.item);
2463
2712
  result.errors.push(...report.errors);
2464
- if (report.drainable.length === 0) {
2713
+ if (report.drainable.length === 0 && report.staleFlags.length === 0) {
2465
2714
  return result;
2466
2715
  }
2467
2716
  // What the LANDED commit ACTUALLY did, filled in by the plan against the base
@@ -2469,6 +2718,10 @@ export async function reconcileTerminalQuestionResidue(params: {
2469
2718
  // anything to do?" probe; reporting from it would claim a gate was disarmed
2470
2719
  // when a contention retry re-derived the residue and skipped the item.
2471
2720
  let applied: TerminalQuestionResidue[] = [];
2721
+ // Filled by the PLAN with what it actually STAGED (not what it intended), so a
2722
+ // body the marker writer cannot annotate is never reported as unflagged.
2723
+ const clearedSidecarFlags: TerminalQuestionResidue[] = [];
2724
+ const clearedStaleFlags: TerminalFlagResidue[] = [];
2472
2725
  // NEVER THROW. `runTreelessLedgerMove` and the git plumbing inside the plan
2473
2726
  // both throw on any non-zero git, and this pass runs from the CLAIM path as
2474
2727
  // OPPORTUNISTIC HYGIENE on unrelated items. A fault here (a stale scratch ref,
@@ -2499,6 +2752,9 @@ export async function reconcileTerminalQuestionResidue(params: {
2499
2752
  cwd,
2500
2753
  base,
2501
2754
  residue: fresh.drainable,
2755
+ staleFlags: fresh.staleFlags,
2756
+ clearedSidecarFlags,
2757
+ clearedStaleFlags,
2502
2758
  env,
2503
2759
  });
2504
2760
  },
@@ -2521,13 +2777,58 @@ export async function reconcileTerminalQuestionResidue(params: {
2521
2777
  }
2522
2778
  for (const r of applied) {
2523
2779
  result.drained.push(r.item);
2524
- if (r.terminal === 'completed' && r.flagged) {
2525
- result.unflagged.push(r.item);
2526
- }
2780
+ }
2781
+ // `unflagged` reports what the commit ACTUALLY staged, from both halves. The
2782
+ // flag-only half never appears in `drained`: it deletes nothing.
2783
+ for (const r of [...clearedSidecarFlags, ...clearedStaleFlags]) {
2784
+ result.unflagged.push(r.item);
2527
2785
  }
2528
2786
  return result;
2529
2787
  }
2530
2788
 
2789
+ /**
2790
+ * Stage `itemPath` with `needsAnswers` cleared, into the scratch index the drain
2791
+ * commit is being built in. Shared by BOTH halves of the residue (the
2792
+ * sidecar-paired flag and the stranded flag-only one) so they can never disagree
2793
+ * about what clearing a gate means.
2794
+ *
2795
+ * Defense-in-depth, mirroring the surface path's guard in the opposite
2796
+ * direction: if the marker does not parse back as `false`, the body is left
2797
+ * ALONE rather than written as something we cannot vouch for. Every uncertainty
2798
+ * resolves to LEAVING STATE ALONE.
2799
+ *
2800
+ * RETURNS whether the gate was actually STAGED, so callers report EFFECT rather
2801
+ * than INTENT. That distinction is load-bearing here: a body this cannot
2802
+ * annotate (e.g. duplicate `needsAnswers` keys, where the writer replaces the
2803
+ * FIRST and the parser reads the LAST) would otherwise be reported as unflagged
2804
+ * on every claim for ever while its gate stayed armed, the precise
2805
+ * "reports success while the defect remains" failure this whole change exists to
2806
+ * correct.
2807
+ */
2808
+ function clearNeedsAnswersInIndex(
2809
+ itemPath: string,
2810
+ base: string,
2811
+ cwd: string,
2812
+ env: NodeJS.ProcessEnv | undefined,
2813
+ withIndex: NodeJS.ProcessEnv,
2814
+ ): boolean {
2815
+ if (!pathInCommit(base, itemPath, cwd, env)) {
2816
+ return false;
2817
+ }
2818
+ const body = catBlob(`${base}:${itemPath}`, cwd, env);
2819
+ const cleared = setNeedsAnswersMarker(body, false);
2820
+ if (parseFrontmatter(cleared).needsAnswers !== false) {
2821
+ return false;
2822
+ }
2823
+ const blob = hashObject(cleared, cwd, env);
2824
+ gitHard(
2825
+ ['update-index', '--add', '--cacheinfo', `100644,${blob},${itemPath}`],
2826
+ cwd,
2827
+ withIndex,
2828
+ );
2829
+ return true;
2830
+ }
2831
+
2531
2832
  /**
2532
2833
  * Build the ONE tree-less commit that removes every drainable sidecar and clears
2533
2834
  * the `needsAnswers` flag on every `completed`-terminal body, using PLUMBING on a
@@ -2542,14 +2843,29 @@ function prepareTerminalQuestionDrainCommit(params: {
2542
2843
  cwd: string;
2543
2844
  base: string;
2544
2845
  residue: TerminalQuestionResidue[];
2846
+ staleFlags: TerminalFlagResidue[];
2847
+ /**
2848
+ * OUT-PARAM: filled with the items whose gate was ACTUALLY staged as cleared,
2849
+ * so the caller reports EFFECT rather than intent. Cleared on entry, because a
2850
+ * contention retry re-plans against a fresh base and the previous attempt's
2851
+ * result must not leak into the report.
2852
+ */
2853
+ clearedSidecarFlags: TerminalQuestionResidue[];
2854
+ clearedStaleFlags: TerminalFlagResidue[];
2545
2855
  env: NodeJS.ProcessEnv | undefined;
2546
2856
  }): TreelessAttemptPlan {
2547
- const {cwd, base, residue, env} = params;
2857
+ const {cwd, base, residue, env, clearedSidecarFlags, clearedStaleFlags} =
2858
+ params;
2859
+ clearedSidecarFlags.length = 0;
2860
+ clearedStaleFlags.length = 0;
2548
2861
  // RE-DERIVE against THIS base: anything already gone is not our business.
2549
2862
  const live = residue.filter((r) =>
2550
2863
  pathInCommit(base, r.sidecarPath, cwd, env),
2551
2864
  );
2552
- if (live.length === 0) {
2865
+ const liveFlags = params.staleFlags.filter((r) =>
2866
+ pathInCommit(base, r.itemPath, cwd, env),
2867
+ );
2868
+ if (live.length === 0 && liveFlags.length === 0) {
2553
2869
  return 'already-done';
2554
2870
  }
2555
2871
  const scratchIndex = join(
@@ -2573,34 +2889,27 @@ function prepareTerminalQuestionDrainCommit(params: {
2573
2889
  if (r.terminal !== 'completed' || !r.flagged) {
2574
2890
  continue;
2575
2891
  }
2576
- if (!pathInCommit(base, r.itemPath, cwd, env)) {
2577
- continue;
2892
+ if (clearNeedsAnswersInIndex(r.itemPath, base, cwd, env, withIndex)) {
2893
+ clearedSidecarFlags.push(r);
2578
2894
  }
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;
2895
+ }
2896
+ // The FLAG-ONLY half: a SUCCESS terminal whose sidecar is already gone. No
2897
+ // `--force-remove` here, because there is nothing to delete; the armed gate IS the
2898
+ // whole residue.
2899
+ // Record what was actually STAGED: a body we could not annotate is dropped
2900
+ // from the report rather than claimed as cleared.
2901
+ for (const r of liveFlags) {
2902
+ if (clearNeedsAnswersInIndex(r.itemPath, base, cwd, env, withIndex)) {
2903
+ clearedStaleFlags.push(r);
2586
2904
  }
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
2905
  }
2599
2906
  const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
2907
+ const touched = live.length + liveFlags.length;
2908
+ const only = live[0]?.item ?? liveFlags[0]?.item;
2600
2909
  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`;
2910
+ touched === 1
2911
+ ? `drain stranded question state for ${only} (terminal on main)`
2912
+ : `drain stranded question state for ${touched} terminal items`;
2604
2913
  const commit = runHard(
2605
2914
  ['commit-tree', tree, '-p', base, '-m', subject],
2606
2915
  cwd,