fix(lanes): clear stale detection on real stage transitions, exclude scratch dirs from implement detect

setStage() left detected_stage/signal/at untouched across a real transition,
so a prior task's leftover inference (e.g. `tests` from earlier work) both
misrepresented a fresh task's progress and — because recordDetection is
forward-only — silently rejected every real detection behind it until the
old one aged past DETECTION_TTL_MS. A real stage change now clears the
detection columns; a same-stage heartbeat leaves a live detection alone.

Also excludes `.superpowers/` from the `implement` node's Write detect rule
- brainstorm-companion scratch files were being counted as implementation
work.

fix(client): disable Node's default --experimental-webstorage in tests

Node 25 enables --experimental-webstorage by default, which defines a
broken global `localStorage` (no backing file configured) ahead of jsdom's
own full polyfill. Every test touching real localStorage failed with
"localStorage.clear/getItem is not a function" — not flaky, not test-specific,
just this one Node default. NODE_OPTIONS=--no-experimental-webstorage on the
test scripts lets jsdom's polyfill take over as before.
This commit is contained in:
2026-07-31 10:53:07 +07:00
parent 9b1fa67385
commit 4905d63b97
4 changed files with 61 additions and 10 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "NODE_OPTIONS=--no-experimental-webstorage vitest run",
"test:watch": "vitest" "test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
+43 -4
View File
@@ -587,6 +587,39 @@ describe("stage detection", () => {
lanes.deleteLane(l.id); lanes.deleteLane(l.id);
}); });
it("a real stage transition clears a stale detection and unblocks a fresh one behind it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-stale-cross-task" });
// A prior, unrelated task's tool calls left `tests` as the detected stage.
lanes.recordDetection(l.id, { nodeId: "tests", signal: "`npm test`" });
assert.equal(lanes.getLane(l.id).detected_stage, "tests");
// A new task starts and the agent declares an EARLIER stage. Without the
// fix, the leftover `tests` detection both misrepresents the new task's
// progress and (via forward-only) rejects every real detection for it
// until `tests` ages past DETECTION_TTL_MS.
const after = lanes.setStage(l.id, { stage: "plan" });
assert.equal(after.detected_stage, null);
assert.equal(after.detected_signal, null);
assert.equal(after.detected_at, null);
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("re-declaring the SAME stage (a heartbeat) leaves a live detection alone", () => {
const l = lanes.createLane({ cwd: "/tmp/wt-detect-heartbeat-noop" });
lanes.setStage(l.id, { stage: "plan" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
lanes.setStage(l.id, { stage: "plan", note: "still planning" });
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
lanes.deleteLane(l.id);
});
it("migration: detection columns are added to a database holding an old-schema lanes row", () => { it("migration: detection columns are added to a database holding an old-schema lanes row", () => {
const tmpPath = pathMod.join( const tmpPath = pathMod.join(
os.tmpdir(), os.tmpdir(),
@@ -744,12 +777,18 @@ describe("stage detection", () => {
it("detection expiry: stale detection behind declared stage still rejects", () => { it("detection expiry: stale detection behind declared stage still rejects", () => {
const { db } = require("../db"); const { db } = require("../db");
const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" }); const l = lanes.createLane({ cwd: "/tmp/wt-detect-expiry-declared" });
// Stand up the detection BEFORE declaring, otherwise declared-wins refuses
// it and there is no standing detection left to age.
lanes.recordDetection(l.id, { nodeId: "tests" });
lanes.setStage(l.id, { stage: "review" }); lanes.setStage(l.id, { stage: "review" });
// setStage now clears detected_* on every real transition (a fresh
// declaration supersedes older inference), so a standing detection is
// written directly here, bypassing that, to isolate the rule this test
// is actually about: declared-wins is never relaxed by staleness, no
// matter how the stale detection got there.
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(oldTime, l.id); db.prepare("UPDATE lanes SET detected_stage = ?, detected_at = ? WHERE id = ?").run(
"tests",
oldTime,
l.id
);
// Try backward detection to implement - should still be rejected because declared stage is ahead // Try backward detection to implement - should still be rejected because declared stage is ahead
const result = lanes.recordDetection(l.id, { nodeId: "implement" }); const result = lanes.recordDetection(l.id, { nodeId: "implement" });
+1 -1
View File
@@ -50,7 +50,7 @@
}, },
{ {
"tool": "Write", "tool": "Write",
"match": "^(?!.*(?:^|/)docs/)" "match": "^(?!.*(?:^|/)(?:docs|\\.superpowers)/)"
} }
] ]
}, },
+15 -3
View File
@@ -189,24 +189,36 @@ function deleteLane(id) {
* Record a stage transition. `stage_since` moves ONLY when the stage value * Record a stage transition. `stage_since` moves ONLY when the stage value
* actually changes, so the UI's time-on-phase is real; a re-report of the same * actually changes, so the UI's time-on-phase is real; a re-report of the same
* stage (a heartbeat, an added note) leaves it alone. * stage (a heartbeat, an added note) leaves it alone.
*
* A real transition also clears `detected_stage`/`detected_signal`/`detected_at`.
* Inference tracks progress relative to whatever the agent last declared; once
* the agent declares again, any older detection is either stale (a prior task's
* leftover, e.g. `tests` from earlier work bleeding into a fresh `plan`) or
* redundant (the agent's own claim now covers it). Left in place it would both
* paint stale progress in the UI AND — because recordDetection is forward-only
* — silently reject every real detection for the new stage until the old one
* ages past DETECTION_TTL_MS.
*/ */
function setStage(id, { stage, status, evidence, note, result } = {}) { function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id); const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" }); if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage; const next = stage || lane.stage;
const changed = next !== lane.stage;
const stages = { ...lane.stages }; const stages = { ...lane.stages };
const prev = stages[next] || {}; const prev = stages[next] || {};
stages[next] = { stages[next] = {
enteredAt: next === lane.stage && prev.enteredAt ? prev.enteredAt : nowIso(), enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(),
evidence: evidence !== undefined ? evidence : prev.evidence || null, evidence: evidence !== undefined ? evidence : prev.evidence || null,
result: result !== undefined ? result : prev.result || null, result: result !== undefined ? result : prev.result || null,
}; };
db.prepare( db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ? `UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
}
WHERE id = ?` WHERE id = ?`
).run( ).run(
next, next,
next === lane.stage ? lane.stage_since || nowIso() : nowIso(), changed ? nowIso() : lane.stage_since || nowIso(),
status || lane.status, status || lane.status,
JSON.stringify(stages), JSON.stringify(stages),
note !== undefined ? note : lane.notes, note !== undefined ? note : lane.notes,