stilltrue 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,111 +2,162 @@
2
2
 
3
3
  > Your tests check your code. **stilltrue** checks your facts.
4
4
 
5
- AI-in-the-loop apps quietly start lying three ways: code changes break old
6
- answers, the world changes and curated facts rot, and the model embellishes
7
- beyond its sources. stilltrue is one watchdog per failure mode, run from CI —
8
- no server, no dashboard, no SaaS.
9
-
10
- | Command | Watchdog | Status |
11
- |---|---|---|
12
- | `npx stilltrue drift` | Curated facts vs their live authoritative sources — fails the build on rot | shipped |
13
- | `npx stilltrue report` | Self-contained HTML drift timeline from recorded runs | shipped |
14
- | `verify` (library) | Staged acceptance pipeline for AI output — judge answers against sources in the request path | shipped |
15
-
16
- For golden-style regression evals (did a code change break an answer?), use
17
- [promptfoo](https://promptfoo.dev) — **promptfoo tests your prompts;
18
- stilltrue tests your facts.** Our eval doctrine and how the tools pair:
19
- [docs/GOLDEN.md](docs/GOLDEN.md).
20
-
21
- **Why drift first:** "drift detection" in the market means statistical ML
22
- feature drift; website-change monitors alert humans, not CI. Nobody ships
23
- *"assert my curated facts still match their live source, as a CI check."*
24
- This was built bespoke three times across three domains before becoming a
25
- package — see [docs/BRIEF.md](docs/BRIEF.md) for the full design brief,
26
- positioning, and build plan.
27
-
28
- ## Core principle
29
-
30
- **AI may extract, but must never decide.** The default drift path has zero AI:
31
- fetch → parse → compare → diff. A consumer's `source()` may use an LLM to pull
32
- structure out of a messy page, but the comparison and the verdict are always
33
- deterministic. Same inputs, same verdict — a nondeterministic alarm trains
34
- people to ignore it.
35
-
36
- Drift has three outcomes, never two: `pass`, `rot` (facts changed — fail the
37
- build), and `error` (source unreachable — warn, don't fail). Conflating the
38
- last two causes alarm fatigue.
39
-
40
- ## Quick start
41
-
42
- ```ts
43
- // stilltrue.config.ts (or .mjs / .js)
5
+ ## The problem
6
+
7
+ Say you've built an app or chatbot that answers questions using facts you
8
+ collected — a list of school board members, office hours, tax rates,
9
+ whatever. You checked those facts when you wrote them down. But the world
10
+ keeps moving: a board member resigns, a rate changes, a first day of school
11
+ rolls over. **Your app doesn't know.** It keeps confidently repeating
12
+ yesterday's truth, and nothing alerts you.
13
+
14
+ stilltrue is the alarm. Once a week (or whenever you like), it re-reads the
15
+ official web pages your facts came from and checks that every fact you
16
+ recorded still appears there. If something vanished, your build fails and
17
+ tells you exactly which fact went stale — so a human can go verify and
18
+ update it.
19
+
20
+ ## What you get
21
+
22
+ | Command | What it does |
23
+ |---|---|
24
+ | `npx stilltrue drift` | Checks your saved facts against their live sources. Fails loudly if a fact has gone stale. |
25
+ | `npx stilltrue report` | Turns past check runs into a simple web page — green/red grid, what broke and when. |
26
+ | `verify` | For developers: a checkpoint that inspects AI-generated answers before they're shown to users. |
27
+
28
+ ## Getting started
29
+
30
+ You'll need [Node.js](https://nodejs.org) (version 20 or newer) and a
31
+ project folder with your facts saved in a JSON file.
32
+
33
+ **1. Install it** — in your project folder, run:
34
+
35
+ ```bash
36
+ npm install --save-dev stilltrue
37
+ ```
38
+
39
+ **2. Scaffold a check** — this creates a ready-to-edit config file and an
40
+ example facts file (it never overwrites anything you already have):
41
+
42
+ ```bash
43
+ npx stilltrue init
44
+ ```
45
+
46
+ Open the generated `stilltrue.config.mjs`. Each check answers three
47
+ questions: *where do I fetch the truth from* (`source`), *what do I
48
+ believe* (`expect`), and *how do I compare the two* (`compare`):
49
+
50
+ ```js
44
51
  import { defineStilltrue, corpus, json, surname } from 'stilltrue';
45
52
 
46
53
  export default defineStilltrue({
47
54
  drift: [
48
55
  {
49
56
  name: 'school-board-roster',
50
- source: corpus(['https://district.example.org/board']),
51
- expect: json('./data/school-board.json', (d) => d.members.map((m) => surname(m.name))),
57
+ // Where the truth lives: the district's own pages.
58
+ source: corpus(['https://www.myschooldistrict.org/board']),
59
+ // What you believe: read your saved file, keep just the last names.
60
+ expect: json('./data/school-board.json', (data) =>
61
+ data.members.map((m) => surname(m.name))
62
+ ),
63
+ // How to compare: every name must still appear on those pages.
52
64
  compare: 'contains-all',
53
65
  },
54
66
  ],
55
67
  });
56
68
  ```
57
69
 
70
+ This example says: *fetch the board page, and make sure every last name in
71
+ my `school-board.json` file still appears somewhere on it.*
72
+
73
+ Choosing what to assert is the judgment call — surnames beat full names,
74
+ "143 schools" beats a bare "143". The short guide:
75
+ [docs/MARKERS.md](docs/MARKERS.md).
76
+
77
+ **3. Run it:**
78
+
79
+ ```bash
80
+ npx stilltrue drift
81
+ ```
82
+
83
+ You'll see one line per check:
84
+
85
+ ```
86
+ ✓ school-board-roster — pass (1204ms)
87
+
88
+ 1 pass, 0 rot, 0 error (errors warn, never fail)
89
+ ```
90
+
91
+ ## The three results (and why there are three, not two)
92
+
93
+ - **pass** — every saved fact still appears at its source. All good.
94
+ - **rot** — a fact is gone from the source. The world changed; your data is
95
+ stale. This *fails the run* so you notice and fix the data.
96
+ - **error** — the website couldn't be reached (down, timeout, blocked).
97
+ This only *warns*. An unreachable site doesn't mean your facts are wrong,
98
+ and an alarm that cries wolf gets ignored — so outages never fail the build.
99
+
100
+ ## Running it automatically every week
101
+
102
+ If your project is on GitHub, add this file as
103
+ `.github/workflows/drift.yml` and GitHub will run the check every Wednesday
104
+ and email you if anything rotted:
105
+
58
106
  ```yaml
59
- # .github/workflows/drift.yml
107
+ name: Fact drift check
60
108
  on:
61
- schedule: [{ cron: '17 14 * * 3' }] # off-beat minute: GitHub drops :00 crons
109
+ schedule:
110
+ - cron: '17 14 * * 3' # Wednesdays, ~10am US Eastern
111
+ workflow_dispatch: # adds a manual "Run workflow" button
62
112
  jobs:
63
113
  drift:
64
114
  runs-on: ubuntu-latest
65
115
  steps:
66
- - uses: actions/checkout@v4
67
- - uses: actions/setup-node@v4
116
+ - uses: actions/checkout@v5
117
+ - uses: actions/setup-node@v5
68
118
  with: { node-version: '22' }
69
119
  - run: npm ci
70
120
  - run: npx stilltrue drift
71
121
  ```
72
122
 
73
- `source` is your code (fetch+parse, or LLM extraction — the comparison stays
74
- deterministic either way). `expect` is your curated file. Rot fails the run
75
- with one message per vanished fact; unreachable sources only warn.
123
+ ## The report
76
124
 
77
- ## Verify — staged acceptance for AI output
125
+ Add `--record` to save each run, then generate the web page:
78
126
 
79
- An ordered, short-circuiting pipeline: each stage is a pluggable judge — a
80
- Zod parse, a deterministic validator, or an LLM. Rejection is structured
81
- (`{ stage, messages[] }`, one issue per entry) and designed to be fed back to
82
- the generator for a retry. Stages that *throw* are infrastructure failures,
83
- not verdicts: `failPolicy: 'open'` (default) warns and continues — a judge
84
- outage must never block your app.
127
+ ```bash
128
+ npx stilltrue drift --record
129
+ npx stilltrue report
130
+ ```
85
131
 
86
- ```ts
87
- import { runVerify, zodStage, generateVerified, formatRejection } from 'stilltrue';
132
+ That writes `stilltrue-report.html` — open it in any browser. It shows a
133
+ timeline grid (one row per check, one column per run, green ✓ / red ✗ /
134
+ amber ⚠) and a log of everything that ever rotted, with details.
88
135
 
89
- const stages = [
90
- zodStage('parse_schema', AnswerSchema),
91
- { name: 'grounding', timeoutMs: 10_000, check: llmJudge }, // may return a revision
92
- { name: 'chase-sim', tier: 'ci', check: expensiveSimulation }, // skipped inline
93
- ];
136
+ ## One rule underneath everything
94
137
 
95
- // Request path:
96
- const result = await runVerify(stages, draft);
97
- publish(result.value); // draft, parsed draft, or the judge's rewrite
138
+ **AI may extract, but never decides.** The drift check itself uses no AI at
139
+ all — it fetches, reads, and compares text, the same way every time, with
140
+ no API key and no cost. Deterministic alarms are trustworthy alarms. (If
141
+ *your* fact-gathering step wants to use AI to pull structure out of a messy
142
+ page, that's fine — it happens inside your `source` function, and the
143
+ comparison stays mechanical.)
98
144
 
99
- // Generator loop (one automatic retry, then fall back — don't loop forever):
100
- const gen = await generateVerified({ generate: (feedback) => ask(model, prompt, feedback && formatRejection(feedback)), stages });
101
- ```
145
+ ## For developers: `verify`
146
+
147
+ `verify` is a library for teams shipping AI-generated answers: an ordered
148
+ series of checkpoints (a schema parse, a rules check, an AI judge — your
149
+ choice) that each answer must pass before publishing. Rejections say
150
+ exactly which checkpoint failed and why, in a format you can feed back to
151
+ the model for a retry. A broken checker never blocks your app — it fails
152
+ open. See the [project docs](docs/BRIEF.md) and the typed API in
153
+ `stilltrue`'s exports (`runVerify`, `zodStage`, `generateVerified`).
102
154
 
103
- ## Status
155
+ ## What about testing prompts?
104
156
 
105
- v0.4 — feature-complete for the 1.0 shape: `drift` and `report` run in
106
- production CI (AskGwinnett), `verify` runs in the production request path
107
- (AskGwinnett's pre-publish check sweep). Golden is deliberately out of
108
- scope — settled in [docs/GOLDEN.md](docs/GOLDEN.md).
157
+ That's a different job with a great existing tool:
158
+ [promptfoo](https://promptfoo.dev) tests your prompts; stilltrue tests your
159
+ facts. How they fit together: [docs/GOLDEN.md](docs/GOLDEN.md).
109
160
 
110
161
  ## License
111
162
 
112
- MIT
163
+ MIT — free to use, modify, and share.
package/dist/cli.js CHANGED
@@ -94,13 +94,31 @@ try {
94
94
  case 'report':
95
95
  process.exit(await report(rest));
96
96
  break;
97
+ case 'init': {
98
+ const { init } = await import("./init.js");
99
+ const { created, skipped } = await init(process.cwd());
100
+ for (const f of created)
101
+ console.log(`created ${f}`);
102
+ for (const f of skipped)
103
+ console.log(`kept existing ${f} (never overwritten)`);
104
+ if (created.length) {
105
+ console.log('\nNext steps:\n' +
106
+ ' 1. Edit stilltrue.config.mjs — point source at the real page(s) your facts came from\n' +
107
+ ' 2. Edit data/example-facts.json — or point the config at a facts file you already have\n' +
108
+ ' 3. Run: npx stilltrue drift\n' +
109
+ 'Picking good markers: docs/MARKERS.md in the stilltrue repo');
110
+ }
111
+ process.exit(0);
112
+ break;
113
+ }
97
114
  case 'golden':
98
115
  console.error('stilltrue deliberately has no golden runner — promptfoo tests your prompts; stilltrue tests your facts.\n' +
99
116
  'Use https://promptfoo.dev for prompt/answer regression evals. Doctrine + pairing guide: docs/GOLDEN.md');
100
117
  process.exit(1);
101
118
  break;
102
119
  default:
103
- console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>] [--record] [--history <path>]\n' +
120
+ console.log('usage: stilltrue init\n' +
121
+ ' stilltrue drift [--config <path>] [--only <names>] [--json <path>] [--record] [--history <path>]\n' +
104
122
  ' stilltrue report [--history <path>] [--out <path>] [--title <text>]');
105
123
  process.exit(command ? 1 : 0);
106
124
  }
package/dist/init.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export interface InitResult {
2
+ created: string[];
3
+ skipped: string[];
4
+ }
5
+ /** Write starter config + example data file. Never overwrites. */
6
+ export declare function init(dir: string): Promise<InitResult>;
package/dist/init.js ADDED
@@ -0,0 +1,78 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ const CONFIG_TEMPLATE = `// stilltrue configuration — one entry per fact-set you want watched.
5
+ // Docs: https://github.com/sg-bfoster/stilltrue#readme
6
+ // Picking good markers: https://github.com/sg-bfoster/stilltrue/blob/main/docs/MARKERS.md
7
+ import { defineStilltrue, corpus, json, surname } from 'stilltrue';
8
+
9
+ export default defineStilltrue({
10
+ drift: [
11
+ {
12
+ // A short id for this check — shows up in output and reports.
13
+ name: 'example-board-roster',
14
+
15
+ // WHERE THE TRUTH LIVES: the official page(s) your facts came from.
16
+ // corpus() fetches each page and strips it down to readable text.
17
+ // If one page is unreachable it warns; only if ALL are down does the
18
+ // check report an error (which never fails your build).
19
+ source: corpus([
20
+ 'https://example.org/board',
21
+ // 'https://example.org/about', // add more pages if facts span several
22
+ ]),
23
+
24
+ // WHAT YOU BELIEVE: read your saved facts file and pick out the
25
+ // "markers" — short, stable strings that must still appear on the
26
+ // page(s) above. Here: each member's last name, plus one number.
27
+ expect: json('./data/example-facts.json', (data) => [
28
+ ...data.members.map((m) => surname(m.name)),
29
+ \`\${data.totalSchools} schools\`,
30
+ ]),
31
+
32
+ // HOW TO COMPARE: 'contains-all' = every marker must appear in the
33
+ // fetched text (case-insensitive). Any missing marker = rot = the
34
+ // build fails and tells you which fact went stale.
35
+ compare: 'contains-all',
36
+ },
37
+ ],
38
+ });
39
+ `;
40
+ const DATA_TEMPLATE = `{
41
+ "_comment": "Example facts file - replace with your own data, or point the config at a file you already have.",
42
+ "members": [
43
+ { "name": "Dr. Jane Smith" },
44
+ { "name": "Robert Jones Jr." }
45
+ ],
46
+ "totalSchools": 12
47
+ }
48
+ `;
49
+ /** Write starter config + example data file. Never overwrites. */
50
+ export async function init(dir) {
51
+ const created = [];
52
+ const skipped = [];
53
+ const configPath = join(dir, 'stilltrue.config.mjs');
54
+ const existingConfig = [
55
+ 'stilltrue.config.mjs',
56
+ 'stilltrue.config.ts',
57
+ 'stilltrue.config.mts',
58
+ 'stilltrue.config.js',
59
+ 'stilltrue.config.cjs',
60
+ ].find((name) => existsSync(join(dir, name)));
61
+ if (existingConfig) {
62
+ skipped.push(existingConfig);
63
+ }
64
+ else {
65
+ await writeFile(configPath, CONFIG_TEMPLATE);
66
+ created.push('stilltrue.config.mjs');
67
+ }
68
+ const dataPath = join(dir, 'data', 'example-facts.json');
69
+ if (existsSync(dataPath)) {
70
+ skipped.push('data/example-facts.json');
71
+ }
72
+ else {
73
+ await mkdir(join(dir, 'data'), { recursive: true });
74
+ await writeFile(dataPath, DATA_TEMPLATE);
75
+ created.push('data/example-facts.json');
76
+ }
77
+ return { created, skipped };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stilltrue",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Your tests check your code. stilltrue checks your facts. CI checks for AI-in-the-loop apps: drift (curated facts vs live sources), golden (regression evals), verify (judge answers against sources).",
5
5
  "keywords": [
6
6
  "drift",
@@ -37,5 +37,13 @@
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=20"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/sg-bfoster/stilltrue.git"
44
+ },
45
+ "homepage": "https://github.com/sg-bfoster/stilltrue#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/sg-bfoster/stilltrue/issues"
40
48
  }
41
49
  }