@natjswenson/devlog 0.4.1 → 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.
@@ -3,7 +3,11 @@
3
3
  "branch": "main",
4
4
  "gitAuthor": "Your Name",
5
5
  "githubUser": "yourusername",
6
- "voicePath": "~/.claude/skills/ghostwriter/voice",
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",
@@ -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
+ }