@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.
- package/AGENTS.md +11 -4
- package/CLAUDE.md +11 -4
- package/CONTRIBUTING.md +6 -1
- package/README.md +1 -1
- package/cli/doctor.js +122 -0
- package/cli/index.js +4 -0
- package/cli/install.js +8 -0
- package/cli/lib/namespace-migrate.cjs +216 -0
- package/cli/migrate-namespace.js +61 -0
- package/cli/update.js +19 -0
- package/dist/rcode.js +215 -208
- package/package.json +14 -12
- package/rcode/bin/lib/brain.cjs +353 -0
- package/rcode/bin/lib/gitignore.cjs +126 -0
- package/rcode/bin/lib/memory-drift.cjs +237 -0
- package/rcode/bin/lib/memory-select.cjs +263 -0
- package/rcode/bin/lib/progress.cjs +440 -0
- package/rcode/bin/lib/state-reader.cjs +127 -0
- package/rcode/bin/lib/summary.cjs +82 -0
- package/rcode/bin/rcode-hooks.cjs +194 -64
- package/rcode/bin/rcode-tools.cjs +183 -997
- package/rcode/data/intent-table.json +19 -19
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/heartbeat.sh +0 -0
- package/rcode/templates/memory/INDEX.md +6 -1
- package/rcode/templates/settings-hooks.json +12 -1
- package/rcode/workflows/do.md +2 -2
- package/rcode/workflows/enable-hooks.md +3 -2
- package/rcode/workflows/execute.md +10 -1
- package/rcode/workflows/insert-phase.md +8 -0
- package/rcode/workflows/new-project-roadmap.md +2 -2
- package/rcode/workflows/plan.md +17 -0
|
@@ -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 };
|