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.
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +7 -1
- package/dist/cwd-section.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/needs-attention.d.ts +81 -18
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +282 -44
- package/dist/needs-attention.js.map +1 -1
- package/dist/skills/one-shot/SKILL.md +210 -0
- package/dist/skills/work/SKILL.md +3 -1
- package/package.json +1 -1
- package/src/cwd-section.ts +7 -1
- package/src/frontmatter.ts +16 -4
- package/src/needs-attention.ts +360 -51
package/dist/needs-attention.js
CHANGED
|
@@ -1306,10 +1306,17 @@ export function prepareTreelessSurfaceCommit(params) {
|
|
|
1306
1306
|
rmSync(scratchIndex, { force: true });
|
|
1307
1307
|
}
|
|
1308
1308
|
}
|
|
1309
|
-
/**
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1309
|
+
/**
|
|
1310
|
+
* The terminal `work/` paths for an item, tagged by {@link TerminalKind}, so a
|
|
1311
|
+
* reader can tell "the work happened" from "the item was abandoned".
|
|
1312
|
+
*
|
|
1313
|
+
* Same SHAPE as `terminalMainPaths` in `item-lock.ts`, but deliberately NOT the
|
|
1314
|
+
* same folder set, and the difference must not be "tidied" away: locks treat
|
|
1315
|
+
* `specs/tasked/` as terminal (correctly, a tasked spec must release its lock),
|
|
1316
|
+
* whereas QUESTION state there is still live. This map is keyed to "is the
|
|
1317
|
+
* question loop CLOSED at this resting place?", not "has the item stopped
|
|
1318
|
+
* moving?". See the `case 'spec'` comment below.
|
|
1319
|
+
*/
|
|
1313
1320
|
export function terminalMainPathsByKind(type, slug) {
|
|
1314
1321
|
const file = `${slug}.md`;
|
|
1315
1322
|
switch (type) {
|
|
@@ -1319,16 +1326,54 @@ export function terminalMainPathsByKind(type, slug) {
|
|
|
1319
1326
|
{ path: workItemRel('cancelled', file), kind: 'wont-proceed' },
|
|
1320
1327
|
];
|
|
1321
1328
|
case 'spec':
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1329
|
+
// `specs/tasked/` is deliberately NOT listed. WORK-CONTRACT ("A SPEC that
|
|
1330
|
+
// has drifted AFTER it was TASKED") makes a bare `needsAnswers:true` on a
|
|
1331
|
+
// tasked spec LEGAL and load-bearing: it means "tasked, but the spec has
|
|
1332
|
+
// drifted, do not RE-task or rely on it until reconciled", and the
|
|
1333
|
+
// contract says to set it *while the spec stays in `specs/tasked/`*
|
|
1334
|
+
// (moving it back would falsely un-record a tasking that really happened
|
|
1335
|
+
// and orphan the tasks it already emitted).
|
|
1336
|
+
//
|
|
1337
|
+
// So the reasoning that makes a task's question state moot at its terminal
|
|
1338
|
+
// does NOT transfer: a tasked spec is still IN the question loop.
|
|
1339
|
+
// `lifecycle-gather.ts` enumerates tasked resting specs UNCONDITIONALLY,
|
|
1340
|
+
// routing a bare flag to the SURFACE rung and an answered sidecar to the
|
|
1341
|
+
// APPLY rung, so BOTH halves are live inputs to a rung that WILL run.
|
|
1342
|
+
// Draining either would disarm a live drift gate and let a stale spec be
|
|
1343
|
+
// re-tasked. That is precisely the "clearing a live needsAnswers hands gated work
|
|
1344
|
+
// to agents" harm this pass exists to avoid.
|
|
1345
|
+
//
|
|
1346
|
+
// `specs/dropped/` needs no such carve-out: a dropped spec is abandoned,
|
|
1347
|
+
// and no rung enumerates it.
|
|
1348
|
+
return [{ path: workItemRel('specs-dropped', file), kind: 'wont-proceed' }];
|
|
1326
1349
|
case 'observation':
|
|
1327
1350
|
// A note has no durable terminal folder: it leaves by DELETION, so there
|
|
1328
1351
|
// is no resting record to reconcile against.
|
|
1329
1352
|
return [];
|
|
1330
1353
|
}
|
|
1331
1354
|
}
|
|
1355
|
+
/** Every SUCCESS-terminal folder that can hold a stranded `needsAnswers` flag,
|
|
1356
|
+
* paired with the item type that rests there. Derived from
|
|
1357
|
+
* {@link terminalMainPathsByKind} with a sentinel slug so the folder set stays
|
|
1358
|
+
* SINGLE-SOURCED: adding a regime there adds it here, and the `wont-proceed`
|
|
1359
|
+
* terminals are excluded by the SAME `kind` split the drain already branches on
|
|
1360
|
+
* (a cancelled item's flag is accurate history, not residue). `observation`
|
|
1361
|
+
* contributes nothing, having no durable terminal. */
|
|
1362
|
+
function successTerminalFolders() {
|
|
1363
|
+
const out = [];
|
|
1364
|
+
for (const type of ['task', 'spec', 'observation']) {
|
|
1365
|
+
for (const candidate of terminalMainPathsByKind(type, '__slug__')) {
|
|
1366
|
+
if (candidate.kind !== 'completed') {
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
out.push({
|
|
1370
|
+
folder: candidate.path.slice(0, candidate.path.lastIndexOf('/')),
|
|
1371
|
+
type,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return out;
|
|
1376
|
+
}
|
|
1332
1377
|
/**
|
|
1333
1378
|
* Classify the arbiter's STRANDED QUESTION STATE, read-only (observation
|
|
1334
1379
|
* `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
|
|
@@ -1364,11 +1409,21 @@ export function terminalMainPathsByKind(type, slug) {
|
|
|
1364
1409
|
* folder on `main`; an item resting in a pool or staging folder keeps whatever
|
|
1365
1410
|
* state it has, untouched.
|
|
1366
1411
|
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1412
|
+
* TWO ENUMERATIONS, because the two halves can be separated in either order and
|
|
1413
|
+
* a sweep anchored on one is blind to the other:
|
|
1414
|
+
* 1. the SIDECAR SET (`work/questions/` on `main`), small and cheap to list,
|
|
1415
|
+
* which finds a stale sidecar and the flag paired with it; and
|
|
1416
|
+
* 2. the SUCCESS-TERMINAL BODIES that are flagged with NO sidecar beside them
|
|
1417
|
+
* ({@link collectStrandedTerminalFlags}), which finds the armed gate ALONE.
|
|
1418
|
+
*
|
|
1419
|
+
* (2) is not optional tidiness. It is the half that actually gates work, and a
|
|
1420
|
+
* sweep anchored only on (1) reports success while any gate it cannot see stays
|
|
1421
|
+
* armed. The sidecar is the
|
|
1422
|
+
* half a human deletes by hand (it is the visible one, in a folder they scan),
|
|
1423
|
+
* and deleting it REMOVES the only handle (1) has, stranding the flag for good.
|
|
1424
|
+
* The discriminator that keeps (2) safe is POSITION, exactly as for (1): a bare
|
|
1425
|
+
* flag is LEGAL on a pool/staging item (the `surface` rung's input) and residue
|
|
1426
|
+
* only once the item has shipped, where `surface` can never run again.
|
|
1372
1427
|
*
|
|
1373
1428
|
* Best-effort and never throws.
|
|
1374
1429
|
*/
|
|
@@ -1404,8 +1459,16 @@ function deriveTerminalQuestionResidue(base, cwd, env) {
|
|
|
1404
1459
|
const out = {
|
|
1405
1460
|
drainable: [],
|
|
1406
1461
|
answeredHeld: [],
|
|
1462
|
+
staleFlags: [],
|
|
1407
1463
|
errors: [],
|
|
1408
1464
|
};
|
|
1465
|
+
// The SECOND half of the residue, enumerated from the OTHER side. The sidecar
|
|
1466
|
+
// sweep below can only ever see items that still HAVE a sidecar; this one finds
|
|
1467
|
+
// the SUCCESS-terminal bodies whose gate is armed with no sidecar left to point
|
|
1468
|
+
// at them. Both must run: they are the same defect observed through the two
|
|
1469
|
+
// halves the surface path writes atomically, and either half can outlive the
|
|
1470
|
+
// other.
|
|
1471
|
+
collectStrandedTerminalFlags(mainRef, cwd, env, out);
|
|
1409
1472
|
const questionsDir = workFolderRel('questions');
|
|
1410
1473
|
const ls = run('git', ['ls-tree', '--name-only', `${mainRef}:${questionsDir}`], cwd, { env });
|
|
1411
1474
|
if (ls.status !== 0) {
|
|
@@ -1482,25 +1545,159 @@ function deriveTerminalQuestionResidue(base, cwd, env) {
|
|
|
1482
1545
|
}
|
|
1483
1546
|
return out;
|
|
1484
1547
|
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Find every SUCCESS-terminal body on `base` carrying `needsAnswers:true` with NO
|
|
1550
|
+
* sidecar beside it, appending them to `out.staleFlags`.
|
|
1551
|
+
*
|
|
1552
|
+
* ENUMERATION COST is why this is a `git grep` and not a walk. The terminal
|
|
1553
|
+
* folders are the repo's largest and most monotonically growing (this repo holds
|
|
1554
|
+
* 404 done tasks), and this runs on the CLAIM path, so reading every terminal
|
|
1555
|
+
* body per claim would be a real tax on a hot path. One `git grep -l` returns
|
|
1556
|
+
* only the candidates, and the frontmatter parse runs over that short list.
|
|
1557
|
+
*
|
|
1558
|
+
* The pattern is ANCHORED to match the PARSER rather than the word.
|
|
1559
|
+
* `parseFrontmatter` reads keys with `/^([A-Za-z0-9_.]+)\s*:\s*(.*)$/`, so a key
|
|
1560
|
+
* it will honour is always at column 0; an unanchored needle instead matches
|
|
1561
|
+
* every body that merely DISCUSSES the flag, which in `work/tasks/done/` here is
|
|
1562
|
+
* 77 files against 18 anchored, and the truthy form narrows it to 1.
|
|
1563
|
+
*
|
|
1564
|
+
* The value part is matched LOOSELY on purpose (optional quote, any case),
|
|
1565
|
+
* because `toBoolean` unquotes and lower-cases before comparing, so
|
|
1566
|
+
* `needsAnswers: 'True'` is a real armed gate. A needle of `:\s*true` would read
|
|
1567
|
+
* tighter and be WRONG: it would silently skip those bodies for ever, which is
|
|
1568
|
+
* the blind-spot class this function exists to remove. A superset is the safe
|
|
1569
|
+
* direction for a shortlist; a subset is not.
|
|
1570
|
+
*
|
|
1571
|
+
* The grep is still only a CANDIDATE FILTER, never the decision: prose can sit
|
|
1572
|
+
* at column 0 too (`needsAnswers: true?` appears in this repo's own bodies), so
|
|
1573
|
+
* every hit is confirmed by actually PARSING the frontmatter.
|
|
1574
|
+
*
|
|
1575
|
+
* Two git-isms are pinned rather than left to the environment:
|
|
1576
|
+
* - `core.quotePath=false`, or git C-quotes any non-ASCII path
|
|
1577
|
+
* (`"work/.../caf\303\251.md"`). A quoted line still starts with the
|
|
1578
|
+
* `<base>:` prefix but then fails the folder-prefix test, so such a body
|
|
1579
|
+
* would be SILENTLY skipped for ever, a permanent blind spot of exactly the
|
|
1580
|
+
* class this function exists to remove.
|
|
1581
|
+
* - `--full-name` + `:(top,literal)` pathspecs, because `git grep`'s pathspecs
|
|
1582
|
+
* are CWD-RELATIVE (unlike the `ls-tree`/`cat-file` probes elsewhere here,
|
|
1583
|
+
* which are tree-relative) and are globs. Without these, running any dorfl
|
|
1584
|
+
* command from a SUBDIRECTORY makes this half a silent no-op while the
|
|
1585
|
+
* sidecar half keeps working.
|
|
1586
|
+
*
|
|
1587
|
+
* Never throws; a failed grep yields no candidates, which leaves state alone.
|
|
1588
|
+
*/
|
|
1589
|
+
function collectStrandedTerminalFlags(base, cwd, env, out) {
|
|
1590
|
+
const folders = successTerminalFolders();
|
|
1591
|
+
if (folders.length === 0) {
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
// `-l` names files only, `-I` skips binaries. Exit 1 means NO MATCH, which
|
|
1595
|
+
// ALSO covers "the folder does not exist on this base yet" (verified: an
|
|
1596
|
+
// absent pathspec folder exits 1 with no stderr), and an absent lifecycle
|
|
1597
|
+
// folder is legal per WORK-CONTRACT rule 3. Any OTHER non-zero is a genuine
|
|
1598
|
+
// fault and is REPORTED rather than swallowed: degrading silently to "no
|
|
1599
|
+
// candidates" would leave this half a no-op while the sidecar half keeps
|
|
1600
|
+
// reporting success, which is the very "reports success while the gate stays
|
|
1601
|
+
// armed" shape this change exists to correct.
|
|
1602
|
+
const grep = run('git', [
|
|
1603
|
+
'-c',
|
|
1604
|
+
'core.quotePath=false',
|
|
1605
|
+
'grep',
|
|
1606
|
+
'-l',
|
|
1607
|
+
'-I',
|
|
1608
|
+
'--full-name',
|
|
1609
|
+
'-E',
|
|
1610
|
+
'^needsAnswers:[[:space:]]*[\'"]?[Tt][Rr][Uu][Ee]',
|
|
1611
|
+
base,
|
|
1612
|
+
'--',
|
|
1613
|
+
...folders.map((f) => `:(top,literal)${f.folder}`),
|
|
1614
|
+
], cwd, { env });
|
|
1615
|
+
if (grep.status !== 0) {
|
|
1616
|
+
if (grep.status !== 1) {
|
|
1617
|
+
out.errors.push({
|
|
1618
|
+
item: '(stranded-flag scan)',
|
|
1619
|
+
message: `git grep over the terminal folders failed (exit ${grep.status}): ` +
|
|
1620
|
+
`${grep.stderr.trim() || 'no stderr'}; stranded gates were NOT scanned.`,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const prefix = `${base}:`;
|
|
1626
|
+
for (const line of grep.stdout.split('\n')) {
|
|
1627
|
+
const raw = line.trim();
|
|
1628
|
+
if (raw === '' || !raw.startsWith(prefix)) {
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
const path = raw.slice(prefix.length);
|
|
1632
|
+
try {
|
|
1633
|
+
const home = folders.find((f) => path.startsWith(`${f.folder}/`));
|
|
1634
|
+
if (home === undefined) {
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
const name = path.slice(home.folder.length + 1);
|
|
1638
|
+
// Direct children only: a nested path is not an item body.
|
|
1639
|
+
if (name.includes('/') || !isWorkItemFile(name)) {
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
// Case-INSENSITIVE to match `isWorkItemFile` above: a `Foo.MD` body must
|
|
1643
|
+
// yield the slug `Foo`, or the sidecar-existence guard below would probe
|
|
1644
|
+
// the wrong path and could clear a gate whose sidecar holds an answer.
|
|
1645
|
+
const slug = name.replace(/\.md$/i, '');
|
|
1646
|
+
if (slug === '') {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const item = `${home.type}:${slug}`;
|
|
1650
|
+
// A sidecar STILL EXISTS for this item (canonical or legacy alias) ⇒ this
|
|
1651
|
+
// is the sidecar-anchored sweep's business, not ours. Skipping keeps the
|
|
1652
|
+
// two enumerations DISJOINT, so an item is never planned twice in one
|
|
1653
|
+
// commit and the answered-sidecar carve-out cannot be bypassed through
|
|
1654
|
+
// this path (an item held for an unapplied human answer keeps its flag).
|
|
1655
|
+
if (sidecarPathCandidates(item).some((c) => pathInCommit(base, c, cwd, env))) {
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
// CONFIRM against the parsed frontmatter: the grep only shortlisted.
|
|
1659
|
+
const body = catBlob(`${base}:${path}`, cwd, env);
|
|
1660
|
+
if (parseFrontmatter(body).needsAnswers !== true) {
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
out.staleFlags.push({ item, itemPath: path });
|
|
1664
|
+
}
|
|
1665
|
+
catch (err) {
|
|
1666
|
+
out.errors.push({
|
|
1667
|
+
item: path,
|
|
1668
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1485
1673
|
/**
|
|
1486
1674
|
* Drain the stranded question state {@link classifyTerminalQuestionResidue}
|
|
1487
1675
|
* finds, in ONE tree-less commit CAS-published to the arbiter's `main` through
|
|
1488
1676
|
* the SAME {@link runTreelessLedgerMove} core the surface path uses (same
|
|
1489
1677
|
* contention-retry, same lease, same write seam; there is no second mechanism).
|
|
1490
1678
|
*
|
|
1491
|
-
* WHAT IT CLEARS, and the deliberate asymmetry between the two terminals
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1679
|
+
* WHAT IT CLEARS, and the deliberate asymmetry between the two terminals. Note
|
|
1680
|
+
* the terminal SET first: `specs/tasked/` is deliberately NOT in this map at all
|
|
1681
|
+
* (see {@link terminalMainPathsByKind}), so nothing below applies to a tasked
|
|
1682
|
+
* spec, whose question state stays untouched in both halves.
|
|
1683
|
+
* - the SIDECAR is deleted for EITHER terminal in the map. A question asking
|
|
1684
|
+
* whether to cancel an item that has already come to rest is stale in both
|
|
1685
|
+
* cases, and it sits in a folder a human scans carrying a destructive
|
|
1686
|
+
* default.
|
|
1495
1687
|
* - the `needsAnswers` FLAG is cleared ONLY for a `completed` terminal
|
|
1496
|
-
* (`tasks/done
|
|
1688
|
+
* (`tasks/done/`). On a `wont-proceed` terminal
|
|
1497
1689
|
* (`tasks/cancelled/`, `specs/dropped/`) the flag is KEPT, because an item
|
|
1498
1690
|
* can be cancelled precisely BECAUSE its questions were never answered: there
|
|
1499
1691
|
* the flag is accurate history, not residue, and the body may carry a real
|
|
1500
1692
|
* `## Open questions` section saying so. Keeping it is harmless, a terminal
|
|
1501
1693
|
* item is in no pool, so the flag gates nothing.
|
|
1694
|
+
* - a SUCCESS-terminal item whose gate is armed with NO sidecar left beside it
|
|
1695
|
+
* has that FLAG cleared and nothing deleted (there is nothing to delete).
|
|
1696
|
+
* Restricted to the `completed` terminal by the same asymmetry above.
|
|
1502
1697
|
*
|
|
1503
|
-
* A sidecar with ANY answered entry is never touched (see the classifier)
|
|
1698
|
+
* A sidecar with ANY answered entry is never touched (see the classifier), and
|
|
1699
|
+
* an item still holding such a sidecar is excluded from the flag-only half too,
|
|
1700
|
+
* so the carve-out cannot be bypassed by clearing its gate.
|
|
1504
1701
|
*
|
|
1505
1702
|
* Best-effort: it never throws, and any fault leaves the state exactly as it was.
|
|
1506
1703
|
*/
|
|
@@ -1532,7 +1729,7 @@ export async function reconcileTerminalQuestionResidue(params) {
|
|
|
1532
1729
|
}
|
|
1533
1730
|
result.answeredHeld = report.answeredHeld.map((r) => r.item);
|
|
1534
1731
|
result.errors.push(...report.errors);
|
|
1535
|
-
if (report.drainable.length === 0) {
|
|
1732
|
+
if (report.drainable.length === 0 && report.staleFlags.length === 0) {
|
|
1536
1733
|
return result;
|
|
1537
1734
|
}
|
|
1538
1735
|
// What the LANDED commit ACTUALLY did, filled in by the plan against the base
|
|
@@ -1540,6 +1737,10 @@ export async function reconcileTerminalQuestionResidue(params) {
|
|
|
1540
1737
|
// anything to do?" probe; reporting from it would claim a gate was disarmed
|
|
1541
1738
|
// when a contention retry re-derived the residue and skipped the item.
|
|
1542
1739
|
let applied = [];
|
|
1740
|
+
// Filled by the PLAN with what it actually STAGED (not what it intended), so a
|
|
1741
|
+
// body the marker writer cannot annotate is never reported as unflagged.
|
|
1742
|
+
const clearedSidecarFlags = [];
|
|
1743
|
+
const clearedStaleFlags = [];
|
|
1543
1744
|
// NEVER THROW. `runTreelessLedgerMove` and the git plumbing inside the plan
|
|
1544
1745
|
// both throw on any non-zero git, and this pass runs from the CLAIM path as
|
|
1545
1746
|
// OPPORTUNISTIC HYGIENE on unrelated items. A fault here (a stale scratch ref,
|
|
@@ -1570,6 +1771,9 @@ export async function reconcileTerminalQuestionResidue(params) {
|
|
|
1570
1771
|
cwd,
|
|
1571
1772
|
base,
|
|
1572
1773
|
residue: fresh.drainable,
|
|
1774
|
+
staleFlags: fresh.staleFlags,
|
|
1775
|
+
clearedSidecarFlags,
|
|
1776
|
+
clearedStaleFlags,
|
|
1573
1777
|
env,
|
|
1574
1778
|
});
|
|
1575
1779
|
},
|
|
@@ -1592,12 +1796,46 @@ export async function reconcileTerminalQuestionResidue(params) {
|
|
|
1592
1796
|
}
|
|
1593
1797
|
for (const r of applied) {
|
|
1594
1798
|
result.drained.push(r.item);
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1799
|
+
}
|
|
1800
|
+
// `unflagged` reports what the commit ACTUALLY staged, from both halves. The
|
|
1801
|
+
// flag-only half never appears in `drained`: it deletes nothing.
|
|
1802
|
+
for (const r of [...clearedSidecarFlags, ...clearedStaleFlags]) {
|
|
1803
|
+
result.unflagged.push(r.item);
|
|
1598
1804
|
}
|
|
1599
1805
|
return result;
|
|
1600
1806
|
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Stage `itemPath` with `needsAnswers` cleared, into the scratch index the drain
|
|
1809
|
+
* commit is being built in. Shared by BOTH halves of the residue (the
|
|
1810
|
+
* sidecar-paired flag and the stranded flag-only one) so they can never disagree
|
|
1811
|
+
* about what clearing a gate means.
|
|
1812
|
+
*
|
|
1813
|
+
* Defense-in-depth, mirroring the surface path's guard in the opposite
|
|
1814
|
+
* direction: if the marker does not parse back as `false`, the body is left
|
|
1815
|
+
* ALONE rather than written as something we cannot vouch for. Every uncertainty
|
|
1816
|
+
* resolves to LEAVING STATE ALONE.
|
|
1817
|
+
*
|
|
1818
|
+
* RETURNS whether the gate was actually STAGED, so callers report EFFECT rather
|
|
1819
|
+
* than INTENT. That distinction is load-bearing here: a body this cannot
|
|
1820
|
+
* annotate (e.g. duplicate `needsAnswers` keys, where the writer replaces the
|
|
1821
|
+
* FIRST and the parser reads the LAST) would otherwise be reported as unflagged
|
|
1822
|
+
* on every claim for ever while its gate stayed armed, the precise
|
|
1823
|
+
* "reports success while the defect remains" failure this whole change exists to
|
|
1824
|
+
* correct.
|
|
1825
|
+
*/
|
|
1826
|
+
function clearNeedsAnswersInIndex(itemPath, base, cwd, env, withIndex) {
|
|
1827
|
+
if (!pathInCommit(base, itemPath, cwd, env)) {
|
|
1828
|
+
return false;
|
|
1829
|
+
}
|
|
1830
|
+
const body = catBlob(`${base}:${itemPath}`, cwd, env);
|
|
1831
|
+
const cleared = setNeedsAnswersMarker(body, false);
|
|
1832
|
+
if (parseFrontmatter(cleared).needsAnswers !== false) {
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
const blob = hashObject(cleared, cwd, env);
|
|
1836
|
+
gitHard(['update-index', '--add', '--cacheinfo', `100644,${blob},${itemPath}`], cwd, withIndex);
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1601
1839
|
/**
|
|
1602
1840
|
* Build the ONE tree-less commit that removes every drainable sidecar and clears
|
|
1603
1841
|
* the `needsAnswers` flag on every `completed`-terminal body, using PLUMBING on a
|
|
@@ -1609,10 +1847,13 @@ export async function reconcileTerminalQuestionResidue(params) {
|
|
|
1609
1847
|
* land atomically.
|
|
1610
1848
|
*/
|
|
1611
1849
|
function prepareTerminalQuestionDrainCommit(params) {
|
|
1612
|
-
const { cwd, base, residue, env } = params;
|
|
1850
|
+
const { cwd, base, residue, env, clearedSidecarFlags, clearedStaleFlags } = params;
|
|
1851
|
+
clearedSidecarFlags.length = 0;
|
|
1852
|
+
clearedStaleFlags.length = 0;
|
|
1613
1853
|
// RE-DERIVE against THIS base: anything already gone is not our business.
|
|
1614
1854
|
const live = residue.filter((r) => pathInCommit(base, r.sidecarPath, cwd, env));
|
|
1615
|
-
|
|
1855
|
+
const liveFlags = params.staleFlags.filter((r) => pathInCommit(base, r.itemPath, cwd, env));
|
|
1856
|
+
if (live.length === 0 && liveFlags.length === 0) {
|
|
1616
1857
|
return 'already-done';
|
|
1617
1858
|
}
|
|
1618
1859
|
const scratchIndex = join(tmpdir(), `dorfl-question-drain-${process.pid}-${Date.now()}.index`);
|
|
@@ -1629,29 +1870,26 @@ function prepareTerminalQuestionDrainCommit(params) {
|
|
|
1629
1870
|
if (r.terminal !== 'completed' || !r.flagged) {
|
|
1630
1871
|
continue;
|
|
1631
1872
|
}
|
|
1632
|
-
if (
|
|
1633
|
-
|
|
1873
|
+
if (clearNeedsAnswersInIndex(r.itemPath, base, cwd, env, withIndex)) {
|
|
1874
|
+
clearedSidecarFlags.push(r);
|
|
1634
1875
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1876
|
+
}
|
|
1877
|
+
// The FLAG-ONLY half: a SUCCESS terminal whose sidecar is already gone. No
|
|
1878
|
+
// `--force-remove` here, because there is nothing to delete; the armed gate IS the
|
|
1879
|
+
// whole residue.
|
|
1880
|
+
// Record what was actually STAGED: a body we could not annotate is dropped
|
|
1881
|
+
// from the report rather than claimed as cleared.
|
|
1882
|
+
for (const r of liveFlags) {
|
|
1883
|
+
if (clearNeedsAnswersInIndex(r.itemPath, base, cwd, env, withIndex)) {
|
|
1884
|
+
clearedStaleFlags.push(r);
|
|
1642
1885
|
}
|
|
1643
|
-
const blob = hashObject(cleared, cwd, env);
|
|
1644
|
-
gitHard([
|
|
1645
|
-
'update-index',
|
|
1646
|
-
'--add',
|
|
1647
|
-
'--cacheinfo',
|
|
1648
|
-
`100644,${blob},${r.itemPath}`,
|
|
1649
|
-
], cwd, withIndex);
|
|
1650
1886
|
}
|
|
1651
1887
|
const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
|
|
1652
|
-
const
|
|
1653
|
-
|
|
1654
|
-
|
|
1888
|
+
const touched = live.length + liveFlags.length;
|
|
1889
|
+
const only = live[0]?.item ?? liveFlags[0]?.item;
|
|
1890
|
+
const subject = touched === 1
|
|
1891
|
+
? `drain stranded question state for ${only} (terminal on main)`
|
|
1892
|
+
: `drain stranded question state for ${touched} terminal items`;
|
|
1655
1893
|
const commit = runHard(['commit-tree', tree, '-p', base, '-m', subject], cwd, env).stdout.trim();
|
|
1656
1894
|
const ref = 'refs/dorfl/question-drain/batch';
|
|
1657
1895
|
gitHard(['update-ref', ref, commit], cwd, env);
|