dorfl 0.13.2 → 0.13.4
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.
- package/dist/claim-cas.d.ts.map +1 -1
- package/dist/claim-cas.js +39 -0
- package/dist/claim-cas.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +9 -2
- package/dist/complete.js.map +1 -1
- package/dist/cwd-section.d.ts +40 -0
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +111 -5
- package/dist/cwd-section.js.map +1 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +56 -0
- package/dist/format.js.map +1 -1
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +16 -4
- package/dist/frontmatter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/item-lock.d.ts +167 -0
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +255 -1
- package/dist/item-lock.js.map +1 -1
- package/dist/needs-attention.d.ts +216 -1
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +595 -2
- package/dist/needs-attention.js.map +1 -1
- package/dist/reconcile-terminal.d.ts +97 -0
- package/dist/reconcile-terminal.d.ts.map +1 -0
- package/dist/reconcile-terminal.js +88 -0
- package/dist/reconcile-terminal.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +51 -2
- package/dist/scan.js.map +1 -1
- package/dist/status.d.ts +28 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +79 -2
- package/dist/status.js.map +1 -1
- package/package.json +1 -1
- package/src/claim-cas.ts +40 -0
- package/src/cli.ts +18 -1
- package/src/complete.ts +9 -2
- package/src/cwd-section.ts +157 -4
- package/src/format.ts +72 -0
- package/src/frontmatter.ts +16 -4
- package/src/index.ts +2 -0
- package/src/item-lock.ts +369 -1
- package/src/needs-attention.ts +783 -0
- package/src/reconcile-terminal.ts +180 -0
- package/src/scan.ts +82 -5
- package/src/status.ts +131 -6
package/src/needs-attention.ts
CHANGED
|
@@ -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,787 @@ 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/`). Any surviving question
|
|
2149
|
+
* state is pure residue: the questions were about how to proceed, and the
|
|
2150
|
+
* 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
|
+
* 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).
|
|
2160
|
+
*/
|
|
2161
|
+
export type TerminalKind = 'completed' | 'wont-proceed';
|
|
2162
|
+
|
|
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
|
+
*/
|
|
2174
|
+
export function terminalMainPathsByKind(
|
|
2175
|
+
type: SidecarType,
|
|
2176
|
+
slug: string,
|
|
2177
|
+
): {path: string; kind: TerminalKind}[] {
|
|
2178
|
+
const file = `${slug}.md`;
|
|
2179
|
+
switch (type) {
|
|
2180
|
+
case 'task':
|
|
2181
|
+
return [
|
|
2182
|
+
{path: workItemRel('done', file), kind: 'completed'},
|
|
2183
|
+
{path: workItemRel('cancelled', file), kind: 'wont-proceed'},
|
|
2184
|
+
];
|
|
2185
|
+
case 'spec':
|
|
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'}];
|
|
2206
|
+
case 'observation':
|
|
2207
|
+
// A note has no durable terminal folder: it leaves by DELETION, so there
|
|
2208
|
+
// is no resting record to reconcile against.
|
|
2209
|
+
return [];
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
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
|
+
|
|
2263
|
+
/** One item whose question state survived into a terminal resting place. */
|
|
2264
|
+
export interface TerminalQuestionResidue {
|
|
2265
|
+
/** The namespaced identity (`task:<slug>`). */
|
|
2266
|
+
item: string;
|
|
2267
|
+
/** The sidecar's path on `main` (`work/questions/<type>-<slug>.md`). */
|
|
2268
|
+
sidecarPath: string;
|
|
2269
|
+
/** The item body's terminal path on `main`. */
|
|
2270
|
+
itemPath: string;
|
|
2271
|
+
/** Which terminal the body rests in. */
|
|
2272
|
+
terminal: TerminalKind;
|
|
2273
|
+
/** Does the body still carry `needsAnswers: true`? */
|
|
2274
|
+
flagged: boolean;
|
|
2275
|
+
/**
|
|
2276
|
+
* Does the sidecar carry at least one ANSWERED entry? Such a sidecar holds
|
|
2277
|
+
* human-written prose that was never consumed by the apply rung, so the drain
|
|
2278
|
+
* refuses to touch it (see {@link classifyTerminalQuestionResidue}).
|
|
2279
|
+
*/
|
|
2280
|
+
answered: boolean;
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/** The read-only classification of the arbiter's stranded question state. */
|
|
2284
|
+
export interface TerminalQuestionReport {
|
|
2285
|
+
/** Residue the drain WILL clear: terminal + no answered entry. */
|
|
2286
|
+
drainable: TerminalQuestionResidue[];
|
|
2287
|
+
/**
|
|
2288
|
+
* Residue the drain deliberately LEAVES: a terminal item whose sidecar carries
|
|
2289
|
+
* a human's ANSWER that was never applied. Reported for a human, never
|
|
2290
|
+
* silently deleted (the answer is data the tool did not author).
|
|
2291
|
+
*/
|
|
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[];
|
|
2299
|
+
errors: {item: string; message: string}[];
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* Classify the arbiter's STRANDED QUESTION STATE, read-only (observation
|
|
2304
|
+
* `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
|
|
2305
|
+
*
|
|
2306
|
+
* THE BUG THIS EXISTS FOR. When a build bounces, the surface path atomically
|
|
2307
|
+
* writes BOTH halves of the question state in ONE commit: the sidecar
|
|
2308
|
+
* `work/questions/<type>-<slug>.md` AND `needsAnswers: true` on the item body.
|
|
2309
|
+
* That is correct, and the atomicity is what makes this reconciliation decidable
|
|
2310
|
+
* at all. But if the human DISAGREES with the bounce and simply re-dispatches,
|
|
2311
|
+
* and the rebuild SUCCEEDS (PR opened, gate green, merged, body done-moved),
|
|
2312
|
+
* NEITHER half is ever cleared. The item comes to rest in `tasks/done/` still
|
|
2313
|
+
* carrying a question asking whether to CANCEL it, with a destructive default.
|
|
2314
|
+
*
|
|
2315
|
+
* The flag is the harmful half. A stranded sidecar is a stale question in a
|
|
2316
|
+
* folder a human scans; a stranded `needsAnswers` is a GATE LEFT ARMED, and it
|
|
2317
|
+
* makes `status` report shipped (sometimes released) work under "open questions
|
|
2318
|
+
* block autonomous work".
|
|
2319
|
+
*
|
|
2320
|
+
* Dorfl ALREADY knows this state is illegal: `advance-classify.ts` refuses it as
|
|
2321
|
+
* `invariant-violation` / `sidecar-without-needsAnswers`. The defect is purely
|
|
2322
|
+
* that the detector lives in the `advance` tick's classifier, and a human driving
|
|
2323
|
+
* `do` and merging a PR never enters that loop. So this is the same shape as the
|
|
2324
|
+
* propose-path lock leak, settled by the same reconcile pass at the same moment
|
|
2325
|
+
* (the done-move), rather than by a second mechanism.
|
|
2326
|
+
*
|
|
2327
|
+
* THE TRAP, and why the TERMINAL POSITION is the discriminator rather than the
|
|
2328
|
+
* flag/sidecar disagreement: the MIRROR state (`needsAnswers:true` with NO
|
|
2329
|
+
* sidecar) is LEGAL and COMMON. An item authored with open questions carries the
|
|
2330
|
+
* flag and has no sidecar until `surface` runs, and that flagged-but-unsurfaced
|
|
2331
|
+
* item is precisely the `surface` rung's INPUT. Clearing the flag there would
|
|
2332
|
+
* silently disarm every un-surfaced item in the repo and hand gated work to
|
|
2333
|
+
* agents. So this only ever considers items whose body has reached a TERMINAL
|
|
2334
|
+
* folder on `main`; an item resting in a pool or staging folder keeps whatever
|
|
2335
|
+
* state it has, untouched.
|
|
2336
|
+
*
|
|
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.
|
|
2352
|
+
*
|
|
2353
|
+
* Best-effort and never throws.
|
|
2354
|
+
*/
|
|
2355
|
+
export async function classifyTerminalQuestionResidue(params: {
|
|
2356
|
+
cwd: string;
|
|
2357
|
+
arbiter: string;
|
|
2358
|
+
/** The ref holding the arbiter's authoritative `main`. */
|
|
2359
|
+
mainRef: string;
|
|
2360
|
+
env?: NodeJS.ProcessEnv;
|
|
2361
|
+
/** Skip the `mainRef` refresh because the CALLER just did it (the combined
|
|
2362
|
+
* pass refreshes once and runs both sub-passes against that ONE snapshot). */
|
|
2363
|
+
mainAlreadyFresh?: boolean;
|
|
2364
|
+
}): Promise<TerminalQuestionReport> {
|
|
2365
|
+
const {cwd, mainRef, env} = params;
|
|
2366
|
+
// REFRESH `mainRef` FIRST, with an explicit refspec that writes exactly the ref
|
|
2367
|
+
// we are about to read. Without this the pass reads a STALE view: the caller
|
|
2368
|
+
// may not have fetched, and the lock sub-pass of the combined reconciliation
|
|
2369
|
+
// early-returns (so does not refresh) when no locks are held. A failed refresh
|
|
2370
|
+
// is NOT fatal, but it does mean the view may be stale in EITHER direction (an
|
|
2371
|
+
// item may have left a terminal folder, or acquired an answer, since we last
|
|
2372
|
+
// looked), which is exactly why the WRITE path re-derives this same
|
|
2373
|
+
// classification against its own freshly-resolved base rather than trusting
|
|
2374
|
+
// this snapshot.
|
|
2375
|
+
await refreshMainRef(mainRef, params.arbiter, cwd, env);
|
|
2376
|
+
return deriveTerminalQuestionResidue(mainRef, cwd, env);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* The SYNC, PURE-of-network derivation of the question residue AT ONE COMMIT.
|
|
2381
|
+
*
|
|
2382
|
+
* Split out of {@link classifyTerminalQuestionResidue} so the WRITE path can
|
|
2383
|
+
* re-derive the SAME classification against the base it is actually about to
|
|
2384
|
+
* commit on, per contention attempt. That matters for correctness, not tidiness:
|
|
2385
|
+
* a classification taken before a contention retry can be stale in two ways that
|
|
2386
|
+
* both break a documented guarantee. An item may have LEFT its terminal folder
|
|
2387
|
+
* (re-opened), in which case its sidecar is live again and must not be deleted;
|
|
2388
|
+
* and a human may have written an ANSWER into a sidecar in the window, which must
|
|
2389
|
+
* never be auto-deleted. Re-deriving against `base` closes both, because the
|
|
2390
|
+
* commit is built on exactly that base.
|
|
2391
|
+
*/
|
|
2392
|
+
function deriveTerminalQuestionResidue(
|
|
2393
|
+
base: string,
|
|
2394
|
+
cwd: string,
|
|
2395
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2396
|
+
): TerminalQuestionReport {
|
|
2397
|
+
const mainRef = base;
|
|
2398
|
+
const out: TerminalQuestionReport = {
|
|
2399
|
+
drainable: [],
|
|
2400
|
+
answeredHeld: [],
|
|
2401
|
+
staleFlags: [],
|
|
2402
|
+
errors: [],
|
|
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);
|
|
2411
|
+
const questionsDir = workFolderRel('questions');
|
|
2412
|
+
const ls = run(
|
|
2413
|
+
'git',
|
|
2414
|
+
['ls-tree', '--name-only', `${mainRef}:${questionsDir}`],
|
|
2415
|
+
cwd,
|
|
2416
|
+
{env},
|
|
2417
|
+
);
|
|
2418
|
+
if (ls.status !== 0) {
|
|
2419
|
+
// No `work/questions/` on main at all: nothing surfaced, nothing to drain.
|
|
2420
|
+
return out;
|
|
2421
|
+
}
|
|
2422
|
+
for (const name of ls.stdout.split('\n').map((l) => l.trim())) {
|
|
2423
|
+
if (name === '' || !isWorkItemFile(name)) {
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
const sidecarPath = `${questionsDir}/${name}`;
|
|
2427
|
+
try {
|
|
2428
|
+
// `<type>-<slug>.md` → `<type>:<slug>`. Only the CURRENT namespaces are
|
|
2429
|
+
// addressable; a legacy `prd-` file has no current item-form and is left
|
|
2430
|
+
// for the migration command.
|
|
2431
|
+
const stem = name.replace(/\.md$/, '');
|
|
2432
|
+
const dash = stem.indexOf('-');
|
|
2433
|
+
const type = stem.slice(0, dash) as SidecarType;
|
|
2434
|
+
const slug = stem.slice(dash + 1);
|
|
2435
|
+
if (!['task', 'spec', 'observation'].includes(type) || slug === '') {
|
|
2436
|
+
continue;
|
|
2437
|
+
}
|
|
2438
|
+
const item = `${type}:${slug}`;
|
|
2439
|
+
// Is the body at rest in a terminal folder on `main`?
|
|
2440
|
+
const terminalHit = terminalMainPathsByKind(type, slug).find((c) =>
|
|
2441
|
+
pathInCommit(mainRef, c.path, cwd, env),
|
|
2442
|
+
);
|
|
2443
|
+
if (terminalHit === undefined) {
|
|
2444
|
+
// NOT terminal: a live item. Its question state is its own business
|
|
2445
|
+
// a pending sidecar is a human's outstanding decision, and clearing a
|
|
2446
|
+
// flag here is the trap above. Untouched.
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
// N5 GUARD: a mid-migration spec can have BOTH `spec-<slug>.md` and the
|
|
2450
|
+
// legacy `prd-<slug>.md` on main (`sidecarPathCandidates` still resolves
|
|
2451
|
+
// the legacy name for readers). Draining only the canonical one while
|
|
2452
|
+
// clearing the flag would leave the legacy sidecar live against
|
|
2453
|
+
// `needsAnswers:false`, which is precisely the
|
|
2454
|
+
// `sidecar-without-needsAnswers` invariant violation this change exists
|
|
2455
|
+
// to remove. If any OTHER candidate for this item still exists, leave the
|
|
2456
|
+
// whole item to `dorfl prd-to-spec`, which renames the DATA.
|
|
2457
|
+
const hasLegacyAlias = sidecarPathCandidates(item).some(
|
|
2458
|
+
(c) => c !== sidecarPath && pathInCommit(mainRef, c, cwd, env),
|
|
2459
|
+
);
|
|
2460
|
+
if (hasLegacyAlias) {
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
const model = parseSidecar(
|
|
2464
|
+
catBlob(`${mainRef}:${sidecarPath}`, cwd, env),
|
|
2465
|
+
);
|
|
2466
|
+
const answered = model.entries.some((e) => isEntryAnswered(e));
|
|
2467
|
+
const body = catBlob(`${mainRef}:${terminalHit.path}`, cwd, env);
|
|
2468
|
+
const flagged = parseFrontmatter(body).needsAnswers === true;
|
|
2469
|
+
const residue: TerminalQuestionResidue = {
|
|
2470
|
+
item,
|
|
2471
|
+
sidecarPath,
|
|
2472
|
+
itemPath: terminalHit.path,
|
|
2473
|
+
terminal: terminalHit.kind,
|
|
2474
|
+
flagged,
|
|
2475
|
+
answered,
|
|
2476
|
+
};
|
|
2477
|
+
if (answered) {
|
|
2478
|
+
// A human WROTE an answer here and the apply rung never consumed it.
|
|
2479
|
+
// Deleting it would discard prose the tool did not author, so this is
|
|
2480
|
+
// surfaced for a human instead. (That the drain never runs on the
|
|
2481
|
+
// human-answer path either is a SEPARATE defect; this pass must not
|
|
2482
|
+
// paper over it by destroying the evidence.)
|
|
2483
|
+
out.answeredHeld.push(residue);
|
|
2484
|
+
} else {
|
|
2485
|
+
out.drainable.push(residue);
|
|
2486
|
+
}
|
|
2487
|
+
} catch (err) {
|
|
2488
|
+
out.errors.push({
|
|
2489
|
+
item: sidecarPath,
|
|
2490
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return out;
|
|
2495
|
+
}
|
|
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
|
+
|
|
2635
|
+
/** What a {@link reconcileTerminalQuestionResidue} pass did. */
|
|
2636
|
+
export interface TerminalQuestionDrainResult {
|
|
2637
|
+
/** Items whose sidecar was deleted. */
|
|
2638
|
+
drained: string[];
|
|
2639
|
+
/** Items whose `needsAnswers` flag was additionally cleared. */
|
|
2640
|
+
unflagged: string[];
|
|
2641
|
+
/** Terminal items left alone because a human's answer is unapplied. */
|
|
2642
|
+
answeredHeld: string[];
|
|
2643
|
+
errors: {item: string; message: string}[];
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Drain the stranded question state {@link classifyTerminalQuestionResidue}
|
|
2648
|
+
* finds, in ONE tree-less commit CAS-published to the arbiter's `main` through
|
|
2649
|
+
* the SAME {@link runTreelessLedgerMove} core the surface path uses (same
|
|
2650
|
+
* contention-retry, same lease, same write seam; there is no second mechanism).
|
|
2651
|
+
*
|
|
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.
|
|
2660
|
+
* - the `needsAnswers` FLAG is cleared ONLY for a `completed` terminal
|
|
2661
|
+
* (`tasks/done/`). On a `wont-proceed` terminal
|
|
2662
|
+
* (`tasks/cancelled/`, `specs/dropped/`) the flag is KEPT, because an item
|
|
2663
|
+
* can be cancelled precisely BECAUSE its questions were never answered: there
|
|
2664
|
+
* the flag is accurate history, not residue, and the body may carry a real
|
|
2665
|
+
* `## Open questions` section saying so. Keeping it is harmless, a terminal
|
|
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.
|
|
2670
|
+
*
|
|
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.
|
|
2674
|
+
*
|
|
2675
|
+
* Best-effort: it never throws, and any fault leaves the state exactly as it was.
|
|
2676
|
+
*/
|
|
2677
|
+
export async function reconcileTerminalQuestionResidue(params: {
|
|
2678
|
+
cwd: string;
|
|
2679
|
+
arbiter: string;
|
|
2680
|
+
mainRef: string;
|
|
2681
|
+
env?: NodeJS.ProcessEnv;
|
|
2682
|
+
/** Skip the `mainRef` refresh because the CALLER just did it (the combined
|
|
2683
|
+
* pass refreshes once and runs both sub-passes against that ONE snapshot). */
|
|
2684
|
+
mainAlreadyFresh?: boolean;
|
|
2685
|
+
note?: (message: string) => void;
|
|
2686
|
+
}): Promise<TerminalQuestionDrainResult> {
|
|
2687
|
+
const {cwd, arbiter, mainRef, env} = params;
|
|
2688
|
+
const note = params.note ?? (() => {});
|
|
2689
|
+
const result: TerminalQuestionDrainResult = {
|
|
2690
|
+
drained: [],
|
|
2691
|
+
unflagged: [],
|
|
2692
|
+
answeredHeld: [],
|
|
2693
|
+
errors: [],
|
|
2694
|
+
};
|
|
2695
|
+
let report: TerminalQuestionReport;
|
|
2696
|
+
try {
|
|
2697
|
+
report = await classifyTerminalQuestionResidue({
|
|
2698
|
+
cwd,
|
|
2699
|
+
arbiter,
|
|
2700
|
+
mainRef,
|
|
2701
|
+
env,
|
|
2702
|
+
mainAlreadyFresh: params.mainAlreadyFresh,
|
|
2703
|
+
});
|
|
2704
|
+
} catch (err) {
|
|
2705
|
+
result.errors.push({
|
|
2706
|
+
item: '(classify)',
|
|
2707
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2708
|
+
});
|
|
2709
|
+
return result;
|
|
2710
|
+
}
|
|
2711
|
+
result.answeredHeld = report.answeredHeld.map((r) => r.item);
|
|
2712
|
+
result.errors.push(...report.errors);
|
|
2713
|
+
if (report.drainable.length === 0 && report.staleFlags.length === 0) {
|
|
2714
|
+
return result;
|
|
2715
|
+
}
|
|
2716
|
+
// What the LANDED commit ACTUALLY did, filled in by the plan against the base
|
|
2717
|
+
// it committed on. The pre-plan `report` above is only a fast "is there
|
|
2718
|
+
// anything to do?" probe; reporting from it would claim a gate was disarmed
|
|
2719
|
+
// when a contention retry re-derived the residue and skipped the item.
|
|
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[] = [];
|
|
2725
|
+
// NEVER THROW. `runTreelessLedgerMove` and the git plumbing inside the plan
|
|
2726
|
+
// both throw on any non-zero git, and this pass runs from the CLAIM path as
|
|
2727
|
+
// OPPORTUNISTIC HYGIENE on unrelated items. A fault here (a stale scratch ref,
|
|
2728
|
+
// a protected `main`, a permission refusal) must degrade to "left it alone",
|
|
2729
|
+
// never fail the caller's actual work.
|
|
2730
|
+
let landed = false;
|
|
2731
|
+
try {
|
|
2732
|
+
landed = await runTreelessLedgerMove({
|
|
2733
|
+
cwd,
|
|
2734
|
+
// The ref name only has to be unique for the scratch ref; this pass is
|
|
2735
|
+
// batch (many items, one commit), so it is not keyed to a single slug.
|
|
2736
|
+
slug: 'terminal-question-drain',
|
|
2737
|
+
arbiter,
|
|
2738
|
+
kind: 'needs-attention',
|
|
2739
|
+
onContended: 'drain stranded questions',
|
|
2740
|
+
explicitMainRefspec: true,
|
|
2741
|
+
env,
|
|
2742
|
+
note,
|
|
2743
|
+
// RE-PLANNED per attempt against the freshly-fetched base: the residue is
|
|
2744
|
+
// RE-DERIVED from that base, never reused from the probe above, so an item
|
|
2745
|
+
// re-opened out of its terminal folder, or a sidecar a human answered, in the
|
|
2746
|
+
// contention window is correctly left alone.
|
|
2747
|
+
plan: (base) => {
|
|
2748
|
+
const fresh = deriveTerminalQuestionResidue(base, cwd, env);
|
|
2749
|
+
applied = fresh.drainable;
|
|
2750
|
+
result.answeredHeld = fresh.answeredHeld.map((r) => r.item);
|
|
2751
|
+
return prepareTerminalQuestionDrainCommit({
|
|
2752
|
+
cwd,
|
|
2753
|
+
base,
|
|
2754
|
+
residue: fresh.drainable,
|
|
2755
|
+
staleFlags: fresh.staleFlags,
|
|
2756
|
+
clearedSidecarFlags,
|
|
2757
|
+
clearedStaleFlags,
|
|
2758
|
+
env,
|
|
2759
|
+
});
|
|
2760
|
+
},
|
|
2761
|
+
});
|
|
2762
|
+
} catch (err) {
|
|
2763
|
+
result.errors.push({
|
|
2764
|
+
item: '(publish)',
|
|
2765
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2766
|
+
});
|
|
2767
|
+
return result;
|
|
2768
|
+
}
|
|
2769
|
+
if (!landed) {
|
|
2770
|
+
result.errors.push({
|
|
2771
|
+
item: '(publish)',
|
|
2772
|
+
message:
|
|
2773
|
+
'the stranded-question drain did not land on the arbiter’s main ' +
|
|
2774
|
+
'(contention exhausted, or nothing to do); state left untouched.',
|
|
2775
|
+
});
|
|
2776
|
+
return result;
|
|
2777
|
+
}
|
|
2778
|
+
for (const r of applied) {
|
|
2779
|
+
result.drained.push(r.item);
|
|
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);
|
|
2785
|
+
}
|
|
2786
|
+
return result;
|
|
2787
|
+
}
|
|
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
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* Build the ONE tree-less commit that removes every drainable sidecar and clears
|
|
2834
|
+
* the `needsAnswers` flag on every `completed`-terminal body, using PLUMBING on a
|
|
2835
|
+
* SCRATCH INDEX (the caller's index/HEAD/working tree are never touched)
|
|
2836
|
+
* exactly as {@link prepareTreelessSurfaceCommit} does in the opposite direction.
|
|
2837
|
+
*
|
|
2838
|
+
* Batched into a single commit on purpose: the residue is a SET, one commit is
|
|
2839
|
+
* one CAS against `main` instead of N, and the whole drain then lands or does not
|
|
2840
|
+
* land atomically.
|
|
2841
|
+
*/
|
|
2842
|
+
function prepareTerminalQuestionDrainCommit(params: {
|
|
2843
|
+
cwd: string;
|
|
2844
|
+
base: string;
|
|
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[];
|
|
2855
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2856
|
+
}): TreelessAttemptPlan {
|
|
2857
|
+
const {cwd, base, residue, env, clearedSidecarFlags, clearedStaleFlags} =
|
|
2858
|
+
params;
|
|
2859
|
+
clearedSidecarFlags.length = 0;
|
|
2860
|
+
clearedStaleFlags.length = 0;
|
|
2861
|
+
// RE-DERIVE against THIS base: anything already gone is not our business.
|
|
2862
|
+
const live = residue.filter((r) =>
|
|
2863
|
+
pathInCommit(base, r.sidecarPath, cwd, env),
|
|
2864
|
+
);
|
|
2865
|
+
const liveFlags = params.staleFlags.filter((r) =>
|
|
2866
|
+
pathInCommit(base, r.itemPath, cwd, env),
|
|
2867
|
+
);
|
|
2868
|
+
if (live.length === 0 && liveFlags.length === 0) {
|
|
2869
|
+
return 'already-done';
|
|
2870
|
+
}
|
|
2871
|
+
const scratchIndex = join(
|
|
2872
|
+
tmpdir(),
|
|
2873
|
+
`dorfl-question-drain-${process.pid}-${Date.now()}.index`,
|
|
2874
|
+
);
|
|
2875
|
+
const withIndex: NodeJS.ProcessEnv = {
|
|
2876
|
+
...(env ?? process.env),
|
|
2877
|
+
GIT_INDEX_FILE: scratchIndex,
|
|
2878
|
+
};
|
|
2879
|
+
try {
|
|
2880
|
+
gitHard(['read-tree', base], cwd, withIndex);
|
|
2881
|
+
for (const r of live) {
|
|
2882
|
+
// Remove the stale sidecar.
|
|
2883
|
+
gitHard(
|
|
2884
|
+
['update-index', '--force-remove', r.sidecarPath],
|
|
2885
|
+
cwd,
|
|
2886
|
+
withIndex,
|
|
2887
|
+
);
|
|
2888
|
+
// Clear the flag ONLY on a `completed` terminal (see the doc above).
|
|
2889
|
+
if (r.terminal !== 'completed' || !r.flagged) {
|
|
2890
|
+
continue;
|
|
2891
|
+
}
|
|
2892
|
+
if (clearNeedsAnswersInIndex(r.itemPath, base, cwd, env, withIndex)) {
|
|
2893
|
+
clearedSidecarFlags.push(r);
|
|
2894
|
+
}
|
|
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);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
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;
|
|
2909
|
+
const subject =
|
|
2910
|
+
touched === 1
|
|
2911
|
+
? `drain stranded question state for ${only} (terminal on main)`
|
|
2912
|
+
: `drain stranded question state for ${touched} terminal items`;
|
|
2913
|
+
const commit = runHard(
|
|
2914
|
+
['commit-tree', tree, '-p', base, '-m', subject],
|
|
2915
|
+
cwd,
|
|
2916
|
+
env,
|
|
2917
|
+
).stdout.trim();
|
|
2918
|
+
const ref = 'refs/dorfl/question-drain/batch';
|
|
2919
|
+
gitHard(['update-ref', ref, commit], cwd, env);
|
|
2920
|
+
return {ref, commit};
|
|
2921
|
+
} finally {
|
|
2922
|
+
rmSync(scratchIndex, {force: true});
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2143
2926
|
export interface SurfaceStuckToNeedsAttentionOptions {
|
|
2144
2927
|
/**
|
|
2145
2928
|
* The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
|