@natjswenson/devlog 0.4.2 → 0.5.1
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/SKILL.md +223 -405
- package/bin/devlog.js +319 -203
- package/config.example.json +4 -0
- package/evals/budget.mjs +60 -0
- package/evals/fixtures/bad-post.md +33 -0
- package/evals/fixtures/good-post.md +118 -0
- package/evals/fixtures/irreproducible-post.md +71 -0
- package/evals/judge_post.mjs +113 -0
- package/evals/run_eval.mjs +95 -0
- package/lib/config_ops.mjs +58 -0
- package/lib/core.mjs +206 -0
- package/lib/lint_post.mjs +152 -0
- package/lib/publish_entry.mjs +71 -0
- package/lib/scan.mjs +229 -0
- package/package.json +4 -1
- package/skill-invariants.json +66 -0
package/config.example.json
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
"gitAuthor": "Your Name",
|
|
5
5
|
"githubUser": "yourusername",
|
|
6
6
|
"voicePath": "~/.claude/ghostwriter/voice",
|
|
7
|
+
"deepDive": {
|
|
8
|
+
"topicDomains": ["AI", "DevOps/SRE", "software engineering"],
|
|
9
|
+
"minSources": 3
|
|
10
|
+
},
|
|
7
11
|
"projects": [
|
|
8
12
|
{
|
|
9
13
|
"key": "midnight-side-quest",
|
package/evals/budget.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Cost control for live eval runs. Ported from ghostwriter's evals/budget.py:
|
|
2
|
+
// deliberately over-estimates, guards BEFORE any call that would exceed the
|
|
3
|
+
// cap, and forces mock mode when there is no API key so CI can never spend.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_SPEND = 0.50; // USD
|
|
6
|
+
|
|
7
|
+
// Conservative blended $/1K-token prices by model-family substring. These are
|
|
8
|
+
// intentionally pessimistic (treated as if every token cost the output rate).
|
|
9
|
+
const PRICE_PER_1K = [
|
|
10
|
+
[/haiku/i, 0.005],
|
|
11
|
+
[/sonnet/i, 0.018],
|
|
12
|
+
[/opus/i, 0.09],
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_PRICE_PER_1K = 0.02;
|
|
15
|
+
|
|
16
|
+
export function pricePer1k(model) {
|
|
17
|
+
for (const [re, price] of PRICE_PER_1K) {
|
|
18
|
+
if (re.test(model || '')) return price;
|
|
19
|
+
}
|
|
20
|
+
return DEFAULT_PRICE_PER_1K;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Over-estimate the cost of one `claude -p` judge call: a context baseline for
|
|
24
|
+
// the re-sent system prompt, ~4 chars/token for the prompt itself, plus the
|
|
25
|
+
// output budget.
|
|
26
|
+
export function estimateUsd(text, model, { outputTokens = 800, contextTokens = 6000 } = {}) {
|
|
27
|
+
const promptTokens = Math.ceil((text || '').length / 4);
|
|
28
|
+
const totalTokens = contextTokens + promptTokens + outputTokens;
|
|
29
|
+
return (totalTokens / 1000) * pricePer1k(model);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class BudgetExceeded extends Error {}
|
|
33
|
+
|
|
34
|
+
export class Budget {
|
|
35
|
+
constructor(maxSpend = DEFAULT_MAX_SPEND) {
|
|
36
|
+
this.maxSpend = maxSpend;
|
|
37
|
+
this.spent = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Call BEFORE spending: throws if the estimate would push cumulative spend
|
|
41
|
+
// over the cap. The cap is a hard ceiling, not advisory.
|
|
42
|
+
guard(estimate) {
|
|
43
|
+
if (this.spent + estimate > this.maxSpend) {
|
|
44
|
+
throw new BudgetExceeded(
|
|
45
|
+
`Refusing call: ~$${estimate.toFixed(3)} would push spend to `
|
|
46
|
+
+ `$${(this.spent + estimate).toFixed(3)} > cap $${this.maxSpend.toFixed(2)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
record(actual) {
|
|
52
|
+
this.spent += actual;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Mock is forced when there is no ANTHROPIC_API_KEY — CI has no key, so a
|
|
57
|
+
// forgotten --mock flag can never turn into a live spend there.
|
|
58
|
+
export function mockEnabled(flag) {
|
|
59
|
+
return Boolean(flag) || !process.env.ANTHROPIC_API_KEY;
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Release v1.4.0"
|
|
3
|
+
date: 2026-07-10
|
|
4
|
+
project: fixture
|
|
5
|
+
version: v1.4.0
|
|
6
|
+
tags: [update]
|
|
7
|
+
summary: "We shipped some updates."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
This release is a game-changer for our workflow. We revolutionized the retry logic and
|
|
13
|
+
made everything more robust, scalable, and future-proof.
|
|
14
|
+
|
|
15
|
+
## The Update
|
|
16
|
+
|
|
17
|
+
We updated the code. Here is some of it:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
def retry(fn):
|
|
21
|
+
return fn
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It's better now. No more issues. The system is faster. The system is cleaner. The system
|
|
25
|
+
is unstoppable.
|
|
26
|
+
|
|
27
|
+
## Sources
|
|
28
|
+
|
|
29
|
+
- [Some blog](https://example.com/blog) — general vibes
|
|
30
|
+
|
|
31
|
+
## Changelog
|
|
32
|
+
|
|
33
|
+
- update stuff ([abc1234](https://github.com/example/fixture/commit/abc1234))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Retries that don't stampede: exponential backoff with jitter in 40 lines"
|
|
3
|
+
date: 2026-07-10
|
|
4
|
+
project: fixture
|
|
5
|
+
version: v1.3.0
|
|
6
|
+
tags: [reliability, python, distributed-systems]
|
|
7
|
+
summary: "This release moved our flaky HTTP calls behind a retry wrapper. Here's how to build one with full jitter, and the two traps that bit me."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
v1.3.0 wraps every outbound HTTP call in a retry decorator with exponential backoff and
|
|
13
|
+
full jitter. The change itself is small; the interesting part is why naive retries make
|
|
14
|
+
outages worse, and how little code the correct version takes. This post walks the whole
|
|
15
|
+
build.
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
You need Python 3.10+ and `httpx`. No other dependencies — the retry logic is stdlib.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install httpx==0.27.0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Build the backoff schedule
|
|
26
|
+
|
|
27
|
+
Start with the delay calculation, isolated so you can unit-test it. Full jitter means:
|
|
28
|
+
sleep a uniform random amount between 0 and the exponential ceiling, which spreads
|
|
29
|
+
retrying clients across the whole window instead of synchronizing them into waves.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import random
|
|
33
|
+
|
|
34
|
+
def backoff_delay(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
|
|
35
|
+
"""Full-jitter delay for a zero-indexed attempt number."""
|
|
36
|
+
ceiling = min(cap, base * (2 ** attempt))
|
|
37
|
+
return random.uniform(0, ceiling)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Wrap it into a retry decorator
|
|
41
|
+
|
|
42
|
+
The decorator retries only on retryable failures (connection errors and 5xx), never on
|
|
43
|
+
4xx — a 404 will be a 404 no matter how many times you ask.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import functools
|
|
47
|
+
import time
|
|
48
|
+
import httpx
|
|
49
|
+
|
|
50
|
+
RETRYABLE_STATUS = {500, 502, 503, 504}
|
|
51
|
+
|
|
52
|
+
def with_retries(max_attempts: int = 5):
|
|
53
|
+
def decorator(fn):
|
|
54
|
+
@functools.wraps(fn)
|
|
55
|
+
def wrapper(*args, **kwargs):
|
|
56
|
+
for attempt in range(max_attempts):
|
|
57
|
+
try:
|
|
58
|
+
response = fn(*args, **kwargs)
|
|
59
|
+
if response.status_code not in RETRYABLE_STATUS:
|
|
60
|
+
return response
|
|
61
|
+
except httpx.TransportError:
|
|
62
|
+
if attempt == max_attempts - 1:
|
|
63
|
+
raise
|
|
64
|
+
time.sleep(backoff_delay(attempt))
|
|
65
|
+
return response
|
|
66
|
+
return wrapper
|
|
67
|
+
return decorator
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Use it
|
|
71
|
+
|
|
72
|
+
Apply it to any function that returns an `httpx.Response`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
@with_retries(max_attempts=5)
|
|
76
|
+
def fetch_profile(user_id: str) -> httpx.Response:
|
|
77
|
+
return httpx.get(f"https://api.example.com/users/{user_id}", timeout=5.0)
|
|
78
|
+
|
|
79
|
+
profile = fetch_profile("u_123")
|
|
80
|
+
print(profile.status_code) # 200 after up to 5 attempts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Verify it
|
|
84
|
+
|
|
85
|
+
Prove the schedule spreads load before you trust it. Run this against your copy of
|
|
86
|
+
`backoff_delay` — every delay must fall inside its window and the cap must hold:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
for attempt in range(10):
|
|
90
|
+
d = backoff_delay(attempt)
|
|
91
|
+
assert 0 <= d <= min(30.0, 0.5 * 2 ** attempt), (attempt, d)
|
|
92
|
+
print("schedule ok")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Expected output: `schedule ok`. To see the retry path itself, point `fetch_profile` at
|
|
96
|
+
`https://httpbin.org/status/503` — you should observe five attempts spaced by growing,
|
|
97
|
+
randomized delays, then the final 503 returned.
|
|
98
|
+
|
|
99
|
+
## Gotchas
|
|
100
|
+
|
|
101
|
+
- **Retrying 4xx responses.** My first version retried everything non-200. Symptom: a
|
|
102
|
+
bad auth token turned into 5 slow failures instead of 1 fast one, and our alert fired
|
|
103
|
+
on latency instead of auth. Escape: allowlist the retryable statuses (5xx + transport
|
|
104
|
+
errors) explicitly.
|
|
105
|
+
- **Equal jitter isn't enough under real outages.** I started with `ceiling/2 +
|
|
106
|
+
uniform(0, ceiling/2)`. Symptom: load tests showed retry waves still clustering at the
|
|
107
|
+
half-window mark. Escape: full jitter (`uniform(0, ceiling)`), which the AWS analysis
|
|
108
|
+
below shows keeps total calls lowest across client counts.
|
|
109
|
+
|
|
110
|
+
## Sources
|
|
111
|
+
|
|
112
|
+
- [Exponential Backoff And Jitter (AWS Architecture Blog)](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) — full vs. equal jitter comparison
|
|
113
|
+
- [httpx documentation](https://www.python-httpx.org/exceptions/) — TransportError hierarchy
|
|
114
|
+
- [Google SRE Book: Handling Overload](https://sre.google/sre-book/handling-overload/) — why synchronized retries amplify outages
|
|
115
|
+
|
|
116
|
+
## Changelog
|
|
117
|
+
|
|
118
|
+
- feat: retry wrapper with full jitter ([a1b2c3d](https://github.com/example/fixture/commit/a1b2c3d4))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "How our pipeline learned to validate itself end-to-end"
|
|
3
|
+
date: 2026-07-10
|
|
4
|
+
project: fixture
|
|
5
|
+
version: v2.1.0
|
|
6
|
+
tags: [testing, data-pipelines, python]
|
|
7
|
+
summary: "v2.1.0 added self-validating pipeline stages. A look at how the validation layer came together."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
v2.1.0 wires validation into every pipeline stage. Each stage now declares a schema, and
|
|
13
|
+
the runner checks outputs before they flow downstream. This post walks through how the
|
|
14
|
+
system fits together.
|
|
15
|
+
|
|
16
|
+
## The validation layer
|
|
17
|
+
|
|
18
|
+
The heart of it is the stage wrapper. It pulls the declared schema off the stage and
|
|
19
|
+
routes failures to our dead-letter handler:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
def validated(stage):
|
|
23
|
+
def wrapper(batch):
|
|
24
|
+
result = stage(batch)
|
|
25
|
+
schema = registry.schema_for(stage)
|
|
26
|
+
for row in result:
|
|
27
|
+
check_row(row, schema, on_error=dead_letter.route)
|
|
28
|
+
return result
|
|
29
|
+
return wrapper
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`registry` holds all our stage schemas and `dead_letter.route` sends bad rows to the
|
|
33
|
+
usual place. With that in place, the runner just wraps everything:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
pipeline = build_pipeline(config)
|
|
37
|
+
for stage in pipeline.stages:
|
|
38
|
+
stage.fn = validated(stage.fn)
|
|
39
|
+
run_pipeline(pipeline, source=events_source())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Watching it work
|
|
43
|
+
|
|
44
|
+
Once deployed, the validation layer immediately started catching issues. In our repo,
|
|
45
|
+
running the nightly job now prints:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
[validate] stage=enrich rows=48210 rejected=17 (0.04%)
|
|
49
|
+
[validate] stage=aggregate rows=48193 rejected=0
|
|
50
|
+
nightly: OK
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Those 17 rejected rows were exactly the malformed events we'd been chasing for weeks.
|
|
54
|
+
The dead-letter queue fills up with them and our dashboard graphs the rejection rate per
|
|
55
|
+
stage, which has already flattened out nicely since launch.
|
|
56
|
+
|
|
57
|
+
## Gotchas
|
|
58
|
+
|
|
59
|
+
- The main lesson here is philosophical: validation is a journey, not a destination. We
|
|
60
|
+
learned to think of schemas as living documents and to stay flexible about where
|
|
61
|
+
checking happens. Teams should find the balance that works for them.
|
|
62
|
+
|
|
63
|
+
## Sources
|
|
64
|
+
|
|
65
|
+
- [Great Expectations documentation](https://docs.greatexpectations.io/docs/) — data validation concepts
|
|
66
|
+
- [Designing Data-Intensive Applications](https://dataintensive.net/) — schema evolution background
|
|
67
|
+
- [Martin Fowler on ContractTest](https://martinfowler.com/bliki/ContractTest.html) — testing at boundaries
|
|
68
|
+
|
|
69
|
+
## Changelog
|
|
70
|
+
|
|
71
|
+
- feat: self-validating stages ([f9e8d7c](https://github.com/example/fixture/commit/f9e8d7c6))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Two-layer quality judge for generated posts.
|
|
2
|
+
// Layer 1 ($0, deterministic): lint-post findings are an automatic fail.
|
|
3
|
+
// Layer 2 ($, LLM): a cheap judge scores the post against the how-to contract
|
|
4
|
+
// and returns strict JSON. Mock mode substitutes a fixed passing score — it
|
|
5
|
+
// can catch contract violations (via layer 1) but NOT subtle quality drift;
|
|
6
|
+
// that is exactly what the irreproducible fixture documents.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { lintPost } from '../lib/lint_post.mjs';
|
|
9
|
+
import { estimateUsd } from './budget.mjs';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_JUDGE_MODEL = 'claude-haiku-4-5-20251001';
|
|
12
|
+
export const DEFAULT_MIN_SCORE = 7.0;
|
|
13
|
+
|
|
14
|
+
export const DIMENSIONS = [
|
|
15
|
+
'reproducibility', // could a stranger build this from the post alone?
|
|
16
|
+
'code_completeness', // no phantom fixtures; blocks compose into a runnable whole
|
|
17
|
+
'gotcha_quality', // concrete trap → symptom → escape, plausibly from real history
|
|
18
|
+
'citation_quality', // distinct reputable sources actually supporting the claims
|
|
19
|
+
'voice_fidelity', // authentic first-person, no AI tells
|
|
20
|
+
'scope_honesty', // title/summary sized to what actually shipped
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function buildJudgePrompt(post, voiceGuide = '') {
|
|
24
|
+
return `You are scoring a developer-blog release post against a strict "how-to guide" contract.
|
|
25
|
+
|
|
26
|
+
The contract: (1) REPRODUCIBILITY — a reader with no access to the author's repository can
|
|
27
|
+
follow the post and build the technique end-to-end (the "stranger test"); (2) CODE
|
|
28
|
+
COMPLETENESS — every symbol a code block references is defined in an earlier block or
|
|
29
|
+
explicitly stubbed; the blocks compose into a runnable whole, not fragments around an
|
|
30
|
+
essay; (3) GOTCHA QUALITY — the Gotchas section gives concrete trap → symptom → escape
|
|
31
|
+
items that read as genuinely experienced, not generic advice; (4) CITATION QUALITY —
|
|
32
|
+
distinct, reputable sources that actually support the specific claims made; (5) VOICE
|
|
33
|
+
FIDELITY — authentic first-person writing with no AI tells (no padded symmetry, no
|
|
34
|
+
hype, no filler)${voiceGuide ? ', judged against the voice guide below' : ''}; (6) SCOPE
|
|
35
|
+
HONESTY — the title and framing match what actually shipped (a single test file is not
|
|
36
|
+
"end-to-end").
|
|
37
|
+
|
|
38
|
+
Score each dimension 0-10 and give an overall 0-10 score (your holistic judgment, not an
|
|
39
|
+
average — a fatal reproducibility failure caps the overall at 5 even if prose is lovely).
|
|
40
|
+
|
|
41
|
+
Return ONLY a JSON object, no prose:
|
|
42
|
+
{"score": <0-10 float>, "dimensions": {"reproducibility": <0-10>, "code_completeness": <0-10>, "gotcha_quality": <0-10>, "citation_quality": <0-10>, "voice_fidelity": <0-10>, "scope_honesty": <0-10>}, "worst_problem": "<one sentence>"}
|
|
43
|
+
${voiceGuide ? `\n=== VOICE GUIDE ===\n${voiceGuide}\n` : ''}
|
|
44
|
+
=== POST ===
|
|
45
|
+
${post}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Extract and validate the first {...} JSON blob from the judge's reply.
|
|
49
|
+
export function parseJudgeResponse(text) {
|
|
50
|
+
const m = /\{[\s\S]*\}/.exec(text || '');
|
|
51
|
+
if (!m) throw new Error(`Judge returned no JSON object: ${String(text).slice(0, 200)}`);
|
|
52
|
+
const parsed = JSON.parse(m[0]);
|
|
53
|
+
if (typeof parsed.score !== 'number' || parsed.score < 0 || parsed.score > 10) {
|
|
54
|
+
throw new Error(`Judge score out of range: ${JSON.stringify(parsed.score)}`);
|
|
55
|
+
}
|
|
56
|
+
const dims = parsed.dimensions || {};
|
|
57
|
+
for (const d of DIMENSIONS) {
|
|
58
|
+
if (typeof dims[d] !== 'number' || dims[d] < 0 || dims[d] > 10) {
|
|
59
|
+
throw new Error(`Judge dimension "${d}" missing or out of range`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { score: parsed.score, dimensions: dims, worstProblem: parsed.worst_problem || null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Live call site — injectable so tests never touch the network.
|
|
66
|
+
export function callClaude(prompt, model) {
|
|
67
|
+
const r = spawnSync('claude', ['-p', prompt, '--model', model], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
timeout: 120_000,
|
|
71
|
+
});
|
|
72
|
+
if (r.status !== 0) throw new Error(`claude -p failed (${r.status}): ${(r.stderr || '').slice(0, 300)}`);
|
|
73
|
+
return r.stdout;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Score one post. Returns
|
|
77
|
+
// { pass, score, dimensions?, lintFindings, mocked, worstProblem? }
|
|
78
|
+
export function scorePost(content, {
|
|
79
|
+
mock = true,
|
|
80
|
+
model = DEFAULT_JUDGE_MODEL,
|
|
81
|
+
minScore = DEFAULT_MIN_SCORE,
|
|
82
|
+
minSources = 3,
|
|
83
|
+
voiceGuide = '',
|
|
84
|
+
budget = null,
|
|
85
|
+
runJudge = callClaude,
|
|
86
|
+
} = {}) {
|
|
87
|
+
// Layer 1: contract violations fail before any money is spent.
|
|
88
|
+
const lint = lintPost(content, { minSources });
|
|
89
|
+
if (!lint.ok) {
|
|
90
|
+
return { pass: false, score: 0, lintFindings: lint.findings, mocked: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (mock) {
|
|
94
|
+
// $0 smoke path: lint passed, so report a passing score. Cannot catch
|
|
95
|
+
// subtle quality drift — only a live judge can.
|
|
96
|
+
return { pass: true, score: 9.0, lintFindings: [], mocked: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prompt = buildJudgePrompt(content, voiceGuide);
|
|
100
|
+
const estimate = estimateUsd(prompt, model);
|
|
101
|
+
if (budget) budget.guard(estimate);
|
|
102
|
+
const raw = runJudge(prompt, model);
|
|
103
|
+
if (budget) budget.record(estimate); // conservative: record the estimate, not less
|
|
104
|
+
const judged = parseJudgeResponse(raw);
|
|
105
|
+
return {
|
|
106
|
+
pass: judged.score >= minScore,
|
|
107
|
+
score: judged.score,
|
|
108
|
+
dimensions: judged.dimensions,
|
|
109
|
+
worstProblem: judged.worstProblem,
|
|
110
|
+
lintFindings: [],
|
|
111
|
+
mocked: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Eval runner for the non-deterministic half of devlog: scores the golden
|
|
3
|
+
// fixtures with the two-layer judge and checks each lands on its expected
|
|
4
|
+
// side. Mock mode is $0 and runs in CI; live mode quotes its estimated spend
|
|
5
|
+
// up front and refuses to start over the cap.
|
|
6
|
+
//
|
|
7
|
+
// node evals/run_eval.mjs --mock
|
|
8
|
+
// node evals/run_eval.mjs --live [--max-spend 0.50] [--model <id>] [--min-score 7]
|
|
9
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import { Budget, DEFAULT_MAX_SPEND, estimateUsd, mockEnabled } from './budget.mjs';
|
|
14
|
+
import { scorePost, buildJudgePrompt, DEFAULT_JUDGE_MODEL, DEFAULT_MIN_SCORE, callClaude } from './judge_post.mjs';
|
|
15
|
+
|
|
16
|
+
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
17
|
+
|
|
18
|
+
// expect: which side of the gate the fixture must land on.
|
|
19
|
+
// liveOnly: passes the deterministic layer by design — only a live judge can
|
|
20
|
+
// fail it, so mock runs report it as skipped rather than vacuously green.
|
|
21
|
+
export const CASES = [
|
|
22
|
+
{ file: 'good-post.md', expect: 'pass', liveOnly: false },
|
|
23
|
+
{ file: 'bad-post.md', expect: 'fail', liveOnly: false },
|
|
24
|
+
{ file: 'irreproducible-post.md', expect: 'fail', liveOnly: true },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function runCases({ mock, model = DEFAULT_JUDGE_MODEL, minScore = DEFAULT_MIN_SCORE, budget, runJudge = callClaude, fixturesDir = FIXTURES_DIR }) {
|
|
28
|
+
return CASES.map((c) => {
|
|
29
|
+
if (mock && c.liveOnly) {
|
|
30
|
+
return { ...c, outcome: 'skipped', note: 'needs a live judge (passes the deterministic layer by design)' };
|
|
31
|
+
}
|
|
32
|
+
const content = readFileSync(join(fixturesDir, c.file), 'utf8');
|
|
33
|
+
const result = scorePost(content, { mock, model, minScore, budget, runJudge });
|
|
34
|
+
const got = result.pass ? 'pass' : 'fail';
|
|
35
|
+
return { ...c, outcome: got === c.expect ? 'ok' : 'UNEXPECTED', got, result };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function main() {
|
|
40
|
+
const { values } = parseArgs({
|
|
41
|
+
args: process.argv.slice(2),
|
|
42
|
+
options: {
|
|
43
|
+
mock: { type: 'boolean', default: false },
|
|
44
|
+
live: { type: 'boolean', default: false },
|
|
45
|
+
model: { type: 'string', default: DEFAULT_JUDGE_MODEL },
|
|
46
|
+
'min-score': { type: 'string', default: String(DEFAULT_MIN_SCORE) },
|
|
47
|
+
'max-spend': { type: 'string', default: String(DEFAULT_MAX_SPEND) },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const mock = mockEnabled(values.mock || !values.live);
|
|
52
|
+
const model = values.model;
|
|
53
|
+
const minScore = Number(values['min-score']);
|
|
54
|
+
const maxSpend = Number(values['max-spend']);
|
|
55
|
+
const budget = new Budget(maxSpend);
|
|
56
|
+
|
|
57
|
+
if (!mock) {
|
|
58
|
+
// Quote the worst-case spend before the first call and refuse over cap.
|
|
59
|
+
const liveCases = CASES.filter((c) => c.file !== 'bad-post.md'); // fails at the $0 layer
|
|
60
|
+
const estimate = liveCases.reduce((sum, c) => {
|
|
61
|
+
const content = readFileSync(join(FIXTURES_DIR, c.file), 'utf8');
|
|
62
|
+
return sum + estimateUsd(buildJudgePrompt(content), model);
|
|
63
|
+
}, 0);
|
|
64
|
+
console.log(`Estimated spend ~$${estimate.toFixed(3)} (cap $${maxSpend.toFixed(2)}, model ${model})`);
|
|
65
|
+
if (estimate > maxSpend) {
|
|
66
|
+
console.error('Estimated spend exceeds the cap — refusing to start. Raise --max-spend deliberately.');
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
console.log('Mock mode ($0). The live-only fixture is skipped; run --live to exercise the judge.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const results = runCases({ mock, model, minScore, budget });
|
|
74
|
+
let bad = 0;
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
if (r.outcome === 'skipped') {
|
|
77
|
+
console.log(`~ ${r.file}: skipped — ${r.note}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const mark = r.outcome === 'ok' ? '✓' : '✗';
|
|
81
|
+
if (r.outcome !== 'ok') bad += 1;
|
|
82
|
+
const score = r.result.score !== undefined ? ` score=${r.result.score}` : '';
|
|
83
|
+
const lint = r.result.lintFindings?.length ? ` lint=[${r.result.lintFindings.map((f) => f.rule).join(', ')}]` : '';
|
|
84
|
+
const worst = r.result.worstProblem ? ` worst="${r.result.worstProblem}"` : '';
|
|
85
|
+
console.log(`${mark} ${r.file}: expected ${r.expect}, got ${r.got}${score}${lint}${worst}`);
|
|
86
|
+
if (r.result.dimensions) {
|
|
87
|
+
console.log(` ${Object.entries(r.result.dimensions).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!mock) console.log(`Recorded spend (conservative estimates): $${budget.spent.toFixed(3)}`);
|
|
91
|
+
process.exit(bad === 0 ? 0 : 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
95
|
+
if (isMain) main();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pure, agent-drivable config mutations. Each function takes the current
|
|
2
|
+
// config object and returns a NEW validated config — the CLI layer owns
|
|
3
|
+
// reading/writing ~/.claude/skills/devlog/config.json atomically.
|
|
4
|
+
import { validateConfig, expandHome } from './core.mjs';
|
|
5
|
+
|
|
6
|
+
export function addProject(config, { key, path, remote, label, tagPrefix, pathFilter }) {
|
|
7
|
+
if (config.projects.some((p) => p.key === key)) {
|
|
8
|
+
throw new Error(`Project key "${key}" is already registered.`);
|
|
9
|
+
}
|
|
10
|
+
const project = { key, path: expandHome(path), remote };
|
|
11
|
+
if (label) project.label = label;
|
|
12
|
+
// Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
|
|
13
|
+
if (tagPrefix && tagPrefix !== 'v') project.tagPrefix = tagPrefix;
|
|
14
|
+
if (pathFilter) project.pathFilter = pathFilter;
|
|
15
|
+
return validateConfig({ ...config, projects: [...config.projects, project] });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function removeProject(config, key) {
|
|
19
|
+
const projects = config.projects.filter((p) => p.key !== key);
|
|
20
|
+
if (projects.length === config.projects.length) {
|
|
21
|
+
throw new Error(`No project with key "${key}". Registered: ${config.projects.map((p) => p.key).join(', ') || '(none)'}`);
|
|
22
|
+
}
|
|
23
|
+
return validateConfig({ ...config, projects });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fields settable via `devlog set <field> <value>`. Everything funnels through
|
|
27
|
+
// validateConfig, so a bad value can never be persisted.
|
|
28
|
+
const SETTERS = {
|
|
29
|
+
targetRepo: (c, v) => ({ ...c, targetRepo: v }),
|
|
30
|
+
branch: (c, v) => ({ ...c, branch: v }),
|
|
31
|
+
gitAuthor: (c, v) => ({ ...c, gitAuthor: v }),
|
|
32
|
+
githubUser: (c, v) => ({ ...c, githubUser: v }),
|
|
33
|
+
voicePath: (c, v) => (v === '' ? omit(c, 'voicePath') : { ...c, voicePath: expandHome(v) }),
|
|
34
|
+
'deepDive.minSources': (c, v) => {
|
|
35
|
+
const n = Number(v);
|
|
36
|
+
return { ...c, deepDive: { ...(c.deepDive || {}), minSources: n } };
|
|
37
|
+
},
|
|
38
|
+
'deepDive.topicDomains': (c, v) => {
|
|
39
|
+
const domains = v.split(',').map((t) => t.trim()).filter(Boolean);
|
|
40
|
+
return { ...c, deepDive: { ...(c.deepDive || {}), topicDomains: domains } };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const SETTABLE_FIELDS = Object.keys(SETTERS);
|
|
45
|
+
|
|
46
|
+
export function setField(config, field, value) {
|
|
47
|
+
const setter = SETTERS[field];
|
|
48
|
+
if (!setter) {
|
|
49
|
+
throw new Error(`Unknown field "${field}". Settable: ${SETTABLE_FIELDS.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== 'string') throw new Error('Value must be a string.');
|
|
52
|
+
return validateConfig(setter(config, value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function omit(obj, key) {
|
|
56
|
+
const { [key]: _dropped, ...rest } = obj;
|
|
57
|
+
return rest;
|
|
58
|
+
}
|