wavework 0.0.0-stage → 0.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/bin/wavework.mjs +7 -0
  5. package/examples/shipir.wavework.json +54 -0
  6. package/package.json +35 -4
  7. package/src/cli/args.mjs +28 -0
  8. package/src/cli/dashboard.mjs +41 -0
  9. package/src/cli/hooks.mjs +109 -0
  10. package/src/cli/init.mjs +95 -0
  11. package/src/cli/safe-path.mjs +63 -0
  12. package/src/cli/sync.mjs +269 -0
  13. package/src/cli/update.mjs +24 -0
  14. package/src/cli.mjs +78 -0
  15. package/src/config.mjs +313 -0
  16. package/src/context.mjs +133 -0
  17. package/src/render.mjs +148 -0
  18. package/src/targets.mjs +100 -0
  19. package/templates/agents/implementer.md +16 -0
  20. package/templates/dashboard/README.md +96 -0
  21. package/templates/dashboard/hooks/session-start.mjs +305 -0
  22. package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
  23. package/templates/dashboard/lib/ci.mjs +52 -0
  24. package/templates/dashboard/lib/derive.mjs +105 -0
  25. package/templates/dashboard/lib/settings.mjs +34 -0
  26. package/templates/dashboard/lib/tokens.mjs +21 -0
  27. package/templates/dashboard/pricing.json +11 -0
  28. package/templates/dashboard/public/app.css +134 -0
  29. package/templates/dashboard/public/app.js +260 -0
  30. package/templates/dashboard/public/default-theme.css +86 -0
  31. package/templates/dashboard/public/format.mjs +138 -0
  32. package/templates/dashboard/public/index.html +20 -0
  33. package/templates/dashboard/record.mjs +144 -0
  34. package/templates/dashboard/server.mjs +254 -0
  35. package/templates/dashboard-config.json +1 -0
  36. package/templates/scripts/red-green-check.sh +287 -0
  37. package/templates/scripts/red-green-check.test.sh +151 -0
  38. package/templates/settings/hooks.json +31 -0
  39. package/templates/skills/next-task/SKILL.md +77 -0
  40. package/templates/skills/next-task-batch/SKILL.md +27 -0
  41. package/templates/skills/next-task-beads/SKILL.md +29 -0
  42. package/templates/skills/next-task-branch/SKILL.md +19 -0
  43. package/templates/skills/next-task-code/SKILL.md +22 -0
  44. package/templates/skills/next-task-deps/SKILL.md +31 -0
  45. package/templates/skills/next-task-e2e/SKILL.md +35 -0
  46. package/templates/skills/next-task-findings/SKILL.md +65 -0
  47. package/templates/skills/next-task-finish/SKILL.md +43 -0
  48. package/templates/skills/next-task-implement/SKILL.md +34 -0
  49. package/templates/skills/next-task-plan/SKILL.md +52 -0
  50. package/templates/skills/next-task-review/SKILL.md +61 -0
  51. package/templates/skills/next-task-test/SKILL.md +30 -0
  52. package/templates/skills/next-task-write-tests/SKILL.md +34 -0
  53. package/templates/skills/text-to-task/SKILL.md +78 -0
  54. package/templates/skills/watch-ci/SKILL.md +42 -0
@@ -0,0 +1,269 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
4
+ } from 'node:fs';
5
+ import { basename, dirname, join, resolve, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { buildContext } from '../context.mjs';
8
+ import { render } from '../render.mjs';
9
+ import { safeResolve } from './safe-path.mjs';
10
+ import { mergeHooks, parseHookDefs } from './hooks.mjs';
11
+
12
+ export const LOCK_PATH = 'wavework.lock.json';
13
+ export const SETTINGS_PATH = 'settings.json';
14
+ export const NEW_SUFFIX = '.wavework-new';
15
+ export const PACKAGE_TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
16
+ const LOCK_FORMAT = 1;
17
+
18
+ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
19
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
20
+
21
+ /** Optional features whose hooks (`features.<name>` in hooks.json) the config switches on. */
22
+ export const enabledFeatures = (config) => (config.dashboard?.enabled ? ['dashboard'] : []);
23
+
24
+ /** Hook definitions: injected object, or templates/settings/hooks.json of the package; feature hooks only when the config enables them. */
25
+ export function loadHookDefs(injected, config) {
26
+ const json = injected ?? JSON.parse(readFileSync(join(PACKAGE_TEMPLATES, 'settings', 'hooks.json'), 'utf8'));
27
+ return parseHookDefs(json, config ? enabledFeatures(config) : []);
28
+ }
29
+
30
+ /** The default target provider is src/targets.mjs, imported lazily so init/update can be tested without it. */
31
+ export async function defaultTargets() {
32
+ try {
33
+ return (await import('../targets.mjs')).templateTargets;
34
+ } catch (e) {
35
+ if (e.code === 'ERR_MODULE_NOT_FOUND' && /targets\.mjs/.test(e.message)) {
36
+ throw new Error('this wavework installation is missing src/targets.mjs');
37
+ }
38
+ throw e;
39
+ }
40
+ }
41
+
42
+ const TARGET_PREFIX = '.claude/';
43
+ const TMP_SUFFIX = '.wavework-tmp';
44
+
45
+ /** `<file>.wavework-new` and the temp files are wavework's own; no target or lock entry may use those names. */
46
+ function refuseInternalSuffix(rel) {
47
+ if (rel.endsWith(NEW_SUFFIX) || rel.endsWith(TMP_SUFFIX)) {
48
+ throw new Error(`unsafe path ${JSON.stringify(rel)}: names ending in ${NEW_SUFFIX} or ${TMP_SUFFIX} are reserved`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Renders every target. Does not touch `.claude/`. The provider's `target` is relative to the repo root
54
+ * and must start with `.claude/`; it is stored (lock, reports) relative to `.claude/`. `vars` are merged
55
+ * over buildContext(config) (src/targets.mjs contract); `verbatim: true` skips rendering.
56
+ * @returns {{ target: string, content: string, hash: string, mode: number }[]}
57
+ */
58
+ export function renderTargets(config, provider, templatesDir, repoName) {
59
+ const seen = new Set();
60
+ const root = resolve(templatesDir);
61
+ const context = buildContext(config, { repoName });
62
+ return provider(config).map((t) => {
63
+ if (typeof t?.template !== 'string' || typeof t?.target !== 'string') throw new Error('invalid target definition (template and target must be strings)');
64
+ if (!t.target.startsWith(TARGET_PREFIX)) throw new Error(`unsafe path ${JSON.stringify(t.target)}: targets must be inside ${TARGET_PREFIX}`);
65
+ const target = t.target.slice(TARGET_PREFIX.length);
66
+ refuseInternalSuffix(target);
67
+ if (seen.has(target.toLowerCase())) throw new Error(`duplicate target ${JSON.stringify(t.target)} (names are compared case-insensitively)`);
68
+ seen.add(target.toLowerCase());
69
+ const file = resolve(root, t.template);
70
+ if (!file.startsWith(root + sep)) throw new Error(`unsafe template path ${JSON.stringify(t.template)}`);
71
+ // `verbatim` targets (the dashboard runtime) are copied byte for byte: they are JavaScript and CSS, not templates.
72
+ const text = readFileSync(file, 'utf8');
73
+ const content = t.verbatim === true ? text : render(text, { ...context, ...(t.vars ?? {}) }, t.template);
74
+ return { target, content, hash: sha256(content), mode: t.mode ?? 0o644 };
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Reads and validates the lock. Every path in it is checked like a template target: a tampered lock
80
+ * must not make update touch anything outside `.claude/`.
81
+ * @returns {{ version: string, files: Record<string, string>, hooks: { event: string, entry: object }[] } | null} null when absent
82
+ */
83
+ export function readLock(claudeDir) {
84
+ const file = safeResolve(claudeDir, LOCK_PATH, { allowReserved: true });
85
+ let text;
86
+ try {
87
+ text = readFileSync(file, 'utf8');
88
+ } catch (e) {
89
+ if (e.code === 'ENOENT') return null;
90
+ throw new Error(`cannot read lock file ${file}: ${e.message}`);
91
+ }
92
+ const bad = (why) => new Error(`invalid lock file ${file}: ${why}`);
93
+ let lock;
94
+ try {
95
+ lock = JSON.parse(text);
96
+ } catch (e) {
97
+ throw bad(`not valid JSON (${e.message})`);
98
+ }
99
+ if (!isObject(lock)) throw bad('expected an object');
100
+ if (lock.lockVersion !== LOCK_FORMAT) throw bad(`unsupported lockVersion ${JSON.stringify(lock.lockVersion)}`);
101
+ if (typeof lock.version !== 'string') throw bad('"version" must be a string');
102
+ if (!isObject(lock.files)) throw bad('"files" must be an object');
103
+ for (const [rel, hash] of Object.entries(lock.files)) {
104
+ if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) throw bad(`files.${rel}: expected a sha256 hex digest`);
105
+ try {
106
+ refuseInternalSuffix(rel);
107
+ safeResolve(claudeDir, rel);
108
+ } catch (e) {
109
+ throw bad(e.message);
110
+ }
111
+ }
112
+ if (!Array.isArray(lock.hooks)) throw bad('"hooks" must be an array');
113
+ lock.hooks.forEach((h, i) => {
114
+ if (!isObject(h) || typeof h.event !== 'string' || !isObject(h.entry)) throw bad(`hooks[${i}]: expected { event, entry }`);
115
+ });
116
+ return lock;
117
+ }
118
+
119
+ /** sha256 of the regular file at `abs`, undefined when missing. */
120
+ function diskHash(abs, rel) {
121
+ let st;
122
+ try {
123
+ st = lstatSync(abs);
124
+ } catch (e) {
125
+ if (e.code === 'ENOENT') return undefined;
126
+ throw e;
127
+ }
128
+ if (!st.isFile()) throw new Error(`${rel} exists but is not a regular file`);
129
+ return sha256(readFileSync(abs));
130
+ }
131
+
132
+ /**
133
+ * Decides what to do for every file and for settings.json. Reads, never writes.
134
+ * Throws on any unsafe path or invalid input, so a failing plan leaves the repo untouched.
135
+ *
136
+ * @param {object} p
137
+ * @param {string} p.claudeDir
138
+ * @param {ReturnType<typeof renderTargets>} p.rendered
139
+ * @param {{ files: Record<string, string>, hooks: object[] }} p.lock `{ files: {}, hooks: [] }` for init
140
+ * @param {{ event: string, entry: object }[]} p.defs
141
+ */
142
+ export function plan({ claudeDir, rendered, lock, defs }) {
143
+ const actions = [];
144
+ const files = {};
145
+ const wanted = new Set(rendered.map((t) => t.target));
146
+
147
+ for (const t of rendered) {
148
+ const abs = safeResolve(claudeDir, t.target);
149
+ const disk = diskHash(abs, t.target);
150
+ const locked = lock.files[t.target];
151
+ const write = (kind) => { actions.push({ kind, target: t.target, abs, content: t.content, mode: t.mode }); files[t.target] = t.hash; };
152
+ if (disk === undefined) {
153
+ write(locked === undefined ? 'create' : 'restore');
154
+ } else if (disk === t.hash) {
155
+ files[t.target] = t.hash; // already what we would write: adopt
156
+ if ((lstatSync(abs).mode & 0o111) !== (t.mode & 0o111)) actions.push({ kind: 'mode', target: t.target, abs, mode: t.mode });
157
+ } else if (locked !== undefined && disk === locked) {
158
+ write('update'); // untouched since the last run
159
+ } else {
160
+ // Edited locally (or an unknown file sits there). The old lock hash stays, so it is reported until resolved.
161
+ if (locked !== undefined) files[t.target] = locked;
162
+ if (locked !== undefined && t.hash === locked) continue; // nothing new upstream, nothing to merge
163
+ const newAbs = safeResolve(claudeDir, `${t.target}${NEW_SUFFIX}`);
164
+ actions.push({ kind: 'review', target: t.target, newTarget: `${t.target}${NEW_SUFFIX}`, newAbs, content: t.content });
165
+ }
166
+ }
167
+
168
+ for (const [rel, locked] of Object.entries(lock.files)) {
169
+ if (wanted.has(rel)) continue;
170
+ const abs = safeResolve(claudeDir, rel);
171
+ const disk = diskHash(abs, rel);
172
+ if (disk === undefined) continue; // gone already
173
+ if (disk === locked) {
174
+ actions.push({ kind: 'delete', target: rel, abs });
175
+ } else {
176
+ files[rel] = locked;
177
+ actions.push({ kind: 'review-removed', target: rel });
178
+ }
179
+ }
180
+
181
+ const settingsAbs = safeResolve(claudeDir, SETTINGS_PATH, { allowReserved: true });
182
+ let current = {};
183
+ let existed = true;
184
+ try {
185
+ const text = readFileSync(settingsAbs, 'utf8');
186
+ try {
187
+ current = JSON.parse(text);
188
+ } catch (e) {
189
+ throw new Error(`${settingsAbs} is not valid JSON (${e.message}); fix it or remove it, nothing was changed`);
190
+ }
191
+ if (!isObject(current)) throw new Error(`${settingsAbs}: expected a JSON object`);
192
+ } catch (e) {
193
+ if (e.code !== 'ENOENT') throw e;
194
+ existed = false;
195
+ }
196
+ const merged = mergeHooks(current, defs, lock.hooks);
197
+ const notes = merged.lost.map((h) => `hook ${h.event}: owned entry changed locally, not re-added`);
198
+ const changed = JSON.stringify(merged.settings) !== JSON.stringify(current) || (!existed && Object.keys(merged.settings).length > 0);
199
+ if (changed) actions.push({ kind: 'settings', abs: settingsAbs, content: `${JSON.stringify(merged.settings, null, 2)}\n` });
200
+
201
+ return { actions, notes, files, hooks: merged.owned, review: actions.filter((a) => a.kind.startsWith('review')).length };
202
+ }
203
+
204
+ /** Writes via a temp file and rename, so a symlink that appeared meanwhile is replaced, never written through. */
205
+ function writeAtomic(abs, content, mode) {
206
+ mkdirSync(dirname(abs), { recursive: true });
207
+ const tmp = join(dirname(abs), `.${basename(abs)}${TMP_SUFFIX}`);
208
+ try {
209
+ unlinkSync(tmp);
210
+ } catch (e) {
211
+ if (e.code !== 'ENOENT') throw e;
212
+ }
213
+ writeFileSync(tmp, content, { flag: 'wx', mode });
214
+ chmodSync(tmp, mode);
215
+ renameSync(tmp, abs);
216
+ }
217
+
218
+ function pruneEmptyDirs(claudeDir, from) {
219
+ let dir = dirname(from);
220
+ while (dir.startsWith(resolve(claudeDir) + sep)) {
221
+ try {
222
+ rmdirSync(dir);
223
+ } catch {
224
+ return; // not empty (or gone): stop
225
+ }
226
+ dir = dirname(dir);
227
+ }
228
+ }
229
+
230
+ /** Executes a plan and writes the lock last. */
231
+ export function apply(claudeDir, result, version) {
232
+ for (const a of result.actions) {
233
+ if (a.kind === 'create' || a.kind === 'update' || a.kind === 'restore') writeAtomic(a.abs, a.content, a.mode);
234
+ else if (a.kind === 'mode') chmodSync(a.abs, a.mode);
235
+ else if (a.kind === 'review') writeAtomic(a.newAbs, a.content, 0o644);
236
+ else if (a.kind === 'delete') {
237
+ unlinkSync(a.abs);
238
+ pruneEmptyDirs(claudeDir, a.abs);
239
+ } else if (a.kind === 'settings') writeAtomic(a.abs, a.content, 0o644);
240
+ }
241
+ const files = Object.fromEntries(Object.entries(result.files).sort(([a], [b]) => (a < b ? -1 : 1)));
242
+ const lock = { lockVersion: LOCK_FORMAT, version, files, hooks: result.hooks };
243
+ writeAtomic(safeResolve(claudeDir, LOCK_PATH, { allowReserved: true }), `${JSON.stringify(lock, null, 2)}\n`, 0o644);
244
+ }
245
+
246
+ const LABEL = {
247
+ create: 'create ', update: 'update ', restore: 'restore', delete: 'delete ', settings: 'update ', mode: 'mode ',
248
+ };
249
+
250
+ /** Prints one short line per change; returns the exit code (2 when files need review). */
251
+ export function report(result, stdout, { dryRun, done }) {
252
+ const prefix = dryRun ? 'dry-run: ' : '';
253
+ const counts = {};
254
+ for (const a of result.actions) {
255
+ counts[a.kind] = (counts[a.kind] ?? 0) + 1;
256
+ if (a.kind === 'review') {
257
+ stdout.write(`${prefix}review ${a.target}: edited locally, kept; new version in ${a.newTarget}\n`);
258
+ } else if (a.kind === 'review-removed') {
259
+ stdout.write(`${prefix}review ${a.target}: removed upstream but edited locally, kept\n`);
260
+ } else {
261
+ stdout.write(`${prefix}${LABEL[a.kind]} ${a.kind === 'settings' ? 'settings.json (hooks)' : a.target}\n`);
262
+ }
263
+ }
264
+ for (const note of result.notes) stdout.write(`${prefix}${note}\n`);
265
+ if (result.review > 0) stdout.write(`${prefix}${result.review} file(s) edited locally need review: merge the .wavework-new files, then delete them\n`);
266
+ if (result.actions.length === 0 && result.notes.length === 0) stdout.write(`${prefix}nothing to do\n`);
267
+ else if (!dryRun) stdout.write(`${done}\n`);
268
+ return result.review > 0 ? 2 : 0;
269
+ }
@@ -0,0 +1,24 @@
1
+ import { basename, join, resolve } from 'node:path';
2
+ import { loadConfig } from '../config.mjs';
3
+ import {
4
+ apply, defaultTargets, loadHookDefs, PACKAGE_TEMPLATES, plan, readLock, renderTargets, report,
5
+ } from './sync.mjs';
6
+
7
+ /**
8
+ * `wavework update [--dry-run]`: re-renders with the installed version and the current config and
9
+ * syncs `.claude/` per the lock (see docs/lock.md). Never modifies `.claude/wavework.json`.
10
+ *
11
+ * @returns {Promise<number>} exit code
12
+ */
13
+ export async function update(args, ctx) {
14
+ const claudeDir = join(ctx.cwd, '.claude');
15
+ const lock = readLock(claudeDir);
16
+ if (!lock) throw new Error('no .claude/wavework.lock.json: run `wavework init` first');
17
+ const config = loadConfig(ctx.cwd);
18
+ const provider = ctx.targets ?? await defaultTargets();
19
+ const rendered = renderTargets(config, provider, ctx.templatesDir ?? PACKAGE_TEMPLATES, basename(resolve(ctx.cwd)));
20
+ const result = plan({ claudeDir, rendered, lock, defs: loadHookDefs(ctx.hooks, config) });
21
+ const dryRun = args['--dry-run'] === true;
22
+ if (!dryRun) apply(claudeDir, result, ctx.version);
23
+ return report(result, ctx.stdout, { dryRun, done: `wavework ${ctx.version}: updated .claude/` });
24
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { init } from './cli/init.mjs';
4
+ import { update } from './cli/update.mjs';
5
+ import { dashboard } from './cli/dashboard.mjs';
6
+ import { parseArgs, UsageError } from './cli/args.mjs';
7
+
8
+ const pkg = JSON.parse(
9
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
10
+ );
11
+
12
+ export const USAGE = `Usage: wavework <command> [options]
13
+
14
+ Commands:
15
+ init Set up .claude/ in the current repository [--config <file>] [--dry-run]
16
+ update Re-render .claude/ from the installed version [--dry-run]
17
+ dashboard Start the pipeline dashboard of this repository (installed by init)
18
+
19
+ Options:
20
+ -v, --version Print the version
21
+ -h, --help Print this help
22
+ `;
23
+
24
+ const COMMANDS = {
25
+ init: { run: init, options: { '--config': 'value', '--dry-run': 'flag' } },
26
+ update: { run: update, options: { '--dry-run': 'flag' } },
27
+ dashboard: { run: dashboard, options: {} },
28
+ };
29
+
30
+ /**
31
+ * Runs the CLI with the given arguments (without node and script path).
32
+ * Writes to the provided streams and returns the exit code; does not exit the process.
33
+ * Exit codes: 0 ok, 1 error, 2 done, but locally edited files need review.
34
+ *
35
+ * `io.cwd` (default: process.cwd()) is the repository root. `io.targets`, `io.templatesDir` and `io.hooks`
36
+ * replace the packaged target list, templates directory and hook definitions; `io.env` and `io.spawn` replace the
37
+ * environment and child_process.spawn of `dashboard` (tests).
38
+ *
39
+ * @param {string[]} argv
40
+ * @param {{ stdout: { write(s: string): unknown }, stderr: { write(s: string): unknown }, cwd?: string, targets?: Function, templatesDir?: string, hooks?: object }} io
41
+ * @returns {Promise<number>}
42
+ */
43
+ export async function run(argv, io) {
44
+ const { stdout, stderr } = io;
45
+ const [command, ...rest] = argv;
46
+
47
+ if (command === undefined || command === '--help' || command === '-h') {
48
+ stdout.write(USAGE);
49
+ return 0;
50
+ }
51
+ if (command === '--version' || command === '-v') {
52
+ stdout.write(`${pkg.version}\n`);
53
+ return 0;
54
+ }
55
+
56
+ const spec = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
57
+ if (!spec) {
58
+ stderr.write(`wavework: unknown command: ${command}\n\n${USAGE}`);
59
+ return 1;
60
+ }
61
+ try {
62
+ const args = parseArgs(rest, spec.options);
63
+ return await spec.run(args, {
64
+ cwd: io.cwd ?? process.cwd(),
65
+ stdout,
66
+ version: pkg.version,
67
+ targets: io.targets,
68
+ templatesDir: io.templatesDir,
69
+ hooks: io.hooks,
70
+ env: io.env,
71
+ spawn: io.spawn,
72
+ });
73
+ } catch (e) {
74
+ if (e instanceof UsageError) stderr.write(`wavework ${command}: ${e.message}\n\n${USAGE}`);
75
+ else stderr.write(`wavework ${command}: ${e.message}\n`);
76
+ return 1;
77
+ }
78
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,313 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** Location of the config file, relative to the repository root. */
5
+ export const CONFIG_PATH = '.claude/wavework.json';
6
+
7
+ /** Defaults for every optional field with a default. Never mutated; results get deep copies. */
8
+ export const DEFAULTS = Object.freeze({
9
+ worktree: Object.freeze({
10
+ pathPattern: '../{repo}-{unit}',
11
+ branchPattern: '{prefix}/{unit}-{slug}',
12
+ }),
13
+ models: Object.freeze({
14
+ planning: 'opus',
15
+ orchestration: 'opus',
16
+ implementers: Object.freeze({
17
+ default: 'sonnet',
18
+ rules: Object.freeze({
19
+ haiku: 'Small, well-specified, low-risk changes: boilerplate, renames, config tweaks, docs, straightforward tests.',
20
+ sonnet: 'Everything else: multi-file features, non-trivial logic, debugging, refactors. When unsure, use sonnet.',
21
+ }),
22
+ }),
23
+ }),
24
+ tracker: Object.freeze({ kind: 'beads' }),
25
+ findings: Object.freeze({
26
+ epicTitles: Object.freeze({
27
+ security: 'Findings: Security & authorization',
28
+ bugs: 'Findings: Bugs & correctness',
29
+ tests: 'Findings: Tests & CI stability',
30
+ quality: 'Findings: Code quality, UI & docs',
31
+ }),
32
+ }),
33
+ attribution: Object.freeze({ allowAiTrailer: false }),
34
+ dashboard: Object.freeze({ enabled: true, port: 4690 }),
35
+ });
36
+
37
+ const PATH_PLACEHOLDERS = ['repo', 'unit'];
38
+ const BRANCH_PLACEHOLDERS = ['prefix', 'unit', 'slug'];
39
+ const TRACKER_KINDS = ['beads'];
40
+
41
+ const clone = (v) => structuredClone(v);
42
+
43
+ function typeName(v) {
44
+ if (v === null) return 'null';
45
+ if (Array.isArray(v)) return 'array';
46
+ return typeof v;
47
+ }
48
+
49
+ const isObject = (v) => typeName(v) === 'object';
50
+ const join_ = (base, key) => (base ? `${base}.${key}` : key);
51
+
52
+ // Each helper returns the validated value (or undefined when invalid/absent) and pushes problems.
53
+
54
+ function str(v, path, errs) {
55
+ if (typeof v === 'string' && v.trim() !== '') return v;
56
+ errs.push(`${path}: expected a non-empty string, got ${typeName(v)}`);
57
+ return undefined;
58
+ }
59
+
60
+ function bool(v, path, errs) {
61
+ if (typeof v === 'boolean') return v;
62
+ errs.push(`${path}: expected a boolean, got ${typeName(v)}`);
63
+ return undefined;
64
+ }
65
+
66
+ function strArray(v, path, errs, { minItems = 0, item = str } = {}) {
67
+ if (!Array.isArray(v)) {
68
+ errs.push(`${path}: expected an array, got ${typeName(v)}`);
69
+ return undefined;
70
+ }
71
+ if (v.length < minItems) errs.push(`${path}: must contain at least ${minItems} item(s)`);
72
+ return v.map((entry, i) => item(entry, `${path}[${i}]`, errs));
73
+ }
74
+
75
+ function port(v, path, errs) {
76
+ if (Number.isInteger(v) && v >= 1024 && v <= 65535) return v;
77
+ errs.push(`${path}: expected an integer from 1024 to 65535, got ${JSON.stringify(v)}`);
78
+ return undefined;
79
+ }
80
+
81
+ /** Path prefix relative to the repo root: no leading slash, no `..` segments, no `.` segments; `.` alone means the whole repo. */
82
+ function relativePrefix(v, path, errs) {
83
+ if (str(v, path, errs) === undefined) return undefined;
84
+ if (v.startsWith('/')) errs.push(`${path}: must be relative to the repo root (no leading /)`);
85
+ const segments = v.split(/[\\/]/);
86
+ if (segments.includes('..')) errs.push(`${path}: must not contain .. segments`);
87
+ if (v !== '.' && segments.includes('.')) errs.push(`${path}: must not contain "." segments (use "." alone for the whole repo)`);
88
+ return v;
89
+ }
90
+
91
+ /** Checks that `v` is an object with only `allowed` keys; returns it or undefined. */
92
+ function obj(v, path, errs, allowed) {
93
+ if (!isObject(v)) {
94
+ errs.push(`${path || '(root)'}: expected an object, got ${typeName(v)}`);
95
+ return undefined;
96
+ }
97
+ for (const key of Object.keys(v)) {
98
+ if (!allowed.includes(key)) errs.push(`${join_(path, key)}: unknown key (allowed: ${allowed.join(', ')})`);
99
+ }
100
+ return v;
101
+ }
102
+
103
+ function pattern(v, path, errs, allowed) {
104
+ if (str(v, path, errs) === undefined) return undefined;
105
+ for (const [, name] of v.matchAll(/\{([^{}]*)\}/g)) {
106
+ if (!allowed.includes(name)) {
107
+ errs.push(`${path}: unknown placeholder {${name}} (allowed: ${allowed.map((p) => `{${p}}`).join(', ')})`);
108
+ }
109
+ }
110
+ if (/[{}]/.test(v.replace(/\{[^{}]*\}/g, ''))) errs.push(`${path}: unbalanced or stray brace`);
111
+ if (!v.includes('{unit}')) errs.push(`${path}: must contain {unit}`);
112
+ return v;
113
+ }
114
+
115
+ /** Reads `key` of `o`: calls `check` when present, otherwise the default (or "required" error). */
116
+ function field(o, key, path, errs, check, { fallback, required = false } = {}) {
117
+ if (!(key in o)) {
118
+ if (required) errs.push(`${join_(path, key)}: required`);
119
+ return fallback === undefined ? undefined : clone(fallback);
120
+ }
121
+ const value = check(o[key], join_(path, key), errs);
122
+ return value === undefined ? fallback && clone(fallback) : value;
123
+ }
124
+
125
+ function stringMap(v, path, errs) {
126
+ if (!obj(v, path, errs, Object.keys(v ?? {}))) return undefined;
127
+ const out = {};
128
+ for (const [k, val] of Object.entries(v)) {
129
+ if (k === '__proto__') {
130
+ errs.push(`${path}.${k}: reserved key`);
131
+ continue;
132
+ }
133
+ out[k] = str(val, `${path}.${k}`, errs);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function worktree(v, path, errs) {
139
+ if (!obj(v, path, errs, ['pathPattern', 'branchPattern'])) return undefined;
140
+ return {
141
+ pathPattern: field(v, 'pathPattern', path, errs, (x, p, e) => pattern(x, p, e, PATH_PLACEHOLDERS), { fallback: DEFAULTS.worktree.pathPattern }),
142
+ branchPattern: field(v, 'branchPattern', path, errs, (x, p, e) => pattern(x, p, e, BRANCH_PLACEHOLDERS), { fallback: DEFAULTS.worktree.branchPattern }),
143
+ };
144
+ }
145
+
146
+ function area(v, path, errs) {
147
+ const keys = ['name', 'dir', 'whenPaths', 'focused', 'full', 'lint', 'build', 'e2e'];
148
+ if (!obj(v, path, errs, keys)) return undefined;
149
+ const out = {
150
+ name: field(v, 'name', path, errs, str, { required: true }),
151
+ dir: field(v, 'dir', path, errs, relativePrefix, { required: true }),
152
+ whenPaths: field(v, 'whenPaths', path, errs, (x, p, e) => strArray(x, p, e, { minItems: 1, item: relativePrefix }), { required: true }),
153
+ focused: field(v, 'focused', path, errs, str, { required: true }),
154
+ full: field(v, 'full', path, errs, str, { required: true }),
155
+ };
156
+ for (const optional of ['lint', 'build', 'e2e']) {
157
+ if (optional in v) out[optional] = str(v[optional], join_(path, optional), errs);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function checks(v, path, errs) {
163
+ if (!Array.isArray(v)) {
164
+ errs.push(`${path}: expected an array, got ${typeName(v)}`);
165
+ return undefined;
166
+ }
167
+ if (v.length === 0) errs.push(`${path}: must contain at least one area`);
168
+ const seen = new Set();
169
+ return v.map((item, i) => {
170
+ const result = area(item, `${path}[${i}]`, errs);
171
+ if (result?.name !== undefined) {
172
+ if (seen.has(result.name)) errs.push(`${path}[${i}].name: duplicate area name "${result.name}"`);
173
+ seen.add(result.name);
174
+ }
175
+ return result;
176
+ });
177
+ }
178
+
179
+ function models(v, path, errs) {
180
+ if (!obj(v, path, errs, ['planning', 'orchestration', 'implementers'])) return undefined;
181
+ const impl = (x, p, e) => {
182
+ if (!obj(x, p, e, ['default', 'rules'])) return undefined;
183
+ return {
184
+ default: field(x, 'default', p, e, str, { fallback: DEFAULTS.models.implementers.default }),
185
+ rules: field(x, 'rules', p, e, stringMap, { fallback: DEFAULTS.models.implementers.rules }),
186
+ };
187
+ };
188
+ return {
189
+ planning: field(v, 'planning', path, errs, str, { fallback: DEFAULTS.models.planning }),
190
+ orchestration: field(v, 'orchestration', path, errs, str, { fallback: DEFAULTS.models.orchestration }),
191
+ implementers: field(v, 'implementers', path, errs, impl, { fallback: DEFAULTS.models.implementers }),
192
+ };
193
+ }
194
+
195
+ function tracker(v, path, errs) {
196
+ if (!obj(v, path, errs, ['kind'])) return undefined;
197
+ const kind = field(v, 'kind', path, errs, (x, p, e) => {
198
+ if (TRACKER_KINDS.includes(x)) return x;
199
+ e.push(`${p}: must be ${TRACKER_KINDS.map((k) => `"${k}"`).join(' or ')}, got ${JSON.stringify(x)}`);
200
+ return undefined;
201
+ }, { fallback: DEFAULTS.tracker.kind });
202
+ return { kind };
203
+ }
204
+
205
+ function codeMap(v, path, errs) {
206
+ if (!obj(v, path, errs, ['path'])) return undefined;
207
+ return { path: field(v, 'path', path, errs, relativePrefix, { required: true }) };
208
+ }
209
+
210
+ function uiSkill(v, path, errs) {
211
+ if (!obj(v, path, errs, ['name', 'appliesTo'])) return undefined;
212
+ return {
213
+ name: field(v, 'name', path, errs, str, { required: true }),
214
+ appliesTo: field(v, 'appliesTo', path, errs, (x, p, e) => strArray(x, p, e, { minItems: 1, item: relativePrefix }), { required: true }),
215
+ };
216
+ }
217
+
218
+ function findings(v, path, errs) {
219
+ if (!obj(v, path, errs, ['epicTitles'])) return undefined;
220
+ const titles = field(v, 'epicTitles', path, errs, stringMap);
221
+ // User-given titles override the defaults per category; other categories keep their default.
222
+ return { epicTitles: { ...clone(DEFAULTS.findings.epicTitles), ...(titles ?? {}) } };
223
+ }
224
+
225
+ function attribution(v, path, errs) {
226
+ if (!obj(v, path, errs, ['allowAiTrailer'])) return undefined;
227
+ return {
228
+ allowAiTrailer: field(v, 'allowAiTrailer', path, errs, bool, { fallback: DEFAULTS.attribution.allowAiTrailer }),
229
+ };
230
+ }
231
+
232
+ function tokensFile(v, path, errs) {
233
+ if (relativePrefix(v, path, errs) === undefined) return undefined;
234
+ if (v.includes('\\')) errs.push(`${path}: must not contain a backslash`);
235
+ if (!v.endsWith('.css')) errs.push(`${path}: must end in .css`);
236
+ return v;
237
+ }
238
+
239
+ function dashboard(v, path, errs) {
240
+ if (!obj(v, path, errs, ['enabled', 'port', 'tokensFile'])) return undefined;
241
+ const out = {
242
+ enabled: field(v, 'enabled', path, errs, bool, { fallback: DEFAULTS.dashboard.enabled }),
243
+ port: field(v, 'port', path, errs, port, { fallback: DEFAULTS.dashboard.port }),
244
+ };
245
+ if ('tokensFile' in v) out.tokensFile = tokensFile(v.tokensFile, join_(path, 'tokensFile'), errs);
246
+ return out;
247
+ }
248
+
249
+ const TOP_LEVEL = ['baseBranch', 'worktree', 'checks', 'models', 'tracker', 'codeMap', 'uiSkill', 'findings', 'attribution', 'dashboard'];
250
+
251
+ /**
252
+ * Validates a parsed wavework config and fills defaults. Pure: does not touch the file system.
253
+ * Throws one Error listing every problem as `<json.path>: <reason>`.
254
+ *
255
+ * @param {unknown} raw parsed JSON
256
+ * @returns {object} the config with defaults filled in
257
+ */
258
+ export function validateConfig(raw) {
259
+ return validate(raw, undefined);
260
+ }
261
+
262
+ /** Shared by validateConfig and loadConfig; `file` only decorates the error message. */
263
+ function validate(raw, file) {
264
+ const errs = [];
265
+ if (!obj(raw, '', errs, TOP_LEVEL)) throw configError(errs, file);
266
+
267
+ const config = {
268
+ baseBranch: field(raw, 'baseBranch', '', errs, str, { required: true }),
269
+ worktree: field(raw, 'worktree', '', errs, worktree, { fallback: DEFAULTS.worktree }),
270
+ checks: field(raw, 'checks', '', errs, checks, { required: true }),
271
+ models: field(raw, 'models', '', errs, models, { fallback: DEFAULTS.models }),
272
+ tracker: field(raw, 'tracker', '', errs, tracker, { fallback: DEFAULTS.tracker }),
273
+ findings: field(raw, 'findings', '', errs, findings, { fallback: { epicTitles: DEFAULTS.findings.epicTitles } }),
274
+ attribution: field(raw, 'attribution', '', errs, attribution, { fallback: DEFAULTS.attribution }),
275
+ dashboard: field(raw, 'dashboard', '', errs, dashboard, { fallback: DEFAULTS.dashboard }),
276
+ };
277
+ for (const [key, check] of [['codeMap', codeMap], ['uiSkill', uiSkill]]) {
278
+ if (key in raw) config[key] = check(raw[key], key, errs);
279
+ }
280
+
281
+ if (errs.length > 0) throw configError(errs, file);
282
+ return config;
283
+ }
284
+
285
+ function configError(errs, file) {
286
+ const where = file ? ` in ${file}` : '';
287
+ const noun = errs.length === 1 ? 'problem' : 'problems';
288
+ return new Error(`invalid wavework config${where} (${errs.length} ${noun}):\n${errs.map((e) => ` - ${e}`).join('\n')}`);
289
+ }
290
+
291
+ /**
292
+ * Loads and validates `<repoRoot>/.claude/wavework.json`.
293
+ *
294
+ * @param {string} repoRoot
295
+ * @returns {object} validated config with defaults
296
+ */
297
+ export function loadConfig(repoRoot) {
298
+ const file = join(repoRoot, CONFIG_PATH);
299
+ let text;
300
+ try {
301
+ text = readFileSync(file, 'utf8');
302
+ } catch (e) {
303
+ if (e.code === 'ENOENT') throw new Error(`wavework config not found: ${file}`);
304
+ throw new Error(`cannot read wavework config ${file}: ${e.message}`);
305
+ }
306
+ let raw;
307
+ try {
308
+ raw = JSON.parse(text);
309
+ } catch (e) {
310
+ throw new Error(`wavework config is not valid JSON: ${file}: ${e.message}`);
311
+ }
312
+ return validate(raw, file);
313
+ }