criteria-review 0.3.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/LICENSE +21 -0
- package/README.md +299 -0
- package/agents/README.md +123 -0
- package/agents/skills/claude-code/criteria-lookup/SKILL.md +236 -0
- package/bin/criteria-review.js +870 -0
- package/docs/decisions.md +310 -0
- package/docs/help/01-getting-started.md +170 -0
- package/docs/standard/00-roles-and-ownership.md +353 -0
- package/docs/standard/01-qa-approach.md +273 -0
- package/docs/standard/02-writing-acceptance-criteria.md +187 -0
- package/docs/standard/03-example-acceptance-document.md +244 -0
- package/docs/standard/04-playwright-journeys.md +329 -0
- package/docs/standard/05-example-journey.spec.ts +130 -0
- package/docs/standard/06-engineering-obligations.md +170 -0
- package/docs/standard/07-manual-qa-and-defects.md +138 -0
- package/docs/standard/08-definition-of-done.md +128 -0
- package/docs/standard/09-recording-journeys.md +644 -0
- package/docs/standard/README.md +70 -0
- package/docs/standard/adopting-the-standard.md +104 -0
- package/docs/standard/emit-contract.md +175 -0
- package/package.json +41 -0
- package/public/app.js +791 -0
- package/public/doc-name.js +35 -0
- package/public/index.html +100 -0
- package/public/markdown.js +0 -0
- package/public/queue-order.js +78 -0
- package/public/styles.css +414 -0
- package/src/config.js +104 -0
- package/src/emit.js +183 -0
- package/src/media.js +126 -0
- package/src/parse.js +298 -0
- package/src/scan.js +158 -0
- package/src/server.js +433 -0
- package/src/standard.js +174 -0
- package/src/version.js +19 -0
- package/src/videos.js +185 -0
- package/src/write.js +307 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// criteria-review - review acceptance criteria across projects.
|
|
3
|
+
//
|
|
4
|
+
// Projects are registered once in a config file rather than passed each time, so
|
|
5
|
+
// the command is the same from any working directory. That is the whole point: the
|
|
6
|
+
// review is cross-project, and having to remember paths is friction on the one
|
|
7
|
+
// activity this is trying to make cheap.
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve, basename } from 'node:path';
|
|
13
|
+
import { createReviewServer } from '../src/server.js';
|
|
14
|
+
import { scanAll, changedSince } from '../src/scan.js';
|
|
15
|
+
import { loadSettings, PROJECT_CONFIG } from '../src/config.js';
|
|
16
|
+
import { ejectStandard } from '../src/standard.js';
|
|
17
|
+
import { isUntracked, needsReview } from '../src/parse.js';
|
|
18
|
+
import { addNote, setStatus, ACTOR_ARCHITECT } from '../src/write.js';
|
|
19
|
+
import { orderQueue } from '../public/queue-order.js';
|
|
20
|
+
import { buildModel, RENDERERS } from '../src/emit.js';
|
|
21
|
+
import { scanAll as scanAllRoots } from '../src/scan.js';
|
|
22
|
+
import {
|
|
23
|
+
resolveMediaRoot,
|
|
24
|
+
masterVideoDir,
|
|
25
|
+
MEDIA_CONFIG,
|
|
26
|
+
branchName,
|
|
27
|
+
headCommit,
|
|
28
|
+
} from '../src/media.js';
|
|
29
|
+
|
|
30
|
+
const CONFIG_DIR = join(homedir(), '.config', 'criteria-review');
|
|
31
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
32
|
+
// A pidfile rather than pgrep: a skill needs to start and stop this without
|
|
33
|
+
// guessing which process is ours, and matching on a command name would happily
|
|
34
|
+
// kill an unrelated editor session that happens to have the word in its args.
|
|
35
|
+
const PID_FILE = join(CONFIG_DIR, 'server.pid');
|
|
36
|
+
|
|
37
|
+
async function loadConfig() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return { projects: [] };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function saveConfig(cfg) {
|
|
46
|
+
await mkdir(dirname(CONFIG_FILE), { recursive: true });
|
|
47
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const args = { _: [], projects: [], port: 4380, open: true, idle: 120 };
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === '--port') args.port = Number(argv[++i]);
|
|
55
|
+
else if (a === '--no-open') args.open = false;
|
|
56
|
+
else if (a === '--idle') args.idle = Number(argv[++i]);
|
|
57
|
+
else if (a === '--message') args.message = argv[++i];
|
|
58
|
+
// Who the architect sees above a question in the review pane. Defaults to
|
|
59
|
+
// "agent"; name the session when several are asking at once.
|
|
60
|
+
else if (a === '--as') args.as = argv[++i];
|
|
61
|
+
else if (a === '--out') args.out = argv[++i];
|
|
62
|
+
else if (a === '--limit') args.limit = argv[++i];
|
|
63
|
+
else if (a === '--commit') args.commit = argv[++i];
|
|
64
|
+
else if (a === '--json') args.json = true;
|
|
65
|
+
// Widen past the tree the session is in. See scopedRoots.
|
|
66
|
+
else if (a === '--all') args.all = true;
|
|
67
|
+
// One mechanism, two audiences: a developer passes their trunk, a pipeline passes
|
|
68
|
+
// the last release tag. See changedSince.
|
|
69
|
+
else if (a === '--since') args.since = argv[++i];
|
|
70
|
+
else if (a === '--format') args.format = argv[++i];
|
|
71
|
+
// Verify rather than write: the gate's half of generation. See cmdGenerate.
|
|
72
|
+
else if (a === '--check') args.check = true;
|
|
73
|
+
else if (a === '--filter') args.filter = argv[++i];
|
|
74
|
+
else if (a === '--highlight') args.highlight = argv[++i];
|
|
75
|
+
else if (a === '--focus') args.focus = argv[++i];
|
|
76
|
+
else if (a === '--in') args.in = argv[++i];
|
|
77
|
+
else if (a === '--project') {
|
|
78
|
+
const v = argv[++i] ?? '';
|
|
79
|
+
const eq = v.indexOf('=');
|
|
80
|
+
if (eq === -1) throw new Error('--project expects name=path');
|
|
81
|
+
args.projects.push({ name: v.slice(0, eq), path: resolve(v.slice(eq + 1)) });
|
|
82
|
+
} else args._.push(a);
|
|
83
|
+
}
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The running server's pid, or null. Clears a stale pidfile as a side effect. */
|
|
88
|
+
async function runningPid() {
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = await readFile(PID_FILE, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
try {
|
|
97
|
+
// Signal 0 tests for existence without touching the process.
|
|
98
|
+
process.kill(parsed.pid, 0);
|
|
99
|
+
return parsed;
|
|
100
|
+
} catch {
|
|
101
|
+
await rm(PID_FILE, { force: true });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function cmdStatus() {
|
|
107
|
+
const live = await runningPid();
|
|
108
|
+
if (!live) {
|
|
109
|
+
console.log('criteria-review: not running');
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
console.log(`criteria-review: running on ${live.url} (pid ${live.pid})`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cmdStop() {
|
|
117
|
+
const live = await runningPid();
|
|
118
|
+
if (!live) {
|
|
119
|
+
console.log('criteria-review: not running');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
process.kill(live.pid, 'SIGTERM');
|
|
123
|
+
await rm(PID_FILE, { force: true });
|
|
124
|
+
console.log(`criteria-review: stopped (pid ${live.pid})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Start detached, so the caller gets its prompt back. Idempotent: an already
|
|
129
|
+
* running server is reported rather than a second one started on a port that is
|
|
130
|
+
* taken, which would fail confusingly.
|
|
131
|
+
*/
|
|
132
|
+
async function cmdStart(args) {
|
|
133
|
+
const live = await runningPid();
|
|
134
|
+
if (live) {
|
|
135
|
+
console.log(`criteria-review: already running on ${live.url} (pid ${live.pid})`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const { spawn } = await import('node:child_process');
|
|
139
|
+
const logPath = join(CONFIG_DIR, 'server.log');
|
|
140
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
141
|
+
const { openSync } = await import('node:fs');
|
|
142
|
+
const out = openSync(logPath, 'a');
|
|
143
|
+
const child = spawn(
|
|
144
|
+
process.execPath,
|
|
145
|
+
[
|
|
146
|
+
fileURLToPath(import.meta.url),
|
|
147
|
+
'serve',
|
|
148
|
+
'--port',
|
|
149
|
+
String(args.port),
|
|
150
|
+
'--no-open',
|
|
151
|
+
'--idle',
|
|
152
|
+
String(args.idle),
|
|
153
|
+
],
|
|
154
|
+
{ detached: true, stdio: ['ignore', out, out] }
|
|
155
|
+
);
|
|
156
|
+
child.unref();
|
|
157
|
+
|
|
158
|
+
const url = `http://127.0.0.1:${args.port}/`;
|
|
159
|
+
// Wait for it to actually answer before claiming success, so a failure to bind
|
|
160
|
+
// is reported here rather than discovered later in a browser.
|
|
161
|
+
for (let i = 0; i < 40; i++) {
|
|
162
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
165
|
+
if (res.ok) {
|
|
166
|
+
await writeFile(PID_FILE, JSON.stringify({ pid: child.pid, url, port: args.port }), 'utf8');
|
|
167
|
+
console.log(`criteria-review: started on ${url} (pid ${child.pid})`);
|
|
168
|
+
console.log(`log: ${logPath}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// not up yet
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`did not become ready on ${url}; see ${logPath}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function usage() {
|
|
179
|
+
console.log(`criteria-review - review acceptance criteria across projects
|
|
180
|
+
|
|
181
|
+
criteria-review open the review UI (foreground)
|
|
182
|
+
criteria-review here [path] [name] register this tree, start, and show it
|
|
183
|
+
criteria-review start start in the background
|
|
184
|
+
criteria-review stop stop the background server
|
|
185
|
+
criteria-review status is it running, and where
|
|
186
|
+
criteria-review restart stop then start
|
|
187
|
+
criteria-review ask <ID> [proj] ask the architect, beside the scenario:
|
|
188
|
+
--message "..." the question
|
|
189
|
+
--as <name> who is asking
|
|
190
|
+
criteria-review notes print the architect's outstanding notes
|
|
191
|
+
criteria-review handled <ID> [proj] clear its notes and raise it for re-review
|
|
192
|
+
criteria-review refresh make every open page reload
|
|
193
|
+
criteria-review push [opts] narrate + steer:
|
|
194
|
+
--message "..." banner text
|
|
195
|
+
--filter k=v,k=v project|status|video|search
|
|
196
|
+
--highlight A,B mark rows
|
|
197
|
+
--focus ID select it
|
|
198
|
+
--in <project> scope for --focus
|
|
199
|
+
criteria-review focus <ID> jump every open page to a scenario
|
|
200
|
+
criteria-review flag <ID> mark it LOOK NOW
|
|
201
|
+
criteria-review unflag <ID> clear LOOK NOW
|
|
202
|
+
criteria-review standard eject <dir> copy the standard into this project to own it
|
|
203
|
+
criteria-review guide [skill] print the agent instruction set
|
|
204
|
+
criteria-review queue what needs a decision here, most important first
|
|
205
|
+
--limit <n> how many to list (default 10)
|
|
206
|
+
--all every registered project
|
|
207
|
+
--since <ref> only what changed since a ref
|
|
208
|
+
--json machine-readable
|
|
209
|
+
criteria-review show <ID> [proj] one scenario in full, with its notes
|
|
210
|
+
criteria-review note <ID> [proj] write the architect's note (raises @review):
|
|
211
|
+
--message "..." what it says
|
|
212
|
+
criteria-review accept <ID> [proj] confirm it is what the software SHOULD do
|
|
213
|
+
criteria-review verify <ID> [proj] you watched it happen: --commit <sha> required
|
|
214
|
+
criteria-review reject <ID> [proj] send it back to derived
|
|
215
|
+
criteria-review generate [path] emit the criteria for a test suite to cite:
|
|
216
|
+
--out <path> where to write (required)
|
|
217
|
+
--format ts|json default from the extension
|
|
218
|
+
--check verify, do not write (the gate)
|
|
219
|
+
criteria-review list print a summary, no browser
|
|
220
|
+
criteria-review add <path> [name] register a project
|
|
221
|
+
criteria-review remove <name> unregister a project
|
|
222
|
+
criteria-review projects show registered projects
|
|
223
|
+
|
|
224
|
+
Options
|
|
225
|
+
--port <n> port for the UI (default 4380)
|
|
226
|
+
--no-open do not open a browser
|
|
227
|
+
--idle <minutes> exit after this long with no page open (0 disables, default 120)
|
|
228
|
+
--project n=path scan this root instead of the registered ones (repeatable)
|
|
229
|
+
|
|
230
|
+
Config: ${CONFIG_FILE}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function rootsFrom(args) {
|
|
234
|
+
if (args.projects.length) return args.projects;
|
|
235
|
+
const cfg = await loadConfig();
|
|
236
|
+
if (cfg.projects?.length) return cfg.projects;
|
|
237
|
+
// Fall back to the current directory, so the tool is useful before anything is
|
|
238
|
+
// registered rather than greeting a new user with an empty screen.
|
|
239
|
+
return [{ name: basename(process.cwd()), path: process.cwd() }];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function cmdList(roots) {
|
|
243
|
+
const { results, missing } = await scanAll(roots);
|
|
244
|
+
let total = 0;
|
|
245
|
+
for (const r of results) {
|
|
246
|
+
const by = {};
|
|
247
|
+
for (const s of r.scenarios) {
|
|
248
|
+
const k = isUntracked(s) ? 'untracked' : s.status;
|
|
249
|
+
by[k] = (by[k] || 0) + 1;
|
|
250
|
+
}
|
|
251
|
+
total += r.scenarios.length;
|
|
252
|
+
const parts = Object.entries(by).map(([k, v]) => `${k} ${v}`);
|
|
253
|
+
const b = await branchName(r.root);
|
|
254
|
+
console.log(
|
|
255
|
+
`${r.project.padEnd(14)} ${(b ? '@' + b : '').padEnd(36)} ` +
|
|
256
|
+
`${String(r.scenarios.length).padStart(4)} scenarios ${parts.join(', ') || '(none)'}`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
for (const m of missing) console.log(`${m.name.padEnd(14)} MISSING: ${m.path} (${m.reason})`);
|
|
260
|
+
if (!total) {
|
|
261
|
+
console.log(
|
|
262
|
+
'\nNo scenarios found. Criteria live under a `ui-qa/` or `acceptance/` directory,\n' +
|
|
263
|
+
'in fenced ```gherkin blocks. Register a project with: criteria-review add <path>'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Emit the criteria as an artefact a test suite references, so a journey's step names
|
|
270
|
+
* are the scenario's own words rather than a copy of them. See docs/decisions.md D-003.
|
|
271
|
+
*
|
|
272
|
+
* WRITE AND CHECK ARE TWO COMMANDS, deliberately. `--check` verifies the committed
|
|
273
|
+
* artefact matches the documents and changes nothing; the plain form rewrites it. A gate
|
|
274
|
+
* that silently regenerated would hide exactly the drift it exists to catch: someone
|
|
275
|
+
* edits a document, the gate quietly rewrites the artefact, and the stale test that no
|
|
276
|
+
* longer matches its criteria is never corrected.
|
|
277
|
+
*
|
|
278
|
+
* The destination is passed in rather than configured here. This tool does not know its
|
|
279
|
+
* consumers, and the consumer's own package script is where "where my scenarios go"
|
|
280
|
+
* belongs - it travels with the repository and works on a machine that has never
|
|
281
|
+
* registered anything.
|
|
282
|
+
*/
|
|
283
|
+
async function cmdGenerate(args) {
|
|
284
|
+
const named = args._[1];
|
|
285
|
+
const settingsRoot = named ? resolve(named) : (await scopedRoots(args))[0]?.path ?? process.cwd();
|
|
286
|
+
const settings = await loadSettings(settingsRoot);
|
|
287
|
+
// Declared in the project rather than passed every time, so the same command works
|
|
288
|
+
// from a package script, a gate and a pipeline without three different invocations.
|
|
289
|
+
const out = args.out ?? settings.emit.out;
|
|
290
|
+
if (!out) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'generate expects --out <path>, e.g.\n' +
|
|
293
|
+
' criteria-review generate . --out tests/e2e/support/scenarios.generated.ts\n' +
|
|
294
|
+
'Put it in the consuming project\'s package scripts so it travels with the repo.'
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const format = args.format ?? settings.emit.format ?? (out.endsWith('.json') ? 'json' : 'ts');
|
|
299
|
+
const render = RENDERERS[format];
|
|
300
|
+
if (!render) throw new Error(`unknown --format ${format}. Expected: ${Object.keys(RENDERERS).join(', ')}`);
|
|
301
|
+
|
|
302
|
+
// A named project, else a path, else the registered set. Generation is per consumer:
|
|
303
|
+
// emitting two projects' scenarios into one artefact would let one project's rename
|
|
304
|
+
// break another's build.
|
|
305
|
+
const roots = named
|
|
306
|
+
? [{ name: basename(resolve(named)), path: resolve(named) }]
|
|
307
|
+
: await rootsFrom(args);
|
|
308
|
+
if (roots.length > 1) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`generate targets one project; ${roots.length} are registered. Name one: ` +
|
|
311
|
+
`criteria-review generate <path> --out ${out}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const { results, missing } = await scanAllRoots(roots);
|
|
316
|
+
for (const m of missing) throw new Error(`${m.name}: ${m.path} (${m.reason})`);
|
|
317
|
+
|
|
318
|
+
const scenarios = results.flatMap((r) => r.scenarios);
|
|
319
|
+
const model = buildModel(scenarios);
|
|
320
|
+
const text = render(model);
|
|
321
|
+
|
|
322
|
+
const target = resolve(roots[0].path, out);
|
|
323
|
+
const existing = await readFile(target, 'utf8').catch(() => null);
|
|
324
|
+
|
|
325
|
+
if (args.check) {
|
|
326
|
+
if (existing === text) {
|
|
327
|
+
console.log(`up to date: ${out} (${model.scenarios.length} scenarios)`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Exit non-zero without writing: this is the gate, and its job is to report.
|
|
331
|
+
console.error(
|
|
332
|
+
existing === null
|
|
333
|
+
? `MISSING: ${out} has never been generated. Run: criteria-review generate --out ${out}`
|
|
334
|
+
: `STALE: ${out} does not match the acceptance documents.\n` +
|
|
335
|
+
`An acceptance document changed and the artefact was not regenerated, or the\n` +
|
|
336
|
+
`artefact was edited by hand. Run: criteria-review generate --out ${out}`
|
|
337
|
+
);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await mkdir(dirname(target), { recursive: true });
|
|
342
|
+
await writeFile(target, text, 'utf8');
|
|
343
|
+
|
|
344
|
+
const changed = existing !== text;
|
|
345
|
+
console.log(
|
|
346
|
+
`${changed ? 'wrote' : 'unchanged'} ${out} ` +
|
|
347
|
+
`${model.scenarios.length} scenarios, standard ${model.standardVersion}`
|
|
348
|
+
);
|
|
349
|
+
if (model.untracked.length) {
|
|
350
|
+
// Never silent: an excluded scenario looks identical to one that does not exist.
|
|
351
|
+
// Summarised per document rather than listed, because a real backfill has dozens
|
|
352
|
+
// and a wall of them on every run is the same as printing nothing.
|
|
353
|
+
const byFile = new Map();
|
|
354
|
+
for (const u of model.untracked) byFile.set(u.source, (byFile.get(u.source) ?? 0) + 1);
|
|
355
|
+
console.log(
|
|
356
|
+
`${model.untracked.length} scenario(s) excluded as untracked (no id or no status):`
|
|
357
|
+
);
|
|
358
|
+
for (const [source, n] of [...byFile].sort((a, b) => b[1] - a[1])) {
|
|
359
|
+
console.log(` ${String(n).padStart(4)} ${source}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The verbs a CONVERSATIONAL review needs: walk the queue, read one scenario, answer it.
|
|
366
|
+
*
|
|
367
|
+
* These exist because the browser is not always the right surface. Asked about one
|
|
368
|
+
* scenario mid-task, opening a page, finding it and reading it costs more than the
|
|
369
|
+
* answer is worth, and the review then does not happen. The design rule the whole
|
|
370
|
+
* standard turns on is that an artefact must be checkable in less time than it took to
|
|
371
|
+
* produce; a second surface with a lower floor is that rule applied to the tool itself.
|
|
372
|
+
*
|
|
373
|
+
* THEY WRITE DIRECTLY TO THE DOCUMENTS rather than posting to the server, so a review
|
|
374
|
+
* works with nothing running. An open page still updates itself: the server watches the
|
|
375
|
+
* criteria directories, so a write from here reaches a browser without either knowing
|
|
376
|
+
* about the other.
|
|
377
|
+
*
|
|
378
|
+
* Every write here is the ARCHITECT acting. That is what retires a raised @looknow and
|
|
379
|
+
* what points a note at an agent rather than back at the person who wrote it. An agent
|
|
380
|
+
* asking a question uses `ask`, which is the same write with the other actor.
|
|
381
|
+
*/
|
|
382
|
+
/**
|
|
383
|
+
* The projects a conversational review works on.
|
|
384
|
+
*
|
|
385
|
+
* Defaults to the tree the session is in, NOT every registered project. A review pass is
|
|
386
|
+
* work on one codebase, and dropping another project's queue into it is both noise and a
|
|
387
|
+
* real hazard: the flagged items at the top would belong to a different piece of work,
|
|
388
|
+
* and answering them from here means answering for a repository nobody in this session
|
|
389
|
+
* has open. `--all` widens deliberately.
|
|
390
|
+
*
|
|
391
|
+
* Falls back to everything when the working directory is not a registered project, which
|
|
392
|
+
* is what makes the command useful from anywhere.
|
|
393
|
+
*/
|
|
394
|
+
async function scopedRoots(args) {
|
|
395
|
+
if (args.projects.length || args.all) return rootsFrom(args);
|
|
396
|
+
const cfg = await loadConfig();
|
|
397
|
+
const cwd = process.cwd();
|
|
398
|
+
const here = (cfg.projects ?? []).filter(
|
|
399
|
+
(p) => cwd === resolve(p.path) || cwd.startsWith(resolve(p.path) + '/')
|
|
400
|
+
);
|
|
401
|
+
// The deepest match wins, so a worktree inside a registered parent scopes to itself.
|
|
402
|
+
if (here.length) return [here.sort((a, b) => b.path.length - a.path.length)[0]];
|
|
403
|
+
return rootsFrom(args);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function resolveScenario(args, id) {
|
|
407
|
+
const roots = await scopedRoots(args);
|
|
408
|
+
const { results } = await scanAllRoots(roots);
|
|
409
|
+
const wanted = String(id).replace(/^@/, '').toLowerCase();
|
|
410
|
+
const project = args._[2];
|
|
411
|
+
|
|
412
|
+
const hits = results
|
|
413
|
+
.flatMap((r) => r.scenarios.map((s) => ({ ...s, root: r.root })))
|
|
414
|
+
.filter((s) => s.id && s.id.toLowerCase() === wanted)
|
|
415
|
+
.filter((s) => (project ? s.project === project : true));
|
|
416
|
+
|
|
417
|
+
if (!hits.length) throw new Error(`No scenario ${id} in the registered projects.`);
|
|
418
|
+
if (hits.length > 1) {
|
|
419
|
+
// Never guess between two: the id is the join, and writing to the wrong copy would
|
|
420
|
+
// record an answer against a scenario the architect never read.
|
|
421
|
+
throw new Error(
|
|
422
|
+
`${id} exists in ${hits.length} projects (${hits.map((h) => h.project).join(', ')}). ` +
|
|
423
|
+
`Name one: criteria-review <verb> ${id} <project>`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return { ...hits[0], file: join(hits[0].root, hits[0].source) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** One scenario, in full, as a person reads it. */
|
|
430
|
+
function printScenario(s, { index, total } = {}) {
|
|
431
|
+
const where = index ? `[${index}/${total}] ` : '';
|
|
432
|
+
const flags = (s.flags ?? []).map((f) => `@${f}`).join(' ');
|
|
433
|
+
console.log(`\n${where}${s.id ?? '(no id)'} ${s.title}`);
|
|
434
|
+
console.log(
|
|
435
|
+
` ${s.project} · ${s.source}${s.feature ? ` · ${s.feature}` : ''}\n` +
|
|
436
|
+
` status: ${s.status ?? '(none)'}${s.persona ? ` · persona: ${s.persona}` : ''}` +
|
|
437
|
+
`${s.verifiedOn ? ` · verified ${s.verifiedOn}` : ''}${flags ? ` · ${flags}` : ''}`
|
|
438
|
+
);
|
|
439
|
+
// The intent line is printed even when absent, and says so. An unsourced scenario is
|
|
440
|
+
// the most dangerous kind here, and silence would read as "fine".
|
|
441
|
+
console.log(` intent: ${s.intent ?? 'NONE - not sourced'}`);
|
|
442
|
+
if (s.steps?.length) {
|
|
443
|
+
console.log('');
|
|
444
|
+
for (const step of s.steps) console.log(` ${step}`);
|
|
445
|
+
}
|
|
446
|
+
for (const n of s.notes ?? []) {
|
|
447
|
+
console.log(`\n note${n.who ? ` (${n.who})` : ''}: ${n.text.replace(/\n/g, '\n ')}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Narrow a scan to the documents a branch has touched, when asked.
|
|
453
|
+
*
|
|
454
|
+
* Reports when the diff could not be taken rather than returning nothing: an empty
|
|
455
|
+
* result and a failed diff look identical from the outside, and the second one silently
|
|
456
|
+
* tells a reviewer their branch is clean.
|
|
457
|
+
*/
|
|
458
|
+
async function narrowToChanged(results, roots, base) {
|
|
459
|
+
if (!base) return { scenarios: results.flatMap((r) => r.scenarios), scope: null };
|
|
460
|
+
const kept = [];
|
|
461
|
+
const unavailable = [];
|
|
462
|
+
for (const r of results) {
|
|
463
|
+
const root = roots.find((x) => x.name === r.project);
|
|
464
|
+
const changed = await changedSince(root?.path ?? r.root, base);
|
|
465
|
+
if (changed === null) {
|
|
466
|
+
unavailable.push(r.project);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
kept.push(...r.scenarios.filter((s) => changed.has(s.source)));
|
|
470
|
+
}
|
|
471
|
+
return { scenarios: kept, scope: { base, unavailable } };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function cmdQueue(args) {
|
|
475
|
+
const roots = await scopedRoots(args);
|
|
476
|
+
const settings = await loadSettings(roots[0]?.path ?? process.cwd()).catch(() => null);
|
|
477
|
+
const since = args.since ?? settings?.since ?? null;
|
|
478
|
+
const { results } = await scanAllRoots(roots);
|
|
479
|
+
const { scenarios: all, scope } = await narrowToChanged(results, roots, since);
|
|
480
|
+
for (const project of scope?.unavailable ?? []) {
|
|
481
|
+
// Never silent: a project whose diff failed is not a project with nothing to review.
|
|
482
|
+
console.error(`warning: cannot diff ${project} against ${scope.base}; it is not shown`);
|
|
483
|
+
}
|
|
484
|
+
// Same ordering the page uses, imported rather than reimplemented: a walk that
|
|
485
|
+
// disagreed with the screen would leave the reviewer unable to tell which was right.
|
|
486
|
+
const queue = orderQueue(all.filter((s) => needsReview(s)));
|
|
487
|
+
const limit = Number(args.limit ?? settings?.limit ?? 10);
|
|
488
|
+
|
|
489
|
+
if (args.json) {
|
|
490
|
+
console.log(JSON.stringify({ total: queue.length, items: queue.slice(0, limit) }, null, 2));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
console.log(
|
|
494
|
+
`${queue.length} scenario(s) still need a decision in ` +
|
|
495
|
+
`${roots.map((r) => r.name).join(', ')}` +
|
|
496
|
+
`${since ? `, changed since ${since}` : ''}. Most important first:\n`
|
|
497
|
+
);
|
|
498
|
+
queue.slice(0, limit).forEach((s, i) => {
|
|
499
|
+
const flags = (s.flags ?? []).includes('looknow') ? ' LOOK NOW' : '';
|
|
500
|
+
console.log(
|
|
501
|
+
`${String(i + 1).padStart(3)}. ${(s.id ?? '(no id)').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)}` +
|
|
502
|
+
` ${s.title}${flags}`
|
|
503
|
+
);
|
|
504
|
+
});
|
|
505
|
+
if (queue.length > limit) console.log(`\n... and ${queue.length - limit} more.`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function cmdShow(args) {
|
|
509
|
+
const s = await resolveScenario(args, args._[1]);
|
|
510
|
+
if (args.json) return void console.log(JSON.stringify(s, null, 2));
|
|
511
|
+
printScenario(s);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Record the architect's answer: a note, or a status, or both. */
|
|
515
|
+
async function cmdAnswer(args, verb) {
|
|
516
|
+
const id = args._[1];
|
|
517
|
+
if (!id) throw new Error(`${verb} expects a scenario id`);
|
|
518
|
+
const s = await resolveScenario(args, id);
|
|
519
|
+
|
|
520
|
+
const done = [];
|
|
521
|
+
if (args.message) {
|
|
522
|
+
await addNote(s.file, s.id, args.message, {
|
|
523
|
+
author: args.as ?? 'architect',
|
|
524
|
+
actor: ACTOR_ARCHITECT,
|
|
525
|
+
});
|
|
526
|
+
done.push('note written, raised @review for an agent to act on');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (verb !== 'note') {
|
|
530
|
+
const status = verb === 'reject' ? 'derived' : verb === 'accept' ? 'accepted' : 'verified';
|
|
531
|
+
let commit = args.commit;
|
|
532
|
+
if (status === 'verified' && !commit) {
|
|
533
|
+
// Resolved from the project being reviewed, not asked for. `verified` records WHICH
|
|
534
|
+
// build was watched, and the honest answer is the checkout the reviewer was looking
|
|
535
|
+
// at. Making them find a hash is how a verification stops being recorded at all.
|
|
536
|
+
const head = await headCommit(s.root);
|
|
537
|
+
if (!head) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`Cannot resolve a commit for ${s.project}: it is not a git checkout. ` +
|
|
540
|
+
'Pass --commit <sha>, because verified records which build was watched.'
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
commit = head.sha;
|
|
544
|
+
// Stated rather than hidden: a verification taken against uncommitted work names a
|
|
545
|
+
// commit that does not contain what was watched.
|
|
546
|
+
done.push(head.dirty ? `commit ${commit} (WORKING TREE DIRTY)` : `commit ${commit}`);
|
|
547
|
+
}
|
|
548
|
+
await setStatus(s.file, s.id, status, { commit, actor: ACTOR_ARCHITECT });
|
|
549
|
+
done.push(`status -> ${status}`);
|
|
550
|
+
} else if (!args.message) {
|
|
551
|
+
throw new Error('note expects --message "..."');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log(`${s.id}: ${done.join('; ')}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function main() {
|
|
558
|
+
const argv = process.argv.slice(2);
|
|
559
|
+
const args = parseArgs(argv);
|
|
560
|
+
const cmd = args._[0];
|
|
561
|
+
|
|
562
|
+
if (cmd === 'help' || args._.includes('--help') || args._.includes('-h')) return usage();
|
|
563
|
+
|
|
564
|
+
if (cmd === 'add') {
|
|
565
|
+
const path = resolve(args._[1] ?? process.cwd());
|
|
566
|
+
const name = args._[2] ?? basename(path);
|
|
567
|
+
const cfg = await loadConfig();
|
|
568
|
+
cfg.projects = (cfg.projects ?? []).filter((p) => p.name !== name);
|
|
569
|
+
cfg.projects.push({ name, path });
|
|
570
|
+
await saveConfig(cfg);
|
|
571
|
+
console.log(`registered ${name} -> ${path}`);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (cmd === 'remove') {
|
|
576
|
+
const name = args._[1];
|
|
577
|
+
if (!name) throw new Error('remove expects a project name');
|
|
578
|
+
const cfg = await loadConfig();
|
|
579
|
+
const before = cfg.projects?.length ?? 0;
|
|
580
|
+
cfg.projects = (cfg.projects ?? []).filter((p) => p.name !== name);
|
|
581
|
+
await saveConfig(cfg);
|
|
582
|
+
console.log(before === cfg.projects.length ? `no project named ${name}` : `removed ${name}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (cmd === 'projects') {
|
|
587
|
+
const cfg = await loadConfig();
|
|
588
|
+
if (!cfg.projects?.length) return console.log(`no projects registered (${CONFIG_FILE})`);
|
|
589
|
+
for (const p of cfg.projects) {
|
|
590
|
+
const b = await branchName(p.path);
|
|
591
|
+
console.log(`${p.name.padEnd(14)} ${(b ? '@' + b : '').padEnd(36)} ${p.path}`);
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Narrate + steer in one call. Everything is optional, so an agent can send
|
|
597
|
+
// just a message, just a filter, or the lot.
|
|
598
|
+
// criteria-review push --message "..." --filter status=proposed --highlight A,B --focus A
|
|
599
|
+
if (cmd === 'push' || cmd === 'refresh') {
|
|
600
|
+
const live = await runningPid();
|
|
601
|
+
if (!live) throw new Error('not running; start it with: criteria-review start');
|
|
602
|
+
const body = { reload: true };
|
|
603
|
+
if (args.message) body.message = args.message;
|
|
604
|
+
if (args.focus) body.focus = args.focus;
|
|
605
|
+
if (args.in) body.project = args.in;
|
|
606
|
+
if (args.highlight) body.highlight = args.highlight.split(',').map((x) => x.trim());
|
|
607
|
+
if (args.filter) {
|
|
608
|
+
body.filter = {};
|
|
609
|
+
for (const pair of args.filter.split(',')) {
|
|
610
|
+
const [k, v] = pair.split('=');
|
|
611
|
+
if (k) body.filter[k.trim()] = (v ?? '').trim();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const r = await fetch(new URL('/api/push', live.url), {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: { 'content-type': 'application/json' },
|
|
617
|
+
body: JSON.stringify(body),
|
|
618
|
+
});
|
|
619
|
+
const out = await r.json();
|
|
620
|
+
if (!r.ok) throw new Error(out.error || `HTTP ${r.status}`);
|
|
621
|
+
console.log(JSON.stringify(out));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Commands a working session uses to reach the reviewer's open page.
|
|
626
|
+
if (cmd === 'focus' || cmd === 'flag' || cmd === 'unflag') {
|
|
627
|
+
const id = args._[1];
|
|
628
|
+
if (!id) throw new Error(`${cmd} expects a scenario id`);
|
|
629
|
+
const live = await runningPid();
|
|
630
|
+
if (!live) throw new Error('not running; start it with: criteria-review start');
|
|
631
|
+
const path = cmd === 'focus' ? '/api/focus' : '/api/flag';
|
|
632
|
+
const body =
|
|
633
|
+
cmd === 'focus'
|
|
634
|
+
? { id, project: args._[2] }
|
|
635
|
+
: { id, project: args._[2], flag: 'looknow', on: cmd === 'flag' };
|
|
636
|
+
const r = await fetch(new URL(path, live.url), {
|
|
637
|
+
method: 'POST',
|
|
638
|
+
headers: { 'content-type': 'application/json' },
|
|
639
|
+
body: JSON.stringify(body),
|
|
640
|
+
});
|
|
641
|
+
const out = await r.json();
|
|
642
|
+
if (!r.ok) throw new Error(out.error || `HTTP ${r.status}`);
|
|
643
|
+
console.log(JSON.stringify(out));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// The single command a session runs on entry: make sure this working tree is
|
|
648
|
+
// registered, make sure the server is up, and point the page at it. Written as
|
|
649
|
+
// one verb because three (add, start, push) is three chances to do two of them.
|
|
650
|
+
if (cmd === 'here') {
|
|
651
|
+
const path = resolve(args._[1] ?? process.cwd());
|
|
652
|
+
// A worktree and its main clone share a repo name, so the DIRECTORY name is
|
|
653
|
+
// what distinguishes them in the queue. Both can be registered at once and
|
|
654
|
+
// reviewed side by side; they resolve to the same master video library, which
|
|
655
|
+
// is correct, because the recordings are of the same product.
|
|
656
|
+
const name = args._[2] ?? basename(path);
|
|
657
|
+
const cfg = await loadConfig();
|
|
658
|
+
// Drop any entry with this name OR this path. Registering one tree twice under
|
|
659
|
+
// two names would list its scenarios twice and make the counts wrong.
|
|
660
|
+
const before = cfg.projects ?? [];
|
|
661
|
+
cfg.projects = before.filter((p) => p.name !== name && resolve(p.path) !== path);
|
|
662
|
+
const replaced = before.length !== cfg.projects.length;
|
|
663
|
+
cfg.projects.push({ name, path });
|
|
664
|
+
await saveConfig(cfg);
|
|
665
|
+
console.log(`${replaced ? 're-registered' : 'registered'} ${name} -> ${path}`);
|
|
666
|
+
|
|
667
|
+
if (!(await runningPid())) await cmdStart(args);
|
|
668
|
+
const live = await runningPid();
|
|
669
|
+
if (live) {
|
|
670
|
+
await fetch(new URL('/api/push', live.url), {
|
|
671
|
+
method: 'POST',
|
|
672
|
+
headers: { 'content-type': 'application/json' },
|
|
673
|
+
body: JSON.stringify({
|
|
674
|
+
filter: { project: name },
|
|
675
|
+
message: `Reviewing ${name}`,
|
|
676
|
+
reload: true,
|
|
677
|
+
}),
|
|
678
|
+
}).catch(() => {});
|
|
679
|
+
console.log(`criteria-review: ${live.url} showing ${name}`);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// The agent's half of the loop: pull what the architect wrote, rather than
|
|
685
|
+
// discovering it by accident. Without this there is no channel from the tool
|
|
686
|
+
// back to a session at all - the push API is one-way, server to browser.
|
|
687
|
+
if (cmd === 'notes') {
|
|
688
|
+
const roots = await rootsFrom(args);
|
|
689
|
+
const { results } = await scanAllRoots(roots);
|
|
690
|
+
let found = 0;
|
|
691
|
+
for (const r of results) {
|
|
692
|
+
for (const sc of r.scenarios) {
|
|
693
|
+
const notes = sc.notes ?? [];
|
|
694
|
+
const awaiting = (sc.flags ?? []).includes('review');
|
|
695
|
+
if (!notes.length && !awaiting) continue;
|
|
696
|
+
found++;
|
|
697
|
+
console.log(
|
|
698
|
+
`\n${awaiting ? '[NOTED] ' : ''}${sc.id ?? '(no id)'} ${r.project} ${sc.source}`
|
|
699
|
+
);
|
|
700
|
+
console.log(` ${sc.title}`);
|
|
701
|
+
for (const n of notes) {
|
|
702
|
+
console.log(` ${n.who ? n.who + ': ' : ''}${n.text.replace(/\n/g, '\n ')}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (!found) console.log('no review notes outstanding');
|
|
707
|
+
else
|
|
708
|
+
console.log(
|
|
709
|
+
`\n${found} scenario(s). Act on them, then: criteria-review handled <ID> [project]`
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Ask the architect something, next to the thing being asked about.
|
|
715
|
+
//
|
|
716
|
+
// `flag` says "look at this" and carries no words; `push` carries words and
|
|
717
|
+
// evaporates on reload. Neither can ask a question and still be there an hour
|
|
718
|
+
// later, so questions lived in chat transcripts and were answered nowhere near
|
|
719
|
+
// the scenario. This writes the question as a note and raises @looknow, which
|
|
720
|
+
// puts it in the review queue with the text attached. The architect answers with
|
|
721
|
+
// their own note in the same pane, and that raises @review to hand it back.
|
|
722
|
+
//
|
|
723
|
+
// Deliberately NOT a push: a push yanks whatever page they have open, and a
|
|
724
|
+
// question is not urgent enough to interrupt a review pass in progress. The flag
|
|
725
|
+
// reaches them through the live queue on its own.
|
|
726
|
+
if (cmd === 'ask') {
|
|
727
|
+
const id = args._[1];
|
|
728
|
+
if (!id) throw new Error('ask expects a scenario id');
|
|
729
|
+
const question = args.message;
|
|
730
|
+
if (!question) throw new Error('ask expects --message "the question"');
|
|
731
|
+
const live = await runningPid();
|
|
732
|
+
if (!live) throw new Error('not running; start it with: criteria-review here');
|
|
733
|
+
const r = await fetch(new URL('/api/note', live.url), {
|
|
734
|
+
method: 'POST',
|
|
735
|
+
headers: { 'content-type': 'application/json' },
|
|
736
|
+
// No actor: this is an agent writing, which is what routes the flag the other
|
|
737
|
+
// way. The author is what the architect sees above the text in the pane.
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
id,
|
|
740
|
+
project: args._[2],
|
|
741
|
+
note: question,
|
|
742
|
+
author: args.as ?? 'agent',
|
|
743
|
+
}),
|
|
744
|
+
});
|
|
745
|
+
const out = await r.json();
|
|
746
|
+
if (!r.ok) throw new Error(out.error || `HTTP ${r.status}`);
|
|
747
|
+
console.log(JSON.stringify({ ok: true, id, asked: true, awaiting: 'architect' }));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Close the loop: the note is dealt with, so remove it and hand the item back
|
|
752
|
+
// for re-review rather than leaving a finished discussion in the document.
|
|
753
|
+
if (cmd === 'handled') {
|
|
754
|
+
const id = args._[1];
|
|
755
|
+
if (!id) throw new Error('handled expects a scenario id');
|
|
756
|
+
const live = await runningPid();
|
|
757
|
+
if (!live) throw new Error('not running; start it with: criteria-review start');
|
|
758
|
+
const project = args._[2];
|
|
759
|
+
const post = (path, body) =>
|
|
760
|
+
fetch(new URL(path, live.url), {
|
|
761
|
+
method: 'POST',
|
|
762
|
+
headers: { 'content-type': 'application/json' },
|
|
763
|
+
body: JSON.stringify(body),
|
|
764
|
+
}).then(async (r) => {
|
|
765
|
+
const o = await r.json();
|
|
766
|
+
if (!r.ok) throw new Error(o.error || `HTTP ${r.status}`);
|
|
767
|
+
return o;
|
|
768
|
+
});
|
|
769
|
+
const cleared = await post('/api/notes/clear', { id, project });
|
|
770
|
+
await post('/api/flag', { id, project, flag: 'looknow', on: true });
|
|
771
|
+
console.log(
|
|
772
|
+
JSON.stringify({ ok: true, id, notesRemoved: cleared.removed, raisedForReview: true })
|
|
773
|
+
);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// The instruction set, printed rather than pointed at: a harness that can run a
|
|
778
|
+
// command can absorb this without knowing where the tool is installed.
|
|
779
|
+
if (cmd === 'guide') {
|
|
780
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
781
|
+
const topic = args._[1];
|
|
782
|
+
const file = topic
|
|
783
|
+
? join(here, '..', 'agents', 'skills', 'claude-code', topic, 'SKILL.md')
|
|
784
|
+
: join(here, '..', 'agents', 'README.md');
|
|
785
|
+
try {
|
|
786
|
+
console.log(await readFile(file, 'utf8'));
|
|
787
|
+
} catch {
|
|
788
|
+
throw new Error(`No guide for "${topic ?? ''}". Try: criteria-review guide`);
|
|
789
|
+
}
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Take a copy of the standard so a team can own it. The shipped one stays visible
|
|
794
|
+
// beside theirs unless they turn it off, which is what makes divergence noticeable
|
|
795
|
+
// when the package is upgraded.
|
|
796
|
+
if (cmd === 'standard' && args._[1] === 'eject') {
|
|
797
|
+
const target = args._[2];
|
|
798
|
+
if (!target) throw new Error('standard eject expects a directory, e.g. docs/qa-standard');
|
|
799
|
+
const r = await ejectStandard(resolve(target));
|
|
800
|
+
console.log(`copied ${r.files} documents (standard ${r.version}) to ${target}`);
|
|
801
|
+
console.log(
|
|
802
|
+
`Now declare it, so this is what your team sees:\n` +
|
|
803
|
+
` ${PROJECT_CONFIG}: {"standard":{"path":"${target}"}}\n` +
|
|
804
|
+
`The shipped standard stays visible as a reference; add "showReference": false to hide it.\n` +
|
|
805
|
+
`Editing these documents does not change what the tool ENFORCES - the status\n` +
|
|
806
|
+
`vocabulary, tag grammar and emitted shape are code, and a copy that disagrees is wrong.`
|
|
807
|
+
);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (cmd === 'generate') return cmdGenerate(args);
|
|
812
|
+
|
|
813
|
+
// The conversational surface: walk, read, answer. No browser, no running server.
|
|
814
|
+
if (cmd === 'queue') return cmdQueue(args);
|
|
815
|
+
if (cmd === 'show') return cmdShow(args);
|
|
816
|
+
if (cmd === 'note' || cmd === 'accept' || cmd === 'verify' || cmd === 'reject') {
|
|
817
|
+
return cmdAnswer(args, cmd);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (cmd === 'status') return void process.exit(await cmdStatus());
|
|
821
|
+
if (cmd === 'stop') return cmdStop();
|
|
822
|
+
if (cmd === 'start') return cmdStart(args);
|
|
823
|
+
if (cmd === 'restart') {
|
|
824
|
+
await cmdStop();
|
|
825
|
+
return cmdStart(args);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const roots = await rootsFrom(args);
|
|
829
|
+
|
|
830
|
+
if (cmd === 'list') return cmdList(roots);
|
|
831
|
+
|
|
832
|
+
const cfg = await loadConfig();
|
|
833
|
+
const mediaRoot = await resolveMediaRoot(cfg.mediaRoot);
|
|
834
|
+
// Re-read the config per request so `here` takes effect without a restart.
|
|
835
|
+
const loadRoots = args.projects.length
|
|
836
|
+
? async () => args.projects
|
|
837
|
+
: async () => {
|
|
838
|
+
const c = await loadConfig();
|
|
839
|
+
return c.projects?.length
|
|
840
|
+
? c.projects
|
|
841
|
+
: [{ name: basename(process.cwd()), path: process.cwd() }];
|
|
842
|
+
};
|
|
843
|
+
const server = createReviewServer(loadRoots, { mediaRoot, idleMinutes: args.idle });
|
|
844
|
+
await new Promise((res) => server.listen(args.port, '127.0.0.1', res));
|
|
845
|
+
const url = `http://127.0.0.1:${args.port}/`;
|
|
846
|
+
console.log(`criteria-review on ${url}`);
|
|
847
|
+
// Print the resolved PATH, not just the name. A worktree and its main clone
|
|
848
|
+
// share a project name, so a name alone cannot tell you which tree is being
|
|
849
|
+
// reviewed - and reviewing the wrong tree looks exactly like reviewing the
|
|
850
|
+
// right one until the scenarios disagree.
|
|
851
|
+
console.log('scanning:');
|
|
852
|
+
for (const r of roots) {
|
|
853
|
+
const b = await branchName(r.path);
|
|
854
|
+
console.log(` ${r.name.padEnd(14)} ${(b ? '@' + b : '').padEnd(36)} ${r.path}`);
|
|
855
|
+
}
|
|
856
|
+
console.log(`master video library: ${mediaRoot ?? 'not configured (' + MEDIA_CONFIG + ')'}`);
|
|
857
|
+
console.log('ctrl-c to stop');
|
|
858
|
+
|
|
859
|
+
if (args.open) {
|
|
860
|
+
const { spawn } = await import('node:child_process');
|
|
861
|
+
const opener =
|
|
862
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
863
|
+
spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
main().catch((e) => {
|
|
868
|
+
console.error(`criteria-review: ${e.message}`);
|
|
869
|
+
process.exit(1);
|
|
870
|
+
});
|