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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/wavework.mjs +7 -0
- package/examples/shipir.wavework.json +54 -0
- package/package.json +35 -4
- package/src/cli/args.mjs +28 -0
- package/src/cli/dashboard.mjs +41 -0
- package/src/cli/diff-stat.mjs +41 -0
- package/src/cli/hooks.mjs +109 -0
- package/src/cli/init.mjs +104 -0
- package/src/cli/safe-path.mjs +63 -0
- package/src/cli/sync.mjs +302 -0
- package/src/cli/update.mjs +24 -0
- package/src/cli.mjs +78 -0
- package/src/config.mjs +313 -0
- package/src/context.mjs +133 -0
- package/src/render.mjs +148 -0
- package/src/targets.mjs +100 -0
- package/templates/agents/implementer.md +16 -0
- package/templates/dashboard/README.md +96 -0
- package/templates/dashboard/hooks/session-start.mjs +305 -0
- package/templates/dashboard/hooks/subagent-stop.mjs +129 -0
- package/templates/dashboard/lib/ci.mjs +52 -0
- package/templates/dashboard/lib/derive.mjs +105 -0
- package/templates/dashboard/lib/settings.mjs +34 -0
- package/templates/dashboard/lib/tokens.mjs +21 -0
- package/templates/dashboard/pricing.json +11 -0
- package/templates/dashboard/public/app.css +134 -0
- package/templates/dashboard/public/app.js +260 -0
- package/templates/dashboard/public/default-theme.css +86 -0
- package/templates/dashboard/public/format.mjs +138 -0
- package/templates/dashboard/public/index.html +20 -0
- package/templates/dashboard/record.mjs +144 -0
- package/templates/dashboard/server.mjs +254 -0
- package/templates/dashboard-config.json +1 -0
- package/templates/scripts/red-green-check.sh +287 -0
- package/templates/scripts/red-green-check.test.sh +151 -0
- package/templates/settings/hooks.json +31 -0
- package/templates/skills/next-task/SKILL.md +77 -0
- package/templates/skills/next-task-batch/SKILL.md +27 -0
- package/templates/skills/next-task-beads/SKILL.md +29 -0
- package/templates/skills/next-task-branch/SKILL.md +19 -0
- package/templates/skills/next-task-code/SKILL.md +22 -0
- package/templates/skills/next-task-deps/SKILL.md +31 -0
- package/templates/skills/next-task-e2e/SKILL.md +35 -0
- package/templates/skills/next-task-findings/SKILL.md +65 -0
- package/templates/skills/next-task-finish/SKILL.md +43 -0
- package/templates/skills/next-task-implement/SKILL.md +34 -0
- package/templates/skills/next-task-plan/SKILL.md +52 -0
- package/templates/skills/next-task-review/SKILL.md +61 -0
- package/templates/skills/next-task-test/SKILL.md +30 -0
- package/templates/skills/next-task-write-tests/SKILL.md +34 -0
- package/templates/skills/text-to-task/SKILL.md +78 -0
- package/templates/skills/watch-ci/SKILL.md +42 -0
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
|
+
}
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a validated wavework config (src/config.mjs) into the variables the skill templates use
|
|
3
|
+
* (src/render.mjs). Repeated structures (check areas, model rules, findings epics) are computed here
|
|
4
|
+
* as Markdown, so the template engine needs no loops.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const FINDING_CLASSES = {
|
|
8
|
+
security: { kind: 'Real', typical: 'auth, permission, token, egress, path, subprocess, secret handling, missing re-authorization' },
|
|
9
|
+
bugs: { kind: 'Real', typical: 'wrong behaviour, data loss, races, broken UX flows' },
|
|
10
|
+
tests: { kind: 'Quality', typical: 'flaky tests, missing malicious-case or edge tests, CI gaps' },
|
|
11
|
+
quality: { kind: 'Quality', typical: 'duplication, dead code, naming, design-system gaps, stale docs' },
|
|
12
|
+
};
|
|
13
|
+
const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
|
14
|
+
|
|
15
|
+
const code = (s) => `\`${s}\``;
|
|
16
|
+
const orList = (items) => items.map(code).join(' or ');
|
|
17
|
+
const fill = (pattern, values) => pattern.replace(/\{(\w+)\}/g, (_, key) => values[key]);
|
|
18
|
+
|
|
19
|
+
function worktreeExpression(pattern, repoName) {
|
|
20
|
+
const values = { repo: repoName ?? '$(basename "$MAIN")', unit: '<unit-id>' };
|
|
21
|
+
// `../x` is a sibling of the main checkout; anything else is anchored at the main checkout itself.
|
|
22
|
+
return pattern.startsWith('../')
|
|
23
|
+
? `$(dirname "$MAIN")/${fill(pattern.slice(3), values)}`
|
|
24
|
+
: `$MAIN/${fill(pattern, values)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function areaSection(area) {
|
|
28
|
+
const lines = [
|
|
29
|
+
`## ${area.name}: files under ${area.whenPaths.map(code).join(', ')}`,
|
|
30
|
+
'',
|
|
31
|
+
`- **Focused:** ${code(area.focused)}`,
|
|
32
|
+
`- **Full:** ${code(area.full)}`,
|
|
33
|
+
];
|
|
34
|
+
if (area.lint) lines.push(`- **Lint:** ${code(area.lint)}`);
|
|
35
|
+
if (area.build) lines.push(`- **Build:** ${code(area.build)}`);
|
|
36
|
+
if (area.e2e) lines.push(`- **E2E:** ${code(area.e2e)}`);
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findingsRows(epicTitles) {
|
|
41
|
+
return Object.entries(epicTitles)
|
|
42
|
+
.map(([slug, title]) => {
|
|
43
|
+
const { kind, typical } = FINDING_CLASSES[slug] ?? { kind: 'Quality', typical: '' };
|
|
44
|
+
return `| ${kind} | ${title.replace(/^Findings:\s*/, '')} | ${code(slug)} | ${typical} |`;
|
|
45
|
+
})
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Implementer models in agent order: the default first, then the other rule keys. The default is included
|
|
51
|
+
* even without a rule of its own (rule is then null).
|
|
52
|
+
*
|
|
53
|
+
* @param {object} config validated config
|
|
54
|
+
* @returns {{model: string, rule: string|null, isDefault: boolean}[]}
|
|
55
|
+
*/
|
|
56
|
+
export function implementerAgents(config) {
|
|
57
|
+
const { default: fallback, rules } = config.models.implementers;
|
|
58
|
+
return [fallback, ...Object.keys(rules).filter((m) => m !== fallback)].map((model) => ({
|
|
59
|
+
model,
|
|
60
|
+
rule: Object.hasOwn(rules, model) ? rules[model] : null,
|
|
61
|
+
isDefault: model === fallback,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const AREA_KINDS = [['maven', /\bmvnw?\b/], ['vitest', /\bvitest\b/]];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One line per check area for the `AREAS` table of scripts/red-green-check.sh: `'name|kind|dir'`. The kind
|
|
69
|
+
* (maven, vitest) is guessed from the area's focused command; anything else is `unsupported` and the script
|
|
70
|
+
* prints `SKIP <name>: unsupported` for it.
|
|
71
|
+
*/
|
|
72
|
+
function redGreenAreas(checks) {
|
|
73
|
+
return checks.map((a) => {
|
|
74
|
+
for (const field of [a.name, a.dir]) {
|
|
75
|
+
if (/[|'\n]/.test(field)) throw new Error(`area "${a.name}": name and dir must not contain "|", "'" or newlines (red-green-check.sh area table)`);
|
|
76
|
+
}
|
|
77
|
+
const kind = AREA_KINDS.find(([, re]) => re.test(a.focused))?.[0] ?? 'unsupported';
|
|
78
|
+
return `'${a.name}|${kind}|${a.dir.replace(/\/+$/, '') || '.'}'`;
|
|
79
|
+
}).join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} config validated config (see validateConfig)
|
|
84
|
+
* @param {object} [options]
|
|
85
|
+
* @param {string} [options.repoName] repository directory name for the worktree path; the skill asks the shell when absent
|
|
86
|
+
* @returns {object} template variables
|
|
87
|
+
*/
|
|
88
|
+
export function buildContext(config, options = {}) {
|
|
89
|
+
const { repoName } = options;
|
|
90
|
+
const { worktree, models, checks } = config;
|
|
91
|
+
const rules = models.implementers.rules;
|
|
92
|
+
const labels = Object.keys(rules);
|
|
93
|
+
if (!labels.includes(models.implementers.default)) labels.push(models.implementers.default);
|
|
94
|
+
const agents = implementerAgents(config).map((a) => a.model);
|
|
95
|
+
const branch = (values) => fill(worktree.branchPattern, values);
|
|
96
|
+
const withE2e = checks.filter((a) => a.e2e);
|
|
97
|
+
const titles = config.findings.epicTitles;
|
|
98
|
+
const count = Object.keys(titles).length;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
baseBranch: config.baseBranch,
|
|
102
|
+
repoName: repoName ?? '',
|
|
103
|
+
worktreeExpr: worktreeExpression(worktree.pathPattern, repoName),
|
|
104
|
+
worktreeExample: fill(worktree.pathPattern, { repo: repoName ?? 'repo', unit: 'shipr-cxk' }),
|
|
105
|
+
branchExpr: branch({ prefix: '<prefix>', unit: '<unit-id>', slug: '<slug>' }),
|
|
106
|
+
epicBranch: branch({ prefix: 'epic', unit: '<epic-id>', slug: '<slug>' }),
|
|
107
|
+
branchGlob: branch({ prefix: '<prefix>', unit: '<unit-id>', slug: '*' }),
|
|
108
|
+
taskBranchExample: branch({ prefix: 'task', unit: 'shipr-hae', slug: 'event-worker-interrupt' }),
|
|
109
|
+
models,
|
|
110
|
+
orchestrationModelName: models.orchestration.charAt(0).toUpperCase() + models.orchestration.slice(1),
|
|
111
|
+
modelLabelsOr: orList(labels.map((l) => `model:${l}`)),
|
|
112
|
+
modelLabelPipe: labels.join('|'),
|
|
113
|
+
implementerModelPipe: agents.join('|'),
|
|
114
|
+
implementerAgentsPlain: agents.map((a) => `implementer-${a}`).join(' and '),
|
|
115
|
+
implementerAgentsOr: orList(agents.map((a) => `implementer-${a}`)),
|
|
116
|
+
modelRules: Object.entries(rules).map(([model, text]) => `- ${code(model)}: ${text}`).join('\n'),
|
|
117
|
+
areaNamesPipe: checks.map((a) => a.name).join('|'),
|
|
118
|
+
checksSection: checks.map(areaSection).join('\n\n'),
|
|
119
|
+
depsSection: checks.map((a) => `- ${code(a.name)} (${code(a.dir)}, only if it exists)`).join('\n'),
|
|
120
|
+
fullChecksList: checks.map((a) => `- ${code(a.name)}: ${code(a.full)}`).join('\n'),
|
|
121
|
+
hasLint: checks.some((a) => a.lint),
|
|
122
|
+
hasE2e: withE2e.length > 0,
|
|
123
|
+
e2eList: withE2e.map((a) => `- ${code(a.name)}: ${code(a.e2e)}`).join('\n'),
|
|
124
|
+
codeMap: config.codeMap ?? false,
|
|
125
|
+
uiSkill: config.uiSkill ?? false,
|
|
126
|
+
uiPaths: config.uiSkill ? config.uiSkill.appliesTo.map(code).join(', ') : '',
|
|
127
|
+
findingsEpics: findingsRows(titles),
|
|
128
|
+
findingsEpicCount: NUMBER_WORDS[count] ?? String(count),
|
|
129
|
+
redGreenAreas: redGreenAreas(checks),
|
|
130
|
+
noAttribution: !config.attribution.allowAiTrailer,
|
|
131
|
+
dashboard: config.dashboard.enabled,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal template engine for the skill templates. No dependencies, no escaping (output is Markdown).
|
|
3
|
+
*
|
|
4
|
+
* Syntax:
|
|
5
|
+
* {{path.to.var}} value of a context variable (string or number)
|
|
6
|
+
* {{#if key}} ... {{/if}} body when truthy: true, non-empty string, non-empty array, any object
|
|
7
|
+
* {{#if key}} ... {{else}} ... {{/if}}
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Unknown variables (also in `#if`) are errors, so typos never render as empty text.
|
|
11
|
+
* - Blocks must balance, also in branches that are not taken.
|
|
12
|
+
* - A block tag alone on its line removes the whole line, so blocks leave no blank lines.
|
|
13
|
+
* - A variable alone on an indented line indents every line of a multi-line value the same way.
|
|
14
|
+
* - There is no escape for a literal `{{` or `}}`; a stray one is an error.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
18
|
+
const TAG = /\{\{([^{}]*)\}\}/g;
|
|
19
|
+
|
|
20
|
+
function fail(name, line, message) {
|
|
21
|
+
return new Error(`template "${name}", line ${line}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
25
|
+
|
|
26
|
+
/** Splits the template into text and tag tokens; block tags alone on a line swallow the whole line. */
|
|
27
|
+
function tokenize(text, name) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
let pos = 0;
|
|
30
|
+
const pushText = (end) => {
|
|
31
|
+
const chunk = text.slice(pos, end);
|
|
32
|
+
if (/\{\{|\}\}/.test(chunk)) {
|
|
33
|
+
const at = pos + chunk.search(/\{\{|\}\}/);
|
|
34
|
+
throw fail(name, lineOf(text, at), `stray "${text.slice(at, at + 2)}" (no escape exists for literal braces)`);
|
|
35
|
+
}
|
|
36
|
+
if (chunk) tokens.push({ type: 'text', value: chunk });
|
|
37
|
+
};
|
|
38
|
+
for (const match of text.matchAll(TAG)) {
|
|
39
|
+
const inner = match[1].trim();
|
|
40
|
+
const line = lineOf(text, match.index);
|
|
41
|
+
let start = match.index;
|
|
42
|
+
let end = match.index + match[0].length;
|
|
43
|
+
let token;
|
|
44
|
+
if (inner === '') throw fail(name, line, 'empty tag');
|
|
45
|
+
if (inner.startsWith('#if ')) {
|
|
46
|
+
const key = inner.slice(4).trim();
|
|
47
|
+
if (!NAME.test(key)) throw fail(name, line, `invalid tag "{{${inner}}}"`);
|
|
48
|
+
token = { type: 'if', key, line };
|
|
49
|
+
} else if (inner === 'else' || inner === '/if') {
|
|
50
|
+
token = { type: inner, line };
|
|
51
|
+
} else if (NAME.test(inner)) {
|
|
52
|
+
token = { type: 'var', key: inner, line };
|
|
53
|
+
} else {
|
|
54
|
+
throw fail(name, line, `invalid tag "{{${inner}}}"`);
|
|
55
|
+
}
|
|
56
|
+
if (token.type !== 'var') {
|
|
57
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
58
|
+
const nl = text.indexOf('\n', end);
|
|
59
|
+
const lineEnd = nl === -1 ? text.length : nl;
|
|
60
|
+
if (/^[ \t]*$/.test(text.slice(lineStart, start)) && /^[ \t]*$/.test(text.slice(end, lineEnd))) {
|
|
61
|
+
start = lineStart;
|
|
62
|
+
end = nl === -1 ? text.length : nl + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pushText(start);
|
|
66
|
+
tokens.push(token);
|
|
67
|
+
pos = end;
|
|
68
|
+
}
|
|
69
|
+
pushText(text.length);
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Builds a tree of { type: 'text' | 'var' | 'if', ... } and checks that blocks balance. */
|
|
74
|
+
function parse(tokens, name) {
|
|
75
|
+
const root = { children: [] };
|
|
76
|
+
const stack = [{ node: root, list: root.children }];
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
const top = stack[stack.length - 1];
|
|
79
|
+
if (token.type === 'text' || token.type === 'var') {
|
|
80
|
+
top.list.push(token);
|
|
81
|
+
} else if (token.type === 'if') {
|
|
82
|
+
const node = { type: 'if', key: token.key, line: token.line, then: [], otherwise: null };
|
|
83
|
+
top.list.push(node);
|
|
84
|
+
stack.push({ node, list: node.then });
|
|
85
|
+
} else if (token.type === 'else') {
|
|
86
|
+
if (stack.length === 1) throw fail(name, token.line, '{{else}} outside of an {{#if}} block');
|
|
87
|
+
if (top.node.otherwise !== null) throw fail(name, token.line, 'more than one {{else}} in one {{#if}} block');
|
|
88
|
+
top.node.otherwise = [];
|
|
89
|
+
top.list = top.node.otherwise;
|
|
90
|
+
} else {
|
|
91
|
+
if (stack.length === 1) throw fail(name, token.line, '{{/if}} without a matching {{#if}}');
|
|
92
|
+
stack.pop();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (stack.length > 1) {
|
|
96
|
+
const open = stack[stack.length - 1].node;
|
|
97
|
+
throw fail(name, open.line, `unclosed block {{#if ${open.key}}}`);
|
|
98
|
+
}
|
|
99
|
+
return root.children;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lookup(context, key, name, line) {
|
|
103
|
+
let value = context;
|
|
104
|
+
for (const part of key.split('.')) {
|
|
105
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, part)) {
|
|
106
|
+
throw fail(name, line, `unknown variable "${key}"`);
|
|
107
|
+
}
|
|
108
|
+
value = value[part];
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function truthy(value) {
|
|
114
|
+
if (Array.isArray(value) || typeof value === 'string') return value.length > 0;
|
|
115
|
+
return Boolean(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} template
|
|
120
|
+
* @param {object} context variables; see src/context.mjs
|
|
121
|
+
* @param {string} [name] template name for error messages
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function render(template, context, name = '<template>') {
|
|
125
|
+
const tree = parse(tokenize(template, name), name);
|
|
126
|
+
let out = '';
|
|
127
|
+
const walk = (nodes) => {
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
if (node.type === 'text') {
|
|
130
|
+
out += node.value;
|
|
131
|
+
} else if (node.type === 'var') {
|
|
132
|
+
const value = lookup(context, node.key, name, node.line);
|
|
133
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
134
|
+
throw fail(name, node.line, `variable "${node.key}" is not a string or number`);
|
|
135
|
+
}
|
|
136
|
+
const tail = out.slice(out.lastIndexOf('\n') + 1);
|
|
137
|
+
const indent = /^[ \t]+$/.test(tail) ? tail : '';
|
|
138
|
+
out += String(value).split('\n').join(`\n${indent}`);
|
|
139
|
+
} else if (truthy(lookup(context, node.key, name, node.line))) {
|
|
140
|
+
walk(node.then);
|
|
141
|
+
} else if (node.otherwise !== null) {
|
|
142
|
+
walk(node.otherwise);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
walk(tree);
|
|
147
|
+
return out;
|
|
148
|
+
}
|