The visible layer of R2 (R2a landed the data layer @ 7a15803). A pure
rankForBrief (pillar-overlap -> recency over the store, publishedAt ?? capturedAt
freshness, 7d window) + renderBrief (dated Markdown with a hook-surfaceable
summary frontmatter) + briefSummary (one summary source) + defaultBriefDir
(derived from defaultStorePath). CLI `brief` writes the dated file; session-start
surfaces the latest, zero-tsx. Wired into trend-spotter (scan->capture->brief->
surfaced). No store-schema change, no scoring change, no scheduler (R3).
Light-Voyage hardened: scope-guardian ALIGNED (0); brief-reviewer
PROCEED_WITH_RISKS; plan-critic REVISE (2 blockers, 5 majors, 4 minors) — all
folded (briefSummary single source, wrong-value RED stubs, --out !== "true"
guard, defaultBriefDir derives from defaultStorePath, two-gate split since the
hook suite is not in test-runner.sh, url-asc total order). Go-gate: WIRE +
English "## Morning Brief" heading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
24 KiB
Plan — RE-R2b: dated morning-brief artifact + session-start surfacing
Brief:
docs/research-engine/brief-re-r2b.md. Slice: RE-R2b (research-engine rung-2 — R2 visible layer). TDD-order: RED (brief.tsrank/render/path +cli.ts brieftests as logic-RED) → GREEN (brief.tspure functions) → GREEN (cli.ts briefsubcommand) → GREEN (session-start.mjssurfacing + hook test) → wiretrend-spotter.md+ README → gate floors + Section 16i → behavioural → land. Counts recounted live at land, never pinned/guessed. Light-Voyage hardened: scope-guardian / brief-reviewer / plan-critic findings folded (see §Plan-critic — folded).
Goal
Make the accumulated, publish-dated store visible: a pure rankForBrief (pillar-overlap → recency over the
store, with a publishedAt ?? capturedAt freshness window) + a pure renderBrief (a dated Markdown artifact
with a hook-surfaceable summary frontmatter), a brief CLI that writes the dated file, and a zero-tsx
session-start surfacing of the latest brief. No store schema change; no scoring change; no scheduler — the
autonomous trigger + freshness-as-seen-log + relevance scoring stay R3.
Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
scripts/trends/src/brief.ts |
NEW — pure rankForBrief + renderBrief + briefSummary (single summary source) + defaultBriefDir (derived from defaultStorePath) + BriefRanking/BriefEntry types + BRIEF_SCHEMA_VERSION |
SC1, SC2, SC3 |
scripts/trends/src/cli.ts |
EDIT — brief subcommand (flag-driven: store→rank→render→write <outDir>/<today>.md; --pillars/--fresh-days/--out/--store/--json; exit 2 bad invocation) + usage()/header doc line + import from brief.js |
SC4 |
scripts/trends/tests/brief.test.ts |
NEW — rankForBrief grouping/freshness/sort + renderBrief frontmatter/summary/empty-case/determinism + defaultBriefDir |
SC1, SC2, SC3 |
scripts/trends/tests/cli.test.ts |
EDIT — brief happy path (writes dated file, prints path), --json summary, --fresh-days bad → exit 2, empty --pillars → exit 0 no-match brief, --out override |
SC4 |
hooks/scripts/session-start.mjs |
EDIT — module-private latestMorningBrief(briefDir) (zero-tsx readdir+read+extractYaml) + an unconditional ## Morning Brief block after the brain nudge (:500-504), brief dir = join(getDataRoot('trends'),'morning-brief') |
SC5 |
hooks/scripts/__tests__/session-start-morning-brief.test.mjs |
NEW — subprocess + LINKEDIN_STUDIO_DATA fixture (mirrors session-start-trends-staleness.test.mjs): brief present → block surfaces; absent → no block/no crash; zero-tsx |
SC5 |
agents/trend-spotter.md |
EDIT (Open Q#1, default) — after Step 4.5 capture, run brief --pillars <scanned pillars>; carries literal src/cli.ts brief; domain-general |
SC6 |
scripts/trends/README.md |
EDIT — document brief subcommand + trends/morning-brief/YYYY-MM-DD.md artifact + frontmatter shape |
— |
scripts/test-runner.sh |
EDIT — TRENDS_TESTS_FLOOR 79→recount (inside deps guard) + inline breakdown comment (:697); NEW unconditional Section 16i (after 16h / before 18); ASSERT_BASELINE_FLOOR 90→recount; header-enumeration comment (:33-46) |
SC6 |
docs/research-engine/{brief,plan}-re-r2b.md |
NEW — slice docs (TRACKED, like docs/second-brain/*) |
— |
STATE.md |
EDIT at land — Telling-block reconcile (trends floor, ASSERT floor, hook-suite, gate total). Land bookkeeping, LOCAL-ONLY. | — |
Not touched (scope fence): scripts/trends/src/{types,store,item,score}.ts (no schema change, no scoring
change, queryByTopic NOT refactored) · references/* (SSOT unchanged, no new ref) · config/* · no new
.mjs under hooks/scripts/ (surfacing edits existing session-start.mjs) · agents/* count (19) ·
commands/* (29) · references/* (27) · .gitignore (trends lines present).
Step 1 — (RED) failing tests for brief ranking, rendering, and the brief CLI
Author brief.test.ts (new) + extend cli.test.ts. To make the brief cases fail on assertion (not on
module-absent — the R2a discipline, plan-re-r2a.md:38-47), Step 1 creates brief.ts with wrong-but-non-
throwing stubs: rankForBrief → { today, freshDays: opts?.freshDays ?? 7, totals:{trends:0,matched:0, fresh:0}, topMatches:[], singleMatches:[], olderMatched:[] }; renderBrief → ""; briefSummary → "";
defaultBriefDir → "". Cases then fail on value assertions (empty buckets ≠ expected, "" lacks
frontmatter), not module-not-found. The stubs are replaced in Step 2 — none survives into GREEN.
brief.test.ts:
- rank/group (SC1): a fixture store with trends at known
overlap(0/1/2+) × freshness →topMatchesonlyoverlap≥2 & fresh,singleMatchesonlyoverlap===1 & fresh,olderMatchedonlyoverlap≥1 & stale,overlap===0absent everywhere; within-group orderoverlap desc → effectiveDate desc → title asc → url asc;matchedPillars= actual matched names (case-insensitive match, pillar original case kept);totalscorrect. Total-order fixture: two records with the same title + effectiveDate + overlap but different url → assert a fixed order byurl asc(proves order is independent of store insertion / V8 sort stability). - freshness (SC2):
effectiveDate = publishedAt ?? capturedAt; fresh-by-publishedAt-despite-old-capturedAt and fresh-by-capturedAt-fallback-when-publishedAt-absent; boundaryageDays === freshDaysis fresh;freshDays: 14re-buckets a 10-day item from older→fresh. - render + summary (SC3):
renderBriefoutput starts with frontmatter carryingdate/ a column-0 single-linesummary(assert no"and no\nin the summary line) /store: { trends, matched, fresh }/schemaVersion: 1; the frontmattersummaryequalsbriefSummary(ranking)(one source); body has the three section headings + the pinned entry-line shape; the empty ranking → a valid brief whosesummaryisbriefSummary(emptyRanking)= the "no fresh" line; determinism: tworenderBriefcalls on the same ranking are byte-identical. - path:
defaultBriefDir()ends withtrends/morning-briefand honorsLINKEDIN_STUDIO_DATA(set the env in-test, assert prefix == that root; restore) — proving it rides ondefaultStorePath's root.
cli.test.ts (subprocess node --import tsx src/cli.ts brief with a --store temp + --out temp):
- happy:
--pillars a,b --store <tmp-with-matches> --out <tmp>writes<out>/<today>.md(file exists; today-shaped name) and prints the path;--json→ object withpath/date/totals/summary,summaryequal to what the file's frontmatter carries. --fresh-days xyz→ exit 2; empty--pillars(omit the flag) → exit 0 + a file written (no-match brief); bare--out(no value) → does NOT write to./true/(falls back todefaultBriefDir()— theflags.out !== "true"guard).
RED proof (record in commit): (cd scripts/trends && npm test) → brief.test.ts cases fail on assertion
(wrong-value stubs), and cli.test.ts brief cases fail logic-RED against the existing dispatch (an unknown
brief command hits usage() → exit 2, no file written) — neither is module-not-found.
Step 2 — (GREEN) pure brief layer: brief.ts
Create scripts/trends/src/brief.ts:
import type { TrendStore, TrendRecord } from "./types.js";- Types:
BriefEntry { trend: TrendRecord; overlap: number; matchedPillars: string[]; effectiveDate: string; ageDays: number };BriefRanking { today: string; freshDays: number; totals: { trends: number; matched: number; fresh: number }; topMatches: BriefEntry[]; singleMatches: BriefEntry[]; olderMatched: BriefEntry[] }. export const BRIEF_SCHEMA_VERSION = 1;import { defaultStorePath } from "./store.js";+import { join, dirname } from "node:path";(NOhomedir— the root comes viadefaultStorePath, not re-resolved).rankForBrief(store, pillars, today, opts):freshDays = opts?.freshDays ?? 7; lowercase the pillar set once; for each trend computeoverlap/matchedPillars(case-insensitive membership, original-case pillar preserved — same idiom asstore.ts:151-152, recomputed here,queryByTopicuntouched),effectiveDate = trend.publishedAt ?? trend.capturedAt,ageDays = Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000)(a local day-delta — NOT imported fromcli.ts'sdaysBetweencli.ts:107-109; importing it would invert the dependency direction, ascli.tsimportsbrief.ts). Bucket; sort each bucketoverlap desc → effectiveDate desc → title asc → url asc(total order);totals.briefSummary(ranking): the single summary source — fresh>0 ?<fresh> ferske tema-signaler matcher pillarene dine. Topp: «<top title>» (<top pillar> · <age>d).:Ingen ferske tema-signaler på pillarene dine (av <trends> i lager).One line, no", no\n.renderBrief(ranking): build the YAML frontmatter withsummary: ${briefSummary(ranking)}(the shared source, never re-derived),date/store/ranking/schemaVersion, then the three sections with the pinned entry-line shape (titles in «»). Column-0summary.defaultBriefDir():join(dirname(defaultStorePath()), "morning-brief")—defaultStorePath()=<root>/trends/trends.json, sodirname=<root>/trends, +morning-brief=<root>/trends/morning-brief. One root resolution (reusesdefaultStorePath); no duplicateLINKEDIN_STUDIO_DATA ?? join(homedir()…).- Replace the Step-1 wrong-value stubs. Make Step 1's
brief.test.tscases green.
Step 3 — (GREEN) CLI brief + cli.test.ts
In cli.ts: add import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";, add
mkdirSync/writeFileSync to the node:fs import (currently only readFileSync cli.ts:30), and add
import { join } from "node:path";. Add the brief branch to main's dispatch (after capture): const pillars = splitTopics(flags.pillars); parse --fresh-days (default 7; Number.isNaN → usage("--fresh-days must be a number")); const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir() (the
!== "true" guard is required — parseFlags cli.ts:52-53 yields "true" for a bare --out); const ranking = rankForBrief(loadStore(storePath), pillars, today(), { freshDays }); const md = renderBrief(ranking);
const path = join(outDir, today() + ".md"); mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, "utf8"); human → Wrote brief: <path> (M matched, K fresh); --json → { path, date: ranking.today, totals: ranking.totals, summary: briefSummary(ranking) } — summary is briefSummary(ranking), the same source
renderBrief puts in the frontmatter (no re-derivation). Add the brief … line to usage() (cli.ts:71-84)
and the header doc-comment (cli.ts:1-28). Make Step 1's cli.test.ts brief cases green. today()
exact-value assertions are NOT in cli.test.ts (it reads the wall clock) — the date-shape
(/^\d{4}-\d{2}-\d{2}$/) is asserted on the written filename; exact-date logic is covered in brief.test.ts
via injected today.
Step 4 — (GREEN) session-start surfacing + hook test
session-start.mjs: add latestMorningBrief(briefDir) near trendsNewestCapture (:38): existsSync guard →
readdirSync(briefDir).filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse()[0] → read it →
{ date: extractYaml(c,'date'), summary: extractYaml(c,'summary'), file: join(briefDir, name) }; any
throw/absence → null. extractYaml (:19-23) captures [^"\n]* + .trim(), so date/summary are
newline-free → the surfaced block needs no .replace(/\n/g,'\\n') (contrast the multi-line state
sections :320); only the static \\n literals are used. Inject after the brain-missing nudge
(:500-504), unconditional:
const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief'));
if (latestBrief && latestBrief.summary) {
context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`;
}
(getDataRoot :11 + join :6 are already imported; readdirSync already imported :5.) No tsx. Then
session-start-morning-brief.test.mjs (new): read session-start-trends-staleness.test.mjs first and copy
its exact structure (the env-var name it sets, how it spawns session-start.mjs, how it parses the JSON
hookSpecificOutput.additionalContext, how it writes the fixture under the temp root). Cases: (a) with a
fixture trends/morning-brief/<date>.md under env.LINKEDIN_STUDIO_DATA → additionalContext includes ## Morning Brief, the summary, the file path, and no raw \n inside that block (the \\n idiom held);
(b) no brief dir → no Morning Brief block + continue: true (no crash); (c) path cross-check — write the
fixture at the path defaultBriefDir() resolves to under that same LINKEDIN_STUDIO_DATA and confirm the hook
finds it (CLI-write path == hook-read path). The temp root has no node_modules/tsx (zero-dep proof).
This test runs under node --test hooks/scripts/__tests__/, the separate hook-suite gate — NOT
test-runner.sh.
Step 5 — wire trend-spotter.md (Open Q#1, default) + README
In agents/trend-spotter.md, after the Step 4.5 capture block, add a step: build the pillar list the agent
already scans and run node --import tsx scripts/trends/src/cli.ts brief --pillars <pillars> to write
today's dated brief. Replacement prose must contain the literal src/cli.ts brief (Section 16i grep -qF).
Keep the "skip silently if no deps" escape hatch + domain-general phrasing (no vendor/sector tokens — Section
17). Update scripts/trends/README.md: a brief CLI example + the trends/morning-brief/YYYY-MM-DD.md
artifact + its frontmatter (date/summary/store/schemaVersion), framed as "the dated, surfaced read over
the store" (distinct from query/list interactive dumps).
Step 6 — gate: floors + new unconditional Section 16i
In scripts/test-runner.sh:
- Set
TRENDS_TESTS_FLOOR(:697, currently 79) to thetests Nline reported by(cd scripts/trends && npm test)after Steps 1–3 — recounted live, NOT an additive guess. Stays inside theif [ -x …/tsx ]deps guard. Append+ RE-R2b: brief +N + cli +M (morning-brief)to the inline per-slice breakdown comment on:697so the comment can't drift from the number. - Add Section 16i ("Trends Brief Wiring", RE-R2b), mirroring Section 16h's shape (
:1074-1116). Placement (verified): file order is 17→16g→16h→18 (:943/:1010/:1074/:1118), so 16h is the last section before Section 18 — insert 16i after 16h's closingfi/echo ""(~:1116), before the Section 18 block (:1118) (anti-erosion Section 18 must stay last so it counts every prior check). Four unconditional, deps-absent-safe checks (puregrep -qF/self-test, notsx): (1) a non-vacuity self-test (probe carryingsrc/cli.ts briefaccepted, one without rejected); (2)grep -qF 'command === "brief"' scripts/trends/src/cli.ts; (3)grep -qF 'src/cli.ts brief' agents/trend-spotter.md; (4)grep -qF 'latestMorningBrief' hooks/scripts/session-start.mjs(surfacing is wired, not just doc'd). - Bump
ASSERT_BASELINE_FLOOR(:1136, currently 90) → live recount (= 90 + the new unconditional 16i emitters; expected ~94, recounted at land). Update the header-enumeration prose chain (:33-46) by inserting the 16i clause between the 16h clause (:43-45) and the Section-18 clause (:46), preserving sentence flow (it's prose, not an append). - NOT touched here: the hook suite has no floor in
test-runner.sh(noHOOK_TESTS_FLOOR, no rootpackage.json); the new hook test is gated bynode --test hooks/scripts/__tests__/(Step 4), separately.
Step 7 — behavioural verification
(cd scripts/trends && npm install) if needed, then run brief §7's three behavioural steps (seed via
capture, generate via brief --json, surface via session-start.mjs with LINKEDIN_STUDIO_DATA). Confirm
the top-match grouping, the summary, and the surfaced block (tsx absent for the surfacing). Run full
bash scripts/test-runner.sh → FAIL=0.
Step 8 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, hook-suite, gate
total). Commit order (house style): (1) docs commit docs/research-engine/{brief,plan}-re-r2b.md (no
suffix, tracked); (2) code commit scripts/trends/src/brief.ts + cli.ts + tests +
hooks/scripts/session-start.mjs + the new hook test + agents/trend-spotter.md + scripts/trends/README.md
scripts/test-runner.shwith[skip-docs]. Push freely (window lifted; gitleaks at commit;origin= PUBLICopen/— STATE/*.local.*never pushed). No version bump (additive;v0.5.2dev).
Verification (testable)
| SC | Check | Command | Expected |
|---|---|---|---|
| — | RED proof | (cd scripts/trends && npm test) after Step 1 |
brief cases fail on assertion (logic-RED), not module-not-found |
| SC1 | rank/group | npm test (brief.test.ts) |
groups by overlap×freshness; overlap-0 excluded; sort overlap→effectiveDate→title; matchedPillars + totals correct |
| SC2 | freshness | npm test (brief.test.ts) |
effectiveDate = publishedAt ?? capturedAt; boundary =freshDays fresh; freshDays configurable |
| SC3 | render | npm test (brief.test.ts) |
frontmatter date/summary(no ")/store/schemaVersion:1; 3 sections; empty→valid no-match brief; deterministic bytes |
| SC4 | CLI brief | npm test (cli.test.ts) + manual |
writes <out>/<today>.md; --json {path,date,totals,summary}; --fresh-days bad → exit 2; empty --pillars → exit 0 no-match |
| SC5 | surfacing | node --test hooks/scripts/__tests__/ + manual |
brief present → ## Morning Brief + summary + path, no raw \n; absent → no block/no crash; zero-tsx; CLI-write path == hook-read path |
| SC6(a) | gate + wiring + de-niche | bash scripts/test-runner.sh |
FAIL=0; trends ≥ floor; Section 16i green; ASSERT floor recount; Section 17; counts 27/19/29 |
| SC6(b) | hook gate | node --test hooks/scripts/__tests__/ |
new session-start-morning-brief.test.mjs green (hook suite is NOT part of test-runner.sh) |
Risks
- R1 — non-determinism leaks into the brief (clock/env inside the pure functions → flaky bytes). Mitigated:
today/pillars/freshDaysinjected; SC3 asserts byte-identical output;ageDaysuses the injectedtoday. - R2 —
extractYamlmis-reads thesummary(a"or newline in the value truncates it). Mitigated:renderBriefemits a single-linesummarywith no embedded"(titles in «»); SC3 asserts it; the hook guardslatestBrief.summarytruthy before surfacing. - R3 — the hook accidentally needs tsx (e.g. someone "reuses" the TS ranker in the hook). Mitigated:
surfacing is
readdir+readFile+extractYamlonly; SC5 runs with tsx absent; the B-S3 precedent (:32-37) is the explicit pattern. - R4 — editing
trend-spotter.mdtrips the de-niche guard. Mitigated: Section 17 runs in the gate; the added step is pillar-driven (user config), vendor/sector-free; only abriefcall is added. - R5 — new gate checks must survive a deps-absent fresh clone. Mitigated: Section 16i is pure
grep/self-test on tracked source (notsx) → unconditional;TRENDS_TESTS_FLOORstays inside the deps guard. - R6 — brief dir vs store path drift (hook
join(getDataRoot('trends'),'morning-brief')vs the CLI'sdefaultBriefDir()). Mitigated:defaultBriefDir()now derives fromdefaultStorePath()(one root resolution, not a re-implementation) — sobrief.tsandstore.tscannot disagree on the root. The remaining hook-vs-CLI pair (getDataRoot('trends')vsdefaultStorePath's root) is the same store-path twin already in production (the B-S3 staleness nudge readsjoin(getDataRoot('trends'),'trends.json'):376and must already equal the CLI'sdefaultStorePath()for that nudge to work) and is behaviorally guarded by__tests__/data-root.test.mjs. SC5(c) adds a direct write-then-read cross-check;brief.testpinsdefaultBriefDiragainstLINKEDIN_STUDIO_DATA. - R7 —
brief.tsis a new source file (R2a forbade one for the bridge). Mitigated: justified by cohesion (a ranking+rendering module with its own types/version, not a single-use function); §5 states it; counts unaffected (no new ref/agent/command).
Plan-critic — folded
plan-critic returned REVISE (2 blockers, 5 majors, 4 minors); brief-reviewer PROCEED_WITH_RISKS (1 major + minors); scope-guardian ALIGNED (0 findings; counts 27/19/29 verified live). Resolution, each verified against live code:
- [BLOCKER]
--jsonsummary source left as "re-derive OR expose" — RED can't target an undecided API. ✅ committed toexport function briefSummary(ranking): string(Step 2);renderBrieffrontmatter + CLI--jsonboth call it (Step 3); Step 1 assertsrenderBrief's frontmattersummary === briefSummary(ranking). - [BLOCKER] Section 16i placement ambiguous ("after 16h / before 18" vs brief's wording). ✅ verified file
order 17→16g→16h→18 (
:943/:1010/:1074/:1118); Step 6 pins 16i after 16h's closingecho ""(~:1116), before Section 18 (:1118); states 16h is the last section before 18. - [MAJOR] throwing-stub RED contradicts "fail on assertion, not module-not-found" (R2a's blocker class).
✅ Step 1 now uses wrong-but-non-throwing stubs (empty buckets /
"") sobrief.testfails on assertion;cli.testbrief cases are logic-RED against the existing dispatch (unknown command →usage()exit 2). - [MAJOR] brief §3
flags.out ?? defaultBriefDir()writes./true/for a bare--out(parseFlagscli.ts:52-53). ✅ brief §3 + Step 3 use theflags.out !== "true"guard; Step 1 adds a bare---outcli.test case. - [MAJOR]
TRENDS_TESTS_FLOORrecount ambiguous (fulltests Nvs additive). ✅ Step 6 pins it to thetests Nline after Steps 1–3, comment appends+ RE-R2b: brief +N + cli +M. - [MAJOR]
defaultBriefDirtriple-twin root re-resolution (drift risk). ✅ it now derives fromdefaultStorePath()(one root resolution, Step 2); R6 + SC5(c) cite the existingdata-root.test.mjstwin guard and add a write-then-read cross-check. - [MAJOR] surfacing newline-escape unproven. ✅ Step 4 + SC5 state
extractYaml's[^"\n]*+.trim()makesdate/summarynewline-free → no.replaceneeded; SC5 asserts no raw\nin the block. - [brief-reviewer MAJOR] SC6 "hook-suite at recount" attributed to
test-runner.sh(which neither runs nor counts it — noHOOK_TESTS_FLOOR, no rootpackage.json). ✅ SC6 + the verification table split into two gate commands:test-runner.sh(trends/16i/ASSERT/de-niche/counts) andnode --test hooks/scripts/__tests__/(the new hook test). - [MINOR] non-total sort order (same title, different url ties fully). ✅ added
url ascfinal tie-break (Step 2/SC1); Step 1 adds a same-title/diff-url fixture. [MINOR] localageDaysduplication unstated ✅ Step 2 states the deliberate non-import (dependency directioncli.ts → brief.ts). [MINOR].md$anchor ✅ confirmed in the filter regex (Step 4). [MINOR] header-enumeration prose flow ✅ Step 6 inserts the 16i clause in-prose between the 16h and Section-18 clauses. [brief-reviewer MINOR] body entry-line age format ✅ pinned in brief §3 + SC3. [brief-reviewer MINOR] Open Q#2/#4 are load-bearing ✅ reframed as confirmations (brief §8). - [plan-critic headless-readiness] N/A — R2b executes in-session, operator-driven (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a had none either).
scope-guardian — ALIGNED: every SC1–SC6 traces to a step; zero creep; all nine §4 non-goals held (no
scheduler, no scoring change, no store-schema change, no relevance/saturation/status fields, no pillar-from-state
resolution, no delivery channel, no brief-diffing, no new agent/command); counts 27/19/29 verified live;
brief.ts the only new source file.