@hanzlaa/rcode 4.4.3 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Progress — single pre-computed progress blob (issue #159).
3
+ *
4
+ * Subcommands:
5
+ * progress init Full snapshot — everything /rcode-progress needs.
6
+ * progress bar --raw ASCII bar only (e.g. "[████░░░░] 50%").
7
+ * progress insights insights[] array (drift warnings, between-milestone detection).
8
+ * progress routes intent-tree routes[] for Next Up menu.
9
+ *
10
+ * Pushing logic into the CLI lets the workflow file shrink to pure
11
+ * rendering — no ROADMAP.md parsing, no SUMMARY.md walking, no grep.
12
+ *
13
+ * Extracted from rcode-tools.cjs's cmdProgress (issue #204) — pure
14
+ * mechanical move, no behavior change.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ function cmdProgress(args, { PROJECT_ROOT, RCODE_DIR, PLANNING_DIR }) {
21
+ const sub = args[0] || 'init';
22
+ const rawMode = args.includes('--raw');
23
+ // #200 — opt-in strict mode: exit 1 when insights contain drift/undercount.
24
+ // Off by default (warning preserves the soft-surface UX). Toggle via --strict
25
+ // flag or RCODE_STRICT_STATE=true env var. Used by CI / pre-deploy gates.
26
+ const strictMode = args.includes('--strict')
27
+ || /^(true|1|yes)$/i.test(process.env.RCODE_STRICT_STATE || '');
28
+
29
+ // Resolve paths — workflow files may run this from any subdirectory.
30
+ const statePath = path.join(RCODE_DIR, 'state.json');
31
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
32
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
33
+
34
+ function readState() {
35
+ if (!fs.existsSync(statePath)) return null;
36
+ try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); }
37
+ catch { return null; }
38
+ }
39
+
40
+ function parseRoadmapPhases() {
41
+ if (!fs.existsSync(roadmapPath)) return [];
42
+ const text = fs.readFileSync(roadmapPath, 'utf8');
43
+ const phases = [];
44
+ const seen = new Set();
45
+
46
+ // Format A — markdown pipe tables: | 07 | Name | Goal |
47
+ // Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
48
+ const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
49
+ let m;
50
+ while ((m = rowRe.exec(text)) !== null) {
51
+ const num = m[1].trim();
52
+ const name = m[2].trim();
53
+ const goal = m[3].trim();
54
+ if (!/^\d/.test(num)) continue;
55
+ if (name.toLowerCase() === 'phase') continue;
56
+ if (seen.has(num)) continue;
57
+ seen.add(num);
58
+ phases.push({ number: num, name, goal });
59
+ }
60
+
61
+ // Format B — heading style: ## Phase 07 — Name / ### Phase 07: Name / ## Phase 07 - Name
62
+ // Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
63
+ const headRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
64
+ while ((m = headRe.exec(text)) !== null) {
65
+ const num = m[1].trim();
66
+ const name = m[2].trim();
67
+ if (seen.has(num)) continue;
68
+ seen.add(num);
69
+ // Goal: pull the first non-empty line after the heading that starts with **Goal:** or is plain text
70
+ const after = text.slice(headRe.lastIndex).split(/\n/).slice(0, 8).join('\n');
71
+ const goalMatch = after.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
72
+ phases.push({ number: num, name, goal: goalMatch ? goalMatch[1].trim() : '' });
73
+ }
74
+
75
+ // Sort numerically (handles "07" vs "10" string ordering correctly)
76
+ phases.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
77
+ return phases;
78
+ }
79
+
80
+ function extractMilestoneName() {
81
+ // 1. Try ROADMAP.md headings — match any milestone header form
82
+ if (fs.existsSync(roadmapPath)) {
83
+ const text = fs.readFileSync(roadmapPath, 'utf8');
84
+ // Bold form: **Milestone: v1.0 — Name** or **Milestone v1.0 — Name**
85
+ let m = text.match(/\*\*\s*Milestone\s*:?\s*([^\n*]+?)\s*\*\*/i);
86
+ if (m) return m[1].trim();
87
+ // Header form: ## Milestone v1.0 — Name / ## Milestone: v1.0 — Name
88
+ m = text.match(/^#{1,4}\s+Milestone\s*:?\s*([^\n]+)$/m);
89
+ if (m) return m[1].trim();
90
+ }
91
+ // 2. Fall back to state.json milestone field
92
+ try {
93
+ if (fs.existsSync(statePath)) {
94
+ const s = JSON.parse(fs.readFileSync(statePath, 'utf8'));
95
+ if (s && s.milestone) return String(s.milestone).trim();
96
+ }
97
+ } catch { /* ignore */ }
98
+ return null;
99
+ }
100
+
101
+ // Treat any of `number`, `id`, or `name` as the phase identifier.
102
+ // Different commands historically write different field names — accept all.
103
+ function phaseKey(p) {
104
+ return String(p?.number ?? p?.id ?? p?.name ?? '').trim();
105
+ }
106
+
107
+ function walkPhaseDirs() {
108
+ if (!fs.existsSync(phasesDir)) return {};
109
+ const byNum = {};
110
+ for (const entry of fs.readdirSync(phasesDir)) {
111
+ const full = path.join(phasesDir, entry);
112
+ if (!fs.statSync(full).isDirectory()) continue;
113
+ // Phase 14 / #476 — \d+ supports high-N phase dirs (1000+).
114
+ const numMatch = entry.match(/^(\d+(?:\.\d+)?)/);
115
+ if (!numMatch) continue;
116
+ const num = numMatch[1];
117
+ const files = fs.readdirSync(full);
118
+ byNum[num] = {
119
+ path: full,
120
+ dirName: entry,
121
+ plan_count: files.filter(f => /-SPRINT\.md$/i.test(f)).length,
122
+ summary_count: files.filter(f => /SUMMARY\.md$|-SUMMARY\.md$/.test(f)).length,
123
+ has_research: files.includes('RESEARCH.md'),
124
+ has_context: files.includes('CONTEXT.md'),
125
+ has_verification: files.some(f => /VERIFICATION\.md$/i.test(f)),
126
+ };
127
+ }
128
+ return byNum;
129
+ }
130
+
131
+ // #200 — opt-in strict gate. Walks insights for drift/undercount kinds and
132
+ // exits 1 with the failure list to stderr. No-op when strictMode=false.
133
+ function enforceStrictGate(insightsList) {
134
+ if (!strictMode) return;
135
+ const blocking = (insightsList || []).filter(i =>
136
+ i && (i.kind === 'drift' || i.kind === 'undercount') && i.severity !== 'info'
137
+ );
138
+ if (blocking.length === 0) return;
139
+ process.stderr.write('✖ State drift detected — state.json is out of sync with disk.\n');
140
+ for (const i of blocking) process.stderr.write(` • ${i.message}\n`);
141
+ process.stderr.write('\n Auto-fix: node .rcode/bin/rcode-tools.cjs state sync --from-disk\n');
142
+ process.stderr.write(' Inspect: node .rcode/bin/rcode-tools.cjs state read\n');
143
+ process.exit(1);
144
+ }
145
+
146
+ function detectInsights(state, roadmapPhases, diskByNum) {
147
+ const insights = [];
148
+ const statePhases = (state && (state.state?.phases || state.phases)) || [];
149
+
150
+ // Drift: ROADMAP phase count vs state.json phase count
151
+ if (roadmapPhases.length > 0 && statePhases.length !== roadmapPhases.length) {
152
+ insights.push({
153
+ kind: 'drift',
154
+ severity: 'warn',
155
+ message: `ROADMAP.md has ${roadmapPhases.length} phases, state.json has ${statePhases.length}. Run: node .rcode/bin/rcode-tools.cjs state sync --from-disk`,
156
+ });
157
+ }
158
+
159
+ // Undercount: phases that exist on disk but not in state.
160
+ // Accept any of `number`, `id`, or `name` as the phase identifier — the codebase historically writes different fields.
161
+ // Also normalize "07" / "7" / 7 to a comparable form.
162
+ const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
163
+ const statePhaseNums = new Set(statePhases.map(p => norm(phaseKey(p))));
164
+ const diskPhaseNums = Object.keys(diskByNum);
165
+ const missingFromState = diskPhaseNums.filter(n => !statePhaseNums.has(norm(n)));
166
+ if (missingFromState.length > 0) {
167
+ insights.push({
168
+ kind: 'undercount',
169
+ severity: 'warn',
170
+ message: `${missingFromState.length} phase dir(s) on disk not registered in state.json: ${missingFromState.slice(0, 5).join(', ')}`,
171
+ });
172
+ }
173
+
174
+ // Phantom-complete: phase claimed Complete (in ROADMAP or state) but missing
175
+ // PLAN.md AND SUMMARY.md on disk. User-visible bug: /rcode-status would
176
+ // happily report 'all complete' while /rcode-audit correctly flagged the
177
+ // gap because the two read different sources of truth.
178
+ // Surfaced 2026-04-29 in a real session — siraaj phases 07-12 had ROADMAP
179
+ // markers but zero artifacts.
180
+ const phantomCompletes = [];
181
+ const claimedComplete = (p) => {
182
+ if (!p) return false;
183
+ const s = String(p.status ?? '').toLowerCase();
184
+ return p.completed || s === 'complete' || s === 'completed' || s === 'done';
185
+ };
186
+ // Walk ROADMAP-claimed completes and state-claimed completes, both directions.
187
+ const completeKeys = new Set();
188
+ for (const p of roadmapPhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
189
+ for (const p of statePhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
190
+ for (const k of completeKeys) {
191
+ const disk = diskByNum[k] || diskByNum[k.padStart(2, '0')];
192
+ // Only flag when the phase dir EXISTS — purely-state-only entries are a
193
+ // separate problem (drift/undercount above). Here we want claim-vs-files.
194
+ if (!disk) continue;
195
+ if (disk.plan_count === 0 && disk.summary_count === 0) {
196
+ phantomCompletes.push(k);
197
+ }
198
+ }
199
+ if (phantomCompletes.length > 0) {
200
+ insights.push({
201
+ kind: 'phantom-complete',
202
+ severity: 'warn',
203
+ message: `${phantomCompletes.length} phase(s) marked Complete but missing both PLAN.md and SUMMARY.md on disk: ${phantomCompletes.slice(0, 5).join(', ')}. The completion claim is unsupported. Run /rcode-audit phase <N> to inspect.`,
204
+ });
205
+ }
206
+
207
+ // Between-milestones heuristic: no current_phase + previous milestone's last phase is complete
208
+ if (state && state.current_phase === null && statePhases.length > 0) {
209
+ const allComplete = statePhases.every(p => p.status === 'complete' || p.completed);
210
+ if (allComplete) {
211
+ insights.push({
212
+ kind: 'between-milestones',
213
+ severity: 'info',
214
+ message: 'All registered phases complete — effectively between milestones. Consider /rcode-audit-milestone or /rcode-new-milestone.',
215
+ });
216
+ }
217
+ }
218
+
219
+ // Stuck-phase: in_progress phase with no commits touching its .planning dir in 7+ days
220
+ try {
221
+ const inProgressPhases = statePhases.filter(p => {
222
+ const s = String(p.status ?? '').toLowerCase();
223
+ return s === 'in_progress' || s === 'in-progress' || s === 'executing';
224
+ });
225
+ for (const p of inProgressPhases) {
226
+ const key = norm(phaseKey(p));
227
+ const disk = diskByNum[key] || diskByNum[key.padStart(2, '0')];
228
+ if (!disk) continue;
229
+ const dirName = disk.dirName;
230
+ const gitArgs = ['log', '--oneline', '--since=7 days ago', '--', `.planning/phases/${dirName}/`];
231
+ let recentCommits = '';
232
+ try {
233
+ recentCommits = require('child_process').execSync(
234
+ `git ${gitArgs.join(' ')}`,
235
+ { cwd: PROJECT_ROOT, stdio: 'pipe', timeout: 5000 }
236
+ ).toString().trim();
237
+ } catch { /* git not available or no history */ }
238
+ if (recentCommits === '') {
239
+ insights.push({
240
+ kind: 'stuck-phase',
241
+ severity: 'warn',
242
+ message: `Phase ${key} is in progress but has no commits in the last 7 days. It may be stuck. Run /rcode-status or /rcode-audit phase ${key} to investigate.`,
243
+ });
244
+ }
245
+ }
246
+ } catch { /* non-fatal — git unavailable or project root not set */ }
247
+
248
+ return insights;
249
+ }
250
+
251
+ function deriveRoutes(state, roadmapPhases, diskByNum, insights) {
252
+ const routes = [];
253
+ const statePhases = (state && (state.state?.phases || state.phases)) || [];
254
+
255
+ // Route A — phases with pending plans (ready to execute).
256
+ // Issue #653 — never recommend executing a phase whose state.json status
257
+ // is already complete/done/verified, even if its on-disk plan_count >
258
+ // summary_count. Missing second summary file is not the canonical
259
+ // completion signal; state.json is. Run /rcode-audit phase <N> for
260
+ // disk-vs-state drift, but stop steering users into re-executing
261
+ // finished work.
262
+ const isPhaseDone = (p) => {
263
+ const s = String((p && p.status) || '').toLowerCase();
264
+ return s === 'complete' || s === 'completed' || s === 'done' || s === 'verified' || Boolean(p && p.completed);
265
+ };
266
+ const pendingExec = statePhases.filter(p => {
267
+ if (isPhaseDone(p)) return false;
268
+ const disk = diskByNum[phaseKey(p)];
269
+ return disk && disk.plan_count > disk.summary_count;
270
+ }).slice(0, 3);
271
+ for (const p of pendingExec) {
272
+ const k = phaseKey(p);
273
+ routes.push({ letter: 'A', label: '', command: `/rcode-execute ${k}` });
274
+ }
275
+
276
+ // Route B — phases with research but no plans
277
+ const researchOnly = Object.entries(diskByNum)
278
+ .filter(([num, d]) => d.has_research && d.plan_count === 0)
279
+ .slice(0, 3);
280
+ for (const [num] of researchOnly) {
281
+ routes.push({ letter: 'B', label: '', command: `/rcode-plan ${num}` });
282
+ }
283
+
284
+ // Route B' — in-progress phases without plans
285
+ const inProgressNoPlan = statePhases
286
+ .filter(p => (p.status === 'in_progress' || p.status === 'in-progress'))
287
+ .filter(p => {
288
+ const disk = diskByNum[phaseKey(p)];
289
+ return !disk || disk.plan_count === 0;
290
+ })
291
+ .slice(0, 2);
292
+ for (const p of inProgressNoPlan) {
293
+ const k = phaseKey(p);
294
+ routes.push({ letter: 'B', label: '', command: `/rcode-plan ${k}` });
295
+ }
296
+
297
+ // Route C — close out milestone if everything seems done
298
+ const allDone = statePhases.length > 0 && statePhases.every(p => p.status === 'complete' || p.completed);
299
+ if (allDone) {
300
+ // Count unverified phases (complete but no VERIFICATION.md on disk)
301
+ const unverifiedCount = statePhases.filter(p => {
302
+ const disk = diskByNum[phaseKey(p)];
303
+ return (p.status === 'complete' || p.completed) && disk && !disk.has_verification;
304
+ }).length;
305
+ const hasDrift = (insights || []).some(i => i.kind === 'roadmap-drift' || (i.message && i.message.includes('ROADMAP')));
306
+ const auditArgs = [];
307
+ if (unverifiedCount > 0) auditArgs.push(String(unverifiedCount));
308
+ if (hasDrift) auditArgs.push('--fix-drift');
309
+ const auditCmd = auditArgs.length > 0
310
+ ? `/rcode-audit-milestone ${auditArgs.join(' ')}`
311
+ : '/rcode-audit-milestone';
312
+ routes.push({ letter: 'C', label: '', command: auditCmd });
313
+ routes.push({ letter: 'C', label: '', command: '/rcode-complete-milestone' });
314
+ }
315
+
316
+ // Fallback — nothing obvious: offer status
317
+ if (routes.length === 0) {
318
+ routes.push({ letter: 'A', label: '', command: '/rcode-progress' });
319
+ routes.push({ letter: 'B', label: '', command: '/rcode-council' });
320
+ }
321
+
322
+ return routes;
323
+ }
324
+
325
+ function buildBar(completed, total) {
326
+ if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
327
+ const pct = Math.round((completed / total) * 100);
328
+ const width = 20;
329
+ const filled = Math.min(width, Math.round((completed / total) * width));
330
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
331
+ return `[${bar}] ${completed}/${total} (${pct}%)`;
332
+ }
333
+
334
+ /**
335
+ * Compute weighted progress that recognizes intermediate phase states.
336
+ * Weights: has_context only = 0.15, has_research = 0.25, has plan = 0.5,
337
+ * has verification or summary = 1.0.
338
+ * Returns { weighted: number (0..total), pct: number (0..100) }.
339
+ */
340
+ function computeWeightedProgress(stPhases, diskMap) {
341
+ if (!stPhases.length) return { weighted: 0, pct: 0 };
342
+ const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
343
+ let sum = 0;
344
+ for (const p of stPhases) {
345
+ const k = norm(phaseKey(p));
346
+ if (p.status === 'complete' || p.completed) { sum += 1; continue; }
347
+ const disk = diskMap[k] || diskMap[phaseKey(p)];
348
+ if (!disk) continue;
349
+ if (disk.summary_count > 0) { sum += 1; continue; }
350
+ if (disk.has_verification) { sum += 0.85; continue; }
351
+ if (disk.plan_count > 0) { sum += 0.5; continue; }
352
+ if (disk.has_research) { sum += 0.25; continue; }
353
+ if (disk.has_context) { sum += 0.15; continue; }
354
+ }
355
+ const total = Math.max(stPhases.length, 1);
356
+ return { weighted: Math.round(sum * 100) / 100, pct: Math.round((sum / total) * 100) };
357
+ }
358
+
359
+ function buildWeightedBar(stPhases, diskMap, total) {
360
+ const { weighted, pct } = computeWeightedProgress(stPhases, diskMap);
361
+ if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
362
+ const width = 20;
363
+ const filled = Math.min(width, Math.round((weighted / total) * width));
364
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
365
+ return `[${bar}] ~${pct}% weighted`;
366
+ }
367
+
368
+ // Build the core snapshot once — all subcommands derive from it.
369
+ const state = readState();
370
+ const roadmapPhases = parseRoadmapPhases();
371
+ const diskByNum = walkPhaseDirs();
372
+ const statePhases = (state && (state.state?.phases || state.phases)) || [];
373
+ const completedCount = statePhases.filter(p => p.status === 'complete' || p.completed).length;
374
+ const phaseCount = Math.max(statePhases.length, roadmapPhases.length);
375
+
376
+ if (sub === 'bar') {
377
+ const bar = buildBar(completedCount, phaseCount);
378
+ if (rawMode) { console.log(bar); process.exit(0); }
379
+ return { ok: true, bar, completed: completedCount, total: phaseCount };
380
+ }
381
+
382
+ if (sub === 'insights') {
383
+ const insightsList = detectInsights(state, roadmapPhases, diskByNum);
384
+ enforceStrictGate(insightsList);
385
+ return { ok: true, insights: insightsList };
386
+ }
387
+
388
+ if (sub === 'routes') {
389
+ const routeInsights = detectInsights(state, roadmapPhases, diskByNum);
390
+ return { ok: true, routes: deriveRoutes(state, roadmapPhases, diskByNum, routeInsights) };
391
+ }
392
+
393
+ // sub === 'init' (default) — full snapshot
394
+ const currentPhase = state && state.current_phase;
395
+ const insights = detectInsights(state, roadmapPhases, diskByNum);
396
+ enforceStrictGate(insights);
397
+ const routes = deriveRoutes(state, roadmapPhases, diskByNum, insights);
398
+ const { weighted: weightedCompleted, pct: weightedPct } = computeWeightedProgress(statePhases, diskByNum);
399
+
400
+ return {
401
+ ok: true,
402
+ project: state && state.project,
403
+ milestone: extractMilestoneName(),
404
+ current_phase: currentPhase,
405
+ phase_count: phaseCount,
406
+ completed_count: completedCount,
407
+ weighted_progress: weightedPct,
408
+ bar: buildBar(completedCount, phaseCount),
409
+ weighted_bar: buildWeightedBar(statePhases, diskByNum, phaseCount),
410
+ phases: (() => {
411
+ // Prefer ROADMAP-parsed phases when available; fall back to state.phases
412
+ // when the roadmap doesn't use a parseable format. Normalize "07" / "7" / 7.
413
+ const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
414
+ const source = roadmapPhases.length > 0 ? roadmapPhases : statePhases.map(p => ({
415
+ number: phaseKey(p),
416
+ name: p.name || '',
417
+ goal: p.goal || '',
418
+ status: p.status,
419
+ }));
420
+ return source.map(p => {
421
+ const k = phaseKey(p);
422
+ const sp = statePhases.find(x => norm(phaseKey(x)) === norm(k));
423
+ return {
424
+ ...p,
425
+ number: k,
426
+ status: p.status || (sp && sp.status) || null,
427
+ disk: diskByNum[k] || null,
428
+ in_state: !!sp,
429
+ };
430
+ });
431
+ })(),
432
+ decisions: state ? (state.decisions || []).slice(-3) : [],
433
+ blockers: state ? (state.blockers || []).filter(b => !b.resolved).slice(0, 5) : [],
434
+ insights,
435
+ routes,
436
+ updated: state && state.updated,
437
+ };
438
+ }
439
+
440
+ module.exports = { cmdProgress };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /**
3
+ * state-reader.cjs — shared state-reading helpers for rcode-hooks.cjs subcommands.
4
+ * Extracted from preCompact to keep rcode-hooks.cjs under 1000 lines. (#947)
5
+ * Pure Node stdlib. No external dependencies.
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+
11
+ // Legacy status spellings that mean "complete" (#955). resolveActivePhase()
12
+ // may see raw, unmigrated state.json (callers here read the file directly
13
+ // rather than through rcode-tools.cjs's migrateState()), so it normalizes
14
+ // inline instead of assuming its input already went through migration.
15
+ const COMPLETE_PHASE_STATUSES = new Set(['complete', 'completed', 'executed', 'verified']);
16
+
17
+ /**
18
+ * Resolve the active phase entry and a human-readable label from state.json.
19
+ * Returns { activePhase, phaseLabel } — both may be null if state is absent.
20
+ *
21
+ * Preference order (#955): an explicit current_phase match is the authoritative
22
+ * pointer and wins first. Falling back to "first/last phase with status
23
+ * 'executing'" is what caused the bug — a stale executing entry earlier in the
24
+ * roadmap (e.g. phase 37) shadowed real current work (phase 43, already
25
+ * complete) whenever current_phase didn't match by exact string. Instead, the
26
+ * fallback is the highest-numbered phase that isn't complete.
27
+ */
28
+ function resolveActivePhase(state) {
29
+ const phases = Array.isArray(state?.phases) ? state.phases : [];
30
+
31
+ const matched = phases.find(
32
+ (p) => p && (p.name === state?.current_phase || p.number === state?.current_phase)
33
+ );
34
+
35
+ let activePhase = matched || null;
36
+ if (!activePhase) {
37
+ const nonComplete = phases.filter((p) => p && !COMPLETE_PHASE_STATUSES.has(p.status));
38
+ activePhase = nonComplete.reduce((highest, p) => {
39
+ const n = parseFloat(p.number ?? p.id);
40
+ if (Number.isNaN(n)) return highest;
41
+ const highestN = highest ? parseFloat(highest.number ?? highest.id) : -Infinity;
42
+ return n > highestN ? p : highest;
43
+ }, null);
44
+ }
45
+
46
+ const phaseLabel = activePhase
47
+ ? (activePhase.number || activePhase.name || state?.current_phase)
48
+ : (state?.current_phase || null);
49
+ return { activePhase, phaseLabel };
50
+ }
51
+
52
+ /**
53
+ * Read the most recent SPRINT.md under .planning/phases/<phaseLabel>-* and return
54
+ * sprint progress counts + up to 10 incomplete task strings.
55
+ * Returns { completedCount: { done, total }, incompleteTasks: string[] }.
56
+ */
57
+ function readSprintProgress(phaseLabel, cwd) {
58
+ const completedCount = { done: 0, total: 0 };
59
+ const incompleteTasks = [];
60
+ const planningBase = path.join(cwd, '.planning', 'phases');
61
+ if (!phaseLabel || !fs.existsSync(planningBase)) {
62
+ return { completedCount, incompleteTasks };
63
+ }
64
+ try {
65
+ const phaseDirs = fs.readdirSync(planningBase)
66
+ .filter(d => d.startsWith(String(phaseLabel)));
67
+ for (const pd of phaseDirs) {
68
+ const pdPath = path.join(planningBase, pd);
69
+ if (!fs.statSync(pdPath).isDirectory()) continue;
70
+ const sprintFiles = fs.readdirSync(pdPath)
71
+ .filter(f => f.endsWith('-SPRINT.md'))
72
+ .sort()
73
+ .reverse();
74
+ if (sprintFiles.length === 0) continue;
75
+ const sprintText = fs.readFileSync(path.join(pdPath, sprintFiles[0]), 'utf8');
76
+ for (const line of sprintText.split('\n')) {
77
+ const done = /^\s*-\s*\[x\]/i.test(line);
78
+ const pending = /^\s*-\s*\[ \]/.test(line);
79
+ if (done || pending) completedCount.total++;
80
+ if (done) completedCount.done++;
81
+ if (pending) {
82
+ const task = line.replace(/^\s*-\s*\[ \]\s*/, '').trim();
83
+ if (task) incompleteTasks.push(task);
84
+ }
85
+ }
86
+ break; // use first matching phase dir only
87
+ }
88
+ } catch { /* ignore — readSprintProgress is advisory */ }
89
+ return { completedCount, incompleteTasks: incompleteTasks.slice(0, 10) };
90
+ }
91
+
92
+ /**
93
+ * Read the 5 most recent git commit subjects. Returns string[].
94
+ * Returns [] when git is unavailable or outside a repository.
95
+ */
96
+ function readRecentCommits(cwd) {
97
+ try {
98
+ const log = execSync('git log --oneline -5 --no-decorate 2>/dev/null', {
99
+ cwd, encoding: 'utf8', timeout: 3000,
100
+ }).trim();
101
+ return log ? log.split('\n').filter(Boolean) : [];
102
+ } catch { return []; }
103
+ }
104
+
105
+ /**
106
+ * Read the milestone hint from state.json or .planning/ROADMAP.md.
107
+ * Returns a string or null.
108
+ *
109
+ * L1 (#952 review): the per-file readFileSync is wrapped in a silent catch by
110
+ * design — a milestone hint is advisory, so an unreadable ROADMAP (permissions,
111
+ * race) must degrade to "no hint", never propagate and break an advisory hook.
112
+ * This is an intentional resilience improvement over the original inline code.
113
+ */
114
+ function readMilestoneHint(state, cwd) {
115
+ if (state?.milestone) return state.milestone;
116
+ for (const rp of ['.planning/ROADMAP.md', '.planning/milestones/ROADMAP.md']) {
117
+ const full = path.join(cwd, rp);
118
+ if (!fs.existsSync(full)) continue;
119
+ try {
120
+ const m = fs.readFileSync(full, 'utf8').match(/^##\s+Milestone\s+(M\d+[^\n]*)/m);
121
+ if (m) return m[1].trim();
122
+ } catch { /* ignore */ }
123
+ }
124
+ return null;
125
+ }
126
+
127
+ module.exports = { resolveActivePhase, readSprintProgress, readRecentCommits, readMilestoneHint };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Summary — SUMMARY.md field extraction and compact state.json snapshots.
3
+ *
4
+ * Extracted from rcode-tools.cjs's cmdSummaryExtract + cmdStateSnapshot
5
+ * (issue #204) — pure mechanical move, no behavior change.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * cmdSummaryExtract — surgically pull named fields from a SUMMARY.md.
13
+ * Avoids whole-file loads when the caller only wants one or two headings.
14
+ * Usage: summary-extract <path> --fields one_liner,status
15
+ */
16
+ function cmdSummaryExtract(args) {
17
+ const filePath = args[0];
18
+ const fieldsFlag = args.indexOf('--fields');
19
+ const fields = fieldsFlag >= 0 ? (args[fieldsFlag + 1] || '').split(',').map(s => s.trim()).filter(Boolean) : ['one_liner'];
20
+
21
+ if (!filePath) return { ok: false, error: 'Usage: summary-extract <path> [--fields a,b,c]' };
22
+ if (!fs.existsSync(filePath)) return { ok: false, error: `file not found: ${filePath}` };
23
+
24
+ const text = fs.readFileSync(filePath, 'utf8');
25
+ const out = { ok: true, path: filePath };
26
+
27
+ const fieldToPatterns = {
28
+ one_liner: [/^##\s+One[-\s]?liner\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Summary\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
29
+ status: [/^##\s+Status\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^status:\s*(.+)$/im],
30
+ outcomes: [/^##\s+Outcomes?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
31
+ decisions: [/^##\s+Decisions?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
32
+ blockers: [/^##\s+Blockers?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
33
+ followups: [/^##\s+Follow[-\s]?ups?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Next[-\s]?steps?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
34
+ };
35
+
36
+ for (const f of fields) {
37
+ const patterns = fieldToPatterns[f] || [new RegExp(`^##\\s+${f.replace(/_/g, '[ _-]?')}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n---|$)`, 'im')];
38
+ let value = null;
39
+ for (const re of patterns) {
40
+ const m = text.match(re);
41
+ if (m && m[1]) { value = m[1].trim().split('\n').map(l => l.trim()).filter(Boolean).join('\n'); break; }
42
+ }
43
+ // Fallback for one_liner: first non-empty paragraph after H1
44
+ if (f === 'one_liner' && !value) {
45
+ const afterH1 = text.replace(/^#[^\n]*\n/, '');
46
+ const firstPara = afterH1.match(/^[^\n#][^\n]*(?:\n(?!\n)[^\n#][^\n]*)*/m);
47
+ if (firstPara) value = firstPara[0].trim();
48
+ }
49
+ out[f] = value;
50
+ }
51
+
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * cmdStateSnapshot — compact, display-friendly state extract.
57
+ * Hides internal machinery (lock metadata, full history) from callers
58
+ * that only need a render-ready summary.
59
+ */
60
+ function cmdStateSnapshot({ RCODE_DIR }) {
61
+ const statePath = path.join(RCODE_DIR, 'state.json');
62
+ if (!fs.existsSync(statePath)) return { ok: true, state: null };
63
+ let state;
64
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
65
+ catch (e) { return { ok: false, error: `invalid state.json: ${e.message}` }; }
66
+
67
+ return {
68
+ ok: true,
69
+ project: state.project,
70
+ current_phase: state.current_phase,
71
+ current_plan: state.current_plan,
72
+ current_sprint: state.current_sprint,
73
+ phase_count: (state.phases || []).length,
74
+ decisions_count: (state.decisions || []).length,
75
+ blockers_open: (state.blockers || []).filter(b => !b.resolved).length,
76
+ last_session: state.last_session,
77
+ updated: state.updated,
78
+ active_workstream: state.active_workstream,
79
+ };
80
+ }
81
+
82
+ module.exports = { cmdSummaryExtract, cmdStateSnapshot };