@hanzlaa/rcode 2.1.0 → 2.3.1

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 (76) hide show
  1. package/CONTRIBUTING.md +138 -0
  2. package/README.md +83 -19
  3. package/cli/install.js +687 -80
  4. package/cli/uninstall.js +8 -0
  5. package/dist/rcode.js +19777 -0
  6. package/package.json +17 -4
  7. package/rihal/DOCS-AUDIT.md +14 -0
  8. package/rihal/agents/rihal-code-reviewer.md +1 -1
  9. package/rihal/agents/rihal-codebase-mapper.md +1 -1
  10. package/rihal/agents/rihal-docs-auditor.md +1 -1
  11. package/rihal/agents/rihal-edge-case-hunter.md +1 -1
  12. package/rihal/agents/rihal-executor.md +1 -1
  13. package/rihal/agents/rihal-hussain-pm.md +1 -0
  14. package/rihal/agents/rihal-nyquist-auditor.md +1 -1
  15. package/rihal/agents/rihal-phase-researcher.md +1 -2
  16. package/rihal/agents/rihal-planner.md +1 -1
  17. package/rihal/agents/rihal-roadmapper.md +1 -0
  18. package/rihal/agents/rihal-security-adversary.md +1 -1
  19. package/rihal/agents/rihal-security-auditor.md +1 -1
  20. package/rihal/agents/rihal-sprint-checker.md +1 -1
  21. package/rihal/agents/rihal-verifier.md +1 -1
  22. package/rihal/bin/lib/roadmap.cjs +2 -3
  23. package/rihal/bin/rihal-tools.cjs +153 -36
  24. package/rihal/brain/sources.yaml +7 -4
  25. package/rihal/commands/audit.md +8 -0
  26. package/rihal/commands/checkpoint-preview.md +13 -0
  27. package/rihal/commands/config.md +4 -4
  28. package/rihal/commands/prfaq.md +15 -0
  29. package/rihal/commands/settings.md +2 -2
  30. package/rihal/references/agent-contracts.md +12 -0
  31. package/rihal/references/karpathy-guidelines-full.md +79 -0
  32. package/rihal/references/karpathy-guidelines.md +8 -76
  33. package/rihal/references/model-profile-resolution.md +8 -0
  34. package/rihal/references/phase-argument-parsing.md +11 -0
  35. package/rihal/references/revision-loop.md +11 -0
  36. package/rihal/references/universal-anti-patterns.md +15 -0
  37. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +10 -0
  38. package/rihal/skills/actions/2-plan/rihal-create-epics-and-stories/SKILL.md +3 -1
  39. package/rihal/skills/actions/2-plan/rihal-create-milestone/SKILL.md +3 -1
  40. package/rihal/skills/actions/2-plan/rihal-create-milestone/steps/step-10-complete.md +1 -1
  41. package/rihal/skills/actions/2-plan/rihal-create-prd/SKILL.md +13 -0
  42. package/rihal/skills/actions/2-plan/rihal-create-story/SKILL.md +4 -2
  43. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +10 -0
  44. package/rihal/skills/actions/4-implementation/rihal-sprint-planning/SKILL.md +3 -1
  45. package/rihal/skills/agents/hussain-pm/SKILL.md +8 -0
  46. package/rihal/skills/agents/hussain-sm/SKILL.md +8 -0
  47. package/rihal/templates/UAT.md +29 -0
  48. package/rihal/templates/milestone.md +2 -0
  49. package/rihal/templates/sprint.md +11 -28
  50. package/rihal/templates/summary.md +30 -0
  51. package/rihal/templates/verification-report.md +28 -0
  52. package/rihal/workflows/audit-milestone.md +34 -2
  53. package/rihal/workflows/audit.md +172 -0
  54. package/rihal/workflows/autonomous.md +67 -0
  55. package/rihal/workflows/checkpoint-preview.md +7 -0
  56. package/rihal/workflows/council.md +3 -1
  57. package/rihal/workflows/dashboard.md +2 -2
  58. package/rihal/workflows/debug.md +8 -1
  59. package/rihal/workflows/diagnose-issues.md +34 -0
  60. package/rihal/workflows/do.md +47 -3
  61. package/rihal/workflows/execute-sprint.md +11 -4
  62. package/rihal/workflows/execute.md +9 -3
  63. package/rihal/workflows/install.md +2 -2
  64. package/rihal/workflows/karpathy-audit.md +7 -14
  65. package/rihal/workflows/pause-work.md +7 -1
  66. package/rihal/workflows/prfaq.md +7 -0
  67. package/rihal/workflows/profile-user.md +2 -2
  68. package/rihal/workflows/progress.md +1 -1
  69. package/rihal/workflows/settings.md +116 -118
  70. package/rihal/workflows/sprint-planning.md +39 -8
  71. package/rihal/workflows/status.md +6 -1
  72. package/rihal/workflows/ui-phase.md +3 -3
  73. package/rihal/workflows/update.md +80 -22
  74. package/rihal/workflows/validate-phase.md +7 -1
  75. package/rihal/agents/rihal-ui-designer.md +0 -6
  76. package/rihal/workflows/config.md +0 -105
package/cli/install.js CHANGED
@@ -32,18 +32,22 @@
32
32
  * .planning/
33
33
  * council-sessions/ (empty dir, populated on first council run)
34
34
  *
35
- * Zero external dependencies. Pure Node stdlib.
35
+ * Bundled packages (devDeps, inlined by esbuild in dist/rcode.js):
36
+ * picocolors, nanospinner, fast-glob, zod, semver, diff
36
37
  *
37
38
  * Usage:
38
39
  * node cli/install.js [target-project-dir]
39
40
  * node cli/install.js --help
40
41
  *
41
42
  * Flags:
42
- * --force overwrite existing files without prompting
43
- * --yes non-interactive, accept defaults
44
- * --user <name> set user_name in config.yaml (default: $USER)
45
- * --project <name> set project_name in config.yaml (default: basename of target)
46
- * --language <lang> set communication_language (default: English)
43
+ * --force overwrite existing files without prompting
44
+ * --yes non-interactive, accept defaults
45
+ * --user <name> set user_name in config.yaml (default: $USER)
46
+ * --project <name> set project_name in config.yaml (default: basename of target)
47
+ * --language <lang> set communication_language (default: English)
48
+ * --show-diff print full unified diff for preserved files during update
49
+ * --diff-stat print +N -N summary for preserved files (default on update)
50
+ * --accept-all overwrite all user-modified files with source version
47
51
  */
48
52
 
49
53
  const fs = require('fs');
@@ -51,9 +55,51 @@ const path = require('path');
51
55
  const crypto = require('crypto');
52
56
  const os = require('os');
53
57
 
58
+ // Bundled packages — devDeps inlined by esbuild, loaded from node_modules in dev.
59
+ const pc = require('picocolors');
60
+ const { createSpinner } = require('nanospinner');
61
+ const fg = require('fast-glob');
62
+ const { z } = require('zod');
63
+ const semver = require('semver');
64
+ const { createTwoFilesPatch } = require('diff');
65
+
66
+ // Output helpers: always respect NO_COLOR / non-TTY (picocolors handles this).
67
+ const ok = (s) => pc.green('✓') + ' ' + s;
68
+ const fail = (s) => pc.red('✗') + ' ' + s;
69
+ const warn = (s) => pc.yellow('⚠') + ' ' + s;
70
+ const info = (s) => pc.cyan('→') + ' ' + s;
71
+ const dim = (s) => pc.dim(s);
72
+ const bold = (s) => pc.bold(s);
73
+
54
74
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
55
75
  const SOURCE_ROOT = path.join(PACKAGE_ROOT, 'rihal');
56
76
 
77
+ // Zod schema for .rihal/config.yaml validation (#250).
78
+ const ConfigSchema = z.object({
79
+ user_name: z.string().min(1),
80
+ project_name: z.string().min(1),
81
+ communication_language: z.string().default('English'),
82
+ mode: z.enum(['guided', 'yolo'], {
83
+ errorMap: () => ({ message: 'expected "guided" or "yolo"' }),
84
+ }).default('guided'),
85
+ model_profile: z.string().optional(),
86
+ commit_planning: z.boolean().optional(),
87
+ rihal_source_path: z.string().optional(),
88
+ workflow: z.object({
89
+ research_by_default: z.boolean().optional(),
90
+ plan_checker: z.boolean().optional(),
91
+ post_execute_gates: z.boolean().optional(),
92
+ ui_safety_gate: z.boolean().optional(),
93
+ nyquist_validation: z.boolean().optional(),
94
+ }).optional(),
95
+ output: z.object({
96
+ verbose: z.boolean().optional(),
97
+ }).optional(),
98
+ git: z.object({
99
+ branching_strategy: z.string().optional(),
100
+ }).optional(),
101
+ }).passthrough();
102
+
57
103
  /**
58
104
  * Parse command-line args into a normalized options object.
59
105
  */
@@ -71,6 +117,19 @@ function parseArgs(argv) {
71
117
  ide: 'claude', // claude, cursor, gemini (copilot = TODO)
72
118
  help: false,
73
119
  modules: [], // --module core --module execution or empty = all
120
+ // #189 — planning commit policy. null = ask interactively (or default true under --yes).
121
+ // Set true by --commit-planning, false by --no-commit-planning or --ignore-planning.
122
+ commitPlanning: null,
123
+ // #232 — non-destructive update. Preserves files the user modified after install.
124
+ nonDestructive: false,
125
+ // #232 — force-overwrite always wins.
126
+ forceOverwrite: false,
127
+ // #251 — diff display flags
128
+ showDiff: false,
129
+ diffStat: false,
130
+ acceptAll: false,
131
+ // #252 — skip update-notifier check
132
+ noUpdateCheck: false,
74
133
  };
75
134
  const positional = [];
76
135
  for (let i = 0; i < argv.length; i++) {
@@ -85,6 +144,14 @@ function parseArgs(argv) {
85
144
  else if (arg === '--mode') opts.mode = argv[++i];
86
145
  else if (arg === '--ide') opts.ide = argv[++i];
87
146
  else if (arg === '--module') opts.modules.push(argv[++i]);
147
+ else if (arg === '--commit-planning') opts.commitPlanning = true;
148
+ else if (arg === '--no-commit-planning' || arg === '--ignore-planning') opts.commitPlanning = false;
149
+ else if (arg === '--non-destructive') opts.nonDestructive = true;
150
+ else if (arg === '--force-overwrite') opts.forceOverwrite = true;
151
+ else if (arg === '--show-diff') opts.showDiff = true; // #251 full unified diff
152
+ else if (arg === '--diff-stat') opts.diffStat = true; // #251 +N -N summary (default)
153
+ else if (arg === '--accept-all') opts.acceptAll = true; // #251 overwrite all preserved
154
+ else if (arg === '--no-update-check') opts.noUpdateCheck = true; // #252
88
155
  else if (!arg.startsWith('--')) positional.push(arg);
89
156
  }
90
157
  if (positional[0]) {
@@ -95,6 +162,30 @@ function parseArgs(argv) {
95
162
  return opts;
96
163
  }
97
164
 
165
+ /**
166
+ * Resolve commit-planning preference — CLI flag wins, then interactive
167
+ * prompt (when TTY + not --yes), else GSD-style default: true.
168
+ * #189.
169
+ */
170
+ async function resolveCommitPlanning(opts) {
171
+ if (opts.commitPlanning !== null) return opts.commitPlanning;
172
+ if (opts.yes || !process.stdin.isTTY) return true; // non-interactive default
173
+
174
+ const readline = require('readline');
175
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
176
+ const prompt = (q) => new Promise(r => rl.question(q, a => r(a)));
177
+ console.log('');
178
+ console.log('📋 .planning/ holds PRDs, roadmaps, sprints, SUMMARY files.');
179
+ console.log(' Commit them to git, or keep them local?');
180
+ console.log('');
181
+ console.log(' [Y] Commit — collaborators see the same plans (default, recommended)');
182
+ console.log(' [n] Gitignore — planning stays local (good for sensitive PRDs)');
183
+ console.log('');
184
+ const answer = (await prompt(' Commit planning artifacts? [Y/n]: ')).trim().toLowerCase();
185
+ rl.close();
186
+ return !(answer === 'n' || answer === 'no');
187
+ }
188
+
98
189
  function printHelp() {
99
190
  console.log(`
100
191
  Rihal Code installer
@@ -159,17 +250,32 @@ function getPathsForIde(ide, target) {
159
250
  }
160
251
 
161
252
  /**
162
- * Recursively walk a directory and return absolute file paths.
253
+ * Walk a directory and return absolute file paths. Uses fast-glob so
254
+ * symlink cycles are never followed and patterns can be excluded via
255
+ * .rihalignore files (#249).
163
256
  */
164
- function walkFiles(dir) {
165
- const out = [];
166
- if (!fs.existsSync(dir)) return out;
167
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
168
- const full = path.join(dir, entry.name);
169
- if (entry.isDirectory()) out.push(...walkFiles(full));
170
- else if (entry.isFile()) out.push(full);
171
- }
172
- return out;
257
+ function walkFiles(dir, extraIgnore = []) {
258
+ if (!fs.existsSync(dir)) return [];
259
+ return fg.sync('**/*', {
260
+ cwd: dir,
261
+ dot: true,
262
+ onlyFiles: true,
263
+ followSymbolicLinks: false,
264
+ ignore: extraIgnore,
265
+ }).map((rel) => path.join(dir, rel));
266
+ }
267
+
268
+ /**
269
+ * Read .rihalignore patterns from a given root directory.
270
+ * Returns an array of glob-style ignore patterns (same syntax as .gitignore).
271
+ */
272
+ function readRihalIgnore(root) {
273
+ const ignoreFile = path.join(root, '.rihalignore');
274
+ if (!fs.existsSync(ignoreFile)) return [];
275
+ return fs.readFileSync(ignoreFile, 'utf8')
276
+ .split('\n')
277
+ .map((l) => l.trim())
278
+ .filter((l) => l && !l.startsWith('#'));
173
279
  }
174
280
 
175
281
  function sha256(buffer) {
@@ -284,6 +390,146 @@ function seedStarterPlanning(target, projectName) {
284
390
  return true;
285
391
  }
286
392
 
393
+ /**
394
+ * Ensure the target project's .gitignore has the rcode-managed block.
395
+ *
396
+ * Idempotent via a sentinel comment line. On first install, appends a block
397
+ * that separates:
398
+ * - installed methodology files (ignored; re-install to refresh)
399
+ * - user's project config, state, and planning artifacts (committable)
400
+ *
401
+ * If the user already has a block (marker present) we leave their customizations
402
+ * alone. This function is best-effort — never throws. A missing .gitignore
403
+ * is created. A read/write error is logged and install continues.
404
+ *
405
+ * Returns: { action: 'created' | 'appended' | 'already-present' | 'skipped-error' }
406
+ */
407
+ function ensureRcodeGitignore(target, options = {}) {
408
+ const commitPlanning = options.commitPlanning !== false; // default true
409
+ const BEGIN = '# ===== rcode-managed gitignore block (npx @hanzlaa/rcode install) =====';
410
+ const END = '# ===== end rcode-managed gitignore block =====';
411
+
412
+ const lines = [
413
+ '',
414
+ BEGIN,
415
+ '# Added automatically on first rcode install. Idempotent — safe to re-run.',
416
+ '# Edit `commit_planning` in .rihal/config.yaml to flip planning-artifact tracking.',
417
+ '',
418
+ '# Installed methodology files (regenerate with: npx @hanzlaa/rcode install)',
419
+ '.claude/',
420
+ '.rihal/bin/',
421
+ '.rihal/workflows/',
422
+ '.rihal/references/',
423
+ '.rihal/commands/',
424
+ '.rihal/skills/',
425
+ '',
426
+ '# Pulled Rihal brain content (refresh with: rcode brain pull)',
427
+ '.rihal/brain/rihal-github/',
428
+ '.rihal/brain/rihal-docs/',
429
+ '.rihal/brain/best-practices/',
430
+ '',
431
+ '# Runtime noise',
432
+ '.rihal/state.json.lock',
433
+ '.planning/debug/',
434
+ '.planning/_backup/',
435
+ ];
436
+
437
+ if (!commitPlanning) {
438
+ lines.push(
439
+ '',
440
+ '# Planning artifacts — kept local (commit_planning: false)',
441
+ '.planning/'
442
+ );
443
+ }
444
+
445
+ lines.push(
446
+ '',
447
+ '# What you DO commit:',
448
+ '# .rihal/config.yaml - project mode/language/profile/commit_planning',
449
+ '# .rihal/state.json - decisions, roadmap pointer, blockers',
450
+ '# .rihal/brain/sources.yaml - brain source manifest',
451
+ commitPlanning
452
+ ? '# .planning/ - PRD, roadmap, sprints, SUMMARY.md files'
453
+ : '# (planning artifacts are NOT committed — see commit_planning in config)',
454
+ END,
455
+ ''
456
+ );
457
+ const BLOCK = lines.join('\n');
458
+
459
+ const gitignorePath = path.join(target, '.gitignore');
460
+ try {
461
+ if (!fs.existsSync(gitignorePath)) {
462
+ fs.writeFileSync(gitignorePath, BLOCK);
463
+ return { action: 'created' };
464
+ }
465
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
466
+ // Replace existing rcode block using indexOf (regex escaping on the
467
+ // sentinel is fiddly — indexOf is deterministic and easier to audit).
468
+ function spliceBlock(text, newBlock) {
469
+ const start = text.indexOf(BEGIN);
470
+ if (start < 0) return null;
471
+ const endIdx = text.indexOf(END, start);
472
+ if (endIdx < 0) return null;
473
+ let sliceStart = start;
474
+ if (sliceStart > 0 && text[sliceStart - 1] === '\n') sliceStart -= 1;
475
+ let sliceEnd = endIdx + END.length;
476
+ if (text[sliceEnd] === '\n') sliceEnd += 1;
477
+ return text.slice(0, sliceStart) + newBlock + text.slice(sliceEnd);
478
+ }
479
+ if (existing.includes(BEGIN)) {
480
+ const rewritten = spliceBlock(existing, BLOCK);
481
+ if (rewritten !== null && rewritten !== existing) {
482
+ fs.writeFileSync(gitignorePath, rewritten);
483
+ return { action: 'updated' };
484
+ }
485
+ return { action: 'already-present' };
486
+ }
487
+ fs.writeFileSync(gitignorePath, existing + BLOCK);
488
+ return { action: 'appended' };
489
+ } catch (err) {
490
+ return { action: 'skipped-error', error: err.message };
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Install brain scaffold (sources.yaml + README.md) into .rihal/brain/ on target.
496
+ * Actual brain content lands after `brain pull` runs.
497
+ * Closes #188 — previously the package's rihal/brain/sources.yaml was never
498
+ * copied to the target at all, leaving brain pull permanently broken.
499
+ */
500
+ function installBrainScaffold(packageRoot, target) {
501
+ const srcDir = path.join(packageRoot, 'rihal', 'brain');
502
+ const destDir = path.join(target, '.rihal', 'brain');
503
+ fs.mkdirSync(destDir, { recursive: true });
504
+ let copied = 0;
505
+ for (const name of ['sources.yaml', 'README.md']) {
506
+ const src = path.join(srcDir, name);
507
+ const dest = path.join(destDir, name);
508
+ if (fs.existsSync(src) && !fs.existsSync(dest)) {
509
+ fs.copyFileSync(src, dest);
510
+ copied++;
511
+ }
512
+ }
513
+ // Also pre-seed the best-practices subfolder from the package's
514
+ // rihal/skills/_shared/ so a fresh install has working brain content
515
+ // immediately, even before brain pull runs against real upstream URLs.
516
+ const sharedSrc = path.join(packageRoot, 'rihal', 'skills', '_shared');
517
+ if (fs.existsSync(sharedSrc)) {
518
+ const bpDest = path.join(destDir, 'best-practices');
519
+ fs.mkdirSync(bpDest, { recursive: true });
520
+ for (const entry of fs.readdirSync(sharedSrc, { withFileTypes: true })) {
521
+ if (entry.isFile() && entry.name.endsWith('.md')) {
522
+ const dest = path.join(bpDest, entry.name);
523
+ if (!fs.existsSync(dest)) {
524
+ fs.copyFileSync(path.join(sharedSrc, entry.name), dest);
525
+ copied++;
526
+ }
527
+ }
528
+ }
529
+ }
530
+ return copied;
531
+ }
532
+
287
533
  /**
288
534
  * Install v1-style skills into the target project.
289
535
  *
@@ -575,6 +821,67 @@ function generateFilesManifest(plan, target) {
575
821
  return rows.map((r) => r.join(',')).join('\n') + '\n';
576
822
  }
577
823
 
824
+ /**
825
+ * Orphan sweep — remove files that were part of a previous install but aren't
826
+ * in the current plan. Reads `.rihal/_config/files-manifest.csv` from the
827
+ * previous install and computes the diff against the new plan.
828
+ *
829
+ * Closes #196 — without this, upgrading rcode leaves stale skill/command
830
+ * files around that show up as ghost slash commands in the IDE.
831
+ *
832
+ * Deliberately conservative:
833
+ * - Only removes files that appeared in the PREVIOUS manifest.
834
+ * - Never removes files the user created themselves.
835
+ * - Never touches .rihal/config.yaml, .rihal/state.json, or .planning/.
836
+ *
837
+ * Returns the number of orphan files removed.
838
+ */
839
+ function sweepStaleInstalledFiles(target, newPlan) {
840
+ const manifestPath = path.join(target, '.rihal', '_config', 'files-manifest.csv');
841
+ if (!fs.existsSync(manifestPath)) return 0;
842
+
843
+ let oldRels;
844
+ try {
845
+ const rows = fs.readFileSync(manifestPath, 'utf8').split('\n').slice(1).filter(Boolean);
846
+ oldRels = rows.map(r => r.split(',')[0]).filter(Boolean);
847
+ } catch {
848
+ return 0;
849
+ }
850
+
851
+ const newRelsSet = new Set(newPlan.map(e => e.rel.split(path.sep).join('/')));
852
+ // Safety — never sweep these, even if they somehow landed in the manifest.
853
+ const neverSweep = /^(\.rihal\/config\.yaml|\.rihal\/state\.json|\.rihal\/state\.json\.lock|\.planning\/|\.rihal\/brain\/sources\.yaml)/;
854
+
855
+ let removed = 0;
856
+ const emptyCandidateDirs = new Set();
857
+ for (const rel of oldRels) {
858
+ if (newRelsSet.has(rel)) continue;
859
+ if (neverSweep.test(rel)) continue;
860
+ const full = path.join(target, rel);
861
+ try {
862
+ if (fs.existsSync(full)) {
863
+ fs.rmSync(full, { force: true });
864
+ emptyCandidateDirs.add(path.dirname(full));
865
+ removed += 1;
866
+ }
867
+ } catch {
868
+ // ignore individual failures — sweep is best-effort
869
+ }
870
+ }
871
+
872
+ // Remove any now-empty parent dirs (bottom-up, so nested emptiness cascades).
873
+ const dirsSortedDeep = Array.from(emptyCandidateDirs).sort((a, b) => b.length - a.length);
874
+ for (const dir of dirsSortedDeep) {
875
+ try {
876
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
877
+ fs.rmdirSync(dir);
878
+ }
879
+ } catch {}
880
+ }
881
+
882
+ return removed;
883
+ }
884
+
578
885
  function readPackageVersion() {
579
886
  try {
580
887
  const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
@@ -587,13 +894,18 @@ function readPackageVersion() {
587
894
  function generateInstallManifest(opts) {
588
895
  const version = readPackageVersion();
589
896
  const newModules = opts.modules.length > 0 ? opts.modules : listAvailableModules();
590
- // Merge with existing manifest if present
897
+ // Merge with existing manifest if present; capture previous_version for rollback (#253).
591
898
  let existingModules = [];
899
+ let previousVersion = null;
592
900
  const existingPath = path.join(opts.target, '.rihal', '_config', 'manifest.yaml');
593
901
  if (fs.existsSync(existingPath)) {
594
902
  const text = fs.readFileSync(existingPath, 'utf8');
595
903
  let inModules = false;
596
904
  for (const line of text.split('\n')) {
905
+ if (line.startsWith('version:')) {
906
+ const v = line.replace('version:', '').trim();
907
+ if (semver.valid(v) && v !== version) previousVersion = v;
908
+ }
597
909
  if (line.startsWith('modules:')) { inModules = true; continue; }
598
910
  if (inModules && line.trim().startsWith('-')) { existingModules.push(line.trim().slice(1).trim()); }
599
911
  else if (inModules && !line.startsWith(' ')) { inModules = false; }
@@ -601,16 +913,14 @@ function generateInstallManifest(opts) {
601
913
  }
602
914
  const allModules = [...new Set([...existingModules, ...newModules])];
603
915
  const moduleLines = allModules.map((m) => ` - ${m}`).join('\n');
604
- return [
916
+ const lines = [
605
917
  '# Rihal v2 install manifest',
606
918
  `version: ${version}`,
607
919
  `installDate: ${new Date().toISOString()}`,
608
- 'modules:',
609
- moduleLines,
610
- 'ides:',
611
- ' - claude-code',
612
- '',
613
- ].join('\n');
920
+ ];
921
+ if (previousVersion) lines.push(`previous_version: ${previousVersion}`);
922
+ lines.push('modules:', moduleLines, 'ides:', ' - claude-code', '');
923
+ return lines.join('\n');
614
924
  }
615
925
 
616
926
  function sanitizeYamlValue(val) {
@@ -626,6 +936,7 @@ function generateConfigYaml(opts) {
626
936
  `communication_language: "${sanitizeYamlValue(opts.language)}"`,
627
937
  `mode: "${sanitizeYamlValue(opts.mode)}"`,
628
938
  `model_profile: "balanced"`,
939
+ `commit_planning: ${opts.commitPlanning !== false}`,
629
940
  `rihal_source_path: "${sanitizeYamlValue(path.dirname(path.dirname(process.argv[1])))}/"`,
630
941
  'workflow:',
631
942
  ' research_by_default: false',
@@ -638,6 +949,57 @@ function generateConfigYaml(opts) {
638
949
  ].join('\n');
639
950
  }
640
951
 
952
+ /**
953
+ * Validate a parsed config.yaml object against ConfigSchema (#250).
954
+ * Returns { valid: true } or { valid: false, errors: string[] }.
955
+ */
956
+ function validateConfig(data) {
957
+ const result = ConfigSchema.safeParse(data);
958
+ if (result.success) return { valid: true };
959
+ const errors = result.error.issues.map((issue) => {
960
+ const field = issue.path.join('.');
961
+ return ` ${field || '(root)'}: ${issue.message}`;
962
+ });
963
+ return { valid: false, errors };
964
+ }
965
+
966
+ /**
967
+ * Parse a minimal YAML key:value file into a plain object.
968
+ * Only handles scalar values — sufficient for config.yaml.
969
+ */
970
+ function parseSimpleYaml(text) {
971
+ const obj = {};
972
+ let currentParent = null;
973
+ for (const raw of text.split('\n')) {
974
+ const line = raw.replace(/#.*$/, '');
975
+ if (!line.trim()) continue;
976
+ const indent = line.match(/^(\s*)/)[1].length;
977
+ if (indent === 0) {
978
+ const colonAt = line.indexOf(':');
979
+ if (colonAt === -1) continue;
980
+ const key = line.slice(0, colonAt).trim();
981
+ let val = line.slice(colonAt + 1).trim();
982
+ if (val === '') { currentParent = key; obj[key] = {}; continue; }
983
+ currentParent = null;
984
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
985
+ if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
986
+ if (val === 'true') val = true;
987
+ else if (val === 'false') val = false;
988
+ obj[key] = val;
989
+ } else if (currentParent && indent > 0) {
990
+ const colonAt = line.indexOf(':');
991
+ if (colonAt === -1) continue;
992
+ const key = line.slice(0, colonAt).trim();
993
+ let val = line.slice(colonAt + 1).trim();
994
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
995
+ if (val === 'true') val = true;
996
+ else if (val === 'false') val = false;
997
+ obj[currentParent][key] = val;
998
+ }
999
+ }
1000
+ return obj;
1001
+ }
1002
+
641
1003
  /**
642
1004
  * Convert a markdown command/agent file to Cursor's .mdc format.
643
1005
  * Wraps the file with Cursor-specific rules frontmatter.
@@ -652,18 +1014,56 @@ function convertToCursorMdc(sourceText) {
652
1014
  /**
653
1015
  * Main install routine. Copies files, generates manifests, writes config.
654
1016
  */
655
- function install(opts) {
1017
+ async function install(opts) {
656
1018
  if (opts.help) { printHelp(); return 0; }
657
1019
 
658
- console.log(`\n🕌 Rihal Code installer ${opts.target}`);
1020
+ // Resolve commit-planning preference (interactive prompt or flag) #189.
1021
+ opts.commitPlanning = await resolveCommitPlanning(opts);
1022
+
1023
+ const pkgVersion = readPackageVersion();
1024
+ console.log(`\n🕌 ${bold('Rihal Code')} ${pc.cyan('v' + pkgVersion)} ${dim('→')} ${opts.target}`);
1025
+
1026
+ // Detect an existing install and surface it (#195).
1027
+ const existingManifestPath = path.join(opts.target, '.rihal', '_config', 'manifest.yaml');
1028
+ if (fs.existsSync(existingManifestPath)) {
1029
+ const m = fs.readFileSync(existingManifestPath, 'utf8').match(/^version:\s*(.+)$/m);
1030
+ const existingVersion = m ? m[1].trim() : 'unknown';
1031
+ const isUpgrade = semver.valid(existingVersion) && semver.valid(pkgVersion)
1032
+ ? semver.lt(existingVersion, pkgVersion)
1033
+ : existingVersion !== pkgVersion;
1034
+ if (isUpgrade) {
1035
+ console.log(' ' + info(`Upgrading ${pc.dim('v' + existingVersion)} → ${pc.green('v' + pkgVersion)} (config + state + .planning preserved)`));
1036
+ } else {
1037
+ console.log(' ' + info(`Refreshing v${existingVersion} (config + state + .planning preserved)`));
1038
+ }
1039
+ if (!opts.force) {
1040
+ console.log(dim(' Pass --force to also sweep orphaned files from the previous version.'));
1041
+ }
1042
+ }
659
1043
  if (!fs.existsSync(SOURCE_ROOT)) {
660
1044
  console.error(`✖ Source tree not found at ${SOURCE_ROOT}. Running from wrong dir?`);
661
1045
  return 1;
662
1046
  }
663
1047
 
664
- // Validate IDE
1048
+ // Validate IDE — structured error for unsupported editors (#197).
665
1049
  if (!['claude', 'cursor', 'gemini'].includes(opts.ide)) {
666
- console.error(`✖ Unknown IDE: ${opts.ide}. Supported: claude, cursor, gemini`);
1050
+ console.error(`✖ --ide ${opts.ide} is not supported in v${readPackageVersion()}.`);
1051
+ console.error('');
1052
+ console.error(' Currently supported:');
1053
+ console.error(' claude — Claude Code native (recommended)');
1054
+ console.error(' cursor — Cursor IDE');
1055
+ console.error(' gemini — Gemini CLI');
1056
+ console.error('');
1057
+ console.error(' Tracked for v3.0 (see issue #182):');
1058
+ console.error(' vscode — VS Code native extension');
1059
+ console.error(' jetbrains — IntelliJ / PyCharm');
1060
+ console.error(' zed — Zed editor');
1061
+ console.error('');
1062
+ if (/^(vscode|vs-code|code)$/i.test(opts.ide)) {
1063
+ console.error(' Workaround: if you use VS Code WITH the Claude Code extension,');
1064
+ console.error(' run `--ide claude` — the extension reads from .claude/ too.');
1065
+ console.error('');
1066
+ }
667
1067
  return 1;
668
1068
  }
669
1069
 
@@ -698,46 +1098,103 @@ function install(opts) {
698
1098
  console.log(` Modules: ${opts.modules.join(', ')}`);
699
1099
  }
700
1100
 
701
- // Copy files
1101
+ // Orphan sweep — remove files from previous install not in the new plan (#196).
1102
+ // Runs on --force only, to preserve user-edited or hand-dropped files on regular installs.
1103
+ let sweptOrphans = 0;
1104
+ if (opts.force) {
1105
+ sweptOrphans = sweepStaleInstalledFiles(opts.target, plan);
1106
+ }
1107
+
1108
+ // Load previous manifest for non-destructive mode (#232).
1109
+ // Map<rel, expectedHashFromPriorInstall> — if a file's current hash matches
1110
+ // its expected-from-prior-install hash, the user hasn't touched it → safe
1111
+ // to overwrite. If hashes differ, user customized it → preserve.
1112
+ const priorManifest = new Map();
1113
+ if (opts.nonDestructive) {
1114
+ const manifestPath = path.join(opts.target, '.rihal', '_config', 'files-manifest.csv');
1115
+ if (fs.existsSync(manifestPath)) {
1116
+ try {
1117
+ const lines = fs.readFileSync(manifestPath, 'utf8').split('\n').slice(1).filter(Boolean);
1118
+ for (const line of lines) {
1119
+ const [rel, hash] = line.split(',');
1120
+ if (rel && hash) priorManifest.set(rel, hash);
1121
+ }
1122
+ } catch {
1123
+ // best-effort — if manifest is malformed, fall back to behaving like fresh install
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ // Copy files — spinner gives feedback on long installs (#248).
702
1129
  let copied = 0;
703
1130
  let skipped = 0;
1131
+ let preserved = 0;
1132
+ const preservedFiles = [];
1133
+ const preservedDiffs = []; // { rel, insertions, deletions, patch } for #251
1134
+ const spinner = createSpinner(dim(`Installing ${plan.length} files…`), { color: 'cyan' }).start();
1135
+
704
1136
  for (const entry of plan) {
705
1137
  const destPath = path.join(opts.target, entry.rel);
1138
+ const relForward = entry.rel.split(path.sep).join('/');
706
1139
  ensureDir(path.dirname(destPath));
707
- if (fs.existsSync(destPath) && !opts.force) {
1140
+
1141
+ // Non-destructive guard (#232): preserve user-modified files.
1142
+ // --accept-all (#251) overrides: treat all files as pristine.
1143
+ if (opts.nonDestructive && !opts.forceOverwrite && !opts.acceptAll && fs.existsSync(destPath)) {
1144
+ const priorHash = priorManifest.get(relForward);
1145
+ if (priorHash) {
1146
+ const installedContent = fs.readFileSync(destPath, 'utf8');
1147
+ const currentHash = sha256(Buffer.from(installedContent));
1148
+ if (currentHash !== priorHash) {
1149
+ // Compute diff stat for display (#251)
1150
+ const srcContent = fs.readFileSync(entry.src, 'utf8');
1151
+ const patch = createTwoFilesPatch(relForward, relForward, installedContent, srcContent, 'installed', 'source');
1152
+ let ins = 0, del = 0;
1153
+ for (const line of patch.split('\n')) {
1154
+ if (line.startsWith('+') && !line.startsWith('+++')) ins++;
1155
+ if (line.startsWith('-') && !line.startsWith('---')) del++;
1156
+ }
1157
+ preserved += 1;
1158
+ preservedFiles.push(relForward);
1159
+ preservedDiffs.push({ rel: relForward, insertions: ins, deletions: del, patch });
1160
+ skipped += 1;
1161
+ continue;
1162
+ }
1163
+ // Hash matches prior install → pristine → safe to overwrite
1164
+ }
1165
+ // No prior hash → new file in this plan → install normally
1166
+ }
1167
+
1168
+ if (fs.existsSync(destPath) && !opts.force && !opts.forceOverwrite) {
708
1169
  const existingHash = sha256(fs.readFileSync(destPath));
709
1170
  const sourceHash = sha256(fs.readFileSync(entry.src));
710
1171
  if (existingHash === sourceHash) { skipped++; continue; }
711
- if (!opts.yes) {
712
- console.warn(` ⚠ ${entry.rel} differs from package version — use --force to overwrite`);
1172
+ if (!opts.yes && !opts.nonDestructive) {
1173
+ spinner.stop();
1174
+ console.warn(' ' + warn(`${entry.rel} differs from package version — use --force-overwrite to overwrite`));
1175
+ spinner.start();
713
1176
  skipped++;
714
1177
  continue;
715
1178
  }
716
1179
  }
717
1180
 
718
- // Warn if overwriting modified file
719
- if (fs.existsSync(destPath) && opts.force) {
1181
+ if (fs.existsSync(destPath) && opts.forceOverwrite) {
720
1182
  const existing = fs.readFileSync(destPath);
721
1183
  const incoming = fs.readFileSync(entry.src);
722
1184
  if (!existing.equals(incoming)) {
723
- console.log(` ⚠ Overwriting modified file: ${destPath}`);
1185
+ spinner.update({ text: dim(`overwriting ${entry.rel}`) });
724
1186
  }
725
1187
  }
726
1188
 
727
- // Read source file
728
1189
  let content = fs.readFileSync(entry.src, 'utf8');
729
-
730
- // Convert to Cursor .mdc format if needed
731
- if (entry.cursor) {
732
- content = convertToCursorMdc(content);
733
- }
734
-
735
- // Write to destination
1190
+ if (entry.cursor) content = convertToCursorMdc(content);
736
1191
  fs.writeFileSync(destPath, content, 'utf8');
737
1192
  if (entry.executable) fs.chmodSync(destPath, 0o755);
738
1193
  copied++;
739
1194
  }
740
1195
 
1196
+ spinner.success({ text: ok(`${copied} files installed`) });
1197
+
741
1198
  // Write .rihal/_config/manifest.yaml + agent-manifest.csv + files-manifest.csv
742
1199
  const configDir = path.join(opts.target, '.rihal', '_config');
743
1200
  ensureDir(configDir);
@@ -765,6 +1222,18 @@ function install(opts) {
765
1222
  if (!fs.existsSync(configPath)) {
766
1223
  fs.writeFileSync(configPath, generateConfigYaml(opts));
767
1224
  }
1225
+ // Validate config.yaml with zod schema (#250) — warn but never block install.
1226
+ try {
1227
+ const configText = fs.readFileSync(configPath, 'utf8');
1228
+ const configData = parseSimpleYaml(configText);
1229
+ const validation = validateConfig(configData);
1230
+ if (!validation.valid) {
1231
+ console.log('');
1232
+ console.log(' ' + warn('config.yaml has validation errors:'));
1233
+ for (const e of validation.errors) console.log(pc.yellow(e));
1234
+ console.log(dim(' → Edit .rihal/config.yaml to fix, then run /rihal:status'));
1235
+ }
1236
+ } catch { /* best-effort */ }
768
1237
 
769
1238
  // Seed .rihal/state.json (skip if already exists — don't overwrite on re-install unless --reset)
770
1239
  if (!fs.existsSync(stateDest)) {
@@ -801,6 +1270,14 @@ function install(opts) {
801
1270
  // Seed .planning/ with starter ROADMAP + STATE so workflows work immediately
802
1271
  const starterSeeded = seedStarterPlanning(opts.target, opts.projectName);
803
1272
 
1273
+ // Install brain scaffolding at .rihal/brain/ (sources.yaml + README).
1274
+ // Actual brain content lands after first brain pull runs.
1275
+ installBrainScaffold(PACKAGE_ROOT, opts.target);
1276
+
1277
+ // Ensure .gitignore separates installed methodology from committable artifacts.
1278
+ // Reads opts.commitPlanning to decide whether .planning/ is in the ignore block.
1279
+ const gitignoreReport = ensureRcodeGitignore(opts.target, { commitPlanning: opts.commitPlanning });
1280
+
804
1281
  // Pull Rihal brain content (v2.0 — issue #158).
805
1282
  // Runs rihal-tools brain pull as a child process. Placeholder URLs
806
1283
  // are skipped gracefully so this does not fail a fresh install.
@@ -823,53 +1300,185 @@ function install(opts) {
823
1300
 
824
1301
  // Summary
825
1302
  console.log('');
826
- console.log(` Installed: ${copied} file${copied === 1 ? '' : 's'}`);
827
- if (skillsInstalled > 0) {
828
- console.log(` Skills: ${skillsInstalled} phrase-activated (in .claude/skills/)`);
1303
+ if (opts.force && sweptOrphans > 0) console.log(' ' + info(`${sweptOrphans} stale files swept`));
1304
+ if (opts.force && existedBefore) {
1305
+ console.log(' ' + warn('config.yaml and state.json preserved (pass --reset to wipe)'));
829
1306
  }
830
1307
  if (brainReport && brainReport.ok) {
831
1308
  const pulledCount = (brainReport.pulled || []).length;
832
1309
  const skippedCount = (brainReport.skipped || []).length;
833
- console.log(` Brain: ${pulledCount} source${pulledCount === 1 ? '' : 's'} pulled` +
834
- (skippedCount ? `, ${skippedCount} skipped (placeholder URLs — see issue #162)` : ''));
1310
+ console.log(' ' + ok(`Brain: ${pulledCount} source${pulledCount === 1 ? '' : 's'} pulled` +
1311
+ (skippedCount ? `, ${skippedCount} skipped (placeholder URLs)` : '')));
835
1312
  } else if (brainReport && brainReport.error) {
836
- console.log(` Brain: skipped (${brainReport.error})`);
1313
+ console.log(' ' + dim(`Brain: skipped (${brainReport.error})`));
837
1314
  }
838
- if (skipped > 0) console.log(` Skipped: ${skipped} (already present, unchanged)`);
839
- if (opts.force && existedBefore) {
840
- console.log(' ⚠ Preserved: .rihal/config.yaml and .rihal/state.json');
841
- console.log(' Pass --reset to wipe and re-init those too.');
1315
+ if (gitignoreReport) {
1316
+ const gitMsg = {
1317
+ 'created': '.gitignore created with rcode block',
1318
+ 'appended': '.gitignore updated rcode block appended',
1319
+ 'already-present': '.gitignore rcode block already present',
1320
+ 'updated': '.gitignore rcode block refreshed',
1321
+ 'skipped-error': `.gitignore skipped (${gitignoreReport.error})`,
1322
+ }[gitignoreReport.action] || '.gitignore unchanged';
1323
+ console.log(' ' + dim(gitMsg));
842
1324
  }
1325
+ if (skipped > 0) console.log(' ' + dim(`${skipped} files skipped (unchanged)`));
1326
+
1327
+ // Diff display for preserved files (#251)
1328
+ if (preserved > 0 && opts.nonDestructive) {
1329
+ console.log('');
1330
+ console.log(' ' + warn(`${preserved} file${preserved === 1 ? '' : 's'} preserved (modified since install):`));
1331
+ for (const d of preservedDiffs.slice(0, 10)) {
1332
+ const stat = pc.green(`+${d.insertions}`) + ' ' + pc.red(`-${d.deletions}`);
1333
+ console.log(` ${dim(d.rel)} ${stat}`);
1334
+ if (opts.showDiff && d.patch) {
1335
+ for (const line of d.patch.split('\n').slice(4)) { // skip file headers
1336
+ if (line.startsWith('+')) process.stdout.write(pc.green(line) + '\n');
1337
+ else if (line.startsWith('-')) process.stdout.write(pc.red(line) + '\n');
1338
+ else if (line.startsWith('@')) process.stdout.write(pc.cyan(line) + '\n');
1339
+ else process.stdout.write(dim(line) + '\n');
1340
+ }
1341
+ }
1342
+ }
1343
+ if (preservedDiffs.length > 10) console.log(dim(` … and ${preservedDiffs.length - 10} more`));
1344
+ console.log(dim(' To overwrite: re-run with --force-overwrite | To see full diffs: --show-diff'));
1345
+ console.log('');
1346
+ }
1347
+
1348
+ // Count installed agents + commands dynamically (#190).
1349
+ const agentsDir = path.join(opts.target, '.claude', 'agents');
1350
+ const commandsDir = path.join(opts.target, '.claude', 'commands', 'rihal');
1351
+ let agentCount = 0, commandCount = 0;
1352
+ try {
1353
+ if (fs.existsSync(agentsDir)) {
1354
+ agentCount = fs.readdirSync(agentsDir).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
1355
+ }
1356
+ if (fs.existsSync(commandsDir)) {
1357
+ commandCount = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')).length;
1358
+ }
1359
+ } catch {}
1360
+
1361
+ const version = readPackageVersion();
843
1362
  console.log('');
844
- console.log(` Installed for IDE: ${opts.ide}`);
845
- console.log(` Language: ${opts.language} (change in .rihal/config.yaml → communication_language)`);
846
- console.log(` Mode: ${opts.mode} (guided=confirm at gates, yolo=autonomous)`);
847
- console.log(` Model profile: balanced`);
848
- console.log('');
849
- console.log(' Agents installed (first-class subagents):');
850
- console.log(' 🧭 rihal-sadiq — Director of Strategy');
851
- console.log(' 🏗️ rihal-waleed — CTO');
852
- console.log(' 🛡️ rihal-fatima — QA Lead');
1363
+ console.log(` ${bold('Version:')} ${pc.cyan('@hanzlaa/rcode@' + version)}`);
1364
+ console.log(` ${bold('IDE:')} ${opts.ide}`);
1365
+ console.log(` ${bold('Language:')} ${opts.language} ${dim('(change in .rihal/config.yaml)')}`);
1366
+ console.log(` ${bold('Mode:')} ${opts.mode} ${dim('(guided=confirm at gates, yolo=autonomous)')}`);
1367
+ console.log(` ${bold('Planning:')} ${opts.commitPlanning !== false ? 'committed' : 'gitignored'} ${dim('(flip: rihal-tools gitignore refresh)')}`);
853
1368
  console.log('');
854
- console.log(' Slash commands installed:');
855
- console.log(' /rihal:council parallel multi-agent council');
856
- console.log(' /rihal:status — project state dashboard');
857
- console.log(' /rihal:insert-phase — insert decimal phase for urgent work');
1369
+ console.log(` ${bold('Agents:')} ${pc.green(String(agentCount))} in .claude/agents/`);
1370
+ console.log(` ${bold('Commands:')} ${pc.green(String(commandCount))} slash commands in .claude/commands/rihal/`);
1371
+ if (skillsInstalled > 0) console.log(` ${bold('Skills:')} ${pc.green(String(skillsInstalled))} phrase-activated in .claude/skills/`);
858
1372
  console.log('');
859
1373
  if (starterSeeded) {
860
- console.log(' Starter planning scaffolded in .planning/ (ROADMAP, STATE, PROJECT)');
1374
+ console.log(' ' + ok('Starter planning scaffolded in .planning/ (ROADMAP, STATE, PROJECT)'));
861
1375
  console.log('');
862
1376
  }
863
- console.log(' Next:');
1377
+ console.log(` ${bold('Next:')}`);
864
1378
  console.log(` cd ${opts.target}`);
865
- console.log(' claude # start Claude Code (or restart if already open)');
866
- console.log(' /rihal:sprint-planning # plan your first sprint');
867
- console.log(' /rihal:do # interactive command picker');
868
- console.log(' /rihal:council <question> # multi-agent strategic answer');
1379
+ console.log(' claude # start Claude Code (reload window if already open)');
1380
+ console.log(' /rihal:progress # where you are, what\'s next');
1381
+ console.log(' /rihal:do # interactive command picker');
1382
+ console.log(' /rihal:council <q> # multi-agent strategic answer');
1383
+ console.log('');
1384
+ console.log(dim(' Refresh anytime:'));
1385
+ console.log(dim(' npx @hanzlaa/rcode@latest install # pull the latest rcode + brain'));
1386
+ console.log(dim(` /rihal:update v${version} # pin rcode to a specific version`));
869
1387
  console.log('');
870
- console.log(' If Claude Code is already running, start a new session to load commands.');
1388
+ console.log(' ' + warn('If your IDE is already open, reload the window to refresh skills/commands.'));
1389
+ console.log(dim(' Claude Code / VS Code / Cursor: Cmd+Shift+P → Reload Window'));
871
1390
  console.log('');
872
- return 0;
1391
+
1392
+ // Lightweight update check (#252) — async background, never blocks install.
1393
+ // Suppressed in non-TTY / CI or when --no-update-check is passed.
1394
+ if (!opts.noUpdateCheck && process.stdout.isTTY && !process.env.CI && !process.env.RIHAL_NO_UPDATE_NOTIFIER) {
1395
+ const { execFile } = require('child_process');
1396
+ execFile('npm', ['view', '@hanzlaa/rcode', 'version', '--json'], { timeout: 4000 }, (err, stdout) => {
1397
+ if (err) return;
1398
+ try {
1399
+ const latest = JSON.parse(stdout.trim());
1400
+ if (semver.valid(latest) && semver.gt(latest, version)) {
1401
+ console.log('');
1402
+ console.log(' ╭──────────────────────────────────────────────────────╮');
1403
+ console.log(` │ ${pc.yellow('Update available:')} ${pc.dim(version)} → ${pc.green(latest)}${' '.repeat(Math.max(0, 20 - version.length - latest.length))} │`);
1404
+ console.log(' │ Run: npx @hanzlaa/rcode@latest install . │');
1405
+ console.log(' ╰──────────────────────────────────────────────────────╯');
1406
+ console.log('');
1407
+ }
1408
+ } catch { /* ignore parse errors */ }
1409
+ });
1410
+ }
1411
+
1412
+ // Health check — smoke test that the install actually works (#193).
1413
+ const healthPass = runInstallHealthCheck(opts.target, { agentCount, commandCount, skillsInstalled });
1414
+ return healthPass ? 0 : 1;
1415
+ }
1416
+
1417
+ /**
1418
+ * Run a 5-point smoke test against the fresh install. Closes #193.
1419
+ * Returns true if all pass, false if any critical check failed.
1420
+ * Prints a clean ✓/✖ line per check.
1421
+ */
1422
+ function runInstallHealthCheck(target, counts) {
1423
+ console.log(` ${bold('Health check:')}`);
1424
+ const { execFileSync } = require('child_process');
1425
+ let fails = 0;
1426
+
1427
+ function check(label, fn) {
1428
+ try {
1429
+ const out = fn();
1430
+ console.log(` ${ok(label)}${out ? dim(' — ' + out) : ''}`);
1431
+ } catch (err) {
1432
+ fails += 1;
1433
+ console.log(` ${fail(label)} ${pc.red('—')} ${String(err.message || err).slice(0, 120)}`);
1434
+ }
1435
+ }
1436
+
1437
+ check('rihal-tools.cjs runs', () => {
1438
+ const toolsPath = path.join(target, '.rihal', 'bin', 'rihal-tools.cjs');
1439
+ if (!fs.existsSync(toolsPath)) throw new Error('bin/rihal-tools.cjs not installed');
1440
+ execFileSync('node', ['-c', toolsPath], { stdio: 'pipe' });
1441
+ return 'syntax ok';
1442
+ });
1443
+
1444
+ check('.rihal/config.yaml present', () => {
1445
+ const p = path.join(target, '.rihal', 'config.yaml');
1446
+ if (!fs.existsSync(p)) throw new Error('missing');
1447
+ const text = fs.readFileSync(p, 'utf8');
1448
+ if (!/user_name:|project_name:/.test(text)) throw new Error('config.yaml incomplete');
1449
+ return `${fs.statSync(p).size} bytes`;
1450
+ });
1451
+
1452
+ check('.rihal/state.json parses', () => {
1453
+ const p = path.join(target, '.rihal', 'state.json');
1454
+ if (!fs.existsSync(p)) throw new Error('missing');
1455
+ JSON.parse(fs.readFileSync(p, 'utf8'));
1456
+ return 'valid JSON';
1457
+ });
1458
+
1459
+ check('agents installed', () => {
1460
+ if ((counts.agentCount || 0) < 20) throw new Error(`only ${counts.agentCount} agents (expected ≥ 20)`);
1461
+ return `${counts.agentCount}`;
1462
+ });
1463
+
1464
+ check('skills + commands installed', () => {
1465
+ const issues = [];
1466
+ if ((counts.skillsInstalled || 0) < 20) issues.push(`${counts.skillsInstalled} skills`);
1467
+ if ((counts.commandCount || 0) < 20) issues.push(`${counts.commandCount} commands`);
1468
+ if (issues.length) throw new Error(`low count: ${issues.join(', ')}`);
1469
+ return `${counts.skillsInstalled} skills + ${counts.commandCount} commands`;
1470
+ });
1471
+
1472
+ if (fails > 0) {
1473
+ console.log('');
1474
+ console.log(' ' + fail(`${fails} health check${fails === 1 ? '' : 's'} failed — install may be broken.`));
1475
+ console.log(dim(' Debug: node .rihal/bin/rihal-tools.cjs state read && ls -la .rihal/'));
1476
+ console.log(dim(' Reinstall: npx @hanzlaa/rcode install . --force'));
1477
+ console.log('');
1478
+ return false;
1479
+ }
1480
+ console.log('');
1481
+ return true;
873
1482
  }
874
1483
 
875
1484
  async function main() {
@@ -909,9 +1518,7 @@ async function main() {
909
1518
  }
910
1519
  }
911
1520
 
912
- try {
913
- process.exit(install(opts));
914
- } catch (err) {
1521
+ install(opts).then(code => process.exit(code)).catch(err => {
915
1522
  if (err.code === 'EACCES' || err.code === 'EPERM') {
916
1523
  console.error(`✖ Permission denied: ${err.path || err.message}`);
917
1524
  process.exit(1);
@@ -923,7 +1530,7 @@ async function main() {
923
1530
  console.error(`✖ Install failed: ${err.message}`);
924
1531
  if (process.env.DEBUG) console.error(err.stack);
925
1532
  process.exit(1);
926
- }
1533
+ });
927
1534
  }
928
1535
 
929
1536
  if (require.main === module) main();
@@ -933,10 +1540,10 @@ if (require.main === module) main();
933
1540
  * Converts the index.js-style (args, ctx) signature into a cli/install.js
934
1541
  * parseArgs-compatible argv and runs install().
935
1542
  */
936
- function runFromCli(args /* , ctx */) {
1543
+ async function runFromCli(args /* , ctx */) {
937
1544
  const argv = Array.isArray(args) ? args : [];
938
1545
  const opts = parseArgs(argv);
939
- const code = install(opts);
1546
+ const code = await install(opts);
940
1547
  if (code !== 0) process.exit(code);
941
1548
  }
942
1549