@hanzlaa/rcode 3.4.21 → 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
@@ -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)' },
@@ -873,16 +894,26 @@ function installBrainScaffold(packageRoot, target) {
873
894
  *
874
895
  * A skill is marked internal by adding `internal: true` to its SKILL.md frontmatter.
875
896
  */
876
- function installSkills(packageRoot, target) {
897
+ function installSkills(packageRoot, target, options = {}) {
877
898
  const skillsSource = path.join(packageRoot, 'rihal/skills');
878
899
  const skillsDest = path.join(target, '.claude/skills');
879
900
  const internalDest = path.join(target, '.rihal/skills');
880
901
 
881
- if (!fs.existsSync(skillsSource)) return 0;
902
+ if (!fs.existsSync(skillsSource)) return { count: 0, skippedGlobal: 0 };
882
903
  fs.mkdirSync(skillsDest, { recursive: true });
883
904
  fs.mkdirSync(internalDest, { recursive: true });
884
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
+
885
915
  let count = 0;
916
+ let skippedGlobal = 0;
886
917
 
887
918
  function isInternalSkill(skillDir) {
888
919
  const skillMd = path.join(skillDir, 'SKILL.md');
@@ -891,6 +922,13 @@ function installSkills(packageRoot, target) {
891
922
  return /^internal:\s*true\s*$/m.test(text);
892
923
  }
893
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
+
894
932
  function walkForSkills(dir) {
895
933
  if (!fs.existsSync(dir)) return;
896
934
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -901,9 +939,23 @@ function installSkills(packageRoot, target) {
901
939
  const destName = entry.name.startsWith('rihal-')
902
940
  ? entry.name
903
941
  : `rihal-${entry.name}`;
904
- const dest = isInternalSkill(src)
942
+ const internal = isInternalSkill(src);
943
+ const dest = internal
905
944
  ? path.join(internalDest, destName) // internal → .rihal/skills/
906
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
+ }
907
959
  copyDirRecursive(src, dest);
908
960
  count++;
909
961
  } else {
@@ -916,7 +968,7 @@ function installSkills(packageRoot, target) {
916
968
  walkForSkills(path.join(skillsSource, bucket));
917
969
  }
918
970
 
919
- return count;
971
+ return { count, skippedGlobal };
920
972
  }
921
973
 
922
974
  /**
@@ -1425,6 +1477,18 @@ function convertToCursorMdc(sourceText) {
1425
1477
  async function install(opts) {
1426
1478
  if (opts.help) { printHelp(); return 0; }
1427
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
+
1428
1492
  const pkgVersion = readPackageVersion();
1429
1493
 
1430
1494
  // Header banner — only shown for interactive runs to keep CI/non-TTY logs terse.
@@ -1754,8 +1818,10 @@ async function install(opts) {
1754
1818
  const configDir = path.join(opts.target, '.rihal', '_config');
1755
1819
  ensureDir(configDir);
1756
1820
  fs.writeFileSync(path.join(configDir, 'manifest.yaml'), generateInstallManifest(opts));
1757
- // Install skills + sidebar stubs globally
1758
- 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;
1759
1825
  try {
1760
1826
  const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
1761
1827
  const stubsDir = path.join(opts.target, '.claude', 'skills');
@@ -1831,6 +1897,7 @@ async function install(opts) {
1831
1897
  plan.length = 0;
1832
1898
  filtered.forEach(e => plan.push(e));
1833
1899
  }
1900
+
1834
1901
  } catch { /* non-fatal — skip detection on permission errors */ }
1835
1902
  }
1836
1903
 
@@ -1855,11 +1922,34 @@ async function install(opts) {
1855
1922
  } else if (opts.force && (fs.existsSync(configPath) || fs.existsSync(stateDest))) {
1856
1923
  existedBefore = true;
1857
1924
  }
1925
+ // Note: --reset without --force is rejected at the top of install() (#680).
1858
1926
 
1859
1927
  // Write .rihal/config.yaml (user_name, project_name, language, mode)
1860
1928
  // Note: config.yaml is user data and should NOT be overwritten on --force (unless --reset)
1861
1929
  if (!fs.existsSync(configPath)) {
1862
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 */ }
1863
1953
  }
1864
1954
  // Validate config.yaml with zod schema (#250) — warn but never block install.
1865
1955
  try {
@@ -1917,7 +2007,16 @@ async function install(opts) {
1917
2007
 
1918
2008
  // Install v1-style phrase-activated skills (scaffold-project, create-prd,
1919
2009
  // retrospective, etc.) into .claude/skills/ alongside the v2 agents/commands.
1920
- 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
+ }
1921
2020
 
1922
2021
  // Generate install-time skill stubs that mirror sidebar-worthy slash commands.
1923
2022
  // Source codebase stays clean — these stubs only exist at the install
@@ -1926,11 +2025,16 @@ async function install(opts) {
1926
2025
  try {
1927
2026
  const { main: generateCommandSkills } = require(path.join(PACKAGE_ROOT, 'cli', 'generate-command-skills.cjs'));
1928
2027
  const stubsDir = path.join(opts.target, '.claude', 'skills');
1929
- const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion());
2028
+ const result = generateCommandSkills(PACKAGE_ROOT, stubsDir, readPackageVersion(), {
2029
+ skipGlobalDuplicates: isProjectInstall,
2030
+ });
1930
2031
  if (result.generated > 0) {
1931
2032
  console.log(' ' + dim(`${result.generated} sidebar skill stub${result.generated === 1 ? '' : 's'} generated for command discoverability`));
1932
2033
  skillsInstalled += result.generated;
1933
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
+ }
1934
2038
  } catch (err) {
1935
2039
  // Non-fatal: install succeeds without sidebar stubs
1936
2040
  console.log(' ' + dim(`(sidebar stub generation skipped: ${err.message})`));
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.4.21",
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;