@hanzlaa/rcode 4.4.4 → 4.6.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.
Files changed (43) hide show
  1. package/AGENTS.md +11 -4
  2. package/CLAUDE.md +11 -4
  3. package/CONTRIBUTING.md +6 -1
  4. package/cli/doctor.js +122 -0
  5. package/cli/index.js +4 -0
  6. package/cli/install.js +8 -0
  7. package/cli/lib/namespace-migrate.cjs +216 -0
  8. package/cli/migrate-namespace.js +61 -0
  9. package/cli/update.js +19 -0
  10. package/dist/rcode.js +215 -208
  11. package/package.json +14 -12
  12. package/rcode/agents/rcode-mariam.md +6 -0
  13. package/rcode/agents/rcode-sadiq.md +6 -0
  14. package/rcode/agents/rcode-waleed.md +6 -0
  15. package/rcode/bin/lib/brain.cjs +353 -0
  16. package/rcode/bin/lib/gitignore.cjs +126 -0
  17. package/rcode/bin/lib/memory-drift.cjs +237 -0
  18. package/rcode/bin/lib/memory-select.cjs +263 -0
  19. package/rcode/bin/lib/progress.cjs +440 -0
  20. package/rcode/bin/lib/state-reader.cjs +127 -0
  21. package/rcode/bin/lib/summary.cjs +82 -0
  22. package/rcode/bin/rcode-hooks.cjs +230 -64
  23. package/rcode/bin/rcode-tools.cjs +106 -985
  24. package/rcode/data/intent-table.json +19 -19
  25. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/heartbeat.sh +0 -0
  26. package/rcode/skills/agents/mariam-marketing/SKILL.md +1 -0
  27. package/rcode/skills/agents/sadiq-analyst/SKILL.md +1 -0
  28. package/rcode/skills/agents/waleed-architect/SKILL.md +1 -0
  29. package/rcode/templates/memory/INDEX.md +6 -1
  30. package/rcode/templates/settings-hooks.json +12 -1
  31. package/rcode/workflows/council.md +29 -1
  32. package/rcode/workflows/do.md +2 -2
  33. package/rcode/workflows/enable-hooks.md +3 -2
  34. package/rcode/workflows/new-project-roadmap.md +2 -2
  35. package/server/dashboard.js +6 -3
  36. package/server/lib/html/client/components/App.js +1 -1
  37. package/server/lib/html/client/components/OrchPanel.js +2 -2
  38. package/server/lib/html/client/components/XtermPanel.js +98 -23
  39. package/server/lib/html/client/orchestrator.js +28 -15
  40. package/server/lib/html/client/views/OrchestrationView.js +247 -169
  41. package/server/lib/html/css.js +596 -227
  42. package/server/lib/html/shell.js +9 -3
  43. package/server/orchestrator.js +3 -4
@@ -14,6 +14,8 @@
14
14
  * compact-nudge — advise /rcode-trim or /clear after N Edit/Write calls (#749)
15
15
  * pre-tool-use — stderr warning before large file reads to avoid context bloat (#749)
16
16
  * prompt-router — nudge toward rcode commands for memory consistency (#892)
17
+ * session-start — emit one-line project status primer at session open (#947)
18
+ * drift — print the full memory-drift report (#958)
17
19
  *
18
20
  * All subcommands read stdin JSON from the hook execution context.
19
21
  * Pure Node stdlib. No external dependencies.
@@ -24,6 +26,54 @@ const os = require('os');
24
26
  const path = require('path');
25
27
  const { execSync, spawnSync } = require('child_process');
26
28
 
29
+ /**
30
+ * Self-healing lib require (#960). When this file runs from an installed
31
+ * `.rcode/bin/` whose `lib/` is stale or partial (fresh git worktree, merge/
32
+ * pull that changed `rcode/bin/lib/` without a mirror sync), a hard require
33
+ * crashes EVERY hook — the user sees a SessionStart loader error and loses
34
+ * the status line entirely. Instead: on MODULE_NOT_FOUND, try healing from
35
+ * the in-repo source of truth (`rcode/bin/lib/<name>` relative to the project
36
+ * root that contains this `.rcode/`), retry once, and otherwise fail open so
37
+ * hooks degrade (no memory injection / drift check) rather than die.
38
+ */
39
+ function requireLib(name) {
40
+ const local = path.join(__dirname, 'lib', name);
41
+ try { return require(local); } catch (err) {
42
+ if (err && err.code !== 'MODULE_NOT_FOUND') throw err;
43
+ try {
44
+ const src = path.join(__dirname, '..', '..', 'rcode', 'bin', 'lib', name);
45
+ if (fs.existsSync(src)) {
46
+ fs.mkdirSync(path.dirname(local), { recursive: true });
47
+ fs.copyFileSync(src, local);
48
+ return require(local);
49
+ }
50
+ } catch { /* healing is best-effort */ }
51
+ return null;
52
+ }
53
+ }
54
+
55
+ const _stateReader = requireLib('state-reader.cjs') || {};
56
+ const resolveActivePhase = _stateReader.resolveActivePhase || (() => ({ activePhase: null, phaseLabel: null }));
57
+ const readSprintProgress = _stateReader.readSprintProgress || (() => ({ completedCount: 0, incompleteTasks: [] }));
58
+ const readRecentCommits = _stateReader.readRecentCommits || (() => []);
59
+ const readMilestoneHint = _stateReader.readMilestoneHint || (() => null);
60
+
61
+ const _memSelect = requireLib('memory-select.cjs') || {};
62
+ const selectMemoryChunks = _memSelect.selectMemoryChunks || (() => []);
63
+ const formatMemoryContext = _memSelect.formatMemoryContext || (() => '');
64
+ const hasMemory = _memSelect.hasMemory || (() => false);
65
+
66
+ // lib/memory-drift.cjs is optional at the module-load level: some hook-copy
67
+ // test fixtures deliberately stage a minimal bin/lib/ (only state-reader.cjs)
68
+ // to exercise other subcommands' missing-file handling. A hard top-level
69
+ // require would crash every subcommand, not just `drift`/`post-commit`, so
70
+ // this loads lazily and fails open — same pattern as INTENT_TABLE below.
71
+ let checkDrift = null;
72
+ {
73
+ const _drift = requireLib('memory-drift.cjs');
74
+ if (_drift) ({ checkDrift } = _drift);
75
+ }
76
+
27
77
  /**
28
78
  * Read and parse stdin JSON.
29
79
  */
@@ -116,6 +166,56 @@ async function preWorkflow() {
116
166
  }
117
167
  }
118
168
 
169
+ /**
170
+ * Emit a one-line systemMessage nudge toward /rcode-memory-update when
171
+ * memory-drift.cjs finds drifts, at most once per session (#958).
172
+ *
173
+ * "Session" here has no reliable id on a post-commit hook payload, so we
174
+ * fall back to parent-pid + hourly bucket — same fallback prompt-router
175
+ * already uses (see promptRouter() below) — scoped naturally to the
176
+ * current shell without requiring a session_id in the payload.
177
+ */
178
+ function maybeEmitDriftNudge(cwd, input) {
179
+ try {
180
+ if (!checkDrift) return;
181
+ const { drifts } = checkDrift(cwd);
182
+ if (drifts.length === 0) return;
183
+
184
+ const sessionFallback = String(process.ppid) + '-' + String(Math.floor(Date.now() / 3600000));
185
+ const sessionId = input?.session_id || input?.tool_input?.session_id || sessionFallback;
186
+ const safeId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
187
+
188
+ const cacheDir = path.join(cwd, '.rcode', '.cache');
189
+ const markerFile = path.join(cacheDir, `drift-nudge-${safeId}.json`);
190
+ if (fs.existsSync(markerFile)) return;
191
+
192
+ fs.mkdirSync(cacheDir, { recursive: true });
193
+ fs.writeFileSync(markerFile, JSON.stringify({ ts: Date.now(), count: drifts.length }));
194
+
195
+ const kinds = [...new Set(drifts.map((d) => d.kind))].join(', ');
196
+ const nudge =
197
+ `⚠ Memory drift detected (${drifts.length} finding${drifts.length === 1 ? '' : 's'}: ${kinds}) — ` +
198
+ `run /rcode-memory-update, or \`rcode-hooks drift\` for the full report.`;
199
+ process.stdout.write(JSON.stringify({ systemMessage: nudge }) + '\n');
200
+ } catch {
201
+ // Advisory only — never break the commit flow.
202
+ }
203
+ }
204
+
205
+ /**
206
+ * drift: Print the full memory-drift report (#958). Standalone CLI use —
207
+ * not gated by the once-per-session nudge limiter in maybeEmitDriftNudge().
208
+ */
209
+ function driftCommand() {
210
+ if (!checkDrift) {
211
+ console.error('rcode/bin/lib/memory-drift.cjs failed to load — cannot run drift check.');
212
+ process.exit(1);
213
+ }
214
+ const report = checkDrift(process.cwd());
215
+ console.log(JSON.stringify(report, null, 2));
216
+ process.exit(0);
217
+ }
218
+
119
219
  /**
120
220
  * post-commit: Verify commit format and banned patterns.
121
221
  * Warns (not blocking) if violations found.
@@ -197,6 +297,8 @@ async function postCommit() {
197
297
  violations.forEach((v) => console.warn(` • ${v}`));
198
298
  }
199
299
 
300
+ maybeEmitDriftNudge(process.cwd(), input);
301
+
200
302
  process.exit(0);
201
303
  } catch (err) {
202
304
  console.error(`Hook error: ${err.message}`);
@@ -353,67 +455,16 @@ async function preCompact() {
353
455
  }
354
456
 
355
457
  // ── 2. Determine active phase ────────────────────────────────────────
356
- const phases = Array.isArray(state?.phases) ? state.phases : [];
357
- const executing = phases.find((p) => p && p.status === 'executing');
358
- const matched = phases.find(
359
- (p) => p && (p.name === state?.current_phase || p.number === state?.current_phase)
360
- );
361
- const activePhase = executing || matched || null;
362
- const phaseLabel = activePhase
363
- ? (activePhase.number || activePhase.name || state?.current_phase)
364
- : (state?.current_phase || null);
458
+ const { activePhase, phaseLabel } = resolveActivePhase(state);
365
459
 
366
460
  // ── 3. Read active SPRINT.md (incomplete tasks) ──────────────────────
367
- const incompleteTasks = [];
368
- const completedCount = { done: 0, total: 0 };
369
- const planningBase = path.join(cwd, '.planning', 'phases');
370
- if (phaseLabel && fs.existsSync(planningBase)) {
371
- try {
372
- const phaseDirs = fs.readdirSync(planningBase)
373
- .filter(d => d.startsWith(String(phaseLabel)));
374
- for (const pd of phaseDirs) {
375
- const pdPath = path.join(planningBase, pd);
376
- if (!fs.statSync(pdPath).isDirectory()) continue;
377
- const sprintFiles = fs.readdirSync(pdPath)
378
- .filter(f => f.endsWith('-SPRINT.md'))
379
- .sort()
380
- .reverse(); // most recent first
381
- if (sprintFiles.length === 0) continue;
382
- const sprintText = fs.readFileSync(path.join(pdPath, sprintFiles[0]), 'utf8');
383
- for (const line of sprintText.split('\n')) {
384
- const done = /^\s*-\s*\[x\]/i.test(line);
385
- const pending = /^\s*-\s*\[ \]/.test(line);
386
- if (done || pending) completedCount.total++;
387
- if (done) completedCount.done++;
388
- if (pending) {
389
- const task = line.replace(/^\s*-\s*\[ \]\s*/, '').trim();
390
- if (task) incompleteTasks.push(task);
391
- }
392
- }
393
- break; // use first matching phase dir only
394
- }
395
- } catch {}
396
- }
461
+ const { completedCount, incompleteTasks } = readSprintProgress(phaseLabel, cwd);
397
462
 
398
463
  // ── 4. Recent git commits ────────────────────────────────────────────
399
- let recentCommits = [];
400
- try {
401
- const log = execSync('git log --oneline -5 --no-decorate 2>/dev/null', {
402
- cwd, encoding: 'utf8', timeout: 3000,
403
- }).trim();
404
- recentCommits = log ? log.split('\n').filter(Boolean) : [];
405
- } catch {}
464
+ const recentCommits = readRecentCommits(cwd);
406
465
 
407
466
  // ── 5. Read milestone / roadmap headline ────────────────────────────
408
- let milestoneHint = state?.milestone || null;
409
- if (!milestoneHint) {
410
- for (const rp of ['.planning/ROADMAP.md', '.planning/milestones/ROADMAP.md']) {
411
- const full = path.join(cwd, rp);
412
- if (!fs.existsSync(full)) continue;
413
- const m = fs.readFileSync(full, 'utf8').match(/^##\s+Milestone\s+(M\d+[^\n]*)/m);
414
- if (m) { milestoneHint = m[1].trim(); break; }
415
- }
416
- }
467
+ const milestoneHint = readMilestoneHint(state, cwd);
417
468
 
418
469
  // ── 6. Read last 3 decisions from STATE.md ───────────────────────────
419
470
  let recentDecisions = [];
@@ -502,7 +553,24 @@ async function preCompact() {
502
553
  }
503
554
  msgParts.push('Run `/rcode-resume-work` to restore full context, or `/clear` then paste `.rcode/.continue-here.md`.');
504
555
 
505
- process.stdout.write(JSON.stringify({ systemMessage: msgParts.join(' | ') }) + '\n');
556
+ // ── 10. Relevance-ranked memory survival context (#958) ──────────────
557
+ // Smaller budget than session-start — this rides alongside HANDOFF.json
558
+ // as compaction survival context, not a full primer. Degrades silently
559
+ // when .rcode/memory/ is missing or empty.
560
+ const payload = { systemMessage: msgParts.join(' | ') };
561
+ if (hasMemory(cwd)) {
562
+ try {
563
+ const selection = selectMemoryChunks(cwd, { defaultBudget: 600 });
564
+ const additionalContext = formatMemoryContext(selection);
565
+ if (additionalContext) {
566
+ payload.hookSpecificOutput = {
567
+ hookEventName: 'PreCompact',
568
+ additionalContext,
569
+ };
570
+ }
571
+ } catch { /* memory injection is advisory — never block compaction */ }
572
+ }
573
+ process.stdout.write(JSON.stringify(payload) + '\n');
506
574
  process.exit(0);
507
575
  } catch (err) {
508
576
  console.error(`Hook error: ${err.message}`);
@@ -626,12 +694,30 @@ async function costTrack() {
626
694
  // INTENT_TABLE — keyword map for prompt-router (#892).
627
695
  // Source of truth: rcode/workflows/do.md routing table (~285-320). First-match-wins.
628
696
  // Loaded from data file to keep this file under 1000 lines (#896).
629
- const INTENT_TABLE = JSON.parse(
630
- require('fs').readFileSync(
631
- require('path').join(__dirname, '..', 'data', 'intent-table.json'),
632
- 'utf8'
633
- )
634
- );
697
+ //
698
+ // Fail-open (#952 review H1): the load is wrapped so a MISSING data file degrades
699
+ // the prompt-router to a no-op instead of throwing at module-require time, which
700
+ // would crash EVERY hook subcommand (bash-guard, pre-edit, session-start, …) —
701
+ // not just the router.
702
+ //
703
+ // #952 follow-up: a missing data file used to degrade silently (empty table,
704
+ // zero output, no way for the user to know auto-detection is broken). It now
705
+ // still fails open — prompt-router still exits 0 and never blocks — but
706
+ // promptRouter() emits a one-time additionalContext warning pointing at the
707
+ // fix (`npx @hanzlaa/rcode update`) instead of no-op-ing forever.
708
+ let INTENT_TABLE = [];
709
+ let INTENT_TABLE_LOAD_ERROR = null;
710
+ try {
711
+ INTENT_TABLE = JSON.parse(
712
+ require('fs').readFileSync(
713
+ require('path').join(__dirname, '..', 'data', 'intent-table.json'),
714
+ 'utf8'
715
+ )
716
+ );
717
+ } catch (err) {
718
+ INTENT_TABLE = [];
719
+ INTENT_TABLE_LOAD_ERROR = err;
720
+ }
635
721
 
636
722
  /**
637
723
  * Inline flat-YAML parser — mirrors parseSimpleYaml in rcode-tools.cjs:91.
@@ -758,6 +844,30 @@ function promptRouter() {
758
844
  process.exit(0);
759
845
  }
760
846
 
847
+ // ── Missing data file: warn once instead of silently no-op-ing (#952) ──
848
+ // A consumer install missing rcode/data/intent-table.json used to degrade
849
+ // to a permanent, invisible no-op — no output, no hint anything was wrong.
850
+ // Warn once per machine (tmpdir marker keyed by project path) and point
851
+ // at the fix, then continue with the (empty) table like before.
852
+ if (INTENT_TABLE_LOAD_ERROR) {
853
+ const warnKey = cwd.replace(/[^a-zA-Z0-9]/g, '_');
854
+ const warnFile = path.join(os.tmpdir(), 'rcode-intent-table-missing-warned-' + warnKey);
855
+ if (!fs.existsSync(warnFile)) {
856
+ try { fs.writeFileSync(warnFile, String(process.pid)); } catch { /* fail open */ }
857
+ const payload = {
858
+ hookSpecificOutput: {
859
+ hookEventName,
860
+ additionalContext:
861
+ 'rcode/data/intent-table.json is missing — skill auto-detection from prompts is disabled. ' +
862
+ 'Run `npx @hanzlaa/rcode update` to reinstall the missing data files.',
863
+ },
864
+ };
865
+ process.stdout.write(JSON.stringify(payload));
866
+ process.exit(0);
867
+ }
868
+ process.exit(0);
869
+ }
870
+
761
871
  // ── Keyword match (first-match-wins, case-insensitive) ───────────────
762
872
  const lower = prompt.toLowerCase();
763
873
  let matched = null;
@@ -808,8 +918,11 @@ function promptRouter() {
808
918
  // gentle memory-framed tip loses the skill-selection race to imperative
809
919
  // SessionStart primers (e.g. superpowers' "you MUST invoke"). The memory
810
920
  // rationale stays, but as the fallback note rather than the headline.
921
+ // #956: dropped the "records the outcome in .rcode/state.json" claim —
922
+ // most routed workflows (e.g. karpathy-audit) only write a report file,
923
+ // they don't touch state.json, so the blanket claim was false.
811
924
  const advisory =
812
- `Use ${matched.command} for this ${matched.intent} task — it's the rcode workflow built for it, and it records the outcome in .rcode/state.json. ` +
925
+ `Use ${matched.command} for this ${matched.intent} task — it's the rcode workflow built for it. ` +
813
926
  `Prefer it over handling this ad-hoc; if you do proceed manually, run /rcode-memory-update afterward so long-term memory stays consistent.`;
814
927
 
815
928
  const payload = {
@@ -943,6 +1056,53 @@ async function stopHandler() {
943
1056
  }
944
1057
  }
945
1058
 
1059
+ /**
1060
+ * session-start: Emit a one-line project status primer at session open. (#947)
1061
+ * Uses resolveActivePhase from state-reader.cjs. Advisory only — exits 0 on any error.
1062
+ */
1063
+ function sessionStart() {
1064
+ try {
1065
+ try { fs.readFileSync(0, 'utf8'); } catch { /* drain stdin */ }
1066
+ const cwd = process.cwd();
1067
+ const statePath = path.join(cwd, '.rcode', 'state.json');
1068
+ if (!fs.existsSync(statePath)) process.exit(0);
1069
+ let state;
1070
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { process.exit(0); }
1071
+ const { activePhase, phaseLabel } = resolveActivePhase(state);
1072
+ if (!phaseLabel) process.exit(0);
1073
+ const phaseKey = String(activePhase?.number ?? phaseLabel);
1074
+ const phaseSprints = (Array.isArray(state.sprints) ? state.sprints : []).filter(s => String(s.phase) === phaseKey);
1075
+ const doneCount = phaseSprints.filter(s => s.status === 'completed' || s.status === 'complete').length;
1076
+ const sprintSummary = phaseSprints.length > 0 ? `${doneCount}/${phaseSprints.length} sprints done` : 'no sprints yet';
1077
+ const phaseStatus = activePhase?.status || 'planned';
1078
+ const nextCmd = phaseStatus === 'executing' ? '/rcode-execute'
1079
+ : phaseStatus === 'complete' ? '/rcode-add-phase'
1080
+ : phaseSprints.length === 0 ? `/rcode-plan ${phaseLabel}`
1081
+ : '/rcode-execute';
1082
+ const primer = `\u{1F4CD} Phase ${phaseLabel} ${phaseStatus} · ${sprintSummary} · next: ${nextCmd}`;
1083
+
1084
+ // ── Relevance-ranked memory injection (#958) ─────────────────────────
1085
+ // Only attempted when .rcode/memory/ exists and has content — a missing
1086
+ // or empty memory bank degrades silently to the primer-only behavior
1087
+ // that predates this feature.
1088
+ const payload = { systemMessage: primer };
1089
+ if (hasMemory(cwd)) {
1090
+ try {
1091
+ const selection = selectMemoryChunks(cwd);
1092
+ const additionalContext = formatMemoryContext(selection);
1093
+ if (additionalContext) {
1094
+ payload.hookSpecificOutput = {
1095
+ hookEventName: 'SessionStart',
1096
+ additionalContext,
1097
+ };
1098
+ }
1099
+ } catch { /* memory injection is advisory — never block session start */ }
1100
+ }
1101
+ process.stdout.write(JSON.stringify(payload) + '\n');
1102
+ } catch { /* fail open — never block session start */ }
1103
+ process.exit(0);
1104
+ }
1105
+
946
1106
  /**
947
1107
  * Main entry point.
948
1108
  */
@@ -983,9 +1143,15 @@ async function main() {
983
1143
  case 'prompt-router':
984
1144
  promptRouter(); // synchronous — exits inside; never falls through to async path
985
1145
  break;
1146
+ case 'session-start':
1147
+ sessionStart();
1148
+ break;
1149
+ case 'drift':
1150
+ driftCommand();
1151
+ break;
986
1152
  default:
987
1153
  console.error(`Unknown subcommand: ${subcommand}`);
988
- console.error('Usage: rcode-hooks.cjs pre-edit|pre-workflow|post-commit|bash-guard|pre-compact|stop-verify|cost-track|stop|compact-nudge|pre-tool-use|prompt-router');
1154
+ console.error('Usage: rcode-hooks.cjs pre-edit|pre-workflow|post-commit|bash-guard|pre-compact|stop-verify|cost-track|stop|compact-nudge|pre-tool-use|prompt-router|session-start|drift');
989
1155
  process.exit(1);
990
1156
  }
991
1157
  }