@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pp-core",
3
3
  "displayName": "PP Core",
4
- "version": "1.21.1",
4
+ "version": "1.22.0",
5
5
  "description": "PP Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "pareto4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pp-core",
3
- "version": "1.21.1",
3
+ "version": "1.22.0",
4
4
  "description": "PP Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads pp-core's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pareto4/pp-core",
3
- "version": "1.21.1",
3
+ "version": "1.22.0",
4
4
  "description": "Pareto pp-core — The Pareto Project's AI-assisted engineering framework: meta-prompting, context engineering, and spec-driven development for AI coding agents.",
5
5
  "bin": {
6
6
  "pp-core": "bin/install.js",
@@ -71,7 +71,7 @@
71
71
  },
72
72
  "overrides": {
73
73
  "qs": ">=6.16.0",
74
- "@hono/node-server": ">=2.0.5",
74
+ "@hono/node-server": ">=2.1.3",
75
75
  "body-parser": "^2.4.0",
76
76
  "fast-uri": "^3.1.9",
77
77
  "zod": "^4.6.6"
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery flow page (Session 3 Step 5C — iterate a swimlane with the room).
4
+ *
5
+ * Session 3 refines one main process at a time: the agent drafts the
6
+ * swimlane into `_processes.md` § Flows, the room reacts, the agent edits the
7
+ * draft in place, and only an accepted diagram becomes a step matrix. A
8
+ * terminal cannot draw Mermaid, so this command renders one main process —
9
+ * its swimlane, its § Sub-processes rows and its step matrix, read from the
10
+ * register and nothing else — as a self-contained HTML page the agent
11
+ * publishes as a private artifact (which renders `<pre class="mermaid">`
12
+ * natively) and republishes after every round. The register stays the only
13
+ * source: the page is regenerated, never edited.
14
+ *
15
+ * The page carries no script and no external resource; every cell is
16
+ * HTML-escaped, the Mermaid source included (Mermaid reads the element's
17
+ * text). Structure fails loud (`discovery_flow_page_failed`): no
18
+ * `_processes.md`, an `MP-nn` that is not in § To-be, or a main process
19
+ * without a flow under § Flows.
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ const node_fs_1 = __importDefault(require("node:fs"));
25
+ const node_path_1 = __importDefault(require("node:path"));
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const io = require("./io.cjs");
28
+ const { output, error, ERROR_REASON } = io;
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const table = require("./discovery-table.cjs");
31
+ // ─── Constants ────────────────────────────────────────────────────────────────
32
+ const DISCOVERY_DIR = '.planning/discovery';
33
+ const PROCESSES_FILE = `${DISCOVERY_DIR}/_processes.md`;
34
+ const PAGES_DIR = `${DISCOVERY_DIR}/_flows`;
35
+ const MP_RE = /^MP-\d{2}$/;
36
+ const MP_HEADING_RE = /^(MP-\d{2})\s+—\s+(.+)$/;
37
+ const SUB_PROCESS_RE = /^(MP-\d{2})\.\d+$/;
38
+ const MERMAID_RE = /```mermaid\s*\n([\s\S]*?)\n\s*```/;
39
+ const TO_BE_LABELS = Object.freeze(['ID', 'Main process', 'Replaces', 'Trigger', 'End state', 'Primary actor', 'EPICs']);
40
+ const USAGE = 'Usage: pp-tools discovery flow-page <MP-nn> [--out <file.html>]';
41
+ // ─── Read ─────────────────────────────────────────────────────────────────────
42
+ function readProcessView(content, mp) {
43
+ const sections = table.splitSections(content.split(/\r?\n/), 2);
44
+ const toBe = sections.find((s) => s.heading === 'To-be');
45
+ const toBeRow = toBe ? table.parseTables(toBe.body)[0]?.rows.find((r) => (r.cells[0] ?? '').trim() === mp) : undefined;
46
+ if (!toBeRow)
47
+ return { ok: false, reason: 'unknown_process' };
48
+ const sub = (heading) => {
49
+ const s = sections.find((x) => x.heading === heading);
50
+ return s ? table.splitSections(s.body.map((l) => l.text), 3) : [];
51
+ };
52
+ const flow = sub('Flows').find((s) => MP_HEADING_RE.exec(s.heading)?.[1] === mp);
53
+ const mermaid = flow ? MERMAID_RE.exec(flow.body.map((l) => l.text).join('\n'))?.[1] : undefined;
54
+ if (!mermaid)
55
+ return { ok: false, reason: 'flow_missing' };
56
+ const stepsSec = sub('Steps').find((s) => MP_HEADING_RE.exec(s.heading)?.[1] === mp);
57
+ const stepsTable = stepsSec ? table.parseTables(stepsSec.body)[0] : undefined;
58
+ const subSec = sections.find((s) => s.heading === 'Sub-processes');
59
+ const subRows = subSec ? (table.parseTables(subSec.body)[0]?.rows ?? []) : [];
60
+ return {
61
+ ok: true,
62
+ view: {
63
+ id: mp,
64
+ name: (toBeRow.cells[1] ?? '').trim(),
65
+ toBe: toBeRow.cells.map((c) => c.trim()),
66
+ mermaid,
67
+ subProcesses: subRows.filter((r) => SUB_PROCESS_RE.exec((r.cells[0] ?? '').trim())?.[1] === mp),
68
+ stepsHeader: stepsTable ? stepsTable.header.map((c) => c.trim()) : [],
69
+ steps: stepsTable ? stepsTable.rows : [],
70
+ },
71
+ };
72
+ }
73
+ // ─── Render ───────────────────────────────────────────────────────────────────
74
+ function esc(s) {
75
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
76
+ }
77
+ function htmlTable(header, rows) {
78
+ const head = `<tr>${header.map((h) => `<th>${esc(h)}</th>`).join('')}</tr>`;
79
+ const body = rows.map((r) => `<tr>${header.map((_, i) => `<td>${esc((r.cells[i] ?? '').trim())}</td>`).join('')}</tr>`).join('\n');
80
+ return `<div class="scroll"><table>\n<thead>${head}</thead>\n<tbody>\n${body}\n</tbody>\n</table></div>`;
81
+ }
82
+ function renderPage(v) {
83
+ const facts = TO_BE_LABELS.slice(2)
84
+ .map((label, i) => `<div><dt>${esc(label)}</dt><dd>${esc(v.toBe[i + 2] ?? '')}</dd></div>`)
85
+ .join('');
86
+ const subs = v.subProcesses.length > 0
87
+ ? htmlTable(['ID', 'Sub-process', 'Steps'], v.subProcesses)
88
+ : '<p class="empty">No sub-processes yet.</p>';
89
+ const steps = v.steps.length > 0
90
+ ? htmlTable(v.stepsHeader, v.steps)
91
+ : '<p class="empty">No step matrix yet — it follows once the room accepts the diagram.</p>';
92
+ return `<title>${esc(`${v.id} ${v.name}`)}</title>
93
+ <meta name="claude:viewport-fit-sides" content="cover">
94
+ <style>
95
+ :root { --bg: #f7f8fa; --surface: #ffffff; --fg: #14181f; --muted: #5b6472; --line: #dde1e8; --accent: #2251ff; }
96
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0f1218; --surface: #171b23; --fg: #e8ebf0; --muted: #9aa3b2; --line: #2a303b; --accent: #7d97ff; color-scheme: dark; } }
97
+ :root[data-theme="dark"] { --bg: #0f1218; --surface: #171b23; --fg: #e8ebf0; --muted: #9aa3b2; --line: #2a303b; --accent: #7d97ff; color-scheme: dark; }
98
+ body { background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); padding-block: 24px 48px; }
99
+ main { max-width: 1200px; margin: 0 auto; display: grid; gap: 24px; }
100
+ header p { margin: 0; color: var(--muted); font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
101
+ h1 { margin: 4px 0 0; font-size: 26px; text-wrap: balance; }
102
+ h2 { margin: 0 0 8px; font-size: 16px; }
103
+ dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 24px; margin: 12px 0 0; }
104
+ dt { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
105
+ dd { margin: 0; }
106
+ section { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px; min-width: 0; }
107
+ .scroll { overflow-x: auto; }
108
+ pre.mermaid { margin: 0; overflow-x: auto; background: transparent; }
109
+ table { border-collapse: collapse; width: 100%; font-size: 14px; }
110
+ th, td { text-align: left; vertical-align: top; padding: 6px 10px; border-bottom: 1px solid var(--line); }
111
+ th { color: var(--muted); font-weight: 600; white-space: nowrap; }
112
+ td:first-child { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--accent); }
113
+ .empty { color: var(--muted); margin: 0; }
114
+ footer { color: var(--muted); font-size: 12px; }
115
+ </style>
116
+ <main>
117
+ <header>
118
+ <p>Discovery · Session 3 · process refinement</p>
119
+ <h1>${esc(v.id)} — ${esc(v.name)}</h1>
120
+ <dl>${facts}</dl>
121
+ </header>
122
+ <section aria-label="Swimlane">
123
+ <h2>Swimlane</h2>
124
+ <pre class="mermaid">
125
+ ${esc(v.mermaid)}
126
+ </pre>
127
+ </section>
128
+ <section aria-label="Sub-processes">
129
+ <h2>Sub-processes</h2>
130
+ ${subs}
131
+ </section>
132
+ <section aria-label="Steps">
133
+ <h2>Steps</h2>
134
+ ${steps}
135
+ </section>
136
+ <footer>Generated from <code>${esc(PROCESSES_FILE)}</code> by <code>pp-tools discovery flow-page ${esc(v.id)}</code>. Change the register, not this page.</footer>
137
+ </main>
138
+ `;
139
+ }
140
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
141
+ /**
142
+ * `pp-tools discovery flow-page <MP-nn> [--out <file.html>]` (dotted
143
+ * `query discovery.flow-page`). Writes the page (default
144
+ * `.planning/discovery/_flows/<MP-nn>.html`) and prints `{ ok, process, file,
145
+ * steps, sub_processes }`.
146
+ */
147
+ function cmdDiscoveryFlowPage(cwd, args, raw) {
148
+ const rest = args.slice(2);
149
+ const mp = rest[0];
150
+ const outAt = rest.indexOf('--out');
151
+ const extra = rest.filter((a, i) => i !== 0 && a !== '--out' && i !== outAt + 1);
152
+ if (!mp || !MP_RE.test(mp) || extra.length > 0 || (outAt !== -1 && !rest[outAt + 1])) {
153
+ error(USAGE, ERROR_REASON.USAGE);
154
+ return; // unreachable
155
+ }
156
+ const out = outAt !== -1 ? rest[outAt + 1] : `${PAGES_DIR}/${mp}.html`;
157
+ if (!out.endsWith('.html')) {
158
+ error(USAGE, ERROR_REASON.USAGE);
159
+ return; // unreachable
160
+ }
161
+ const abs = node_path_1.default.join(cwd, PROCESSES_FILE);
162
+ const fail = (reason) => error(`discovery flow-page: ${PROCESSES_FILE}: ${reason} (${mp})`, ERROR_REASON.DISCOVERY_FLOW_PAGE_FAILED);
163
+ if (!node_fs_1.default.existsSync(abs)) {
164
+ fail('processes_missing');
165
+ return; // unreachable
166
+ }
167
+ let content;
168
+ try {
169
+ content = node_fs_1.default.readFileSync(abs, 'utf-8');
170
+ }
171
+ catch {
172
+ fail('artifact_unreadable');
173
+ return; // unreachable
174
+ }
175
+ const result = readProcessView(content, mp);
176
+ if (!result.ok) {
177
+ fail(result.reason);
178
+ return; // unreachable
179
+ }
180
+ const outAbs = node_path_1.default.resolve(cwd, out);
181
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(outAbs), { recursive: true });
182
+ node_fs_1.default.writeFileSync(outAbs, renderPage(result.view));
183
+ output({ ok: true, process: mp, file: out, steps: result.view.steps.length, sub_processes: result.view.subProcesses.length }, raw);
184
+ }
185
+ module.exports = {
186
+ PAGES_DIR,
187
+ FLOW_PAGE_FAILED: ERROR_REASON.DISCOVERY_FLOW_PAGE_FAILED,
188
+ readProcessView,
189
+ renderPage,
190
+ cmdDiscoveryFlowPage,
191
+ };
@@ -29,6 +29,9 @@
29
29
  * - `_integrations.md` — the § Interfaces rows citing one or named by a
30
30
  * selected step's `Integration` cell (`IF-nn`), with their § Contract and
31
31
  * § Errors rows (`references/discovery-integrations.md`);
32
+ * - `_tests.md` — the § Process risk rows with `End-to-end` `yes` of the
33
+ * main processes touched, the § Focus areas rows citing one, and the
34
+ * § Acceptance rows of the selected stories (`references/discovery-tests.md`);
32
35
  * - `_migration.md`, `_deviations.md` — generic: every table carrying a
33
36
  * `Requirements` column contributes the rows citing one; a table without
34
37
  * that column is reported as unfilterable, never silently dropped.
@@ -75,6 +78,7 @@ const ART = Object.freeze({
75
78
  integrations: `${DISCOVERY_DIR}/_integrations.md`,
76
79
  migration: `${DISCOVERY_DIR}/_migration.md`,
77
80
  deviations: `${DISCOVERY_DIR}/_deviations.md`,
81
+ tests: `${DISCOVERY_DIR}/_tests.md`,
78
82
  phases: `${DISCOVERY_DIR}/_phases.md`,
79
83
  });
80
84
  /** The generic registers: filtered by a `Requirements` column wherever one exists. */
@@ -108,6 +112,9 @@ const ROLE_SCREENS_HEADER = Object.freeze(['Role', 'Screen']);
108
112
  const INTERFACES_HEADER = Object.freeze(['ID', 'System', 'Direction', 'Pattern', 'Protocol', 'Format', 'Idempotency', 'Owner', 'Requirements']);
109
113
  const CONTRACT_HEADER = Object.freeze(['Interface', 'Field', 'Type', 'Required', 'Mapping', 'Validation']);
110
114
  const ERRORS_HEADER = Object.freeze(['Interface', 'Failure', 'Handling', 'Retry', 'Dead letter', 'Alert']);
115
+ const RISK_HEADER = Object.freeze(['Process', 'Impact', 'Likelihood', 'Risk', 'End-to-end', 'Roles', 'Test data', 'Rationale']);
116
+ const FOCUS_HEADER = Object.freeze(['Subject', 'Complexity', 'Extra testing', 'Requirements']);
117
+ const ACCEPTANCE_HEADER = Object.freeze(['Story', 'Testable', 'Role', 'Test data']);
111
118
  const PHASE_ARG_RE = /^\d+(\.\d+)?$/;
112
119
  const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
113
120
  const REQUIREMENTS_LINE_RE = /^\*\*Requirements\*\*:?\s*(.*)$/im;
@@ -234,26 +241,27 @@ function collectExtract(cwd, phase) {
234
241
  }
235
242
  }
236
243
  // _stories.md
244
+ const storyIds = new Set();
237
245
  const sto = readIfPresent(node_path_1.default.join(cwd, ART.stories));
238
246
  if (sto.kind === 'unreadable')
239
247
  violations.push({ artifact: ART.stories, reason: 'artifact_unreadable' });
240
248
  if (sto.kind === 'ok') {
241
249
  const t = tableUnder(sto.content, 'Stories', STORIES_HEADER, ART.stories, violations);
242
250
  if (t) {
243
- push({
244
- artifact: ART.stories, title: 'User stories', subtitle: null, header: [...STORIES_HEADER], keyed: true, keyPrefix: '',
245
- rows: t.rows.filter((r) => cites(r.cells[2], ids)),
246
- });
251
+ const rows = t.rows.filter((r) => cites(r.cells[2], ids));
252
+ for (const r of rows)
253
+ storyIds.add((r.cells[0] ?? '').trim());
254
+ push({ artifact: ART.stories, title: 'User stories', subtitle: null, header: [...STORIES_HEADER], keyed: true, keyPrefix: '', rows });
247
255
  }
248
256
  }
249
257
  // _processes.md — steps, then the to-be rows of the main processes touched
250
258
  const entityNames = new Set();
251
259
  const integrationIds = new Set();
260
+ const mpsTouched = new Set();
252
261
  const proc = readIfPresent(node_path_1.default.join(cwd, ART.processes));
253
262
  if (proc.kind === 'unreadable')
254
263
  violations.push({ artifact: ART.processes, reason: 'artifact_unreadable' });
255
264
  if (proc.kind === 'ok') {
256
- const mpsTouched = new Set();
257
265
  const steps = sectionsOf(proc.content).find((s) => s.heading === 'Steps');
258
266
  if (!steps) {
259
267
  violations.push({ artifact: ART.processes, reason: 'missing_section', id: 'Steps' });
@@ -417,6 +425,26 @@ function collectExtract(cwd, phase) {
417
425
  push({ artifact: ART.integrations, title: 'Integrations', subtitle: 'Errors', header: [...ERRORS_HEADER], keyed: false, keyPrefix: '', rows: errors.rows.filter((r) => selected.has((r.cells[0] ?? '').trim())) });
418
426
  }
419
427
  }
428
+ // _tests.md — end-to-end processes touched, focus areas by requirement, acceptance of the selected stories
429
+ const tst = readIfPresent(node_path_1.default.join(cwd, ART.tests));
430
+ if (tst.kind === 'unreadable')
431
+ violations.push({ artifact: ART.tests, reason: 'artifact_unreadable' });
432
+ if (tst.kind === 'ok') {
433
+ const risk = tableUnder(tst.content, 'Process risk', RISK_HEADER, ART.tests, violations);
434
+ const focus = tableUnder(tst.content, 'Focus areas', FOCUS_HEADER, ART.tests, violations);
435
+ const acceptance = tableUnder(tst.content, 'Acceptance', ACCEPTANCE_HEADER, ART.tests, violations);
436
+ if (risk && focus && acceptance) {
437
+ push({
438
+ artifact: ART.tests, title: 'Tests', subtitle: 'End-to-end', header: [...RISK_HEADER], keyed: true, keyPrefix: 'e2e ',
439
+ rows: risk.rows.filter((r) => mpsTouched.has((r.cells[0] ?? '').trim()) && (r.cells[4] ?? '').trim() === 'yes'),
440
+ });
441
+ push({ artifact: ART.tests, title: 'Tests', subtitle: 'Focus areas', header: [...FOCUS_HEADER], keyed: true, keyPrefix: 'focus ', rows: focus.rows.filter((r) => cites(r.cells[3], ids)) });
442
+ push({
443
+ artifact: ART.tests, title: 'Tests', subtitle: 'Acceptance', header: [...ACCEPTANCE_HEADER], keyed: false, keyPrefix: '',
444
+ rows: acceptance.rows.filter((r) => storyIds.has((r.cells[0] ?? '').trim())),
445
+ });
446
+ }
447
+ }
420
448
  // Generic registers: filtered by a Requirements column.
421
449
  for (const g of GENERIC) {
422
450
  const read = readIfPresent(node_path_1.default.join(cwd, g.file));
@@ -19,6 +19,12 @@
19
19
  * is on file; every data object is `Name [C/R/U/D]` or `—`;
20
20
  * - every cited requirement exists in `_requirements.md`, when it is on
21
21
  * file and parseable;
22
+ * - when the optional `## Sub-processes` section is on file (engagements
23
+ * since the Session 3 rework): every main process has sub-processes
24
+ * `MP-nn.k`, numbered from 1; every step row belongs to exactly one
25
+ * sub-process of its main process and every sub-process has steps; and
26
+ * every step's `#` appears in its main process's flow, so the diagram
27
+ * and the matrix describe the same steps;
22
28
  * - with `--require-cited`: every step cites at least one requirement —
23
29
  * the state Step 5D leaves behind, required at the handoff.
24
30
  *
@@ -45,12 +51,15 @@ const DISCOVERY_DIR = '.planning/discovery';
45
51
  const PROCESSES_FILE = `${DISCOVERY_DIR}/_processes.md`;
46
52
  const REQUIREMENTS_FILE = `${DISCOVERY_DIR}/_requirements.md`;
47
53
  const SESSION_02_FILE = `${DISCOVERY_DIR}/session-02.md`;
48
- const SECTIONS = Object.freeze({ asIs: 'As-is', toBe: 'To-be', flows: 'Flows', steps: 'Steps' });
54
+ const SECTIONS = Object.freeze({ asIs: 'As-is', toBe: 'To-be', subProcesses: 'Sub-processes', flows: 'Flows', steps: 'Steps' });
49
55
  const AS_IS_HEADER = Object.freeze(['ID', 'Process', 'Owner', 'Trigger', 'End state', 'Frequency', 'Avg duration', 'Criticality']);
50
56
  const TO_BE_HEADER = Object.freeze(['ID', 'Main process', 'Replaces', 'Trigger', 'End state', 'Primary actor', 'EPICs']);
51
57
  const STEPS_HEADER = Object.freeze(['#', 'Step', 'Actor', 'UI', 'Data objects', 'Integration', 'Exception', 'Requirements']);
58
+ const SUB_PROCESSES_HEADER = Object.freeze(['ID', 'Sub-process', 'Steps']);
52
59
  const PRC_RE = /^PRC-(\d{2})$/;
53
60
  const MP_RE = /^MP-(\d{2})$/;
61
+ /** A sub-process: its main process and its 1-based number within it (`MP-01.2`). */
62
+ const SUB_PROCESS_RE = /^(MP-\d{2})\.([1-9]\d*)$/;
54
63
  /** `### MP-01 — <name>` under Flows and Steps. */
55
64
  const MP_HEADING_RE = /^(MP-\d{2})\s+—\s+\S/;
56
65
  const PENDING = '—';
@@ -161,6 +170,7 @@ function checkProcesses(content, sources, requireCited = false) {
161
170
  let flows = 0;
162
171
  const flowsSection = sections.find((s) => s.heading === SECTIONS.flows);
163
172
  const withFlow = new Set();
173
+ const flowText = new Map();
164
174
  if (!flowsSection) {
165
175
  violations.push({ artifact: PROCESSES_FILE, reason: 'missing_section', id: SECTIONS.flows });
166
176
  }
@@ -175,6 +185,7 @@ function checkProcesses(content, sources, requireCited = false) {
175
185
  flows += 1;
176
186
  withFlow.add(m[1]);
177
187
  const text = s.body.map((l) => l.text).join('\n');
188
+ flowText.set(m[1], (flowText.get(m[1]) ?? '') + text);
178
189
  const hasFlowchart = /```mermaid\s*\n\s*flowchart\b/.test(text);
179
190
  const hasSubgraph = /^\s*subgraph\b/m.test(text);
180
191
  if (!hasFlowchart || !hasSubgraph)
@@ -185,6 +196,8 @@ function checkProcesses(content, sources, requireCited = false) {
185
196
  let steps = 0;
186
197
  let uncited = 0;
187
198
  const withSteps = new Set();
199
+ /** Per main process: each step's `#` cell and its line. */
200
+ const stepNums = new Map();
188
201
  const stepsSection = sections.find((s) => s.heading === SECTIONS.steps);
189
202
  if (!stepsSection) {
190
203
  violations.push({ artifact: PROCESSES_FILE, reason: 'missing_section', id: SECTIONS.steps });
@@ -204,9 +217,13 @@ function checkProcesses(content, sources, requireCited = false) {
204
217
  continue;
205
218
  }
206
219
  withSteps.add(m[1]);
220
+ const nums = stepNums.get(m[1]) ?? new Map();
221
+ stepNums.set(m[1], nums);
207
222
  for (const row of tables[0].rows) {
208
223
  steps += 1;
209
224
  const [num, step, actor, , objects, , , requirements] = row.cells.map((c) => c.trim());
225
+ if ((num ?? '') !== '' && !nums.has(num))
226
+ nums.set(num, row.line);
210
227
  if ((num ?? '') === '' || (step ?? '') === '')
211
228
  violations.push({ artifact: PROCESSES_FILE, reason: 'empty_cell', line: row.line, id: 'Step' });
212
229
  checkActor(actor ?? '', row.line, sources, violations);
@@ -239,9 +256,85 @@ function checkProcesses(content, sources, requireCited = false) {
239
256
  if (stepsSection && !withSteps.has(id))
240
257
  violations.push({ artifact: PROCESSES_FILE, reason: 'steps_missing', line, id });
241
258
  }
259
+ // 5 ─ Sub-processes (optional section): every step in exactly one, every flow showing every step
260
+ let subProcesses = 0;
261
+ if (sections.some((s) => s.heading === SECTIONS.subProcesses)) {
262
+ const subTable = sectionTable(sections, SECTIONS.subProcesses, SUB_PROCESSES_HEADER, violations);
263
+ if (subTable)
264
+ subProcesses = checkSubProcesses(subTable.rows, toBe, stepNums, flowText, violations);
265
+ }
242
266
  if (sources.requirementIds === 'unparseable')
243
267
  violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
244
- return { ok: violations.length === 0, as_is: asIs.size, to_be: toBe.size, flows, steps, uncited, violations };
268
+ return { ok: violations.length === 0, as_is: asIs.size, to_be: toBe.size, flows, steps, sub_processes: subProcesses, uncited, violations };
269
+ }
270
+ /** A step's `#` cell as a whole token in a flow: `01.3` must not match inside `101.3` or `01.30`. */
271
+ function flowShowsStep(text, num) {
272
+ const escaped = num.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
273
+ return new RegExp(`(^|[^\\w.])${escaped}(?![\\w]|\\.\\d)`).test(text);
274
+ }
275
+ function checkSubProcesses(rows, toBe, stepNums, flowText, violations) {
276
+ const ids = new Set();
277
+ const perProcess = new Map();
278
+ /** `MP-nn` → step `#` → the sub-process that claimed it. */
279
+ const claimed = new Map();
280
+ for (const row of rows) {
281
+ const [id, name, stepsCell] = row.cells.map((c) => c.trim());
282
+ const m = SUB_PROCESS_RE.exec(id ?? '');
283
+ if (!m) {
284
+ violations.push({ artifact: PROCESSES_FILE, reason: 'bad_subprocess', line: row.line });
285
+ continue;
286
+ }
287
+ if (ids.has(id)) {
288
+ violations.push({ artifact: PROCESSES_FILE, reason: 'duplicate_id', line: row.line, id });
289
+ continue;
290
+ }
291
+ ids.add(id);
292
+ const mp = m[1];
293
+ if (!toBe.has(mp)) {
294
+ violations.push({ artifact: PROCESSES_FILE, reason: 'unknown_process', line: row.line, id: mp });
295
+ continue;
296
+ }
297
+ perProcess.set(mp, [...(perProcess.get(mp) ?? []), Number(m[2])]);
298
+ if ((name ?? '') === '')
299
+ violations.push({ artifact: PROCESSES_FILE, reason: 'empty_cell', line: row.line, id: 'Sub-process' });
300
+ const tokens = (stepsCell ?? '') === PENDING ? [] : (stepsCell ?? '').split(',').map((t) => t.trim()).filter(Boolean);
301
+ if (tokens.length === 0)
302
+ violations.push({ artifact: PROCESSES_FILE, reason: 'subprocess_without_steps', line: row.line, id });
303
+ const known = stepNums.get(mp);
304
+ const owner = claimed.get(mp) ?? new Map();
305
+ claimed.set(mp, owner);
306
+ for (const t of tokens) {
307
+ if (known && !known.has(t))
308
+ violations.push({ artifact: PROCESSES_FILE, reason: 'unknown_step', line: row.line, id: `${mp} #${t}`.slice(0, 40) });
309
+ else if (owner.has(t))
310
+ violations.push({ artifact: PROCESSES_FILE, reason: 'step_in_two_subprocesses', line: row.line, id: `${mp} #${t}` });
311
+ else
312
+ owner.set(t, id);
313
+ }
314
+ }
315
+ for (const [mp, nums] of perProcess) {
316
+ const max = table.boundedSequenceMax(nums);
317
+ if (max === null)
318
+ continue;
319
+ const have = new Set(nums);
320
+ for (let n = 1; n <= max; n += 1) {
321
+ if (!have.has(n))
322
+ violations.push({ artifact: PROCESSES_FILE, reason: 'sequence_gap', id: `${mp}.${n}` });
323
+ }
324
+ }
325
+ for (const [mp, line] of toBe) {
326
+ if (!perProcess.has(mp))
327
+ violations.push({ artifact: PROCESSES_FILE, reason: 'subprocess_missing', line, id: mp });
328
+ const owner = claimed.get(mp);
329
+ const text = flowText.get(mp);
330
+ for (const [num, stepLine] of stepNums.get(mp) ?? []) {
331
+ if (owner && !owner.has(num))
332
+ violations.push({ artifact: PROCESSES_FILE, reason: 'step_without_subprocess', line: stepLine, id: `${mp} #${num}` });
333
+ if (text !== undefined && !flowShowsStep(text, num))
334
+ violations.push({ artifact: PROCESSES_FILE, reason: 'step_not_in_flow', line: stepLine, id: `${mp} #${num}` });
335
+ }
336
+ }
337
+ return ids.size;
245
338
  }
246
339
  /** An actor cell: one persona or `System`, or a comma list of them when a step genuinely has two actors. */
247
340
  function checkActor(actor, line, sources, violations) {
@@ -285,7 +378,7 @@ function checkProjectProcesses(cwd, requireCited = false) {
285
378
  const read = readIfPresent(node_path_1.default.join(cwd, PROCESSES_FILE));
286
379
  if (read.kind === 'absent')
287
380
  return null;
288
- const empty = { as_is: 0, to_be: 0, flows: 0, steps: 0, uncited: 0 };
381
+ const empty = { as_is: 0, to_be: 0, flows: 0, steps: 0, sub_processes: 0, uncited: 0 };
289
382
  if (read.kind === 'unreadable')
290
383
  return { ok: false, file: PROCESSES_FILE, ...empty, violations: [{ artifact: PROCESSES_FILE, reason: 'artifact_unreadable' }] };
291
384
  const extra = [];
@@ -330,7 +423,7 @@ function cmdDiscoveryProcessCheck(cwd, args, raw) {
330
423
  output({ ok: true, present: false, file: PROCESSES_FILE }, raw);
331
424
  return;
332
425
  }
333
- const payload = { ok: result.ok, present: true, file: result.file, as_is: result.as_is, to_be: result.to_be, flows: result.flows, steps: result.steps, uncited: result.uncited };
426
+ const payload = { ok: result.ok, present: true, file: result.file, as_is: result.as_is, to_be: result.to_be, flows: result.flows, steps: result.steps, sub_processes: result.sub_processes, uncited: result.uncited };
334
427
  if (!result.ok) {
335
428
  output({ ...payload, violations: result.violations.length }, raw);
336
429
  const onlyUncited = result.violations.every((v) => v.reason === 'uncited_step');
@@ -17,8 +17,12 @@
17
17
  * `_requirements.md` when it is on file and parseable;
18
18
  * - `Story` and `Acceptance criteria` are non-empty;
19
19
  * - `Acceptance criteria` is a criterion, not a pointer (`As in F-01`);
20
- * - `Source step` is `—` or a comma list of `MP-nn #<cell>`, the main process
21
- * and the step row existing in `_processes.md` when it is on file;
20
+ * - `Source step` is a comma list of `MP-nn #<cell>`, the main process and
21
+ * the step row existing in `_processes.md` when it is on file — a story
22
+ * tied to no step (`—`) is refused (Session 3 rework: stories come from
23
+ * steps);
24
+ * - every persona-actor step that realises an MVP requirement is the
25
+ * source step of at least one story, so the stories cover the processes;
22
26
  * - every MVP-disposition requirement that a persona-actor step cites has at
23
27
  * least one story (the `N-`/`T-` prefixes never need one; a requirement
24
28
  * only `System` steps realise, or no step at all, has no user to tell it).
@@ -118,6 +122,23 @@ function needsStory(r, steps) {
118
122
  return true;
119
123
  return steps.some((s) => s.requirements.includes(r.id) && s.actors.some((a) => a !== SYSTEM_ACTOR));
120
124
  }
125
+ /**
126
+ * Does this step need a story naming it as its source? A step a persona
127
+ * performs does, when it realises an MVP requirement that is not `N-`/`T-`.
128
+ * With no parseable `_requirements.md` every persona step does; a step that
129
+ * cites nothing yet is `process-check --require-cited`'s finding, not this one.
130
+ */
131
+ function stepNeedsStory(step, requirements) {
132
+ if (!step.actors.some((a) => a !== SYSTEM_ACTOR))
133
+ return false;
134
+ if (!(requirements instanceof Array))
135
+ return true;
136
+ const byId = new Map(requirements.map((r) => [r.id, r]));
137
+ return step.requirements.some((id) => {
138
+ const r = byId.get(id);
139
+ return r !== undefined && r.disposition === rollup.DISPOSITIONS.MVP && !STORYLESS_PREFIXES.includes(r.prefix);
140
+ });
141
+ }
121
142
  // ─── Check ────────────────────────────────────────────────────────────────────
122
143
  function checkStories(content, sources) {
123
144
  const violations = [];
@@ -137,6 +158,7 @@ function checkStories(content, sources) {
137
158
  const ids = new Set();
138
159
  const seqs = [];
139
160
  const storied = new Set();
161
+ const sourced = new Set();
140
162
  for (const row of tables[0].rows) {
141
163
  const [id, epic, requirements, story, acceptance, sourceStep] = row.cells.map((c) => c.trim());
142
164
  const m = US_RE.exec(id ?? '');
@@ -170,11 +192,13 @@ function checkStories(content, sources) {
170
192
  storied.add(r);
171
193
  }
172
194
  const src = sourceStep ?? '';
173
- if (src !== PENDING) {
195
+ if (src === PENDING || src === '') {
196
+ violations.push({ artifact: STORIES_FILE, reason: 'no_source_step', line: row.line, id: m ? id : undefined });
197
+ }
198
+ else {
174
199
  const refs = splitList(src);
175
- if (refs.length === 0)
176
- violations.push({ artifact: STORIES_FILE, reason: 'bad_source_step', line: row.line });
177
200
  for (const ref of refs) {
201
+ sourced.add(ref);
178
202
  const sm = SOURCE_STEP_RE.exec(ref);
179
203
  if (!sm)
180
204
  violations.push({ artifact: STORIES_FILE, reason: 'bad_source_step', line: row.line });
@@ -193,6 +217,12 @@ function checkStories(content, sources) {
193
217
  violations.push({ artifact: STORIES_FILE, reason: 'sequence_gap', id: `US-${String(n).padStart(2, '0')}` });
194
218
  }
195
219
  }
220
+ for (const step of sources.steps ?? []) {
221
+ if (!stepNeedsStory(step, sources.requirements))
222
+ continue;
223
+ if (!sourced.has(step.key))
224
+ violations.push({ artifact: PROCESSES_FILE, reason: 'step_without_story', id: step.key.slice(0, 40) });
225
+ }
196
226
  if (sources.requirements instanceof Array) {
197
227
  for (const r of sources.requirements) {
198
228
  if (r.disposition !== rollup.DISPOSITIONS.MVP)
@@ -266,6 +296,7 @@ module.exports = {
266
296
  STORYLESS_PREFIXES,
267
297
  SYSTEM_ACTOR,
268
298
  needsStory,
299
+ stepNeedsStory,
269
300
  STORIES_CHECK_FAILED: ERROR_REASON.DISCOVERY_STORIES_CHECK_FAILED,
270
301
  checkStories,
271
302
  checkProjectStories,