feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]

The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 01:08:43 +02:00
commit b185db9a12
10 changed files with 661 additions and 44 deletions

View file

@ -319,3 +319,123 @@ describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
}
});
});
describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", () => {
// One temp dir per fixture holds both the store file and the brief out dir, so the
// real per-user data dir (defaultBriefDir) is never touched.
const fixture = () => {
const dir = mkdtempSync(join(tmpdir(), "trends-r3b-"));
return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") };
};
const listJson = (store: string): Array<Record<string, any>> =>
JSON.parse(run(["list", "--store", store, "--json"], "").stdout);
const seedScored = (store: string, title: string, url: string, timing = 9): void => {
const batch = JSON.stringify([
{
source: "tavily",
title,
url,
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing, angle: 7, authority: 6 } },
},
]);
run(["capture", "--store", store], batch);
};
test("RED: act --id → acted; skip → skipped; reset → new (read back via list --json)", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Lifecycle", "https://e/lc");
const id = listJson(store)[0].id;
assert.equal(run(["act", "--id", id, "--store", store], "").status, 0);
assert.equal(listJson(store)[0].status, "acted");
run(["skip", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "skipped");
run(["reset", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "new");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: an unknown id → exit 2 + store unchanged; a missing --id → exit 2", () => {
const { dir, store } = fixture();
try {
seedScored(store, "X", "https://e/x");
const before = readFileSync(store, "utf8");
assert.equal(run(["act", "--id", "nope", "--store", store], "").status, 2);
assert.equal(readFileSync(store, "utf8"), before, "store untouched on a not-found id");
assert.equal(run(["skip", "--store", store], "").status, 2, "missing --id → exit 2");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief records surfacing (surfacedCount + lastSurfacedAt + marked); a second same-day run is idempotent", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Surf", "https://e/surf");
const o1 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o1.marked, 1, "first brief marks 1");
const rec = listJson(store)[0];
assert.equal(rec.surfacedCount, 1);
assert.match(rec.lastSurfacedAt, /^\d{4}-\d{2}-\d{2}$/);
const o2 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o2.marked, 0, "second same-day brief is idempotent");
assert.equal(listJson(store)[0].surfacedCount, 1, "count unchanged on the second same-day run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief --no-mark writes no surfacedCount", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Dry", "https://e/dry");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--no-mark", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o.marked, 0);
assert.equal("surfacedCount" in listJson(store)[0], false, "--no-mark must not write surfacedCount");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: the brief .md omits an acted record", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Handled", "https://e/handled");
const id = listJson(store)[0].id;
run(["act", "--id", id, "--store", store], "");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
const md = readFileSync(o.path, "utf8");
assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: a re-capture with a changed score reports merged:1 + refreshes the composite", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Re", "https://e/re", 9);
const c1 = listJson(store)[0].score.composite;
const batch = JSON.stringify([
{
source: "tavily",
title: "Re",
url: "https://e/re",
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 2, angle: 7, authority: 6 } },
},
]);
const o = JSON.parse(run(["capture", "--store", store, "--json"], batch).stdout);
assert.equal(o.merged, 1, "a changed score on a duplicate → merged");
const c2 = listJson(store)[0].score.composite;
assert.ok(c2 < c1, "a lower timing → lower composite (re-score last-wins)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});