diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a6461..97a51bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **deja no longer records host notifications as solved work.** In a long cloud session + the only prompts left can be ones the host injects (``, + ``, ``, another agent's hand-back). `buildSummary` took the + first of them as the session's task, so the repo ledger gained a `summary` claim reading + " queued-remote-…" after every check-in, which the next + notification then surfaced as a déjà-vu hit. Such prompts are now skipped on both sides + (`isHarnessPrompt`): the summary uses the first prompt a person typed, or the files + touched, and a notification never triggers a lookup. + ## [1.4.2] - 2026-09-24 ### Changed diff --git a/src/deja.js b/src/deja.js index 38bfbf4..548804e 100644 --- a/src/deja.js +++ b/src/deja.js @@ -56,10 +56,21 @@ const MASKED_RE = /\|\|\s*(true|:)\b|;\s*(true|exit\s+0)\b/; /** Did this command actually RUN a test suite, with its exit code intact? */ const isTestRun = (command) => TEST_RE.test(command) && !MASKED_RE.test(command); +// Turns the host injects rather than a person typing: a background task finishing, a +// scheduled check-in, a PR notification, another agent's hand-back. In a long cloud +// session these can be the only "prompts" left, and a summary minted from one records +// the wrapper as solved work (" queued-remote-…"), which +// then comes back as a déjà-vu hit on the next notification. +const HARNESS_PROMPT_RE = + /^\s*<(task-notification|system-reminder|wake|agent-message|local-command-stdout|local-command-stderr|command-name|command-message)\b/; + +/** Was this prompt injected by the host (a notification wrapper), not typed by a person? */ +export const isHarnessPrompt = (text) => HARNESS_PROMPT_RE.test(String(text ?? "")); + /** * Distill a session's normalized event log into a deterministic summary body, or null * when there is nothing worth remembering (no prompt and no edits). The gist is the - * first user prompt, secret-redacted (redactSecrets — one truth, two verbs) and + * first prompt a person typed (host-injected notifications are skipped), secret-redacted (redactSecrets — one truth, two verbs) and * whitespace-collapsed; files are the sorted unique edit targets. `tested` reports * whether a test command exited 0 this session (drives the confirm outcome, NOT the body * — verification must be evidence, never a self-asserted flag). @@ -71,7 +82,11 @@ export function buildSummary(events = []) { ...new Set(events.filter((e) => e.type === "edit" && e.file).map((e) => e.file)), ].sort(); const first = events.find( - (e) => e.type === "prompt" && typeof e.text === "string" && e.text.trim(), + (e) => + e.type === "prompt" && + typeof e.text === "string" && + e.text.trim() && + !isHarnessPrompt(e.text), ); const gist = first ? redactSecrets(first.text).replace(/\s+/g, " ").trim().slice(0, 280) : ""; if (!gist && !files.length) return null; @@ -184,7 +199,8 @@ export function dejaLine(top, nowDay = 0) { /** * Full best-effort advisory for a task: kill-switch check (FORGE_DEJA=0), load, rank, - * format. Returns "" for a disabled switch, an empty query, no hits, or any failure — + * format. Returns "" for a disabled switch, an empty or host-injected query, no hits, or + * any failure — * safe to call from a hook or a preflight path. * @param {string} root * @param {string} task @@ -193,7 +209,7 @@ export function dejaLine(top, nowDay = 0) { */ export function dejaAdvisory(root, task, nowDay = epochDay()) { if (process.env.FORGE_DEJA === "0") return ""; - if (!task || !String(task).trim()) return ""; + if (!task || !String(task).trim() || isHarnessPrompt(task)) return ""; try { const hits = dejaFromLedger(root, task, { nowDay, budget: 3 }); const line = dejaLine(hits[0], nowDay); diff --git a/test/deja.test.js b/test/deja.test.js index 608bb9e..0015584 100644 --- a/test/deja.test.js +++ b/test/deja.test.js @@ -10,6 +10,7 @@ import { dejaFromLedger, dejaLine, dejaLookup, + isHarnessPrompt, recordSessionSummary, } from "../src/deja.js"; import { mintClaim, val } from "../src/ledger.js"; @@ -110,6 +111,50 @@ test("dejaAdvisory: kill switch and empty task both yield silence", () => { assert.equal(dejaAdvisory(root, " ", 100), ""); }); +test("buildSummary: host-injected notification prompts are not the session's task", () => { + const note = + " queued-remote-notifications pending"; + assert.equal(isHarnessPrompt(note), true); + assert.equal(isHarnessPrompt(" scheduled check-in"), true); + assert.equal(isHarnessPrompt(''), true); + assert.equal( + isHarnessPrompt("fix the
overflow"), + false, + "a tag mid-sentence is a person", + ); + assert.equal(isHarnessPrompt(" spacing is off"), false, "a JSX name is not a host wrapper"); + // Only notifications and no edits: nothing worth remembering. + assert.equal(buildSummary([{ type: "prompt", text: note }]), null); + // The first prompt a person typed wins over an earlier notification. + const s = buildSummary([ + { type: "prompt", text: note }, + { type: "prompt", text: "tighten the pricing grid gap" }, + { type: "edit", file: "src/Pricing.tsx" }, + ]); + assert.equal(s.text, "tighten the pricing grid gap"); + // Notifications only, but files changed: fall back to the files, never the wrapper. + const f = buildSummary([ + { type: "prompt", text: note }, + { type: "edit", file: "src/a.js" }, + ]); + assert.equal(f.text, "touched src/a.js"); +}); + +test("dejaAdvisory: a host-injected notification never triggers a déjà-vu lookup", () => { + const root = fixture(); + const note = " queued-remote-notifications"; + recordSessionSummary( + root, + "sess-N", + [ + { type: "prompt", text: `${note} x` }, + { type: "edit", file: "a.js" }, + ], + 100, + ); + assert.equal(dejaAdvisory(root, note, 101), ""); +}); + test("recordSessionSummary mints a retrievable summary; passing tests make it verified", () => { const root = fixture(); const events = [