wavework 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +135 -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/hooks.mjs +109 -0
- package/src/cli/init.mjs +95 -0
- package/src/cli/safe-path.mjs +63 -0
- package/src/cli/sync.mjs +269 -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/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
|
+
}
|
package/src/targets.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which template becomes which file in a target repository. Pure: no file system access.
|
|
3
|
+
*
|
|
4
|
+
* Contract (consumed by the init/update CLI):
|
|
5
|
+
*
|
|
6
|
+
* templateTargets(config) -> [{ template, target, vars, mode }]
|
|
7
|
+
* template path below `templates/` (always `/`-separated), e.g. `skills/next-task/SKILL.md`
|
|
8
|
+
* target path relative to the repository root, e.g. `.claude/skills/next-task/SKILL.md`
|
|
9
|
+
* vars extra template variables, merged OVER buildContext(config, options); `{}` for most templates
|
|
10
|
+
* mode file mode: 0o644 for text, 0o755 for scripts
|
|
11
|
+
* verbatim optional, `true`: copy the template byte for byte instead of rendering it (JavaScript/CSS
|
|
12
|
+
* that may contain braces). Only the dashboard runtime uses it.
|
|
13
|
+
*
|
|
14
|
+
* One entry per file to write, for ALL templates (skills, agents, scripts). The agent template
|
|
15
|
+
* `agents/implementer.md` appears once per implementer model (the keys of `models.implementers.rules`
|
|
16
|
+
* plus `models.implementers.default`), each with `vars.agent = { model, name, description, rule, isDefault, defaultName }` (`description` is a quoted YAML scalar).
|
|
17
|
+
* With `dashboard.enabled` there is one more entry per file of `templates/dashboard/` (DASHBOARD_FILES, verbatim,
|
|
18
|
+
* target `.claude/wavework/dashboard/<path>`) plus `.claude/wavework/dashboard/config.json` rendered from
|
|
19
|
+
* `templates/dashboard-config.json` (port, tokensFile). Disabled: none of them, so `update` removes them.
|
|
20
|
+
* Targets are unique. A model name that is not safe as a file name throws.
|
|
21
|
+
*
|
|
22
|
+
* renderTargets(config, options) -> [{ target, content, mode }]
|
|
23
|
+
* Renders every entry with buildContext(config, options) (same `options` as buildContext) plus `vars`.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { buildContext, implementerAgents } from './context.mjs';
|
|
28
|
+
import { render } from './render.mjs';
|
|
29
|
+
|
|
30
|
+
export const SKILLS = [
|
|
31
|
+
'next-task', 'next-task-batch', 'next-task-beads', 'next-task-branch', 'next-task-code',
|
|
32
|
+
'next-task-deps', 'next-task-e2e', 'next-task-findings', 'next-task-finish', 'next-task-implement',
|
|
33
|
+
'next-task-plan', 'next-task-review', 'next-task-test', 'next-task-write-tests',
|
|
34
|
+
'text-to-task', 'watch-ci',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Where the dashboard runtime lives in a repository. Skills and hooks call it by this path (tests keep them in sync). */
|
|
38
|
+
export const DASHBOARD_DIR = '.claude/wavework/dashboard';
|
|
39
|
+
/** Files of templates/dashboard/ (test/targets.test.mjs checks the list against the directory). */
|
|
40
|
+
export const DASHBOARD_FILES = [
|
|
41
|
+
'README.md', 'pricing.json', 'record.mjs', 'server.mjs',
|
|
42
|
+
'hooks/session-start.mjs', 'hooks/subagent-stop.mjs',
|
|
43
|
+
'lib/ci.mjs', 'lib/derive.mjs', 'lib/settings.mjs', 'lib/tokens.mjs',
|
|
44
|
+
'public/app.css', 'public/app.js', 'public/default-theme.css', 'public/format.mjs', 'public/index.html',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SCRIPTS = ['red-green-check.sh', 'red-green-check.test.sh'];
|
|
48
|
+
const AGENT_TEMPLATE = 'agents/implementer.md';
|
|
49
|
+
const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
50
|
+
|
|
51
|
+
/** A JSON string is a valid YAML double-quoted scalar, so rule texts with ": ", quotes or "#" cannot break the frontmatter. */
|
|
52
|
+
function agentDescription(rule) {
|
|
53
|
+
return JSON.stringify(rule
|
|
54
|
+
? `Implements a planned beads task. Scope: ${rule} Pass the bead ID.`
|
|
55
|
+
: 'Implements a planned beads task. Default implementer for work that no other model rule covers. Pass the bead ID.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function templateTargets(config) {
|
|
59
|
+
const entries = SKILLS.map((name) => ({
|
|
60
|
+
template: `skills/${name}/SKILL.md`,
|
|
61
|
+
target: `.claude/skills/${name}/SKILL.md`,
|
|
62
|
+
vars: {},
|
|
63
|
+
mode: 0o644,
|
|
64
|
+
}));
|
|
65
|
+
const agents = implementerAgents(config);
|
|
66
|
+
const defaultName = `implementer-${config.models.implementers.default}`;
|
|
67
|
+
for (const { model, rule, isDefault } of agents) {
|
|
68
|
+
if (!SAFE_MODEL.test(model)) throw new Error(`implementer model "${model}" cannot be used as an agent file name (allowed: letters, digits, ".", "_", "-")`);
|
|
69
|
+
entries.push({
|
|
70
|
+
template: AGENT_TEMPLATE,
|
|
71
|
+
target: `.claude/agents/implementer-${model}.md`,
|
|
72
|
+
vars: { agent: { model, name: `implementer-${model}`, description: agentDescription(rule), rule: rule ?? '', isDefault, defaultName } },
|
|
73
|
+
mode: 0o644,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const name of SCRIPTS) {
|
|
77
|
+
entries.push({ template: `scripts/${name}`, target: `.claude/scripts/${name}`, vars: {}, mode: 0o755 });
|
|
78
|
+
}
|
|
79
|
+
if (config.dashboard?.enabled) {
|
|
80
|
+
for (const name of DASHBOARD_FILES) {
|
|
81
|
+
entries.push({ template: `dashboard/${name}`, target: `${DASHBOARD_DIR}/${name}`, vars: {}, mode: 0o644, verbatim: true });
|
|
82
|
+
}
|
|
83
|
+
const settings = { port: config.dashboard.port, tokensFile: config.dashboard.tokensFile ?? null };
|
|
84
|
+
entries.push({
|
|
85
|
+
template: 'dashboard-config.json',
|
|
86
|
+
target: `${DASHBOARD_DIR}/config.json`,
|
|
87
|
+
vars: { dashboardConfigJson: JSON.stringify(settings, null, 2) },
|
|
88
|
+
mode: 0o644,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderTargets(config, options = {}) {
|
|
95
|
+
const context = buildContext(config, options);
|
|
96
|
+
return templateTargets(config).map(({ template, target, vars, mode, verbatim }) => {
|
|
97
|
+
const text = readFileSync(fileURLToPath(new URL(`../templates/${template}`, import.meta.url)), 'utf8');
|
|
98
|
+
return { target, content: verbatim === true ? text : render(text, { ...context, ...vars }, template), mode };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: {{agent.name}}
|
|
3
|
+
description: {{agent.description}}
|
|
4
|
+
model: {{agent.model}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You implement exactly one planned beads task in this codebase. You do not plan or redesign.
|
|
8
|
+
|
|
9
|
+
Follow `.claude/skills/next-task-implement/SKILL.md` step by step: check you are in the worktree and on the branch your prompt names (never switch branches), claim the bead, {{#if codeMap}}grep `{{codeMap.path}}` to find files (only when needed), {{/if}}write tests first, implement, e2e where a setup exists, run focused tests via `.claude/skills/next-task-test/SKILL.md`, {{#if codeMap}}update your `{{codeMap.path}}` lines, {{/if}}report out-of-scope findings.
|
|
10
|
+
|
|
11
|
+
Do not commit, push or close the bead: whoever delegated the task verifies your work, commits and closes it. Report back as the skill describes.
|
|
12
|
+
{{#if agent.isDefault}}
|
|
13
|
+
{{else}}
|
|
14
|
+
|
|
15
|
+
If the task turns out larger or riskier than described, or outside the scope in your description, stop and report back so it can go to {{agent.defaultName}}.
|
|
16
|
+
{{/if}}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Pipeline dashboard
|
|
2
|
+
|
|
3
|
+
Installed by `wavework init` / `wavework update` into `.claude/wavework/dashboard/` (managed by the lock file: do not edit, change `dashboard` in `.claude/wavework.json` instead). Dev tooling (Node >= 22, built-ins only, no node_modules) that records and shows `/next-task*` runs. This folder holds the recorder (`record.mjs`), the SubagentStop and SessionStart hooks (`hooks/`), the server (`server.mjs`, `lib/`), the UI (`public/`) and `config.json` (port and tokensFile, written from the config).
|
|
4
|
+
|
|
5
|
+
## Store
|
|
6
|
+
|
|
7
|
+
`$(git rev-parse --git-common-dir)/pipeline-runs/<run-id>/`, shared by every worktree of the repo (never committed, lives inside `.git`). Override with `WAVEWORK_PIPELINE_STORE=<dir>` (used by tests).
|
|
8
|
+
|
|
9
|
+
- `run.json`: summary, replaced atomically (write temp file, rename).
|
|
10
|
+
- `events.jsonl`: append-only, one JSON object per line, written with `O_APPEND` so parallel sub-agents do not interleave lines.
|
|
11
|
+
|
|
12
|
+
`run.json` fields: `id`, `kind` (`next-task|beads|batch`), `units` (epic/bead ids), `parent` (run id of the batch, for batch members), `branch`, `worktree`, `status` (`running|done|blocked|failed`), `started`, `ended` (ISO timestamps, `ended` is null while running).
|
|
13
|
+
|
|
14
|
+
## CLI
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
node .claude/wavework/dashboard/record.mjs <event> [--run <id>] [--key value ...]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Without `--run` the run is found by cwd: the running run whose `worktree` contains the current directory (deepest worktree wins). `--units a,b` is a list, known numeric keys are stored as numbers, `--some-key` becomes `someKey`. Run ids match `[A-Za-z0-9][A-Za-z0-9._-]*` and are at most 128 characters. `readRun` applies the same check and returns null for anything else, so URL params can be passed in safely.
|
|
21
|
+
|
|
22
|
+
Recording never blocks the pipeline: any error prints one `pipeline-record: ...` line to stderr and the exit code is 0.
|
|
23
|
+
|
|
24
|
+
## Events
|
|
25
|
+
|
|
26
|
+
Every event has `ts` (ISO), `type` and `run`, plus:
|
|
27
|
+
|
|
28
|
+
| type | fields |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `run-start` | `kind`, `units`, `parent`, `branch`, `worktree` (creates `run.json`) |
|
|
31
|
+
| `run-end` | `status` (`done` default, `blocked`, `failed`; updates `run.json`) |
|
|
32
|
+
| `step-start`, `step-end` | `step` (`deps`, `plan`, `wave`, `review`, `finish`, `push`), `wave` (on `wave` steps; start and end are paired by step + wave), `sha` (on `step-end --step push`) |
|
|
33
|
+
| `child-start`, `child-end` | `bead`, `model`, `wave`, `result`, `redBeforeGreen` (evidence) |
|
|
34
|
+
| `review` | `verdict`, `mustFix` (count), `round` |
|
|
35
|
+
| `tests` | `scope`, `passed`, `failed`, `duration` |
|
|
36
|
+
| `finding` | `bead` (id of the finding bead) |
|
|
37
|
+
| `agent` | `agentId`, `agentType`, `bead` (from the first user prompt, after the word "bead"; absent if none), `model`, `durationMs`, `tokensIn`, `tokensOut`, `tokensCacheRead`, `tokensCacheWrite`, `cost` (estimate); written by the SubagentStop hook |
|
|
38
|
+
|
|
39
|
+
Other keys passed on the command line are stored as given, except `ts`, `type` and `run`, which are always set by the recorder.
|
|
40
|
+
|
|
41
|
+
`run-start` for an existing id rewrites `run.json` from scratch (status back to `running`); events stay appended. `run-end` without a prior `run-start` creates a minimal `run.json` (`started` null).
|
|
42
|
+
|
|
43
|
+
## Agent events and pricing
|
|
44
|
+
|
|
45
|
+
The `agent` events come from the SubagentStop hook (`hooks/subagent-stop.mjs`, registered in `.claude/settings.json` by wavework). Claude Code passes `cwd`, `agent_id`, `agent_type` and `agent_transcript_path` on stdin. The hook reads the sub-agent transcript: usage is taken from assistant messages (`message.usage`, `message.model`), deduplicated by message id (streamed content blocks repeat the id; the max per field wins), duration is last minus first timestamp. The run is found by cwd (falling back to the cwd recorded in the transcript); no running run means nothing is written. The hook always exits 0 and prints nothing to stdout.
|
|
46
|
+
|
|
47
|
+
`pricing.json` holds USD per million tokens (`input`, `output`, `cacheRead`, `cacheWrite`) per model id. The cost is the sum over messages, each priced with its own model. If any message's model is missing from the table, the event has no `cost` field (never 0). Prices are estimates: cache write is the 5-minute rate (1.25x input), and Haiku uses the rate card for prompts up to 100K tokens.
|
|
48
|
+
|
|
49
|
+
## Importing
|
|
50
|
+
|
|
51
|
+
`record.mjs` exports `resolveStoreDir`, `findRunByCwd`, `recordEvent`, `readRun`, `listRuns`, `parseArgs`, `main`, `EVENT_TYPES`, `RUN_ID`, so the server and hooks reuse store resolution and lookup.
|
|
52
|
+
|
|
53
|
+
## SessionStart hook
|
|
54
|
+
|
|
55
|
+
`hooks/session-start.mjs` is registered in `.claude/settings.json` by wavework. At session start it checks `GET /api/health` on the dashboard port (`PIPELINE_DASHBOARD_PORT`, else `config.json`, else 4690) (300 ms timeout):
|
|
56
|
+
|
|
57
|
+
- Health answers `{"ok":true, "root":"<dir>"}` and `root` exists: prints nothing.
|
|
58
|
+
- Health answers but its `root` (the checkout `server.mjs` runs from) no longer exists, e.g. a deleted worktree: stops that server via `<store>/server.pid` and starts a fresh one. The pid file is `{"pid","port","root"}`. The hook kills the recorded pid only when port and root match the answering server and `lsof` lists that pid as the single listener on the port while `ps` shows that record's own `<root>/server.mjs` (so another repo's dashboard is never touched) (a recorded pid alone is not proof, pids get reused). If the record does not match or either check cannot run, it prints one warning and kills nothing.
|
|
59
|
+
- Nothing listens: starts `server.mjs` detached (`stdio` to `<store>/server.log`, pid file `<store>/server.pid`) and prints `{"systemMessage":"Pipeline dashboard: http://127.0.0.1:<port>"}` once it answers. The server runs from the main checkout's `.claude/wavework/dashboard/server.mjs` (main checkout = parent of `git rev-parse --path-format=absolute --git-common-dir`) when that exists, else from the copy the hook belongs to. So the long-lived server does not depend on a worktree that `/next-task-finish` later deletes.
|
|
60
|
+
- Another process holds the port: prints one `systemMessage` warning and starts nothing.
|
|
61
|
+
- Nothing answers within the start wait (2 s) after the spawn: prints `{"systemMessage":"Pipeline dashboard: could not start, see <store>/server.log"}`. The spawned pid is recorded at spawn, so a server that is still starting stays stoppable.
|
|
62
|
+
|
|
63
|
+
`PIPELINE_DASHBOARD_START_WAIT_MS` overrides the start wait. It is a test-only knob (tests set it near zero to force the "gave up waiting" path); do not set it in normal use.
|
|
64
|
+
|
|
65
|
+
A pid record is not overwritten while it names a live server on the same port, and a spawned child clears the record only if it exits without ever answering health.
|
|
66
|
+
|
|
67
|
+
Two sessions starting together start one server: an exclusive `<store>/server.starting` lock lets only one hook spawn; the other waits for the server. A lock older than 5 s (crashed hook) is taken over by renaming it to a unique name first (rename is atomic, so only one hook moves a given lock); a fresh lock moved by mistake is linked back.
|
|
68
|
+
|
|
69
|
+
Timing: the hook always exits 0 and has a worst case of about 1.5 s on the stale-server replace path (probe 300 ms, `lsof`/`ps` checks with a 500 ms timeout each, stop wait 250 ms, start wait 600 ms, probes of 150 ms, plus node start-up). Output format: `systemMessage` on stdout JSON, per the Claude Code hooks docs. The 5 s `timeout` in `.claude/settings.json` is only a hard kill.
|
|
70
|
+
|
|
71
|
+
## Server/API
|
|
72
|
+
|
|
73
|
+
`wavework dashboard` (or `node .claude/wavework/dashboard/server.mjs`) binds `127.0.0.1` only, port `PIPELINE_DASHBOARD_PORT`, else `config.json` (`dashboard.port`), else 4690, reads the store via `resolveStoreDir`. `createServer({storeDir, port, publicDir, tokensPath, project, ghRunner, ciRefreshMs, watch, pollMs})` is the factory used by tests (`port: 0`, stubbed `ghRunner`; `await server.listen()`, `server.port`, `await server.close()`).
|
|
74
|
+
|
|
75
|
+
- `GET /api/health`: `{ok:true, root}`; `root` is the directory `server.mjs` runs from (the SessionStart hook uses it to spot a server whose checkout is gone).
|
|
76
|
+
- `GET /api/runs`: run summaries (`run.json` fields + `totals`), newest `started` first; batch members (runs whose `parent` is another run) are nested in the batch's `children`.
|
|
77
|
+
- `GET /api/runs/:id`: `{run, events, timeline, ci}`. `timeline` = `steps` (paired step-start/end with `durationMs`, `sha` on push), `children` (per bead: model, wave, result, `redBeforeGreen`, `durationMs`, `agents` summed from `agent` events by `bead`, else by the child's time window), `reviews`, `tests`, `totals` (tokens, `cost`, `agents`, `durationMs`; `cost` is null when there are no agents or any agent has no cost, never 0 or a partial sum), `sha`. Invalid id (not the run-id pattern, `../`, encoded `%2e%2e%2f`, `%2F`, NUL) is 400, unknown id 404.
|
|
78
|
+
- A batch that is still `running` but whose members have all ended is reported as ended (`blocked` if any member is blocked/failed, else `done`, `ended` = latest member end) in both endpoints.
|
|
79
|
+
- Requests need a `Host` of `127.0.0.1:<port>` or `localhost:<port>`, else 403 (DNS-rebinding guard).
|
|
80
|
+
- `GET /api/events`: Server-Sent Events, `event: run-changed` with `data: {"id": "<run id>"}` when a store file changes (`fs.watch` recursive as trigger, polling fallback, also if the store does not exist yet).
|
|
81
|
+
- `GET /api/info`: `{project}`, the repo directory name (the main checkout), shown in the UI.
|
|
82
|
+
- `/tokens.css` is the project's own tokens file (`dashboard.tokensFile`, resolved inside the repo, symlinks leaving it refused) or an empty stylesheet when none is configured; everything else is served from `public/` only (resolved, symlinks and traversal refused).
|
|
83
|
+
|
|
84
|
+
**CI status:** the pushed SHA is the `sha` field of the last `step-end` event with `step=push` (the skill emits `record.mjs step-end --step push --sha <sha>`). For such runs, `ci` is `{sha, state, runs[]}` from `gh run list --commit <sha> --json status,conclusion,url,name` (execFile with an argument list, SHA must be 7-40 hex chars). `state` is `success|failure|pending|none|unknown`; missing/failing `gh` gives `unknown`. Results are cached per SHA; only `success` is cached for good, everything else (pending, failure because CI may be re-run, none, unknown) is refreshed after `ciRefreshMs` (15 s). `ci` is null without a pushed SHA.
|
|
85
|
+
|
|
86
|
+
## UI
|
|
87
|
+
|
|
88
|
+
Open `http://127.0.0.1:4690/` (your port) after starting the server. Plain HTML/CSS/JS in `public/` (no build): `index.html`, `default-theme.css` (neutral tokens, light and dark), `app.css` (design tokens only, `prefers-reduced-motion`; the project's `/tokens.css` loads after the default theme and may override any token), `app.js` (hash routing, SSE) and `format.mjs` (pure helpers).
|
|
89
|
+
|
|
90
|
+
- `#/` lists all runs: running first, then newest; batch members are nested under their batch. It refreshes on every `run-changed` SSE event, no reload. The header shows `Live` or `Reconnecting`.
|
|
91
|
+
- `#/runs/<id>` is a deep link to a run: header stats, step timeline, children (model, wave, duration, tokens, cost, result, review verdict and rounds, red-before-green), reviews, tests, findings filed and CI links.
|
|
92
|
+
- Costs are estimates (marked as such); an unknown cost is shown as `—`, never `$0.00`.
|
|
93
|
+
- The list view fetches `/api/runs/:id` per run to get the CI state (the list endpoint has no `ci`).
|
|
94
|
+
- Untrusted strings are rendered with `textContent`; CI links must be http(s).
|
|
95
|
+
|
|
96
|
+
Tests live in the wavework package (`test/dashboard/`), not in the target repository.
|