@hanzlaa/rcode 3.4.20 → 3.4.22

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.
@@ -149,7 +149,7 @@ Identical to \`/rihal-${cmdName}\`. See the workflow file for the canonical outp
149
149
  `;
150
150
  }
151
151
 
152
- function main(packageRoot, targetSkillsDir, version) {
152
+ function main(packageRoot, targetSkillsDir, version, options = {}) {
153
153
  if (!fs.existsSync(targetSkillsDir)) {
154
154
  fs.mkdirSync(targetSkillsDir, { recursive: true });
155
155
  }
@@ -158,11 +158,20 @@ function main(packageRoot, targetSkillsDir, version) {
158
158
  const commandsDir = path.join(packageRoot, 'rihal', 'commands');
159
159
  if (!fs.existsSync(commandsDir)) {
160
160
  console.warn(`[generate-command-skills] commands dir not found: ${commandsDir}`);
161
- return { generated: 0, skipped: 0 };
161
+ return { generated: 0, skipped: 0, skippedGlobal: 0 };
162
162
  }
163
163
 
164
+ // Issue #679: skip stubs whose name already exists in ~/.claude/skills/.
165
+ // Otherwise the slash picker shows the same /rihal-* twice.
166
+ const os = require('os');
167
+ const globalSkillsDir = path.join(os.homedir(), '.claude', 'skills');
168
+ const globalSkills = (options.skipGlobalDuplicates && fs.existsSync(globalSkillsDir))
169
+ ? new Set(fs.readdirSync(globalSkillsDir).filter(n => n.startsWith('rihal-')))
170
+ : new Set();
171
+
164
172
  let generated = 0;
165
173
  let skipped = 0;
174
+ let skippedGlobal = 0;
166
175
 
167
176
  for (const file of fs.readdirSync(commandsDir)) {
168
177
  if (!file.endsWith('.md')) continue;
@@ -177,6 +186,28 @@ function main(packageRoot, targetSkillsDir, version) {
177
186
  continue;
178
187
  }
179
188
 
189
+ if (globalSkills.has(skillName)) {
190
+ // Global skill of same name exists — installing a sidebar stub here would
191
+ // duplicate the entry in Claude Code's slash picker. Also clean up any
192
+ // previously-generated stub with the same name.
193
+ const existingStub = path.join(targetSkillsDir, skillName);
194
+ if (fs.existsSync(existingStub)) {
195
+ try {
196
+ const existingFile = path.join(existingStub, 'SKILL.md');
197
+ if (fs.existsSync(existingFile)) {
198
+ const text = fs.readFileSync(existingFile, 'utf8');
199
+ // Only delete previously-generated stubs (marker present) — never
200
+ // user customizations.
201
+ if (/^generated:\s*true/m.test(text)) {
202
+ fs.rmSync(existingStub, { recursive: true, force: true });
203
+ }
204
+ }
205
+ } catch { /* non-fatal */ }
206
+ }
207
+ skippedGlobal++;
208
+ continue;
209
+ }
210
+
180
211
  const cmdPath = path.join(commandsDir, file);
181
212
  const cmdText = fs.readFileSync(cmdPath, 'utf8');
182
213
  const cmdFm = parseFrontmatter(cmdText);
package/cli/index.js CHANGED
@@ -3,14 +3,14 @@
3
3
  * Rihal Code CLI
4
4
  *
5
5
  * Usage:
6
- * npx @hanzlahabib/rihal-code init → scaffold .rihal/ in current project
7
- * npx @hanzlahabib/rihal-code dashboard → start the Diwan view-only dashboard
8
- * npx @hanzlahabib/rihal-code serve → alias for dashboard
9
- * npx @hanzlahabib/rihal-code digest → print compact agent digests
10
- * npx @hanzlahabib/rihal-code team → list the team roster
11
- * npx @hanzlahabib/rihal-code doctor → compliance check
12
- * npx @hanzlahabib/rihal-code version → print version
13
- * npx @hanzlahabib/rihal-code help → this message
6
+ * npx @hanzlaa/rcode init → scaffold .rihal/ in current project
7
+ * npx @hanzlaa/rcode dashboard → start the Diwan view-only dashboard
8
+ * npx @hanzlaa/rcode serve → alias for dashboard
9
+ * npx @hanzlaa/rcode digest → print compact agent digests
10
+ * npx @hanzlaa/rcode team → list the team roster
11
+ * npx @hanzlaa/rcode doctor → compliance check
12
+ * npx @hanzlaa/rcode version → print version
13
+ * npx @hanzlaa/rcode help → this message
14
14
  */
15
15
 
16
16
  const path = require('path');
package/cli/install.js CHANGED
@@ -275,7 +275,7 @@ function printInstallHeader(targetVersion) {
275
275
  pc.cyan('│') + ' ' + dim('A persistent context-brain for your editor') + ' ' + pc.cyan('│'),
276
276
  pc.cyan('│') + ' ' + pc.cyan('│'),
277
277
  pc.cyan('│') + ' ' + dim('version ') + pc.green('v' + v) + ' ' + pc.cyan('│'),
278
- pc.cyan('│') + ' ' + dim('docs ') + 'github.com/hanzla-habib/rihal-code ' + pc.cyan('│'),
278
+ pc.cyan('│') + ' ' + dim('docs ') + 'github.com/hanzlahabib/rihal-code ' + pc.cyan('│'),
279
279
  pc.cyan('│') + ' ' + pc.cyan('│'),
280
280
  pc.cyan('╰───────────────────────────────────────────────────────────╯'),
281
281
  '',
@@ -370,11 +370,32 @@ async function resolveIde(opts) {
370
370
  async function resolveCommitPlanning(opts) {
371
371
  if (opts.commitPlanning !== null) return opts.commitPlanning;
372
372
  if (opts.noPrompt || opts.global) return false; // global install: no planning artifacts
373
- if (opts.yes || !process.stdin.isTTY) return true; // non-interactive default
374
373
 
374
+ // Issue #685: on re-install, read the existing .rihal/config.yaml and use
375
+ // its commit_planning value as the default. Otherwise the new prompt
376
+ // answer overwrites .gitignore but NOT config.yaml, leaving two sources of
377
+ // truth that silently diverge. Users on re-install almost always want to
378
+ // KEEP their existing setting unless they explicitly pass --commit-planning.
379
+ let existingValue = null;
380
+ try {
381
+ const cfgPath = path.join(opts.target, '.rihal', 'config.yaml');
382
+ if (fs.existsSync(cfgPath)) {
383
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
384
+ const m = cfg.match(/^commit_planning:\s*(true|false)\s*$/m);
385
+ if (m) existingValue = m[1] === 'true';
386
+ }
387
+ } catch { /* fall through to prompt */ }
388
+
389
+ if (opts.yes || !process.stdin.isTTY) {
390
+ return existingValue !== null ? existingValue : true; // honor existing on re-install
391
+ }
392
+
393
+ const initialValue = existingValue === false ? 'gitignore' : 'commit';
375
394
  const choice = await clack.select({
376
- message: '📋 .planning/ holds PRDs, roadmaps, sprints, SUMMARY files. How should they be tracked?',
377
- initialValue: 'commit',
395
+ message: existingValue !== null
396
+ ? '📋 .planning/ tracking — current setting preserved unless you change it.'
397
+ : '📋 .planning/ holds PRDs, roadmaps, sprints, SUMMARY files. How should they be tracked?',
398
+ initialValue,
378
399
  options: [
379
400
  { value: 'commit', label: 'Commit', hint: 'collaborators see the same plans (recommended)' },
380
401
  { value: 'gitignore', label: 'Gitignore', hint: 'planning stays local (good for sensitive PRDs)' },
@@ -548,7 +569,15 @@ function seedStarterPlanning(target, projectName) {
548
569
  const today = new Date().toISOString().slice(0, 10);
549
570
  const name = projectName || path.basename(target);
550
571
 
572
+ // Stub planning files: clearly marked as install templates so users (and
573
+ // /rihal-new-project Step 0.5 detection) can tell them apart from real
574
+ // planning artifacts. See issues #670 #671 #676.
575
+ const STUB_BANNER =
576
+ `<!-- INSTALL STUB — overwritten by /rihal-new-project. Delete this file or run\n` +
577
+ ` /rihal-new-project before committing. See https://github.com/hanzlahabib/rihal-code/issues/670 -->\n\n`;
578
+
551
579
  fs.writeFileSync(projectPath,
580
+ STUB_BANNER +
552
581
  `# ${name}\n\n` +
553
582
  `**One-line:** Describe what this project is in one sentence.\n\n` +
554
583
  `## Vision\n\n` +
@@ -558,6 +587,7 @@ function seedStarterPlanning(target, projectName) {
558
587
  );
559
588
 
560
589
  fs.writeFileSync(roadmapPath,
590
+ STUB_BANNER +
561
591
  `# ${name} — Roadmap\n\n` +
562
592
  `**Milestone: M1 — Initial Delivery** (v1.0)\n` +
563
593
  `Started: ${today} · Current\n\n` +
@@ -572,6 +602,7 @@ function seedStarterPlanning(target, projectName) {
572
602
  );
573
603
 
574
604
  fs.writeFileSync(statePath,
605
+ STUB_BANNER +
575
606
  `# ${name} — State\n\n` +
576
607
  `**Last updated:** ${today}\n` +
577
608
  `**Milestone:** M1 — Initial Delivery\n` +
@@ -580,27 +611,33 @@ function seedStarterPlanning(target, projectName) {
580
611
  `---\n\n` +
581
612
  `## Decisions\n\n_None yet._\n\n` +
582
613
  `## Blockers\n\n_None._\n\n` +
583
- `## Next Action\n\nSay "plan a sprint" or run \`/rihal-sprint-planning\` to break Phase 01 into stories.\n`
614
+ `## Next Action\n\nRun \`/rihal-new-project <description>\` to bootstrap, or \`/rihal-sprint-planning\` once a real phase exists.\n`
584
615
  );
585
616
 
586
- // Also pre-seed .rihal/state.json with Phase 01 so sprint tools work
587
- // immediately (otherwise auto-init in rihal-tools.cjs creates state with
588
- // empty phases[], requiring manual set-phase before sprint add).
617
+ // Issue #670: do NOT pre-seed .rihal/state.json with a fake project +
618
+ // "Setup & Scaffolding" phase. That made every fresh install look like a
619
+ // real initialized project and broke /rihal-new-project Step 0.5 detection.
620
+ //
621
+ // Write a minimal shell with _seeded_stub:true so:
622
+ // - rihal-tools doesn't have to re-init on first call (avoids race)
623
+ // - /rihal-new-project Step 0.5 (issue #671) can detect "stub" reliably
624
+ // - sprint tools that previously relied on phase 01 will surface a clear
625
+ // "no phases yet — run /rihal-new-project first" error instead of
626
+ // silently operating on a fake phase
589
627
  const rihalStateJson = path.join(target, '.rihal', 'state.json');
590
628
  if (!fs.existsSync(rihalStateJson)) {
591
629
  const now = new Date().toISOString();
592
630
  const state = {
593
631
  version: '1',
594
- project: name,
632
+ project: null,
633
+ _seeded_stub: true,
595
634
  created: now,
596
635
  updated: now,
597
- current_phase: '01',
636
+ current_phase: null,
598
637
  current_plan: 0,
599
638
  current_sprint: null,
600
- milestone: 'M1 — Initial Delivery',
601
- phases: [
602
- { id: '01', name: 'Setup & Scaffolding', status: 'planned' }
603
- ],
639
+ milestone: null,
640
+ phases: [],
604
641
  executions: [],
605
642
  decisions: [],
606
643
  blockers: [],
@@ -857,16 +894,26 @@ function installBrainScaffold(packageRoot, target) {
857
894
  *
858
895
  * A skill is marked internal by adding `internal: true` to its SKILL.md frontmatter.
859
896
  */
860
- function installSkills(packageRoot, target) {
897
+ function installSkills(packageRoot, target, options = {}) {
861
898
  const skillsSource = path.join(packageRoot, 'rihal/skills');
862
899
  const skillsDest = path.join(target, '.claude/skills');
863
900
  const internalDest = path.join(target, '.rihal/skills');
864
901
 
865
- if (!fs.existsSync(skillsSource)) return 0;
902
+ if (!fs.existsSync(skillsSource)) return { count: 0, skippedGlobal: 0 };
866
903
  fs.mkdirSync(skillsDest, { recursive: true });
867
904
  fs.mkdirSync(internalDest, { recursive: true });
868
905
 
906
+ // Issue #679: when ~/.claude/skills/<name>/ already exists with the rihal-
907
+ // prefix, Claude Code reads from BOTH global and project, showing every
908
+ // /rihal-* twice in the slash picker. Skip the project copy for any rihal-*
909
+ // skill that already lives in the global skills dir.
910
+ const globalSkillsDir = path.join(os.homedir(), '.claude', 'skills');
911
+ const globalRihalSkills = (options.skipGlobalDuplicates && fs.existsSync(globalSkillsDir))
912
+ ? new Set(fs.readdirSync(globalSkillsDir).filter(n => n.startsWith('rihal-')))
913
+ : new Set();
914
+
869
915
  let count = 0;
916
+ let skippedGlobal = 0;
870
917
 
871
918
  function isInternalSkill(skillDir) {
872
919
  const skillMd = path.join(skillDir, 'SKILL.md');
@@ -875,6 +922,13 @@ function installSkills(packageRoot, target) {
875
922
  return /^internal:\s*true\s*$/m.test(text);
876
923
  }
877
924
 
925
+ function hasLocalOverride(destDir) {
926
+ if (!fs.existsSync(destDir)) return false;
927
+ try {
928
+ return fs.readdirSync(destDir).some(f => f.endsWith('.local.md'));
929
+ } catch { return false; }
930
+ }
931
+
878
932
  function walkForSkills(dir) {
879
933
  if (!fs.existsSync(dir)) return;
880
934
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -885,9 +939,23 @@ function installSkills(packageRoot, target) {
885
939
  const destName = entry.name.startsWith('rihal-')
886
940
  ? entry.name
887
941
  : `rihal-${entry.name}`;
888
- const dest = isInternalSkill(src)
942
+ const internal = isInternalSkill(src);
943
+ const dest = internal
889
944
  ? path.join(internalDest, destName) // internal → .rihal/skills/
890
945
  : path.join(skillsDest, destName); // user-facing → .claude/skills/
946
+
947
+ // Skip user-facing (non-internal) rihal-* skills when the same name
948
+ // exists globally — UNLESS the user has a *.local.md override on the
949
+ // project copy, in which case we always preserve their customization.
950
+ if (!internal && globalRihalSkills.has(destName) && !hasLocalOverride(dest)) {
951
+ // Also remove the existing project copy (left over from previous
952
+ // installs that didn't dedup) so it stops showing in the picker.
953
+ if (fs.existsSync(dest)) {
954
+ try { fs.rmSync(dest, { recursive: true, force: true }); } catch { /* non-fatal */ }
955
+ }
956
+ skippedGlobal++;
957
+ continue;
958
+ }
891
959
  copyDirRecursive(src, dest);
892
960
  count++;
893
961
  } else {
@@ -900,7 +968,7 @@ function installSkills(packageRoot, target) {
900
968
  walkForSkills(path.join(skillsSource, bucket));
901
969
  }
902
970
 
903
- return count;
971
+ return { count, skippedGlobal };
904
972
  }
905
973
 
906
974
  /**
@@ -1409,6 +1477,18 @@ function convertToCursorMdc(sourceText) {
1409
1477
  async function install(opts) {
1410
1478
  if (opts.help) { printHelp(); return 0; }
1411
1479
 
1480
+ // Issue #680: --reset alone is a footgun — silently does nothing. Fail
1481
+ // fast with a clear message before any work happens.
1482
+ if (opts.reset && !opts.force) {
1483
+ console.log('');
1484
+ console.log(' ' + warn('--reset has no effect without --force.'));
1485
+ console.log(' ' + dim(' --reset wipes config.yaml and state.json. To prevent accidental data loss,'));
1486
+ console.log(' ' + dim(' it must be paired with --force. Re-run as:'));
1487
+ console.log(' ' + dim(' rcode install --reset --force'));
1488
+ console.log('');
1489
+ return 2;
1490
+ }
1491
+
1412
1492
  const pkgVersion = readPackageVersion();
1413
1493
 
1414
1494
  // Header banner — only shown for interactive runs to keep CI/non-TTY logs terse.
@@ -1738,8 +1818,10 @@ async function install(opts) {
1738
1818
  const configDir = path.join(opts.target, '.rihal', '_config');
1739
1819
  ensureDir(configDir);
1740
1820
  fs.writeFileSync(path.join(configDir, 'manifest.yaml'), generateInstallManifest(opts));
1741
- // Install skills + sidebar stubs globally
1742
- let skillsInstalled = installSkills(PACKAGE_ROOT, opts.target);
1821
+ // Install skills + sidebar stubs globally — never dedup against globals,
1822
+ // because in --global mode the target IS the global dir.
1823
+ const skillsResult = installSkills(PACKAGE_ROOT, opts.target);
1824
+ let skillsInstalled = skillsResult.count;
1743
1825
  try {
1744
1826
  const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
1745
1827
  const stubsDir = path.join(opts.target, '.claude', 'skills');
@@ -1815,6 +1897,7 @@ async function install(opts) {
1815
1897
  plan.length = 0;
1816
1898
  filtered.forEach(e => plan.push(e));
1817
1899
  }
1900
+
1818
1901
  } catch { /* non-fatal — skip detection on permission errors */ }
1819
1902
  }
1820
1903
 
@@ -1839,11 +1922,34 @@ async function install(opts) {
1839
1922
  } else if (opts.force && (fs.existsSync(configPath) || fs.existsSync(stateDest))) {
1840
1923
  existedBefore = true;
1841
1924
  }
1925
+ // Note: --reset without --force is rejected at the top of install() (#680).
1842
1926
 
1843
1927
  // Write .rihal/config.yaml (user_name, project_name, language, mode)
1844
1928
  // Note: config.yaml is user data and should NOT be overwritten on --force (unless --reset)
1845
1929
  if (!fs.existsSync(configPath)) {
1846
1930
  fs.writeFileSync(configPath, generateConfigYaml(opts));
1931
+ } else {
1932
+ // Issue #685: re-install path. config.yaml is preserved BUT if the user
1933
+ // just changed commit_planning via the prompt/flag, .gitignore will be
1934
+ // rewritten with the new value while config.yaml keeps the old one,
1935
+ // creating a silent drift. Update only commit_planning in-place
1936
+ // (preserve everything else the user may have customized).
1937
+ try {
1938
+ const before = fs.readFileSync(configPath, 'utf8');
1939
+ const desired = opts.commitPlanning !== false;
1940
+ const re = /^commit_planning:\s*(true|false)\s*$/m;
1941
+ const match = before.match(re);
1942
+ const currentInFile = match ? match[1] === 'true' : null;
1943
+ if (match && currentInFile !== desired) {
1944
+ const updated = before.replace(re, `commit_planning: ${desired}`);
1945
+ fs.writeFileSync(configPath, updated);
1946
+ console.log(' ' + dim(`Updated commit_planning in config.yaml (${currentInFile} → ${desired}) — closes #685.`));
1947
+ } else if (!match) {
1948
+ // Older config without the key — append it so the next read finds it.
1949
+ const appended = before.replace(/\n*$/, '') + `\ncommit_planning: ${desired}\n`;
1950
+ fs.writeFileSync(configPath, appended);
1951
+ }
1952
+ } catch { /* best-effort — never fail install on this */ }
1847
1953
  }
1848
1954
  // Validate config.yaml with zod schema (#250) — warn but never block install.
1849
1955
  try {
@@ -1901,7 +2007,16 @@ async function install(opts) {
1901
2007
 
1902
2008
  // Install v1-style phrase-activated skills (scaffold-project, create-prd,
1903
2009
  // retrospective, etc.) into .claude/skills/ alongside the v2 agents/commands.
1904
- let skillsInstalled = installSkills(PACKAGE_ROOT, opts.target);
2010
+ // Issue #679: skip rihal-* skills that already exist in ~/.claude/skills/
2011
+ // (global precedence) so the slash picker doesn't show every command twice.
2012
+ // Reuse the isProjectInstall flag declared earlier in this scope.
2013
+ const skillsResult = installSkills(PACKAGE_ROOT, opts.target, {
2014
+ skipGlobalDuplicates: isProjectInstall,
2015
+ });
2016
+ let skillsInstalled = skillsResult.count;
2017
+ if (skillsResult.skippedGlobal > 0) {
2018
+ console.log(' ' + dim(`Skipped ${skillsResult.skippedGlobal} project-level rihal skills (global ones in ~/.claude/skills/ take precedence) — closes #679.`));
2019
+ }
1905
2020
 
1906
2021
  // Generate install-time skill stubs that mirror sidebar-worthy slash commands.
1907
2022
  // Source codebase stays clean — these stubs only exist at the install
@@ -1910,11 +2025,16 @@ async function install(opts) {
1910
2025
  try {
1911
2026
  const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
1912
2027
  const stubsDir = path.join(opts.target, '.claude', 'skills');
1913
- const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion());
2028
+ const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion(), {
2029
+ skipGlobalDuplicates: isProjectInstall,
2030
+ });
1914
2031
  if (result.generated > 0) {
1915
2032
  console.log(' ' + dim(`${result.generated} sidebar skill stub${result.generated === 1 ? '' : 's'} generated for command discoverability`));
1916
2033
  skillsInstalled += result.generated;
1917
2034
  }
2035
+ if (result.skippedGlobal > 0) {
2036
+ console.log(' ' + dim(`Skipped ${result.skippedGlobal} sidebar stub${result.skippedGlobal === 1 ? '' : 's'} that duplicate global ~/.claude/skills/ — closes #679.`));
2037
+ }
1918
2038
  } catch (err) {
1919
2039
  // Non-fatal: install succeeds without sidebar stubs
1920
2040
  console.log(' ' + dim(`(sidebar stub generation skipped: ${err.message})`));
@@ -2023,6 +2143,7 @@ async function install(opts) {
2023
2143
  const agentsDir = idePaths.agentsDir;
2024
2144
  const commandsDir = idePaths.commandsDir;
2025
2145
  let agentCount = 0, commandCount = 0;
2146
+ let agentsFromGlobal = false, commandsFromGlobal = false;
2026
2147
  try {
2027
2148
  if (fs.existsSync(agentsDir)) {
2028
2149
  agentCount = fs.readdirSync(agentsDir).filter(f => (f.startsWith('rihal-') || f.startsWith('rcode-')) && (f.endsWith('.md') || f.endsWith('.mdc'))).length;
@@ -2034,6 +2155,22 @@ async function install(opts) {
2034
2155
  : f => f.endsWith('.md') || f.endsWith('.mdc');
2035
2156
  commandCount = fs.readdirSync(commandsDir).filter(commandFilter).length;
2036
2157
  }
2158
+ // Issue #669 — when global precedence applied (project copies were
2159
+ // intentionally removed), count from ~/.claude/ instead so the summary
2160
+ // doesn't lie about the install state.
2161
+ if (agentCount === 0 || commandCount === 0) {
2162
+ const os = require('os');
2163
+ const homeAgents = path.join(os.homedir(), '.claude/agents');
2164
+ const homeCommands = path.join(os.homedir(), '.claude/commands');
2165
+ if (agentCount === 0 && fs.existsSync(homeAgents)) {
2166
+ const n = fs.readdirSync(homeAgents).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2167
+ if (n > 0) { agentCount = n; agentsFromGlobal = true; }
2168
+ }
2169
+ if (commandCount === 0 && fs.existsSync(homeCommands)) {
2170
+ const n = fs.readdirSync(homeCommands).filter(f => f.startsWith('rihal-') && f.endsWith('.md')).length;
2171
+ if (n > 0) { commandCount = n; commandsFromGlobal = true; }
2172
+ }
2173
+ }
2037
2174
  } catch {}
2038
2175
 
2039
2176
  const version = readPackageVersion();
@@ -2047,8 +2184,8 @@ async function install(opts) {
2047
2184
  // Show the actual install paths so cursor/gemini/antigravity output is accurate
2048
2185
  const relAgents = path.relative(opts.target, idePaths.agentsDir) || idePaths.agentsDir;
2049
2186
  const relCommands = path.relative(opts.target, idePaths.commandsDir) || idePaths.commandsDir;
2050
- console.log(` ${bold('Agents:')} ${pc.green(String(agentCount))} in ${relAgents}/`);
2051
- console.log(` ${bold('Commands:')} ${pc.green(String(commandCount))} slash commands in ${relCommands}/`);
2187
+ console.log(` ${bold('Agents:')} ${pc.green(String(agentCount))} in ${agentsFromGlobal ? '~/.claude/agents/ (global)' : relAgents + '/'}`);
2188
+ console.log(` ${bold('Commands:')} ${pc.green(String(commandCount))} slash commands in ${commandsFromGlobal ? '~/.claude/commands/ (global)' : relCommands + '/'}`);
2052
2189
  if (skillsInstalled > 0) console.log(` ${bold('Skills:')} ${pc.green(String(skillsInstalled))} phrase-activated`);
2053
2190
  console.log('');
2054
2191
  if (starterSeeded) {
@@ -124,6 +124,25 @@ function verifyClaudeInstall(cwd, packageRoot) {
124
124
  }
125
125
  }
126
126
 
127
+ // Issue #664 — global precedence fallback.
128
+ // The installer (cli/install.js ~line 1773) intentionally removes project-
129
+ // level .claude/agents/rihal-*.md when the user's ~/.claude/ already has
130
+ // them, to avoid duplicate commands. Without this fallback the verifier
131
+ // reports 0 agents on every successful install in that scenario.
132
+ if (installedAgents.size === 0) {
133
+ try {
134
+ const os = require('os');
135
+ const globalAgentsDir = path.join(os.homedir(), '.claude/agents');
136
+ if (fs.existsSync(globalAgentsDir)) {
137
+ for (const f of fs.readdirSync(globalAgentsDir)) {
138
+ if (f.startsWith('rihal-') && f.endsWith('.md')) {
139
+ installedAgents.add(f.replace(/^rihal-/, '').replace(/\.md$/, ''));
140
+ }
141
+ }
142
+ }
143
+ } catch { /* non-fatal — permission errors etc. */ }
144
+ }
145
+
127
146
  // Actions: .claude/skills/<bare-name>/ — exclude rihal-* dirs (those are
128
147
  // either agent stubs or command stubs, never action skills).
129
148
  const allInstalled = readInstalledDirs(skillsDir);
@@ -2,11 +2,11 @@
2
2
  * rihal-code set-profile — change the model profile for the current project
3
3
  *
4
4
  * Usage:
5
- * npx @hanzlahabib/rihal-code set-profile balanced
6
- * npx @hanzlahabib/rihal-code set-profile quality
7
- * npx @hanzlahabib/rihal-code set-profile budget
8
- * npx @hanzlahabib/rihal-code set-profile inherit
9
- * npx @hanzlahabib/rihal-code set-profile # show current
5
+ * npx @hanzlaa/rcode set-profile balanced
6
+ * npx @hanzlaa/rcode set-profile quality
7
+ * npx @hanzlaa/rcode set-profile budget
8
+ * npx @hanzlaa/rcode set-profile inherit
9
+ * npx @hanzlaa/rcode set-profile # show current
10
10
  *
11
11
  * This is a thin wrapper over `rihal-code config model_profile <name>`.
12
12
  * All config read/write goes through cli/lib/config.cjs, which handles
package/cli/show-model.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * rihal-code show-model — print the resolved model for an agent (or all agents)
3
3
  *
4
4
  * Usage:
5
- * npx @hanzlahabib/rihal-code show-model # all agents in current profile
6
- * npx @hanzlahabib/rihal-code show-model waleed # single agent
7
- * npx @hanzlahabib/rihal-code show-model --profile=quality # different profile
5
+ * npx @hanzlaa/rcode show-model # all agents in current profile
6
+ * npx @hanzlaa/rcode show-model waleed # single agent
7
+ * npx @hanzlaa/rcode show-model --profile=quality # different profile
8
8
  */
9
9
 
10
10
  const {
package/cli/uninstall.js CHANGED
@@ -250,8 +250,15 @@ function isKnownSkillName(name) {
250
250
  /**
251
251
  * Build the list of files/dirs (relative to cwd) that the uninstall plan
252
252
  * will delete or mutate. Used to feed `tar --files-from=-`.
253
+ *
254
+ * @param {object} plan — uninstall plan
255
+ * @param {string} cwd — project root
256
+ * @param {object} [options]
257
+ * @param {boolean} [options.purge=false] — when true, also include .rihal/
258
+ * and .planning/ in the backup so --purge users can recover state.json,
259
+ * decisions, and planning artifacts. Issue #683.
253
260
  */
254
- function planToPathList(plan, cwd) {
261
+ function planToPathList(plan, cwd, options = {}) {
255
262
  const paths = [];
256
263
 
257
264
  for (const name of plan.claude.skills) {
@@ -278,6 +285,26 @@ function planToPathList(plan, cwd) {
278
285
  paths.push('AGENTS.md');
279
286
  }
280
287
 
288
+ // Issue #683: --purge wipes .rihal/ AND .planning/ but the backup never
289
+ // included them. User loses state.json, decisions, planning artifacts with
290
+ // no recovery. Add them when purging — but EXCLUDE .rihal/backups/ itself
291
+ // (we'd be writing into the dir we're tar-ing).
292
+ if (options.purge) {
293
+ const rihalDir = path.join(cwd, '.rihal');
294
+ if (fs.existsSync(rihalDir)) {
295
+ // Walk one level deep and add everything except backups/
296
+ try {
297
+ for (const entry of fs.readdirSync(rihalDir)) {
298
+ if (entry === 'backups') continue;
299
+ paths.push(path.join('.rihal', entry));
300
+ }
301
+ } catch { /* fall through; ok=false from tar will warn */ }
302
+ }
303
+ if (fs.existsSync(path.join(cwd, '.planning'))) {
304
+ paths.push('.planning');
305
+ }
306
+ }
307
+
281
308
  return paths;
282
309
  }
283
310
 
@@ -288,8 +315,8 @@ function planToPathList(plan, cwd) {
288
315
  * (tar missing, no paths, etc.); the caller should warn the user but may
289
316
  * still proceed since the user already confirmed the destructive action.
290
317
  */
291
- function createBackup(cwd, plan) {
292
- const paths = planToPathList(plan, cwd);
318
+ function createBackup(cwd, plan, options = {}) {
319
+ const paths = planToPathList(plan, cwd, { purge: options.purge === true });
293
320
  if (paths.length === 0) {
294
321
  return { ok: false, warning: 'nothing to back up' };
295
322
  }
@@ -301,11 +328,17 @@ function createBackup(cwd, plan) {
301
328
  return { ok: false, warning: 'tar not available on this system' };
302
329
  }
303
330
 
304
- const backupsDir = path.join(cwd, '.rihal/backups');
331
+ // Issue #683: when --purge wipes .rihal/, a backup written into
332
+ // .rihal/backups/ would be deleted moments later. Write to a sibling
333
+ // .rihal-backups/ at the project root instead so the backup survives.
334
+ // For non-purge runs, keep the historical .rihal/backups/ location.
335
+ const backupsDir = options.purge
336
+ ? path.join(cwd, '.rihal-backups')
337
+ : path.join(cwd, '.rihal/backups');
305
338
  try {
306
339
  fs.mkdirSync(backupsDir, { recursive: true });
307
340
  } catch (err) {
308
- return { ok: false, warning: `could not create .rihal/backups/: ${err.message}` };
341
+ return { ok: false, warning: `could not create ${path.relative(cwd, backupsDir)}/: ${err.message}` };
309
342
  }
310
343
 
311
344
  const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
@@ -489,10 +522,15 @@ async function runUninstall(args) {
489
522
 
490
523
  // Create a timestamped backup before doing anything destructive.
491
524
  // Non-fatal on failure — the user already confirmed, we just warn.
525
+ // Issue #683: --purge backs up .rihal/ and .planning/ too so users can
526
+ // recover state.json, decisions log, and planning artifacts.
492
527
  console.log();
493
- const backup = createBackup(cwd, plan);
528
+ const backup = createBackup(cwd, plan, { purge: opts.purge === true });
494
529
  if (backup.ok) {
495
530
  console.log(` 💾 backup created: ${backup.path}`);
531
+ if (opts.purge) {
532
+ console.log(' includes .rihal/ and .planning/ (state, decisions, planning artifacts)');
533
+ }
496
534
  } else {
497
535
  console.log(` ⚠ no backup created (${backup.warning}) — continuing anyway`);
498
536
  }
@@ -649,15 +687,29 @@ async function runUninstall(args) {
649
687
 
650
688
  // Strip the rcode-managed block from .gitignore. The installer writes
651
689
  // a fenced block; we remove it cleanly without touching user lines.
690
+ //
691
+ // Issue #684: previous regex `/\n?# rcode[\s\S]*?(?=\n\n|\n$|$)/g` was a
692
+ // footgun — it matched ANY user line starting with "# rcode" (e.g.
693
+ // "# rcode notes", "# rcode is great") and greedily consumed everything
694
+ // up to the next blank line, silently nuking user content.
695
+ //
696
+ // Three shapes have ever shipped:
697
+ // 1. Current (install.js:653-654): "# ===== rcode-managed gitignore block ... =====" ... "# ===== end rcode-managed gitignore block ====="
698
+ // 2. Old fenced markers: "# >>> rihal-code >>>" ... "# <<< rihal-code <<<"
699
+ // 3. Hypothetical legacy single-line "# rcode" — never actually
700
+ // committed by any installer version we can find. Removed.
701
+ //
702
+ // Both kept patterns require BOTH sentinel markers to be present —
703
+ // user content with "# rcode" prefix is now safe.
652
704
  const gitignorePath = path.join(cwd, '.gitignore');
653
705
  if (fs.existsSync(gitignorePath)) {
654
706
  try {
655
707
  const before = fs.readFileSync(gitignorePath, 'utf8');
656
- // Match either fenced markers or the legacy "# rcode" header through to
657
- // the next blank line — both shapes the installer has used historically.
658
708
  const stripped = before
709
+ // Current shape (install.js BEGIN/END markers — exact match).
710
+ .replace(/\n?# ===== rcode-managed gitignore block[\s\S]*?# ===== end rcode-managed gitignore block =====\n?/g, '\n')
711
+ // Legacy >>> / <<< fenced shape.
659
712
  .replace(/\n?# >>> rihal-code >>>[\s\S]*?# <<< rihal-code <<<\n?/g, '\n')
660
- .replace(/\n?# rcode[\s\S]*?(?=\n\n|\n$|$)/g, '\n')
661
713
  .replace(/\n{3,}/g, '\n\n');
662
714
  if (stripped !== before) {
663
715
  fs.writeFileSync(gitignorePath, stripped);
package/dist/rcode.js CHANGED
@@ -15129,7 +15129,7 @@ var require_install = __commonJS({
15129
15129
  pc.cyan("\u2502") + " " + dim("A persistent context-brain for your editor") + " " + pc.cyan("\u2502"),
15130
15130
  pc.cyan("\u2502") + " " + pc.cyan("\u2502"),
15131
15131
  pc.cyan("\u2502") + " " + dim("version ") + pc.green("v" + v) + " " + pc.cyan("\u2502"),
15132
- pc.cyan("\u2502") + " " + dim("docs ") + "github.com/hanzla-habib/rihal-code " + pc.cyan("\u2502"),
15132
+ pc.cyan("\u2502") + " " + dim("docs ") + "github.com/hanzlahabib/rihal-code " + pc.cyan("\u2502"),
15133
15133
  pc.cyan("\u2502") + " " + pc.cyan("\u2502"),
15134
15134
  pc.cyan("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"),
15135
15135
  ""
@@ -16485,6 +16485,7 @@ ${BLOCK}`);
16485
16485
  const agentsDir = idePaths.agentsDir;
16486
16486
  const commandsDir = idePaths.commandsDir;
16487
16487
  let agentCount = 0, commandCount = 0;
16488
+ let agentsFromGlobal = false, commandsFromGlobal = false;
16488
16489
  try {
16489
16490
  if (fs2.existsSync(agentsDir)) {
16490
16491
  agentCount = fs2.readdirSync(agentsDir).filter((f) => (f.startsWith("rihal-") || f.startsWith("rcode-")) && (f.endsWith(".md") || f.endsWith(".mdc"))).length;
@@ -16493,6 +16494,25 @@ ${BLOCK}`);
16493
16494
  const commandFilter = primaryIde === "claude" ? (f) => f.startsWith("rihal-") && (f.endsWith(".md") || f.endsWith(".mdc")) : (f) => f.endsWith(".md") || f.endsWith(".mdc");
16494
16495
  commandCount = fs2.readdirSync(commandsDir).filter(commandFilter).length;
16495
16496
  }
16497
+ if (agentCount === 0 || commandCount === 0) {
16498
+ const os2 = require("os");
16499
+ const homeAgents = path2.join(os2.homedir(), ".claude/agents");
16500
+ const homeCommands = path2.join(os2.homedir(), ".claude/commands");
16501
+ if (agentCount === 0 && fs2.existsSync(homeAgents)) {
16502
+ const n = fs2.readdirSync(homeAgents).filter((f) => f.startsWith("rihal-") && f.endsWith(".md")).length;
16503
+ if (n > 0) {
16504
+ agentCount = n;
16505
+ agentsFromGlobal = true;
16506
+ }
16507
+ }
16508
+ if (commandCount === 0 && fs2.existsSync(homeCommands)) {
16509
+ const n = fs2.readdirSync(homeCommands).filter((f) => f.startsWith("rihal-") && f.endsWith(".md")).length;
16510
+ if (n > 0) {
16511
+ commandCount = n;
16512
+ commandsFromGlobal = true;
16513
+ }
16514
+ }
16515
+ }
16496
16516
  } catch {
16497
16517
  }
16498
16518
  const version = readPackageVersion();
@@ -16505,8 +16525,8 @@ ${BLOCK}`);
16505
16525
  console.log("");
16506
16526
  const relAgents = path2.relative(opts.target, idePaths.agentsDir) || idePaths.agentsDir;
16507
16527
  const relCommands = path2.relative(opts.target, idePaths.commandsDir) || idePaths.commandsDir;
16508
- console.log(` ${bold("Agents:")} ${pc.green(String(agentCount))} in ${relAgents}/`);
16509
- console.log(` ${bold("Commands:")} ${pc.green(String(commandCount))} slash commands in ${relCommands}/`);
16528
+ console.log(` ${bold("Agents:")} ${pc.green(String(agentCount))} in ${agentsFromGlobal ? "~/.claude/agents/ (global)" : relAgents + "/"}`);
16529
+ console.log(` ${bold("Commands:")} ${pc.green(String(commandCount))} slash commands in ${commandsFromGlobal ? "~/.claude/commands/ (global)" : relCommands + "/"}`);
16510
16530
  if (skillsInstalled > 0) console.log(` ${bold("Skills:")} ${pc.green(String(skillsInstalled))} phrase-activated`);
16511
16531
  console.log("");
16512
16532
  if (starterSeeded) {
@@ -17070,6 +17090,20 @@ var require_manifest = __commonJS({
17070
17090
  }
17071
17091
  }
17072
17092
  }
17093
+ if (installedAgents.size === 0) {
17094
+ try {
17095
+ const os = require("os");
17096
+ const globalAgentsDir = path2.join(os.homedir(), ".claude/agents");
17097
+ if (fs2.existsSync(globalAgentsDir)) {
17098
+ for (const f of fs2.readdirSync(globalAgentsDir)) {
17099
+ if (f.startsWith("rihal-") && f.endsWith(".md")) {
17100
+ installedAgents.add(f.replace(/^rihal-/, "").replace(/\.md$/, ""));
17101
+ }
17102
+ }
17103
+ }
17104
+ } catch {
17105
+ }
17106
+ }
17073
17107
  const allInstalled = readInstalledDirs(skillsDir);
17074
17108
  const actionsInstalled = new Set(
17075
17109
  [...allInstalled].filter((n) => !n.startsWith("rihal-"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.4.20",
3
+ "version": "3.4.22",
4
4
  "description": "rcode — the memory bank for AI-driven SaaS teams. Persistent project context, distinctive engineering personas, and phase-based workflows. Built by Rihal. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -985,6 +985,26 @@ function cmdState(subArgs) {
985
985
  try { process.kill(pid, 0); return true; } catch { return false; }
986
986
  }
987
987
 
988
+ // Issue #681: auto-clear the install-time _seeded_stub marker once the
989
+ // state has graduated to a real project (project field set + at least one
990
+ // real phase OR REQUIREMENTS.md present). project-status (#675) reads
991
+ // _seeded_stub; if no writer ever clears it, every project stays "stub"
992
+ // forever and downstream workflows misroute.
993
+ if (state._seeded_stub === true) {
994
+ const phases = Array.isArray(state.phases) ? state.phases : [];
995
+ const firstPhaseName = phases[0]?.name || '';
996
+ const hasRealPhase = phases.length > 1 ||
997
+ (firstPhaseName && firstPhaseName !== 'Setup & Scaffolding');
998
+ const hasRequirements = (() => {
999
+ try {
1000
+ return fs.existsSync(path.join(PROJECT_ROOT, '.planning', 'REQUIREMENTS.md'));
1001
+ } catch { return false; }
1002
+ })();
1003
+ if ((state.project && hasRealPhase) || hasRequirements) {
1004
+ delete state._seeded_stub;
1005
+ }
1006
+ }
1007
+
988
1008
  state.updated = new Date().toISOString();
989
1009
  fs.mkdirSync(RIHAL_DIR, { recursive: true });
990
1010
  const lockPath = statePath + '.lock';
@@ -1069,6 +1089,23 @@ function cmdState(subArgs) {
1069
1089
  return state;
1070
1090
  }
1071
1091
 
1092
+ // --- clear-stub --- (issue #681)
1093
+ // Explicit way to flip _seeded_stub off. Useful for /rihal-new-project once
1094
+ // PROJECT.md / REQUIREMENTS.md / ROADMAP.md are committed. The auto-clear in
1095
+ // writeState() also handles this, but having an explicit subcommand lets
1096
+ // workflows be self-documenting and idempotent.
1097
+ if (sub === 'clear-stub') {
1098
+ if (!fs.existsSync(statePath)) {
1099
+ return { ok: false, error: 'No state.json — nothing to clear.' };
1100
+ }
1101
+ const state = readState();
1102
+ if (!state) return { ok: false, error: 'state.json unreadable' };
1103
+ const wasStub = state._seeded_stub === true;
1104
+ if (wasStub) delete state._seeded_stub;
1105
+ writeState(state);
1106
+ return { ok: true, was_stub: wasStub, project: state.project || null };
1107
+ }
1108
+
1072
1109
  // --- init ---
1073
1110
  if (sub === 'init') {
1074
1111
  let existing;
@@ -5278,6 +5315,62 @@ function cmdSummaryExtract(args) {
5278
5315
  * Hides internal machinery (lock metadata, full history) from callers
5279
5316
  * that only need a render-ready summary.
5280
5317
  */
5318
+ /**
5319
+ * cmdProjectStatus — classify project lifecycle state into one of:
5320
+ * uninstalled — no .rihal/config.yaml
5321
+ * uninitialized — config present, no state.json
5322
+ * stub — install-seeded scaffolding only (issue #670)
5323
+ * real — /rihal-new-project has run
5324
+ *
5325
+ * Real-project signals (any → real):
5326
+ * - .planning/REQUIREMENTS.md exists
5327
+ * - .planning/research/ directory exists
5328
+ * - state.phases.length > 1
5329
+ * - first phase name ≠ "Setup & Scaffolding"
5330
+ *
5331
+ * Closes #675 — single source of truth for "is this project initialized."
5332
+ */
5333
+ function cmdProjectStatus() {
5334
+ const configPath = path.join(RIHAL_DIR, 'config.yaml');
5335
+ const statePath = path.join(RIHAL_DIR, 'state.json');
5336
+ const planningDir = path.join(PROJECT_ROOT, '.planning');
5337
+
5338
+ if (!fs.existsSync(configPath)) return { ok: true, status: 'uninstalled' };
5339
+ if (!fs.existsSync(statePath)) return { ok: true, status: 'uninitialized' };
5340
+
5341
+ let state;
5342
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
5343
+ catch (e) { return { ok: false, error: `invalid state.json: ${e.message}` }; }
5344
+
5345
+ const hasRequirements = fs.existsSync(path.join(planningDir, 'REQUIREMENTS.md'));
5346
+ const hasResearch = fs.existsSync(path.join(planningDir, 'research'));
5347
+ const phases = state.phases || [];
5348
+ const phaseCountReal = phases.length > 1;
5349
+ const firstPhaseName = phases[0]?.name || '';
5350
+ const phaseNameReal = firstPhaseName && firstPhaseName !== 'Setup & Scaffolding';
5351
+
5352
+ const isReal = hasRequirements || hasResearch || phaseCountReal || phaseNameReal;
5353
+ const isStub = state._seeded_stub === true || !state.project || !isReal;
5354
+
5355
+ let status;
5356
+ if (isReal) status = 'real';
5357
+ else if (isStub) status = 'stub';
5358
+ else status = 'uninitialized';
5359
+
5360
+ return {
5361
+ ok: true,
5362
+ status,
5363
+ signals: {
5364
+ project: state.project || null,
5365
+ seeded_stub: state._seeded_stub === true,
5366
+ has_requirements: hasRequirements,
5367
+ has_research: hasResearch,
5368
+ phase_count: phases.length,
5369
+ first_phase_name: firstPhaseName || null,
5370
+ },
5371
+ };
5372
+ }
5373
+
5281
5374
  function cmdStateSnapshot() {
5282
5375
  const statePath = path.join(RIHAL_DIR, 'state.json');
5283
5376
  if (!fs.existsSync(statePath)) return { ok: true, state: null };
@@ -5652,6 +5745,9 @@ async function main() {
5652
5745
  case 'agent-skills':
5653
5746
  result = cmdAgentInfo(args[0]);
5654
5747
  break;
5748
+ case 'project-status':
5749
+ result = cmdProjectStatus();
5750
+ break;
5655
5751
  case 'version':
5656
5752
  console.log(readPackageVersion());
5657
5753
  return;
package/rihal/state.json CHANGED
@@ -1,15 +1,14 @@
1
1
  {
2
2
  "version": "1",
3
- "project": "__PROJECT_NAME__",
3
+ "project": null,
4
+ "_seeded_stub": true,
4
5
  "created": "__INSTALL_DATE__",
5
6
  "updated": "__INSTALL_DATE__",
6
- "current_phase": "01",
7
+ "current_phase": null,
7
8
  "current_plan": 0,
8
9
  "current_sprint": null,
9
- "milestone": "M1 — Initial Delivery",
10
- "phases": [
11
- { "id": "01", "name": "Setup & Scaffolding", "status": "planned" }
12
- ],
10
+ "milestone": null,
11
+ "phases": [],
13
12
  "executions": [],
14
13
  "decisions": [],
15
14
  "blockers": [],
@@ -88,25 +88,67 @@ Valid Rihal subagent types (use exact names — do not fall back to 'general-pur
88
88
  - rihal-roadmapper — Creates phased execution roadmaps
89
89
  </available_agent_types>
90
90
 
91
- ## Step 0.5 — Detect existing project (redirect)
91
+ ## Step 0.5 — Detect existing project (stub-aware redirect)
92
92
 
93
- Before any processing, check if a project already exists in this directory:
93
+ Before any processing, classify the project state into one of:
94
+
95
+ - **none** — no `.rihal/state.json`, no `.planning/` → proceed
96
+ - **stub** — install-seeded scaffolding only (issue #670) → proceed (overwrite stub)
97
+ - **real** — a previous `/rihal-new-project` ran here → guard, unless `--force`
94
98
 
95
99
  ```bash
96
- EXISTING=$(node .rihal/bin/rihal-tools.cjs state read 2>/dev/null | grep '"project"' | head -1)
100
+ # --force / --reinit bypasses the guard entirely (issue #672).
101
+ # --auto implies --force on stub state (issue #674).
102
+ FORCE=false
103
+ case " $ARGUMENTS " in
104
+ *" --force "*|*" --reinit "*) FORCE=true ;;
105
+ esac
106
+
107
+ # Single source of truth: rihal-tools project-status returns one of
108
+ # uninstalled | uninitialized | stub | real
109
+ # (see issue #675 for the contract). Falls back to `none` when
110
+ # rihal-tools is unavailable so the workflow still proceeds.
111
+ PROJECT_STATE=$(node .rihal/bin/rihal-tools.cjs project-status 2>/dev/null \
112
+ | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).status||'none')}catch{console.log('none')}})" \
113
+ || echo "none")
114
+ [ "$PROJECT_STATE" = "uninstalled" ] || [ "$PROJECT_STATE" = "uninitialized" ] && PROJECT_STATE="none"
97
115
  ```
98
116
 
99
- If `$EXISTING` is non-empty (project already initialized):
117
+ **If `PROJECT_STATE=real` and `FORCE=false`:** show the guard:
100
118
 
101
119
  ```
102
120
  ⚠ A rihal project already exists here.
103
121
 
104
- To check current state: /rihal-status
105
- To find next action: /rihal-next
106
- To start a fresh phase instead: /rihal-add-phase
122
+ Quick actions:
123
+ /rihal-status check current state
124
+ /rihal-next find next action
125
+ /rihal-add-phase add a phase to the current milestone
126
+
127
+ To start over (overwrites .planning/* and .rihal/state.json):
128
+ /rihal-new-project --force <description>
129
+ rcode install --reset nuclear option — wipes config + state
130
+ ```
131
+
132
+ STOP — do not proceed.
133
+
134
+ **If `PROJECT_STATE=stub` (issue #670 install scaffolding):** print a one-liner and proceed:
135
+
136
+ ```
137
+ ℹ Install stub detected — overwriting with real project setup.
138
+ ```
139
+
140
+ **If `PROJECT_STATE=none`:** proceed silently.
141
+
142
+ **If `PROJECT_STATE=real` and `FORCE=true`:** create a rollback tag, then proceed:
143
+
144
+ ```bash
145
+ if git rev-parse --git-dir >/dev/null 2>&1; then
146
+ TAG="pre-rihal-rewrite-$(date +%Y%m%d-%H%M%S)"
147
+ git tag "$TAG" 2>/dev/null && echo "ℹ Rollback tag created: $TAG"
148
+ fi
107
149
  ```
108
150
 
109
- Only proceed past this step if no project exists (`$EXISTING` is empty).
151
+ In interactive mode (not `--auto`), confirm via AskUserQuestion before overwriting. In `--auto` or YOLO mode, proceed without confirmation.
110
152
 
111
153
  <auto_mode>
112
154