Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# The whole test suite is `node --test` over plain modules, so CI needs no
# services, no secrets and no network. Anything here that WOULD need an API key
# is deliberately left out: a green tick that depends on a repository secret is
# a tick contributors cannot reproduce, and forks cannot run at all.
name: CI

on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 20 is the floor declared in engines; 22 is the current LTS.
node: [20, 22]
name: test (node ${{ matrix.node }})
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm

- name: Install
run: npm ci --no-audit --no-fund

- run: npm test

smoke:
runs-on: ubuntu-latest
name: cli and rubric packs
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install
run: npm ci --no-audit --no-fund

# No arguments prints usage and must exit 0. `doctor` is NOT run here:
# it requires a provider key and exits 1 without one, so in CI it would
# only ever prove that the secret is missing.
- name: CLI help path exits 0
run: node src/cli.mjs

# validatePack is called directly so the packs are checked for structural
# errors without a key, a network call, or the rest of doctor.
- name: Validate every rubric pack
run: |
node -e '
const { readdirSync } = require("node:fs");
(async () => {
const dir = "./src/rubrics";
let checked = 0, bad = 0;
const { validatePack } = await import(dir + "/pack.mjs");
for (const file of readdirSync(dir).filter((f) => f.endsWith(".mjs") && f !== "pack.mjs")) {
const mod = await import(dir + "/" + file);
for (const [name, value] of Object.entries(mod)) {
if (!value || typeof value !== "object" || !value.questions) continue;
checked++;
const problems = validatePack(value);
if (problems.length) {
bad++;
console.error("FAIL " + file + " -> " + name);
for (const p of problems) console.error(" " + p);
} else {
console.log("OK " + file + " -> " + name + " (" + Object.keys(value.questions).length + " questions)");
}
}
}
if (!checked) { console.error("no rubric packs found"); process.exit(1); }
process.exit(bad ? 1 : 0);
})();
'
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ node_modules/
.env
.env.*
!.env.example
# Never commit anyone's exported content or derived ratings.

# Never commit anyone's exported content or anything derived from it.
# report.json and dashboard.html both embed the full text of every post, so the
# output directory is ignored wholesale rather than by file type. An earlier
# version listed `*.jsonl` and `dashboard.html`, which missed `jev-out/report.json`
# entirely.
jev-out/
data/
out/
*.jsonl
dashboard.html
report.json

.DS_Store
106 changes: 93 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,41 @@ If a change makes the output more impressive and less defensible, it is wrong.
```
src/stats.mjs rank correlation, permutation tests, the power gate
src/report.mjs the statistics -> English boundary. Read this first.
src/dashboard.mjs buildReport output -> one self-contained HTML string
src/index.mjs library entry point, re-exporting the stable surface
src/rubrics/pack.mjs pack format and validation
src/rubrics/*.mjs shipped packs
src/rubrics/*.mjs shipped packs, each with its own `directions` export
src/adapters/*.mjs platform export parsing
src/providers/*.mjs one file per transport
src/cli.mjs doctor / build / rate / analyze / run
skills/jev-writer/ the agent skill and its references
src/cli.mjs doctor / build / rate / analyze / dashboard / run
skills/jev-writer/ the agent skill and its five reference files
test/ node:test, no framework
.github/workflows/ci.yml node 20 and 22, plus a keyless pack-validation smoke job
llms.txt the short machine-readable description of the project
```

`src/report.mjs` is the most important file to understand. Everything above it
speaks statistics; everything below it speaks English. No p-value, rho or
confidence interval may cross that boundary onto a user-facing surface.

`src/dashboard.mjs` does no I/O. It takes what `buildReport` returned and gives
back a string; the CLI owns reading and writing files. Keep it that way, so the
page can be rendered from a test or an agent without touching a disk.

## Conventions

- **ESM only**, Node >= 20, no TypeScript, no build step.
- **One runtime dependency** (`ai`, required by the AI Gateway path). Do not add
more without a strong reason; the CSV and xlsx readers are hand-written
specifically to avoid dependencies.
- **The `exports` map in `package.json` is the public surface.** Consumers import
`jev-writer`, `jev-writer/stats.mjs`, `jev-writer/report.mjs`,
`jev-writer/dashboard.mjs`, `jev-writer/rubrics/pack.mjs`,
`jev-writer/rubrics/linkedin-post.mjs` or `jev-writer/adapters/linkedin.mjs`.
A deep path into `src/` no longer resolves from outside the package, so adding
a module that users need means adding a subpath to that map as well.
- **Comments explain why, not what.** Several comments in this repo record a
measured fact or a bug that was actually hit — those are load-bearing
measured fact or a bug that was actually hit. Those are load-bearing
documentation, not clutter. Do not strip them.
- Tests use `node:test` and synthetic fixtures. **No real post data in the
repository, ever.**
Expand All @@ -71,13 +85,51 @@ confidence interval may cross that boundary onto a user-facing surface.
SDK rejects with "did not select a highest-probability option". That is
sampling noise on a genuinely ambiguous item; retry the repetition, never drop
the item.
- **`score` accepts 2–10 levels only.** The API rejects anything else.
- **`score` accepts 2 to 10 levels only.** The API rejects anything else.
- **`validatePack` does not check directions.** It checks ids, types, tiers,
instructions, score-level counts, the Choice no-match option, and that every
backticked reference in an instruction names a declared `stateField`. Direction
of merit is a separate `directions` export beside the pack, and it is the
report layer that enforces it: `buildReport` throws when a rated dimension has
no direction, and `cli.mjs` skips report generation with a warning when a pack
has no directions entry at all. A pack can therefore be structurally valid and
still produce no `report.json`.
- **Rubric wording is load-bearing.** Tightening one clause moved a judgment from
0.99 to 0.71 on identical input. Treat pack edits as breaking changes: bump the
version and re-rate rather than pooling ratings across wordings.
- **Bands are direction-aware.** Never colour or rank by a raw rubric value.
- **Bands are quintiles**, so "share of posts in the bottom two bands" is 40% by
construction. That bug shipped once; do not reintroduce it.
- **A pack's `groups` are presentation only.** They give the dashboard families
for the expanded post view and never touch how a question is asked, rated or
correlated. A pack that declares none still renders, with everything falling
into one catch-all list.
- **The LinkedIn adapter reports rather than guesses.** Two posts on the same
date cannot be told apart, because the analytics export joins on date, so
metrics, post URL and media fields are all left null on those days. The
follower count is exempt on purpose: it is a per-day value, so two posts on one
date genuinely share it. Unparseable dates, and a follower total that fails its
consistency check against the daily rows, each raise a warning rather than
failing silently. Surface `warnings` to the user; do not swallow them.

### Two statistical bugs that were live, and must not come back

- **Slicing the control array.** An earlier version built the publication-date
control over the whole subset and then took `control.slice(0, pairs.length)`.
That is the FIRST n dates, not the dates OF the n surviving items, so a single
post dropped for a missing rating shifted every later date onto the wrong post
and silently corrupted the date control that decides whether a finding is
confirmed. It is fixed in both `src/report.mjs` and `src/cli.mjs` by carrying
`[value, outcome, date]` triples through one filter, which makes the
misalignment unrepresentable. Never reintroduce a parallel array that has to be
filtered in step with another.
- **Truthiness on a metric.** `impressions && engagements ? ... : null` treated a
genuine zero as missing data. Zero engagements on a real post is the single
most informative outcome in the file, and dropping it is not random: it removes
the worst-performing posts from every correlation and pulls the whole analysis
toward "everything I write is fine". Only a zero *denominator* is undefined.
Test `Number.isFinite`, never truthiness, on anything that can legitimately be
zero.

## Statistics rules

Expand All @@ -86,26 +138,54 @@ confidence interval may cross that boundary onto a user-facing surface.
between levels.
- **Permutation p-values and bootstrap CIs**, not normal approximations; n is
routinely under 60.
- **Control for publication date** on any headline result. A rated dimension and
an outcome that both drift over time correlate for no reason.
- **Resampling is seeded, and the seed is reported.** `permutationP`,
`bootstrapCI` and `testDimension` all take an overridable `seed`, defaulting to
the exported `DEFAULT_SEED` (20260920), and `testDimension` puts the seed it
used on the row it returns. A published finding is therefore reproducible by
anyone holding the same data, and a finding that survives on only one seed was
never a finding.
- **Control for publication date** on any headline result, and run the
false-discovery correction on the controlled p-value rather than the raw one.
A rated dimension and an outcome that both drift over time correlate for no
reason, and correcting the uncontrolled p would let a result the tool itself
flagged as shared time drift reach the headline.
- **Keep the two outcomes separate.** `reach_rate` is impressions over followers
at post time; `conversion_rate` is engagements over impressions. They answer
different questions and must not be blended into one score.

## Privacy

This tool sends private writing to a third party. `.gitignore` excludes `data/`,
`out/` and `*.jsonl`. A generated dashboard embeds full post text, so it is
generated, never committed. Zero Data Retention is requested on every gateway
call. Never ask a user to paste an API key into a chat.
This tool sends private writing to a third party. `.gitignore` excludes
`jev-out/`, `data/`, `out/`, `*.jsonl`, `dashboard.html` and `report.json`. Both
`report.json` and the generated dashboard embed the full text of every post, so
they are generated, never committed. Zero Data Retention is requested on every
gateway call. Never ask a user to paste an API key into a chat.

## The skill

The agent skill lives in `skills/jev-writer/` and is installed with:

```bash
npx skills add Kaos599/jev-writer --skill jev-writer
```

The full URL form works too and installs the same skill. If you change the skill,
change `SKILL.md` and its reference files together: anything too long for the
skill body belongs in `skills/jev-writer/references/`, and `SKILL.md` should
point at it rather than repeat it.

## Before you open a PR

```bash
npm test # node:test, all must pass
npm test # node:test, 67 tests, all must pass
node src/cli.mjs doctor # validates every pack and makes one live call
```

CI runs the suite on Node 20 and 22 and separately validates every rubric pack
without a key. `doctor` is deliberately not run in CI: it needs a provider key
and exits 1 without one, so a green tick there would only ever prove that a
repository secret exists. Run it locally before you open the PR.

Add a test for any new statistic or pack-validation rule. Several existing tests
exist because they caught a real bug — read them before changing behaviour they
exist because they caught a real bug; read them before changing behaviour they
pin.
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Changelog

Notable changes to jev-writer. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added
- `jev-writer dashboard` renders `report.json` as one self-contained HTML page: inline CSS and JS, data embedded, no CDN and no build step, so it opens over `file://`. Exposed as `buildDashboard()` for programmatic use.
- `corpus` facts on the report: posting volume by year, follower trajectory, and headline medians.
- A derived posting-cadence classification, so the dashboard writes a true sentence about your volume instead of a templated one.
- An `exports` map, making `jev-writer/rubrics/pack.mjs`, `jev-writer/stats.mjs`, `jev-writer/report.mjs`, `jev-writer/dashboard.mjs` and `jev-writer/adapters/linkedin.mjs` the stable public surface.
- Question groups on the LinkedIn pack, covering all 30 questions across 5 families. Presentation only.
- CI on Node 20 and 22, plus a smoke job validating every rubric pack without a key.
- Tests for `report.mjs` and `dashboard.mjs`, which had none. Suite grew from 27 to 67.
- `CONTRIBUTING.md`, `SECURITY.md`, and this file.

### Fixed
- **The dashboard reported "0.0% reach" when there was no engagement data at all.** `null * 100` is `0` in JavaScript, so the percentage formatter turned "we cannot measure this" into a confident claim of zero reach, on the hero card, whenever no analytics export was supplied. It now reads "No data".
- Benjamini-Hochberg corrected over the dimensions that could actually be tested while the power headline announced dimensions times outcomes. Where outcome coverage is thin the two diverge. The family size is now computed once and the number announced is the number corrected over.
- `cadenceOf` picked the peak year with a strict `>`, so an author whose latest year tied their best year was told they were publishing less.
- `buildDashboard` threw an opaque `TypeError` on a `report.json` written before the corpus fields existed. It now names the stale file and the command to re-run.
- **The date-control gate was silently corrupted.** `control.slice(0, pairs.length)` took the first n publication dates rather than the dates of the n surviving items, so a single post missing a rating misaligned every date after it. This is the mechanism that decides whether a finding is confirmed or dismissed as time drift. Values, outcomes and dates now travel as one tuple through a single filter, making the misalignment unrepresentable.
- **Zero engagements were treated as missing data.** `impressions && engagements ? ... : null` dropped any post with no engagement from every correlation, which systematically excluded the worst-performing posts and biased every finding upward. Only a zero denominator now yields null.
- Same-date collisions left media type and media count attributed to a pooled guess while correctly nulling metrics. All per-post fields are now null on a colliding date.
- Rows with unparseable dates were dropped in four places without a word. They are now counted and reported, with sample values, in `warnings`.
- The follower back-walk assumed the export's headline total aligned with the last daily row, unvalidated. A mismatch or a moved sheet layout now nulls follower-derived fields rather than biasing every reach rate.
- Posts published before the follower window received the earliest known follower count. They now receive none.
- The permutation seed is overridable and reported, rather than a hardcoded default no caller could reach.
- `.gitignore` missed `jev-out/report.json`, the one output containing every post in full.
- The lockfile still carried the pre-rename package name.

### Changed
- The dashboard's narrative is derived from the data rather than hardcoded. An earlier version's heading asserted that the author had stopped posting, which was true of exactly one corpus.
- Methods text derives its question counts from the pack instead of hardcoding them.
- Platform names are parameterised rather than assuming LinkedIn.

## [0.1.0]

Initial public release: providers, statistics, rubric packs, the LinkedIn adapter, the report layer, and the agent skill.
Loading
Loading