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 +134 -0
- package/agents/skills/claude-code/criteria-architect/SKILL.md +47 -0
- package/agents/skills/claude-code/criteria-backfill/SKILL.md +47 -0
- package/agents/skills/claude-code/criteria-glossary/SKILL.md +16 -1
- package/agents/skills/claude-code/criteria-plan/SKILL.md +16 -4
- package/bin/criteria-review.js +178 -33
- package/docs/standard/10-glossary.md +31 -0
- package/examples/glossary/acceptance/terms.json +29 -0
- package/examples/glossary/acceptance/till-lock/till-lock-acceptance.md +66 -0
- package/examples/glossary/criteria.json +3 -0
- package/package.json +4 -2
- package/public/app.js +75 -8
- package/public/index.html +1 -0
- package/public/styles.css +15 -0
- package/src/plan.js +80 -11
- package/src/server.js +40 -3
- package/src/terms.js +32 -0
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 #
|
|
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...>
|
|
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
|
|
96
|
-
npx criteria-review
|
|
97
|
-
npx criteria-review plan
|
|
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
|
package/bin/criteria-review.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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>
|
|
223
|
-
|
|
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
|
|
541
|
-
|
|
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(
|
|
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
|
|
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
|
-
`${
|
|
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(
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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)'},
|
|
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
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "criteria-review",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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 {
|
|
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,
|
|
76
|
+
export async function savePlan(root, tasks) {
|
|
50
77
|
const file = await planPath(root);
|
|
51
78
|
await mkdir(join(root, DIR), { recursive: true });
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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 = [];
|