@oevery/rsp 1.2.0 → 2.0.2
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/README.md +99 -43
- package/README.zh-CN.md +99 -49
- package/dist/cli.mjs +1341 -926
- package/package.json +8 -5
- package/rules/rsp-rules.md +80 -82
- package/skills/rsp/SKILL.md +138 -0
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,
|
|
11
|
-
import {
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
78
|
-
|
|
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
|
|
111
|
-
const rel = relative(
|
|
115
|
+
function changeNameFromPath(changesDir, filePath) {
|
|
116
|
+
const rel = normalizeLogicalPath(relative(changesDir, filePath));
|
|
112
117
|
return rel.replace(/\.md$/, "");
|
|
113
118
|
}
|
|
114
|
-
function
|
|
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
|
-
|
|
118
|
-
priority: medium
|
|
119
|
-
tags:
|
|
126
|
+
kind: ops
|
|
120
127
|
---
|
|
121
128
|
|
|
122
|
-
#
|
|
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
|
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
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
|
-
-
|
|
165
|
+
- Keep the setup lightweight and avoid duplicating durable project facts
|
|
131
166
|
|
|
132
|
-
##
|
|
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
|
-
##
|
|
138
|
-
-
|
|
139
|
-
- [ ] Run
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
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
|
-
|
|
150
|
-
priority: medium
|
|
151
|
-
tags:
|
|
188
|
+
kind: "${frontmatterKind}"
|
|
152
189
|
---
|
|
153
190
|
|
|
154
|
-
#
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
203
|
+
${template.specSection}
|
|
204
|
+
|
|
205
|
+
### Acceptance
|
|
206
|
+
${template.acceptanceSection}
|
|
162
207
|
|
|
163
|
-
##
|
|
164
|
-
-
|
|
165
|
-
-
|
|
208
|
+
## Design
|
|
209
|
+
- Approach:
|
|
210
|
+
- ${template.approach}
|
|
211
|
+
- Affected areas:
|
|
212
|
+
- ${template.affectedAreas}
|
|
213
|
+
- Constraints:
|
|
214
|
+
- ${template.constraints}
|
|
166
215
|
|
|
167
|
-
##
|
|
168
|
-
- [ ]
|
|
216
|
+
## Tasks
|
|
217
|
+
- [ ] ${template.task}
|
|
169
218
|
|
|
170
|
-
##
|
|
171
|
-
-
|
|
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
|
|
183
|
-
2. .rsp/
|
|
184
|
-
3. .rsp/
|
|
185
|
-
4. .rsp/
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
##
|
|
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:
|
|
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
|
-
##
|
|
276
|
-
- <
|
|
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
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
499
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
349
500
|
var PKG_ROOT = join2(__dirname, "..");
|
|
350
501
|
var RSP_DIR = ".rsp";
|
|
351
|
-
var
|
|
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
|
|
356
|
-
var
|
|
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
|
|
367
|
-
const parsed =
|
|
517
|
+
const raw = await readFile2(CONFIG_PATH, "utf-8");
|
|
518
|
+
const parsed = parseYamlText(raw);
|
|
368
519
|
_configCache = {
|
|
369
|
-
|
|
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
|
|
376
|
-
return config.
|
|
377
|
-
}
|
|
378
|
-
function resolvePriorities(config) {
|
|
379
|
-
return config.priorities && config.priorities.length > 0 ? config.priorities : [...VALID_PRIORITIES];
|
|
524
|
+
function resolveKinds(config) {
|
|
525
|
+
return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
|
|
380
526
|
}
|
|
381
|
-
function resolveRequiredSections(
|
|
382
|
-
return
|
|
527
|
+
function resolveRequiredSections(_config) {
|
|
528
|
+
return [...DEFAULT_REQUIRED_SECTIONS];
|
|
383
529
|
}
|
|
384
530
|
async function getVersion() {
|
|
385
|
-
const { version } = JSON.parse(await
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
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 || !
|
|
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
|
-
|
|
464
|
-
const
|
|
465
|
-
if (
|
|
466
|
-
console.error(` ${pc.red("Error:")}
|
|
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
|
-
|
|
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,
|
|
489
|
-
import {
|
|
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";
|
|
@@ -501,28 +632,32 @@ async function buildSpecsIndex({ acquireLock = true } = {}) {
|
|
|
501
632
|
`);
|
|
502
633
|
return;
|
|
503
634
|
}
|
|
504
|
-
const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) =>
|
|
635
|
+
const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) => {
|
|
636
|
+
const name = basename2(fp);
|
|
637
|
+
return name !== "INDEX.md" && name !== "design.md";
|
|
638
|
+
}).sort();
|
|
505
639
|
const lines = [];
|
|
506
640
|
lines.push("# Specs Index");
|
|
507
641
|
lines.push("");
|
|
508
|
-
lines.push("
|
|
642
|
+
lines.push("_Additional project-level specs beyond `design.md`._");
|
|
509
643
|
lines.push("");
|
|
510
644
|
if (specFiles.length === 0) {
|
|
511
|
-
lines.push("_No project-level specs yet._");
|
|
645
|
+
lines.push("_No additional project-level specs yet._");
|
|
512
646
|
} else {
|
|
513
647
|
lines.push("| File | Title | Summary |");
|
|
514
648
|
lines.push("|------|-------|---------|");
|
|
515
649
|
for (const fp of specFiles) {
|
|
516
|
-
const rel = relative2(specsDir, fp);
|
|
650
|
+
const rel = normalizeLogicalPath(relative2(specsDir, fp));
|
|
517
651
|
const content = await readFile4(fp, "utf-8");
|
|
518
652
|
const fm = parseFrontmatter(content);
|
|
519
|
-
const
|
|
653
|
+
const fmTitle = fm && typeof fm.title === "string" ? fm.title : "";
|
|
654
|
+
const title = fmTitle || extractTitle(content) || rel.replace(/\.md$/, "");
|
|
520
655
|
const summary = extractSummary(content, fm?.summary);
|
|
521
656
|
lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
|
|
522
657
|
}
|
|
523
658
|
}
|
|
524
659
|
lines.push("");
|
|
525
|
-
lines.push("_Auto-generated by `rsp
|
|
660
|
+
lines.push("_Auto-generated by `rsp update`._");
|
|
526
661
|
await writeFile3(join4(specsDir, "INDEX.md"), lines.join("\n"));
|
|
527
662
|
console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
|
|
528
663
|
`);
|
|
@@ -561,40 +696,20 @@ function escapeCell(value) {
|
|
|
561
696
|
}
|
|
562
697
|
|
|
563
698
|
// 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
699
|
async function addSpec(name, projectName = "project") {
|
|
578
|
-
if (!name || !
|
|
700
|
+
if (!name || !isValidChangeName(name)) {
|
|
579
701
|
console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
|
|
580
702
|
process.exit(1);
|
|
581
703
|
}
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
if (
|
|
585
|
-
console.error(` ${pc.red("Error:")}
|
|
586
|
-
console.error(` ${pc.dim("Run: rsp init")}`);
|
|
704
|
+
guardRspInitialized();
|
|
705
|
+
const path = join5(RSP_DIR, "specs", `${name}.md`);
|
|
706
|
+
if (existsSync5(path)) {
|
|
707
|
+
console.error(` ${pc.red("Error:")} spec file already exists: .rsp/specs/${name}.md`);
|
|
587
708
|
process.exit(1);
|
|
588
709
|
}
|
|
589
710
|
return withRspLock("add-spec", async () => {
|
|
590
|
-
|
|
591
|
-
const
|
|
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;
|
|
711
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
712
|
+
const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
|
|
598
713
|
const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
|
|
599
714
|
await writeFile4(path, content);
|
|
600
715
|
await buildSpecsIndex({ acquireLock: false });
|
|
@@ -603,148 +718,359 @@ async function addSpec(name, projectName = "project") {
|
|
|
603
718
|
});
|
|
604
719
|
}
|
|
605
720
|
|
|
721
|
+
// src/commands/archive.ts
|
|
722
|
+
import { existsSync as existsSync7 } from "fs";
|
|
723
|
+
import { mkdir as mkdir4, readFile as readFile6, rename, unlink as unlink2 } from "fs/promises";
|
|
724
|
+
import { basename as basename4, dirname as dirname6, join as join7 } from "path";
|
|
725
|
+
|
|
606
726
|
// src/commands/archive-index.ts
|
|
607
727
|
import { existsSync as existsSync6 } from "fs";
|
|
608
|
-
import { readFile as
|
|
609
|
-
import { basename as
|
|
728
|
+
import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
729
|
+
import { basename as basename3, dirname as dirname5, join as join6, relative as relative3 } from "path";
|
|
610
730
|
async function buildArchiveIndex({ acquireLock = true } = {}) {
|
|
611
731
|
if (acquireLock)
|
|
612
732
|
return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
|
|
613
|
-
|
|
614
|
-
if (!existsSync6(archiveDir)) {
|
|
733
|
+
if (!existsSync6(ARCHIVES_DIR)) {
|
|
615
734
|
console.log(` ${pc.dim("No archives directory.")}
|
|
616
735
|
`);
|
|
617
736
|
return;
|
|
618
737
|
}
|
|
619
|
-
const archiveFiles = (await walkMarkdownFiles(
|
|
738
|
+
const archiveFiles = (await walkMarkdownFiles(ARCHIVES_DIR)).filter((fp) => basename3(fp) !== "INDEX.md").sort();
|
|
620
739
|
const lines = [];
|
|
621
740
|
lines.push("# Archive Index");
|
|
622
741
|
lines.push("");
|
|
623
742
|
lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
|
|
624
743
|
lines.push("");
|
|
625
744
|
if (archiveFiles.length === 0) {
|
|
626
|
-
lines.push("_No archived
|
|
745
|
+
lines.push("_No archived changes yet._");
|
|
627
746
|
} else {
|
|
628
|
-
lines.push("| Date |
|
|
629
|
-
lines.push("
|
|
747
|
+
lines.push("| Date | Change | Summary |");
|
|
748
|
+
lines.push("|------|--------|---------|");
|
|
630
749
|
}
|
|
631
750
|
for (const fp of archiveFiles) {
|
|
632
|
-
const base =
|
|
633
|
-
const archiveRel = relative3(
|
|
634
|
-
const archiveDirPart =
|
|
751
|
+
const base = basename3(fp);
|
|
752
|
+
const archiveRel = normalizeLogicalPath(relative3(ARCHIVES_DIR, fp));
|
|
753
|
+
const archiveDirPart = dirname5(archiveRel);
|
|
635
754
|
const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
|
|
636
755
|
const date = dateMatch ? dateMatch[1] : "\u2014";
|
|
637
756
|
const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
|
|
638
|
-
const
|
|
757
|
+
const changeName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
|
|
639
758
|
let summary = "";
|
|
640
759
|
try {
|
|
641
|
-
const content = await
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const specMatch = content.match(/^## Spec[\s\S]*?\n- Summary:[ \t]*([^\n]+)$/m);
|
|
647
|
-
if (specMatch)
|
|
648
|
-
summary = specMatch[1].trim();
|
|
649
|
-
}
|
|
760
|
+
const content = await readFile5(fp, "utf-8");
|
|
761
|
+
const proposal = extractSection(content, "Proposal");
|
|
762
|
+
const summaryLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Summary:"));
|
|
763
|
+
if (summaryLine)
|
|
764
|
+
summary = summaryLine.slice("- Summary:".length).trim();
|
|
650
765
|
} catch {
|
|
651
766
|
}
|
|
652
767
|
const summaryCell = summary ? summary.replace(/\|/g, "\\|") : "\u2014";
|
|
653
|
-
lines.push(`| ${date} | ${
|
|
768
|
+
lines.push(`| ${date} | ${changeName} | ${summaryCell} |`);
|
|
654
769
|
}
|
|
655
770
|
lines.push("");
|
|
656
|
-
lines.push("_Auto-generated by `rsp
|
|
657
|
-
const indexPath = join6(
|
|
771
|
+
lines.push("_Auto-generated by `rsp update`._");
|
|
772
|
+
const indexPath = join6(ARCHIVES_DIR, "INDEX.md");
|
|
658
773
|
await writeFile5(indexPath, lines.join("\n"));
|
|
659
|
-
console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived
|
|
774
|
+
console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived change(s).
|
|
660
775
|
`);
|
|
661
776
|
}
|
|
662
777
|
|
|
778
|
+
// src/commands/archive.ts
|
|
779
|
+
async function archiveChange(name) {
|
|
780
|
+
if (!name) {
|
|
781
|
+
console.error(` ${pc.red("Usage:")} rsp archive <name>`);
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
if (!isValidChangeName(name)) {
|
|
785
|
+
console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
const srcPath = join7(CHANGES_DIR, `${name}.md`);
|
|
789
|
+
if (!existsSync7(srcPath)) {
|
|
790
|
+
console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
return withRspLock("archive-change", async () => {
|
|
794
|
+
const content = await readFile6(srcPath, "utf-8");
|
|
795
|
+
const checklist = collectArchiveChecklist(content);
|
|
796
|
+
for (const line of checklist)
|
|
797
|
+
console.log(` ${pc.yellow("\u26A0")} ${line}`);
|
|
798
|
+
if (checklist.length > 0)
|
|
799
|
+
console.log(` ${pc.dim("Archive will continue. Review the warnings above before treating this work as fully closed.")}
|
|
800
|
+
`);
|
|
801
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
802
|
+
const dir = dirname6(name);
|
|
803
|
+
const base = basename4(name);
|
|
804
|
+
const archiveSubdir = dir !== "." ? join7(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
|
|
805
|
+
await mkdir4(archiveSubdir, { recursive: true });
|
|
806
|
+
const archiveName = resolveArchiveName(archiveSubdir, date, base);
|
|
807
|
+
await rename(srcPath, join7(archiveSubdir, archiveName));
|
|
808
|
+
const postArchiveWarnings = [];
|
|
809
|
+
let focusCleared = false;
|
|
810
|
+
const focusEntry = join7(FOCUS_DIR, name);
|
|
811
|
+
if (existsSync7(focusEntry)) {
|
|
812
|
+
try {
|
|
813
|
+
await unlink2(focusEntry);
|
|
814
|
+
await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
|
|
815
|
+
focusCleared = true;
|
|
816
|
+
} catch (error) {
|
|
817
|
+
postArchiveWarnings.push(`focus marker cleanup failed: ${toErrorMessage(error)}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
|
|
824
|
+
}
|
|
825
|
+
const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
|
|
826
|
+
` : "";
|
|
827
|
+
console.log(` ${pc.green("Archived:")} ${archiveName}
|
|
828
|
+
${clearedMsg}`);
|
|
829
|
+
try {
|
|
830
|
+
await buildArchiveIndex({ acquireLock: false });
|
|
831
|
+
} catch (error) {
|
|
832
|
+
postArchiveWarnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
|
|
833
|
+
}
|
|
834
|
+
for (const warning of postArchiveWarnings)
|
|
835
|
+
console.log(` ${pc.yellow("\u26A0")} ${warning}`);
|
|
836
|
+
if (postArchiveWarnings.length > 0)
|
|
837
|
+
console.log(` ${pc.dim("Archive completed, but follow-up cleanup was only partially successful.")}
|
|
838
|
+
`);
|
|
839
|
+
if (existsSync7(".git")) {
|
|
840
|
+
const archiveRelPath = dir !== "." ? join7(dir, archiveName) : archiveName;
|
|
841
|
+
console.log(` ${pc.cyan("Git workflow:")}
|
|
842
|
+
`);
|
|
843
|
+
console.log(` git add .rsp/archives/${archiveRelPath}`);
|
|
844
|
+
console.log(` git commit -m "feat: archive ${name}"`);
|
|
845
|
+
console.log();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function resolveArchiveName(archiveSubdir, date, base) {
|
|
850
|
+
const initialName = `${date}_${base}.md`;
|
|
851
|
+
if (!existsSync7(join7(archiveSubdir, initialName)))
|
|
852
|
+
return initialName;
|
|
853
|
+
const MAX_SUFFIX = 9999;
|
|
854
|
+
for (let index = 2; index <= MAX_SUFFIX; index++) {
|
|
855
|
+
const candidate = `${date}_${base}-${index}.md`;
|
|
856
|
+
if (!existsSync7(join7(archiveSubdir, candidate)))
|
|
857
|
+
return candidate;
|
|
858
|
+
}
|
|
859
|
+
console.error(` ${pc.red("Error:")} archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`);
|
|
860
|
+
process.exit(1);
|
|
861
|
+
}
|
|
862
|
+
function collectArchiveChecklist(content) {
|
|
863
|
+
const warnings = [];
|
|
864
|
+
const tasksSection = extractSection(content, "Tasks");
|
|
865
|
+
const verifySection = extractSection(content, "Verify");
|
|
866
|
+
const taskTodos = getOpenCheckboxes(tasksSection);
|
|
867
|
+
if (taskTodos.length > 0)
|
|
868
|
+
warnings.push(`${taskTodos.length} task item(s) still incomplete`);
|
|
869
|
+
const verifyTodos = getOpenCheckboxes(verifySection);
|
|
870
|
+
if (verifyTodos.length > 0)
|
|
871
|
+
warnings.push(`${verifyTodos.length} Verify checklist item(s) are still incomplete`);
|
|
872
|
+
if (hasMeaningfulBlockers(content))
|
|
873
|
+
warnings.push("active blockers are present in the change file");
|
|
874
|
+
const scenarios = parseScenarios(content);
|
|
875
|
+
if (scenarios.length === 0)
|
|
876
|
+
warnings.push("no Scenario blocks found (some changes do not need them)");
|
|
877
|
+
return warnings;
|
|
878
|
+
}
|
|
879
|
+
|
|
663
880
|
// src/commands/check.ts
|
|
664
|
-
import { existsSync as
|
|
881
|
+
import { existsSync as existsSync8 } from "fs";
|
|
665
882
|
import { readFile as readFile7 } from "fs/promises";
|
|
666
|
-
import { join as
|
|
667
|
-
async function runCheck() {
|
|
668
|
-
const featuresDir = join7(RSP_DIR, "features");
|
|
883
|
+
import { join as join8, relative as relative4 } from "path";
|
|
884
|
+
async function runCheck(options = {}) {
|
|
669
885
|
const config = await loadRspConfig();
|
|
670
|
-
const
|
|
671
|
-
const validPriorities = resolvePriorities(config);
|
|
886
|
+
const validKinds = resolveKinds(config);
|
|
672
887
|
const requiredSections = resolveRequiredSections(config);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (
|
|
679
|
-
const entries = await walkFiles(
|
|
888
|
+
const choosePlaceholderRe = /^<choose:/i;
|
|
889
|
+
const diagnostics = [];
|
|
890
|
+
const runtime = [];
|
|
891
|
+
const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
|
|
892
|
+
const addDiagnostic = (diagnostic) => diagnostics.push(diagnostic);
|
|
893
|
+
if (existsSync8(FOCUS_DIR)) {
|
|
894
|
+
const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
|
|
680
895
|
for (const entryPath of entries) {
|
|
681
|
-
const entryContent = relative4(
|
|
682
|
-
|
|
896
|
+
const entryContent = normalizeLogicalPath(relative4(FOCUS_DIR, entryPath));
|
|
897
|
+
let markerContent = "";
|
|
898
|
+
try {
|
|
899
|
+
markerContent = (await readFile7(entryPath, "utf-8")).trim();
|
|
900
|
+
} catch (error) {
|
|
901
|
+
addDiagnostic({
|
|
902
|
+
severity: "error",
|
|
903
|
+
code: "focus_marker_read_failed",
|
|
904
|
+
path: `focus.d/${entryContent}`,
|
|
905
|
+
change: entryContent,
|
|
906
|
+
message: "unable to read focus marker file",
|
|
907
|
+
hint: toErrorMessage(error)
|
|
908
|
+
});
|
|
909
|
+
reportRuntime({
|
|
910
|
+
code: "focus_marker_read_failed",
|
|
911
|
+
operation: "readFile",
|
|
912
|
+
path: entryPath,
|
|
913
|
+
message: toErrorMessage(error)
|
|
914
|
+
});
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
683
917
|
if (markerContent) {
|
|
684
|
-
|
|
685
|
-
|
|
918
|
+
addDiagnostic({
|
|
919
|
+
severity: "warning",
|
|
920
|
+
code: "focus_marker_not_empty",
|
|
921
|
+
path: `focus.d/${entryContent}`,
|
|
922
|
+
message: "should be an empty marker file; path is the source of truth"
|
|
923
|
+
});
|
|
686
924
|
}
|
|
687
|
-
const fp =
|
|
688
|
-
if (!
|
|
689
|
-
|
|
690
|
-
|
|
925
|
+
const fp = join8(CHANGES_DIR, `${entryContent}.md`);
|
|
926
|
+
if (!existsSync8(fp)) {
|
|
927
|
+
addDiagnostic({
|
|
928
|
+
severity: "error",
|
|
929
|
+
code: "focused_change_missing",
|
|
930
|
+
path: `focus.d/${entryContent}`,
|
|
931
|
+
change: entryContent,
|
|
932
|
+
message: `focuses "${entryContent}" but changes/${entryContent}.md not found`
|
|
933
|
+
});
|
|
691
934
|
}
|
|
692
935
|
}
|
|
693
936
|
}
|
|
694
|
-
const
|
|
695
|
-
if (
|
|
696
|
-
|
|
937
|
+
const changeFiles = existsSync8(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
|
|
938
|
+
if (changeFiles.length === 0) {
|
|
939
|
+
const result2 = {
|
|
940
|
+
command: "check",
|
|
941
|
+
ok: diagnostics.every((d) => d.severity !== "error"),
|
|
942
|
+
diagnostics,
|
|
943
|
+
runtime,
|
|
944
|
+
summary: {
|
|
945
|
+
changeFiles: 0,
|
|
946
|
+
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
947
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").length
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
if (options.json) {
|
|
951
|
+
emitJson(result2);
|
|
952
|
+
return result2;
|
|
953
|
+
}
|
|
954
|
+
console.log();
|
|
955
|
+
console.log(` ${pc.bold("RSP check")}`);
|
|
956
|
+
console.log();
|
|
957
|
+
if (diagnostics.length === 0) {
|
|
958
|
+
console.log(` ${pc.dim("No change files to check.")}
|
|
959
|
+
`);
|
|
960
|
+
return result2;
|
|
961
|
+
}
|
|
962
|
+
for (const diagnostic of diagnostics) {
|
|
963
|
+
const icon = diagnostic.severity === "error" ? pc.red("\u2717") : diagnostic.severity === "warning" ? pc.yellow("\u26A0") : pc.dim("\u2139");
|
|
964
|
+
const label = diagnostic.change ?? diagnostic.path;
|
|
965
|
+
const headline = label ? `${label} \u2014 ${diagnostic.message}` : diagnostic.message;
|
|
966
|
+
console.log(` ${icon} ${headline}`);
|
|
967
|
+
for (const detail of diagnostic.details || [])
|
|
968
|
+
console.log(` ${pc.dim(detail)}`);
|
|
969
|
+
if (diagnostic.hint)
|
|
970
|
+
console.log(` ${pc.dim(diagnostic.hint)}`);
|
|
971
|
+
}
|
|
972
|
+
console.log();
|
|
973
|
+
console.log(` ${pc.red(String(result2.summary.errors))} error(s), ${pc.yellow(String(result2.summary.warnings))} warning(s) in 0 change file(s).
|
|
697
974
|
`);
|
|
698
|
-
return
|
|
975
|
+
return result2;
|
|
699
976
|
}
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
const name = featureNameFromPath(featuresDir, fp);
|
|
977
|
+
for (const fp of changeFiles) {
|
|
978
|
+
const name = changeNameFromPath(CHANGES_DIR, fp);
|
|
703
979
|
let content;
|
|
704
980
|
try {
|
|
705
981
|
content = await readFile7(fp, "utf-8");
|
|
706
|
-
} catch {
|
|
707
|
-
|
|
708
|
-
|
|
982
|
+
} catch (error) {
|
|
983
|
+
addDiagnostic({
|
|
984
|
+
severity: "error",
|
|
985
|
+
code: "change_read_failed",
|
|
986
|
+
change: name,
|
|
987
|
+
path: fp,
|
|
988
|
+
message: "unable to read file",
|
|
989
|
+
hint: toErrorMessage(error)
|
|
990
|
+
});
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
let fm = null;
|
|
994
|
+
try {
|
|
995
|
+
fm = parseFrontmatter(content);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
998
|
+
addDiagnostic({
|
|
999
|
+
severity: "error",
|
|
1000
|
+
code: "invalid_frontmatter",
|
|
1001
|
+
change: name,
|
|
1002
|
+
path: fp,
|
|
1003
|
+
message: `invalid YAML frontmatter (${message})`
|
|
1004
|
+
});
|
|
709
1005
|
continue;
|
|
710
1006
|
}
|
|
711
|
-
const fm = parseFrontmatter(content);
|
|
712
1007
|
if (!fm) {
|
|
713
|
-
|
|
714
|
-
|
|
1008
|
+
addDiagnostic({
|
|
1009
|
+
severity: "error",
|
|
1010
|
+
code: "missing_frontmatter",
|
|
1011
|
+
change: name,
|
|
1012
|
+
path: fp,
|
|
1013
|
+
message: "missing YAML frontmatter"
|
|
1014
|
+
});
|
|
715
1015
|
}
|
|
716
1016
|
for (const section of requiredSections) {
|
|
717
1017
|
if (!new RegExp(`^## ${section}$`, "m").test(content)) {
|
|
718
|
-
|
|
719
|
-
|
|
1018
|
+
addDiagnostic({
|
|
1019
|
+
severity: "error",
|
|
1020
|
+
code: "missing_section",
|
|
1021
|
+
change: name,
|
|
1022
|
+
path: fp,
|
|
1023
|
+
message: `missing "## ${section}" section`
|
|
1024
|
+
});
|
|
720
1025
|
}
|
|
721
1026
|
}
|
|
722
1027
|
if (fm) {
|
|
723
|
-
if (!("
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1028
|
+
if (!("kind" in fm)) {
|
|
1029
|
+
addDiagnostic({
|
|
1030
|
+
severity: "error",
|
|
1031
|
+
code: "missing_kind",
|
|
1032
|
+
change: name,
|
|
1033
|
+
path: fp,
|
|
1034
|
+
message: "missing frontmatter field: kind"
|
|
1035
|
+
});
|
|
1036
|
+
} else if (choosePlaceholderRe.test(String(fm.kind))) {
|
|
1037
|
+
addDiagnostic({
|
|
1038
|
+
severity: "error",
|
|
1039
|
+
code: "placeholder_kind",
|
|
1040
|
+
change: name,
|
|
1041
|
+
path: fp,
|
|
1042
|
+
message: `kind still uses the template placeholder; choose one of: ${validKinds.join(", ")}`
|
|
1043
|
+
});
|
|
1044
|
+
} else if (!validKinds.includes(String(fm.kind))) {
|
|
1045
|
+
addDiagnostic({
|
|
1046
|
+
severity: "error",
|
|
1047
|
+
code: "invalid_kind",
|
|
1048
|
+
change: name,
|
|
1049
|
+
path: fp,
|
|
1050
|
+
message: `invalid kind "${fm.kind}" (valid: ${validKinds.join(", ")})`
|
|
1051
|
+
});
|
|
736
1052
|
}
|
|
737
1053
|
}
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
const heading =
|
|
1054
|
+
const headingLine = content.split("\n").map((line) => line.trim()).find((line) => line.startsWith("# Change:"));
|
|
1055
|
+
if (headingLine) {
|
|
1056
|
+
const heading = headingLine.slice("# Change:".length).trim();
|
|
741
1057
|
if (heading !== name) {
|
|
742
|
-
|
|
743
|
-
|
|
1058
|
+
addDiagnostic({
|
|
1059
|
+
severity: "error",
|
|
1060
|
+
code: "heading_mismatch",
|
|
1061
|
+
change: name,
|
|
1062
|
+
path: fp,
|
|
1063
|
+
message: `# Change: heading "${heading}" differs from change name`
|
|
1064
|
+
});
|
|
744
1065
|
}
|
|
745
1066
|
} else {
|
|
746
|
-
|
|
747
|
-
|
|
1067
|
+
addDiagnostic({
|
|
1068
|
+
severity: "error",
|
|
1069
|
+
code: "missing_heading",
|
|
1070
|
+
change: name,
|
|
1071
|
+
path: fp,
|
|
1072
|
+
message: 'missing "# Change:" heading'
|
|
1073
|
+
});
|
|
748
1074
|
}
|
|
749
1075
|
const deltas = detectDeltaSections(content);
|
|
750
1076
|
if (deltas.added || deltas.modified || deltas.removed) {
|
|
@@ -755,7 +1081,13 @@ async function runCheck() {
|
|
|
755
1081
|
parts.push("MODIFIED");
|
|
756
1082
|
if (deltas.removed)
|
|
757
1083
|
parts.push("REMOVED");
|
|
758
|
-
|
|
1084
|
+
addDiagnostic({
|
|
1085
|
+
severity: "info",
|
|
1086
|
+
code: "delta_markers_found",
|
|
1087
|
+
change: name,
|
|
1088
|
+
path: fp,
|
|
1089
|
+
message: `delta markers found: ${parts.join(", ")}`
|
|
1090
|
+
});
|
|
759
1091
|
}
|
|
760
1092
|
const scenarios = parseScenarios(content);
|
|
761
1093
|
if (scenarios.length > 0) {
|
|
@@ -775,466 +1107,433 @@ async function runCheck() {
|
|
|
775
1107
|
scenarioIssues.push(`"${s.heading}" missing ${missing.join("/")}`);
|
|
776
1108
|
}
|
|
777
1109
|
if (scenarioIssues.length > 0) {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1110
|
+
addDiagnostic({
|
|
1111
|
+
severity: "warning",
|
|
1112
|
+
code: "scenario_format_issues",
|
|
1113
|
+
change: name,
|
|
1114
|
+
path: fp,
|
|
1115
|
+
message: "scenario format issues",
|
|
1116
|
+
details: scenarioIssues
|
|
1117
|
+
});
|
|
782
1118
|
}
|
|
783
1119
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
}
|
|
1120
|
+
}
|
|
1121
|
+
const result = {
|
|
1122
|
+
command: "check",
|
|
1123
|
+
ok: diagnostics.every((d) => d.severity !== "error"),
|
|
1124
|
+
diagnostics,
|
|
1125
|
+
runtime,
|
|
1126
|
+
summary: {
|
|
1127
|
+
changeFiles: changeFiles.length,
|
|
1128
|
+
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
1129
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").length
|
|
806
1130
|
}
|
|
1131
|
+
};
|
|
1132
|
+
if (options.json) {
|
|
1133
|
+
emitJson(result);
|
|
1134
|
+
return result;
|
|
807
1135
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1136
|
+
console.log();
|
|
1137
|
+
console.log(` ${pc.bold("RSP check")}`);
|
|
1138
|
+
console.log();
|
|
1139
|
+
for (const diagnostic of diagnostics) {
|
|
1140
|
+
const icon = diagnostic.severity === "error" ? pc.red("\u2717") : diagnostic.severity === "warning" ? pc.yellow("\u26A0") : pc.dim("\u2139");
|
|
1141
|
+
const label = diagnostic.change ?? diagnostic.path;
|
|
1142
|
+
const headline = label ? `${label} \u2014 ${diagnostic.message}` : diagnostic.message;
|
|
1143
|
+
console.log(` ${icon} ${headline}`);
|
|
1144
|
+
for (const detail of diagnostic.details || [])
|
|
1145
|
+
console.log(` ${pc.dim(detail)}`);
|
|
1146
|
+
if (diagnostic.hint)
|
|
1147
|
+
console.log(` ${pc.dim(diagnostic.hint)}`);
|
|
812
1148
|
}
|
|
813
1149
|
console.log();
|
|
814
|
-
if (errors === 0 && warnings === 0)
|
|
815
|
-
console.log(` ${pc.green("\u2713")} All ${
|
|
1150
|
+
if (result.summary.errors === 0 && result.summary.warnings === 0)
|
|
1151
|
+
console.log(` ${pc.green("\u2713")} All ${changeFiles.length} change file(s) valid.
|
|
816
1152
|
`);
|
|
817
1153
|
else
|
|
818
|
-
console.log(` ${pc.red(String(errors))} error(s), ${pc.yellow(String(warnings))} warning(s) in ${
|
|
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:")}
|
|
1154
|
+
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
1155
|
`);
|
|
873
|
-
|
|
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
|
-
}
|
|
1156
|
+
return result;
|
|
920
1157
|
}
|
|
921
1158
|
|
|
922
|
-
// src/commands/
|
|
1159
|
+
// src/commands/create.ts
|
|
923
1160
|
import { existsSync as existsSync9 } from "fs";
|
|
924
|
-
import {
|
|
925
|
-
import { join as join9 } from "path";
|
|
926
|
-
async function
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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;
|
|
1161
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
|
|
1162
|
+
import { dirname as dirname7, join as join9 } from "path";
|
|
1163
|
+
async function createChange(name, summary = "", kind) {
|
|
1164
|
+
if (!name) {
|
|
1165
|
+
console.error(` ${pc.red("Usage:")} rsp create <name> [summary]`);
|
|
1166
|
+
process.exit(1);
|
|
942
1167
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
}
|
|
1168
|
+
if (!isValidChangeName(name)) {
|
|
1169
|
+
console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
1170
|
+
process.exit(1);
|
|
962
1171
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1172
|
+
guardRspInitialized();
|
|
1173
|
+
return withRspLock("create-change", async () => {
|
|
1174
|
+
const changePath = join9(CHANGES_DIR, `${name}.md`);
|
|
1175
|
+
await mkdir5(dirname7(changePath), { recursive: true });
|
|
1176
|
+
const existed = existsSync9(changePath);
|
|
1177
|
+
if (!existed) {
|
|
1178
|
+
const content = generateChangeContent(name, summary, kind);
|
|
1179
|
+
await writeFile6(changePath, content);
|
|
1180
|
+
await mkdir5(FOCUS_DIR, { recursive: true });
|
|
1181
|
+
const focusEntry = join9(FOCUS_DIR, name);
|
|
1182
|
+
await mkdir5(dirname7(focusEntry), { recursive: true });
|
|
1183
|
+
await writeFile6(focusEntry, "");
|
|
970
1184
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
if ((focus || reverse) && visibleRecords.length === 0) {
|
|
979
|
-
console.log(` ${pc.dim("No dependency matches found for the current filter.")}
|
|
1185
|
+
const label = existed ? "Using" : pc.green("Created");
|
|
1186
|
+
console.log(` ${label}: ${changePath}`);
|
|
1187
|
+
if (existed)
|
|
1188
|
+
console.log(` ${pc.dim("Unchanged focus.")} Run: rsp focus ${name}`);
|
|
1189
|
+
else
|
|
1190
|
+
console.log(` ${pc.dim("focused via focus.d")} \u2192 ${name}`);
|
|
1191
|
+
console.log(` ${pc.cyan("Next:")} edit the change file and complete the tasks
|
|
980
1192
|
`);
|
|
981
|
-
|
|
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();
|
|
1193
|
+
});
|
|
1014
1194
|
}
|
|
1015
1195
|
|
|
1016
1196
|
// src/commands/doctor.ts
|
|
1017
1197
|
import { existsSync as existsSync10 } from "fs";
|
|
1018
|
-
import { readFile as
|
|
1019
|
-
import { join as join10, relative as relative5 } from "path";
|
|
1020
|
-
async function runDoctor() {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
console.log();
|
|
1198
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
1199
|
+
import { basename as basename5, join as join10, relative as relative5 } from "path";
|
|
1200
|
+
async function runDoctor(options = {}) {
|
|
1201
|
+
const checks = [];
|
|
1202
|
+
const runtime = [];
|
|
1203
|
+
const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
|
|
1025
1204
|
const rspRulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
|
|
1026
1205
|
const designPath = join10(RSP_DIR, "specs", "design.md");
|
|
1027
1206
|
const specsIndexPath = join10(RSP_DIR, "specs", "INDEX.md");
|
|
1028
1207
|
const archivesIndexPath = join10(RSP_DIR, "archives", "INDEX.md");
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1208
|
+
reportCheck(checks, ".rsp exists", existsSync10(RSP_DIR), "Run: rsp init");
|
|
1209
|
+
reportCheck(checks, "rules/rsp-rules.md exists", existsSync10(rspRulesPath), "Run: rsp init");
|
|
1210
|
+
reportCheck(checks, "specs/design.md exists", existsSync10(designPath), "Run: rsp init");
|
|
1211
|
+
reportCheck(checks, "specs/INDEX.md exists", existsSync10(specsIndexPath), "Run: rsp update");
|
|
1212
|
+
reportCheck(checks, "archives/INDEX.md exists", existsSync10(archivesIndexPath), "Run: rsp update");
|
|
1213
|
+
await checkAgents(checks, reportRuntime);
|
|
1214
|
+
await checkGeneratedIndex(checks, reportRuntime, specsIndexPath, "_Auto-generated by `rsp update`._", "specs/INDEX.md has generated signature", "Run: rsp update");
|
|
1215
|
+
await checkGeneratedIndex(checks, reportRuntime, archivesIndexPath, "_Auto-generated by `rsp update`._", "archives/INDEX.md has generated signature", "Run: rsp update");
|
|
1216
|
+
await checkArchiveNaming(checks, reportRuntime);
|
|
1217
|
+
await checkConfigSemantics(checks, reportRuntime);
|
|
1218
|
+
await checkActiveChangeConsistency(checks, reportRuntime);
|
|
1219
|
+
const result = {
|
|
1220
|
+
command: "doctor",
|
|
1221
|
+
ok: checks.every((check) => check.status !== "issue"),
|
|
1222
|
+
checks,
|
|
1223
|
+
runtime,
|
|
1224
|
+
summary: {
|
|
1225
|
+
issues: checks.filter((check) => check.status === "issue").length
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
if (options.json) {
|
|
1229
|
+
emitJson(result);
|
|
1230
|
+
return result;
|
|
1231
|
+
}
|
|
1232
|
+
console.log();
|
|
1233
|
+
console.log(` ${pc.bold("RSP doctor")}`);
|
|
1234
|
+
console.log();
|
|
1235
|
+
for (const check of checks)
|
|
1236
|
+
printDoctorCheck(check);
|
|
1237
|
+
if (result.summary.issues === 0)
|
|
1041
1238
|
console.log(`
|
|
1042
1239
|
${pc.green("\u2713")} RSP setup looks healthy.
|
|
1043
1240
|
`);
|
|
1044
1241
|
else
|
|
1045
1242
|
console.log(`
|
|
1046
|
-
${pc.yellow(String(issues))} issue(s) detected.
|
|
1243
|
+
${pc.yellow(String(result.summary.issues))} issue(s) detected.
|
|
1047
1244
|
`);
|
|
1048
|
-
return
|
|
1245
|
+
return result;
|
|
1049
1246
|
}
|
|
1050
|
-
function reportCheck(label, ok, fixHint) {
|
|
1247
|
+
function reportCheck(checks, label, ok, fixHint) {
|
|
1051
1248
|
if (ok) {
|
|
1052
|
-
|
|
1053
|
-
return
|
|
1249
|
+
checks.push({ status: "ok", label });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
checks.push({ status: "issue", label, hint: fixHint });
|
|
1253
|
+
}
|
|
1254
|
+
function printDoctorCheck(check) {
|
|
1255
|
+
if (check.status === "ok") {
|
|
1256
|
+
console.log(` ${pc.green("\u2713")} ${check.label}`);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (check.status === "info") {
|
|
1260
|
+
console.log(` ${pc.dim(`Info: ${check.message || check.label}`)}`);
|
|
1261
|
+
if (check.hint)
|
|
1262
|
+
console.log(` ${pc.dim(check.hint)}`);
|
|
1263
|
+
return;
|
|
1054
1264
|
}
|
|
1055
|
-
console.log(` ${pc.yellow("!")} ${label}`);
|
|
1056
|
-
if (
|
|
1057
|
-
console.log(` ${pc.dim(
|
|
1058
|
-
return 1;
|
|
1265
|
+
console.log(` ${pc.yellow("!")} ${check.message || check.label}`);
|
|
1266
|
+
if (check.hint)
|
|
1267
|
+
console.log(` ${pc.dim(check.hint)}`);
|
|
1059
1268
|
}
|
|
1060
|
-
async function checkAgents() {
|
|
1269
|
+
async function checkAgents(checks, reportRuntime) {
|
|
1061
1270
|
if (!existsSync10("AGENTS.md")) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1271
|
+
checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing", hint: "Run: rsp init" });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
let agents;
|
|
1275
|
+
try {
|
|
1276
|
+
agents = await readFile8("AGENTS.md", "utf-8");
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
reportRuntime({
|
|
1279
|
+
code: "agents_read_failed",
|
|
1280
|
+
operation: "readFile",
|
|
1281
|
+
path: "AGENTS.md",
|
|
1282
|
+
message: toErrorMessage(error)
|
|
1283
|
+
});
|
|
1284
|
+
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" });
|
|
1285
|
+
return;
|
|
1065
1286
|
}
|
|
1066
|
-
const agents = await readFile10("AGENTS.md", "utf-8");
|
|
1067
1287
|
if (hasRspAgentsBlock(agents)) {
|
|
1068
|
-
|
|
1069
|
-
return
|
|
1288
|
+
checks.push({ status: "ok", label: "AGENTS.md contains managed RSP block" });
|
|
1289
|
+
return;
|
|
1070
1290
|
}
|
|
1071
|
-
|
|
1072
|
-
console.log(` ${pc.dim("Run: rsp init --agents-mode managed")}`);
|
|
1073
|
-
return 1;
|
|
1291
|
+
checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing managed RSP block", hint: "Run: rsp init" });
|
|
1074
1292
|
}
|
|
1075
|
-
async function checkGeneratedIndex(path, signature, label, fixHint) {
|
|
1293
|
+
async function checkGeneratedIndex(checks, reportRuntime, path, signature, label, fixHint) {
|
|
1076
1294
|
if (!existsSync10(path))
|
|
1077
|
-
return
|
|
1078
|
-
|
|
1295
|
+
return;
|
|
1296
|
+
let content;
|
|
1297
|
+
try {
|
|
1298
|
+
content = await readFile8(path, "utf-8");
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
reportRuntime({
|
|
1301
|
+
code: "generated_index_read_failed",
|
|
1302
|
+
operation: "readFile",
|
|
1303
|
+
path,
|
|
1304
|
+
message: toErrorMessage(error)
|
|
1305
|
+
});
|
|
1306
|
+
checks.push({ status: "issue", label, message: `${label} could not be read`, hint: fixHint });
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1079
1309
|
if (content.includes(signature)) {
|
|
1080
|
-
|
|
1081
|
-
return
|
|
1310
|
+
checks.push({ status: "ok", label });
|
|
1311
|
+
return;
|
|
1082
1312
|
}
|
|
1083
|
-
|
|
1084
|
-
console.log(` ${pc.dim(fixHint)}`);
|
|
1085
|
-
return 1;
|
|
1313
|
+
checks.push({ status: "issue", label, hint: fixHint });
|
|
1086
1314
|
}
|
|
1087
|
-
async function checkArchiveNaming() {
|
|
1315
|
+
async function checkArchiveNaming(checks, reportRuntime) {
|
|
1088
1316
|
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
|
|
1317
|
+
const archiveFiles = existsSync10(archivesDir) ? (await walkMarkdownFiles(archivesDir, { onError: reportRuntime })).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
|
|
1318
|
+
const invalidFiles = archiveFiles.map((fp) => normalizeLogicalPath(relative5(archivesDir, fp))).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(basename5(fp)) === false);
|
|
1091
1319
|
if (invalidFiles.length === 0) {
|
|
1092
|
-
|
|
1093
|
-
return
|
|
1320
|
+
checks.push({ status: "ok", label: "archived change files follow the date_name.md convention" });
|
|
1321
|
+
return;
|
|
1094
1322
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1323
|
+
checks.push({
|
|
1324
|
+
status: "issue",
|
|
1325
|
+
label: "archived change files follow the date_name.md convention",
|
|
1326
|
+
message: `archived change files with invalid names: ${invalidFiles.join(", ")}`,
|
|
1327
|
+
hint: "Rename archive files to YYYY-MM-DD_name.md and run: rsp update"
|
|
1328
|
+
});
|
|
1098
1329
|
}
|
|
1099
|
-
async function checkConfigSemantics() {
|
|
1330
|
+
async function checkConfigSemantics(checks, reportRuntime) {
|
|
1100
1331
|
if (!existsSync10(CONFIG_PATH))
|
|
1101
|
-
return
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1332
|
+
return;
|
|
1333
|
+
let content;
|
|
1334
|
+
try {
|
|
1335
|
+
content = await readFile8(CONFIG_PATH, "utf-8");
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
reportRuntime({
|
|
1338
|
+
code: "config_read_failed",
|
|
1339
|
+
operation: "readFile",
|
|
1340
|
+
path: CONFIG_PATH,
|
|
1341
|
+
message: toErrorMessage(error)
|
|
1342
|
+
});
|
|
1343
|
+
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" });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
let parsed;
|
|
1347
|
+
try {
|
|
1348
|
+
parsed = parseYamlText(content);
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1351
|
+
checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml is not valid YAML", hint: message });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
reportConfigListField(checks, parsed, "kinds");
|
|
1355
|
+
if ("required_sections" in parsed) {
|
|
1356
|
+
checks.push({
|
|
1357
|
+
status: "issue",
|
|
1358
|
+
label: "config.yaml semantic checks",
|
|
1359
|
+
message: 'config.yaml field "required_sections" is no longer supported',
|
|
1360
|
+
hint: 'RSP keeps the core change section structure fixed. Remove "required_sections" from .rsp/config.yaml.'
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function reportConfigListField(checks, parsed, key) {
|
|
1111
1365
|
if (!(key in parsed))
|
|
1112
|
-
return
|
|
1366
|
+
return;
|
|
1113
1367
|
const value = parsed[key];
|
|
1114
1368
|
if (!Array.isArray(value)) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1369
|
+
checks.push({
|
|
1370
|
+
status: "issue",
|
|
1371
|
+
label: "config.yaml semantic checks",
|
|
1372
|
+
message: `config.yaml field "${key}" must be a YAML list`,
|
|
1373
|
+
hint: `Update .rsp/config.yaml so "${key}" is a YAML sequence (for example: ${key}: [item])`
|
|
1374
|
+
});
|
|
1375
|
+
return;
|
|
1118
1376
|
}
|
|
1119
1377
|
const items = value.map(String);
|
|
1120
1378
|
const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
|
|
1121
1379
|
if (duplicates.length === 0) {
|
|
1122
|
-
|
|
1123
|
-
return
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
const
|
|
1135
|
-
const
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1380
|
+
checks.push({ status: "ok", label: `config.yaml field "${key}" has valid list semantics` });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
checks.push({
|
|
1384
|
+
status: "issue",
|
|
1385
|
+
label: "config.yaml semantic checks",
|
|
1386
|
+
message: `config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`,
|
|
1387
|
+
hint: `Remove duplicate values from .rsp/config.yaml:${key}`
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
async function checkActiveChangeConsistency(checks, reportRuntime) {
|
|
1391
|
+
const changeFiles = existsSync10(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
|
|
1392
|
+
const changeNames = new Set(changeFiles.map((fp) => changeNameFromPath(CHANGES_DIR, fp)));
|
|
1393
|
+
const focusEntries = existsSync10(FOCUS_DIR) ? await walkFiles(FOCUS_DIR, { onError: reportRuntime }) : [];
|
|
1394
|
+
const focusNames = new Set(focusEntries.map((fp) => normalizeLogicalPath(relative5(FOCUS_DIR, fp))));
|
|
1395
|
+
const missingChangeFiles = [...focusNames].filter((name) => !changeNames.has(name));
|
|
1396
|
+
const unfocusedChangeFiles = [...changeNames].filter((name) => !focusNames.has(name));
|
|
1397
|
+
if (missingChangeFiles.length === 0) {
|
|
1398
|
+
checks.push({ status: "ok", label: "every focus marker maps to a change file" });
|
|
1140
1399
|
} else {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1400
|
+
checks.push({
|
|
1401
|
+
status: "issue",
|
|
1402
|
+
label: "every focus marker maps to a change file",
|
|
1403
|
+
message: `focus markers without matching change files: ${missingChangeFiles.join(", ")}`,
|
|
1404
|
+
hint: "Run: remove stale files in .rsp/focus.d/ or recreate the change with rsp create <name>"
|
|
1405
|
+
});
|
|
1144
1406
|
}
|
|
1145
|
-
if (
|
|
1146
|
-
|
|
1407
|
+
if (unfocusedChangeFiles.length === 0) {
|
|
1408
|
+
checks.push({ status: "ok", label: "focus markers are consistent with open changes" });
|
|
1147
1409
|
} else {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1410
|
+
const preview = unfocusedChangeFiles.slice(0, 5).join(", ");
|
|
1411
|
+
const extra = unfocusedChangeFiles.length > 5 ? ` (+${unfocusedChangeFiles.length - 5} more)` : "";
|
|
1412
|
+
checks.push({
|
|
1413
|
+
status: "info",
|
|
1414
|
+
label: "focus markers are consistent with open changes",
|
|
1415
|
+
message: `unfocused open changes: ${preview}${extra}`,
|
|
1416
|
+
hint: "Only changes listed in .rsp/focus.d/ are currently focused. Use rsp focus <name> to foreground one."
|
|
1417
|
+
});
|
|
1151
1418
|
}
|
|
1152
|
-
return issues;
|
|
1153
1419
|
}
|
|
1154
1420
|
|
|
1155
|
-
// src/commands/
|
|
1421
|
+
// src/commands/focus.ts
|
|
1156
1422
|
import { existsSync as existsSync11 } from "fs";
|
|
1157
|
-
import { mkdir as
|
|
1158
|
-
import {
|
|
1159
|
-
|
|
1160
|
-
|
|
1423
|
+
import { mkdir as mkdir6, unlink as unlink3, writeFile as writeFile7 } from "fs/promises";
|
|
1424
|
+
import { dirname as dirname8, join as join11 } from "path";
|
|
1425
|
+
async function focusChange(name) {
|
|
1426
|
+
if (!name) {
|
|
1427
|
+
console.error(` ${pc.red("Usage:")} rsp focus <name>`);
|
|
1428
|
+
process.exit(1);
|
|
1429
|
+
}
|
|
1430
|
+
if (!isValidChangeName(name)) {
|
|
1431
|
+
console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
1432
|
+
process.exit(1);
|
|
1433
|
+
}
|
|
1434
|
+
guardRspInitialized();
|
|
1435
|
+
const changePath = join11(CHANGES_DIR, `${name}.md`);
|
|
1436
|
+
if (!existsSync11(changePath)) {
|
|
1437
|
+
console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
|
|
1438
|
+
process.exit(1);
|
|
1439
|
+
}
|
|
1440
|
+
return withRspLock("focus-change", async () => {
|
|
1441
|
+
await mkdir6(FOCUS_DIR, { recursive: true });
|
|
1442
|
+
const focusEntry = join11(FOCUS_DIR, name);
|
|
1443
|
+
await mkdir6(dirname8(focusEntry), { recursive: true });
|
|
1444
|
+
await writeFile7(focusEntry, "");
|
|
1445
|
+
console.log(` ${pc.green("Focused:")} ${name}`);
|
|
1446
|
+
console.log(` ${pc.dim("focus.d")} \u2192 ${name}`);
|
|
1447
|
+
console.log();
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
async function unfocusChange(name) {
|
|
1451
|
+
if (!name) {
|
|
1452
|
+
console.error(` ${pc.red("Usage:")} rsp unfocus <name>`);
|
|
1453
|
+
process.exit(1);
|
|
1454
|
+
}
|
|
1455
|
+
if (!isValidChangeName(name)) {
|
|
1456
|
+
console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
|
|
1457
|
+
process.exit(1);
|
|
1458
|
+
}
|
|
1459
|
+
guardRspInitialized();
|
|
1460
|
+
const focusEntry = join11(FOCUS_DIR, name);
|
|
1461
|
+
if (!existsSync11(focusEntry)) {
|
|
1462
|
+
console.error(` ${pc.red("Focus marker not found:")} .rsp/focus.d/${name}`);
|
|
1463
|
+
process.exit(1);
|
|
1464
|
+
}
|
|
1465
|
+
return withRspLock("unfocus-change", async () => {
|
|
1466
|
+
await unlink3(focusEntry);
|
|
1467
|
+
await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
|
|
1468
|
+
console.log(` ${pc.green("Unfocused:")} ${name}`);
|
|
1469
|
+
console.log(` ${pc.dim("focus.d cleared")} \u2192 ${name}`);
|
|
1470
|
+
console.log();
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/commands/init.ts
|
|
1475
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1476
|
+
import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
1477
|
+
import { join as join12 } from "path";
|
|
1478
|
+
function generateConfigTemplate() {
|
|
1479
|
+
const fmtList = (items, indent) => items.map((i) => `${" ".repeat(indent)}# - ${i}`).join("\n");
|
|
1480
|
+
return `# RSP project configuration
|
|
1481
|
+
# Uncomment any section below to customize classification values.
|
|
1161
1482
|
#
|
|
1162
1483
|
# Built-in defaults:
|
|
1163
|
-
#
|
|
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
|
|
1173
|
-
#
|
|
1174
|
-
# priorities:
|
|
1175
|
-
# - low
|
|
1176
|
-
# - medium
|
|
1177
|
-
# - high
|
|
1178
|
-
# - critical
|
|
1484
|
+
# kinds: ${VALID_KINDS.join(", ")}
|
|
1179
1485
|
#
|
|
1180
|
-
#
|
|
1181
|
-
|
|
1182
|
-
# - Plan
|
|
1486
|
+
# kinds:
|
|
1487
|
+
${fmtList(VALID_KINDS, 2)}
|
|
1183
1488
|
`;
|
|
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
1489
|
}
|
|
1196
1490
|
async function ensureFile(path, content) {
|
|
1197
|
-
if (
|
|
1491
|
+
if (existsSync12(path))
|
|
1198
1492
|
return false;
|
|
1199
|
-
await
|
|
1493
|
+
await writeFile8(path, content);
|
|
1200
1494
|
return true;
|
|
1201
1495
|
}
|
|
1202
1496
|
function toTitle(projectName) {
|
|
1203
1497
|
return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
|
|
1204
1498
|
}
|
|
1205
1499
|
async function initProject(args = {}) {
|
|
1206
|
-
const isNew = !
|
|
1207
|
-
const projectName = await
|
|
1500
|
+
const isNew = !existsSync12(RSP_DIR);
|
|
1501
|
+
const projectName = await detectProjectName();
|
|
1502
|
+
const mode = args.agentsMode ?? "managed";
|
|
1503
|
+
if (mode !== "managed" && mode !== "print") {
|
|
1504
|
+
console.error(` ${pc.red("Error:")} unsupported --agents-mode "${mode}". Use: managed or print`);
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1208
1507
|
return withRspLock("init", async () => {
|
|
1209
1508
|
const dirs = [
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1509
|
+
join12(RSP_DIR, "rules"),
|
|
1510
|
+
join12(RSP_DIR, "specs"),
|
|
1511
|
+
CHANGES_DIR,
|
|
1512
|
+
FOCUS_DIR,
|
|
1513
|
+
join12(RSP_DIR, "archives")
|
|
1215
1514
|
];
|
|
1216
1515
|
for (const d of dirs)
|
|
1217
|
-
await
|
|
1516
|
+
await mkdir7(d, { recursive: true });
|
|
1218
1517
|
let created = false;
|
|
1219
|
-
const bundledRules = await
|
|
1220
|
-
created = await ensureFile(
|
|
1221
|
-
created = await ensureFile(
|
|
1222
|
-
const createdSpecsIndex = await ensureFile(
|
|
1518
|
+
const bundledRules = await readFile9(join12(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
|
|
1519
|
+
created = await ensureFile(join12(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
|
|
1520
|
+
created = await ensureFile(join12(RSP_DIR, "config.yaml"), generateConfigTemplate()) || created;
|
|
1521
|
+
const createdSpecsIndex = await ensureFile(join12(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Additional project-level specs beyond `design.md`._\n");
|
|
1223
1522
|
created = createdSpecsIndex || created;
|
|
1224
|
-
const createdDesign = await ensureFile(
|
|
1523
|
+
const createdDesign = await ensureFile(join12(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
|
|
1225
1524
|
created = createdDesign || created;
|
|
1226
|
-
const createdArchivesIndex = await ensureFile(
|
|
1525
|
+
const createdArchivesIndex = await ensureFile(join12(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
|
|
1227
1526
|
created = createdArchivesIndex || created;
|
|
1228
|
-
created = await ensureFile(
|
|
1229
|
-
created = await ensureFile(
|
|
1230
|
-
created = await ensureFile(
|
|
1527
|
+
created = await ensureFile(join12(CHANGES_DIR, ".gitkeep"), "") || created;
|
|
1528
|
+
created = await ensureFile(join12(FOCUS_DIR, ".gitkeep"), "") || created;
|
|
1529
|
+
created = await ensureFile(join12(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
|
|
1231
1530
|
if (args.withProjectRules)
|
|
1232
|
-
created = await ensureFile(
|
|
1531
|
+
created = await ensureFile(join12(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
|
|
1233
1532
|
if (args.withProjectSetup) {
|
|
1234
|
-
const
|
|
1235
|
-
const
|
|
1236
|
-
created = await ensureFile(
|
|
1237
|
-
created = await ensureFile(
|
|
1533
|
+
const changePath = join12(CHANGES_DIR, "project-setup.md");
|
|
1534
|
+
const focusMarker = join12(FOCUS_DIR, "project-setup");
|
|
1535
|
+
created = await ensureFile(changePath, generateChangeContent("project-setup")) || created;
|
|
1536
|
+
created = await ensureFile(focusMarker, "") || created;
|
|
1238
1537
|
}
|
|
1239
1538
|
if (createdSpecsIndex || createdDesign)
|
|
1240
1539
|
await buildSpecsIndex({ acquireLock: false });
|
|
@@ -1242,51 +1541,48 @@ async function initProject(args = {}) {
|
|
|
1242
1541
|
await buildArchiveIndex({ acquireLock: false });
|
|
1243
1542
|
const agentsPath = "AGENTS.md";
|
|
1244
1543
|
const title = toTitle(projectName);
|
|
1245
|
-
|
|
1246
|
-
if (!
|
|
1247
|
-
|
|
1248
|
-
await writeFile6(agentsPath, `# ${title}
|
|
1544
|
+
let agentsUpdated = false;
|
|
1545
|
+
if (!existsSync12(agentsPath)) {
|
|
1546
|
+
await writeFile8(agentsPath, `# ${title}
|
|
1249
1547
|
|
|
1250
1548
|
${upsertRspAgentsBlock("").content}
|
|
1251
1549
|
`);
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
} else
|
|
1255
|
-
const existing = await
|
|
1550
|
+
created = true;
|
|
1551
|
+
agentsUpdated = true;
|
|
1552
|
+
} else {
|
|
1553
|
+
const existing = await readFile9(agentsPath, "utf-8");
|
|
1256
1554
|
const next = upsertRspAgentsBlock(existing);
|
|
1257
1555
|
if (next.changed) {
|
|
1258
|
-
await
|
|
1556
|
+
await writeFile8(agentsPath, next.content);
|
|
1259
1557
|
created = true;
|
|
1558
|
+
agentsUpdated = true;
|
|
1260
1559
|
}
|
|
1261
1560
|
}
|
|
1262
1561
|
if (mode === "print") {
|
|
1263
|
-
const managed =
|
|
1264
|
-
|
|
1265
|
-
${renderRspAgentsBlock()}
|
|
1266
|
-
`;
|
|
1562
|
+
const managed = await readFile9(agentsPath, "utf-8");
|
|
1267
1563
|
console.log(`
|
|
1268
1564
|
${managed}
|
|
1269
1565
|
`);
|
|
1270
1566
|
}
|
|
1271
1567
|
if (isNew) {
|
|
1272
|
-
const createdProjectSetup = args.withProjectSetup ? "\n .rsp/
|
|
1273
|
-
const
|
|
1568
|
+
const createdProjectSetup = args.withProjectSetup ? "\n .rsp/changes/project-setup.md\n .rsp/focus.d/project-setup" : "";
|
|
1569
|
+
const createdAgents = agentsUpdated ? "\n AGENTS.md" : "";
|
|
1570
|
+
const next = args.withProjectSetup ? "fill .rsp/changes/project-setup.md" : "rsp create project-setup";
|
|
1274
1571
|
console.log(`
|
|
1275
1572
|
${pc.green("RSP scaffolded.")}
|
|
1276
1573
|
`);
|
|
1277
1574
|
console.log(` Created: .rsp/rules/
|
|
1278
1575
|
.rsp/specs/
|
|
1279
|
-
.rsp/
|
|
1280
|
-
.rsp/
|
|
1576
|
+
.rsp/changes/
|
|
1577
|
+
.rsp/focus.d/
|
|
1281
1578
|
.rsp/archives/
|
|
1282
1579
|
.rsp/.gitignore
|
|
1283
1580
|
.rsp/config.yaml
|
|
1284
|
-
.rsp/specs/design.md${createdProjectSetup}
|
|
1285
|
-
AGENTS.md
|
|
1581
|
+
.rsp/specs/design.md${createdProjectSetup}${createdAgents}
|
|
1286
1582
|
`);
|
|
1287
1583
|
console.log(` ${pc.cyan("Next:")} ${next}
|
|
1288
1584
|
${pc.dim("Then:")} fill .rsp/specs/design.md
|
|
1289
|
-
${pc.dim("Also:")} rsp status rsp
|
|
1585
|
+
${pc.dim("Also:")} rsp status rsp check
|
|
1290
1586
|
`);
|
|
1291
1587
|
} else if (created) {
|
|
1292
1588
|
console.log(`
|
|
@@ -1300,125 +1596,101 @@ ${managed}
|
|
|
1300
1596
|
});
|
|
1301
1597
|
}
|
|
1302
1598
|
|
|
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
1599
|
// src/commands/status.ts
|
|
1348
1600
|
import { existsSync as existsSync13 } from "fs";
|
|
1349
|
-
import { readFile as
|
|
1601
|
+
import { readFile as readFile10, stat as stat2 } from "fs/promises";
|
|
1350
1602
|
import { join as join13, relative as relative6 } from "path";
|
|
1351
|
-
var
|
|
1352
|
-
var
|
|
1603
|
+
var COL_CHANGE = 32;
|
|
1604
|
+
var COL_KIND = 10;
|
|
1353
1605
|
var COL_AGE = 7;
|
|
1354
1606
|
var COL_PROGRESS = 10;
|
|
1355
|
-
async function showStatus(options = {}) {
|
|
1356
|
-
const
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
const
|
|
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
|
-
}
|
|
1607
|
+
async function showStatus(options = {}, runOptions = {}) {
|
|
1608
|
+
const focusedSet = /* @__PURE__ */ new Set();
|
|
1609
|
+
const runtime = [];
|
|
1610
|
+
const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(runOptions.verbose) && !runOptions.json);
|
|
1611
|
+
if (existsSync13(FOCUS_DIR)) {
|
|
1612
|
+
const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
|
|
1613
|
+
for (const entryPath of entries)
|
|
1614
|
+
focusedSet.add(normalizeLogicalPath(relative6(FOCUS_DIR, entryPath)));
|
|
1615
|
+
}
|
|
1616
|
+
const changeFiles = existsSync13(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
|
|
1617
|
+
const changeMap = {};
|
|
1618
|
+
for (const fp of changeFiles)
|
|
1619
|
+
changeMap[changeNameFromPath(CHANGES_DIR, fp)] = fp;
|
|
1620
|
+
const allNames = [.../* @__PURE__ */ new Set([...Object.keys(changeMap), ...focusedSet])].sort();
|
|
1390
1621
|
const records = [];
|
|
1622
|
+
const outputRecords = [];
|
|
1391
1623
|
for (const name of allNames) {
|
|
1392
|
-
const fp =
|
|
1393
|
-
let
|
|
1624
|
+
const fp = changeMap[name];
|
|
1625
|
+
let kind = "\u2014";
|
|
1394
1626
|
let progress = "\u2014";
|
|
1395
|
-
let
|
|
1396
|
-
|
|
1397
|
-
const isActive = activeSet.has(name);
|
|
1627
|
+
let ageDays = null;
|
|
1628
|
+
const isFocused = focusedSet.has(name);
|
|
1398
1629
|
let isBlocked = false;
|
|
1630
|
+
let done = 0;
|
|
1631
|
+
let total = 0;
|
|
1399
1632
|
if (fp) {
|
|
1400
1633
|
try {
|
|
1401
|
-
const content = await
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1634
|
+
const content = await readFile10(fp, "utf-8");
|
|
1635
|
+
try {
|
|
1636
|
+
const fm = parseFrontmatter(content);
|
|
1637
|
+
kind = fm?.kind ? String(fm.kind) : "\u2014";
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
kind = "(invalid)";
|
|
1640
|
+
reportRuntime({
|
|
1641
|
+
code: "frontmatter_parse_failed",
|
|
1642
|
+
operation: "parseFrontmatter",
|
|
1643
|
+
path: fp,
|
|
1644
|
+
message: toErrorMessage(error)
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
isBlocked = hasMeaningfulBlockers(content);
|
|
1407
1648
|
const cb = countCheckboxes(content);
|
|
1408
|
-
|
|
1649
|
+
done = cb.done;
|
|
1650
|
+
total = cb.total;
|
|
1651
|
+
progress = `${done}/${total}`;
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
reportRuntime({
|
|
1654
|
+
code: "change_read_failed",
|
|
1655
|
+
operation: "readFile",
|
|
1656
|
+
path: fp,
|
|
1657
|
+
message: toErrorMessage(error)
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1409
1661
|
const s = await stat2(fp);
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1662
|
+
ageDays = Math.floor((Date.now() - s.mtime.getTime()) / (1e3 * 60 * 60 * 24));
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
reportRuntime({
|
|
1665
|
+
code: "change_stat_failed",
|
|
1666
|
+
operation: "stat",
|
|
1667
|
+
path: fp,
|
|
1668
|
+
message: toErrorMessage(error)
|
|
1669
|
+
});
|
|
1414
1670
|
}
|
|
1415
1671
|
} else {
|
|
1416
|
-
|
|
1672
|
+
kind = "(missing)";
|
|
1673
|
+
reportRuntime({
|
|
1674
|
+
code: "focused_change_missing",
|
|
1675
|
+
operation: "resolveChange",
|
|
1676
|
+
path: join13(CHANGES_DIR, `${name}.md`),
|
|
1677
|
+
message: "focus marker points to a missing change file"
|
|
1678
|
+
});
|
|
1417
1679
|
}
|
|
1418
|
-
|
|
1680
|
+
const age = ageDays === null ? "\u2014" : String(ageDays);
|
|
1681
|
+
records.push({ name, kind, progress, age, isFocused, isBlocked });
|
|
1682
|
+
outputRecords.push({
|
|
1683
|
+
name,
|
|
1684
|
+
kind,
|
|
1685
|
+
progress: { done, total },
|
|
1686
|
+
ageDays,
|
|
1687
|
+
isFocused,
|
|
1688
|
+
isBlocked,
|
|
1689
|
+
path: fp ? normalizeLogicalPath(fp) : null
|
|
1690
|
+
});
|
|
1419
1691
|
}
|
|
1420
1692
|
const filteredRecords = records.filter((record) => {
|
|
1421
|
-
if (options.
|
|
1693
|
+
if (options.focused && !record.isFocused)
|
|
1422
1694
|
return false;
|
|
1423
1695
|
if (options.blocked && !record.isBlocked)
|
|
1424
1696
|
return false;
|
|
@@ -1429,48 +1701,90 @@ async function showStatus(options = {}) {
|
|
|
1429
1701
|
}
|
|
1430
1702
|
return true;
|
|
1431
1703
|
});
|
|
1432
|
-
|
|
1433
|
-
|
|
1704
|
+
const filteredOutputRecords = outputRecords.filter((record) => {
|
|
1705
|
+
if (options.focused && !record.isFocused)
|
|
1706
|
+
return false;
|
|
1707
|
+
if (options.blocked && !record.isBlocked)
|
|
1708
|
+
return false;
|
|
1709
|
+
if (typeof options.stale === "number") {
|
|
1710
|
+
if (record.ageDays === null || record.ageDays < options.stale)
|
|
1711
|
+
return false;
|
|
1712
|
+
}
|
|
1713
|
+
return true;
|
|
1714
|
+
});
|
|
1715
|
+
const blockedCount = filteredOutputRecords.filter((r) => r.isBlocked).length;
|
|
1716
|
+
const statusResult = {
|
|
1717
|
+
command: "status",
|
|
1718
|
+
ok: true,
|
|
1719
|
+
filters: {
|
|
1720
|
+
focused: Boolean(options.focused),
|
|
1721
|
+
blocked: Boolean(options.blocked),
|
|
1722
|
+
stale: typeof options.stale === "number" ? options.stale : null
|
|
1723
|
+
},
|
|
1724
|
+
focused: [...focusedSet].sort(),
|
|
1725
|
+
records: filteredOutputRecords,
|
|
1726
|
+
summary: {
|
|
1727
|
+
total: filteredOutputRecords.length,
|
|
1728
|
+
focused: filteredOutputRecords.filter((r) => r.isFocused).length,
|
|
1729
|
+
blocked: blockedCount
|
|
1730
|
+
},
|
|
1731
|
+
archiveTrend: await readArchiveTrend(runtime, runOptions),
|
|
1732
|
+
runtime
|
|
1733
|
+
};
|
|
1734
|
+
if (runOptions.json) {
|
|
1735
|
+
emitJson(statusResult);
|
|
1736
|
+
return statusResult;
|
|
1737
|
+
}
|
|
1738
|
+
console.log();
|
|
1739
|
+
console.log(` ${pc.bold("RSP status")}`);
|
|
1740
|
+
console.log();
|
|
1741
|
+
if (focusedSet.size > 0) {
|
|
1742
|
+
console.log(` ${pc.cyan("Focused:")} ${[...focusedSet].join(", ")}`);
|
|
1434
1743
|
console.log();
|
|
1435
|
-
|
|
1744
|
+
} else {
|
|
1745
|
+
console.log(` ${pc.dim("No focused change.")} Run: rsp create <name> or rsp focus <name>`);
|
|
1746
|
+
console.log();
|
|
1747
|
+
}
|
|
1748
|
+
if (allNames.length === 0) {
|
|
1749
|
+
console.log(` ${pc.dim("No changes found.")} Run: rsp create <name>
|
|
1750
|
+
`);
|
|
1751
|
+
return statusResult;
|
|
1752
|
+
}
|
|
1753
|
+
if (filteredRecords.length === 0) {
|
|
1754
|
+
console.log(` ${pc.dim("No changes match the current filters.")}
|
|
1755
|
+
`);
|
|
1756
|
+
return statusResult;
|
|
1436
1757
|
}
|
|
1437
1758
|
const pad = (s, w) => s.padEnd(w);
|
|
1438
|
-
console.log(` ${pad("
|
|
1439
|
-
console.log(` ${"\u2500".repeat(
|
|
1440
|
-
let blockedCount = 0;
|
|
1759
|
+
console.log(` ${pad("Change", COL_CHANGE)} ${pad("Kind", COL_KIND)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)}`);
|
|
1760
|
+
console.log(` ${"\u2500".repeat(COL_CHANGE)} ${"\u2500".repeat(COL_KIND)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)}`);
|
|
1441
1761
|
for (const r of filteredRecords) {
|
|
1442
|
-
const marker = r.
|
|
1443
|
-
const namePadded = r.name.padEnd(
|
|
1444
|
-
const
|
|
1762
|
+
const marker = r.isFocused ? pc.cyan("*") : " ";
|
|
1763
|
+
const namePadded = r.name.padEnd(COL_CHANGE - 1);
|
|
1764
|
+
const kindPadded = r.kind.padEnd(COL_KIND);
|
|
1445
1765
|
const agePadded = r.age.padEnd(COL_AGE);
|
|
1446
1766
|
const progressPadded = r.progress.padEnd(COL_PROGRESS);
|
|
1447
1767
|
const nameDisplay = r.isBlocked ? pc.yellow(namePadded) : namePadded;
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
console.log(` ${marker}${nameDisplay} ${statusDisplay} ${ageDisplay} ${progressPadded} ${r.depends}`);
|
|
1451
|
-
if (r.isBlocked)
|
|
1452
|
-
blockedCount++;
|
|
1768
|
+
const ageDisplay = r.isFocused ? pc.cyan(agePadded) : agePadded;
|
|
1769
|
+
console.log(` ${marker}${nameDisplay} ${kindPadded} ${ageDisplay} ${progressPadded}`);
|
|
1453
1770
|
}
|
|
1454
1771
|
console.log();
|
|
1455
|
-
|
|
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`);
|
|
1772
|
+
console.log(` ${pc.bold("Summary:")} ${statusResult.summary.total} change(s), ${statusResult.summary.focused} focused, ${pc.yellow(String(statusResult.summary.blocked))} blocked`);
|
|
1459
1773
|
console.log();
|
|
1460
|
-
|
|
1461
|
-
if (
|
|
1774
|
+
printArchiveTrend(statusResult.archiveTrend);
|
|
1775
|
+
if (statusResult.summary.blocked > 0) {
|
|
1462
1776
|
const blocked = filteredRecords.filter((r) => r.isBlocked);
|
|
1463
1777
|
console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
|
|
1464
1778
|
console.log();
|
|
1465
1779
|
}
|
|
1780
|
+
return statusResult;
|
|
1466
1781
|
}
|
|
1467
|
-
async function
|
|
1468
|
-
const
|
|
1469
|
-
const indexPath = join13(archiveDir, "INDEX.md");
|
|
1782
|
+
async function readArchiveTrend(runtime, runOptions) {
|
|
1783
|
+
const indexPath = join13(ARCHIVES_DIR, "INDEX.md");
|
|
1470
1784
|
if (!existsSync13(indexPath))
|
|
1471
|
-
return;
|
|
1785
|
+
return [];
|
|
1472
1786
|
try {
|
|
1473
|
-
const content = await
|
|
1787
|
+
const content = await readFile10(indexPath, "utf-8");
|
|
1474
1788
|
const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
|
|
1475
1789
|
const counts = {};
|
|
1476
1790
|
let match;
|
|
@@ -1480,28 +1794,83 @@ async function showArchiveTrend() {
|
|
|
1480
1794
|
}
|
|
1481
1795
|
const months = Object.keys(counts).sort();
|
|
1482
1796
|
if (months.length === 0)
|
|
1483
|
-
return;
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1797
|
+
return [];
|
|
1798
|
+
return months.map((month) => ({ month, count: counts[month] }));
|
|
1799
|
+
} catch (error) {
|
|
1800
|
+
recordRuntimeDiagnostic(runtime, {
|
|
1801
|
+
code: "archive_index_read_failed",
|
|
1802
|
+
operation: "readFile",
|
|
1803
|
+
path: indexPath,
|
|
1804
|
+
message: toErrorMessage(error)
|
|
1805
|
+
}, Boolean(runOptions.verbose) && !runOptions.json);
|
|
1806
|
+
return [];
|
|
1492
1807
|
}
|
|
1493
1808
|
}
|
|
1809
|
+
function printArchiveTrend(trend) {
|
|
1810
|
+
if (trend.length === 0)
|
|
1811
|
+
return;
|
|
1812
|
+
console.log(` ${pc.bold("Archive trend:")}`);
|
|
1813
|
+
const bar = trend.map(({ month, count }) => pc.dim(`${month} ${"\u2588".repeat(Math.min(count, 20))} ${count}`)).join("\n ");
|
|
1814
|
+
console.log(` ${bar}`);
|
|
1815
|
+
console.log();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/commands/update.ts
|
|
1819
|
+
import { existsSync as existsSync14 } from "fs";
|
|
1820
|
+
import { mkdir as mkdir8, readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
|
|
1821
|
+
import { join as join14 } from "path";
|
|
1822
|
+
var SKILL_REFRESH_HINT = " Note: if you use the published RSP skill, refresh it too:\n npx skills add oevery/rsp\n";
|
|
1823
|
+
async function updateProject() {
|
|
1824
|
+
if (!existsSync14(RSP_DIR)) {
|
|
1825
|
+
console.log(` ${pc.red("Error:")} RSP is not initialized. Run: rsp init`);
|
|
1826
|
+
process.exit(1);
|
|
1827
|
+
}
|
|
1828
|
+
return withRspLock("update", async () => {
|
|
1829
|
+
let updated = false;
|
|
1830
|
+
const bundledRules = await readFile11(join14(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
|
|
1831
|
+
const rulesPath = join14(RSP_DIR, "rules", "rsp-rules.md");
|
|
1832
|
+
await mkdir8(join14(RSP_DIR, "rules"), { recursive: true });
|
|
1833
|
+
const existingRules = existsSync14(rulesPath) ? await readFile11(rulesPath, "utf-8") : null;
|
|
1834
|
+
if (existingRules !== bundledRules) {
|
|
1835
|
+
await writeFile9(rulesPath, bundledRules);
|
|
1836
|
+
console.log(` ${pc.green("\u2713")} rules/rsp-rules.md updated`);
|
|
1837
|
+
updated = true;
|
|
1838
|
+
}
|
|
1839
|
+
const agentsPath = "AGENTS.md";
|
|
1840
|
+
const projectName = await detectProjectName();
|
|
1841
|
+
const baseAgents = existsSync14(agentsPath) ? await readFile11(agentsPath, "utf-8") : `# ${projectName}
|
|
1842
|
+
|
|
1843
|
+
`;
|
|
1844
|
+
const nextAgents = upsertRspAgentsBlock(baseAgents);
|
|
1845
|
+
if (!existsSync14(agentsPath) || nextAgents.changed) {
|
|
1846
|
+
await writeFile9(agentsPath, nextAgents.content);
|
|
1847
|
+
console.log(` ${pc.green("\u2713")} AGENTS.md managed block refreshed`);
|
|
1848
|
+
updated = true;
|
|
1849
|
+
}
|
|
1850
|
+
await buildSpecsIndex({ acquireLock: false });
|
|
1851
|
+
await buildArchiveIndex({ acquireLock: false });
|
|
1852
|
+
if (!updated) {
|
|
1853
|
+
console.log(` ${pc.dim("Already up to date.")}
|
|
1854
|
+
`);
|
|
1855
|
+
console.log(SKILL_REFRESH_HINT);
|
|
1856
|
+
} else {
|
|
1857
|
+
console.log(` ${pc.green("Update complete.")}
|
|
1858
|
+
`);
|
|
1859
|
+
console.log(SKILL_REFRESH_HINT);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1494
1863
|
|
|
1495
1864
|
// src/cli.ts
|
|
1496
1865
|
var initCommand = defineCommand({
|
|
1497
1866
|
meta: {
|
|
1498
1867
|
name: "init",
|
|
1499
|
-
description: "Scaffold .rsp/
|
|
1868
|
+
description: "Scaffold .rsp/ and ensure AGENTS.md contains the RSP entry block"
|
|
1500
1869
|
},
|
|
1501
1870
|
args: {
|
|
1502
1871
|
"agents-mode": {
|
|
1503
1872
|
type: "string",
|
|
1504
|
-
description: "How to
|
|
1873
|
+
description: "How to handle AGENTS.md output: managed or print",
|
|
1505
1874
|
default: "managed"
|
|
1506
1875
|
},
|
|
1507
1876
|
"with-project-rules": {
|
|
@@ -1511,33 +1880,37 @@ var initCommand = defineCommand({
|
|
|
1511
1880
|
},
|
|
1512
1881
|
"with-project-setup": {
|
|
1513
1882
|
type: "boolean",
|
|
1514
|
-
description: "Create .rsp/
|
|
1883
|
+
description: "Create .rsp/changes/project-setup.md and focus it",
|
|
1515
1884
|
default: false
|
|
1516
1885
|
}
|
|
1517
1886
|
},
|
|
1518
1887
|
async run({ args }) {
|
|
1519
1888
|
await initProject({
|
|
1520
|
-
agentsMode: args["agents-mode"] === "
|
|
1889
|
+
agentsMode: args["agents-mode"] === "print" ? "print" : "managed",
|
|
1521
1890
|
withProjectRules: Boolean(args["with-project-rules"]),
|
|
1522
1891
|
withProjectSetup: Boolean(args["with-project-setup"])
|
|
1523
1892
|
});
|
|
1524
1893
|
}
|
|
1525
1894
|
});
|
|
1526
|
-
var
|
|
1895
|
+
var createCommand = defineCommand({
|
|
1527
1896
|
meta: {
|
|
1528
|
-
name: "
|
|
1529
|
-
description: "Create .rsp/
|
|
1897
|
+
name: "create",
|
|
1898
|
+
description: "Create .rsp/changes/<name>.md [summary]"
|
|
1530
1899
|
},
|
|
1531
1900
|
args: {
|
|
1532
1901
|
name: {
|
|
1533
1902
|
type: "positional",
|
|
1534
|
-
description: "
|
|
1903
|
+
description: "Change name",
|
|
1535
1904
|
required: true
|
|
1905
|
+
},
|
|
1906
|
+
kind: {
|
|
1907
|
+
type: "string",
|
|
1908
|
+
description: "Optional kind for a kind-aware template (feature, fix, refactor, docs, ops, research)"
|
|
1536
1909
|
}
|
|
1537
1910
|
},
|
|
1538
1911
|
async run({ args }) {
|
|
1539
1912
|
const summary = Array.isArray(args._) && args._.length > 1 ? args._.slice(1).join(" ") : "";
|
|
1540
|
-
await
|
|
1913
|
+
await createChange(args.name, summary, args.kind);
|
|
1541
1914
|
}
|
|
1542
1915
|
});
|
|
1543
1916
|
var addRulesCommand = defineCommand({
|
|
@@ -1582,111 +1955,141 @@ var addCommand = defineCommand({
|
|
|
1582
1955
|
spec: addSpecCommand
|
|
1583
1956
|
}
|
|
1584
1957
|
});
|
|
1585
|
-
var
|
|
1958
|
+
var archiveCommand = defineCommand({
|
|
1959
|
+
meta: {
|
|
1960
|
+
name: "archive",
|
|
1961
|
+
description: "Archive an open change into .rsp/archives/"
|
|
1962
|
+
},
|
|
1963
|
+
args: {
|
|
1964
|
+
name: {
|
|
1965
|
+
type: "positional",
|
|
1966
|
+
description: "Change name",
|
|
1967
|
+
required: true
|
|
1968
|
+
}
|
|
1969
|
+
},
|
|
1970
|
+
async run({ args }) {
|
|
1971
|
+
await archiveChange(args.name);
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
var focusCommand = defineCommand({
|
|
1586
1975
|
meta: {
|
|
1587
|
-
name: "
|
|
1588
|
-
description: "
|
|
1976
|
+
name: "focus",
|
|
1977
|
+
description: "Mark an existing change as currently focused in .rsp/focus.d/"
|
|
1589
1978
|
},
|
|
1590
1979
|
args: {
|
|
1591
1980
|
name: {
|
|
1592
1981
|
type: "positional",
|
|
1593
|
-
description: "
|
|
1982
|
+
description: "Change name",
|
|
1594
1983
|
required: true
|
|
1595
1984
|
}
|
|
1596
1985
|
},
|
|
1597
1986
|
async run({ args }) {
|
|
1598
|
-
await
|
|
1987
|
+
await focusChange(args.name);
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
var unfocusCommand = defineCommand({
|
|
1991
|
+
meta: {
|
|
1992
|
+
name: "unfocus",
|
|
1993
|
+
description: "Remove a change from the current focus set in .rsp/focus.d/"
|
|
1994
|
+
},
|
|
1995
|
+
args: {
|
|
1996
|
+
name: {
|
|
1997
|
+
type: "positional",
|
|
1998
|
+
description: "Change name",
|
|
1999
|
+
required: true
|
|
2000
|
+
}
|
|
2001
|
+
},
|
|
2002
|
+
async run({ args }) {
|
|
2003
|
+
await unfocusChange(args.name);
|
|
1599
2004
|
}
|
|
1600
2005
|
});
|
|
1601
2006
|
var statusCommand = defineCommand({
|
|
1602
2007
|
meta: {
|
|
1603
2008
|
name: "status",
|
|
1604
|
-
description: "Show project status summary"
|
|
2009
|
+
description: "Show project status summary with current focus information"
|
|
1605
2010
|
},
|
|
1606
2011
|
args: {
|
|
1607
|
-
|
|
2012
|
+
focused: {
|
|
1608
2013
|
type: "boolean",
|
|
1609
|
-
description: "Show only
|
|
2014
|
+
description: "Show only currently focused changes",
|
|
1610
2015
|
default: false
|
|
1611
2016
|
},
|
|
1612
2017
|
blocked: {
|
|
1613
2018
|
type: "boolean",
|
|
1614
|
-
description: "Show only blocked
|
|
2019
|
+
description: "Show only blocked changes",
|
|
1615
2020
|
default: false
|
|
1616
2021
|
},
|
|
1617
2022
|
stale: {
|
|
1618
2023
|
type: "string",
|
|
1619
|
-
description: "Show only
|
|
2024
|
+
description: "Show only changes with age >= days"
|
|
2025
|
+
},
|
|
2026
|
+
json: {
|
|
2027
|
+
type: "boolean",
|
|
2028
|
+
description: "Print machine-readable JSON output",
|
|
2029
|
+
default: false
|
|
2030
|
+
},
|
|
2031
|
+
verbose: {
|
|
2032
|
+
type: "boolean",
|
|
2033
|
+
description: "Print runtime diagnostics for suppressed I/O issues",
|
|
2034
|
+
default: false
|
|
1620
2035
|
}
|
|
1621
2036
|
},
|
|
1622
2037
|
async run({ args }) {
|
|
1623
2038
|
const stale = args.stale === void 0 ? void 0 : Number(args.stale);
|
|
1624
2039
|
if (args.stale !== void 0 && (Number.isFinite(stale) === false || Number.isInteger(stale) === false || stale < 0)) {
|
|
1625
|
-
|
|
2040
|
+
if (args.json) {
|
|
2041
|
+
emitStatusJsonError({
|
|
2042
|
+
code: "invalid_stale_filter",
|
|
2043
|
+
message: "--stale must be a non-negative integer number of days"
|
|
2044
|
+
}, {
|
|
2045
|
+
focused: Boolean(args.focused),
|
|
2046
|
+
blocked: Boolean(args.blocked)
|
|
2047
|
+
});
|
|
2048
|
+
} else {
|
|
2049
|
+
console.error(` Error: --stale must be a non-negative integer number of days`);
|
|
2050
|
+
}
|
|
1626
2051
|
process.exit(1);
|
|
1627
2052
|
}
|
|
1628
2053
|
await showStatus({
|
|
1629
|
-
|
|
2054
|
+
focused: Boolean(args.focused),
|
|
1630
2055
|
blocked: Boolean(args.blocked),
|
|
1631
2056
|
stale
|
|
2057
|
+
}, {
|
|
2058
|
+
json: Boolean(args.json),
|
|
2059
|
+
verbose: Boolean(args.verbose)
|
|
1632
2060
|
});
|
|
1633
2061
|
}
|
|
1634
2062
|
});
|
|
1635
2063
|
var checkCommand = defineCommand({
|
|
1636
2064
|
meta: {
|
|
1637
2065
|
name: "check",
|
|
1638
|
-
description: "Validate
|
|
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)"
|
|
2066
|
+
description: "Validate changes (frontmatter, sections, deltas, scenarios)"
|
|
1650
2067
|
},
|
|
1651
2068
|
args: {
|
|
1652
|
-
|
|
2069
|
+
json: {
|
|
1653
2070
|
type: "boolean",
|
|
1654
|
-
description: "
|
|
2071
|
+
description: "Print machine-readable JSON output",
|
|
1655
2072
|
default: false
|
|
1656
2073
|
},
|
|
1657
|
-
|
|
1658
|
-
type: "
|
|
1659
|
-
description: "
|
|
1660
|
-
|
|
1661
|
-
reverse: {
|
|
1662
|
-
type: "string",
|
|
1663
|
-
description: "Show only features that depend on the given feature"
|
|
2074
|
+
verbose: {
|
|
2075
|
+
type: "boolean",
|
|
2076
|
+
description: "Print runtime diagnostics for suppressed I/O issues",
|
|
2077
|
+
default: false
|
|
1664
2078
|
}
|
|
1665
2079
|
},
|
|
1666
2080
|
async run({ args }) {
|
|
1667
|
-
await
|
|
1668
|
-
|
|
1669
|
-
|
|
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();
|
|
2081
|
+
const result = await runCheck({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
|
|
2082
|
+
if (!result.ok)
|
|
2083
|
+
process.exit(1);
|
|
1681
2084
|
}
|
|
1682
2085
|
});
|
|
1683
|
-
var
|
|
2086
|
+
var updateCommand = defineCommand({
|
|
1684
2087
|
meta: {
|
|
1685
|
-
name: "
|
|
1686
|
-
description: "
|
|
2088
|
+
name: "update",
|
|
2089
|
+
description: "Refresh RSP project structure after upgrade (rules, AGENTS, indices)"
|
|
1687
2090
|
},
|
|
1688
2091
|
async run() {
|
|
1689
|
-
await
|
|
2092
|
+
await updateProject();
|
|
1690
2093
|
}
|
|
1691
2094
|
});
|
|
1692
2095
|
var doctorCommand = defineCommand({
|
|
@@ -1694,9 +2097,21 @@ var doctorCommand = defineCommand({
|
|
|
1694
2097
|
name: "doctor",
|
|
1695
2098
|
description: "Check RSP setup health and common integration issues"
|
|
1696
2099
|
},
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
2100
|
+
args: {
|
|
2101
|
+
json: {
|
|
2102
|
+
type: "boolean",
|
|
2103
|
+
description: "Print machine-readable JSON output",
|
|
2104
|
+
default: false
|
|
2105
|
+
},
|
|
2106
|
+
verbose: {
|
|
2107
|
+
type: "boolean",
|
|
2108
|
+
description: "Print runtime diagnostics for suppressed I/O issues",
|
|
2109
|
+
default: false
|
|
2110
|
+
}
|
|
2111
|
+
},
|
|
2112
|
+
async run({ args }) {
|
|
2113
|
+
const result = await runDoctor({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
|
|
2114
|
+
if (!result.ok)
|
|
1700
2115
|
process.exit(1);
|
|
1701
2116
|
}
|
|
1702
2117
|
});
|
|
@@ -1706,19 +2121,19 @@ async function runCli(rawArgs = process.argv.slice(2)) {
|
|
|
1706
2121
|
meta: {
|
|
1707
2122
|
name: "rsp",
|
|
1708
2123
|
version,
|
|
1709
|
-
description: "RSP (Rules, Specs, Plans) workflow for AI-assisted
|
|
2124
|
+
description: "RSP (Rules, Specs, Plans) workflow for lightweight AI-assisted change management"
|
|
1710
2125
|
},
|
|
1711
2126
|
subCommands: {
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2127
|
+
init: initCommand,
|
|
2128
|
+
add: addCommand,
|
|
2129
|
+
create: createCommand,
|
|
2130
|
+
focus: focusCommand,
|
|
2131
|
+
unfocus: unfocusCommand,
|
|
2132
|
+
archive: archiveCommand,
|
|
2133
|
+
status: statusCommand,
|
|
2134
|
+
check: checkCommand,
|
|
2135
|
+
update: updateCommand,
|
|
2136
|
+
doctor: doctorCommand
|
|
1722
2137
|
}
|
|
1723
2138
|
});
|
|
1724
2139
|
await runMain(main, { rawArgs });
|