wavework 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -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/diff-stat.mjs +41 -0
  10. package/src/cli/hooks.mjs +109 -0
  11. package/src/cli/init.mjs +104 -0
  12. package/src/cli/safe-path.mjs +63 -0
  13. package/src/cli/sync.mjs +302 -0
  14. package/src/cli/update.mjs +24 -0
  15. package/src/cli.mjs +78 -0
  16. package/src/config.mjs +313 -0
  17. package/src/context.mjs +133 -0
  18. package/src/render.mjs +148 -0
  19. package/src/targets.mjs +100 -0
  20. package/templates/agents/implementer.md +16 -0
  21. package/templates/dashboard/README.md +96 -0
  22. package/templates/dashboard/hooks/session-start.mjs +305 -0
  23. package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
  24. package/templates/dashboard/lib/ci.mjs +52 -0
  25. package/templates/dashboard/lib/derive.mjs +105 -0
  26. package/templates/dashboard/lib/settings.mjs +34 -0
  27. package/templates/dashboard/lib/tokens.mjs +21 -0
  28. package/templates/dashboard/pricing.json +11 -0
  29. package/templates/dashboard/public/app.css +134 -0
  30. package/templates/dashboard/public/app.js +260 -0
  31. package/templates/dashboard/public/default-theme.css +86 -0
  32. package/templates/dashboard/public/format.mjs +138 -0
  33. package/templates/dashboard/public/index.html +20 -0
  34. package/templates/dashboard/record.mjs +144 -0
  35. package/templates/dashboard/server.mjs +254 -0
  36. package/templates/dashboard-config.json +1 -0
  37. package/templates/scripts/red-green-check.sh +287 -0
  38. package/templates/scripts/red-green-check.test.sh +151 -0
  39. package/templates/settings/hooks.json +31 -0
  40. package/templates/skills/next-task/SKILL.md +77 -0
  41. package/templates/skills/next-task-batch/SKILL.md +27 -0
  42. package/templates/skills/next-task-beads/SKILL.md +29 -0
  43. package/templates/skills/next-task-branch/SKILL.md +19 -0
  44. package/templates/skills/next-task-code/SKILL.md +22 -0
  45. package/templates/skills/next-task-deps/SKILL.md +31 -0
  46. package/templates/skills/next-task-e2e/SKILL.md +35 -0
  47. package/templates/skills/next-task-findings/SKILL.md +65 -0
  48. package/templates/skills/next-task-finish/SKILL.md +43 -0
  49. package/templates/skills/next-task-implement/SKILL.md +34 -0
  50. package/templates/skills/next-task-plan/SKILL.md +52 -0
  51. package/templates/skills/next-task-review/SKILL.md +61 -0
  52. package/templates/skills/next-task-test/SKILL.md +30 -0
  53. package/templates/skills/next-task-write-tests/SKILL.md +34 -0
  54. package/templates/skills/text-to-task/SKILL.md +78 -0
  55. package/templates/skills/watch-ci/SKILL.md +42 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-11-13
11
+
12
+ ### Added
13
+
14
+ - `wavework init --adopt` for repositories that already have hand-written pipeline files: existing files with identical content are kept and locked, differing files are kept untouched with the rendered version written to `<file>.wavework-new` and listed with a short `+N -M` line diff summary, missing files are created, and a script whose content matches but whose exec bit is wrong gets the bit fixed. The lock records the rendered hash for every target, so `update` keeps reporting unresolved files (exit code 2) until you merge and delete the `.wavework-new` file or make the file equal to the rendered one. `--adopt` needs `.claude/wavework.json` or `--config` (no starter config for adoption), refuses an existing lock, supports `--dry-run`, and applies the same path safety as `init`.
15
+
16
+ ### Changed
17
+
18
+ - `update` reports a file whose `.wavework-new` still exists even when upstream did not change since the last run, so unresolved review files no longer go silent, and it fails closed (exit 1, nothing written) when a symlink sits at a `.wavework-new` path.
19
+
20
+ ## [0.1.0] - 2026-11-13
21
+
22
+ ### Added
23
+
24
+ - Package skeleton: `wavework` CLI with `--version` and `--help`, test setup with `node:test`, and CI on Node 22 and 24.
25
+ - Config: `src/config.mjs` validates and loads `.claude/wavework.json` (defaults, unknown-key rejection, errors as `<json.path>: <reason>`), documented in `docs/config.md` with Shipir's values as `examples/shipir.wavework.json`.
26
+ - Skill templates: the 14 `next-task*` skills as `templates/skills/<name>/SKILL.md`, rendered by `src/render.mjs` (`{{var}}`, `{{#if}}`/`{{else}}`) from `src/context.mjs`; golden test against Shipir's skills with reviewed differences in `docs/porting.md`.
27
+ - Agent, skill and script templates: the implementer agents (one file per implementer model), the `text-to-task` and `watch-ci` skills and `red-green-check.sh` with its test; `src/targets.mjs` maps every template to its `.claude/` target. `red-green-check.sh` needs only bash, git and node, discovers Maven and Vitest tests and prints `SKIP <area>: unsupported` for other areas.
28
+ - `wavework init` and `wavework update`: render the templates into `.claude/`, record a sha256 lock (`docs/lock.md`), keep locally edited files and write `<file>.wavework-new`, restore lost exec bits, merge hooks into `.claude/settings.json` without touching foreign entries, `--dry-run`, exit code 2 when files need review. Every path stays inside `.claude/` (no `..`, absolute paths or symlinks, also for a tampered lock). Config path fields (`checks[].dir`, `whenPaths`, `codeMap.path`, `uiSkill.appliesTo`) must be repo-relative; `.` means the whole repo.
29
+ - Pipeline dashboard: `init`/`update` install the recorder, hooks, server and UI into `.claude/wavework/dashboard/` (committed, works in worktrees without `node_modules`) and register its `SessionStart`/`SubagentStop` hooks while `dashboard.enabled`; `dashboard.port` and `dashboard.tokensFile` in the config; `wavework dashboard` starts the server; the skills' `Record:` lines follow the config; neutral default theme; disabling removes its hooks and unedited runtime files on `update` (`docs/dashboard.md`).
30
+ - README: install, daily use, configuration, upgrading, dashboard and a sourced comparison with Sous Factory. `scripts/smoke.sh` runs the README commands against the packed package in CI (`smoke` job), and `test/readme.test.mjs` fails when a README command has no smoke step or test.
31
+
32
+ [Unreleased]: https://github.com/csSTK/wavework/compare/v0.1.1...HEAD
33
+ [0.1.1]: https://github.com/csSTK/wavework/releases/tag/v0.1.1
34
+ [0.1.0]: https://github.com/csSTK/wavework/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Bethge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # wavework
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ wavework is a versioned Claude Code pipeline for repositories that use [beads](https://github.com/steveyegge/beads) (`bd`) for task tracking. It installs the `/next-task*` skills, implementer agents and helper scripts into `.claude/` of your repository and keeps them up to date with a lock file.
4
+
5
+ The pipeline works like this:
6
+
7
+ - **Waves of parallel sub-agents.** An epic's tasks are planned into waves; each task is implemented by a sub-agent, and each run works in its own git worktree.
8
+ - **Independent review.** A fresh, read-only sub-agent reviews every task's diff against the bead's acceptance criteria and the project's rules.
9
+ - **Red before green.** Tests are written first, and `red-green-check.sh` checks that new tests fail on the old code.
10
+ - **Findings backlog.** Problems found outside a task's scope are filed as beads under a few standing epics, so they can be run later with the same pipeline.
11
+
12
+ **Status: 0.1.0, first release. Expect changes before 1.0.** The commands below describe the CLI as released.
13
+
14
+ ## Requirements
15
+
16
+ - Node.js 22 or newer (npm)
17
+ - git
18
+ - [beads](https://github.com/steveyegge/beads) (`bd`), with the repository set up for it
19
+ - [Claude Code](https://claude.com/claude-code)
20
+ - bash (for `red-green-check.sh`)
21
+ - [GitHub CLI](https://cli.github.com) (`gh`), only for `/watch-ci`
22
+
23
+ ## Install
24
+
25
+ From the root of a git repository:
26
+
27
+ ```bash
28
+ npm i -D wavework
29
+ npx wavework --version
30
+ npx wavework init
31
+ ```
32
+
33
+ Without `--config`, `init` writes a starter config: `baseBranch` comes from `origin/HEAD`, and the one check runs `npm test` when `package.json` has a `test` script; otherwise it is a placeholder that fails until you replace it. Without `--config` and without `origin/HEAD`, `init` exits with code 1 and asks for `--config`. To start from a complete config file instead (never overwritten once it exists):
34
+
35
+ ```bash
36
+ npx wavework init --config <file>
37
+ ```
38
+
39
+ To adopt a repository that already has hand-written pipeline files (it never overwrites them):
40
+
41
+ ```bash
42
+ npx wavework init --adopt --dry-run --config <file>
43
+ npx wavework init --adopt --config <file>
44
+ ```
45
+
46
+ For each file `init` would write that already exists: identical content is kept and locked; different content is kept untouched and the rendered version is written to `<file>.wavework-new`, listed with a short `+N -M` line summary (diff it yourself: `diff <file> <file>.wavework-new`; a file with CRLF line endings against the LF rendered version shows every line as changed); missing files are created. `--dry-run` prints this plan and writes nothing. Exit code 0 when everything matched or was created, 2 when files differ. `--adopt` needs `.claude/wavework.json` or `--config <file>` (a starter config would be a guess, so every rendered file would differ) and refuses when a lock exists. Afterwards `npx wavework update` keeps reporting the differing files until you merge them and delete the `.wavework-new` files, or make the file equal to the rendered one. See [docs/lock.md](docs/lock.md).
47
+
48
+ `init` writes:
49
+
50
+ - `.claude/skills/`: the 16 skills (`next-task`, `next-task-*`, `text-to-task`, `watch-ci`)
51
+ - `.claude/agents/implementer-<model>.md`: one implementer agent per model in the config (the default config gives `implementer-sonnet` and `implementer-haiku`)
52
+ - `.claude/scripts/red-green-check.sh` and its test
53
+ - `.claude/wavework/dashboard/`: the pipeline dashboard (with `dashboard.enabled`, the default)
54
+ - `.claude/settings.json`: the dashboard's two hooks, merged into your settings without touching other entries
55
+ - `.claude/wavework.json`: your configuration (never overwritten)
56
+ - `.claude/wavework.lock.json`: the hashes of what wavework wrote (see [docs/lock.md](docs/lock.md))
57
+
58
+ Commit `.claude/`, including the lock file and the dashboard runtime. Each run works in a git worktree, which has no `node_modules`, so the committed files are what the skills run.
59
+
60
+ ## Daily use
61
+
62
+ Start these from Claude Code:
63
+
64
+ - `/next-task <epic-id>`: implements a whole beads epic end to end and pushes it to the base branch.
65
+ - `/next-task-beads <id> [<id> ...]`: the same for individual beads (tasks, bugs, chores, findings), without an epic.
66
+ - `/next-task-batch <epic-id> <epic-id> ...`: runs `/next-task` for several epics one after another, ordered by their dependencies, and watches CI once at the end.
67
+ - `/text-to-task`: turns a free-text feature request into an epic with child tasks, asking questions first when the request is too vague.
68
+ - `/watch-ci`: reports the GitHub Actions result for the current commit, branch or run, read-only.
69
+
70
+ The other `next-task-*` skills are steps that these commands use; you do not start them directly.
71
+
72
+ ## Configuration
73
+
74
+ The configuration is `.claude/wavework.json`. It names the base branch, the test commands of each area of the codebase and the model per role. The required fields are `baseBranch` and `checks`:
75
+
76
+ ```json
77
+ {
78
+ "baseBranch": "main",
79
+ "checks": [
80
+ { "name": "app", "dir": ".", "whenPaths": ["src/"], "focused": "npm test", "full": "npm test" }
81
+ ]
82
+ }
83
+ ```
84
+
85
+ Every field, its default and its validation rules are in [docs/config.md](docs/config.md). A complete example for a multi-area project is [examples/shipir.wavework.json](examples/shipir.wavework.json).
86
+
87
+ ## Upgrading
88
+
89
+ Upgrade the package, preview the changes, then apply them:
90
+
91
+ ```bash
92
+ npm i -D wavework@latest
93
+ npx wavework update --dry-run
94
+ npx wavework update
95
+ ```
96
+
97
+ `update` re-renders `.claude/` from the installed version and your config. It never changes `.claude/wavework.json`. `--dry-run` prints the plan and writes nothing.
98
+
99
+ Exit codes:
100
+
101
+ - `0`: done.
102
+ - `1`: error. The plan is checked before anything is written, so a failing run leaves the repository unchanged.
103
+ - `2`: done, but some files need your review.
104
+
105
+ A file needs review when you edited it and the new version changed it too. `update` keeps your file and writes the new version next to it as `<file>.wavework-new`. To resolve it, merge what you want into your file, delete the `.wavework-new` file, and make the file either match the new version or hold the old content again. The next `update` then clears the report; until then every `update` reports it and exits with `2`. The rules are in [docs/lock.md](docs/lock.md).
106
+
107
+ ## Dashboard
108
+
109
+ The pipeline dashboard shows every `/next-task*` run live: steps, waves, sub-agents with model, duration, tokens and estimated cost, reviews, tests and the CI state of the pushed commit. Its `SessionStart` hook starts the server and prints its URL. To run it in the foreground yourself:
110
+
111
+ ```bash
112
+ npx wavework dashboard
113
+ ```
114
+
115
+ It listens on `127.0.0.1` at port 4690 by default (`dashboard.port`). Set `dashboard.enabled` to `false` and run `update` to remove it. Details are in [docs/dashboard.md](docs/dashboard.md).
116
+
117
+ ## Comparison with Sous Factory
118
+
119
+ [Sous Factory](https://sousfactory.com/) (MIT, npm package `sous-factory`) is another tool for running beads tasks through agents. The wavework author trialled Sous Factory on Shipir, the project wavework was ported from, against Shipir's own `/next-task*` pipeline. The numbers below are copied from that trial's handoff notes (`docs/sous-handoff.md` in Shipir, a private repository, so they are cited by run rather than linked). They measure Shipir's pipeline, not wavework; wavework is a port of that pipeline and has not been measured as a whole.
120
+
121
+ | Run (source) | Tool | Bead | Wall clock | Agent time | Cost | Review |
122
+ |---|---|---|---|---|---|---|
123
+ | 1 (Run 1, setup 2026-10-24, run date not recorded) | Sous | `shipr-hae` | ~36 min | ~5.5 min | not measured | diff review, then 3 whole-branch rounds |
124
+ | 2 (Run 2, date not recorded) | `/next-task-beads` | `shipr-8qd` | ~5 min | API time 2m28s | $1.34 Sonnet, plus $0.56 for a first attempt on Opus that was refused | 1 round, PASS |
125
+ | 3 (Run 3, 2026-11-13) | Sous 0.8.0 | `shipr-kipv.2` | ~28 min | ~2.5 min | $1.37 | review-full, 1 round |
126
+
127
+ Most of the wall clock in both Sous runs was the project's full checks (Maven `verify`), not agent work: Run 1 had about 30 of its ~36 minutes in checks ("~30 min was full Maven `verify` after every step"), and Run 3's implement step had 25m15s of checks out of 26m08s (agent 53s), so about 25 of its ~28 minutes. The wall-clock gap therefore mostly reflects when and how often the full checks run, not agent speed. Run 2's source gives no split between checks and agent time. Run 3's cost is listed per step (check-issue $0.45, plan $0.24, implement $0.27, review $0.22, review-full $0.19); Run 2's source gives one total of $1.34 without a per-step breakdown, so whether it includes planning or orchestration is unknown. The handoff's conclusion was to keep the `/next-task*` pipeline and to take over the one thing Sous did better, a check that new tests fail on the old code. wavework ships that check as `red-green-check.sh`.
128
+
129
+ Limitations of this comparison:
130
+
131
+ - One run per tool, on different beads of unrecorded size. The numbers are anecdotes, not a benchmark.
132
+ - Sous Run 1 hit a hard-coded `main` branch that cost a stop and a second attempt, and Run 1 was on an earlier Sous version (not recorded). Run 3 used Sous 0.8.0.
133
+ - Run 1 has no cost, so the two tools' costs are compared only for Runs 2 and 3.
134
+ - The review results compare different review designs, and the Run 2 red-before-green check was done by hand afterwards.
135
+ - No neutral party: the same person ran the Sous trial, built the pipeline it was compared with (Shipir's, which wavework was ported from) and made the decision. The project is a Java and React codebase; other repositories would give other numbers.
136
+
137
+ ## Development
138
+
139
+ `npm test` runs the unit tests (`node:test`, no dependencies). `bash scripts/smoke.sh` packs the package and runs the commands from this README against it in two temporary repositories; CI runs it on Node 22 and 24. The dashboard's server, hooks and UI are tested in `test/dashboard/`; `test/dashboard-cli.test.mjs` covers `wavework dashboard`.
140
+
141
+ Releases are published to npm by `.github/workflows/publish.yml` when a `v*` tag is pushed, using npm trusted publishing (no npm token). The maintainer steps are in [docs/releasing.md](docs/releasing.md).
142
+
143
+ ## License
144
+
145
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+
4
+ process.exitCode = await run(process.argv.slice(2), {
5
+ stdout: process.stdout,
6
+ stderr: process.stderr,
7
+ });
@@ -0,0 +1,54 @@
1
+ {
2
+ "baseBranch": "dev",
3
+ "worktree": {
4
+ "pathPattern": "../{repo}-{unit}",
5
+ "branchPattern": "{prefix}/{unit}-{slug}"
6
+ },
7
+ "checks": [
8
+ {
9
+ "name": "backend",
10
+ "dir": "backend",
11
+ "whenPaths": ["backend/"],
12
+ "focused": "./backend/mvnw -f backend/pom.xml test -Dtest='dev.bethge.shipir.<pkg>.**' -Dsurefire.failIfNoSpecifiedTests=false",
13
+ "full": "./backend/mvnw -f backend/pom.xml verify"
14
+ },
15
+ {
16
+ "name": "frontend",
17
+ "dir": "frontend",
18
+ "whenPaths": ["frontend/"],
19
+ "focused": "pnpm vitest run <changed dirs>",
20
+ "full": "pnpm lint && pnpm test && pnpm build",
21
+ "lint": "pnpm lint",
22
+ "build": "pnpm build",
23
+ "e2e": "pnpm e2e"
24
+ }
25
+ ],
26
+ "models": {
27
+ "planning": "opus",
28
+ "orchestration": "opus",
29
+ "implementers": {
30
+ "default": "sonnet",
31
+ "rules": {
32
+ "haiku": "Small, well-specified, low-risk changes: boilerplate, renames, simple DTOs or entities, config tweaks, docs, straightforward tests. Never for auth, permissions, remote host access or the audit log.",
33
+ "sonnet": "Everything else: multi-file features, non-trivial logic, debugging, refactors, concurrency and streaming, and anything touching auth, permissions, remote host access or the audit log. When unsure, use sonnet."
34
+ }
35
+ }
36
+ },
37
+ "tracker": { "kind": "beads" },
38
+ "codeMap": { "path": "docs/CODE_MAP.md" },
39
+ "uiSkill": { "name": "apple-design", "appliesTo": ["frontend/"] },
40
+ "findings": {
41
+ "epicTitles": {
42
+ "security": "Findings: Security & authorization",
43
+ "bugs": "Findings: Bugs & correctness",
44
+ "tests": "Findings: Tests & CI stability",
45
+ "quality": "Findings: Code quality, UI & docs"
46
+ }
47
+ },
48
+ "attribution": { "allowAiTrailer": false },
49
+ "dashboard": {
50
+ "enabled": true,
51
+ "port": 4690,
52
+ "tokensFile": "frontend/src/shared/styles/tokens.css"
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "wavework",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Versioned Claude Code pipeline for beads repos: waves of parallel sub-agents, independent review, red-before-green, findings backlog.",
5
+ "type": "module",
6
+ "bin": {
7
+ "wavework": "bin/wavework.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "templates/",
13
+ "examples/",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "beads",
25
+ "sub-agents",
26
+ "pipeline"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/csSTK/wavework.git"
32
+ },
33
+ "homepage": "https://github.com/csSTK/wavework#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/csSTK/wavework/issues"
36
+ }
37
+ }
@@ -0,0 +1,28 @@
1
+ /** A mistake in how the command line was written; the CLI answers with the usage text. */
2
+ export class UsageError extends Error {}
3
+
4
+ /**
5
+ * Minimal option parser. `spec` maps option name to 'flag' or 'value'; `--name=value` is accepted too.
6
+ * Positional arguments are not allowed.
7
+ *
8
+ * @param {string[]} argv
9
+ * @param {Record<string, 'flag' | 'value'>} spec
10
+ * @returns {Record<string, string | boolean>}
11
+ */
12
+ export function parseArgs(argv, spec) {
13
+ const out = {};
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const [name, inline] = argv[i].startsWith('--') ? argv[i].split(/=(.*)/s, 2) : [argv[i]];
16
+ const kind = Object.hasOwn(spec, name) ? spec[name] : undefined;
17
+ if (kind === undefined) throw new UsageError(`unknown option or argument: ${argv[i]}`);
18
+ if (kind === 'flag') {
19
+ if (inline !== undefined) throw new UsageError(`${name} does not take a value`);
20
+ out[name] = true;
21
+ } else {
22
+ const value = inline ?? argv[++i];
23
+ if (value === undefined || value === '') throw new UsageError(`${name} needs a value`);
24
+ out[name] = value;
25
+ }
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,41 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { loadConfig } from '../config.mjs';
5
+ import { safeResolve } from './safe-path.mjs';
6
+
7
+ /**
8
+ * `wavework dashboard`: runs this repository's installed dashboard server (`.claude/wavework/dashboard/server.mjs`)
9
+ * in the foreground until it is stopped. The server is the repo's own copy, versioned with the lock like every
10
+ * other file, so it needs no node_modules and no installed wavework.
11
+ *
12
+ * @param {object} _args
13
+ * @param {{ cwd: string, env?: object, spawn?: Function }} ctx `spawn` and `env` replace child_process.spawn and process.env (tests)
14
+ * @returns {Promise<number>} the server's exit code
15
+ */
16
+ export async function dashboard(_args, ctx) {
17
+ const claudeDir = join(ctx.cwd, '.claude');
18
+ const config = loadConfig(ctx.cwd);
19
+ if (!config.dashboard.enabled) {
20
+ throw new Error('the dashboard is disabled ("dashboard.enabled": false in .claude/wavework.json); enable it and run `wavework update`');
21
+ }
22
+ const server = safeResolve(claudeDir, 'wavework/dashboard/server.mjs');
23
+ let isFile = false;
24
+ try { isFile = statSync(server).isFile(); } catch { /* missing */ }
25
+ if (!isFile) throw new Error('the dashboard is not installed in this repository (.claude/wavework/dashboard/server.mjs is missing); run `wavework update`');
26
+
27
+ const env = { ...(ctx.env ?? process.env) };
28
+ env.PIPELINE_DASHBOARD_PORT ??= String(config.dashboard.port);
29
+ const child = (ctx.spawn ?? nodeSpawn)(process.execPath, [server], { cwd: ctx.cwd, env, stdio: 'inherit' });
30
+ const forward = (signal) => () => child.kill?.(signal);
31
+ const handlers = ctx.spawn ? [] : ['SIGINT', 'SIGTERM'].map((sig) => [sig, forward(sig)]);
32
+ for (const [sig, fn] of handlers) process.once(sig, fn);
33
+ try {
34
+ return await new Promise((resolve, reject) => {
35
+ child.once('error', reject);
36
+ child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
37
+ });
38
+ } finally {
39
+ for (const [sig, fn] of handlers) process.removeListener(sig, fn);
40
+ }
41
+ }
@@ -0,0 +1,41 @@
1
+ /** Lines of a text as an array; a trailing newline does not add an empty last line. */
2
+ const lines = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
3
+
4
+ /** Above this many cells the exact LCS is skipped (memory); the stat then counts the middle section as replaced. */
5
+ const MAX_CELLS = 4_000_000;
6
+
7
+ /**
8
+ * Short line diff summary between two texts: how many lines the new text adds and the old text removes
9
+ * (longest-common-subsequence based, dependency-free). A full diff is the user's job.
10
+ *
11
+ * @param {string} from existing content
12
+ * @param {string} to rendered content
13
+ * @param {{ maxCells?: number }} [options] LCS size cap (tests lower it)
14
+ * @returns {{ added: number, removed: number, approx?: true, whitespaceOnly?: true }}
15
+ * `approx`: over the cap, the numbers are an upper bound. `whitespaceOnly`: texts differ but no line does (trailing newline).
16
+ */
17
+ export function lineDiffStat(from, to, { maxCells = MAX_CELLS } = {}) {
18
+ const a = lines(from);
19
+ const b = lines(to);
20
+ let start = 0;
21
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
22
+ let endA = a.length;
23
+ let endB = b.length;
24
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
25
+ const x = a.slice(start, endA);
26
+ const y = b.slice(start, endB);
27
+ if (x.length === 0 && y.length === 0) return from === to ? { added: 0, removed: 0 } : { added: 0, removed: 0, whitespaceOnly: true };
28
+ if (x.length === 0 || y.length === 0) return { added: y.length, removed: x.length };
29
+ if (x.length * y.length > maxCells) return { added: y.length, removed: x.length, approx: true };
30
+
31
+ let prev = new Uint32Array(y.length + 1);
32
+ let cur = new Uint32Array(y.length + 1);
33
+ for (let i = 1; i <= x.length; i++) {
34
+ for (let j = 1; j <= y.length; j++) {
35
+ cur[j] = x[i - 1] === y[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
36
+ }
37
+ [prev, cur] = [cur, prev];
38
+ }
39
+ const common = prev[y.length];
40
+ return { added: y.length - common, removed: x.length - common };
41
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Merging of wavework's hook entries into `.claude/settings.json`.
3
+ *
4
+ * A definition file has the same shape as the `hooks` key of settings.json:
5
+ * `{ "hooks": { "<Event>": [ <entry>, ... ] } }`. An entry is one element of an event's array
6
+ * (usually `{ matcher?, hooks: [...] }`). Ownership is per entry: wavework owns an entry when it is
7
+ * deep-equal to one it was told to add. Foreign entries are never touched, reordered or removed.
8
+ */
9
+
10
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
11
+
12
+ /** JSON with sorted keys, so deep equality ignores key order. */
13
+ function canonical(value) {
14
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
15
+ if (isObject(value)) {
16
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
17
+ }
18
+ return JSON.stringify(value);
19
+ }
20
+
21
+ const same = (a, b) => canonical(a) === canonical(b);
22
+
23
+ function eventEntries(hooks, where, defs) {
24
+ if (!isObject(hooks)) throw new Error(`hook definitions: ${where} must be an object`);
25
+ for (const [event, entries] of Object.entries(hooks)) {
26
+ if (!Array.isArray(entries)) throw new Error(`hook definitions: ${where}.${event} must be an array`);
27
+ entries.forEach((entry, i) => {
28
+ if (!isObject(entry)) throw new Error(`hook definitions: ${where}.${event}[${i}] must be an object`);
29
+ defs.push({ event, entry });
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * `hooks` always apply. `features.<name>.hooks` apply only when `<name>` is in `enabled` (the CLI passes the
36
+ * features the config switches on, e.g. "dashboard"); a disabled feature's entries are simply not defined, so
37
+ * `mergeHooks` removes them again. Every part is validated, enabled or not.
38
+ *
39
+ * @param {unknown} json parsed definition file
40
+ * @param {string[]} [enabled] names of the enabled features
41
+ * @returns {{ event: string, entry: object }[]}
42
+ */
43
+ export function parseHookDefs(json, enabled = []) {
44
+ if (!isObject(json) || !isObject(json.hooks)) throw new Error('hook definitions: expected an object with a "hooks" object');
45
+ const defs = [];
46
+ eventEntries(json.hooks, 'hooks', defs);
47
+ if (json.features !== undefined) {
48
+ if (!isObject(json.features)) throw new Error('hook definitions: "features" must be an object');
49
+ for (const [name, feature] of Object.entries(json.features)) {
50
+ const featureDefs = [];
51
+ if (!isObject(feature) || !isObject(feature.hooks)) throw new Error(`hook definitions: features.${name} must be an object with a "hooks" object`);
52
+ eventEntries(feature.hooks, `features.${name}.hooks`, featureDefs);
53
+ if (enabled.includes(name)) defs.push(...featureDefs);
54
+ }
55
+ }
56
+ return defs;
57
+ }
58
+
59
+ /**
60
+ * Pure. Removes previously owned entries that are no longer defined, adds missing defined ones.
61
+ *
62
+ * @param {object} settings parsed settings.json (not mutated)
63
+ * @param {{ event: string, entry: object }[]} defs entries wavework defines now
64
+ * @param {{ event: string, entry: object }[]} previouslyOwned from the lock
65
+ * @returns {{ settings: object, owned: { event: string, entry: object }[], lost: { event: string, entry: object }[] }} `lost`: owned entries not found in settings (changed or removed by the user), not re-added
66
+ */
67
+ export function mergeHooks(settings, defs, previouslyOwned) {
68
+ const next = structuredClone(settings);
69
+ if (next.hooks !== undefined && !isObject(next.hooks)) throw new Error('settings.json: "hooks" must be an object');
70
+ const hooks = next.hooks;
71
+ const events = () => next.hooks ?? {};
72
+ const eventArray = (event) => {
73
+ const value = events()[event];
74
+ if (value !== undefined && !Array.isArray(value)) throw new Error(`settings.json: hooks.${event} must be an array`);
75
+ return value;
76
+ };
77
+
78
+ let removedAny = false;
79
+ for (const old of previouslyOwned) {
80
+ if (defs.some((d) => d.event === old.event && same(d.entry, old.entry))) continue;
81
+ const list = eventArray(old.event);
82
+ const at = list?.findIndex((e) => same(e, old.entry)) ?? -1;
83
+ if (at === -1) continue;
84
+ list.splice(at, 1);
85
+ removedAny = true;
86
+ if (list.length === 0) delete next.hooks[old.event];
87
+ }
88
+ if (removedAny && hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
89
+
90
+ const owned = [];
91
+ const lost = [];
92
+ for (const { event, entry } of defs) {
93
+ let list = eventArray(event);
94
+ const wasOwned = previouslyOwned.some((o) => o.event === event && same(o.entry, entry));
95
+ const kept = { event, entry: structuredClone(entry) };
96
+ if (list?.some((e) => same(e, entry))) {
97
+ if (wasOwned) owned.push(kept); // an identical entry that was there before us stays foreign
98
+ } else if (wasOwned) {
99
+ owned.push(kept); // we added it before, now it is gone or changed by the user: do not add a second copy
100
+ lost.push(kept);
101
+ } else {
102
+ if (next.hooks === undefined) next.hooks = {};
103
+ list ??= (next.hooks[event] = []);
104
+ list.push(structuredClone(entry));
105
+ owned.push(kept);
106
+ }
107
+ }
108
+ return { settings: next, owned, lost };
109
+ }
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { loadConfig, validateConfig, CONFIG_PATH } from '../config.mjs';
5
+ import {
6
+ apply, defaultTargets, loadHookDefs, PACKAGE_TEMPLATES, plan, readLock, renderTargets, report,
7
+ } from './sync.mjs';
8
+ import { safeResolve } from './safe-path.mjs';
9
+
10
+ /** Default branch from `refs/remotes/origin/HEAD`, or undefined when there is no such ref. */
11
+ function detectBaseBranch(cwd) {
12
+ try {
13
+ const ref = execFileSync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], {
14
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
15
+ }).trim();
16
+ return ref.replace(/^origin\//, '') || undefined;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * The config has no usable default for `checks`, so a starter config only exists to be edited:
24
+ * one area whose commands fail loudly until the user fills them in (or `npm test` when package.json has it).
25
+ */
26
+ function starterConfig(cwd, baseBranch) {
27
+ let command = 'echo "wavework: set the checks in .claude/wavework.json" >&2; exit 1';
28
+ try {
29
+ if (JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'))?.scripts?.test) command = 'npm test';
30
+ } catch { /* no package.json */ }
31
+ return { baseBranch, checks: [{ name: 'project', dir: '.', whenPaths: ['.'], focused: command, full: command }] };
32
+ }
33
+
34
+ /** Resolves where the config comes from; returns it plus the text to write (null when it already exists). */
35
+ function resolveConfig(args, ctx, out, adopt) {
36
+ const existing = existsSync(join(ctx.cwd, CONFIG_PATH));
37
+ const from = args['--config'];
38
+ if (existing) {
39
+ if (from) throw new Error(`${CONFIG_PATH} already exists; drop --config to use it (it is never overwritten)`);
40
+ return { config: loadConfig(ctx.cwd), text: null };
41
+ }
42
+ if (from) {
43
+ let text;
44
+ try {
45
+ text = readFileSync(resolve(ctx.cwd, from), 'utf8');
46
+ } catch (e) {
47
+ throw new Error(`cannot read --config file ${from}: ${e.message}`);
48
+ }
49
+ let raw;
50
+ try {
51
+ raw = JSON.parse(text);
52
+ } catch (e) {
53
+ throw new Error(`--config file ${from} is not valid JSON: ${e.message}`);
54
+ }
55
+ return { config: validateConfig(raw), text: text.endsWith('\n') ? text : `${text}\n` };
56
+ }
57
+ if (adopt) {
58
+ throw new Error(
59
+ `--adopt requires --config <file> when ${CONFIG_PATH} does not exist: adopting existing hand-written files only makes sense `
60
+ + 'against a config that describes your repository (checks, models); a starter config would be a guess, and every file '
61
+ + `rendered from it would differ. Create ${CONFIG_PATH} (see docs/config.md) or pass --config <file>.`,
62
+ );
63
+ }
64
+ const baseBranch = detectBaseBranch(ctx.cwd);
65
+ if (!baseBranch) {
66
+ throw new Error(
67
+ `no ${CONFIG_PATH} and no default branch to take baseBranch from (git has no refs/remotes/origin/HEAD). `
68
+ + `Create ${CONFIG_PATH} (see docs/config.md) or pass --config <file>.`,
69
+ );
70
+ }
71
+ const raw = starterConfig(ctx.cwd, baseBranch);
72
+ out.write(`no ${CONFIG_PATH}: writing a starter config (baseBranch "${baseBranch}" from origin/HEAD); edit "checks" in it\n`);
73
+ return { config: validateConfig(raw), text: `${JSON.stringify(raw, null, 2)}\n` };
74
+ }
75
+
76
+ /**
77
+ * `wavework init [--config <file>] [--adopt] [--dry-run]`: first-time setup of `.claude/` in the repo at `ctx.cwd`.
78
+ * With `--adopt`, existing files are kept: identical ones are locked, differing ones get a `.wavework-new` next to them.
79
+ *
80
+ * @returns {Promise<number>} exit code
81
+ */
82
+ export async function init(args, ctx) {
83
+ if (!existsSync(join(ctx.cwd, '.git'))) throw new Error('not at a git repository root (no .git here): run wavework from the repository root');
84
+ const claudeDir = join(ctx.cwd, '.claude');
85
+ if (readLock(claudeDir)) throw new Error('.claude/wavework.lock.json exists: wavework is already set up here; run `wavework update`');
86
+
87
+ const dryRun = args['--dry-run'] === true;
88
+ const adopt = args['--adopt'] === true;
89
+ const { config, text } = resolveConfig(args, ctx, ctx.stdout, adopt);
90
+ const provider = ctx.targets ?? await defaultTargets();
91
+ const rendered = renderTargets(config, provider, ctx.templatesDir ?? PACKAGE_TEMPLATES, basename(resolve(ctx.cwd)));
92
+ const result = plan({ claudeDir, rendered, lock: { files: {}, hooks: [] }, defs: loadHookDefs(ctx.hooks, config), adopt });
93
+
94
+ if (!dryRun) {
95
+ if (text !== null) {
96
+ const configPath = safeResolve(claudeDir, 'wavework.json', { allowReserved: true });
97
+ result.actions.unshift({ kind: 'create', target: 'wavework.json', abs: configPath, content: text, mode: 0o644 });
98
+ }
99
+ apply(claudeDir, result, ctx.version);
100
+ } else if (text !== null) {
101
+ result.actions.unshift({ kind: 'create', target: 'wavework.json' });
102
+ }
103
+ return report(result, ctx.stdout, { dryRun, adopt, done: `wavework ${ctx.version}: ${adopt ? 'adopted' : 'initialized'} .claude/` });
104
+ }