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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/agents/README.md +123 -0
  4. package/agents/skills/claude-code/criteria-lookup/SKILL.md +236 -0
  5. package/bin/criteria-review.js +870 -0
  6. package/docs/decisions.md +310 -0
  7. package/docs/help/01-getting-started.md +170 -0
  8. package/docs/standard/00-roles-and-ownership.md +353 -0
  9. package/docs/standard/01-qa-approach.md +273 -0
  10. package/docs/standard/02-writing-acceptance-criteria.md +187 -0
  11. package/docs/standard/03-example-acceptance-document.md +244 -0
  12. package/docs/standard/04-playwright-journeys.md +329 -0
  13. package/docs/standard/05-example-journey.spec.ts +130 -0
  14. package/docs/standard/06-engineering-obligations.md +170 -0
  15. package/docs/standard/07-manual-qa-and-defects.md +138 -0
  16. package/docs/standard/08-definition-of-done.md +128 -0
  17. package/docs/standard/09-recording-journeys.md +644 -0
  18. package/docs/standard/README.md +70 -0
  19. package/docs/standard/adopting-the-standard.md +104 -0
  20. package/docs/standard/emit-contract.md +175 -0
  21. package/package.json +41 -0
  22. package/public/app.js +791 -0
  23. package/public/doc-name.js +35 -0
  24. package/public/index.html +100 -0
  25. package/public/markdown.js +0 -0
  26. package/public/queue-order.js +78 -0
  27. package/public/styles.css +414 -0
  28. package/src/config.js +104 -0
  29. package/src/emit.js +183 -0
  30. package/src/media.js +126 -0
  31. package/src/parse.js +298 -0
  32. package/src/scan.js +158 -0
  33. package/src/server.js +433 -0
  34. package/src/standard.js +174 -0
  35. package/src/version.js +19 -0
  36. package/src/videos.js +185 -0
  37. 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
+ });