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 +12 -1
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/cli/diff-stat.mjs +41 -0
- package/src/cli/init.mjs +14 -5
- package/src/cli/sync.mjs +39 -6
- package/src/cli.mjs +2 -2
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.
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|