criteria-review 0.7.2 → 0.10.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/AGENTS.md ADDED
@@ -0,0 +1,134 @@
1
+ # For an AI assistant working with criteria-review
2
+
3
+ **Point any assistant at this file.** It is the entry point: enough to work correctly from
4
+ immediately, and instructions for loading the rest on demand.
5
+
6
+ It deliberately **routes rather than restates**. Everything here is either a command or a rule
7
+ that cannot be discovered by running one. The standard itself is long, versioned and lives in
8
+ the package - copying it into this file would create a second copy that goes stale, which is the
9
+ exact failure the whole tool exists to prevent.
10
+
11
+ ```bash
12
+ npx criteria-review guide # the agent protocol, in four ideas
13
+ npx criteria-review here # register this tree, start the review page
14
+ npx criteria-review --version # what is installed, and which standard it implements
15
+ ```
16
+
17
+ ---
18
+
19
+ ## What this is for
20
+
21
+ Acceptance criteria written up from delivered software describe what the software **appears** to
22
+ do. Until a person confirms that is what it **should** do, a test citing them proves the test
23
+ runs, not that the requirement is right.
24
+
25
+ That gap is where defects hide. This tool makes it visible and cheap to close, and everything
26
+ below follows from it.
27
+
28
+ ## The five things to get right
29
+
30
+ **1. Never invent intent.** Code contains what a system does, never why anyone wanted it. Cite a
31
+ source or write `INFERRED`. An assistant asked to document intent while reading only the
32
+ implementation will produce it fluently, plausibly and fabricated - that is the default
33
+ behaviour, not a risk to be careful about.
34
+
35
+ **2. Never promote a status.** `verified` claims a person watched the software do it. `accepted`
36
+ is a claim about intent. Neither belongs to an assistant, least of all about its own work.
37
+
38
+ **3. Never edit a criterion to make a test pass.** It is the one failure that is both easy and
39
+ invisible: the suite goes green, the requirement quietly became whatever the code already did,
40
+ and nobody can see it happened. When a criterion is wrong, raise it and stop:
41
+
42
+ ```bash
43
+ npx criteria-review ask <ID> --message "..." --as <who>
44
+ ```
45
+
46
+ **4. Regenerate after editing an acceptance document**, before running any test. Otherwise the
47
+ suite is proving the previous requirement.
48
+
49
+ **5. Show the scenario, do not summarise it.** The wording is the requirement. A paraphrase is
50
+ the reviewer confirming your rewrite rather than the criterion.
51
+
52
+ ## The commands worth knowing
53
+
54
+ ```bash
55
+ criteria-review queue [--plan] [--task X] [--since main] what needs a decision
56
+ criteria-review show <ID> one scenario, in full
57
+ criteria-review note <ID> --message "..." record a decision
58
+ criteria-review accept|verify|reject <ID> a person's judgement only
59
+ criteria-review generate . --out <path> emit clauses for tests to cite
60
+ criteria-review terms [check] the glossary
61
+ criteria-review plan set|add|show|check what a task covers
62
+ criteria-review manifest --out <file> evidence index
63
+ criteria-review status 0 current · 1 down · 2 stale
64
+ ```
65
+
66
+ **`status` exits 2 when the running server is older than what is installed**, so
67
+ `criteria-review status || criteria-review restart` is the check to run before trusting a page.
68
+
69
+ ## Two directions of work, and they are opposites
70
+
71
+ Confusing them produces criteria that look right and mean the reverse.
72
+
73
+ **Forward** - somebody wanted something, the software does not exist yet. Intent first, criteria
74
+ from it, tests prove them, code satisfies them. A test disagreeing with its criterion is **a
75
+ defect in the software**.
76
+
77
+ **Reverse** - the software exists, nobody wrote down what it promises. Behaviour first, criteria
78
+ derived from it, intent inferred and unconfirmed. A test disagreeing with its criterion means
79
+ **you observed it wrongly**.
80
+
81
+ Ask for "criteria for the payments module" when payments already exists and the right answer is
82
+ the reverse direction, not the forward one.
83
+
84
+ ## The skills
85
+
86
+ Nine, shipped in the package at `agents/skills/claude-code/`, and written to be copied into any
87
+ assistant that can run a command. `criteria-help` routes between them by situation.
88
+
89
+ ```bash
90
+ cp -R node_modules/criteria-review/agents/skills/claude-code/* ~/.claude/skills/
91
+ ```
92
+
93
+ On another harness, read them as prose and express them in whatever form it takes - a system
94
+ prompt, a rule file, a tool description. **The commands are the contract; the packaging is not.**
95
+
96
+ Full list and what each one refuses: `node_modules/criteria-review/agents/README.md`.
97
+
98
+ ## Recommending it to someone else
99
+
100
+ The honest pitch is the problem, not the feature list: *your tests agree with your software, and
101
+ nothing tells you whether your software agrees with what anyone wanted.*
102
+
103
+ Then the smallest useful first step, which is not adoption:
104
+
105
+ ```bash
106
+ npx criteria-review here
107
+ ```
108
+
109
+ That registers the tree and opens a page listing every scenario already in the repository, with
110
+ its confirmation status. Most teams discover the gap is bigger than they thought, and that is
111
+ the argument. Nothing has been changed and nothing has to be.
112
+
113
+ Three things worth saying plainly when recommending it:
114
+
115
+ - **It writes into the source markdown**, next to the scenario. No database, no sidecar. Review
116
+ state lands in git beside what it annotates.
117
+ - **A team can own the rules.** `criteria-review standard eject <dir>` copies the standard into
118
+ the project to edit; the shipped one stays visible beside it as a labelled reference so a fork
119
+ can tell when the original moved.
120
+ - **What cannot be forked by editing prose**: the status vocabulary, the tag grammar and the
121
+ emitted shape are enforced in code and versioned. A copy that disagrees is wrong, not
122
+ authoritative.
123
+
124
+ ## Where the rest is
125
+
126
+ | Question | Where |
127
+ |---|---|
128
+ | The protocol, for an assistant | `criteria-review guide` |
129
+ | How criteria are written | the standard, `docs/standard/` - readable in the page's Standard tab |
130
+ | What a status means | `docs/standard/01-qa-approach.md` |
131
+ | The emitted artefact's contract | `docs/standard/emit-contract.md` |
132
+ | Setting it up in a project | `docs/standard/adopting-the-standard.md` |
133
+ | A worked glossary project | `examples/glossary/` |
134
+ | Why a rule exists | `docs/decisions.md` |
@@ -75,6 +75,53 @@ Present both for correction before writing anything. For tier 1, present the ids
75
75
  let them be renamed - ids are stable forever and never reused, so a bad prefix is expensive
76
76
  later.
77
77
 
78
+ ## Terms: name the concept, do not spell the word
79
+
80
+ Before writing a scenario, look at the glossary:
81
+
82
+ ```bash
83
+ npx criteria-review terms # what exists, and how each one is used
84
+ ```
85
+
86
+ Every domain noun you are about to write goes through it. A criterion reading "a Company must
87
+ have an owner" is correct today and silently wrong the day Company becomes something else, and
88
+ nothing can notice because the words are prose:
89
+
90
+ ```gherkin
91
+ Scenario: A {loginGroup} must have at least one {operationType}
92
+ When staff save it with no {operationType.lower} chosen
93
+ ```
94
+
95
+ Variants derive - `{term.lower}`, `{term.plural}`, `{term.lowerPlural}`, `{term.possessive}`.
96
+
97
+ **When a word looks like a term and is not in the glossary, stop and ask.** Do not invent an
98
+ entry and do not quietly spell the word instead. Present it as a question with a recommendation:
99
+
100
+ ```
101
+ "Operation Type" appears in 4 of these scenarios and is not in the glossary.
102
+ It looks like a domain concept the product could rename.
103
+ key: operationType (names the internal domain, not the current word)
104
+ value: Operation Type
105
+ plural: Operation Types
106
+ description: What a company does
107
+ Add it? It has to be added at the real source and re-generated - see criteria-glossary.
108
+ ```
109
+
110
+ The judgement is **"who owns this word"**, not "is this string correct". A word naming a domain
111
+ concept - the organisation, the person, the operation, the report, the status - belongs to the
112
+ glossary. Text genuinely about this screen and nothing else does not, and a glossary that
113
+ swallows ordinary English makes documents unreadable and buys nothing.
114
+
115
+ Check before handing over:
116
+
117
+ ```bash
118
+ npx criteria-review terms check # unresolved markers fail; spelled terms are reported
119
+ ```
120
+
121
+ A **spelled term** in that report is a sentence of yours that says the word where it should have
122
+ named the concept. Fix those before presenting the document; they are the drift this exists to
123
+ prevent, and they are cheapest to fix the minute they are written.
124
+
78
125
  ## 3. Write the technical brief
79
126
 
80
127
  Short on purpose. **If it is long, the architect is doing the team's job.**
@@ -161,6 +161,53 @@ nothing writes, an affordance with nothing behind it - record it in the anomaly
161
161
  the scenario without a journey. Writing a journey that fakes its way into an unreachable state
162
162
  would produce a green test for something that cannot happen.
163
163
 
164
+ ## Terms: name the concept, do not spell the word
165
+
166
+ Before writing a scenario, look at the glossary:
167
+
168
+ ```bash
169
+ npx criteria-review terms # what exists, and how each one is used
170
+ ```
171
+
172
+ Every domain noun you are about to write goes through it. A criterion reading "a Company must
173
+ have an owner" is correct today and silently wrong the day Company becomes something else, and
174
+ nothing can notice because the words are prose:
175
+
176
+ ```gherkin
177
+ Scenario: A {loginGroup} must have at least one {operationType}
178
+ When staff save it with no {operationType.lower} chosen
179
+ ```
180
+
181
+ Variants derive - `{term.lower}`, `{term.plural}`, `{term.lowerPlural}`, `{term.possessive}`.
182
+
183
+ **When a word looks like a term and is not in the glossary, stop and ask.** Do not invent an
184
+ entry and do not quietly spell the word instead. Present it as a question with a recommendation:
185
+
186
+ ```
187
+ "Operation Type" appears in 4 of these scenarios and is not in the glossary.
188
+ It looks like a domain concept the product could rename.
189
+ key: operationType (names the internal domain, not the current word)
190
+ value: Operation Type
191
+ plural: Operation Types
192
+ description: What a company does
193
+ Add it? It has to be added at the real source and re-generated - see criteria-glossary.
194
+ ```
195
+
196
+ The judgement is **"who owns this word"**, not "is this string correct". A word naming a domain
197
+ concept - the organisation, the person, the operation, the report, the status - belongs to the
198
+ glossary. Text genuinely about this screen and nothing else does not, and a glossary that
199
+ swallows ordinary English makes documents unreadable and buys nothing.
200
+
201
+ Check before handing over:
202
+
203
+ ```bash
204
+ npx criteria-review terms check # unresolved markers fail; spelled terms are reported
205
+ ```
206
+
207
+ A **spelled term** in that report is a sentence of yours that says the word where it should have
208
+ named the concept. Fix those before presenting the document; they are the drift this exists to
209
+ prevent, and they are cheapest to fix the minute they are written.
210
+
164
211
  ## 5. Hand it on
165
212
 
166
213
  Say what was produced and what is owed:
@@ -86,7 +86,22 @@ tool refuses without:
86
86
 
87
87
  ```bash
88
88
  npx criteria-review terms # the glossary, its derived forms, and usage counts
89
- npx criteria-review terms check # every marker in every document resolves; non-zero if not
89
+ npx criteria-review terms check # markers resolve, AND what spells a term instead of naming it
90
+ npx criteria-review terms check --strict # a spelled term fails, once you have migrated
91
+ ```
92
+
93
+ `check` reports two different things, and they need different responses. An **unresolved
94
+ marker** is a broken criterion and fails immediately. A **spelled term** is a sentence saying
95
+ the word where it should name the concept - reported but not fatal, because on the day a team
96
+ adopts a glossary every existing document spells its terms, and a gate that fails everything on
97
+ day one is a gate that gets switched off. `--strict` is what a team turns on once migrated.
98
+
99
+ A worked example lives in the tool at `examples/glossary/` - a real project with a manifest and
100
+ a document using every variant, including a `preserve`-cased proper noun and an authored
101
+ possessive. Register it to see the shape:
102
+
103
+ ```bash
104
+ npx criteria-review add <path-to-package>/examples/glossary glossary-example
90
105
  ```
91
106
 
92
107
  Read the usage report for two findings it surfaces without being asked:
@@ -59,9 +59,14 @@ Ask for confirmation, corrections, or additions. Then, and only then, step 3.
59
59
 
60
60
  ```bash
61
61
  npx criteria-review plan set <IDS...> --task "<name>" --source "<where it came from>"
62
- npx criteria-review plan add <IDS...> # scope grew: append, never re-set
62
+ npx criteria-review plan add <IDS...> # scope grew on the current task
63
+ npx criteria-review plan add <IDS...> --task "<another>" # a SECOND task, in flight alongside
63
64
  ```
64
65
 
66
+ **A plan holds a set of tasks, not one.** Working two or three at once is normal, so each keeps
67
+ its own ids and its own provenance, and every id remembers which task declared it. `set` replaces
68
+ the whole plan; `add` with a new name starts another task beside the existing ones.
69
+
65
70
  Piped input works, so a work item body can go in whole:
66
71
 
67
72
  ```bash
@@ -92,11 +97,18 @@ Every one of those reads the plan rather than being handed a list, so none of th
92
97
  own idea of "the current batch":
93
98
 
94
99
  ```bash
95
- npx criteria-review queue --plan # the queue, narrowed to this task
96
- npx criteria-review plan next # the next outstanding one, in queue order
97
- npx criteria-review plan check # exits non-zero while anything is outstanding
100
+ npx criteria-review queue --plan # everything the plan covers
101
+ npx criteria-review queue --task "Refunds" # one of them; repeatable for a subset
102
+ npx criteria-review plan next # the next outstanding one, in queue order
103
+ npx criteria-review plan check # non-zero while anything is outstanding
98
104
  ```
99
105
 
106
+ A task name matches loosely, so `--task refund` finds "Refund fixes". A name matching **two**
107
+ tasks is refused rather than picked: working the wrong task is worse than being asked again.
108
+
109
+ The page narrows the same way, through the task dropdown beside the filters, so a review pass and
110
+ a terminal pass are looking at the same set.
111
+
100
112
  ---
101
113
 
102
114
  ## Keeping it honest
@@ -14,7 +14,7 @@ import { createReviewServer } from '../src/server.js';
14
14
  import { scanAll, changedSince } from '../src/scan.js';
15
15
  import { loadSettings, PROJECT_CONFIG } from '../src/config.js';
16
16
  import { ejectStandard } from '../src/standard.js';
17
- import { loadTerms, renderTerms, variantOf } from '../src/terms.js';
17
+ import { loadTerms, renderTerms, variantOf, findSpelledTerms } from '../src/terms.js';
18
18
  import {
19
19
  loadPlan,
20
20
  savePlan,
@@ -22,6 +22,7 @@ import {
22
22
  resolveItems,
23
23
  readPlan,
24
24
  completeness,
25
+ selectTasks,
25
26
  } from '../src/plan.js';
26
27
  import { isUntracked, needsReview } from '../src/parse.js';
27
28
  import { addNote, setStatus, ACTOR_ARCHITECT } from '../src/write.js';
@@ -79,11 +80,14 @@ function parseArgs(argv) {
79
80
  // the last release tag. See changedSince.
80
81
  else if (a === '--since') args.since = argv[++i];
81
82
  else if (a === '--plan') args.plan = true;
82
- else if (a === '--task') args.task = argv[++i];
83
+ // Repeatable: several tasks can be in flight, and working a set of them is normal.
84
+ else if (a === '--task') (args.tasks ??= []).push(argv[++i]);
83
85
  else if (a === '--source') args.source = argv[++i];
84
86
  else if (a === '--format') args.format = argv[++i];
85
87
  // Verify rather than write: the gate's half of generation. See cmdGenerate.
86
88
  else if (a === '--check') args.check = true;
89
+ // Makes a spelled term fail `terms check`, for a team that has finished migrating.
90
+ else if (a === '--strict') args.strict = true;
87
91
  else if (a === '--filter') args.filter = argv[++i];
88
92
  else if (a === '--highlight') args.highlight = argv[++i];
89
93
  else if (a === '--focus') args.focus = argv[++i];
@@ -117,16 +121,67 @@ async function runningPid() {
117
121
  }
118
122
  }
119
123
 
124
+ /**
125
+ * Is it running, and is what is running the same version as what is installed?
126
+ *
127
+ * Three states rather than two, because "running" was never the whole question. A server
128
+ * started before an upgrade keeps serving its original code indefinitely - the idle timeout
129
+ * is two hours and the page refreshes its DATA without ever reloading its script - so an
130
+ * agent that only asked "is it up" would leave a stale process serving a stale page and have
131
+ * no way to know.
132
+ *
133
+ * 0 running, and current
134
+ * 1 not running
135
+ * 2 running, but older than what is installed - restart it
136
+ *
137
+ * Exit 2 is deliberately non-zero so `criteria-review status || criteria-review restart` is
138
+ * a correct idiom for both the down case and the stale case.
139
+ */
120
140
  async function cmdStatus() {
121
141
  const live = await runningPid();
122
142
  if (!live) {
123
143
  console.log('criteria-review: not running');
124
144
  return 1;
125
145
  }
126
- console.log(`criteria-review: running on ${live.url} (pid ${live.pid})`);
146
+
147
+ const mine = await installedVersion();
148
+ let running = null;
149
+ try {
150
+ const res = await fetch(new URL('/api/version', live.url), { signal: AbortSignal.timeout(2000) });
151
+ if (res.ok) running = await res.json();
152
+ } catch {
153
+ // Answered the pidfile but not HTTP: report what is known rather than guessing.
154
+ }
155
+
156
+ if (!running) {
157
+ console.log(`criteria-review: running on ${live.url} (pid ${live.pid}), version unknown`);
158
+ console.log(' it did not answer /api/version - it predates this check. Restart it.');
159
+ return 2;
160
+ }
161
+
162
+ console.log(
163
+ `criteria-review: running on ${live.url} (pid ${running.pid}) - ` +
164
+ `${running.package} (standard ${running.standard}), started ${running.started.slice(0, 16).replace('T', ' ')}`
165
+ );
166
+
167
+ if (running.package !== mine) {
168
+ console.log(
169
+ ` STALE: ${mine} is installed. The running process still serves ${running.package}.\n` +
170
+ ' Restart it: criteria-review restart'
171
+ );
172
+ return 2;
173
+ }
127
174
  return 0;
128
175
  }
129
176
 
177
+ /** The version of the code this command is running from. */
178
+ async function installedVersion() {
179
+ const pkg = JSON.parse(
180
+ await readFile(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
181
+ );
182
+ return pkg.version;
183
+ }
184
+
130
185
  async function cmdStop() {
131
186
  const live = await runningPid();
132
187
  if (!live) {
@@ -215,12 +270,15 @@ function usage() {
215
270
  criteria-review unflag <ID> clear LOOK NOW
216
271
  criteria-review standard eject <dir> copy the standard into this project to own it
217
272
  criteria-review version package and standard version (also --version)
218
- criteria-review guide [skill] print the agent instruction set
273
+ criteria-review guide [skill] print the agent instruction set (see also AGENTS.md)
219
274
  criteria-review plan [verb] the scenarios this task covers (ids only):
220
275
  set|add [IDs|-] from args or piped text
221
276
  show | next | check | clear
222
- --task <name> --source <where it came from>
223
- criteria-review terms [show|check] the glossary: what it defines and what documents use
277
+ --task <name> repeatable: several in flight
278
+ --source <where it came from>
279
+ criteria-review terms [show|check] the glossary: what it defines, what documents use,
280
+ and what spells a term instead of naming it
281
+ --strict a spelled term fails the check
224
282
  criteria-review manifest evidence index: scenarios, clips, and the work item
225
283
  --out <file> write it (default: stdout)
226
284
  --since <ref> | --all scope, else the plan
@@ -229,6 +287,7 @@ function usage() {
229
287
  --all every registered project
230
288
  --since <ref> only what changed since a ref
231
289
  --plan only what the plan covers
290
+ --task <name> narrow to one or more tasks
232
291
  --json machine-readable
233
292
  criteria-review show <ID> [proj] one scenario in full, with its notes
234
293
  criteria-review note <ID> [proj] write the architect's note (raises @review):
@@ -531,16 +590,46 @@ async function cmdPlan(args) {
531
590
 
532
591
  const plan = await loadPlan(root);
533
592
 
593
+ // Narrow to named tasks, refusing an ambiguous name rather than picking one - working the
594
+ // wrong task is worse than being asked again.
595
+ const pick = (tasks) => {
596
+ const { selected, unmatched, ambiguous } = selectTasks(tasks, args.tasks ?? []);
597
+ for (const n of unmatched) console.error(`warning: no task matching "${n}" in this plan`);
598
+ for (const a of ambiguous) {
599
+ throw new Error(`"${a.name}" matches ${a.matched.length} tasks: ${a.matched.join(', ')}`);
600
+ }
601
+ return selected;
602
+ };
603
+
534
604
  if (verb === 'set' || verb === 'add') {
535
605
  // Items come from stdin when nothing is on the command line, so a work item's body can
536
606
  // be piped in whole rather than reduced to a tidy list first.
537
607
  const fromArgs = args._.slice(2);
538
608
  const raw = fromArgs.length ? fromArgs : (await readStdin()).split('\n');
539
609
  const { ids, unresolved } = resolveItems(raw, scenarios);
540
- const next = verb === 'set' ? ids : [...(plan.ids ?? []), ...ids];
541
- await savePlan(root, { task: args.task ?? plan.task, source: args.source ?? plan.source, ids: next });
610
+ const name = (args.tasks ?? [])[0] ?? null;
611
+
612
+ // `add` with a task NAME appends a task; `add` without one grows the last task. `set`
613
+ // replaces everything, because a fresh selection should not silently inherit yesterday's.
614
+ let tasks;
615
+ if (verb === 'set') {
616
+ tasks = [{ task: name, source: args.source ?? null, ids }];
617
+ } else {
618
+ tasks = [...plan.tasks];
619
+ const existing = name ? tasks.find((t) => t.task === name) : tasks[tasks.length - 1];
620
+ if (existing) {
621
+ existing.ids = [...new Set([...existing.ids, ...ids])];
622
+ if (args.source) existing.source = args.source;
623
+ } else {
624
+ tasks.push({ task: name, source: args.source ?? null, ids });
625
+ }
626
+ }
627
+ await savePlan(root, tasks);
542
628
 
543
- console.log(`${verb === 'set' ? 'plan set to' : 'added'} ${ids.length} scenario(s)`);
629
+ console.log(
630
+ `${verb === 'set' ? 'plan set to' : 'added'} ${ids.length} scenario(s)` +
631
+ `${name ? ` under "${name}"` : ''}; ${tasks.length} task(s) in the plan`
632
+ );
544
633
  if (unresolved.length) {
545
634
  // Never silent: an item that vanished here is scope that quietly left the task.
546
635
  console.log(`\n${unresolved.length} item(s) did NOT resolve:`);
@@ -550,18 +639,24 @@ async function cmdPlan(args) {
550
639
  return;
551
640
  }
552
641
 
553
- const { live, orphaned } = readPlan(plan, scenarios);
642
+ const chosen = pick(plan.tasks);
643
+ const { live, orphaned } = readPlan(plan, scenarios, chosen);
554
644
  const state = completeness(live);
555
645
 
556
646
  if (verb === 'check') {
557
647
  console.log(
558
- `${state.settled}/${state.total} settled` +
559
- `${plan.task ? ` for ${plan.task}` : ''}${orphaned.length ? `, ${orphaned.length} orphaned` : ''}`
648
+ `${state.settled}/${state.total} settled across ${chosen.length} task(s)` +
649
+ `${orphaned.length ? `, ${orphaned.length} orphaned` : ''}`
560
650
  );
561
651
  for (const s of state.outstanding) {
562
- console.log(` outstanding ${(s.id ?? '').padEnd(22)} ${s.status ?? 'untracked'} ${s.title}`);
652
+ console.log(
653
+ ` outstanding ${(s.id ?? '').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)}` +
654
+ ` [${s.planTask ?? 'no task'}] ${s.title}`
655
+ );
656
+ }
657
+ for (const o of orphaned) {
658
+ console.log(` ORPHANED ${o.id} [${o.task ?? 'no task'}] - no longer in the documents`);
563
659
  }
564
- for (const id of orphaned) console.log(` ORPHANED ${id} - no longer in the documents`);
565
660
  // Exit non-zero so this can gate: a task is not done while a declared scenario is not.
566
661
  if (state.outstanding.length || orphaned.length) process.exit(1);
567
662
  return;
@@ -580,15 +675,23 @@ async function cmdPlan(args) {
580
675
  console.log(`no plan for this branch yet (${plan.file})`);
581
676
  return;
582
677
  }
583
- console.log(
584
- `${plan.task ?? '(unnamed task)'}${plan.source ? ` · ${plan.source}` : ''}\n` +
585
- `${state.settled}/${state.total} settled\n`
586
- );
587
- for (const s of live) {
588
- const mark = s.status === 'accepted' || s.status === 'verified' ? 'x' : ' ';
589
- console.log(` [${mark}] ${(s.id ?? '').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)} ${s.title}`);
678
+ console.log(`${state.settled}/${state.total} settled across ${chosen.length} task(s)\n`);
679
+ for (const group of chosen) {
680
+ const mine = live.filter((s) => s.planTask === group.task);
681
+ const done = mine.filter((s) => s.status === 'accepted' || s.status === 'verified').length;
682
+ console.log(
683
+ `${group.task ?? '(unnamed task)'}${group.source ? ` · ${group.source}` : ''}` +
684
+ ` ${done}/${mine.length}`
685
+ );
686
+ for (const s of mine) {
687
+ const mark = s.status === 'accepted' || s.status === 'verified' ? 'x' : ' ';
688
+ console.log(` [${mark}] ${(s.id ?? '').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)} ${s.title}`);
689
+ }
690
+ for (const o of orphaned.filter((o) => o.task === group.task)) {
691
+ console.log(` [!] ${o.id.padEnd(22)} ORPHANED - no longer in the documents`);
692
+ }
693
+ console.log('');
590
694
  }
591
- for (const id of orphaned) console.log(` [!] ${id.padEnd(22)} ORPHANED - no longer in the documents`);
592
695
  }
593
696
 
594
697
  /** Read piped input, so a work item's body can arrive whole. */
@@ -648,10 +751,16 @@ async function cmdTerms(args) {
648
751
  // half happened.
649
752
  const used = new Map();
650
753
  const unresolved = [];
754
+ const spelled = [];
651
755
  for (const s of scenarios) {
652
756
  for (const text of [s.title, ...s.steps]) {
653
757
  const { missing } = renderTerms(text, terms);
654
758
  for (const ref of missing) unresolved.push({ ref, id: s.id, source: s.source });
759
+ // A criterion that SPELLS a word the glossary owns is the drift this exists to
760
+ // prevent, caught the day it is written rather than at the next rename.
761
+ for (const hit of findSpelledTerms(text, terms)) {
762
+ spelled.push({ ...hit, id: s.id, source: s.source, text });
763
+ }
655
764
  for (const m of String(text).matchAll(/\{([a-zA-Z][\w]*)(?:\.[a-zA-Z]+)?\}/g)) {
656
765
  used.set(m[1], (used.get(m[1]) ?? 0) + 1);
657
766
  }
@@ -659,8 +768,22 @@ async function cmdTerms(args) {
659
768
  }
660
769
 
661
770
  if (verb === 'check') {
771
+ if (spelled.length) {
772
+ // Reported, and NOT fatal by default. On the day a team adopts a glossary every
773
+ // existing document spells its terms, and a gate that fails everything on day one is
774
+ // a gate that gets switched off. `--strict` is what a team turns on once migrated.
775
+ console.log(`${spelled.length} place(s) spell a term instead of naming it:`);
776
+ for (const h of spelled) {
777
+ console.log(` "${h.spelled}" -> {${h.key}} in ${h.id} (${h.source})`);
778
+ console.log(` ${h.text.trim().slice(0, 96)}`);
779
+ }
780
+ console.log('');
781
+ }
662
782
  if (!unresolved.length) {
663
783
  console.log(`glossary ok: ${Object.keys(terms).length} terms, every marker resolves`);
784
+ // Only --strict makes a spelled term fail, so adopting is a migration rather than a
785
+ // wall, and finishing the migration is something a team opts into.
786
+ if (spelled.length && args.strict) process.exit(1);
664
787
  return;
665
788
  }
666
789
  console.error(`${unresolved.length} unresolved term reference(s):`);
@@ -705,10 +828,13 @@ async function cmdManifest(args) {
705
828
  chosen = narrowed.scenarios;
706
829
  scope = `changed since ${args.since}`;
707
830
  } else if (plan.found && !args.all) {
708
- const { live, orphaned } = readPlan(plan, all);
831
+ const { selected } = selectTasks(plan.tasks, args.tasks ?? []);
832
+ const { live, orphaned } = readPlan(plan, all, selected);
709
833
  chosen = live;
710
- scope = 'the plan';
711
- for (const id of orphaned) console.error(`warning: ${id} is in the plan but not in the documents`);
834
+ scope = selected.length === plan.tasks.length ? 'the plan' : selected.map((t) => t.task).join(', ');
835
+ for (const o of orphaned) {
836
+ console.error(`warning: ${o.id} is in the plan but not in the documents`);
837
+ }
712
838
  }
713
839
 
714
840
  const index = await indexVideos(roots[0], (await loadConfig()).mediaRoot).catch(() => null);
@@ -728,8 +854,7 @@ async function cmdManifest(args) {
728
854
  }
729
855
 
730
856
  const manifest = {
731
- task: plan.task ?? null,
732
- source: plan.source ?? null,
857
+ tasks: plan.tasks.map((t) => ({ task: t.task, source: t.source })),
733
858
  project: roots[0]?.name ?? null,
734
859
  branch: (await branchName(root)) ?? null,
735
860
  scope,
@@ -750,7 +875,8 @@ async function cmdManifest(args) {
750
875
  await writeFile(target, text, 'utf8');
751
876
  console.log(
752
877
  `wrote ${args.out}: ${manifest.counts.scenarios} scenario(s) for ` +
753
- `${manifest.task ?? '(no task)'}, ${manifest.counts.missing} with no recording`
878
+ `${manifest.tasks.map((t) => t.task ?? '(unnamed)').join(', ') || '(no task)'}, ` +
879
+ `${manifest.counts.missing} with no recording`
754
880
  );
755
881
  for (const i of items.filter((x) => !x.video)) console.log(` no recording: ${i.id} ${i.title}`);
756
882
  return;
@@ -764,10 +890,18 @@ async function cmdQueue(args) {
764
890
  const since = args.since ?? settings?.since ?? null;
765
891
  const { results } = await scanAllRoots(roots);
766
892
  let { scenarios: all, scope } = await narrowToChanged(results, roots, since);
767
- if (args.plan) {
768
- // The third scope, and the one a developer actually works to: what I chose.
769
- const { live, orphaned } = readPlan(await loadPlan(roots[0]?.path ?? process.cwd()), all);
770
- for (const id of orphaned) console.error(`warning: ${id} is in the plan but not in the documents`);
893
+ if (args.plan || args.tasks?.length) {
894
+ // The third scope, and the one a developer actually works to: what I chose. Narrowing
895
+ // further to named tasks is how a set of them is worked without the rest in the way.
896
+ const loaded = await loadPlan(roots[0]?.path ?? process.cwd());
897
+ const { selected, ambiguous } = selectTasks(loaded.tasks, args.tasks ?? []);
898
+ for (const a of ambiguous) {
899
+ throw new Error(`"${a.name}" matches ${a.matched.length} tasks: ${a.matched.join(', ')}`);
900
+ }
901
+ const { live, orphaned } = readPlan(loaded, all, selected);
902
+ for (const o of orphaned) {
903
+ console.error(`warning: ${o.id} is in the plan but not in the documents`);
904
+ }
771
905
  all = live;
772
906
  }
773
907
  for (const project of scope?.unavailable ?? []) {
@@ -970,7 +1104,18 @@ async function main() {
970
1104
  await saveConfig(cfg);
971
1105
  console.log(`${replaced ? 're-registered' : 'registered'} ${name} -> ${path}`);
972
1106
 
973
- if (!(await runningPid())) await cmdStart(args);
1107
+ // `here` is the command people actually type, and it is documented as idempotent and
1108
+ // safe to run on entering a repo. So it is the right place to retire a stale server:
1109
+ // leaving one running would serve the previous release's page to somebody who just
1110
+ // upgraded, and the symptom - a feature that looks broken - says nothing about the cause.
1111
+ const state = await cmdStatus();
1112
+ if (state === 2) {
1113
+ console.log('restarting: the running server is older than what is installed');
1114
+ await cmdStop();
1115
+ await cmdStart(args);
1116
+ } else if (state === 1) {
1117
+ await cmdStart(args);
1118
+ }
974
1119
  const live = await runningPid();
975
1120
  if (live) {
976
1121
  await fetch(new URL('/api/push', live.url), {
@@ -122,6 +122,37 @@ Not everything is. Screen-local copy, verbs, and words with no domain meaning st
122
122
  glossary that swallows ordinary English makes documents unreadable and buys nothing, because
123
123
  nobody renames "the".
124
124
 
125
+ ## Catching a word that should have been a term
126
+
127
+ `criteria-review terms check` reports two different things, and they need different responses.
128
+
129
+ An **unresolved marker** is a broken criterion: it fails immediately, and generation refuses.
130
+
131
+ A **spelled term** is a sentence that says the word where it should name the concept. Reported,
132
+ and not fatal by default - on the day a team adopts a glossary every existing document spells
133
+ its terms, and a gate that fails everything on day one is a gate that gets switched off.
134
+ `--strict` is what a team turns on once it has migrated.
135
+
136
+ ```
137
+ 3 place(s) spell a term instead of naming it:
138
+ "Company" -> {loginGroup} in CO-TYPES-002 (member-administration/company-list-acceptance.md)
139
+ Given a Company with two sites
140
+ ```
141
+
142
+ Whether a given word is the domain concept or ordinary prose is a judgement, so this reports
143
+ rather than rewrites. The question is **"who owns this word"**, not "is this string correct".
144
+
145
+ ## A worked example
146
+
147
+ `examples/glossary/` in this package is a real project rather than a snippet: a manifest, a
148
+ document using every variant, a `preserve`-cased proper noun, an authored possessive. The test
149
+ suite runs the actual pipeline over it, so an example that stopped working would fail the build
150
+ rather than quietly rot.
151
+
152
+ ```bash
153
+ criteria-review add <package>/examples/glossary glossary-example
154
+ ```
155
+
125
156
  ## When a term changes
126
157
 
127
158
  1. Change it at its real source, and regenerate the manifest.
@@ -0,0 +1,29 @@
1
+ {
2
+ "operator": {
3
+ "value": "Cashier",
4
+ "plural": "Cashiers",
5
+ "description": "The person serving customers at a till. Was 'Clerk' until 2026-03."
6
+ },
7
+ "till": {
8
+ "value": "Register",
9
+ "plural": "Registers",
10
+ "description": "The physical terminal a cashier serves from"
11
+ },
12
+ "session": {
13
+ "value": "Shift",
14
+ "plural": "Shifts",
15
+ "description": "One period of trading worked at a register, by one or more cashiers"
16
+ },
17
+ "vendor": {
18
+ "value": "iPOS",
19
+ "plural": "iPOS terminals",
20
+ "description": "The terminal software supplier",
21
+ "casing": "preserve"
22
+ },
23
+ "premises": {
24
+ "value": "Business",
25
+ "plural": "Businesses",
26
+ "description": "The shop a register belongs to",
27
+ "possessive": "Business'"
28
+ }
29
+ }
@@ -0,0 +1,66 @@
1
+ # Locking the register - acceptance criteria
2
+
3
+ **Scope**: locking a register when stepping away, and reopening it.
4
+
5
+ **Why this document exists**: it is the worked example for the glossary. Every domain noun
6
+ names its concept rather than spelling it, so renaming any of them is a one-line change to
7
+ `acceptance/terms.json` and nothing here is touched.
8
+
9
+ ---
10
+
11
+ ## Roles
12
+
13
+ | Role | What they do here |
14
+ |------|-------------------|
15
+ | **{operator}** | Locks the {till.lower} and reopens it |
16
+
17
+ ---
18
+
19
+ ## Gherkin scenarios
20
+
21
+ ### Feature: Locking
22
+
23
+ <!-- intent: design-notes/till-lock.md section 4 -->
24
+
25
+ ```gherkin
26
+ @LOCK-SET-001 @status:derived @persona:Cashier
27
+ Scenario: Locking de-authorises the {till.lower}
28
+ Given a {operator.lower} is signed in and on {session.lower}
29
+ When they lock the {till.lower}
30
+ Then their stored session is cleared from the {till.lower}
31
+ And nothing can act as them until a PIN is entered
32
+ ```
33
+
34
+ <!-- intent: design-notes/till-lock.md section 4 -->
35
+
36
+ ```gherkin
37
+ @LOCK-SET-002 @status:derived @persona:Cashier
38
+ Scenario: Locking leaves the {session.lower} open
39
+ Given a {operator.lower} is on {session.lower}
40
+ When they lock the {till.lower}
41
+ Then the {session.lower} remains open
42
+ And the {premises.possessive} trading day is unaffected
43
+ ```
44
+
45
+ ### Feature: Reopening
46
+
47
+ <!-- intent: design-notes/till-lock.md section 5 -->
48
+
49
+ ```gherkin
50
+ @LOCK-OPEN-001 @status:derived @persona:Cashier
51
+ Scenario: The {operator.possessive} own PIN resumes their {session.lower}
52
+ Given the {till.lower} was locked during a {session.lower}
53
+ When that {operator.lower} enters their own PIN
54
+ Then they are returned to the {till.lower}
55
+ And the same {session.lower} is still open, not a new one
56
+ ```
57
+
58
+ <!-- intent: INFERRED from the {vendor} integration - needs confirmation -->
59
+
60
+ ```gherkin
61
+ @LOCK-OPEN-002 @status:derived @persona:Cashier
62
+ Scenario: A {vendor} terminal reports the unlock upstream
63
+ Given the {till.lower} is locked
64
+ When a {operator.lower} unlocks it
65
+ Then {vendor} is told which {operator.lower} is now serving
66
+ ```
@@ -0,0 +1,3 @@
1
+ {
2
+ "terms": "acceptance/terms.json"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "criteria-review",
3
- "version": "0.7.2",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Review acceptance criteria across projects, and generate the scenario clauses your tests cite.",
6
6
  "license": "MIT",
@@ -28,7 +28,9 @@
28
28
  "src/",
29
29
  "public/",
30
30
  "docs/",
31
- "agents/"
31
+ "agents/",
32
+ "examples/",
33
+ "AGENTS.md"
32
34
  ],
33
35
  "keywords": [
34
36
  "acceptance-criteria",
package/public/app.js CHANGED
@@ -41,6 +41,14 @@ const state = {
41
41
  // so both, rather than a compromise that serves neither.
42
42
  tree: false,
43
43
  collapsed: new Set(),
44
+ // The version this page loaded against. A tab left open across an upgrade keeps running
45
+ // its original script while its data refreshes live, so the two can silently disagree
46
+ // about how a scenario should render.
47
+ version: null,
48
+ // Inspection mode, not a preference: a reviewer occasionally needs to see WHICH term a
49
+ // sentence used rather than the word it renders to. Deliberately not persisted - display
50
+ // is the right default every time a page is opened.
51
+ rawTerms: false,
44
52
  };
45
53
 
46
54
  /**
@@ -71,6 +79,7 @@ function saveViewState() {
71
79
  video: $('#video').value,
72
80
  search: $('#search').value,
73
81
  selected: scenarioKey(state.view[state.index]),
82
+ task: $('#task').value,
74
83
  tab: state.tab,
75
84
  doc: state.doc,
76
85
  tree: state.tree,
@@ -95,6 +104,7 @@ function restoreViewState() {
95
104
  if (saved.status != null) $('#status').value = saved.status;
96
105
  if (saved.video != null) $('#video').value = saved.video;
97
106
  if (saved.search != null) $('#search').value = saved.search;
107
+ state.savedTask = saved.task ?? '';
98
108
  state.restoreKey = saved.selected ?? null;
99
109
  state.savedProject = saved.project ?? '';
100
110
  // The reading position is part of the session too: someone half way through a
@@ -112,14 +122,14 @@ function restoreViewState() {
112
122
  * context the reviewer needs while working through the affected scenarios, not a
113
123
  * notification to acknowledge and forget.
114
124
  */
115
- function banner(text) {
125
+ function banner(text, onClick) {
116
126
  const el = document.getElementById('banner');
117
127
  if (!el) return;
118
128
  el.textContent = text;
119
129
  el.hidden = false;
120
- el.onclick = () => {
121
- el.hidden = true;
122
- };
130
+ // A banner that offers an action performs it; otherwise clicking dismisses. Both are
131
+ // one click, and which one it is has just been stated in the text.
132
+ el.onclick = onClick ?? (() => { el.hidden = true; });
123
133
  }
124
134
 
125
135
  async function api(path, options) {
@@ -181,8 +191,11 @@ function applyFilters() {
181
191
  const q = $('#search').value.trim().toLowerCase();
182
192
 
183
193
  const video = $('#video').value;
194
+ const task = $('#task').value;
184
195
 
185
196
  const filtered = state.all
197
+ // A set of tasks is normal, so narrowing to one is a filter rather than a mode.
198
+ .filter((s) => (task ? s.planTask === task : true))
186
199
  .filter((s) => (project ? s.project === project : true))
187
200
  .filter((s) => matchesStatus(s, status))
188
201
  .filter((s) => matchesVideo(s, video))
@@ -433,6 +446,19 @@ function renderVideo(s) {
433
446
  </div>`;
434
447
  }
435
448
 
449
+ /**
450
+ * Does this scenario name any concept through the glossary?
451
+ *
452
+ * Compared against the RENDERED form rather than by looking for braces, so a scenario whose
453
+ * prose happens to contain a brace does not grow a toggle that would do nothing. The offer
454
+ * only appears where there is something behind it.
455
+ */
456
+ function usesTerms(s) {
457
+ if (!s.stepsDisplay && !s.titleDisplay) return false;
458
+ if ((s.titleDisplay ?? s.title) !== s.title) return true;
459
+ return (s.steps || []).some((raw, i) => (s.stepsDisplay || [])[i] !== raw);
460
+ }
461
+
436
462
  function renderDetail() {
437
463
  const s = state.view[state.index];
438
464
  if (!s) {
@@ -462,12 +488,21 @@ function renderDetail() {
462
488
  <code class="id">${s.id || '(no id)'}</code>
463
489
  ${s.persona ? `<span class="persona">${escapeHtml(s.persona)}</span>` : ''}
464
490
  </div>
465
- <h2>${escapeHtml(s.titleDisplay ?? s.title)}</h2>
491
+ <h2>${escapeHtml(state.rawTerms ? s.title : (s.titleDisplay ?? s.title))}</h2>
466
492
  <p class="where">${escapeHtml(s.project)} · ${escapeHtml(s.source)}${
467
493
  s.feature ? ` · <em>${escapeHtml(s.feature)}</em>` : ''
468
494
  }</p>
469
495
  ${intent}
470
- <pre class="steps">${(s.stepsDisplay ?? s.steps).map(escapeHtml).join('\n')}</pre>
496
+ ${
497
+ usesTerms(s)
498
+ ? `<button class="term-toggle" data-act="toggle-raw">${
499
+ state.rawTerms ? 'showing glossary keys - show words' : 'show glossary keys'
500
+ }</button>`
501
+ : ''
502
+ }
503
+ <pre class="steps">${(state.rawTerms ? s.steps : (s.stepsDisplay ?? s.steps))
504
+ .map(escapeHtml)
505
+ .join('\n')}</pre>
471
506
  ${renderNotes(s)}
472
507
  ${renderVideo(s)}
473
508
  ${
@@ -530,7 +565,7 @@ async function act(action) {
530
565
  */
531
566
  function openNoteEditor(s) {
532
567
  const dlg = document.getElementById('note-dialog');
533
- document.getElementById('note-for').textContent = `${s.id} - ${s.title}`;
568
+ document.getElementById('note-for').textContent = `${s.id} - ${s.titleDisplay ?? s.title}`;
534
569
  const ta = document.getElementById('note-text');
535
570
  ta.value = '';
536
571
  dlg.showModal();
@@ -615,6 +650,19 @@ async function load({ keepIndex } = {}) {
615
650
  '<option value="">everything</option>';
616
651
  st.value = stCurrent;
617
652
 
653
+ // The task list comes from what the plan actually declared, so an option cannot exist for
654
+ // a task nothing is planned under - and a selection that no longer exists falls back to
655
+ // "all" rather than silently showing an empty queue.
656
+ const taskSel = $('#task');
657
+ const tasks = [...new Set(state.all.map((s) => s.planTask).filter(Boolean))].sort();
658
+ const wantTask = taskSel.value || state.savedTask || '';
659
+ taskSel.innerHTML =
660
+ '<option value="">all tasks</option>' +
661
+ tasks.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
662
+ taskSel.value = tasks.includes(wantTask) ? wantTask : '';
663
+ state.savedTask = null;
664
+ taskSel.hidden = tasks.length === 0;
665
+
618
666
  renderRoots();
619
667
 
620
668
  if (!keepIndex) state.index = 0;
@@ -632,6 +680,20 @@ async function load({ keepIndex } = {}) {
632
680
  }
633
681
  saveViewState();
634
682
 
683
+ // Say so ONCE, and only when it has actually changed - a page nagging on every refresh
684
+ // gets its banner dismissed reflexively, which is the same as not warning at all.
685
+ if (data.standardVersion) {
686
+ if (!state.version) state.version = data.standardVersion;
687
+ else if (state.version !== data.standardVersion && !state.staleNotified) {
688
+ state.staleNotified = true;
689
+ banner(
690
+ `This page was loaded against standard ${state.version}; the tool is now running ` +
691
+ `${data.standardVersion}. Click here to reload and pick it up.`,
692
+ () => location.reload()
693
+ );
694
+ }
695
+ }
696
+
635
697
  if (data.missing?.length) {
636
698
  toast(`Missing roots: ${data.missing.map((m) => m.name).join(', ')}`, true);
637
699
  }
@@ -825,10 +887,15 @@ $('#detail').addEventListener('click', (e) => {
825
887
  return;
826
888
  }
827
889
  const b = e.target.closest('button[data-act]');
890
+ if (b?.dataset.act === 'toggle-raw') {
891
+ state.rawTerms = !state.rawTerms;
892
+ renderDetail();
893
+ return;
894
+ }
828
895
  if (b) act(b.dataset.act);
829
896
  });
830
897
 
831
- for (const id of ['#project', '#status', '#video', '#search']) {
898
+ for (const id of ['#project', '#status', '#video', '#search', '#task']) {
832
899
  $(id).addEventListener('input', () => {
833
900
  applyFilters();
834
901
  renderRoots();
package/public/index.html CHANGED
@@ -38,6 +38,7 @@
38
38
  <option value="none">no recording</option>
39
39
  </select>
40
40
  <input id="search" type="search" placeholder="filter text" />
41
+ <select id="task" title="Narrow to one of the tasks in the plan"><option value="">all tasks</option></select>
41
42
  <button id="view-mode" type="button" title="Flat queue or grouped tree (t)">tree</button>
42
43
  </div>
43
44
  </header>
package/public/styles.css CHANGED
@@ -327,6 +327,21 @@ kbd {
327
327
  .note-dialog button { background: var(--panel-2); border: 1px solid var(--line); color: var(--text); border-radius: 6px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
328
328
  .note-dialog button.primary { border-color: var(--accent); }
329
329
 
330
+ /* The glossary toggle. Quiet by default: it is an inspection affordance, not an action, and
331
+ it must not compete with Accept and Verified for the eye. */
332
+ .term-toggle {
333
+ background: none;
334
+ border: 1px solid var(--line);
335
+ color: var(--muted);
336
+ font: inherit;
337
+ font-size: 11px;
338
+ padding: 2px 8px;
339
+ border-radius: 5px;
340
+ margin-bottom: 6px;
341
+ cursor: pointer;
342
+ }
343
+ .term-toggle:hover { color: var(--text); border-color: var(--accent); }
344
+
330
345
  /* ---- Tree view ---- */
331
346
 
332
347
  .tree-head, .feature-head { cursor: pointer; user-select: none; }
package/src/plan.js CHANGED
@@ -35,26 +35,82 @@ export async function planPath(root) {
35
35
  return join(root, DIR, `plan-${slug(await branchName(root))}.json`);
36
36
  }
37
37
 
38
+ /**
39
+ * Several tasks at once, because that is how work actually arrives.
40
+ *
41
+ * A plan is a LIST of tasks, each with its own ids and its own provenance. Still only
42
+ * numbers and where they came from - no titles, no statuses, no working state, exactly as
43
+ * before. Grouping does not make the plan know anything; it makes it able to say WHICH task
44
+ * an id was declared under, which is the thing a person needs when two are in flight.
45
+ *
46
+ * A plan written before this read as a single task. It still loads: a flat `{task, source,
47
+ * ids}` normalises into one entry, so nothing has to be migrated and an old file cannot
48
+ * silently lose its contents.
49
+ */
50
+ function normalise(raw) {
51
+ if (Array.isArray(raw?.tasks)) {
52
+ return raw.tasks.map((t) => ({
53
+ task: t.task ?? null,
54
+ source: t.source ?? null,
55
+ ids: [...new Set(t.ids ?? [])],
56
+ }));
57
+ }
58
+ if (raw?.ids?.length || raw?.task) {
59
+ return [{ task: raw.task ?? null, source: raw.source ?? null, ids: [...new Set(raw.ids ?? [])] }];
60
+ }
61
+ return [];
62
+ }
63
+
38
64
  export async function loadPlan(root) {
39
65
  const file = await planPath(root);
40
66
  try {
41
- const plan = JSON.parse(await readFile(file, 'utf8'));
42
- return { task: null, source: null, ids: [], ...plan, file, found: true };
67
+ const raw = JSON.parse(await readFile(file, 'utf8'));
68
+ const tasks = normalise(raw);
69
+ return { tasks, ids: tasks.flatMap((t) => t.ids), file, found: true };
43
70
  } catch (err) {
44
- if (err.code === 'ENOENT') return { task: null, source: null, ids: [], file, found: false };
71
+ if (err.code === 'ENOENT') return { tasks: [], ids: [], file, found: false };
45
72
  throw new Error(`${file}: ${err.message}`);
46
73
  }
47
74
  }
48
75
 
49
- export async function savePlan(root, { task, source, ids }) {
76
+ export async function savePlan(root, tasks) {
50
77
  const file = await planPath(root);
51
78
  await mkdir(join(root, DIR), { recursive: true });
52
- // Exactly three fields. A field added here is a field that can contradict a document.
53
- const body = { task: task ?? null, source: source ?? null, ids: [...new Set(ids)] };
79
+ // Three fields per task and nothing else. A field added here is a field that can
80
+ // contradict a document.
81
+ const body = {
82
+ tasks: tasks.map((t) => ({
83
+ task: t.task ?? null,
84
+ source: t.source ?? null,
85
+ ids: [...new Set(t.ids)],
86
+ })),
87
+ };
54
88
  await writeFile(file, JSON.stringify(body, null, 2) + '\n', 'utf8');
55
89
  return file;
56
90
  }
57
91
 
92
+ /**
93
+ * Which tasks a name selects.
94
+ *
95
+ * Matches loosely on purpose - a person typing `--task till` should not have to reproduce
96
+ * "Till hardening" exactly - but reports an ambiguous match rather than picking, because
97
+ * working the wrong task is worse than being asked again.
98
+ */
99
+ export function selectTasks(tasks, names = []) {
100
+ if (!names.length) return { selected: tasks, unmatched: [], ambiguous: [] };
101
+ const selected = [];
102
+ const unmatched = [];
103
+ const ambiguous = [];
104
+ for (const name of names) {
105
+ const needle = String(name).toLowerCase();
106
+ const hits = tasks.filter((t) => (t.task ?? '').toLowerCase().includes(needle));
107
+ if (!hits.length) unmatched.push(name);
108
+ else if (hits.length > 1) ambiguous.push({ name, matched: hits.map((h) => h.task) });
109
+ else if (!selected.includes(hits[0])) selected.push(hits[0]);
110
+ }
111
+ return { selected, unmatched, ambiguous };
112
+ }
113
+
58
114
  export async function clearPlan(root) {
59
115
  await rm(await planPath(root), { force: true });
60
116
  }
@@ -114,14 +170,27 @@ export function resolveItems(inputs, scenarios) {
114
170
  * deleted is stale, and that failure is silent by nature - a missing item simply does not
115
171
  * appear anywhere.
116
172
  */
117
- export function readPlan(plan, scenarios) {
173
+ export function readPlan(plan, scenarios, tasks = null) {
118
174
  const byId = new Map(scenarios.filter((s) => s.id).map((s) => [s.id, s]));
175
+ // Tolerates the flat shape here as well as at load, so the compatibility claim holds for
176
+ // any caller rather than only for a file read off disk.
177
+ const groups = tasks ?? (plan.tasks ? plan.tasks : normalise(plan));
119
178
  const live = [];
120
179
  const orphaned = [];
121
- for (const id of plan.ids ?? []) {
122
- const hit = byId.get(id);
123
- if (hit) live.push(hit);
124
- else orphaned.push(id);
180
+ const seen = new Set();
181
+ for (const group of groups) {
182
+ for (const id of group.ids) {
183
+ const hit = byId.get(id);
184
+ if (!hit) {
185
+ orphaned.push({ id, task: group.task });
186
+ continue;
187
+ }
188
+ if (seen.has(id)) continue;
189
+ seen.add(id);
190
+ // The task an id was declared under travels with it, because with two in flight the
191
+ // useful question is not "is this planned" but "which of these am I looking at".
192
+ live.push({ ...hit, planTask: group.task, planSource: group.source });
193
+ }
125
194
  }
126
195
  return { live, orphaned };
127
196
  }
package/src/server.js CHANGED
@@ -20,11 +20,18 @@ import { setStatus, addNote, setFlag, clearNotes } from './write.js';
20
20
  import { indexVideos, videoFor, expectedPath } from './videos.js';
21
21
  import { listStandardDocs, readStandardDoc } from './standard.js';
22
22
  import { loadPlan } from './plan.js';
23
+ import { STANDARD_VERSION } from './version.js';
23
24
  import { loadSettings } from './config.js';
24
25
  import { loadTerms, renderTerms } from './terms.js';
25
26
  import { branchName, repoName } from './media.js';
26
27
 
27
28
  const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
29
+ const PKG = JSON.parse(
30
+ await readFile(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
31
+ );
32
+
33
+ /** When this process started, so a stale server can be reported with its age. */
34
+ const STARTED = new Date().toISOString();
28
35
 
29
36
  const MIME = {
30
37
  '.html': 'text/html; charset=utf-8',
@@ -197,6 +204,19 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
197
204
  const roots = await getRoots();
198
205
  const url = new URL(req.url ?? '/', 'http://localhost');
199
206
 
207
+ // What is ACTUALLY running, asked of the process rather than inferred from a pidfile
208
+ // the CLI wrote. A long-lived server keeps serving the code it started with, so after
209
+ // an upgrade the binary on PATH and the process answering the browser are different
210
+ // versions - and nothing said so.
211
+ if (req.method === 'GET' && url.pathname === '/api/version') {
212
+ return json(res, 200, {
213
+ package: PKG.version,
214
+ standard: STANDARD_VERSION,
215
+ pid: process.pid,
216
+ started: STARTED,
217
+ });
218
+ }
219
+
200
220
  if (req.method === 'GET' && url.pathname === '/api/events') {
201
221
  res.writeHead(200, {
202
222
  'content-type': 'text/event-stream',
@@ -279,6 +299,8 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
279
299
  // globally because each worktree is its own registered entry with its own branch
280
300
  // and its own plan - which is what lets several people work at once without their
281
301
  // batches bleeding into each other's page.
302
+ // Which task each id was declared under, so the page can narrow to a set of them
303
+ // the same way the command line does.
282
304
  const planned = new Map();
283
305
  // The glossary, per project. The page is where a person READS criteria, so it shows
284
306
  // the words the product uses; the raw markers stay in the payload because that is
@@ -287,9 +309,11 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
287
309
  for (const root of roots) {
288
310
  try {
289
311
  const plan = await loadPlan(root.path);
290
- planned.set(root.name, new Set(plan.ids ?? []));
312
+ const byId = new Map();
313
+ for (const t of plan.tasks ?? []) for (const id of t.ids) byId.set(id, t.task);
314
+ planned.set(root.name, byId);
291
315
  } catch {
292
- planned.set(root.name, new Set());
316
+ planned.set(root.name, new Map());
293
317
  }
294
318
  try {
295
319
  const settings = await loadSettings(root.path);
@@ -326,6 +350,7 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
326
350
  // should read the same whoever is looking at it. Display only, unlike
327
351
  // `source`, which reaches a committed artefact.
328
352
  inPlan: planned.get(s.project)?.has(s.id) ?? false,
353
+ planTask: planned.get(s.project)?.get(s.id) ?? null,
329
354
  titleDisplay: renderTerms(s.title, glossaries.get(s.project) ?? {}).rendered,
330
355
  stepsDisplay: (s.steps ?? []).map(
331
356
  (t) => renderTerms(t, glossaries.get(s.project) ?? {}).rendered
@@ -346,6 +371,10 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
346
371
  }
347
372
  }
348
373
  return json(res, 200, {
374
+ // Stamped so a page can tell it is older than the server it is talking to. An open
375
+ // tab keeps updating its DATA over SSE while running the JS it loaded hours ago, so
376
+ // without this an upgrade looks like a broken feature rather than a stale page.
377
+ standardVersion: STANDARD_VERSION,
349
378
  scenarios,
350
379
  projects: results.map((r) => ({
351
380
  name: r.project,
@@ -444,7 +473,15 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
444
473
  if (name.includes('..')) return json(res, 400, { error: 'Bad path' });
445
474
  try {
446
475
  const buf = await readFile(join(PUBLIC_DIR, name));
447
- res.writeHead(200, { 'content-type': MIME[extname(name)] ?? 'text/plain' });
476
+ res.writeHead(200, {
477
+ 'content-type': MIME[extname(name)] ?? 'text/plain',
478
+ // Always revalidate. This tool is designed to be left open for hours and to
479
+ // upgrade underneath itself, and a cached script keeps rendering by the
480
+ // previous release's rules while its DATA updates live - which looks like the
481
+ // new feature is broken rather than like the page is old. The files are local,
482
+ // so revalidating costs nothing.
483
+ 'cache-control': 'no-cache',
484
+ });
448
485
  return res.end(buf);
449
486
  } catch {
450
487
  return json(res, 404, { error: 'Not found' });
package/src/terms.js CHANGED
@@ -85,6 +85,38 @@ export function renderTerms(text, terms = {}) {
85
85
  return { rendered, missing };
86
86
  }
87
87
 
88
+ /**
89
+ * Text that SPELLS a word the glossary owns, instead of naming the concept.
90
+ *
91
+ * This is the drift the glossary exists to prevent, caught at the moment it is introduced
92
+ * rather than at the next rename. A criterion reading "a Company must have an owner" looks
93
+ * perfectly correct today and is silently wrong the day Company becomes something else.
94
+ *
95
+ * Matched case-insensitively on whole words against `value` and `plural`, because a
96
+ * criterion writing "company" mid-sentence means the term just as much as one writing it
97
+ * capitalised. Reported rather than corrected: whether a word names the domain concept or is
98
+ * ordinary prose is a judgement, and a glossary that swallows ordinary English makes
99
+ * documents unreadable.
100
+ *
101
+ * Text already inside a marker cannot match, because markers hold KEYS and keys name the
102
+ * internal domain rather than the visible word.
103
+ */
104
+ export function findSpelledTerms(text, terms = {}) {
105
+ const bare = String(text ?? '').replace(MARKER, ' ');
106
+ const hits = [];
107
+ for (const [key, term] of Object.entries(terms)) {
108
+ for (const form of [term.value, term.plural]) {
109
+ if (!form) continue;
110
+ const pattern = new RegExp(`\\b${form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
111
+ if (pattern.test(bare)) {
112
+ hits.push({ key, spelled: form });
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ return hits;
118
+ }
119
+
88
120
  /** Every problem in a manifest, reported together rather than one run at a time. */
89
121
  export function validateTerms(terms) {
90
122
  const problems = [];