@pareto4/pp-core 1.21.1 → 1.23.1

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 (136) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/install.js +205 -28
  3. package/commands/pp-help.md +2 -1
  4. package/gemini-extension.json +1 -1
  5. package/hooks/dist/pp-check-update-worker.js +1 -1
  6. package/hooks/dist/pp-context-monitor.js +3 -3
  7. package/hooks/dist/pp-statusline.js +88 -14
  8. package/hooks/dist/pp-update-banner.js +44 -4
  9. package/hooks/dist/pp-workflow-guard.js +3 -3
  10. package/hooks/pp-check-update-worker.js +1 -1
  11. package/hooks/pp-context-monitor.js +3 -3
  12. package/hooks/pp-statusline.js +88 -14
  13. package/hooks/pp-update-banner.js +44 -4
  14. package/hooks/pp-workflow-guard.js +3 -3
  15. package/package.json +2 -2
  16. package/pp-core/bin/lib/audience-command.cjs +271 -0
  17. package/pp-core/bin/lib/audience.cjs +293 -0
  18. package/pp-core/bin/lib/commands.cjs +26 -17
  19. package/pp-core/bin/lib/discovery-charter.cjs +13 -1
  20. package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
  21. package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
  22. package/pp-core/bin/lib/discovery-input-check.cjs +11 -11
  23. package/pp-core/bin/lib/discovery-processes.cjs +97 -4
  24. package/pp-core/bin/lib/discovery-session-check.cjs +7 -7
  25. package/pp-core/bin/lib/discovery-status.cjs +419 -0
  26. package/pp-core/bin/lib/discovery-stories.cjs +36 -5
  27. package/pp-core/bin/lib/discovery-tests.cjs +398 -0
  28. package/pp-core/bin/lib/explain-command.cjs +48 -0
  29. package/pp-core/bin/lib/gate-card.cjs +97 -0
  30. package/pp-core/bin/lib/hook-messages.cjs +132 -0
  31. package/pp-core/bin/lib/init-contract.cjs +116 -76
  32. package/pp-core/bin/lib/init-interactive.cjs +110 -49
  33. package/pp-core/bin/lib/init.cjs +44 -3
  34. package/pp-core/bin/lib/io.cjs +38 -0
  35. package/pp-core/bin/lib/message-labels.cjs +75 -0
  36. package/pp-core/bin/lib/messages/core.cjs +20 -0
  37. package/pp-core/bin/lib/messages/discovery.cjs +267 -0
  38. package/pp-core/bin/lib/messages/gate.cjs +257 -0
  39. package/pp-core/bin/lib/messages/hook.cjs +114 -0
  40. package/pp-core/bin/lib/messages/init.cjs +513 -0
  41. package/pp-core/bin/lib/messages/progress.cjs +110 -0
  42. package/pp-core/bin/lib/messages.cjs +208 -0
  43. package/pp-core/bin/lib/vault-command-router.cjs +16 -3
  44. package/pp-core/bin/lib/violation-render.cjs +78 -0
  45. package/pp-core/bin/pp-tools.cjs +92 -23
  46. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  47. package/pp-core/references/audience-moments.md +119 -0
  48. package/pp-core/references/audience.md +114 -0
  49. package/pp-core/references/discovery-build-consumption.md +5 -2
  50. package/pp-core/references/discovery-charter.md +2 -2
  51. package/pp-core/references/discovery-id-conventions.md +1 -1
  52. package/pp-core/references/discovery-processes.md +31 -7
  53. package/pp-core/references/discovery-session-output.md +1 -1
  54. package/pp-core/references/discovery-tests.md +186 -0
  55. package/pp-core/templates/project-charter.md +3 -4
  56. package/pp-core/workflows/add-phase.md +2 -0
  57. package/pp-core/workflows/add-tests.md +2 -0
  58. package/pp-core/workflows/add-todo.md +2 -0
  59. package/pp-core/workflows/ai-integration-phase.md +2 -0
  60. package/pp-core/workflows/audit-milestone.md +2 -0
  61. package/pp-core/workflows/autonomous.md +2 -0
  62. package/pp-core/workflows/check-todos.md +2 -0
  63. package/pp-core/workflows/code-review-fix.md +2 -0
  64. package/pp-core/workflows/code-review.md +2 -0
  65. package/pp-core/workflows/complete-milestone.md +10 -1
  66. package/pp-core/workflows/discovery-sprint/charter.md +3 -2
  67. package/pp-core/workflows/discovery-sprint/prefill.md +3 -0
  68. package/pp-core/workflows/discovery-sprint/prototype.md +14 -0
  69. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  70. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +4 -1
  71. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
  72. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
  73. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +3 -2
  74. package/pp-core/workflows/discovery-sprint/vault-context.md +15 -0
  75. package/pp-core/workflows/discovery-sprint.md +16 -2
  76. package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
  77. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
  78. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
  79. package/pp-core/workflows/discovery-to-build.md +10 -4
  80. package/pp-core/workflows/discuss-phase/discovery.md +2 -2
  81. package/pp-core/workflows/discuss-phase-assumptions.md +2 -0
  82. package/pp-core/workflows/discuss-phase.md +1 -1
  83. package/pp-core/workflows/edit-phase.md +2 -0
  84. package/pp-core/workflows/eval-review.md +2 -0
  85. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +25 -1
  86. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -0
  87. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +2 -0
  88. package/pp-core/workflows/execute-phase.md +1 -1
  89. package/pp-core/workflows/execute-plan.md +2 -0
  90. package/pp-core/workflows/extract-learnings.md +2 -0
  91. package/pp-core/workflows/help/modes/business.md +56 -0
  92. package/pp-core/workflows/help.md +12 -2
  93. package/pp-core/workflows/import.md +10 -1
  94. package/pp-core/workflows/ingest-docs.md +2 -0
  95. package/pp-core/workflows/init.md +17 -1
  96. package/pp-core/workflows/insert-phase.md +2 -0
  97. package/pp-core/workflows/list-workspaces.md +2 -0
  98. package/pp-core/workflows/manager.md +2 -0
  99. package/pp-core/workflows/map-codebase.md +2 -0
  100. package/pp-core/workflows/milestone-summary.md +2 -0
  101. package/pp-core/workflows/new-milestone.md +10 -1
  102. package/pp-core/workflows/new-project.md +12 -2
  103. package/pp-core/workflows/new-workspace.md +2 -0
  104. package/pp-core/workflows/next.md +8 -0
  105. package/pp-core/workflows/plan-milestone-gaps.md +10 -1
  106. package/pp-core/workflows/plan-phase.md +2 -1
  107. package/pp-core/workflows/plan-review-convergence.md +1 -1
  108. package/pp-core/workflows/progress.md +10 -0
  109. package/pp-core/workflows/quick.md +11 -1
  110. package/pp-core/workflows/remove-phase.md +2 -0
  111. package/pp-core/workflows/remove-workspace.md +2 -0
  112. package/pp-core/workflows/resume-project.md +25 -0
  113. package/pp-core/workflows/review.md +2 -0
  114. package/pp-core/workflows/scan.md +2 -0
  115. package/pp-core/workflows/secure-phase.md +2 -0
  116. package/pp-core/workflows/settings.md +41 -0
  117. package/pp-core/workflows/ship.md +2 -0
  118. package/pp-core/workflows/spec-phase.md +1 -1
  119. package/pp-core/workflows/ui-phase.md +2 -0
  120. package/pp-core/workflows/ui-review.md +2 -0
  121. package/pp-core/workflows/ultraplan-phase.md +2 -0
  122. package/pp-core/workflows/validate-phase.md +2 -0
  123. package/pp-core/workflows/vault-review.md +2 -0
  124. package/pp-core/workflows/verify-phase.md +2 -0
  125. package/pp-core/workflows/verify-work.md +2 -0
  126. package/scripts/affected-tests-lib.cjs +26 -12
  127. package/scripts/ci-release-only.cjs +158 -0
  128. package/scripts/live-runs/driver.cjs +326 -0
  129. package/scripts/live-runs/run.cjs +906 -0
  130. package/scripts/live-runs/scenarios.cjs +326 -0
  131. package/scripts/live-runs/scratch-project.cjs +132 -0
  132. package/scripts/live-runs/scrub.cjs +291 -0
  133. package/scripts/live-runs/turn-guard.cjs +107 -0
  134. package/scripts/live-runs/walk-setup.cjs +198 -0
  135. package/scripts/run-tests.cjs +151 -132
  136. package/scripts/test-env.cjs +64 -0
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ /**
3
+ * `pp-tools discovery status` (Phase 42, DOOR-01; D-01..D-08).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/discovery-status.cjs`; do NOT
7
+ * hand-edit the compiled output.
8
+ *
9
+ * Tells a project that is only in the Discovery Sprint from one that has a build,
10
+ * and shows the sprint's state in the person's own wording through the Phase 41
11
+ * catalog: a headline count, one row per session, where the sprint stopped and
12
+ * the next step with its command. The wording lives in `src/messages/discovery.cts`
13
+ * (`discovery.status*`); this module supplies every slot.
14
+ *
15
+ * Display only: the JSON keeps the canonical status words and the canonical
16
+ * commands, and a status word is relabelled for the person only at the last step,
17
+ * when a row or a line is written (D-13). `--raw` prints the same JSON for every
18
+ * audience (41 D-08); only the text view follows the audience.
19
+ *
20
+ * Everything echoed from the tracker or the charter (project name, session names,
21
+ * charter status) is cleaned for EVERY audience, developer included, because the
22
+ * developer path of the catalog does not sanitise (T-42-01).
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.MAX_FILE_BYTES = exports.REGISTRY_SIZE = exports.MAX_ROWS = exports.TRACKER_FILE = void 0;
29
+ exports.renderStatus = renderStatus;
30
+ exports.computeStatus = computeStatus;
31
+ exports.cmdDiscoveryStatus = cmdDiscoveryStatus;
32
+ const node_fs_1 = __importDefault(require("node:fs"));
33
+ const node_path_1 = __importDefault(require("node:path"));
34
+ const messages_cjs_1 = require("./messages.cjs");
35
+ const message_labels_cjs_1 = require("./message-labels.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const io = require("./io.cjs");
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const table = require("./discovery-table.cjs");
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const inputCheck = require("./discovery-input-check.cjs");
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ const charterMod = require("./discovery-charter.cjs");
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const planningWorkspace = require("./planning-workspace.cjs");
46
+ const { output, ERROR_REASON } = io;
47
+ const { SESSION_STATUS } = inputCheck;
48
+ const { CHARTER_FILE, SIGNED_VALUES } = charterMod;
49
+ /** Explicitly typed so TypeScript narrows after a failing guard (the destructured `io.error` does not). */
50
+ function error(message, reason) {
51
+ return io.error(message, reason);
52
+ }
53
+ /** The progress sheet every discovery session start and prefill creates (D-01). */
54
+ exports.TRACKER_FILE = '.planning/discovery/_progress.md';
55
+ /** The tracker's table is examined up to the table-primitive span, never beyond (T-42-03). */
56
+ exports.MAX_ROWS = 99;
57
+ /**
58
+ * The Session Registry's size (discovery-sprint.md). Only sessions 1..13 count, a
59
+ * registry session with no row counts as Not Started, and the totals are always
60
+ * out of 13, as Flow 1 counts them (42-REVIEW WR-02).
61
+ */
62
+ exports.REGISTRY_SIZE = 13;
63
+ /** A tracker or charter larger than this is never read (T-42-03). */
64
+ exports.MAX_FILE_BYTES = 1048576;
65
+ /** The columns a Session Progress table must have, by lower-cased header name (D-06). */
66
+ const REQUIRED_COLUMNS = ['#', 'session', 'status'];
67
+ const PLACEHOLDER_PROJECT = '[PROJECT_NAME]';
68
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
69
+ const SESSION_NUMBER_RE = /^\d{1,2}$/;
70
+ /** Every Unicode format character (bidi overrides and isolates, zero-width, BOM, tag characters). */
71
+ const FORMAT_CHARS_RE = /\p{Cf}/gu;
72
+ /**
73
+ * A value read from a file, made safe to print for every audience: format
74
+ * characters removed first, then control characters stripped, whitespace
75
+ * collapsed and the length capped (T-42-01).
76
+ */
77
+ function cleanText(v) {
78
+ if (typeof v !== 'string' && typeof v !== 'number')
79
+ return '';
80
+ return (0, messages_cjs_1.sanitizeParam)(String(v).replace(FORMAT_CHARS_RE, ''));
81
+ }
82
+ /** A table cell: cleaned, and a `|` can never add a column (T-42-02). */
83
+ function cell(v) {
84
+ return cleanText(v).replace(/\|/g, '/');
85
+ }
86
+ /**
87
+ * The sprint view for one audience. Text only: every visible word comes from the
88
+ * catalog, every status word through `label()`, and business and engineer text
89
+ * ends with exactly one `Ref:` line (developer text has none).
90
+ */
91
+ function renderStatus(status, audience) {
92
+ if (!status.in_discovery) {
93
+ return (0, messages_cjs_1.msg)('discovery.status.none', { file: exports.TRACKER_FILE, command: '/pp-discovery 1' }, { audience }) + '\n';
94
+ }
95
+ if (status.tracker === 'unreadable') {
96
+ return (0, messages_cjs_1.msg)('discovery.status.unreadable', { file: exports.TRACKER_FILE, command: '/pp-discovery' }, { audience }) + '\n';
97
+ }
98
+ const words = (code) => (0, messages_cjs_1.text)(code, {}, { audience });
99
+ const nameOf = (name) => cell(name) || words('discovery.status.session_unnamed');
100
+ const wording = (canonical) => (0, message_labels_cjs_1.label)('discovery.status', String(canonical), { audience });
101
+ const counts = status.counts ?? { total: 0, completed: 0, in_progress: 0, pre_filled: 0, not_started: 0 };
102
+ const sessions = status.sessions ?? [];
103
+ const out = [];
104
+ out.push((0, messages_cjs_1.text)('discovery.status', { project: cleanText(status.project) || words('discovery.status.project_unknown') }, { audience }));
105
+ out.push('');
106
+ out.push((0, messages_cjs_1.text)('discovery.status.counts', {
107
+ done: counts.completed,
108
+ total: counts.total,
109
+ in_progress: counts.in_progress,
110
+ pre_filled: counts.pre_filled,
111
+ not_started: counts.not_started,
112
+ }, { audience }));
113
+ out.push('');
114
+ out.push(words('discovery.status.header'));
115
+ for (const s of sessions) {
116
+ out.push(`| ${String(s.number)} | ${nameOf(s.name)} | ${wording(s.status)} |`);
117
+ }
118
+ out.push('');
119
+ const unknown = status.unknown_status ?? [];
120
+ if (unknown.length > 0) {
121
+ out.push((0, messages_cjs_1.text)('discovery.status.unknown_status', { sessions: unknown.join(', ') }, { audience }));
122
+ }
123
+ const stopped = status.stopped_at;
124
+ if (stopped !== undefined && stopped.basis === 'in_progress') {
125
+ out.push((0, messages_cjs_1.text)('discovery.status.stopped.in_progress', { session: stopped.session ?? '', name: nameOf(stopped.name), status: wording(stopped.status ?? '') }, { audience }));
126
+ }
127
+ else if (stopped !== undefined && stopped.basis === 'completed') {
128
+ out.push((0, messages_cjs_1.text)('discovery.status.stopped.completed', { session: stopped.session ?? '', name: nameOf(stopped.name) }, { audience }));
129
+ }
130
+ else {
131
+ out.push(words('discovery.status.stopped.none'));
132
+ }
133
+ const next = status.next;
134
+ if (next !== undefined) {
135
+ const named = (code) => {
136
+ const target = sessions.find((s) => s.number === next.session);
137
+ return (0, messages_cjs_1.text)(code, { session: next.session ?? '', name: nameOf(target === undefined ? '' : target.name), command: next.command }, { audience });
138
+ };
139
+ switch (next.kind) {
140
+ case 'resume':
141
+ out.push(named('discovery.status.next.resume'));
142
+ break;
143
+ case 'confirm':
144
+ out.push(named('discovery.status.next.confirm'));
145
+ break;
146
+ case 'start':
147
+ out.push(named('discovery.status.next.start'));
148
+ break;
149
+ case 'charter_draft':
150
+ out.push((0, messages_cjs_1.text)('discovery.status.next.charter_draft', { command: next.command }, { audience }));
151
+ break;
152
+ case 'charter_sign':
153
+ out.push((0, messages_cjs_1.text)('discovery.status.next.charter_sign', {
154
+ command: next.command,
155
+ charter_status: cleanText(status.charter?.status) || words('discovery.status.charter_no_status'),
156
+ }, { audience }));
157
+ break;
158
+ case 'to_build':
159
+ out.push((0, messages_cjs_1.text)('discovery.status.next.to_build', { command: next.command }, { audience }));
160
+ break;
161
+ case 'open_sprint':
162
+ // With a readable tracker: a session with an unrecognised status is the lowest open one (WR-03).
163
+ out.push((0, messages_cjs_1.text)('discovery.status.next.check_sheet', { command: next.command }, { audience }));
164
+ break;
165
+ }
166
+ }
167
+ if (audience !== 'developer')
168
+ out.push((0, messages_cjs_1.refLine)('discovery.status'));
169
+ return out.join('\n') + '\n';
170
+ }
171
+ // ─── computeStatus ────────────────────────────────────────────────────────────
172
+ const CANONICAL_WORDS = [
173
+ SESSION_STATUS.NOT_STARTED,
174
+ SESSION_STATUS.PRE_FILLED,
175
+ SESSION_STATUS.IN_PROGRESS,
176
+ SESSION_STATUS.COMPLETED,
177
+ ];
178
+ /**
179
+ * What is at `p`: a regular file within the size bound, something else, or nothing.
180
+ * The tracker is judged without following a link; the charter follows one, as
181
+ * `charter-check` does (42-REVIEW WR-01).
182
+ */
183
+ function kindOf(p, followLink = false) {
184
+ try {
185
+ const st = followLink ? node_fs_1.default.statSync(p) : node_fs_1.default.lstatSync(p);
186
+ return st.isFile() && st.size <= exports.MAX_FILE_BYTES ? 'regular' : 'other';
187
+ }
188
+ catch {
189
+ return 'absent';
190
+ }
191
+ }
192
+ function readText(p) {
193
+ try {
194
+ return node_fs_1.default.readFileSync(p, 'utf8');
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** The sessions of a tracker's `## Session Progress` table, or null when the table cannot be read (D-06, D-08). */
201
+ function parseSessions(content) {
202
+ const lines = content.split(/\r?\n/);
203
+ const section = table.splitSections(lines, 2).find((s) => s.heading.trim().toLowerCase() === 'session progress');
204
+ if (section === undefined)
205
+ return null;
206
+ const parsed = table.parseTables(section.body)[0];
207
+ if (parsed === undefined)
208
+ return null;
209
+ const header = parsed.header.map((h) => h.trim().toLowerCase());
210
+ const [iNumber, iName, iStatus] = REQUIRED_COLUMNS.map((c) => header.indexOf(c));
211
+ if (iNumber < 0 || iName < 0 || iStatus < 0)
212
+ return null;
213
+ const iDone = header.indexOf('completed');
214
+ const byNumber = new Map();
215
+ const unknown = [];
216
+ for (const row of parsed.rows.slice(0, exports.MAX_ROWS)) {
217
+ const numberCell = (row.cells[iNumber] ?? '').trim();
218
+ if (!SESSION_NUMBER_RE.test(numberCell))
219
+ continue;
220
+ const number = Number(numberCell);
221
+ // Outside the registry: `/pp-discovery` rejects it, so it is never a session here (WR-02).
222
+ if (number < 1 || number > exports.REGISTRY_SIZE || byNumber.has(number))
223
+ continue;
224
+ const wordCell = (row.cells[iStatus] ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
225
+ const canonical = CANONICAL_WORDS.find((w) => w.toLowerCase() === wordCell);
226
+ if (canonical === undefined)
227
+ unknown.push(number);
228
+ const doneCell = iDone >= 0 ? (row.cells[iDone] ?? '').trim() : '';
229
+ byNumber.set(number, {
230
+ number,
231
+ name: cleanText(row.cells[iName] ?? ''),
232
+ status: canonical ?? SESSION_STATUS.NOT_STARTED,
233
+ completed: ISO_DATE_RE.test(doneCell) ? doneCell : null,
234
+ });
235
+ }
236
+ if (byNumber.size === 0)
237
+ return null;
238
+ // A registry session with no row has not been held: Not Started, so a truncated
239
+ // tracker is never "every session done" (WR-02).
240
+ for (let n = 1; n <= exports.REGISTRY_SIZE; n += 1) {
241
+ if (!byNumber.has(n))
242
+ byNumber.set(n, { number: n, name: '', status: SESSION_STATUS.NOT_STARTED, completed: null });
243
+ }
244
+ const sessions = [...byNumber.values()].sort((a, b) => a.number - b.number);
245
+ return { sessions, unknown: unknown.sort((a, b) => a - b) };
246
+ }
247
+ /**
248
+ * The Project Charter's state, read only when every session is Completed (D-05, D-06).
249
+ * The status is read by `charter-check`'s own reader, so "signed" here means what
250
+ * `/pp-discovery-to-build`'s gate means by it (42-REVIEW WR-01).
251
+ */
252
+ function charterOf(cwd) {
253
+ const file = node_path_1.default.join(cwd, CHARTER_FILE);
254
+ const kind = kindOf(file, true);
255
+ if (kind === 'absent')
256
+ return { state: 'missing', status: null };
257
+ let value = null;
258
+ if (kind === 'regular') {
259
+ const content = readText(file);
260
+ if (content !== null)
261
+ value = charterMod.charterStatusOf(content);
262
+ }
263
+ const signed = value !== null && SIGNED_VALUES.includes(value);
264
+ return { state: signed ? 'signed' : 'unsigned', status: value === null ? null : cleanText(value) || null };
265
+ }
266
+ /**
267
+ * Flow 1's rule (discovery-sprint.md, Flow 1 step 3): in progress, then pre-filled, then not started, then the charter.
268
+ * A session whose status word is not recognised counts as not started but is never
269
+ * the session to start: it may be a finished workshop with a typo (`Done`). When it
270
+ * is the lowest open session the next step is the sprint itself, so the sheet is
271
+ * checked first (42-REVIEW WR-03).
272
+ */
273
+ function nextOf(sessions, unknown, charter) {
274
+ const unknownSet = new Set(unknown);
275
+ const lowest = (word) => sessions.find((s) => s.status === word && !unknownSet.has(s.number));
276
+ const firstUnknown = sessions.find((s) => unknownSet.has(s.number));
277
+ const open = [
278
+ [SESSION_STATUS.IN_PROGRESS, 'resume'],
279
+ [SESSION_STATUS.PRE_FILLED, 'confirm'],
280
+ [SESSION_STATUS.NOT_STARTED, 'start'],
281
+ ];
282
+ for (const [word, kind] of open) {
283
+ const hit = lowest(word);
284
+ if (kind === 'start' && firstUnknown !== undefined && (hit === undefined || firstUnknown.number < hit.number)) {
285
+ return { kind: 'open_sprint', session: null, command: '/pp-discovery' };
286
+ }
287
+ // The command is built from the integer session number, never from file text (T-42-05).
288
+ if (hit !== undefined)
289
+ return { kind, session: hit.number, command: `/pp-discovery ${String(hit.number)}` };
290
+ }
291
+ if (charter.state === 'missing')
292
+ return { kind: 'charter_draft', session: null, command: '/pp-discovery 13' };
293
+ if (charter.state === 'signed')
294
+ return { kind: 'to_build', session: null, command: '/pp-discovery-to-build' };
295
+ return { kind: 'charter_sign', session: null, command: '/pp-discovery charter' };
296
+ }
297
+ /** Where the sprint stopped (D-08): the lowest In Progress session, else the one completed last, else nowhere. */
298
+ function stoppedOf(sessions) {
299
+ const inProgress = sessions.find((s) => s.status === SESSION_STATUS.IN_PROGRESS);
300
+ if (inProgress !== undefined) {
301
+ return { basis: 'in_progress', session: inProgress.number, name: inProgress.name, status: SESSION_STATUS.IN_PROGRESS };
302
+ }
303
+ const done = sessions.filter((s) => s.status === SESSION_STATUS.COMPLETED);
304
+ if (done.length === 0)
305
+ return { basis: 'none', session: null, name: null, status: null };
306
+ let best = done[0];
307
+ for (const s of done) {
308
+ // Sessions are sorted by number, so `>=` makes a tie, or a blank date, go to the higher number.
309
+ if ((s.completed ?? '') >= (best.completed ?? ''))
310
+ best = s;
311
+ }
312
+ return { basis: 'completed', session: best.number, name: best.name, status: SESSION_STATUS.COMPLETED };
313
+ }
314
+ /**
315
+ * The sprint's state for `cwd`. Read-only and never throws: every file step has
316
+ * its own guard, and a workspace setting that cannot be resolved answers "not
317
+ * discovery-only", so a broken workstream setup keeps today's routing (research A4).
318
+ */
319
+ function computeStatus(cwd) {
320
+ let projectExists = false;
321
+ let roadmapExists = false;
322
+ let workspaceError = false;
323
+ try {
324
+ // The resolvers init.progress and init.resume use (D-02). STATE.md is never read.
325
+ projectExists = planningWorkspace.resolveProjectFile(cwd).exists;
326
+ roadmapExists = node_fs_1.default.existsSync(node_path_1.default.join(planningWorkspace.planningDir(cwd), 'ROADMAP.md'));
327
+ }
328
+ catch {
329
+ projectExists = false;
330
+ roadmapExists = false;
331
+ workspaceError = true;
332
+ }
333
+ const hasBuild = projectExists || roadmapExists;
334
+ let trackerPath = '';
335
+ let trackerKind = 'absent';
336
+ try {
337
+ trackerPath = node_path_1.default.join(planningWorkspace.planningRoot(cwd), 'discovery', '_progress.md');
338
+ trackerKind = kindOf(trackerPath);
339
+ }
340
+ catch {
341
+ trackerKind = 'absent';
342
+ }
343
+ const facts = {
344
+ has_build: hasBuild,
345
+ project_exists: projectExists,
346
+ roadmap_exists: roadmapExists,
347
+ workspace_error: workspaceError,
348
+ };
349
+ if (trackerKind === 'absent') {
350
+ return { discovery_only: false, in_discovery: false, ...facts, tracker: 'absent' };
351
+ }
352
+ const discoveryOnly = !hasBuild && !workspaceError;
353
+ const unreadable = (project) => ({
354
+ discovery_only: discoveryOnly,
355
+ in_discovery: true,
356
+ ...facts,
357
+ tracker: 'unreadable',
358
+ project,
359
+ next: { kind: 'open_sprint', session: null, command: '/pp-discovery' },
360
+ });
361
+ try {
362
+ const content = trackerKind === 'regular' ? readText(trackerPath) : null;
363
+ if (content === null)
364
+ return unreadable(null);
365
+ let project = null;
366
+ for (const line of content.split(/\r?\n/)) {
367
+ const m = /^\*\*Project:\*\*\s*(.+)$/.exec(line);
368
+ if (m !== null) {
369
+ const name = cleanText(m[1]);
370
+ project = name === '' || name === PLACEHOLDER_PROJECT ? null : name;
371
+ break;
372
+ }
373
+ }
374
+ const parsed = parseSessions(content);
375
+ if (parsed === null)
376
+ return unreadable(project);
377
+ const { sessions, unknown } = parsed;
378
+ const count = (word) => sessions.filter((s) => s.status === word).length;
379
+ const counts = {
380
+ total: sessions.length,
381
+ completed: count(SESSION_STATUS.COMPLETED),
382
+ in_progress: count(SESSION_STATUS.IN_PROGRESS),
383
+ pre_filled: count(SESSION_STATUS.PRE_FILLED),
384
+ not_started: count(SESSION_STATUS.NOT_STARTED),
385
+ };
386
+ // The charter matters only once all 13 sessions are Completed; until then it is not read.
387
+ const charter = counts.completed === exports.REGISTRY_SIZE ? charterOf(cwd) : { state: 'not_checked', status: null };
388
+ return {
389
+ discovery_only: discoveryOnly,
390
+ in_discovery: true,
391
+ ...facts,
392
+ tracker: 'ok',
393
+ project,
394
+ counts,
395
+ sessions,
396
+ unknown_status: unknown,
397
+ stopped_at: stoppedOf(sessions),
398
+ charter,
399
+ next: nextOf(sessions, unknown, charter),
400
+ };
401
+ }
402
+ catch {
403
+ return unreadable(null);
404
+ }
405
+ }
406
+ /**
407
+ * `pp-tools discovery status [--raw]`. `--raw` prints the JSON, the same for every
408
+ * audience (41 D-08); without it the view is printed in the person's wording.
409
+ * Do not copy `cmdProgressRender`, whose `--raw` prints audience text (41-REVIEW WR-05).
410
+ */
411
+ function cmdDiscoveryStatus(cwd, args, raw) {
412
+ if (args.slice(2).length > 0)
413
+ error('Usage: pp-tools discovery status [--raw]', ERROR_REASON.USAGE);
414
+ const status = computeStatus(cwd);
415
+ if (raw)
416
+ output(status, false);
417
+ else
418
+ output(status, true, renderStatus(status, (0, messages_cjs_1.audienceOf)(process.env)));
419
+ }
@@ -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,