@oevery/rsp 2.0.4 → 3.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -62
- package/README.zh-CN.md +177 -60
- package/bin/rsp.mjs +2 -2
- package/dist/chunk-QLXIDVKK.mjs +2618 -0
- package/dist/cli.mjs +1997 -1355
- package/dist/entry-LAPR3TNI.mjs +966 -0
- package/dist/locale-HT44UIUP.mjs +21 -0
- package/package.json +13 -4
- package/rules/rsp-rules.md +63 -91
- package/skills/rsp/SKILL.md +142 -60
- package/skills/rsp-address-review/SKILL.md +61 -0
- package/skills/rsp-design/SKILL.md +48 -0
- package/skills/rsp-design/references/domain-modeling.md +11 -0
- package/skills/rsp-design/references/module-seams.md +11 -0
- package/skills/rsp-design/references/reversible-exploration.md +19 -0
- package/skills/rsp-diagnose/SKILL.md +46 -0
- package/skills/rsp-implement/NOTICE.md +27 -0
- package/skills/rsp-implement/SKILL.md +52 -0
- package/skills/rsp-manage/SKILL.md +53 -0
- package/skills/rsp-release-docs/SKILL.md +120 -0
- package/skills/rsp-release-docs/references/convention-discovery.md +65 -0
- package/skills/rsp-release-docs/references/output-contracts.md +115 -0
- package/skills/rsp-review/SKILL.md +98 -0
- package/skills/rsp-shape/SKILL.md +65 -0
- package/skills/rsp-shape/references/complex-shaping.md +30 -0
- package/skills/rsp-shape/references/deep-clarification.md +29 -0
- package/skills/rsp-tdd/SKILL.md +48 -0
|
@@ -0,0 +1,2618 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/config.ts
|
|
4
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
5
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// src/core/decisions.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { mkdir, readdir as readdir2, realpath, stat } from "fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, join as join2, posix, relative, resolve, sep } from "path";
|
|
13
|
+
|
|
14
|
+
// src/core/managed-path.ts
|
|
15
|
+
import { lstatSync } from "fs";
|
|
16
|
+
import { readdir, writeFile } from "fs/promises";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
var ManagedPathError = class extends Error {
|
|
19
|
+
constructor(path, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.path = path;
|
|
22
|
+
this.name = "ManagedPathError";
|
|
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
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/status/inspect.ts
|
|
933
|
+
import { existsSync as existsSync3 } from "fs";
|
|
934
|
+
import { readFile as readFile5, stat as stat2 } from "fs/promises";
|
|
935
|
+
import { join as join7 } from "path";
|
|
936
|
+
|
|
937
|
+
// src/core/change-group.ts
|
|
938
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
939
|
+
import { dirname as dirname4, join as join6, relative as relative4 } from "path";
|
|
940
|
+
|
|
941
|
+
// src/core/work-ref.ts
|
|
942
|
+
import { lstatSync as lstatSync2 } from "fs";
|
|
943
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
944
|
+
import { isAbsolute as isAbsolute2, join as join5, relative as relative3 } from "path";
|
|
945
|
+
var WORK_SEGMENT_RE = /^[a-z0-9-]+$/;
|
|
946
|
+
var GROUP_BRIEF_FILENAME = "00-brief.md";
|
|
947
|
+
var WorkRefError = class extends Error {
|
|
948
|
+
constructor(code, message, input) {
|
|
949
|
+
super(message);
|
|
950
|
+
this.code = code;
|
|
951
|
+
this.input = input;
|
|
952
|
+
this.name = "WorkRefError";
|
|
953
|
+
}
|
|
954
|
+
code;
|
|
955
|
+
input;
|
|
956
|
+
};
|
|
957
|
+
function resolveWorkRef(name, options = {}) {
|
|
958
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
959
|
+
const segments = name.split("/");
|
|
960
|
+
const isGroupBrief = segments.length === 2 && segments[1] === "brief";
|
|
961
|
+
if (segments.length > 2) {
|
|
962
|
+
throw new WorkRefError(
|
|
963
|
+
"unsupported_work_depth",
|
|
964
|
+
`work identity "${name}" exceeds the supported one-level Change Group depth`,
|
|
965
|
+
name
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
if (!name || segments.some((segment) => !WORK_SEGMENT_RE.test(segment))) {
|
|
969
|
+
throw new WorkRefError(
|
|
970
|
+
"invalid_work_ref",
|
|
971
|
+
`work identity "${name}" must contain one or two lowercase kebab-case segments`,
|
|
972
|
+
name
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME.slice(0, -3)) {
|
|
976
|
+
throw new WorkRefError(
|
|
977
|
+
"invalid_work_ref",
|
|
978
|
+
`work identity "${name}" uses the reserved Group Brief filename`,
|
|
979
|
+
name
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
assertValidWorkRoot(changesDir, name);
|
|
983
|
+
assertNoIdentityCollision(changesDir, segments, name, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
|
|
984
|
+
const path = join5(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
|
|
985
|
+
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
|
+
if (ref.kind === "group-change" && options.executable !== false)
|
|
987
|
+
assertGroupBrief(changesDir, ref);
|
|
988
|
+
if (options.executable && ref.kind === "group-brief") {
|
|
989
|
+
throw new WorkRefError(
|
|
990
|
+
"non_executable_work_ref",
|
|
991
|
+
`work identity "${name}" is a Group Brief and cannot be used as an executable Change`,
|
|
992
|
+
name
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
assertRegularWorkFile(ref, Boolean(options.mustExist));
|
|
996
|
+
return ref;
|
|
997
|
+
}
|
|
998
|
+
function assertGroupBrief(changesDir, ref) {
|
|
999
|
+
const briefPath = join5(changesDir, ref.group, GROUP_BRIEF_FILENAME);
|
|
1000
|
+
const kind = getPathKind(briefPath, ref.name);
|
|
1001
|
+
if (kind === "missing") {
|
|
1002
|
+
throw new WorkRefError(
|
|
1003
|
+
"group_brief_missing",
|
|
1004
|
+
`Change Group "${ref.group}" requires a Group Brief; run: rsp group create ${ref.group}`,
|
|
1005
|
+
ref.name
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
if (kind !== "file") {
|
|
1009
|
+
throw new WorkRefError(
|
|
1010
|
+
"work_ref_not_file",
|
|
1011
|
+
`Group Brief must be a regular file: ${briefPath}`,
|
|
1012
|
+
ref.name
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function assertRegularWorkFile(ref, mustExist) {
|
|
1017
|
+
let stats;
|
|
1018
|
+
try {
|
|
1019
|
+
stats = lstatSync2(ref.path);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (error.code === "ENOENT") {
|
|
1022
|
+
if (mustExist) {
|
|
1023
|
+
throw new WorkRefError(
|
|
1024
|
+
"work_ref_not_found",
|
|
1025
|
+
`open work file not found: ${ref.path}`,
|
|
1026
|
+
ref.name
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
throw asReadFailure(ref.path, ref.name, error);
|
|
1032
|
+
}
|
|
1033
|
+
if (!stats.isFile()) {
|
|
1034
|
+
throw new WorkRefError(
|
|
1035
|
+
"work_ref_not_file",
|
|
1036
|
+
`open work path is not a regular file: ${ref.path}`,
|
|
1037
|
+
ref.name
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function resolveWorkRefPath(path, options = {}) {
|
|
1042
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1043
|
+
const logicalPath = normalizeLogicalPath2(relative3(changesDir, path));
|
|
1044
|
+
if (isAbsolute2(logicalPath) || logicalPath === ".." || logicalPath.startsWith("../") || !logicalPath.endsWith(".md")) {
|
|
1045
|
+
throw new WorkRefError(
|
|
1046
|
+
"invalid_work_ref_path",
|
|
1047
|
+
`work path must be a Markdown file inside ${changesDir}: ${path}`,
|
|
1048
|
+
path
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const segments = logicalPath.split("/");
|
|
1052
|
+
if (segments.length === 2 && segments[1] === "brief.md") {
|
|
1053
|
+
throw new WorkRefError(
|
|
1054
|
+
"invalid_work_ref_path",
|
|
1055
|
+
`unsupported legacy Group Brief path: ${path}`,
|
|
1056
|
+
path
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
|
|
1060
|
+
return resolveWorkRef(name, options);
|
|
1061
|
+
}
|
|
1062
|
+
function isExecutableWorkRef(ref) {
|
|
1063
|
+
return ref.kind !== "group-brief";
|
|
1064
|
+
}
|
|
1065
|
+
function resolveFocusMarkerPath(ref, options = {}) {
|
|
1066
|
+
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
1067
|
+
assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
|
|
1068
|
+
if (ref.group) {
|
|
1069
|
+
assertOptionalManagedDirectory(
|
|
1070
|
+
join5(focusDir, ref.group),
|
|
1071
|
+
ref.name,
|
|
1072
|
+
"invalid_focus_path",
|
|
1073
|
+
"focus path"
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const markerPath = join5(focusDir, ...ref.name.split("/"));
|
|
1077
|
+
try {
|
|
1078
|
+
requireManagedFile(markerPath, "focus marker", { allowMissing: true });
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
if (error instanceof ManagedPathError)
|
|
1081
|
+
throw new WorkRefError("invalid_focus_path", error.message, ref.name);
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
1084
|
+
return markerPath;
|
|
1085
|
+
}
|
|
1086
|
+
function resolveArchiveDirectory(ref, options = {}) {
|
|
1087
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1088
|
+
assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
|
|
1089
|
+
if (!ref.group)
|
|
1090
|
+
return archivesDir;
|
|
1091
|
+
const groupPath = join5(archivesDir, ref.group);
|
|
1092
|
+
assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
|
|
1093
|
+
return groupPath;
|
|
1094
|
+
}
|
|
1095
|
+
async function inspectFocusTree(options = {}) {
|
|
1096
|
+
const focusDir = options.focusDir ?? FOCUS_DIR;
|
|
1097
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1098
|
+
const result = { markers: [], diagnostics: [] };
|
|
1099
|
+
const rootInspection = inspectManagedDirectory(focusDir, "focus root", { allowMissing: true });
|
|
1100
|
+
if (rootInspection.issue) {
|
|
1101
|
+
addDiagnostic(result, new WorkRefError("invalid_focus_root", rootInspection.issue.message, focusDir), focusDir);
|
|
1102
|
+
return result;
|
|
1103
|
+
}
|
|
1104
|
+
if (!rootInspection.exists)
|
|
1105
|
+
return result;
|
|
1106
|
+
const rootEntries = await readDirectory(focusDir, focusDir, result);
|
|
1107
|
+
if (!rootEntries)
|
|
1108
|
+
return result;
|
|
1109
|
+
for (const entry of rootEntries) {
|
|
1110
|
+
const path = join5(focusDir, entry.name);
|
|
1111
|
+
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1112
|
+
continue;
|
|
1113
|
+
if (entry.isDirectory()) {
|
|
1114
|
+
if (!WORK_SEGMENT_RE.test(entry.name)) {
|
|
1115
|
+
addDiagnostic(result, new WorkRefError(
|
|
1116
|
+
"invalid_work_ref",
|
|
1117
|
+
`work identity "${entry.name}" must contain one or two lowercase kebab-case segments`,
|
|
1118
|
+
entry.name
|
|
1119
|
+
), path);
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
await inspectFocusGroup(entry.name, path, changesDir, result);
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
if (!entry.isFile()) {
|
|
1126
|
+
addDiagnostic(result, new WorkRefError(
|
|
1127
|
+
"invalid_focus_path",
|
|
1128
|
+
`focus path must be a regular marker file or real directory: ${path}`,
|
|
1129
|
+
entry.name
|
|
1130
|
+
), path);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
collectFocusMarker(entry.name, path, changesDir, result);
|
|
1134
|
+
}
|
|
1135
|
+
result.markers.sort((a, b) => a.name.localeCompare(b.name));
|
|
1136
|
+
return result;
|
|
1137
|
+
}
|
|
1138
|
+
async function inspectArchiveTree(options = {}) {
|
|
1139
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
1140
|
+
const result = { rootExists: false, files: [], diagnostics: [] };
|
|
1141
|
+
const rootInspection = inspectManagedDirectory(archivesDir, "archive root", { allowMissing: true });
|
|
1142
|
+
if (rootInspection.issue) {
|
|
1143
|
+
addDiagnostic(result, new WorkRefError("invalid_archive_root", rootInspection.issue.message, archivesDir), archivesDir);
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
if (!rootInspection.exists)
|
|
1147
|
+
return result;
|
|
1148
|
+
result.rootExists = true;
|
|
1149
|
+
const rootEntries = await readDirectory(archivesDir, archivesDir, result);
|
|
1150
|
+
if (!rootEntries)
|
|
1151
|
+
return result;
|
|
1152
|
+
for (const entry of rootEntries) {
|
|
1153
|
+
const path = join5(archivesDir, entry.name);
|
|
1154
|
+
if (entry.name === "INDEX.md" && entry.isFile())
|
|
1155
|
+
continue;
|
|
1156
|
+
if (entry.isDirectory()) {
|
|
1157
|
+
if (!WORK_SEGMENT_RE.test(entry.name)) {
|
|
1158
|
+
addArchivePathDiagnostic(result, entry.name, path);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
await inspectArchiveGroup(entry.name, path, result);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1165
|
+
addArchivePathDiagnostic(result, entry.name, path);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
result.files.push(path);
|
|
1169
|
+
}
|
|
1170
|
+
result.files.sort();
|
|
1171
|
+
return result;
|
|
1172
|
+
}
|
|
1173
|
+
async function inspectWorkTree(options = {}) {
|
|
1174
|
+
const changesDir = options.changesDir ?? CHANGES_DIR;
|
|
1175
|
+
const result = { changes: [], briefs: [], diagnostics: [] };
|
|
1176
|
+
try {
|
|
1177
|
+
assertValidWorkRoot(changesDir, changesDir);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (!(error instanceof WorkRefError))
|
|
1180
|
+
throw error;
|
|
1181
|
+
addDiagnostic(result, error, changesDir);
|
|
1182
|
+
return result;
|
|
1183
|
+
}
|
|
1184
|
+
const rootEntries = await readDirectory(changesDir, changesDir, result);
|
|
1185
|
+
if (!rootEntries)
|
|
1186
|
+
return result;
|
|
1187
|
+
const rootDirectories = new Set(rootEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
1188
|
+
const rootMarkdownNames = new Set(rootEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3)));
|
|
1189
|
+
const collidingGroups = new Set([...rootDirectories].filter((name) => rootMarkdownNames.has(name)));
|
|
1190
|
+
for (const entry of rootEntries) {
|
|
1191
|
+
const path = join5(changesDir, entry.name);
|
|
1192
|
+
if (entry.name === ".gitkeep" && entry.isFile())
|
|
1193
|
+
continue;
|
|
1194
|
+
if (entry.isDirectory()) {
|
|
1195
|
+
if (!WORK_SEGMENT_RE.test(entry.name)) {
|
|
1196
|
+
addDiagnostic(result, new WorkRefError(
|
|
1197
|
+
"invalid_work_ref",
|
|
1198
|
+
`work identity "${entry.name}" must contain one or two lowercase kebab-case segments`,
|
|
1199
|
+
entry.name
|
|
1200
|
+
), path);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
const colliding = collidingGroups.has(entry.name);
|
|
1204
|
+
if (colliding) {
|
|
1205
|
+
addDiagnostic(result, new WorkRefError(
|
|
1206
|
+
"work_ref_collision",
|
|
1207
|
+
`work identity "${entry.name}" is claimed by both a file and a directory`,
|
|
1208
|
+
entry.name
|
|
1209
|
+
), path);
|
|
1210
|
+
}
|
|
1211
|
+
await inspectGroupDirectory(entry.name, path, changesDir, result, colliding);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1215
|
+
addInvalidEntryDiagnostic(result, entry.name, path);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const name = entry.name.slice(0, -3);
|
|
1219
|
+
if (collidingGroups.has(name)) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
collectResolvedPath(path, changesDir, result);
|
|
1223
|
+
}
|
|
1224
|
+
result.changes.sort((a, b) => a.name.localeCompare(b.name));
|
|
1225
|
+
result.briefs.sort((a, b) => a.name.localeCompare(b.name));
|
|
1226
|
+
return result;
|
|
1227
|
+
}
|
|
1228
|
+
async function inspectGroupDirectory(group, groupPath, changesDir, result, colliding) {
|
|
1229
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1230
|
+
if (!entries)
|
|
1231
|
+
return;
|
|
1232
|
+
const briefEntry = entries.find((entry) => entry.name === GROUP_BRIEF_FILENAME);
|
|
1233
|
+
if (!briefEntry) {
|
|
1234
|
+
addDiagnostic(result, new WorkRefError(
|
|
1235
|
+
"group_brief_missing",
|
|
1236
|
+
`Change Group "${group}" requires a Group Brief; run: rsp group create ${group}`,
|
|
1237
|
+
group
|
|
1238
|
+
), join5(groupPath, GROUP_BRIEF_FILENAME));
|
|
1239
|
+
} else if (!briefEntry.isFile()) {
|
|
1240
|
+
addDiagnostic(result, new WorkRefError(
|
|
1241
|
+
"work_ref_not_file",
|
|
1242
|
+
`Group Brief must be a regular file: ${join5(groupPath, GROUP_BRIEF_FILENAME)}`,
|
|
1243
|
+
group
|
|
1244
|
+
), join5(groupPath, GROUP_BRIEF_FILENAME));
|
|
1245
|
+
}
|
|
1246
|
+
for (const entry of entries) {
|
|
1247
|
+
const input = `${group}/${entry.name}`;
|
|
1248
|
+
const path = join5(groupPath, entry.name);
|
|
1249
|
+
if (entry.name === GROUP_BRIEF_FILENAME && !entry.isFile())
|
|
1250
|
+
continue;
|
|
1251
|
+
if (entry.name === "brief.md") {
|
|
1252
|
+
addInvalidEntryDiagnostic(result, input, path);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (entry.isDirectory()) {
|
|
1256
|
+
addDiagnostic(result, new WorkRefError(
|
|
1257
|
+
"unsupported_work_depth",
|
|
1258
|
+
`work identity "${input}" exceeds the supported one-level Change Group depth`,
|
|
1259
|
+
input
|
|
1260
|
+
), path);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1264
|
+
addInvalidEntryDiagnostic(result, input, path);
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (!colliding)
|
|
1268
|
+
collectResolvedPath(path, changesDir, result);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
async function inspectFocusGroup(group, groupPath, changesDir, result) {
|
|
1272
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1273
|
+
if (!entries)
|
|
1274
|
+
return;
|
|
1275
|
+
for (const entry of entries) {
|
|
1276
|
+
const input = `${group}/${entry.name}`;
|
|
1277
|
+
const path = join5(groupPath, entry.name);
|
|
1278
|
+
if (entry.isDirectory()) {
|
|
1279
|
+
addDiagnostic(result, new WorkRefError(
|
|
1280
|
+
"unsupported_work_depth",
|
|
1281
|
+
`work identity "${input}" exceeds the supported one-level Change Group depth`,
|
|
1282
|
+
input
|
|
1283
|
+
), path);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (!entry.isFile()) {
|
|
1287
|
+
addDiagnostic(result, new WorkRefError(
|
|
1288
|
+
"invalid_focus_path",
|
|
1289
|
+
`focus path must be a regular marker file: ${path}`,
|
|
1290
|
+
input
|
|
1291
|
+
), path);
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
collectFocusMarker(input, path, changesDir, result);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
async function inspectArchiveGroup(group, groupPath, result) {
|
|
1298
|
+
const entries = await readDirectory(groupPath, group, result);
|
|
1299
|
+
if (!entries)
|
|
1300
|
+
return;
|
|
1301
|
+
for (const entry of entries) {
|
|
1302
|
+
const input = `${group}/${entry.name}`;
|
|
1303
|
+
const path = join5(groupPath, entry.name);
|
|
1304
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
1305
|
+
addArchivePathDiagnostic(result, input, path);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
result.files.push(path);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function collectFocusMarker(input, path, changesDir, result) {
|
|
1312
|
+
try {
|
|
1313
|
+
const ref = resolveWorkRef(input, { changesDir, executable: true });
|
|
1314
|
+
result.markers.push({ name: ref.name, path });
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
if (!(error instanceof WorkRefError))
|
|
1317
|
+
throw error;
|
|
1318
|
+
addDiagnostic(result, error, path);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
function collectResolvedPath(path, changesDir, result) {
|
|
1322
|
+
try {
|
|
1323
|
+
const ref = resolveWorkRefPath(path, { changesDir });
|
|
1324
|
+
if (isExecutableWorkRef(ref))
|
|
1325
|
+
result.changes.push(ref);
|
|
1326
|
+
else
|
|
1327
|
+
result.briefs.push(ref);
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
if (!(error instanceof WorkRefError))
|
|
1330
|
+
throw error;
|
|
1331
|
+
addDiagnostic(result, error, path);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async function readDirectory(path, input, result) {
|
|
1335
|
+
try {
|
|
1336
|
+
return (await readdir4(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
addDiagnostic(result, asReadFailure(path, input, error), path);
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
function addInvalidEntryDiagnostic(result, input, path) {
|
|
1343
|
+
addDiagnostic(result, new WorkRefError(
|
|
1344
|
+
"invalid_work_ref_path",
|
|
1345
|
+
`unsupported entry in the open-work tree: ${path}`,
|
|
1346
|
+
input
|
|
1347
|
+
), path);
|
|
1348
|
+
}
|
|
1349
|
+
function addArchivePathDiagnostic(result, input, path) {
|
|
1350
|
+
addDiagnostic(result, new WorkRefError(
|
|
1351
|
+
"invalid_archive_path",
|
|
1352
|
+
`unsupported entry in the archive tree: ${path}`,
|
|
1353
|
+
input
|
|
1354
|
+
), path);
|
|
1355
|
+
}
|
|
1356
|
+
function addDiagnostic(result, error, path) {
|
|
1357
|
+
if (result.diagnostics.some((item) => item.code === error.code && item.input === error.input))
|
|
1358
|
+
return;
|
|
1359
|
+
result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
|
|
1360
|
+
}
|
|
1361
|
+
function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
|
|
1362
|
+
for (let depth = 1; depth <= segments.length; depth++) {
|
|
1363
|
+
const identityPath = join5(changesDir, ...segments.slice(0, depth));
|
|
1364
|
+
const filePath = finalFilename && depth === segments.length ? join5(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
|
|
1365
|
+
const fileKind = getPathKind(filePath, input);
|
|
1366
|
+
const identityKind = getPathKind(identityPath, input);
|
|
1367
|
+
const isPrefix = depth < segments.length;
|
|
1368
|
+
const isFlatIdentity = segments.length === 1;
|
|
1369
|
+
const identity = segments.slice(0, depth).join("/");
|
|
1370
|
+
if (fileKind !== "missing" && fileKind !== "file") {
|
|
1371
|
+
throw new WorkRefError(
|
|
1372
|
+
"work_ref_not_file",
|
|
1373
|
+
`open work path is not a regular file: ${filePath}`,
|
|
1374
|
+
input
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
if (fileKind === "file" && identityKind === "directory" || isPrefix && fileKind === "file" || isFlatIdentity && identityKind === "directory") {
|
|
1378
|
+
throw new WorkRefError(
|
|
1379
|
+
"work_ref_collision",
|
|
1380
|
+
`work identity "${identity}" is or would be claimed by both a file and a directory`,
|
|
1381
|
+
input
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (isPrefix && identityKind !== "missing" && identityKind !== "directory") {
|
|
1385
|
+
throw new WorkRefError(
|
|
1386
|
+
"invalid_work_ref_path",
|
|
1387
|
+
`open work path must be a real directory: ${identityPath}`,
|
|
1388
|
+
input
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
if (!isPrefix && !isFlatIdentity && identityKind === "directory") {
|
|
1392
|
+
throw new WorkRefError(
|
|
1393
|
+
"unsupported_work_depth",
|
|
1394
|
+
`work identity "${identity}" exceeds the supported one-level Change Group depth`,
|
|
1395
|
+
input
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
if (!isPrefix && identityKind !== "missing" && identityKind !== "directory") {
|
|
1399
|
+
throw new WorkRefError(
|
|
1400
|
+
"invalid_work_ref_path",
|
|
1401
|
+
`unsupported entry in the open-work tree: ${identityPath}`,
|
|
1402
|
+
input
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function assertValidWorkRoot(changesDir, input) {
|
|
1408
|
+
assertManagedRoot(changesDir, input, "invalid_work_root", "open work root");
|
|
1409
|
+
}
|
|
1410
|
+
function assertManagedRoot(path, input, code, label, allowMissing = false) {
|
|
1411
|
+
try {
|
|
1412
|
+
requireManagedDirectory(path, label, { allowMissing });
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (error instanceof ManagedPathError)
|
|
1415
|
+
throw new WorkRefError(code, error.message, input);
|
|
1416
|
+
throw error;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function assertOptionalManagedDirectory(path, input, code, label) {
|
|
1420
|
+
try {
|
|
1421
|
+
requireManagedDirectory(path, label, { allowMissing: true });
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
if (error instanceof ManagedPathError)
|
|
1424
|
+
throw new WorkRefError(code, error.message, input);
|
|
1425
|
+
throw error;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
function getPathKind(path, input) {
|
|
1429
|
+
try {
|
|
1430
|
+
const stats = lstatSync2(path);
|
|
1431
|
+
if (stats.isFile())
|
|
1432
|
+
return "file";
|
|
1433
|
+
if (stats.isDirectory())
|
|
1434
|
+
return "directory";
|
|
1435
|
+
return "other";
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
if (error.code === "ENOENT")
|
|
1438
|
+
return "missing";
|
|
1439
|
+
throw asReadFailure(path, input, error);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
function asReadFailure(path, input, error) {
|
|
1443
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1444
|
+
return new WorkRefError(
|
|
1445
|
+
"work_tree_read_failed",
|
|
1446
|
+
`unable to inspect open work path ${path}: ${message}`,
|
|
1447
|
+
input
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
function normalizeLogicalPath2(path) {
|
|
1451
|
+
return path.split("\\").join("/");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/core/change-group.ts
|
|
1455
|
+
var GROUP_BRIEF_SECTIONS = ["Goal", "Scope", "Shared Constraints", "Slices", "Completion Conditions", "Durable Outcomes", "Blockers"];
|
|
1456
|
+
async function resolveExecutableChange(name, options = {}) {
|
|
1457
|
+
const ref = resolveWorkRef(name, { ...options, executable: true });
|
|
1458
|
+
if (ref.kind === "group-change")
|
|
1459
|
+
await requireDeclaredGroupSlice(ref);
|
|
1460
|
+
return ref;
|
|
1461
|
+
}
|
|
1462
|
+
function generateGroupBriefContent(name, goal = "") {
|
|
1463
|
+
return `---
|
|
1464
|
+
kind: group
|
|
1465
|
+
---
|
|
1466
|
+
|
|
1467
|
+
# Change Group: ${name}
|
|
1468
|
+
|
|
1469
|
+
## Goal
|
|
1470
|
+
- ${goal || "<what shared outcome this group must deliver>"}
|
|
1471
|
+
|
|
1472
|
+
## Scope
|
|
1473
|
+
- <what is coordinated by this group>
|
|
1474
|
+
|
|
1475
|
+
## Shared Constraints
|
|
1476
|
+
- <constraint shared by every child Change>
|
|
1477
|
+
|
|
1478
|
+
## Slices
|
|
1479
|
+
- \`${name}/<change>\`: <independently executable boundary>
|
|
1480
|
+
|
|
1481
|
+
## Completion Conditions
|
|
1482
|
+
- [ ] <end-to-end condition beyond individual child verification>
|
|
1483
|
+
|
|
1484
|
+
## Durable Outcomes
|
|
1485
|
+
- Current facts: <Spec, CONTEXT.md, scoped instruction target, or none>
|
|
1486
|
+
- Lasting rationale: <Decision Record target, or none>
|
|
1487
|
+
|
|
1488
|
+
## Blockers
|
|
1489
|
+
- none
|
|
1490
|
+
`;
|
|
1491
|
+
}
|
|
1492
|
+
async function inspectChangeGroups(options = {}) {
|
|
1493
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
1494
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
1495
|
+
const focusTree = options.focusTree ?? await inspectFocusTree();
|
|
1496
|
+
const diagnostics = [];
|
|
1497
|
+
const groups = [];
|
|
1498
|
+
const archivedSlicesByGroup = /* @__PURE__ */ new Map();
|
|
1499
|
+
const archivedGroups = /* @__PURE__ */ new Set();
|
|
1500
|
+
const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
|
|
1501
|
+
for (const path of archiveTree.files) {
|
|
1502
|
+
const archivePath = normalizeLogicalPath(relative4(ARCHIVES_DIR, path));
|
|
1503
|
+
const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
|
|
1504
|
+
if (!archiveGroup || !openGroupNames.has(archiveGroup))
|
|
1505
|
+
continue;
|
|
1506
|
+
try {
|
|
1507
|
+
const content = await readFile3(path, "utf-8");
|
|
1508
|
+
const heading = content.match(/^# Change:\s+(\S+)\s*$/m);
|
|
1509
|
+
if (heading?.[1]?.includes("/")) {
|
|
1510
|
+
const identity = heading[1];
|
|
1511
|
+
if (!new RegExp(`^${escapeRegExp(archiveGroup)}/[a-z0-9-]+$`).test(identity)) {
|
|
1512
|
+
diagnostics.push({
|
|
1513
|
+
severity: "error",
|
|
1514
|
+
code: "group_archive_identity_mismatch",
|
|
1515
|
+
change: archiveGroup,
|
|
1516
|
+
path,
|
|
1517
|
+
message: `archived Change identity ${identity} does not belong to archive group ${archiveGroup}`
|
|
1518
|
+
});
|
|
1519
|
+
} else {
|
|
1520
|
+
const groupSlices = archivedSlicesByGroup.get(archiveGroup) ?? /* @__PURE__ */ new Set();
|
|
1521
|
+
groupSlices.add(identity);
|
|
1522
|
+
archivedSlicesByGroup.set(archiveGroup, groupSlices);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (new RegExp(`^# Change Group:\\s+${escapeRegExp(archiveGroup)}\\s*$`, "m").test(content))
|
|
1526
|
+
archivedGroups.add(archiveGroup);
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
diagnostics.push({
|
|
1529
|
+
severity: "error",
|
|
1530
|
+
code: "group_archive_read_failed",
|
|
1531
|
+
change: archiveGroup,
|
|
1532
|
+
path,
|
|
1533
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
for (const brief of workTree.briefs) {
|
|
1538
|
+
let content;
|
|
1539
|
+
try {
|
|
1540
|
+
content = await readFile3(brief.path, "utf-8");
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
diagnostics.push({
|
|
1543
|
+
severity: "error",
|
|
1544
|
+
code: "group_brief_read_failed",
|
|
1545
|
+
change: brief.group,
|
|
1546
|
+
path: brief.path,
|
|
1547
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1548
|
+
});
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
const parsed = parseGroupBrief(brief.group, content);
|
|
1552
|
+
diagnostics.push(...parsed.diagnostics.map((diagnostic) => ({ ...diagnostic, path: brief.path })));
|
|
1553
|
+
if (archivedGroups.has(brief.group)) {
|
|
1554
|
+
diagnostics.push({
|
|
1555
|
+
severity: "error",
|
|
1556
|
+
code: "group_identity_reopened",
|
|
1557
|
+
change: brief.group,
|
|
1558
|
+
path: brief.path,
|
|
1559
|
+
message: `archived Change Group cannot be reopened: ${brief.group}`
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
for (const diagnostic of archiveTree.diagnostics.filter((item) => {
|
|
1563
|
+
if (item.code === "invalid_archive_root")
|
|
1564
|
+
return true;
|
|
1565
|
+
const archivePath = normalizeLogicalPath(relative4(ARCHIVES_DIR, item.path));
|
|
1566
|
+
return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
|
|
1567
|
+
})) {
|
|
1568
|
+
diagnostics.push({
|
|
1569
|
+
severity: "error",
|
|
1570
|
+
code: diagnostic.code,
|
|
1571
|
+
change: brief.group,
|
|
1572
|
+
path: diagnostic.path,
|
|
1573
|
+
message: diagnostic.message
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
for (const diagnostic of focusTree.diagnostics.filter((item) => item.input.startsWith(`${brief.group}/`))) {
|
|
1577
|
+
diagnostics.push({
|
|
1578
|
+
severity: "error",
|
|
1579
|
+
code: "group_focus_invalid",
|
|
1580
|
+
change: brief.group,
|
|
1581
|
+
path: diagnostic.path,
|
|
1582
|
+
message: `invalid focus for ${diagnostic.input}: ${diagnostic.message}`
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
const openChildren = new Set(workTree.changes.filter((ref) => ref.group === brief.group).map((ref) => ref.name));
|
|
1586
|
+
const archivedSlices = archivedSlicesByGroup.get(brief.group) ?? /* @__PURE__ */ new Set();
|
|
1587
|
+
const slices = parsed.slices.map((slice) => {
|
|
1588
|
+
const state = openChildren.has(slice.name) ? "open" : archivedSlices.has(slice.name) ? "archived" : "missing";
|
|
1589
|
+
return { ...slice, state };
|
|
1590
|
+
});
|
|
1591
|
+
const focusedNames = new Set(focusTree.markers.map((marker) => marker.name));
|
|
1592
|
+
for (const slice of slices.filter((slice2) => slice2.state !== "open" && focusedNames.has(slice2.name))) {
|
|
1593
|
+
diagnostics.push({
|
|
1594
|
+
severity: "error",
|
|
1595
|
+
code: "group_focus_invalid",
|
|
1596
|
+
change: brief.group,
|
|
1597
|
+
path: focusTree.markers.find((marker) => marker.name === slice.name)?.path,
|
|
1598
|
+
message: `invalid focus for ${slice.name}: focused group slice is not open`
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
const declared = new Set(slices.map((slice) => slice.name));
|
|
1602
|
+
for (const child of [...openChildren].filter((name) => !declared.has(name)).sort()) {
|
|
1603
|
+
diagnostics.push({
|
|
1604
|
+
severity: "error",
|
|
1605
|
+
code: "group_child_undeclared",
|
|
1606
|
+
change: brief.group,
|
|
1607
|
+
path: brief.path,
|
|
1608
|
+
message: `open child Change is not declared by the Group Brief: ${child}`
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
for (const child of [...archivedSlices].filter((name) => !declared.has(name)).sort()) {
|
|
1612
|
+
diagnostics.push({
|
|
1613
|
+
severity: "error",
|
|
1614
|
+
code: "group_archived_child_undeclared",
|
|
1615
|
+
change: brief.group,
|
|
1616
|
+
path: brief.path,
|
|
1617
|
+
message: `archived child Change is not declared by the Group Brief: ${child}`
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
for (const slice of slices.filter((slice2) => slice2.state === "missing")) {
|
|
1621
|
+
diagnostics.push({
|
|
1622
|
+
severity: "error",
|
|
1623
|
+
code: "group_slice_missing",
|
|
1624
|
+
change: brief.group,
|
|
1625
|
+
path: brief.path,
|
|
1626
|
+
message: `declared group slice is neither open nor archived: ${slice.name}`
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
const warnings = parsed.diagnostics.filter((diagnostic) => diagnostic.severity === "warning").map((diagnostic) => diagnostic.message);
|
|
1630
|
+
const readyToClose = diagnostics.every((diagnostic) => diagnostic.change !== brief.group || diagnostic.severity !== "error") && slices.length >= 2 && slices.every((slice) => slice.state === "archived") && parsed.completion.total > 0 && parsed.completion.done === parsed.completion.total && !parsed.blockers;
|
|
1631
|
+
groups.push({
|
|
1632
|
+
name: brief.group,
|
|
1633
|
+
path: normalizeLogicalPath(brief.path),
|
|
1634
|
+
slices,
|
|
1635
|
+
completion: parsed.completion,
|
|
1636
|
+
blockers: parsed.blockers,
|
|
1637
|
+
readyToClose,
|
|
1638
|
+
warnings
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
groups.sort((left, right) => left.name.localeCompare(right.name));
|
|
1642
|
+
return { groups, diagnostics };
|
|
1643
|
+
}
|
|
1644
|
+
async function requireDeclaredGroupSlice(ref) {
|
|
1645
|
+
const briefPath = join6(dirname4(ref.path), GROUP_BRIEF_FILENAME);
|
|
1646
|
+
let content;
|
|
1647
|
+
try {
|
|
1648
|
+
content = await readFile3(briefPath, "utf-8");
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1651
|
+
throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
|
|
1652
|
+
}
|
|
1653
|
+
const parsed = parseGroupBrief(ref.group, content);
|
|
1654
|
+
const errors = parsed.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
1655
|
+
if (errors.length > 0) {
|
|
1656
|
+
throw new WorkRefError(
|
|
1657
|
+
"invalid_group_brief",
|
|
1658
|
+
`Change Group "${ref.group}" brief is invalid: ${errors.map((error) => error.message).join("; ")}`,
|
|
1659
|
+
ref.name
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
if (!parsed.slices.some((slice) => slice.name === ref.name)) {
|
|
1663
|
+
throw new WorkRefError(
|
|
1664
|
+
"group_child_undeclared",
|
|
1665
|
+
`grouped Change is not declared by ${ref.group}/brief: ${ref.name}`,
|
|
1666
|
+
ref.name
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
async function hasArchivedGroupBrief(group) {
|
|
1671
|
+
const archiveTree = await inspectArchiveTree();
|
|
1672
|
+
if (archiveTree.diagnostics.length > 0) {
|
|
1673
|
+
const diagnostic = archiveTree.diagnostics[0];
|
|
1674
|
+
throw new WorkRefError(diagnostic.code, diagnostic.message, diagnostic.input);
|
|
1675
|
+
}
|
|
1676
|
+
const groupPrefix = `${group}/`;
|
|
1677
|
+
for (const path of archiveTree.files) {
|
|
1678
|
+
if (!normalizeLogicalPath(relative4(ARCHIVES_DIR, path)).startsWith(groupPrefix))
|
|
1679
|
+
continue;
|
|
1680
|
+
let content;
|
|
1681
|
+
try {
|
|
1682
|
+
content = await readFile3(path, "utf-8");
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1685
|
+
throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
|
|
1686
|
+
}
|
|
1687
|
+
if (new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
function parseGroupBrief(group, content) {
|
|
1693
|
+
const diagnostics = [];
|
|
1694
|
+
let frontmatter;
|
|
1695
|
+
try {
|
|
1696
|
+
frontmatter = parseFrontmatter(content);
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
diagnostics.push({ severity: "error", code: "group_frontmatter_invalid", change: group, message: error instanceof Error ? error.message : String(error) });
|
|
1699
|
+
}
|
|
1700
|
+
if (frontmatter?.kind !== "group")
|
|
1701
|
+
diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
|
|
1702
|
+
if (!new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
|
|
1703
|
+
diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
|
|
1704
|
+
for (const section of GROUP_BRIEF_SECTIONS) {
|
|
1705
|
+
if (!extractSection(content, section))
|
|
1706
|
+
diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${section}` });
|
|
1707
|
+
}
|
|
1708
|
+
const slices = [];
|
|
1709
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1710
|
+
for (const line of extractSection(content, "Slices").split("\n")) {
|
|
1711
|
+
const trimmed = line.trim();
|
|
1712
|
+
const identity = trimmed.match(/^-\s+`(\S+)`/);
|
|
1713
|
+
if (!identity || identity[1].includes("<"))
|
|
1714
|
+
continue;
|
|
1715
|
+
const suffix = trimmed.slice(identity[0].length).trim();
|
|
1716
|
+
if (![":", "\u2014", "-"].includes(suffix[0])) {
|
|
1717
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires an identity and boundary: ${trimmed}` });
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const name = identity[1];
|
|
1721
|
+
const boundary = suffix.slice(1).trim();
|
|
1722
|
+
if (!boundary) {
|
|
1723
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
if (!new RegExp(`^${escapeRegExp(group)}/[a-z0-9-]+$`).test(name) || name === `${group}/brief`) {
|
|
1727
|
+
diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (seen.has(name)) {
|
|
1731
|
+
diagnostics.push({ severity: "error", code: "group_slice_duplicate", change: group, message: `duplicate group slice: ${name}` });
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
seen.add(name);
|
|
1735
|
+
slices.push({ name, boundary: boundary.trim() });
|
|
1736
|
+
}
|
|
1737
|
+
if (slices.length < 2)
|
|
1738
|
+
diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
|
|
1739
|
+
const completion = countCheckboxes(extractSection(content, "Completion Conditions"));
|
|
1740
|
+
if (completion.total === 0)
|
|
1741
|
+
diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
|
|
1742
|
+
return {
|
|
1743
|
+
slices,
|
|
1744
|
+
completion: { done: completion.done + completion.dropped, total: completion.total },
|
|
1745
|
+
blockers: hasMeaningfulBlockers(content),
|
|
1746
|
+
diagnostics
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
function escapeRegExp(value) {
|
|
1750
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// src/core/dependency-plan.ts
|
|
1754
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1755
|
+
var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
|
|
1756
|
+
var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
|
|
1757
|
+
var EXECUTABLE_WORK_REF_RE = /^[a-z0-9-]+(?:\/(?!(?:brief|00-brief)$)[a-z0-9-]+)?$/;
|
|
1758
|
+
async function inspectChangeDependencies(options = {}) {
|
|
1759
|
+
const workTree = options.workTree ?? await inspectWorkTree();
|
|
1760
|
+
const archiveTree = options.archiveTree ?? await inspectArchiveTree();
|
|
1761
|
+
const openNames = new Set(workTree.changes.map((ref) => ref.name));
|
|
1762
|
+
const archivedNames = /* @__PURE__ */ new Set();
|
|
1763
|
+
const parsedByName = /* @__PURE__ */ new Map();
|
|
1764
|
+
const blockedGroups = /* @__PURE__ */ new Set();
|
|
1765
|
+
const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
|
|
1766
|
+
severity: "error",
|
|
1767
|
+
code: diagnostic.code,
|
|
1768
|
+
path: diagnostic.path,
|
|
1769
|
+
message: diagnostic.message
|
|
1770
|
+
}));
|
|
1771
|
+
for (const path of archiveTree.files) {
|
|
1772
|
+
try {
|
|
1773
|
+
const heading = (await readFile4(path, "utf-8")).match(/^# Change:\s+(\S+)\s*$/m);
|
|
1774
|
+
if (heading)
|
|
1775
|
+
archivedNames.add(heading[1]);
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
diagnostics.push({
|
|
1778
|
+
severity: "error",
|
|
1779
|
+
code: "dependency_archive_read_failed",
|
|
1780
|
+
path,
|
|
1781
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
for (const brief of workTree.briefs) {
|
|
1786
|
+
try {
|
|
1787
|
+
if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
|
|
1788
|
+
blockedGroups.add(brief.group);
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
diagnostics.push({
|
|
1791
|
+
severity: "error",
|
|
1792
|
+
code: "group_brief_read_failed",
|
|
1793
|
+
change: brief.group,
|
|
1794
|
+
path: brief.path,
|
|
1795
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
for (const ref of workTree.changes) {
|
|
1800
|
+
try {
|
|
1801
|
+
const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
|
|
1802
|
+
if (ref.group && blockedGroups.has(ref.group))
|
|
1803
|
+
parsed.external = true;
|
|
1804
|
+
parsedByName.set(ref.name, parsed);
|
|
1805
|
+
for (const line of parsed.malformed) {
|
|
1806
|
+
diagnostics.push({
|
|
1807
|
+
severity: "error",
|
|
1808
|
+
code: "dependency_syntax_invalid",
|
|
1809
|
+
change: ref.name,
|
|
1810
|
+
path: ref.path,
|
|
1811
|
+
message: `invalid dependency blocker syntax: ${line}`,
|
|
1812
|
+
hint: "Use: - requires `<change-work-ref>`: <reason>"
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
diagnostics.push({
|
|
1817
|
+
severity: "error",
|
|
1818
|
+
code: "change_read_failed",
|
|
1819
|
+
change: ref.name,
|
|
1820
|
+
path: ref.path,
|
|
1821
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
const edges = [];
|
|
1826
|
+
for (const [change, blockers] of parsedByName) {
|
|
1827
|
+
for (const requirement of blockers.requires) {
|
|
1828
|
+
const required = requirement.name;
|
|
1829
|
+
const edge = {
|
|
1830
|
+
change,
|
|
1831
|
+
requires: required,
|
|
1832
|
+
reason: requirement.reason,
|
|
1833
|
+
state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
|
|
1834
|
+
};
|
|
1835
|
+
edges.push(edge);
|
|
1836
|
+
if (!EXECUTABLE_WORK_REF_RE.test(required)) {
|
|
1837
|
+
diagnostics.push({
|
|
1838
|
+
severity: "error",
|
|
1839
|
+
code: "dependency_target_invalid",
|
|
1840
|
+
change,
|
|
1841
|
+
message: `dependency target must be an executable Change WorkRef: ${required}`
|
|
1842
|
+
});
|
|
1843
|
+
} else if (required === change) {
|
|
1844
|
+
diagnostics.push({
|
|
1845
|
+
severity: "error",
|
|
1846
|
+
code: "dependency_self_reference",
|
|
1847
|
+
change,
|
|
1848
|
+
message: "Change cannot require itself"
|
|
1849
|
+
});
|
|
1850
|
+
} else if (edge.state === "missing") {
|
|
1851
|
+
diagnostics.push({
|
|
1852
|
+
severity: "error",
|
|
1853
|
+
code: "dependency_target_missing",
|
|
1854
|
+
change,
|
|
1855
|
+
message: `dependency target is neither open nor archived: ${required}`
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
edges.sort(compareEdges);
|
|
1861
|
+
diagnostics.push(...findCycleDiagnostics(edges));
|
|
1862
|
+
const activeBlockers = /* @__PURE__ */ new Map();
|
|
1863
|
+
const blocked = [...parsedByName].map(([change, blockers]) => {
|
|
1864
|
+
const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
|
|
1865
|
+
activeBlockers.set(change, blockers.external || requires.length > 0);
|
|
1866
|
+
return { change, requires, external: blockers.external };
|
|
1867
|
+
}).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
|
|
1868
|
+
const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
|
|
1869
|
+
const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
1870
|
+
if (!graphValid) {
|
|
1871
|
+
for (const name of openNames)
|
|
1872
|
+
activeBlockers.set(name, true);
|
|
1873
|
+
}
|
|
1874
|
+
const ready = graphValid ? waves[0] ?? [] : [];
|
|
1875
|
+
const executable = new Set(graphValid ? waves.flat() : []);
|
|
1876
|
+
const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
|
|
1877
|
+
let state;
|
|
1878
|
+
if (archivedNames.has(name))
|
|
1879
|
+
state = "archived";
|
|
1880
|
+
else if (!openNames.has(name))
|
|
1881
|
+
state = "missing";
|
|
1882
|
+
else if (ready.includes(name))
|
|
1883
|
+
state = "ready";
|
|
1884
|
+
else if (executable.has(name))
|
|
1885
|
+
state = "waiting";
|
|
1886
|
+
else
|
|
1887
|
+
state = "blocked";
|
|
1888
|
+
return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
|
|
1889
|
+
});
|
|
1890
|
+
return {
|
|
1891
|
+
plan: {
|
|
1892
|
+
nodes,
|
|
1893
|
+
ready,
|
|
1894
|
+
edges,
|
|
1895
|
+
blocked,
|
|
1896
|
+
waves: graphValid ? waves : []
|
|
1897
|
+
},
|
|
1898
|
+
activeBlockers,
|
|
1899
|
+
diagnostics
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
function parseDependencyBlockers(content) {
|
|
1903
|
+
const requires = /* @__PURE__ */ new Map();
|
|
1904
|
+
let external = false;
|
|
1905
|
+
const malformed = [];
|
|
1906
|
+
for (const line of extractBlockerLines(content)) {
|
|
1907
|
+
if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
|
|
1908
|
+
continue;
|
|
1909
|
+
const dependency = line.match(REQUIRES_RE);
|
|
1910
|
+
if (dependency) {
|
|
1911
|
+
if (!requires.has(dependency[1]))
|
|
1912
|
+
requires.set(dependency[1], dependency[2]);
|
|
1913
|
+
} else if (/^[-*]\s+requires\b/i.test(line)) {
|
|
1914
|
+
malformed.push(line);
|
|
1915
|
+
external = true;
|
|
1916
|
+
} else {
|
|
1917
|
+
external = true;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return {
|
|
1921
|
+
requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
1922
|
+
external,
|
|
1923
|
+
malformed
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
function buildWaves(names, edges, blockersByName, preferredOrder) {
|
|
1927
|
+
const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
|
|
1928
|
+
const excluded = new Set(
|
|
1929
|
+
[...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
|
|
1930
|
+
);
|
|
1931
|
+
let changed = true;
|
|
1932
|
+
while (changed) {
|
|
1933
|
+
changed = false;
|
|
1934
|
+
for (const edge of edges) {
|
|
1935
|
+
if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
|
|
1936
|
+
excluded.add(edge.change);
|
|
1937
|
+
changed = true;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const remaining = new Set(names.filter((name) => !excluded.has(name)));
|
|
1942
|
+
const waves = [];
|
|
1943
|
+
while (remaining.size > 0) {
|
|
1944
|
+
const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
|
|
1945
|
+
if (wave.length === 0)
|
|
1946
|
+
break;
|
|
1947
|
+
waves.push(wave);
|
|
1948
|
+
for (const name of wave)
|
|
1949
|
+
remaining.delete(name);
|
|
1950
|
+
}
|
|
1951
|
+
return waves;
|
|
1952
|
+
}
|
|
1953
|
+
function comparePlanNames(left, right, orderIndex) {
|
|
1954
|
+
const leftGroup = left.includes("/") ? left.split("/")[0] : null;
|
|
1955
|
+
const rightGroup = right.includes("/") ? right.split("/")[0] : null;
|
|
1956
|
+
if (leftGroup && leftGroup === rightGroup) {
|
|
1957
|
+
const leftIndex = orderIndex.get(left);
|
|
1958
|
+
const rightIndex = orderIndex.get(right);
|
|
1959
|
+
if (leftIndex !== void 0 && rightIndex !== void 0)
|
|
1960
|
+
return leftIndex - rightIndex;
|
|
1961
|
+
}
|
|
1962
|
+
return left.localeCompare(right);
|
|
1963
|
+
}
|
|
1964
|
+
function compareEdges(left, right) {
|
|
1965
|
+
return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
|
|
1966
|
+
}
|
|
1967
|
+
function findCycleDiagnostics(edges) {
|
|
1968
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
1969
|
+
for (const edge of edges) {
|
|
1970
|
+
if (edge.state !== "open" || edge.change === edge.requires)
|
|
1971
|
+
continue;
|
|
1972
|
+
const targets = adjacency.get(edge.change) ?? [];
|
|
1973
|
+
targets.push(edge.requires);
|
|
1974
|
+
adjacency.set(edge.change, targets.sort());
|
|
1975
|
+
}
|
|
1976
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1977
|
+
const active = /* @__PURE__ */ new Set();
|
|
1978
|
+
const stack = [];
|
|
1979
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1980
|
+
const diagnostics = [];
|
|
1981
|
+
const visit = (name) => {
|
|
1982
|
+
if (active.has(name)) {
|
|
1983
|
+
const start = stack.indexOf(name);
|
|
1984
|
+
const cycle = [...stack.slice(start), name];
|
|
1985
|
+
const key = [...new Set(cycle)].sort().join("|");
|
|
1986
|
+
if (!reported.has(key)) {
|
|
1987
|
+
reported.add(key);
|
|
1988
|
+
const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
|
|
1989
|
+
for (const participant of [...new Set(cycle)].sort()) {
|
|
1990
|
+
diagnostics.push({
|
|
1991
|
+
severity: "error",
|
|
1992
|
+
code: "dependency_cycle",
|
|
1993
|
+
change: participant,
|
|
1994
|
+
message
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (visited.has(name))
|
|
2001
|
+
return;
|
|
2002
|
+
visited.add(name);
|
|
2003
|
+
active.add(name);
|
|
2004
|
+
stack.push(name);
|
|
2005
|
+
for (const target of adjacency.get(name) ?? [])
|
|
2006
|
+
visit(target);
|
|
2007
|
+
stack.pop();
|
|
2008
|
+
active.delete(name);
|
|
2009
|
+
};
|
|
2010
|
+
for (const name of [...adjacency.keys()].sort())
|
|
2011
|
+
visit(name);
|
|
2012
|
+
return diagnostics;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// src/status/inspect.ts
|
|
2016
|
+
async function inspectProjectStatus(options = {}) {
|
|
2017
|
+
const runtime = [];
|
|
2018
|
+
const diagnostics = [];
|
|
2019
|
+
const focusTree = await inspectFocusTree();
|
|
2020
|
+
for (const diagnostic of focusTree.diagnostics) {
|
|
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
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// src/history/model.ts
|
|
2234
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
2235
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
2236
|
+
var HISTORY_MAX_TEXT_CODE_POINTS = 500;
|
|
2237
|
+
var HISTORY_MAX_EVIDENCE_ITEMS = 20;
|
|
2238
|
+
var HISTORY_MAX_DIAGNOSTICS = 20;
|
|
2239
|
+
var HISTORY_MAX_CANDIDATES = 20;
|
|
2240
|
+
var ArchiveHistoryError = class extends Error {
|
|
2241
|
+
constructor(code, message, candidates = []) {
|
|
2242
|
+
super(message);
|
|
2243
|
+
this.code = code;
|
|
2244
|
+
this.name = "ArchiveHistoryError";
|
|
2245
|
+
const ordered = [...candidates].sort();
|
|
2246
|
+
this.candidateTotal = ordered.length;
|
|
2247
|
+
this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
|
|
2248
|
+
this.candidatesTruncated = this.candidateTotal > this.candidates.length;
|
|
2249
|
+
}
|
|
2250
|
+
code;
|
|
2251
|
+
candidates;
|
|
2252
|
+
candidateTotal;
|
|
2253
|
+
candidatesTruncated;
|
|
2254
|
+
};
|
|
2255
|
+
|
|
2256
|
+
// 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
|
+
var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
|
|
2260
|
+
var GROUP_NAME_RE = /^[a-z0-9-]+$/;
|
|
2261
|
+
var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
|
|
2262
|
+
async function inspectArchiveHistory(options = {}) {
|
|
2263
|
+
const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
|
|
2264
|
+
const tree = await inspectArchiveTree({ archivesDir });
|
|
2265
|
+
const diagnostics = tree.diagnostics.map((diagnostic) => ({
|
|
2266
|
+
severity: "error",
|
|
2267
|
+
code: diagnostic.code,
|
|
2268
|
+
path: normalizeOutputPath(diagnostic.path, archivesDir),
|
|
2269
|
+
message: diagnostic.message
|
|
2270
|
+
}));
|
|
2271
|
+
const runtime = [];
|
|
2272
|
+
const records = [];
|
|
2273
|
+
if (!tree.rootExists && tree.diagnostics.length === 0) {
|
|
2274
|
+
diagnostics.push({
|
|
2275
|
+
severity: "error",
|
|
2276
|
+
code: "archive_root_missing",
|
|
2277
|
+
path: ".rsp/archives",
|
|
2278
|
+
message: "archive root does not exist; run rsp init or rsp update before querying history"
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
for (const sourcePath of tree.files) {
|
|
2282
|
+
const path = archiveOutputPath(sourcePath, archivesDir);
|
|
2283
|
+
let content;
|
|
2284
|
+
try {
|
|
2285
|
+
content = await readFile6(sourcePath, "utf-8");
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
const message = toErrorMessage(error);
|
|
2288
|
+
diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
|
|
2292
|
+
if (parsed.diagnostic)
|
|
2293
|
+
diagnostics.push(parsed.diagnostic);
|
|
2294
|
+
if (parsed.record)
|
|
2295
|
+
records.push(parsed.record);
|
|
2296
|
+
}
|
|
2297
|
+
records.sort(compareHistoryRecords);
|
|
2298
|
+
diagnostics.sort(compareDiagnostics);
|
|
2299
|
+
const diagnosticTotal = diagnostics.length;
|
|
2300
|
+
const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
|
|
2301
|
+
return {
|
|
2302
|
+
rootExists: tree.rootExists,
|
|
2303
|
+
records,
|
|
2304
|
+
diagnostics: boundedDiagnostics,
|
|
2305
|
+
diagnosticSummary: {
|
|
2306
|
+
total: diagnosticTotal,
|
|
2307
|
+
returned: boundedDiagnostics.length,
|
|
2308
|
+
hasMore: diagnosticTotal > boundedDiagnostics.length
|
|
2309
|
+
},
|
|
2310
|
+
runtime
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
function parseArchiveEntry(sourcePath, path, archivesDir, content) {
|
|
2314
|
+
const nameMatch = basename2(sourcePath).match(ARCHIVE_NAME_RE);
|
|
2315
|
+
if (!nameMatch || !isCalendarDate(nameMatch[1])) {
|
|
2316
|
+
return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
|
|
2317
|
+
}
|
|
2318
|
+
const date = nameMatch[1];
|
|
2319
|
+
const archiveStem = nameMatch[2];
|
|
2320
|
+
const relativeDirectory = normalizeLogicalPath(relative5(archivesDir, dirname5(sourcePath)));
|
|
2321
|
+
const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
|
|
2322
|
+
if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
|
|
2323
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
|
|
2324
|
+
const changeHeadings = [...content.matchAll(/^# Change:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
2325
|
+
const groupHeadings = [...content.matchAll(/^# Change Group:\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
2326
|
+
if (changeHeadings.length + groupHeadings.length !== 1) {
|
|
2327
|
+
return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
|
|
2328
|
+
}
|
|
2329
|
+
let frontmatter;
|
|
2330
|
+
try {
|
|
2331
|
+
frontmatter = parseFrontmatter(content);
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
|
|
2334
|
+
}
|
|
2335
|
+
if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
|
|
2336
|
+
return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
|
|
2337
|
+
if (groupHeadings.length === 1) {
|
|
2338
|
+
const group = groupHeadings[0];
|
|
2339
|
+
if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
|
|
2340
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
|
|
2341
|
+
}
|
|
2342
|
+
return {};
|
|
2343
|
+
}
|
|
2344
|
+
const workRef = changeHeadings[0];
|
|
2345
|
+
const segments = workRef.split("/");
|
|
2346
|
+
if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
|
|
2347
|
+
return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
|
|
2348
|
+
}
|
|
2349
|
+
const expectedGroup = segments.length === 2 ? segments[0] : null;
|
|
2350
|
+
const expectedBase = segments.at(-1);
|
|
2351
|
+
if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
|
|
2352
|
+
return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
|
|
2353
|
+
}
|
|
2354
|
+
const rawSummary = extractSummary(content);
|
|
2355
|
+
if (!rawSummary)
|
|
2356
|
+
return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
|
|
2357
|
+
const summary = boundText(rawSummary);
|
|
2358
|
+
return {
|
|
2359
|
+
record: {
|
|
2360
|
+
date,
|
|
2361
|
+
workRef,
|
|
2362
|
+
group: expectedGroup,
|
|
2363
|
+
kind: frontmatter.kind.trim(),
|
|
2364
|
+
summary: summary.value,
|
|
2365
|
+
summaryTruncated: summary.truncated,
|
|
2366
|
+
path,
|
|
2367
|
+
sourcePath
|
|
2368
|
+
}
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
function extractSummary(content) {
|
|
2372
|
+
const frontmatter = parseFrontmatter(content);
|
|
2373
|
+
if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
|
|
2374
|
+
return frontmatter.summary.trim();
|
|
2375
|
+
const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
|
|
2376
|
+
for (const prefix of ["- Outcome:", "- Summary:"]) {
|
|
2377
|
+
const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
|
|
2378
|
+
if (line && line.slice(prefix.length).trim())
|
|
2379
|
+
return line.slice(prefix.length).trim();
|
|
2380
|
+
}
|
|
2381
|
+
return "";
|
|
2382
|
+
}
|
|
2383
|
+
function boundText(value) {
|
|
2384
|
+
const codePoints = [...value];
|
|
2385
|
+
return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
|
|
2386
|
+
}
|
|
2387
|
+
function matchesArchiveStem(stem, identityBase) {
|
|
2388
|
+
return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
|
|
2389
|
+
}
|
|
2390
|
+
function isCalendarDate(value) {
|
|
2391
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2392
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
2393
|
+
}
|
|
2394
|
+
function archiveOutputPath(sourcePath, archivesDir) {
|
|
2395
|
+
return `.rsp/archives/${normalizeLogicalPath(relative5(archivesDir, sourcePath))}`;
|
|
2396
|
+
}
|
|
2397
|
+
function normalizeOutputPath(path, archivesDir) {
|
|
2398
|
+
const relativePath = normalizeLogicalPath(relative5(archivesDir, path));
|
|
2399
|
+
return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
|
|
2400
|
+
}
|
|
2401
|
+
function errorDiagnostic(code, path, message) {
|
|
2402
|
+
return { severity: "error", code, path, message };
|
|
2403
|
+
}
|
|
2404
|
+
function escapeRegExp2(value) {
|
|
2405
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2406
|
+
}
|
|
2407
|
+
function compareHistoryRecords(left, right) {
|
|
2408
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
2409
|
+
}
|
|
2410
|
+
function compareDiagnostics(left, right) {
|
|
2411
|
+
return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/history/query.ts
|
|
2415
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
2416
|
+
function queryArchiveHistory(records, query = {}) {
|
|
2417
|
+
validateArchiveHistoryQuery(query);
|
|
2418
|
+
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)).sort(compareHistoryRecords2);
|
|
2420
|
+
const selected = matched.slice(0, limit);
|
|
2421
|
+
return {
|
|
2422
|
+
records: selected.map(toOutputRecord),
|
|
2423
|
+
summary: {
|
|
2424
|
+
matched: matched.length,
|
|
2425
|
+
returned: selected.length,
|
|
2426
|
+
hasMore: matched.length > selected.length
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function validateArchiveHistoryQuery(query) {
|
|
2431
|
+
const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
2432
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
|
|
2433
|
+
throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
|
|
2434
|
+
if (query.since && !isCalendarDate2(query.since))
|
|
2435
|
+
throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
|
|
2436
|
+
if (query.until && !isCalendarDate2(query.until))
|
|
2437
|
+
throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
|
|
2438
|
+
if (query.since && query.until && query.since > query.until)
|
|
2439
|
+
throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
|
|
2440
|
+
if (query.kind !== void 0 && query.kind.trim() === "")
|
|
2441
|
+
throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
|
|
2442
|
+
if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
|
|
2443
|
+
throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
|
|
2444
|
+
}
|
|
2445
|
+
function selectArchiveHistoryRecord(records, selector) {
|
|
2446
|
+
const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
|
|
2447
|
+
const identity = "path" in selector ? selector.path : selector.workRef;
|
|
2448
|
+
if (matches.length === 0)
|
|
2449
|
+
throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
|
|
2450
|
+
if (matches.length > 1) {
|
|
2451
|
+
const candidates = matches.map((record) => record.path).sort();
|
|
2452
|
+
throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
|
|
2453
|
+
}
|
|
2454
|
+
return matches[0];
|
|
2455
|
+
}
|
|
2456
|
+
async function readArchiveHistoryDetail(record) {
|
|
2457
|
+
let content;
|
|
2458
|
+
try {
|
|
2459
|
+
content = await readFile7(record.sourcePath, "utf-8");
|
|
2460
|
+
} catch (error) {
|
|
2461
|
+
throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2462
|
+
}
|
|
2463
|
+
const tasks = extractSection(content, "Tasks");
|
|
2464
|
+
const verify = extractSection(content, "Verify");
|
|
2465
|
+
const blockers = extractSection(content, "Blockers");
|
|
2466
|
+
return {
|
|
2467
|
+
...toOutputRecord(record),
|
|
2468
|
+
scenarioCount: parseScenarios(content).length,
|
|
2469
|
+
checkboxes: {
|
|
2470
|
+
tasks: countCheckboxes(tasks),
|
|
2471
|
+
verify: countCheckboxes(verify)
|
|
2472
|
+
},
|
|
2473
|
+
evidence: {
|
|
2474
|
+
tasks: boundEvidence(tasks),
|
|
2475
|
+
verify: boundEvidence(verify),
|
|
2476
|
+
blockers: boundEvidence(blockers, true)
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
function historyInspectionComplete(inspection) {
|
|
2481
|
+
return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
|
|
2482
|
+
}
|
|
2483
|
+
function toOutputRecord(record) {
|
|
2484
|
+
const { sourcePath: _sourcePath, ...output } = record;
|
|
2485
|
+
return output;
|
|
2486
|
+
}
|
|
2487
|
+
function boundEvidence(section, omitNone = false) {
|
|
2488
|
+
const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
|
|
2489
|
+
const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
|
|
2490
|
+
return {
|
|
2491
|
+
items: boundedItems.map((item) => item.value),
|
|
2492
|
+
truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
function isCalendarDate2(value) {
|
|
2496
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
2497
|
+
return false;
|
|
2498
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2499
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
2500
|
+
}
|
|
2501
|
+
function compareHistoryRecords2(left, right) {
|
|
2502
|
+
return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/status/dependency-forest.ts
|
|
2506
|
+
function defaultRoots(plan) {
|
|
2507
|
+
const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
|
|
2508
|
+
const nested = new Set(plan.edges.filter((edge) => selected.has(edge.change) && selected.has(edge.requires)).map((edge) => edge.requires));
|
|
2509
|
+
const roots = [...selected].filter((name) => !nested.has(name)).sort();
|
|
2510
|
+
return roots.length > 0 ? roots : [...selected].sort();
|
|
2511
|
+
}
|
|
2512
|
+
function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
|
|
2513
|
+
const nodes = new Map(plan.nodes.map((node) => [node.name, node]));
|
|
2514
|
+
const edges = /* @__PURE__ */ new Map();
|
|
2515
|
+
for (const edge of plan.edges) {
|
|
2516
|
+
const children = edges.get(edge.change) ?? [];
|
|
2517
|
+
children.push(edge);
|
|
2518
|
+
edges.set(edge.change, children);
|
|
2519
|
+
}
|
|
2520
|
+
for (const children of edges.values())
|
|
2521
|
+
children.sort((left, right) => left.requires.localeCompare(right.requires));
|
|
2522
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
2523
|
+
const visit = (name, edge) => {
|
|
2524
|
+
const node = nodes.get(name);
|
|
2525
|
+
if (!node)
|
|
2526
|
+
return null;
|
|
2527
|
+
const shared = expanded.has(name);
|
|
2528
|
+
if (!shared)
|
|
2529
|
+
expanded.add(name);
|
|
2530
|
+
return {
|
|
2531
|
+
...node,
|
|
2532
|
+
...edge ? { reason: edge.reason, edgeState: edge.state } : {},
|
|
2533
|
+
shared,
|
|
2534
|
+
children: shared ? [] : (edges.get(name) ?? []).map((child) => visit(child.requires, child)).filter((child) => child !== null)
|
|
2535
|
+
};
|
|
2536
|
+
};
|
|
2537
|
+
return requestedRoots.map((name) => visit(name)).filter((node) => node !== null);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
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
|
+
GROUP_BRIEF_FILENAME,
|
|
2596
|
+
WorkRefError,
|
|
2597
|
+
resolveWorkRef,
|
|
2598
|
+
resolveFocusMarkerPath,
|
|
2599
|
+
resolveArchiveDirectory,
|
|
2600
|
+
inspectFocusTree,
|
|
2601
|
+
inspectArchiveTree,
|
|
2602
|
+
inspectWorkTree,
|
|
2603
|
+
resolveExecutableChange,
|
|
2604
|
+
generateGroupBriefContent,
|
|
2605
|
+
inspectChangeGroups,
|
|
2606
|
+
hasArchivedGroupBrief,
|
|
2607
|
+
inspectChangeDependencies,
|
|
2608
|
+
HISTORY_DEFAULT_LIMIT,
|
|
2609
|
+
HISTORY_MAX_LIMIT,
|
|
2610
|
+
ArchiveHistoryError,
|
|
2611
|
+
inspectArchiveHistory,
|
|
2612
|
+
queryArchiveHistory,
|
|
2613
|
+
selectArchiveHistoryRecord,
|
|
2614
|
+
readArchiveHistoryDetail,
|
|
2615
|
+
historyInspectionComplete,
|
|
2616
|
+
inspectProjectStatus,
|
|
2617
|
+
projectDependencyForest
|
|
2618
|
+
};
|