@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
|
@@ -1031,12 +1031,42 @@ function cmdState(subArgs) {
|
|
|
1031
1031
|
}
|
|
1032
1032
|
try {
|
|
1033
1033
|
const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
1034
|
-
|
|
1034
|
+
const migrated = migrateState(raw);
|
|
1035
|
+
// One-time idempotent status migration (#955): persist the normalized
|
|
1036
|
+
// phase statuses back to disk the first time legacy aliases are found,
|
|
1037
|
+
// so state.json itself becomes canonical and every other reader (e.g.
|
|
1038
|
+
// resolveActivePhase() in state-reader.cjs, which reads the file
|
|
1039
|
+
// directly rather than through this helper) sees clean values too.
|
|
1040
|
+
// Idempotent: once written, raw already matches migrated and this is a
|
|
1041
|
+
// no-op on every subsequent load.
|
|
1042
|
+
const rawPhases = Array.isArray(raw?.phases) ? raw.phases : [];
|
|
1043
|
+
const migratedPhases = Array.isArray(migrated?.phases) ? migrated.phases : [];
|
|
1044
|
+
const hasLegacyStatus = rawPhases.some((p, i) => p?.status !== migratedPhases[i]?.status);
|
|
1045
|
+
if (hasLegacyStatus) {
|
|
1046
|
+
writeState(migrated);
|
|
1047
|
+
}
|
|
1048
|
+
return migrated;
|
|
1035
1049
|
} catch (e) {
|
|
1036
1050
|
throw new Error(`Invalid JSON in state.json: ${e.message}`);
|
|
1037
1051
|
}
|
|
1038
1052
|
}
|
|
1039
1053
|
|
|
1054
|
+
// Canonical phase status enum (#955). Legacy state files accumulated four
|
|
1055
|
+
// spellings for the same three states — normalize on every read so callers
|
|
1056
|
+
// never have to special-case 'completed'/'executed' against 'complete'.
|
|
1057
|
+
const PHASE_STATUS_ALIASES = {
|
|
1058
|
+
completed: 'complete',
|
|
1059
|
+
executed: 'complete',
|
|
1060
|
+
verified: 'complete',
|
|
1061
|
+
};
|
|
1062
|
+
const PHASE_STATUS_ENUM = new Set(['planned', 'executing', 'complete']);
|
|
1063
|
+
|
|
1064
|
+
/** Map a legacy status spelling to the canonical enum value (idempotent). */
|
|
1065
|
+
function normalizePhaseStatus(status) {
|
|
1066
|
+
if (typeof status !== 'string') return status;
|
|
1067
|
+
return PHASE_STATUS_ALIASES[status] ?? status;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1040
1070
|
/**
|
|
1041
1071
|
* migrateState — pure normalizer that upgrades any legacy state shape to v2.
|
|
1042
1072
|
*
|
|
@@ -1081,7 +1111,7 @@ function cmdState(subArgs) {
|
|
|
1081
1111
|
number: number ?? p.id ?? null,
|
|
1082
1112
|
id: id ?? null,
|
|
1083
1113
|
name: p.name ?? null,
|
|
1084
|
-
status: p.status ?? 'planned',
|
|
1114
|
+
status: normalizePhaseStatus(p.status ?? 'planned'),
|
|
1085
1115
|
started: p.started ?? null,
|
|
1086
1116
|
completed: p.completed ?? null,
|
|
1087
1117
|
sprints: Array.isArray(p.sprints) ? p.sprints : [],
|
|
@@ -2278,12 +2308,16 @@ function cmdState(subArgs) {
|
|
|
2278
2308
|
}
|
|
2279
2309
|
|
|
2280
2310
|
writeState(state);
|
|
2311
|
+
// #942 — surface the milestone close nudge for inserted phases too.
|
|
2312
|
+
const insHealth = milestoneCloseNudge();
|
|
2281
2313
|
return {
|
|
2282
2314
|
ok: true,
|
|
2283
2315
|
phase_number: phaseNumber,
|
|
2284
2316
|
name: phaseName,
|
|
2285
2317
|
slug: slug,
|
|
2286
2318
|
directory: path.join(PLANNING_DIR, 'phases', `${phaseNumber}-${slug}`),
|
|
2319
|
+
milestone_health: insHealth.milestone_health,
|
|
2320
|
+
...(insHealth.nudge ? { nudge: insHealth.nudge } : {}),
|
|
2287
2321
|
};
|
|
2288
2322
|
}
|
|
2289
2323
|
|
|
@@ -3173,10 +3207,36 @@ function cmdState(subArgs) {
|
|
|
3173
3207
|
if (previousStatus === 'planned') {
|
|
3174
3208
|
process.stderr.write(`Warning: completing phase ${phaseKey} from 'planned' without executing.\n`);
|
|
3175
3209
|
}
|
|
3210
|
+
|
|
3211
|
+
// State-hygiene gate (#955): if an earlier-numbered phase is still stuck
|
|
3212
|
+
// 'executing' while this later phase gets marked complete, that's exactly
|
|
3213
|
+
// the drift that misorients resolveActivePhase() / the SessionStart greeter.
|
|
3214
|
+
// Warn rather than block — completing out of order is sometimes correct
|
|
3215
|
+
// (parallel workstreams), but it must never happen silently.
|
|
3216
|
+
const thisNum = parseFloat(phaseKey);
|
|
3217
|
+
const stalePhases = Number.isNaN(thisNum) ? [] : state.phases.filter((p) => {
|
|
3218
|
+
if (!p || p.status !== 'executing') return false;
|
|
3219
|
+
const n = parseFloat(p.number ?? p.id);
|
|
3220
|
+
return !Number.isNaN(n) && n < thisNum;
|
|
3221
|
+
});
|
|
3222
|
+
if (stalePhases.length > 0 && !flags.force) {
|
|
3223
|
+
const staleList = stalePhases.map((p) => p.number ?? p.id).join(', ');
|
|
3224
|
+
process.stderr.write(
|
|
3225
|
+
`Warning: phase ${phaseKey} marked complete while earlier phase(s) ${staleList} are still 'executing'. ` +
|
|
3226
|
+
`Use --force to suppress this warning, or close out the stale phase(s) first.\n`
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3176
3230
|
entry.status = 'complete';
|
|
3177
3231
|
entry.completed = new Date().toISOString();
|
|
3178
3232
|
writeState(state);
|
|
3179
|
-
return {
|
|
3233
|
+
return {
|
|
3234
|
+
updated: true,
|
|
3235
|
+
phase: phaseKey,
|
|
3236
|
+
status: 'complete',
|
|
3237
|
+
previous_status: previousStatus,
|
|
3238
|
+
stale_executing_phases: stalePhases.map((p) => p.number ?? p.id),
|
|
3239
|
+
};
|
|
3180
3240
|
}
|
|
3181
3241
|
|
|
3182
3242
|
// Truncates execution state but preserves decisions, council_sessions, and workstreams.
|
|
@@ -3734,22 +3794,30 @@ function cmdPhase(subArgs) {
|
|
|
3734
3794
|
// value at the scales we operate. Applies to phases, sprints, epics, stories,
|
|
3735
3795
|
// tasks, decisions across all artifacts (dirs, ROADMAP, state.json, banners).
|
|
3736
3796
|
|
|
3737
|
-
// #583 sanity guard: prevent phantom phase numbers caused by stale
|
|
3738
|
-
// entries in ROADMAP.md or phases/ (e.g. a prior phantom
|
|
3739
|
-
// in ROADMAP triggers the next add to produce 1010).
|
|
3740
|
-
//
|
|
3741
|
-
//
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3797
|
+
// #583 / #944 sanity guard: prevent phantom phase numbers caused by stale
|
|
3798
|
+
// high-number entries in ROADMAP.md or phases/ (e.g. a prior phantom
|
|
3799
|
+
// "## Phase 1009" left in ROADMAP triggers the next add to produce 1010).
|
|
3800
|
+
//
|
|
3801
|
+
// The guard must NOT misfire on an INTENTIONAL high-base numbering scheme
|
|
3802
|
+
// (e.g. a milestone that deliberately numbers phases 1031, 1032, …). The
|
|
3803
|
+
// discriminant: is the high number an actual TRACKED phase in state.json,
|
|
3804
|
+
// or only a ROADMAP/dir entry that state has never seen?
|
|
3805
|
+
// - next === maxTracked + 1 → contiguous with real tracked phases →
|
|
3806
|
+
// intentional, allow regardless of absolute magnitude.
|
|
3807
|
+
// - maxNum (overall) sits far ABOVE maxTracked → a non-tracked phantom
|
|
3808
|
+
// is driving the number → suspect, abort.
|
|
3809
|
+
const trackedNums = state.phases
|
|
3810
|
+
.map(p => parseInt(String(p.number || ''), 10))
|
|
3811
|
+
.filter(n => !Number.isNaN(n) && n > 0);
|
|
3812
|
+
const trackedCount = trackedNums.length;
|
|
3813
|
+
const maxTracked = trackedNums.length ? Math.max(...trackedNums) : 0;
|
|
3814
|
+
if (maxNum > maxTracked && (maxNum - maxTracked) > 50) {
|
|
3747
3815
|
throw new Error(
|
|
3748
|
-
`Computed phase number ${next} is
|
|
3749
|
-
`(
|
|
3750
|
-
`ROADMAP.md or the phases/ directory
|
|
3816
|
+
`Computed phase number ${next} is driven by a non-tracked entry ` +
|
|
3817
|
+
`(highest in ROADMAP/phases = ${maxNum}, highest in state.json = ${maxTracked}). ` +
|
|
3818
|
+
`ROADMAP.md or the phases/ directory likely contains a stale high-number entry. ` +
|
|
3751
3819
|
`Inspect with: node rcode-tools.cjs phases list\n` +
|
|
3752
|
-
`Then retry with an explicit number: rcode-tools.cjs phase add "${phaseName}" --number ${
|
|
3820
|
+
`Then retry with an explicit number: rcode-tools.cjs phase add "${phaseName}" --number ${maxTracked + 1}`
|
|
3753
3821
|
);
|
|
3754
3822
|
}
|
|
3755
3823
|
|
|
@@ -3822,12 +3890,17 @@ function cmdPhase(subArgs) {
|
|
|
3822
3890
|
}
|
|
3823
3891
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
3824
3892
|
|
|
3893
|
+
// #942 — surface the milestone close nudge from the CLI itself so it can't
|
|
3894
|
+
// be bypassed by adding phases outside the add-phase workflow.
|
|
3895
|
+
const { milestone_health, nudge } = milestoneCloseNudge();
|
|
3825
3896
|
return {
|
|
3826
3897
|
ok: true,
|
|
3827
3898
|
phase_number: number,
|
|
3828
3899
|
name: phaseName,
|
|
3829
3900
|
slug,
|
|
3830
3901
|
directory: path.relative(PROJECT_ROOT, directory),
|
|
3902
|
+
milestone_health,
|
|
3903
|
+
...(nudge ? { nudge } : {}),
|
|
3831
3904
|
};
|
|
3832
3905
|
}
|
|
3833
3906
|
|
|
@@ -3866,6 +3939,19 @@ function cmdPhase(subArgs) {
|
|
|
3866
3939
|
.sort((a, b) => parseInt(String(a.number), 10) - parseInt(String(b.number), 10))[0] || null;
|
|
3867
3940
|
|
|
3868
3941
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
3942
|
+
|
|
3943
|
+
// #943 — when no open phases remain, the milestone is effectively finished.
|
|
3944
|
+
// Surface the close/next guidance from this chokepoint so finishing the
|
|
3945
|
+
// last phase via execute/verify/dev-story doesn't strand the user (the
|
|
3946
|
+
// guidance previously only appeared in /rcode-status or progress insights).
|
|
3947
|
+
const doneStatuses = new Set(['complete', 'completed', 'verified', 'shipped']);
|
|
3948
|
+
const openRemaining = state.phases.filter(p => !doneStatuses.has(p.status)).length;
|
|
3949
|
+
let nudge = null;
|
|
3950
|
+
if (openRemaining === 0 && state.phases.length > 0) {
|
|
3951
|
+
nudge = 'All phases are complete — this milestone is finished. ' +
|
|
3952
|
+
'Run /rcode-complete-milestone to archive it, then /rcode-new-milestone to start the next.';
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3869
3955
|
return {
|
|
3870
3956
|
ok: true,
|
|
3871
3957
|
phase: phaseRef,
|
|
@@ -3874,6 +3960,8 @@ function cmdPhase(subArgs) {
|
|
|
3874
3960
|
next_phase: next ? next.number : null,
|
|
3875
3961
|
next_phase_name: next ? (next.name || null) : null,
|
|
3876
3962
|
is_last_phase: !next,
|
|
3963
|
+
open_phases_remaining: openRemaining,
|
|
3964
|
+
...(nudge ? { nudge } : {}),
|
|
3877
3965
|
warnings: [],
|
|
3878
3966
|
has_warnings: false,
|
|
3879
3967
|
};
|
|
@@ -4175,7 +4263,13 @@ function cmdPhase(subArgs) {
|
|
|
4175
4263
|
if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
|
|
4176
4264
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
4177
4265
|
|
|
4178
|
-
|
|
4266
|
+
// #942 — same milestone close nudge for the bulk-draft path.
|
|
4267
|
+
const bulkHealth = milestoneCloseNudge();
|
|
4268
|
+
return {
|
|
4269
|
+
ok: true, count: created.length, phases: created, roadmap_skipped: roadmapSkipped,
|
|
4270
|
+
milestone_health: bulkHealth.milestone_health,
|
|
4271
|
+
...(bulkHealth.nudge ? { nudge: bulkHealth.nudge } : {}),
|
|
4272
|
+
};
|
|
4179
4273
|
}
|
|
4180
4274
|
|
|
4181
4275
|
// =====================================================================
|
|
@@ -4421,15 +4515,21 @@ function cmdCommit(argv) {
|
|
|
4421
4515
|
}
|
|
4422
4516
|
|
|
4423
4517
|
/**
|
|
4424
|
-
* cmdGenerateClaudeMd — Phase 11 / #467 / closes part of #465.
|
|
4518
|
+
* cmdGenerateClaudeMd — Phase 11 / #467 / closes part of #465. Phase 42 / #946.
|
|
4425
4519
|
*
|
|
4426
|
-
* Bootstrap
|
|
4427
|
-
*
|
|
4520
|
+
* Bootstrap project agent-rules scaffolds. Writes the same rule set to both
|
|
4521
|
+
* CLAUDE.md (Claude Code, Grok) and AGENTS.md (the cross-tool open standard read
|
|
4522
|
+
* by Codex, Cursor, Windsurf, Antigravity, Gemini) so the rcode Command Routing
|
|
4523
|
+
* rule reaches every supported agent — not just Claude. Used by
|
|
4524
|
+
* new-project-roadmap.md. Refuses to overwrite an existing CLAUDE.md unless
|
|
4525
|
+
* --force is set; AGENTS.md is written when absent (or with --force) so an
|
|
4526
|
+
* install-managed roster section is never clobbered.
|
|
4428
4527
|
*/
|
|
4429
4528
|
function cmdGenerateClaudeMd(rawArgs) {
|
|
4430
4529
|
const args = (rawArgs || '').split(/\s+/).filter(Boolean);
|
|
4431
4530
|
const force = args.includes('--force');
|
|
4432
4531
|
const claudeMdPath = path.join(PROJECT_ROOT, 'CLAUDE.md');
|
|
4532
|
+
const agentsMdPath = path.join(PROJECT_ROOT, 'AGENTS.md');
|
|
4433
4533
|
|
|
4434
4534
|
if (fs.existsSync(claudeMdPath) && !force) {
|
|
4435
4535
|
throw new Error(`CLAUDE.md already exists at ${claudeMdPath}. Use --force to overwrite.`);
|
|
@@ -4516,15 +4616,40 @@ If you have a real reason to bypass (e.g. retroactively documenting a phase that
|
|
|
4516
4616
|
|
|
4517
4617
|
---
|
|
4518
4618
|
|
|
4619
|
+
## rcode Command Routing
|
|
4620
|
+
|
|
4621
|
+
Before handling planning, exploration, auditing, refactoring, or multi-step build work ad-hoc, check whether a matching rcode command exists first.
|
|
4622
|
+
|
|
4623
|
+
**How to check:** read \`.rcode/workflows/do.md\` (installed by rcode) for the intent → command routing table — it is the single source of truth. Common cases: planning a phase → \`/rcode-plan\`, adding a phase → \`/rcode-add-phase\`, exploring/brainstorming → \`/rcode-brainstorm\`, auditing → \`/rcode-audit\`, executing a sprint → \`/rcode-execute\`, mapping the codebase → \`/rcode-map-codebase\`. Always consult \`do.md\` — never infer from memory alone, as the table changes with the release.
|
|
4624
|
+
|
|
4625
|
+
**Why:** rcode commands record outcomes in \`.rcode/state.json\` and \`.planning/\`. Work done ad-hoc creates silent state divergence. If you must proceed ad-hoc, run \`/rcode-memory-update\` afterward to keep long-term memory consistent.
|
|
4626
|
+
|
|
4627
|
+
---
|
|
4628
|
+
|
|
4519
4629
|
**This file is part of the project. Treat it as load-bearing.**
|
|
4520
4630
|
`;
|
|
4521
4631
|
|
|
4632
|
+
const claudeExisted = fs.existsSync(claudeMdPath);
|
|
4522
4633
|
fs.writeFileSync(claudeMdPath, content);
|
|
4634
|
+
|
|
4635
|
+
// Mirror the same rules to AGENTS.md (the cross-tool standard Codex, Cursor,
|
|
4636
|
+
// Windsurf, Antigravity, and Gemini read). Skip when it already exists without
|
|
4637
|
+
// --force so an install-appended "## rcode Agents (installed)" roster survives.
|
|
4638
|
+
const agentsExisted = fs.existsSync(agentsMdPath);
|
|
4639
|
+
const wroteAgents = !agentsExisted || force;
|
|
4640
|
+
if (wroteAgents) {
|
|
4641
|
+
fs.writeFileSync(agentsMdPath, content);
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4523
4644
|
return {
|
|
4524
4645
|
ok: true,
|
|
4525
4646
|
path: path.relative(PROJECT_ROOT, claudeMdPath),
|
|
4647
|
+
paths: wroteAgents
|
|
4648
|
+
? [path.relative(PROJECT_ROOT, claudeMdPath), path.relative(PROJECT_ROOT, agentsMdPath)]
|
|
4649
|
+
: [path.relative(PROJECT_ROOT, claudeMdPath)],
|
|
4526
4650
|
project_name: projectName,
|
|
4527
|
-
overwritten: force &&
|
|
4651
|
+
overwritten: force && claudeExisted,
|
|
4652
|
+
agents_md_skipped: !wroteAgents,
|
|
4528
4653
|
};
|
|
4529
4654
|
}
|
|
4530
4655
|
|
|
@@ -6017,20 +6142,6 @@ function cmdNotesCount() {
|
|
|
6017
6142
|
return { count };
|
|
6018
6143
|
}
|
|
6019
6144
|
|
|
6020
|
-
/**
|
|
6021
|
-
* cmdBrain — pull rcode brain content from configured sources.
|
|
6022
|
-
*
|
|
6023
|
-
* Subcommands:
|
|
6024
|
-
* brain pull Fetch all configured sources into rcode/brain/
|
|
6025
|
-
* brain pull <name> Fetch a single named source
|
|
6026
|
-
* brain status Report cache freshness and placeholder status
|
|
6027
|
-
* brain list Print configured sources
|
|
6028
|
-
*
|
|
6029
|
-
* Uses git sparse-checkout so we pull only the paths listed per source.
|
|
6030
|
-
* Placeholder URLs (containing `<PLACEHOLDER`) are skipped with a clear
|
|
6031
|
-
* message — useful in v2.0 before M5 lands real rcode repo URLs.
|
|
6032
|
-
*/
|
|
6033
|
-
|
|
6034
6145
|
/**
|
|
6035
6146
|
* cmdHandoff — cross-skill continuation token system. Closes #741.
|
|
6036
6147
|
*
|
|
@@ -6119,827 +6230,7 @@ function cmdHandoff(args) {
|
|
|
6119
6230
|
return { ok: false, error: `Unknown handoff subcommand: ${sub}. Valid: write, read, clear` };
|
|
6120
6231
|
}
|
|
6121
6232
|
|
|
6122
|
-
function cmdBrain(args) {
|
|
6123
|
-
const sub = args[0] || 'help';
|
|
6124
|
-
// sources.yaml lives under .rcode/brain/ in user installs (v2.2+).
|
|
6125
|
-
// Older installs may have it at rcode/brain/ (pre-v2.2) — fall back for compat.
|
|
6126
|
-
let sourcesPath = path.join(RCODE_DIR, 'brain', 'sources.yaml');
|
|
6127
|
-
let brainDir = path.join(RCODE_DIR, 'brain');
|
|
6128
|
-
if (!fs.existsSync(sourcesPath)) {
|
|
6129
|
-
const legacyPath = path.join(PROJECT_ROOT, 'rcode', 'brain', 'sources.yaml');
|
|
6130
|
-
if (fs.existsSync(legacyPath)) {
|
|
6131
|
-
sourcesPath = legacyPath;
|
|
6132
|
-
brainDir = path.join(PROJECT_ROOT, 'rcode', 'brain');
|
|
6133
|
-
}
|
|
6134
|
-
}
|
|
6135
|
-
|
|
6136
|
-
// Resolve a source's dest directory relative to brainDir.
|
|
6137
|
-
// Accepts legacy absolute-looking values ("rcode/brain/rcode-github/") by
|
|
6138
|
-
// stripping any leading "rcode/brain/" so the resolved path sits inside the
|
|
6139
|
-
// chosen brainDir. New sources.yaml should use bare names ("rcode-github/").
|
|
6140
|
-
function resolveDest(dest) {
|
|
6141
|
-
const trimmed = String(dest || '').replace(/^rcode\/brain\//, '').replace(/^\/+/, '');
|
|
6142
|
-
return path.join(brainDir, trimmed);
|
|
6143
|
-
}
|
|
6144
|
-
|
|
6145
|
-
if (!fs.existsSync(sourcesPath)) {
|
|
6146
|
-
return {
|
|
6147
|
-
ok: false,
|
|
6148
|
-
error: `sources.yaml missing at ${sourcesPath}. Run install or see issue #158.`,
|
|
6149
|
-
};
|
|
6150
|
-
}
|
|
6151
|
-
|
|
6152
|
-
// Minimal YAML reader specifically for sources.yaml — not a general parser.
|
|
6153
|
-
// Handles: `version: 1`, `defaults:` block, `sources:` list where each
|
|
6154
|
-
// entry is a `- name: X` block with sibling key: value lines and an
|
|
6155
|
-
// `paths:` sub-list of strings.
|
|
6156
|
-
function parseSourcesYaml(text) {
|
|
6157
|
-
const root = { version: null, defaults: {}, sources: [] };
|
|
6158
|
-
const lines = text.split('\n');
|
|
6159
|
-
let section = null;
|
|
6160
|
-
let current = null; // current source map
|
|
6161
|
-
let inPaths = false;
|
|
6162
|
-
let inDescription = false;
|
|
6163
|
-
let descLines = [];
|
|
6164
|
-
|
|
6165
|
-
function unquote(s) { return s.replace(/^['"]|['"]$/g, ''); }
|
|
6166
|
-
|
|
6167
|
-
for (const raw of lines) {
|
|
6168
|
-
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
6169
|
-
|
|
6170
|
-
// Flush description if we were collecting
|
|
6171
|
-
if (inDescription && raw.match(/^ {4}\S/) && !raw.trim().startsWith('-')) {
|
|
6172
|
-
// still inside the description block
|
|
6173
|
-
const m = raw.match(/^ *(.*)$/);
|
|
6174
|
-
if (m) descLines.push(m[1]);
|
|
6175
|
-
continue;
|
|
6176
|
-
} else if (inDescription) {
|
|
6177
|
-
current.description = descLines.join(' ').trim();
|
|
6178
|
-
inDescription = false;
|
|
6179
|
-
descLines = [];
|
|
6180
|
-
}
|
|
6181
|
-
|
|
6182
|
-
// Top-level keys
|
|
6183
|
-
const top = raw.match(/^(\w+):\s*(.*)$/);
|
|
6184
|
-
if (top) {
|
|
6185
|
-
const key = top[1], val = top[2].trim();
|
|
6186
|
-
if (key === 'version') { root.version = unquote(val); section = null; continue; }
|
|
6187
|
-
if (key === 'defaults') { section = 'defaults'; continue; }
|
|
6188
|
-
if (key === 'sources') { section = 'sources'; continue; }
|
|
6189
|
-
}
|
|
6190
|
-
|
|
6191
|
-
// defaults: indented key-value
|
|
6192
|
-
if (section === 'defaults') {
|
|
6193
|
-
const m = raw.match(/^ +([\w_]+):\s*(.*)$/);
|
|
6194
|
-
if (m) root.defaults[m[1]] = unquote(m[2]);
|
|
6195
|
-
continue;
|
|
6196
|
-
}
|
|
6197
|
-
|
|
6198
|
-
// sources: list items
|
|
6199
|
-
if (section === 'sources') {
|
|
6200
|
-
const startItem = raw.match(/^ *- ([\w_-]+):\s*(.*)$/);
|
|
6201
|
-
if (startItem) {
|
|
6202
|
-
current = {};
|
|
6203
|
-
current[startItem[1]] = unquote(startItem[2]);
|
|
6204
|
-
root.sources.push(current);
|
|
6205
|
-
inPaths = false;
|
|
6206
|
-
continue;
|
|
6207
|
-
}
|
|
6208
|
-
// paths: list-of-strings under current
|
|
6209
|
-
const pathsStart = raw.match(/^ +paths:\s*$/);
|
|
6210
|
-
if (pathsStart) { current.paths = []; inPaths = true; continue; }
|
|
6211
|
-
if (inPaths) {
|
|
6212
|
-
const pItem = raw.match(/^ *- (.*)$/);
|
|
6213
|
-
if (pItem) { current.paths.push(unquote(pItem[1])); continue; }
|
|
6214
|
-
inPaths = false;
|
|
6215
|
-
}
|
|
6216
|
-
// description: block scalar `>`
|
|
6217
|
-
const descStart = raw.match(/^ +description:\s*>\s*$/);
|
|
6218
|
-
if (descStart) { inDescription = true; descLines = []; continue; }
|
|
6219
|
-
// Regular key: value on current item
|
|
6220
|
-
const kv = raw.match(/^ +([\w_-]+):\s*(.*)$/);
|
|
6221
|
-
if (kv && current) {
|
|
6222
|
-
current[kv[1]] = unquote(kv[2]);
|
|
6223
|
-
}
|
|
6224
|
-
}
|
|
6225
|
-
}
|
|
6226
|
-
// final flush
|
|
6227
|
-
if (inDescription && current) current.description = descLines.join(' ').trim();
|
|
6228
|
-
return root;
|
|
6229
|
-
}
|
|
6230
|
-
|
|
6231
|
-
const cfg = parseSourcesYaml(fs.readFileSync(sourcesPath, 'utf8'));
|
|
6232
|
-
const sources = Array.isArray(cfg.sources) ? cfg.sources : [];
|
|
6233
|
-
|
|
6234
|
-
if (sub === 'list') {
|
|
6235
|
-
return {
|
|
6236
|
-
ok: true,
|
|
6237
|
-
version: cfg.version,
|
|
6238
|
-
sources: sources.map(s => ({
|
|
6239
|
-
name: s.name,
|
|
6240
|
-
repo: s.repo,
|
|
6241
|
-
dest: s.dest,
|
|
6242
|
-
placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
|
|
6243
|
-
})),
|
|
6244
|
-
};
|
|
6245
|
-
}
|
|
6246
|
-
|
|
6247
|
-
if (sub === 'status') {
|
|
6248
|
-
const report = { ok: true, sources: [] };
|
|
6249
|
-
for (const s of sources) {
|
|
6250
|
-
const destPath = resolveDest(s.dest);
|
|
6251
|
-
const exists = fs.existsSync(destPath);
|
|
6252
|
-
report.sources.push({
|
|
6253
|
-
name: s.name,
|
|
6254
|
-
dest: s.dest,
|
|
6255
|
-
fetched: exists,
|
|
6256
|
-
placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
|
|
6257
|
-
});
|
|
6258
|
-
}
|
|
6259
|
-
return report;
|
|
6260
|
-
}
|
|
6261
|
-
|
|
6262
|
-
if (sub !== 'pull') {
|
|
6263
|
-
return {
|
|
6264
|
-
ok: false,
|
|
6265
|
-
error: `Unknown brain subcommand: ${sub}. Try: pull | status | list`,
|
|
6266
|
-
};
|
|
6267
|
-
}
|
|
6268
|
-
|
|
6269
|
-
// sub === 'pull'
|
|
6270
|
-
const onlyName = args[1];
|
|
6271
|
-
const report = { ok: true, pulled: [], skipped: [], errors: [] };
|
|
6272
|
-
|
|
6273
|
-
for (const s of sources) {
|
|
6274
|
-
if (onlyName && s.name !== onlyName) continue;
|
|
6275
|
-
const repo = String(s.repo || '');
|
|
6276
|
-
|
|
6277
|
-
if (repo.includes('<PLACEHOLDER')) {
|
|
6278
|
-
report.skipped.push({ name: s.name, reason: 'placeholder URL — fill in via issue #162 (M5)' });
|
|
6279
|
-
continue;
|
|
6280
|
-
}
|
|
6281
|
-
|
|
6282
|
-
if (repo === 'self') {
|
|
6283
|
-
// In-repo copy — use rsync-ish node copy from paths under project root.
|
|
6284
|
-
const destPath = resolveDest(s.dest);
|
|
6285
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6286
|
-
const paths = Array.isArray(s.paths) ? s.paths : [];
|
|
6287
|
-
let copied = 0;
|
|
6288
|
-
for (const pattern of paths) {
|
|
6289
|
-
// Very simple glob: expand ** to recursive copy.
|
|
6290
|
-
const base = pattern.split('**')[0].replace(/\/$/, '');
|
|
6291
|
-
const srcDir = path.join(PROJECT_ROOT, base);
|
|
6292
|
-
if (!fs.existsSync(srcDir)) continue;
|
|
6293
|
-
// Recursive copy of .md files
|
|
6294
|
-
function walk(dir) {
|
|
6295
|
-
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
6296
|
-
const full = path.join(dir, e.name);
|
|
6297
|
-
if (e.isDirectory()) { walk(full); continue; }
|
|
6298
|
-
if (!e.isFile()) continue;
|
|
6299
|
-
if (!full.endsWith('.md')) continue;
|
|
6300
|
-
const rel = path.relative(srcDir, full);
|
|
6301
|
-
const out = path.join(destPath, rel);
|
|
6302
|
-
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
6303
|
-
fs.copyFileSync(full, out);
|
|
6304
|
-
copied++;
|
|
6305
|
-
}
|
|
6306
|
-
}
|
|
6307
|
-
walk(srcDir);
|
|
6308
|
-
}
|
|
6309
|
-
report.pulled.push({ name: s.name, kind: 'self', files: copied });
|
|
6310
|
-
continue;
|
|
6311
|
-
}
|
|
6312
|
-
|
|
6313
|
-
// #925 — supply-chain guard. `brain pull` clones a remote repo and copies
|
|
6314
|
-
// its content into every rcode user's project context, so an attacker who
|
|
6315
|
-
// can edit sources.yaml (or a typo) must not silently pull untrusted code.
|
|
6316
|
-
// Only allow github.com URLs under an approved org allowlist; anything else
|
|
6317
|
-
// is rejected unless the user explicitly opts in with
|
|
6318
|
-
// RCODE_BRAIN_ALLOW_UNVERIFIED=1. Pinning to a commit SHA (source.ref) is
|
|
6319
|
-
// recommended over a moving branch — warn when a source tracks a branch.
|
|
6320
|
-
const BRAIN_ALLOWED_HOSTS = new Set(['github.com']);
|
|
6321
|
-
const BRAIN_ALLOWED_ORGS = new Set(['hanzlahabib', 'rcode-om']);
|
|
6322
|
-
if (process.env.RCODE_BRAIN_ALLOW_UNVERIFIED !== '1') {
|
|
6323
|
-
let host = '', org = '';
|
|
6324
|
-
const mm = repo.match(/(?:https?:\/\/|git@)([^/:]+)[/:]([^/]+)\//);
|
|
6325
|
-
if (mm) { host = mm[1]; org = mm[2]; }
|
|
6326
|
-
if (!BRAIN_ALLOWED_HOSTS.has(host) || !BRAIN_ALLOWED_ORGS.has(org)) {
|
|
6327
|
-
report.skipped.push({
|
|
6328
|
-
name: s.name,
|
|
6329
|
-
reason: `repo not in brain allowlist (${host || 'unknown host'}/${org || '?'}). ` +
|
|
6330
|
-
`Add the org to BRAIN_ALLOWED_ORGS or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to override.`,
|
|
6331
|
-
});
|
|
6332
|
-
continue;
|
|
6333
|
-
}
|
|
6334
|
-
if (!s.ref) {
|
|
6335
|
-
// Tracking a branch is mutable — a force-push changes what you pull.
|
|
6336
|
-
// Not fatal, but surface it so maintainers can pin a SHA via `ref:`.
|
|
6337
|
-
report.skipped.push({
|
|
6338
|
-
name: s.name,
|
|
6339
|
-
reason: `no pinned 'ref:' SHA — tracking branch '${s.branch || root.defaults.branch || 'main'}' is mutable. ` +
|
|
6340
|
-
`Pin a commit SHA in sources.yaml, or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to pull the branch tip.`,
|
|
6341
|
-
});
|
|
6342
|
-
continue;
|
|
6343
|
-
}
|
|
6344
|
-
}
|
|
6345
|
-
|
|
6346
|
-
// External git source — use sparse checkout into a tmp dir then copy.
|
|
6347
|
-
// #170 — global brain cache at ~/.rcode/brain-cache/<sha1(repo+branch+paths)>/.
|
|
6348
|
-
// Same source pulled from N projects = N clones today, 1 clone + N copies
|
|
6349
|
-
// after this change. Cache TTL is configurable per source (defaults to 6h).
|
|
6350
|
-
const { execSync, execFileSync: execFileSyncBrain } = require('child_process');
|
|
6351
|
-
const crypto = require('crypto');
|
|
6352
|
-
const os = require('os');
|
|
6353
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
|
|
6354
|
-
const branch = s.branch || cfg.defaults?.branch || 'main';
|
|
6355
|
-
const sparsePaths = Array.isArray(s.paths) ? s.paths : [];
|
|
6356
|
-
|
|
6357
|
-
// Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
|
|
6358
|
-
// those gets a fresh cache slot. Different projects pulling the same
|
|
6359
|
-
// (repo, branch, paths) tuple share one cached download.
|
|
6360
|
-
const cacheKey = crypto
|
|
6361
|
-
.createHash('sha1')
|
|
6362
|
-
.update(`${repo}\n${branch}\n${sparsePaths.sort().join(',')}`)
|
|
6363
|
-
.digest('hex')
|
|
6364
|
-
.slice(0, 16);
|
|
6365
|
-
const cacheRoot = path.join(os.homedir(), '.rcode', 'brain-cache');
|
|
6366
|
-
const cacheDir = path.join(cacheRoot, cacheKey);
|
|
6367
|
-
const cacheManifest = path.join(cacheDir, '.cache-manifest.json');
|
|
6368
|
-
|
|
6369
|
-
// Parse cache_ttl: accept '6h', '15m', '2d', or seconds as bare number.
|
|
6370
|
-
function parseTtlSeconds(raw, fallback) {
|
|
6371
|
-
if (raw == null || raw === '') return fallback;
|
|
6372
|
-
const s = String(raw).trim();
|
|
6373
|
-
const m = s.match(/^(\d+)([smhd]?)$/i);
|
|
6374
|
-
if (!m) return fallback;
|
|
6375
|
-
const n = parseInt(m[1], 10);
|
|
6376
|
-
switch ((m[2] || 's').toLowerCase()) {
|
|
6377
|
-
case 'd': return n * 86400;
|
|
6378
|
-
case 'h': return n * 3600;
|
|
6379
|
-
case 'm': return n * 60;
|
|
6380
|
-
default: return n;
|
|
6381
|
-
}
|
|
6382
|
-
}
|
|
6383
|
-
const ttlSeconds = parseTtlSeconds(s.cache_ttl || cfg.defaults?.cache_ttl, 6 * 3600);
|
|
6384
|
-
|
|
6385
|
-
function readCacheManifest() {
|
|
6386
|
-
if (!fs.existsSync(cacheManifest)) return null;
|
|
6387
|
-
try { return JSON.parse(fs.readFileSync(cacheManifest, 'utf8')); }
|
|
6388
|
-
catch { return null; }
|
|
6389
|
-
}
|
|
6390
|
-
function isCacheFresh(manifest) {
|
|
6391
|
-
if (!manifest || typeof manifest.pulled_at !== 'string') return false;
|
|
6392
|
-
const ageMs = Date.now() - Date.parse(manifest.pulled_at);
|
|
6393
|
-
return Number.isFinite(ageMs) && (ageMs / 1000) < ttlSeconds;
|
|
6394
|
-
}
|
|
6395
|
-
function copyTree(src, dst) {
|
|
6396
|
-
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
6397
|
-
if (e.name === '.git' || e.name === '.cache-manifest.json') continue;
|
|
6398
|
-
const sp = path.join(src, e.name);
|
|
6399
|
-
const dp = path.join(dst, e.name);
|
|
6400
|
-
if (e.isDirectory()) { fs.mkdirSync(dp, { recursive: true }); copyTree(sp, dp); }
|
|
6401
|
-
else if (e.isFile()) fs.copyFileSync(sp, dp);
|
|
6402
|
-
}
|
|
6403
|
-
}
|
|
6404
|
-
|
|
6405
|
-
const destPath = resolveDest(s.dest);
|
|
6406
|
-
try {
|
|
6407
|
-
// Cache hit path — copy from ~/.rcode/brain-cache/<key>/ directly.
|
|
6408
|
-
const cached = readCacheManifest();
|
|
6409
|
-
if (cached && isCacheFresh(cached)) {
|
|
6410
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6411
|
-
copyTree(cacheDir, destPath);
|
|
6412
|
-
report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'hit', cache_key: cacheKey });
|
|
6413
|
-
continue;
|
|
6414
|
-
}
|
|
6415
|
-
|
|
6416
|
-
// Cache miss — clone, then warm the cache for next time.
|
|
6417
|
-
// Use --no-checkout + explicit sparse-checkout init + set + checkout
|
|
6418
|
-
// because `git clone --sparse` combined with --filter=blob:none has
|
|
6419
|
-
// an intermittent failure mode where git misreads the URL as a path.
|
|
6420
|
-
// execFileSync — repo/branch/tmp/sparsePaths from user config; no shell so
|
|
6421
|
-
// values with spaces, quotes, or semicolons cannot inject commands (#754).
|
|
6422
|
-
execFileSyncBrain('git', [
|
|
6423
|
-
'clone', '--depth=1', '--filter=blob:none', '--no-checkout',
|
|
6424
|
-
`--branch=${branch}`, repo, tmp,
|
|
6425
|
-
], { stdio: 'pipe' });
|
|
6426
|
-
execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'init', '--no-cone'], { stdio: 'pipe' });
|
|
6427
|
-
execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'set', ...sparsePaths], { stdio: 'pipe' });
|
|
6428
|
-
execFileSyncBrain('git', ['-C', tmp, 'checkout'], { stdio: 'pipe' });
|
|
6429
|
-
|
|
6430
|
-
// Warm cache before destination copy so a copy failure to dest still
|
|
6431
|
-
// saves the next pull. Replace any stale slot atomically.
|
|
6432
|
-
try {
|
|
6433
|
-
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
6434
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
6435
|
-
copyTree(tmp, cacheDir);
|
|
6436
|
-
const commitSha = (() => {
|
|
6437
|
-
try { return execFileSyncBrain('git', ['-C', tmp, 'rev-parse', 'HEAD'], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
|
|
6438
|
-
catch { return null; }
|
|
6439
|
-
})();
|
|
6440
|
-
fs.writeFileSync(cacheManifest, JSON.stringify({
|
|
6441
|
-
repo, branch, paths: sparsePaths,
|
|
6442
|
-
pulled_at: new Date().toISOString(),
|
|
6443
|
-
commit_sha: commitSha,
|
|
6444
|
-
ttl_seconds: ttlSeconds,
|
|
6445
|
-
}, null, 2));
|
|
6446
|
-
} catch (_) { /* cache warming is best-effort */ }
|
|
6447
|
-
|
|
6448
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6449
|
-
copyTree(tmp, destPath);
|
|
6450
|
-
report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'miss', cache_key: cacheKey });
|
|
6451
|
-
} catch (e) {
|
|
6452
|
-
report.errors.push({ name: s.name, error: String(e.message || e).slice(0, 200) });
|
|
6453
|
-
} finally {
|
|
6454
|
-
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
6455
|
-
}
|
|
6456
|
-
}
|
|
6457
|
-
|
|
6458
|
-
if (report.errors.length) report.ok = false;
|
|
6459
|
-
return report;
|
|
6460
|
-
}
|
|
6461
|
-
|
|
6462
|
-
/**
|
|
6463
|
-
* cmdProgress — single pre-computed progress blob (issue #159).
|
|
6464
|
-
*
|
|
6465
|
-
* Subcommands:
|
|
6466
|
-
* progress init Full snapshot — everything /rcode-progress needs.
|
|
6467
|
-
* progress bar --raw ASCII bar only (e.g. "[████░░░░] 50%").
|
|
6468
|
-
* progress insights insights[] array (drift warnings, between-milestone detection).
|
|
6469
|
-
* progress routes intent-tree routes[] for Next Up menu.
|
|
6470
|
-
*
|
|
6471
|
-
* Pushing logic into the CLI lets the workflow file shrink to pure
|
|
6472
|
-
* rendering — no ROADMAP.md parsing, no SUMMARY.md walking, no grep.
|
|
6473
|
-
*/
|
|
6474
|
-
function cmdProgress(args) {
|
|
6475
|
-
const sub = args[0] || 'init';
|
|
6476
|
-
const rawMode = args.includes('--raw');
|
|
6477
|
-
// #200 — opt-in strict mode: exit 1 when insights contain drift/undercount.
|
|
6478
|
-
// Off by default (warning preserves the soft-surface UX). Toggle via --strict
|
|
6479
|
-
// flag or RCODE_STRICT_STATE=true env var. Used by CI / pre-deploy gates.
|
|
6480
|
-
const strictMode = args.includes('--strict')
|
|
6481
|
-
|| /^(true|1|yes)$/i.test(process.env.RCODE_STRICT_STATE || '');
|
|
6482
|
-
|
|
6483
|
-
// Resolve paths — workflow files may run this from any subdirectory.
|
|
6484
|
-
const statePath = path.join(RCODE_DIR, 'state.json');
|
|
6485
|
-
const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
|
|
6486
|
-
const phasesDir = path.join(PLANNING_DIR, 'phases');
|
|
6487
|
-
|
|
6488
|
-
function readState() {
|
|
6489
|
-
if (!fs.existsSync(statePath)) return null;
|
|
6490
|
-
try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
6491
|
-
catch { return null; }
|
|
6492
|
-
}
|
|
6493
|
-
|
|
6494
|
-
function parseRoadmapPhases() {
|
|
6495
|
-
if (!fs.existsSync(roadmapPath)) return [];
|
|
6496
|
-
const text = fs.readFileSync(roadmapPath, 'utf8');
|
|
6497
|
-
const phases = [];
|
|
6498
|
-
const seen = new Set();
|
|
6499
|
-
|
|
6500
|
-
// Format A — markdown pipe tables: | 07 | Name | Goal |
|
|
6501
|
-
// Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
|
|
6502
|
-
const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
|
|
6503
|
-
let m;
|
|
6504
|
-
while ((m = rowRe.exec(text)) !== null) {
|
|
6505
|
-
const num = m[1].trim();
|
|
6506
|
-
const name = m[2].trim();
|
|
6507
|
-
const goal = m[3].trim();
|
|
6508
|
-
if (!/^\d/.test(num)) continue;
|
|
6509
|
-
if (name.toLowerCase() === 'phase') continue;
|
|
6510
|
-
if (seen.has(num)) continue;
|
|
6511
|
-
seen.add(num);
|
|
6512
|
-
phases.push({ number: num, name, goal });
|
|
6513
|
-
}
|
|
6514
|
-
|
|
6515
|
-
// Format B — heading style: ## Phase 07 — Name / ### Phase 07: Name / ## Phase 07 - Name
|
|
6516
|
-
// Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
|
|
6517
|
-
const headRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
|
|
6518
|
-
while ((m = headRe.exec(text)) !== null) {
|
|
6519
|
-
const num = m[1].trim();
|
|
6520
|
-
const name = m[2].trim();
|
|
6521
|
-
if (seen.has(num)) continue;
|
|
6522
|
-
seen.add(num);
|
|
6523
|
-
// Goal: pull the first non-empty line after the heading that starts with **Goal:** or is plain text
|
|
6524
|
-
const after = text.slice(headRe.lastIndex).split(/\n/).slice(0, 8).join('\n');
|
|
6525
|
-
const goalMatch = after.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
|
|
6526
|
-
phases.push({ number: num, name, goal: goalMatch ? goalMatch[1].trim() : '' });
|
|
6527
|
-
}
|
|
6528
|
-
|
|
6529
|
-
// Sort numerically (handles "07" vs "10" string ordering correctly)
|
|
6530
|
-
phases.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
|
6531
|
-
return phases;
|
|
6532
|
-
}
|
|
6533
|
-
|
|
6534
|
-
function extractMilestoneName() {
|
|
6535
|
-
// 1. Try ROADMAP.md headings — match any milestone header form
|
|
6536
|
-
if (fs.existsSync(roadmapPath)) {
|
|
6537
|
-
const text = fs.readFileSync(roadmapPath, 'utf8');
|
|
6538
|
-
// Bold form: **Milestone: v1.0 — Name** or **Milestone v1.0 — Name**
|
|
6539
|
-
let m = text.match(/\*\*\s*Milestone\s*:?\s*([^\n*]+?)\s*\*\*/i);
|
|
6540
|
-
if (m) return m[1].trim();
|
|
6541
|
-
// Header form: ## Milestone v1.0 — Name / ## Milestone: v1.0 — Name
|
|
6542
|
-
m = text.match(/^#{1,4}\s+Milestone\s*:?\s*([^\n]+)$/m);
|
|
6543
|
-
if (m) return m[1].trim();
|
|
6544
|
-
}
|
|
6545
|
-
// 2. Fall back to state.json milestone field
|
|
6546
|
-
try {
|
|
6547
|
-
if (fs.existsSync(statePath)) {
|
|
6548
|
-
const s = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
6549
|
-
if (s && s.milestone) return String(s.milestone).trim();
|
|
6550
|
-
}
|
|
6551
|
-
} catch { /* ignore */ }
|
|
6552
|
-
return null;
|
|
6553
|
-
}
|
|
6554
|
-
|
|
6555
|
-
// Treat any of `number`, `id`, or `name` as the phase identifier.
|
|
6556
|
-
// Different commands historically write different field names — accept all.
|
|
6557
|
-
function phaseKey(p) {
|
|
6558
|
-
return String(p?.number ?? p?.id ?? p?.name ?? '').trim();
|
|
6559
|
-
}
|
|
6560
|
-
|
|
6561
|
-
function walkPhaseDirs() {
|
|
6562
|
-
if (!fs.existsSync(phasesDir)) return {};
|
|
6563
|
-
const byNum = {};
|
|
6564
|
-
for (const entry of fs.readdirSync(phasesDir)) {
|
|
6565
|
-
const full = path.join(phasesDir, entry);
|
|
6566
|
-
if (!fs.statSync(full).isDirectory()) continue;
|
|
6567
|
-
// Phase 14 / #476 — \d+ supports high-N phase dirs (1000+).
|
|
6568
|
-
const numMatch = entry.match(/^(\d+(?:\.\d+)?)/);
|
|
6569
|
-
if (!numMatch) continue;
|
|
6570
|
-
const num = numMatch[1];
|
|
6571
|
-
const files = fs.readdirSync(full);
|
|
6572
|
-
byNum[num] = {
|
|
6573
|
-
path: full,
|
|
6574
|
-
dirName: entry,
|
|
6575
|
-
plan_count: files.filter(f => /-SPRINT\.md$/i.test(f)).length,
|
|
6576
|
-
summary_count: files.filter(f => /SUMMARY\.md$|-SUMMARY\.md$/.test(f)).length,
|
|
6577
|
-
has_research: files.includes('RESEARCH.md'),
|
|
6578
|
-
has_context: files.includes('CONTEXT.md'),
|
|
6579
|
-
has_verification: files.some(f => /VERIFICATION\.md$/i.test(f)),
|
|
6580
|
-
};
|
|
6581
|
-
}
|
|
6582
|
-
return byNum;
|
|
6583
|
-
}
|
|
6584
|
-
|
|
6585
|
-
// #200 — opt-in strict gate. Walks insights for drift/undercount kinds and
|
|
6586
|
-
// exits 1 with the failure list to stderr. No-op when strictMode=false.
|
|
6587
|
-
function enforceStrictGate(insightsList) {
|
|
6588
|
-
if (!strictMode) return;
|
|
6589
|
-
const blocking = (insightsList || []).filter(i =>
|
|
6590
|
-
i && (i.kind === 'drift' || i.kind === 'undercount') && i.severity !== 'info'
|
|
6591
|
-
);
|
|
6592
|
-
if (blocking.length === 0) return;
|
|
6593
|
-
process.stderr.write('✖ State drift detected — state.json is out of sync with disk.\n');
|
|
6594
|
-
for (const i of blocking) process.stderr.write(` • ${i.message}\n`);
|
|
6595
|
-
process.stderr.write('\n Auto-fix: node .rcode/bin/rcode-tools.cjs state sync --from-disk\n');
|
|
6596
|
-
process.stderr.write(' Inspect: node .rcode/bin/rcode-tools.cjs state read\n');
|
|
6597
|
-
process.exit(1);
|
|
6598
|
-
}
|
|
6599
|
-
|
|
6600
|
-
function detectInsights(state, roadmapPhases, diskByNum) {
|
|
6601
|
-
const insights = [];
|
|
6602
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6603
|
-
|
|
6604
|
-
// Drift: ROADMAP phase count vs state.json phase count
|
|
6605
|
-
if (roadmapPhases.length > 0 && statePhases.length !== roadmapPhases.length) {
|
|
6606
|
-
insights.push({
|
|
6607
|
-
kind: 'drift',
|
|
6608
|
-
severity: 'warn',
|
|
6609
|
-
message: `ROADMAP.md has ${roadmapPhases.length} phases, state.json has ${statePhases.length}. Run: node .rcode/bin/rcode-tools.cjs state sync --from-disk`,
|
|
6610
|
-
});
|
|
6611
|
-
}
|
|
6612
|
-
|
|
6613
|
-
// Undercount: phases that exist on disk but not in state.
|
|
6614
|
-
// Accept any of `number`, `id`, or `name` as the phase identifier — the codebase historically writes different fields.
|
|
6615
|
-
// Also normalize "07" / "7" / 7 to a comparable form.
|
|
6616
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6617
|
-
const statePhaseNums = new Set(statePhases.map(p => norm(phaseKey(p))));
|
|
6618
|
-
const diskPhaseNums = Object.keys(diskByNum);
|
|
6619
|
-
const missingFromState = diskPhaseNums.filter(n => !statePhaseNums.has(norm(n)));
|
|
6620
|
-
if (missingFromState.length > 0) {
|
|
6621
|
-
insights.push({
|
|
6622
|
-
kind: 'undercount',
|
|
6623
|
-
severity: 'warn',
|
|
6624
|
-
message: `${missingFromState.length} phase dir(s) on disk not registered in state.json: ${missingFromState.slice(0, 5).join(', ')}`,
|
|
6625
|
-
});
|
|
6626
|
-
}
|
|
6627
|
-
|
|
6628
|
-
// Phantom-complete: phase claimed Complete (in ROADMAP or state) but missing
|
|
6629
|
-
// PLAN.md AND SUMMARY.md on disk. User-visible bug: /rcode-status would
|
|
6630
|
-
// happily report 'all complete' while /rcode-audit correctly flagged the
|
|
6631
|
-
// gap because the two read different sources of truth.
|
|
6632
|
-
// Surfaced 2026-04-29 in a real session — siraaj phases 07-12 had ROADMAP
|
|
6633
|
-
// markers but zero artifacts.
|
|
6634
|
-
const phantomCompletes = [];
|
|
6635
|
-
const claimedComplete = (p) => {
|
|
6636
|
-
if (!p) return false;
|
|
6637
|
-
const s = String(p.status ?? '').toLowerCase();
|
|
6638
|
-
return p.completed || s === 'complete' || s === 'completed' || s === 'done';
|
|
6639
|
-
};
|
|
6640
|
-
// Walk ROADMAP-claimed completes and state-claimed completes, both directions.
|
|
6641
|
-
const completeKeys = new Set();
|
|
6642
|
-
for (const p of roadmapPhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
|
|
6643
|
-
for (const p of statePhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
|
|
6644
|
-
for (const k of completeKeys) {
|
|
6645
|
-
const disk = diskByNum[k] || diskByNum[k.padStart(2, '0')];
|
|
6646
|
-
// Only flag when the phase dir EXISTS — purely-state-only entries are a
|
|
6647
|
-
// separate problem (drift/undercount above). Here we want claim-vs-files.
|
|
6648
|
-
if (!disk) continue;
|
|
6649
|
-
if (disk.plan_count === 0 && disk.summary_count === 0) {
|
|
6650
|
-
phantomCompletes.push(k);
|
|
6651
|
-
}
|
|
6652
|
-
}
|
|
6653
|
-
if (phantomCompletes.length > 0) {
|
|
6654
|
-
insights.push({
|
|
6655
|
-
kind: 'phantom-complete',
|
|
6656
|
-
severity: 'warn',
|
|
6657
|
-
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.`,
|
|
6658
|
-
});
|
|
6659
|
-
}
|
|
6660
|
-
|
|
6661
|
-
// Between-milestones heuristic: no current_phase + previous milestone's last phase is complete
|
|
6662
|
-
if (state && state.current_phase === null && statePhases.length > 0) {
|
|
6663
|
-
const allComplete = statePhases.every(p => p.status === 'complete' || p.completed);
|
|
6664
|
-
if (allComplete) {
|
|
6665
|
-
insights.push({
|
|
6666
|
-
kind: 'between-milestones',
|
|
6667
|
-
severity: 'info',
|
|
6668
|
-
message: 'All registered phases complete — effectively between milestones. Consider /rcode-audit-milestone or /rcode-new-milestone.',
|
|
6669
|
-
});
|
|
6670
|
-
}
|
|
6671
|
-
}
|
|
6672
|
-
|
|
6673
|
-
// Stuck-phase: in_progress phase with no commits touching its .planning dir in 7+ days
|
|
6674
|
-
try {
|
|
6675
|
-
const inProgressPhases = statePhases.filter(p => {
|
|
6676
|
-
const s = String(p.status ?? '').toLowerCase();
|
|
6677
|
-
return s === 'in_progress' || s === 'in-progress' || s === 'executing';
|
|
6678
|
-
});
|
|
6679
|
-
for (const p of inProgressPhases) {
|
|
6680
|
-
const key = norm(phaseKey(p));
|
|
6681
|
-
const disk = diskByNum[key] || diskByNum[key.padStart(2, '0')];
|
|
6682
|
-
if (!disk) continue;
|
|
6683
|
-
const dirName = disk.dirName;
|
|
6684
|
-
const gitArgs = ['log', '--oneline', '--since=7 days ago', '--', `.planning/phases/${dirName}/`];
|
|
6685
|
-
let recentCommits = '';
|
|
6686
|
-
try {
|
|
6687
|
-
recentCommits = require('child_process').execSync(
|
|
6688
|
-
`git ${gitArgs.join(' ')}`,
|
|
6689
|
-
{ cwd: PROJECT_ROOT, stdio: 'pipe', timeout: 5000 }
|
|
6690
|
-
).toString().trim();
|
|
6691
|
-
} catch { /* git not available or no history */ }
|
|
6692
|
-
if (recentCommits === '') {
|
|
6693
|
-
insights.push({
|
|
6694
|
-
kind: 'stuck-phase',
|
|
6695
|
-
severity: 'warn',
|
|
6696
|
-
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.`,
|
|
6697
|
-
});
|
|
6698
|
-
}
|
|
6699
|
-
}
|
|
6700
|
-
} catch { /* non-fatal — git unavailable or project root not set */ }
|
|
6701
|
-
|
|
6702
|
-
return insights;
|
|
6703
|
-
}
|
|
6704
|
-
|
|
6705
|
-
function deriveRoutes(state, roadmapPhases, diskByNum, insights) {
|
|
6706
|
-
const routes = [];
|
|
6707
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6708
|
-
|
|
6709
|
-
// Route A — phases with pending plans (ready to execute).
|
|
6710
|
-
// Issue #653 — never recommend executing a phase whose state.json status
|
|
6711
|
-
// is already complete/done/verified, even if its on-disk plan_count >
|
|
6712
|
-
// summary_count. Missing second summary file is not the canonical
|
|
6713
|
-
// completion signal; state.json is. Run /rcode-audit phase <N> for
|
|
6714
|
-
// disk-vs-state drift, but stop steering users into re-executing
|
|
6715
|
-
// finished work.
|
|
6716
|
-
const isPhaseDone = (p) => {
|
|
6717
|
-
const s = String((p && p.status) || '').toLowerCase();
|
|
6718
|
-
return s === 'complete' || s === 'completed' || s === 'done' || s === 'verified' || Boolean(p && p.completed);
|
|
6719
|
-
};
|
|
6720
|
-
const pendingExec = statePhases.filter(p => {
|
|
6721
|
-
if (isPhaseDone(p)) return false;
|
|
6722
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6723
|
-
return disk && disk.plan_count > disk.summary_count;
|
|
6724
|
-
}).slice(0, 3);
|
|
6725
|
-
for (const p of pendingExec) {
|
|
6726
|
-
const k = phaseKey(p);
|
|
6727
|
-
routes.push({ letter: 'A', label: '', command: `/rcode-execute ${k}` });
|
|
6728
|
-
}
|
|
6729
|
-
|
|
6730
|
-
// Route B — phases with research but no plans
|
|
6731
|
-
const researchOnly = Object.entries(diskByNum)
|
|
6732
|
-
.filter(([num, d]) => d.has_research && d.plan_count === 0)
|
|
6733
|
-
.slice(0, 3);
|
|
6734
|
-
for (const [num] of researchOnly) {
|
|
6735
|
-
routes.push({ letter: 'B', label: '', command: `/rcode-plan ${num}` });
|
|
6736
|
-
}
|
|
6737
|
-
|
|
6738
|
-
// Route B' — in-progress phases without plans
|
|
6739
|
-
const inProgressNoPlan = statePhases
|
|
6740
|
-
.filter(p => (p.status === 'in_progress' || p.status === 'in-progress'))
|
|
6741
|
-
.filter(p => {
|
|
6742
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6743
|
-
return !disk || disk.plan_count === 0;
|
|
6744
|
-
})
|
|
6745
|
-
.slice(0, 2);
|
|
6746
|
-
for (const p of inProgressNoPlan) {
|
|
6747
|
-
const k = phaseKey(p);
|
|
6748
|
-
routes.push({ letter: 'B', label: '', command: `/rcode-plan ${k}` });
|
|
6749
|
-
}
|
|
6750
|
-
|
|
6751
|
-
// Route C — close out milestone if everything seems done
|
|
6752
|
-
const allDone = statePhases.length > 0 && statePhases.every(p => p.status === 'complete' || p.completed);
|
|
6753
|
-
if (allDone) {
|
|
6754
|
-
// Count unverified phases (complete but no VERIFICATION.md on disk)
|
|
6755
|
-
const unverifiedCount = statePhases.filter(p => {
|
|
6756
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6757
|
-
return (p.status === 'complete' || p.completed) && disk && !disk.has_verification;
|
|
6758
|
-
}).length;
|
|
6759
|
-
const hasDrift = (insights || []).some(i => i.kind === 'roadmap-drift' || (i.message && i.message.includes('ROADMAP')));
|
|
6760
|
-
const auditArgs = [];
|
|
6761
|
-
if (unverifiedCount > 0) auditArgs.push(String(unverifiedCount));
|
|
6762
|
-
if (hasDrift) auditArgs.push('--fix-drift');
|
|
6763
|
-
const auditCmd = auditArgs.length > 0
|
|
6764
|
-
? `/rcode-audit-milestone ${auditArgs.join(' ')}`
|
|
6765
|
-
: '/rcode-audit-milestone';
|
|
6766
|
-
routes.push({ letter: 'C', label: '', command: auditCmd });
|
|
6767
|
-
routes.push({ letter: 'C', label: '', command: '/rcode-complete-milestone' });
|
|
6768
|
-
}
|
|
6769
|
-
|
|
6770
|
-
// Fallback — nothing obvious: offer status
|
|
6771
|
-
if (routes.length === 0) {
|
|
6772
|
-
routes.push({ letter: 'A', label: '', command: '/rcode-progress' });
|
|
6773
|
-
routes.push({ letter: 'B', label: '', command: '/rcode-council' });
|
|
6774
|
-
}
|
|
6775
|
-
|
|
6776
|
-
return routes;
|
|
6777
|
-
}
|
|
6778
|
-
|
|
6779
|
-
function buildBar(completed, total) {
|
|
6780
|
-
if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
|
|
6781
|
-
const pct = Math.round((completed / total) * 100);
|
|
6782
|
-
const width = 20;
|
|
6783
|
-
const filled = Math.min(width, Math.round((completed / total) * width));
|
|
6784
|
-
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
6785
|
-
return `[${bar}] ${completed}/${total} (${pct}%)`;
|
|
6786
|
-
}
|
|
6787
|
-
|
|
6788
|
-
/**
|
|
6789
|
-
* Compute weighted progress that recognizes intermediate phase states.
|
|
6790
|
-
* Weights: has_context only = 0.15, has_research = 0.25, has plan = 0.5,
|
|
6791
|
-
* has verification or summary = 1.0.
|
|
6792
|
-
* Returns { weighted: number (0..total), pct: number (0..100) }.
|
|
6793
|
-
*/
|
|
6794
|
-
function computeWeightedProgress(stPhases, diskMap) {
|
|
6795
|
-
if (!stPhases.length) return { weighted: 0, pct: 0 };
|
|
6796
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6797
|
-
let sum = 0;
|
|
6798
|
-
for (const p of stPhases) {
|
|
6799
|
-
const k = norm(phaseKey(p));
|
|
6800
|
-
if (p.status === 'complete' || p.completed) { sum += 1; continue; }
|
|
6801
|
-
const disk = diskMap[k] || diskMap[phaseKey(p)];
|
|
6802
|
-
if (!disk) continue;
|
|
6803
|
-
if (disk.summary_count > 0) { sum += 1; continue; }
|
|
6804
|
-
if (disk.has_verification) { sum += 0.85; continue; }
|
|
6805
|
-
if (disk.plan_count > 0) { sum += 0.5; continue; }
|
|
6806
|
-
if (disk.has_research) { sum += 0.25; continue; }
|
|
6807
|
-
if (disk.has_context) { sum += 0.15; continue; }
|
|
6808
|
-
}
|
|
6809
|
-
const total = Math.max(stPhases.length, 1);
|
|
6810
|
-
return { weighted: Math.round(sum * 100) / 100, pct: Math.round((sum / total) * 100) };
|
|
6811
|
-
}
|
|
6812
|
-
|
|
6813
|
-
function buildWeightedBar(stPhases, diskMap, total) {
|
|
6814
|
-
const { weighted, pct } = computeWeightedProgress(stPhases, diskMap);
|
|
6815
|
-
if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
|
|
6816
|
-
const width = 20;
|
|
6817
|
-
const filled = Math.min(width, Math.round((weighted / total) * width));
|
|
6818
|
-
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
6819
|
-
return `[${bar}] ~${pct}% weighted`;
|
|
6820
|
-
}
|
|
6821
|
-
|
|
6822
|
-
// Build the core snapshot once — all subcommands derive from it.
|
|
6823
|
-
const state = readState();
|
|
6824
|
-
const roadmapPhases = parseRoadmapPhases();
|
|
6825
|
-
const diskByNum = walkPhaseDirs();
|
|
6826
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6827
|
-
const completedCount = statePhases.filter(p => p.status === 'complete' || p.completed).length;
|
|
6828
|
-
const phaseCount = Math.max(statePhases.length, roadmapPhases.length);
|
|
6829
|
-
|
|
6830
|
-
if (sub === 'bar') {
|
|
6831
|
-
const bar = buildBar(completedCount, phaseCount);
|
|
6832
|
-
if (rawMode) { console.log(bar); process.exit(0); }
|
|
6833
|
-
return { ok: true, bar, completed: completedCount, total: phaseCount };
|
|
6834
|
-
}
|
|
6835
|
-
|
|
6836
|
-
if (sub === 'insights') {
|
|
6837
|
-
const insightsList = detectInsights(state, roadmapPhases, diskByNum);
|
|
6838
|
-
enforceStrictGate(insightsList);
|
|
6839
|
-
return { ok: true, insights: insightsList };
|
|
6840
|
-
}
|
|
6841
|
-
|
|
6842
|
-
if (sub === 'routes') {
|
|
6843
|
-
const routeInsights = detectInsights(state, roadmapPhases, diskByNum);
|
|
6844
|
-
return { ok: true, routes: deriveRoutes(state, roadmapPhases, diskByNum, routeInsights) };
|
|
6845
|
-
}
|
|
6846
|
-
|
|
6847
|
-
// sub === 'init' (default) — full snapshot
|
|
6848
|
-
const currentPhase = state && state.current_phase;
|
|
6849
|
-
const insights = detectInsights(state, roadmapPhases, diskByNum);
|
|
6850
|
-
enforceStrictGate(insights);
|
|
6851
|
-
const routes = deriveRoutes(state, roadmapPhases, diskByNum, insights);
|
|
6852
|
-
const { weighted: weightedCompleted, pct: weightedPct } = computeWeightedProgress(statePhases, diskByNum);
|
|
6853
|
-
|
|
6854
|
-
return {
|
|
6855
|
-
ok: true,
|
|
6856
|
-
project: state && state.project,
|
|
6857
|
-
milestone: extractMilestoneName(),
|
|
6858
|
-
current_phase: currentPhase,
|
|
6859
|
-
phase_count: phaseCount,
|
|
6860
|
-
completed_count: completedCount,
|
|
6861
|
-
weighted_progress: weightedPct,
|
|
6862
|
-
bar: buildBar(completedCount, phaseCount),
|
|
6863
|
-
weighted_bar: buildWeightedBar(statePhases, diskByNum, phaseCount),
|
|
6864
|
-
phases: (() => {
|
|
6865
|
-
// Prefer ROADMAP-parsed phases when available; fall back to state.phases
|
|
6866
|
-
// when the roadmap doesn't use a parseable format. Normalize "07" / "7" / 7.
|
|
6867
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6868
|
-
const source = roadmapPhases.length > 0 ? roadmapPhases : statePhases.map(p => ({
|
|
6869
|
-
number: phaseKey(p),
|
|
6870
|
-
name: p.name || '',
|
|
6871
|
-
goal: p.goal || '',
|
|
6872
|
-
status: p.status,
|
|
6873
|
-
}));
|
|
6874
|
-
return source.map(p => {
|
|
6875
|
-
const k = phaseKey(p);
|
|
6876
|
-
const sp = statePhases.find(x => norm(phaseKey(x)) === norm(k));
|
|
6877
|
-
return {
|
|
6878
|
-
...p,
|
|
6879
|
-
number: k,
|
|
6880
|
-
status: p.status || (sp && sp.status) || null,
|
|
6881
|
-
disk: diskByNum[k] || null,
|
|
6882
|
-
in_state: !!sp,
|
|
6883
|
-
};
|
|
6884
|
-
});
|
|
6885
|
-
})(),
|
|
6886
|
-
decisions: state ? (state.decisions || []).slice(-3) : [],
|
|
6887
|
-
blockers: state ? (state.blockers || []).filter(b => !b.resolved).slice(0, 5) : [],
|
|
6888
|
-
insights,
|
|
6889
|
-
routes,
|
|
6890
|
-
updated: state && state.updated,
|
|
6891
|
-
};
|
|
6892
|
-
}
|
|
6893
|
-
|
|
6894
|
-
/**
|
|
6895
|
-
* cmdSummaryExtract — surgically pull named fields from a SUMMARY.md.
|
|
6896
|
-
* Avoids whole-file loads when the caller only wants one or two headings.
|
|
6897
|
-
* Usage: summary-extract <path> --fields one_liner,status
|
|
6898
|
-
*/
|
|
6899
|
-
function cmdSummaryExtract(args) {
|
|
6900
|
-
const filePath = args[0];
|
|
6901
|
-
const fieldsFlag = args.indexOf('--fields');
|
|
6902
|
-
const fields = fieldsFlag >= 0 ? (args[fieldsFlag + 1] || '').split(',').map(s => s.trim()).filter(Boolean) : ['one_liner'];
|
|
6903
|
-
|
|
6904
|
-
if (!filePath) return { ok: false, error: 'Usage: summary-extract <path> [--fields a,b,c]' };
|
|
6905
|
-
if (!fs.existsSync(filePath)) return { ok: false, error: `file not found: ${filePath}` };
|
|
6906
|
-
|
|
6907
|
-
const text = fs.readFileSync(filePath, 'utf8');
|
|
6908
|
-
const out = { ok: true, path: filePath };
|
|
6909
|
-
|
|
6910
|
-
const fieldToPatterns = {
|
|
6911
|
-
one_liner: [/^##\s+One[-\s]?liner\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Summary\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6912
|
-
status: [/^##\s+Status\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^status:\s*(.+)$/im],
|
|
6913
|
-
outcomes: [/^##\s+Outcomes?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6914
|
-
decisions: [/^##\s+Decisions?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6915
|
-
blockers: [/^##\s+Blockers?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6916
|
-
followups: [/^##\s+Follow[-\s]?ups?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Next[-\s]?steps?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6917
|
-
};
|
|
6918
6233
|
|
|
6919
|
-
for (const f of fields) {
|
|
6920
|
-
const patterns = fieldToPatterns[f] || [new RegExp(`^##\\s+${f.replace(/_/g, '[ _-]?')}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n---|$)`, 'im')];
|
|
6921
|
-
let value = null;
|
|
6922
|
-
for (const re of patterns) {
|
|
6923
|
-
const m = text.match(re);
|
|
6924
|
-
if (m && m[1]) { value = m[1].trim().split('\n').map(l => l.trim()).filter(Boolean).join('\n'); break; }
|
|
6925
|
-
}
|
|
6926
|
-
// Fallback for one_liner: first non-empty paragraph after H1
|
|
6927
|
-
if (f === 'one_liner' && !value) {
|
|
6928
|
-
const afterH1 = text.replace(/^#[^\n]*\n/, '');
|
|
6929
|
-
const firstPara = afterH1.match(/^[^\n#][^\n]*(?:\n(?!\n)[^\n#][^\n]*)*/m);
|
|
6930
|
-
if (firstPara) value = firstPara[0].trim();
|
|
6931
|
-
}
|
|
6932
|
-
out[f] = value;
|
|
6933
|
-
}
|
|
6934
|
-
|
|
6935
|
-
return out;
|
|
6936
|
-
}
|
|
6937
|
-
|
|
6938
|
-
/**
|
|
6939
|
-
* cmdStateSnapshot — compact, display-friendly state extract.
|
|
6940
|
-
* Hides internal machinery (lock metadata, full history) from callers
|
|
6941
|
-
* that only need a render-ready summary.
|
|
6942
|
-
*/
|
|
6943
6234
|
/**
|
|
6944
6235
|
* cmdProjectStatus — classify project lifecycle state into one of:
|
|
6945
6236
|
* uninstalled — no .rcode/config.yaml
|
|
@@ -7226,142 +6517,31 @@ function cmdMilestoneHealth() {
|
|
|
7226
6517
|
};
|
|
7227
6518
|
}
|
|
7228
6519
|
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
return {
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
phase_count: (state.phases || []).length,
|
|
7243
|
-
decisions_count: (state.decisions || []).length,
|
|
7244
|
-
blockers_open: (state.blockers || []).filter(b => !b.resolved).length,
|
|
7245
|
-
last_session: state.last_session,
|
|
7246
|
-
updated: state.updated,
|
|
7247
|
-
active_workstream: state.active_workstream,
|
|
6520
|
+
// #942 — build a milestone-health summary + human-readable nudge for any
|
|
6521
|
+
// phase-adding code path (single add, bulk draft, plan, insert) so the
|
|
6522
|
+
// "milestone has too many open phases" guidance can't be bypassed by adding
|
|
6523
|
+
// phases outside the add-phase workflow. Returns { milestone_health, nudge }.
|
|
6524
|
+
function milestoneCloseNudge() {
|
|
6525
|
+
let h;
|
|
6526
|
+
try { h = cmdMilestoneHealth(); } catch { return { milestone_health: null, nudge: null }; }
|
|
6527
|
+
if (!h || !h.ok) return { milestone_health: null, nudge: null };
|
|
6528
|
+
const summary = {
|
|
6529
|
+
open_phases: h.open_phases,
|
|
6530
|
+
recommendation: h.recommendation,
|
|
6531
|
+
threshold_should: h.threshold_should,
|
|
6532
|
+
threshold_consider: h.threshold_consider,
|
|
7248
6533
|
};
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
* by convention. Any change to the block format should update both.
|
|
7261
|
-
* Closes #189 — runtime toggle for commit_planning.
|
|
7262
|
-
*/
|
|
7263
|
-
function cmdGitignore(args) {
|
|
7264
|
-
const sub = args[0] || 'refresh';
|
|
7265
|
-
const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
|
|
7266
|
-
const configPath = path.join(RCODE_DIR, 'config.yaml');
|
|
7267
|
-
|
|
7268
|
-
// Read commit_planning from config; default true if missing.
|
|
7269
|
-
let commitPlanning = true;
|
|
7270
|
-
if (fs.existsSync(configPath)) {
|
|
7271
|
-
const cfg = fs.readFileSync(configPath, 'utf8');
|
|
7272
|
-
const m = cfg.match(/^\s*commit_planning:\s*(true|false)\s*$/m);
|
|
7273
|
-
if (m) commitPlanning = (m[1] === 'true');
|
|
7274
|
-
}
|
|
7275
|
-
|
|
7276
|
-
const BEGIN = '# ===== rcode-managed gitignore block (npx @hanzlaa/rcode install) =====';
|
|
7277
|
-
const END = '# ===== end rcode-managed gitignore block =====';
|
|
7278
|
-
|
|
7279
|
-
if (sub === 'status') {
|
|
7280
|
-
const exists = fs.existsSync(gitignorePath);
|
|
7281
|
-
const hasBlock = exists && fs.readFileSync(gitignorePath, 'utf8').includes(BEGIN);
|
|
7282
|
-
return {
|
|
7283
|
-
ok: true,
|
|
7284
|
-
gitignore_exists: exists,
|
|
7285
|
-
block_present: hasBlock,
|
|
7286
|
-
commit_planning: commitPlanning,
|
|
7287
|
-
};
|
|
7288
|
-
}
|
|
7289
|
-
|
|
7290
|
-
if (sub !== 'refresh') {
|
|
7291
|
-
return { ok: false, error: `Unknown gitignore subcommand: ${sub}. Try: refresh | status` };
|
|
7292
|
-
}
|
|
7293
|
-
|
|
7294
|
-
const lines = [
|
|
7295
|
-
'',
|
|
7296
|
-
BEGIN,
|
|
7297
|
-
'# Added automatically on rcode install. Idempotent — safe to re-run.',
|
|
7298
|
-
'# Edit `commit_planning` in .rcode/config.yaml, then: rcode-tools gitignore refresh',
|
|
7299
|
-
'',
|
|
7300
|
-
'# Installed methodology files (regenerate with: npx @hanzlaa/rcode install)',
|
|
7301
|
-
'.claude/',
|
|
7302
|
-
'.rcode/bin/',
|
|
7303
|
-
'.rcode/workflows/',
|
|
7304
|
-
'.rcode/references/',
|
|
7305
|
-
'.rcode/commands/',
|
|
7306
|
-
'.rcode/skills/',
|
|
7307
|
-
'',
|
|
7308
|
-
'# Pulled rcode brain content (refresh with: rcode brain pull)',
|
|
7309
|
-
'.rcode/brain/rcode-github/',
|
|
7310
|
-
'.rcode/brain/rcode-docs/',
|
|
7311
|
-
'.rcode/brain/best-practices/',
|
|
7312
|
-
'',
|
|
7313
|
-
'# Runtime noise',
|
|
7314
|
-
'node_modules/',
|
|
7315
|
-
'.rcode/state.json.lock',
|
|
7316
|
-
'.planning/debug/',
|
|
7317
|
-
'.planning/_backup/',
|
|
7318
|
-
];
|
|
7319
|
-
if (!commitPlanning) {
|
|
7320
|
-
lines.push('', '# Planning artifacts — kept local (commit_planning: false)', '.planning/');
|
|
7321
|
-
}
|
|
7322
|
-
lines.push(
|
|
7323
|
-
'',
|
|
7324
|
-
'# What you DO commit:',
|
|
7325
|
-
'# .rcode/config.yaml - project mode/language/profile/commit_planning',
|
|
7326
|
-
'# .rcode/state.json - decisions, roadmap pointer, blockers',
|
|
7327
|
-
'# .rcode/brain/sources.yaml - brain source manifest',
|
|
7328
|
-
commitPlanning
|
|
7329
|
-
? '# .planning/ - PRD, roadmap, sprints, SUMMARY.md files'
|
|
7330
|
-
: '# (planning artifacts are NOT committed — see commit_planning in config)',
|
|
7331
|
-
END,
|
|
7332
|
-
''
|
|
7333
|
-
);
|
|
7334
|
-
const BLOCK = lines.join('\n');
|
|
7335
|
-
|
|
7336
|
-
/** Replace the rcode block in text using indexOf — safer than regex. */
|
|
7337
|
-
function spliceBlock(existing, newBlock) {
|
|
7338
|
-
const start = existing.indexOf(BEGIN);
|
|
7339
|
-
if (start < 0) return null;
|
|
7340
|
-
const endIdx = existing.indexOf(END, start);
|
|
7341
|
-
if (endIdx < 0) return null;
|
|
7342
|
-
// Include trailing newline after END if present, and leading newline before BEGIN.
|
|
7343
|
-
let sliceStart = start;
|
|
7344
|
-
if (sliceStart > 0 && existing[sliceStart - 1] === '\n') sliceStart -= 1;
|
|
7345
|
-
let sliceEnd = endIdx + END.length;
|
|
7346
|
-
if (existing[slice_end] === '\n') slice_end += 1;
|
|
7347
|
-
return existing.slice(0, sliceStart) + newBlock + existing.slice(slice_end);
|
|
7348
|
-
}
|
|
7349
|
-
|
|
7350
|
-
if (!fs.existsSync(gitignorePath)) {
|
|
7351
|
-
fs.writeFileSync(gitignorePath, BLOCK);
|
|
7352
|
-
return { ok: true, action: 'created', commit_planning: commitPlanning };
|
|
7353
|
-
}
|
|
7354
|
-
const existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
7355
|
-
if (existing.includes(BEGIN)) {
|
|
7356
|
-
const rewritten = spliceBlock(existing, BLOCK);
|
|
7357
|
-
if (rewritten !== null && rewritten !== existing) {
|
|
7358
|
-
fs.writeFileSync(gitignorePath, rewritten);
|
|
7359
|
-
return { ok: true, action: 'updated', commit_planning: commitPlanning };
|
|
7360
|
-
}
|
|
7361
|
-
return { ok: true, action: 'no-change', commit_planning: commitPlanning };
|
|
7362
|
-
}
|
|
7363
|
-
fs.writeFileSync(gitignorePath, existing + BLOCK);
|
|
7364
|
-
return { ok: true, action: 'appended', commit_planning: commitPlanning };
|
|
6534
|
+
let nudge = null;
|
|
6535
|
+
if (h.recommendation === 'should-close') {
|
|
6536
|
+
nudge = `Milestone "${h.milestone || 'current'}" has ${h.open_phases} open phases ` +
|
|
6537
|
+
`(≥${h.threshold_should}). Consider /rcode-complete-milestone to archive done ` +
|
|
6538
|
+
`phases, then /rcode-new-milestone for ongoing work — before adding more.`;
|
|
6539
|
+
} else if (h.recommendation === 'consider-closing') {
|
|
6540
|
+
nudge = `Milestone "${h.milestone || 'current'}" has ${h.open_phases} open phases ` +
|
|
6541
|
+
`(≥${h.threshold_consider}). Getting large — /rcode-complete-milestone + ` +
|
|
6542
|
+
`/rcode-new-milestone will keep the roadmap navigable.`;
|
|
6543
|
+
}
|
|
6544
|
+
return { milestone_health: summary, nudge };
|
|
7365
6545
|
}
|
|
7366
6546
|
|
|
7367
6547
|
function cmdFindFiles(rawArgs) {
|
|
@@ -7833,7 +7013,8 @@ async function main() {
|
|
|
7833
7013
|
break;
|
|
7834
7014
|
}
|
|
7835
7015
|
case 'brain': {
|
|
7836
|
-
|
|
7016
|
+
const brain = require(path.join(__dirname, 'lib', 'brain.cjs'));
|
|
7017
|
+
result = brain.cmdBrain(args, { PROJECT_ROOT, RCODE_DIR });
|
|
7837
7018
|
break;
|
|
7838
7019
|
}
|
|
7839
7020
|
case 'handoff': {
|
|
@@ -7841,19 +7022,23 @@ async function main() {
|
|
|
7841
7022
|
break;
|
|
7842
7023
|
}
|
|
7843
7024
|
case 'progress': {
|
|
7844
|
-
|
|
7025
|
+
const progress = require(path.join(__dirname, 'lib', 'progress.cjs'));
|
|
7026
|
+
result = progress.cmdProgress(args, { PROJECT_ROOT, RCODE_DIR, PLANNING_DIR });
|
|
7845
7027
|
break;
|
|
7846
7028
|
}
|
|
7847
7029
|
case 'summary-extract': {
|
|
7848
|
-
|
|
7030
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7031
|
+
result = summary.cmdSummaryExtract(args);
|
|
7849
7032
|
break;
|
|
7850
7033
|
}
|
|
7851
7034
|
case 'state-snapshot': {
|
|
7852
|
-
|
|
7035
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7036
|
+
result = summary.cmdStateSnapshot({ RCODE_DIR });
|
|
7853
7037
|
break;
|
|
7854
7038
|
}
|
|
7855
7039
|
case 'gitignore': {
|
|
7856
|
-
|
|
7040
|
+
const gitignore = require(path.join(__dirname, 'lib', 'gitignore.cjs'));
|
|
7041
|
+
result = gitignore.cmdGitignore(args, { PROJECT_ROOT, RCODE_DIR });
|
|
7857
7042
|
break;
|
|
7858
7043
|
}
|
|
7859
7044
|
case 'agent-skills':
|
|
@@ -7878,8 +7063,9 @@ async function main() {
|
|
|
7878
7063
|
// Closes #836 — top-level health check so agents can call
|
|
7879
7064
|
// `rcode-tools.cjs health` directly without the CLI wrapper.
|
|
7880
7065
|
// Returns a combined snapshot: milestone health + state snapshot + project status.
|
|
7066
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7881
7067
|
const mh = cmdMilestoneHealth();
|
|
7882
|
-
const ss = cmdStateSnapshot();
|
|
7068
|
+
const ss = summary.cmdStateSnapshot({ RCODE_DIR });
|
|
7883
7069
|
const ps = cmdProjectStatus();
|
|
7884
7070
|
result = { ok: mh.ok && ss.ok, milestone_health: mh, state: ss, project: ps };
|
|
7885
7071
|
break;
|