criteria-review 0.7.2 → 0.8.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/skills/claude-code/criteria-plan/SKILL.md +16 -4
- package/bin/criteria-review.js +87 -28
- package/package.json +1 -1
- package/public/app.js +19 -1
- package/public/index.html +1 -0
- package/src/plan.js +80 -11
- package/src/server.js +7 -2
|
@@ -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
|
@@ -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,7 +80,8 @@ 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.
|
|
@@ -219,7 +221,8 @@ function usage() {
|
|
|
219
221
|
criteria-review plan [verb] the scenarios this task covers (ids only):
|
|
220
222
|
set|add [IDs|-] from args or piped text
|
|
221
223
|
show | next | check | clear
|
|
222
|
-
--task <name>
|
|
224
|
+
--task <name> repeatable: several in flight
|
|
225
|
+
--source <where it came from>
|
|
223
226
|
criteria-review terms [show|check] the glossary: what it defines and what documents use
|
|
224
227
|
criteria-review manifest evidence index: scenarios, clips, and the work item
|
|
225
228
|
--out <file> write it (default: stdout)
|
|
@@ -229,6 +232,7 @@ function usage() {
|
|
|
229
232
|
--all every registered project
|
|
230
233
|
--since <ref> only what changed since a ref
|
|
231
234
|
--plan only what the plan covers
|
|
235
|
+
--task <name> narrow to one or more tasks
|
|
232
236
|
--json machine-readable
|
|
233
237
|
criteria-review show <ID> [proj] one scenario in full, with its notes
|
|
234
238
|
criteria-review note <ID> [proj] write the architect's note (raises @review):
|
|
@@ -531,16 +535,46 @@ async function cmdPlan(args) {
|
|
|
531
535
|
|
|
532
536
|
const plan = await loadPlan(root);
|
|
533
537
|
|
|
538
|
+
// Narrow to named tasks, refusing an ambiguous name rather than picking one - working the
|
|
539
|
+
// wrong task is worse than being asked again.
|
|
540
|
+
const pick = (tasks) => {
|
|
541
|
+
const { selected, unmatched, ambiguous } = selectTasks(tasks, args.tasks ?? []);
|
|
542
|
+
for (const n of unmatched) console.error(`warning: no task matching "${n}" in this plan`);
|
|
543
|
+
for (const a of ambiguous) {
|
|
544
|
+
throw new Error(`"${a.name}" matches ${a.matched.length} tasks: ${a.matched.join(', ')}`);
|
|
545
|
+
}
|
|
546
|
+
return selected;
|
|
547
|
+
};
|
|
548
|
+
|
|
534
549
|
if (verb === 'set' || verb === 'add') {
|
|
535
550
|
// Items come from stdin when nothing is on the command line, so a work item's body can
|
|
536
551
|
// be piped in whole rather than reduced to a tidy list first.
|
|
537
552
|
const fromArgs = args._.slice(2);
|
|
538
553
|
const raw = fromArgs.length ? fromArgs : (await readStdin()).split('\n');
|
|
539
554
|
const { ids, unresolved } = resolveItems(raw, scenarios);
|
|
540
|
-
const
|
|
541
|
-
|
|
555
|
+
const name = (args.tasks ?? [])[0] ?? null;
|
|
556
|
+
|
|
557
|
+
// `add` with a task NAME appends a task; `add` without one grows the last task. `set`
|
|
558
|
+
// replaces everything, because a fresh selection should not silently inherit yesterday's.
|
|
559
|
+
let tasks;
|
|
560
|
+
if (verb === 'set') {
|
|
561
|
+
tasks = [{ task: name, source: args.source ?? null, ids }];
|
|
562
|
+
} else {
|
|
563
|
+
tasks = [...plan.tasks];
|
|
564
|
+
const existing = name ? tasks.find((t) => t.task === name) : tasks[tasks.length - 1];
|
|
565
|
+
if (existing) {
|
|
566
|
+
existing.ids = [...new Set([...existing.ids, ...ids])];
|
|
567
|
+
if (args.source) existing.source = args.source;
|
|
568
|
+
} else {
|
|
569
|
+
tasks.push({ task: name, source: args.source ?? null, ids });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
await savePlan(root, tasks);
|
|
542
573
|
|
|
543
|
-
console.log(
|
|
574
|
+
console.log(
|
|
575
|
+
`${verb === 'set' ? 'plan set to' : 'added'} ${ids.length} scenario(s)` +
|
|
576
|
+
`${name ? ` under "${name}"` : ''}; ${tasks.length} task(s) in the plan`
|
|
577
|
+
);
|
|
544
578
|
if (unresolved.length) {
|
|
545
579
|
// Never silent: an item that vanished here is scope that quietly left the task.
|
|
546
580
|
console.log(`\n${unresolved.length} item(s) did NOT resolve:`);
|
|
@@ -550,18 +584,24 @@ async function cmdPlan(args) {
|
|
|
550
584
|
return;
|
|
551
585
|
}
|
|
552
586
|
|
|
553
|
-
const
|
|
587
|
+
const chosen = pick(plan.tasks);
|
|
588
|
+
const { live, orphaned } = readPlan(plan, scenarios, chosen);
|
|
554
589
|
const state = completeness(live);
|
|
555
590
|
|
|
556
591
|
if (verb === 'check') {
|
|
557
592
|
console.log(
|
|
558
|
-
`${state.settled}/${state.total} settled` +
|
|
559
|
-
`${
|
|
593
|
+
`${state.settled}/${state.total} settled across ${chosen.length} task(s)` +
|
|
594
|
+
`${orphaned.length ? `, ${orphaned.length} orphaned` : ''}`
|
|
560
595
|
);
|
|
561
596
|
for (const s of state.outstanding) {
|
|
562
|
-
console.log(
|
|
597
|
+
console.log(
|
|
598
|
+
` outstanding ${(s.id ?? '').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)}` +
|
|
599
|
+
` [${s.planTask ?? 'no task'}] ${s.title}`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
for (const o of orphaned) {
|
|
603
|
+
console.log(` ORPHANED ${o.id} [${o.task ?? 'no task'}] - no longer in the documents`);
|
|
563
604
|
}
|
|
564
|
-
for (const id of orphaned) console.log(` ORPHANED ${id} - no longer in the documents`);
|
|
565
605
|
// Exit non-zero so this can gate: a task is not done while a declared scenario is not.
|
|
566
606
|
if (state.outstanding.length || orphaned.length) process.exit(1);
|
|
567
607
|
return;
|
|
@@ -580,15 +620,23 @@ async function cmdPlan(args) {
|
|
|
580
620
|
console.log(`no plan for this branch yet (${plan.file})`);
|
|
581
621
|
return;
|
|
582
622
|
}
|
|
583
|
-
console.log(
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
623
|
+
console.log(`${state.settled}/${state.total} settled across ${chosen.length} task(s)\n`);
|
|
624
|
+
for (const group of chosen) {
|
|
625
|
+
const mine = live.filter((s) => s.planTask === group.task);
|
|
626
|
+
const done = mine.filter((s) => s.status === 'accepted' || s.status === 'verified').length;
|
|
627
|
+
console.log(
|
|
628
|
+
`${group.task ?? '(unnamed task)'}${group.source ? ` · ${group.source}` : ''}` +
|
|
629
|
+
` ${done}/${mine.length}`
|
|
630
|
+
);
|
|
631
|
+
for (const s of mine) {
|
|
632
|
+
const mark = s.status === 'accepted' || s.status === 'verified' ? 'x' : ' ';
|
|
633
|
+
console.log(` [${mark}] ${(s.id ?? '').padEnd(22)} ${(s.status ?? 'untracked').padEnd(9)} ${s.title}`);
|
|
634
|
+
}
|
|
635
|
+
for (const o of orphaned.filter((o) => o.task === group.task)) {
|
|
636
|
+
console.log(` [!] ${o.id.padEnd(22)} ORPHANED - no longer in the documents`);
|
|
637
|
+
}
|
|
638
|
+
console.log('');
|
|
590
639
|
}
|
|
591
|
-
for (const id of orphaned) console.log(` [!] ${id.padEnd(22)} ORPHANED - no longer in the documents`);
|
|
592
640
|
}
|
|
593
641
|
|
|
594
642
|
/** Read piped input, so a work item's body can arrive whole. */
|
|
@@ -705,10 +753,13 @@ async function cmdManifest(args) {
|
|
|
705
753
|
chosen = narrowed.scenarios;
|
|
706
754
|
scope = `changed since ${args.since}`;
|
|
707
755
|
} else if (plan.found && !args.all) {
|
|
708
|
-
const {
|
|
756
|
+
const { selected } = selectTasks(plan.tasks, args.tasks ?? []);
|
|
757
|
+
const { live, orphaned } = readPlan(plan, all, selected);
|
|
709
758
|
chosen = live;
|
|
710
|
-
scope = 'the plan';
|
|
711
|
-
for (const
|
|
759
|
+
scope = selected.length === plan.tasks.length ? 'the plan' : selected.map((t) => t.task).join(', ');
|
|
760
|
+
for (const o of orphaned) {
|
|
761
|
+
console.error(`warning: ${o.id} is in the plan but not in the documents`);
|
|
762
|
+
}
|
|
712
763
|
}
|
|
713
764
|
|
|
714
765
|
const index = await indexVideos(roots[0], (await loadConfig()).mediaRoot).catch(() => null);
|
|
@@ -728,8 +779,7 @@ async function cmdManifest(args) {
|
|
|
728
779
|
}
|
|
729
780
|
|
|
730
781
|
const manifest = {
|
|
731
|
-
|
|
732
|
-
source: plan.source ?? null,
|
|
782
|
+
tasks: plan.tasks.map((t) => ({ task: t.task, source: t.source })),
|
|
733
783
|
project: roots[0]?.name ?? null,
|
|
734
784
|
branch: (await branchName(root)) ?? null,
|
|
735
785
|
scope,
|
|
@@ -750,7 +800,8 @@ async function cmdManifest(args) {
|
|
|
750
800
|
await writeFile(target, text, 'utf8');
|
|
751
801
|
console.log(
|
|
752
802
|
`wrote ${args.out}: ${manifest.counts.scenarios} scenario(s) for ` +
|
|
753
|
-
`${manifest.task ?? '(no task)'},
|
|
803
|
+
`${manifest.tasks.map((t) => t.task ?? '(unnamed)').join(', ') || '(no task)'}, ` +
|
|
804
|
+
`${manifest.counts.missing} with no recording`
|
|
754
805
|
);
|
|
755
806
|
for (const i of items.filter((x) => !x.video)) console.log(` no recording: ${i.id} ${i.title}`);
|
|
756
807
|
return;
|
|
@@ -764,10 +815,18 @@ async function cmdQueue(args) {
|
|
|
764
815
|
const since = args.since ?? settings?.since ?? null;
|
|
765
816
|
const { results } = await scanAllRoots(roots);
|
|
766
817
|
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
|
-
|
|
818
|
+
if (args.plan || args.tasks?.length) {
|
|
819
|
+
// The third scope, and the one a developer actually works to: what I chose. Narrowing
|
|
820
|
+
// further to named tasks is how a set of them is worked without the rest in the way.
|
|
821
|
+
const loaded = await loadPlan(roots[0]?.path ?? process.cwd());
|
|
822
|
+
const { selected, ambiguous } = selectTasks(loaded.tasks, args.tasks ?? []);
|
|
823
|
+
for (const a of ambiguous) {
|
|
824
|
+
throw new Error(`"${a.name}" matches ${a.matched.length} tasks: ${a.matched.join(', ')}`);
|
|
825
|
+
}
|
|
826
|
+
const { live, orphaned } = readPlan(loaded, all, selected);
|
|
827
|
+
for (const o of orphaned) {
|
|
828
|
+
console.error(`warning: ${o.id} is in the plan but not in the documents`);
|
|
829
|
+
}
|
|
771
830
|
all = live;
|
|
772
831
|
}
|
|
773
832
|
for (const project of scope?.unavailable ?? []) {
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -71,6 +71,7 @@ function saveViewState() {
|
|
|
71
71
|
video: $('#video').value,
|
|
72
72
|
search: $('#search').value,
|
|
73
73
|
selected: scenarioKey(state.view[state.index]),
|
|
74
|
+
task: $('#task').value,
|
|
74
75
|
tab: state.tab,
|
|
75
76
|
doc: state.doc,
|
|
76
77
|
tree: state.tree,
|
|
@@ -95,6 +96,7 @@ function restoreViewState() {
|
|
|
95
96
|
if (saved.status != null) $('#status').value = saved.status;
|
|
96
97
|
if (saved.video != null) $('#video').value = saved.video;
|
|
97
98
|
if (saved.search != null) $('#search').value = saved.search;
|
|
99
|
+
state.savedTask = saved.task ?? '';
|
|
98
100
|
state.restoreKey = saved.selected ?? null;
|
|
99
101
|
state.savedProject = saved.project ?? '';
|
|
100
102
|
// The reading position is part of the session too: someone half way through a
|
|
@@ -181,8 +183,11 @@ function applyFilters() {
|
|
|
181
183
|
const q = $('#search').value.trim().toLowerCase();
|
|
182
184
|
|
|
183
185
|
const video = $('#video').value;
|
|
186
|
+
const task = $('#task').value;
|
|
184
187
|
|
|
185
188
|
const filtered = state.all
|
|
189
|
+
// A set of tasks is normal, so narrowing to one is a filter rather than a mode.
|
|
190
|
+
.filter((s) => (task ? s.planTask === task : true))
|
|
186
191
|
.filter((s) => (project ? s.project === project : true))
|
|
187
192
|
.filter((s) => matchesStatus(s, status))
|
|
188
193
|
.filter((s) => matchesVideo(s, video))
|
|
@@ -615,6 +620,19 @@ async function load({ keepIndex } = {}) {
|
|
|
615
620
|
'<option value="">everything</option>';
|
|
616
621
|
st.value = stCurrent;
|
|
617
622
|
|
|
623
|
+
// The task list comes from what the plan actually declared, so an option cannot exist for
|
|
624
|
+
// a task nothing is planned under - and a selection that no longer exists falls back to
|
|
625
|
+
// "all" rather than silently showing an empty queue.
|
|
626
|
+
const taskSel = $('#task');
|
|
627
|
+
const tasks = [...new Set(state.all.map((s) => s.planTask).filter(Boolean))].sort();
|
|
628
|
+
const wantTask = taskSel.value || state.savedTask || '';
|
|
629
|
+
taskSel.innerHTML =
|
|
630
|
+
'<option value="">all tasks</option>' +
|
|
631
|
+
tasks.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
|
|
632
|
+
taskSel.value = tasks.includes(wantTask) ? wantTask : '';
|
|
633
|
+
state.savedTask = null;
|
|
634
|
+
taskSel.hidden = tasks.length === 0;
|
|
635
|
+
|
|
618
636
|
renderRoots();
|
|
619
637
|
|
|
620
638
|
if (!keepIndex) state.index = 0;
|
|
@@ -828,7 +846,7 @@ $('#detail').addEventListener('click', (e) => {
|
|
|
828
846
|
if (b) act(b.dataset.act);
|
|
829
847
|
});
|
|
830
848
|
|
|
831
|
-
for (const id of ['#project', '#status', '#video', '#search']) {
|
|
849
|
+
for (const id of ['#project', '#status', '#video', '#search', '#task']) {
|
|
832
850
|
$(id).addEventListener('input', () => {
|
|
833
851
|
applyFilters();
|
|
834
852
|
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/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
|
@@ -279,6 +279,8 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
|
|
|
279
279
|
// globally because each worktree is its own registered entry with its own branch
|
|
280
280
|
// and its own plan - which is what lets several people work at once without their
|
|
281
281
|
// batches bleeding into each other's page.
|
|
282
|
+
// Which task each id was declared under, so the page can narrow to a set of them
|
|
283
|
+
// the same way the command line does.
|
|
282
284
|
const planned = new Map();
|
|
283
285
|
// The glossary, per project. The page is where a person READS criteria, so it shows
|
|
284
286
|
// the words the product uses; the raw markers stay in the payload because that is
|
|
@@ -287,9 +289,11 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
|
|
|
287
289
|
for (const root of roots) {
|
|
288
290
|
try {
|
|
289
291
|
const plan = await loadPlan(root.path);
|
|
290
|
-
|
|
292
|
+
const byId = new Map();
|
|
293
|
+
for (const t of plan.tasks ?? []) for (const id of t.ids) byId.set(id, t.task);
|
|
294
|
+
planned.set(root.name, byId);
|
|
291
295
|
} catch {
|
|
292
|
-
planned.set(root.name, new
|
|
296
|
+
planned.set(root.name, new Map());
|
|
293
297
|
}
|
|
294
298
|
try {
|
|
295
299
|
const settings = await loadSettings(root.path);
|
|
@@ -326,6 +330,7 @@ export function createReviewServer(rootsOrLoader, opts = {}) {
|
|
|
326
330
|
// should read the same whoever is looking at it. Display only, unlike
|
|
327
331
|
// `source`, which reaches a committed artefact.
|
|
328
332
|
inPlan: planned.get(s.project)?.has(s.id) ?? false,
|
|
333
|
+
planTask: planned.get(s.project)?.get(s.id) ?? null,
|
|
329
334
|
titleDisplay: renderTerms(s.title, glossaries.get(s.project) ?? {}).rendered,
|
|
330
335
|
stepsDisplay: (s.steps ?? []).map(
|
|
331
336
|
(t) => renderTerms(t, glossaries.get(s.project) ?? {}).rendered
|