@pareto4/pp-core 1.21.0 → 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.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/gemini-extension.json +1 -1
  3. package/package.json +2 -2
  4. package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
  5. package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
  6. package/pp-core/bin/lib/discovery-processes.cjs +97 -4
  7. package/pp-core/bin/lib/discovery-stories.cjs +36 -5
  8. package/pp-core/bin/lib/discovery-tests.cjs +398 -0
  9. package/pp-core/bin/lib/io.cjs +5 -0
  10. package/pp-core/bin/lib/phase.cjs +11 -5
  11. package/pp-core/bin/lib/state.cjs +1 -0
  12. package/pp-core/bin/pp-tools.cjs +25 -1
  13. package/pp-core/references/discovery-build-consumption.md +5 -2
  14. package/pp-core/references/discovery-id-conventions.md +1 -1
  15. package/pp-core/references/discovery-processes.md +31 -7
  16. package/pp-core/references/discovery-session-output.md +1 -1
  17. package/pp-core/references/discovery-tests.md +186 -0
  18. package/pp-core/workflows/code-review.md +6 -3
  19. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  20. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
  21. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
  22. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +2 -1
  23. package/pp-core/workflows/discovery-sprint.md +2 -2
  24. package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
  25. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
  26. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
  27. package/pp-core/workflows/discovery-to-build.md +2 -2
  28. package/pp-core/workflows/discuss-phase/discovery.md +2 -2
  29. package/pp-core/workflows/execute-phase.md +1 -1
  30. package/pp-core/workflows/init/steps/client-context-capture.md +1 -1
  31. package/pp-core/workflows/profile-user.md +2 -2
  32. package/pp-core/workflows/quick.md +1 -1
  33. package/pp-core/workflows/ship.md +1 -1
  34. package/pp-core/workflows/spec-phase.md +2 -2
  35. package/scripts/run-tests.cjs +9 -6
@@ -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
@@ -58,7 +58,7 @@ const uatPredicate = require("./uat-predicate.cjs");
58
58
  const { evaluateUatPassed } = uatPredicate;
59
59
  const { planningDir, withPlanningLock, milestonePathsFor } = planningWorkspace;
60
60
  const { extractFrontmatter } = frontmatterMod;
61
- const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
61
+ const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, stripFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
62
62
  // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
63
63
  const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
64
64
  // Any .md file with PLAN anywhere in the basename — diagnostic net
@@ -1355,7 +1355,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1355
1355
  }
1356
1356
  if (node_fs_1.default.existsSync(statePath)) {
1357
1357
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1358
- let stateContent = originalStateContent;
1358
+ // #1257 class: the body-field patterns are case-insensitive and line-anchored, so
1359
+ // run over the whole file they hit the YAML keys first (`status:`, `progress:`)
1360
+ // and the body Status line never moved. Edit the body only; syncStateFrontmatter
1361
+ // below re-derives the frontmatter from it.
1362
+ const strippedBody = stripFrontmatter(originalStateContent);
1363
+ let frontmatterPrefix = originalStateContent.slice(0, originalStateContent.length - strippedBody.length);
1364
+ let stateContent = strippedBody;
1359
1365
  const phaseValue = nextPhaseNum || phaseNum;
1360
1366
  const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
1361
1367
  phaseDisplayNameFromSlug(nextPhaseName);
@@ -1420,7 +1426,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1420
1426
  const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases);
1421
1427
  stateContent =
1422
1428
  stateReplaceField(stateContent, 'Progress', `${newPercent}%`) || stateContent;
1423
- stateContent = stateContent.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
1429
+ frontmatterPrefix = frontmatterPrefix.replace(/(percent:\s*)\d+/, `$1${newPercent}`);
1424
1430
  }
1425
1431
  }
1426
1432
  stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
@@ -1430,14 +1436,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1430
1436
  // one still on disk.
1431
1437
  // D-10: this transaction sets Last activity itself; that explicit date flows.
1432
1438
  const activityFieldSet = (0, state_document_cjs_1.extractPositionalField)(stateContent, 'Last Activity', 'Last activity') !==
1433
- (0, state_document_cjs_1.extractPositionalField)(originalStateContent, 'Last Activity', 'Last activity');
1439
+ (0, state_document_cjs_1.extractPositionalField)(strippedBody, 'Last Activity', 'Last activity');
1434
1440
  // 261007-rly D-11: phase complete opts OUT of the stopped_at hold and keeps
1435
1441
  // re-deriving the key from the Session line, as it did before the hold moved
1436
1442
  // into syncStateFrontmatter: bug #3517's pin (tests/phase.test.cjs) needs a
1437
1443
  // newer Session line to replace a stale stored value here, the opposite of
1438
1444
  // the operator-note shape the hold protects. Filed, not decided here:
1439
1445
  // todos/pending/phase-complete-rederives-stopped-at-from-the-session-line.md.
1440
- stateContent = syncStateFrontmatter(stateContent, cwd, roadmapContent, activityFieldSet, true);
1446
+ stateContent = syncStateFrontmatter(frontmatterPrefix + stateContent, cwd, roadmapContent, activityFieldSet, true);
1441
1447
  stateFrontmatterUpdated = (0, state_document_cjs_1.diffFrontmatterFields)(extractFrontmatter(originalStateContent), extractFrontmatter(stateContent));
1442
1448
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
1443
1449
  }
@@ -3011,6 +3011,7 @@ module.exports = {
3011
3011
  writeStateMd,
3012
3012
  readModifyWriteStateMd,
3013
3013
  syncStateFrontmatter,
3014
+ stripFrontmatter,
3014
3015
  matchSessionSection,
3015
3016
  withStateLock,
3016
3017
  updatePerformanceMetricsSection,
@@ -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