@oevery/rsp 3.1.0-beta.1 → 3.1.0-beta.3
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 +101 -87
- package/README.zh-CN.md +88 -64
- package/dist/{chunk-QLXIDVKK.mjs → chunk-7KKAUBKN.mjs} +309 -1255
- package/dist/chunk-JOMFFQRG.mjs +99 -0
- package/dist/chunk-NX55NIC3.mjs +358 -0
- package/dist/chunk-OTJFL6XH.mjs +1022 -0
- package/dist/cli.mjs +533 -561
- package/dist/entry-WZDVKDUM.mjs +174 -0
- package/dist/{entry-LAPR3TNI.mjs → entry-X23GPCVS.mjs} +8 -92
- package/package.json +11 -10
- package/rules/rsp-rules.md +16 -14
- package/skills/rsp/SKILL.md +48 -176
- package/skills/rsp/references/conflict-handling.md +11 -0
- package/skills/rsp/references/durable-review.md +17 -0
- package/skills/rsp/references/groups-dependencies.md +11 -0
- package/skills/rsp/references/managed-routing.md +51 -0
- package/skills/rsp/references/setup-repair.md +11 -0
- package/skills/rsp-commit/SKILL.md +42 -0
- package/skills/rsp-design/SKILL.md +14 -9
- package/skills/rsp-manage/SKILL.md +38 -26
- package/skills/rsp-release-docs/SKILL.md +36 -128
- package/skills/rsp-release-docs/references/evidence-and-surfaces.md +21 -0
- package/skills/rsp-release-docs/references/output-contracts.md +0 -18
- package/skills/rsp-release-docs/references/publication-lifecycle.md +24 -0
- package/skills/{rsp-address-review → rsp-resolve-findings}/SKILL.md +7 -7
- package/skills/rsp-shape/SKILL.md +19 -17
- package/skills/rsp-shape/references/complex-shaping.md +2 -2
- package/skills/rsp-structural-audit/SKILL.md +71 -0
- package/skills/rsp-structural-audit/references/structural-lenses.md +41 -0
|
@@ -1,947 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
path;
|
|
25
|
-
};
|
|
26
|
-
function inspectManagedDirectory(path, label, options = {}) {
|
|
27
|
-
try {
|
|
28
|
-
const stats = lstatSync(path);
|
|
29
|
-
if (stats.isDirectory())
|
|
30
|
-
return { exists: true, issue: null };
|
|
31
|
-
return {
|
|
32
|
-
exists: true,
|
|
33
|
-
issue: new ManagedPathError(path, `${label} must be a real directory: ${path}`)
|
|
34
|
-
};
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (error.code === "ENOENT") {
|
|
37
|
-
if (options.allowMissing)
|
|
38
|
-
return { exists: false, issue: null };
|
|
39
|
-
return {
|
|
40
|
-
exists: false,
|
|
41
|
-
issue: new ManagedPathError(path, `${label} must be a real directory: ${path}`)
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
-
return {
|
|
46
|
-
exists: false,
|
|
47
|
-
issue: new ManagedPathError(path, `unable to inspect ${label} ${path}: ${message}`)
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function requireManagedDirectory(path, label, options = {}) {
|
|
52
|
-
const inspection = inspectManagedDirectory(path, label, options);
|
|
53
|
-
if (inspection.issue)
|
|
54
|
-
throw inspection.issue;
|
|
55
|
-
return inspection.exists;
|
|
56
|
-
}
|
|
57
|
-
function inspectManagedFile(path, label, options = {}) {
|
|
58
|
-
try {
|
|
59
|
-
const stats = lstatSync(path);
|
|
60
|
-
if (stats.isFile())
|
|
61
|
-
return { exists: true, issue: null };
|
|
62
|
-
return {
|
|
63
|
-
exists: true,
|
|
64
|
-
issue: new ManagedPathError(path, `${label} must be a regular file: ${path}`)
|
|
65
|
-
};
|
|
66
|
-
} catch (error) {
|
|
67
|
-
if (error.code === "ENOENT") {
|
|
68
|
-
if (options.allowMissing)
|
|
69
|
-
return { exists: false, issue: null };
|
|
70
|
-
return {
|
|
71
|
-
exists: false,
|
|
72
|
-
issue: new ManagedPathError(path, `${label} must be a regular file: ${path}`)
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
-
return {
|
|
77
|
-
exists: false,
|
|
78
|
-
issue: new ManagedPathError(path, `unable to inspect ${label} ${path}: ${message}`)
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function requireManagedFile(path, label, options = {}) {
|
|
83
|
-
const inspection = inspectManagedFile(path, label, options);
|
|
84
|
-
if (inspection.issue)
|
|
85
|
-
throw inspection.issue;
|
|
86
|
-
return inspection.exists;
|
|
87
|
-
}
|
|
88
|
-
async function ensureManagedFile(path, content, label) {
|
|
89
|
-
if (requireManagedFile(path, label, { allowMissing: true }))
|
|
90
|
-
return false;
|
|
91
|
-
await writeFile(path, content, { flag: "wx" });
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
async function writeManagedFile(path, content, label) {
|
|
95
|
-
const exists = requireManagedFile(path, label, { allowMissing: true });
|
|
96
|
-
if (exists)
|
|
97
|
-
await writeFile(path, content);
|
|
98
|
-
else
|
|
99
|
-
await writeFile(path, content, { flag: "wx" });
|
|
100
|
-
}
|
|
101
|
-
function resolveManagedDirectoryChain(root, segments, label) {
|
|
102
|
-
requireManagedDirectory(root, `${label} root`);
|
|
103
|
-
let current = root;
|
|
104
|
-
for (const segment of segments) {
|
|
105
|
-
if (!segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))
|
|
106
|
-
throw new ManagedPathError(current, `${label} contains an invalid directory segment: ${segment}`);
|
|
107
|
-
current = join(current, segment);
|
|
108
|
-
requireManagedDirectory(current, label, { allowMissing: true });
|
|
109
|
-
}
|
|
110
|
-
return current;
|
|
111
|
-
}
|
|
112
|
-
async function inspectManagedFileTree(root, label, options = {}) {
|
|
113
|
-
const result = { rootExists: false, files: [], issues: [] };
|
|
114
|
-
const rootInspection = inspectManagedDirectory(root, `${label} root`, options);
|
|
115
|
-
if (rootInspection.issue) {
|
|
116
|
-
result.issues.push(rootInspection.issue);
|
|
117
|
-
return result;
|
|
118
|
-
}
|
|
119
|
-
if (!rootInspection.exists)
|
|
120
|
-
return result;
|
|
121
|
-
result.rootExists = true;
|
|
122
|
-
await collectManagedFiles(root, label, result);
|
|
123
|
-
result.files.sort();
|
|
124
|
-
return result;
|
|
125
|
-
}
|
|
126
|
-
async function collectManagedFiles(directory, label, result) {
|
|
127
|
-
let entries;
|
|
128
|
-
try {
|
|
129
|
-
entries = await readdir(directory, { withFileTypes: true });
|
|
130
|
-
} catch (error) {
|
|
131
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
-
result.issues.push(new ManagedPathError(directory, `unable to inspect the ${label} tree at ${directory}: ${message}`));
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
136
|
-
if (entry.name.startsWith("."))
|
|
137
|
-
continue;
|
|
138
|
-
const path = join(directory, entry.name);
|
|
139
|
-
if (entry.isDirectory()) {
|
|
140
|
-
await collectManagedFiles(path, label, result);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (entry.isFile()) {
|
|
144
|
-
result.files.push(path);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
result.issues.push(new ManagedPathError(path, `unsupported entry in the ${label} tree: ${path}`));
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// src/core/decisions.ts
|
|
152
|
-
var DEFAULT_DECISION_RECORDS_PATH = ".rsp/specs/decisions";
|
|
153
|
-
function normalizeDecisionRecordsPath(pathValue) {
|
|
154
|
-
return posix.normalize(pathValue.trim().replace(/\\/g, "/")).replace(/^\.\//, "").replace(/\/$/, "");
|
|
155
|
-
}
|
|
156
|
-
function validateDecisionRecordsPath(pathValue) {
|
|
157
|
-
if (typeof pathValue !== "string" || pathValue.trim() === "")
|
|
158
|
-
return 'config.yaml field "decisions.path" must be a non-empty string';
|
|
159
|
-
const raw = pathValue.trim();
|
|
160
|
-
const portable = raw.replace(/\\/g, "/");
|
|
161
|
-
if (isAbsolute(raw) || posix.isAbsolute(portable) || /^[a-z]:[\\/]/i.test(raw))
|
|
162
|
-
return 'config.yaml field "decisions.path" must be project-relative';
|
|
163
|
-
const normalized = normalizeDecisionRecordsPath(raw);
|
|
164
|
-
if (normalized === "." || normalized === ".." || normalized.startsWith("../"))
|
|
165
|
-
return 'config.yaml field "decisions.path" must stay inside the Host Project';
|
|
166
|
-
if ((normalized === ".rsp" || normalized.startsWith(".rsp/")) && normalized !== DEFAULT_DECISION_RECORDS_PATH)
|
|
167
|
-
return `config.yaml field "decisions.path" must use ${DEFAULT_DECISION_RECORDS_PATH} or a path outside .rsp/`;
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
function getDecisionRecordsConfigIssue(config) {
|
|
171
|
-
if (!("decisions" in config))
|
|
172
|
-
return null;
|
|
173
|
-
const decisions = config.decisions;
|
|
174
|
-
if (!decisions || typeof decisions !== "object" || Array.isArray(decisions))
|
|
175
|
-
return 'config.yaml field "decisions" must be a mapping with one path';
|
|
176
|
-
const keys = Object.keys(decisions);
|
|
177
|
-
if (keys.length !== 1 || keys[0] !== "path")
|
|
178
|
-
return 'config.yaml field "decisions" supports only "path"';
|
|
179
|
-
return validateDecisionRecordsPath(decisions.path);
|
|
180
|
-
}
|
|
181
|
-
function resolveDecisionRecordsPath(config) {
|
|
182
|
-
const configured = config.decisions?.path;
|
|
183
|
-
return configured && validateDecisionRecordsPath(configured) === null ? normalizeDecisionRecordsPath(configured) : DEFAULT_DECISION_RECORDS_PATH;
|
|
184
|
-
}
|
|
185
|
-
async function ensureDecisionRecordsDirectory(pathValue) {
|
|
186
|
-
const filesystemError = await validateDecisionRecordsFilesystemPath(pathValue);
|
|
187
|
-
if (filesystemError)
|
|
188
|
-
throw new Error(filesystemError);
|
|
189
|
-
const existed = existsSync(pathValue);
|
|
190
|
-
await mkdir(pathValue, { recursive: true });
|
|
191
|
-
const entries = await readdir2(pathValue);
|
|
192
|
-
if (entries.length === 0) {
|
|
193
|
-
await ensureManagedFile(join2(pathValue, ".gitkeep"), "", "Decision Record placeholder");
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
if (entries.includes(".gitkeep"))
|
|
197
|
-
requireManagedFile(join2(pathValue, ".gitkeep"), "Decision Record placeholder");
|
|
198
|
-
return !existed;
|
|
199
|
-
}
|
|
200
|
-
async function validateDecisionRecordsFilesystemPath(pathValue) {
|
|
201
|
-
const projectRoot = await realpath(".");
|
|
202
|
-
let existingAncestor = resolve(pathValue);
|
|
203
|
-
while (!existsSync(existingAncestor)) {
|
|
204
|
-
const parent = dirname(existingAncestor);
|
|
205
|
-
if (parent === existingAncestor)
|
|
206
|
-
return `Decision Record path "${pathValue}" has no accessible Host Project ancestor`;
|
|
207
|
-
existingAncestor = parent;
|
|
208
|
-
}
|
|
209
|
-
const resolvedAncestor = await realpath(existingAncestor);
|
|
210
|
-
const rel = relative(projectRoot, resolvedAncestor);
|
|
211
|
-
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
212
|
-
return `Decision Record path "${pathValue}" resolves outside the Host Project through ${existingAncestor}`;
|
|
213
|
-
if (existsSync(resolve(pathValue)) && !(await stat(resolve(pathValue))).isDirectory())
|
|
214
|
-
return `Decision Record path "${pathValue}" must be a directory`;
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// src/core/helpers.ts
|
|
219
|
-
import { existsSync as existsSync2 } from "fs";
|
|
220
|
-
import { readdir as readdir3, readFile, rmdir } from "fs/promises";
|
|
221
|
-
import { basename, dirname as dirname2, join as join3, relative as relative2, sep as sep2 } from "path";
|
|
222
|
-
import { parse } from "yaml";
|
|
223
|
-
var RSP_AGENTS_BEGIN = "<!-- rsp:begin -->";
|
|
224
|
-
var RSP_AGENTS_END = "<!-- rsp:end -->";
|
|
225
|
-
function parseYamlText(text) {
|
|
226
|
-
const parsed = parse(text);
|
|
227
|
-
if (parsed === null || parsed === void 0)
|
|
228
|
-
return {};
|
|
229
|
-
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
230
|
-
throw new Error("YAML document must be a mapping/object");
|
|
231
|
-
return parsed;
|
|
232
|
-
}
|
|
233
|
-
function parseFrontmatter(content) {
|
|
234
|
-
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n/);
|
|
235
|
-
if (!m)
|
|
236
|
-
return null;
|
|
237
|
-
return parseYamlText(m[1]);
|
|
238
|
-
}
|
|
239
|
-
function countCheckboxes(content) {
|
|
240
|
-
const todo = (content.match(/\[ \]/g) || []).length;
|
|
241
|
-
const progress = (content.match(/\[\/\]/g) || []).length;
|
|
242
|
-
const done = (content.match(/\[x\]/g) || []).length;
|
|
243
|
-
const dropped = (content.match(/\[-\]/g) || []).length;
|
|
244
|
-
return { todo, progress, done, dropped, total: todo + progress + done + dropped };
|
|
245
|
-
}
|
|
246
|
-
async function walkMarkdownFiles(dir, options = {}) {
|
|
247
|
-
const all = await walkFiles(dir, options);
|
|
248
|
-
return all.filter((f) => f.endsWith(".md"));
|
|
249
|
-
}
|
|
250
|
-
async function inspectUnsupportedRules() {
|
|
251
|
-
const rulesDir = join3(RSP_DIR, "rules");
|
|
252
|
-
const diagnostics = [];
|
|
253
|
-
const rawEntries = await walkAllEntries(rulesDir, diagnostics);
|
|
254
|
-
const entries = rawEntries.filter((entry) => entry.path !== OBSOLETE_RSP_RULES_PATH).map((entry) => ({
|
|
255
|
-
path: normalizeLogicalPath(relative2(rulesDir, entry.path)),
|
|
256
|
-
kind: entry.kind
|
|
257
|
-
}));
|
|
258
|
-
const obsoleteFallbackPresent = existsSync2(OBSOLETE_RSP_RULES_PATH);
|
|
259
|
-
return {
|
|
260
|
-
entries,
|
|
261
|
-
diagnostics,
|
|
262
|
-
directoryRemaining: existsSync2(rulesDir) && (!obsoleteFallbackPresent || entries.length > 0)
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
async function walkAllEntries(dir, diagnostics) {
|
|
266
|
-
const entries = [];
|
|
267
|
-
try {
|
|
268
|
-
const items = await readdir3(dir, { withFileTypes: true });
|
|
269
|
-
for (const item of items) {
|
|
270
|
-
const path = join3(dir, item.name);
|
|
271
|
-
const kind = item.isDirectory() ? "directory" : "file";
|
|
272
|
-
entries.push({ path, kind });
|
|
273
|
-
if (kind === "directory")
|
|
274
|
-
entries.push(...await walkAllEntries(path, diagnostics));
|
|
275
|
-
}
|
|
276
|
-
} catch (error) {
|
|
277
|
-
if (error.code !== "ENOENT") {
|
|
278
|
-
diagnostics.push({
|
|
279
|
-
code: "walk_failed",
|
|
280
|
-
operation: "readdir",
|
|
281
|
-
path: dir,
|
|
282
|
-
message: toErrorMessage(error)
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return entries;
|
|
287
|
-
}
|
|
288
|
-
async function walkFiles(dir, options = {}) {
|
|
289
|
-
const files = [];
|
|
290
|
-
try {
|
|
291
|
-
const items = await readdir3(dir, { withFileTypes: true });
|
|
292
|
-
for (const item of items) {
|
|
293
|
-
if (item.name.startsWith("."))
|
|
294
|
-
continue;
|
|
295
|
-
const filePath = join3(dir, item.name);
|
|
296
|
-
if (item.isDirectory())
|
|
297
|
-
files.push(...await walkFiles(filePath, options));
|
|
298
|
-
else
|
|
299
|
-
files.push(filePath);
|
|
300
|
-
}
|
|
301
|
-
} catch (error) {
|
|
302
|
-
if (error.code !== "ENOENT") {
|
|
303
|
-
options.onError?.({
|
|
304
|
-
code: "walk_failed",
|
|
305
|
-
operation: "readdir",
|
|
306
|
-
path: dir,
|
|
307
|
-
message: toErrorMessage(error)
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return files;
|
|
312
|
-
}
|
|
313
|
-
function normalizeLogicalPath(pathValue) {
|
|
314
|
-
return pathValue.split(sep2).join("/").replace(/\\/g, "/");
|
|
315
|
-
}
|
|
316
|
-
function generateChangeContent(name, summary = "", kind, options = {}) {
|
|
317
|
-
const proposalSummary = summary || (name === "project-setup" ? "Capture the project model, boundaries, and stable local constraints" : "<one-line summary>");
|
|
318
|
-
if (name === "project-setup") {
|
|
319
|
-
return `---
|
|
320
|
-
kind: ops
|
|
321
|
-
---
|
|
322
|
-
|
|
323
|
-
# Change: project-setup
|
|
324
|
-
|
|
325
|
-
## Proposal
|
|
326
|
-
- Outcome: ${proposalSummary}
|
|
327
|
-
- Why:
|
|
328
|
-
- Establish a durable project model before normal implementation work starts
|
|
329
|
-
- Scope:
|
|
330
|
-
- Review the repository structure, entrypoints, and primary outputs
|
|
331
|
-
- Fill .rsp/specs/design.md with durable architecture facts
|
|
332
|
-
- Add stable navigation, context, instructions, or validation steps to project-owned CONTEXT.md or the nearest AGENTS.md when those conventions exist
|
|
333
|
-
- Non-goals:
|
|
334
|
-
- Do not duplicate durable project facts across this change, specs, and AGENTS.md
|
|
335
|
-
|
|
336
|
-
## Spec
|
|
337
|
-
### ADDED
|
|
338
|
-
- Requirement: project bootstrap capture
|
|
339
|
-
- The repository's purpose, scope, and structure are reflected in .rsp/specs/design.md
|
|
340
|
-
|
|
341
|
-
### MODIFIED
|
|
342
|
-
- Requirement: stable project-owned context and instructions
|
|
343
|
-
- Stable navigation and context are reflected in project-owned CONTEXT.md when that convention exists
|
|
344
|
-
- Stable agent instructions are reflected in the nearest project-owned AGENTS.md when needed
|
|
345
|
-
|
|
346
|
-
### Acceptance
|
|
347
|
-
#### Scenario: project model captured
|
|
348
|
-
- GIVEN an initialized RSP project
|
|
349
|
-
- WHEN project setup is completed
|
|
350
|
-
- THEN .rsp/specs/design.md reflects durable project facts
|
|
351
|
-
- AND project-owned CONTEXT.md and the nearest AGENTS.md contain only the stable context and instructions assigned to them by Host Project conventions
|
|
352
|
-
|
|
353
|
-
## Design
|
|
354
|
-
- Approach:
|
|
355
|
-
- Keep bootstrap work in this single Change while returning durable outcomes to their existing project owners
|
|
356
|
-
- Boundaries:
|
|
357
|
-
- Specs own stable current facts; project-owned CONTEXT.md and AGENTS.md retain their navigation, context, and instruction roles
|
|
358
|
-
- Affected areas:
|
|
359
|
-
- .rsp/specs/design.md
|
|
360
|
-
- Project-owned CONTEXT.md or AGENTS.md when applicable
|
|
361
|
-
- Durable outcome targets:
|
|
362
|
-
- Current facts: .rsp/specs/design.md, existing scoped Specs, CONTEXT.md, or AGENTS.md as applicable
|
|
363
|
-
- Lasting rationale: one Decision Record under the configured authoritative path, only when a hard-to-reverse tradeoff exists
|
|
364
|
-
- Constraints:
|
|
365
|
-
- Keep the setup lightweight and avoid duplicating durable project facts
|
|
366
|
-
|
|
367
|
-
## Tasks
|
|
368
|
-
- [ ] Review the repository structure, entrypoints, and primary outputs
|
|
369
|
-
- [ ] Review project-owned CONTEXT.md and AGENTS.md and confirm the RSP entry points to the right project files
|
|
370
|
-
- [ ] Fill .rsp/specs/design.md with durable architecture facts
|
|
371
|
-
- [ ] Add stable project-owned context, instructions, or validation steps only where the Host Project convention requires them
|
|
372
|
-
|
|
373
|
-
## Verify
|
|
374
|
-
- Automated:
|
|
375
|
-
- [ ] Run rsp doctor \u2014 proves: the initialized RSP workspace is structurally valid
|
|
376
|
-
- Manual or environment:
|
|
377
|
-
- [ ] Review .rsp/specs/design.md and project-owned context or instructions and confirm they match the repository
|
|
378
|
-
- Coverage:
|
|
379
|
-
- none
|
|
380
|
-
|
|
381
|
-
## Blockers
|
|
382
|
-
- none
|
|
383
|
-
`;
|
|
384
|
-
}
|
|
385
|
-
const frontmatterKind = kind ?? "<choose: feature | fix | refactor | docs | ops | research>";
|
|
386
|
-
if (options.lite)
|
|
387
|
-
return generateLiteChangeContent(name, proposalSummary, frontmatterKind);
|
|
388
|
-
const template = getChangeTemplateByKind(kind);
|
|
389
|
-
return `---
|
|
390
|
-
kind: "${frontmatterKind}"
|
|
391
|
-
---
|
|
392
|
-
|
|
393
|
-
# Change: ${name}
|
|
394
|
-
|
|
395
|
-
## Proposal
|
|
396
|
-
- Outcome: ${proposalSummary}
|
|
397
|
-
- Why:
|
|
398
|
-
- ${template.why}
|
|
399
|
-
- Scope:
|
|
400
|
-
- ${template.scope}
|
|
401
|
-
- Non-goals:
|
|
402
|
-
- ${template.nonGoals}
|
|
403
|
-
|
|
404
|
-
## Spec
|
|
405
|
-
${template.specSection}
|
|
406
|
-
|
|
407
|
-
### Acceptance
|
|
408
|
-
${template.acceptanceSection}
|
|
409
|
-
|
|
410
|
-
## Design
|
|
411
|
-
- Approach:
|
|
412
|
-
- ${template.approach}
|
|
413
|
-
- Boundaries:
|
|
414
|
-
- <module, interface, data, document, or ownership boundary affected>
|
|
415
|
-
- Affected areas:
|
|
416
|
-
- ${template.affectedArea1}
|
|
417
|
-
- ${template.affectedArea2}
|
|
418
|
-
- Constraints:
|
|
419
|
-
- ${template.constraints}
|
|
420
|
-
|
|
421
|
-
## Tasks
|
|
422
|
-
- [ ] ${template.task}
|
|
423
|
-
|
|
424
|
-
## Verify
|
|
425
|
-
- Automated:
|
|
426
|
-
- [ ] ${template.automatedVerify} \u2014 proves: <behavior or scope covered>
|
|
427
|
-
- Manual or environment:
|
|
428
|
-
- [ ] ${template.manualVerify}
|
|
429
|
-
- Coverage:
|
|
430
|
-
- <omitted or unavailable relevant coverage and reason, or none>
|
|
431
|
-
|
|
432
|
-
## Blockers
|
|
433
|
-
- none
|
|
434
|
-
`;
|
|
435
|
-
}
|
|
436
|
-
function generateLiteChangeContent(name, proposalSummary, frontmatterKind) {
|
|
437
|
-
return `---
|
|
438
|
-
kind: "${frontmatterKind}"
|
|
439
|
-
---
|
|
440
|
-
|
|
441
|
-
# Change: ${name}
|
|
442
|
-
|
|
443
|
-
## Proposal
|
|
444
|
-
- Outcome: ${proposalSummary}
|
|
445
|
-
- Why:
|
|
446
|
-
- <why this small change matters>
|
|
447
|
-
- Scope:
|
|
448
|
-
- <what will change>
|
|
449
|
-
- Non-goals:
|
|
450
|
-
- none
|
|
451
|
-
|
|
452
|
-
## Spec
|
|
453
|
-
### MODIFIED
|
|
454
|
-
- Requirement: <observable outcome>
|
|
455
|
-
- <what should be true after this change>
|
|
456
|
-
|
|
457
|
-
### Acceptance
|
|
458
|
-
#### Scenario: change is complete
|
|
459
|
-
- GIVEN <current context>
|
|
460
|
-
- WHEN <the change is applied>
|
|
461
|
-
- THEN <expected outcome>
|
|
462
|
-
|
|
463
|
-
## Design
|
|
464
|
-
- Approach:
|
|
465
|
-
- <minimal implementation approach>
|
|
466
|
-
- Boundaries:
|
|
467
|
-
- <primary ownership or interface boundary>
|
|
468
|
-
- Affected areas:
|
|
469
|
-
- <primary file, directory, or doc path>
|
|
470
|
-
- Constraints:
|
|
471
|
-
- <important constraint, or none>
|
|
472
|
-
|
|
473
|
-
## Tasks
|
|
474
|
-
- [ ] Implement the small change
|
|
475
|
-
|
|
476
|
-
## Verify
|
|
477
|
-
- Automated:
|
|
478
|
-
- [ ] <cheapest decisive check; retain a new test only when justified, or not applicable: reason> \u2014 proves: <behavior or scope covered>
|
|
479
|
-
- Manual or environment:
|
|
480
|
-
- [ ] <exact acceptance scenario, or unavailable/not applicable: owner and reason>
|
|
481
|
-
- Coverage:
|
|
482
|
-
- <omitted or unavailable relevant coverage and reason, or none>
|
|
483
|
-
|
|
484
|
-
## Blockers
|
|
485
|
-
- none
|
|
486
|
-
`;
|
|
487
|
-
}
|
|
488
|
-
function getChangeTemplateByKind(kind) {
|
|
489
|
-
const automatedVerify = "<cheapest decisive check; retain a new test only when justified, or not applicable: reason>";
|
|
490
|
-
switch (kind) {
|
|
491
|
-
case "feature":
|
|
492
|
-
return {
|
|
493
|
-
why: "<what user need or capability gap this addresses>",
|
|
494
|
-
scope: "<what new behavior or capability will be delivered>",
|
|
495
|
-
nonGoals: "<what related capabilities are explicitly out of scope>",
|
|
496
|
-
specSection: "<!-- Describe observable behavior and requirements. Implementation notes belong in ## Design. -->\n### ADDED\n- Requirement: <new user-facing behavior>\n - <verifiable requirement for the new capability>",
|
|
497
|
-
acceptanceSection: "#### Scenario: user exercises the new capability\n- GIVEN <context>\n- WHEN <user action>\n- THEN <expected new behavior>",
|
|
498
|
-
approach: "<how the new capability will be implemented>",
|
|
499
|
-
affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
|
|
500
|
-
affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
|
|
501
|
-
constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
|
|
502
|
-
task: "<implement the new behavior and its acceptance scenario in the affected areas>",
|
|
503
|
-
automatedVerify,
|
|
504
|
-
manualVerify: "<exact end-to-end user scenario to validate the new capability, or unavailable/not applicable: owner and reason>"
|
|
505
|
-
};
|
|
506
|
-
case "fix":
|
|
507
|
-
return {
|
|
508
|
-
why: "<what current behavior is wrong or broken>",
|
|
509
|
-
scope: "<what specific defect is being corrected>",
|
|
510
|
-
nonGoals: "<what related but separate issues are out of scope>",
|
|
511
|
-
specSection: "<!-- Describe expected correct behavior. Implementation notes belong in ## Design. -->\n### MODIFIED\n- Requirement: correct behavior\n - <expected correct behavior after the fix>",
|
|
512
|
-
acceptanceSection: "#### Scenario: defect is resolved\n- GIVEN <conditions that trigger the defect>\n- WHEN <action that previously failed>\n- THEN <expected correct outcome>\n- AND the original broken outcome no longer occurs",
|
|
513
|
-
approach: "<confirmed cause and correction strategy, or exact evidence needed to establish the cause>",
|
|
514
|
-
affectedArea1: "<concrete file path, directory, module, or subsystem containing the defect>",
|
|
515
|
-
affectedArea2: "<secondary path, dependency, or test file if needed>",
|
|
516
|
-
constraints: "<regression, backward-compatibility, safety, or scope constraint that must hold>",
|
|
517
|
-
task: "<identify the root cause and apply the minimal fix>",
|
|
518
|
-
automatedVerify,
|
|
519
|
-
manualVerify: "<exact steps to reproduce the original issue and confirm it no longer occurs, or unavailable/not applicable: owner and reason>"
|
|
520
|
-
};
|
|
521
|
-
case "refactor":
|
|
522
|
-
return {
|
|
523
|
-
why: "<what maintainability, readability, or structural issue motivates this>",
|
|
524
|
-
scope: "<what code areas are being restructured>",
|
|
525
|
-
nonGoals: "<no behavior change is expected or intended>",
|
|
526
|
-
specSection: "<!-- Describe the desired structural outcome. Implementation notes belong in ## Design. -->\n### MODIFIED\n- Requirement: internal structure improvement\n - <desired structural outcome without observable behavior change>",
|
|
527
|
-
acceptanceSection: "#### Scenario: behavior is preserved after restructuring\n- GIVEN the existing codebase before the refactor\n- WHEN the refactored code runs against existing tests\n- THEN all existing tests pass unchanged",
|
|
528
|
-
approach: "<how the code will be restructured (rename, extract, move, etc.)>",
|
|
529
|
-
affectedArea1: "<concrete file path, directory, module, or subsystem being refactored>",
|
|
530
|
-
affectedArea2: "<secondary path, dependency, or test file if needed>",
|
|
531
|
-
constraints: "<observable behavior must remain unchanged; compatibility, performance, and safety constraints must still hold>",
|
|
532
|
-
task: "<restructure the code while keeping behavior identical>",
|
|
533
|
-
automatedVerify,
|
|
534
|
-
manualVerify: "<exact scenario to spot-check that behavior is unchanged after the refactor, or unavailable/not applicable: owner and reason>"
|
|
535
|
-
};
|
|
536
|
-
case "docs":
|
|
537
|
-
return {
|
|
538
|
-
why: "<why the documentation change matters>",
|
|
539
|
-
scope: "<which docs, readers, or workflows will be updated>",
|
|
540
|
-
nonGoals: "<what documentation behavior or audience is out of scope>",
|
|
541
|
-
specSection: "<!-- Describe what the reader should see or experience. Implementation notes belong in ## Design. -->\n### MODIFIED\n- Requirement: documentation accuracy\n - <what durable reader-facing behavior or explanation changes>",
|
|
542
|
-
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",
|
|
543
|
-
approach: "<how the documentation will be updated and organized>",
|
|
544
|
-
affectedArea1: "<concrete doc path, directory, module doc, or documentation surface 1>",
|
|
545
|
-
affectedArea2: "<secondary doc path or reference file if needed>",
|
|
546
|
-
constraints: "<durable wording, consistency, scope, or audience constraint that must hold>",
|
|
547
|
-
task: "<update the target documentation and supporting references>",
|
|
548
|
-
automatedVerify,
|
|
549
|
-
manualVerify: "<exact reader or operator scenario to confirm the updated guidance is accurate, or unavailable/not applicable: owner and reason>"
|
|
550
|
-
};
|
|
551
|
-
case "research":
|
|
552
|
-
return {
|
|
553
|
-
why: "<what question or uncertainty this research resolves>",
|
|
554
|
-
scope: "<what system area, option set, or hypothesis is being examined>",
|
|
555
|
-
nonGoals: "<what implementation work is explicitly out of scope>",
|
|
556
|
-
specSection: "<!-- Describe what finding or decision must be captured. Implementation notes belong in ## Design. -->\n### ADDED\n- Requirement: research outcome recording\n - <what finding, option, or decision must be captured clearly>",
|
|
557
|
-
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",
|
|
558
|
-
approach: "<how the investigation will be performed and what evidence will be gathered>",
|
|
559
|
-
affectedArea1: "<concrete file path, directory, module, subsystem, or source under investigation>",
|
|
560
|
-
affectedArea2: "<secondary path, dependency, environment, or evidence source if needed>",
|
|
561
|
-
constraints: "<time, evidence, safety, or scope constraint for the investigation>",
|
|
562
|
-
task: "<gather evidence, record the recommendation or finding, and note any follow-up implementation work if needed>",
|
|
563
|
-
automatedVerify,
|
|
564
|
-
manualVerify: "<exact review of the findings to confirm they answer the original question and identify follow-up work if needed, or unavailable/not applicable: owner and reason>"
|
|
565
|
-
};
|
|
566
|
-
case "ops":
|
|
567
|
-
return {
|
|
568
|
-
why: "<why this operational or environment change matters>",
|
|
569
|
-
scope: "<what environment, workflow, or operational path changes>",
|
|
570
|
-
nonGoals: "<what product or feature behavior will not change>",
|
|
571
|
-
specSection: "<!-- Describe the reliable operational outcome. Implementation notes belong in ## Design. -->\n### MODIFIED\n- Requirement: operational behavior\n - <what reliable operational outcome should change or stay true>",
|
|
572
|
-
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",
|
|
573
|
-
approach: "<how the operational change will be applied safely>",
|
|
574
|
-
affectedArea1: "<concrete script, config path, workflow, environment path, or operational surface 1>",
|
|
575
|
-
affectedArea2: "<secondary script, config path, dependency, or environment surface if needed>",
|
|
576
|
-
constraints: "<rollback, safety, reliability, environment, or scope constraint that must hold>",
|
|
577
|
-
task: "<apply and verify the operational change>",
|
|
578
|
-
automatedVerify,
|
|
579
|
-
manualVerify: "<exact operator workflow or environment scenario to confirm the operational result, or unavailable/not applicable: owner and reason>"
|
|
580
|
-
};
|
|
581
|
-
default:
|
|
582
|
-
return {
|
|
583
|
-
why: "<why this change matters>",
|
|
584
|
-
scope: "<what this change will do>",
|
|
585
|
-
nonGoals: "<what this change will not do>",
|
|
586
|
-
specSection: "<!-- Describe observable behavior and requirements. Implementation notes belong in ## Design. -->\n### ADDED\n- Requirement: <new or updated behavior>\n - <verifiable requirement>",
|
|
587
|
-
acceptanceSection: "#### Scenario: <name>\n- GIVEN <context>\n- WHEN <action>\n- THEN <expected outcome>",
|
|
588
|
-
approach: "<how the change will be implemented>",
|
|
589
|
-
affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
|
|
590
|
-
affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
|
|
591
|
-
constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
|
|
592
|
-
task: "<implement the core change in the affected areas>",
|
|
593
|
-
automatedVerify,
|
|
594
|
-
manualVerify: "<exact acceptance scenario, or unavailable/not applicable: owner and reason>"
|
|
595
|
-
};
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
function renderRspAgentsBlock() {
|
|
599
|
-
return `${RSP_AGENTS_BEGIN}
|
|
600
|
-
## RSP Entry
|
|
601
|
-
|
|
602
|
-
RSP tracks current work, stable specs, and archives under \`.rsp/\`.
|
|
603
|
-
|
|
604
|
-
Read in order:
|
|
605
|
-
1. Nearest \`AGENTS.md\` for project or module instructions.
|
|
606
|
-
2. Root \`CONTEXT-MAP.md\` if present, then the relevant nearest \`CONTEXT.md\`.
|
|
607
|
-
3. The \`rsp\` skill; if unavailable, read \`.rsp/rsp-rules.md\` as the fallback protocol.
|
|
608
|
-
4. \`.rsp/focus.d/\`; for grouped work read the sibling Group Brief, then the explicitly selected focused Change.
|
|
609
|
-
5. Only the relevant Specs and Decision Records under the configured authoritative path.
|
|
610
|
-
|
|
611
|
-
If \`.rsp/focus.d/\` is empty and the user has not provided a concrete task, ask what to work on or suggest \`npx -y @oevery/rsp create <name>\` for tracked work.
|
|
612
|
-
Do not treat \`.rsp/specs/\` or \`.rsp/changes/\` as replacements for nearest \`AGENTS.md\` or \`CONTEXT.md\`.
|
|
613
|
-
${RSP_AGENTS_END}`;
|
|
614
|
-
}
|
|
615
|
-
function upsertRspAgentsBlock(content) {
|
|
616
|
-
const block = renderRspAgentsBlock();
|
|
617
|
-
const managedRe = /<!-- rsp:begin -->[\s\S]*?<!-- rsp:end -->/;
|
|
618
|
-
if (managedRe.test(content)) {
|
|
619
|
-
const next2 = content.replace(managedRe, block);
|
|
620
|
-
return { content: next2, changed: next2 !== content };
|
|
621
|
-
}
|
|
622
|
-
const trimmed = content.trimStart();
|
|
623
|
-
const next = trimmed ? `${block}
|
|
624
|
-
|
|
625
|
-
${trimmed}` : `${block}
|
|
626
|
-
`;
|
|
627
|
-
return { content: next, changed: true };
|
|
628
|
-
}
|
|
629
|
-
function hasRspAgentsBlock(content) {
|
|
630
|
-
return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
|
|
631
|
-
}
|
|
632
|
-
var SPEC_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
633
|
-
function isValidSpecName(name) {
|
|
634
|
-
return SPEC_NAME_RE.test(name);
|
|
635
|
-
}
|
|
636
|
-
function guardRspInitialized() {
|
|
637
|
-
const designPath = join3(RSP_DIR, "specs", "design.md");
|
|
638
|
-
const rules = inspectManagedFile(RSP_RULES_PATH, "fallback protocol", { allowMissing: true });
|
|
639
|
-
const design = inspectManagedFile(designPath, "design Spec", { allowMissing: true });
|
|
640
|
-
if (rules.issue || design.issue || !rules.exists || !design.exists) {
|
|
641
|
-
const initialized = existsSync2(RSP_DIR);
|
|
642
|
-
console.error(` ${pc.red("Error:")} ${initialized ? "RSP project requires an update" : "RSP is not initialized in this project"}`);
|
|
643
|
-
console.error(` ${pc.dim(initialized ? "Run: rsp update" : "Run: rsp init")}`);
|
|
644
|
-
process.exit(1);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
function getOpenCheckboxes(sectionText) {
|
|
648
|
-
return sectionText.split("\n").map((line) => line.trim()).filter((line) => /^- \[[ /]\]/.test(line));
|
|
649
|
-
}
|
|
650
|
-
async function cleanupEmptyParentDirs(path, stopDir) {
|
|
651
|
-
let parent = dirname2(path);
|
|
652
|
-
while (parent !== stopDir) {
|
|
653
|
-
try {
|
|
654
|
-
const remaining = await readdir3(parent);
|
|
655
|
-
if (remaining.length === 0)
|
|
656
|
-
await rmdir(parent);
|
|
657
|
-
else
|
|
658
|
-
break;
|
|
659
|
-
} catch {
|
|
660
|
-
break;
|
|
661
|
-
}
|
|
662
|
-
parent = dirname2(parent);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
async function detectProjectName() {
|
|
666
|
-
try {
|
|
667
|
-
if (existsSync2("package.json")) {
|
|
668
|
-
const raw = await readFile("package.json", "utf-8");
|
|
669
|
-
const pkg = JSON.parse(raw);
|
|
670
|
-
if (pkg.name && pkg.name !== "")
|
|
671
|
-
return pkg.name;
|
|
672
|
-
}
|
|
673
|
-
} catch {
|
|
674
|
-
}
|
|
675
|
-
return basename(process.cwd());
|
|
676
|
-
}
|
|
677
|
-
function generateDesignContent(projectName) {
|
|
678
|
-
return `# Project Design: ${projectName}
|
|
679
|
-
|
|
680
|
-
## Purpose
|
|
681
|
-
- <what this project is responsible for>
|
|
682
|
-
- <who or what it serves>
|
|
683
|
-
|
|
684
|
-
## Stable Facts
|
|
685
|
-
- <durable fact that future agents or developers must know>
|
|
686
|
-
- <key architectural invariant>
|
|
687
|
-
|
|
688
|
-
## Boundaries
|
|
689
|
-
- In scope:
|
|
690
|
-
- <major capability or boundary>
|
|
691
|
-
- Out of scope:
|
|
692
|
-
- <intentional non-goal>
|
|
693
|
-
|
|
694
|
-
## Structure
|
|
695
|
-
- <important directory or subsystem> \u2014 <responsibility>
|
|
696
|
-
|
|
697
|
-
## Constraints
|
|
698
|
-
- <cross-cutting technical or operational constraint, when it affects architecture>
|
|
699
|
-
`;
|
|
700
|
-
}
|
|
701
|
-
function generateSpecContent(name) {
|
|
702
|
-
const title = toTitleCase(name);
|
|
703
|
-
return `# ${title}
|
|
704
|
-
|
|
705
|
-
## Purpose
|
|
706
|
-
- <why this project-level spec exists>
|
|
707
|
-
|
|
708
|
-
## Stable Facts
|
|
709
|
-
- <durable fact that future work must know>
|
|
710
|
-
|
|
711
|
-
## Boundaries
|
|
712
|
-
- In scope:
|
|
713
|
-
- <what this spec covers>
|
|
714
|
-
- Out of scope:
|
|
715
|
-
- <what this spec does not cover>
|
|
716
|
-
|
|
717
|
-
## Constraints
|
|
718
|
-
- <stable constraint, if any>
|
|
719
|
-
`;
|
|
720
|
-
}
|
|
721
|
-
function toTitleCase(value) {
|
|
722
|
-
return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
723
|
-
}
|
|
724
|
-
function extractSection(content, heading) {
|
|
725
|
-
const lines = content.split("\n");
|
|
726
|
-
const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
|
|
727
|
-
if (start === -1)
|
|
728
|
-
return "";
|
|
729
|
-
const body = [];
|
|
730
|
-
for (let index = start + 1; index < lines.length; index++) {
|
|
731
|
-
const line = lines[index];
|
|
732
|
-
if (line.startsWith("## "))
|
|
733
|
-
break;
|
|
734
|
-
body.push(line);
|
|
735
|
-
}
|
|
736
|
-
return body.join("\n").trim();
|
|
737
|
-
}
|
|
738
|
-
function extractBlockerLines(content) {
|
|
739
|
-
const uncommented = content.replace(/<!--[\s\S]*?-->/g, "");
|
|
740
|
-
return extractSection(uncommented, "Blockers").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
741
|
-
}
|
|
742
|
-
function hasMeaningfulBlockers(content) {
|
|
743
|
-
const lines = extractBlockerLines(content);
|
|
744
|
-
if (lines.length === 0)
|
|
745
|
-
return false;
|
|
746
|
-
return lines.some((line) => !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
747
|
-
}
|
|
748
|
-
function detectDeltaSections(content) {
|
|
749
|
-
const body = extractSection(content, "Spec");
|
|
750
|
-
return {
|
|
751
|
-
added: /^###\s*ADDED/im.test(body),
|
|
752
|
-
modified: /^###\s*MODIFIED/im.test(body),
|
|
753
|
-
removed: /^###\s*REMOVED/im.test(body)
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
function collectArchiveReadiness(content, options = {}) {
|
|
757
|
-
const warnings = [];
|
|
758
|
-
const tasksSection = extractSection(content, "Tasks");
|
|
759
|
-
const verifySection = extractSection(content, "Verify");
|
|
760
|
-
const taskTodos = getOpenCheckboxes(tasksSection);
|
|
761
|
-
if (taskTodos.length > 0)
|
|
762
|
-
warnings.push(`${taskTodos.length} task item(s) still incomplete`);
|
|
763
|
-
const verifyTodos = getOpenCheckboxes(verifySection);
|
|
764
|
-
if (verifyTodos.length > 0)
|
|
765
|
-
warnings.push(`${verifyTodos.length} Verify checklist item(s) are still incomplete`);
|
|
766
|
-
const activeBlockers = options.activeBlockers ?? hasMeaningfulBlockers(content);
|
|
767
|
-
if (activeBlockers)
|
|
768
|
-
warnings.push("active blockers are present in the change file");
|
|
769
|
-
const scenarios = parseScenarios(content);
|
|
770
|
-
const missingScenarios = scenarios.length === 0;
|
|
771
|
-
if (missingScenarios)
|
|
772
|
-
warnings.push("no Scenario blocks found (some changes do not need them)");
|
|
773
|
-
const archiveReady = activeBlockers ? "no" : warnings.length === 0 ? "yes" : "judgment";
|
|
774
|
-
return {
|
|
775
|
-
taskTodos,
|
|
776
|
-
verifyTodos,
|
|
777
|
-
activeBlockers,
|
|
778
|
-
scenarioCount: scenarios.length,
|
|
779
|
-
missingScenarios,
|
|
780
|
-
deterministic: warnings.length === 0 ? "pass" : "warnings",
|
|
781
|
-
semantic: "needs-review",
|
|
782
|
-
archiveReady,
|
|
783
|
-
warnings
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
function buildDurableReviewGuidance(factCandidateTargets, decisionRecordsPath) {
|
|
787
|
-
return {
|
|
788
|
-
required: true,
|
|
789
|
-
factDecisions: [
|
|
790
|
-
"No current-fact update needed",
|
|
791
|
-
"Update existing spec or scoped instruction",
|
|
792
|
-
"Create a new durable spec"
|
|
793
|
-
],
|
|
794
|
-
rationaleDecisions: [
|
|
795
|
-
"No Decision Record needed",
|
|
796
|
-
"Create or update a Decision Record"
|
|
797
|
-
],
|
|
798
|
-
factCandidateTargets,
|
|
799
|
-
decisionRecordsPath,
|
|
800
|
-
note: "Semantic review decides current-fact and lasting-rationale updates independently. The CLI never infers scoped instruction or Decision Record filenames and never promotes Change content automatically."
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
function getDurableReviewCandidateTargets() {
|
|
804
|
-
return [".rsp/specs/design.md"];
|
|
805
|
-
}
|
|
806
|
-
function collectArchiveChecklist(content, options = {}) {
|
|
807
|
-
return collectArchiveReadiness(content, options).warnings;
|
|
808
|
-
}
|
|
809
|
-
function parseScenarios(content) {
|
|
810
|
-
const scenarios = [];
|
|
811
|
-
const regex = /^#{3,4}\s*Scenario:\s*(.+)$/gim;
|
|
812
|
-
let match;
|
|
813
|
-
while ((match = regex.exec(content)) !== null) {
|
|
814
|
-
const heading = match[1].trim();
|
|
815
|
-
const startPos = match.index + match[0].length;
|
|
816
|
-
const restContent = content.slice(startPos);
|
|
817
|
-
const nextSection = restContent.match(/^#{3,4}\s/m);
|
|
818
|
-
const block = nextSection ? restContent.slice(0, nextSection.index) : restContent;
|
|
819
|
-
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*/, ""));
|
|
820
|
-
if (steps.length > 0)
|
|
821
|
-
scenarios.push({ heading, steps });
|
|
822
|
-
}
|
|
823
|
-
return scenarios;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// src/core/config.ts
|
|
827
|
-
var __dirname = dirname3(fileURLToPath(import.meta.url));
|
|
828
|
-
var PKG_ROOT = join4(__dirname, "..");
|
|
829
|
-
var RSP_DIR = ".rsp";
|
|
830
|
-
var RSP_RULES_PATH = join4(RSP_DIR, "rsp-rules.md");
|
|
831
|
-
var OBSOLETE_RSP_RULES_PATH = join4(RSP_DIR, "rules", "rsp-rules.md");
|
|
832
|
-
var CHANGES_DIR = join4(RSP_DIR, "changes");
|
|
833
|
-
var FOCUS_DIR = join4(RSP_DIR, "focus.d");
|
|
834
|
-
var ARCHIVES_DIR = join4(RSP_DIR, "archives");
|
|
835
|
-
var LOCK_PATH = join4(RSP_DIR, ".lock");
|
|
836
|
-
var CONFIG_PATH = join4(RSP_DIR, "config.yaml");
|
|
837
|
-
var VALID_KINDS = ["feature", "fix", "refactor", "docs", "ops", "research"];
|
|
838
|
-
var DEFAULT_REQUIRED_SECTIONS = ["Proposal", "Spec", "Design", "Tasks", "Verify", "Blockers"];
|
|
839
|
-
var _configCache = null;
|
|
840
|
-
async function loadRspConfig() {
|
|
841
|
-
const inspection = await inspectRspConfig();
|
|
842
|
-
if (inspection.issues.length > 0)
|
|
843
|
-
throw new Error(inspection.issues.join("; "));
|
|
844
|
-
return inspection.config;
|
|
845
|
-
}
|
|
846
|
-
async function inspectRspConfig() {
|
|
847
|
-
const cwd = process.cwd();
|
|
848
|
-
if (_configCache?.cwd === cwd)
|
|
849
|
-
return _configCache.inspection;
|
|
850
|
-
const configFile = inspectManagedFile(CONFIG_PATH, "config file", { allowMissing: true });
|
|
851
|
-
if (configFile.issue)
|
|
852
|
-
throw configFile.issue;
|
|
853
|
-
if (!configFile.exists) {
|
|
854
|
-
const inspection2 = { config: {}, issues: [] };
|
|
855
|
-
_configCache = { cwd, inspection: inspection2 };
|
|
856
|
-
return inspection2;
|
|
857
|
-
}
|
|
858
|
-
const raw = await readFile2(CONFIG_PATH, "utf-8");
|
|
859
|
-
const parsed = parseYamlText(raw);
|
|
860
|
-
const issues = validateRspConfig(parsed);
|
|
861
|
-
const decisions = parsed.decisions && typeof parsed.decisions === "object" && !Array.isArray(parsed.decisions) ? parsed.decisions : void 0;
|
|
862
|
-
const decisionPath = decisions?.path;
|
|
863
|
-
const kinds = Array.isArray(parsed.kinds) && parsed.kinds.every((value) => typeof value === "string" && value.trim() !== "") ? parsed.kinds.map((value) => value.trim()) : void 0;
|
|
864
|
-
const decisionRecordsIssue = getDecisionRecordsConfigIssue(parsed);
|
|
865
|
-
const inspection = {
|
|
866
|
-
config: {
|
|
867
|
-
kinds,
|
|
868
|
-
decisions: decisionRecordsIssue === null && validateDecisionRecordsPath(decisionPath) === null ? { path: normalizeDecisionRecordsPath(String(decisionPath)) } : void 0
|
|
869
|
-
},
|
|
870
|
-
issues
|
|
871
|
-
};
|
|
872
|
-
_configCache = { cwd, inspection };
|
|
873
|
-
return inspection;
|
|
874
|
-
}
|
|
875
|
-
function validateRspConfig(parsed) {
|
|
876
|
-
const issues = [];
|
|
877
|
-
const supported = /* @__PURE__ */ new Set(["kinds", "decisions"]);
|
|
878
|
-
for (const key of Object.keys(parsed)) {
|
|
879
|
-
if (key === "required_sections") {
|
|
880
|
-
issues.push('config.yaml field "required_sections" is no longer supported');
|
|
881
|
-
} else if (!supported.has(key)) {
|
|
882
|
-
issues.push(`config.yaml field "${key}" is not supported`);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
if ("kinds" in parsed) {
|
|
886
|
-
if (!Array.isArray(parsed.kinds)) {
|
|
887
|
-
issues.push('config.yaml field "kinds" must be a YAML list');
|
|
888
|
-
} else if (!parsed.kinds.every((value) => typeof value === "string" && value.trim() !== "")) {
|
|
889
|
-
issues.push('config.yaml field "kinds" must contain only non-empty strings');
|
|
890
|
-
} else {
|
|
891
|
-
const kinds = parsed.kinds.map((value) => value.trim());
|
|
892
|
-
const duplicates = [...new Set(kinds.filter((value, index) => kinds.indexOf(value) !== index))];
|
|
893
|
-
if (duplicates.length > 0)
|
|
894
|
-
issues.push(`config.yaml field "kinds" contains duplicate entries: ${duplicates.join(", ")}`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
const decisionRecordsIssue = getDecisionRecordsConfigIssue(parsed);
|
|
898
|
-
if (decisionRecordsIssue)
|
|
899
|
-
issues.push(decisionRecordsIssue);
|
|
900
|
-
return issues;
|
|
901
|
-
}
|
|
902
|
-
function clearConfigCache() {
|
|
903
|
-
_configCache = null;
|
|
904
|
-
}
|
|
905
|
-
function resolveKinds(config) {
|
|
906
|
-
return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
|
|
907
|
-
}
|
|
908
|
-
function resolveRequiredSections(_config) {
|
|
909
|
-
return [...DEFAULT_REQUIRED_SECTIONS];
|
|
910
|
-
}
|
|
911
|
-
async function getVersion() {
|
|
912
|
-
const { version } = JSON.parse(await readFile2(join4(PKG_ROOT, "package.json"), "utf-8"));
|
|
913
|
-
return version;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// src/core/output.ts
|
|
917
|
-
function toErrorMessage(error) {
|
|
918
|
-
return error instanceof Error ? error.message : String(error);
|
|
919
|
-
}
|
|
920
|
-
function emitJson(data, options = {}) {
|
|
921
|
-
const indentation = options.compact ? void 0 : 2;
|
|
922
|
-
process.stdout.write(`${JSON.stringify(data, null, indentation)}
|
|
923
|
-
`);
|
|
924
|
-
}
|
|
925
|
-
function recordRuntimeDiagnostic(runtime, diagnostic, verbose = false) {
|
|
926
|
-
runtime.push(diagnostic);
|
|
927
|
-
if (verbose) {
|
|
928
|
-
console.error(` ${pc.dim(`[verbose] ${diagnostic.operation} ${diagnostic.path}: ${diagnostic.message}`)}`);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
2
|
+
import {
|
|
3
|
+
ARCHIVES_DIR,
|
|
4
|
+
CHANGES_DIR,
|
|
5
|
+
CONFIG_PATH,
|
|
6
|
+
FOCUS_DIR,
|
|
7
|
+
ManagedPathError,
|
|
8
|
+
collectArchiveReadiness,
|
|
9
|
+
countCheckboxes,
|
|
10
|
+
extractBlockerLines,
|
|
11
|
+
extractSection,
|
|
12
|
+
hasMeaningfulBlockers,
|
|
13
|
+
inspectManagedDirectory,
|
|
14
|
+
inspectRspConfig,
|
|
15
|
+
normalizeLogicalPath,
|
|
16
|
+
parseFrontmatter,
|
|
17
|
+
parseScenarios,
|
|
18
|
+
requireManagedDirectory,
|
|
19
|
+
requireManagedFile,
|
|
20
|
+
resolveManagePolicy,
|
|
21
|
+
toErrorMessage
|
|
22
|
+
} from "./chunk-OTJFL6XH.mjs";
|
|
931
23
|
|
|
932
24
|
// src/status/inspect.ts
|
|
933
|
-
import {
|
|
934
|
-
import { readFile as readFile5, stat as stat2 } from "fs/promises";
|
|
935
|
-
import { join as join7 } from "path";
|
|
25
|
+
import { readFile as readFile5, stat } from "fs/promises";
|
|
936
26
|
|
|
937
27
|
// src/core/change-group.ts
|
|
938
|
-
import { readFile
|
|
939
|
-
import { dirname
|
|
28
|
+
import { readFile } from "fs/promises";
|
|
29
|
+
import { dirname, join as join2, relative as relative2 } from "path";
|
|
940
30
|
|
|
941
31
|
// src/core/work-ref.ts
|
|
942
|
-
import { lstatSync
|
|
943
|
-
import { readdir
|
|
944
|
-
import { isAbsolute
|
|
32
|
+
import { lstatSync } from "fs";
|
|
33
|
+
import { readdir } from "fs/promises";
|
|
34
|
+
import { isAbsolute, join, relative } from "path";
|
|
945
35
|
var WORK_SEGMENT_RE = /^[a-z0-9-]+$/;
|
|
946
36
|
var GROUP_BRIEF_FILENAME = "00-brief.md";
|
|
947
37
|
var WorkRefError = class extends Error {
|
|
@@ -981,7 +71,7 @@ function resolveWorkRef(name, options = {}) {
|
|
|
981
71
|
}
|
|
982
72
|
assertValidWorkRoot(changesDir, name);
|
|
983
73
|
assertNoIdentityCollision(changesDir, segments, name, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
|
|
984
|
-
const path =
|
|
74
|
+
const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
985
75
|
const ref = segments.length === 1 ? { kind: "change", name, path, group: null } : isGroupBrief ? { kind: "group-brief", name, path, group: segments[0] } : { kind: "group-change", name, path, group: segments[0] };
|
|
986
76
|
if (ref.kind === "group-change" && options.executable !== false)
|
|
987
77
|
assertGroupBrief(changesDir, ref);
|
|
@@ -996,7 +86,7 @@ function resolveWorkRef(name, options = {}) {
|
|
|
996
86
|
return ref;
|
|
997
87
|
}
|
|
998
88
|
function assertGroupBrief(changesDir, ref) {
|
|
999
|
-
const briefPath =
|
|
89
|
+
const briefPath = join(changesDir, ref.group, GROUP_BRIEF_FILENAME);
|
|
1000
90
|
const kind = getPathKind(briefPath, ref.name);
|
|
1001
91
|
if (kind === "missing") {
|
|
1002
92
|
throw new WorkRefError(
|
|
@@ -1016,7 +106,7 @@ function assertGroupBrief(changesDir, ref) {
|
|
|
1016
106
|
function assertRegularWorkFile(ref, mustExist) {
|
|
1017
107
|
let stats;
|
|
1018
108
|
try {
|
|
1019
|
-
stats =
|
|
109
|
+
stats = lstatSync(ref.path);
|
|
1020
110
|
} catch (error) {
|
|
1021
111
|
if (error.code === "ENOENT") {
|
|
1022
112
|
if (mustExist) {
|
|
@@ -1040,8 +130,8 @@ function assertRegularWorkFile(ref, mustExist) {
|
|
|
1040
130
|
}
|
|
1041
131
|
function resolveWorkRefPath(path, options = {}) {
|
|
1042
132
|
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1043
|
-
const logicalPath = normalizeLogicalPath2(
|
|
1044
|
-
if (
|
|
133
|
+
const logicalPath = normalizeLogicalPath2(relative(changesDir, path));
|
|
134
|
+
if (isAbsolute(logicalPath) || logicalPath === ".." || logicalPath.startsWith("../") || !logicalPath.endsWith(".md")) {
|
|
1045
135
|
throw new WorkRefError(
|
|
1046
136
|
"invalid_work_ref_path",
|
|
1047
137
|
`work path must be a Markdown file inside ${changesDir}: ${path}`,
|
|
@@ -1067,13 +157,13 @@ function resolveFocusMarkerPath(ref, options = {}) {
|
|
|
1067
157
|
assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
|
|
1068
158
|
if (ref.group) {
|
|
1069
159
|
assertOptionalManagedDirectory(
|
|
1070
|
-
|
|
160
|
+
join(focusDir, ref.group),
|
|
1071
161
|
ref.name,
|
|
1072
162
|
"invalid_focus_path",
|
|
1073
163
|
"focus path"
|
|
1074
164
|
);
|
|
1075
165
|
}
|
|
1076
|
-
const markerPath =
|
|
166
|
+
const markerPath = join(focusDir, ...ref.name.split("/"));
|
|
1077
167
|
try {
|
|
1078
168
|
requireManagedFile(markerPath, "focus marker", { allowMissing: true });
|
|
1079
169
|
} catch (error) {
|
|
@@ -1088,7 +178,7 @@ function resolveArchiveDirectory(ref, options = {}) {
|
|
|
1088
178
|
assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
|
|
1089
179
|
if (!ref.group)
|
|
1090
180
|
return archivesDir;
|
|
1091
|
-
const groupPath =
|
|
181
|
+
const groupPath = join(archivesDir, ref.group);
|
|
1092
182
|
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
1093
183
|
return groupPath;
|
|
1094
184
|
}
|
|
@@ -1107,7 +197,7 @@ async function inspectFocusTree(options = {}) {
|
|
|
1107
197
|
if (!rootEntries)
|
|
1108
198
|
return result;
|
|
1109
199
|
for (const entry of rootEntries) {
|
|
1110
|
-
const path =
|
|
200
|
+
const path = join(focusDir, entry.name);
|
|
1111
201
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1112
202
|
continue;
|
|
1113
203
|
if (entry.isDirectory()) {
|
|
@@ -1150,7 +240,7 @@ async function inspectArchiveTree(options = {}) {
|
|
|
1150
240
|
if (!rootEntries)
|
|
1151
241
|
return result;
|
|
1152
242
|
for (const entry of rootEntries) {
|
|
1153
|
-
const path =
|
|
243
|
+
const path = join(archivesDir, entry.name);
|
|
1154
244
|
if (entry.name === "INDEX.md" && entry.isFile())
|
|
1155
245
|
continue;
|
|
1156
246
|
if (entry.isDirectory()) {
|
|
@@ -1188,7 +278,7 @@ async function inspectWorkTree(options = {}) {
|
|
|
1188
278
|
const rootMarkdownNames = new Set(rootEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3)));
|
|
1189
279
|
const collidingGroups = new Set([...rootDirectories].filter((name) => rootMarkdownNames.has(name)));
|
|
1190
280
|
for (const entry of rootEntries) {
|
|
1191
|
-
const path =
|
|
281
|
+
const path = join(changesDir, entry.name);
|
|
1192
282
|
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1193
283
|
continue;
|
|
1194
284
|
if (entry.isDirectory()) {
|
|
@@ -1235,17 +325,17 @@ async function inspectGroupDirectory(group, groupPath, changesDir, result, colli
|
|
|
1235
325
|
"group_brief_missing",
|
|
1236
326
|
`Change Group "${group}" requires a Group Brief; run: rsp group create ${group}`,
|
|
1237
327
|
group
|
|
1238
|
-
),
|
|
328
|
+
), join(groupPath, GROUP_BRIEF_FILENAME));
|
|
1239
329
|
} else if (!briefEntry.isFile()) {
|
|
1240
330
|
addDiagnostic(result, new WorkRefError(
|
|
1241
331
|
"work_ref_not_file",
|
|
1242
|
-
`Group Brief must be a regular file: ${
|
|
332
|
+
`Group Brief must be a regular file: ${join(groupPath, GROUP_BRIEF_FILENAME)}`,
|
|
1243
333
|
group
|
|
1244
|
-
),
|
|
334
|
+
), join(groupPath, GROUP_BRIEF_FILENAME));
|
|
1245
335
|
}
|
|
1246
336
|
for (const entry of entries) {
|
|
1247
337
|
const input = `${group}/${entry.name}`;
|
|
1248
|
-
const path =
|
|
338
|
+
const path = join(groupPath, entry.name);
|
|
1249
339
|
if (entry.name === GROUP_BRIEF_FILENAME && !entry.isFile())
|
|
1250
340
|
continue;
|
|
1251
341
|
if (entry.name === "brief.md") {
|
|
@@ -1274,7 +364,7 @@ async function inspectFocusGroup(group, groupPath, changesDir, result) {
|
|
|
1274
364
|
return;
|
|
1275
365
|
for (const entry of entries) {
|
|
1276
366
|
const input = `${group}/${entry.name}`;
|
|
1277
|
-
const path =
|
|
367
|
+
const path = join(groupPath, entry.name);
|
|
1278
368
|
if (entry.isDirectory()) {
|
|
1279
369
|
addDiagnostic(result, new WorkRefError(
|
|
1280
370
|
"unsupported_work_depth",
|
|
@@ -1300,7 +390,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
|
|
|
1300
390
|
return;
|
|
1301
391
|
for (const entry of entries) {
|
|
1302
392
|
const input = `${group}/${entry.name}`;
|
|
1303
|
-
const path =
|
|
393
|
+
const path = join(groupPath, entry.name);
|
|
1304
394
|
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1305
395
|
addArchivePathDiagnostic(result, input, path);
|
|
1306
396
|
continue;
|
|
@@ -1333,7 +423,7 @@ function collectResolvedPath(path, changesDir, result) {
|
|
|
1333
423
|
}
|
|
1334
424
|
async function readDirectory(path, input, result) {
|
|
1335
425
|
try {
|
|
1336
|
-
return (await
|
|
426
|
+
return (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
1337
427
|
} catch (error) {
|
|
1338
428
|
addDiagnostic(result, asReadFailure(path, input, error), path);
|
|
1339
429
|
return null;
|
|
@@ -1360,8 +450,8 @@ function addDiagnostic(result, error, path) {
|
|
|
1360
450
|
}
|
|
1361
451
|
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
1362
452
|
for (let depth = 1; depth <= segments.length; depth++) {
|
|
1363
|
-
const identityPath =
|
|
1364
|
-
const filePath = finalFilename && depth === segments.length ?
|
|
453
|
+
const identityPath = join(changesDir, ...segments.slice(0, depth));
|
|
454
|
+
const filePath = finalFilename && depth === segments.length ? join(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
|
|
1365
455
|
const fileKind = getPathKind(filePath, input);
|
|
1366
456
|
const identityKind = getPathKind(identityPath, input);
|
|
1367
457
|
const isPrefix = depth < segments.length;
|
|
@@ -1427,7 +517,7 @@ function assertOptionalManagedDirectory(path, input, code, label) {
|
|
|
1427
517
|
}
|
|
1428
518
|
function getPathKind(path, input) {
|
|
1429
519
|
try {
|
|
1430
|
-
const stats =
|
|
520
|
+
const stats = lstatSync(path);
|
|
1431
521
|
if (stats.isFile())
|
|
1432
522
|
return "file";
|
|
1433
523
|
if (stats.isDirectory())
|
|
@@ -1499,12 +589,12 @@ async function inspectChangeGroups(options = {}) {
|
|
|
1499
589
|
const archivedGroups = /* @__PURE__ */ new Set();
|
|
1500
590
|
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
1501
591
|
for (const path of archiveTree.files) {
|
|
1502
|
-
const archivePath = normalizeLogicalPath(
|
|
592
|
+
const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
|
|
1503
593
|
const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
|
|
1504
594
|
if (!archiveGroup || !openGroupNames.has(archiveGroup))
|
|
1505
595
|
continue;
|
|
1506
596
|
try {
|
|
1507
|
-
const content = await
|
|
597
|
+
const content = await readFile(path, "utf-8");
|
|
1508
598
|
const heading = content.match(/^# Change:\s+(\S+)\s*$/m);
|
|
1509
599
|
if (heading?.[1]?.includes("/")) {
|
|
1510
600
|
const identity = heading[1];
|
|
@@ -1537,7 +627,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
1537
627
|
for (const brief of workTree.briefs) {
|
|
1538
628
|
let content;
|
|
1539
629
|
try {
|
|
1540
|
-
content = await
|
|
630
|
+
content = await readFile(brief.path, "utf-8");
|
|
1541
631
|
} catch (error) {
|
|
1542
632
|
diagnostics.push({
|
|
1543
633
|
severity: "error",
|
|
@@ -1562,7 +652,7 @@ async function inspectChangeGroups(options = {}) {
|
|
|
1562
652
|
for (const diagnostic of archiveTree.diagnostics.filter((item) => {
|
|
1563
653
|
if (item.code === "invalid_archive_root")
|
|
1564
654
|
return true;
|
|
1565
|
-
const archivePath = normalizeLogicalPath(
|
|
655
|
+
const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, item.path));
|
|
1566
656
|
return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
|
|
1567
657
|
})) {
|
|
1568
658
|
diagnostics.push({
|
|
@@ -1642,10 +732,10 @@ async function inspectChangeGroups(options = {}) {
|
|
|
1642
732
|
return { groups, diagnostics };
|
|
1643
733
|
}
|
|
1644
734
|
async function requireDeclaredGroupSlice(ref) {
|
|
1645
|
-
const briefPath =
|
|
735
|
+
const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
|
|
1646
736
|
let content;
|
|
1647
737
|
try {
|
|
1648
|
-
content = await
|
|
738
|
+
content = await readFile(briefPath, "utf-8");
|
|
1649
739
|
} catch (error) {
|
|
1650
740
|
const message = error instanceof Error ? error.message : String(error);
|
|
1651
741
|
throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
|
|
@@ -1675,11 +765,11 @@ async function hasArchivedGroupBrief(group) {
|
|
|
1675
765
|
}
|
|
1676
766
|
const groupPrefix = `${group}/`;
|
|
1677
767
|
for (const path of archiveTree.files) {
|
|
1678
|
-
if (!normalizeLogicalPath(
|
|
768
|
+
if (!normalizeLogicalPath(relative2(ARCHIVES_DIR, path)).startsWith(groupPrefix))
|
|
1679
769
|
continue;
|
|
1680
770
|
let content;
|
|
1681
771
|
try {
|
|
1682
|
-
content = await
|
|
772
|
+
content = await readFile(path, "utf-8");
|
|
1683
773
|
} catch (error) {
|
|
1684
774
|
const message = error instanceof Error ? error.message : String(error);
|
|
1685
775
|
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
@@ -1751,7 +841,7 @@ function escapeRegExp(value) {
|
|
|
1751
841
|
}
|
|
1752
842
|
|
|
1753
843
|
// src/core/dependency-plan.ts
|
|
1754
|
-
import { readFile as
|
|
844
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1755
845
|
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1756
846
|
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1757
847
|
var EXECUTABLE_WORK_REF_RE = /^[a-z0-9-]+(?:\/(?!(?:brief|00-brief)$)[a-z0-9-]+)?$/;
|
|
@@ -1770,7 +860,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1770
860
|
}));
|
|
1771
861
|
for (const path of archiveTree.files) {
|
|
1772
862
|
try {
|
|
1773
|
-
const heading = (await
|
|
863
|
+
const heading = (await readFile2(path, "utf-8")).match(/^# Change:\s+(\S+)\s*$/m);
|
|
1774
864
|
if (heading)
|
|
1775
865
|
archivedNames.add(heading[1]);
|
|
1776
866
|
} catch (error) {
|
|
@@ -1784,7 +874,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1784
874
|
}
|
|
1785
875
|
for (const brief of workTree.briefs) {
|
|
1786
876
|
try {
|
|
1787
|
-
if (hasMeaningfulBlockers(await
|
|
877
|
+
if (hasMeaningfulBlockers(await readFile2(brief.path, "utf-8")))
|
|
1788
878
|
blockedGroups.add(brief.group);
|
|
1789
879
|
} catch (error) {
|
|
1790
880
|
diagnostics.push({
|
|
@@ -1798,7 +888,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1798
888
|
}
|
|
1799
889
|
for (const ref of workTree.changes) {
|
|
1800
890
|
try {
|
|
1801
|
-
const parsed = parseDependencyBlockers(await
|
|
891
|
+
const parsed = parseDependencyBlockers(await readFile2(ref.path, "utf-8"));
|
|
1802
892
|
if (ref.group && blockedGroups.has(ref.group))
|
|
1803
893
|
parsed.external = true;
|
|
1804
894
|
parsedByName.set(ref.name, parsed);
|
|
@@ -1875,7 +965,7 @@ async function inspectChangeDependencies(options = {}) {
|
|
|
1875
965
|
const executable = new Set(graphValid ? waves.flat() : []);
|
|
1876
966
|
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
1877
967
|
let state;
|
|
1878
|
-
if (archivedNames.has(name))
|
|
968
|
+
if (!openNames.has(name) && archivedNames.has(name))
|
|
1879
969
|
state = "archived";
|
|
1880
970
|
else if (!openNames.has(name))
|
|
1881
971
|
state = "missing";
|
|
@@ -2012,223 +1102,12 @@ function findCycleDiagnostics(edges) {
|
|
|
2012
1102
|
return diagnostics;
|
|
2013
1103
|
}
|
|
2014
1104
|
|
|
2015
|
-
// src/
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
diagnostics.push({
|
|
2022
|
-
severity: "error",
|
|
2023
|
-
code: diagnostic.code,
|
|
2024
|
-
change: diagnostic.input,
|
|
2025
|
-
path: diagnostic.path,
|
|
2026
|
-
message: diagnostic.message
|
|
2027
|
-
});
|
|
2028
|
-
}
|
|
2029
|
-
const rawFocusedSet = new Set(focusTree.markers.map((marker) => marker.name));
|
|
2030
|
-
const focusedSet = /* @__PURE__ */ new Set();
|
|
2031
|
-
const focusedPaths = /* @__PURE__ */ new Map();
|
|
2032
|
-
for (const name of rawFocusedSet) {
|
|
2033
|
-
try {
|
|
2034
|
-
const ref = resolveWorkRef(name, { executable: true });
|
|
2035
|
-
focusedSet.add(ref.name);
|
|
2036
|
-
focusedPaths.set(ref.name, ref.path);
|
|
2037
|
-
} catch (error) {
|
|
2038
|
-
if (!(error instanceof WorkRefError))
|
|
2039
|
-
throw error;
|
|
2040
|
-
diagnostics.push({
|
|
2041
|
-
severity: "error",
|
|
2042
|
-
code: error.code,
|
|
2043
|
-
path: `.rsp/focus.d/${name}`,
|
|
2044
|
-
change: name,
|
|
2045
|
-
message: error.message
|
|
2046
|
-
});
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
const workTree = await inspectWorkTree();
|
|
2050
|
-
diagnostics.push(...workTree.diagnostics.map((diagnostic) => ({
|
|
2051
|
-
severity: "error",
|
|
2052
|
-
code: diagnostic.code,
|
|
2053
|
-
change: diagnostic.input,
|
|
2054
|
-
path: diagnostic.path,
|
|
2055
|
-
message: diagnostic.message
|
|
2056
|
-
})));
|
|
2057
|
-
const groupInspection = await inspectChangeGroups({ workTree, focusTree });
|
|
2058
|
-
diagnostics.push(...groupInspection.diagnostics);
|
|
2059
|
-
const dependencyInspection = await inspectChangeDependencies({
|
|
2060
|
-
workTree,
|
|
2061
|
-
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
2062
|
-
});
|
|
2063
|
-
diagnostics.push(...dependencyInspection.diagnostics);
|
|
2064
|
-
const changeMap = new Map(workTree.changes.map((ref) => [ref.name, ref.path]));
|
|
2065
|
-
const allNames = [.../* @__PURE__ */ new Set([...changeMap.keys(), ...focusedSet])].sort();
|
|
2066
|
-
const records = [];
|
|
2067
|
-
const nowMs = options.nowMs ?? Date.now();
|
|
2068
|
-
for (const name of allNames) {
|
|
2069
|
-
const path = changeMap.get(name);
|
|
2070
|
-
let kind = "\u2014";
|
|
2071
|
-
let title = name;
|
|
2072
|
-
let blockerEntries = [];
|
|
2073
|
-
let readiness = emptyReadiness(false);
|
|
2074
|
-
let ageDays = null;
|
|
2075
|
-
const isFocused = focusedSet.has(name);
|
|
2076
|
-
let isBlocked = false;
|
|
2077
|
-
let done = 0;
|
|
2078
|
-
let total = 0;
|
|
2079
|
-
let progressKnown = false;
|
|
2080
|
-
if (path) {
|
|
2081
|
-
try {
|
|
2082
|
-
const content = await readFile5(path, "utf-8");
|
|
2083
|
-
title = extractChangeTitle(content) ?? name;
|
|
2084
|
-
blockerEntries = extractBlockerEntries(content);
|
|
2085
|
-
try {
|
|
2086
|
-
const frontmatter = parseFrontmatter(content);
|
|
2087
|
-
kind = frontmatter?.kind ? String(frontmatter.kind) : "\u2014";
|
|
2088
|
-
} catch (error) {
|
|
2089
|
-
kind = "(invalid)";
|
|
2090
|
-
runtime.push({
|
|
2091
|
-
code: "frontmatter_parse_failed",
|
|
2092
|
-
operation: "parseFrontmatter",
|
|
2093
|
-
path,
|
|
2094
|
-
message: toErrorMessage(error)
|
|
2095
|
-
});
|
|
2096
|
-
}
|
|
2097
|
-
isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
|
|
2098
|
-
const checkboxes = countCheckboxes(content);
|
|
2099
|
-
done = checkboxes.done;
|
|
2100
|
-
total = checkboxes.total;
|
|
2101
|
-
progressKnown = true;
|
|
2102
|
-
const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
|
|
2103
|
-
readiness = {
|
|
2104
|
-
incompleteTasks: details.taskTodos.length,
|
|
2105
|
-
incompleteVerify: details.verifyTodos.length,
|
|
2106
|
-
activeBlockers: details.activeBlockers,
|
|
2107
|
-
missingScenarios: details.missingScenarios,
|
|
2108
|
-
deterministic: details.deterministic,
|
|
2109
|
-
semantic: details.semantic,
|
|
2110
|
-
archiveReady: details.archiveReady
|
|
2111
|
-
};
|
|
2112
|
-
} catch (error) {
|
|
2113
|
-
diagnostics.push({
|
|
2114
|
-
severity: "error",
|
|
2115
|
-
code: "change_read_failed",
|
|
2116
|
-
change: name,
|
|
2117
|
-
path,
|
|
2118
|
-
message: "unable to read open Change",
|
|
2119
|
-
hint: toErrorMessage(error)
|
|
2120
|
-
});
|
|
2121
|
-
runtime.push({
|
|
2122
|
-
code: "change_read_failed",
|
|
2123
|
-
operation: "readFile",
|
|
2124
|
-
path,
|
|
2125
|
-
message: toErrorMessage(error)
|
|
2126
|
-
});
|
|
2127
|
-
}
|
|
2128
|
-
try {
|
|
2129
|
-
const metadata = await stat2(path);
|
|
2130
|
-
ageDays = Math.floor((nowMs - metadata.mtime.getTime()) / (1e3 * 60 * 60 * 24));
|
|
2131
|
-
} catch (error) {
|
|
2132
|
-
diagnostics.push({
|
|
2133
|
-
severity: "error",
|
|
2134
|
-
code: "change_stat_failed",
|
|
2135
|
-
change: name,
|
|
2136
|
-
path,
|
|
2137
|
-
message: "unable to inspect open Change metadata",
|
|
2138
|
-
hint: toErrorMessage(error)
|
|
2139
|
-
});
|
|
2140
|
-
runtime.push({
|
|
2141
|
-
code: "change_stat_failed",
|
|
2142
|
-
operation: "stat",
|
|
2143
|
-
path,
|
|
2144
|
-
message: toErrorMessage(error)
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
} else {
|
|
2148
|
-
kind = "(missing)";
|
|
2149
|
-
const path2 = focusedPaths.get(name);
|
|
2150
|
-
diagnostics.push({
|
|
2151
|
-
severity: "error",
|
|
2152
|
-
code: "focused_change_missing",
|
|
2153
|
-
change: name,
|
|
2154
|
-
path: path2,
|
|
2155
|
-
message: "focus marker points to a missing Change file"
|
|
2156
|
-
});
|
|
2157
|
-
runtime.push({
|
|
2158
|
-
code: "focused_change_missing",
|
|
2159
|
-
operation: "resolveChange",
|
|
2160
|
-
path: path2,
|
|
2161
|
-
message: "focus marker points to a missing change file"
|
|
2162
|
-
});
|
|
2163
|
-
}
|
|
2164
|
-
records.push({
|
|
2165
|
-
output: {
|
|
2166
|
-
name,
|
|
2167
|
-
kind,
|
|
2168
|
-
progress: { done, total },
|
|
2169
|
-
ageDays,
|
|
2170
|
-
isFocused,
|
|
2171
|
-
isBlocked,
|
|
2172
|
-
path: path ? normalizeLogicalPath(path) : null
|
|
2173
|
-
},
|
|
2174
|
-
progressKnown,
|
|
2175
|
-
title,
|
|
2176
|
-
blockerEntries,
|
|
2177
|
-
readiness
|
|
2178
|
-
});
|
|
2179
|
-
}
|
|
2180
|
-
return {
|
|
2181
|
-
focused: [...focusedSet].sort(),
|
|
2182
|
-
records,
|
|
2183
|
-
groups: groupInspection.groups,
|
|
2184
|
-
plan: dependencyInspection.plan,
|
|
2185
|
-
archiveTrend: await readArchiveTrend(runtime),
|
|
2186
|
-
diagnostics,
|
|
2187
|
-
runtime
|
|
2188
|
-
};
|
|
2189
|
-
}
|
|
2190
|
-
function emptyReadiness(activeBlockers) {
|
|
2191
|
-
return {
|
|
2192
|
-
incompleteTasks: 0,
|
|
2193
|
-
incompleteVerify: 0,
|
|
2194
|
-
activeBlockers,
|
|
2195
|
-
missingScenarios: false,
|
|
2196
|
-
deterministic: "pass",
|
|
2197
|
-
semantic: "needs-review",
|
|
2198
|
-
archiveReady: activeBlockers ? "no" : "judgment"
|
|
2199
|
-
};
|
|
2200
|
-
}
|
|
2201
|
-
function extractChangeTitle(content) {
|
|
2202
|
-
const heading = content.split(/\r?\n/).find((line) => line.startsWith("# Change: "));
|
|
2203
|
-
return heading?.slice("# Change: ".length).trim() || null;
|
|
2204
|
-
}
|
|
2205
|
-
function extractBlockerEntries(content) {
|
|
2206
|
-
return extractSection(content, "Blockers").split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter((line) => line.toLowerCase() !== "none");
|
|
2207
|
-
}
|
|
2208
|
-
async function readArchiveTrend(runtime) {
|
|
2209
|
-
const indexPath = join7(ARCHIVES_DIR, "INDEX.md");
|
|
2210
|
-
if (!existsSync3(indexPath))
|
|
2211
|
-
return [];
|
|
2212
|
-
try {
|
|
2213
|
-
const content = await readFile5(indexPath, "utf-8");
|
|
2214
|
-
const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
|
|
2215
|
-
const counts = {};
|
|
2216
|
-
let match;
|
|
2217
|
-
while ((match = dateRe.exec(content)) !== null) {
|
|
2218
|
-
const month = match[1];
|
|
2219
|
-
counts[month] = (counts[month] || 0) + 1;
|
|
2220
|
-
}
|
|
2221
|
-
return Object.keys(counts).sort().map((month) => ({ month, count: counts[month] }));
|
|
2222
|
-
} catch (error) {
|
|
2223
|
-
runtime.push({
|
|
2224
|
-
code: "archive_index_read_failed",
|
|
2225
|
-
operation: "readFile",
|
|
2226
|
-
path: indexPath,
|
|
2227
|
-
message: toErrorMessage(error)
|
|
2228
|
-
});
|
|
2229
|
-
return [];
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
1105
|
+
// src/history/query.ts
|
|
1106
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1107
|
+
|
|
1108
|
+
// src/history/inspect.ts
|
|
1109
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1110
|
+
import { basename, dirname as dirname2, relative as relative3 } from "path";
|
|
2232
1111
|
|
|
2233
1112
|
// src/history/model.ts
|
|
2234
1113
|
var HISTORY_DEFAULT_LIMIT = 20;
|
|
@@ -2254,14 +1133,12 @@ var ArchiveHistoryError = class extends Error {
|
|
|
2254
1133
|
};
|
|
2255
1134
|
|
|
2256
1135
|
// src/history/inspect.ts
|
|
2257
|
-
import { readFile as readFile6 } from "fs/promises";
|
|
2258
|
-
import { basename as basename2, dirname as dirname5, relative as relative5 } from "path";
|
|
2259
1136
|
var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
|
|
2260
1137
|
var GROUP_NAME_RE = /^[a-z0-9-]+$/;
|
|
2261
1138
|
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
2262
1139
|
async function inspectArchiveHistory(options = {}) {
|
|
2263
1140
|
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
2264
|
-
const tree = await inspectArchiveTree({ archivesDir });
|
|
1141
|
+
const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
|
|
2265
1142
|
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
2266
1143
|
severity: "error",
|
|
2267
1144
|
code: diagnostic.code,
|
|
@@ -2282,7 +1159,7 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
2282
1159
|
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
2283
1160
|
let content;
|
|
2284
1161
|
try {
|
|
2285
|
-
content = await
|
|
1162
|
+
content = await readFile3(sourcePath, "utf-8");
|
|
2286
1163
|
} catch (error) {
|
|
2287
1164
|
const message = toErrorMessage(error);
|
|
2288
1165
|
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
@@ -2311,13 +1188,13 @@ async function inspectArchiveHistory(options = {}) {
|
|
|
2311
1188
|
};
|
|
2312
1189
|
}
|
|
2313
1190
|
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
2314
|
-
const nameMatch =
|
|
1191
|
+
const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
|
|
2315
1192
|
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
2316
1193
|
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
2317
1194
|
}
|
|
2318
1195
|
const date = nameMatch[1];
|
|
2319
1196
|
const archiveStem = nameMatch[2];
|
|
2320
|
-
const relativeDirectory = normalizeLogicalPath(
|
|
1197
|
+
const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
|
|
2321
1198
|
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
2322
1199
|
if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
|
|
2323
1200
|
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
@@ -2364,7 +1241,8 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
|
2364
1241
|
summary: summary.value,
|
|
2365
1242
|
summaryTruncated: summary.truncated,
|
|
2366
1243
|
path,
|
|
2367
|
-
sourcePath
|
|
1244
|
+
sourcePath,
|
|
1245
|
+
searchSummary: rawSummary
|
|
2368
1246
|
}
|
|
2369
1247
|
};
|
|
2370
1248
|
}
|
|
@@ -2392,10 +1270,10 @@ function isCalendarDate(value) {
|
|
|
2392
1270
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
2393
1271
|
}
|
|
2394
1272
|
function archiveOutputPath(sourcePath, archivesDir) {
|
|
2395
|
-
return `.rsp/archives/${normalizeLogicalPath(
|
|
1273
|
+
return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
|
|
2396
1274
|
}
|
|
2397
1275
|
function normalizeOutputPath(path, archivesDir) {
|
|
2398
|
-
const relativePath = normalizeLogicalPath(
|
|
1276
|
+
const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
|
|
2399
1277
|
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
2400
1278
|
}
|
|
2401
1279
|
function errorDiagnostic(code, path, message) {
|
|
@@ -2412,11 +1290,11 @@ function compareDiagnostics(left, right) {
|
|
|
2412
1290
|
}
|
|
2413
1291
|
|
|
2414
1292
|
// src/history/query.ts
|
|
2415
|
-
import { readFile as readFile7 } from "fs/promises";
|
|
2416
1293
|
function queryArchiveHistory(records, query = {}) {
|
|
2417
1294
|
validateArchiveHistoryQuery(query);
|
|
2418
1295
|
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
2419
|
-
const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!query.group || record.group === query.group)
|
|
1296
|
+
const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!query.group || record.group === query.group) && (!query.search || `${record.workRef}
|
|
1297
|
+
${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
|
|
2420
1298
|
const selected = matched.slice(0, limit);
|
|
2421
1299
|
return {
|
|
2422
1300
|
records: selected.map(toOutputRecord),
|
|
@@ -2441,6 +1319,8 @@ function validateArchiveHistoryQuery(query) {
|
|
|
2441
1319
|
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
2442
1320
|
if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
|
|
2443
1321
|
throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
|
|
1322
|
+
if (query.search !== void 0 && query.search.trim() === "")
|
|
1323
|
+
throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
|
|
2444
1324
|
}
|
|
2445
1325
|
function selectArchiveHistoryRecord(records, selector) {
|
|
2446
1326
|
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
@@ -2456,7 +1336,7 @@ function selectArchiveHistoryRecord(records, selector) {
|
|
|
2456
1336
|
async function readArchiveHistoryDetail(record) {
|
|
2457
1337
|
let content;
|
|
2458
1338
|
try {
|
|
2459
|
-
content = await
|
|
1339
|
+
content = await readFile4(record.sourcePath, "utf-8");
|
|
2460
1340
|
} catch (error) {
|
|
2461
1341
|
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2462
1342
|
}
|
|
@@ -2481,7 +1361,7 @@ function historyInspectionComplete(inspection) {
|
|
|
2481
1361
|
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
2482
1362
|
}
|
|
2483
1363
|
function toOutputRecord(record) {
|
|
2484
|
-
const { sourcePath: _sourcePath, ...output } = record;
|
|
1364
|
+
const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
|
|
2485
1365
|
return output;
|
|
2486
1366
|
}
|
|
2487
1367
|
function boundEvidence(section, omitNone = false) {
|
|
@@ -2502,6 +1382,234 @@ function compareHistoryRecords2(left, right) {
|
|
|
2502
1382
|
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
2503
1383
|
}
|
|
2504
1384
|
|
|
1385
|
+
// src/status/inspect.ts
|
|
1386
|
+
async function inspectProjectStatus(options = {}) {
|
|
1387
|
+
const runtime = [];
|
|
1388
|
+
const diagnostics = [];
|
|
1389
|
+
let manage = resolveManagePolicy({});
|
|
1390
|
+
try {
|
|
1391
|
+
const configInspection = await inspectRspConfig();
|
|
1392
|
+
const configValid = configInspection.issues.length === 0;
|
|
1393
|
+
manage = resolveManagePolicy(configInspection.config, { configValid });
|
|
1394
|
+
if (!configValid) {
|
|
1395
|
+
diagnostics.push({
|
|
1396
|
+
severity: "error",
|
|
1397
|
+
code: "invalid_config",
|
|
1398
|
+
path: CONFIG_PATH,
|
|
1399
|
+
message: configInspection.issues.join("; ")
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
manage = resolveManagePolicy({}, { configValid: false });
|
|
1404
|
+
diagnostics.push({
|
|
1405
|
+
severity: "error",
|
|
1406
|
+
code: "invalid_config",
|
|
1407
|
+
path: CONFIG_PATH,
|
|
1408
|
+
message: toErrorMessage(error)
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
const focusTree = await inspectFocusTree();
|
|
1412
|
+
for (const diagnostic of focusTree.diagnostics) {
|
|
1413
|
+
diagnostics.push({
|
|
1414
|
+
severity: "error",
|
|
1415
|
+
code: diagnostic.code,
|
|
1416
|
+
change: diagnostic.input,
|
|
1417
|
+
path: diagnostic.path,
|
|
1418
|
+
message: diagnostic.message
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
const rawFocusedSet = new Set(focusTree.markers.map((marker) => marker.name));
|
|
1422
|
+
const focusedSet = /* @__PURE__ */ new Set();
|
|
1423
|
+
const focusedPaths = /* @__PURE__ */ new Map();
|
|
1424
|
+
for (const name of rawFocusedSet) {
|
|
1425
|
+
try {
|
|
1426
|
+
const ref = resolveWorkRef(name, { executable: true });
|
|
1427
|
+
focusedSet.add(ref.name);
|
|
1428
|
+
focusedPaths.set(ref.name, ref.path);
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
if (!(error instanceof WorkRefError))
|
|
1431
|
+
throw error;
|
|
1432
|
+
diagnostics.push({
|
|
1433
|
+
severity: "error",
|
|
1434
|
+
code: error.code,
|
|
1435
|
+
path: `.rsp/focus.d/${name}`,
|
|
1436
|
+
change: name,
|
|
1437
|
+
message: error.message
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
const workTree = await inspectWorkTree();
|
|
1442
|
+
const archiveTree = await inspectArchiveTree();
|
|
1443
|
+
const historyInspection = await inspectArchiveHistory({ archiveTree });
|
|
1444
|
+
diagnostics.push(...workTree.diagnostics.map((diagnostic) => ({
|
|
1445
|
+
severity: "error",
|
|
1446
|
+
code: diagnostic.code,
|
|
1447
|
+
change: diagnostic.input,
|
|
1448
|
+
path: diagnostic.path,
|
|
1449
|
+
message: diagnostic.message
|
|
1450
|
+
})));
|
|
1451
|
+
const groupInspection = await inspectChangeGroups({ workTree, focusTree, archiveTree });
|
|
1452
|
+
diagnostics.push(...groupInspection.diagnostics);
|
|
1453
|
+
const dependencyInspection = await inspectChangeDependencies({
|
|
1454
|
+
workTree,
|
|
1455
|
+
archiveTree,
|
|
1456
|
+
preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
|
|
1457
|
+
});
|
|
1458
|
+
diagnostics.push(...dependencyInspection.diagnostics);
|
|
1459
|
+
const changeMap = new Map(workTree.changes.map((ref) => [ref.name, ref.path]));
|
|
1460
|
+
const allNames = [.../* @__PURE__ */ new Set([...changeMap.keys(), ...focusedSet])].sort();
|
|
1461
|
+
const records = [];
|
|
1462
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
1463
|
+
for (const name of allNames) {
|
|
1464
|
+
const path = changeMap.get(name);
|
|
1465
|
+
let kind = "\u2014";
|
|
1466
|
+
let title = name;
|
|
1467
|
+
let blockerEntries = [];
|
|
1468
|
+
let readiness = emptyReadiness(false);
|
|
1469
|
+
let ageDays = null;
|
|
1470
|
+
const isFocused = focusedSet.has(name);
|
|
1471
|
+
let isBlocked = false;
|
|
1472
|
+
let done = 0;
|
|
1473
|
+
let total = 0;
|
|
1474
|
+
let progressKnown = false;
|
|
1475
|
+
if (path) {
|
|
1476
|
+
try {
|
|
1477
|
+
const content = await readFile5(path, "utf-8");
|
|
1478
|
+
title = extractChangeTitle(content) ?? name;
|
|
1479
|
+
blockerEntries = extractBlockerEntries(content);
|
|
1480
|
+
try {
|
|
1481
|
+
const frontmatter = parseFrontmatter(content);
|
|
1482
|
+
kind = frontmatter?.kind ? String(frontmatter.kind) : "\u2014";
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
kind = "(invalid)";
|
|
1485
|
+
runtime.push({
|
|
1486
|
+
code: "frontmatter_parse_failed",
|
|
1487
|
+
operation: "parseFrontmatter",
|
|
1488
|
+
path,
|
|
1489
|
+
message: toErrorMessage(error)
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
|
|
1493
|
+
const checkboxes = countCheckboxes(content);
|
|
1494
|
+
done = checkboxes.done;
|
|
1495
|
+
total = checkboxes.total;
|
|
1496
|
+
progressKnown = true;
|
|
1497
|
+
const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
|
|
1498
|
+
readiness = {
|
|
1499
|
+
incompleteTasks: details.taskTodos.length,
|
|
1500
|
+
incompleteVerify: details.verifyTodos.length,
|
|
1501
|
+
activeBlockers: details.activeBlockers,
|
|
1502
|
+
missingScenarios: details.missingScenarios,
|
|
1503
|
+
deterministic: details.deterministic,
|
|
1504
|
+
semantic: details.semantic,
|
|
1505
|
+
archiveReady: details.archiveReady
|
|
1506
|
+
};
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
diagnostics.push({
|
|
1509
|
+
severity: "error",
|
|
1510
|
+
code: "change_read_failed",
|
|
1511
|
+
change: name,
|
|
1512
|
+
path,
|
|
1513
|
+
message: "unable to read open Change",
|
|
1514
|
+
hint: toErrorMessage(error)
|
|
1515
|
+
});
|
|
1516
|
+
runtime.push({
|
|
1517
|
+
code: "change_read_failed",
|
|
1518
|
+
operation: "readFile",
|
|
1519
|
+
path,
|
|
1520
|
+
message: toErrorMessage(error)
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
const metadata = await stat(path);
|
|
1525
|
+
ageDays = Math.floor((nowMs - metadata.mtime.getTime()) / (1e3 * 60 * 60 * 24));
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
diagnostics.push({
|
|
1528
|
+
severity: "error",
|
|
1529
|
+
code: "change_stat_failed",
|
|
1530
|
+
change: name,
|
|
1531
|
+
path,
|
|
1532
|
+
message: "unable to inspect open Change metadata",
|
|
1533
|
+
hint: toErrorMessage(error)
|
|
1534
|
+
});
|
|
1535
|
+
runtime.push({
|
|
1536
|
+
code: "change_stat_failed",
|
|
1537
|
+
operation: "stat",
|
|
1538
|
+
path,
|
|
1539
|
+
message: toErrorMessage(error)
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
} else {
|
|
1543
|
+
kind = "(missing)";
|
|
1544
|
+
const path2 = focusedPaths.get(name);
|
|
1545
|
+
diagnostics.push({
|
|
1546
|
+
severity: "error",
|
|
1547
|
+
code: "focused_change_missing",
|
|
1548
|
+
change: name,
|
|
1549
|
+
path: path2,
|
|
1550
|
+
message: "focus marker points to a missing Change file"
|
|
1551
|
+
});
|
|
1552
|
+
runtime.push({
|
|
1553
|
+
code: "focused_change_missing",
|
|
1554
|
+
operation: "resolveChange",
|
|
1555
|
+
path: path2,
|
|
1556
|
+
message: "focus marker points to a missing change file"
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
records.push({
|
|
1560
|
+
output: {
|
|
1561
|
+
name,
|
|
1562
|
+
kind,
|
|
1563
|
+
progress: { done, total },
|
|
1564
|
+
ageDays,
|
|
1565
|
+
isFocused,
|
|
1566
|
+
isBlocked,
|
|
1567
|
+
path: path ? normalizeLogicalPath(path) : null
|
|
1568
|
+
},
|
|
1569
|
+
progressKnown,
|
|
1570
|
+
title,
|
|
1571
|
+
blockerEntries,
|
|
1572
|
+
readiness
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
manage,
|
|
1577
|
+
focused: [...focusedSet].sort(),
|
|
1578
|
+
records,
|
|
1579
|
+
groups: groupInspection.groups,
|
|
1580
|
+
plan: dependencyInspection.plan,
|
|
1581
|
+
archiveTrend: historyInspectionComplete(historyInspection) ? deriveArchiveTrend(historyInspection.records) : [],
|
|
1582
|
+
diagnostics: [...diagnostics, ...historyInspection.rootExists ? historyInspection.diagnostics : []],
|
|
1583
|
+
runtime: [...runtime, ...historyInspection.runtime]
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
function emptyReadiness(activeBlockers) {
|
|
1587
|
+
return {
|
|
1588
|
+
incompleteTasks: 0,
|
|
1589
|
+
incompleteVerify: 0,
|
|
1590
|
+
activeBlockers,
|
|
1591
|
+
missingScenarios: false,
|
|
1592
|
+
deterministic: "pass",
|
|
1593
|
+
semantic: "needs-review",
|
|
1594
|
+
archiveReady: activeBlockers ? "no" : "judgment"
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
function extractChangeTitle(content) {
|
|
1598
|
+
const heading = content.split(/\r?\n/).find((line) => line.startsWith("# Change: "));
|
|
1599
|
+
return heading?.slice("# Change: ".length).trim() || null;
|
|
1600
|
+
}
|
|
1601
|
+
function extractBlockerEntries(content) {
|
|
1602
|
+
return extractSection(content, "Blockers").split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter((line) => line.toLowerCase() !== "none");
|
|
1603
|
+
}
|
|
1604
|
+
function deriveArchiveTrend(records) {
|
|
1605
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1606
|
+
for (const record of records) {
|
|
1607
|
+
const month = record.date.slice(0, 7);
|
|
1608
|
+
counts.set(month, (counts.get(month) ?? 0) + 1);
|
|
1609
|
+
}
|
|
1610
|
+
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
2505
1613
|
// src/status/dependency-forest.ts
|
|
2506
1614
|
function defaultRoots(plan) {
|
|
2507
1615
|
const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
|
|
@@ -2538,60 +1646,6 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
|
2538
1646
|
}
|
|
2539
1647
|
|
|
2540
1648
|
export {
|
|
2541
|
-
ManagedPathError,
|
|
2542
|
-
inspectManagedDirectory,
|
|
2543
|
-
requireManagedDirectory,
|
|
2544
|
-
inspectManagedFile,
|
|
2545
|
-
ensureManagedFile,
|
|
2546
|
-
writeManagedFile,
|
|
2547
|
-
resolveManagedDirectoryChain,
|
|
2548
|
-
inspectManagedFileTree,
|
|
2549
|
-
DEFAULT_DECISION_RECORDS_PATH,
|
|
2550
|
-
resolveDecisionRecordsPath,
|
|
2551
|
-
ensureDecisionRecordsDirectory,
|
|
2552
|
-
validateDecisionRecordsFilesystemPath,
|
|
2553
|
-
toErrorMessage,
|
|
2554
|
-
emitJson,
|
|
2555
|
-
recordRuntimeDiagnostic,
|
|
2556
|
-
parseFrontmatter,
|
|
2557
|
-
countCheckboxes,
|
|
2558
|
-
walkMarkdownFiles,
|
|
2559
|
-
inspectUnsupportedRules,
|
|
2560
|
-
normalizeLogicalPath,
|
|
2561
|
-
generateChangeContent,
|
|
2562
|
-
upsertRspAgentsBlock,
|
|
2563
|
-
hasRspAgentsBlock,
|
|
2564
|
-
isValidSpecName,
|
|
2565
|
-
guardRspInitialized,
|
|
2566
|
-
cleanupEmptyParentDirs,
|
|
2567
|
-
detectProjectName,
|
|
2568
|
-
generateDesignContent,
|
|
2569
|
-
generateSpecContent,
|
|
2570
|
-
extractSection,
|
|
2571
|
-
hasMeaningfulBlockers,
|
|
2572
|
-
detectDeltaSections,
|
|
2573
|
-
collectArchiveReadiness,
|
|
2574
|
-
buildDurableReviewGuidance,
|
|
2575
|
-
getDurableReviewCandidateTargets,
|
|
2576
|
-
collectArchiveChecklist,
|
|
2577
|
-
parseScenarios,
|
|
2578
|
-
pc,
|
|
2579
|
-
PKG_ROOT,
|
|
2580
|
-
RSP_DIR,
|
|
2581
|
-
RSP_RULES_PATH,
|
|
2582
|
-
OBSOLETE_RSP_RULES_PATH,
|
|
2583
|
-
CHANGES_DIR,
|
|
2584
|
-
FOCUS_DIR,
|
|
2585
|
-
ARCHIVES_DIR,
|
|
2586
|
-
LOCK_PATH,
|
|
2587
|
-
CONFIG_PATH,
|
|
2588
|
-
VALID_KINDS,
|
|
2589
|
-
loadRspConfig,
|
|
2590
|
-
inspectRspConfig,
|
|
2591
|
-
clearConfigCache,
|
|
2592
|
-
resolveKinds,
|
|
2593
|
-
resolveRequiredSections,
|
|
2594
|
-
getVersion,
|
|
2595
1649
|
GROUP_BRIEF_FILENAME,
|
|
2596
1650
|
WorkRefError,
|
|
2597
1651
|
resolveWorkRef,
|