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:
Kjell Tore Guttormsen 2026-07-25 06:29:01 +02:00
commit b54e450c3e
20 changed files with 2152 additions and 19 deletions

View 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.440.55, unrelated material 0.040.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
View 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"
}
}
}

View 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
View 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();

View 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.440.55, unrelated material
* 0.040.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 slugpath map.
*/
export function defaultDistillatePath(seriesRoot: string): string {
return join(seriesRoot, "linkedin", "series-distillate.json");
}

View 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;
}

View 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 });
}
});
});

View 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, []);
});
});

View 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"]
}

View file

@ -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

View file

@ -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;
}

View file

@ -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");
}

View file

@ -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 {

View 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 });
}
});
});

View 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([])), []);
});
});

View file

@ -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"