feat(linkedin-studio): N11 — serie-destillat + skjelett-sjekk + specifics-bank bruks-logging [skip-docs]
Every long-form gate agent sees ONE edition, so «the reader has heard this
before» was structurally invisible — and the specifics-bank dedupe actively
encouraged re-surfacing the same material. At series cadence that is the
fastest-growing defect class. N11 makes it visible BEFORE the skeleton is
approved, at two grains:
- Series grain — new scripts/editions package: each locked edition's spent
anecdotes/arguments/hooks are written to <serie>/linkedin/series-distillate.json
at Step 8 lock (distil-append), and the next skeleton is checked against it at
Step 2.5 (distil-check) with the finding folded into the annotation gate the
operator already reads. Advisory, never blocking: a deliberate callback is a
legitimate move, an unnoticed retread is not.
- Material grain — specifics-bank usedIn log: record-usage stamps «used in
edition NN» on the specifics an edition actually consumed (read from the bound
slot-map, so abstrakt/ekstern stamp nothing). At lock, so it means published;
idempotent under a pivot re-lock. Additive-optional, schema stays v1.
Placement deviates from the plan text (${DATA}) after premise-verification:
per-series state belongs in the series root beside edition-state.json, where
Step 0 already resolves the path and no slug→path map is needed. Operator
approved. The distillate module also lands in scripts/editions now rather than
at N12, since the AC required a testable roundtrip and N12 planned that package
anyway — N12 extends it instead of creating it.
Similarity is character-trigram Jaccard, not word overlap: the plugin is
language-general and inflection (migrere/migreringen) breaks word tokens.
Calibrated on 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.
TDD (Iron Law): tests written first and verified red before implementation, for
both the new package and the bank logging.
Suites: editions 27/0 (new suite line + guard, floor 27) · specifics-bank
28 -> 45 · test-runner 163 -> 173 (Section 16r: 9 unconditional greps +
non-vacuity self-test; anti-erosion floor 146 -> 155) · trends 300/0 ·
brain 134/0 · hooks 140/0 · tests 35/0 · render 60/0. tsc --noEmit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
parent
08106317db
commit
b54e450c3e
20 changed files with 2152 additions and 19 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -60,6 +60,8 @@ scripts/brain/node_modules/
|
|||
scripts/brain/build/
|
||||
scripts/contract-gate/node_modules/
|
||||
scripts/contract-gate/build/
|
||||
scripts/editions/node_modules/
|
||||
scripts/editions/build/
|
||||
scripts/specifics-bank/node_modules/
|
||||
scripts/specifics-bank/build/
|
||||
scripts/trends/node_modules/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **WebFetch on both fact agents (C-7)** — `fact-checker` and `fact-reviewer` gain `WebFetch`: 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`, beside `edition-state.json` in 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:pivot` **replaces** 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` + the `record-usage` CLI verb stamp «used in edition NN» on the specifics an edition actually consumed (read from the bound slot-map via `boundSpecificIds`, so `abstrakt`/`ekstern` slots 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 `editions` 27/0; specifics-bank 28 → 45; test-runner 163 → 173 (Section 16r: 9 unconditional greps + non-vacuity self-test; anti-erosion floor 146 → 155). `tsc --noEmit` clean in both packages. Domain-general throughout.
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
|
|||
- **Figure renderer:** `render/build-figur.mjs` — coded data figures (SVG/HTML → PNG via headless Chrome); three targets (article/carousel/single); brand tokens from the user data dir's `profile/brand-tokens.json`, neutral defaults otherwise (see `references/figure-design-guidelines.md`). Standalone CLI + importable module
|
||||
- **Post queue:** `assets/drafts/queue.json` (managed by `hooks/scripts/queue-manager.mjs`)
|
||||
- **Analytics:** CLI `scripts/analytics/` (TypeScript, needs `tsx` + `npm install`); data `assets/analytics/` (gitignored)
|
||||
- **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler
|
||||
- **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler. Consumption is logged back at Step 8 lock (`record-usage` → `usedIn`), so cross-edition re-use of the same material is visible rather than silently encouraged by the dedupe
|
||||
- **Series distillate:** `scripts/editions/` (TypeScript, needs `tsx` + `npm install`) — series-level memory: each locked edition's spent narrative units (anecdotes/arguments/hooks) in `<serie>/linkedin/series-distillate.json`; written at `/linkedin:newsletter` Step 8 (`distil-append`), checked against the next skeleton at Step 2.5 (`distil-check`, advisory). Deterministic character-trigram similarity — AI extracts, code compares
|
||||
- **Contract-gate:** `scripts/contract-gate/` (TypeScript, needs `tsx` + `npm install`) — deterministic §B/§C1 rule-gate on the full draft (`/linkedin:newsletter` Step 4.5, before the AI sweeps); ratifies `rules.ts` against the edition's §E-manifest, then gates with BLOCK/WARN
|
||||
- **Analytics metrics (S16):** parsed CSV columns + an optional, manually-entered `saves` count (count-only in native LinkedIn analytics since ~Sept 2025, no CSV export; the Marketing API exposes `POST_SAVE` on `/memberCreatorPostAnalytics` from v202604, but access is partner-gated — so manual entry remains the right UX). `parseOptionalCount()`: blank / non-numeric / negative → `undefined` (`unknown`, never 0), a genuine `0` is kept; saves surfaced per-post + as `totalSaves`, but **not** folded into `engagementRate`. `dwell` stays **explicitly unmeasurable** (internal to LinkedIn, no export/API). All analytics I/O routes through the `getAnalyticsRoot()` seam (M0 per-user data-dir).
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias).
|
|||
| 1 | **Brief + calibration** | angle, voice, audience personas (mark primær), key points, tone, leader-takeaway. ≤3 questions | `AskUserQuestion` |
|
||||
| 1.5 | **Lived-specifics extraction — BEFORE research** | elicitation interview (3–5 sharp questions, **vagueness refused**) → `add` lived material to the specifics-bank + populate `articles.NN.livedSpecifics` slot-map (each load-bearing key-point bound specific/abstrakt/ekstern/unresolved) → render `NN-kilder.md`. The grounded-sourcing layer (Fix #2, kilde-så-draft; never fabricated — invariant 3). | `AskUserQuestion` + `scripts/specifics-bank` (`node --import tsx src/cli.ts`) |
|
||||
| 2 | **Research — scoped by the binding** | parallel scoped mandates → verified notes; triangulation. Fills the `ekstern`/`unresolved` slots + verifies your own numbers (regel 6/7) | **`Task` fan-out (foreground)** |
|
||||
| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + **`persona-reviewer`** (skjelett mode) |
|
||||
| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. **Serie-destillat-sjekk** (has the reader heard this anecdote/argument/hook before? advisory, into the annotation gate). Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + `scripts/editions` (`distil-check`) + **`persona-reviewer`** (skjelett mode) |
|
||||
| 3a | **Spine prose — BEFORE full expansion** | one paragraph per section carrying that section's pitch, nothing more. ~20–30 % of final length. Operator-gate on whether the axis is right now that there is prose on it. | inline drafting + `content-repurposer` |
|
||||
| 3b | **Full prose expansion** | expand each section with argument, examples, anchors from research; may span sessions | `content-repurposer` + `Task` |
|
||||
| 4 | **Consistency + quality** | threads, premise→conclusion arc, leader-takeaway, AI-slop removal, de-AI/voice scrub, formatting dose | inline + `references/longform-quality-rules.md` + **`voice-scrubber`** |
|
||||
|
|
@ -110,7 +110,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias).
|
|||
| 6.5 | **Headless adversarial review — BEFORE lock** | COLD review package on a frozen draft, no drafting-session context: content-reviewer (argument) + language-reviewer (Norwegian) + fact-reviewer (cold re-verification incl. pivot premises) + persona-reviewer resonance/conversion. Consolidated, operator-gated via `SendUserFile`. The independence layer the in-session gates can't be. | **`content-reviewer` + `language-reviewer` + `fact-reviewer` + `persona-reviewer`** (parallel) + `SendUserFile` |
|
||||
| 7 | **Annotation (optional)** | render annotatable review HTML for a manual pass | `render/build-html.mjs` |
|
||||
| 7.5 | **Visual assets — BEFORE lock** | cover (+ optional inline figures) or carousel deck: behov → per-image brief → generate (coded `build-figur.mjs` primary for data figures / mcp-image for illustrative / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `render/build-figur.mjs` (data figures) + `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` |
|
||||
| 8 | **LOCK → delivery** | POST.html "all in one place" | `render/build-linkedin.mjs` |
|
||||
| 8 | **LOCK → delivery** | POST.html "all in one place"; auto-gull (gold corpus) + **serie-minne** (series distillate + specifics-bank usage log) | `render/build-linkedin.mjs` + `scripts/editions` (`distil-append`) + `scripts/specifics-bank` (`record-usage`) |
|
||||
| 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) |
|
||||
| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling | `hooks/scripts/queue-manager.mjs` |
|
||||
|
||||
|
|
@ -666,7 +666,36 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
2. …
|
||||
```
|
||||
|
||||
4. **Operator-gate (render + annotate — primary flow).** The operator review is
|
||||
4. **Serie-destillat-sjekk — «har leseren hørt dette før?» (deterministic).**
|
||||
Every other gate in this pipeline sees ONE edition. At series cadence the
|
||||
fastest-growing defect is not a bad edition but a **repeated** one — the
|
||||
anecdote, the argument or the hook the reader already met three editions ago.
|
||||
Check the proposed skeleton against everything the series has locked, while
|
||||
changing course is still cheap. Write the units the spine + pitches actually
|
||||
propose to a small JSON file (`{anecdotes, arguments, hooks}` — the operator's
|
||||
phrasing, not a summary), then run it from the package dir with **absolute**
|
||||
paths:
|
||||
|
||||
```bash
|
||||
cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \
|
||||
node --import tsx src/cli.ts distil-check \
|
||||
--distillate "<abs>/linkedin/series-distillate.json" \
|
||||
--skeleton "<abs>/skjelett-units.json"
|
||||
```
|
||||
|
||||
- **CLEAR** → nothing here echoes an earlier edition. Carry the one-liner into
|
||||
the annotation page as a receipt.
|
||||
- **REUSE** → the output names each unit, the edition it echoes, and how close
|
||||
it is. This is **advisory, never a block** (exit 0 either way): a deliberate
|
||||
callback is a legitimate move — an *unnoticed* retread is not. Fold the
|
||||
finding into the annotation page (step 5) so the operator decides with the
|
||||
re-use in view: keep it, sharpen it into an explicit callback, or replace
|
||||
the unit.
|
||||
- No distillate yet (a series' first edition, or an adopter who has not locked
|
||||
one since this gate existed) → CLEAR against 0 editions; say so and move on.
|
||||
Deps absent → skip with a one-line note, like the binding-gate.
|
||||
|
||||
5. **Operator-gate (render + annotate — primary flow).** The operator review is
|
||||
**HTML annotation**, not a multiple-choice prompt — and this holds for *every*
|
||||
write deliverable, the skeleton included, not just the final POST.html. Render
|
||||
the skeleton to an annotatable page and let the operator annotate in the browser:
|
||||
|
|
@ -680,7 +709,9 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
`<serie-mappe>/review/NN-skjelett.html` exists before handing it off.
|
||||
3. Surface `<serie-mappe>/review/NN-skjelett.html` to the operator as a
|
||||
`file://` link (use `SendUserFile` if available, else a markdown `file://`
|
||||
link) for the annotation pass.
|
||||
link) for the annotation pass. If the destillat-sjekk (step 4) returned
|
||||
**REUSE**, state each echoed unit + the edition it came from alongside the
|
||||
link — the operator annotates with the re-use visible, not after the fact.
|
||||
4. The operator annotates in the browser and pastes the annotated markdown
|
||||
back. Fold the notes into `<serie>/NN-skjelett.md` by tightening (rule 6).
|
||||
5. **Receipt, not gate.** After folding in the annotations, use
|
||||
|
|
@ -688,8 +719,8 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
JA proceed, or another round?». `AskUserQuestion` (JA / REVIDER / NEI) is
|
||||
also the **fallback** gate when rendering is unavailable; it is not the
|
||||
primary flow:
|
||||
- **JA** — proceed to the binding-gate (step 5), then the
|
||||
persona-skjelett-sweep (step 6).
|
||||
- **JA** — proceed to the binding-gate (step 6), then the
|
||||
persona-skjelett-sweep (step 7).
|
||||
- **REVIDER** — another annotation round; revise and re-render.
|
||||
- **NEI** — the skeleton is wrong at a load-bearing level (premise unsound,
|
||||
argument-line incoherent). Return to brief calibration (Step 1) or
|
||||
|
|
@ -699,7 +730,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
advance to Step 3a (spine prose) until this operator-gate, the deterministic
|
||||
binding-gate, AND the persona-skjelett-sweep below all pass. `[OPERATØR]`
|
||||
|
||||
5. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona
|
||||
6. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona
|
||||
sweep).** Now that the spine is visible, enforce the Step 1.5 binding: every
|
||||
load-bearing slot must resolve to a real specific, a justified `abstrakt`, or an
|
||||
`ekstern` source — no `unresolved` slot, no `specific` pointing at a missing bank
|
||||
|
|
@ -728,7 +759,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
When the gate passes, set `articles.NN.livedSpecifics.status: "bound"` in
|
||||
`edition-state.json`.
|
||||
|
||||
6. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.**
|
||||
7. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.**
|
||||
Issue one `persona-reviewer` call per active persona in parallel — a SINGLE
|
||||
message with multiple `Task` tool-uses, `subagent_type:
|
||||
linkedin-studio:persona-reviewer`, from THIS command layer in
|
||||
|
|
@ -738,7 +769,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
This is NOT resonans mode (Step 6 — that runs on full prose) and NOT
|
||||
konverter mode (Step 9 — that judges the hook only).
|
||||
|
||||
7. **Collect skjelett verdicts and gate.** Each call returns per-axis flags
|
||||
8. **Collect skjelett verdicts and gate.** Each call returns per-axis flags
|
||||
(HOLDER/TVILER/MANGLER), ≤3 direction-only flags, a section-pitch check
|
||||
(any pitch that does not pay in), a per-persona verdict (JA/NEI), and a
|
||||
gate decision. Aggregate per the agent's rule:
|
||||
|
|
@ -757,19 +788,19 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
the editor (this session) holds the pen; never paste a persona's rewritten
|
||||
skeleton. `[GATE]`
|
||||
|
||||
8. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the
|
||||
9. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the
|
||||
skeleton + pitches (or, on BLOCK, return upstream) and re-run the same
|
||||
`persona-reviewer` calls against the revision. Loop until the primær
|
||||
returns a clean JA. This loop is **cheap and frequent at this stage** —
|
||||
every round saved here is hours saved at the prose stage.
|
||||
|
||||
9. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the
|
||||
10. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the
|
||||
binding-gate passed, AND the persona-skjelett-sweep returns a clean primær JA,
|
||||
record:
|
||||
|
||||
- The final skeleton + pitches in `<serie>/NN-skjelett.md` (already written
|
||||
in step 3, with any in-loop revisions applied).
|
||||
- `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 5).
|
||||
- `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 6).
|
||||
- Per-persona skjelett verdicts in
|
||||
`edition-state.json` → `articles.NN.personaSweep.skeleton` (or alongside
|
||||
resonance/conversion under the same `personaSweep` object).
|
||||
|
|
@ -782,6 +813,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
|
|||
Skeleton + section pitch (BEFORE prose) — complete.
|
||||
- Skeleton: 5 lines (premiss / problem / anbefaling / gevinst / vei videre)
|
||||
- Section pitches: <N> sections, all paying into the spine (else: pitches reworked, see flags)
|
||||
- Series distillate: CLEAR against <N> locked edition(s) (else REUSE: <N> unit(s) — kept as explicit callback / replaced)
|
||||
- Operator gate: JA (after <N> revision rounds)
|
||||
- Binding-gate (vaghet avvises): PASS — all load-bearing slots bound; livedSpecifics → "bound" (else BLOCK: <N> unresolved/dangling)
|
||||
- Persona-skjelett-sweep: primær JA (else: still NEI — loop open, NOT ready for prose)
|
||||
|
|
@ -1797,12 +1829,56 @@ produces the editor's single delivery artifact — `POST.html`, the
|
|||
one edition at a time — never the engine's drafts. The next edition's Step 0
|
||||
therefore binds to a sharper profile without any manual corpus curation.
|
||||
|
||||
6. **Serie-minne — record what this edition spent.** The reader-facing twin of
|
||||
auto-gull: auto-gull grows the *voice* corpus, this grows the *series memory*
|
||||
that the next edition's Step 2.5 destillat-sjekk is checked against. Both
|
||||
writes happen at LOCK, so they mean **published**, never merely considered —
|
||||
an abandoned draft leaves no trace.
|
||||
|
||||
- **Series distillate.** Extract the narrative units this edition actually
|
||||
spent — the anecdotes told, the arguments argued, the hooks used — **in the
|
||||
operator's own phrasing**, not as a summary (the check compares text, so a
|
||||
paraphrased summary would compare the summariser's words, not the reader's
|
||||
experience). Write them to a JSON extract and fold it in:
|
||||
|
||||
```bash
|
||||
cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \
|
||||
node --import tsx src/cli.ts distil-append \
|
||||
--distillate "<abs>/linkedin/series-distillate.json" \
|
||||
--series "<slug>" --extract "<abs>/NN-destillat.json"
|
||||
```
|
||||
|
||||
Extract shape: `{editionId, title, lockedAt, anecdotes[], arguments[],
|
||||
hooks[]}`. A re-lock after `/linkedin:pivot` **replaces** that edition's
|
||||
entry rather than duplicating it — the reader still only met it once.
|
||||
|
||||
- **Specifics-bank usage log.** Stamp the lived material this edition consumed
|
||||
with «used in edition NN», so the next Step 1.5 query surfaces re-use
|
||||
instead of silently encouraging it (the bank's dedupe unions tags across
|
||||
editions by design — without this log, re-surfacing the same specific is
|
||||
invisible):
|
||||
|
||||
```bash
|
||||
cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \
|
||||
node --import tsx src/cli.ts record-usage \
|
||||
--edition "<abs>/linkedin/edition-state.json" --edition-id "<slug>/NN"
|
||||
```
|
||||
|
||||
The ids are read from the article's bound slot-map, so only material the
|
||||
edition actually leaned on is stamped; `abstrakt`/`ekstern` slots consume no
|
||||
bank material. Idempotent — a pivot re-lock does not double-count.
|
||||
|
||||
- Deps/contract absent (adopter — Step 1.5 was a no-op, or `npm install` never
|
||||
run) → skip both with a one-line note, like the Step 2.5 gates. The lock
|
||||
itself never depends on this bookkeeping.
|
||||
|
||||
```
|
||||
LOCK → delivery.
|
||||
- Preconditions: factcheck 🔴 = none, persona resonans primær = JA (else: STOP)
|
||||
- Delivered: <serie-mappe>/linkedin/NN/POST.html
|
||||
- build-linkedin exit: 0 + POST.html present (else: non-zero / skip — NOT locked)
|
||||
- Article status: locked
|
||||
- Serie-minne: distillate +1 edition (<N> units) · bank usage stamped on <N> specific(s) (else: skipped — deps absent)
|
||||
Next: Step 9 — Hook / conversion gate (post-lock).
|
||||
```
|
||||
|
||||
|
|
@ -2041,7 +2117,8 @@ the honest decision surface; it sells nothing.
|
|||
## Reference Files
|
||||
|
||||
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`)
|
||||
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger). Design record: `docs/fix2/slice2-binding.md`
|
||||
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger), `record-usage` (Step 8 lock — «used in edition NN»). Design record: `docs/fix2/slice2-binding.md`
|
||||
- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking
|
||||
- `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8
|
||||
- `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5
|
||||
- `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9
|
||||
|
|
|
|||
75
scripts/editions/README.md
Normal file
75
scripts/editions/README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# editions — series-level memory for long-form (N11, serie-nivå-vern)
|
||||
|
||||
Every long-form gate agent sees **one** edition. At two editions a week the fastest-
|
||||
growing defect class is not a bad edition — it is a **repeated** one: the anecdote, the
|
||||
argument or the hook the reader already met three editions ago. That failure is
|
||||
structurally invisible today, and the specifics-bank dedupe actively *encourages*
|
||||
re-surfacing the same material.
|
||||
|
||||
So each locked edition leaves behind a **distillate** of the narrative units it spent,
|
||||
and the Step 2.5 skeleton gate checks the next skeleton against it — **before prose
|
||||
exists**, the only point where changing course is still cheap.
|
||||
|
||||
## Where the distillate lives
|
||||
|
||||
`<serie>/linkedin/series-distillate.json` — beside `edition-state.json` in the **series
|
||||
root**, not in the per-user data dir. It is series-scoped state, the series folder is
|
||||
already where the edition's state lives, and putting it there means it travels with the
|
||||
series and needs no slug→path map.
|
||||
|
||||
Series-scoped is the point: this answers *"has this reader heard it?"*. Cross-series
|
||||
re-use of the operator's raw material is a different grain, tracked by the
|
||||
specifics-bank's `usedIn` log.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd scripts/editions && npm install
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# Step 8 (lock): fold the edition's spent units into the series distillate
|
||||
node --import tsx src/cli.ts distil-append \
|
||||
--distillate "<serie>/linkedin/series-distillate.json" \
|
||||
--series seres --extract "<abs>/extract.json"
|
||||
|
||||
# Step 2.5 (before prose): check the proposed skeleton against everything published
|
||||
node --import tsx src/cli.ts distil-check \
|
||||
--distillate "<serie>/linkedin/series-distillate.json" \
|
||||
--skeleton "<abs>/skeleton.json" [--threshold 0.4] [--json]
|
||||
```
|
||||
|
||||
`extract.json` = `{editionId, title, lockedAt, anecdotes[], arguments[], hooks[]}` — the
|
||||
AI extract from the locked edition. `skeleton.json` = `{anecdotes?, arguments?, hooks?}`.
|
||||
|
||||
Exit codes: `0` on success — **including a REUSE verdict**. The check is advisory by
|
||||
design (it reports into the annotation gate the operator already reads at Step 2.5),
|
||||
unlike the binding gate's BLOCK. `2` on usage error.
|
||||
|
||||
## Model
|
||||
|
||||
- **Extraction is AI, comparison is code.** The command layer distils the units from the
|
||||
locked prose; the store, the similarity and the verdict are plain deterministic code —
|
||||
no model in the loop, no run-to-run variance.
|
||||
- **Similarity = character-trigram Jaccard**, not word overlap. The plugin is
|
||||
language-general and the languages it is used in are inflection-heavy: *migrere* /
|
||||
*migreringen* are the same unit to a reader but different word tokens. Calibrated
|
||||
against real paraphrase pairs (see `tests/distillate.test.ts`): retellings score
|
||||
0.44–0.55, unrelated material 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,
|
||||
not a retread.
|
||||
- **A re-locked edition replaces its entry**, never duplicates it — `/linkedin:pivot`
|
||||
re-opens and re-locks the same edition, and the reader still only met it once.
|
||||
- **A missing distillate is not an error.** A series' first edition compares against
|
||||
nothing and is CLEAR.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # node:test, deterministic (callers supply lockedAt)
|
||||
npm run build # tsc — must stay clean
|
||||
```
|
||||
566
scripts/editions/package-lock.json
generated
Normal file
566
scripts/editions/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
{
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
scripts/editions/package.json
Normal file
16
scripts/editions/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Series-level memory for long-form editions (N11 — serie-nivå-vern). A per-series distillate of the anecdotes, arguments and hooks each locked edition already spent, so the Step 2.5 skeleton gate can surface «the reader has heard this before» BEFORE prose is written. Deterministic store + similarity check; the extraction of narrative units from a locked edition lives in the command/agent layer.",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"start": "node --import tsx src/cli.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
167
scripts/editions/src/cli.ts
Normal file
167
scripts/editions/src/cli.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI for the series distillate (N11 — serie-nivå-vern).
|
||||
*
|
||||
* node --import tsx src/cli.ts distil-append --distillate <path> --series <slug> --extract <extract.json>
|
||||
* node --import tsx src/cli.ts distil-check --distillate <path> --skeleton <skeleton.json>
|
||||
* [--threshold <0..1>] [--json]
|
||||
*
|
||||
* `distil-append` runs at Step 8 lock: the command layer writes the AI extract
|
||||
* (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and
|
||||
* this folds it in deterministically. `distil-check` runs at Step 2.5, before
|
||||
* prose, and reports re-use into the annotation gate.
|
||||
*
|
||||
* Exit code: 0 on success (INCLUDING a REUSE verdict — the check is advisory by
|
||||
* design, unlike the binding gate's BLOCK), 2 on usage error.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
DEFAULT_THRESHOLD,
|
||||
appendEntry,
|
||||
checkSkeleton,
|
||||
loadDistillate,
|
||||
saveDistillate,
|
||||
} from "./distillate.js";
|
||||
import type { DistillateEntry, SkeletonCandidate } from "./types.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
out[key] = "true";
|
||||
} else {
|
||||
out[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage(msg: string): never {
|
||||
console.error(`error: ${msg}`);
|
||||
console.error(
|
||||
"usage:\n" +
|
||||
" distil-append --distillate <path> --series <slug> --extract <extract.json>\n" +
|
||||
" distil-check --distillate <path> --skeleton <skeleton.json> [--threshold <0..1>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** A flag that was given a real value (not absent, not a bare boolean flag). */
|
||||
function value(flags: Record<string, string>, key: string): string | undefined {
|
||||
const v = flags[key];
|
||||
return v === undefined || v === "true" ? undefined : v;
|
||||
}
|
||||
|
||||
function readJson(path: string, what: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
usage(`could not read ${what} at ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((x): x is string => typeof x === "string" && x.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Validate the AI extract at the edge — a malformed extract must not become a silent empty entry. */
|
||||
function toEntry(raw: unknown): DistillateEntry {
|
||||
const o = (raw ?? {}) as Record<string, unknown>;
|
||||
for (const field of ["editionId", "title", "lockedAt"]) {
|
||||
const v = o[field];
|
||||
if (typeof v !== "string" || v.trim().length === 0) {
|
||||
usage(`extract is missing required string field: ${field}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
editionId: o.editionId as string,
|
||||
title: o.title as string,
|
||||
lockedAt: o.lockedAt as string,
|
||||
anecdotes: stringList(o.anecdotes),
|
||||
arguments: stringList(o.arguments),
|
||||
hooks: stringList(o.hooks),
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const flags = parseFlags(rest);
|
||||
const distillatePath = value(flags, "distillate");
|
||||
const asJson = flags.json === "true";
|
||||
|
||||
if (command === "distil-append") {
|
||||
if (!distillatePath) usage("distil-append needs --distillate <path>");
|
||||
const series = value(flags, "series");
|
||||
if (!series) usage("distil-append needs --series <slug>");
|
||||
const extractPath = value(flags, "extract");
|
||||
if (!extractPath) usage("distil-append needs --extract <extract.json>");
|
||||
|
||||
const entry = toEntry(readJson(extractPath, "extract"));
|
||||
const res = appendEntry(loadDistillate(distillatePath, series), entry);
|
||||
saveDistillate(distillatePath, res.distillate);
|
||||
|
||||
const units = entry.anecdotes.length + entry.arguments.length + entry.hooks.length;
|
||||
console.log(
|
||||
`${res.replaced ? "Replaced" : "Appended"} edition ${entry.editionId} (${units} narrative unit(s)) ` +
|
||||
`in ${distillatePath} — ${res.distillate.editions.length} edition(s) in the series distillate`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "distil-check") {
|
||||
if (!distillatePath) usage("distil-check needs --distillate <path>");
|
||||
const skeletonPath = value(flags, "skeleton");
|
||||
if (!skeletonPath) usage("distil-check needs --skeleton <skeleton.json>");
|
||||
|
||||
const rawThreshold = value(flags, "threshold");
|
||||
let threshold = DEFAULT_THRESHOLD;
|
||||
if (rawThreshold !== undefined) {
|
||||
threshold = Number(rawThreshold);
|
||||
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
|
||||
usage("--threshold must be a number in (0, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = (readJson(skeletonPath, "skeleton") ?? {}) as SkeletonCandidate;
|
||||
const report = checkSkeleton(loadDistillate(distillatePath, value(flags, "series") ?? ""), candidate, {
|
||||
threshold,
|
||||
});
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.verdict === "CLEAR") {
|
||||
console.log(
|
||||
`CLEAR — nothing in this skeleton resembles the ${report.comparedAgainst} locked edition(s) ` +
|
||||
`(threshold ${report.threshold}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`REUSE — ${report.hits.length} unit(s) resemble earlier editions (threshold ${report.threshold}, ` +
|
||||
`${report.comparedAgainst} edition(s) checked):`,
|
||||
);
|
||||
for (const hit of report.hits) {
|
||||
console.log(`\n · ${hit.kind} — ${(hit.score * 100).toFixed(0)}% like edition ${hit.editionId}`);
|
||||
console.log(` new: ${hit.candidate}`);
|
||||
console.log(` then: ${hit.matched}`);
|
||||
}
|
||||
console.log("\nAdvisory: a deliberate callback is fine — an unnoticed retread is not.");
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
main();
|
||||
179
scripts/editions/src/distillate.ts
Normal file
179
scripts/editions/src/distillate.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Deterministic store + reuse check for the series distillate (N11 — C-5).
|
||||
*
|
||||
* Pure where it matters: normalization, similarity and the skeleton check are
|
||||
* side-effect-free and fully testable. Only load/save touch the filesystem.
|
||||
*
|
||||
* Similarity is CHARACTER TRIGRAM Jaccard, not word overlap. The plugin is
|
||||
* language-general, and the languages it is actually used in are inflection-
|
||||
* heavy: "migrere"/"migreringen" and "pipeline"/"pipelinen" are the same unit to
|
||||
* a reader but different word tokens. Calibrated against real paraphrase pairs
|
||||
* (see tests/distillate.test.ts): retellings score 0.44–0.55, unrelated material
|
||||
* 0.04–0.06, and 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.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { SCHEMA_VERSION } from "./types.js";
|
||||
import type {
|
||||
DistillateEntry,
|
||||
NarrativeKind,
|
||||
ReuseHit,
|
||||
ReuseReport,
|
||||
SeriesDistillate,
|
||||
SkeletonCandidate,
|
||||
} from "./types.js";
|
||||
|
||||
export { SCHEMA_VERSION } from "./types.js";
|
||||
|
||||
/** Similarity at or above this counts as re-use. Empirically calibrated — see the header. */
|
||||
export const DEFAULT_THRESHOLD = 0.4;
|
||||
|
||||
const KINDS: NarrativeKind[] = ["anecdote", "argument", "hook"];
|
||||
|
||||
/** Lowercase, strip everything that is not a letter or digit, collapse whitespace. */
|
||||
export function normalizeUnit(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Character trigrams over the normalized text, space-padded so short units still overlap. */
|
||||
function trigrams(text: string): Set<string> {
|
||||
const normalized = normalizeUnit(text);
|
||||
if (normalized.length === 0) return new Set();
|
||||
const padded = ` ${normalized} `;
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i + 3 <= padded.length; i++) out.add(padded.slice(i, i + 3));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Jaccard over character trigrams: 1 = identical after normalization, 0 = nothing shared. */
|
||||
export function similarity(a: string, b: string): number {
|
||||
const A = trigrams(a);
|
||||
const B = trigrams(b);
|
||||
if (A.size === 0 || B.size === 0) return 0;
|
||||
let intersection = 0;
|
||||
for (const gram of A) if (B.has(gram)) intersection++;
|
||||
return intersection / (A.size + B.size - intersection);
|
||||
}
|
||||
|
||||
export function emptyDistillate(series: string): SeriesDistillate {
|
||||
return { schemaVersion: SCHEMA_VERSION, series, editions: [] };
|
||||
}
|
||||
|
||||
/** Read the distillate; a missing file is an empty one (a series' first edition is not an error). */
|
||||
export function loadDistillate(path: string, series: string): SeriesDistillate {
|
||||
if (!existsSync(path)) return emptyDistillate(series);
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<SeriesDistillate>;
|
||||
return {
|
||||
schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION,
|
||||
series: parsed.series ?? series,
|
||||
editions: Array.isArray(parsed.editions) ? parsed.editions : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */
|
||||
export function saveDistillate(path: string, distillate: SeriesDistillate): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(distillate, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
distillate: SeriesDistillate;
|
||||
/** true iff the edition was new. */
|
||||
appended: boolean;
|
||||
/** true iff an existing edition was overwritten in place (a pivot re-lock). */
|
||||
replaced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one locked edition into the distillate. An edition already present is
|
||||
* REPLACED in place, never duplicated: `/linkedin:pivot` re-opens and re-locks
|
||||
* the same edition, and the reader still only ever met it once.
|
||||
*/
|
||||
export function appendEntry(distillate: SeriesDistillate, entry: DistillateEntry): AppendResult {
|
||||
const stored: DistillateEntry = {
|
||||
editionId: entry.editionId,
|
||||
title: entry.title,
|
||||
lockedAt: entry.lockedAt,
|
||||
anecdotes: [...entry.anecdotes],
|
||||
arguments: [...entry.arguments],
|
||||
hooks: [...entry.hooks],
|
||||
};
|
||||
const at = distillate.editions.findIndex((e) => e.editionId === entry.editionId);
|
||||
if (at >= 0) {
|
||||
distillate.editions[at] = stored;
|
||||
return { distillate, appended: false, replaced: true };
|
||||
}
|
||||
distillate.editions.push(stored);
|
||||
return { distillate, appended: true, replaced: false };
|
||||
}
|
||||
|
||||
function unitsOf(entry: DistillateEntry, kind: NarrativeKind): string[] {
|
||||
if (kind === "anecdote") return entry.anecdotes;
|
||||
if (kind === "argument") return entry.arguments;
|
||||
return entry.hooks;
|
||||
}
|
||||
|
||||
function candidateUnits(candidate: SkeletonCandidate, kind: NarrativeKind): string[] {
|
||||
if (kind === "anecdote") return candidate.anecdotes ?? [];
|
||||
if (kind === "argument") return candidate.arguments ?? [];
|
||||
return candidate.hooks ?? [];
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
/** Override the calibrated default when a series wants to be stricter or looser. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a proposed skeleton against everything the series already published.
|
||||
* Units are compared WITHIN their kind — an argument re-used as a hook is a new
|
||||
* move, not a retread. Advisory: the verdict feeds the Step 2.5 annotation gate.
|
||||
*/
|
||||
export function checkSkeleton(
|
||||
distillate: SeriesDistillate,
|
||||
candidate: SkeletonCandidate,
|
||||
options: CheckOptions = {},
|
||||
): ReuseReport {
|
||||
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
||||
const hits: ReuseHit[] = [];
|
||||
|
||||
for (const kind of KINDS) {
|
||||
for (const proposed of candidateUnits(candidate, kind)) {
|
||||
for (const edition of distillate.editions) {
|
||||
for (const published of unitsOf(edition, kind)) {
|
||||
const score = similarity(proposed, published);
|
||||
if (score >= threshold) {
|
||||
hits.push({ kind, candidate: proposed, matched: published, editionId: edition.editionId, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
|
||||
return {
|
||||
verdict: hits.length > 0 ? "REUSE" : "CLEAR",
|
||||
hits,
|
||||
threshold,
|
||||
comparedAgainst: distillate.editions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The distillate sits beside `edition-state.json` in the series root, not in the
|
||||
* per-user data dir: it is series-scoped state, and the series folder is where
|
||||
* the edition's state already lives (`<serie>/linkedin/`), so it travels with
|
||||
* the series and needs no slug→path map.
|
||||
*/
|
||||
export function defaultDistillatePath(seriesRoot: string): string {
|
||||
return join(seriesRoot, "linkedin", "series-distillate.json");
|
||||
}
|
||||
83
scripts/editions/src/types.ts
Normal file
83
scripts/editions/src/types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Types for the series distillate (N11 — serie-nivå-vern / C-5).
|
||||
*
|
||||
* The gap this closes: every long-form gate agent sees ONE edition. At two
|
||||
* editions a week the fastest-growing defect class is not a bad edition — it is
|
||||
* a *repeated* one: the anecdote, the argument or the hook the reader already
|
||||
* met three editions ago. Structurally invisible today, and the specifics-bank
|
||||
* dedupe actively encourages re-surfacing the same material.
|
||||
*
|
||||
* So each locked edition leaves behind a distillate of the narrative units it
|
||||
* spent, and the Step 2.5 skeleton gate checks the NEXT skeleton against it —
|
||||
* before prose exists, which is the only point where changing course is cheap.
|
||||
*
|
||||
* Deterministic by design: the extraction of units from a locked edition is an
|
||||
* AI job (the command layer), but the store, the comparison and the verdict are
|
||||
* plain code — no model in the loop, no run-to-run variance.
|
||||
*
|
||||
* Scope note: this file is SERIES-scoped memory ("has this reader heard it?").
|
||||
* Cross-series re-use of the operator's raw material is the specifics-bank's
|
||||
* `usedIn` log — a different grain, deliberately a different store.
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/** The three narrative units a reader actually recognizes across editions. */
|
||||
export type NarrativeKind = "anecdote" | "argument" | "hook";
|
||||
|
||||
/** What one locked edition spent. */
|
||||
export interface DistillateEntry {
|
||||
/** Edition id within the series, e.g. "05". */
|
||||
editionId: string;
|
||||
title: string;
|
||||
/** ISO date the edition was locked. Supplied by the caller (CLI edge) — no clock in the pure core. */
|
||||
lockedAt: string;
|
||||
/** Stories told, in the operator's own phrasing. */
|
||||
anecdotes: string[];
|
||||
/** Claims argued. */
|
||||
arguments: string[];
|
||||
/** Opening moves used. */
|
||||
hooks: string[];
|
||||
}
|
||||
|
||||
export interface SeriesDistillate {
|
||||
schemaVersion: number;
|
||||
/** Series slug — the folder the editions live in. */
|
||||
series: string;
|
||||
editions: DistillateEntry[];
|
||||
}
|
||||
|
||||
/** The units a NEW skeleton proposes, checked before prose (Step 2.5). */
|
||||
export interface SkeletonCandidate {
|
||||
anecdotes?: string[];
|
||||
arguments?: string[];
|
||||
hooks?: string[];
|
||||
}
|
||||
|
||||
/** One proposed unit that resembles something the series already published. */
|
||||
export interface ReuseHit {
|
||||
kind: NarrativeKind;
|
||||
/** The proposed text. */
|
||||
candidate: string;
|
||||
/** The already-published text it resembles. */
|
||||
matched: string;
|
||||
/** Which edition spent it. */
|
||||
editionId: string;
|
||||
/** 0..1 similarity; 1 = verbatim. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type ReuseVerdict = "CLEAR" | "REUSE";
|
||||
|
||||
/**
|
||||
* Advisory by design — this reports into the annotation gate the operator
|
||||
* already reads at Step 2.5; it never blocks. Re-use is sometimes the right
|
||||
* call (a deliberate callback); silence about it never is.
|
||||
*/
|
||||
export interface ReuseReport {
|
||||
verdict: ReuseVerdict;
|
||||
hits: ReuseHit[];
|
||||
threshold: number;
|
||||
/** How many locked editions the skeleton was compared against. */
|
||||
comparedAgainst: number;
|
||||
}
|
||||
152
scripts/editions/tests/cli.test.ts
Normal file
152
scripts/editions/tests/cli.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path +
|
||||
// the `tsx` loader resolve regardless of the runner's cwd.
|
||||
const editionsDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: editionsDir,
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "editions-cli-"));
|
||||
|
||||
const EXTRACT = {
|
||||
editionId: "05",
|
||||
title: "Da pipelinen brøt sammen",
|
||||
lockedAt: "2026-07-20",
|
||||
anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."],
|
||||
arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."],
|
||||
hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."],
|
||||
};
|
||||
|
||||
describe("editions CLI — distil-append / distil-check round-trip (N11 AC)", () => {
|
||||
test("append -> check round-trips through a real file and flags the reuse", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "linkedin", "series-distillate.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
|
||||
// 1. lock writes the distillate (file did not exist)
|
||||
const appended = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
assert.equal(appended.status, 0, appended.stderr);
|
||||
assert.ok(existsSync(distillate), "distil-append must create the distillate file");
|
||||
const onDisk = JSON.parse(readFileSync(distillate, "utf8"));
|
||||
assert.equal(onDisk.series, "seres");
|
||||
assert.equal(onDisk.editions.length, 1);
|
||||
assert.equal(onDisk.editions[0].editionId, "05");
|
||||
|
||||
// 2. a later skeleton reusing the same hook is flagged, with attribution
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8");
|
||||
const checked = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]);
|
||||
assert.equal(checked.status, 0, checked.stderr);
|
||||
const report = JSON.parse(checked.stdout);
|
||||
assert.equal(report.verdict, "REUSE");
|
||||
assert.equal(report.hits[0].editionId, "05");
|
||||
assert.equal(report.hits[0].kind, "hook");
|
||||
|
||||
// 3. fresh material on the same distillate is CLEAR
|
||||
writeFileSync(
|
||||
skeleton,
|
||||
JSON.stringify({ hooks: ["Innkjøpsavdelingen krevde en garanti vi ikke kunne gi uten revisjon."] }),
|
||||
"utf8",
|
||||
);
|
||||
const clear = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]);
|
||||
assert.equal(clear.status, 0, clear.stderr);
|
||||
assert.equal(JSON.parse(clear.stdout).verdict, "CLEAR");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("re-appending the same edition replaces it — a pivot re-lock does not duplicate", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "series-distillate.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
|
||||
writeFileSync(extract, JSON.stringify({ ...EXTRACT, title: "Revidert" }), "utf8");
|
||||
const again = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
assert.equal(again.status, 0, again.stderr);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(distillate, "utf8"));
|
||||
assert.equal(onDisk.editions.length, 1);
|
||||
assert.equal(onDisk.editions[0].title, "Revidert");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("distil-check against a distillate that does not exist yet is CLEAR, not an error", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: ["første utgave i serien"] }), "utf8");
|
||||
const res = run([
|
||||
"distil-check",
|
||||
"--distillate",
|
||||
join(dir, "absent.json"),
|
||||
"--skeleton",
|
||||
skeleton,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const report = JSON.parse(res.stdout);
|
||||
assert.equal(report.verdict, "CLEAR");
|
||||
assert.equal(report.comparedAgainst, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("human-readable output names the reused unit and its edition", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "d.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8");
|
||||
|
||||
const res = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /REUSE/);
|
||||
assert.match(res.stdout, /05/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bad invocations exit 2 with usage on stderr", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
assert.equal(run(["distil-append", "--series", "seres"]).status, 2); // no --extract/--distillate
|
||||
assert.equal(run(["distil-check"]).status, 2); // no --skeleton
|
||||
assert.equal(run(["nonsense"]).status, 2);
|
||||
assert.equal(run([]).status, 2);
|
||||
|
||||
// an extract missing required fields is a usage error, not a silent empty entry
|
||||
const bad = join(dir, "bad.json");
|
||||
writeFileSync(bad, JSON.stringify({ title: "no id" }), "utf8");
|
||||
const res = run(["distil-append", "--distillate", join(dir, "d.json"), "--series", "s", "--extract", bad]);
|
||||
assert.equal(res.status, 2);
|
||||
assert.match(res.stderr, /editionId/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
260
scripts/editions/tests/distillate.test.ts
Normal file
260
scripts/editions/tests/distillate.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
DEFAULT_THRESHOLD,
|
||||
appendEntry,
|
||||
checkSkeleton,
|
||||
defaultDistillatePath,
|
||||
emptyDistillate,
|
||||
loadDistillate,
|
||||
normalizeUnit,
|
||||
saveDistillate,
|
||||
similarity,
|
||||
} from "../src/distillate.js";
|
||||
import { SCHEMA_VERSION } from "../src/types.js";
|
||||
import type { DistillateEntry } from "../src/types.js";
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "editions-"));
|
||||
|
||||
const entry = (over: Partial<DistillateEntry> = {}): DistillateEntry => ({
|
||||
editionId: "05",
|
||||
title: "Da pipelinen brøt sammen",
|
||||
lockedAt: "2026-07-20",
|
||||
anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."],
|
||||
arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."],
|
||||
hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("series distillate — store", () => {
|
||||
test("emptyDistillate carries the schema version, the series and no editions", () => {
|
||||
const d = emptyDistillate("seres");
|
||||
assert.equal(d.schemaVersion, SCHEMA_VERSION);
|
||||
assert.equal(d.series, "seres");
|
||||
assert.deepEqual(d.editions, []);
|
||||
});
|
||||
|
||||
test("loadDistillate on a missing file returns an empty distillate (never throws)", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const d = loadDistillate(join(dir, "nope.json"), "seres");
|
||||
assert.equal(d.series, "seres");
|
||||
assert.deepEqual(d.editions, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("save -> load round-trips an appended edition byte-faithfully", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, "linkedin", "series-distillate.json");
|
||||
const { distillate } = appendEntry(emptyDistillate("seres"), entry());
|
||||
saveDistillate(path, distillate);
|
||||
|
||||
const back = loadDistillate(path, "seres");
|
||||
assert.equal(back.editions.length, 1);
|
||||
assert.deepEqual(back.editions[0], entry());
|
||||
assert.equal(back.schemaVersion, SCHEMA_VERSION);
|
||||
// trailing newline, like every other store in the plugin
|
||||
assert.ok(readFileSync(path, "utf8").endsWith("}\n"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("appendEntry appends a new edition and reports it", () => {
|
||||
const res = appendEntry(emptyDistillate("seres"), entry());
|
||||
assert.equal(res.appended, true);
|
||||
assert.equal(res.replaced, false);
|
||||
assert.equal(res.distillate.editions.length, 1);
|
||||
});
|
||||
|
||||
test("re-locking the same edition REPLACES it, never duplicates (pivot re-lock)", () => {
|
||||
const first = appendEntry(emptyDistillate("seres"), entry());
|
||||
const revised = entry({ title: "Da pipelinen brøt sammen (revidert)" });
|
||||
const second = appendEntry(first.distillate, revised);
|
||||
|
||||
assert.equal(second.appended, false);
|
||||
assert.equal(second.replaced, true);
|
||||
assert.equal(second.distillate.editions.length, 1);
|
||||
assert.equal(second.distillate.editions[0].title, "Da pipelinen brøt sammen (revidert)");
|
||||
});
|
||||
|
||||
test("editions keep insertion order", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "05" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04" })).distillate;
|
||||
assert.deepEqual(
|
||||
d.editions.map((e) => e.editionId),
|
||||
["03", "05", "04"],
|
||||
);
|
||||
});
|
||||
|
||||
test("defaultDistillatePath sits beside edition-state in the series root", () => {
|
||||
assert.equal(
|
||||
defaultDistillatePath("/series/seres"),
|
||||
join("/series/seres", "linkedin", "series-distillate.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("series distillate — similarity (language-agnostic character trigrams)", () => {
|
||||
test("normalizeUnit lowercases, strips punctuation and collapses whitespace", () => {
|
||||
assert.equal(normalizeUnit(" Hei, DU — da! "), "hei du da");
|
||||
});
|
||||
|
||||
test("identical text scores 1", () => {
|
||||
const s = "Vi målte 40 prosent kortere ledetid etter omleggingen.";
|
||||
assert.equal(similarity(s, s), 1);
|
||||
});
|
||||
|
||||
test("empty input scores 0 instead of dividing by zero", () => {
|
||||
assert.equal(similarity("", "noe tekst"), 0);
|
||||
assert.equal(similarity("noe tekst", " "), 0);
|
||||
});
|
||||
|
||||
test("similarity is symmetric", () => {
|
||||
const a = "Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år.";
|
||||
const b = "Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet.";
|
||||
assert.equal(similarity(a, b), similarity(b, a));
|
||||
});
|
||||
|
||||
// The calibration set the threshold is derived from. Paraphrase (the same story
|
||||
// retold) must land ABOVE the default; unrelated material and same-topic-different-
|
||||
// story must land BELOW it — inflection-heavy Norwegian included, which is why this
|
||||
// is character trigrams and not word tokens (a shortened hook paraphrase scores
|
||||
// 0.11 on word-Jaccard and would slip through).
|
||||
test("paraphrase of the same story clears the default threshold", () => {
|
||||
const pairs: Array<[string, string]> = [
|
||||
[
|
||||
"Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.",
|
||||
"Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata.",
|
||||
],
|
||||
[
|
||||
"Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år.",
|
||||
"Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet.",
|
||||
],
|
||||
[
|
||||
"De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.",
|
||||
"AI-prosjekter dør sjelden av modellen. De dør av dataene.",
|
||||
],
|
||||
];
|
||||
for (const [a, b] of pairs) {
|
||||
assert.ok(
|
||||
similarity(a, b) >= DEFAULT_THRESHOLD,
|
||||
`expected paraphrase >= ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("unrelated material and same-topic-different-story stay below the threshold", () => {
|
||||
const pairs: Array<[string, string]> = [
|
||||
[
|
||||
"Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.",
|
||||
"Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon.",
|
||||
],
|
||||
[
|
||||
"De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.",
|
||||
"Vi har hatt en fast ukentlig gjennomgang av porteføljen siden januar.",
|
||||
],
|
||||
[
|
||||
// same topic, different measured claim — a NEW edition, not a retread
|
||||
"Vi målte 40 prosent kortere ledetid etter omleggingen.",
|
||||
"Vi målte 12 prosent høyere treffrate etter at vi byttet embedding-modell.",
|
||||
],
|
||||
];
|
||||
for (const [a, b] of pairs) {
|
||||
assert.ok(
|
||||
similarity(a, b) < DEFAULT_THRESHOLD,
|
||||
`expected non-reuse < ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("series distillate — skeleton check (Step 2.5)", () => {
|
||||
const distillate = appendEntry(emptyDistillate("seres"), entry()).distillate;
|
||||
|
||||
test("an empty distillate is always CLEAR (first edition in a series)", () => {
|
||||
const r = checkSkeleton(emptyDistillate("seres"), { hooks: ["hva som helst"] });
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
assert.equal(r.comparedAgainst, 0);
|
||||
});
|
||||
|
||||
test("fresh material is CLEAR", () => {
|
||||
const r = checkSkeleton(distillate, {
|
||||
anecdotes: ["Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon."],
|
||||
});
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
});
|
||||
|
||||
test("verbatim reuse is REUSE and names the edition it came from", () => {
|
||||
const r = checkSkeleton(distillate, { hooks: [entry().hooks[0]] });
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.equal(r.hits.length, 1);
|
||||
assert.equal(r.hits[0].kind, "hook");
|
||||
assert.equal(r.hits[0].editionId, "05");
|
||||
assert.equal(r.hits[0].score, 1);
|
||||
assert.equal(r.hits[0].matched, entry().hooks[0]);
|
||||
});
|
||||
|
||||
test("paraphrased reuse is caught, not just verbatim", () => {
|
||||
const r = checkSkeleton(distillate, {
|
||||
anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."],
|
||||
});
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.equal(r.hits[0].kind, "anecdote");
|
||||
assert.ok(r.hits[0].score >= DEFAULT_THRESHOLD);
|
||||
});
|
||||
|
||||
test("kinds are compared within kind — a hook is not matched against an anecdote", () => {
|
||||
// the anecdote text, offered as a hook: the anecdote bucket must not match it
|
||||
const r = checkSkeleton(distillate, { hooks: [entry().anecdotes[0]] });
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
});
|
||||
|
||||
test("hits are sorted by score descending", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03", hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04", hooks: ["AI-prosjekter dør sjelden av modellen. De dør av dataene."], anecdotes: [], arguments: [] })).distillate;
|
||||
|
||||
const r = checkSkeleton(d, { hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] });
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.ok(r.hits.length >= 2);
|
||||
for (let i = 1; i < r.hits.length; i++) {
|
||||
assert.ok(r.hits[i - 1].score >= r.hits[i].score, "hits must be sorted by score desc");
|
||||
}
|
||||
assert.equal(r.hits[0].score, 1);
|
||||
});
|
||||
|
||||
test("the threshold is configurable and reported back", () => {
|
||||
const strict = checkSkeleton(
|
||||
distillate,
|
||||
{ anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."] },
|
||||
{ threshold: 0.95 },
|
||||
);
|
||||
assert.equal(strict.verdict, "CLEAR");
|
||||
assert.equal(strict.threshold, 0.95);
|
||||
});
|
||||
|
||||
test("comparedAgainst reports how many editions the skeleton was checked against", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04" })).distillate;
|
||||
assert.equal(checkSkeleton(d, { hooks: ["noe helt annet om innkjøp og garantier"] }).comparedAgainst, 2);
|
||||
});
|
||||
|
||||
test("an absent bucket on the candidate is not an error", () => {
|
||||
const r = checkSkeleton(distillate, {});
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
});
|
||||
});
|
||||
16
scripts/editions/tsconfig.json
Normal file
16
scripts/editions/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "tests"]
|
||||
}
|
||||
|
|
@ -142,6 +142,56 @@ export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] {
|
|||
return hits;
|
||||
}
|
||||
|
||||
/** What a caller supplies to recordUsage. */
|
||||
export interface UsageInput {
|
||||
/** Bank ids the edition actually consumed — see `boundSpecificIds` in binding.ts. */
|
||||
ids: string[];
|
||||
/** The edition the material was published in, e.g. "seres/05". */
|
||||
editionId: string;
|
||||
}
|
||||
|
||||
export interface UsageResult {
|
||||
bank: Bank;
|
||||
/** ids that gained this edition now. */
|
||||
recorded: string[];
|
||||
/** ids already carrying it — a re-lock after a pivot is idempotent. */
|
||||
alreadyRecorded: string[];
|
||||
/** ids absent from the bank; the caller decides how loud to be. */
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp "used in edition NN" on the specifics an edition consumed. Called at
|
||||
* LOCK (Step 8) rather than at binding (Step 2.5), so an abandoned draft never
|
||||
* pollutes the history — `usedIn` means published, not considered.
|
||||
* Archived material still records: the use is historical fact.
|
||||
*/
|
||||
export function recordUsage(bank: Bank, input: UsageInput): UsageResult {
|
||||
const editionId = input.editionId.trim();
|
||||
if (editionId.length === 0) {
|
||||
throw new Error("recordUsage: editionId must be non-blank — an unattributable use is worse than none");
|
||||
}
|
||||
const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s]));
|
||||
const recorded: string[] = [];
|
||||
const alreadyRecorded: string[] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const id of input.ids) {
|
||||
const specific = byId.get(id);
|
||||
if (!specific) {
|
||||
unknown.push(id);
|
||||
continue;
|
||||
}
|
||||
const usedIn = specific.usedIn ?? [];
|
||||
if (usedIn.includes(editionId)) {
|
||||
alreadyRecorded.push(id);
|
||||
continue;
|
||||
}
|
||||
specific.usedIn = [...usedIn, editionId];
|
||||
recorded.push(id);
|
||||
}
|
||||
return { bank, recorded, alreadyRecorded, unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bank path under the per-user data dir (M0 data-path convention), so the
|
||||
* bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the
|
||||
|
|
|
|||
|
|
@ -154,3 +154,21 @@ export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult {
|
|||
coverage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank ids an edition's slot-map actually consumed — slot order, deduped.
|
||||
* Pure. This is the input to `recordUsage` at lock: only `specific` bindings
|
||||
* count as consumption (abstrakt/ekstern/unresolved consume no bank material).
|
||||
*/
|
||||
export function boundSpecificIds(ls: LivedSpecifics): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const slot of ls.slots) {
|
||||
if (slot.binding.type !== "specific") continue;
|
||||
const { specificId } = slot.binding;
|
||||
if (seen.has(specificId)) continue;
|
||||
seen.add(specificId);
|
||||
ids.push(specificId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import {
|
|||
defaultBankPath,
|
||||
loadBank,
|
||||
queryByTopic,
|
||||
recordUsage,
|
||||
saveBank,
|
||||
} from "./bank.js";
|
||||
import { validateBinding } from "./binding.js";
|
||||
import { boundSpecificIds, validateBinding } from "./binding.js";
|
||||
import { renderKilder } from "./kilder.js";
|
||||
import type { LivedSpecifics } from "./binding.js";
|
||||
import type { SpecificType, Verification } from "./types.js";
|
||||
|
|
@ -72,7 +73,8 @@ function usage(msg: string): never {
|
|||
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
|
||||
" list [--bank <path>] [--json]\n" +
|
||||
" validate-binding --edition <edition-state.json> [--article <id>] [--bank <path>] [--json]\n" +
|
||||
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]",
|
||||
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]\n" +
|
||||
" record-usage --edition <edition-state.json> --edition-id <slug/NN> [--article <id>] [--bank <path>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -213,6 +215,32 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "record-usage") {
|
||||
if (!flags.edition || flags.edition === "true") usage("record-usage needs --edition <path>");
|
||||
const editionId = flags["edition-id"];
|
||||
if (!editionId || editionId === "true") {
|
||||
usage("record-usage needs --edition-id <slug/NN> — an unattributable use is worse than none");
|
||||
}
|
||||
const edition = loadEdition(flags.edition);
|
||||
const { id, article } = resolveArticle(edition, flags.article);
|
||||
const ids = boundSpecificIds(article.livedSpecifics ?? EMPTY_LS);
|
||||
const res = recordUsage(loadBank(bankPath), { ids, editionId });
|
||||
saveBank(bankPath, res.bank);
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ article: id, editionId, ...res, bank: undefined }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Usage recorded for article ${id} as "${editionId}": ` +
|
||||
`${res.recorded.length} stamped, ${res.alreadyRecorded.length} already logged.`,
|
||||
);
|
||||
if (res.unknown.length > 0) {
|
||||
console.log(` ⚠ ${res.unknown.length} bound id(s) absent from the bank: ${res.unknown.join(", ")}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ export interface Specific {
|
|||
/** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */
|
||||
verification: Verification;
|
||||
status: SpecificStatus;
|
||||
/**
|
||||
* Editions this material was actually published in ("seres/05"), appended at
|
||||
* lock. Additive-optional: absent means never used, so existing banks load
|
||||
* unchanged and the schema stays v1. The dedupe in `addSpecific` deliberately
|
||||
* ENCOURAGES re-surfacing the same material; this field is what makes the
|
||||
* re-use visible to the operator at Step 1.5 instead of silent (N11 / C-5).
|
||||
*/
|
||||
usedIn?: string[];
|
||||
}
|
||||
|
||||
export interface Bank {
|
||||
|
|
|
|||
142
scripts/specifics-bank/tests/cli-usage.test.ts
Normal file
142
scripts/specifics-bank/tests/cli-usage.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { addSpecific, emptyBank, specificId, saveBank } from "../src/bank.js";
|
||||
|
||||
// Resolve the package root so the subprocess `src/cli.ts` path + the `tsx` loader
|
||||
// resolve regardless of the runner's cwd.
|
||||
const bankDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: bankDir,
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "specifics-cli-usage-"));
|
||||
|
||||
const CONTENT = "Vi målte 40 prosent kortere ledetid etter omleggingen.";
|
||||
|
||||
/** A bank + an edition-state whose slot-map binds one slot to the bank specific. */
|
||||
function fixture(dir: string): { bankPath: string; editionPath: string; id: string } {
|
||||
const bankPath = join(dir, "specifics-bank.json");
|
||||
const id = specificId(CONTENT);
|
||||
const bank = addSpecific(emptyBank(), {
|
||||
type: "number",
|
||||
content: CONTENT,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
}).bank;
|
||||
saveBank(bankPath, bank);
|
||||
|
||||
const editionPath = join(dir, "edition-state.json");
|
||||
writeFileSync(
|
||||
editionPath,
|
||||
JSON.stringify({
|
||||
currentArticle: "05",
|
||||
articles: {
|
||||
"05": {
|
||||
title: "Da pipelinen brøt sammen",
|
||||
livedSpecifics: {
|
||||
status: "bound",
|
||||
slots: [
|
||||
{ slotId: "kp1", kind: "key-point", label: "ledetid", binding: { type: "specific", specificId: id } },
|
||||
{ slotId: "kp2", kind: "key-point", label: "annet", binding: { type: "abstrakt", rationale: "bevisst" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return { bankPath, editionPath, id };
|
||||
}
|
||||
|
||||
describe("specifics-bank CLI — record-usage (Step 8 lock)", () => {
|
||||
test("stamps the edition on the bound specifics and persists it", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath, id } = fixture(dir);
|
||||
|
||||
const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(bankPath, "utf8"));
|
||||
const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id);
|
||||
assert.deepEqual(stamped.usedIn, ["seres/05"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("only specific-bound slots are stamped — abstrakt/ekstern consume no bank material", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath } = fixture(dir);
|
||||
const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.recorded.length, 1);
|
||||
assert.deepEqual(out.unknown, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("re-running after a pivot re-lock is idempotent", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath, id } = fixture(dir);
|
||||
run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]);
|
||||
const again = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]);
|
||||
assert.equal(again.status, 0, again.stderr);
|
||||
|
||||
const out = JSON.parse(again.stdout);
|
||||
assert.deepEqual(out.recorded, []);
|
||||
assert.equal(out.alreadyRecorded.length, 1);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(bankPath, "utf8"));
|
||||
const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id);
|
||||
assert.deepEqual(stamped.usedIn, ["seres/05"], "a re-lock must not double-stamp");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an edition with no bound specifics is a no-op, exit 0", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath } = fixture(dir);
|
||||
const empty = join(dir, "empty-edition.json");
|
||||
writeFileSync(empty, JSON.stringify({ currentArticle: "06", articles: { "06": {} } }), "utf8");
|
||||
|
||||
const res = run(["record-usage", "--edition", empty, "--edition-id", "seres/06", "--bank", bankPath, "--json"]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(JSON.parse(res.stdout).recorded, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bad invocations exit 2", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath } = fixture(dir);
|
||||
assert.equal(run(["record-usage", "--bank", bankPath]).status, 2); // no --edition
|
||||
assert.equal(
|
||||
run(["record-usage", "--edition", editionPath, "--bank", bankPath]).status,
|
||||
2, // no --edition-id: an unattributable use is worse than none
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
157
scripts/specifics-bank/tests/usage.test.ts
Normal file
157
scripts/specifics-bank/tests/usage.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { addSpecific, emptyBank, loadBank, recordUsage, saveBank, specificId } from "../src/bank.js";
|
||||
import { boundSpecificIds } from "../src/binding.js";
|
||||
import type { LivedSpecifics } from "../src/binding.js";
|
||||
import type { Bank } from "../src/types.js";
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "specifics-usage-"));
|
||||
|
||||
const CONTENT = {
|
||||
a: "Vi målte 40 prosent kortere ledetid etter omleggingen.",
|
||||
b: "Kunden avlyste pilotene fordi ingen eide dataene.",
|
||||
};
|
||||
|
||||
function bankWith(...contents: string[]): Bank {
|
||||
let bank = emptyBank();
|
||||
for (const content of contents) {
|
||||
bank = addSpecific(bank, {
|
||||
type: "number",
|
||||
content,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
}).bank;
|
||||
}
|
||||
return bank;
|
||||
}
|
||||
|
||||
describe("specifics-bank — usage logging (N11: «used in edition NN»)", () => {
|
||||
test("recording usage stamps the edition on the specific", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const res = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.recorded, [id]);
|
||||
assert.deepEqual(res.alreadyRecorded, []);
|
||||
assert.deepEqual(res.unknown, []);
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("recording the same edition twice is idempotent (a re-lock does not double-count)", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const once = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
const twice = recordUsage(once.bank, { ids: [id], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(twice.recorded, []);
|
||||
assert.deepEqual(twice.alreadyRecorded, [id]);
|
||||
assert.deepEqual(twice.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("a second edition appends, preserving order", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const first = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
const second = recordUsage(first.bank, { ids: [id], editionId: "seres/06" });
|
||||
|
||||
assert.deepEqual(second.bank.specifics[0].usedIn, ["seres/05", "seres/06"]);
|
||||
});
|
||||
|
||||
test("an id absent from the bank is reported as unknown, not silently dropped", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a), "deadbeef1234"], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.unknown, ["deadbeef1234"]);
|
||||
assert.deepEqual(res.recorded, [specificId(CONTENT.a)]);
|
||||
});
|
||||
|
||||
test("archived material still records use — the use is historical fact", () => {
|
||||
let bank = emptyBank();
|
||||
bank = addSpecific(bank, {
|
||||
type: "named-case",
|
||||
content: CONTENT.b,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
status: "archived",
|
||||
}).bank;
|
||||
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.b)], editionId: "seres/05" });
|
||||
assert.deepEqual(res.recorded, [specificId(CONTENT.b)]);
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("recording touches only the named specifics", () => {
|
||||
const bank = bankWith(CONTENT.a, CONTENT.b);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
assert.equal(res.bank.specifics[1].usedIn, undefined);
|
||||
});
|
||||
|
||||
test("an empty id list is a no-op, not an error", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const res = recordUsage(bank, { ids: [], editionId: "seres/05" });
|
||||
assert.deepEqual(res.recorded, []);
|
||||
assert.equal(res.bank.specifics[0].usedIn, undefined);
|
||||
});
|
||||
|
||||
test("a blank edition id throws — an unattributable use is worse than none", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
assert.throws(() => recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: " " }), /editionId/);
|
||||
});
|
||||
|
||||
test("usedIn survives a save/load round-trip; an untouched bank stays field-free", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, "specifics-bank.json");
|
||||
const bank = bankWith(CONTENT.a, CONTENT.b);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" });
|
||||
saveBank(path, res.bank);
|
||||
|
||||
const back = loadBank(path);
|
||||
assert.deepEqual(back.specifics[0].usedIn, ["seres/05"]);
|
||||
// additive-optional: never-used material carries no field at all (schema stays v1)
|
||||
assert.equal("usedIn" in back.specifics[1], false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("specifics-bank — boundSpecificIds (what an edition actually consumed)", () => {
|
||||
const ls = (slots: LivedSpecifics["slots"]): LivedSpecifics => ({ slots, status: "bound" });
|
||||
|
||||
test("extracts the ids of specific-bound slots only", () => {
|
||||
const res = boundSpecificIds(
|
||||
ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp2", kind: "key-point", label: "b", binding: { type: "abstrakt", rationale: "bevisst" } },
|
||||
{ slotId: "kp3", kind: "section", label: "c", binding: { type: "ekstern", source: "https://x" } },
|
||||
{ slotId: "kp4", kind: "section", label: "d", binding: { type: "unresolved" } },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(res, ["aaa111"]);
|
||||
});
|
||||
|
||||
test("dedupes an id bound in several slots", () => {
|
||||
const res = boundSpecificIds(
|
||||
ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp2", kind: "section", label: "b", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp3", kind: "section", label: "c", binding: { type: "specific", specificId: "bbb222" } },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(res, ["aaa111", "bbb222"]);
|
||||
});
|
||||
|
||||
test("an empty slot map yields no ids", () => {
|
||||
assert.deepEqual(boundSpecificIds(ls([])), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -698,7 +698,7 @@ if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then
|
|||
SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" )
|
||||
SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
SPECIFICS_BANK_TESTS_FLOOR=28
|
||||
SPECIFICS_BANK_TESTS_FLOOR=45
|
||||
if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then
|
||||
pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)"
|
||||
else
|
||||
|
|
@ -710,6 +710,30 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 15b: Series-Distillate Suite (N11 / C-5) ---
|
||||
echo "--- Series Distillate ---"
|
||||
|
||||
# The series distillate (scripts/editions) is wired into /linkedin:newsletter as the Step 2.5
|
||||
# destillat-sjekk and the Step 8 lock write. Same discipline as Section 15: the suite belongs in
|
||||
# CI (store + similarity + skeleton-check stay green, case count never erodes), and an adopter
|
||||
# without deps warn-skips rather than failing. Same set +e / subshell discipline (bash 3.2-safe).
|
||||
ED_DIR="scripts/editions"
|
||||
if [ -x "$ED_DIR/node_modules/.bin/tsx" ]; then
|
||||
ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" )
|
||||
ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
EDITIONS_TESTS_FLOOR=27
|
||||
if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then
|
||||
pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)"
|
||||
else
|
||||
fail "editions suite NOT green (exit=${ED_EXIT:-?}, tests=${ED_TESTS:-?}, floor $EDITIONS_TESTS_FLOOR) — run: (cd $ED_DIR && npm test)"
|
||||
fi
|
||||
else
|
||||
warn "editions skipped — deps absent ($ED_DIR/node_modules); run: (cd $ED_DIR && npm install)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 16: Trends-Store Binding (research-engine slice 2b) ---
|
||||
echo "--- Trends-Store Binding ---"
|
||||
|
||||
|
|
@ -1804,6 +1828,106 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16r: Series-Level Protection - distillate + usage log (N11 / C-5) ---
|
||||
echo "--- Series-Level Protection (N11) ---"
|
||||
|
||||
# N11 makes "the reader has heard this before" visible BEFORE the skeleton is approved.
|
||||
# Every other gate sees ONE edition; at series cadence the fastest-growing defect is a
|
||||
# REPEATED edition, and the specifics-bank dedupe actively encourages re-surfacing material.
|
||||
# Two stores, two grains, both UNCONDITIONAL greps (they raise the deps-absent floor):
|
||||
# (C-5 series grain) scripts/editions - what THIS SERIES already published (anecdote/argument/hook)
|
||||
# (C-5 material grain) specifics-bank usedIn - which editions consumed a given lived specific
|
||||
# (C-5 Step 2.5) the destillat-sjekk feeds the annotation gate BEFORE prose - a compound predicate
|
||||
# (distil-check call + the series-scoped store + the advisory REUSE verdict), so a
|
||||
# non-vacuity self-test guards it (mirrors Sections 16p/16q).
|
||||
# (C-5 Step 8) lock writes both stores - published, never merely considered.
|
||||
NL_N11="commands/newsletter.md"
|
||||
DIST_SRC="scripts/editions/src/distillate.ts"
|
||||
|
||||
distillate_gated() { # $1 = text; wired iff it CALLS the check, names the series-scoped store AND carries the advisory verdict
|
||||
echo "$1" | grep -qF "distil-check" \
|
||||
&& echo "$1" | grep -qF "series-distillate.json" \
|
||||
&& echo "$1" | grep -qF "REUSE"
|
||||
}
|
||||
|
||||
DG_SELFTEST_OK=1
|
||||
if ! distillate_gated "run distil-check --distillate <abs>/linkedin/series-distillate.json; on REUSE fold the finding into the annotation page"; then
|
||||
DG_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired destillat-sjekk probe was not detected"
|
||||
fi
|
||||
while IFS= read -r probe; do
|
||||
[ -z "$probe" ] && continue
|
||||
if distillate_gated "$probe"; then
|
||||
DG_SELFTEST_OK=0; echo " false-positive FAIL: under-wired destillat probe accepted -> $probe"
|
||||
fi
|
||||
done <<'NEGATIVE16R'
|
||||
run distil-check against the store and act on REUSE without naming the file
|
||||
the series-distillate.json store exists and REUSE is possible, but nothing calls the check
|
||||
call distil-check on series-distillate.json but never say what a hit means
|
||||
NEGATIVE16R
|
||||
if [ "$DG_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "destillat-gate self-test: predicate needs distil-check + series-distillate.json + REUSE verdict (1 accepted, 3 under-wired rejected)"
|
||||
else
|
||||
fail "destillat-gate self-test failed - the N11 wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
# (C-5 Step 2.5) the destillat-sjekk runs in the skeleton gate, BEFORE prose
|
||||
STEP25_BLOCK=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N11")
|
||||
if distillate_gated "$STEP25_BLOCK"; then
|
||||
pass "newsletter Step 2.5 runs the destillat-sjekk against the series store with an advisory REUSE verdict (C-5 Step 2.5)"
|
||||
else
|
||||
fail "newsletter Step 2.5 does not run the destillat-sjekk - series-level re-use stays invisible before prose (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 Step 8 distillate) lock folds the spent units into the series distillate
|
||||
STEP8_BLOCK=$(awk '/^## Step 8:/{f=1} /^## Step 9:/{f=0} f' "$NL_N11")
|
||||
if echo "$STEP8_BLOCK" | grep -qF "distil-append"; then
|
||||
pass "newsletter Step 8 lock writes the series distillate (distil-append) (C-5 Step 8)"
|
||||
else
|
||||
fail "newsletter Step 8 never calls distil-append - nothing feeds the next edition's Step 2.5 check (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 Step 8 usage) lock stamps the consumed lived material
|
||||
if echo "$STEP8_BLOCK" | grep -qF "record-usage"; then
|
||||
pass "newsletter Step 8 lock stamps consumed specifics (record-usage) (C-5 Step 8)"
|
||||
else
|
||||
fail "newsletter Step 8 never calls record-usage - bank re-use across editions stays invisible (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 material grain) the bank record carries the usage log
|
||||
if grep -qF "usedIn" scripts/specifics-bank/src/types.ts; then
|
||||
pass "specifics-bank Specific declares usedIn - the cross-edition usage log exists (C-5 material grain)"
|
||||
else
|
||||
fail "specifics-bank/src/types.ts has no usedIn field - «used in edition NN» is unrecordable (C-5)"
|
||||
fi
|
||||
|
||||
if grep -qF "export function recordUsage" scripts/specifics-bank/src/bank.ts; then
|
||||
pass "specifics-bank exports recordUsage (C-5 material grain)"
|
||||
else
|
||||
fail "specifics-bank/src/bank.ts does not export recordUsage (C-5)"
|
||||
fi
|
||||
|
||||
if grep -qF "export function boundSpecificIds" scripts/specifics-bank/src/binding.ts; then
|
||||
pass "specifics-bank exports boundSpecificIds - only specific-bound slots count as consumption (C-5)"
|
||||
else
|
||||
fail "specifics-bank/src/binding.ts does not export boundSpecificIds (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 series grain) the distillate module exists with its deterministic check
|
||||
if grep -qF "export function checkSkeleton" "$DIST_SRC"; then
|
||||
pass "scripts/editions exports checkSkeleton - the deterministic series check exists (C-5 series grain)"
|
||||
else
|
||||
fail "$DIST_SRC does not export checkSkeleton (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 placement) the distillate lives in the SERIES root beside edition-state, not the data dir
|
||||
if grep -qF "series-distillate.json" "$DIST_SRC" && grep -qF '"linkedin"' "$DIST_SRC"; then
|
||||
pass "series distillate resolves under <serie>/linkedin/ beside edition-state (C-5 placement)"
|
||||
else
|
||||
fail "$DIST_SRC does not resolve the distillate under the series root - series-scoped state placed wrong (C-5)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -1842,12 +1966,16 @@ echo ""
|
|||
# (targetLevel-consumption self-test + no-hardcoded-line-manager absence grep + references target-level-span
|
||||
# grep + personas opposite-end obligatorisk-sekundær grep + personas explicit-ceiling-justification grep +
|
||||
# personas technical-end seed grep + edition-state targetLevel-slot grep + newsletter articles.NN.targetLevel
|
||||
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146.
|
||||
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146; +9 for N11's nine
|
||||
# UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep +
|
||||
# Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep +
|
||||
# binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root
|
||||
# placement grep) = 155.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=146
|
||||
ASSERT_BASELINE_FLOOR=155
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue