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.
@@ -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
@@ -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
- 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.
@@ -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> --source <where it came from>
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 next = verb === 'set' ? ids : [...(plan.ids ?? []), ...ids];
541
- await savePlan(root, { task: args.task ?? plan.task, source: args.source ?? plan.source, ids: next });
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(`${verb === 'set' ? 'plan set to' : 'added'} ${ids.length} scenario(s)`);
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 { live, orphaned } = readPlan(plan, scenarios);
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
- `${plan.task ? ` for ${plan.task}` : ''}${orphaned.length ? `, ${orphaned.length} orphaned` : ''}`
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(` outstanding ${(s.id ?? '').padEnd(22)} ${s.status ?? 'untracked'} ${s.title}`);
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
- `${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}`);
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 { live, orphaned } = readPlan(plan, all);
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 id of orphaned) console.error(`warning: ${id} is in the plan but not in the documents`);
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
- task: plan.task ?? null,
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)'}, ${manifest.counts.missing} with no recording`
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
- 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`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "criteria-review",
3
- "version": "0.7.2",
3
+ "version": "0.8.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",
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 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
@@ -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
- planned.set(root.name, new Set(plan.ids ?? []));
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 Set());
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