-
v0.8.0 Stable
released this
2026-07-27 07:11:40 +00:00 | 18 commits to main since this releaseThe production line, closed. 0.7.0 built the bridge from discovery to a scheduled edition; 0.8.0 makes the whole line hold together and, for the first time, feed back into itself. A candidate now travels id-traceably from capture to a scheduled slot with no manual re-entry anywhere in the chain (proven end-to-end before this release). A measurement now changes the next piece instead of dying in chat. A correction made once is enforced from then on. Voice is bound before prose rather than scrubbed after. The series remembers what it has already spent. And the engine finally knows your own normal well enough to refuse a verdict when the data is too thin — the answer that keeps everything above it honest.
Added — demand-sweep «innenfra og ut» (N7.5 / MR-F9)
demand-spotteragent — the demand-side twin oftrend-spotter. Discovery finds what happened; the sweep translates it into the reader's problem via three passes after discovery, before drafting: demand-sweep (verbatim questions from where readers ask) → pain-point map → vocabulary translation (the reader's words, not the field's jargon). Inherits the session model; agents 19 → 20.- Tier 5 demand sources — a new tier in the source-list convention (
config/trends-sources.template.md): practitioner forums, GitHub issues on spec repos, procurement data, regulator guidance, channel comments — the inverse of the Tier 1–4 supply sources. Known blind spots stated honestly (YouTube JS-rendered → API; Reddit approximate; HN/GitHub = ground truth; an honest null is a finding). demandsignal on the trend record — the rankable twin of the verbatimsaturationtext:strength(etterspørsel: strong/moderate/thin) +answered(is it already answered). Additive-optional; store schema stays v4 (no migration). Absent ⇒ unmeasured (the honest null the sweep may return instead of inventing demand).- Arc (åre) output contract §4 —
scripts/trends/src/arc.ts: groups demand-swept records into veins (relatedIds transitive closure), ranks each vein's questions onetterspørsel × kan-svare × ikke-besvart, and classifies an honest market verdict distinguishing a supply-gap (many ask, none answer — write here) from a demand-gap (few ask, but the sender can answer — high value, low audience) from saturated (skip). Pure + deterministic. arcsCLI verb +/linkedin:trends --demandmode — render the §4 arc map (node --import tsx src/cli.ts arcs), reachable via the command's new demand mode (delegates todemand-spotter, keeps commands at 30). The morning brief also shows the per-candidate demand signal alongside the existing reader fields.- Trends suite 276 → 300; test-runner 139 → 140;
tsc --noEmitclean.
Added — voice chain: bind voice BEFORE prose (N8 / C-1, C-2, C-3, A2-F1, A2-F2)
chronicle-voice-profileartifact (C-1/A2-F1) —voice-trainernow distils a dedicated long-form voice profile (${DATA}/profile/chronicle-voice-profile.md) from the published gold corpus only (ingest/published/,provenance=published), one section per(language, format)pair present in the corpus — never from the English short-formauthentic-voice-samples.mdthe engine itself calls degrading for chronicle voice, never fromprovenance=ai-draft./linkedin:newsletterbinds long-form drafting (Step 0 / 3a / 3b) to THAT profile as the primary reference, with an explicit, never-silent fallback to the short-form corpus when it is absent.- Drift log is READ, not just written (C-2) — Step 3a gains a Pass 0: read
chronicle-voice-drift-log.mdas a negative list of the recurring AI-tells/driftsvoice-scrubberlogged on prior editions, so drafting avoids them BEFORE the scrub (the log was append-only, never consumed); Step 3b re-reads it during expansion. - Review language resolved + persisted at Step 1 (C-3) — the calibration now resolves the review
language(detect/ask) and persists the top-leveledition-state.languageat the Step 1.5 checkpoint, making the template's "Resolved at Step 1" doc-string true. Previously the default"en"was never set, solanguage-reviewer's Norwegian checks andvoice-scrubber's gold standard silently never fired even for a Norwegian edition; Step 6.5 already consumed the field, so resolving it closes the axis end-to-end. - Auto-gull — the gold corpus grows on its own (A2-F2) — Step 8 lock now appends the locked edition to
ingest/published/taggedprovenance=published, so the corpus the chronicle profile is distilled from grows one edition at a time (the author's own locked editions, never the engine's drafts). No manual curation. - Domain-general throughout: languages/formats come from the user's own corpus, never hardcoded. test-runner 140 → 146 (Section 16o: 6 unconditional voice-chain greps + self-test; anti-erosion floor 123 → 129).
Added — randsone gates: gate the feed-facing surfaces (N9 / C-4, C-6, C-7, C-9)
- Randsone fact + language gate (C-4/C-9) —
/linkedin:newsletterStep 9 (post-lock) now runs a scopedfact-reviewer(F1 verifiable claims + F3 number attribution) +language-reviewer(L1 repetition + L2 anglicisms/register) pass over the four feed-facing surfaces the reader actually meets — the delingstekst hook, the samle post, the carousel slide text, and the edition title — before the hook-conversion binary. Until now only the hook's click-worth was judged; its truth and language, the samle post, the carousel slides and the title reached the feed ungated. Placed at Step 9 because that is the first phase where all four surfaces exist (carousel is authored in Step 7.5; delingstekst/samle from Step 8), and Step 9 already operates on post-lock distribution copy — so the gate revises distribution surfaces + re-renders, never the locked body; a defect tracing into the locked body/canonical title is a pivot. - Edition title-strength gate (C-9) — the edition's own title now faces a strength gate at Step 9 (binding on the distribution title, advisory on the locked canonical title), not only the hook's two lines.
- Deterministic post-factcheck fact-recheck (C-6) — a new invariant after Step 5: any later fold-in (5.5/6/6.5/7) whose diff touches a sentence with numbers, names, or quotes fires a mandatory scoped
fact-reviewermini-recheck of exactly those sentences — a deterministic trigger, not a judgment call, replacing the old "re-run if it felt substantive" discretion and closing the pivot-heuristic blind spot (a swapped statistic under the +20 % threshold never trips/linkedin:pivot, but does trip this). - WebFetch on both fact agents (C-7) —
fact-checkerandfact-reviewergainWebFetch: quote precision (F2) and exact-figure attribution (F3) require the verbatim source text in context, not a search snippet. - test-runner 146 → 154 (Section 16p: 8 unconditional randsone-gate greps + self-test; anti-erosion floor 129 → 137). Domain-general; no KTG-specific values hardcoded.
Added — series-level protection: make the retread visible before prose (N11 / C-5)
scripts/editions/— series distillate (new package) — each locked edition leaves behind the narrative units it spent (anecdotes told, arguments argued, hooks used) in<serie>/linkedin/series-distillate.json, besideedition-state.jsonin the series root — series-scoped state where the series' other state already lives, so it travels with the series and needs no slug→path map.distil-append(Step 8 lock) folds an edition in; a re-lock after/linkedin:pivotreplaces its entry rather than duplicating it.- Step 2.5 destillat-sjekk — the check runs BEFORE prose — the skeleton gate now compares the proposed skeleton against everything the series has published and folds any finding into the annotation page the operator already reads, while changing course is still cheap. Advisory, never blocking (exit 0 either way): a deliberate callback is a legitimate move — an unnoticed retread is not. Until now every one of the long-form gate agents saw exactly one edition, so "the reader has heard this before" was structurally invisible; at series cadence it is the fastest-growing defect class.
- Deterministic comparison, AI extraction — the units are distilled from locked prose by the command layer, but the store, the similarity and the verdict are plain code: character-trigram Jaccard, chosen because the plugin is language-general and inflection (migrere / migreringen) breaks word-token overlap. Calibrated against real paraphrase pairs — retellings 0.44–0.55, unrelated 0.04–0.06, same-topic-different-story 0.24 — so the default threshold sits in the gap at 0.40 (word-Jaccard scored a shortened hook paraphrase at 0.11 and would have let it through). Units are compared within their kind; an argument re-used as a hook is a new move.
- Specifics-bank usage log (
usedIn) — the material grain of the same defect:recordUsage+ therecord-usageCLI verb stamp «used in edition NN» on the specifics an edition actually consumed (read from the bound slot-map viaboundSpecificIds, soabstrakt/eksternslots stamp nothing). Recorded at lock, so it means published rather than merely considered, and idempotent under a pivot re-lock. The bank's dedupe deliberately encourages re-surfacing the same material; this log is what makes that re-use visible at the next Step 1.5 instead of silent. Additive-optional — schema stays v1, existing banks load unchanged. - Suites: new
editions27/0; specifics-bank 28 → 45; test-runner 163 → 173 (Section 16r: 9 unconditional greps + non-vacuity self-test; anti-erosion floor 146 → 155).tsc --noEmitclean in both packages. Domain-general throughout.
Added — editions register + phase telemetry: make the production line measurable (N12 / A1-11, A1-12)
- Editions register (
scripts/editions, new module) — one row per edition in production in${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json: series, edition, title, series path, current phase, the one next action, and slot.register-listanswers "what is in flight, and where does each edition stand?" without opening a single series folder — until now that answer required opening everyedition-state.jsonby hand, which is why nobody had it. Data-dir placement (M0), because the register spans all series (the distillate is per-series and stays in the series root). articles.NN.phaseLog— lead time becomes a measured number — every phase transition appends{phase, completedAt}to the edition-state. Additive-optional (default[]), so schemaVersion stays 1 and pre-N12 editions load unchanged; their absence reads as "not measured", never as zero. Per-article rather than top-level, mirroringarticles.NN.phase: lead time is a property of an edition.- One call per transition, both writes —
/linkedin:newslettergains a phase-transition protocol defined once next to the resumption table, and each of the 16 canonical phases invokes it:register-upsert --edition-state <path>appends the phase-log entry and mirrors the register row. Deliberately one command, not two: telemetry the command layer must remember to write separately is incomplete inside a week, and an incomplete log measures nothing. Step 10 closes the row (register-complete) and prints the measured lead time. - The register is a MIRROR, never a source of truth — deterministic resumption still reads
edition-state.jsonand only that. Delete the register and the next transition rebuilds the row; a failedregister-upsertis reported plainly and never stops the pipeline.startedAtis the one value that cannot be recovered, so it never moves: re-upserting a completed edition reactivates the same row (what/linkedin:pivotdoes), keeping the clock on real elapsed production time. - Deterministic, no clock in the core — every mutation takes
nowas an argument (--at/--nowat the CLI edge), as the distillate takeslockedAt. Idempotent where a re-run is legitimate (a repeated transition logs once; completing twice keeps the firstcompletedAt), not where it is real work (a phase recurring after another one is logged again — a pivot back through cleared gates is production time that happened). Missing facts are refused at the edge rather than defaulted: a row naming the wrong phase is worse than no row. - Suites: editions 27 → 72; test-runner 173 → 184 (Section 16s: 11 unconditional greps incl. a 16-phase coverage sweep + non-vacuity self-test; anti-erosion floor 155 → 166).
tsc --noEmitclean. Domain-general throughout.
Added — publishing slots + vacancy warning + WIP roll-up (N13 / A1-10, D-5)
- Publishing slots are operator config (
profile/publishing-slots.json) — a recurring weekly grid ({day, time, label}) in the per-user data dir, with a schema + opt-in template atconfig/publishing-slots.template.json. The plugin writes it for nobody and ships no publishing times: while the file is absent every slot surface stays silent, exactly like the trend and brain nudges. It lives in the data dir rather than the state file because the state file is machine-written (rollover, pruning, post tracking) and its frontmatter reader does not parse lists — user config does not belong in a file the plugin rewrites. - Vacancy at session start — the SessionStart hook now names the next publishing slot nothing covers, how many are open in the next 14 days, and — when the gap is ≤3 days — how to fill it. Coverage counts both the short-form queue (status
scheduled/published; a cancelled entry frees the slot again) and editions-register rows whose slot claims that date, so long-form and short-form can never be booked on top of each other. A day carrying two slots needs two posts to be full. - Work in progress is surfaced, not searched for —
## Editions in Flightat session start, plus the same roll-up in the/linkedinrouter and/linkedin:calendar: series, edition, current phase, the one next action, claimed slot, and days in flight. The N12 register made this measurable; N13 makes it visible without asking. hooks/scripts/slots.mjs— one implementation, four surfaces — zero-dep (a SessionStart hook must not spawn tsx), pure core (dates in as strings, no clock), and imported by the commands the same wayqueue-manager.mjsalready is. The hook's vacancy warning and the slot/linkedin:newsletterdefaults to therefore cannot disagree./linkedin:newsletterStep 10 defaults to the next open slot — scheduling is one confirmation instead of an interview, and the transition now writes--slotonto the register row so a scheduled edition stops reading as an open slot. Unconfigured grid or a full horizon ⇒ the old ask, unchanged.- D-5: the shipped schedule is marked for what it is —
references/scheduling-strategy.mdnow carries the same epistemic standard as the algorithm reference (confidence: low / directional, practitioner heuristic, no primary source) and defers to the operator's own grid, tested against their own analytics. The tables are a starting point, not a finding about anyone's audience. - Suites: hooks 140 → 174 (25 slot-core + 9 session-start tests); test-runner 184 → 197 (Section 16t: 13 unconditional greps incl. a de-niche check that no publishing time is hardcoded in the slot path and no operator grid is committed, + non-vacuity self-test; anti-erosion floor 166 → 179).
Added — the fold-in loop: a correction made once is enforced from then on (N14 / A2-F6, A2-F7, A2-F8, A2-F9, C-10)
- Capture is finally wired (A2-F7) — the
articles.NN.foldIns[]queue and the promote→ratify machinery have shipped since fix #1, but nothing ever wrote to the queue and no step ever emptied it. All four steps where an operator's judgment enters the text — skeleton annotations (2.5), spine annotations (3a), editorial flags (5.5), cold-review flags (6.5) — now append the correction near-verbatim with its trigger,decision: "pending". Capture is unconditional and deliberately dumb: deciding at capture time whether a correction "deserves" a rule is exactly how the queue stays empty and the loop dies. Edition-specific fixes (a wrong number) are explicitly not rules. - New Step 11 — retro (A2-F8), ≤5 min, AFTER scheduling — three fixed jobs, one operator pass. (1) Promote: every pending fold-in gets an explicit JA/NEI; mechanical → an atomic
scripts/contract-gatepromotion (rule + contract row + manifest row) that only counts when--ratifyis green, judgment → the operator's language-rules file; NEI marks the rowrejectedand keeps it. (2) Effort summary: read straight from the measuredarticles.NN.phaseLog(N12) — transitions, recurred phases (where the rounds actually went), lead time — never re-estimated, and "not measured" when the log is absent. (3) One process-friction question, answered back to the operator: the plugin never writes into the operator's own notes or register. It runs after delivery so nothing about shipping waits on it, and it is the last thing the edition does: the pipeline is not complete until the loop is closed. Phases 18 → 19; the resumption table routesscheduling→ Step 11 andretro→ complete, so an aborted session cannot drop the queue. articles.NN.retroin edition-state — additive-optional (defaultnull), so schemaVersion stays 1 and pre-N14 editions load unchanged.- Background headless review (A2-F6) —
/linkedin:headless-review --backgroundruns the cold package in a background agent with its own context window that writes the consolidated report to disk; the drafting session then reads the file. Maximum independence used to cost a manual session switch plus pasting the report back — the seam is gone, and the isolation is identical (the background agent gets only the cold-contract inputs). The inline fan-out remains the explicit fallback; the isolation is never silently downgraded. - Language rules accumulate in a user file (C-10) —
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/<lang>.md(opt-in, templateconfig/language-rules.template.md) holds the judgment-class corrections Step 11 promoted. It is read twice: bylanguage-reviewerat Step 6.5 (catch) and by Step 4 while the draft is still being cleaned (prevent) — a rule that only ever catches is worth half a rule. The shipped ban-list (longform-quality-rules.mdrule 3) stays the baseline and is never edited by the loop; the user file extends it, and its Deliberate exceptions section is honoured. Absent file ⇒ baseline alone, silently. references/fold-in-loop.md(A2-F9) — the capture → classify → promote → enforce loop documented domain-generally and in-tree, so an adopter with no external writing contract has the whole loop (that absence "is not a gap" — the same mirror ruleeditorial-revieweruses). Names the other four accumulation silos (specifics-bank, brain, voice drift log, A/B learnings) that Step 11 surfaces together, because a retro that empties one queue teaches the operator the others are empty too. references 28 → 29.- Suites: test-runner 197 → 217 (Section 16u: 18 unconditional greps + a non-vacuity self-test on the capture predicate; the phase-coverage sweep now spans 17 phases; anti-erosion floor 179 → 198). All nine suites green.
Added — the do-next contract: a measurement changes the next piece (N15 / A2-F10, A2-F12, A2-F13, A2-F14)
recordDoNext()instate-updater.mjs— one channel from measurement to creation./linkedin:report,/linkedin:analyze, an/linkedin:ab-testAdopt verdict and thepost-feedback-monitor's 48h learning each persist 2–3 directives (imperative + evidence pointer, tagged with their source) to## Do-Next Directivesin the state file;post/quick/batch/createStep 0 andnewsletterStep 1 read them before drafting. Before this every analytics surface ended in chat (grep do.nextacross the repo returned 0) and the next drafting session — a fresh context — never saw any of it.- Lifetime is replace-by-source + a 60-day age floor — each writer supersedes only its own rows, so a new weekly report never evicts an A/B Adopt decision, and the section stays a live steering signal instead of a growing backlog. Deterministic, no AI in the write;
--record-do-nextexposes it as a CLI verb. - Previous-edition calibration (A2-F14) —
newsletterStep 1 matches the previous edition to its measured numbers through the queue id (drafts/queue.json→analytics/posts/*.jsonby publish date, disambiguated on the hook preview), with an explicit honest-miss branch: an unmeasured edition is stated as unmeasured, never estimated. - test-runner 213 → 232 (Section 16v: 15 unconditional do-next greps + self-test; anti-erosion floor 198 → 213). Two real bugs found by the tests themselves: a section regex missing
/m(old rows were never superseded or pruned) and fixtures dated 7 months before their owntoday, which had made the replace-by-source test falsely green.
Added — measure-truth: reach split + honest boundary map (N16 / D-3, A2-F11, E#9)
- Out-of-network reach as an optional manual column (D-3) — LinkedIn split a post's impressions into in-network and out-of-network in June 2026 (post analytics → Discovery, under the impressions count): the first native number that says whether a post acquired audience or only resonated with the audience it already had. It is shown as a percentage split and is absent from the CSV export (whether it will ever be exported is unverified), so it follows the saves pattern — add an
Out-of-network(orIn-network) column with the percentage read off that panel and/linkedin:importingests it. - A separate percent parser, deliberately not the count parser —
parseOptionalPercent()reads a comma as a decimal mark (36,5→ 36.5;parseOptionalCount's US-thousands rule would have said 365) and refuses any value above 100: in one column an absolute impression count and a share are indistinguishable, so the honest answer is unknown rather than a guess. Blank, non-numeric and negative stay unknown; a genuine0is kept (nothing left the network). - One stored half, cross-checked — only
outOfNetworkPctis stored, because the two halves describe one split and keeping both would let a record contradict itself. AnIn-networkcell is stored as its complement, and a transcribed pair that does not sum to ~100 (one point of rounding slack, since the UI rounds each half independently) is discarded as unknown instead of half-trusted. - Impressions-weighted roll-ups —
weightedOutOfNetworkPct()feeds an optionalavgOutOfNetworkPcton the weekly and monthly summaries. Weighted, because a flat mean lets a 50-impression post at 90 % outvote a 10,000-impression one at 20 %; posts without a reading are excluded rather than folded in as 0; zero total weight yieldsundefined, never 0 or NaN. Reach-free imports stay byte-identical to pre-N16 output, on screen and on disk. - Not part of the engagement rate — reach is distribution, not engagement, so it never enters
engagementRate(which stays comparable to every historical import)./linkedin:reportrenders the split as acquisition (out) vs resonance (in), states coverage honestly, and says "not entered for this period" rather than estimating an unentered split. A reach reading routes into the N15 do-next channel so it shapes the next post instead of being admired in the report. - Operator-gated baseline updates (A2-F11) — new
/linkedin:reportStep 7c: the report now offers a diff against the user'saudience-insights/engagement-patterns.mdbaseline (the fileanalytics-interpreter,strategy-advisorand/linkedin:audittreat as tracked truth, and which nothing in the pipeline used to write, so it went stale silently). Old → new with the evidence, an explicit go viaAskUserQuestion, changes labelled provisional when they rest on 1–2 posts, an append to the section's update log — and never a silent write, never into the plugin's shipped template. - Boundary map brought current (E#9) — the README boundaries now state all three honestly: dwell explicitly unmeasurable (internal to LinkedIn, no count to transcribe, no API), saves partner-gated (
POST_SAVEon/memberCreatorPostAnalyticsfrom v202604), and reach native-but-not-exported with export status unverified. - analytics suite 119 → 144 (25 new tests: percent contract, either-half resolution, split cross-check, weighted roll-up, backward-compat); test-runner 232 → 247 (Section 16w: 15 unconditional greps + self-test; anti-erosion floor 213 → 228);
tscclean.
Added — baseline engine: your own normal, and a refusal when N is too small (N17 / E#5)
- Every reading leads with "vs your own baseline" (E#5) — at a normal publishing cadence a week holds two or three posts, so an absolute total answers nothing on its own and a week-over-week percentage answers worse than nothing: it swings on sample composition and reads as a trend. The weekly and monthly reports now carry a
baselineblock built from the operator's own recent history, and/linkedin:reportpresents it first, absolutes second. - Median and MAD, never mean and standard deviation — one viral post would otherwise define "normal" as a number no ordinary post can reach, and every ordinary week after it would read as a decline.
median()+medianAbsoluteDeviation()are the robust pair;mean/standardDeviationstay untouched for the alert engine, which wants outlier sensitivity. An empty history yieldsundefined, never a fabricated 0. - A normal-range band instead of a bare number —
median ± 1·MAD(k configurable), floored at 0 for naturally non-negative metrics.readAgainstBaseline()returnsabove-band/within-band/below-band, andwithin-bandis the load-bearing answer: a value 7 % above the median looks like momentum in any week-over-week percentage and is ordinary variation. The reading is computed in code, so a renderer never judges the band by eye. - Minimum-N refusal — no verdict on noise — below
MIN_BASELINE_N(5, documented and overridable) the engine returns a typedinsufficient-datarefusal carryingn,requiredand an operator-facing reason, andreadAgainstBaselinedegrades tono-verdict. With fewer than five values a median moves further when one post enters than any real change would move it. The report prints the refusal verbatim, keeps showing the absolute numbers without a verdict, and may not substitute a week-over-week percentage for the missing one. An operator who has just started publishing gets this refusal for several weeks — that is the correct output. - The baseline excludes the period it judges — history is taken strictly before the reported week/month (
historyBefore), so the comparison is never partly with itself, and the period is compared on its own median, not its mean. The window is counted in posts (last 10), not days: a calendar window silently shrinks to three posts in a slow month without the operator seeing it happen. - Per-format and per-pillar baselines, each judged on its own N — format and pillar are not analytics data (they live in the post queue), so a new read-only date join supplies them (
queue-join.ts, ±2 days for a reschedule, hook-preview tiebreak). Every ambiguity resolves to unlabelled and an entry labels at most one post — a guessed label would poison the baseline it is measured against. Newbaseline [--by format|pillar]CLI verb prints the overall baseline plus per-group ones, reports its own coverage, and refuses per group: a format used twice gets no verdict even when the overall history is long. /linkedin:analyzetests the problem before diagnosing it — a new Step 2a reads the band first.within-bandsays plainly that this looks like ordinary variation rather than a drop (and asks whether to continue) instead of manufacturing a diagnosis;no-verdictcontinues on self-report only, labelled provisional. Step 5's severity assessment now measures against the band rather than a remembered peak.- Additive and backward-compatible:
baselineis optional in the report types, so pre-N17 reports load unchanged (and a consumer says "no baseline in this report" rather than assuming one). analytics suite 144 → 202 (58 new tests: median/MAD, window, band, refusal, grouping, period reading, queue join, report wiring); test-runner 247 → 270 (Section 16x: 23 unconditional greps + self-test; anti-erosion floor 228 → 251);tscclean.
Fixed — cleanup never topples a successful render (N18)
render/build-figur.mjs— a figure that rendered could still fail —runChromeScreenshot's finish path SIGKILLed the parent Chrome process and resolved without waiting for it to die. The renderer/GPU children outlived it and kept writing into--user-data-dirinside the work dir while thefinallyblock was already traversing that same directory, so the cleanup raced the dying browser and threwENOTEMPTY. The PNG lives outside the work dir and was already written by then — so the render had succeeded and the cleanup toppled it. Replaced withcleanupWorkDir(): an explicit retry loop (configurablemaxRetries/retryDelay, injectablermso the retry is testable) that never throws — a work dir that cannot be removed is a leaked temp dir, not a failed figure.- This was never a flaky test. Measured before the fix, the render suite passed 1 run in 4; after, 5 of 5. Suite 60 → 63 (new floor).
Built feedback (awaiting consumer-side proof)
- MR-F9 (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).
Downloads
-
Source code (ZIP)
3 downloads
-
Source code (TAR.GZ)
4 downloads