@oevery/rsp 1.0.3 → 1.2.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));
@@ -106,17 +112,51 @@ function featureNameFromPath(featuresDir, filePath) {
106
112
  return rel.replace(/\.md$/, "");
107
113
  }
108
114
  function generateFeatureContent(name, summary = "") {
115
+ if (name === "project-setup") {
116
+ return `---
117
+ status: draft
118
+ priority: medium
119
+ tags:
120
+ ---
121
+
122
+ # Feature: project-setup
123
+
124
+ ## Spec
125
+ - Summary: ${summary || "Capture the project model, boundaries, and stable local constraints"}
126
+ - Requirements:
127
+ - Project purpose, scope, and structure are reflected in .rsp/specs/design.md
128
+ - Stable validation or workflow constraints are reflected in .rsp/rules/project-rules.md when needed
129
+ - Constraints:
130
+ - Avoid duplicating durable project facts across feature notes, specs, and rules
131
+
132
+ ## Plan
133
+ - [ ] Review the repository structure, entrypoints, and primary outputs
134
+ - [ ] Fill .rsp/specs/design.md with durable architecture facts
135
+ - [ ] Add .rsp/rules/project-rules.md if stable local rules or validation steps exist
136
+
137
+ ## Tests
138
+ - [ ] Run rsp doctor
139
+ - [ ] Run project-native validation if available
140
+
141
+ ## Notes (optional)
142
+ - <facts discovered during project setup>
143
+
144
+ ## Blockers
145
+ -
146
+ `;
147
+ }
109
148
  return `---
110
149
  status: draft
111
150
  priority: medium
112
151
  tags:
113
152
  ---
153
+
114
154
  # Feature: ${name}
115
155
 
116
156
  ## Spec
117
157
  - Summary: ${summary || "<one-line summary>"}
118
158
  - Requirements:
119
- - [ ] <verifiable requirement>
159
+ - <verifiable requirement>
120
160
  - Constraints:
121
161
  -
122
162
 
@@ -134,6 +174,111 @@ tags:
134
174
  -
135
175
  `;
136
176
  }
177
+ function renderRspAgentsBlock() {
178
+ return `${RSP_AGENTS_BEGIN}
179
+ ## RSP Entry
180
+
181
+ Read in order:
182
+ 1. .rsp/rules/*.md
183
+ 2. .rsp/specs/INDEX.md
184
+ 3. .rsp/specs/design.md
185
+ 4. .rsp/active.d/ and matching .rsp/features/*.md
186
+
187
+ Guidelines:
188
+ - .rsp/rules/rsp-rules.md is required
189
+ - .rsp/rules/project-rules.md is optional
190
+ - Keep project design in .rsp/specs/
191
+ - Add extra spec files only when they have durable value
192
+ ${RSP_AGENTS_END}`;
193
+ }
194
+ function upsertRspAgentsBlock(content) {
195
+ const block = renderRspAgentsBlock();
196
+ const managedRe = /<!-- rsp:begin -->[\s\S]*?<!-- rsp:end -->/;
197
+ if (managedRe.test(content)) {
198
+ const next2 = content.replace(managedRe, block);
199
+ return { content: next2, changed: next2 !== content };
200
+ }
201
+ const trimmed = content.trimStart();
202
+ const next = trimmed ? `${block}
203
+
204
+ ${trimmed}` : `${block}
205
+ `;
206
+ return { content: next, changed: true };
207
+ }
208
+ function hasRspAgentsBlock(content) {
209
+ return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
210
+ }
211
+ function generateDesignContent(projectName) {
212
+ return `# Project Design: ${projectName}
213
+
214
+ ## Purpose
215
+ - <what this project is responsible for>
216
+ - <who or what it serves>
217
+
218
+ ## Scope
219
+ - In scope:
220
+ - <major capability or boundary>
221
+ - Out of scope:
222
+ - <intentional non-goal>
223
+
224
+ ## Structure
225
+ - <important directory or subsystem> \u2014 <responsibility>
226
+
227
+ ## Constraints
228
+ - <cross-cutting technical or operational constraint, when it affects architecture>
229
+ `;
230
+ }
231
+ function generateProjectRulesContent(projectName) {
232
+ return `---
233
+ name: project-rules
234
+ description: Project-specific rules for ${projectName}
235
+ ---
236
+
237
+ # Project Rules
238
+
239
+ ## Scope
240
+ - <stable local rules, workflow constraints, or validation expectations>
241
+
242
+ ## Validation
243
+ - <preferred stable validation commands, not temporary troubleshooting steps>
244
+
245
+ ## Conventions
246
+ - <project-specific conventions>
247
+ `;
248
+ }
249
+ function generateRulesContent(name) {
250
+ const title = toTitleCase(name);
251
+ return `---
252
+ name: ${name}
253
+ description: Project-specific rules for ${title}
254
+ ---
255
+
256
+ # ${title}
257
+
258
+ ## Scope
259
+ - <what this rules file governs>
260
+
261
+ ## Rules
262
+ - <stable local rule>
263
+
264
+ ## Validation
265
+ - <relevant validation command or check>
266
+ `;
267
+ }
268
+ function generateSpecContent(name) {
269
+ const title = toTitleCase(name);
270
+ return `# ${title}
271
+
272
+ ## Purpose
273
+ - <why this project-level spec exists>
274
+
275
+ ## Details
276
+ - <important project-level detail>
277
+ `;
278
+ }
279
+ function toTitleCase(value) {
280
+ return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
281
+ }
137
282
  function detectDeltaSections(content) {
138
283
  const match = content.match(/^## Spec\n([\s\S]*?)(?=\n## |\n---|\n\.\.\.)/m);
139
284
  if (!match) {
@@ -204,6 +349,7 @@ var __dirname = dirname(fileURLToPath(import.meta.url));
204
349
  var PKG_ROOT = join2(__dirname, "..");
205
350
  var RSP_DIR = ".rsp";
206
351
  var ACTIVE_DIR = join2(RSP_DIR, "active.d");
352
+ var ARCHIVES_DIR = join2(RSP_DIR, "archives");
207
353
  var LOCK_PATH = join2(RSP_DIR, ".lock");
208
354
  var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
209
355
  var VALID_STATUSES = ["draft", "ready", "in-progress", "blocked", "done"];
@@ -295,40 +441,204 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
295
441
  }
296
442
  }
297
443
 
298
- // src/commands/archive-index.ts
299
- async function buildArchiveIndex({ acquireLock = true } = {}) {
444
+ // src/commands/add-rules.ts
445
+ var NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
446
+ async function detectProjectName() {
447
+ try {
448
+ if (existsSync3("package.json")) {
449
+ const raw = await readFile3("package.json", "utf-8");
450
+ const pkg = JSON.parse(raw);
451
+ if (pkg.name && pkg.name !== "")
452
+ return pkg.name;
453
+ }
454
+ } catch {
455
+ }
456
+ return basename(process.cwd());
457
+ }
458
+ async function addRules(name, projectName = "project") {
459
+ if (!name || !NAME_RE.test(name)) {
460
+ console.error(` ${pc.red("Error:")} rule name must be kebab-case with optional subdirectory`);
461
+ process.exit(1);
462
+ }
463
+ const rspRulesPath = join3(RSP_DIR, "rules", "rsp-rules.md");
464
+ const designPath = join3(RSP_DIR, "specs", "design.md");
465
+ if (!existsSync3(rspRulesPath) || !existsSync3(designPath)) {
466
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
467
+ console.error(` ${pc.dim("Run: rsp init")}`);
468
+ process.exit(1);
469
+ }
470
+ return withRspLock("add-rules", async () => {
471
+ const dir = join3(RSP_DIR, "rules");
472
+ const path = join3(dir, `${name}.md`);
473
+ if (existsSync3(path)) {
474
+ console.error(` ${pc.red("Error:")} rule file already exists: .rsp/rules/${name}.md`);
475
+ process.exit(1);
476
+ }
477
+ await mkdir2(dirname2(path), { recursive: true });
478
+ const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
479
+ const content = name === "project-rules" ? generateProjectRulesContent(resolvedProjectName) : generateRulesContent(name);
480
+ await writeFile2(path, content);
481
+ console.log(` ${pc.green("Created:")} .rsp/rules/${name}.md
482
+ `);
483
+ });
484
+ }
485
+
486
+ // src/commands/add-spec.ts
487
+ import { existsSync as existsSync5 } from "fs";
488
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
489
+ import { basename as basename3, dirname as dirname3, join as join5 } from "path";
490
+
491
+ // src/commands/specs-index.ts
492
+ import { existsSync as existsSync4 } from "fs";
493
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
494
+ import { basename as basename2, join as join4, relative as relative2 } from "path";
495
+ async function buildSpecsIndex({ acquireLock = true } = {}) {
300
496
  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.")}
497
+ return withRspLock("specs-index", async () => buildSpecsIndex({ acquireLock: false }));
498
+ const specsDir = join4(RSP_DIR, "specs");
499
+ if (!existsSync4(specsDir)) {
500
+ console.log(` ${pc.dim("No specs directory.")}
305
501
  `);
306
502
  return;
307
503
  }
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.")}
504
+ const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) => basename2(fp) !== "INDEX.md").sort();
505
+ const lines = [];
506
+ lines.push("# Specs Index");
507
+ lines.push("");
508
+ lines.push("_Project-level specs and design notes._");
509
+ lines.push("");
510
+ if (specFiles.length === 0) {
511
+ lines.push("_No project-level specs yet._");
512
+ } else {
513
+ lines.push("| File | Title | Summary |");
514
+ lines.push("|------|-------|---------|");
515
+ for (const fp of specFiles) {
516
+ const rel = relative2(specsDir, fp);
517
+ const content = await readFile4(fp, "utf-8");
518
+ const fm = parseFrontmatter(content);
519
+ const title = extractTitle(content) || rel.replace(/\.md$/, "");
520
+ const summary = extractSummary(content, fm?.summary);
521
+ lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
522
+ }
523
+ }
524
+ lines.push("");
525
+ lines.push("_Auto-generated by `rsp specs-index`._");
526
+ await writeFile3(join4(specsDir, "INDEX.md"), lines.join("\n"));
527
+ console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
528
+ `);
529
+ }
530
+ function extractTitle(content) {
531
+ for (const rawLine of content.split("\n")) {
532
+ if (!rawLine.startsWith("# "))
533
+ continue;
534
+ return rawLine.slice(2).trim();
535
+ }
536
+ return "";
537
+ }
538
+ function extractSummary(content, frontmatterSummary) {
539
+ if (typeof frontmatterSummary === "string" && frontmatterSummary.trim() !== "")
540
+ return frontmatterSummary.trim();
541
+ const lines = content.split("\n");
542
+ let inFrontmatter = false;
543
+ for (const rawLine of lines) {
544
+ const line = rawLine.trim();
545
+ if (line === "---" || line === "...") {
546
+ inFrontmatter = !inFrontmatter;
547
+ continue;
548
+ }
549
+ if (inFrontmatter || line === "")
550
+ continue;
551
+ if (line.startsWith("#"))
552
+ continue;
553
+ if (line.startsWith("##"))
554
+ continue;
555
+ return line.replace(/^[-*]\s*/, "").trim();
556
+ }
557
+ return "";
558
+ }
559
+ function escapeCell(value) {
560
+ return value.replace(/\|/g, "\\|");
561
+ }
562
+
563
+ // src/commands/add-spec.ts
564
+ var NAME_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
565
+ async function detectProjectName2() {
566
+ try {
567
+ if (existsSync5("package.json")) {
568
+ const raw = await readFile5("package.json", "utf-8");
569
+ const pkg = JSON.parse(raw);
570
+ if (pkg.name && pkg.name !== "")
571
+ return pkg.name;
572
+ }
573
+ } catch {
574
+ }
575
+ return basename3(process.cwd());
576
+ }
577
+ async function addSpec(name, projectName = "project") {
578
+ if (!name || !NAME_RE2.test(name)) {
579
+ console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
580
+ process.exit(1);
581
+ }
582
+ const rspRulesPath = join5(RSP_DIR, "rules", "rsp-rules.md");
583
+ const designPath = join5(RSP_DIR, "specs", "design.md");
584
+ if (!existsSync5(rspRulesPath) || !existsSync5(designPath)) {
585
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
586
+ console.error(` ${pc.dim("Run: rsp init")}`);
587
+ process.exit(1);
588
+ }
589
+ return withRspLock("add-spec", async () => {
590
+ const dir = join5(RSP_DIR, "specs");
591
+ const path = join5(dir, `${name}.md`);
592
+ if (existsSync5(path)) {
593
+ console.error(` ${pc.red("Error:")} spec file already exists: .rsp/specs/${name}.md`);
594
+ process.exit(1);
595
+ }
596
+ await mkdir3(dirname3(path), { recursive: true });
597
+ const resolvedProjectName = projectName === "project" ? await detectProjectName2() : projectName;
598
+ const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
599
+ await writeFile4(path, content);
600
+ await buildSpecsIndex({ acquireLock: false });
601
+ console.log(` ${pc.green("Created:")} .rsp/specs/${name}.md
602
+ `);
603
+ });
604
+ }
605
+
606
+ // src/commands/archive-index.ts
607
+ import { existsSync as existsSync6 } from "fs";
608
+ import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
609
+ import { basename as basename4, dirname as dirname4, join as join6, relative as relative3 } from "path";
610
+ async function buildArchiveIndex({ acquireLock = true } = {}) {
611
+ if (acquireLock)
612
+ return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
613
+ const archiveDir = join6(RSP_DIR, "archives");
614
+ if (!existsSync6(archiveDir)) {
615
+ console.log(` ${pc.dim("No archives directory.")}
311
616
  `);
312
617
  return;
313
618
  }
619
+ const archiveFiles = (await walkMarkdownFiles(archiveDir)).filter((fp) => basename4(fp) !== "INDEX.md").sort();
314
620
  const lines = [];
315
621
  lines.push("# Archive Index");
316
622
  lines.push("");
317
623
  lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
318
624
  lines.push("");
319
- lines.push("| Date | Feature | Summary |");
320
- lines.push("|------|---------|---------|");
625
+ if (archiveFiles.length === 0) {
626
+ lines.push("_No archived features yet._");
627
+ } else {
628
+ lines.push("| Date | Feature | Summary |");
629
+ lines.push("|------|---------|---------|");
630
+ }
321
631
  for (const fp of archiveFiles) {
322
- const base = basename(fp);
323
- const archiveRel = relative2(archiveDir, fp);
324
- const archiveDirPart = dirname2(archiveRel);
632
+ const base = basename4(fp);
633
+ const archiveRel = relative3(archiveDir, fp);
634
+ const archiveDirPart = dirname4(archiveRel);
325
635
  const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
326
636
  const date = dateMatch ? dateMatch[1] : "\u2014";
327
637
  const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
328
638
  const featureName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
329
639
  let summary = "";
330
640
  try {
331
- const content = await readFile3(fp, "utf-8");
641
+ const content = await readFile6(fp, "utf-8");
332
642
  const fm = parseFrontmatter(content);
333
643
  if (fm?.summary)
334
644
  summary = String(fm.summary);
@@ -344,18 +654,18 @@ async function buildArchiveIndex({ acquireLock = true } = {}) {
344
654
  }
345
655
  lines.push("");
346
656
  lines.push("_Auto-generated by `rsp archive-index`._");
347
- const indexPath = join3(archiveDir, "INDEX.md");
348
- await writeFile2(indexPath, lines.join("\n"));
657
+ const indexPath = join6(archiveDir, "INDEX.md");
658
+ await writeFile5(indexPath, lines.join("\n"));
349
659
  console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived feature(s).
350
660
  `);
351
661
  }
352
662
 
353
663
  // 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";
664
+ import { existsSync as existsSync7 } from "fs";
665
+ import { readFile as readFile7 } from "fs/promises";
666
+ import { join as join7, relative as relative4 } from "path";
357
667
  async function runCheck() {
358
- const featuresDir = join4(RSP_DIR, "features");
668
+ const featuresDir = join7(RSP_DIR, "features");
359
669
  const config = await loadRspConfig();
360
670
  const validStatuses = resolveStatuses(config);
361
671
  const validPriorities = resolvePriorities(config);
@@ -365,34 +675,34 @@ async function runCheck() {
365
675
  console.log();
366
676
  console.log(` ${pc.bold("RSP check")}`);
367
677
  console.log();
368
- if (existsSync4(ACTIVE_DIR)) {
678
+ if (existsSync7(ACTIVE_DIR)) {
369
679
  const entries = await walkFiles(ACTIVE_DIR);
370
680
  for (const entryPath of entries) {
371
- const entryContent = relative3(ACTIVE_DIR, entryPath);
372
- const markerContent = (await readFile4(entryPath, "utf-8")).trim();
681
+ const entryContent = relative4(ACTIVE_DIR, entryPath);
682
+ const markerContent = (await readFile7(entryPath, "utf-8")).trim();
373
683
  if (markerContent) {
374
684
  console.log(` ${pc.yellow("\u26A0")} active.d/${entryContent} should be an empty marker file; path is the source of truth`);
375
685
  warnings++;
376
686
  }
377
- const fp = join4(featuresDir, `${entryContent}.md`);
378
- if (!existsSync4(fp)) {
687
+ const fp = join7(featuresDir, `${entryContent}.md`);
688
+ if (!existsSync7(fp)) {
379
689
  console.log(` ${pc.red("\u2717")} active.d/${entryContent} points to "${entryContent}" but features/${entryContent}.md not found`);
380
690
  errors++;
381
691
  }
382
692
  }
383
693
  }
384
- const featureFiles = existsSync4(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
694
+ const featureFiles = existsSync7(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
385
695
  if (featureFiles.length === 0) {
386
696
  console.log(` ${pc.dim("No feature files to check.")}
387
697
  `);
388
- return;
698
+ return 0;
389
699
  }
390
700
  const allDeps = /* @__PURE__ */ new Map();
391
701
  for (const fp of featureFiles) {
392
702
  const name = featureNameFromPath(featuresDir, fp);
393
703
  let content;
394
704
  try {
395
- content = await readFile4(fp, "utf-8");
705
+ content = await readFile7(fp, "utf-8");
396
706
  } catch {
397
707
  console.log(` ${pc.red("\u2717")} ${name} \u2014 unable to read file`);
398
708
  errors++;
@@ -487,8 +797,8 @@ async function runCheck() {
487
797
  }
488
798
  seen.add(dep);
489
799
  allDeps.set(name, [...allDeps.get(name) || [], dep]);
490
- const depFp = join4(featuresDir, `${dep}.md`);
491
- if (!existsSync4(depFp)) {
800
+ const depFp = join7(featuresDir, `${dep}.md`);
801
+ if (!existsSync7(depFp)) {
492
802
  console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on "${dep}" but features/${dep}.md not found`);
493
803
  errors++;
494
804
  }
@@ -511,9 +821,9 @@ async function runCheck() {
511
821
  }
512
822
 
513
823
  // 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";
824
+ import { existsSync as existsSync8 } from "fs";
825
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile8, rename, rmdir, unlink as unlink2 } from "fs/promises";
826
+ import { basename as basename5, dirname as dirname5, join as join8 } from "path";
517
827
  async function closeFeature(name) {
518
828
  return withRspLock("close-feature", async () => {
519
829
  if (!name) {
@@ -524,128 +834,104 @@ async function closeFeature(name) {
524
834
  console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
525
835
  process.exit(1);
526
836
  }
527
- const srcPath = join5(RSP_DIR, "features", `${name}.md`);
528
- if (!existsSync5(srcPath)) {
837
+ const srcPath = join8(RSP_DIR, "features", `${name}.md`);
838
+ if (!existsSync8(srcPath)) {
529
839
  console.error(` ${pc.red("Feature not found:")} .rsp/features/${name}.md`);
530
840
  process.exit(1);
531
841
  }
842
+ const dependents = await findDependents(name);
843
+ if (dependents.length > 0) {
844
+ console.error(` ${pc.red("Error:")} cannot close "${name}" because it is still referenced by: ${dependents.join(", ")}`);
845
+ console.error(` ${pc.dim("Remove or update those depends-on entries before archiving this feature.")}`);
846
+ process.exit(1);
847
+ }
532
848
  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");
849
+ const dir = dirname5(name);
850
+ const base = basename5(name);
851
+ const archiveSubdir = dir !== "." ? join8(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
536
852
  const archiveName = `${date}_${base}.md`;
537
- await mkdir2(archiveSubdir, { recursive: true });
538
- await rename(srcPath, join5(archiveSubdir, archiveName));
853
+ await mkdir4(archiveSubdir, { recursive: true });
854
+ await rename(srcPath, join8(archiveSubdir, archiveName));
539
855
  let activeCleared = false;
540
- const activeEntry = join5(ACTIVE_DIR, name);
541
- if (existsSync5(activeEntry)) {
856
+ const activeEntry = join8(ACTIVE_DIR, name);
857
+ if (existsSync8(activeEntry)) {
542
858
  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
- }
859
+ await cleanupEmptyParentDirs(activeEntry, ACTIVE_DIR);
556
860
  activeCleared = true;
557
861
  }
862
+ await cleanupEmptyParentDirs(srcPath, join8(RSP_DIR, "features"));
558
863
  const clearedMsg = activeCleared ? ` ${pc.dim("active.d cleared")}
559
864
  ` : "";
560
865
  console.log(` ${pc.green("Archived:")} ${archiveName}
561
866
  ${clearedMsg}`);
562
867
  await buildArchiveIndex({ acquireLock: false });
563
868
  await runCheckIfHasDependents(name);
564
- await updateSpecIndex(name, date);
565
- if (existsSync5(".git")) {
566
- const archiveRelPath = dir !== "." ? join5(dir, archiveName) : archiveName;
869
+ if (existsSync8(".git")) {
870
+ const archiveRelPath = dir !== "." ? join8(dir, archiveName) : archiveName;
567
871
  console.log(` ${pc.cyan("Git workflow:")}
568
872
  `);
569
- console.log(` git add .rsp/archive/${archiveRelPath}`);
873
+ console.log(` git add .rsp/archives/${archiveRelPath}`);
570
874
  console.log(` git commit -m "feat: complete ${name}"`);
571
875
  console.log();
572
876
  }
573
877
  });
574
878
  }
879
+ async function findDependents(name) {
880
+ const featuresDir = join8(RSP_DIR, "features");
881
+ const allFeatureFiles = await walkMarkdownFiles(featuresDir);
882
+ const dependents = [];
883
+ for (const fp of allFeatureFiles) {
884
+ const featureName = fp === join8(featuresDir, `${name}.md`) ? name : fp.replace(`${featuresDir}/`, "").replace(/\.md$/, "");
885
+ if (featureName === name)
886
+ continue;
887
+ const content = await readFile8(fp, "utf-8");
888
+ const fm = parseFrontmatter(content);
889
+ if (!fm || !fm["depends-on"])
890
+ continue;
891
+ const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
892
+ if (deps.includes(name))
893
+ dependents.push(featureName);
894
+ }
895
+ return dependents.sort();
896
+ }
575
897
  async function runCheckIfHasDependents(name) {
576
898
  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
- }
899
+ const hasDependents = (await findDependents(name)).length > 0;
591
900
  if (hasDependents)
592
901
  await runCheck();
593
902
  } catch {
594
903
  console.warn(` ${pc.dim("\u26A0 could not verify dependents for")} "${name}"`);
595
904
  }
596
905
  }
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 () => {
603
- 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) {
906
+ async function cleanupEmptyParentDirs(path, stopDir) {
907
+ let parent = dirname5(path);
908
+ while (parent !== stopDir) {
612
909
  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();
910
+ const remaining = await readdir2(parent);
911
+ if (remaining.length === 0)
912
+ await rmdir(parent);
913
+ else
914
+ break;
617
915
  } catch {
916
+ break;
618
917
  }
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 {
918
+ parent = dirname5(parent);
639
919
  }
640
920
  }
641
921
 
642
922
  // 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) : [];
923
+ import { existsSync as existsSync9 } from "fs";
924
+ import { readFile as readFile9 } from "fs/promises";
925
+ import { join as join9 } from "path";
926
+ async function showDependencies(options = false) {
927
+ const resolved = typeof options === "boolean" ? { mermaid: options } : options;
928
+ const mermaid = Boolean(resolved.mermaid);
929
+ const focus = resolved.focus?.trim();
930
+ const reverse = resolved.reverse?.trim();
931
+ if (focus && reverse)
932
+ throw new Error("Use either --focus or --reverse, not both");
933
+ const featuresDir = join9(RSP_DIR, "features");
934
+ const featureFiles = existsSync9(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
649
935
  console.log();
650
936
  console.log(` ${pc.bold("RSP dependencies")}`);
651
937
  console.log();
@@ -660,7 +946,7 @@ async function showDependencies(mermaid = false) {
660
946
  const name = featureNameFromPath(featuresDir, fp);
661
947
  let content;
662
948
  try {
663
- content = await readFile6(fp, "utf-8");
949
+ content = await readFile9(fp, "utf-8");
664
950
  } catch {
665
951
  continue;
666
952
  }
@@ -674,18 +960,41 @@ async function showDependencies(mermaid = false) {
674
960
  dependents.set(dep, list);
675
961
  }
676
962
  }
963
+ let visibleRecords = [...records];
964
+ if (focus) {
965
+ const related = /* @__PURE__ */ new Set([focus]);
966
+ const target = records.find((record) => record.name === focus);
967
+ if (target) {
968
+ for (const dep of target.deps)
969
+ related.add(dep);
970
+ }
971
+ for (const dependent of dependents.get(focus) || [])
972
+ related.add(dependent);
973
+ visibleRecords = records.filter((record) => related.has(record.name));
974
+ } else if (reverse) {
975
+ const related = new Set(dependents.get(reverse) || []);
976
+ visibleRecords = records.filter((record) => related.has(record.name));
977
+ }
978
+ if ((focus || reverse) && visibleRecords.length === 0) {
979
+ console.log(` ${pc.dim("No dependency matches found for the current filter.")}
980
+ `);
981
+ return;
982
+ }
677
983
  if (mermaid) {
678
984
  console.log("```mermaid");
679
985
  console.log("graph LR");
680
986
  const safeId = (s) => s.replace(/\//g, "__slash__").replace(/\W/g, "_");
681
- for (const { name } of records) {
987
+ for (const { name } of visibleRecords) {
682
988
  const id = safeId(name);
683
989
  const label = name.replace(/"/g, "#quot;");
684
990
  console.log(` ${id}["${label}"]`);
685
991
  }
686
- for (const { name, deps } of records) {
992
+ const visibleNames = new Set(visibleRecords.map((record) => record.name));
993
+ for (const { name, deps } of visibleRecords) {
687
994
  const fromId = safeId(name);
688
995
  for (const dep of deps) {
996
+ if (!visibleNames.has(dep))
997
+ continue;
689
998
  const toId = safeId(dep);
690
999
  console.log(` ${fromId} --> ${toId}`);
691
1000
  }
@@ -696,7 +1005,7 @@ async function showDependencies(mermaid = false) {
696
1005
  }
697
1006
  console.log(` ${"Feature".padEnd(28)} ${"Depends On".padEnd(28)} Dependents`);
698
1007
  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))) {
1008
+ for (const { name, deps } of visibleRecords.sort((a, b) => a.name.localeCompare(b.name))) {
700
1009
  const depText = deps.length > 0 ? deps.join(", ") : "\u2014";
701
1010
  const dependentText = (dependents.get(name) || []).length > 0 ? pc.dim((dependents.get(name) || []).join(", ")) : "\u2014";
702
1011
  console.log(` ${name.padEnd(28)} ${depText.padEnd(28)} ${dependentText}`);
@@ -704,36 +1013,149 @@ async function showDependencies(mermaid = false) {
704
1013
  console.log();
705
1014
  }
706
1015
 
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 {
1016
+ // src/commands/doctor.ts
1017
+ import { existsSync as existsSync10 } from "fs";
1018
+ import { readFile as readFile10 } from "fs/promises";
1019
+ import { join as join10, relative as relative5 } from "path";
1020
+ async function runDoctor() {
1021
+ let issues = 0;
1022
+ console.log();
1023
+ console.log(` ${pc.bold("RSP doctor")}`);
1024
+ console.log();
1025
+ const rspRulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
1026
+ const designPath = join10(RSP_DIR, "specs", "design.md");
1027
+ const specsIndexPath = join10(RSP_DIR, "specs", "INDEX.md");
1028
+ const archivesIndexPath = join10(RSP_DIR, "archives", "INDEX.md");
1029
+ issues += reportCheck(".rsp exists", existsSync10(RSP_DIR), "Run: rsp init");
1030
+ issues += reportCheck("rules/rsp-rules.md exists", existsSync10(rspRulesPath), "Run: rsp init");
1031
+ issues += reportCheck("specs/design.md exists", existsSync10(designPath), "Run: rsp init");
1032
+ issues += reportCheck("specs/INDEX.md exists", existsSync10(specsIndexPath), "Run: rsp specs-index");
1033
+ issues += reportCheck("archives/INDEX.md exists", existsSync10(archivesIndexPath), "Run: rsp archive-index");
1034
+ issues += await checkAgents();
1035
+ issues += await checkGeneratedIndex(specsIndexPath, "_Auto-generated by `rsp specs-index`._", "specs/INDEX.md has generated signature", "Run: rsp specs-index");
1036
+ issues += await checkGeneratedIndex(archivesIndexPath, "_Auto-generated by `rsp archive-index`._", "archives/INDEX.md has generated signature", "Run: rsp archive-index");
1037
+ issues += await checkArchiveNaming();
1038
+ issues += await checkConfigSemantics();
1039
+ issues += await checkActiveFeatureConsistency();
1040
+ if (issues === 0)
1041
+ console.log(`
1042
+ ${pc.green("\u2713")} RSP setup looks healthy.
1043
+ `);
1044
+ else
1045
+ console.log(`
1046
+ ${pc.yellow(String(issues))} issue(s) detected.
1047
+ `);
1048
+ return issues;
1049
+ }
1050
+ function reportCheck(label, ok, fixHint) {
1051
+ if (ok) {
1052
+ console.log(` ${pc.green("\u2713")} ${label}`);
1053
+ return 0;
720
1054
  }
721
- return basename3(process.cwd());
1055
+ console.log(` ${pc.yellow("!")} ${label}`);
1056
+ if (fixHint)
1057
+ console.log(` ${pc.dim(fixHint)}`);
1058
+ return 1;
1059
+ }
1060
+ async function checkAgents() {
1061
+ if (!existsSync10("AGENTS.md")) {
1062
+ console.log(` ${pc.yellow("!")} AGENTS.md missing`);
1063
+ console.log(` ${pc.dim("Run: rsp init --agents-mode managed")}`);
1064
+ return 1;
1065
+ }
1066
+ const agents = await readFile10("AGENTS.md", "utf-8");
1067
+ if (hasRspAgentsBlock(agents)) {
1068
+ console.log(` ${pc.green("\u2713")} AGENTS.md contains managed RSP block`);
1069
+ return 0;
1070
+ }
1071
+ console.log(` ${pc.yellow("!")} AGENTS.md missing managed RSP block`);
1072
+ console.log(` ${pc.dim("Run: rsp init --agents-mode managed")}`);
1073
+ return 1;
1074
+ }
1075
+ async function checkGeneratedIndex(path, signature, label, fixHint) {
1076
+ if (!existsSync10(path))
1077
+ return 0;
1078
+ const content = await readFile10(path, "utf-8");
1079
+ if (content.includes(signature)) {
1080
+ console.log(` ${pc.green("\u2713")} ${label}`);
1081
+ return 0;
1082
+ }
1083
+ console.log(` ${pc.yellow("!")} ${label}`);
1084
+ console.log(` ${pc.dim(fixHint)}`);
1085
+ return 1;
1086
+ }
1087
+ async function checkArchiveNaming() {
1088
+ const archivesDir = join10(RSP_DIR, "archives");
1089
+ const archiveFiles = existsSync10(archivesDir) ? (await walkMarkdownFiles(archivesDir)).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
1090
+ const invalidFiles = archiveFiles.map((fp) => relative5(archivesDir, fp)).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(fp.split("/").at(-1) || "") === false);
1091
+ if (invalidFiles.length === 0) {
1092
+ console.log(` ${pc.green("\u2713")} archived feature files follow the date_name.md convention`);
1093
+ return 0;
1094
+ }
1095
+ console.log(` ${pc.yellow("!")} archived feature files with invalid names: ${invalidFiles.join(", ")}`);
1096
+ console.log(` ${pc.dim("Rename archive files to YYYY-MM-DD_name.md and run: rsp archive-index")}`);
1097
+ return 1;
1098
+ }
1099
+ async function checkConfigSemantics() {
1100
+ if (!existsSync10(CONFIG_PATH))
1101
+ return 0;
1102
+ const content = await readFile10(CONFIG_PATH, "utf-8");
1103
+ const parsed = parseYamlLines(content.split("\n"));
1104
+ let issues = 0;
1105
+ issues += reportConfigListField(parsed, "statuses");
1106
+ issues += reportConfigListField(parsed, "priorities");
1107
+ issues += reportConfigListField(parsed, "required_sections");
1108
+ return issues;
1109
+ }
1110
+ function reportConfigListField(parsed, key) {
1111
+ if (!(key in parsed))
1112
+ return 0;
1113
+ const value = parsed[key];
1114
+ if (!Array.isArray(value)) {
1115
+ console.log(` ${pc.yellow("!")} config.yaml field "${key}" must be a YAML list`);
1116
+ console.log(` ${pc.dim(`Update .rsp/config.yaml so "${key}" uses "key:" followed by indented "- item" entries`)}`);
1117
+ return 1;
1118
+ }
1119
+ const items = value.map(String);
1120
+ const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
1121
+ if (duplicates.length === 0) {
1122
+ console.log(` ${pc.green("\u2713")} config.yaml field "${key}" has valid list semantics`);
1123
+ return 0;
1124
+ }
1125
+ console.log(` ${pc.yellow("!")} config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`);
1126
+ console.log(` ${pc.dim(`Remove duplicate values from .rsp/config.yaml:${key}`)}`);
1127
+ return 1;
1128
+ }
1129
+ async function checkActiveFeatureConsistency() {
1130
+ const featuresDir = join10(RSP_DIR, "features");
1131
+ const featureFiles = existsSync10(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
1132
+ const featureNames = new Set(featureFiles.map((fp) => relative5(featuresDir, fp).replace(/\.md$/, "")));
1133
+ const activeEntries = existsSync10(ACTIVE_DIR) ? await walkFiles(ACTIVE_DIR) : [];
1134
+ const activeNames = new Set(activeEntries.map((fp) => relative5(ACTIVE_DIR, fp)));
1135
+ const missingFeatureFiles = [...activeNames].filter((name) => !featureNames.has(name));
1136
+ const unstagedActiveMarkers = [...featureNames].filter((name) => !activeNames.has(name));
1137
+ let issues = 0;
1138
+ if (missingFeatureFiles.length === 0) {
1139
+ console.log(` ${pc.green("\u2713")} every active marker maps to a feature file`);
1140
+ } else {
1141
+ console.log(` ${pc.yellow("!")} active markers without matching feature files: ${missingFeatureFiles.join(", ")}`);
1142
+ console.log(` ${pc.dim("Run: remove stale files in .rsp/active.d/ or recreate the feature with rsp new <name>")}`);
1143
+ issues++;
1144
+ }
1145
+ if (unstagedActiveMarkers.length === 0) {
1146
+ console.log(` ${pc.green("\u2713")} every feature file has an active marker`);
1147
+ } else {
1148
+ console.log(` ${pc.yellow("!")} feature files without active markers: ${unstagedActiveMarkers.join(", ")}`);
1149
+ console.log(` ${pc.dim("Run: recreate the marker with rsp new <name> or archive the feature with rsp close <name>")}`);
1150
+ issues++;
1151
+ }
1152
+ return issues;
722
1153
  }
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
1154
 
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`;
1155
+ // src/commands/init.ts
1156
+ import { existsSync as existsSync11 } from "fs";
1157
+ import { mkdir as mkdir5, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
1158
+ import { basename as basename6, join as join11 } from "path";
737
1159
  var CONFIG_TEMPLATE = `# RSP project configuration
738
1160
  # Uncomment any section below to override defaults.
739
1161
  #
@@ -759,82 +1181,111 @@ var CONFIG_TEMPLATE = `# RSP project configuration
759
1181
  # - Spec
760
1182
  # - Plan
761
1183
  `;
762
- async function initProject() {
763
- const isNew = !existsSync7(RSP_DIR);
1184
+ async function detectProjectName3() {
1185
+ try {
1186
+ if (existsSync11("package.json")) {
1187
+ const raw = await readFile11("package.json", "utf-8");
1188
+ const pkg = JSON.parse(raw);
1189
+ if (pkg.name && pkg.name !== "")
1190
+ return pkg.name;
1191
+ }
1192
+ } catch {
1193
+ }
1194
+ return basename6(process.cwd());
1195
+ }
1196
+ async function ensureFile(path, content) {
1197
+ if (existsSync11(path))
1198
+ return false;
1199
+ await writeFile6(path, content);
1200
+ return true;
1201
+ }
1202
+ function toTitle(projectName) {
1203
+ return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
1204
+ }
1205
+ async function initProject(args = {}) {
1206
+ const isNew = !existsSync11(RSP_DIR);
1207
+ const projectName = await detectProjectName3();
764
1208
  return withRspLock("init", async () => {
765
1209
  const dirs = [
766
- join7(RSP_DIR, "rules"),
767
- join7(RSP_DIR, "specs"),
768
- join7(RSP_DIR, "features"),
769
- join7(RSP_DIR, "archive")
1210
+ join11(RSP_DIR, "rules"),
1211
+ join11(RSP_DIR, "specs"),
1212
+ join11(RSP_DIR, "features"),
1213
+ join11(RSP_DIR, "active.d"),
1214
+ join11(RSP_DIR, "archives")
770
1215
  ];
771
- let created = false;
772
1216
  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;
1217
+ await mkdir5(d, { recursive: true });
1218
+ let created = false;
1219
+ const bundledRules = await readFile11(join11(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1220
+ created = await ensureFile(join11(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
1221
+ created = await ensureFile(join11(RSP_DIR, "config.yaml"), CONFIG_TEMPLATE) || created;
1222
+ const createdSpecsIndex = await ensureFile(join11(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Project-level specs and design notes._\n");
1223
+ created = createdSpecsIndex || created;
1224
+ const createdDesign = await ensureFile(join11(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1225
+ created = createdDesign || created;
1226
+ const createdArchivesIndex = await ensureFile(join11(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1227
+ created = createdArchivesIndex || created;
1228
+ created = await ensureFile(join11(RSP_DIR, "features", ".gitkeep"), "") || created;
1229
+ created = await ensureFile(join11(RSP_DIR, "active.d", ".gitkeep"), "") || created;
1230
+ created = await ensureFile(join11(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1231
+ if (args.withProjectRules)
1232
+ created = await ensureFile(join11(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
1233
+ if (args.withProjectSetup) {
1234
+ const featurePath = join11(RSP_DIR, "features", "project-setup.md");
1235
+ const activeMarker = join11(RSP_DIR, "active.d", "project-setup");
1236
+ created = await ensureFile(featurePath, generateFeatureContent("project-setup")) || created;
1237
+ created = await ensureFile(activeMarker, "") || created;
793
1238
  }
1239
+ if (createdSpecsIndex || createdDesign)
1240
+ await buildSpecsIndex({ acquireLock: false });
1241
+ if (createdArchivesIndex)
1242
+ await buildArchiveIndex({ acquireLock: false });
794
1243
  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}
1244
+ const title = toTitle(projectName);
1245
+ const mode = args.agentsMode ?? "managed";
1246
+ if (!existsSync11(agentsPath)) {
1247
+ if (mode === "managed") {
1248
+ await writeFile6(agentsPath, `# ${title}
805
1249
 
806
- ---
807
-
808
- ${after}`);
809
- } else {
810
- await writeFile4(agentsPath, `${existing}
811
-
812
- ${AGENTS_BODY}
1250
+ ${upsertRspAgentsBlock("").content}
813
1251
  `);
814
- }
815
1252
  created = true;
816
1253
  }
817
- } else {
818
- const title = projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
819
- await writeFile4(agentsPath, `# ${title}
1254
+ } else if (mode === "managed") {
1255
+ const existing = await readFile11(agentsPath, "utf-8");
1256
+ const next = upsertRspAgentsBlock(existing);
1257
+ if (next.changed) {
1258
+ await writeFile6(agentsPath, next.content);
1259
+ created = true;
1260
+ }
1261
+ }
1262
+ if (mode === "print") {
1263
+ const managed = existsSync11(agentsPath) ? upsertRspAgentsBlock(await readFile11(agentsPath, "utf-8")).content : `# ${title}
820
1264
 
821
- ${AGENTS_BODY}
1265
+ ${renderRspAgentsBlock()}
1266
+ `;
1267
+ console.log(`
1268
+ ${managed}
822
1269
  `);
823
- created = true;
824
1270
  }
825
1271
  if (isNew) {
1272
+ const createdProjectSetup = args.withProjectSetup ? "\n .rsp/features/project-setup.md\n .rsp/active.d/project-setup" : "";
1273
+ const next = args.withProjectSetup ? "fill .rsp/features/project-setup.md" : "rsp new project-setup";
826
1274
  console.log(`
827
1275
  ${pc.green("RSP scaffolded.")}
828
1276
  `);
829
1277
  console.log(` Created: .rsp/rules/
830
1278
  .rsp/specs/
831
1279
  .rsp/features/
832
- .rsp/archive/
1280
+ .rsp/active.d/
1281
+ .rsp/archives/
833
1282
  .rsp/.gitignore
834
1283
  .rsp/config.yaml
1284
+ .rsp/specs/design.md${createdProjectSetup}
835
1285
  AGENTS.md
836
1286
  `);
837
- console.log(` ${pc.cyan("Next:")} rsp new <name>
1287
+ console.log(` ${pc.cyan("Next:")} ${next}
1288
+ ${pc.dim("Then:")} fill .rsp/specs/design.md
838
1289
  ${pc.dim("Also:")} rsp status rsp deps
839
1290
  `);
840
1291
  } else if (created) {
@@ -850,34 +1301,40 @@ ${AGENTS_BODY}
850
1301
  }
851
1302
 
852
1303
  // 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";
1304
+ import { existsSync as existsSync12 } from "fs";
1305
+ import { mkdir as mkdir6, writeFile as writeFile7 } from "fs/promises";
1306
+ import { dirname as dirname6, join as join12 } from "path";
856
1307
  async function newFeature(name, summary = "") {
1308
+ if (!name) {
1309
+ console.error(` ${pc.red("Usage:")} rsp new <name> [summary]`);
1310
+ process.exit(1);
1311
+ }
1312
+ if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
1313
+ console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1314
+ process.exit(1);
1315
+ }
1316
+ const rspRulesPath = join12(RSP_DIR, "rules", "rsp-rules.md");
1317
+ const designPath = join12(RSP_DIR, "specs", "design.md");
1318
+ if (!existsSync12(rspRulesPath) || !existsSync12(designPath)) {
1319
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
1320
+ console.error(` ${pc.dim("Run: rsp init")}`);
1321
+ process.exit(1);
1322
+ }
857
1323
  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);
1324
+ const featurePath = join12(RSP_DIR, "features", `${name}.md`);
1325
+ await mkdir6(dirname6(featurePath), { recursive: true });
1326
+ const existed = existsSync12(featurePath);
869
1327
  if (!existed) {
870
1328
  const content = generateFeatureContent(name, summary);
871
- await writeFile5(featurePath, content);
1329
+ await writeFile7(featurePath, content);
872
1330
  }
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, "");
1331
+ await mkdir6(ACTIVE_DIR, { recursive: true });
1332
+ const activeEntry = join12(ACTIVE_DIR, name);
1333
+ await mkdir6(dirname6(activeEntry), { recursive: true });
1334
+ await writeFile7(activeEntry, "");
877
1335
  if (!existed) {
878
- const archiveDir = join8(RSP_DIR, "archive");
879
- const archiveIndex = join8(archiveDir, "INDEX.md");
880
- if (existsSync8(archiveIndex))
1336
+ const archiveIndex = join12(ARCHIVES_DIR, "INDEX.md");
1337
+ if (existsSync12(archiveIndex))
881
1338
  await buildArchiveIndex({ acquireLock: false });
882
1339
  }
883
1340
  const label = existed ? "Using" : pc.green("Created");
@@ -888,27 +1345,27 @@ async function newFeature(name, summary = "") {
888
1345
  }
889
1346
 
890
1347
  // 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";
1348
+ import { existsSync as existsSync13 } from "fs";
1349
+ import { readFile as readFile12, stat as stat2 } from "fs/promises";
1350
+ import { join as join13, relative as relative6 } from "path";
894
1351
  var COL_FEATURE = 30;
895
1352
  var COL_STATUS = 16;
896
1353
  var COL_AGE = 7;
897
1354
  var COL_PROGRESS = 10;
898
- async function showStatus() {
899
- const featuresDir = join9(RSP_DIR, "features");
1355
+ async function showStatus(options = {}) {
1356
+ const featuresDir = join13(RSP_DIR, "features");
900
1357
  const activeSet = /* @__PURE__ */ new Set();
901
- if (existsSync9(ACTIVE_DIR)) {
1358
+ if (existsSync13(ACTIVE_DIR)) {
902
1359
  try {
903
1360
  const entries = await walkFiles(ACTIVE_DIR);
904
1361
  for (const entryPath of entries) {
905
- const name = relative4(ACTIVE_DIR, entryPath);
1362
+ const name = relative6(ACTIVE_DIR, entryPath);
906
1363
  activeSet.add(name);
907
1364
  }
908
1365
  } catch {
909
1366
  }
910
1367
  }
911
- const featureFiles = existsSync9(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
1368
+ const featureFiles = existsSync13(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
912
1369
  const featureMap = {};
913
1370
  for (const fp of featureFiles) {
914
1371
  const name = featureNameFromPath(featuresDir, fp);
@@ -941,7 +1398,7 @@ async function showStatus() {
941
1398
  let isBlocked = false;
942
1399
  if (fp) {
943
1400
  try {
944
- const content = await readFile8(fp, "utf-8");
1401
+ const content = await readFile12(fp, "utf-8");
945
1402
  const fm = parseFrontmatter(content);
946
1403
  status = fm?.status || "\u2014";
947
1404
  isBlocked = status === "blocked";
@@ -960,11 +1417,28 @@ async function showStatus() {
960
1417
  }
961
1418
  records.push({ name, status, progress, age, depends, isActive, isBlocked });
962
1419
  }
1420
+ const filteredRecords = records.filter((record) => {
1421
+ if (options.active && !record.isActive)
1422
+ return false;
1423
+ if (options.blocked && !record.isBlocked)
1424
+ return false;
1425
+ if (typeof options.stale === "number") {
1426
+ const ageDays = Number(record.age);
1427
+ if (!Number.isFinite(ageDays) || ageDays < options.stale)
1428
+ return false;
1429
+ }
1430
+ return true;
1431
+ });
1432
+ if (filteredRecords.length === 0) {
1433
+ console.log(` ${pc.dim("No features match the current filters.")}`);
1434
+ console.log();
1435
+ return;
1436
+ }
963
1437
  const pad = (s, w) => s.padEnd(w);
964
1438
  console.log(` ${pad("Feature", COL_FEATURE)} ${pad("Status", COL_STATUS)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)} Depends On`);
965
1439
  console.log(` ${"\u2500".repeat(COL_FEATURE)} ${"\u2500".repeat(COL_STATUS)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)} ${"\u2500".repeat(30)}`);
966
1440
  let blockedCount = 0;
967
- for (const r of records) {
1441
+ for (const r of filteredRecords) {
968
1442
  const marker = r.isActive ? pc.cyan("*") : " ";
969
1443
  const namePadded = r.name.padEnd(COL_FEATURE - 1);
970
1444
  const statusPadded = String(r.status).padEnd(COL_STATUS);
@@ -978,24 +1452,25 @@ async function showStatus() {
978
1452
  blockedCount++;
979
1453
  }
980
1454
  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`);
1455
+ const total = filteredRecords.length;
1456
+ const doneCount = filteredRecords.filter((r) => r.status === "done").length;
1457
+ const activeCount = filteredRecords.filter((r) => r.isActive).length;
1458
+ console.log(` ${pc.bold("Summary:")} ${total} feature(s), ${activeCount} active, ${doneCount} done, ${pc.yellow(String(blockedCount))} blocked`);
984
1459
  console.log();
985
1460
  await showArchiveTrend();
986
1461
  if (blockedCount > 0) {
987
- const blocked = records.filter((r) => r.isBlocked);
1462
+ const blocked = filteredRecords.filter((r) => r.isBlocked);
988
1463
  console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
989
1464
  console.log();
990
1465
  }
991
1466
  }
992
1467
  async function showArchiveTrend() {
993
- const archiveDir = join9(RSP_DIR, "archive");
994
- const indexPath = join9(archiveDir, "INDEX.md");
995
- if (!existsSync9(indexPath))
1468
+ const archiveDir = join13(RSP_DIR, "archives");
1469
+ const indexPath = join13(archiveDir, "INDEX.md");
1470
+ if (!existsSync13(indexPath))
996
1471
  return;
997
1472
  try {
998
- const content = await readFile8(indexPath, "utf-8");
1473
+ const content = await readFile12(indexPath, "utf-8");
999
1474
  const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
1000
1475
  const counts = {};
1001
1476
  let match;
@@ -1023,8 +1498,29 @@ var initCommand = defineCommand({
1023
1498
  name: "init",
1024
1499
  description: "Scaffold .rsp/ + AGENTS.md in current project"
1025
1500
  },
1026
- async run() {
1027
- await initProject();
1501
+ args: {
1502
+ "agents-mode": {
1503
+ type: "string",
1504
+ description: "How to update AGENTS.md: managed, skip, or print",
1505
+ default: "managed"
1506
+ },
1507
+ "with-project-rules": {
1508
+ type: "boolean",
1509
+ description: "Create .rsp/rules/project-rules.md",
1510
+ default: false
1511
+ },
1512
+ "with-project-setup": {
1513
+ type: "boolean",
1514
+ description: "Create .rsp/features/project-setup.md and mark it active",
1515
+ default: false
1516
+ }
1517
+ },
1518
+ async run({ args }) {
1519
+ await initProject({
1520
+ agentsMode: args["agents-mode"] === "skip" || args["agents-mode"] === "print" ? args["agents-mode"] : "managed",
1521
+ withProjectRules: Boolean(args["with-project-rules"]),
1522
+ withProjectSetup: Boolean(args["with-project-setup"])
1523
+ });
1028
1524
  }
1029
1525
  });
1030
1526
  var newFeatureCommand = defineCommand({
@@ -1044,10 +1540,52 @@ var newFeatureCommand = defineCommand({
1044
1540
  await newFeature(args.name, summary);
1045
1541
  }
1046
1542
  });
1543
+ var addRulesCommand = defineCommand({
1544
+ meta: {
1545
+ name: "rules",
1546
+ description: "Create .rsp/rules/<name>.md"
1547
+ },
1548
+ args: {
1549
+ name: {
1550
+ type: "positional",
1551
+ description: "Rules file name",
1552
+ required: true
1553
+ }
1554
+ },
1555
+ async run({ args }) {
1556
+ await addRules(args.name);
1557
+ }
1558
+ });
1559
+ var addSpecCommand = defineCommand({
1560
+ meta: {
1561
+ name: "spec",
1562
+ description: "Create .rsp/specs/<name>.md and rebuild specs index"
1563
+ },
1564
+ args: {
1565
+ name: {
1566
+ type: "positional",
1567
+ description: "Spec file name",
1568
+ required: true
1569
+ }
1570
+ },
1571
+ async run({ args }) {
1572
+ await addSpec(args.name);
1573
+ }
1574
+ });
1575
+ var addCommand = defineCommand({
1576
+ meta: {
1577
+ name: "add",
1578
+ description: "Add optional rules or spec files"
1579
+ },
1580
+ subCommands: {
1581
+ rules: addRulesCommand,
1582
+ spec: addSpecCommand
1583
+ }
1584
+ });
1047
1585
  var closeFeatureCommand = defineCommand({
1048
1586
  meta: {
1049
1587
  name: "close",
1050
- description: "Archive to .rsp/archive/"
1588
+ description: "Archive to .rsp/archives/"
1051
1589
  },
1052
1590
  args: {
1053
1591
  name: {
@@ -1065,8 +1603,33 @@ var statusCommand = defineCommand({
1065
1603
  name: "status",
1066
1604
  description: "Show project status summary"
1067
1605
  },
1068
- async run() {
1069
- await showStatus();
1606
+ args: {
1607
+ active: {
1608
+ type: "boolean",
1609
+ description: "Show only active features",
1610
+ default: false
1611
+ },
1612
+ blocked: {
1613
+ type: "boolean",
1614
+ description: "Show only blocked features",
1615
+ default: false
1616
+ },
1617
+ stale: {
1618
+ type: "string",
1619
+ description: "Show only features with age >= days"
1620
+ }
1621
+ },
1622
+ async run({ args }) {
1623
+ const stale = args.stale === void 0 ? void 0 : Number(args.stale);
1624
+ if (args.stale !== void 0 && (Number.isFinite(stale) === false || Number.isInteger(stale) === false || stale < 0)) {
1625
+ console.error(` Error: --stale must be a non-negative integer number of days`);
1626
+ process.exit(1);
1627
+ }
1628
+ await showStatus({
1629
+ active: Boolean(args.active),
1630
+ blocked: Boolean(args.blocked),
1631
+ stale
1632
+ });
1070
1633
  }
1071
1634
  });
1072
1635
  var checkCommand = defineCommand({
@@ -1090,21 +1653,53 @@ var depsCommand = defineCommand({
1090
1653
  type: "boolean",
1091
1654
  description: "Output as Mermaid.js graph",
1092
1655
  default: false
1656
+ },
1657
+ focus: {
1658
+ type: "string",
1659
+ description: "Show only a feature and its direct dependency neighborhood"
1660
+ },
1661
+ reverse: {
1662
+ type: "string",
1663
+ description: "Show only features that depend on the given feature"
1093
1664
  }
1094
1665
  },
1095
1666
  async run({ args }) {
1096
- await showDependencies(Boolean(args.mermaid));
1667
+ await showDependencies({
1668
+ mermaid: Boolean(args.mermaid),
1669
+ focus: typeof args.focus === "string" ? args.focus : void 0,
1670
+ reverse: typeof args.reverse === "string" ? args.reverse : void 0
1671
+ });
1097
1672
  }
1098
1673
  });
1099
1674
  var archiveIndexCommand = defineCommand({
1100
1675
  meta: {
1101
1676
  name: "archive-index",
1102
- description: "Regenerate archive INDEX.md"
1677
+ description: "Regenerate archives INDEX.md"
1103
1678
  },
1104
1679
  async run() {
1105
1680
  await buildArchiveIndex();
1106
1681
  }
1107
1682
  });
1683
+ var specsIndexCommand = defineCommand({
1684
+ meta: {
1685
+ name: "specs-index",
1686
+ description: "Regenerate specs INDEX.md"
1687
+ },
1688
+ async run() {
1689
+ await buildSpecsIndex();
1690
+ }
1691
+ });
1692
+ var doctorCommand = defineCommand({
1693
+ meta: {
1694
+ name: "doctor",
1695
+ description: "Check RSP setup health and common integration issues"
1696
+ },
1697
+ async run() {
1698
+ const issues = await runDoctor();
1699
+ if (issues > 0)
1700
+ process.exit(1);
1701
+ }
1702
+ });
1108
1703
  async function runCli(rawArgs = process.argv.slice(2)) {
1109
1704
  const version = await getVersion();
1110
1705
  const main = defineCommand({
@@ -1115,12 +1710,15 @@ async function runCli(rawArgs = process.argv.slice(2)) {
1115
1710
  },
1116
1711
  subCommands: {
1117
1712
  "init": initCommand,
1713
+ "add": addCommand,
1118
1714
  "new": newFeatureCommand,
1119
1715
  "close": closeFeatureCommand,
1120
1716
  "status": statusCommand,
1121
1717
  "check": checkCommand,
1122
1718
  "deps": depsCommand,
1123
- "archive-index": archiveIndexCommand
1719
+ "archive-index": archiveIndexCommand,
1720
+ "specs-index": specsIndexCommand,
1721
+ "doctor": doctorCommand
1124
1722
  }
1125
1723
  });
1126
1724
  await runMain(main, { rawArgs });