@oevery/rsp 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -5,10 +5,10 @@ import { resolve } from "path";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
6
  import { defineCommand, runMain } from "citty";
7
7
 
8
- // src/commands/archive-index.ts
8
+ // src/commands/add-rules.ts
9
9
  import { existsSync as existsSync3 } from "fs";
10
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
11
- import { basename, dirname as dirname2, join as join3, relative as relative2 } from "path";
10
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
11
+ import { basename, dirname as dirname2, join as join3 } from "path";
12
12
 
13
13
  // src/core/config.ts
14
14
  import { existsSync as existsSync2 } from "fs";
@@ -21,6 +21,8 @@ import pc from "picocolors";
21
21
  import { existsSync } from "fs";
22
22
  import { readdir, stat } from "fs/promises";
23
23
  import { join, relative } from "path";
24
+ var RSP_AGENTS_BEGIN = "<!-- rsp:begin -->";
25
+ var RSP_AGENTS_END = "<!-- rsp:end -->";
24
26
  function parseYamlLines(lines) {
25
27
  const result = {};
26
28
  let currentKey = null;
@@ -59,7 +61,7 @@ function parseYamlLines(lines) {
59
61
  return result;
60
62
  }
61
63
  function parseFrontmatter(content) {
62
- const m = content.match(/^---\n([\s\S]*?)\n(?:---|\.\.\.)\n/);
64
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n/);
63
65
  if (!m)
64
66
  return null;
65
67
  return parseYamlLines(m[1].split("\n"));
@@ -76,6 +78,8 @@ async function walkMarkdownFiles(dir) {
76
78
  try {
77
79
  const items = await readdir(dir, { withFileTypes: true });
78
80
  for (const item of items) {
81
+ if (item.name.startsWith("."))
82
+ continue;
79
83
  const filePath = join(dir, item.name);
80
84
  if (item.isDirectory())
81
85
  files.push(...await walkMarkdownFiles(filePath));
@@ -91,6 +95,8 @@ async function walkFiles(dir) {
91
95
  try {
92
96
  const items = await readdir(dir, { withFileTypes: true });
93
97
  for (const item of items) {
98
+ if (item.name.startsWith("."))
99
+ continue;
94
100
  const filePath = join(dir, item.name);
95
101
  if (item.isDirectory())
96
102
  files.push(...await walkFiles(filePath));
@@ -134,6 +140,112 @@ tags:
134
140
  -
135
141
  `;
136
142
  }
143
+ function renderRspAgentsBlock() {
144
+ return `${RSP_AGENTS_BEGIN}
145
+ ## RSP Entry
146
+
147
+ Read in order:
148
+ 1. .rsp/rules/*.md
149
+ 2. .rsp/specs/INDEX.md
150
+ 3. .rsp/specs/design.md
151
+ 4. .rsp/active.d/ and matching .rsp/features/*.md
152
+
153
+ Guidelines:
154
+ - .rsp/rules/rsp-rules.md is required
155
+ - .rsp/rules/project-rules.md is optional
156
+ - Keep project design in .rsp/specs/
157
+ - Add extra spec files only when they have durable value
158
+ - Do not create a feature named 'init'
159
+ ${RSP_AGENTS_END}`;
160
+ }
161
+ function upsertRspAgentsBlock(content) {
162
+ const block = renderRspAgentsBlock();
163
+ const managedRe = /<!-- rsp:begin -->[\s\S]*?<!-- rsp:end -->/;
164
+ if (managedRe.test(content)) {
165
+ const next2 = content.replace(managedRe, block);
166
+ return { content: next2, changed: next2 !== content };
167
+ }
168
+ const trimmed = content.trimStart();
169
+ const next = trimmed ? `${block}
170
+
171
+ ${trimmed}` : `${block}
172
+ `;
173
+ return { content: next, changed: true };
174
+ }
175
+ function hasRspAgentsBlock(content) {
176
+ return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
177
+ }
178
+ function generateDesignContent(projectName) {
179
+ return `# Project Design: ${projectName}
180
+
181
+ ## Purpose
182
+ - <what this project is responsible for>
183
+ - <who or what it serves>
184
+
185
+ ## Scope
186
+ - In scope:
187
+ - <major capability or boundary>
188
+ - Out of scope:
189
+ - <intentional non-goal>
190
+
191
+ ## Structure
192
+ - <important directory or subsystem> \u2014 <responsibility>
193
+
194
+ ## Constraints
195
+ - <cross-cutting technical or operational constraint>
196
+ `;
197
+ }
198
+ function generateProjectRulesContent(projectName) {
199
+ return `---
200
+ name: project-rules
201
+ description: Project-specific rules for ${projectName}
202
+ ---
203
+
204
+ # Project Rules
205
+
206
+ ## Scope
207
+ - <rules that apply across the project>
208
+
209
+ ## Validation
210
+ - <preferred validation commands>
211
+
212
+ ## Conventions
213
+ - <project-specific conventions>
214
+ `;
215
+ }
216
+ function generateRulesContent(name) {
217
+ const title = toTitleCase(name);
218
+ return `---
219
+ name: ${name}
220
+ description: Project-specific rules for ${title}
221
+ ---
222
+
223
+ # ${title}
224
+
225
+ ## Scope
226
+ - <what this rules file governs>
227
+
228
+ ## Rules
229
+ - <stable local rule>
230
+
231
+ ## Validation
232
+ - <relevant validation command or check>
233
+ `;
234
+ }
235
+ function generateSpecContent(name) {
236
+ const title = toTitleCase(name);
237
+ return `# ${title}
238
+
239
+ ## Purpose
240
+ - <why this project-level spec exists>
241
+
242
+ ## Details
243
+ - <important project-level detail>
244
+ `;
245
+ }
246
+ function toTitleCase(value) {
247
+ return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
248
+ }
137
249
  function detectDeltaSections(content) {
138
250
  const match = content.match(/^## Spec\n([\s\S]*?)(?=\n## |\n---|\n\.\.\.)/m);
139
251
  if (!match) {
@@ -204,6 +316,7 @@ var __dirname = dirname(fileURLToPath(import.meta.url));
204
316
  var PKG_ROOT = join2(__dirname, "..");
205
317
  var RSP_DIR = ".rsp";
206
318
  var ACTIVE_DIR = join2(RSP_DIR, "active.d");
319
+ var ARCHIVES_DIR = join2(RSP_DIR, "archives");
207
320
  var LOCK_PATH = join2(RSP_DIR, ".lock");
208
321
  var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
209
322
  var VALID_STATUSES = ["draft", "ready", "in-progress", "blocked", "done"];
@@ -295,40 +408,204 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
295
408
  }
296
409
  }
297
410
 
298
- // src/commands/archive-index.ts
299
- async function buildArchiveIndex({ acquireLock = true } = {}) {
411
+ // src/commands/add-rules.ts
412
+ var NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
413
+ async function detectProjectName() {
414
+ try {
415
+ if (existsSync3("package.json")) {
416
+ const raw = await readFile3("package.json", "utf-8");
417
+ const pkg = JSON.parse(raw);
418
+ if (pkg.name && pkg.name !== "")
419
+ return pkg.name;
420
+ }
421
+ } catch {
422
+ }
423
+ return basename(process.cwd());
424
+ }
425
+ async function addRules(name, projectName = "project") {
426
+ if (!name || !NAME_RE.test(name)) {
427
+ console.error(` ${pc.red("Error:")} rule name must be kebab-case with optional subdirectory`);
428
+ process.exit(1);
429
+ }
430
+ const rspRulesPath = join3(RSP_DIR, "rules", "rsp-rules.md");
431
+ const designPath = join3(RSP_DIR, "specs", "design.md");
432
+ if (!existsSync3(rspRulesPath) || !existsSync3(designPath)) {
433
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
434
+ console.error(` ${pc.dim("Run: rsp init")}`);
435
+ process.exit(1);
436
+ }
437
+ return withRspLock("add-rules", async () => {
438
+ const dir = join3(RSP_DIR, "rules");
439
+ const path = join3(dir, `${name}.md`);
440
+ if (existsSync3(path)) {
441
+ console.error(` ${pc.red("Error:")} rule file already exists: .rsp/rules/${name}.md`);
442
+ process.exit(1);
443
+ }
444
+ await mkdir2(dirname2(path), { recursive: true });
445
+ const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
446
+ const content = name === "project-rules" ? generateProjectRulesContent(resolvedProjectName) : generateRulesContent(name);
447
+ await writeFile2(path, content);
448
+ console.log(` ${pc.green("Created:")} .rsp/rules/${name}.md
449
+ `);
450
+ });
451
+ }
452
+
453
+ // src/commands/add-spec.ts
454
+ import { existsSync as existsSync5 } from "fs";
455
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
456
+ import { basename as basename3, dirname as dirname3, join as join5 } from "path";
457
+
458
+ // src/commands/specs-index.ts
459
+ import { existsSync as existsSync4 } from "fs";
460
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
461
+ import { basename as basename2, join as join4, relative as relative2 } from "path";
462
+ async function buildSpecsIndex({ acquireLock = true } = {}) {
300
463
  if (acquireLock)
301
- return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
302
- const archiveDir = join3(RSP_DIR, "archive");
303
- if (!existsSync3(archiveDir)) {
304
- console.log(` ${pc.dim("No archive directory.")}
464
+ return withRspLock("specs-index", async () => buildSpecsIndex({ acquireLock: false }));
465
+ const specsDir = join4(RSP_DIR, "specs");
466
+ if (!existsSync4(specsDir)) {
467
+ console.log(` ${pc.dim("No specs directory.")}
305
468
  `);
306
469
  return;
307
470
  }
308
- const archiveFiles = (await walkMarkdownFiles(archiveDir)).filter((fp) => basename(fp) !== "INDEX.md").sort();
309
- if (archiveFiles.length === 0) {
310
- console.log(` ${pc.dim("No archived features.")}
471
+ const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) => basename2(fp) !== "INDEX.md").sort();
472
+ const lines = [];
473
+ lines.push("# Specs Index");
474
+ lines.push("");
475
+ lines.push("_Project-level specs and design notes._");
476
+ lines.push("");
477
+ if (specFiles.length === 0) {
478
+ lines.push("_No project-level specs yet._");
479
+ } else {
480
+ lines.push("| File | Title | Summary |");
481
+ lines.push("|------|-------|---------|");
482
+ for (const fp of specFiles) {
483
+ const rel = relative2(specsDir, fp);
484
+ const content = await readFile4(fp, "utf-8");
485
+ const fm = parseFrontmatter(content);
486
+ const title = extractTitle(content) || rel.replace(/\.md$/, "");
487
+ const summary = extractSummary(content, fm?.summary);
488
+ lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
489
+ }
490
+ }
491
+ lines.push("");
492
+ lines.push("_Auto-generated by `rsp specs-index`._");
493
+ await writeFile3(join4(specsDir, "INDEX.md"), lines.join("\n"));
494
+ console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
495
+ `);
496
+ }
497
+ function extractTitle(content) {
498
+ for (const rawLine of content.split("\n")) {
499
+ if (!rawLine.startsWith("# "))
500
+ continue;
501
+ return rawLine.slice(2).trim();
502
+ }
503
+ return "";
504
+ }
505
+ function extractSummary(content, frontmatterSummary) {
506
+ if (typeof frontmatterSummary === "string" && frontmatterSummary.trim() !== "")
507
+ return frontmatterSummary.trim();
508
+ const lines = content.split("\n");
509
+ let inFrontmatter = false;
510
+ for (const rawLine of lines) {
511
+ const line = rawLine.trim();
512
+ if (line === "---" || line === "...") {
513
+ inFrontmatter = !inFrontmatter;
514
+ continue;
515
+ }
516
+ if (inFrontmatter || line === "")
517
+ continue;
518
+ if (line.startsWith("#"))
519
+ continue;
520
+ if (line.startsWith("##"))
521
+ continue;
522
+ return line.replace(/^[-*]\s*/, "").trim();
523
+ }
524
+ return "";
525
+ }
526
+ function escapeCell(value) {
527
+ return value.replace(/\|/g, "\\|");
528
+ }
529
+
530
+ // src/commands/add-spec.ts
531
+ var NAME_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
532
+ async function detectProjectName2() {
533
+ try {
534
+ if (existsSync5("package.json")) {
535
+ const raw = await readFile5("package.json", "utf-8");
536
+ const pkg = JSON.parse(raw);
537
+ if (pkg.name && pkg.name !== "")
538
+ return pkg.name;
539
+ }
540
+ } catch {
541
+ }
542
+ return basename3(process.cwd());
543
+ }
544
+ async function addSpec(name, projectName = "project") {
545
+ if (!name || !NAME_RE2.test(name)) {
546
+ console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
547
+ process.exit(1);
548
+ }
549
+ const rspRulesPath = join5(RSP_DIR, "rules", "rsp-rules.md");
550
+ const designPath = join5(RSP_DIR, "specs", "design.md");
551
+ if (!existsSync5(rspRulesPath) || !existsSync5(designPath)) {
552
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
553
+ console.error(` ${pc.dim("Run: rsp init")}`);
554
+ process.exit(1);
555
+ }
556
+ return withRspLock("add-spec", async () => {
557
+ const dir = join5(RSP_DIR, "specs");
558
+ const path = join5(dir, `${name}.md`);
559
+ if (existsSync5(path)) {
560
+ console.error(` ${pc.red("Error:")} spec file already exists: .rsp/specs/${name}.md`);
561
+ process.exit(1);
562
+ }
563
+ await mkdir3(dirname3(path), { recursive: true });
564
+ const resolvedProjectName = projectName === "project" ? await detectProjectName2() : projectName;
565
+ const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
566
+ await writeFile4(path, content);
567
+ await buildSpecsIndex({ acquireLock: false });
568
+ console.log(` ${pc.green("Created:")} .rsp/specs/${name}.md
569
+ `);
570
+ });
571
+ }
572
+
573
+ // src/commands/archive-index.ts
574
+ import { existsSync as existsSync6 } from "fs";
575
+ import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
576
+ import { basename as basename4, dirname as dirname4, join as join6, relative as relative3 } from "path";
577
+ async function buildArchiveIndex({ acquireLock = true } = {}) {
578
+ if (acquireLock)
579
+ return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
580
+ const archiveDir = join6(RSP_DIR, "archives");
581
+ if (!existsSync6(archiveDir)) {
582
+ console.log(` ${pc.dim("No archives directory.")}
311
583
  `);
312
584
  return;
313
585
  }
586
+ const archiveFiles = (await walkMarkdownFiles(archiveDir)).filter((fp) => basename4(fp) !== "INDEX.md").sort();
314
587
  const lines = [];
315
588
  lines.push("# Archive Index");
316
589
  lines.push("");
317
590
  lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
318
591
  lines.push("");
319
- lines.push("| Date | Feature | Summary |");
320
- lines.push("|------|---------|---------|");
592
+ if (archiveFiles.length === 0) {
593
+ lines.push("_No archived features yet._");
594
+ } else {
595
+ lines.push("| Date | Feature | Summary |");
596
+ lines.push("|------|---------|---------|");
597
+ }
321
598
  for (const fp of archiveFiles) {
322
- const base = basename(fp);
323
- const archiveRel = relative2(archiveDir, fp);
324
- const archiveDirPart = dirname2(archiveRel);
599
+ const base = basename4(fp);
600
+ const archiveRel = relative3(archiveDir, fp);
601
+ const archiveDirPart = dirname4(archiveRel);
325
602
  const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
326
603
  const date = dateMatch ? dateMatch[1] : "\u2014";
327
604
  const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
328
605
  const featureName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
329
606
  let summary = "";
330
607
  try {
331
- const content = await readFile3(fp, "utf-8");
608
+ const content = await readFile6(fp, "utf-8");
332
609
  const fm = parseFrontmatter(content);
333
610
  if (fm?.summary)
334
611
  summary = String(fm.summary);
@@ -344,18 +621,18 @@ async function buildArchiveIndex({ acquireLock = true } = {}) {
344
621
  }
345
622
  lines.push("");
346
623
  lines.push("_Auto-generated by `rsp archive-index`._");
347
- const indexPath = join3(archiveDir, "INDEX.md");
348
- await writeFile2(indexPath, lines.join("\n"));
624
+ const indexPath = join6(archiveDir, "INDEX.md");
625
+ await writeFile5(indexPath, lines.join("\n"));
349
626
  console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived feature(s).
350
627
  `);
351
628
  }
352
629
 
353
630
  // src/commands/check.ts
354
- import { existsSync as existsSync4 } from "fs";
355
- import { readFile as readFile4 } from "fs/promises";
356
- import { join as join4, relative as relative3 } from "path";
631
+ import { existsSync as existsSync7 } from "fs";
632
+ import { readFile as readFile7 } from "fs/promises";
633
+ import { join as join7, relative as relative4 } from "path";
357
634
  async function runCheck() {
358
- const featuresDir = join4(RSP_DIR, "features");
635
+ const featuresDir = join7(RSP_DIR, "features");
359
636
  const config = await loadRspConfig();
360
637
  const validStatuses = resolveStatuses(config);
361
638
  const validPriorities = resolvePriorities(config);
@@ -365,34 +642,34 @@ async function runCheck() {
365
642
  console.log();
366
643
  console.log(` ${pc.bold("RSP check")}`);
367
644
  console.log();
368
- if (existsSync4(ACTIVE_DIR)) {
645
+ if (existsSync7(ACTIVE_DIR)) {
369
646
  const entries = await walkFiles(ACTIVE_DIR);
370
647
  for (const entryPath of entries) {
371
- const entryContent = relative3(ACTIVE_DIR, entryPath);
372
- const markerContent = (await readFile4(entryPath, "utf-8")).trim();
648
+ const entryContent = relative4(ACTIVE_DIR, entryPath);
649
+ const markerContent = (await readFile7(entryPath, "utf-8")).trim();
373
650
  if (markerContent) {
374
651
  console.log(` ${pc.yellow("\u26A0")} active.d/${entryContent} should be an empty marker file; path is the source of truth`);
375
652
  warnings++;
376
653
  }
377
- const fp = join4(featuresDir, `${entryContent}.md`);
378
- if (!existsSync4(fp)) {
654
+ const fp = join7(featuresDir, `${entryContent}.md`);
655
+ if (!existsSync7(fp)) {
379
656
  console.log(` ${pc.red("\u2717")} active.d/${entryContent} points to "${entryContent}" but features/${entryContent}.md not found`);
380
657
  errors++;
381
658
  }
382
659
  }
383
660
  }
384
- const featureFiles = existsSync4(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
661
+ const featureFiles = existsSync7(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
385
662
  if (featureFiles.length === 0) {
386
663
  console.log(` ${pc.dim("No feature files to check.")}
387
664
  `);
388
- return;
665
+ return 0;
389
666
  }
390
667
  const allDeps = /* @__PURE__ */ new Map();
391
668
  for (const fp of featureFiles) {
392
669
  const name = featureNameFromPath(featuresDir, fp);
393
670
  let content;
394
671
  try {
395
- content = await readFile4(fp, "utf-8");
672
+ content = await readFile7(fp, "utf-8");
396
673
  } catch {
397
674
  console.log(` ${pc.red("\u2717")} ${name} \u2014 unable to read file`);
398
675
  errors++;
@@ -487,8 +764,8 @@ async function runCheck() {
487
764
  }
488
765
  seen.add(dep);
489
766
  allDeps.set(name, [...allDeps.get(name) || [], dep]);
490
- const depFp = join4(featuresDir, `${dep}.md`);
491
- if (!existsSync4(depFp)) {
767
+ const depFp = join7(featuresDir, `${dep}.md`);
768
+ if (!existsSync7(depFp)) {
492
769
  console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on "${dep}" but features/${dep}.md not found`);
493
770
  errors++;
494
771
  }
@@ -511,9 +788,9 @@ async function runCheck() {
511
788
  }
512
789
 
513
790
  // src/commands/close-feature.ts
514
- import { existsSync as existsSync5 } from "fs";
515
- import { appendFile, mkdir as mkdir2, readdir as readdir2, readFile as readFile5, rename, rmdir, unlink as unlink2, writeFile as writeFile3 } from "fs/promises";
516
- import { basename as basename2, dirname as dirname3, join as join5 } from "path";
791
+ import { existsSync as existsSync8 } from "fs";
792
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile8, rename, rmdir, unlink as unlink2 } from "fs/promises";
793
+ import { basename as basename5, dirname as dirname5, join as join8 } from "path";
517
794
  async function closeFeature(name) {
518
795
  return withRspLock("close-feature", async () => {
519
796
  if (!name) {
@@ -524,128 +801,104 @@ async function closeFeature(name) {
524
801
  console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
525
802
  process.exit(1);
526
803
  }
527
- const srcPath = join5(RSP_DIR, "features", `${name}.md`);
528
- if (!existsSync5(srcPath)) {
804
+ const srcPath = join8(RSP_DIR, "features", `${name}.md`);
805
+ if (!existsSync8(srcPath)) {
529
806
  console.error(` ${pc.red("Feature not found:")} .rsp/features/${name}.md`);
530
807
  process.exit(1);
531
808
  }
809
+ const dependents = await findDependents(name);
810
+ if (dependents.length > 0) {
811
+ console.error(` ${pc.red("Error:")} cannot close "${name}" because it is still referenced by: ${dependents.join(", ")}`);
812
+ console.error(` ${pc.dim("Remove or update those depends-on entries before archiving this feature.")}`);
813
+ process.exit(1);
814
+ }
532
815
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
533
- const dir = dirname3(name);
534
- const base = basename2(name);
535
- const archiveSubdir = dir !== "." ? join5(RSP_DIR, "archive", dir) : join5(RSP_DIR, "archive");
816
+ const dir = dirname5(name);
817
+ const base = basename5(name);
818
+ const archiveSubdir = dir !== "." ? join8(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
536
819
  const archiveName = `${date}_${base}.md`;
537
- await mkdir2(archiveSubdir, { recursive: true });
538
- await rename(srcPath, join5(archiveSubdir, archiveName));
820
+ await mkdir4(archiveSubdir, { recursive: true });
821
+ await rename(srcPath, join8(archiveSubdir, archiveName));
539
822
  let activeCleared = false;
540
- const activeEntry = join5(ACTIVE_DIR, name);
541
- if (existsSync5(activeEntry)) {
823
+ const activeEntry = join8(ACTIVE_DIR, name);
824
+ if (existsSync8(activeEntry)) {
542
825
  await unlink2(activeEntry);
543
- let parent = dirname3(activeEntry);
544
- while (parent !== ACTIVE_DIR) {
545
- try {
546
- const remaining = await readdir2(parent);
547
- if (remaining.length === 0)
548
- await rmdir(parent);
549
- else
550
- break;
551
- } catch {
552
- break;
553
- }
554
- parent = dirname3(parent);
555
- }
826
+ await cleanupEmptyParentDirs(activeEntry, ACTIVE_DIR);
556
827
  activeCleared = true;
557
828
  }
829
+ await cleanupEmptyParentDirs(srcPath, join8(RSP_DIR, "features"));
558
830
  const clearedMsg = activeCleared ? ` ${pc.dim("active.d cleared")}
559
831
  ` : "";
560
832
  console.log(` ${pc.green("Archived:")} ${archiveName}
561
833
  ${clearedMsg}`);
562
834
  await buildArchiveIndex({ acquireLock: false });
563
835
  await runCheckIfHasDependents(name);
564
- await updateSpecIndex(name, date);
565
- if (existsSync5(".git")) {
566
- const archiveRelPath = dir !== "." ? join5(dir, archiveName) : archiveName;
836
+ if (existsSync8(".git")) {
837
+ const archiveRelPath = dir !== "." ? join8(dir, archiveName) : archiveName;
567
838
  console.log(` ${pc.cyan("Git workflow:")}
568
839
  `);
569
- console.log(` git add .rsp/archive/${archiveRelPath}`);
840
+ console.log(` git add .rsp/archives/${archiveRelPath}`);
570
841
  console.log(` git commit -m "feat: complete ${name}"`);
571
842
  console.log();
572
843
  }
573
844
  });
574
845
  }
846
+ async function findDependents(name) {
847
+ const featuresDir = join8(RSP_DIR, "features");
848
+ const allFeatureFiles = await walkMarkdownFiles(featuresDir);
849
+ const dependents = [];
850
+ for (const fp of allFeatureFiles) {
851
+ const featureName = fp === join8(featuresDir, `${name}.md`) ? name : fp.replace(`${featuresDir}/`, "").replace(/\.md$/, "");
852
+ if (featureName === name)
853
+ continue;
854
+ const content = await readFile8(fp, "utf-8");
855
+ const fm = parseFrontmatter(content);
856
+ if (!fm || !fm["depends-on"])
857
+ continue;
858
+ const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
859
+ if (deps.includes(name))
860
+ dependents.push(featureName);
861
+ }
862
+ return dependents.sort();
863
+ }
575
864
  async function runCheckIfHasDependents(name) {
576
865
  try {
577
- const featuresDir = join5(RSP_DIR, "features");
578
- const allFeatureFiles = await walkMarkdownFiles(featuresDir);
579
- let hasDependents = false;
580
- for (const fp of allFeatureFiles) {
581
- const content = await readFile5(fp, "utf-8");
582
- const fm = parseFrontmatter(content);
583
- if (!fm || !fm["depends-on"])
584
- continue;
585
- const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
586
- if (deps.includes(name)) {
587
- hasDependents = true;
588
- break;
589
- }
590
- }
866
+ const hasDependents = (await findDependents(name)).length > 0;
591
867
  if (hasDependents)
592
868
  await runCheck();
593
869
  } catch {
594
870
  console.warn(` ${pc.dim("\u26A0 could not verify dependents for")} "${name}"`);
595
871
  }
596
872
  }
597
- async function updateSpecIndex(name, date) {
598
- const specIndexPath = join5(RSP_DIR, "specs", "INDEX.md");
599
- const archiveDir = join5(RSP_DIR, "archive");
600
- const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
601
- const lineRe = new RegExp(`^\\| \\d{4}-\\d{2}-\\d{2} \\| ${escaped} \\|`, "m");
602
- const archiveFile = await (async () => {
873
+ async function cleanupEmptyParentDirs(path, stopDir) {
874
+ let parent = dirname5(path);
875
+ while (parent !== stopDir) {
603
876
  try {
604
- const entries = await walkMarkdownFiles(archiveDir);
605
- return entries.find((fp) => basename2(fp).startsWith(`${date}_`) && basename2(fp).endsWith(".md"));
606
- } catch {
607
- return null;
608
- }
609
- })();
610
- let summary = "\u2014";
611
- if (archiveFile) {
612
- try {
613
- const content = await readFile5(join5(archiveDir, archiveFile), "utf-8");
614
- const m = content.match(/^## Spec[\s\S]*?\n- Summary:[ \t]*([^\n]+)$/m);
615
- if (m)
616
- summary = m[1].trim();
877
+ const remaining = await readdir2(parent);
878
+ if (remaining.length === 0)
879
+ await rmdir(parent);
880
+ else
881
+ break;
617
882
  } catch {
883
+ break;
618
884
  }
619
- }
620
- const line = `| ${date} | ${name} | ${summary} |
621
- `;
622
- try {
623
- let existing = "";
624
- if (existsSync5(specIndexPath))
625
- existing = await readFile5(specIndexPath, "utf-8");
626
- if (lineRe.test(existing))
627
- return;
628
- if (!existing.includes("| Date | Feature | Summary |")) {
629
- await writeFile3(specIndexPath, `# Specs Index
630
-
631
- | Date | Feature | Summary |
632
- |------|---------|---------|
633
- ${line}
634
- `);
635
- } else {
636
- await appendFile(specIndexPath, line);
637
- }
638
- } catch {
885
+ parent = dirname5(parent);
639
886
  }
640
887
  }
641
888
 
642
889
  // src/commands/deps.ts
643
- import { existsSync as existsSync6 } from "fs";
644
- import { readFile as readFile6 } from "fs/promises";
645
- import { join as join6 } from "path";
646
- async function showDependencies(mermaid = false) {
647
- const featuresDir = join6(RSP_DIR, "features");
648
- const featureFiles = existsSync6(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
890
+ import { existsSync as existsSync9 } from "fs";
891
+ import { readFile as readFile9 } from "fs/promises";
892
+ import { join as join9 } from "path";
893
+ async function showDependencies(options = false) {
894
+ const resolved = typeof options === "boolean" ? { mermaid: options } : options;
895
+ const mermaid = Boolean(resolved.mermaid);
896
+ const focus = resolved.focus?.trim();
897
+ const reverse = resolved.reverse?.trim();
898
+ if (focus && reverse)
899
+ throw new Error("Use either --focus or --reverse, not both");
900
+ const featuresDir = join9(RSP_DIR, "features");
901
+ const featureFiles = existsSync9(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
649
902
  console.log();
650
903
  console.log(` ${pc.bold("RSP dependencies")}`);
651
904
  console.log();
@@ -660,7 +913,7 @@ async function showDependencies(mermaid = false) {
660
913
  const name = featureNameFromPath(featuresDir, fp);
661
914
  let content;
662
915
  try {
663
- content = await readFile6(fp, "utf-8");
916
+ content = await readFile9(fp, "utf-8");
664
917
  } catch {
665
918
  continue;
666
919
  }
@@ -674,18 +927,41 @@ async function showDependencies(mermaid = false) {
674
927
  dependents.set(dep, list);
675
928
  }
676
929
  }
930
+ let visibleRecords = [...records];
931
+ if (focus) {
932
+ const related = /* @__PURE__ */ new Set([focus]);
933
+ const target = records.find((record) => record.name === focus);
934
+ if (target) {
935
+ for (const dep of target.deps)
936
+ related.add(dep);
937
+ }
938
+ for (const dependent of dependents.get(focus) || [])
939
+ related.add(dependent);
940
+ visibleRecords = records.filter((record) => related.has(record.name));
941
+ } else if (reverse) {
942
+ const related = new Set(dependents.get(reverse) || []);
943
+ visibleRecords = records.filter((record) => related.has(record.name));
944
+ }
945
+ if ((focus || reverse) && visibleRecords.length === 0) {
946
+ console.log(` ${pc.dim("No dependency matches found for the current filter.")}
947
+ `);
948
+ return;
949
+ }
677
950
  if (mermaid) {
678
951
  console.log("```mermaid");
679
952
  console.log("graph LR");
680
953
  const safeId = (s) => s.replace(/\//g, "__slash__").replace(/\W/g, "_");
681
- for (const { name } of records) {
954
+ for (const { name } of visibleRecords) {
682
955
  const id = safeId(name);
683
956
  const label = name.replace(/"/g, "#quot;");
684
957
  console.log(` ${id}["${label}"]`);
685
958
  }
686
- for (const { name, deps } of records) {
959
+ const visibleNames = new Set(visibleRecords.map((record) => record.name));
960
+ for (const { name, deps } of visibleRecords) {
687
961
  const fromId = safeId(name);
688
962
  for (const dep of deps) {
963
+ if (!visibleNames.has(dep))
964
+ continue;
689
965
  const toId = safeId(dep);
690
966
  console.log(` ${fromId} --> ${toId}`);
691
967
  }
@@ -696,7 +972,7 @@ async function showDependencies(mermaid = false) {
696
972
  }
697
973
  console.log(` ${"Feature".padEnd(28)} ${"Depends On".padEnd(28)} Dependents`);
698
974
  console.log(` ${"\u2500".repeat(28)} ${"\u2500".repeat(28)} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
699
- for (const { name, deps } of records.sort((a, b) => a.name.localeCompare(b.name))) {
975
+ for (const { name, deps } of visibleRecords.sort((a, b) => a.name.localeCompare(b.name))) {
700
976
  const depText = deps.length > 0 ? deps.join(", ") : "\u2014";
701
977
  const dependentText = (dependents.get(name) || []).length > 0 ? pc.dim((dependents.get(name) || []).join(", ")) : "\u2014";
702
978
  console.log(` ${name.padEnd(28)} ${depText.padEnd(28)} ${dependentText}`);
@@ -704,36 +980,149 @@ async function showDependencies(mermaid = false) {
704
980
  console.log();
705
981
  }
706
982
 
707
- // src/commands/init.ts
708
- import { existsSync as existsSync7 } from "fs";
709
- import { cp, mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
710
- import { basename as basename3, join as join7 } from "path";
711
- async function detectProjectName() {
712
- try {
713
- if (existsSync7("package.json")) {
714
- const raw = await readFile7("package.json", "utf-8");
715
- const pkg = JSON.parse(raw);
716
- if (pkg.name && pkg.name !== "")
717
- return pkg.name;
718
- }
719
- } catch {
983
+ // src/commands/doctor.ts
984
+ import { existsSync as existsSync10 } from "fs";
985
+ import { readFile as readFile10 } from "fs/promises";
986
+ import { join as join10, relative as relative5 } from "path";
987
+ async function runDoctor() {
988
+ let issues = 0;
989
+ console.log();
990
+ console.log(` ${pc.bold("RSP doctor")}`);
991
+ console.log();
992
+ const rspRulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
993
+ const designPath = join10(RSP_DIR, "specs", "design.md");
994
+ const specsIndexPath = join10(RSP_DIR, "specs", "INDEX.md");
995
+ const archivesIndexPath = join10(RSP_DIR, "archives", "INDEX.md");
996
+ issues += reportCheck(".rsp exists", existsSync10(RSP_DIR), "Run: rsp init");
997
+ issues += reportCheck("rules/rsp-rules.md exists", existsSync10(rspRulesPath), "Run: rsp init");
998
+ issues += reportCheck("specs/design.md exists", existsSync10(designPath), "Run: rsp init");
999
+ issues += reportCheck("specs/INDEX.md exists", existsSync10(specsIndexPath), "Run: rsp specs-index");
1000
+ issues += reportCheck("archives/INDEX.md exists", existsSync10(archivesIndexPath), "Run: rsp archive-index");
1001
+ issues += await checkAgents();
1002
+ issues += await checkGeneratedIndex(specsIndexPath, "_Auto-generated by `rsp specs-index`._", "specs/INDEX.md has generated signature", "Run: rsp specs-index");
1003
+ issues += await checkGeneratedIndex(archivesIndexPath, "_Auto-generated by `rsp archive-index`._", "archives/INDEX.md has generated signature", "Run: rsp archive-index");
1004
+ issues += await checkArchiveNaming();
1005
+ issues += await checkConfigSemantics();
1006
+ issues += await checkActiveFeatureConsistency();
1007
+ if (issues === 0)
1008
+ console.log(`
1009
+ ${pc.green("\u2713")} RSP setup looks healthy.
1010
+ `);
1011
+ else
1012
+ console.log(`
1013
+ ${pc.yellow(String(issues))} issue(s) detected.
1014
+ `);
1015
+ return issues;
1016
+ }
1017
+ function reportCheck(label, ok, fixHint) {
1018
+ if (ok) {
1019
+ console.log(` ${pc.green("\u2713")} ${label}`);
1020
+ return 0;
720
1021
  }
721
- return basename3(process.cwd());
1022
+ console.log(` ${pc.yellow("!")} ${label}`);
1023
+ if (fixHint)
1024
+ console.log(` ${pc.dim(fixHint)}`);
1025
+ return 1;
1026
+ }
1027
+ async function checkAgents() {
1028
+ if (!existsSync10("AGENTS.md")) {
1029
+ console.log(` ${pc.yellow("!")} AGENTS.md missing`);
1030
+ console.log(` ${pc.dim("Run: rsp init --agents-mode managed")}`);
1031
+ return 1;
1032
+ }
1033
+ const agents = await readFile10("AGENTS.md", "utf-8");
1034
+ if (hasRspAgentsBlock(agents)) {
1035
+ console.log(` ${pc.green("\u2713")} AGENTS.md contains managed RSP block`);
1036
+ return 0;
1037
+ }
1038
+ console.log(` ${pc.yellow("!")} AGENTS.md missing managed RSP block`);
1039
+ console.log(` ${pc.dim("Run: rsp init --agents-mode managed")}`);
1040
+ return 1;
1041
+ }
1042
+ async function checkGeneratedIndex(path, signature, label, fixHint) {
1043
+ if (!existsSync10(path))
1044
+ return 0;
1045
+ const content = await readFile10(path, "utf-8");
1046
+ if (content.includes(signature)) {
1047
+ console.log(` ${pc.green("\u2713")} ${label}`);
1048
+ return 0;
1049
+ }
1050
+ console.log(` ${pc.yellow("!")} ${label}`);
1051
+ console.log(` ${pc.dim(fixHint)}`);
1052
+ return 1;
1053
+ }
1054
+ async function checkArchiveNaming() {
1055
+ const archivesDir = join10(RSP_DIR, "archives");
1056
+ const archiveFiles = existsSync10(archivesDir) ? (await walkMarkdownFiles(archivesDir)).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
1057
+ const invalidFiles = archiveFiles.map((fp) => relative5(archivesDir, fp)).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(fp.split("/").at(-1) || "") === false);
1058
+ if (invalidFiles.length === 0) {
1059
+ console.log(` ${pc.green("\u2713")} archived feature files follow the date_name.md convention`);
1060
+ return 0;
1061
+ }
1062
+ console.log(` ${pc.yellow("!")} archived feature files with invalid names: ${invalidFiles.join(", ")}`);
1063
+ console.log(` ${pc.dim("Rename archive files to YYYY-MM-DD_name.md and run: rsp archive-index")}`);
1064
+ return 1;
1065
+ }
1066
+ async function checkConfigSemantics() {
1067
+ if (!existsSync10(CONFIG_PATH))
1068
+ return 0;
1069
+ const content = await readFile10(CONFIG_PATH, "utf-8");
1070
+ const parsed = parseYamlLines(content.split("\n"));
1071
+ let issues = 0;
1072
+ issues += reportConfigListField(parsed, "statuses");
1073
+ issues += reportConfigListField(parsed, "priorities");
1074
+ issues += reportConfigListField(parsed, "required_sections");
1075
+ return issues;
1076
+ }
1077
+ function reportConfigListField(parsed, key) {
1078
+ if (!(key in parsed))
1079
+ return 0;
1080
+ const value = parsed[key];
1081
+ if (!Array.isArray(value)) {
1082
+ console.log(` ${pc.yellow("!")} config.yaml field "${key}" must be a YAML list`);
1083
+ console.log(` ${pc.dim(`Update .rsp/config.yaml so "${key}" uses "key:" followed by indented "- item" entries`)}`);
1084
+ return 1;
1085
+ }
1086
+ const items = value.map(String);
1087
+ const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
1088
+ if (duplicates.length === 0) {
1089
+ console.log(` ${pc.green("\u2713")} config.yaml field "${key}" has valid list semantics`);
1090
+ return 0;
1091
+ }
1092
+ console.log(` ${pc.yellow("!")} config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`);
1093
+ console.log(` ${pc.dim(`Remove duplicate values from .rsp/config.yaml:${key}`)}`);
1094
+ return 1;
1095
+ }
1096
+ async function checkActiveFeatureConsistency() {
1097
+ const featuresDir = join10(RSP_DIR, "features");
1098
+ const featureFiles = existsSync10(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
1099
+ const featureNames = new Set(featureFiles.map((fp) => relative5(featuresDir, fp).replace(/\.md$/, "")));
1100
+ const activeEntries = existsSync10(ACTIVE_DIR) ? await walkFiles(ACTIVE_DIR) : [];
1101
+ const activeNames = new Set(activeEntries.map((fp) => relative5(ACTIVE_DIR, fp)));
1102
+ const missingFeatureFiles = [...activeNames].filter((name) => !featureNames.has(name));
1103
+ const unstagedActiveMarkers = [...featureNames].filter((name) => !activeNames.has(name));
1104
+ let issues = 0;
1105
+ if (missingFeatureFiles.length === 0) {
1106
+ console.log(` ${pc.green("\u2713")} every active marker maps to a feature file`);
1107
+ } else {
1108
+ console.log(` ${pc.yellow("!")} active markers without matching feature files: ${missingFeatureFiles.join(", ")}`);
1109
+ console.log(` ${pc.dim("Run: remove stale files in .rsp/active.d/ or recreate the feature with rsp new <name>")}`);
1110
+ issues++;
1111
+ }
1112
+ if (unstagedActiveMarkers.length === 0) {
1113
+ console.log(` ${pc.green("\u2713")} every feature file has an active marker`);
1114
+ } else {
1115
+ console.log(` ${pc.yellow("!")} feature files without active markers: ${unstagedActiveMarkers.join(", ")}`);
1116
+ console.log(` ${pc.dim("Run: recreate the marker with rsp new <name> or archive the feature with rsp close <name>")}`);
1117
+ issues++;
1118
+ }
1119
+ return issues;
722
1120
  }
723
- var AGENTS_BODY = `## Collaboration Mode
724
- This project uses **RSP** (Rules, Specs, Plans) \u2014 see \`.rsp/rules/\` for the full workflow definition (start with \`rsp-rules.md\`).
725
-
726
- Key conventions:
727
- - \`.rsp/rules/\` \u2014 technical constraints and coding conventions
728
- - \`.rsp/specs/\` \u2014 project-level architecture and design reference
729
- - \`.rsp/features/<name>.md\` \u2014 feature definitions (spec + plan + tests)
730
- - \`.rsp/active.d/\` \u2014 currently active features (path = feature name)
731
- - \`.rsp/archive/\` \u2014 completed features
732
1121
 
733
- ## Workflow
734
- - Run \`rsp status\` to see current project state
735
- - Run \`rsp new <name>\` to start a new feature
736
- - Run \`rsp close <name>\` to archive a completed feature`;
1122
+ // src/commands/init.ts
1123
+ import { existsSync as existsSync11 } from "fs";
1124
+ import { mkdir as mkdir5, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
1125
+ import { basename as basename6, join as join11 } from "path";
737
1126
  var CONFIG_TEMPLATE = `# RSP project configuration
738
1127
  # Uncomment any section below to override defaults.
739
1128
  #
@@ -759,68 +1148,86 @@ var CONFIG_TEMPLATE = `# RSP project configuration
759
1148
  # - Spec
760
1149
  # - Plan
761
1150
  `;
762
- async function initProject() {
763
- const isNew = !existsSync7(RSP_DIR);
1151
+ async function detectProjectName3() {
1152
+ try {
1153
+ if (existsSync11("package.json")) {
1154
+ const raw = await readFile11("package.json", "utf-8");
1155
+ const pkg = JSON.parse(raw);
1156
+ if (pkg.name && pkg.name !== "")
1157
+ return pkg.name;
1158
+ }
1159
+ } catch {
1160
+ }
1161
+ return basename6(process.cwd());
1162
+ }
1163
+ async function ensureFile(path, content) {
1164
+ if (existsSync11(path))
1165
+ return false;
1166
+ await writeFile6(path, content);
1167
+ return true;
1168
+ }
1169
+ function toTitle(projectName) {
1170
+ return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
1171
+ }
1172
+ async function initProject(args = {}) {
1173
+ const isNew = !existsSync11(RSP_DIR);
1174
+ const projectName = await detectProjectName3();
764
1175
  return withRspLock("init", async () => {
765
1176
  const dirs = [
766
- join7(RSP_DIR, "rules"),
767
- join7(RSP_DIR, "specs"),
768
- join7(RSP_DIR, "features"),
769
- join7(RSP_DIR, "archive")
1177
+ join11(RSP_DIR, "rules"),
1178
+ join11(RSP_DIR, "specs"),
1179
+ join11(RSP_DIR, "features"),
1180
+ join11(RSP_DIR, "active.d"),
1181
+ join11(RSP_DIR, "archives")
770
1182
  ];
771
- let created = false;
772
1183
  for (const d of dirs)
773
- await mkdir3(d, { recursive: true });
774
- const rulesDest = join7(RSP_DIR, "rules", "rsp-rules.md");
775
- if (!existsSync7(rulesDest)) {
776
- await cp(join7(PKG_ROOT, "rules", "rsp-rules.md"), rulesDest);
777
- created = true;
778
- }
779
- const configPath = join7(RSP_DIR, "config.yaml");
780
- if (!existsSync7(configPath)) {
781
- await writeFile4(configPath, CONFIG_TEMPLATE);
782
- created = true;
783
- }
784
- const specIndexPath = join7(RSP_DIR, "specs", "INDEX.md");
785
- if (!existsSync7(specIndexPath)) {
786
- await writeFile4(specIndexPath, "# Specs Index\n\n_Extracted from archived features._\n");
787
- created = true;
788
- }
789
- const gitignorePath = join7(RSP_DIR, ".gitignore");
790
- if (!existsSync7(gitignorePath)) {
791
- await writeFile4(gitignorePath, "# Transient files that should not be committed\n.lock\n");
792
- created = true;
793
- }
1184
+ await mkdir5(d, { recursive: true });
1185
+ let created = false;
1186
+ const bundledRules = await readFile11(join11(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1187
+ created = await ensureFile(join11(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
1188
+ created = await ensureFile(join11(RSP_DIR, "config.yaml"), CONFIG_TEMPLATE) || created;
1189
+ const createdSpecsIndex = await ensureFile(join11(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Project-level specs and design notes._\n");
1190
+ created = createdSpecsIndex || created;
1191
+ const createdDesign = await ensureFile(join11(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1192
+ created = createdDesign || created;
1193
+ const createdArchivesIndex = await ensureFile(join11(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1194
+ created = createdArchivesIndex || created;
1195
+ created = await ensureFile(join11(RSP_DIR, "features", ".gitkeep"), "") || created;
1196
+ created = await ensureFile(join11(RSP_DIR, "active.d", ".gitkeep"), "") || created;
1197
+ created = await ensureFile(join11(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1198
+ if (args.withProjectRules)
1199
+ created = await ensureFile(join11(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
1200
+ if (createdSpecsIndex || createdDesign)
1201
+ await buildSpecsIndex({ acquireLock: false });
1202
+ if (createdArchivesIndex)
1203
+ await buildArchiveIndex({ acquireLock: false });
794
1204
  const agentsPath = "AGENTS.md";
795
- const projectName = await detectProjectName();
796
- if (existsSync7(agentsPath)) {
797
- const existing = await readFile7(agentsPath, "utf-8");
798
- const rspRe = /\bRSP\b/;
799
- if (!rspRe.test(existing)) {
800
- const h2Match = existing.match(/^## /m);
801
- if (h2Match) {
802
- const before = existing.slice(0, h2Match.index);
803
- const after = existing.slice(h2Match.index);
804
- await writeFile4(agentsPath, `${before}${AGENTS_BODY}
805
-
806
- ---
807
-
808
- ${after}`);
809
- } else {
810
- await writeFile4(agentsPath, `${existing}
1205
+ const title = toTitle(projectName);
1206
+ const mode = args.agentsMode ?? "managed";
1207
+ if (!existsSync11(agentsPath)) {
1208
+ if (mode === "managed") {
1209
+ await writeFile6(agentsPath, `# ${title}
811
1210
 
812
- ${AGENTS_BODY}
1211
+ ${upsertRspAgentsBlock("").content}
813
1212
  `);
814
- }
815
1213
  created = true;
816
1214
  }
817
- } else {
818
- const title = projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
819
- await writeFile4(agentsPath, `# ${title}
1215
+ } else if (mode === "managed") {
1216
+ const existing = await readFile11(agentsPath, "utf-8");
1217
+ const next = upsertRspAgentsBlock(existing);
1218
+ if (next.changed) {
1219
+ await writeFile6(agentsPath, next.content);
1220
+ created = true;
1221
+ }
1222
+ }
1223
+ if (mode === "print") {
1224
+ const managed = existsSync11(agentsPath) ? upsertRspAgentsBlock(await readFile11(agentsPath, "utf-8")).content : `# ${title}
820
1225
 
821
- ${AGENTS_BODY}
1226
+ ${renderRspAgentsBlock()}
1227
+ `;
1228
+ console.log(`
1229
+ ${managed}
822
1230
  `);
823
- created = true;
824
1231
  }
825
1232
  if (isNew) {
826
1233
  console.log(`
@@ -829,9 +1236,11 @@ ${AGENTS_BODY}
829
1236
  console.log(` Created: .rsp/rules/
830
1237
  .rsp/specs/
831
1238
  .rsp/features/
832
- .rsp/archive/
1239
+ .rsp/active.d/
1240
+ .rsp/archives/
833
1241
  .rsp/.gitignore
834
1242
  .rsp/config.yaml
1243
+ .rsp/specs/design.md
835
1244
  AGENTS.md
836
1245
  `);
837
1246
  console.log(` ${pc.cyan("Next:")} rsp new <name>
@@ -850,34 +1259,44 @@ ${AGENTS_BODY}
850
1259
  }
851
1260
 
852
1261
  // src/commands/new-feature.ts
853
- import { existsSync as existsSync8 } from "fs";
854
- import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
855
- import { dirname as dirname4, join as join8 } from "path";
1262
+ import { existsSync as existsSync12 } from "fs";
1263
+ import { mkdir as mkdir6, writeFile as writeFile7 } from "fs/promises";
1264
+ import { dirname as dirname6, join as join12 } from "path";
856
1265
  async function newFeature(name, summary = "") {
1266
+ if (!name) {
1267
+ console.error(` ${pc.red("Usage:")} rsp new <name> [summary]`);
1268
+ process.exit(1);
1269
+ }
1270
+ if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
1271
+ console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1272
+ process.exit(1);
1273
+ }
1274
+ if (name === "init") {
1275
+ console.error(` ${pc.red("Error:")} "init" is a reserved workflow name, not a feature name`);
1276
+ process.exit(1);
1277
+ }
1278
+ const rspRulesPath = join12(RSP_DIR, "rules", "rsp-rules.md");
1279
+ const designPath = join12(RSP_DIR, "specs", "design.md");
1280
+ if (!existsSync12(rspRulesPath) || !existsSync12(designPath)) {
1281
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
1282
+ console.error(` ${pc.dim("Run: rsp init")}`);
1283
+ process.exit(1);
1284
+ }
857
1285
  return withRspLock("new-feature", async () => {
858
- if (!name) {
859
- console.error(` ${pc.red("Usage:")} rsp new <name> [summary]`);
860
- process.exit(1);
861
- }
862
- if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
863
- console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
864
- process.exit(1);
865
- }
866
- const featurePath = join8(RSP_DIR, "features", `${name}.md`);
867
- await mkdir4(dirname4(featurePath), { recursive: true });
868
- const existed = existsSync8(featurePath);
1286
+ const featurePath = join12(RSP_DIR, "features", `${name}.md`);
1287
+ await mkdir6(dirname6(featurePath), { recursive: true });
1288
+ const existed = existsSync12(featurePath);
869
1289
  if (!existed) {
870
1290
  const content = generateFeatureContent(name, summary);
871
- await writeFile5(featurePath, content);
1291
+ await writeFile7(featurePath, content);
872
1292
  }
873
- await mkdir4(ACTIVE_DIR, { recursive: true });
874
- const activeEntry = join8(ACTIVE_DIR, name);
875
- await mkdir4(dirname4(activeEntry), { recursive: true });
876
- await writeFile5(activeEntry, "");
1293
+ await mkdir6(ACTIVE_DIR, { recursive: true });
1294
+ const activeEntry = join12(ACTIVE_DIR, name);
1295
+ await mkdir6(dirname6(activeEntry), { recursive: true });
1296
+ await writeFile7(activeEntry, "");
877
1297
  if (!existed) {
878
- const archiveDir = join8(RSP_DIR, "archive");
879
- const archiveIndex = join8(archiveDir, "INDEX.md");
880
- if (existsSync8(archiveIndex))
1298
+ const archiveIndex = join12(ARCHIVES_DIR, "INDEX.md");
1299
+ if (existsSync12(archiveIndex))
881
1300
  await buildArchiveIndex({ acquireLock: false });
882
1301
  }
883
1302
  const label = existed ? "Using" : pc.green("Created");
@@ -888,27 +1307,27 @@ async function newFeature(name, summary = "") {
888
1307
  }
889
1308
 
890
1309
  // src/commands/status.ts
891
- import { existsSync as existsSync9 } from "fs";
892
- import { readFile as readFile8, stat as stat2 } from "fs/promises";
893
- import { join as join9, relative as relative4 } from "path";
1310
+ import { existsSync as existsSync13 } from "fs";
1311
+ import { readFile as readFile12, stat as stat2 } from "fs/promises";
1312
+ import { join as join13, relative as relative6 } from "path";
894
1313
  var COL_FEATURE = 30;
895
1314
  var COL_STATUS = 16;
896
1315
  var COL_AGE = 7;
897
1316
  var COL_PROGRESS = 10;
898
- async function showStatus() {
899
- const featuresDir = join9(RSP_DIR, "features");
1317
+ async function showStatus(options = {}) {
1318
+ const featuresDir = join13(RSP_DIR, "features");
900
1319
  const activeSet = /* @__PURE__ */ new Set();
901
- if (existsSync9(ACTIVE_DIR)) {
1320
+ if (existsSync13(ACTIVE_DIR)) {
902
1321
  try {
903
1322
  const entries = await walkFiles(ACTIVE_DIR);
904
1323
  for (const entryPath of entries) {
905
- const name = relative4(ACTIVE_DIR, entryPath);
1324
+ const name = relative6(ACTIVE_DIR, entryPath);
906
1325
  activeSet.add(name);
907
1326
  }
908
1327
  } catch {
909
1328
  }
910
1329
  }
911
- const featureFiles = existsSync9(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
1330
+ const featureFiles = existsSync13(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
912
1331
  const featureMap = {};
913
1332
  for (const fp of featureFiles) {
914
1333
  const name = featureNameFromPath(featuresDir, fp);
@@ -941,7 +1360,7 @@ async function showStatus() {
941
1360
  let isBlocked = false;
942
1361
  if (fp) {
943
1362
  try {
944
- const content = await readFile8(fp, "utf-8");
1363
+ const content = await readFile12(fp, "utf-8");
945
1364
  const fm = parseFrontmatter(content);
946
1365
  status = fm?.status || "\u2014";
947
1366
  isBlocked = status === "blocked";
@@ -960,11 +1379,28 @@ async function showStatus() {
960
1379
  }
961
1380
  records.push({ name, status, progress, age, depends, isActive, isBlocked });
962
1381
  }
1382
+ const filteredRecords = records.filter((record) => {
1383
+ if (options.active && !record.isActive)
1384
+ return false;
1385
+ if (options.blocked && !record.isBlocked)
1386
+ return false;
1387
+ if (typeof options.stale === "number") {
1388
+ const ageDays = Number(record.age);
1389
+ if (!Number.isFinite(ageDays) || ageDays < options.stale)
1390
+ return false;
1391
+ }
1392
+ return true;
1393
+ });
1394
+ if (filteredRecords.length === 0) {
1395
+ console.log(` ${pc.dim("No features match the current filters.")}`);
1396
+ console.log();
1397
+ return;
1398
+ }
963
1399
  const pad = (s, w) => s.padEnd(w);
964
1400
  console.log(` ${pad("Feature", COL_FEATURE)} ${pad("Status", COL_STATUS)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)} Depends On`);
965
1401
  console.log(` ${"\u2500".repeat(COL_FEATURE)} ${"\u2500".repeat(COL_STATUS)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)} ${"\u2500".repeat(30)}`);
966
1402
  let blockedCount = 0;
967
- for (const r of records) {
1403
+ for (const r of filteredRecords) {
968
1404
  const marker = r.isActive ? pc.cyan("*") : " ";
969
1405
  const namePadded = r.name.padEnd(COL_FEATURE - 1);
970
1406
  const statusPadded = String(r.status).padEnd(COL_STATUS);
@@ -978,24 +1414,25 @@ async function showStatus() {
978
1414
  blockedCount++;
979
1415
  }
980
1416
  console.log();
981
- const total = records.length;
982
- const doneCount = records.filter((r) => r.status === "done").length;
983
- console.log(` ${pc.bold("Summary:")} ${total} feature(s), ${activeSet.size} active, ${doneCount} done, ${pc.yellow(String(blockedCount))} blocked`);
1417
+ const total = filteredRecords.length;
1418
+ const doneCount = filteredRecords.filter((r) => r.status === "done").length;
1419
+ const activeCount = filteredRecords.filter((r) => r.isActive).length;
1420
+ console.log(` ${pc.bold("Summary:")} ${total} feature(s), ${activeCount} active, ${doneCount} done, ${pc.yellow(String(blockedCount))} blocked`);
984
1421
  console.log();
985
1422
  await showArchiveTrend();
986
1423
  if (blockedCount > 0) {
987
- const blocked = records.filter((r) => r.isBlocked);
1424
+ const blocked = filteredRecords.filter((r) => r.isBlocked);
988
1425
  console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
989
1426
  console.log();
990
1427
  }
991
1428
  }
992
1429
  async function showArchiveTrend() {
993
- const archiveDir = join9(RSP_DIR, "archive");
994
- const indexPath = join9(archiveDir, "INDEX.md");
995
- if (!existsSync9(indexPath))
1430
+ const archiveDir = join13(RSP_DIR, "archives");
1431
+ const indexPath = join13(archiveDir, "INDEX.md");
1432
+ if (!existsSync13(indexPath))
996
1433
  return;
997
1434
  try {
998
- const content = await readFile8(indexPath, "utf-8");
1435
+ const content = await readFile12(indexPath, "utf-8");
999
1436
  const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
1000
1437
  const counts = {};
1001
1438
  let match;
@@ -1023,8 +1460,23 @@ var initCommand = defineCommand({
1023
1460
  name: "init",
1024
1461
  description: "Scaffold .rsp/ + AGENTS.md in current project"
1025
1462
  },
1026
- async run() {
1027
- await initProject();
1463
+ args: {
1464
+ "agents-mode": {
1465
+ type: "string",
1466
+ description: "How to update AGENTS.md: managed, skip, or print",
1467
+ default: "managed"
1468
+ },
1469
+ "with-project-rules": {
1470
+ type: "boolean",
1471
+ description: "Create .rsp/rules/project-rules.md",
1472
+ default: false
1473
+ }
1474
+ },
1475
+ async run({ args }) {
1476
+ await initProject({
1477
+ agentsMode: args["agents-mode"] === "skip" || args["agents-mode"] === "print" ? args["agents-mode"] : "managed",
1478
+ withProjectRules: Boolean(args["with-project-rules"])
1479
+ });
1028
1480
  }
1029
1481
  });
1030
1482
  var newFeatureCommand = defineCommand({
@@ -1044,10 +1496,52 @@ var newFeatureCommand = defineCommand({
1044
1496
  await newFeature(args.name, summary);
1045
1497
  }
1046
1498
  });
1499
+ var addRulesCommand = defineCommand({
1500
+ meta: {
1501
+ name: "rules",
1502
+ description: "Create .rsp/rules/<name>.md"
1503
+ },
1504
+ args: {
1505
+ name: {
1506
+ type: "positional",
1507
+ description: "Rules file name",
1508
+ required: true
1509
+ }
1510
+ },
1511
+ async run({ args }) {
1512
+ await addRules(args.name);
1513
+ }
1514
+ });
1515
+ var addSpecCommand = defineCommand({
1516
+ meta: {
1517
+ name: "spec",
1518
+ description: "Create .rsp/specs/<name>.md and rebuild specs index"
1519
+ },
1520
+ args: {
1521
+ name: {
1522
+ type: "positional",
1523
+ description: "Spec file name",
1524
+ required: true
1525
+ }
1526
+ },
1527
+ async run({ args }) {
1528
+ await addSpec(args.name);
1529
+ }
1530
+ });
1531
+ var addCommand = defineCommand({
1532
+ meta: {
1533
+ name: "add",
1534
+ description: "Add optional rules or spec files"
1535
+ },
1536
+ subCommands: {
1537
+ rules: addRulesCommand,
1538
+ spec: addSpecCommand
1539
+ }
1540
+ });
1047
1541
  var closeFeatureCommand = defineCommand({
1048
1542
  meta: {
1049
1543
  name: "close",
1050
- description: "Archive to .rsp/archive/"
1544
+ description: "Archive to .rsp/archives/"
1051
1545
  },
1052
1546
  args: {
1053
1547
  name: {
@@ -1065,8 +1559,33 @@ var statusCommand = defineCommand({
1065
1559
  name: "status",
1066
1560
  description: "Show project status summary"
1067
1561
  },
1068
- async run() {
1069
- await showStatus();
1562
+ args: {
1563
+ active: {
1564
+ type: "boolean",
1565
+ description: "Show only active features",
1566
+ default: false
1567
+ },
1568
+ blocked: {
1569
+ type: "boolean",
1570
+ description: "Show only blocked features",
1571
+ default: false
1572
+ },
1573
+ stale: {
1574
+ type: "string",
1575
+ description: "Show only features with age >= days"
1576
+ }
1577
+ },
1578
+ async run({ args }) {
1579
+ const stale = args.stale === void 0 ? void 0 : Number(args.stale);
1580
+ if (args.stale !== void 0 && (Number.isFinite(stale) === false || Number.isInteger(stale) === false || stale < 0)) {
1581
+ console.error(` Error: --stale must be a non-negative integer number of days`);
1582
+ process.exit(1);
1583
+ }
1584
+ await showStatus({
1585
+ active: Boolean(args.active),
1586
+ blocked: Boolean(args.blocked),
1587
+ stale
1588
+ });
1070
1589
  }
1071
1590
  });
1072
1591
  var checkCommand = defineCommand({
@@ -1090,21 +1609,53 @@ var depsCommand = defineCommand({
1090
1609
  type: "boolean",
1091
1610
  description: "Output as Mermaid.js graph",
1092
1611
  default: false
1612
+ },
1613
+ focus: {
1614
+ type: "string",
1615
+ description: "Show only a feature and its direct dependency neighborhood"
1616
+ },
1617
+ reverse: {
1618
+ type: "string",
1619
+ description: "Show only features that depend on the given feature"
1093
1620
  }
1094
1621
  },
1095
1622
  async run({ args }) {
1096
- await showDependencies(Boolean(args.mermaid));
1623
+ await showDependencies({
1624
+ mermaid: Boolean(args.mermaid),
1625
+ focus: typeof args.focus === "string" ? args.focus : void 0,
1626
+ reverse: typeof args.reverse === "string" ? args.reverse : void 0
1627
+ });
1097
1628
  }
1098
1629
  });
1099
1630
  var archiveIndexCommand = defineCommand({
1100
1631
  meta: {
1101
1632
  name: "archive-index",
1102
- description: "Regenerate archive INDEX.md"
1633
+ description: "Regenerate archives INDEX.md"
1103
1634
  },
1104
1635
  async run() {
1105
1636
  await buildArchiveIndex();
1106
1637
  }
1107
1638
  });
1639
+ var specsIndexCommand = defineCommand({
1640
+ meta: {
1641
+ name: "specs-index",
1642
+ description: "Regenerate specs INDEX.md"
1643
+ },
1644
+ async run() {
1645
+ await buildSpecsIndex();
1646
+ }
1647
+ });
1648
+ var doctorCommand = defineCommand({
1649
+ meta: {
1650
+ name: "doctor",
1651
+ description: "Check RSP setup health and common integration issues"
1652
+ },
1653
+ async run() {
1654
+ const issues = await runDoctor();
1655
+ if (issues > 0)
1656
+ process.exit(1);
1657
+ }
1658
+ });
1108
1659
  async function runCli(rawArgs = process.argv.slice(2)) {
1109
1660
  const version = await getVersion();
1110
1661
  const main = defineCommand({
@@ -1115,12 +1666,15 @@ async function runCli(rawArgs = process.argv.slice(2)) {
1115
1666
  },
1116
1667
  subCommands: {
1117
1668
  "init": initCommand,
1669
+ "add": addCommand,
1118
1670
  "new": newFeatureCommand,
1119
1671
  "close": closeFeatureCommand,
1120
1672
  "status": statusCommand,
1121
1673
  "check": checkCommand,
1122
1674
  "deps": depsCommand,
1123
- "archive-index": archiveIndexCommand
1675
+ "archive-index": archiveIndexCommand,
1676
+ "specs-index": specsIndexCommand,
1677
+ "doctor": doctorCommand
1124
1678
  }
1125
1679
  });
1126
1680
  await runMain(main, { rawArgs });