@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.
@@ -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.
@@ -23,6 +25,18 @@ const fs = require('fs');
23
25
  const os = require('os');
24
26
  const path = require('path');
25
27
  const { execSync, spawnSync } = require('child_process');
28
+ const { resolveActivePhase, readSprintProgress, readRecentCommits, readMilestoneHint } = require('./lib/state-reader.cjs');
29
+ const { selectMemoryChunks, formatMemoryContext, hasMemory } = require('./lib/memory-select.cjs');
30
+
31
+ // lib/memory-drift.cjs is optional at the module-load level: some hook-copy
32
+ // test fixtures deliberately stage a minimal bin/lib/ (only state-reader.cjs)
33
+ // to exercise other subcommands' missing-file handling. A hard top-level
34
+ // require would crash every subcommand, not just `drift`/`post-commit`, so
35
+ // this loads lazily and fails open — same pattern as INTENT_TABLE below.
36
+ let checkDrift = null;
37
+ try {
38
+ ({ checkDrift } = require('./lib/memory-drift.cjs'));
39
+ } catch { /* optional — see comment above */ }
26
40
 
27
41
  /**
28
42
  * Read and parse stdin JSON.
@@ -116,6 +130,56 @@ async function preWorkflow() {
116
130
  }
117
131
  }
118
132
 
133
+ /**
134
+ * Emit a one-line systemMessage nudge toward /rcode-memory-update when
135
+ * memory-drift.cjs finds drifts, at most once per session (#958).
136
+ *
137
+ * "Session" here has no reliable id on a post-commit hook payload, so we
138
+ * fall back to parent-pid + hourly bucket — same fallback prompt-router
139
+ * already uses (see promptRouter() below) — scoped naturally to the
140
+ * current shell without requiring a session_id in the payload.
141
+ */
142
+ function maybeEmitDriftNudge(cwd, input) {
143
+ try {
144
+ if (!checkDrift) return;
145
+ const { drifts } = checkDrift(cwd);
146
+ if (drifts.length === 0) return;
147
+
148
+ const sessionFallback = String(process.ppid) + '-' + String(Math.floor(Date.now() / 3600000));
149
+ const sessionId = input?.session_id || input?.tool_input?.session_id || sessionFallback;
150
+ const safeId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
151
+
152
+ const cacheDir = path.join(cwd, '.rcode', '.cache');
153
+ const markerFile = path.join(cacheDir, `drift-nudge-${safeId}.json`);
154
+ if (fs.existsSync(markerFile)) return;
155
+
156
+ fs.mkdirSync(cacheDir, { recursive: true });
157
+ fs.writeFileSync(markerFile, JSON.stringify({ ts: Date.now(), count: drifts.length }));
158
+
159
+ const kinds = [...new Set(drifts.map((d) => d.kind))].join(', ');
160
+ const nudge =
161
+ `⚠ Memory drift detected (${drifts.length} finding${drifts.length === 1 ? '' : 's'}: ${kinds}) — ` +
162
+ `run /rcode-memory-update, or \`rcode-hooks drift\` for the full report.`;
163
+ process.stdout.write(JSON.stringify({ systemMessage: nudge }) + '\n');
164
+ } catch {
165
+ // Advisory only — never break the commit flow.
166
+ }
167
+ }
168
+
169
+ /**
170
+ * drift: Print the full memory-drift report (#958). Standalone CLI use —
171
+ * not gated by the once-per-session nudge limiter in maybeEmitDriftNudge().
172
+ */
173
+ function driftCommand() {
174
+ if (!checkDrift) {
175
+ console.error('rcode/bin/lib/memory-drift.cjs failed to load — cannot run drift check.');
176
+ process.exit(1);
177
+ }
178
+ const report = checkDrift(process.cwd());
179
+ console.log(JSON.stringify(report, null, 2));
180
+ process.exit(0);
181
+ }
182
+
119
183
  /**
120
184
  * post-commit: Verify commit format and banned patterns.
121
185
  * Warns (not blocking) if violations found.
@@ -197,6 +261,8 @@ async function postCommit() {
197
261
  violations.forEach((v) => console.warn(` • ${v}`));
198
262
  }
199
263
 
264
+ maybeEmitDriftNudge(process.cwd(), input);
265
+
200
266
  process.exit(0);
201
267
  } catch (err) {
202
268
  console.error(`Hook error: ${err.message}`);
@@ -353,67 +419,16 @@ async function preCompact() {
353
419
  }
354
420
 
355
421
  // ── 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);
422
+ const { activePhase, phaseLabel } = resolveActivePhase(state);
365
423
 
366
424
  // ── 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
- }
425
+ const { completedCount, incompleteTasks } = readSprintProgress(phaseLabel, cwd);
397
426
 
398
427
  // ── 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 {}
428
+ const recentCommits = readRecentCommits(cwd);
406
429
 
407
430
  // ── 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
- }
431
+ const milestoneHint = readMilestoneHint(state, cwd);
417
432
 
418
433
  // ── 6. Read last 3 decisions from STATE.md ───────────────────────────
419
434
  let recentDecisions = [];
@@ -502,7 +517,24 @@ async function preCompact() {
502
517
  }
503
518
  msgParts.push('Run `/rcode-resume-work` to restore full context, or `/clear` then paste `.rcode/.continue-here.md`.');
504
519
 
505
- process.stdout.write(JSON.stringify({ systemMessage: msgParts.join(' | ') }) + '\n');
520
+ // ── 10. Relevance-ranked memory survival context (#958) ──────────────
521
+ // Smaller budget than session-start — this rides alongside HANDOFF.json
522
+ // as compaction survival context, not a full primer. Degrades silently
523
+ // when .rcode/memory/ is missing or empty.
524
+ const payload = { systemMessage: msgParts.join(' | ') };
525
+ if (hasMemory(cwd)) {
526
+ try {
527
+ const selection = selectMemoryChunks(cwd, { defaultBudget: 600 });
528
+ const additionalContext = formatMemoryContext(selection);
529
+ if (additionalContext) {
530
+ payload.hookSpecificOutput = {
531
+ hookEventName: 'PreCompact',
532
+ additionalContext,
533
+ };
534
+ }
535
+ } catch { /* memory injection is advisory — never block compaction */ }
536
+ }
537
+ process.stdout.write(JSON.stringify(payload) + '\n');
506
538
  process.exit(0);
507
539
  } catch (err) {
508
540
  console.error(`Hook error: ${err.message}`);
@@ -626,12 +658,30 @@ async function costTrack() {
626
658
  // INTENT_TABLE — keyword map for prompt-router (#892).
627
659
  // Source of truth: rcode/workflows/do.md routing table (~285-320). First-match-wins.
628
660
  // 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
- );
661
+ //
662
+ // Fail-open (#952 review H1): the load is wrapped so a MISSING data file degrades
663
+ // the prompt-router to a no-op instead of throwing at module-require time, which
664
+ // would crash EVERY hook subcommand (bash-guard, pre-edit, session-start, …) —
665
+ // not just the router.
666
+ //
667
+ // #952 follow-up: a missing data file used to degrade silently (empty table,
668
+ // zero output, no way for the user to know auto-detection is broken). It now
669
+ // still fails open — prompt-router still exits 0 and never blocks — but
670
+ // promptRouter() emits a one-time additionalContext warning pointing at the
671
+ // fix (`npx @hanzlaa/rcode update`) instead of no-op-ing forever.
672
+ let INTENT_TABLE = [];
673
+ let INTENT_TABLE_LOAD_ERROR = null;
674
+ try {
675
+ INTENT_TABLE = JSON.parse(
676
+ require('fs').readFileSync(
677
+ require('path').join(__dirname, '..', 'data', 'intent-table.json'),
678
+ 'utf8'
679
+ )
680
+ );
681
+ } catch (err) {
682
+ INTENT_TABLE = [];
683
+ INTENT_TABLE_LOAD_ERROR = err;
684
+ }
635
685
 
636
686
  /**
637
687
  * Inline flat-YAML parser — mirrors parseSimpleYaml in rcode-tools.cjs:91.
@@ -758,6 +808,30 @@ function promptRouter() {
758
808
  process.exit(0);
759
809
  }
760
810
 
811
+ // ── Missing data file: warn once instead of silently no-op-ing (#952) ──
812
+ // A consumer install missing rcode/data/intent-table.json used to degrade
813
+ // to a permanent, invisible no-op — no output, no hint anything was wrong.
814
+ // Warn once per machine (tmpdir marker keyed by project path) and point
815
+ // at the fix, then continue with the (empty) table like before.
816
+ if (INTENT_TABLE_LOAD_ERROR) {
817
+ const warnKey = cwd.replace(/[^a-zA-Z0-9]/g, '_');
818
+ const warnFile = path.join(os.tmpdir(), 'rcode-intent-table-missing-warned-' + warnKey);
819
+ if (!fs.existsSync(warnFile)) {
820
+ try { fs.writeFileSync(warnFile, String(process.pid)); } catch { /* fail open */ }
821
+ const payload = {
822
+ hookSpecificOutput: {
823
+ hookEventName,
824
+ additionalContext:
825
+ 'rcode/data/intent-table.json is missing — skill auto-detection from prompts is disabled. ' +
826
+ 'Run `npx @hanzlaa/rcode update` to reinstall the missing data files.',
827
+ },
828
+ };
829
+ process.stdout.write(JSON.stringify(payload));
830
+ process.exit(0);
831
+ }
832
+ process.exit(0);
833
+ }
834
+
761
835
  // ── Keyword match (first-match-wins, case-insensitive) ───────────────
762
836
  const lower = prompt.toLowerCase();
763
837
  let matched = null;
@@ -808,8 +882,11 @@ function promptRouter() {
808
882
  // gentle memory-framed tip loses the skill-selection race to imperative
809
883
  // SessionStart primers (e.g. superpowers' "you MUST invoke"). The memory
810
884
  // rationale stays, but as the fallback note rather than the headline.
885
+ // #956: dropped the "records the outcome in .rcode/state.json" claim —
886
+ // most routed workflows (e.g. karpathy-audit) only write a report file,
887
+ // they don't touch state.json, so the blanket claim was false.
811
888
  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. ` +
889
+ `Use ${matched.command} for this ${matched.intent} task — it's the rcode workflow built for it. ` +
813
890
  `Prefer it over handling this ad-hoc; if you do proceed manually, run /rcode-memory-update afterward so long-term memory stays consistent.`;
814
891
 
815
892
  const payload = {
@@ -943,6 +1020,53 @@ async function stopHandler() {
943
1020
  }
944
1021
  }
945
1022
 
1023
+ /**
1024
+ * session-start: Emit a one-line project status primer at session open. (#947)
1025
+ * Uses resolveActivePhase from state-reader.cjs. Advisory only — exits 0 on any error.
1026
+ */
1027
+ function sessionStart() {
1028
+ try {
1029
+ try { fs.readFileSync(0, 'utf8'); } catch { /* drain stdin */ }
1030
+ const cwd = process.cwd();
1031
+ const statePath = path.join(cwd, '.rcode', 'state.json');
1032
+ if (!fs.existsSync(statePath)) process.exit(0);
1033
+ let state;
1034
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { process.exit(0); }
1035
+ const { activePhase, phaseLabel } = resolveActivePhase(state);
1036
+ if (!phaseLabel) process.exit(0);
1037
+ const phaseKey = String(activePhase?.number ?? phaseLabel);
1038
+ const phaseSprints = (Array.isArray(state.sprints) ? state.sprints : []).filter(s => String(s.phase) === phaseKey);
1039
+ const doneCount = phaseSprints.filter(s => s.status === 'completed' || s.status === 'complete').length;
1040
+ const sprintSummary = phaseSprints.length > 0 ? `${doneCount}/${phaseSprints.length} sprints done` : 'no sprints yet';
1041
+ const phaseStatus = activePhase?.status || 'planned';
1042
+ const nextCmd = phaseStatus === 'executing' ? '/rcode-execute'
1043
+ : phaseStatus === 'complete' ? '/rcode-add-phase'
1044
+ : phaseSprints.length === 0 ? `/rcode-plan ${phaseLabel}`
1045
+ : '/rcode-execute';
1046
+ const primer = `\u{1F4CD} Phase ${phaseLabel} ${phaseStatus} · ${sprintSummary} · next: ${nextCmd}`;
1047
+
1048
+ // ── Relevance-ranked memory injection (#958) ─────────────────────────
1049
+ // Only attempted when .rcode/memory/ exists and has content — a missing
1050
+ // or empty memory bank degrades silently to the primer-only behavior
1051
+ // that predates this feature.
1052
+ const payload = { systemMessage: primer };
1053
+ if (hasMemory(cwd)) {
1054
+ try {
1055
+ const selection = selectMemoryChunks(cwd);
1056
+ const additionalContext = formatMemoryContext(selection);
1057
+ if (additionalContext) {
1058
+ payload.hookSpecificOutput = {
1059
+ hookEventName: 'SessionStart',
1060
+ additionalContext,
1061
+ };
1062
+ }
1063
+ } catch { /* memory injection is advisory — never block session start */ }
1064
+ }
1065
+ process.stdout.write(JSON.stringify(payload) + '\n');
1066
+ } catch { /* fail open — never block session start */ }
1067
+ process.exit(0);
1068
+ }
1069
+
946
1070
  /**
947
1071
  * Main entry point.
948
1072
  */
@@ -983,9 +1107,15 @@ async function main() {
983
1107
  case 'prompt-router':
984
1108
  promptRouter(); // synchronous — exits inside; never falls through to async path
985
1109
  break;
1110
+ case 'session-start':
1111
+ sessionStart();
1112
+ break;
1113
+ case 'drift':
1114
+ driftCommand();
1115
+ break;
986
1116
  default:
987
1117
  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');
1118
+ 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
1119
  process.exit(1);
990
1120
  }
991
1121
  }