@oevery/rsp 1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/README.zh-CN.md +122 -0
- package/dist/cli.mjs +1137 -0
- package/package.json +55 -0
- package/rules/rsp-rules.md +123 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
|
+
import { defineCommand, runMain } from "citty";
|
|
7
|
+
|
|
8
|
+
// src/commands/archive-index.ts
|
|
9
|
+
import { existsSync as existsSync3 } from "fs";
|
|
10
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
11
|
+
import { basename, dirname as dirname2, join as join3, relative as relative2 } from "path";
|
|
12
|
+
|
|
13
|
+
// src/core/config.ts
|
|
14
|
+
import { existsSync as existsSync2 } from "fs";
|
|
15
|
+
import { readFile } from "fs/promises";
|
|
16
|
+
import { dirname, join as join2 } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import pc from "picocolors";
|
|
19
|
+
|
|
20
|
+
// src/core/helpers.ts
|
|
21
|
+
import { existsSync } from "fs";
|
|
22
|
+
import { readdir, stat } from "fs/promises";
|
|
23
|
+
import { join, relative } from "path";
|
|
24
|
+
function parseYamlLines(lines) {
|
|
25
|
+
const result = {};
|
|
26
|
+
let currentKey = null;
|
|
27
|
+
let currentList = null;
|
|
28
|
+
function flushList() {
|
|
29
|
+
if (currentList !== null && currentKey !== null) {
|
|
30
|
+
result[currentKey] = currentList;
|
|
31
|
+
currentList = null;
|
|
32
|
+
currentKey = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const li = line.match(/^[ \t]+-[ \t]+([^\n]+)$/);
|
|
41
|
+
if (li && currentList !== null) {
|
|
42
|
+
currentList.push(li[1].trim());
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
flushList();
|
|
46
|
+
const kv = line.match(/^([\w-]+):[ \t]*([^\n]*)$/);
|
|
47
|
+
if (kv) {
|
|
48
|
+
currentKey = kv[1];
|
|
49
|
+
const val = kv[2].trim();
|
|
50
|
+
if (!val || val === "[]") {
|
|
51
|
+
currentList = [];
|
|
52
|
+
} else {
|
|
53
|
+
result[currentKey] = val;
|
|
54
|
+
currentKey = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
flushList();
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function parseFrontmatter(content) {
|
|
62
|
+
const m = content.match(/^---\n([\s\S]*?)\n(?:---|\.\.\.)\n/);
|
|
63
|
+
if (!m)
|
|
64
|
+
return null;
|
|
65
|
+
return parseYamlLines(m[1].split("\n"));
|
|
66
|
+
}
|
|
67
|
+
function countCheckboxes(content) {
|
|
68
|
+
const todo = (content.match(/\[ \]/g) || []).length;
|
|
69
|
+
const progress = (content.match(/\[\/\]/g) || []).length;
|
|
70
|
+
const done = (content.match(/\[x\]/g) || []).length;
|
|
71
|
+
const dropped = (content.match(/\[-\]/g) || []).length;
|
|
72
|
+
return { todo, progress, done, total: todo + progress + done + dropped };
|
|
73
|
+
}
|
|
74
|
+
async function walkMarkdownFiles(dir) {
|
|
75
|
+
const files = [];
|
|
76
|
+
try {
|
|
77
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
const filePath = join(dir, item.name);
|
|
80
|
+
if (item.isDirectory())
|
|
81
|
+
files.push(...await walkMarkdownFiles(filePath));
|
|
82
|
+
else if (item.name.endsWith(".md"))
|
|
83
|
+
files.push(filePath);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
async function walkFiles(dir) {
|
|
90
|
+
const files = [];
|
|
91
|
+
try {
|
|
92
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
const filePath = join(dir, item.name);
|
|
95
|
+
if (item.isDirectory())
|
|
96
|
+
files.push(...await walkFiles(filePath));
|
|
97
|
+
else
|
|
98
|
+
files.push(filePath);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
return files;
|
|
103
|
+
}
|
|
104
|
+
function featureNameFromPath(featuresDir, filePath) {
|
|
105
|
+
const rel = relative(featuresDir, filePath);
|
|
106
|
+
return rel.replace(/\.md$/, "");
|
|
107
|
+
}
|
|
108
|
+
function generateFeatureContent(name, summary = "") {
|
|
109
|
+
return `---
|
|
110
|
+
status: draft
|
|
111
|
+
priority: medium
|
|
112
|
+
tags:
|
|
113
|
+
---
|
|
114
|
+
# Feature: ${name}
|
|
115
|
+
|
|
116
|
+
## Spec
|
|
117
|
+
- Summary: ${summary || "<one-line summary>"}
|
|
118
|
+
- Requirements:
|
|
119
|
+
- [ ] <verifiable requirement>
|
|
120
|
+
- Constraints:
|
|
121
|
+
-
|
|
122
|
+
|
|
123
|
+
## Plan
|
|
124
|
+
- [ ] Phase 1:
|
|
125
|
+
- [ ] <task>
|
|
126
|
+
|
|
127
|
+
## Tests
|
|
128
|
+
- [ ] <test file or scenario>
|
|
129
|
+
|
|
130
|
+
## Notes (optional)
|
|
131
|
+
- <design decisions discovered during implementation>
|
|
132
|
+
|
|
133
|
+
## Blockers
|
|
134
|
+
-
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
function detectDeltaSections(content) {
|
|
138
|
+
const match = content.match(/^## Spec\n([\s\S]*?)(?=\n## |\n---|\n\.\.\.)/m);
|
|
139
|
+
if (!match) {
|
|
140
|
+
const m2 = content.match(/^## Spec\n([\s\S]*)$/m);
|
|
141
|
+
if (!m2)
|
|
142
|
+
return { added: false, modified: false, removed: false };
|
|
143
|
+
const body2 = m2[1];
|
|
144
|
+
return {
|
|
145
|
+
added: /^###\s*ADDED/im.test(body2),
|
|
146
|
+
modified: /^###\s*MODIFIED/im.test(body2),
|
|
147
|
+
removed: /^###\s*REMOVED/im.test(body2)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const body = match[1];
|
|
151
|
+
return {
|
|
152
|
+
added: /^###\s*ADDED/im.test(body),
|
|
153
|
+
modified: /^###\s*MODIFIED/im.test(body),
|
|
154
|
+
removed: /^###\s*REMOVED/im.test(body)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function parseScenarios(content) {
|
|
158
|
+
const scenarios = [];
|
|
159
|
+
const regex = /^###\s*Scenario:\s*(.+)$/gim;
|
|
160
|
+
let match;
|
|
161
|
+
while ((match = regex.exec(content)) !== null) {
|
|
162
|
+
const heading = match[1].trim();
|
|
163
|
+
const startPos = match.index + match[0].length;
|
|
164
|
+
const restContent = content.slice(startPos);
|
|
165
|
+
const nextSection = restContent.match(/^###/m);
|
|
166
|
+
const block = nextSection ? restContent.slice(0, nextSection.index) : restContent;
|
|
167
|
+
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*/, ""));
|
|
168
|
+
if (steps.length > 0)
|
|
169
|
+
scenarios.push({ heading, steps });
|
|
170
|
+
}
|
|
171
|
+
return scenarios;
|
|
172
|
+
}
|
|
173
|
+
function detectCycles(graph) {
|
|
174
|
+
const cycles = [];
|
|
175
|
+
const visited = /* @__PURE__ */ new Set();
|
|
176
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
177
|
+
function dfs(node, path) {
|
|
178
|
+
if (inStack.has(node)) {
|
|
179
|
+
const cycleStart = path.indexOf(node);
|
|
180
|
+
if (cycleStart !== -1)
|
|
181
|
+
cycles.push(path.slice(cycleStart).concat(node));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (visited.has(node))
|
|
185
|
+
return;
|
|
186
|
+
visited.add(node);
|
|
187
|
+
inStack.add(node);
|
|
188
|
+
path.push(node);
|
|
189
|
+
const neighbors = graph.get(node) || [];
|
|
190
|
+
for (const next of neighbors) {
|
|
191
|
+
if (graph.has(next))
|
|
192
|
+
dfs(next, path);
|
|
193
|
+
}
|
|
194
|
+
path.pop();
|
|
195
|
+
inStack.delete(node);
|
|
196
|
+
}
|
|
197
|
+
for (const node of graph.keys())
|
|
198
|
+
dfs(node, []);
|
|
199
|
+
return cycles;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/core/config.ts
|
|
203
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
204
|
+
var PKG_ROOT = join2(__dirname, "..");
|
|
205
|
+
var RSP_DIR = ".rsp";
|
|
206
|
+
var ACTIVE_DIR = join2(RSP_DIR, "active.d");
|
|
207
|
+
var LOCK_PATH = join2(RSP_DIR, ".lock");
|
|
208
|
+
var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
|
|
209
|
+
var VALID_STATUSES = ["draft", "ready", "in-progress", "blocked", "done"];
|
|
210
|
+
var VALID_PRIORITIES = ["low", "medium", "high", "critical"];
|
|
211
|
+
var DEFAULT_REQUIRED_SECTIONS = ["Spec", "Plan"];
|
|
212
|
+
var _configCache = null;
|
|
213
|
+
async function loadRspConfig() {
|
|
214
|
+
if (_configCache)
|
|
215
|
+
return _configCache;
|
|
216
|
+
if (!existsSync2(CONFIG_PATH)) {
|
|
217
|
+
_configCache = {};
|
|
218
|
+
return _configCache;
|
|
219
|
+
}
|
|
220
|
+
const raw = await readFile(CONFIG_PATH, "utf-8");
|
|
221
|
+
const parsed = parseYamlLines(raw.split("\n"));
|
|
222
|
+
_configCache = {
|
|
223
|
+
statuses: Array.isArray(parsed.statuses) ? parsed.statuses.map(String) : void 0,
|
|
224
|
+
priorities: Array.isArray(parsed.priorities) ? parsed.priorities.map(String) : void 0,
|
|
225
|
+
required_sections: Array.isArray(parsed.required_sections) ? parsed.required_sections.map(String) : void 0
|
|
226
|
+
};
|
|
227
|
+
return _configCache;
|
|
228
|
+
}
|
|
229
|
+
function resolveStatuses(config) {
|
|
230
|
+
return config.statuses && config.statuses.length > 0 ? config.statuses : [...VALID_STATUSES];
|
|
231
|
+
}
|
|
232
|
+
function resolvePriorities(config) {
|
|
233
|
+
return config.priorities && config.priorities.length > 0 ? config.priorities : [...VALID_PRIORITIES];
|
|
234
|
+
}
|
|
235
|
+
function resolveRequiredSections(config) {
|
|
236
|
+
return config.required_sections && config.required_sections.length > 0 ? config.required_sections : [...DEFAULT_REQUIRED_SECTIONS];
|
|
237
|
+
}
|
|
238
|
+
async function getVersion() {
|
|
239
|
+
const { version } = JSON.parse(await readFile(join2(PKG_ROOT, "package.json"), "utf-8"));
|
|
240
|
+
return version;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/core/lock.ts
|
|
244
|
+
import { mkdir, readFile as readFile2, unlink, writeFile } from "fs/promises";
|
|
245
|
+
async function withRspLock(operation, fn) {
|
|
246
|
+
await mkdir(RSP_DIR, { recursive: true });
|
|
247
|
+
try {
|
|
248
|
+
await writeFile(LOCK_PATH, `${process.pid}
|
|
249
|
+
${operation}
|
|
250
|
+
${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err.code === "EEXIST")
|
|
253
|
+
await handleStaleLock(operation);
|
|
254
|
+
else
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return await fn();
|
|
259
|
+
} finally {
|
|
260
|
+
try {
|
|
261
|
+
await unlink(LOCK_PATH);
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function handleStaleLock(operation) {
|
|
267
|
+
try {
|
|
268
|
+
const content = await readFile2(LOCK_PATH, "utf-8");
|
|
269
|
+
const lines = content.trim().split("\n");
|
|
270
|
+
const pid = Number(lines[0]);
|
|
271
|
+
if (pid && Number.isFinite(pid)) {
|
|
272
|
+
try {
|
|
273
|
+
process.kill(pid, 0);
|
|
274
|
+
const op = lines[1] || "unknown";
|
|
275
|
+
const ts = lines[2] || "unknown";
|
|
276
|
+
throw new Error(`RSP locked by pid ${pid} (${op}) since ${ts}`);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (e instanceof Error && e.message.startsWith("RSP locked"))
|
|
279
|
+
throw e;
|
|
280
|
+
await unlink(LOCK_PATH);
|
|
281
|
+
await writeFile(LOCK_PATH, `${process.pid}
|
|
282
|
+
${operation}
|
|
283
|
+
${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
await unlink(LOCK_PATH);
|
|
287
|
+
await writeFile(LOCK_PATH, `${process.pid}
|
|
288
|
+
${operation}
|
|
289
|
+
${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (e instanceof Error && e.message.startsWith("RSP locked"))
|
|
293
|
+
throw e;
|
|
294
|
+
throw new Error(`RSP is locked. Remove ${LOCK_PATH} manually if no other process is running.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/commands/archive-index.ts
|
|
299
|
+
async function buildArchiveIndex({ acquireLock = true } = {}) {
|
|
300
|
+
if (acquireLock)
|
|
301
|
+
return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
|
|
302
|
+
const archiveDir = join3(RSP_DIR, "archive");
|
|
303
|
+
if (!existsSync3(archiveDir)) {
|
|
304
|
+
console.log(` ${pc.dim("No archive directory.")}
|
|
305
|
+
`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const archiveFiles = (await walkMarkdownFiles(archiveDir)).filter((fp) => basename(fp) !== "INDEX.md").sort();
|
|
309
|
+
if (archiveFiles.length === 0) {
|
|
310
|
+
console.log(` ${pc.dim("No archived features.")}
|
|
311
|
+
`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const lines = [];
|
|
315
|
+
lines.push("# Archive Index");
|
|
316
|
+
lines.push("");
|
|
317
|
+
lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
|
|
318
|
+
lines.push("");
|
|
319
|
+
lines.push("| Date | Feature | Summary |");
|
|
320
|
+
lines.push("|------|---------|---------|");
|
|
321
|
+
for (const fp of archiveFiles) {
|
|
322
|
+
const base = basename(fp);
|
|
323
|
+
const archiveRel = relative2(archiveDir, fp);
|
|
324
|
+
const archiveDirPart = dirname2(archiveRel);
|
|
325
|
+
const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
|
|
326
|
+
const date = dateMatch ? dateMatch[1] : "\u2014";
|
|
327
|
+
const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
|
|
328
|
+
const featureName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
|
|
329
|
+
let summary = "";
|
|
330
|
+
try {
|
|
331
|
+
const content = await readFile3(fp, "utf-8");
|
|
332
|
+
const fm = parseFrontmatter(content);
|
|
333
|
+
if (fm?.summary)
|
|
334
|
+
summary = String(fm.summary);
|
|
335
|
+
if (!summary) {
|
|
336
|
+
const specMatch = content.match(/^## Spec[\s\S]*?\n- Summary:[ \t]*([^\n]+)$/m);
|
|
337
|
+
if (specMatch)
|
|
338
|
+
summary = specMatch[1].trim();
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
const summaryCell = summary ? summary.replace(/\|/g, "\\|") : "\u2014";
|
|
343
|
+
lines.push(`| ${date} | ${featureName} | ${summaryCell} |`);
|
|
344
|
+
}
|
|
345
|
+
lines.push("");
|
|
346
|
+
lines.push("_Auto-generated by `rsp archive-index`._");
|
|
347
|
+
const indexPath = join3(archiveDir, "INDEX.md");
|
|
348
|
+
await writeFile2(indexPath, lines.join("\n"));
|
|
349
|
+
console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived feature(s).
|
|
350
|
+
`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/commands/check.ts
|
|
354
|
+
import { existsSync as existsSync4 } from "fs";
|
|
355
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
356
|
+
import { join as join4, relative as relative3 } from "path";
|
|
357
|
+
async function runCheck() {
|
|
358
|
+
const featuresDir = join4(RSP_DIR, "features");
|
|
359
|
+
const config = await loadRspConfig();
|
|
360
|
+
const validStatuses = resolveStatuses(config);
|
|
361
|
+
const validPriorities = resolvePriorities(config);
|
|
362
|
+
const requiredSections = resolveRequiredSections(config);
|
|
363
|
+
let errors = 0;
|
|
364
|
+
let warnings = 0;
|
|
365
|
+
console.log();
|
|
366
|
+
console.log(` ${pc.bold("RSP check")}`);
|
|
367
|
+
console.log();
|
|
368
|
+
if (existsSync4(ACTIVE_DIR)) {
|
|
369
|
+
const entries = await walkFiles(ACTIVE_DIR);
|
|
370
|
+
for (const entryPath of entries) {
|
|
371
|
+
const entryContent = relative3(ACTIVE_DIR, entryPath);
|
|
372
|
+
const markerContent = (await readFile4(entryPath, "utf-8")).trim();
|
|
373
|
+
if (markerContent) {
|
|
374
|
+
console.log(` ${pc.yellow("\u26A0")} active.d/${entryContent} should be an empty marker file; path is the source of truth`);
|
|
375
|
+
warnings++;
|
|
376
|
+
}
|
|
377
|
+
const fp = join4(featuresDir, `${entryContent}.md`);
|
|
378
|
+
if (!existsSync4(fp)) {
|
|
379
|
+
console.log(` ${pc.red("\u2717")} active.d/${entryContent} points to "${entryContent}" but features/${entryContent}.md not found`);
|
|
380
|
+
errors++;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const featureFiles = existsSync4(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
|
|
385
|
+
if (featureFiles.length === 0) {
|
|
386
|
+
console.log(` ${pc.dim("No feature files to check.")}
|
|
387
|
+
`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const allDeps = /* @__PURE__ */ new Map();
|
|
391
|
+
for (const fp of featureFiles) {
|
|
392
|
+
const name = featureNameFromPath(featuresDir, fp);
|
|
393
|
+
let content;
|
|
394
|
+
try {
|
|
395
|
+
content = await readFile4(fp, "utf-8");
|
|
396
|
+
} catch {
|
|
397
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 unable to read file`);
|
|
398
|
+
errors++;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const fm = parseFrontmatter(content);
|
|
402
|
+
if (!fm) {
|
|
403
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 missing YAML frontmatter`);
|
|
404
|
+
errors++;
|
|
405
|
+
}
|
|
406
|
+
for (const section of requiredSections) {
|
|
407
|
+
if (!new RegExp(`^## ${section}$`, "m").test(content)) {
|
|
408
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 missing "## ${section}" section`);
|
|
409
|
+
errors++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (fm) {
|
|
413
|
+
if (!("status" in fm)) {
|
|
414
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 missing frontmatter field: status`);
|
|
415
|
+
errors++;
|
|
416
|
+
} else if (!validStatuses.includes(String(fm.status))) {
|
|
417
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 invalid status "${fm.status}" (valid: ${validStatuses.join(", ")})`);
|
|
418
|
+
errors++;
|
|
419
|
+
}
|
|
420
|
+
if (!("priority" in fm)) {
|
|
421
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 missing frontmatter field: priority`);
|
|
422
|
+
errors++;
|
|
423
|
+
} else if (!validPriorities.includes(String(fm.priority))) {
|
|
424
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 invalid priority "${fm.priority}" (valid: ${validPriorities.join(", ")})`);
|
|
425
|
+
errors++;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const headingMatch = content.match(/^# Feature:[ \t]*([^\n]+)$/m);
|
|
429
|
+
if (headingMatch) {
|
|
430
|
+
const heading = headingMatch[1].trim();
|
|
431
|
+
if (heading !== name) {
|
|
432
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 # Feature: heading "${heading}" differs from feature name`);
|
|
433
|
+
errors++;
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 missing "# Feature:" heading`);
|
|
437
|
+
errors++;
|
|
438
|
+
}
|
|
439
|
+
const deltas = detectDeltaSections(content);
|
|
440
|
+
if (deltas.added || deltas.modified || deltas.removed) {
|
|
441
|
+
const parts = [];
|
|
442
|
+
if (deltas.added)
|
|
443
|
+
parts.push("ADDED");
|
|
444
|
+
if (deltas.modified)
|
|
445
|
+
parts.push("MODIFIED");
|
|
446
|
+
if (deltas.removed)
|
|
447
|
+
parts.push("REMOVED");
|
|
448
|
+
console.log(` ${pc.dim("\u2139")} ${name} \u2014 delta markers found: ${parts.join(", ")}`);
|
|
449
|
+
}
|
|
450
|
+
const scenarios = parseScenarios(content);
|
|
451
|
+
if (scenarios.length > 0) {
|
|
452
|
+
const scenarioIssues = [];
|
|
453
|
+
for (const s of scenarios) {
|
|
454
|
+
const hasGiven = s.steps.some((st) => /^GIVEN/i.test(st));
|
|
455
|
+
const hasWhen = s.steps.some((st) => /^WHEN/i.test(st));
|
|
456
|
+
const hasThen = s.steps.some((st) => /^THEN/i.test(st));
|
|
457
|
+
const missing = [];
|
|
458
|
+
if (!hasGiven)
|
|
459
|
+
missing.push("GIVEN");
|
|
460
|
+
if (!hasWhen)
|
|
461
|
+
missing.push("WHEN");
|
|
462
|
+
if (!hasThen)
|
|
463
|
+
missing.push("THEN");
|
|
464
|
+
if (missing.length > 0)
|
|
465
|
+
scenarioIssues.push(`"${s.heading}" missing ${missing.join("/")}`);
|
|
466
|
+
}
|
|
467
|
+
if (scenarioIssues.length > 0) {
|
|
468
|
+
console.log(` ${pc.yellow("\u26A0")} ${name} \u2014 scenario format issues:`);
|
|
469
|
+
for (const issue of scenarioIssues)
|
|
470
|
+
console.log(` ${pc.dim(issue)}`);
|
|
471
|
+
warnings++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (fm && fm["depends-on"]) {
|
|
475
|
+
const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
|
|
476
|
+
const seen = /* @__PURE__ */ new Set();
|
|
477
|
+
for (const dep of deps) {
|
|
478
|
+
if (dep === name) {
|
|
479
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on itself`);
|
|
480
|
+
errors++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (seen.has(dep)) {
|
|
484
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 duplicate dependency "${dep}"`);
|
|
485
|
+
errors++;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
seen.add(dep);
|
|
489
|
+
allDeps.set(name, [...allDeps.get(name) || [], dep]);
|
|
490
|
+
const depFp = join4(featuresDir, `${dep}.md`);
|
|
491
|
+
if (!existsSync4(depFp)) {
|
|
492
|
+
console.log(` ${pc.red("\u2717")} ${name} \u2014 depends on "${dep}" but features/${dep}.md not found`);
|
|
493
|
+
errors++;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const cycles = detectCycles(allDeps);
|
|
499
|
+
for (const cycle of cycles) {
|
|
500
|
+
console.log(` ${pc.red("\u2717")} circular dependency: ${cycle.join(" \u2192 ")}`);
|
|
501
|
+
errors++;
|
|
502
|
+
}
|
|
503
|
+
console.log();
|
|
504
|
+
if (errors === 0 && warnings === 0)
|
|
505
|
+
console.log(` ${pc.green("\u2713")} All ${featureFiles.length} feature file(s) valid.
|
|
506
|
+
`);
|
|
507
|
+
else
|
|
508
|
+
console.log(` ${pc.red(String(errors))} error(s), ${pc.yellow(String(warnings))} warning(s) in ${featureFiles.length} feature file(s).
|
|
509
|
+
`);
|
|
510
|
+
return errors;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/commands/close-feature.ts
|
|
514
|
+
import { existsSync as existsSync5 } from "fs";
|
|
515
|
+
import { appendFile, mkdir as mkdir2, readdir as readdir2, readFile as readFile5, rename, rmdir, unlink as unlink2, writeFile as writeFile3 } from "fs/promises";
|
|
516
|
+
import { basename as basename2, dirname as dirname3, join as join5 } from "path";
|
|
517
|
+
async function closeFeature(name) {
|
|
518
|
+
return withRspLock("close-feature", async () => {
|
|
519
|
+
if (!name) {
|
|
520
|
+
console.error(` ${pc.red("Usage:")} rsp close <name>`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
|
|
524
|
+
console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
const srcPath = join5(RSP_DIR, "features", `${name}.md`);
|
|
528
|
+
if (!existsSync5(srcPath)) {
|
|
529
|
+
console.error(` ${pc.red("Feature not found:")} .rsp/features/${name}.md`);
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
533
|
+
const dir = dirname3(name);
|
|
534
|
+
const base = basename2(name);
|
|
535
|
+
const archiveSubdir = dir !== "." ? join5(RSP_DIR, "archive", dir) : join5(RSP_DIR, "archive");
|
|
536
|
+
const archiveName = `${date}_${base}.md`;
|
|
537
|
+
await mkdir2(archiveSubdir, { recursive: true });
|
|
538
|
+
await rename(srcPath, join5(archiveSubdir, archiveName));
|
|
539
|
+
let activeCleared = false;
|
|
540
|
+
const activeEntry = join5(ACTIVE_DIR, name);
|
|
541
|
+
if (existsSync5(activeEntry)) {
|
|
542
|
+
await unlink2(activeEntry);
|
|
543
|
+
let parent = dirname3(activeEntry);
|
|
544
|
+
while (parent !== ACTIVE_DIR) {
|
|
545
|
+
try {
|
|
546
|
+
const remaining = await readdir2(parent);
|
|
547
|
+
if (remaining.length === 0)
|
|
548
|
+
await rmdir(parent);
|
|
549
|
+
else
|
|
550
|
+
break;
|
|
551
|
+
} catch {
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
parent = dirname3(parent);
|
|
555
|
+
}
|
|
556
|
+
activeCleared = true;
|
|
557
|
+
}
|
|
558
|
+
const clearedMsg = activeCleared ? ` ${pc.dim("active.d cleared")}
|
|
559
|
+
` : "";
|
|
560
|
+
console.log(` ${pc.green("Archived:")} ${archiveName}
|
|
561
|
+
${clearedMsg}`);
|
|
562
|
+
await buildArchiveIndex({ acquireLock: false });
|
|
563
|
+
await runCheckIfHasDependents(name);
|
|
564
|
+
await updateSpecIndex(name, date);
|
|
565
|
+
if (existsSync5(".git")) {
|
|
566
|
+
const archiveRelPath = dir !== "." ? join5(dir, archiveName) : archiveName;
|
|
567
|
+
console.log(` ${pc.cyan("Git workflow:")}
|
|
568
|
+
`);
|
|
569
|
+
console.log(` git add .rsp/archive/${archiveRelPath}`);
|
|
570
|
+
console.log(` git commit -m "feat: complete ${name}"`);
|
|
571
|
+
console.log();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async function runCheckIfHasDependents(name) {
|
|
576
|
+
try {
|
|
577
|
+
const featuresDir = join5(RSP_DIR, "features");
|
|
578
|
+
const allFeatureFiles = await walkMarkdownFiles(featuresDir);
|
|
579
|
+
let hasDependents = false;
|
|
580
|
+
for (const fp of allFeatureFiles) {
|
|
581
|
+
const content = await readFile5(fp, "utf-8");
|
|
582
|
+
const fm = parseFrontmatter(content);
|
|
583
|
+
if (!fm || !fm["depends-on"])
|
|
584
|
+
continue;
|
|
585
|
+
const deps = Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]];
|
|
586
|
+
if (deps.includes(name)) {
|
|
587
|
+
hasDependents = true;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (hasDependents)
|
|
592
|
+
await runCheck();
|
|
593
|
+
} catch {
|
|
594
|
+
console.warn(` ${pc.dim("\u26A0 could not verify dependents for")} "${name}"`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function updateSpecIndex(name, date) {
|
|
598
|
+
const specIndexPath = join5(RSP_DIR, "spec", "INDEX.md");
|
|
599
|
+
const archiveDir = join5(RSP_DIR, "archive");
|
|
600
|
+
const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
601
|
+
const lineRe = new RegExp(`^\\| \\d{4}-\\d{2}-\\d{2} \\| ${escaped} \\|`, "m");
|
|
602
|
+
const archiveFile = await (async () => {
|
|
603
|
+
try {
|
|
604
|
+
const entries = await walkMarkdownFiles(archiveDir);
|
|
605
|
+
return entries.find((fp) => basename2(fp).startsWith(`${date}_`) && basename2(fp).endsWith(".md"));
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
})();
|
|
610
|
+
let summary = "\u2014";
|
|
611
|
+
if (archiveFile) {
|
|
612
|
+
try {
|
|
613
|
+
const content = await readFile5(join5(archiveDir, archiveFile), "utf-8");
|
|
614
|
+
const m = content.match(/^## Spec[\s\S]*?\n- Summary:[ \t]*([^\n]+)$/m);
|
|
615
|
+
if (m)
|
|
616
|
+
summary = m[1].trim();
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const line = `| ${date} | ${name} | ${summary} |
|
|
621
|
+
`;
|
|
622
|
+
try {
|
|
623
|
+
let existing = "";
|
|
624
|
+
if (existsSync5(specIndexPath))
|
|
625
|
+
existing = await readFile5(specIndexPath, "utf-8");
|
|
626
|
+
if (lineRe.test(existing))
|
|
627
|
+
return;
|
|
628
|
+
if (!existing.includes("| Date | Feature | Summary |")) {
|
|
629
|
+
await writeFile3(specIndexPath, `# Spec Index
|
|
630
|
+
|
|
631
|
+
| Date | Feature | Summary |
|
|
632
|
+
|------|---------|---------|
|
|
633
|
+
${line}
|
|
634
|
+
`);
|
|
635
|
+
} else {
|
|
636
|
+
await appendFile(specIndexPath, line);
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/commands/deps.ts
|
|
643
|
+
import { existsSync as existsSync6 } from "fs";
|
|
644
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
645
|
+
import { join as join6 } from "path";
|
|
646
|
+
async function showDependencies(mermaid = false) {
|
|
647
|
+
const featuresDir = join6(RSP_DIR, "features");
|
|
648
|
+
const featureFiles = existsSync6(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
|
|
649
|
+
console.log();
|
|
650
|
+
console.log(` ${pc.bold("RSP dependencies")}`);
|
|
651
|
+
console.log();
|
|
652
|
+
if (featureFiles.length === 0) {
|
|
653
|
+
console.log(` ${pc.dim("No features found.")}
|
|
654
|
+
`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const records = [];
|
|
658
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const fp of featureFiles) {
|
|
660
|
+
const name = featureNameFromPath(featuresDir, fp);
|
|
661
|
+
let content;
|
|
662
|
+
try {
|
|
663
|
+
content = await readFile6(fp, "utf-8");
|
|
664
|
+
} catch {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const fm = parseFrontmatter(content);
|
|
668
|
+
const depsRaw = fm?.["depends-on"] ? Array.isArray(fm["depends-on"]) ? fm["depends-on"] : [fm["depends-on"]] : [];
|
|
669
|
+
const deps = depsRaw.filter(Boolean);
|
|
670
|
+
records.push({ name, deps });
|
|
671
|
+
for (const dep of deps) {
|
|
672
|
+
const list = dependents.get(dep) || [];
|
|
673
|
+
list.push(name);
|
|
674
|
+
dependents.set(dep, list);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (mermaid) {
|
|
678
|
+
console.log("```mermaid");
|
|
679
|
+
console.log("graph LR");
|
|
680
|
+
const safeId = (s) => s.replace(/\//g, "__slash__").replace(/\W/g, "_");
|
|
681
|
+
for (const { name } of records) {
|
|
682
|
+
const id = safeId(name);
|
|
683
|
+
const label = name.replace(/"/g, "#quot;");
|
|
684
|
+
console.log(` ${id}["${label}"]`);
|
|
685
|
+
}
|
|
686
|
+
for (const { name, deps } of records) {
|
|
687
|
+
const fromId = safeId(name);
|
|
688
|
+
for (const dep of deps) {
|
|
689
|
+
const toId = safeId(dep);
|
|
690
|
+
console.log(` ${fromId} --> ${toId}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
console.log("```");
|
|
694
|
+
console.log();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
console.log(` ${"Feature".padEnd(28)} ${"Depends On".padEnd(28)} Dependents`);
|
|
698
|
+
console.log(` ${"\u2500".repeat(28)} ${"\u2500".repeat(28)} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
699
|
+
for (const { name, deps } of records.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
700
|
+
const depText = deps.length > 0 ? deps.join(", ") : "\u2014";
|
|
701
|
+
const dependentText = (dependents.get(name) || []).length > 0 ? pc.dim((dependents.get(name) || []).join(", ")) : "\u2014";
|
|
702
|
+
console.log(` ${name.padEnd(28)} ${depText.padEnd(28)} ${dependentText}`);
|
|
703
|
+
}
|
|
704
|
+
console.log();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/commands/init.ts
|
|
708
|
+
import { existsSync as existsSync7 } from "fs";
|
|
709
|
+
import { cp, mkdir as mkdir3, readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
|
|
710
|
+
import { basename as basename3, join as join7 } from "path";
|
|
711
|
+
async function detectProjectName() {
|
|
712
|
+
try {
|
|
713
|
+
if (existsSync7("package.json")) {
|
|
714
|
+
const raw = await readFile7("package.json", "utf-8");
|
|
715
|
+
const pkg = JSON.parse(raw);
|
|
716
|
+
if (pkg.name && pkg.name !== "")
|
|
717
|
+
return pkg.name;
|
|
718
|
+
}
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
return basename3(process.cwd());
|
|
722
|
+
}
|
|
723
|
+
var AGENTS_BODY = `## Collaboration Mode
|
|
724
|
+
This project uses **RSP** (Rules, Spec, Plan) \u2014 see \`.rsp/rules/rsp-rules.md\` for the full workflow definition.
|
|
725
|
+
|
|
726
|
+
Key conventions:
|
|
727
|
+
- \`.rsp/rules/\` \u2014 technical constraints and coding conventions
|
|
728
|
+
- \`.rsp/features/<name>.md\` \u2014 feature definitions (spec + plan + tests)
|
|
729
|
+
- \`.rsp/active.d/\` \u2014 currently active features (path = feature name)
|
|
730
|
+
- \`.rsp/archive/\` \u2014 completed features
|
|
731
|
+
|
|
732
|
+
## Workflow
|
|
733
|
+
- Run \`rsp status\` to see current project state
|
|
734
|
+
- Run \`rsp new <name>\` to start a new feature
|
|
735
|
+
- Run \`rsp close <name>\` to archive a completed feature`;
|
|
736
|
+
var CONFIG_TEMPLATE = `# RSP project configuration
|
|
737
|
+
# Uncomment any section below to override defaults.
|
|
738
|
+
#
|
|
739
|
+
# Built-in defaults:
|
|
740
|
+
# statuses: draft, ready, in-progress, blocked, done
|
|
741
|
+
# priorities: low, medium, high, critical
|
|
742
|
+
# required_sections: Spec, Plan
|
|
743
|
+
#
|
|
744
|
+
# statuses:
|
|
745
|
+
# - draft
|
|
746
|
+
# - ready
|
|
747
|
+
# - in-progress
|
|
748
|
+
# - blocked
|
|
749
|
+
# - done
|
|
750
|
+
#
|
|
751
|
+
# priorities:
|
|
752
|
+
# - low
|
|
753
|
+
# - medium
|
|
754
|
+
# - high
|
|
755
|
+
# - critical
|
|
756
|
+
#
|
|
757
|
+
# required_sections:
|
|
758
|
+
# - Spec
|
|
759
|
+
# - Plan
|
|
760
|
+
`;
|
|
761
|
+
async function initProject() {
|
|
762
|
+
const isNew = !existsSync7(RSP_DIR);
|
|
763
|
+
return withRspLock("init", async () => {
|
|
764
|
+
const dirs = [
|
|
765
|
+
join7(RSP_DIR, "rules"),
|
|
766
|
+
join7(RSP_DIR, "spec"),
|
|
767
|
+
join7(RSP_DIR, "features"),
|
|
768
|
+
join7(RSP_DIR, "archive")
|
|
769
|
+
];
|
|
770
|
+
let created = false;
|
|
771
|
+
for (const d of dirs)
|
|
772
|
+
await mkdir3(d, { recursive: true });
|
|
773
|
+
const rulesDest = join7(RSP_DIR, "rules", "rsp-rules.md");
|
|
774
|
+
if (!existsSync7(rulesDest)) {
|
|
775
|
+
await cp(join7(PKG_ROOT, "rules", "rsp-rules.md"), rulesDest);
|
|
776
|
+
created = true;
|
|
777
|
+
}
|
|
778
|
+
const configPath = join7(RSP_DIR, "config.yaml");
|
|
779
|
+
if (!existsSync7(configPath)) {
|
|
780
|
+
await writeFile4(configPath, CONFIG_TEMPLATE);
|
|
781
|
+
created = true;
|
|
782
|
+
}
|
|
783
|
+
const specIndexPath = join7(RSP_DIR, "spec", "INDEX.md");
|
|
784
|
+
if (!existsSync7(specIndexPath)) {
|
|
785
|
+
await writeFile4(specIndexPath, "# Spec Index\n\n_Extracted from archived features._\n");
|
|
786
|
+
created = true;
|
|
787
|
+
}
|
|
788
|
+
const gitignorePath = join7(RSP_DIR, ".gitignore");
|
|
789
|
+
if (!existsSync7(gitignorePath)) {
|
|
790
|
+
await writeFile4(gitignorePath, "# Transient files that should not be committed\n.lock\n");
|
|
791
|
+
created = true;
|
|
792
|
+
}
|
|
793
|
+
const agentsPath = "AGENTS.md";
|
|
794
|
+
const projectName = await detectProjectName();
|
|
795
|
+
if (existsSync7(agentsPath)) {
|
|
796
|
+
const existing = await readFile7(agentsPath, "utf-8");
|
|
797
|
+
const rspRe = /\bRSP\b/;
|
|
798
|
+
if (!rspRe.test(existing)) {
|
|
799
|
+
const h2Match = existing.match(/^## /m);
|
|
800
|
+
if (h2Match) {
|
|
801
|
+
const before = existing.slice(0, h2Match.index);
|
|
802
|
+
const after = existing.slice(h2Match.index);
|
|
803
|
+
await writeFile4(agentsPath, `${before}${AGENTS_BODY}
|
|
804
|
+
|
|
805
|
+
---
|
|
806
|
+
|
|
807
|
+
${after}`);
|
|
808
|
+
} else {
|
|
809
|
+
await writeFile4(agentsPath, `${existing}
|
|
810
|
+
|
|
811
|
+
${AGENTS_BODY}
|
|
812
|
+
`);
|
|
813
|
+
}
|
|
814
|
+
created = true;
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
const title = projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
|
|
818
|
+
await writeFile4(agentsPath, `# ${title}
|
|
819
|
+
|
|
820
|
+
${AGENTS_BODY}
|
|
821
|
+
`);
|
|
822
|
+
created = true;
|
|
823
|
+
}
|
|
824
|
+
if (isNew) {
|
|
825
|
+
console.log(`
|
|
826
|
+
${pc.green("RSP scaffolded.")}
|
|
827
|
+
`);
|
|
828
|
+
console.log(` Created: .rsp/rules/
|
|
829
|
+
.rsp/spec/
|
|
830
|
+
.rsp/features/
|
|
831
|
+
.rsp/archive/
|
|
832
|
+
.rsp/.gitignore
|
|
833
|
+
.rsp/config.yaml
|
|
834
|
+
AGENTS.md
|
|
835
|
+
`);
|
|
836
|
+
console.log(` ${pc.cyan("Next:")} rsp new <name>
|
|
837
|
+
${pc.dim("Also:")} rsp status rsp deps
|
|
838
|
+
`);
|
|
839
|
+
} else if (created) {
|
|
840
|
+
console.log(`
|
|
841
|
+
${pc.green("RSP initialized.")}
|
|
842
|
+
`);
|
|
843
|
+
} else {
|
|
844
|
+
console.log(`
|
|
845
|
+
${pc.yellow("RSP already initialized \u2014 nothing changed.")}
|
|
846
|
+
`);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/commands/new-feature.ts
|
|
852
|
+
import { existsSync as existsSync8 } from "fs";
|
|
853
|
+
import { mkdir as mkdir4, writeFile as writeFile5 } from "fs/promises";
|
|
854
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
855
|
+
async function newFeature(name, summary = "") {
|
|
856
|
+
return withRspLock("new-feature", async () => {
|
|
857
|
+
if (!name) {
|
|
858
|
+
console.error(` ${pc.red("Usage:")} rsp new <name> [summary]`);
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
if (/^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/.test(name) === false) {
|
|
862
|
+
console.error(` ${pc.red("Error:")} feature name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
const featurePath = join8(RSP_DIR, "features", `${name}.md`);
|
|
866
|
+
await mkdir4(dirname4(featurePath), { recursive: true });
|
|
867
|
+
const existed = existsSync8(featurePath);
|
|
868
|
+
if (!existed) {
|
|
869
|
+
const content = generateFeatureContent(name, summary);
|
|
870
|
+
await writeFile5(featurePath, content);
|
|
871
|
+
}
|
|
872
|
+
await mkdir4(ACTIVE_DIR, { recursive: true });
|
|
873
|
+
const activeEntry = join8(ACTIVE_DIR, name);
|
|
874
|
+
await mkdir4(dirname4(activeEntry), { recursive: true });
|
|
875
|
+
await writeFile5(activeEntry, "");
|
|
876
|
+
if (!existed) {
|
|
877
|
+
const archiveDir = join8(RSP_DIR, "archive");
|
|
878
|
+
const archiveIndex = join8(archiveDir, "INDEX.md");
|
|
879
|
+
if (existsSync8(archiveIndex))
|
|
880
|
+
await buildArchiveIndex({ acquireLock: false });
|
|
881
|
+
}
|
|
882
|
+
const label = existed ? "Using" : pc.green("Created");
|
|
883
|
+
console.log(` ${label}: ${featurePath}
|
|
884
|
+
${pc.dim("active.d")} \u2192 ${name}
|
|
885
|
+
`);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/commands/status.ts
|
|
890
|
+
import { existsSync as existsSync9 } from "fs";
|
|
891
|
+
import { readFile as readFile8, stat as stat2 } from "fs/promises";
|
|
892
|
+
import { join as join9, relative as relative4 } from "path";
|
|
893
|
+
var COL_FEATURE = 30;
|
|
894
|
+
var COL_STATUS = 16;
|
|
895
|
+
var COL_AGE = 7;
|
|
896
|
+
var COL_PROGRESS = 10;
|
|
897
|
+
async function showStatus() {
|
|
898
|
+
const featuresDir = join9(RSP_DIR, "features");
|
|
899
|
+
const activeSet = /* @__PURE__ */ new Set();
|
|
900
|
+
if (existsSync9(ACTIVE_DIR)) {
|
|
901
|
+
try {
|
|
902
|
+
const entries = await walkFiles(ACTIVE_DIR);
|
|
903
|
+
for (const entryPath of entries) {
|
|
904
|
+
const name = relative4(ACTIVE_DIR, entryPath);
|
|
905
|
+
activeSet.add(name);
|
|
906
|
+
}
|
|
907
|
+
} catch {
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const featureFiles = existsSync9(featuresDir) ? await walkMarkdownFiles(featuresDir) : [];
|
|
911
|
+
const featureMap = {};
|
|
912
|
+
for (const fp of featureFiles) {
|
|
913
|
+
const name = featureNameFromPath(featuresDir, fp);
|
|
914
|
+
featureMap[name] = fp;
|
|
915
|
+
}
|
|
916
|
+
console.log();
|
|
917
|
+
console.log(` ${pc.bold("RSP status")}`);
|
|
918
|
+
console.log();
|
|
919
|
+
if (activeSet.size > 0) {
|
|
920
|
+
console.log(` ${pc.cyan("Active:")} ${[...activeSet].join(", ")}`);
|
|
921
|
+
console.log();
|
|
922
|
+
} else {
|
|
923
|
+
console.log(` ${pc.dim("No active feature.")} Run: rsp new <name>`);
|
|
924
|
+
console.log();
|
|
925
|
+
}
|
|
926
|
+
const allNames = [.../* @__PURE__ */ new Set([...Object.keys(featureMap), ...activeSet])].sort();
|
|
927
|
+
if (allNames.length === 0) {
|
|
928
|
+
console.log(` ${pc.dim("No features found.")} Run: rsp new <name>
|
|
929
|
+
`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const records = [];
|
|
933
|
+
for (const name of allNames) {
|
|
934
|
+
const fp = featureMap[name];
|
|
935
|
+
let status = "\u2014";
|
|
936
|
+
let progress = "\u2014";
|
|
937
|
+
let depends = "\u2014";
|
|
938
|
+
let age = "\u2014";
|
|
939
|
+
const isActive = activeSet.has(name);
|
|
940
|
+
let isBlocked = false;
|
|
941
|
+
if (fp) {
|
|
942
|
+
try {
|
|
943
|
+
const content = await readFile8(fp, "utf-8");
|
|
944
|
+
const fm = parseFrontmatter(content);
|
|
945
|
+
status = fm?.status || "\u2014";
|
|
946
|
+
isBlocked = status === "blocked";
|
|
947
|
+
if (fm?.["depends-on"])
|
|
948
|
+
depends = Array.isArray(fm["depends-on"]) ? fm["depends-on"].join(", ") : String(fm["depends-on"]);
|
|
949
|
+
const cb = countCheckboxes(content);
|
|
950
|
+
progress = `${cb.done}/${cb.total}`;
|
|
951
|
+
const s = await stat2(fp);
|
|
952
|
+
const created = s.birthtime || s.mtime;
|
|
953
|
+
const diffMs = Date.now() - created.getTime();
|
|
954
|
+
age = String(Math.floor(diffMs / (1e3 * 60 * 60 * 24)));
|
|
955
|
+
} catch {
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
status = "(missing)";
|
|
959
|
+
}
|
|
960
|
+
records.push({ name, status, progress, age, depends, isActive, isBlocked });
|
|
961
|
+
}
|
|
962
|
+
const pad = (s, w) => s.padEnd(w);
|
|
963
|
+
console.log(` ${pad("Feature", COL_FEATURE)} ${pad("Status", COL_STATUS)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)} Depends On`);
|
|
964
|
+
console.log(` ${"\u2500".repeat(COL_FEATURE)} ${"\u2500".repeat(COL_STATUS)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)} ${"\u2500".repeat(30)}`);
|
|
965
|
+
let blockedCount = 0;
|
|
966
|
+
for (const r of records) {
|
|
967
|
+
const marker = r.isActive ? pc.cyan("*") : " ";
|
|
968
|
+
const namePadded = r.name.padEnd(COL_FEATURE - 1);
|
|
969
|
+
const statusPadded = String(r.status).padEnd(COL_STATUS);
|
|
970
|
+
const agePadded = r.age.padEnd(COL_AGE);
|
|
971
|
+
const progressPadded = r.progress.padEnd(COL_PROGRESS);
|
|
972
|
+
const nameDisplay = r.isBlocked ? pc.yellow(namePadded) : namePadded;
|
|
973
|
+
const statusDisplay = r.isBlocked ? pc.yellow(statusPadded) : statusPadded;
|
|
974
|
+
const ageDisplay = r.isActive ? pc.cyan(agePadded) : agePadded;
|
|
975
|
+
console.log(` ${marker}${nameDisplay} ${statusDisplay} ${ageDisplay} ${progressPadded} ${r.depends}`);
|
|
976
|
+
if (r.isBlocked)
|
|
977
|
+
blockedCount++;
|
|
978
|
+
}
|
|
979
|
+
console.log();
|
|
980
|
+
const total = records.length;
|
|
981
|
+
const doneCount = records.filter((r) => r.status === "done").length;
|
|
982
|
+
console.log(` ${pc.bold("Summary:")} ${total} feature(s), ${activeSet.size} active, ${doneCount} done, ${pc.yellow(String(blockedCount))} blocked`);
|
|
983
|
+
console.log();
|
|
984
|
+
await showArchiveTrend();
|
|
985
|
+
if (blockedCount > 0) {
|
|
986
|
+
const blocked = records.filter((r) => r.isBlocked);
|
|
987
|
+
console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
|
|
988
|
+
console.log();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async function showArchiveTrend() {
|
|
992
|
+
const archiveDir = join9(RSP_DIR, "archive");
|
|
993
|
+
const indexPath = join9(archiveDir, "INDEX.md");
|
|
994
|
+
if (!existsSync9(indexPath))
|
|
995
|
+
return;
|
|
996
|
+
try {
|
|
997
|
+
const content = await readFile8(indexPath, "utf-8");
|
|
998
|
+
const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
|
|
999
|
+
const counts = {};
|
|
1000
|
+
let match;
|
|
1001
|
+
while ((match = dateRe.exec(content)) !== null) {
|
|
1002
|
+
const month = match[1];
|
|
1003
|
+
counts[month] = (counts[month] || 0) + 1;
|
|
1004
|
+
}
|
|
1005
|
+
const months = Object.keys(counts).sort();
|
|
1006
|
+
if (months.length === 0)
|
|
1007
|
+
return;
|
|
1008
|
+
console.log(` ${pc.bold("Archive trend:")}`);
|
|
1009
|
+
const bar = months.map((m) => {
|
|
1010
|
+
const n = counts[m];
|
|
1011
|
+
return pc.dim(`${m} ${"\u2588".repeat(Math.min(n, 20))} ${n}`);
|
|
1012
|
+
}).join("\n ");
|
|
1013
|
+
console.log(` ${bar}`);
|
|
1014
|
+
console.log();
|
|
1015
|
+
} catch {
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/cli.ts
|
|
1020
|
+
var initCommand = defineCommand({
|
|
1021
|
+
meta: {
|
|
1022
|
+
name: "init",
|
|
1023
|
+
description: "Scaffold .rsp/ + AGENTS.md in current project"
|
|
1024
|
+
},
|
|
1025
|
+
async run() {
|
|
1026
|
+
await initProject();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
var newFeatureCommand = defineCommand({
|
|
1030
|
+
meta: {
|
|
1031
|
+
name: "new",
|
|
1032
|
+
description: "Create .rsp/features/<name>.md [summary]"
|
|
1033
|
+
},
|
|
1034
|
+
args: {
|
|
1035
|
+
name: {
|
|
1036
|
+
type: "positional",
|
|
1037
|
+
description: "Feature name",
|
|
1038
|
+
required: true
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
async run({ args }) {
|
|
1042
|
+
const summary = Array.isArray(args._) && args._.length > 1 ? args._.slice(1).join(" ") : "";
|
|
1043
|
+
await newFeature(args.name, summary);
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
var closeFeatureCommand = defineCommand({
|
|
1047
|
+
meta: {
|
|
1048
|
+
name: "close",
|
|
1049
|
+
description: "Archive to .rsp/archive/"
|
|
1050
|
+
},
|
|
1051
|
+
args: {
|
|
1052
|
+
name: {
|
|
1053
|
+
type: "positional",
|
|
1054
|
+
description: "Feature name",
|
|
1055
|
+
required: true
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
async run({ args }) {
|
|
1059
|
+
await closeFeature(args.name);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
var statusCommand = defineCommand({
|
|
1063
|
+
meta: {
|
|
1064
|
+
name: "status",
|
|
1065
|
+
description: "Show project status summary"
|
|
1066
|
+
},
|
|
1067
|
+
async run() {
|
|
1068
|
+
await showStatus();
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
var checkCommand = defineCommand({
|
|
1072
|
+
meta: {
|
|
1073
|
+
name: "check",
|
|
1074
|
+
description: "Validate features (frontmatter, sections, deps, deltas, scenarios)"
|
|
1075
|
+
},
|
|
1076
|
+
async run() {
|
|
1077
|
+
const errors = await runCheck();
|
|
1078
|
+
if (errors > 0)
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
var depsCommand = defineCommand({
|
|
1083
|
+
meta: {
|
|
1084
|
+
name: "deps",
|
|
1085
|
+
description: "Show dependency summary (--mermaid for graph)"
|
|
1086
|
+
},
|
|
1087
|
+
args: {
|
|
1088
|
+
mermaid: {
|
|
1089
|
+
type: "boolean",
|
|
1090
|
+
description: "Output as Mermaid.js graph",
|
|
1091
|
+
default: false
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
async run({ args }) {
|
|
1095
|
+
await showDependencies(Boolean(args.mermaid));
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
var archiveIndexCommand = defineCommand({
|
|
1099
|
+
meta: {
|
|
1100
|
+
name: "archive-index",
|
|
1101
|
+
description: "Regenerate archive INDEX.md"
|
|
1102
|
+
},
|
|
1103
|
+
async run() {
|
|
1104
|
+
await buildArchiveIndex();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
async function runCli(rawArgs = process.argv.slice(2)) {
|
|
1108
|
+
const version = await getVersion();
|
|
1109
|
+
const main = defineCommand({
|
|
1110
|
+
meta: {
|
|
1111
|
+
name: "rsp",
|
|
1112
|
+
version,
|
|
1113
|
+
description: "RSP (Rules, Spec, Plan) workflow for AI-assisted development"
|
|
1114
|
+
},
|
|
1115
|
+
subCommands: {
|
|
1116
|
+
"init": initCommand,
|
|
1117
|
+
"new": newFeatureCommand,
|
|
1118
|
+
"close": closeFeatureCommand,
|
|
1119
|
+
"status": statusCommand,
|
|
1120
|
+
"check": checkCommand,
|
|
1121
|
+
"deps": depsCommand,
|
|
1122
|
+
"archive-index": archiveIndexCommand
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
await runMain(main, { rawArgs });
|
|
1126
|
+
}
|
|
1127
|
+
var isMain = process.argv[1] && fileURLToPath2(import.meta.url) === resolve(process.argv[1]);
|
|
1128
|
+
if (isMain) {
|
|
1129
|
+
runCli().catch((err) => {
|
|
1130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1131
|
+
console.error(` Error: ${message}`);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
export {
|
|
1136
|
+
runCli
|
|
1137
|
+
};
|