@pareto4/pp-core 1.21.1 → 1.22.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.
@@ -0,0 +1,398 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery tests check (Session 9 — testing and UAT).
4
+ *
5
+ * `.planning/discovery/_tests.md` is the register of the test manager's calls
6
+ * the agents cannot make: which main processes get an end-to-end test (a risk
7
+ * rating per `MP-` process), which parts of the solution get extra test
8
+ * attention because they are complex (components, interfaces, rules,
9
+ * migration rows, entities), and the testability review of every `US-` story.
10
+ * Session 9 writes it; the build reads it by requirement ID through
11
+ * `discovery for-phase`. This module checks the structure:
12
+ *
13
+ * - `## Process risk`, `## Focus areas` and `## Acceptance` exist with the
14
+ * template's columns;
15
+ * - every main process of `_processes.md` § To-be has exactly one risk row,
16
+ * the risk follows from impact and likelihood by the fixed matrix, and a
17
+ * high-risk process without an end-to-end test cites a `D-09-<n>`;
18
+ * - every focus-area subject resolves against its source when on file;
19
+ * - every story of `_stories.md` has exactly one acceptance row;
20
+ * - roles resolve against `_roles.md` § Roles when on file, requirements
21
+ * against `_requirements.md` when on file and parseable;
22
+ * - `--require-complete`: an `open` story or a focus area whose
23
+ * `Requirements` is still `—` fails on its own exit identity, so the
24
+ * handoff can hard-block on it while Sessions 9 and 13 only report.
25
+ *
26
+ * Posture (D-43/D-53): reports at Sessions 9 and 13; hard-blocks at the
27
+ * handoff. Absent artifact → exit 0 — an engagement that predates it.
28
+ */
29
+ var __importDefault = (this && this.__importDefault) || function (mod) {
30
+ return (mod && mod.__esModule) ? mod : { "default": mod };
31
+ };
32
+ const node_fs_1 = __importDefault(require("node:fs"));
33
+ const node_path_1 = __importDefault(require("node:path"));
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const io = require("./io.cjs");
36
+ const { output, error, ERROR_REASON } = io;
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const table = require("./discovery-table.cjs");
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const rollup = require("./discovery-rollup.cjs");
41
+ // ─── Constants ────────────────────────────────────────────────────────────────
42
+ const DISCOVERY_DIR = '.planning/discovery';
43
+ const TESTS_FILE = `${DISCOVERY_DIR}/_tests.md`;
44
+ const REQUIREMENTS_FILE = `${DISCOVERY_DIR}/_requirements.md`;
45
+ const PROCESSES_FILE = `${DISCOVERY_DIR}/_processes.md`;
46
+ const STORIES_FILE = `${DISCOVERY_DIR}/_stories.md`;
47
+ const ROLES_FILE = `${DISCOVERY_DIR}/_roles.md`;
48
+ const INTEGRATIONS_FILE = `${DISCOVERY_DIR}/_integrations.md`;
49
+ const RULES_FILE = `${DISCOVERY_DIR}/_rules.md`;
50
+ const MIGRATION_FILE = `${DISCOVERY_DIR}/_migration.md`;
51
+ const ENTITIES_FILE = `${DISCOVERY_DIR}/_entities.md`;
52
+ const SESSION_05_FILE = `${DISCOVERY_DIR}/session-05.md`;
53
+ const RISK_SECTION = 'Process risk';
54
+ const FOCUS_SECTION = 'Focus areas';
55
+ const ACCEPTANCE_SECTION = 'Acceptance';
56
+ const RISK_HEADER = Object.freeze(['Process', 'Impact', 'Likelihood', 'Risk', 'End-to-end', 'Roles', 'Test data', 'Rationale']);
57
+ const FOCUS_HEADER = Object.freeze(['Subject', 'Complexity', 'Extra testing', 'Requirements']);
58
+ const ACCEPTANCE_HEADER = Object.freeze(['Story', 'Testable', 'Role', 'Test data']);
59
+ const MP_RE = /^MP-\d{2}$/;
60
+ const US_RE = /^US-\d{2}$/;
61
+ const ENTITY_NAME_RE = /^[A-Z][A-Za-z0-9]*$/;
62
+ const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
63
+ const SESSION_9_DECISION_RE = /\bD-09-\d+\b/;
64
+ const TESTABLE_RE = /^(yes|amended \(D-09-\d+\)|open \(A-09-\d+\))$/;
65
+ const RATINGS = Object.freeze(['low', 'medium', 'high']);
66
+ /** RISK_MATRIX[impact][likelihood] — `references/discovery-tests.md` § Risk rating. */
67
+ const RISK_MATRIX = Object.freeze({
68
+ high: Object.freeze({ low: 'medium', medium: 'high', high: 'high' }),
69
+ medium: Object.freeze({ low: 'low', medium: 'medium', high: 'high' }),
70
+ low: Object.freeze({ low: 'low', medium: 'low', high: 'medium' }),
71
+ });
72
+ /** Subject ID prefixes and the source each resolves against. */
73
+ const SUBJECT_KINDS = Object.freeze([
74
+ { re: /^C-\d{2}$/, source: 'components' },
75
+ { re: /^IF-\d{2}$/, source: 'interfaces' },
76
+ { re: /^BR-\d{2}$/, source: 'rules' },
77
+ { re: /^MG-\d{2}$/, source: 'migrations' },
78
+ ]);
79
+ const PENDING = '—';
80
+ const COMPONENT_INVENTORY_LABEL = 'Component Inventory';
81
+ const USAGE = 'Usage: pp-tools discovery tests-check [--require-complete]';
82
+ function readIfPresent(abs) {
83
+ if (!node_fs_1.default.existsSync(abs))
84
+ return { kind: 'absent' };
85
+ try {
86
+ return { kind: 'ok', content: node_fs_1.default.readFileSync(abs, 'utf-8') };
87
+ }
88
+ catch {
89
+ return { kind: 'unreadable' };
90
+ }
91
+ }
92
+ function sameHeader(actual, expected) {
93
+ return actual.length === expected.length && actual.every((c, i) => c.trim().toLowerCase() === expected[i].toLowerCase());
94
+ }
95
+ function splitList(cell) {
96
+ return cell.split(',').map((t) => t.trim()).filter(Boolean);
97
+ }
98
+ function firstColumnOf(content, heading) {
99
+ const section = table.splitSections(content.split(/\r?\n/), 2).find((s) => s.heading === heading);
100
+ if (!section)
101
+ return new Set();
102
+ const tables = table.parseTables(section.body);
103
+ if (tables.length === 0)
104
+ return new Set();
105
+ return new Set(tables[0].rows.map((r) => (r.cells[0] ?? '').trim()).filter(Boolean));
106
+ }
107
+ /** Component IDs: the first column of Session 5's Component Inventory; null when absent or without IDs. */
108
+ function componentsFrom(content) {
109
+ const lines = content.split(/\r?\n/);
110
+ const norm = (s) => s.replace(/^#+\s*/, '').replace(/\*\*/g, '').trim();
111
+ const at = lines.findIndex((l) => norm(l) === COMPONENT_INVENTORY_LABEL);
112
+ if (at === -1)
113
+ return null;
114
+ const body = [];
115
+ for (let i = at + 1; i < lines.length; i += 1) {
116
+ if (/^#{1,3}\s/.test(lines[i]) || /^\*\*[^*]+\*\*$/.test(lines[i].trim()))
117
+ break;
118
+ body.push({ n: i + 1, text: lines[i] });
119
+ }
120
+ const tables = table.parseTables(body);
121
+ if (tables.length === 0)
122
+ return null;
123
+ // A row counts only when it is more than the template's `| C-01 | | |` label.
124
+ const out = new Set(tables[0].rows
125
+ .filter((r) => r.cells.slice(1).some((c) => c.trim() !== ''))
126
+ .map((r) => (r.cells[0] ?? '').trim())
127
+ .filter((c) => c !== ''));
128
+ return out.size === 0 ? null : out;
129
+ }
130
+ /** The table under a `##` heading, or a violation when it is missing or off the template. */
131
+ function sectionTable(sections, heading, header, violations) {
132
+ const section = sections.find((s) => s.heading === heading);
133
+ if (!section) {
134
+ violations.push({ artifact: TESTS_FILE, reason: 'missing_section', id: heading });
135
+ return null;
136
+ }
137
+ const tables = table.parseTables(section.body);
138
+ if (tables.length === 0) {
139
+ violations.push({ artifact: TESTS_FILE, reason: 'missing_section', line: section.line, id: heading });
140
+ return null;
141
+ }
142
+ if (!sameHeader(tables[0].header, header)) {
143
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_table_shape', line: tables[0].line, id: heading });
144
+ return null;
145
+ }
146
+ return tables[0];
147
+ }
148
+ function checkRequirements(cell, sources, line, violations) {
149
+ for (const r of splitList(cell)) {
150
+ if (!REQUIREMENT_ID_RE.test(r))
151
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_requirement_id', line, id: r.slice(0, 40) });
152
+ else if (sources.requirementIds instanceof Set && !sources.requirementIds.has(r))
153
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_requirement_id', line, id: r });
154
+ }
155
+ }
156
+ function checkRole(role, sources, line, violations) {
157
+ if (sources.roles !== null && !sources.roles.has(role))
158
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_role', line, id: role.slice(0, 40) });
159
+ }
160
+ // ─── Check ────────────────────────────────────────────────────────────────────
161
+ function checkTests(content, sources, requireComplete = false) {
162
+ const violations = [];
163
+ const sections = table.splitSections(content.split(/\r?\n/), 2);
164
+ const risk = sectionTable(sections, RISK_SECTION, RISK_HEADER, violations);
165
+ const focus = sectionTable(sections, FOCUS_SECTION, FOCUS_HEADER, violations);
166
+ const acceptance = sectionTable(sections, ACCEPTANCE_SECTION, ACCEPTANCE_HEADER, violations);
167
+ // Process risk — one row per main process.
168
+ const assessed = new Set();
169
+ let endToEnd = 0;
170
+ for (const row of risk?.rows ?? []) {
171
+ const [mp, impact, likelihood, rating, e2e, roles, testData, rationale] = row.cells.map((c) => c.trim());
172
+ const line = row.line;
173
+ if (!MP_RE.test(mp ?? ''))
174
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_process', line });
175
+ else if (assessed.has(mp))
176
+ violations.push({ artifact: TESTS_FILE, reason: 'duplicate_process', line, id: mp });
177
+ else {
178
+ assessed.add(mp);
179
+ if (sources.processes !== null && !sources.processes.has(mp))
180
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_process', line, id: mp });
181
+ }
182
+ const ratings = [['Impact', impact], ['Likelihood', likelihood], ['Risk', rating]];
183
+ let ratingsOk = true;
184
+ for (const [name, cell] of ratings) {
185
+ if (!RATINGS.includes(cell ?? '')) {
186
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_rating', line, id: name });
187
+ ratingsOk = false;
188
+ }
189
+ }
190
+ if (ratingsOk && RISK_MATRIX[impact][likelihood] !== rating)
191
+ violations.push({ artifact: TESTS_FILE, reason: 'risk_mismatch', line, id: MP_RE.test(mp ?? '') ? mp : undefined });
192
+ if (e2e !== 'yes' && e2e !== 'no')
193
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_end_to_end', line });
194
+ if (e2e === 'yes') {
195
+ endToEnd += 1;
196
+ const names = (roles ?? '') === PENDING ? [] : splitList(roles ?? '');
197
+ if (names.length === 0)
198
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Roles' });
199
+ for (const n of names)
200
+ checkRole(n, sources, line, violations);
201
+ if ((testData ?? '') === '' || testData === PENDING)
202
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Test data' });
203
+ }
204
+ if ((rationale ?? '') === '')
205
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Rationale' });
206
+ if (rating === 'high' && e2e === 'no' && !SESSION_9_DECISION_RE.test(rationale ?? '')) {
207
+ violations.push({ artifact: TESTS_FILE, reason: 'high_risk_not_end_to_end', line, id: MP_RE.test(mp ?? '') ? mp : undefined });
208
+ }
209
+ }
210
+ if (risk && sources.processes !== null) {
211
+ for (const mp of [...sources.processes].sort()) {
212
+ if (!assessed.has(mp))
213
+ violations.push({ artifact: TESTS_FILE, reason: 'process_not_assessed', id: mp });
214
+ }
215
+ }
216
+ // Focus areas — one row per subject.
217
+ const subjects = new Set();
218
+ let uncited = 0;
219
+ for (const row of focus?.rows ?? []) {
220
+ const [subject, complexity, extra, requirements] = row.cells.map((c) => c.trim());
221
+ const line = row.line;
222
+ const kind = SUBJECT_KINDS.find((k) => k.re.test(subject ?? ''));
223
+ if (!kind && !ENTITY_NAME_RE.test(subject ?? ''))
224
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_subject', line });
225
+ else if (subjects.has(subject))
226
+ violations.push({ artifact: TESTS_FILE, reason: 'duplicate_subject', line, id: subject });
227
+ else {
228
+ subjects.add(subject);
229
+ const known = kind ? sources[kind.source] : sources.entities;
230
+ if (known !== null && !known.has(subject))
231
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_subject', line, id: subject });
232
+ }
233
+ if ((complexity ?? '') === '')
234
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Complexity' });
235
+ if ((extra ?? '') === '')
236
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Extra testing' });
237
+ const reqCell = requirements ?? '';
238
+ if (reqCell === '' || reqCell === PENDING) {
239
+ uncited += 1;
240
+ if (requireComplete)
241
+ violations.push({ artifact: TESTS_FILE, reason: 'uncited_focus', line, id: subjects.has(subject) ? subject : undefined });
242
+ }
243
+ else {
244
+ checkRequirements(reqCell, sources, line, violations);
245
+ }
246
+ }
247
+ // Acceptance — one row per story.
248
+ const reviewed = new Set();
249
+ let open = 0;
250
+ for (const row of acceptance?.rows ?? []) {
251
+ const [story, testable, role, testData] = row.cells.map((c) => c.trim());
252
+ const line = row.line;
253
+ if (!US_RE.test(story ?? ''))
254
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_story', line });
255
+ else if (reviewed.has(story))
256
+ violations.push({ artifact: TESTS_FILE, reason: 'duplicate_story', line, id: story });
257
+ else {
258
+ reviewed.add(story);
259
+ if (sources.stories !== null && !sources.stories.has(story))
260
+ violations.push({ artifact: TESTS_FILE, reason: 'unknown_story', line, id: story });
261
+ }
262
+ if (!TESTABLE_RE.test(testable ?? ''))
263
+ violations.push({ artifact: TESTS_FILE, reason: 'bad_testable', line });
264
+ else if (testable.startsWith('open')) {
265
+ open += 1;
266
+ if (requireComplete)
267
+ violations.push({ artifact: TESTS_FILE, reason: 'story_not_testable', line, id: US_RE.test(story ?? '') ? story : undefined });
268
+ }
269
+ if ((role ?? '') === '' || role === PENDING)
270
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Role' });
271
+ else
272
+ checkRole(role, sources, line, violations);
273
+ if ((testData ?? '') === '')
274
+ violations.push({ artifact: TESTS_FILE, reason: 'empty_cell', line, id: 'Test data' });
275
+ }
276
+ if (acceptance && sources.stories !== null) {
277
+ for (const us of [...sources.stories].sort()) {
278
+ if (!reviewed.has(us))
279
+ violations.push({ artifact: TESTS_FILE, reason: 'story_not_reviewed', id: us });
280
+ }
281
+ }
282
+ if (sources.requirementIds === 'unparseable')
283
+ violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
284
+ return {
285
+ ok: violations.length === 0,
286
+ processes: assessed.size,
287
+ end_to_end: endToEnd,
288
+ focus_areas: subjects.size,
289
+ stories: reviewed.size,
290
+ open_stories: open,
291
+ uncited,
292
+ violations,
293
+ };
294
+ }
295
+ function checkProjectTests(cwd, requireComplete = false) {
296
+ const read = readIfPresent(node_path_1.default.join(cwd, TESTS_FILE));
297
+ if (read.kind === 'absent')
298
+ return null;
299
+ const empty = { processes: 0, end_to_end: 0, focus_areas: 0, stories: 0, open_stories: 0, uncited: 0 };
300
+ if (read.kind === 'unreadable')
301
+ return { ok: false, file: TESTS_FILE, ...empty, violations: [{ artifact: TESTS_FILE, reason: 'artifact_unreadable' }] };
302
+ const extra = [];
303
+ const sources = {
304
+ requirementIds: null, processes: null, stories: null, roles: null,
305
+ components: null, interfaces: null, rules: null, migrations: null, entities: null,
306
+ };
307
+ const load = (file) => {
308
+ const r = readIfPresent(node_path_1.default.join(cwd, file));
309
+ if (r.kind === 'unreadable')
310
+ extra.push({ artifact: file, reason: 'artifact_unreadable' });
311
+ return r.kind === 'ok' ? r.content : null;
312
+ };
313
+ const req = load(REQUIREMENTS_FILE);
314
+ if (req !== null) {
315
+ const parsed = rollup.parseRequirementsArtifact(req);
316
+ sources.requirementIds = parsed.ok ? new Set(parsed.rows.map((r) => r.id)) : 'unparseable';
317
+ }
318
+ const proc = load(PROCESSES_FILE);
319
+ if (proc !== null)
320
+ sources.processes = firstColumnOf(proc, 'To-be');
321
+ const sto = load(STORIES_FILE);
322
+ if (sto !== null)
323
+ sources.stories = firstColumnOf(sto, 'Stories');
324
+ const rol = load(ROLES_FILE);
325
+ if (rol !== null)
326
+ sources.roles = firstColumnOf(rol, 'Roles');
327
+ const integ = load(INTEGRATIONS_FILE);
328
+ if (integ !== null)
329
+ sources.interfaces = firstColumnOf(integ, 'Interfaces');
330
+ const rules = load(RULES_FILE);
331
+ if (rules !== null)
332
+ sources.rules = firstColumnOf(rules, 'Rules');
333
+ const mig = load(MIGRATION_FILE);
334
+ if (mig !== null)
335
+ sources.migrations = firstColumnOf(mig, 'Migration');
336
+ const ent = load(ENTITIES_FILE);
337
+ if (ent !== null)
338
+ sources.entities = firstColumnOf(ent, 'Entities');
339
+ const s5 = load(SESSION_05_FILE);
340
+ if (s5 !== null)
341
+ sources.components = componentsFrom(s5);
342
+ const result = checkTests(read.content, sources, requireComplete);
343
+ result.violations.push(...extra);
344
+ result.ok = result.violations.length === 0;
345
+ return { file: TESTS_FILE, ...result };
346
+ }
347
+ function formatViolation(v) {
348
+ const where = v.line !== undefined ? `:${v.line}` : '';
349
+ const what = v.id !== undefined ? ` (${v.id})` : '';
350
+ return ` - ${v.artifact}${where}: ${v.reason}${what}`;
351
+ }
352
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
353
+ /**
354
+ * `pp-tools discovery tests-check [--require-complete]` (dotted
355
+ * `query discovery.tests-check`). Absent artifact → exit 0,
356
+ * `present: false`. Structural faults exit via
357
+ * `DISCOVERY_TESTS_CHECK_FAILED`; when every fault was raised by
358
+ * `--require-complete` (`story_not_testable`, `uncited_focus`), the identity
359
+ * is `DISCOVERY_TESTS_INCOMPLETE`.
360
+ */
361
+ function cmdDiscoveryTestsCheck(cwd, args, raw) {
362
+ const rest = args.slice(2);
363
+ const requireComplete = rest.includes('--require-complete');
364
+ if (rest.some((a) => a !== '--require-complete')) {
365
+ error(USAGE, ERROR_REASON.USAGE);
366
+ return; // unreachable
367
+ }
368
+ const result = checkProjectTests(cwd, requireComplete);
369
+ if (result === null) {
370
+ output({ ok: true, present: false, file: TESTS_FILE }, raw);
371
+ return;
372
+ }
373
+ const payload = {
374
+ ok: result.ok, present: true, file: result.file,
375
+ processes: result.processes, end_to_end: result.end_to_end, focus_areas: result.focus_areas,
376
+ stories: result.stories, open_stories: result.open_stories, uncited: result.uncited,
377
+ };
378
+ if (!result.ok) {
379
+ output({ ...payload, violations: result.violations.length }, raw);
380
+ const onlyIncomplete = result.violations.every((v) => v.reason === 'story_not_testable' || v.reason === 'uncited_focus');
381
+ error(`discovery tests-check: ${result.violations.length} violation(s):\n` + result.violations.map(formatViolation).join('\n'), onlyIncomplete ? ERROR_REASON.DISCOVERY_TESTS_INCOMPLETE : ERROR_REASON.DISCOVERY_TESTS_CHECK_FAILED);
382
+ return; // unreachable
383
+ }
384
+ output(payload, raw);
385
+ }
386
+ module.exports = {
387
+ TESTS_FILE,
388
+ RISK_HEADER,
389
+ FOCUS_HEADER,
390
+ ACCEPTANCE_HEADER,
391
+ RISK_MATRIX,
392
+ TESTS_CHECK_FAILED: ERROR_REASON.DISCOVERY_TESTS_CHECK_FAILED,
393
+ TESTS_INCOMPLETE: ERROR_REASON.DISCOVERY_TESTS_INCOMPLETE,
394
+ componentsFrom,
395
+ checkTests,
396
+ checkProjectTests,
397
+ cmdDiscoveryTestsCheck,
398
+ };
@@ -225,6 +225,11 @@ const ERROR_REASON = Object.freeze({
225
225
  DISCOVERY_SESSION_CHECK_FAILED: 'discovery_session_check_failed',
226
226
  DISCOVERY_DEVIATIONS_CHECK_FAILED: 'discovery_deviations_check_failed',
227
227
  DISCOVERY_DEVIATIONS_UNAPPROVED: 'discovery_deviations_unapproved',
228
+ // discovery tests register (Session 9 — testing and UAT)
229
+ DISCOVERY_TESTS_CHECK_FAILED: 'discovery_tests_check_failed',
230
+ DISCOVERY_TESTS_INCOMPLETE: 'discovery_tests_incomplete',
231
+ // discovery flow page (Session 3 Step 5C — iterate a swimlane)
232
+ DISCOVERY_FLOW_PAGE_FAILED: 'discovery_flow_page_failed',
228
233
  // timeline (Phase 35: end with no matching open run)
229
234
  TIMELINE_NO_OPEN_RUN: 'timeline_no_open_run',
230
235
  // generic
@@ -2773,13 +2773,37 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
2773
2773
  } catch (deviationsCheckErr) {
2774
2774
  error(`discovery deviations-check: ${deviationsCheckErr && deviationsCheckErr.message ? deviationsCheckErr.message : String(deviationsCheckErr)}`, ERROR_REASON.UNKNOWN);
2775
2775
  }
2776
+ } else if (subcommand === 'tests-check') {
2777
+ // discovery tests-check [--require-complete] — _tests.md structure:
2778
+ // one risk row per main process with the matrix rating, end-to-end
2779
+ // for every high risk unless a D-09 decision says why, focus-area
2780
+ // subjects resolving, one acceptance row per story.
2781
+ // --require-complete also fails on an open story or an uncited focus
2782
+ // area (discovery_tests_incomplete).
2783
+ const testsRegister = require('./lib/discovery-tests.cjs');
2784
+ try {
2785
+ testsRegister.cmdDiscoveryTestsCheck(cwd, args, raw);
2786
+ } catch (testsCheckErr) {
2787
+ error(`discovery tests-check: ${testsCheckErr && testsCheckErr.message ? testsCheckErr.message : String(testsCheckErr)}`, ERROR_REASON.UNKNOWN);
2788
+ }
2789
+ } else if (subcommand === 'flow-page') {
2790
+ // discovery flow-page <MP-nn> [--out <file.html>] — render one main
2791
+ // process (swimlane, sub-processes, step matrix) from _processes.md
2792
+ // as a page the agent publishes and republishes while the room
2793
+ // iterates the diagram (Session 3 Step 5C).
2794
+ const flowPage = require('./lib/discovery-flow-page.cjs');
2795
+ try {
2796
+ flowPage.cmdDiscoveryFlowPage(cwd, args, raw);
2797
+ } catch (flowPageErr) {
2798
+ error(`discovery flow-page: ${flowPageErr && flowPageErr.message ? flowPageErr.message : String(flowPageErr)}`, ERROR_REASON.UNKNOWN);
2799
+ }
2776
2800
  } else {
2777
2801
  // WIRING, NOT HELP TEXT: tests/discovery-artifacts.test.cjs derives each
2778
2802
  // subcommand spelling by regexing `<namespace>_<subcommand>_failed` out of
2779
2803
  // the engine's exported exit identity, splits this string on commas and
2780
2804
  // asserts membership. A spelling missing here is a command the engine
2781
2805
  // claims to be and the router does not dispatch.
2782
- error('Unknown discovery subcommand. Available: rollup, input-check, research-check, screen-check, element-sweep, rebaseline-check, vault-check, charter-check, entity-check, process-check, rules-check, stories-check, phases-check, for-phase, roles-check, integrations-check, migration-check, deviations-check, session-check', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2806
+ error('Unknown discovery subcommand. Available: rollup, input-check, research-check, screen-check, element-sweep, rebaseline-check, vault-check, charter-check, entity-check, process-check, rules-check, stories-check, phases-check, for-phase, roles-check, integrations-check, migration-check, deviations-check, session-check, tests-check, flow-page', ERROR_REASON.SDK_UNKNOWN_COMMAND);
2783
2807
  }
2784
2808
  break;
2785
2809
  }
@@ -43,6 +43,7 @@ blocked by it. The verifier takes the acceptance criteria of the extract's `_req
43
43
  | `_screens.md` § Screens, § No screen (still MVP) | screens whose `Requirements` cites one; no-screen rows keyed by one | ui-phase (exists); planner |
44
44
  | `_roles.md` § Permissions, § Roles, § Screens | the permission rows whose `Requirements` cites one; the § Roles row of every role those permissions name; the § Screens rows of those roles and of the phase's screens (`references/discovery-access.md`) | planner auth phase; forbidden-path tests |
45
45
  | `_integrations.md` § Interfaces, § Contract, § Errors | the interface rows whose `Requirements` cites one or that a selected step names in its `Integration` cell (`IF-nn`); their contract and error rows (`references/discovery-integrations.md`) | planner integration phases |
46
+ | `_tests.md` § Process risk, § Focus areas, § Acceptance | the risk rows with `End-to-end` `yes` of the main processes touched; the focus-area rows whose `Requirements` cites one; the acceptance rows of the selected stories (`references/discovery-tests.md`) | planner end-to-end and extra test tasks; verifier UAT (role and test data per story) |
46
47
  | `_migration.md`, `_deviations.md` | generic: every table carrying a `Requirements` (or `Requirement IDs`) column contributes its citing rows. A table with no requirement column is listed as *not filterable* in the extract, never silently dropped — `_deviations.md § Deviations` always is | planner migration phase; `pp-vault-review` reads `_deviations.md` whole (§ Deviations read contract) |
47
48
  | `_phases.md` § Phases | the phase's requirement IDs when ROADMAP.md has none yet | ROADMAP.md (handoff 5f, verbatim); `for-phase` |
48
49
 
@@ -69,7 +70,7 @@ table must carry a `Requirements` column; the column name is the join, not the p
69
70
  | `Requirements` | comma list of `_requirements.md` IDs (`[A-Z][A-Z0-9]*-\d+`), at least one |
70
71
  | `Story` | free text — `As a <persona>, I want <goal> so that <benefit>`; pipes escaped `\|` |
71
72
  | `Acceptance criteria` | free text, non-empty, and a criterion — never a pointer such as `As in F-01`; the verifier reads it verbatim as the UAT |
72
- | `Source step` | comma list of `MP-<nn> #<cell>` — the main process and the `#` cell of the step-matrix row the story was derived from, copied verbatim (`MP-05 #05.1`, `MP-01 #3`) — or `—` |
73
+ | `Source step` | comma list of `MP-<nn> #<cell>` — the main process and the `#` cell of the step-matrix row the story was derived from, copied verbatim (`MP-05 #05.1`, `MP-01 #3`); never `—` — a story comes from a step |
73
74
 
74
75
  ### Parser contract — `pp_run query discovery.stories-check`
75
76
 
@@ -82,7 +83,9 @@ table must carry a `Requirements` column; the column name is the join, not the p
82
83
  | `empty_cell` | `Story` or `Acceptance criteria` is empty (the cell is named) |
83
84
  | `acceptance_is_pointer` | `Acceptance criteria` starts with `As in`, `See`, `Zie` or `Idem` — a pointer, not a criterion the verifier can run |
84
85
  | `no_requirement` / `unknown_requirement_id` | no requirement cited, or one of the wrong shape or absent from `_requirements.md` (resolution skipped when that file is absent) |
85
- | `bad_source_step` / `unknown_process_id` / `unknown_process_step` | a `Source step` token is neither `—` nor `MP-nn #<cell>`; the main process is not in `_processes.md` § To-be; the step row is not in its matrix (both resolutions skipped when `_processes.md` is absent) |
86
+ | `bad_source_step` / `unknown_process_id` / `unknown_process_step` | a `Source step` token is not `MP-nn #<cell>`; the main process is not in `_processes.md` § To-be; the step row is not in its matrix (both resolutions skipped when `_processes.md` is absent) |
87
+ | `no_source_step` | `Source step` is `—` or empty — every story is tied to the step it came from |
88
+ | `step_without_story` | a step row whose actor is a persona and that cites at least one `MVP` requirement (not `N-`/`T-`) is no story's source step — the stories do not cover the process. Without a parseable `_requirements.md` every persona step needs one |
86
89
  | `mvp_requirement_without_story` | an `MVP` requirement that at least one persona-actor step row cites has no story. Mirrors Step 5E: stories come from the step rows whose actor is not `System`, so a requirement realised only by `System` steps, or cited by no step at all (engagement process rows), needs none; `N-`/`T-` rows never do. Without `_processes.md` only the prefix exemption applies |
87
90
  | `requirements_unparseable` / `artifact_unreadable` | `_requirements.md` present but failing `rollup --check`; an artifact present but unreadable |
88
91
 
@@ -102,7 +102,7 @@ Non-functional requirements are not in this list: they are `N-` rows of
102
102
 
103
103
  The canonical artifacts — `_requirements.md`, `_screens.md`, `_feedback.md`, `_sweep.md`,
104
104
  `_processes.md`, `_entities.md`, `_rules.md`, `_glossary.md`, `_stories.md`, `_phases.md`,
105
- `_roles.md`, `_integrations.md`, `_migration.md`, `_deviations.md`, the research registers and
105
+ `_roles.md`, `_integrations.md`, `_migration.md`, `_deviations.md`, `_tests.md`, the research registers and
106
106
  the Project Charter — are the single source of truth for the life of the engagement, through the
107
107
  build and after it. The session files are the facilitation record behind them.
108
108
 
@@ -11,7 +11,7 @@ lazily; it is never `@`-imported.
11
11
  | Session | `_processes.md` | `_rules.md` | `_glossary.md` |
12
12
  |---|---|---|---|
13
13
  | 2 | created from the template; `## As-is` rows (`PRC-`) from topic 1 | — | created from the template; terms the client used for the same thing |
14
- | 3 | Step 5A `## To-be` rows (`MP-`, each with `Replaces`); 5B one swimlane per main process under `## Flows`; 5C one step matrix per main process under `## Steps`, `Exception` column filled; 5D back-fills `Requirements` | Step 5D′ mints `BR-` rows from the step matrices | topic 1 glossary sweep: one row per term, synonyms under `Do not use` |
14
+ | 3 | Step 5B `## To-be` rows (`MP-`, each with `Replaces`) — the full process inventory; per main process, Step 5C its swimlane under `## Flows` with every step node labelled with its `#`, its `## Sub-processes` rows (`MP-nn.k`) and its step matrix under `## Steps`, `Exception` column filled; 5D back-fills `Requirements` | Step 5D′ mints `BR-` rows from the step matrices | topic 1 glossary sweep: one row per term, synonyms under `Do not use` |
15
15
  | 8 | `process-check` and `rules-check` in the Closing Step (reporting) | | |
16
16
  | 13 | both in the Step 0 gates (reporting) | | |
17
17
  | handoff | `process-check --require-cited` and `rules-check` hard-block | | |
@@ -48,13 +48,17 @@ Illustration only — not part of the template, names invented:
48
48
  |---|---|---|---|---|---|---|---|
49
49
  | 01.1 | Create task | Team member | Task form | Task [C] | — | Title missing → form error, nothing saved | K-06 |
50
50
 
51
+ | ID | Sub-process | Steps |
52
+ |---|---|---|
53
+ | MP-01.1 | Register the request | 01.1, 01.2 |
54
+
51
55
  Bootstrap template — Session 2 copies it verbatim to `.planning/discovery/_processes.md`, replacing
52
56
  `[PROJECT_NAME]`:
53
57
 
54
58
  ```markdown
55
59
  # Processes — [PROJECT_NAME]
56
60
 
57
- **Conventions:** pp-core reference `references/discovery-processes.md`. `PRC-` rows are the as-is processes (Session 2), `MP-` rows the to-be main processes (Session 3) with the as-is processes each replaces or `new`. One swimlane and one step matrix per main process. Nothing is renumbered or deleted; a change after sign-off edits the row in place and appends `→ amended by D-<ss>-<n>`.
61
+ **Conventions:** pp-core reference `references/discovery-processes.md`. `PRC-` rows are the as-is processes (Session 2), `MP-` rows the to-be main processes (Session 3) with the as-is processes each replaces or `new`. Every main process has sub-processes `MP-nn.k`, each listing its steps by `#`; every step belongs to exactly one. One swimlane and one step matrix per main process; the swimlane labels every step node with its `#`. Nothing is renumbered or deleted; a change after sign-off edits the row in place and appends `→ amended by D-<ss>-<n>`.
58
62
 
59
63
  ## As-is
60
64
 
@@ -66,12 +70,23 @@ Bootstrap template — Session 2 copies it verbatim to `.planning/discovery/_pro
66
70
  | ID | Main process | Replaces | Trigger | End state | Primary actor | EPICs |
67
71
  |----|--------------|----------|---------|-----------|---------------|-------|
68
72
 
73
+ ## Sub-processes
74
+
75
+ | ID | Sub-process | Steps |
76
+ |----|-------------|-------|
77
+
69
78
  ## Flows
70
79
 
71
80
  ## Steps
72
81
  ```
73
82
 
74
- Under `## Flows`, Step 5B adds one `### MP-nn — <name>` heading per main process holding a swimlane:
83
+ An engagement whose `_processes.md` predates the Session 3 rework has no `## Sub-processes`
84
+ section; `process-check` then skips the sub-process and flow-coverage rules below. Adding the
85
+ section (empty) opts the engagement in.
86
+
87
+ Under `## Flows`, Step 5C adds one `### MP-nn — <name>` heading per main process holding a swimlane.
88
+ Every step node carries its matrix `#` at the start of its label, so the diagram and the matrix
89
+ describe the same steps; decisions and end states need no number:
75
90
 
76
91
  ````markdown
77
92
  ### MP-01 — Handle a task
@@ -79,12 +94,14 @@ Under `## Flows`, Step 5B adds one `### MP-nn — <name>` heading per main proce
79
94
  ```mermaid
80
95
  flowchart LR
81
96
  subgraph Team member
82
- S1[Create task] --> S2[Assign]
97
+ S1["01.1 Create task"] --> S2["01.2 Assign"]
98
+ S2 --> D1{Urgent?}
83
99
  end
84
100
  subgraph System
85
- S3[Notify assignee]
101
+ S3["01.3 Notify assignee"]
86
102
  end
87
- S2 --> S3
103
+ D1 -->|yes| S3
104
+ D1 -->|no| E1([Task queued])
88
105
  ```
89
106
  ````
90
107
 
@@ -117,6 +134,13 @@ column name and the reason code — never cell prose. Absent `_processes.md` →
117
134
  | `### MP-nn — <name>` under Flows and Steps | names a To-be row | `unknown_process` |
118
135
  | a main process without a `### MP-nn` under Flows / Steps | one swimlane and one matrix each | `flow_missing`, `steps_missing` naming the MP |
119
136
  | a flow body | a ```` ```mermaid ```` block opening with `flowchart` and holding at least one `subgraph` | `flow_not_swimlane` |
137
+ | `## Sub-processes` (optional — absent on engagements that predate it) | one table with the template's columns | `bad_table_shape (Sub-processes)` |
138
+ | Sub-processes `ID` | `MP-nn.k` — a To-be main process and `k` from 1, unique, contiguous per main process | `bad_subprocess`, `duplicate_id`, `unknown_process`, `sequence_gap` naming `MP-nn.k` |
139
+ | Sub-processes `Sub-process` | non-empty | `empty_cell (Sub-process)` |
140
+ | Sub-processes `Steps` | a comma list of `#` cells of that main process's step matrix | `subprocess_without_steps`, `unknown_step`, `step_in_two_subprocesses` naming `MP-nn #<cell>` |
141
+ | every main process, when the section is on file | at least one sub-process | `subprocess_missing` |
142
+ | every step row, when the section is on file | listed by exactly one sub-process | `step_without_subprocess` naming `MP-nn #<cell>` |
143
+ | every step row, when the section is on file | its `#` appears as a whole token in its main process's flow | `step_not_in_flow` naming `MP-nn #<cell>` |
120
144
  | step `#`, `Step` | non-empty | `empty_cell (Step)` |
121
145
  | `UI`, `Integration`, `Exception` | free text (`—` allowed) | **not mechanically checked** — the Exception column must exist, its content is the facilitator's |
122
146
  | `Data objects` | `—`, or a comma list of `Name [C/R/U/D]` tokens | `bad_data_object`; whether each name is an entity is `entity-check`'s `unknown_entity` |
@@ -126,7 +150,7 @@ column name and the reason code — never cell prose. Absent `_processes.md` →
126
150
 
127
151
  **Exit identity.** `discovery_process_check_failed` for structural faults;
128
152
  `discovery_process_steps_uncited` when every fault is an `uncited_step`. Payload: `ok`, `present`,
129
- `file`, `as_is`, `to_be`, `flows`, `steps`, `uncited`.
153
+ `file`, `as_is`, `to_be`, `flows`, `steps`, `sub_processes`, `uncited`.
130
154
 
131
155
  ## `_rules.md`
132
156
 
@@ -61,7 +61,7 @@ before setting the score; it exits 0 before a session may read 100.
61
61
 
62
62
  The session files and the registers they point at (`_requirements.md`, `_entities.md`,
63
63
  `_processes.md`, `_rules.md`, `_glossary.md`, `_screens.md`, `_roles.md`,
64
- `_integrations.md`, `_migration.md`, `_deviations.md`) are the only record of what a session
64
+ `_integrations.md`, `_migration.md`, `_deviations.md`, `_tests.md`) are the only record of what a session
65
65
  produced. Nothing is summarised into a second file at session close: later sessions read the
66
66
  earlier session's Notes Template sections (the **Context-Awareness Rules** table names
67
67
  which), the Session 13 cross-reference reads the session files, and `/pp-discovery-to-build`