@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.
- package/.claude-plugin/plugin.json +1 -1
- package/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
- package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
- package/pp-core/bin/lib/discovery-processes.cjs +97 -4
- package/pp-core/bin/lib/discovery-stories.cjs +36 -5
- package/pp-core/bin/lib/discovery-tests.cjs +398 -0
- package/pp-core/bin/lib/io.cjs +5 -0
- package/pp-core/bin/lib/phase.cjs +11 -5
- package/pp-core/bin/lib/state.cjs +1 -0
- package/pp-core/bin/pp-tools.cjs +25 -1
- package/pp-core/references/discovery-build-consumption.md +5 -2
- package/pp-core/references/discovery-id-conventions.md +1 -1
- package/pp-core/references/discovery-processes.md +31 -7
- package/pp-core/references/discovery-session-output.md +1 -1
- package/pp-core/references/discovery-tests.md +186 -0
- package/pp-core/workflows/code-review.md +6 -3
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +2 -1
- package/pp-core/workflows/discovery-sprint.md +2 -2
- package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
- package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
- package/pp-core/workflows/discovery-to-build.md +2 -2
- package/pp-core/workflows/discuss-phase/discovery.md +2 -2
- package/pp-core/workflows/execute-phase.md +1 -1
- package/pp-core/workflows/init/steps/client-context-capture.md +1 -1
- package/pp-core/workflows/profile-user.md +2 -2
- package/pp-core/workflows/quick.md +1 -1
- package/pp-core/workflows/ship.md +1 -1
- package/pp-core/workflows/spec-phase.md +2 -2
- package/scripts/run-tests.cjs +9 -6
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pp-core",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
21
|
-
*
|
|
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
|
|
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,
|