@oevery/rsp 1.2.0 → 2.0.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
@@ -7,90 +7,87 @@ import { defineCommand, runMain } from "citty";
7
7
 
8
8
  // src/commands/add-rules.ts
9
9
  import { existsSync as existsSync3 } from "fs";
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";
10
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
11
+ import { dirname as dirname3, join as join3 } from "path";
12
12
 
13
13
  // src/core/config.ts
14
14
  import { existsSync as existsSync2 } from "fs";
15
- import { readFile } from "fs/promises";
16
- import { dirname, join as join2 } from "path";
15
+ import { readFile as readFile2 } from "fs/promises";
16
+ import { dirname as dirname2, join as join2 } from "path";
17
17
  import { fileURLToPath } from "url";
18
18
  import pc from "picocolors";
19
19
 
20
20
  // src/core/helpers.ts
21
21
  import { existsSync } from "fs";
22
- import { readdir, stat } from "fs/promises";
23
- import { join, relative } from "path";
22
+ import { readdir, readFile, rmdir, stat } from "fs/promises";
23
+ import { basename, dirname, join, relative, sep } from "path";
24
+ import { parse } from "yaml";
25
+
26
+ // src/core/output.ts
27
+ function toErrorMessage(error) {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+ function emitJson(data) {
31
+ console.log(JSON.stringify(data, null, 2));
32
+ }
33
+ function emitStatusJsonError(error, options) {
34
+ const payload = {
35
+ command: "status",
36
+ ok: false,
37
+ filters: {
38
+ focused: options.focused,
39
+ blocked: options.blocked,
40
+ stale: null
41
+ },
42
+ focused: [],
43
+ records: [],
44
+ summary: {
45
+ total: 0,
46
+ focused: 0,
47
+ blocked: 0
48
+ },
49
+ archiveTrend: [],
50
+ runtime: [],
51
+ error
52
+ };
53
+ emitJson(payload);
54
+ }
55
+ function recordRuntimeDiagnostic(runtime, diagnostic, verbose = false) {
56
+ runtime.push(diagnostic);
57
+ if (verbose) {
58
+ console.error(` ${pc.dim(`[verbose] ${diagnostic.operation} ${diagnostic.path}: ${diagnostic.message}`)}`);
59
+ }
60
+ }
61
+
62
+ // src/core/helpers.ts
24
63
  var RSP_AGENTS_BEGIN = "<!-- rsp:begin -->";
25
64
  var RSP_AGENTS_END = "<!-- rsp:end -->";
26
- function parseYamlLines(lines) {
27
- const result = {};
28
- let currentKey = null;
29
- let currentList = null;
30
- function flushList() {
31
- if (currentList !== null && currentKey !== null) {
32
- result[currentKey] = currentList;
33
- currentList = null;
34
- currentKey = null;
35
- }
36
- }
37
- for (const line of lines) {
38
- const trimmed = line.trim();
39
- if (trimmed === "" || trimmed.startsWith("#")) {
40
- continue;
41
- }
42
- const li = line.match(/^[ \t]+-[ \t]+([^\n]+)$/);
43
- if (li && currentList !== null) {
44
- currentList.push(li[1].trim());
45
- continue;
46
- }
47
- flushList();
48
- const kv = line.match(/^([\w-]+):[ \t]*([^\n]*)$/);
49
- if (kv) {
50
- currentKey = kv[1];
51
- const val = kv[2].trim();
52
- if (!val || val === "[]") {
53
- currentList = [];
54
- } else {
55
- result[currentKey] = val;
56
- currentKey = null;
57
- }
58
- }
59
- }
60
- flushList();
61
- return result;
65
+ function parseYamlText(text) {
66
+ const parsed = parse(text);
67
+ if (parsed === null || parsed === void 0)
68
+ return {};
69
+ if (typeof parsed !== "object" || Array.isArray(parsed))
70
+ throw new Error("YAML document must be a mapping/object");
71
+ return parsed;
62
72
  }
63
73
  function parseFrontmatter(content) {
64
74
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n/);
65
75
  if (!m)
66
76
  return null;
67
- return parseYamlLines(m[1].split("\n"));
77
+ return parseYamlText(m[1]);
68
78
  }
69
79
  function countCheckboxes(content) {
70
80
  const todo = (content.match(/\[ \]/g) || []).length;
71
81
  const progress = (content.match(/\[\/\]/g) || []).length;
72
82
  const done = (content.match(/\[x\]/g) || []).length;
73
83
  const dropped = (content.match(/\[-\]/g) || []).length;
74
- return { todo, progress, done, total: todo + progress + done + dropped };
84
+ return { todo, progress, done, dropped, total: todo + progress + done + dropped };
75
85
  }
76
- async function walkMarkdownFiles(dir) {
77
- const files = [];
78
- try {
79
- const items = await readdir(dir, { withFileTypes: true });
80
- for (const item of items) {
81
- if (item.name.startsWith("."))
82
- continue;
83
- const filePath = join(dir, item.name);
84
- if (item.isDirectory())
85
- files.push(...await walkMarkdownFiles(filePath));
86
- else if (item.name.endsWith(".md"))
87
- files.push(filePath);
88
- }
89
- } catch {
90
- }
91
- return files;
86
+ async function walkMarkdownFiles(dir, options = {}) {
87
+ const all = await walkFiles(dir, options);
88
+ return all.filter((f) => f.endsWith(".md"));
92
89
  }
93
- async function walkFiles(dir) {
90
+ async function walkFiles(dir, options = {}) {
94
91
  const files = [];
95
92
  try {
96
93
  const items = await readdir(dir, { withFileTypes: true });
@@ -99,96 +96,210 @@ async function walkFiles(dir) {
99
96
  continue;
100
97
  const filePath = join(dir, item.name);
101
98
  if (item.isDirectory())
102
- files.push(...await walkFiles(filePath));
99
+ files.push(...await walkFiles(filePath, options));
103
100
  else
104
101
  files.push(filePath);
105
102
  }
106
- } catch {
103
+ } catch (error) {
104
+ if (error.code !== "ENOENT") {
105
+ options.onError?.({
106
+ code: "walk_failed",
107
+ operation: "readdir",
108
+ path: dir,
109
+ message: toErrorMessage(error)
110
+ });
111
+ }
107
112
  }
108
113
  return files;
109
114
  }
110
- function featureNameFromPath(featuresDir, filePath) {
111
- const rel = relative(featuresDir, filePath);
115
+ function changeNameFromPath(changesDir, filePath) {
116
+ const rel = normalizeLogicalPath(relative(changesDir, filePath));
112
117
  return rel.replace(/\.md$/, "");
113
118
  }
114
- function generateFeatureContent(name, summary = "") {
119
+ function normalizeLogicalPath(pathValue) {
120
+ return pathValue.split(sep).join("/").replace(/\\/g, "/");
121
+ }
122
+ function generateChangeContent(name, summary = "", kind) {
123
+ const proposalSummary = summary || (name === "project-setup" ? "Capture the project model, boundaries, and stable local constraints" : "<one-line summary>");
115
124
  if (name === "project-setup") {
116
125
  return `---
117
- status: draft
118
- priority: medium
119
- tags:
126
+ kind: ops
120
127
  ---
121
128
 
122
- # Feature: project-setup
129
+ # Change: project-setup
130
+
131
+ ## Proposal
132
+ - Summary: ${proposalSummary}
133
+ - Why:
134
+ - Establish a durable project model before normal implementation work starts
135
+ - Scope:
136
+ - Review the repository structure, entrypoints, and primary outputs
137
+ - Fill .rsp/specs/design.md with durable architecture facts
138
+ - Add .rsp/rules/project-rules.md when stable local rules or validation steps exist
139
+ - Non-goals:
140
+ - Do not duplicate durable project facts across this change, specs, and rules
123
141
 
124
142
  ## 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
143
+ ### ADDED
144
+ - Requirement: project bootstrap capture
145
+ - The repository's purpose, scope, and structure are reflected in .rsp/specs/design.md
146
+
147
+ ### MODIFIED
148
+ - Requirement: stable local operating constraints
128
149
  - Stable validation or workflow constraints are reflected in .rsp/rules/project-rules.md when needed
150
+
151
+ ### Acceptance
152
+ #### Scenario: project model captured
153
+ - GIVEN an initialized RSP project
154
+ - WHEN project setup is completed
155
+ - THEN .rsp/specs/design.md reflects durable project facts
156
+ - AND .rsp/rules/project-rules.md exists only when stable local rules are present
157
+
158
+ ## Design
159
+ - Approach:
160
+ - Keep bootstrap knowledge in this single change while moving durable facts into specs and rules
161
+ - Affected areas:
162
+ - .rsp/specs/design.md
163
+ - .rsp/rules/project-rules.md
129
164
  - Constraints:
130
- - Avoid duplicating durable project facts across feature notes, specs, and rules
165
+ - Keep the setup lightweight and avoid duplicating durable project facts
131
166
 
132
- ## Plan
167
+ ## Tasks
133
168
  - [ ] Review the repository structure, entrypoints, and primary outputs
134
169
  - [ ] Fill .rsp/specs/design.md with durable architecture facts
135
170
  - [ ] Add .rsp/rules/project-rules.md if stable local rules or validation steps exist
136
171
 
137
- ## Tests
138
- - [ ] Run rsp doctor
139
- - [ ] Run project-native validation if available
140
-
141
- ## Notes (optional)
142
- - <facts discovered during project setup>
172
+ ## Verify
173
+ - Automated:
174
+ - [ ] Run rsp doctor
175
+ - Manual:
176
+ - [ ] Review .rsp/specs/design.md and confirm it matches the repository
177
+ - Durable updates:
178
+ - [ ] Decide whether this change produced durable knowledge that belongs in \`.rsp/specs/\` or \`.rsp/rules/\`
179
+ - [ ] If yes, write only stable facts to the smallest correct target file before archive; do not promote task history, debugging notes, or one-off implementation context
143
180
 
144
181
  ## Blockers
145
- -
182
+ - none
146
183
  `;
147
184
  }
185
+ const frontmatterKind = kind ?? "<choose: feature | fix | refactor | docs | ops | research>";
186
+ const template = getChangeTemplateByKind(kind);
148
187
  return `---
149
- status: draft
150
- priority: medium
151
- tags:
188
+ kind: "${frontmatterKind}"
152
189
  ---
153
190
 
154
- # Feature: ${name}
191
+ # Change: ${name}
192
+
193
+ ## Proposal
194
+ - Summary: ${proposalSummary}
195
+ - Why:
196
+ - ${template.why}
197
+ - Scope:
198
+ - ${template.scope}
199
+ - Non-goals:
200
+ - ${template.nonGoals}
155
201
 
156
202
  ## Spec
157
- - Summary: ${summary || "<one-line summary>"}
158
- - Requirements:
159
- - <verifiable requirement>
160
- - Constraints:
161
- -
203
+ ${template.specSection}
204
+
205
+ ### Acceptance
206
+ ${template.acceptanceSection}
162
207
 
163
- ## Plan
164
- - [ ] Phase 1:
165
- - [ ] <task>
208
+ ## Design
209
+ - Approach:
210
+ - ${template.approach}
211
+ - Affected areas:
212
+ - ${template.affectedAreas}
213
+ - Constraints:
214
+ - ${template.constraints}
166
215
 
167
- ## Tests
168
- - [ ] <test file or scenario>
216
+ ## Tasks
217
+ - [ ] ${template.task}
169
218
 
170
- ## Notes (optional)
171
- - <design decisions discovered during implementation>
219
+ ## Verify
220
+ - Automated:
221
+ - [ ] ${template.automatedVerify}
222
+ - Manual:
223
+ - [ ] ${template.manualVerify}
224
+ - Durable updates:
225
+ - [ ] Decide whether this change produced durable knowledge that belongs in \`.rsp/specs/\` or \`.rsp/rules/\`
226
+ - [ ] If yes, write only stable facts to the smallest correct target file before archive; do not promote task history, debugging notes, or one-off implementation context
172
227
 
173
228
  ## Blockers
174
- -
229
+ - none
175
230
  `;
176
231
  }
232
+ function getChangeTemplateByKind(kind) {
233
+ switch (kind) {
234
+ case "docs":
235
+ return {
236
+ why: "<why the documentation change matters>",
237
+ scope: "<which docs, readers, or workflows will be updated>",
238
+ nonGoals: "<what documentation behavior or audience is out of scope>",
239
+ specSection: "### MODIFIED\n- Requirement: documentation accuracy\n - <what durable reader-facing behavior or explanation changes>",
240
+ acceptanceSection: "#### Scenario: reader follows the updated guidance\n- GIVEN the updated documentation\n- WHEN a reader follows the documented workflow\n- THEN the steps are accurate and sufficient",
241
+ approach: "<how the documentation will be updated and organized>",
242
+ affectedAreas: "<doc path or documentation area>",
243
+ constraints: "<durable wording, consistency, or scope constraint>",
244
+ task: "<update the target documentation and supporting references>",
245
+ automatedVerify: "<run markdown, docs, or link checks if applicable>",
246
+ manualVerify: "<review the updated doc as a reader and confirm it is accurate>"
247
+ };
248
+ case "research":
249
+ return {
250
+ why: "<what question or uncertainty this research resolves>",
251
+ scope: "<what system area, option set, or hypothesis is being examined>",
252
+ nonGoals: "<what implementation work is explicitly out of scope>",
253
+ specSection: "### ADDED\n- Requirement: research outcome recording\n - <what finding, option, or decision must be captured clearly>",
254
+ acceptanceSection: "#### Scenario: research question is resolved\n- GIVEN the current uncertainty or open question\n- WHEN the investigation is completed\n- THEN the result is recorded clearly enough to guide follow-up work",
255
+ approach: "<how the investigation will be performed and what evidence will be gathered>",
256
+ affectedAreas: "<code path, subsystem, or source being investigated>",
257
+ constraints: "<time, evidence, or scope constraint for the investigation>",
258
+ task: "<gather evidence and record the resulting recommendation or finding>",
259
+ automatedVerify: "<run any command needed to confirm the observed behavior>",
260
+ manualVerify: "<review the findings and confirm they answer the original question>"
261
+ };
262
+ case "ops":
263
+ return {
264
+ why: "<why this operational or environment change matters>",
265
+ scope: "<what environment, workflow, or operational path changes>",
266
+ nonGoals: "<what product or feature behavior will not change>",
267
+ specSection: "### MODIFIED\n- Requirement: operational behavior\n - <what reliable operational outcome should change or stay true>",
268
+ acceptanceSection: "#### Scenario: operational path succeeds\n- GIVEN the target environment or workflow\n- WHEN the operational change is applied\n- THEN the expected operational outcome is reliable",
269
+ approach: "<how the operational change will be applied safely>",
270
+ affectedAreas: "<script, config, workflow, or environment path>",
271
+ constraints: "<rollback, safety, or environment constraint>",
272
+ task: "<apply and verify the operational change>",
273
+ automatedVerify: "<run the relevant operational validation command>",
274
+ manualVerify: "<exercise the target workflow and confirm the operational result>"
275
+ };
276
+ default:
277
+ return {
278
+ why: "<why this change matters>",
279
+ scope: "<what this change will do>",
280
+ nonGoals: "<what this change will not do>",
281
+ specSection: "### ADDED\n- Requirement: <new or updated behavior>\n - <verifiable requirement>",
282
+ acceptanceSection: "#### Scenario: <name>\n- GIVEN <context>\n- WHEN <action>\n- THEN <expected outcome>",
283
+ approach: "<how the change will be implemented>",
284
+ affectedAreas: "<path or subsystem>",
285
+ constraints: "<binding technical or product constraint>",
286
+ task: "<implementation task>",
287
+ automatedVerify: "<test command or automated check>",
288
+ manualVerify: "<manual scenario to validate>"
289
+ };
290
+ }
291
+ }
177
292
  function renderRspAgentsBlock() {
178
293
  return `${RSP_AGENTS_BEGIN}
179
294
  ## RSP Entry
180
295
 
181
296
  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
297
+ 1. .rsp/rules/rsp-rules.md
298
+ 2. .rsp/focus.d/
299
+ 3. matching .rsp/changes/*.md for the focused entries
300
+ 4. .rsp/specs/design.md
301
+ 5. .rsp/specs/INDEX.md
302
+ 6. only the relevant additional .rsp/rules/*.md and .rsp/specs/*.md files
192
303
  ${RSP_AGENTS_END}`;
193
304
  }
194
305
  function upsertRspAgentsBlock(content) {
@@ -208,6 +319,49 @@ ${trimmed}` : `${block}
208
319
  function hasRspAgentsBlock(content) {
209
320
  return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
210
321
  }
322
+ var CHANGE_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
323
+ function isValidChangeName(name) {
324
+ return CHANGE_NAME_RE.test(name);
325
+ }
326
+ function guardRspInitialized() {
327
+ const rspRulesPath = join(RSP_DIR, "rules", "rsp-rules.md");
328
+ const designPath = join(RSP_DIR, "specs", "design.md");
329
+ if (!existsSync(rspRulesPath) || !existsSync(designPath)) {
330
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
331
+ console.error(` ${pc.dim("Run: rsp init")}`);
332
+ process.exit(1);
333
+ }
334
+ }
335
+ function getOpenCheckboxes(sectionText) {
336
+ return sectionText.split("\n").map((line) => line.trim()).filter((line) => /^- \[[ /]\]/.test(line));
337
+ }
338
+ async function cleanupEmptyParentDirs(path, stopDir) {
339
+ let parent = dirname(path);
340
+ while (parent !== stopDir) {
341
+ try {
342
+ const remaining = await readdir(parent);
343
+ if (remaining.length === 0)
344
+ await rmdir(parent);
345
+ else
346
+ break;
347
+ } catch {
348
+ break;
349
+ }
350
+ parent = dirname(parent);
351
+ }
352
+ }
353
+ async function detectProjectName() {
354
+ try {
355
+ if (existsSync("package.json")) {
356
+ const raw = await readFile("package.json", "utf-8");
357
+ const pkg = JSON.parse(raw);
358
+ if (pkg.name && pkg.name !== "")
359
+ return pkg.name;
360
+ }
361
+ } catch {
362
+ }
363
+ return basename(process.cwd());
364
+ }
211
365
  function generateDesignContent(projectName) {
212
366
  return `# Project Design: ${projectName}
213
367
 
@@ -215,7 +369,11 @@ function generateDesignContent(projectName) {
215
369
  - <what this project is responsible for>
216
370
  - <who or what it serves>
217
371
 
218
- ## Scope
372
+ ## Stable Facts
373
+ - <durable fact that future agents or developers must know>
374
+ - <key architectural invariant>
375
+
376
+ ## Boundaries
219
377
  - In scope:
220
378
  - <major capability or boundary>
221
379
  - Out of scope:
@@ -238,6 +396,7 @@ description: Project-specific rules for ${projectName}
238
396
 
239
397
  ## Scope
240
398
  - <stable local rules, workflow constraints, or validation expectations>
399
+ - Do not put temporary debugging steps here.
241
400
 
242
401
  ## Validation
243
402
  - <preferred stable validation commands, not temporary troubleshooting steps>
@@ -250,7 +409,7 @@ function generateRulesContent(name) {
250
409
  const title = toTitleCase(name);
251
410
  return `---
252
411
  name: ${name}
253
- description: Project-specific rules for ${title}
412
+ description: Durable rules for ${title}
254
413
  ---
255
414
 
256
415
  # ${title}
@@ -272,27 +431,47 @@ function generateSpecContent(name) {
272
431
  ## Purpose
273
432
  - <why this project-level spec exists>
274
433
 
275
- ## Details
276
- - <important project-level detail>
434
+ ## Stable Facts
435
+ - <durable fact that future work must know>
436
+
437
+ ## Boundaries
438
+ - In scope:
439
+ - <what this spec covers>
440
+ - Out of scope:
441
+ - <what this spec does not cover>
442
+
443
+ ## Constraints
444
+ - <stable constraint, if any>
277
445
  `;
278
446
  }
279
447
  function toTitleCase(value) {
280
448
  return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
281
449
  }
282
- function detectDeltaSections(content) {
283
- const match = content.match(/^## Spec\n([\s\S]*?)(?=\n## |\n---|\n\.\.\.)/m);
284
- if (!match) {
285
- const m2 = content.match(/^## Spec\n([\s\S]*)$/m);
286
- if (!m2)
287
- return { added: false, modified: false, removed: false };
288
- const body2 = m2[1];
289
- return {
290
- added: /^###\s*ADDED/im.test(body2),
291
- modified: /^###\s*MODIFIED/im.test(body2),
292
- removed: /^###\s*REMOVED/im.test(body2)
293
- };
450
+ function extractSection(content, heading) {
451
+ const lines = content.split("\n");
452
+ const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
453
+ if (start === -1)
454
+ return "";
455
+ const body = [];
456
+ for (let index = start + 1; index < lines.length; index++) {
457
+ const line = lines[index];
458
+ if (line.startsWith("## "))
459
+ break;
460
+ body.push(line);
294
461
  }
295
- const body = match[1];
462
+ return body.join("\n").trim();
463
+ }
464
+ function hasMeaningfulBlockers(content) {
465
+ const blockers = extractSection(content, "Blockers");
466
+ if (!blockers)
467
+ return false;
468
+ const lines = blockers.split("\n").map((line) => line.trim()).filter(Boolean);
469
+ if (lines.length === 0)
470
+ return false;
471
+ return lines.some((line) => !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
472
+ }
473
+ function detectDeltaSections(content) {
474
+ const body = extractSection(content, "Spec");
296
475
  return {
297
476
  added: /^###\s*ADDED/im.test(body),
298
477
  modified: /^###\s*MODIFIED/im.test(body),
@@ -301,60 +480,32 @@ function detectDeltaSections(content) {
301
480
  }
302
481
  function parseScenarios(content) {
303
482
  const scenarios = [];
304
- const regex = /^###\s*Scenario:\s*(.+)$/gim;
483
+ const regex = /^#{3,4}\s*Scenario:\s*(.+)$/gim;
305
484
  let match;
306
485
  while ((match = regex.exec(content)) !== null) {
307
486
  const heading = match[1].trim();
308
487
  const startPos = match.index + match[0].length;
309
488
  const restContent = content.slice(startPos);
310
- const nextSection = restContent.match(/^###/m);
489
+ const nextSection = restContent.match(/^#{3,4}\s/m);
311
490
  const block = nextSection ? restContent.slice(0, nextSection.index) : restContent;
312
- const steps = block.split("\n").map((s) => s.trim()).filter((s) => s.startsWith("-") && /GIVEN|WHEN|THEN|AND|BUT/i.test(s)).map((s) => s.replace(/^-\s*/, ""));
491
+ const steps = block.split("\n").map((s) => s.trim()).filter((s) => s.startsWith("-") && /GIVEN|WHEN|THEN|AND|BUT/i.test(s)).map((s) => s.replace(/^[-*]\s*/, ""));
313
492
  if (steps.length > 0)
314
493
  scenarios.push({ heading, steps });
315
494
  }
316
495
  return scenarios;
317
496
  }
318
- function detectCycles(graph) {
319
- const cycles = [];
320
- const visited = /* @__PURE__ */ new Set();
321
- const inStack = /* @__PURE__ */ new Set();
322
- function dfs(node, path) {
323
- if (inStack.has(node)) {
324
- const cycleStart = path.indexOf(node);
325
- if (cycleStart !== -1)
326
- cycles.push(path.slice(cycleStart).concat(node));
327
- return;
328
- }
329
- if (visited.has(node))
330
- return;
331
- visited.add(node);
332
- inStack.add(node);
333
- path.push(node);
334
- const neighbors = graph.get(node) || [];
335
- for (const next of neighbors) {
336
- if (graph.has(next))
337
- dfs(next, path);
338
- }
339
- path.pop();
340
- inStack.delete(node);
341
- }
342
- for (const node of graph.keys())
343
- dfs(node, []);
344
- return cycles;
345
- }
346
497
 
347
498
  // src/core/config.ts
348
- var __dirname = dirname(fileURLToPath(import.meta.url));
499
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
349
500
  var PKG_ROOT = join2(__dirname, "..");
350
501
  var RSP_DIR = ".rsp";
351
- var ACTIVE_DIR = join2(RSP_DIR, "active.d");
502
+ var CHANGES_DIR = join2(RSP_DIR, "changes");
503
+ var FOCUS_DIR = join2(RSP_DIR, "focus.d");
352
504
  var ARCHIVES_DIR = join2(RSP_DIR, "archives");
353
505
  var LOCK_PATH = join2(RSP_DIR, ".lock");
354
506
  var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
355
- var VALID_STATUSES = ["draft", "ready", "in-progress", "blocked", "done"];
356
- var VALID_PRIORITIES = ["low", "medium", "high", "critical"];
357
- var DEFAULT_REQUIRED_SECTIONS = ["Spec", "Plan"];
507
+ var VALID_KINDS = ["feature", "fix", "refactor", "docs", "ops", "research"];
508
+ var DEFAULT_REQUIRED_SECTIONS = ["Proposal", "Spec", "Design", "Tasks", "Verify", "Blockers"];
358
509
  var _configCache = null;
359
510
  async function loadRspConfig() {
360
511
  if (_configCache)
@@ -363,31 +514,26 @@ async function loadRspConfig() {
363
514
  _configCache = {};
364
515
  return _configCache;
365
516
  }
366
- const raw = await readFile(CONFIG_PATH, "utf-8");
367
- const parsed = parseYamlLines(raw.split("\n"));
517
+ const raw = await readFile2(CONFIG_PATH, "utf-8");
518
+ const parsed = parseYamlText(raw);
368
519
  _configCache = {
369
- statuses: Array.isArray(parsed.statuses) ? parsed.statuses.map(String) : void 0,
370
- priorities: Array.isArray(parsed.priorities) ? parsed.priorities.map(String) : void 0,
371
- required_sections: Array.isArray(parsed.required_sections) ? parsed.required_sections.map(String) : void 0
520
+ kinds: Array.isArray(parsed.kinds) ? parsed.kinds.map(String) : void 0
372
521
  };
373
522
  return _configCache;
374
523
  }
375
- function resolveStatuses(config) {
376
- return config.statuses && config.statuses.length > 0 ? config.statuses : [...VALID_STATUSES];
524
+ function resolveKinds(config) {
525
+ return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
377
526
  }
378
- function resolvePriorities(config) {
379
- return config.priorities && config.priorities.length > 0 ? config.priorities : [...VALID_PRIORITIES];
380
- }
381
- function resolveRequiredSections(config) {
382
- return config.required_sections && config.required_sections.length > 0 ? config.required_sections : [...DEFAULT_REQUIRED_SECTIONS];
527
+ function resolveRequiredSections(_config) {
528
+ return [...DEFAULT_REQUIRED_SECTIONS];
383
529
  }
384
530
  async function getVersion() {
385
- const { version } = JSON.parse(await readFile(join2(PKG_ROOT, "package.json"), "utf-8"));
531
+ const { version } = JSON.parse(await readFile2(join2(PKG_ROOT, "package.json"), "utf-8"));
386
532
  return version;
387
533
  }
388
534
 
389
535
  // src/core/lock.ts
390
- import { mkdir, readFile as readFile2, unlink, writeFile } from "fs/promises";
536
+ import { mkdir, readFile as readFile3, unlink, writeFile } from "fs/promises";
391
537
  async function withRspLock(operation, fn) {
392
538
  await mkdir(RSP_DIR, { recursive: true });
393
539
  try {
@@ -411,7 +557,7 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
411
557
  }
412
558
  async function handleStaleLock(operation) {
413
559
  try {
414
- const content = await readFile2(LOCK_PATH, "utf-8");
560
+ const content = await readFile3(LOCK_PATH, "utf-8");
415
561
  const lines = content.trim().split("\n");
416
562
  const pid = Number(lines[0]);
417
563
  if (pid && Number.isFinite(pid)) {
@@ -423,10 +569,15 @@ async function handleStaleLock(operation) {
423
569
  } catch (e) {
424
570
  if (e instanceof Error && e.message.startsWith("RSP locked"))
425
571
  throw e;
426
- await unlink(LOCK_PATH);
427
- await writeFile(LOCK_PATH, `${process.pid}
572
+ const code = e.code;
573
+ if (code === "ESRCH") {
574
+ await unlink(LOCK_PATH);
575
+ await writeFile(LOCK_PATH, `${process.pid}
428
576
  ${operation}
429
577
  ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
578
+ return;
579
+ }
580
+ throw e;
430
581
  }
431
582
  } else {
432
583
  await unlink(LOCK_PATH);
@@ -442,39 +593,19 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
442
593
  }
443
594
 
444
595
  // 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
596
  async function addRules(name, projectName = "project") {
459
- if (!name || !NAME_RE.test(name)) {
597
+ if (!name || !isValidChangeName(name)) {
460
598
  console.error(` ${pc.red("Error:")} rule name must be kebab-case with optional subdirectory`);
461
599
  process.exit(1);
462
600
  }
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")}`);
601
+ guardRspInitialized();
602
+ const path = join3(RSP_DIR, "rules", `${name}.md`);
603
+ if (existsSync3(path)) {
604
+ console.error(` ${pc.red("Error:")} rule file already exists: .rsp/rules/${name}.md`);
468
605
  process.exit(1);
469
606
  }
470
607
  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 });
608
+ await mkdir2(dirname3(path), { recursive: true });
478
609
  const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
479
610
  const content = name === "project-rules" ? generateProjectRulesContent(resolvedProjectName) : generateRulesContent(name);
480
611
  await writeFile2(path, content);
@@ -485,8 +616,8 @@ async function addRules(name, projectName = "project") {
485
616
 
486
617
  // src/commands/add-spec.ts
487
618
  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";
619
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
620
+ import { dirname as dirname4, join as join5 } from "path";
490
621
 
491
622
  // src/commands/specs-index.ts
492
623
  import { existsSync as existsSync4 } from "fs";
@@ -513,16 +644,17 @@ async function buildSpecsIndex({ acquireLock = true } = {}) {
513
644
  lines.push("| File | Title | Summary |");
514
645
  lines.push("|------|-------|---------|");
515
646
  for (const fp of specFiles) {
516
- const rel = relative2(specsDir, fp);
647
+ const rel = normalizeLogicalPath(relative2(specsDir, fp));
517
648
  const content = await readFile4(fp, "utf-8");
518
649
  const fm = parseFrontmatter(content);
519
- const title = extractTitle(content) || rel.replace(/\.md$/, "");
650
+ const fmTitle = fm && typeof fm.title === "string" ? fm.title : "";
651
+ const title = fmTitle || extractTitle(content) || rel.replace(/\.md$/, "");
520
652
  const summary = extractSummary(content, fm?.summary);
521
653
  lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
522
654
  }
523
655
  }
524
656
  lines.push("");
525
- lines.push("_Auto-generated by `rsp specs-index`._");
657
+ lines.push("_Auto-generated by `rsp update`._");
526
658
  await writeFile3(join4(specsDir, "INDEX.md"), lines.join("\n"));
527
659
  console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
528
660
  `);
@@ -561,40 +693,20 @@ function escapeCell(value) {
561
693
  }
562
694
 
563
695
  // 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
696
  async function addSpec(name, projectName = "project") {
578
- if (!name || !NAME_RE2.test(name)) {
697
+ if (!name || !isValidChangeName(name)) {
579
698
  console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
580
699
  process.exit(1);
581
700
  }
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")}`);
701
+ guardRspInitialized();
702
+ const path = join5(RSP_DIR, "specs", `${name}.md`);
703
+ if (existsSync5(path)) {
704
+ console.error(` ${pc.red("Error:")} spec file already exists: .rsp/specs/${name}.md`);
587
705
  process.exit(1);
588
706
  }
589
707
  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;
708
+ await mkdir3(dirname4(path), { recursive: true });
709
+ const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
598
710
  const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
599
711
  await writeFile4(path, content);
600
712
  await buildSpecsIndex({ acquireLock: false });
@@ -603,148 +715,359 @@ async function addSpec(name, projectName = "project") {
603
715
  });
604
716
  }
605
717
 
718
+ // src/commands/archive.ts
719
+ import { existsSync as existsSync7 } from "fs";
720
+ import { mkdir as mkdir4, readFile as readFile6, rename, unlink as unlink2 } from "fs/promises";
721
+ import { basename as basename4, dirname as dirname6, join as join7 } from "path";
722
+
606
723
  // src/commands/archive-index.ts
607
724
  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";
725
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
726
+ import { basename as basename3, dirname as dirname5, join as join6, relative as relative3 } from "path";
610
727
  async function buildArchiveIndex({ acquireLock = true } = {}) {
611
728
  if (acquireLock)
612
729
  return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
613
- const archiveDir = join6(RSP_DIR, "archives");
614
- if (!existsSync6(archiveDir)) {
730
+ if (!existsSync6(ARCHIVES_DIR)) {
615
731
  console.log(` ${pc.dim("No archives directory.")}
616
732
  `);
617
733
  return;
618
734
  }
619
- const archiveFiles = (await walkMarkdownFiles(archiveDir)).filter((fp) => basename4(fp) !== "INDEX.md").sort();
735
+ const archiveFiles = (await walkMarkdownFiles(ARCHIVES_DIR)).filter((fp) => basename3(fp) !== "INDEX.md").sort();
620
736
  const lines = [];
621
737
  lines.push("# Archive Index");
622
738
  lines.push("");
623
739
  lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
624
740
  lines.push("");
625
741
  if (archiveFiles.length === 0) {
626
- lines.push("_No archived features yet._");
742
+ lines.push("_No archived changes yet._");
627
743
  } else {
628
- lines.push("| Date | Feature | Summary |");
629
- lines.push("|------|---------|---------|");
744
+ lines.push("| Date | Change | Summary |");
745
+ lines.push("|------|--------|---------|");
630
746
  }
631
747
  for (const fp of archiveFiles) {
632
- const base = basename4(fp);
633
- const archiveRel = relative3(archiveDir, fp);
634
- const archiveDirPart = dirname4(archiveRel);
748
+ const base = basename3(fp);
749
+ const archiveRel = normalizeLogicalPath(relative3(ARCHIVES_DIR, fp));
750
+ const archiveDirPart = dirname5(archiveRel);
635
751
  const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
636
752
  const date = dateMatch ? dateMatch[1] : "\u2014";
637
753
  const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
638
- const featureName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
754
+ const changeName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
639
755
  let summary = "";
640
756
  try {
641
- const content = await readFile6(fp, "utf-8");
642
- const fm = parseFrontmatter(content);
643
- if (fm?.summary)
644
- summary = String(fm.summary);
645
- if (!summary) {
646
- const specMatch = content.match(/^## Spec[\s\S]*?\n- Summary:[ \t]*([^\n]+)$/m);
647
- if (specMatch)
648
- summary = specMatch[1].trim();
649
- }
757
+ const content = await readFile5(fp, "utf-8");
758
+ const proposal = extractSection(content, "Proposal");
759
+ const summaryLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Summary:"));
760
+ if (summaryLine)
761
+ summary = summaryLine.slice("- Summary:".length).trim();
650
762
  } catch {
651
763
  }
652
764
  const summaryCell = summary ? summary.replace(/\|/g, "\\|") : "\u2014";
653
- lines.push(`| ${date} | ${featureName} | ${summaryCell} |`);
765
+ lines.push(`| ${date} | ${changeName} | ${summaryCell} |`);
654
766
  }
655
767
  lines.push("");
656
- lines.push("_Auto-generated by `rsp archive-index`._");
657
- const indexPath = join6(archiveDir, "INDEX.md");
768
+ lines.push("_Auto-generated by `rsp update`._");
769
+ const indexPath = join6(ARCHIVES_DIR, "INDEX.md");
658
770
  await writeFile5(indexPath, lines.join("\n"));
659
- console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived feature(s).
771
+ console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived change(s).
660
772
  `);
661
773
  }
662
774
 
775
+ // src/commands/archive.ts
776
+ async function archiveChange(name) {
777
+ if (!name) {
778
+ console.error(` ${pc.red("Usage:")} rsp archive <name>`);
779
+ process.exit(1);
780
+ }
781
+ if (!isValidChangeName(name)) {
782
+ console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
783
+ process.exit(1);
784
+ }
785
+ const srcPath = join7(CHANGES_DIR, `${name}.md`);
786
+ if (!existsSync7(srcPath)) {
787
+ console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
788
+ process.exit(1);
789
+ }
790
+ return withRspLock("archive-change", async () => {
791
+ const content = await readFile6(srcPath, "utf-8");
792
+ const checklist = collectArchiveChecklist(content);
793
+ for (const line of checklist)
794
+ console.log(` ${pc.yellow("\u26A0")} ${line}`);
795
+ if (checklist.length > 0)
796
+ console.log(` ${pc.dim("Archive will continue. Review the warnings above before treating this work as fully closed.")}
797
+ `);
798
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
799
+ const dir = dirname6(name);
800
+ const base = basename4(name);
801
+ const archiveSubdir = dir !== "." ? join7(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
802
+ await mkdir4(archiveSubdir, { recursive: true });
803
+ const archiveName = resolveArchiveName(archiveSubdir, date, base);
804
+ await rename(srcPath, join7(archiveSubdir, archiveName));
805
+ const postArchiveWarnings = [];
806
+ let focusCleared = false;
807
+ const focusEntry = join7(FOCUS_DIR, name);
808
+ if (existsSync7(focusEntry)) {
809
+ try {
810
+ await unlink2(focusEntry);
811
+ await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
812
+ focusCleared = true;
813
+ } catch (error) {
814
+ postArchiveWarnings.push(`focus marker cleanup failed: ${toErrorMessage(error)}`);
815
+ }
816
+ }
817
+ try {
818
+ await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
819
+ } catch (error) {
820
+ postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
821
+ }
822
+ const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
823
+ ` : "";
824
+ console.log(` ${pc.green("Archived:")} ${archiveName}
825
+ ${clearedMsg}`);
826
+ try {
827
+ await buildArchiveIndex({ acquireLock: false });
828
+ } catch (error) {
829
+ postArchiveWarnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
830
+ }
831
+ for (const warning of postArchiveWarnings)
832
+ console.log(` ${pc.yellow("\u26A0")} ${warning}`);
833
+ if (postArchiveWarnings.length > 0)
834
+ console.log(` ${pc.dim("Archive completed, but follow-up cleanup was only partially successful.")}
835
+ `);
836
+ if (existsSync7(".git")) {
837
+ const archiveRelPath = dir !== "." ? join7(dir, archiveName) : archiveName;
838
+ console.log(` ${pc.cyan("Git workflow:")}
839
+ `);
840
+ console.log(` git add .rsp/archives/${archiveRelPath}`);
841
+ console.log(` git commit -m "feat: archive ${name}"`);
842
+ console.log();
843
+ }
844
+ });
845
+ }
846
+ function resolveArchiveName(archiveSubdir, date, base) {
847
+ const initialName = `${date}_${base}.md`;
848
+ if (!existsSync7(join7(archiveSubdir, initialName)))
849
+ return initialName;
850
+ const MAX_SUFFIX = 9999;
851
+ for (let index = 2; index <= MAX_SUFFIX; index++) {
852
+ const candidate = `${date}_${base}-${index}.md`;
853
+ if (!existsSync7(join7(archiveSubdir, candidate)))
854
+ return candidate;
855
+ }
856
+ console.error(` ${pc.red("Error:")} archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`);
857
+ process.exit(1);
858
+ }
859
+ function collectArchiveChecklist(content) {
860
+ const warnings = [];
861
+ const tasksSection = extractSection(content, "Tasks");
862
+ const verifySection = extractSection(content, "Verify");
863
+ const taskTodos = getOpenCheckboxes(tasksSection);
864
+ if (taskTodos.length > 0)
865
+ warnings.push(`${taskTodos.length} task item(s) still incomplete`);
866
+ const verifyTodos = getOpenCheckboxes(verifySection);
867
+ if (verifyTodos.length > 0)
868
+ warnings.push(`${verifyTodos.length} Verify checklist item(s) are still incomplete`);
869
+ if (hasMeaningfulBlockers(content))
870
+ warnings.push("active blockers are present in the change file");
871
+ const scenarios = parseScenarios(content);
872
+ if (scenarios.length === 0)
873
+ warnings.push("no Scenario blocks found (some changes do not need them)");
874
+ return warnings;
875
+ }
876
+
663
877
  // src/commands/check.ts
664
- import { existsSync as existsSync7 } from "fs";
878
+ import { existsSync as existsSync8 } from "fs";
665
879
  import { readFile as readFile7 } from "fs/promises";
666
- import { join as join7, relative as relative4 } from "path";
667
- async function runCheck() {
668
- const featuresDir = join7(RSP_DIR, "features");
880
+ import { join as join8, relative as relative4 } from "path";
881
+ async function runCheck(options = {}) {
669
882
  const config = await loadRspConfig();
670
- const validStatuses = resolveStatuses(config);
671
- const validPriorities = resolvePriorities(config);
883
+ const validKinds = resolveKinds(config);
672
884
  const requiredSections = resolveRequiredSections(config);
673
- let errors = 0;
674
- let warnings = 0;
675
- console.log();
676
- console.log(` ${pc.bold("RSP check")}`);
677
- console.log();
678
- if (existsSync7(ACTIVE_DIR)) {
679
- const entries = await walkFiles(ACTIVE_DIR);
885
+ const choosePlaceholderRe = /^<choose:/i;
886
+ const diagnostics = [];
887
+ const runtime = [];
888
+ const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
889
+ const addDiagnostic = (diagnostic) => diagnostics.push(diagnostic);
890
+ if (existsSync8(FOCUS_DIR)) {
891
+ const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
680
892
  for (const entryPath of entries) {
681
- const entryContent = relative4(ACTIVE_DIR, entryPath);
682
- const markerContent = (await readFile7(entryPath, "utf-8")).trim();
893
+ const entryContent = normalizeLogicalPath(relative4(FOCUS_DIR, entryPath));
894
+ let markerContent = "";
895
+ try {
896
+ markerContent = (await readFile7(entryPath, "utf-8")).trim();
897
+ } catch (error) {
898
+ addDiagnostic({
899
+ severity: "error",
900
+ code: "focus_marker_read_failed",
901
+ path: `focus.d/${entryContent}`,
902
+ change: entryContent,
903
+ message: "unable to read focus marker file",
904
+ hint: toErrorMessage(error)
905
+ });
906
+ reportRuntime({
907
+ code: "focus_marker_read_failed",
908
+ operation: "readFile",
909
+ path: entryPath,
910
+ message: toErrorMessage(error)
911
+ });
912
+ continue;
913
+ }
683
914
  if (markerContent) {
684
- console.log(` ${pc.yellow("\u26A0")} active.d/${entryContent} should be an empty marker file; path is the source of truth`);
685
- warnings++;
915
+ addDiagnostic({
916
+ severity: "warning",
917
+ code: "focus_marker_not_empty",
918
+ path: `focus.d/${entryContent}`,
919
+ message: "should be an empty marker file; path is the source of truth"
920
+ });
686
921
  }
687
- const fp = join7(featuresDir, `${entryContent}.md`);
688
- if (!existsSync7(fp)) {
689
- console.log(` ${pc.red("\u2717")} active.d/${entryContent} points to "${entryContent}" but features/${entryContent}.md not found`);
690
- errors++;
922
+ const fp = join8(CHANGES_DIR, `${entryContent}.md`);
923
+ if (!existsSync8(fp)) {
924
+ addDiagnostic({
925
+ severity: "error",
926
+ code: "focused_change_missing",
927
+ path: `focus.d/${entryContent}`,
928
+ change: entryContent,
929
+ message: `focuses "${entryContent}" but changes/${entryContent}.md not found`
930
+ });
691
931
  }
692
932
  }
693
933
  }
694
- const featureFiles = existsSync7(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
695
- if (featureFiles.length === 0) {
696
- console.log(` ${pc.dim("No feature files to check.")}
934
+ const changeFiles = existsSync8(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
935
+ if (changeFiles.length === 0) {
936
+ const result2 = {
937
+ command: "check",
938
+ ok: diagnostics.every((d) => d.severity !== "error"),
939
+ diagnostics,
940
+ runtime,
941
+ summary: {
942
+ changeFiles: 0,
943
+ errors: diagnostics.filter((d) => d.severity === "error").length,
944
+ warnings: diagnostics.filter((d) => d.severity === "warning").length
945
+ }
946
+ };
947
+ if (options.json) {
948
+ emitJson(result2);
949
+ return result2;
950
+ }
951
+ console.log();
952
+ console.log(` ${pc.bold("RSP check")}`);
953
+ console.log();
954
+ if (diagnostics.length === 0) {
955
+ console.log(` ${pc.dim("No change files to check.")}
956
+ `);
957
+ return result2;
958
+ }
959
+ for (const diagnostic of diagnostics) {
960
+ const icon = diagnostic.severity === "error" ? pc.red("\u2717") : diagnostic.severity === "warning" ? pc.yellow("\u26A0") : pc.dim("\u2139");
961
+ const label = diagnostic.change ?? diagnostic.path;
962
+ const headline = label ? `${label} \u2014 ${diagnostic.message}` : diagnostic.message;
963
+ console.log(` ${icon} ${headline}`);
964
+ for (const detail of diagnostic.details || [])
965
+ console.log(` ${pc.dim(detail)}`);
966
+ if (diagnostic.hint)
967
+ console.log(` ${pc.dim(diagnostic.hint)}`);
968
+ }
969
+ console.log();
970
+ console.log(` ${pc.red(String(result2.summary.errors))} error(s), ${pc.yellow(String(result2.summary.warnings))} warning(s) in 0 change file(s).
697
971
  `);
698
- return 0;
972
+ return result2;
699
973
  }
700
- const allDeps = /* @__PURE__ */ new Map();
701
- for (const fp of featureFiles) {
702
- const name = featureNameFromPath(featuresDir, fp);
974
+ for (const fp of changeFiles) {
975
+ const name = changeNameFromPath(CHANGES_DIR, fp);
703
976
  let content;
704
977
  try {
705
978
  content = await readFile7(fp, "utf-8");
706
- } catch {
707
- console.log(` ${pc.red("\u2717")} ${name} \u2014 unable to read file`);
708
- errors++;
979
+ } catch (error) {
980
+ addDiagnostic({
981
+ severity: "error",
982
+ code: "change_read_failed",
983
+ change: name,
984
+ path: fp,
985
+ message: "unable to read file",
986
+ hint: toErrorMessage(error)
987
+ });
988
+ continue;
989
+ }
990
+ let fm = null;
991
+ try {
992
+ fm = parseFrontmatter(content);
993
+ } catch (error) {
994
+ const message = error instanceof Error ? error.message : String(error);
995
+ addDiagnostic({
996
+ severity: "error",
997
+ code: "invalid_frontmatter",
998
+ change: name,
999
+ path: fp,
1000
+ message: `invalid YAML frontmatter (${message})`
1001
+ });
709
1002
  continue;
710
1003
  }
711
- const fm = parseFrontmatter(content);
712
1004
  if (!fm) {
713
- console.log(` ${pc.red("\u2717")} ${name} \u2014 missing YAML frontmatter`);
714
- errors++;
1005
+ addDiagnostic({
1006
+ severity: "error",
1007
+ code: "missing_frontmatter",
1008
+ change: name,
1009
+ path: fp,
1010
+ message: "missing YAML frontmatter"
1011
+ });
715
1012
  }
716
1013
  for (const section of requiredSections) {
717
1014
  if (!new RegExp(`^## ${section}$`, "m").test(content)) {
718
- console.log(` ${pc.red("\u2717")} ${name} \u2014 missing "## ${section}" section`);
719
- errors++;
1015
+ addDiagnostic({
1016
+ severity: "error",
1017
+ code: "missing_section",
1018
+ change: name,
1019
+ path: fp,
1020
+ message: `missing "## ${section}" section`
1021
+ });
720
1022
  }
721
1023
  }
722
1024
  if (fm) {
723
- if (!("status" in fm)) {
724
- console.log(` ${pc.red("\u2717")} ${name} \u2014 missing frontmatter field: status`);
725
- errors++;
726
- } else if (!validStatuses.includes(String(fm.status))) {
727
- console.log(` ${pc.red("\u2717")} ${name} \u2014 invalid status "${fm.status}" (valid: ${validStatuses.join(", ")})`);
728
- errors++;
729
- }
730
- if (!("priority" in fm)) {
731
- console.log(` ${pc.red("\u2717")} ${name} \u2014 missing frontmatter field: priority`);
732
- errors++;
733
- } else if (!validPriorities.includes(String(fm.priority))) {
734
- console.log(` ${pc.red("\u2717")} ${name} \u2014 invalid priority "${fm.priority}" (valid: ${validPriorities.join(", ")})`);
735
- errors++;
1025
+ if (!("kind" in fm)) {
1026
+ addDiagnostic({
1027
+ severity: "error",
1028
+ code: "missing_kind",
1029
+ change: name,
1030
+ path: fp,
1031
+ message: "missing frontmatter field: kind"
1032
+ });
1033
+ } else if (choosePlaceholderRe.test(String(fm.kind))) {
1034
+ addDiagnostic({
1035
+ severity: "error",
1036
+ code: "placeholder_kind",
1037
+ change: name,
1038
+ path: fp,
1039
+ message: `kind still uses the template placeholder; choose one of: ${validKinds.join(", ")}`
1040
+ });
1041
+ } else if (!validKinds.includes(String(fm.kind))) {
1042
+ addDiagnostic({
1043
+ severity: "error",
1044
+ code: "invalid_kind",
1045
+ change: name,
1046
+ path: fp,
1047
+ message: `invalid kind "${fm.kind}" (valid: ${validKinds.join(", ")})`
1048
+ });
736
1049
  }
737
1050
  }
738
- const headingMatch = content.match(/^# Feature:[ \t]*([^\n]+)$/m);
739
- if (headingMatch) {
740
- const heading = headingMatch[1].trim();
1051
+ const headingLine = content.split("\n").map((line) => line.trim()).find((line) => line.startsWith("# Change:"));
1052
+ if (headingLine) {
1053
+ const heading = headingLine.slice("# Change:".length).trim();
741
1054
  if (heading !== name) {
742
- console.log(` ${pc.red("\u2717")} ${name} \u2014 # Feature: heading "${heading}" differs from feature name`);
743
- errors++;
1055
+ addDiagnostic({
1056
+ severity: "error",
1057
+ code: "heading_mismatch",
1058
+ change: name,
1059
+ path: fp,
1060
+ message: `# Change: heading "${heading}" differs from change name`
1061
+ });
744
1062
  }
745
1063
  } else {
746
- console.log(` ${pc.red("\u2717")} ${name} \u2014 missing "# Feature:" heading`);
747
- errors++;
1064
+ addDiagnostic({
1065
+ severity: "error",
1066
+ code: "missing_heading",
1067
+ change: name,
1068
+ path: fp,
1069
+ message: 'missing "# Change:" heading'
1070
+ });
748
1071
  }
749
1072
  const deltas = detectDeltaSections(content);
750
1073
  if (deltas.added || deltas.modified || deltas.removed) {
@@ -755,7 +1078,13 @@ async function runCheck() {
755
1078
  parts.push("MODIFIED");
756
1079
  if (deltas.removed)
757
1080
  parts.push("REMOVED");
758
- console.log(` ${pc.dim("\u2139")} ${name} \u2014 delta markers found: ${parts.join(", ")}`);
1081
+ addDiagnostic({
1082
+ severity: "info",
1083
+ code: "delta_markers_found",
1084
+ change: name,
1085
+ path: fp,
1086
+ message: `delta markers found: ${parts.join(", ")}`
1087
+ });
759
1088
  }
760
1089
  const scenarios = parseScenarios(content);
761
1090
  if (scenarios.length > 0) {
@@ -775,466 +1104,433 @@ async function runCheck() {
775
1104
  scenarioIssues.push(`"${s.heading}" missing ${missing.join("/")}`);
776
1105
  }
777
1106
  if (scenarioIssues.length > 0) {
778
- console.log(` ${pc.yellow("\u26A0")} ${name} \u2014 scenario format issues:`);
779
- for (const issue of scenarioIssues)
780
- console.log(` ${pc.dim(issue)}`);
781
- warnings++;
1107
+ addDiagnostic({
1108
+ severity: "warning",
1109
+ code: "scenario_format_issues",
1110
+ change: name,
1111
+ path: fp,
1112
+ message: "scenario format issues",
1113
+ details: scenarioIssues
1114
+ });
782
1115
  }
783
1116
  }
784
- if (fm && fm["depends-on"]) {
785
- const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
786
- const seen = /* @__PURE__ */ new Set();
787
- for (const dep of deps) {
788
- if (dep === name) {
789
- console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on itself`);
790
- errors++;
791
- continue;
792
- }
793
- if (seen.has(dep)) {
794
- console.log(` ${pc.red("\u2717")} ${name} \u2014 duplicate dependency "${dep}"`);
795
- errors++;
796
- continue;
797
- }
798
- seen.add(dep);
799
- allDeps.set(name, [...allDeps.get(name) || [], dep]);
800
- const depFp = join7(featuresDir, `${dep}.md`);
801
- if (!existsSync7(depFp)) {
802
- console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on "${dep}" but features/${dep}.md not found`);
803
- errors++;
804
- }
805
- }
1117
+ }
1118
+ const result = {
1119
+ command: "check",
1120
+ ok: diagnostics.every((d) => d.severity !== "error"),
1121
+ diagnostics,
1122
+ runtime,
1123
+ summary: {
1124
+ changeFiles: changeFiles.length,
1125
+ errors: diagnostics.filter((d) => d.severity === "error").length,
1126
+ warnings: diagnostics.filter((d) => d.severity === "warning").length
806
1127
  }
1128
+ };
1129
+ if (options.json) {
1130
+ emitJson(result);
1131
+ return result;
807
1132
  }
808
- const cycles = detectCycles(allDeps);
809
- for (const cycle of cycles) {
810
- console.log(` ${pc.red("\u2717")} circular dependency: ${cycle.join(" \u2192 ")}`);
811
- errors++;
1133
+ console.log();
1134
+ console.log(` ${pc.bold("RSP check")}`);
1135
+ console.log();
1136
+ for (const diagnostic of diagnostics) {
1137
+ const icon = diagnostic.severity === "error" ? pc.red("\u2717") : diagnostic.severity === "warning" ? pc.yellow("\u26A0") : pc.dim("\u2139");
1138
+ const label = diagnostic.change ?? diagnostic.path;
1139
+ const headline = label ? `${label} \u2014 ${diagnostic.message}` : diagnostic.message;
1140
+ console.log(` ${icon} ${headline}`);
1141
+ for (const detail of diagnostic.details || [])
1142
+ console.log(` ${pc.dim(detail)}`);
1143
+ if (diagnostic.hint)
1144
+ console.log(` ${pc.dim(diagnostic.hint)}`);
812
1145
  }
813
1146
  console.log();
814
- if (errors === 0 && warnings === 0)
815
- console.log(` ${pc.green("\u2713")} All ${featureFiles.length} feature file(s) valid.
1147
+ if (result.summary.errors === 0 && result.summary.warnings === 0)
1148
+ console.log(` ${pc.green("\u2713")} All ${changeFiles.length} change file(s) valid.
816
1149
  `);
817
1150
  else
818
- console.log(` ${pc.red(String(errors))} error(s), ${pc.yellow(String(warnings))} warning(s) in ${featureFiles.length} feature file(s).
819
- `);
820
- return errors;
821
- }
822
-
823
- // src/commands/close-feature.ts
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";
827
- async function closeFeature(name) {
828
- return withRspLock("close-feature", async () => {
829
- if (!name) {
830
- console.error(` ${pc.red("Usage:")} rsp close <name>`);
831
- process.exit(1);
832
- }
833
- if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
834
- console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
835
- process.exit(1);
836
- }
837
- const srcPath = join8(RSP_DIR, "features", `${name}.md`);
838
- if (!existsSync8(srcPath)) {
839
- console.error(` ${pc.red("Feature not found:")} .rsp/features/${name}.md`);
840
- process.exit(1);
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
- }
848
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
849
- const dir = dirname5(name);
850
- const base = basename5(name);
851
- const archiveSubdir = dir !== "." ? join8(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
852
- const archiveName = `${date}_${base}.md`;
853
- await mkdir4(archiveSubdir, { recursive: true });
854
- await rename(srcPath, join8(archiveSubdir, archiveName));
855
- let activeCleared = false;
856
- const activeEntry = join8(ACTIVE_DIR, name);
857
- if (existsSync8(activeEntry)) {
858
- await unlink2(activeEntry);
859
- await cleanupEmptyParentDirs(activeEntry, ACTIVE_DIR);
860
- activeCleared = true;
861
- }
862
- await cleanupEmptyParentDirs(srcPath, join8(RSP_DIR, "features"));
863
- const clearedMsg = activeCleared ? ` ${pc.dim("active.d cleared")}
864
- ` : "";
865
- console.log(` ${pc.green("Archived:")} ${archiveName}
866
- ${clearedMsg}`);
867
- await buildArchiveIndex({ acquireLock: false });
868
- await runCheckIfHasDependents(name);
869
- if (existsSync8(".git")) {
870
- const archiveRelPath = dir !== "." ? join8(dir, archiveName) : archiveName;
871
- console.log(` ${pc.cyan("Git workflow:")}
1151
+ console.log(` ${pc.red(String(result.summary.errors))} error(s), ${pc.yellow(String(result.summary.warnings))} warning(s) in ${changeFiles.length} change file(s).
872
1152
  `);
873
- console.log(` git add .rsp/archives/${archiveRelPath}`);
874
- console.log(` git commit -m "feat: complete ${name}"`);
875
- console.log();
876
- }
877
- });
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
- }
897
- async function runCheckIfHasDependents(name) {
898
- try {
899
- const hasDependents = (await findDependents(name)).length > 0;
900
- if (hasDependents)
901
- await runCheck();
902
- } catch {
903
- console.warn(` ${pc.dim("\u26A0 could not verify dependents for")} "${name}"`);
904
- }
905
- }
906
- async function cleanupEmptyParentDirs(path, stopDir) {
907
- let parent = dirname5(path);
908
- while (parent !== stopDir) {
909
- try {
910
- const remaining = await readdir2(parent);
911
- if (remaining.length === 0)
912
- await rmdir(parent);
913
- else
914
- break;
915
- } catch {
916
- break;
917
- }
918
- parent = dirname5(parent);
919
- }
1153
+ return result;
920
1154
  }
921
1155
 
922
- // src/commands/deps.ts
1156
+ // src/commands/create.ts
923
1157
  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) : [];
935
- console.log();
936
- console.log(` ${pc.bold("RSP dependencies")}`);
937
- console.log();
938
- if (featureFiles.length === 0) {
939
- console.log(` ${pc.dim("No features found.")}
940
- `);
941
- return;
1158
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
1159
+ import { dirname as dirname7, join as join9 } from "path";
1160
+ async function createChange(name, summary = "", kind) {
1161
+ if (!name) {
1162
+ console.error(` ${pc.red("Usage:")} rsp create <name> [summary]`);
1163
+ process.exit(1);
942
1164
  }
943
- const records = [];
944
- const dependents = /* @__PURE__ */ new Map();
945
- for (const fp of featureFiles) {
946
- const name = featureNameFromPath(featuresDir, fp);
947
- let content;
948
- try {
949
- content = await readFile9(fp, "utf-8");
950
- } catch {
951
- continue;
952
- }
953
- const fm = parseFrontmatter(content);
954
- const depsRaw = fm?.["depends-on"] ? Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]] : [];
955
- const deps = depsRaw.filter(Boolean);
956
- records.push({ name, deps });
957
- for (const dep of deps) {
958
- const list = dependents.get(dep) || [];
959
- list.push(name);
960
- dependents.set(dep, list);
961
- }
1165
+ if (!isValidChangeName(name)) {
1166
+ console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1167
+ process.exit(1);
962
1168
  }
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);
1169
+ guardRspInitialized();
1170
+ return withRspLock("create-change", async () => {
1171
+ const changePath = join9(CHANGES_DIR, `${name}.md`);
1172
+ await mkdir5(dirname7(changePath), { recursive: true });
1173
+ const existed = existsSync9(changePath);
1174
+ if (!existed) {
1175
+ const content = generateChangeContent(name, summary, kind);
1176
+ await writeFile6(changePath, content);
1177
+ await mkdir5(FOCUS_DIR, { recursive: true });
1178
+ const focusEntry = join9(FOCUS_DIR, name);
1179
+ await mkdir5(dirname7(focusEntry), { recursive: true });
1180
+ await writeFile6(focusEntry, "");
970
1181
  }
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.")}
1182
+ const label = existed ? "Using" : pc.green("Created");
1183
+ console.log(` ${label}: ${changePath}`);
1184
+ if (existed)
1185
+ console.log(` ${pc.dim("Unchanged focus.")} Run: rsp focus ${name}`);
1186
+ else
1187
+ console.log(` ${pc.dim("focused via focus.d")} \u2192 ${name}`);
1188
+ console.log(` ${pc.cyan("Next:")} edit the change file and complete the tasks
980
1189
  `);
981
- return;
982
- }
983
- if (mermaid) {
984
- console.log("```mermaid");
985
- console.log("graph LR");
986
- const safeId = (s) => s.replace(/\//g, "__slash__").replace(/\W/g, "_");
987
- for (const { name } of visibleRecords) {
988
- const id = safeId(name);
989
- const label = name.replace(/"/g, "#quot;");
990
- console.log(` ${id}["${label}"]`);
991
- }
992
- const visibleNames = new Set(visibleRecords.map((record) => record.name));
993
- for (const { name, deps } of visibleRecords) {
994
- const fromId = safeId(name);
995
- for (const dep of deps) {
996
- if (!visibleNames.has(dep))
997
- continue;
998
- const toId = safeId(dep);
999
- console.log(` ${fromId} --> ${toId}`);
1000
- }
1001
- }
1002
- console.log("```");
1003
- console.log();
1004
- return;
1005
- }
1006
- console.log(` ${"Feature".padEnd(28)} ${"Depends On".padEnd(28)} Dependents`);
1007
- console.log(` ${"\u2500".repeat(28)} ${"\u2500".repeat(28)} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
1008
- for (const { name, deps } of visibleRecords.sort((a, b) => a.name.localeCompare(b.name))) {
1009
- const depText = deps.length > 0 ? deps.join(", ") : "\u2014";
1010
- const dependentText = (dependents.get(name) || []).length > 0 ? pc.dim((dependents.get(name) || []).join(", ")) : "\u2014";
1011
- console.log(` ${name.padEnd(28)} ${depText.padEnd(28)} ${dependentText}`);
1012
- }
1013
- console.log();
1190
+ });
1014
1191
  }
1015
1192
 
1016
1193
  // src/commands/doctor.ts
1017
1194
  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();
1195
+ import { readFile as readFile8 } from "fs/promises";
1196
+ import { basename as basename5, join as join10, relative as relative5 } from "path";
1197
+ async function runDoctor(options = {}) {
1198
+ const checks = [];
1199
+ const runtime = [];
1200
+ const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
1025
1201
  const rspRulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
1026
1202
  const designPath = join10(RSP_DIR, "specs", "design.md");
1027
1203
  const specsIndexPath = join10(RSP_DIR, "specs", "INDEX.md");
1028
1204
  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)
1205
+ reportCheck(checks, ".rsp exists", existsSync10(RSP_DIR), "Run: rsp init");
1206
+ reportCheck(checks, "rules/rsp-rules.md exists", existsSync10(rspRulesPath), "Run: rsp init");
1207
+ reportCheck(checks, "specs/design.md exists", existsSync10(designPath), "Run: rsp init");
1208
+ reportCheck(checks, "specs/INDEX.md exists", existsSync10(specsIndexPath), "Run: rsp update");
1209
+ reportCheck(checks, "archives/INDEX.md exists", existsSync10(archivesIndexPath), "Run: rsp update");
1210
+ await checkAgents(checks, reportRuntime);
1211
+ await checkGeneratedIndex(checks, reportRuntime, specsIndexPath, "_Auto-generated by `rsp update`._", "specs/INDEX.md has generated signature", "Run: rsp update");
1212
+ await checkGeneratedIndex(checks, reportRuntime, archivesIndexPath, "_Auto-generated by `rsp update`._", "archives/INDEX.md has generated signature", "Run: rsp update");
1213
+ await checkArchiveNaming(checks, reportRuntime);
1214
+ await checkConfigSemantics(checks, reportRuntime);
1215
+ await checkActiveChangeConsistency(checks, reportRuntime);
1216
+ const result = {
1217
+ command: "doctor",
1218
+ ok: checks.every((check) => check.status !== "issue"),
1219
+ checks,
1220
+ runtime,
1221
+ summary: {
1222
+ issues: checks.filter((check) => check.status === "issue").length
1223
+ }
1224
+ };
1225
+ if (options.json) {
1226
+ emitJson(result);
1227
+ return result;
1228
+ }
1229
+ console.log();
1230
+ console.log(` ${pc.bold("RSP doctor")}`);
1231
+ console.log();
1232
+ for (const check of checks)
1233
+ printDoctorCheck(check);
1234
+ if (result.summary.issues === 0)
1041
1235
  console.log(`
1042
1236
  ${pc.green("\u2713")} RSP setup looks healthy.
1043
1237
  `);
1044
1238
  else
1045
1239
  console.log(`
1046
- ${pc.yellow(String(issues))} issue(s) detected.
1240
+ ${pc.yellow(String(result.summary.issues))} issue(s) detected.
1047
1241
  `);
1048
- return issues;
1242
+ return result;
1049
1243
  }
1050
- function reportCheck(label, ok, fixHint) {
1244
+ function reportCheck(checks, label, ok, fixHint) {
1051
1245
  if (ok) {
1052
- console.log(` ${pc.green("\u2713")} ${label}`);
1053
- return 0;
1246
+ checks.push({ status: "ok", label });
1247
+ return;
1248
+ }
1249
+ checks.push({ status: "issue", label, hint: fixHint });
1250
+ }
1251
+ function printDoctorCheck(check) {
1252
+ if (check.status === "ok") {
1253
+ console.log(` ${pc.green("\u2713")} ${check.label}`);
1254
+ return;
1255
+ }
1256
+ if (check.status === "info") {
1257
+ console.log(` ${pc.dim(`Info: ${check.message || check.label}`)}`);
1258
+ if (check.hint)
1259
+ console.log(` ${pc.dim(check.hint)}`);
1260
+ return;
1054
1261
  }
1055
- console.log(` ${pc.yellow("!")} ${label}`);
1056
- if (fixHint)
1057
- console.log(` ${pc.dim(fixHint)}`);
1058
- return 1;
1262
+ console.log(` ${pc.yellow("!")} ${check.message || check.label}`);
1263
+ if (check.hint)
1264
+ console.log(` ${pc.dim(check.hint)}`);
1059
1265
  }
1060
- async function checkAgents() {
1266
+ async function checkAgents(checks, reportRuntime) {
1061
1267
  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;
1268
+ checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing", hint: "Run: rsp init" });
1269
+ return;
1270
+ }
1271
+ let agents;
1272
+ try {
1273
+ agents = await readFile8("AGENTS.md", "utf-8");
1274
+ } catch (error) {
1275
+ reportRuntime({
1276
+ code: "agents_read_failed",
1277
+ operation: "readFile",
1278
+ path: "AGENTS.md",
1279
+ message: toErrorMessage(error)
1280
+ });
1281
+ checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md could not be read", hint: "Check AGENTS.md permissions or recreate it with rsp init" });
1282
+ return;
1065
1283
  }
1066
- const agents = await readFile10("AGENTS.md", "utf-8");
1067
1284
  if (hasRspAgentsBlock(agents)) {
1068
- console.log(` ${pc.green("\u2713")} AGENTS.md contains managed RSP block`);
1069
- return 0;
1285
+ checks.push({ status: "ok", label: "AGENTS.md contains managed RSP block" });
1286
+ return;
1070
1287
  }
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;
1288
+ checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing managed RSP block", hint: "Run: rsp init" });
1074
1289
  }
1075
- async function checkGeneratedIndex(path, signature, label, fixHint) {
1290
+ async function checkGeneratedIndex(checks, reportRuntime, path, signature, label, fixHint) {
1076
1291
  if (!existsSync10(path))
1077
- return 0;
1078
- const content = await readFile10(path, "utf-8");
1292
+ return;
1293
+ let content;
1294
+ try {
1295
+ content = await readFile8(path, "utf-8");
1296
+ } catch (error) {
1297
+ reportRuntime({
1298
+ code: "generated_index_read_failed",
1299
+ operation: "readFile",
1300
+ path,
1301
+ message: toErrorMessage(error)
1302
+ });
1303
+ checks.push({ status: "issue", label, message: `${label} could not be read`, hint: fixHint });
1304
+ return;
1305
+ }
1079
1306
  if (content.includes(signature)) {
1080
- console.log(` ${pc.green("\u2713")} ${label}`);
1081
- return 0;
1307
+ checks.push({ status: "ok", label });
1308
+ return;
1082
1309
  }
1083
- console.log(` ${pc.yellow("!")} ${label}`);
1084
- console.log(` ${pc.dim(fixHint)}`);
1085
- return 1;
1310
+ checks.push({ status: "issue", label, hint: fixHint });
1086
1311
  }
1087
- async function checkArchiveNaming() {
1312
+ async function checkArchiveNaming(checks, reportRuntime) {
1088
1313
  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);
1314
+ const archiveFiles = existsSync10(archivesDir) ? (await walkMarkdownFiles(archivesDir, { onError: reportRuntime })).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
1315
+ const invalidFiles = archiveFiles.map((fp) => normalizeLogicalPath(relative5(archivesDir, fp))).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(basename5(fp)) === false);
1091
1316
  if (invalidFiles.length === 0) {
1092
- console.log(` ${pc.green("\u2713")} archived feature files follow the date_name.md convention`);
1093
- return 0;
1317
+ checks.push({ status: "ok", label: "archived change files follow the date_name.md convention" });
1318
+ return;
1094
1319
  }
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;
1320
+ checks.push({
1321
+ status: "issue",
1322
+ label: "archived change files follow the date_name.md convention",
1323
+ message: `archived change files with invalid names: ${invalidFiles.join(", ")}`,
1324
+ hint: "Rename archive files to YYYY-MM-DD_name.md and run: rsp update"
1325
+ });
1098
1326
  }
1099
- async function checkConfigSemantics() {
1327
+ async function checkConfigSemantics(checks, reportRuntime) {
1100
1328
  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) {
1329
+ return;
1330
+ let content;
1331
+ try {
1332
+ content = await readFile8(CONFIG_PATH, "utf-8");
1333
+ } catch (error) {
1334
+ reportRuntime({
1335
+ code: "config_read_failed",
1336
+ operation: "readFile",
1337
+ path: CONFIG_PATH,
1338
+ message: toErrorMessage(error)
1339
+ });
1340
+ checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml could not be read", hint: "Check .rsp/config.yaml permissions or recreate it with rsp init" });
1341
+ return;
1342
+ }
1343
+ let parsed;
1344
+ try {
1345
+ parsed = parseYamlText(content);
1346
+ } catch (error) {
1347
+ const message = error instanceof Error ? error.message : String(error);
1348
+ checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml is not valid YAML", hint: message });
1349
+ return;
1350
+ }
1351
+ reportConfigListField(checks, parsed, "kinds");
1352
+ if ("required_sections" in parsed) {
1353
+ checks.push({
1354
+ status: "issue",
1355
+ label: "config.yaml semantic checks",
1356
+ message: 'config.yaml field "required_sections" is no longer supported',
1357
+ hint: 'RSP keeps the core change section structure fixed. Remove "required_sections" from .rsp/config.yaml.'
1358
+ });
1359
+ }
1360
+ }
1361
+ function reportConfigListField(checks, parsed, key) {
1111
1362
  if (!(key in parsed))
1112
- return 0;
1363
+ return;
1113
1364
  const value = parsed[key];
1114
1365
  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;
1366
+ checks.push({
1367
+ status: "issue",
1368
+ label: "config.yaml semantic checks",
1369
+ message: `config.yaml field "${key}" must be a YAML list`,
1370
+ hint: `Update .rsp/config.yaml so "${key}" is a YAML sequence (for example: ${key}: [item])`
1371
+ });
1372
+ return;
1118
1373
  }
1119
1374
  const items = value.map(String);
1120
1375
  const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
1121
1376
  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`);
1377
+ checks.push({ status: "ok", label: `config.yaml field "${key}" has valid list semantics` });
1378
+ return;
1379
+ }
1380
+ checks.push({
1381
+ status: "issue",
1382
+ label: "config.yaml semantic checks",
1383
+ message: `config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`,
1384
+ hint: `Remove duplicate values from .rsp/config.yaml:${key}`
1385
+ });
1386
+ }
1387
+ async function checkActiveChangeConsistency(checks, reportRuntime) {
1388
+ const changeFiles = existsSync10(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1389
+ const changeNames = new Set(changeFiles.map((fp) => changeNameFromPath(CHANGES_DIR, fp)));
1390
+ const focusEntries = existsSync10(FOCUS_DIR) ? await walkFiles(FOCUS_DIR, { onError: reportRuntime }) : [];
1391
+ const focusNames = new Set(focusEntries.map((fp) => normalizeLogicalPath(relative5(FOCUS_DIR, fp))));
1392
+ const missingChangeFiles = [...focusNames].filter((name) => !changeNames.has(name));
1393
+ const unfocusedChangeFiles = [...changeNames].filter((name) => !focusNames.has(name));
1394
+ if (missingChangeFiles.length === 0) {
1395
+ checks.push({ status: "ok", label: "every focus marker maps to a change file" });
1140
1396
  } 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++;
1397
+ checks.push({
1398
+ status: "issue",
1399
+ label: "every focus marker maps to a change file",
1400
+ message: `focus markers without matching change files: ${missingChangeFiles.join(", ")}`,
1401
+ hint: "Run: remove stale files in .rsp/focus.d/ or recreate the change with rsp create <name>"
1402
+ });
1144
1403
  }
1145
- if (unstagedActiveMarkers.length === 0) {
1146
- console.log(` ${pc.green("\u2713")} every feature file has an active marker`);
1404
+ if (unfocusedChangeFiles.length === 0) {
1405
+ checks.push({ status: "ok", label: "focus markers are consistent with open changes" });
1147
1406
  } 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++;
1407
+ const preview = unfocusedChangeFiles.slice(0, 5).join(", ");
1408
+ const extra = unfocusedChangeFiles.length > 5 ? ` (+${unfocusedChangeFiles.length - 5} more)` : "";
1409
+ checks.push({
1410
+ status: "info",
1411
+ label: "focus markers are consistent with open changes",
1412
+ message: `unfocused open changes: ${preview}${extra}`,
1413
+ hint: "Only changes listed in .rsp/focus.d/ are currently focused. Use rsp focus <name> to foreground one."
1414
+ });
1151
1415
  }
1152
- return issues;
1153
1416
  }
1154
1417
 
1155
- // src/commands/init.ts
1418
+ // src/commands/focus.ts
1156
1419
  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";
1159
- var CONFIG_TEMPLATE = `# RSP project configuration
1160
- # Uncomment any section below to override defaults.
1420
+ import { mkdir as mkdir6, unlink as unlink3, writeFile as writeFile7 } from "fs/promises";
1421
+ import { dirname as dirname8, join as join11 } from "path";
1422
+ async function focusChange(name) {
1423
+ if (!name) {
1424
+ console.error(` ${pc.red("Usage:")} rsp focus <name>`);
1425
+ process.exit(1);
1426
+ }
1427
+ if (!isValidChangeName(name)) {
1428
+ console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1429
+ process.exit(1);
1430
+ }
1431
+ guardRspInitialized();
1432
+ const changePath = join11(CHANGES_DIR, `${name}.md`);
1433
+ if (!existsSync11(changePath)) {
1434
+ console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
1435
+ process.exit(1);
1436
+ }
1437
+ return withRspLock("focus-change", async () => {
1438
+ await mkdir6(FOCUS_DIR, { recursive: true });
1439
+ const focusEntry = join11(FOCUS_DIR, name);
1440
+ await mkdir6(dirname8(focusEntry), { recursive: true });
1441
+ await writeFile7(focusEntry, "");
1442
+ console.log(` ${pc.green("Focused:")} ${name}`);
1443
+ console.log(` ${pc.dim("focus.d")} \u2192 ${name}`);
1444
+ console.log();
1445
+ });
1446
+ }
1447
+ async function unfocusChange(name) {
1448
+ if (!name) {
1449
+ console.error(` ${pc.red("Usage:")} rsp unfocus <name>`);
1450
+ process.exit(1);
1451
+ }
1452
+ if (!isValidChangeName(name)) {
1453
+ console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1454
+ process.exit(1);
1455
+ }
1456
+ guardRspInitialized();
1457
+ const focusEntry = join11(FOCUS_DIR, name);
1458
+ if (!existsSync11(focusEntry)) {
1459
+ console.error(` ${pc.red("Focus marker not found:")} .rsp/focus.d/${name}`);
1460
+ process.exit(1);
1461
+ }
1462
+ return withRspLock("unfocus-change", async () => {
1463
+ await unlink3(focusEntry);
1464
+ await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
1465
+ console.log(` ${pc.green("Unfocused:")} ${name}`);
1466
+ console.log(` ${pc.dim("focus.d cleared")} \u2192 ${name}`);
1467
+ console.log();
1468
+ });
1469
+ }
1470
+
1471
+ // src/commands/init.ts
1472
+ import { existsSync as existsSync12 } from "fs";
1473
+ import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
1474
+ import { join as join12 } from "path";
1475
+ function generateConfigTemplate() {
1476
+ const fmtList = (items, indent) => items.map((i) => `${" ".repeat(indent)}# - ${i}`).join("\n");
1477
+ return `# RSP project configuration
1478
+ # Uncomment any section below to customize classification values.
1161
1479
  #
1162
1480
  # Built-in defaults:
1163
- # statuses: draft, ready, in-progress, blocked, done
1164
- # priorities: low, medium, high, critical
1165
- # required_sections: Spec, Plan
1166
- #
1167
- # statuses:
1168
- # - draft
1169
- # - ready
1170
- # - in-progress
1171
- # - blocked
1172
- # - done
1481
+ # kinds: ${VALID_KINDS.join(", ")}
1173
1482
  #
1174
- # priorities:
1175
- # - low
1176
- # - medium
1177
- # - high
1178
- # - critical
1179
- #
1180
- # required_sections:
1181
- # - Spec
1182
- # - Plan
1483
+ # kinds:
1484
+ ${fmtList(VALID_KINDS, 2)}
1183
1485
  `;
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
1486
  }
1196
1487
  async function ensureFile(path, content) {
1197
- if (existsSync11(path))
1488
+ if (existsSync12(path))
1198
1489
  return false;
1199
- await writeFile6(path, content);
1490
+ await writeFile8(path, content);
1200
1491
  return true;
1201
1492
  }
1202
1493
  function toTitle(projectName) {
1203
1494
  return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
1204
1495
  }
1205
1496
  async function initProject(args = {}) {
1206
- const isNew = !existsSync11(RSP_DIR);
1207
- const projectName = await detectProjectName3();
1497
+ const isNew = !existsSync12(RSP_DIR);
1498
+ const projectName = await detectProjectName();
1499
+ const mode = args.agentsMode ?? "managed";
1500
+ if (mode !== "managed" && mode !== "print") {
1501
+ console.error(` ${pc.red("Error:")} unsupported --agents-mode "${mode}". Use: managed or print`);
1502
+ process.exit(1);
1503
+ }
1208
1504
  return withRspLock("init", async () => {
1209
1505
  const dirs = [
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")
1506
+ join12(RSP_DIR, "rules"),
1507
+ join12(RSP_DIR, "specs"),
1508
+ CHANGES_DIR,
1509
+ FOCUS_DIR,
1510
+ join12(RSP_DIR, "archives")
1215
1511
  ];
1216
1512
  for (const d of dirs)
1217
- await mkdir5(d, { recursive: true });
1513
+ await mkdir7(d, { recursive: true });
1218
1514
  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");
1515
+ const bundledRules = await readFile9(join12(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1516
+ created = await ensureFile(join12(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
1517
+ created = await ensureFile(join12(RSP_DIR, "config.yaml"), generateConfigTemplate()) || created;
1518
+ const createdSpecsIndex = await ensureFile(join12(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Project-level specs and design notes._\n");
1223
1519
  created = createdSpecsIndex || created;
1224
- const createdDesign = await ensureFile(join11(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1520
+ const createdDesign = await ensureFile(join12(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1225
1521
  created = createdDesign || created;
1226
- const createdArchivesIndex = await ensureFile(join11(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1522
+ const createdArchivesIndex = await ensureFile(join12(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1227
1523
  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;
1524
+ created = await ensureFile(join12(CHANGES_DIR, ".gitkeep"), "") || created;
1525
+ created = await ensureFile(join12(FOCUS_DIR, ".gitkeep"), "") || created;
1526
+ created = await ensureFile(join12(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1231
1527
  if (args.withProjectRules)
1232
- created = await ensureFile(join11(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
1528
+ created = await ensureFile(join12(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
1233
1529
  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;
1530
+ const changePath = join12(CHANGES_DIR, "project-setup.md");
1531
+ const focusMarker = join12(FOCUS_DIR, "project-setup");
1532
+ created = await ensureFile(changePath, generateChangeContent("project-setup")) || created;
1533
+ created = await ensureFile(focusMarker, "") || created;
1238
1534
  }
1239
1535
  if (createdSpecsIndex || createdDesign)
1240
1536
  await buildSpecsIndex({ acquireLock: false });
@@ -1242,51 +1538,48 @@ async function initProject(args = {}) {
1242
1538
  await buildArchiveIndex({ acquireLock: false });
1243
1539
  const agentsPath = "AGENTS.md";
1244
1540
  const title = toTitle(projectName);
1245
- const mode = args.agentsMode ?? "managed";
1246
- if (!existsSync11(agentsPath)) {
1247
- if (mode === "managed") {
1248
- await writeFile6(agentsPath, `# ${title}
1541
+ let agentsUpdated = false;
1542
+ if (!existsSync12(agentsPath)) {
1543
+ await writeFile8(agentsPath, `# ${title}
1249
1544
 
1250
1545
  ${upsertRspAgentsBlock("").content}
1251
1546
  `);
1252
- created = true;
1253
- }
1254
- } else if (mode === "managed") {
1255
- const existing = await readFile11(agentsPath, "utf-8");
1547
+ created = true;
1548
+ agentsUpdated = true;
1549
+ } else {
1550
+ const existing = await readFile9(agentsPath, "utf-8");
1256
1551
  const next = upsertRspAgentsBlock(existing);
1257
1552
  if (next.changed) {
1258
- await writeFile6(agentsPath, next.content);
1553
+ await writeFile8(agentsPath, next.content);
1259
1554
  created = true;
1555
+ agentsUpdated = true;
1260
1556
  }
1261
1557
  }
1262
1558
  if (mode === "print") {
1263
- const managed = existsSync11(agentsPath) ? upsertRspAgentsBlock(await readFile11(agentsPath, "utf-8")).content : `# ${title}
1264
-
1265
- ${renderRspAgentsBlock()}
1266
- `;
1559
+ const managed = await readFile9(agentsPath, "utf-8");
1267
1560
  console.log(`
1268
1561
  ${managed}
1269
1562
  `);
1270
1563
  }
1271
1564
  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";
1565
+ const createdProjectSetup = args.withProjectSetup ? "\n .rsp/changes/project-setup.md\n .rsp/focus.d/project-setup" : "";
1566
+ const createdAgents = agentsUpdated ? "\n AGENTS.md" : "";
1567
+ const next = args.withProjectSetup ? "fill .rsp/changes/project-setup.md" : "rsp create project-setup";
1274
1568
  console.log(`
1275
1569
  ${pc.green("RSP scaffolded.")}
1276
1570
  `);
1277
1571
  console.log(` Created: .rsp/rules/
1278
1572
  .rsp/specs/
1279
- .rsp/features/
1280
- .rsp/active.d/
1573
+ .rsp/changes/
1574
+ .rsp/focus.d/
1281
1575
  .rsp/archives/
1282
1576
  .rsp/.gitignore
1283
1577
  .rsp/config.yaml
1284
- .rsp/specs/design.md${createdProjectSetup}
1285
- AGENTS.md
1578
+ .rsp/specs/design.md${createdProjectSetup}${createdAgents}
1286
1579
  `);
1287
1580
  console.log(` ${pc.cyan("Next:")} ${next}
1288
1581
  ${pc.dim("Then:")} fill .rsp/specs/design.md
1289
- ${pc.dim("Also:")} rsp status rsp deps
1582
+ ${pc.dim("Also:")} rsp status rsp check
1290
1583
  `);
1291
1584
  } else if (created) {
1292
1585
  console.log(`
@@ -1300,125 +1593,101 @@ ${managed}
1300
1593
  });
1301
1594
  }
1302
1595
 
1303
- // src/commands/new-feature.ts
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";
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
- }
1323
- return withRspLock("new-feature", async () => {
1324
- const featurePath = join12(RSP_DIR, "features", `${name}.md`);
1325
- await mkdir6(dirname6(featurePath), { recursive: true });
1326
- const existed = existsSync12(featurePath);
1327
- if (!existed) {
1328
- const content = generateFeatureContent(name, summary);
1329
- await writeFile7(featurePath, content);
1330
- }
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, "");
1335
- if (!existed) {
1336
- const archiveIndex = join12(ARCHIVES_DIR, "INDEX.md");
1337
- if (existsSync12(archiveIndex))
1338
- await buildArchiveIndex({ acquireLock: false });
1339
- }
1340
- const label = existed ? "Using" : pc.green("Created");
1341
- console.log(` ${label}: ${featurePath}
1342
- ${pc.dim("active.d")} \u2192 ${name}
1343
- `);
1344
- });
1345
- }
1346
-
1347
1596
  // src/commands/status.ts
1348
1597
  import { existsSync as existsSync13 } from "fs";
1349
- import { readFile as readFile12, stat as stat2 } from "fs/promises";
1598
+ import { readFile as readFile10, stat as stat2 } from "fs/promises";
1350
1599
  import { join as join13, relative as relative6 } from "path";
1351
- var COL_FEATURE = 30;
1352
- var COL_STATUS = 16;
1600
+ var COL_CHANGE = 32;
1601
+ var COL_KIND = 10;
1353
1602
  var COL_AGE = 7;
1354
1603
  var COL_PROGRESS = 10;
1355
- async function showStatus(options = {}) {
1356
- const featuresDir = join13(RSP_DIR, "features");
1357
- const activeSet = /* @__PURE__ */ new Set();
1358
- if (existsSync13(ACTIVE_DIR)) {
1359
- try {
1360
- const entries = await walkFiles(ACTIVE_DIR);
1361
- for (const entryPath of entries) {
1362
- const name = relative6(ACTIVE_DIR, entryPath);
1363
- activeSet.add(name);
1364
- }
1365
- } catch {
1366
- }
1367
- }
1368
- const featureFiles = existsSync13(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
1369
- const featureMap = {};
1370
- for (const fp of featureFiles) {
1371
- const name = featureNameFromPath(featuresDir, fp);
1372
- featureMap[name] = fp;
1373
- }
1374
- console.log();
1375
- console.log(` ${pc.bold("RSP status")}`);
1376
- console.log();
1377
- if (activeSet.size > 0) {
1378
- console.log(` ${pc.cyan("Active:")} ${[...activeSet].join(", ")}`);
1379
- console.log();
1380
- } else {
1381
- console.log(` ${pc.dim("No active feature.")} Run: rsp new <name>`);
1382
- console.log();
1383
- }
1384
- const allNames = [.../* @__PURE__ */ new Set([...Object.keys(featureMap), ...activeSet])].sort();
1385
- if (allNames.length === 0) {
1386
- console.log(` ${pc.dim("No features found.")} Run: rsp new <name>
1387
- `);
1388
- return;
1389
- }
1604
+ async function showStatus(options = {}, runOptions = {}) {
1605
+ const focusedSet = /* @__PURE__ */ new Set();
1606
+ const runtime = [];
1607
+ const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(runOptions.verbose) && !runOptions.json);
1608
+ if (existsSync13(FOCUS_DIR)) {
1609
+ const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
1610
+ for (const entryPath of entries)
1611
+ focusedSet.add(normalizeLogicalPath(relative6(FOCUS_DIR, entryPath)));
1612
+ }
1613
+ const changeFiles = existsSync13(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1614
+ const changeMap = {};
1615
+ for (const fp of changeFiles)
1616
+ changeMap[changeNameFromPath(CHANGES_DIR, fp)] = fp;
1617
+ const allNames = [.../* @__PURE__ */ new Set([...Object.keys(changeMap), ...focusedSet])].sort();
1390
1618
  const records = [];
1619
+ const outputRecords = [];
1391
1620
  for (const name of allNames) {
1392
- const fp = featureMap[name];
1393
- let status = "\u2014";
1621
+ const fp = changeMap[name];
1622
+ let kind = "\u2014";
1394
1623
  let progress = "\u2014";
1395
- let depends = "\u2014";
1396
- let age = "\u2014";
1397
- const isActive = activeSet.has(name);
1624
+ let ageDays = null;
1625
+ const isFocused = focusedSet.has(name);
1398
1626
  let isBlocked = false;
1627
+ let done = 0;
1628
+ let total = 0;
1399
1629
  if (fp) {
1400
1630
  try {
1401
- const content = await readFile12(fp, "utf-8");
1402
- const fm = parseFrontmatter(content);
1403
- status = fm?.status || "\u2014";
1404
- isBlocked = status === "blocked";
1405
- if (fm?.["depends-on"])
1406
- depends = Array.isArray(fm["depends-on"]) ? fm["depends-on"].join(", ") : String(fm["depends-on"]);
1631
+ const content = await readFile10(fp, "utf-8");
1632
+ try {
1633
+ const fm = parseFrontmatter(content);
1634
+ kind = fm?.kind ? String(fm.kind) : "\u2014";
1635
+ } catch (error) {
1636
+ kind = "(invalid)";
1637
+ reportRuntime({
1638
+ code: "frontmatter_parse_failed",
1639
+ operation: "parseFrontmatter",
1640
+ path: fp,
1641
+ message: toErrorMessage(error)
1642
+ });
1643
+ }
1644
+ isBlocked = hasMeaningfulBlockers(content);
1407
1645
  const cb = countCheckboxes(content);
1408
- progress = `${cb.done}/${cb.total}`;
1646
+ done = cb.done;
1647
+ total = cb.total;
1648
+ progress = `${done}/${total}`;
1649
+ } catch (error) {
1650
+ reportRuntime({
1651
+ code: "change_read_failed",
1652
+ operation: "readFile",
1653
+ path: fp,
1654
+ message: toErrorMessage(error)
1655
+ });
1656
+ }
1657
+ try {
1409
1658
  const s = await stat2(fp);
1410
- const created = s.birthtime || s.mtime;
1411
- const diffMs = Date.now() - created.getTime();
1412
- age = String(Math.floor(diffMs / (1e3 * 60 * 60 * 24)));
1413
- } catch {
1659
+ ageDays = Math.floor((Date.now() - s.mtime.getTime()) / (1e3 * 60 * 60 * 24));
1660
+ } catch (error) {
1661
+ reportRuntime({
1662
+ code: "change_stat_failed",
1663
+ operation: "stat",
1664
+ path: fp,
1665
+ message: toErrorMessage(error)
1666
+ });
1414
1667
  }
1415
1668
  } else {
1416
- status = "(missing)";
1669
+ kind = "(missing)";
1670
+ reportRuntime({
1671
+ code: "focused_change_missing",
1672
+ operation: "resolveChange",
1673
+ path: join13(CHANGES_DIR, `${name}.md`),
1674
+ message: "focus marker points to a missing change file"
1675
+ });
1417
1676
  }
1418
- records.push({ name, status, progress, age, depends, isActive, isBlocked });
1677
+ const age = ageDays === null ? "\u2014" : String(ageDays);
1678
+ records.push({ name, kind, progress, age, isFocused, isBlocked });
1679
+ outputRecords.push({
1680
+ name,
1681
+ kind,
1682
+ progress: { done, total },
1683
+ ageDays,
1684
+ isFocused,
1685
+ isBlocked,
1686
+ path: fp ? normalizeLogicalPath(fp) : null
1687
+ });
1419
1688
  }
1420
1689
  const filteredRecords = records.filter((record) => {
1421
- if (options.active && !record.isActive)
1690
+ if (options.focused && !record.isFocused)
1422
1691
  return false;
1423
1692
  if (options.blocked && !record.isBlocked)
1424
1693
  return false;
@@ -1429,48 +1698,90 @@ async function showStatus(options = {}) {
1429
1698
  }
1430
1699
  return true;
1431
1700
  });
1432
- if (filteredRecords.length === 0) {
1433
- console.log(` ${pc.dim("No features match the current filters.")}`);
1701
+ const filteredOutputRecords = outputRecords.filter((record) => {
1702
+ if (options.focused && !record.isFocused)
1703
+ return false;
1704
+ if (options.blocked && !record.isBlocked)
1705
+ return false;
1706
+ if (typeof options.stale === "number") {
1707
+ if (record.ageDays === null || record.ageDays < options.stale)
1708
+ return false;
1709
+ }
1710
+ return true;
1711
+ });
1712
+ const blockedCount = filteredOutputRecords.filter((r) => r.isBlocked).length;
1713
+ const statusResult = {
1714
+ command: "status",
1715
+ ok: true,
1716
+ filters: {
1717
+ focused: Boolean(options.focused),
1718
+ blocked: Boolean(options.blocked),
1719
+ stale: typeof options.stale === "number" ? options.stale : null
1720
+ },
1721
+ focused: [...focusedSet].sort(),
1722
+ records: filteredOutputRecords,
1723
+ summary: {
1724
+ total: filteredOutputRecords.length,
1725
+ focused: filteredOutputRecords.filter((r) => r.isFocused).length,
1726
+ blocked: blockedCount
1727
+ },
1728
+ archiveTrend: await readArchiveTrend(runtime, runOptions),
1729
+ runtime
1730
+ };
1731
+ if (runOptions.json) {
1732
+ emitJson(statusResult);
1733
+ return statusResult;
1734
+ }
1735
+ console.log();
1736
+ console.log(` ${pc.bold("RSP status")}`);
1737
+ console.log();
1738
+ if (focusedSet.size > 0) {
1739
+ console.log(` ${pc.cyan("Focused:")} ${[...focusedSet].join(", ")}`);
1434
1740
  console.log();
1435
- return;
1741
+ } else {
1742
+ console.log(` ${pc.dim("No focused change.")} Run: rsp create <name> or rsp focus <name>`);
1743
+ console.log();
1744
+ }
1745
+ if (allNames.length === 0) {
1746
+ console.log(` ${pc.dim("No changes found.")} Run: rsp create <name>
1747
+ `);
1748
+ return statusResult;
1749
+ }
1750
+ if (filteredRecords.length === 0) {
1751
+ console.log(` ${pc.dim("No changes match the current filters.")}
1752
+ `);
1753
+ return statusResult;
1436
1754
  }
1437
1755
  const pad = (s, w) => s.padEnd(w);
1438
- console.log(` ${pad("Feature", COL_FEATURE)} ${pad("Status", COL_STATUS)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)} Depends On`);
1439
- console.log(` ${"\u2500".repeat(COL_FEATURE)} ${"\u2500".repeat(COL_STATUS)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)} ${"\u2500".repeat(30)}`);
1440
- let blockedCount = 0;
1756
+ console.log(` ${pad("Change", COL_CHANGE)} ${pad("Kind", COL_KIND)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)}`);
1757
+ console.log(` ${"\u2500".repeat(COL_CHANGE)} ${"\u2500".repeat(COL_KIND)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)}`);
1441
1758
  for (const r of filteredRecords) {
1442
- const marker = r.isActive ? pc.cyan("*") : " ";
1443
- const namePadded = r.name.padEnd(COL_FEATURE - 1);
1444
- const statusPadded = String(r.status).padEnd(COL_STATUS);
1759
+ const marker = r.isFocused ? pc.cyan("*") : " ";
1760
+ const namePadded = r.name.padEnd(COL_CHANGE - 1);
1761
+ const kindPadded = r.kind.padEnd(COL_KIND);
1445
1762
  const agePadded = r.age.padEnd(COL_AGE);
1446
1763
  const progressPadded = r.progress.padEnd(COL_PROGRESS);
1447
1764
  const nameDisplay = r.isBlocked ? pc.yellow(namePadded) : namePadded;
1448
- const statusDisplay = r.isBlocked ? pc.yellow(statusPadded) : statusPadded;
1449
- const ageDisplay = r.isActive ? pc.cyan(agePadded) : agePadded;
1450
- console.log(` ${marker}${nameDisplay} ${statusDisplay} ${ageDisplay} ${progressPadded} ${r.depends}`);
1451
- if (r.isBlocked)
1452
- blockedCount++;
1765
+ const ageDisplay = r.isFocused ? pc.cyan(agePadded) : agePadded;
1766
+ console.log(` ${marker}${nameDisplay} ${kindPadded} ${ageDisplay} ${progressPadded}`);
1453
1767
  }
1454
1768
  console.log();
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`);
1769
+ console.log(` ${pc.bold("Summary:")} ${statusResult.summary.total} change(s), ${statusResult.summary.focused} focused, ${pc.yellow(String(statusResult.summary.blocked))} blocked`);
1459
1770
  console.log();
1460
- await showArchiveTrend();
1461
- if (blockedCount > 0) {
1771
+ printArchiveTrend(statusResult.archiveTrend);
1772
+ if (statusResult.summary.blocked > 0) {
1462
1773
  const blocked = filteredRecords.filter((r) => r.isBlocked);
1463
1774
  console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
1464
1775
  console.log();
1465
1776
  }
1777
+ return statusResult;
1466
1778
  }
1467
- async function showArchiveTrend() {
1468
- const archiveDir = join13(RSP_DIR, "archives");
1469
- const indexPath = join13(archiveDir, "INDEX.md");
1779
+ async function readArchiveTrend(runtime, runOptions) {
1780
+ const indexPath = join13(ARCHIVES_DIR, "INDEX.md");
1470
1781
  if (!existsSync13(indexPath))
1471
- return;
1782
+ return [];
1472
1783
  try {
1473
- const content = await readFile12(indexPath, "utf-8");
1784
+ const content = await readFile10(indexPath, "utf-8");
1474
1785
  const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
1475
1786
  const counts = {};
1476
1787
  let match;
@@ -1480,28 +1791,79 @@ async function showArchiveTrend() {
1480
1791
  }
1481
1792
  const months = Object.keys(counts).sort();
1482
1793
  if (months.length === 0)
1483
- return;
1484
- console.log(` ${pc.bold("Archive trend:")}`);
1485
- const bar = months.map((m) => {
1486
- const n = counts[m];
1487
- return pc.dim(`${m} ${"\u2588".repeat(Math.min(n, 20))} ${n}`);
1488
- }).join("\n ");
1489
- console.log(` ${bar}`);
1490
- console.log();
1491
- } catch {
1794
+ return [];
1795
+ return months.map((month) => ({ month, count: counts[month] }));
1796
+ } catch (error) {
1797
+ recordRuntimeDiagnostic(runtime, {
1798
+ code: "archive_index_read_failed",
1799
+ operation: "readFile",
1800
+ path: indexPath,
1801
+ message: toErrorMessage(error)
1802
+ }, Boolean(runOptions.verbose) && !runOptions.json);
1803
+ return [];
1492
1804
  }
1493
1805
  }
1806
+ function printArchiveTrend(trend) {
1807
+ if (trend.length === 0)
1808
+ return;
1809
+ console.log(` ${pc.bold("Archive trend:")}`);
1810
+ const bar = trend.map(({ month, count }) => pc.dim(`${month} ${"\u2588".repeat(Math.min(count, 20))} ${count}`)).join("\n ");
1811
+ console.log(` ${bar}`);
1812
+ console.log();
1813
+ }
1814
+
1815
+ // src/commands/update.ts
1816
+ import { existsSync as existsSync14 } from "fs";
1817
+ import { mkdir as mkdir8, readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
1818
+ import { join as join14 } from "path";
1819
+ async function updateProject() {
1820
+ if (!existsSync14(RSP_DIR)) {
1821
+ console.log(` ${pc.red("Error:")} RSP is not initialized. Run: rsp init`);
1822
+ process.exit(1);
1823
+ }
1824
+ return withRspLock("update", async () => {
1825
+ let updated = false;
1826
+ const bundledRules = await readFile11(join14(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1827
+ const rulesPath = join14(RSP_DIR, "rules", "rsp-rules.md");
1828
+ await mkdir8(join14(RSP_DIR, "rules"), { recursive: true });
1829
+ const existingRules = existsSync14(rulesPath) ? await readFile11(rulesPath, "utf-8") : null;
1830
+ if (existingRules !== bundledRules) {
1831
+ await writeFile9(rulesPath, bundledRules);
1832
+ console.log(` ${pc.green("\u2713")} rules/rsp-rules.md updated`);
1833
+ updated = true;
1834
+ }
1835
+ const agentsPath = "AGENTS.md";
1836
+ const projectName = await detectProjectName();
1837
+ const baseAgents = existsSync14(agentsPath) ? await readFile11(agentsPath, "utf-8") : `# ${projectName}
1838
+
1839
+ `;
1840
+ const nextAgents = upsertRspAgentsBlock(baseAgents);
1841
+ if (!existsSync14(agentsPath) || nextAgents.changed) {
1842
+ await writeFile9(agentsPath, nextAgents.content);
1843
+ console.log(` ${pc.green("\u2713")} AGENTS.md managed block refreshed`);
1844
+ updated = true;
1845
+ }
1846
+ await buildSpecsIndex({ acquireLock: false });
1847
+ await buildArchiveIndex({ acquireLock: false });
1848
+ if (!updated)
1849
+ console.log(` ${pc.dim("Already up to date.")}
1850
+ `);
1851
+ else
1852
+ console.log(` ${pc.green("Update complete.")}
1853
+ `);
1854
+ });
1855
+ }
1494
1856
 
1495
1857
  // src/cli.ts
1496
1858
  var initCommand = defineCommand({
1497
1859
  meta: {
1498
1860
  name: "init",
1499
- description: "Scaffold .rsp/ + AGENTS.md in current project"
1861
+ description: "Scaffold .rsp/ and ensure AGENTS.md contains the RSP entry block"
1500
1862
  },
1501
1863
  args: {
1502
1864
  "agents-mode": {
1503
1865
  type: "string",
1504
- description: "How to update AGENTS.md: managed, skip, or print",
1866
+ description: "How to handle AGENTS.md output: managed or print",
1505
1867
  default: "managed"
1506
1868
  },
1507
1869
  "with-project-rules": {
@@ -1511,33 +1873,37 @@ var initCommand = defineCommand({
1511
1873
  },
1512
1874
  "with-project-setup": {
1513
1875
  type: "boolean",
1514
- description: "Create .rsp/features/project-setup.md and mark it active",
1876
+ description: "Create .rsp/changes/project-setup.md and focus it",
1515
1877
  default: false
1516
1878
  }
1517
1879
  },
1518
1880
  async run({ args }) {
1519
1881
  await initProject({
1520
- agentsMode: args["agents-mode"] === "skip" || args["agents-mode"] === "print" ? args["agents-mode"] : "managed",
1882
+ agentsMode: args["agents-mode"] === "print" ? "print" : "managed",
1521
1883
  withProjectRules: Boolean(args["with-project-rules"]),
1522
1884
  withProjectSetup: Boolean(args["with-project-setup"])
1523
1885
  });
1524
1886
  }
1525
1887
  });
1526
- var newFeatureCommand = defineCommand({
1888
+ var createCommand = defineCommand({
1527
1889
  meta: {
1528
- name: "new",
1529
- description: "Create .rsp/features/<name>.md [summary]"
1890
+ name: "create",
1891
+ description: "Create .rsp/changes/<name>.md [summary]"
1530
1892
  },
1531
1893
  args: {
1532
1894
  name: {
1533
1895
  type: "positional",
1534
- description: "Feature name",
1896
+ description: "Change name",
1535
1897
  required: true
1898
+ },
1899
+ kind: {
1900
+ type: "string",
1901
+ description: "Optional kind for a kind-aware template (feature, fix, refactor, docs, ops, research)"
1536
1902
  }
1537
1903
  },
1538
1904
  async run({ args }) {
1539
1905
  const summary = Array.isArray(args._) && args._.length > 1 ? args._.slice(1).join(" ") : "";
1540
- await newFeature(args.name, summary);
1906
+ await createChange(args.name, summary, args.kind);
1541
1907
  }
1542
1908
  });
1543
1909
  var addRulesCommand = defineCommand({
@@ -1582,111 +1948,141 @@ var addCommand = defineCommand({
1582
1948
  spec: addSpecCommand
1583
1949
  }
1584
1950
  });
1585
- var closeFeatureCommand = defineCommand({
1951
+ var archiveCommand = defineCommand({
1952
+ meta: {
1953
+ name: "archive",
1954
+ description: "Archive an open change into .rsp/archives/"
1955
+ },
1956
+ args: {
1957
+ name: {
1958
+ type: "positional",
1959
+ description: "Change name",
1960
+ required: true
1961
+ }
1962
+ },
1963
+ async run({ args }) {
1964
+ await archiveChange(args.name);
1965
+ }
1966
+ });
1967
+ var focusCommand = defineCommand({
1586
1968
  meta: {
1587
- name: "close",
1588
- description: "Archive to .rsp/archives/"
1969
+ name: "focus",
1970
+ description: "Mark an existing change as currently focused in .rsp/focus.d/"
1589
1971
  },
1590
1972
  args: {
1591
1973
  name: {
1592
1974
  type: "positional",
1593
- description: "Feature name",
1975
+ description: "Change name",
1594
1976
  required: true
1595
1977
  }
1596
1978
  },
1597
1979
  async run({ args }) {
1598
- await closeFeature(args.name);
1980
+ await focusChange(args.name);
1981
+ }
1982
+ });
1983
+ var unfocusCommand = defineCommand({
1984
+ meta: {
1985
+ name: "unfocus",
1986
+ description: "Remove a change from the current focus set in .rsp/focus.d/"
1987
+ },
1988
+ args: {
1989
+ name: {
1990
+ type: "positional",
1991
+ description: "Change name",
1992
+ required: true
1993
+ }
1994
+ },
1995
+ async run({ args }) {
1996
+ await unfocusChange(args.name);
1599
1997
  }
1600
1998
  });
1601
1999
  var statusCommand = defineCommand({
1602
2000
  meta: {
1603
2001
  name: "status",
1604
- description: "Show project status summary"
2002
+ description: "Show project status summary with current focus information"
1605
2003
  },
1606
2004
  args: {
1607
- active: {
2005
+ focused: {
1608
2006
  type: "boolean",
1609
- description: "Show only active features",
2007
+ description: "Show only currently focused changes",
1610
2008
  default: false
1611
2009
  },
1612
2010
  blocked: {
1613
2011
  type: "boolean",
1614
- description: "Show only blocked features",
2012
+ description: "Show only blocked changes",
1615
2013
  default: false
1616
2014
  },
1617
2015
  stale: {
1618
2016
  type: "string",
1619
- description: "Show only features with age >= days"
2017
+ description: "Show only changes with age >= days"
2018
+ },
2019
+ json: {
2020
+ type: "boolean",
2021
+ description: "Print machine-readable JSON output",
2022
+ default: false
2023
+ },
2024
+ verbose: {
2025
+ type: "boolean",
2026
+ description: "Print runtime diagnostics for suppressed I/O issues",
2027
+ default: false
1620
2028
  }
1621
2029
  },
1622
2030
  async run({ args }) {
1623
2031
  const stale = args.stale === void 0 ? void 0 : Number(args.stale);
1624
2032
  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`);
2033
+ if (args.json) {
2034
+ emitStatusJsonError({
2035
+ code: "invalid_stale_filter",
2036
+ message: "--stale must be a non-negative integer number of days"
2037
+ }, {
2038
+ focused: Boolean(args.focused),
2039
+ blocked: Boolean(args.blocked)
2040
+ });
2041
+ } else {
2042
+ console.error(` Error: --stale must be a non-negative integer number of days`);
2043
+ }
1626
2044
  process.exit(1);
1627
2045
  }
1628
2046
  await showStatus({
1629
- active: Boolean(args.active),
2047
+ focused: Boolean(args.focused),
1630
2048
  blocked: Boolean(args.blocked),
1631
2049
  stale
2050
+ }, {
2051
+ json: Boolean(args.json),
2052
+ verbose: Boolean(args.verbose)
1632
2053
  });
1633
2054
  }
1634
2055
  });
1635
2056
  var checkCommand = defineCommand({
1636
2057
  meta: {
1637
2058
  name: "check",
1638
- description: "Validate features (frontmatter, sections, deps, deltas, scenarios)"
1639
- },
1640
- async run() {
1641
- const errors = await runCheck();
1642
- if (errors > 0)
1643
- process.exit(1);
1644
- }
1645
- });
1646
- var depsCommand = defineCommand({
1647
- meta: {
1648
- name: "deps",
1649
- description: "Show dependency summary (--mermaid for graph)"
2059
+ description: "Validate changes (frontmatter, sections, deltas, scenarios)"
1650
2060
  },
1651
2061
  args: {
1652
- mermaid: {
2062
+ json: {
1653
2063
  type: "boolean",
1654
- description: "Output as Mermaid.js graph",
2064
+ description: "Print machine-readable JSON output",
1655
2065
  default: false
1656
2066
  },
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"
2067
+ verbose: {
2068
+ type: "boolean",
2069
+ description: "Print runtime diagnostics for suppressed I/O issues",
2070
+ default: false
1664
2071
  }
1665
2072
  },
1666
2073
  async run({ args }) {
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
- });
1672
- }
1673
- });
1674
- var archiveIndexCommand = defineCommand({
1675
- meta: {
1676
- name: "archive-index",
1677
- description: "Regenerate archives INDEX.md"
1678
- },
1679
- async run() {
1680
- await buildArchiveIndex();
2074
+ const result = await runCheck({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
2075
+ if (!result.ok)
2076
+ process.exit(1);
1681
2077
  }
1682
2078
  });
1683
- var specsIndexCommand = defineCommand({
2079
+ var updateCommand = defineCommand({
1684
2080
  meta: {
1685
- name: "specs-index",
1686
- description: "Regenerate specs INDEX.md"
2081
+ name: "update",
2082
+ description: "Refresh RSP project structure after upgrade (rules, AGENTS, indices)"
1687
2083
  },
1688
2084
  async run() {
1689
- await buildSpecsIndex();
2085
+ await updateProject();
1690
2086
  }
1691
2087
  });
1692
2088
  var doctorCommand = defineCommand({
@@ -1694,9 +2090,21 @@ var doctorCommand = defineCommand({
1694
2090
  name: "doctor",
1695
2091
  description: "Check RSP setup health and common integration issues"
1696
2092
  },
1697
- async run() {
1698
- const issues = await runDoctor();
1699
- if (issues > 0)
2093
+ args: {
2094
+ json: {
2095
+ type: "boolean",
2096
+ description: "Print machine-readable JSON output",
2097
+ default: false
2098
+ },
2099
+ verbose: {
2100
+ type: "boolean",
2101
+ description: "Print runtime diagnostics for suppressed I/O issues",
2102
+ default: false
2103
+ }
2104
+ },
2105
+ async run({ args }) {
2106
+ const result = await runDoctor({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
2107
+ if (!result.ok)
1700
2108
  process.exit(1);
1701
2109
  }
1702
2110
  });
@@ -1706,19 +2114,19 @@ async function runCli(rawArgs = process.argv.slice(2)) {
1706
2114
  meta: {
1707
2115
  name: "rsp",
1708
2116
  version,
1709
- description: "RSP (Rules, Specs, Plans) workflow for AI-assisted development"
2117
+ description: "RSP (Rules, Specs, Plans) workflow for lightweight AI-assisted change management"
1710
2118
  },
1711
2119
  subCommands: {
1712
- "init": initCommand,
1713
- "add": addCommand,
1714
- "new": newFeatureCommand,
1715
- "close": closeFeatureCommand,
1716
- "status": statusCommand,
1717
- "check": checkCommand,
1718
- "deps": depsCommand,
1719
- "archive-index": archiveIndexCommand,
1720
- "specs-index": specsIndexCommand,
1721
- "doctor": doctorCommand
2120
+ init: initCommand,
2121
+ add: addCommand,
2122
+ create: createCommand,
2123
+ focus: focusCommand,
2124
+ unfocus: unfocusCommand,
2125
+ archive: archiveCommand,
2126
+ status: statusCommand,
2127
+ check: checkCommand,
2128
+ update: updateCommand,
2129
+ doctor: doctorCommand
1722
2130
  }
1723
2131
  });
1724
2132
  await runMain(main, { rawArgs });