wavework 0.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
20
  ## [0.1.0] - 2026-11-13
11
21
 
12
22
  ### Added
@@ -19,5 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
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`).
20
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.
21
31
 
22
- [Unreleased]: https://github.com/csSTK/wavework/compare/v0.1.0...HEAD
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
23
34
  [0.1.0]: https://github.com/csSTK/wavework/releases/tag/v0.1.0
package/README.md CHANGED
@@ -36,6 +36,15 @@ Without `--config`, `init` writes a starter config: `baseBranch` comes from `ori
36
36
  npx wavework init --config <file>
37
37
  ```
38
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
+
39
48
  `init` writes:
40
49
 
41
50
  - `.claude/skills/`: the 16 skills (`next-task`, `next-task-*`, `text-to-task`, `watch-ci`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wavework",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Versioned Claude Code pipeline for beads repos: waves of parallel sub-agents, independent review, red-before-green, findings backlog.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
package/src/cli/init.mjs CHANGED
@@ -32,7 +32,7 @@ function starterConfig(cwd, baseBranch) {
32
32
  }
33
33
 
34
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) {
35
+ function resolveConfig(args, ctx, out, adopt) {
36
36
  const existing = existsSync(join(ctx.cwd, CONFIG_PATH));
37
37
  const from = args['--config'];
38
38
  if (existing) {
@@ -54,6 +54,13 @@ function resolveConfig(args, ctx, out) {
54
54
  }
55
55
  return { config: validateConfig(raw), text: text.endsWith('\n') ? text : `${text}\n` };
56
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
+ }
57
64
  const baseBranch = detectBaseBranch(ctx.cwd);
58
65
  if (!baseBranch) {
59
66
  throw new Error(
@@ -67,7 +74,8 @@ function resolveConfig(args, ctx, out) {
67
74
  }
68
75
 
69
76
  /**
70
- * `wavework init [--config <file>] [--dry-run]`: first-time setup of `.claude/` in the repo at `ctx.cwd`.
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.
71
79
  *
72
80
  * @returns {Promise<number>} exit code
73
81
  */
@@ -77,10 +85,11 @@ export async function init(args, ctx) {
77
85
  if (readLock(claudeDir)) throw new Error('.claude/wavework.lock.json exists: wavework is already set up here; run `wavework update`');
78
86
 
79
87
  const dryRun = args['--dry-run'] === true;
80
- const { config, text } = resolveConfig(args, ctx, ctx.stdout);
88
+ const adopt = args['--adopt'] === true;
89
+ const { config, text } = resolveConfig(args, ctx, ctx.stdout, adopt);
81
90
  const provider = ctx.targets ?? await defaultTargets();
82
91
  const rendered = renderTargets(config, provider, ctx.templatesDir ?? PACKAGE_TEMPLATES, basename(resolve(ctx.cwd)));
83
- const result = plan({ claudeDir, rendered, lock: { files: {}, hooks: [] }, defs: loadHookDefs(ctx.hooks, config) });
92
+ const result = plan({ claudeDir, rendered, lock: { files: {}, hooks: [] }, defs: loadHookDefs(ctx.hooks, config), adopt });
84
93
 
85
94
  if (!dryRun) {
86
95
  if (text !== null) {
@@ -91,5 +100,5 @@ export async function init(args, ctx) {
91
100
  } else if (text !== null) {
92
101
  result.actions.unshift({ kind: 'create', target: 'wavework.json' });
93
102
  }
94
- return report(result, ctx.stdout, { dryRun, done: `wavework ${ctx.version}: initialized .claude/` });
103
+ return report(result, ctx.stdout, { dryRun, adopt, done: `wavework ${ctx.version}: ${adopt ? 'adopted' : 'initialized'} .claude/` });
95
104
  }
package/src/cli/sync.mjs CHANGED
@@ -8,6 +8,7 @@ import { buildContext } from '../context.mjs';
8
8
  import { render } from '../render.mjs';
9
9
  import { safeResolve } from './safe-path.mjs';
10
10
  import { mergeHooks, parseHookDefs } from './hooks.mjs';
11
+ import { lineDiffStat } from './diff-stat.mjs';
11
12
 
12
13
  export const LOCK_PATH = 'wavework.lock.json';
13
14
  export const SETTINGS_PATH = 'settings.json';
@@ -129,6 +130,16 @@ function diskHash(abs, rel) {
129
130
  return sha256(readFileSync(abs));
130
131
  }
131
132
 
133
+ const exists = (abs) => {
134
+ try {
135
+ lstatSync(abs);
136
+ return true;
137
+ } catch (e) {
138
+ if (e.code === 'ENOENT') return false;
139
+ throw e;
140
+ }
141
+ };
142
+
132
143
  /**
133
144
  * Decides what to do for every file and for settings.json. Reads, never writes.
134
145
  * Throws on any unsafe path or invalid input, so a failing plan leaves the repo untouched.
@@ -138,9 +149,12 @@ function diskHash(abs, rel) {
138
149
  * @param {ReturnType<typeof renderTargets>} p.rendered
139
150
  * @param {{ files: Record<string, string>, hooks: object[] }} p.lock `{ files: {}, hooks: [] }` for init
140
151
  * @param {{ event: string, entry: object }[]} p.defs
152
+ * @param {boolean} [p.adopt] `init --adopt`: existing files that differ are kept, their lock entry is the RENDERED hash,
153
+ * and the result carries `adoption` (identical / differing with diff stats / created). Nothing else changes.
141
154
  */
142
- export function plan({ claudeDir, rendered, lock, defs }) {
155
+ export function plan({ claudeDir, rendered, lock, defs, adopt = false }) {
143
156
  const actions = [];
157
+ const adoption = { identical: [], differing: [], created: [] };
144
158
  const files = {};
145
159
  const wanted = new Set(rendered.map((t) => t.target));
146
160
 
@@ -151,17 +165,27 @@ export function plan({ claudeDir, rendered, lock, defs }) {
151
165
  const write = (kind) => { actions.push({ kind, target: t.target, abs, content: t.content, mode: t.mode }); files[t.target] = t.hash; };
152
166
  if (disk === undefined) {
153
167
  write(locked === undefined ? 'create' : 'restore');
168
+ adoption.created.push(t.target);
154
169
  } else if (disk === t.hash) {
155
170
  files[t.target] = t.hash; // already what we would write: adopt
171
+ adoption.identical.push(t.target);
156
172
  if ((lstatSync(abs).mode & 0o111) !== (t.mode & 0o111)) actions.push({ kind: 'mode', target: t.target, abs, mode: t.mode });
157
173
  } else if (locked !== undefined && disk === locked) {
158
174
  write('update'); // untouched since the last run
159
175
  } else {
160
176
  // Edited locally (or an unknown file sits there). The old lock hash stays, so it is reported until resolved.
161
177
  if (locked !== undefined) files[t.target] = locked;
162
- if (locked !== undefined && t.hash === locked) continue; // nothing new upstream, nothing to merge
163
178
  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 });
179
+ const review = { kind: 'review', target: t.target, newTarget: `${t.target}${NEW_SUFFIX}`, newAbs, content: t.content };
180
+ if (adopt) {
181
+ // Adopted: the lock records the rendered hash, so `update` reports the file for as long as its .wavework-new exists.
182
+ files[t.target] = t.hash;
183
+ review.stat = lineDiffStat(readFileSync(abs, 'utf8'), t.content);
184
+ adoption.differing.push({ target: t.target, ...review.stat });
185
+ } else if (locked !== undefined && t.hash === locked && !exists(newAbs)) {
186
+ continue; // nothing new upstream, nothing to merge (a still existing .wavework-new means it is not resolved yet)
187
+ }
188
+ actions.push(review);
165
189
  }
166
190
  }
167
191
 
@@ -198,7 +222,7 @@ export function plan({ claudeDir, rendered, lock, defs }) {
198
222
  const changed = JSON.stringify(merged.settings) !== JSON.stringify(current) || (!existed && Object.keys(merged.settings).length > 0);
199
223
  if (changed) actions.push({ kind: 'settings', abs: settingsAbs, content: `${JSON.stringify(merged.settings, null, 2)}\n` });
200
224
 
201
- return { actions, notes, files, hooks: merged.owned, review: actions.filter((a) => a.kind.startsWith('review')).length };
225
+ return { actions, notes, files, adoption, hooks: merged.owned, review: actions.filter((a) => a.kind.startsWith('review')).length };
202
226
  }
203
227
 
204
228
  /** Writes via a temp file and rename, so a symlink that appeared meanwhile is replaced, never written through. */
@@ -248,13 +272,17 @@ const LABEL = {
248
272
  };
249
273
 
250
274
  /** Prints one short line per change; returns the exit code (2 when files need review). */
251
- export function report(result, stdout, { dryRun, done }) {
275
+ export function report(result, stdout, { dryRun, done, adopt = false }) {
252
276
  const prefix = dryRun ? 'dry-run: ' : '';
253
277
  const counts = {};
254
278
  for (const a of result.actions) {
255
279
  counts[a.kind] = (counts[a.kind] ?? 0) + 1;
256
280
  if (a.kind === 'review') {
257
- stdout.write(`${prefix}review ${a.target}: edited locally, kept; new version in ${a.newTarget}\n`);
281
+ const st = a.stat;
282
+ const note = st?.approx ? ', upper bound' : st?.whitespaceOnly ? ', whitespace/newline only' : '';
283
+ const stat = st ? ` (+${st.added} -${st.removed}${note})` : '';
284
+ const why = a.stat ? 'differs from the rendered version, kept untouched; rendered version in' : 'edited locally, kept; new version in';
285
+ stdout.write(`${prefix}review ${a.target}: ${why} ${a.newTarget}${stat}\n`);
258
286
  } else if (a.kind === 'review-removed') {
259
287
  stdout.write(`${prefix}review ${a.target}: removed upstream but edited locally, kept\n`);
260
288
  } else {
@@ -262,6 +290,11 @@ export function report(result, stdout, { dryRun, done }) {
262
290
  }
263
291
  }
264
292
  for (const note of result.notes) stdout.write(`${prefix}${note}\n`);
293
+ if (adopt) {
294
+ const { identical, differing, created } = result.adoption;
295
+ stdout.write(`${prefix}adopt: ${identical.length} identical, ${differing.length} differing, ${created.length} new\n`);
296
+ if (differing.length > 0) stdout.write(`${prefix}for a full diff run: diff <file> <file>${NEW_SUFFIX} (+N -M = lines the rendered version adds / the kept file has extra)\n`);
297
+ }
265
298
  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
299
  if (result.actions.length === 0 && result.notes.length === 0) stdout.write(`${prefix}nothing to do\n`);
267
300
  else if (!dryRun) stdout.write(`${done}\n`);
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ const pkg = JSON.parse(
12
12
  export const USAGE = `Usage: wavework <command> [options]
13
13
 
14
14
  Commands:
15
- init Set up .claude/ in the current repository [--config <file>] [--dry-run]
15
+ init Set up .claude/ in the current repository [--config <file>] [--adopt] [--dry-run]
16
16
  update Re-render .claude/ from the installed version [--dry-run]
17
17
  dashboard Start the pipeline dashboard of this repository (installed by init)
18
18
 
@@ -22,7 +22,7 @@ Options:
22
22
  `;
23
23
 
24
24
  const COMMANDS = {
25
- init: { run: init, options: { '--config': 'value', '--dry-run': 'flag' } },
25
+ init: { run: init, options: { '--config': 'value', '--adopt': 'flag', '--dry-run': 'flag' } },
26
26
  update: { run: update, options: { '--dry-run': 'flag' } },
27
27
  dashboard: { run: dashboard, options: {} },
28
28
  };