@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/dist/cli.mjs CHANGED
@@ -1,714 +1,97 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ ARCHIVES_DIR,
4
+ ArchiveHistoryError,
5
+ CHANGES_DIR,
6
+ CONFIG_PATH,
7
+ DEFAULT_DECISION_RECORDS_PATH,
8
+ FOCUS_DIR,
9
+ GROUP_BRIEF_FILENAME,
10
+ HISTORY_DEFAULT_LIMIT,
11
+ HISTORY_MAX_LIMIT,
12
+ LOCK_PATH,
13
+ ManagedPathError,
14
+ OBSOLETE_RSP_RULES_PATH,
15
+ PKG_ROOT,
16
+ RSP_DIR,
17
+ RSP_RULES_PATH,
18
+ VALID_KINDS,
19
+ WorkRefError,
20
+ buildDurableReviewGuidance,
21
+ cleanupEmptyParentDirs,
22
+ clearConfigCache,
23
+ collectArchiveChecklist,
24
+ collectArchiveReadiness,
25
+ countCheckboxes,
26
+ detectDeltaSections,
27
+ detectProjectName,
28
+ emitJson,
29
+ ensureDecisionRecordsDirectory,
30
+ ensureManagedFile,
31
+ extractSection,
32
+ generateChangeContent,
33
+ generateDesignContent,
34
+ generateGroupBriefContent,
35
+ generateSpecContent,
36
+ getDurableReviewCandidateTargets,
37
+ getVersion,
38
+ guardRspInitialized,
39
+ hasArchivedGroupBrief,
40
+ hasMeaningfulBlockers,
41
+ hasRspAgentsBlock,
42
+ historyInspectionComplete,
43
+ inspectArchiveHistory,
44
+ inspectArchiveTree,
45
+ inspectChangeDependencies,
46
+ inspectChangeGroups,
47
+ inspectFocusTree,
48
+ inspectManagedDirectory,
49
+ inspectManagedFile,
50
+ inspectManagedFileTree,
51
+ inspectProjectStatus,
52
+ inspectRspConfig,
53
+ inspectUnsupportedRules,
54
+ inspectWorkTree,
55
+ isValidSpecName,
56
+ loadRspConfig,
57
+ normalizeLogicalPath,
58
+ parseFrontmatter,
59
+ parseScenarios,
60
+ pc,
61
+ projectDependencyForest,
62
+ queryArchiveHistory,
63
+ readArchiveHistoryDetail,
64
+ recordRuntimeDiagnostic,
65
+ requireManagedDirectory,
66
+ resolveArchiveDirectory,
67
+ resolveDecisionRecordsPath,
68
+ resolveExecutableChange,
69
+ resolveFocusMarkerPath,
70
+ resolveKinds,
71
+ resolveManagedDirectoryChain,
72
+ resolveRequiredSections,
73
+ resolveWorkRef,
74
+ selectArchiveHistoryRecord,
75
+ toErrorMessage,
76
+ upsertRspAgentsBlock,
77
+ validateDecisionRecordsFilesystemPath,
78
+ walkMarkdownFiles,
79
+ writeManagedFile
80
+ } from "./chunk-QLXIDVKK.mjs";
2
81
 
3
82
  // src/cli.ts
4
83
  import { resolve } from "path";
5
- import { fileURLToPath as fileURLToPath2 } from "url";
6
- import { defineCommand, runMain } from "citty";
7
-
8
- // src/commands/add-rules.ts
9
- import { existsSync as existsSync3 } from "fs";
10
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
11
- import { dirname as dirname3, join as join3 } from "path";
12
-
13
- // src/core/config.ts
14
- import { existsSync as existsSync2 } from "fs";
15
- import { readFile as readFile2 } from "fs/promises";
16
- import { dirname as dirname2, join as join2 } from "path";
17
84
  import { fileURLToPath } from "url";
18
- import pc from "picocolors";
19
-
20
- // src/core/helpers.ts
21
- import { existsSync } from "fs";
22
- import { readdir, readFile, rmdir } from "fs/promises";
23
- import { basename, dirname, join, relative, sep } from "path";
24
- import { parse } from "yaml";
25
-
26
- // src/core/output.ts
27
- function toErrorMessage(error) {
28
- return error instanceof Error ? error.message : String(error);
29
- }
30
- function emitJson(data) {
31
- console.log(JSON.stringify(data, null, 2));
32
- }
33
- function emitStatusJsonError(error, options) {
34
- const payload = {
35
- command: "status",
36
- ok: false,
37
- filters: {
38
- focused: options.focused,
39
- blocked: options.blocked,
40
- stale: null
41
- },
42
- focused: [],
43
- records: [],
44
- summary: {
45
- total: 0,
46
- focused: 0,
47
- blocked: 0
48
- },
49
- archiveTrend: [],
50
- runtime: [],
51
- error
52
- };
53
- emitJson(payload);
54
- }
55
- function recordRuntimeDiagnostic(runtime, diagnostic, verbose = false) {
56
- runtime.push(diagnostic);
57
- if (verbose) {
58
- console.error(` ${pc.dim(`[verbose] ${diagnostic.operation} ${diagnostic.path}: ${diagnostic.message}`)}`);
59
- }
60
- }
61
-
62
- // src/core/helpers.ts
63
- var RSP_AGENTS_BEGIN = "<!-- rsp:begin -->";
64
- var RSP_AGENTS_END = "<!-- rsp:end -->";
65
- function parseYamlText(text) {
66
- const parsed = parse(text);
67
- if (parsed === null || parsed === void 0)
68
- return {};
69
- if (typeof parsed !== "object" || Array.isArray(parsed))
70
- throw new Error("YAML document must be a mapping/object");
71
- return parsed;
72
- }
73
- function parseFrontmatter(content) {
74
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n/);
75
- if (!m)
76
- return null;
77
- return parseYamlText(m[1]);
78
- }
79
- function countCheckboxes(content) {
80
- const todo = (content.match(/\[ \]/g) || []).length;
81
- const progress = (content.match(/\[\/\]/g) || []).length;
82
- const done = (content.match(/\[x\]/g) || []).length;
83
- const dropped = (content.match(/\[-\]/g) || []).length;
84
- return { todo, progress, done, dropped, total: todo + progress + done + dropped };
85
- }
86
- async function walkMarkdownFiles(dir, options = {}) {
87
- const all = await walkFiles(dir, options);
88
- return all.filter((f) => f.endsWith(".md"));
89
- }
90
- async function walkFiles(dir, options = {}) {
91
- const files = [];
92
- try {
93
- const items = await readdir(dir, { withFileTypes: true });
94
- for (const item of items) {
95
- if (item.name.startsWith("."))
96
- continue;
97
- const filePath = join(dir, item.name);
98
- if (item.isDirectory())
99
- files.push(...await walkFiles(filePath, options));
100
- else
101
- files.push(filePath);
102
- }
103
- } catch (error) {
104
- if (error.code !== "ENOENT") {
105
- options.onError?.({
106
- code: "walk_failed",
107
- operation: "readdir",
108
- path: dir,
109
- message: toErrorMessage(error)
110
- });
111
- }
112
- }
113
- return files;
114
- }
115
- function changeNameFromPath(changesDir, filePath) {
116
- const rel = normalizeLogicalPath(relative(changesDir, filePath));
117
- return rel.replace(/\.md$/, "");
118
- }
119
- function normalizeLogicalPath(pathValue) {
120
- return pathValue.split(sep).join("/").replace(/\\/g, "/");
121
- }
122
- function generateChangeContent(name, summary = "", kind, options = {}) {
123
- const proposalSummary = summary || (name === "project-setup" ? "Capture the project model, boundaries, and stable local constraints" : "<one-line summary>");
124
- if (name === "project-setup") {
125
- return `---
126
- kind: ops
127
- ---
128
-
129
- # Change: project-setup
130
-
131
- ## Proposal
132
- - Summary: ${proposalSummary}
133
- - Why:
134
- - Establish a durable project model before normal implementation work starts
135
- - Scope:
136
- - Review the repository structure, entrypoints, and primary outputs
137
- - Fill .rsp/specs/design.md with durable architecture facts
138
- - Add .rsp/rules/project-rules.md when stable local rules or validation steps exist
139
- - Non-goals:
140
- - Do not duplicate durable project facts across this change, specs, and rules
141
-
142
- ## Spec
143
- ### ADDED
144
- - Requirement: project bootstrap capture
145
- - The repository's purpose, scope, and structure are reflected in .rsp/specs/design.md
146
-
147
- ### MODIFIED
148
- - Requirement: stable local operating constraints
149
- - Stable validation or workflow constraints are reflected in .rsp/rules/project-rules.md when needed
150
-
151
- ### Acceptance
152
- #### Scenario: project model captured
153
- - GIVEN an initialized RSP project
154
- - WHEN project setup is completed
155
- - THEN .rsp/specs/design.md reflects durable project facts
156
- - AND .rsp/rules/project-rules.md exists only when stable local rules are present
157
-
158
- ## Design
159
- - Approach:
160
- - Keep bootstrap knowledge in this single change while moving durable facts into specs and rules
161
- - Affected areas:
162
- - .rsp/specs/design.md
163
- - .rsp/rules/project-rules.md
164
- - Constraints:
165
- - Keep the setup lightweight and avoid duplicating durable project facts
166
-
167
- ## Tasks
168
- - [ ] Review the repository structure, entrypoints, and primary outputs
169
- - [ ] Review AGENTS.md and confirm the RSP entry points to the right project files
170
- - [ ] Fill .rsp/specs/design.md with durable architecture facts
171
- - [ ] Add .rsp/rules/project-rules.md if stable local rules or validation steps exist
172
-
173
- ## Verify
174
- - Automated:
175
- - [ ] Run rsp doctor
176
- - Manual:
177
- - [ ] Review .rsp/specs/design.md and confirm it matches the repository
178
- - Durable updates:
179
- - [ ] Decide whether this change produced durable knowledge that belongs in \`.rsp/specs/\` or \`.rsp/rules/\`
180
- - [ ] If yes, write only stable facts to the smallest correct target file before archive; do not promote task history, debugging notes, or one-off implementation context
181
-
182
- ## Blockers
183
- - none
184
- `;
185
- }
186
- const frontmatterKind = kind ?? "<choose: feature | fix | refactor | docs | ops | research>";
187
- if (options.lite)
188
- return generateLiteChangeContent(name, proposalSummary, frontmatterKind);
189
- const template = getChangeTemplateByKind(kind);
190
- return `---
191
- kind: "${frontmatterKind}"
192
- ---
193
-
194
- # Change: ${name}
195
-
196
- ## Proposal
197
- - Summary: ${proposalSummary}
198
- - Why:
199
- - ${template.why}
200
- - Scope:
201
- - ${template.scope}
202
- - Non-goals:
203
- - ${template.nonGoals}
204
-
205
- ## Spec
206
- ${template.specSection}
207
-
208
- ### Acceptance
209
- ${template.acceptanceSection}
210
-
211
- ## Design
212
- - Approach:
213
- - ${template.approach}
214
- - Affected areas:
215
- - ${template.affectedArea1}
216
- - ${template.affectedArea2}
217
- - Constraints:
218
- - ${template.constraints}
219
-
220
- ## Tasks
221
- - [ ] Finalize the proposal, spec, and design details for this change
222
- - [ ] ${template.task}
223
- - [ ] Verify the result and update any required durable docs or rules
224
-
225
- ## Verify
226
- - Automated:
227
- - [ ] ${template.automatedVerify}
228
- - Manual:
229
- - [ ] ${template.manualVerify}
230
- - Durable updates:
231
- - [ ] Decide whether this change produced durable knowledge that belongs in \`.rsp/specs/\` or \`.rsp/rules/\`
232
- - [ ] If yes, write only stable facts to the smallest correct target file before archive; do not promote task history, debugging notes, or one-off implementation context
233
-
234
- ## Blockers
235
- - none
236
- `;
237
- }
238
- function generateLiteChangeContent(name, proposalSummary, frontmatterKind) {
239
- return `---
240
- kind: "${frontmatterKind}"
241
- ---
242
-
243
- # Change: ${name}
244
-
245
- ## Proposal
246
- - Summary: ${proposalSummary}
247
- - Why:
248
- - <why this small change matters>
249
- - Scope:
250
- - <what will change>
251
- - Non-goals:
252
- - none
253
-
254
- ## Spec
255
- ### MODIFIED
256
- - Requirement: <observable outcome>
257
- - <what should be true after this change>
258
-
259
- ### Acceptance
260
- #### Scenario: change is complete
261
- - GIVEN <current context>
262
- - WHEN <the change is applied>
263
- - THEN <expected outcome>
264
-
265
- ## Design
266
- - Approach:
267
- - <minimal implementation approach>
268
- - Affected areas:
269
- - <primary file, directory, or doc path>
270
- - Constraints:
271
- - <important constraint, or none>
272
-
273
- ## Tasks
274
- - [ ] Implement the small change
275
- - [ ] Verify the result
276
-
277
- ## Verify
278
- - Automated:
279
- - [ ] <exact command, or not needed: reason>
280
- - Manual:
281
- - [ ] <exact scenario, or not needed: reason>
282
- - Durable updates:
283
- - [ ] Decide whether this change produced durable knowledge that belongs in ".rsp/specs/" or ".rsp/rules/"
284
- - [ ] If yes, write only stable facts to the smallest correct target file before archive
285
-
286
- ## Blockers
287
- - none
288
- `;
289
- }
290
- function getChangeTemplateByKind(kind) {
291
- switch (kind) {
292
- case "feature":
293
- return {
294
- why: "<what user need or capability gap this addresses>",
295
- scope: "<what new behavior or capability will be delivered>",
296
- nonGoals: "<what related capabilities are explicitly out of scope>",
297
- 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>",
298
- acceptanceSection: "#### Scenario: user exercises the new capability\n- GIVEN <context>\n- WHEN <user action>\n- THEN <expected new behavior>",
299
- approach: "<how the new capability will be implemented>",
300
- affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
301
- affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
302
- constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
303
- task: "<implement the new behavior and its acceptance scenario in the affected areas>",
304
- automatedVerify: "<exact test, lint, build, or check command for the new behavior>",
305
- manualVerify: "<exact end-to-end user scenario to validate the new capability>"
306
- };
307
- case "fix":
308
- return {
309
- why: "<what current behavior is wrong or broken>",
310
- scope: "<what specific defect is being corrected>",
311
- nonGoals: "<what related but separate issues are out of scope>",
312
- specSection: "<!-- Describe expected correct behavior. Implementation notes belong in ## Design. -->\n### MODIFIED\n- Requirement: correct behavior\n - <expected correct behavior after the fix>",
313
- 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",
314
- approach: "<root cause analysis and fix strategy>",
315
- affectedArea1: "<concrete file path, directory, module, or subsystem containing the defect>",
316
- affectedArea2: "<secondary path, dependency, or test file if needed>",
317
- constraints: "<regression, backward-compatibility, safety, or scope constraint that must hold>",
318
- task: "<identify the root cause and apply the minimal fix>",
319
- automatedVerify: "<exact regression test, lint, build, or check command that proves the fix>",
320
- manualVerify: "<exact steps to reproduce the original issue and confirm it no longer occurs>"
321
- };
322
- case "refactor":
323
- return {
324
- why: "<what maintainability, readability, or structural issue motivates this>",
325
- scope: "<what code areas are being restructured>",
326
- nonGoals: "<no behavior change is expected or intended>",
327
- 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>",
328
- 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",
329
- approach: "<how the code will be restructured (rename, extract, move, etc.)>",
330
- affectedArea1: "<concrete file path, directory, module, or subsystem being refactored>",
331
- affectedArea2: "<secondary path, dependency, or test file if needed>",
332
- constraints: "<observable behavior must remain unchanged; compatibility, performance, and safety constraints must still hold>",
333
- task: "<restructure the code while keeping behavior identical>",
334
- automatedVerify: "<exact existing test, lint, build, or check command that must continue to pass>",
335
- manualVerify: "<exact scenario to spot-check that behavior is unchanged after the refactor>"
336
- };
337
- case "docs":
338
- return {
339
- why: "<why the documentation change matters>",
340
- scope: "<which docs, readers, or workflows will be updated>",
341
- nonGoals: "<what documentation behavior or audience is out of scope>",
342
- 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>",
343
- 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",
344
- approach: "<how the documentation will be updated and organized>",
345
- affectedArea1: "<concrete doc path, directory, module doc, or documentation surface 1>",
346
- affectedArea2: "<secondary doc path or reference file if needed>",
347
- constraints: "<durable wording, consistency, scope, or audience constraint that must hold>",
348
- task: "<update the target documentation and supporting references>",
349
- automatedVerify: "<exact markdown, docs, link, lint, build, or check command if applicable>",
350
- manualVerify: "<exact reader or operator scenario to confirm the updated guidance is accurate>"
351
- };
352
- case "research":
353
- return {
354
- why: "<what question or uncertainty this research resolves>",
355
- scope: "<what system area, option set, or hypothesis is being examined>",
356
- nonGoals: "<what implementation work is explicitly out of scope>",
357
- 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>",
358
- 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",
359
- approach: "<how the investigation will be performed and what evidence will be gathered>",
360
- affectedArea1: "<concrete file path, directory, module, subsystem, or source under investigation>",
361
- affectedArea2: "<secondary path, dependency, environment, or evidence source if needed>",
362
- constraints: "<time, evidence, safety, or scope constraint for the investigation>",
363
- task: "<gather evidence, record the recommendation or finding, and note any follow-up implementation work if needed>",
364
- automatedVerify: "<exact command, script, query, or check used to confirm the observed behavior>",
365
- manualVerify: "<exact review of the findings to confirm they answer the original question and identify follow-up work if needed>"
366
- };
367
- case "ops":
368
- return {
369
- why: "<why this operational or environment change matters>",
370
- scope: "<what environment, workflow, or operational path changes>",
371
- nonGoals: "<what product or feature behavior will not change>",
372
- 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>",
373
- 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",
374
- approach: "<how the operational change will be applied safely>",
375
- affectedArea1: "<concrete script, config path, workflow, environment path, or operational surface 1>",
376
- affectedArea2: "<secondary script, config path, dependency, or environment surface if needed>",
377
- constraints: "<rollback, safety, reliability, environment, or scope constraint that must hold>",
378
- task: "<apply and verify the operational change>",
379
- automatedVerify: "<exact operational validation, lint, build, deploy, or check command>",
380
- manualVerify: "<exact operator workflow or environment scenario to confirm the operational result>"
381
- };
382
- default:
383
- return {
384
- why: "<why this change matters>",
385
- scope: "<what this change will do>",
386
- nonGoals: "<what this change will not do>",
387
- specSection: "<!-- Describe observable behavior and requirements. Implementation notes belong in ## Design. -->\n### ADDED\n- Requirement: <new or updated behavior>\n - <verifiable requirement>",
388
- acceptanceSection: "#### Scenario: <name>\n- GIVEN <context>\n- WHEN <action>\n- THEN <expected outcome>",
389
- approach: "<how the change will be implemented>",
390
- affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
391
- affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
392
- constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
393
- task: "<implement the core change in the affected areas>",
394
- automatedVerify: "<exact test, lint, build, or check command>",
395
- manualVerify: "<exact end-to-end scenario to validate>"
396
- };
397
- }
398
- }
399
- function renderRspAgentsBlock() {
400
- return `${RSP_AGENTS_BEGIN}
401
- ## RSP Entry
402
-
403
- RSP keeps durable rules, specs, and current work under \`.rsp/\`.
404
- Treat AGENTS.md as navigation only; keep durable rules and design in \`.rsp/\`.
405
-
406
- Read in order:
407
- 1. .rsp/rules/rsp-rules.md
408
- 2. .rsp/focus.d/
409
- 3. matching .rsp/changes/*.md for the focused entries
410
- 4. .rsp/specs/design.md
411
- 5. .rsp/specs/INDEX.md
412
- 6. only the relevant additional .rsp/rules/*.md and .rsp/specs/*.md files
413
-
414
- 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.
415
- If your agent supports Agent Skills, load \`rsp\` for setup, repair, and durable-decision tasks.
416
- ${RSP_AGENTS_END}`;
417
- }
418
- function upsertRspAgentsBlock(content) {
419
- const block = renderRspAgentsBlock();
420
- const managedRe = /<!-- rsp:begin -->[\s\S]*?<!-- rsp:end -->/;
421
- if (managedRe.test(content)) {
422
- const next2 = content.replace(managedRe, block);
423
- return { content: next2, changed: next2 !== content };
424
- }
425
- const trimmed = content.trimStart();
426
- const next = trimmed ? `${block}
427
-
428
- ${trimmed}` : `${block}
429
- `;
430
- return { content: next, changed: true };
431
- }
432
- function hasRspAgentsBlock(content) {
433
- return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
434
- }
435
- var CHANGE_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
436
- function isValidChangeName(name) {
437
- return CHANGE_NAME_RE.test(name);
438
- }
439
- function guardRspInitialized() {
440
- const rspRulesPath = join(RSP_DIR, "rules", "rsp-rules.md");
441
- const designPath = join(RSP_DIR, "specs", "design.md");
442
- if (!existsSync(rspRulesPath) || !existsSync(designPath)) {
443
- console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
444
- console.error(` ${pc.dim("Run: rsp init")}`);
445
- process.exit(1);
446
- }
447
- }
448
- async function getFocusedChangeNames(options = {}) {
449
- const names = /* @__PURE__ */ new Set();
450
- const focusDir = join(RSP_DIR, "focus.d");
451
- if (!existsSync(focusDir))
452
- return names;
453
- const entries = await walkFiles(focusDir, options);
454
- for (const entryPath of entries)
455
- names.add(normalizeLogicalPath(relative(focusDir, entryPath)));
456
- return names;
457
- }
458
- function getOpenCheckboxes(sectionText) {
459
- return sectionText.split("\n").map((line) => line.trim()).filter((line) => /^- \[[ /]\]/.test(line));
460
- }
461
- async function cleanupEmptyParentDirs(path, stopDir) {
462
- let parent = dirname(path);
463
- while (parent !== stopDir) {
464
- try {
465
- const remaining = await readdir(parent);
466
- if (remaining.length === 0)
467
- await rmdir(parent);
468
- else
469
- break;
470
- } catch {
471
- break;
472
- }
473
- parent = dirname(parent);
474
- }
475
- }
476
- async function detectProjectName() {
477
- try {
478
- if (existsSync("package.json")) {
479
- const raw = await readFile("package.json", "utf-8");
480
- const pkg = JSON.parse(raw);
481
- if (pkg.name && pkg.name !== "")
482
- return pkg.name;
483
- }
484
- } catch {
485
- }
486
- return basename(process.cwd());
487
- }
488
- function generateDesignContent(projectName) {
489
- return `# Project Design: ${projectName}
490
-
491
- ## Purpose
492
- - <what this project is responsible for>
493
- - <who or what it serves>
494
-
495
- ## Stable Facts
496
- - <durable fact that future agents or developers must know>
497
- - <key architectural invariant>
498
-
499
- ## Boundaries
500
- - In scope:
501
- - <major capability or boundary>
502
- - Out of scope:
503
- - <intentional non-goal>
504
-
505
- ## Structure
506
- - <important directory or subsystem> \u2014 <responsibility>
507
-
508
- ## Constraints
509
- - <cross-cutting technical or operational constraint, when it affects architecture>
510
- `;
511
- }
512
- function generateProjectRulesContent(projectName) {
513
- return `---
514
- name: project-rules
515
- description: Project-specific rules for ${projectName}
516
- ---
517
-
518
- # Project Rules
519
-
520
- ## Scope
521
- - <stable local rules, workflow constraints, or validation expectations>
522
- - Do not put temporary debugging steps here.
523
-
524
- ## Validation
525
- - <preferred stable validation commands, not temporary troubleshooting steps>
526
-
527
- ## Conventions
528
- - <project-specific conventions>
529
- `;
530
- }
531
- function generateRulesContent(name) {
532
- const title = toTitleCase(name);
533
- return `---
534
- name: ${name}
535
- description: Durable rules for ${title}
536
- ---
537
-
538
- # ${title}
539
-
540
- ## Scope
541
- - <what this rules file governs>
542
-
543
- ## Rules
544
- - <stable local rule>
545
-
546
- ## Validation
547
- - <relevant validation command or check>
548
- `;
549
- }
550
- function generateSpecContent(name) {
551
- const title = toTitleCase(name);
552
- return `# ${title}
553
-
554
- ## Purpose
555
- - <why this project-level spec exists>
556
-
557
- ## Stable Facts
558
- - <durable fact that future work must know>
559
-
560
- ## Boundaries
561
- - In scope:
562
- - <what this spec covers>
563
- - Out of scope:
564
- - <what this spec does not cover>
565
-
566
- ## Constraints
567
- - <stable constraint, if any>
568
- `;
569
- }
570
- function toTitleCase(value) {
571
- return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
572
- }
573
- function extractSection(content, heading) {
574
- const lines = content.split("\n");
575
- const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
576
- if (start === -1)
577
- return "";
578
- const body = [];
579
- for (let index = start + 1; index < lines.length; index++) {
580
- const line = lines[index];
581
- if (line.startsWith("## "))
582
- break;
583
- body.push(line);
584
- }
585
- return body.join("\n").trim();
586
- }
587
- function hasMeaningfulBlockers(content) {
588
- const blockers = extractSection(content, "Blockers");
589
- if (!blockers)
590
- return false;
591
- const lines = blockers.split("\n").map((line) => line.trim()).filter(Boolean);
592
- if (lines.length === 0)
593
- return false;
594
- return lines.some((line) => !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
595
- }
596
- function detectDeltaSections(content) {
597
- const body = extractSection(content, "Spec");
598
- return {
599
- added: /^###\s*ADDED/im.test(body),
600
- modified: /^###\s*MODIFIED/im.test(body),
601
- removed: /^###\s*REMOVED/im.test(body)
602
- };
603
- }
604
- function collectArchiveReadiness(content) {
605
- const warnings = [];
606
- const tasksSection = extractSection(content, "Tasks");
607
- const verifySection = extractSection(content, "Verify");
608
- const taskTodos = getOpenCheckboxes(tasksSection);
609
- if (taskTodos.length > 0)
610
- warnings.push(`${taskTodos.length} task item(s) still incomplete`);
611
- const verifyTodos = getOpenCheckboxes(verifySection);
612
- if (verifyTodos.length > 0)
613
- warnings.push(`${verifyTodos.length} Verify checklist item(s) are still incomplete`);
614
- const activeBlockers = hasMeaningfulBlockers(content);
615
- if (activeBlockers)
616
- warnings.push("active blockers are present in the change file");
617
- const scenarios = parseScenarios(content);
618
- const missingScenarios = scenarios.length === 0;
619
- if (missingScenarios)
620
- warnings.push("no Scenario blocks found (some changes do not need them)");
621
- const archiveReady = activeBlockers ? "no" : warnings.length === 0 ? "yes" : "judgment";
622
- return {
623
- taskTodos,
624
- verifyTodos,
625
- activeBlockers,
626
- scenarioCount: scenarios.length,
627
- missingScenarios,
628
- deterministic: warnings.length === 0 ? "pass" : "warnings",
629
- semantic: "needs-review",
630
- archiveReady,
631
- warnings
632
- };
633
- }
634
- function buildDurableReviewGuidance(candidateTargets) {
635
- return {
636
- required: true,
637
- decisions: [
638
- "No durable update needed",
639
- "Update existing spec or rule",
640
- "Create a new durable spec"
641
- ],
642
- candidateTargets,
643
- note: "Semantic review decides whether stable facts belong in specs or rules; the CLI never merges delta specs automatically."
644
- };
645
- }
646
- function getDurableReviewCandidateTargets(options = {}) {
647
- const targets = [".rsp/specs/design.md"];
648
- if (options.projectRulesExists)
649
- targets.push(".rsp/rules/project-rules.md");
650
- return targets;
651
- }
652
- function collectArchiveChecklist(content) {
653
- return collectArchiveReadiness(content).warnings;
654
- }
655
- function parseScenarios(content) {
656
- const scenarios = [];
657
- const regex = /^#{3,4}\s*Scenario:\s*(.+)$/gim;
658
- let match;
659
- while ((match = regex.exec(content)) !== null) {
660
- const heading = match[1].trim();
661
- const startPos = match.index + match[0].length;
662
- const restContent = content.slice(startPos);
663
- const nextSection = restContent.match(/^#{3,4}\s/m);
664
- const block = nextSection ? restContent.slice(0, nextSection.index) : restContent;
665
- 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*/, ""));
666
- if (steps.length > 0)
667
- scenarios.push({ heading, steps });
668
- }
669
- return scenarios;
670
- }
85
+ import { defineCommand, runMain } from "citty";
671
86
 
672
- // src/core/config.ts
673
- var __dirname = dirname2(fileURLToPath(import.meta.url));
674
- var PKG_ROOT = join2(__dirname, "..");
675
- var RSP_DIR = ".rsp";
676
- var CHANGES_DIR = join2(RSP_DIR, "changes");
677
- var FOCUS_DIR = join2(RSP_DIR, "focus.d");
678
- var ARCHIVES_DIR = join2(RSP_DIR, "archives");
679
- var LOCK_PATH = join2(RSP_DIR, ".lock");
680
- var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
681
- var VALID_KINDS = ["feature", "fix", "refactor", "docs", "ops", "research"];
682
- var DEFAULT_REQUIRED_SECTIONS = ["Proposal", "Spec", "Design", "Tasks", "Verify", "Blockers"];
683
- var _configCache = null;
684
- async function loadRspConfig() {
685
- if (_configCache)
686
- return _configCache;
687
- if (!existsSync2(CONFIG_PATH)) {
688
- _configCache = {};
689
- return _configCache;
690
- }
691
- const raw = await readFile2(CONFIG_PATH, "utf-8");
692
- const parsed = parseYamlText(raw);
693
- _configCache = {
694
- kinds: Array.isArray(parsed.kinds) ? parsed.kinds.map(String) : void 0
695
- };
696
- return _configCache;
697
- }
698
- function resolveKinds(config) {
699
- return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
700
- }
701
- function resolveRequiredSections(_config) {
702
- return [...DEFAULT_REQUIRED_SECTIONS];
703
- }
704
- async function getVersion() {
705
- const { version } = JSON.parse(await readFile2(join2(PKG_ROOT, "package.json"), "utf-8"));
706
- return version;
707
- }
87
+ // src/commands/add-spec.ts
88
+ import { mkdir as mkdir2, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
89
+ import { join as join2 } from "path";
708
90
 
709
91
  // src/core/lock.ts
710
- import { mkdir, readFile as readFile3, unlink, writeFile } from "fs/promises";
92
+ import { mkdir, readFile, unlink, writeFile } from "fs/promises";
711
93
  async function withRspLock(operation, fn) {
94
+ requireManagedDirectory(RSP_DIR, "RSP root", { allowMissing: true });
712
95
  await mkdir(RSP_DIR, { recursive: true });
713
96
  try {
714
97
  await writeFile(LOCK_PATH, `${process.pid}
@@ -731,7 +114,7 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
731
114
  }
732
115
  async function handleStaleLock(operation) {
733
116
  try {
734
- const content = await readFile3(LOCK_PATH, "utf-8");
117
+ const content = await readFile(LOCK_PATH, "utf-8");
735
118
  const lines = content.trim().split("\n");
736
119
  const pid = Number(lines[0]);
737
120
  if (pid && Number.isFinite(pid)) {
@@ -766,50 +149,30 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
766
149
  }
767
150
  }
768
151
 
769
- // src/commands/add-rules.ts
770
- async function addRules(name, projectName = "project") {
771
- if (!name || !isValidChangeName(name)) {
772
- console.error(` ${pc.red("Error:")} rule name must be kebab-case with optional subdirectory`);
773
- process.exit(1);
774
- }
775
- guardRspInitialized();
776
- const path = join3(RSP_DIR, "rules", `${name}.md`);
777
- if (existsSync3(path)) {
778
- console.error(` ${pc.red("Error:")} rule file already exists: .rsp/rules/${name}.md`);
779
- process.exit(1);
780
- }
781
- return withRspLock("add-rules", async () => {
782
- await mkdir2(dirname3(path), { recursive: true });
783
- const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
784
- const content = name === "project-rules" ? generateProjectRulesContent(resolvedProjectName) : generateRulesContent(name);
785
- await writeFile2(path, content);
786
- console.log(` ${pc.green("Created:")} .rsp/rules/${name}.md
787
- `);
788
- });
789
- }
790
-
791
- // src/commands/add-spec.ts
792
- import { existsSync as existsSync5 } from "fs";
793
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
794
- import { dirname as dirname4, join as join5 } from "path";
795
-
796
152
  // src/commands/specs-index.ts
797
- import { existsSync as existsSync4 } from "fs";
798
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
799
- import { basename as basename2, join as join4, relative as relative2 } from "path";
153
+ import { existsSync } from "fs";
154
+ import { readFile as readFile2 } from "fs/promises";
155
+ import { basename, isAbsolute, join, relative, sep } from "path";
800
156
  async function buildSpecsIndex({ acquireLock = true, quiet = false } = {}) {
801
157
  if (acquireLock)
802
158
  return withRspLock("specs-index", async () => buildSpecsIndex({ acquireLock: false, quiet }));
803
- const specsDir = join4(RSP_DIR, "specs");
804
- if (!existsSync4(specsDir)) {
159
+ const specsDir = join(RSP_DIR, "specs");
160
+ const configInspection = await inspectRspConfig();
161
+ if (configInspection.issues.length > 0)
162
+ throw new Error(configInspection.issues.join("; "));
163
+ const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
164
+ const inspection = await inspectManagedFileTree(specsDir, "Specs", { allowMissing: true });
165
+ if (inspection.issues.length > 0)
166
+ throw inspection.issues[0];
167
+ if (!inspection.rootExists) {
805
168
  if (!quiet)
806
169
  console.log(` ${pc.dim("No specs directory.")}
807
170
  `);
808
171
  return false;
809
172
  }
810
- const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) => {
811
- const name = basename2(fp);
812
- return name !== "INDEX.md" && name !== "design.md";
173
+ const specFiles = inspection.files.filter((fp) => fp.endsWith(".md")).filter((fp) => {
174
+ const name = basename(fp);
175
+ return name !== "INDEX.md" && name !== "design.md" && !isInside(DEFAULT_DECISION_RECORDS_PATH, fp) && !isInside(decisionRecordsPath, fp);
813
176
  }).sort();
814
177
  const lines = [];
815
178
  lines.push("---");
@@ -831,8 +194,8 @@ async function buildSpecsIndex({ acquireLock = true, quiet = false } = {}) {
831
194
  lines.push("| File | Title | Summary |");
832
195
  lines.push("|------|-------|---------|");
833
196
  for (const fp of specFiles) {
834
- const rel = normalizeLogicalPath(relative2(specsDir, fp));
835
- const content = await readFile4(fp, "utf-8");
197
+ const rel = normalizeLogicalPath(relative(specsDir, fp));
198
+ const content = await readFile2(fp, "utf-8");
836
199
  const fm = parseFrontmatter(content);
837
200
  const fmTitle = fm && typeof fm.title === "string" ? fm.title : "";
838
201
  const title = fmTitle || extractTitle(content) || rel.replace(/\.md$/, "");
@@ -840,17 +203,21 @@ async function buildSpecsIndex({ acquireLock = true, quiet = false } = {}) {
840
203
  lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
841
204
  }
842
205
  }
843
- const indexPath = join4(specsDir, "INDEX.md");
206
+ const indexPath = join(specsDir, "INDEX.md");
844
207
  const nextContent = lines.join("\n");
845
- const existingContent = existsSync4(indexPath) ? await readFile4(indexPath, "utf-8") : null;
208
+ const existingContent = existsSync(indexPath) ? await readFile2(indexPath, "utf-8") : null;
846
209
  const changed = existingContent !== nextContent;
847
210
  if (changed)
848
- await writeFile3(indexPath, nextContent);
211
+ await writeManagedFile(indexPath, nextContent, "Specs index");
849
212
  if (!quiet)
850
213
  console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
851
214
  `);
852
215
  return changed;
853
216
  }
217
+ function isInside(directory, filePath) {
218
+ const rel = relative(directory, filePath);
219
+ return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
220
+ }
854
221
  function extractTitle(content) {
855
222
  for (const rawLine of content.split("\n")) {
856
223
  if (!rawLine.startsWith("# "))
@@ -886,46 +253,82 @@ function escapeCell(value) {
886
253
 
887
254
  // src/commands/add-spec.ts
888
255
  async function addSpec(name, projectName = "project") {
889
- if (!name || !isValidChangeName(name)) {
256
+ if (!name || !isValidSpecName(name)) {
890
257
  console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
891
258
  process.exit(1);
892
259
  }
260
+ if (name.startsWith("decisions/")) {
261
+ console.error(` ${pc.red("Error:")} .rsp/specs/decisions/ is reserved for Decision Records`);
262
+ process.exit(1);
263
+ }
893
264
  guardRspInitialized();
894
- const path = join5(RSP_DIR, "specs", `${name}.md`);
895
- if (existsSync5(path)) {
896
- console.error(` ${pc.red("Error:")} spec file already exists: .rsp/specs/${name}.md`);
265
+ try {
266
+ const configInspection = await inspectRspConfig();
267
+ if (configInspection.issues.length > 0) {
268
+ console.error(` ${pc.red("Error:")} ${configInspection.issues.join("; ")}`);
269
+ process.exit(1);
270
+ }
271
+ } catch (error) {
272
+ console.error(` ${pc.red("Error:")} .rsp/config.yaml could not be parsed: ${toErrorMessage(error)}`);
897
273
  process.exit(1);
898
274
  }
899
275
  return withRspLock("add-spec", async () => {
900
- await mkdir3(dirname4(path), { recursive: true });
276
+ const specsDir = join2(RSP_DIR, "specs");
277
+ const segments = name.split("/");
278
+ const parent = resolveManagedDirectoryChain(specsDir, segments.slice(0, -1), "spec path");
279
+ const path = join2(parent, `${segments.at(-1)}.md`);
280
+ const inspection = await inspectManagedFileTree(specsDir, "Specs");
281
+ if (inspection.issues.length > 0)
282
+ throw inspection.issues[0];
283
+ await mkdir2(parent, { recursive: true });
901
284
  const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
902
285
  const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
903
- await writeFile4(path, content);
904
- await buildSpecsIndex({ acquireLock: false });
286
+ let created = false;
287
+ try {
288
+ await writeFile2(path, content, { flag: "wx" });
289
+ created = true;
290
+ await buildSpecsIndex({ acquireLock: false });
291
+ } catch (error) {
292
+ if (created) {
293
+ try {
294
+ await unlink2(path);
295
+ await cleanupEmptyParentDirs(path, specsDir);
296
+ } catch {
297
+ }
298
+ }
299
+ if (error.code === "EEXIST")
300
+ throw new Error(`spec file already exists: .rsp/specs/${name}.md`);
301
+ throw error;
302
+ }
905
303
  console.log(` ${pc.green("Created:")} .rsp/specs/${name}.md
906
304
  `);
907
305
  });
908
306
  }
909
307
 
910
308
  // src/commands/archive.ts
911
- import { existsSync as existsSync7 } from "fs";
912
- import { mkdir as mkdir4, readFile as readFile6, rename, unlink as unlink2 } from "fs/promises";
913
- import { basename as basename4, dirname as dirname6, join as join7 } from "path";
309
+ import { existsSync as existsSync3 } from "fs";
310
+ import { mkdir as mkdir3, readFile as readFile4, rename, unlink as unlink3 } from "fs/promises";
311
+ import { basename as basename3, join as join4 } from "path";
914
312
 
915
313
  // src/commands/archive-index.ts
916
- import { existsSync as existsSync6 } from "fs";
917
- import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
918
- import { basename as basename3, dirname as dirname5, join as join6, relative as relative3 } from "path";
314
+ import { existsSync as existsSync2 } from "fs";
315
+ import { readFile as readFile3 } from "fs/promises";
316
+ import { basename as basename2, dirname, join as join3, relative as relative2 } from "path";
919
317
  async function buildArchiveIndex({ acquireLock = true, quiet = false } = {}) {
920
318
  if (acquireLock)
921
319
  return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false, quiet }));
922
- if (!existsSync6(ARCHIVES_DIR)) {
320
+ const inspection = await inspectArchiveTree();
321
+ if (inspection.diagnostics.length > 0) {
322
+ const diagnostic = inspection.diagnostics[0];
323
+ throw new WorkRefError(diagnostic.code, diagnostic.message, diagnostic.input);
324
+ }
325
+ if (!inspection.rootExists) {
923
326
  if (!quiet)
924
327
  console.log(` ${pc.dim("No archives directory.")}
925
328
  `);
926
329
  return false;
927
330
  }
928
- const archiveFiles = (await walkMarkdownFiles(ARCHIVES_DIR)).filter((fp) => basename3(fp) !== "INDEX.md").sort();
331
+ const archiveFiles = inspection.files;
929
332
  const lines = [];
930
333
  lines.push("---");
931
334
  lines.push("title: Archive Index");
@@ -945,9 +348,9 @@ async function buildArchiveIndex({ acquireLock = true, quiet = false } = {}) {
945
348
  lines.push("|------|--------|------|---------|");
946
349
  }
947
350
  for (const fp of archiveFiles) {
948
- const base = basename3(fp);
949
- const archiveRel = normalizeLogicalPath(relative3(ARCHIVES_DIR, fp));
950
- const archiveDirPart = dirname5(archiveRel);
351
+ const base = basename2(fp);
352
+ const archiveRel = normalizeLogicalPath(relative2(ARCHIVES_DIR, fp));
353
+ const archiveDirPart = dirname(archiveRel);
951
354
  const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
952
355
  const date = dateMatch ? dateMatch[1] : "\u2014";
953
356
  const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
@@ -955,26 +358,29 @@ async function buildArchiveIndex({ acquireLock = true, quiet = false } = {}) {
955
358
  let summary = "";
956
359
  let kind = "";
957
360
  try {
958
- const content = await readFile5(fp, "utf-8");
361
+ const content = await readFile3(fp, "utf-8");
959
362
  const fm = parseFrontmatter(content);
960
363
  if (typeof fm?.kind === "string")
961
364
  kind = fm.kind;
962
365
  if (typeof fm?.summary === "string")
963
366
  summary = fm.summary.trim();
964
367
  const proposal = extractSection(content, "Proposal");
965
- const summaryLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Summary:"));
966
- if (!summary && summaryLine)
967
- summary = summaryLine.slice("- Summary:".length).trim();
368
+ const outcomeLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Outcome:"));
369
+ const legacySummaryLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Summary:"));
370
+ if (!summary && outcomeLine)
371
+ summary = outcomeLine.slice("- Outcome:".length).trim();
372
+ if (!summary && legacySummaryLine)
373
+ summary = legacySummaryLine.slice("- Summary:".length).trim();
968
374
  } catch {
969
375
  }
970
376
  lines.push(`| ${date} | ${escapeCell2(changeName)} | ${escapeCell2(kind || "\u2014")} | ${escapeCell2(summary || "\u2014")} |`);
971
377
  }
972
- const indexPath = join6(ARCHIVES_DIR, "INDEX.md");
378
+ const indexPath = join3(ARCHIVES_DIR, "INDEX.md");
973
379
  const nextContent = lines.join("\n");
974
- const existingContent = existsSync6(indexPath) ? await readFile5(indexPath, "utf-8") : null;
380
+ const existingContent = existsSync2(indexPath) ? await readFile3(indexPath, "utf-8") : null;
975
381
  const changed = existingContent !== nextContent;
976
382
  if (changed)
977
- await writeFile5(indexPath, nextContent);
383
+ await writeManagedFile(indexPath, nextContent, "archive index");
978
384
  if (!quiet)
979
385
  console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived change(s).
980
386
  `);
@@ -990,19 +396,14 @@ async function archiveChange(name, options = {}) {
990
396
  console.error(` ${pc.red("Usage:")} rsp archive <name>`);
991
397
  process.exit(1);
992
398
  }
993
- if (!isValidChangeName(name)) {
994
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
995
- process.exit(1);
996
- }
997
399
  guardRspInitialized();
998
- const srcPath = join7(CHANGES_DIR, `${name}.md`);
999
- if (!existsSync7(srcPath)) {
1000
- console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
1001
- process.exit(1);
1002
- }
1003
400
  if (options.dryRun) {
1004
- const content = await readFile6(srcPath, "utf-8");
1005
- const checklist = collectArchiveChecklist(content);
401
+ const workRef = await resolveArchiveWorkRefOrExit(name);
402
+ const content = await readFile4(workRef.path, "utf-8");
403
+ const dependencyInspection = await inspectChangeDependencies();
404
+ const checklist = collectArchiveChecklist(content, {
405
+ activeBlockers: dependencyInspection.activeBlockers.get(name)
406
+ });
1006
407
  console.log();
1007
408
  console.log(` ${pc.bold("Archive dry-run for")} ${pc.cyan(name)}`);
1008
409
  console.log();
@@ -1018,140 +419,214 @@ async function archiveChange(name, options = {}) {
1018
419
  }
1019
420
  return;
1020
421
  }
1021
- return withRspLock("archive-change", async () => {
1022
- const content = await readFile6(srcPath, "utf-8");
1023
- const checklist = collectArchiveChecklist(content);
1024
- for (const line of checklist)
1025
- console.log(` ${pc.yellow("\u26A0")} ${line}`);
1026
- if (checklist.length > 0)
1027
- console.log(` ${pc.dim("Archive will continue. Review the warnings above before treating this work as fully closed.")}
422
+ try {
423
+ return await withRspLock("archive-change", async () => {
424
+ const workRef = await resolveExecutableChange(name, { mustExist: true });
425
+ const srcPath = workRef.path;
426
+ const archiveSubdir = resolveArchiveDirectory(workRef);
427
+ const focusEntry = resolveFocusMarkerPath(workRef);
428
+ const content = await readFile4(srcPath, "utf-8");
429
+ const dependencyInspection = await inspectChangeDependencies();
430
+ const checklist = collectArchiveChecklist(content, {
431
+ activeBlockers: dependencyInspection.activeBlockers.get(name)
432
+ });
433
+ for (const line of checklist)
434
+ console.log(` ${pc.yellow("\u26A0")} ${line}`);
435
+ if (checklist.length > 0)
436
+ console.log(` ${pc.dim("Archive will continue. Review the warnings above before treating this work as fully closed.")}
1028
437
  `);
1029
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1030
- const dir = dirname6(name);
1031
- const base = basename4(name);
1032
- const archiveSubdir = dir !== "." ? join7(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
1033
- await mkdir4(archiveSubdir, { recursive: true });
1034
- const archiveName = resolveArchiveName(archiveSubdir, date, base);
1035
- await rename(srcPath, join7(archiveSubdir, archiveName));
1036
- const postArchiveWarnings = [];
1037
- let focusCleared = false;
1038
- const focusEntry = join7(FOCUS_DIR, name);
1039
- if (existsSync7(focusEntry)) {
438
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
439
+ const base = basename3(workRef.name);
440
+ await mkdir3(archiveSubdir, { recursive: true });
441
+ const archiveName = resolveArchiveName(archiveSubdir, date, base);
442
+ await rename(srcPath, join4(archiveSubdir, archiveName));
443
+ const postArchiveWarnings = [];
444
+ let focusCleared = false;
445
+ if (existsSync3(focusEntry)) {
446
+ try {
447
+ await unlink3(focusEntry);
448
+ await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
449
+ focusCleared = true;
450
+ } catch (error) {
451
+ postArchiveWarnings.push(`focus marker cleanup failed: ${toErrorMessage(error)}`);
452
+ }
453
+ }
1040
454
  try {
1041
- await unlink2(focusEntry);
1042
- await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
1043
- focusCleared = true;
455
+ await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
1044
456
  } catch (error) {
1045
- postArchiveWarnings.push(`focus marker cleanup failed: ${toErrorMessage(error)}`);
457
+ postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
1046
458
  }
1047
- }
1048
- try {
1049
- await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
1050
- } catch (error) {
1051
- postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
1052
- }
1053
- const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
459
+ const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
1054
460
  ` : "";
1055
- console.log(` ${pc.green("Archived:")} ${archiveName}
461
+ console.log(` ${pc.green("Archived:")} ${archiveName}
1056
462
  ${clearedMsg}`);
1057
- try {
1058
- await buildArchiveIndex({ acquireLock: false });
1059
- } catch (error) {
1060
- postArchiveWarnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
1061
- }
1062
- for (const warning of postArchiveWarnings)
1063
- console.log(` ${pc.yellow("\u26A0")} ${warning}`);
1064
- if (postArchiveWarnings.length > 0)
1065
- console.log(` ${pc.dim("Archive completed, but follow-up cleanup was only partially successful.")}
463
+ try {
464
+ await buildArchiveIndex({ acquireLock: false });
465
+ } catch (error) {
466
+ postArchiveWarnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
467
+ }
468
+ for (const warning of postArchiveWarnings)
469
+ console.log(` ${pc.yellow("\u26A0")} ${warning}`);
470
+ if (postArchiveWarnings.length > 0)
471
+ console.log(` ${pc.dim("Archive completed, but follow-up cleanup was only partially successful.")}
1066
472
  `);
1067
- if (existsSync7(".git")) {
1068
- const archiveRelPath = dir !== "." ? join7(dir, archiveName) : archiveName;
1069
- console.log(` ${pc.cyan("Git workflow:")}
473
+ if (existsSync3(".git")) {
474
+ console.log(` ${pc.cyan("Git delivery:")}
1070
475
  `);
1071
- console.log(` git add .rsp/archives/${archiveRelPath}`);
1072
- console.log(` git commit -m "feat: archive ${name}"`);
1073
- console.log();
476
+ console.log(` git status --short`);
477
+ console.log(` Inspect the complete archive transition; stage and commit only with separate Git authority.`);
478
+ console.log();
479
+ }
480
+ });
481
+ } catch (error) {
482
+ if (error instanceof WorkRefError) {
483
+ console.error(` ${pc.red("Error:")} ${error.message}`);
484
+ process.exit(1);
1074
485
  }
1075
- });
486
+ throw error;
487
+ }
488
+ }
489
+ async function resolveArchiveWorkRefOrExit(name) {
490
+ try {
491
+ const workRef = await resolveExecutableChange(name, { mustExist: true });
492
+ resolveArchiveDirectory(workRef);
493
+ resolveFocusMarkerPath(workRef);
494
+ return workRef;
495
+ } catch (error) {
496
+ if (error instanceof WorkRefError) {
497
+ console.error(` ${pc.red("Error:")} ${error.message}`);
498
+ process.exit(1);
499
+ }
500
+ throw error;
501
+ }
1076
502
  }
1077
503
  function resolveArchiveName(archiveSubdir, date, base) {
1078
504
  const initialName = `${date}_${base}.md`;
1079
- if (!existsSync7(join7(archiveSubdir, initialName)))
505
+ if (!existsSync3(join4(archiveSubdir, initialName)))
1080
506
  return initialName;
1081
507
  const MAX_SUFFIX = 9999;
1082
508
  for (let index = 2; index <= MAX_SUFFIX; index++) {
1083
509
  const candidate = `${date}_${base}-${index}.md`;
1084
- if (!existsSync7(join7(archiveSubdir, candidate)))
510
+ if (!existsSync3(join4(archiveSubdir, candidate)))
1085
511
  return candidate;
1086
512
  }
1087
- console.error(` ${pc.red("Error:")} archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`);
1088
- process.exit(1);
513
+ throw new WorkRefError(
514
+ "archive_name_exhausted",
515
+ `archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`,
516
+ base
517
+ );
1089
518
  }
1090
519
 
1091
520
  // src/commands/check.ts
1092
- import { existsSync as existsSync8 } from "fs";
1093
- import { readFile as readFile7 } from "fs/promises";
1094
- import { join as join8, relative as relative4 } from "path";
521
+ import { readFile as readFile5 } from "fs/promises";
1095
522
  async function runCheck(options = {}) {
1096
- const config = await loadRspConfig();
523
+ const diagnostics = [];
524
+ const runtime = [];
525
+ let config;
526
+ try {
527
+ config = await loadRspConfig();
528
+ } catch (error) {
529
+ const result2 = {
530
+ command: "check",
531
+ ok: false,
532
+ focused: Boolean(options.focused),
533
+ diagnostics: [{ severity: "error", code: "invalid_config", path: ".rsp/config.yaml", message: toErrorMessage(error) }],
534
+ runtime,
535
+ summary: { changeFiles: 0, errors: 1, warnings: 0 }
536
+ };
537
+ if (options.json)
538
+ emitJson(result2, options);
539
+ else
540
+ console.error(` ${pc.red("Error:")} ${toErrorMessage(error)}`);
541
+ return result2;
542
+ }
1097
543
  const validKinds = resolveKinds(config);
1098
544
  const requiredSections = resolveRequiredSections(config);
1099
545
  const choosePlaceholderRe = /^<choose:/i;
1100
- const diagnostics = [];
1101
- const runtime = [];
1102
546
  const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
1103
547
  const addDiagnostic = (diagnostic) => diagnostics.push(diagnostic);
1104
- let focusedSet = /* @__PURE__ */ new Set();
1105
- if (options.focused) {
1106
- focusedSet = await getFocusedChangeNames({ onError: reportRuntime });
548
+ const focusTree = await inspectFocusTree();
549
+ for (const diagnostic of focusTree.diagnostics) {
550
+ addDiagnostic({
551
+ severity: "error",
552
+ code: diagnostic.code,
553
+ change: diagnostic.input,
554
+ path: diagnostic.path,
555
+ message: diagnostic.message
556
+ });
1107
557
  }
1108
- if (existsSync8(FOCUS_DIR)) {
1109
- const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
1110
- for (const entryPath of entries) {
1111
- const entryContent = normalizeLogicalPath(relative4(FOCUS_DIR, entryPath));
1112
- let markerContent = "";
1113
- try {
1114
- markerContent = (await readFile7(entryPath, "utf-8")).trim();
1115
- } catch (error) {
1116
- addDiagnostic({
1117
- severity: "error",
1118
- code: "focus_marker_read_failed",
1119
- path: `focus.d/${entryContent}`,
1120
- change: entryContent,
1121
- message: "unable to read focus marker file",
1122
- hint: toErrorMessage(error)
1123
- });
1124
- reportRuntime({
1125
- code: "focus_marker_read_failed",
1126
- operation: "readFile",
1127
- path: entryPath,
1128
- message: toErrorMessage(error)
1129
- });
1130
- continue;
1131
- }
1132
- if (markerContent) {
1133
- addDiagnostic({
1134
- severity: "warning",
1135
- code: "focus_marker_not_empty",
1136
- path: `focus.d/${entryContent}`,
1137
- message: "should be an empty marker file; path is the source of truth"
1138
- });
1139
- }
1140
- const fp = join8(CHANGES_DIR, `${entryContent}.md`);
1141
- if (!existsSync8(fp)) {
1142
- addDiagnostic({
1143
- severity: "error",
1144
- code: "focused_change_missing",
1145
- path: `focus.d/${entryContent}`,
1146
- change: entryContent,
1147
- message: `focuses "${entryContent}" but changes/${entryContent}.md not found`
1148
- });
1149
- }
558
+ const focusedSet = options.focused ? new Set(focusTree.markers.map((marker) => marker.name)) : /* @__PURE__ */ new Set();
559
+ for (const marker of focusTree.markers) {
560
+ const entryPath = marker.path;
561
+ const entryContent = marker.name;
562
+ let markerContent = "";
563
+ try {
564
+ markerContent = (await readFile5(entryPath, "utf-8")).trim();
565
+ } catch (error) {
566
+ addDiagnostic({
567
+ severity: "error",
568
+ code: "focus_marker_read_failed",
569
+ path: `focus.d/${entryContent}`,
570
+ change: entryContent,
571
+ message: "unable to read focus marker file",
572
+ hint: toErrorMessage(error)
573
+ });
574
+ reportRuntime({
575
+ code: "focus_marker_read_failed",
576
+ operation: "readFile",
577
+ path: entryPath,
578
+ message: toErrorMessage(error)
579
+ });
580
+ continue;
581
+ }
582
+ if (markerContent) {
583
+ addDiagnostic({
584
+ severity: "warning",
585
+ code: "focus_marker_not_empty",
586
+ path: `focus.d/${entryContent}`,
587
+ message: "should be an empty marker file; path is the source of truth"
588
+ });
589
+ }
590
+ try {
591
+ resolveWorkRef(entryContent, { executable: true, mustExist: true });
592
+ } catch (error) {
593
+ if (!(error instanceof WorkRefError))
594
+ throw error;
595
+ addDiagnostic({
596
+ severity: "error",
597
+ code: error.code === "work_ref_not_found" ? "focused_change_missing" : error.code,
598
+ path: `focus.d/${entryContent}`,
599
+ change: entryContent,
600
+ message: error.code === "work_ref_not_found" ? `focuses "${entryContent}" but changes/${entryContent}.md not found` : error.message
601
+ });
602
+ }
603
+ }
604
+ const workTree = await inspectWorkTree();
605
+ for (const diagnostic of workTree.diagnostics) {
606
+ const inspectionIncomplete = diagnostic.code === "invalid_work_root" || diagnostic.code === "work_tree_read_failed";
607
+ if (inspectionIncomplete || !options.focused || focusedSet.has(diagnostic.input)) {
608
+ addDiagnostic({
609
+ severity: "error",
610
+ code: diagnostic.code,
611
+ change: diagnostic.input,
612
+ path: diagnostic.path,
613
+ message: diagnostic.message
614
+ });
1150
615
  }
1151
616
  }
1152
- const allChangeFiles = existsSync8(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1153
- const changeFiles = options.focused ? allChangeFiles.filter((fp) => focusedSet.has(changeNameFromPath(CHANGES_DIR, fp))) : allChangeFiles;
1154
- if (changeFiles.length === 0) {
617
+ const changeRefs = options.focused ? workTree.changes.filter((ref) => focusedSet.has(ref.name)) : workTree.changes;
618
+ const groupInspection = await inspectChangeGroups({ workTree, focusTree });
619
+ const focusedGroups = new Set(changeRefs.flatMap((ref) => ref.group ? [ref.group] : []));
620
+ for (const diagnostic of groupInspection.diagnostics) {
621
+ if (!options.focused || !diagnostic.change || focusedGroups.has(diagnostic.change))
622
+ addDiagnostic(diagnostic);
623
+ }
624
+ const dependencyInspection = await inspectChangeDependencies({ workTree });
625
+ for (const diagnostic of dependencyInspection.diagnostics) {
626
+ if (!options.focused || !diagnostic.change || focusedSet.has(diagnostic.change))
627
+ addDiagnostic(diagnostic);
628
+ }
629
+ if (changeRefs.length === 0) {
1155
630
  const result2 = {
1156
631
  command: "check",
1157
632
  ok: diagnostics.every((d) => d.severity !== "error"),
@@ -1165,7 +640,7 @@ async function runCheck(options = {}) {
1165
640
  }
1166
641
  };
1167
642
  if (options.json) {
1168
- emitJson(result2);
643
+ emitJson(result2, options);
1169
644
  return result2;
1170
645
  }
1171
646
  console.log();
@@ -1193,11 +668,11 @@ async function runCheck(options = {}) {
1193
668
  `);
1194
669
  return result2;
1195
670
  }
1196
- for (const fp of changeFiles) {
1197
- const name = changeNameFromPath(CHANGES_DIR, fp);
671
+ for (const ref of changeRefs) {
672
+ const { name, path: fp } = ref;
1198
673
  let content;
1199
674
  try {
1200
- content = await readFile7(fp, "utf-8");
675
+ content = await readFile5(fp, "utf-8");
1201
676
  } catch (error) {
1202
677
  addDiagnostic({
1203
678
  severity: "error",
@@ -1368,13 +843,13 @@ async function runCheck(options = {}) {
1368
843
  diagnostics,
1369
844
  runtime,
1370
845
  summary: {
1371
- changeFiles: changeFiles.length,
846
+ changeFiles: changeRefs.length,
1372
847
  errors: diagnostics.filter((d) => d.severity === "error").length,
1373
848
  warnings: diagnostics.filter((d) => d.severity === "warning").length
1374
849
  }
1375
850
  };
1376
851
  if (options.json) {
1377
- emitJson(result);
852
+ emitJson(result, options);
1378
853
  return result;
1379
854
  }
1380
855
  console.log();
@@ -1394,10 +869,10 @@ async function runCheck(options = {}) {
1394
869
  }
1395
870
  console.log();
1396
871
  if (result.summary.errors === 0 && result.summary.warnings === 0)
1397
- console.log(` ${pc.green("\u2713")} All ${changeFiles.length} change file(s) valid.
872
+ console.log(` ${pc.green("\u2713")} All ${changeRefs.length} change file(s) valid.
1398
873
  `);
1399
874
  else
1400
- console.log(` ${pc.red(String(result.summary.errors))} error(s), ${pc.yellow(String(result.summary.warnings))} warning(s) in ${changeFiles.length} change file(s).
875
+ console.log(` ${pc.red(String(result.summary.errors))} error(s), ${pc.yellow(String(result.summary.warnings))} warning(s) in ${changeRefs.length} change file(s).
1401
876
  `);
1402
877
  return result;
1403
878
  }
@@ -1426,92 +901,190 @@ function collectMatchingLines(content, predicate) {
1426
901
  }
1427
902
 
1428
903
  // src/commands/create.ts
1429
- import { existsSync as existsSync9 } from "fs";
1430
- import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
1431
- import { dirname as dirname7, join as join9 } from "path";
904
+ import { existsSync as existsSync4 } from "fs";
905
+ import { mkdir as mkdir4, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
906
+ import { dirname as dirname2 } from "path";
1432
907
  async function createChange(name, summary = "", kind, options = {}) {
1433
908
  if (!name) {
1434
909
  console.error(` ${pc.red("Usage:")} rsp create <name> [summary]`);
1435
910
  process.exit(1);
1436
911
  }
1437
- if (!isValidChangeName(name)) {
1438
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1439
- process.exit(1);
1440
- }
1441
912
  guardRspInitialized();
1442
- return withRspLock("create-change", async () => {
1443
- const changePath = join9(CHANGES_DIR, `${name}.md`);
1444
- await mkdir5(dirname7(changePath), { recursive: true });
1445
- const existed = existsSync9(changePath);
1446
- if (!existed) {
1447
- const content = generateChangeContent(name, summary, kind, { lite: Boolean(options.lite) });
1448
- await writeFile6(changePath, content);
1449
- await mkdir5(FOCUS_DIR, { recursive: true });
1450
- const focusEntry = join9(FOCUS_DIR, name);
1451
- await mkdir5(dirname7(focusEntry), { recursive: true });
1452
- await writeFile6(focusEntry, "");
1453
- }
1454
- const label = existed ? "Using" : pc.green("Created");
1455
- console.log(` ${label}: ${changePath}`);
1456
- if (existed)
1457
- console.log(` ${pc.dim("Unchanged focus.")} Run: rsp focus ${name}`);
1458
- else
1459
- console.log(` ${pc.dim("focused via focus.d")} \u2192 ${name}`);
1460
- console.log(` ${pc.cyan("Next:")} ${options.lite ? "fill the lite change details, then implement and verify" : "fill proposal/spec/design first, then implement and complete the tasks"}
913
+ try {
914
+ return await withRspLock("create-change", async () => {
915
+ const workRef = await resolveExecutableChange(name);
916
+ const changePath = workRef.path;
917
+ const existed = existsSync4(changePath);
918
+ if (!existed) {
919
+ const focusEntry = resolveFocusMarkerPath(workRef);
920
+ const content = generateChangeContent(name, summary, kind, { lite: Boolean(options.lite) });
921
+ await mkdir4(dirname2(changePath), { recursive: true });
922
+ let changeCreated = false;
923
+ try {
924
+ await writeFile3(changePath, content, { flag: "wx" });
925
+ changeCreated = true;
926
+ await mkdir4(FOCUS_DIR, { recursive: true });
927
+ await mkdir4(dirname2(focusEntry), { recursive: true });
928
+ await writeManagedFile(focusEntry, "", "focus marker");
929
+ } catch (error) {
930
+ if (changeCreated) {
931
+ try {
932
+ await unlink4(changePath);
933
+ await cleanupEmptyParentDirs(changePath, CHANGES_DIR);
934
+ } catch {
935
+ }
936
+ }
937
+ throw error;
938
+ }
939
+ }
940
+ const label = existed ? "Using" : pc.green("Created");
941
+ console.log(` ${label}: ${changePath}`);
942
+ if (existed)
943
+ console.log(` ${pc.dim("Unchanged focus.")} Run: rsp focus ${name}`);
944
+ else
945
+ console.log(` ${pc.dim("focused via focus.d")} \u2192 ${name}`);
946
+ console.log(` ${pc.cyan("Next:")} ${options.lite ? "fill the lite change details, then implement and verify" : "fill proposal/spec/design first, then implement and complete the tasks"}
1461
947
  `);
1462
- });
948
+ });
949
+ } catch (error) {
950
+ if (error instanceof WorkRefError) {
951
+ console.error(` ${pc.red("Error:")} ${error.message}`);
952
+ process.exit(1);
953
+ }
954
+ throw error;
955
+ }
1463
956
  }
1464
957
 
1465
958
  // src/commands/doctor.ts
1466
- import { existsSync as existsSync11 } from "fs";
1467
- import { readFile as readFile9 } from "fs/promises";
1468
- import { basename as basename5, join as join11, relative as relative5 } from "path";
959
+ import { existsSync as existsSync6 } from "fs";
960
+ import { readdir, readFile as readFile7, stat } from "fs/promises";
961
+ import { basename as basename4, join as join6, relative as relative3 } from "path";
1469
962
 
1470
963
  // src/commands/update.ts
1471
- import { existsSync as existsSync10 } from "fs";
1472
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1473
- import { join as join10 } from "path";
1474
- var SKILL_REFRESH_HINT = " Note: if you use the published RSP skill, refresh it too:\n npx skills add oevery/rsp\n";
964
+ import { existsSync as existsSync5 } from "fs";
965
+ import { mkdir as mkdir5, readFile as readFile6, rm, rmdir } from "fs/promises";
966
+ import { join as join5 } from "path";
967
+ var SKILL_REFRESH_HINT = " Note: if you use package-bundled RSP Skills, refresh them too:\n rsp skills install --dry-run\n rsp skills install --force\n";
1475
968
  async function updateProject(options = {}) {
1476
- if (!existsSync10(RSP_DIR)) {
969
+ if (!existsSync5(RSP_DIR)) {
1477
970
  console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
1478
971
  console.error(` ${pc.dim("Run: rsp init")}`);
1479
972
  process.exit(1);
1480
973
  }
1481
974
  return withRspLock("update", async () => {
975
+ const agentsPath = "AGENTS.md";
976
+ const agentsInspection = inspectManagedFile(agentsPath, "AGENTS.md", { allowMissing: true });
977
+ if (agentsInspection.issue)
978
+ throw agentsInspection.issue;
1482
979
  let updated = false;
1483
980
  const actions = [];
1484
- const bundledRules = await readFile8(join10(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1485
- const rulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
1486
- await mkdir6(join10(RSP_DIR, "rules"), { recursive: true });
1487
- const existingRules = existsSync10(rulesPath) ? await readFile8(rulesPath, "utf-8") : null;
981
+ clearConfigCache();
982
+ const configInspection = await inspectRspConfig();
983
+ if (configInspection.issues.length > 0)
984
+ throw new Error(configInspection.issues.join("; "));
985
+ const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
986
+ const decisionRecordsFilesystemIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath);
987
+ if (decisionRecordsFilesystemIssue)
988
+ throw new Error(decisionRecordsFilesystemIssue);
989
+ const changesInspection = inspectManagedDirectory(CHANGES_DIR, "open work root", { allowMissing: true });
990
+ if (changesInspection.issue)
991
+ throw changesInspection.issue;
992
+ if (!changesInspection.exists) {
993
+ await mkdir5(CHANGES_DIR, { recursive: true });
994
+ await ensureManagedFile(join5(CHANGES_DIR, ".gitkeep"), "", "changes placeholder");
995
+ actions.push("changes/ directory restored");
996
+ if (!options.quiet)
997
+ console.log(` ${pc.green("\u2713")} changes/ directory restored`);
998
+ updated = true;
999
+ }
1000
+ const bundledRules = await readFile6(join5(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1001
+ const rulesInspection = inspectManagedFile(RSP_RULES_PATH, "fallback protocol", { allowMissing: true });
1002
+ if (rulesInspection.issue)
1003
+ throw rulesInspection.issue;
1004
+ const existingRules = rulesInspection.exists ? await readFile6(RSP_RULES_PATH, "utf-8") : null;
1488
1005
  if (existingRules !== bundledRules) {
1489
- await writeFile7(rulesPath, bundledRules);
1490
- actions.push("rules/rsp-rules.md updated");
1006
+ await writeManagedFile(RSP_RULES_PATH, bundledRules, "fallback protocol");
1007
+ actions.push("rsp-rules.md updated");
1491
1008
  if (!options.quiet)
1492
- console.log(` ${pc.green("\u2713")} rules/rsp-rules.md updated`);
1009
+ console.log(` ${pc.green("\u2713")} rsp-rules.md updated`);
1493
1010
  updated = true;
1494
1011
  }
1495
- const agentsPath = "AGENTS.md";
1012
+ if (existsSync5(OBSOLETE_RSP_RULES_PATH)) {
1013
+ await rm(OBSOLETE_RSP_RULES_PATH);
1014
+ actions.push("obsolete rules/rsp-rules.md removed");
1015
+ if (!options.quiet)
1016
+ console.log(` ${pc.green("\u2713")} obsolete rules/rsp-rules.md removed`);
1017
+ updated = true;
1018
+ }
1019
+ try {
1020
+ await rmdir(join5(RSP_DIR, "rules"));
1021
+ actions.push("empty rules directory removed");
1022
+ updated = true;
1023
+ } catch (error) {
1024
+ if (!["ENOENT", "ENOTEMPTY"].includes(error.code || ""))
1025
+ throw error;
1026
+ }
1027
+ let inspection = await inspectUnsupportedRules();
1028
+ if (inspection.diagnostics.length === 0 && inspection.directoryRemaining && inspection.entries.every((entry) => entry.kind === "directory")) {
1029
+ const directories = inspection.entries.map((entry) => entry.path).sort((left, right) => right.split("/").length - left.split("/").length);
1030
+ for (const path of directories)
1031
+ await rmdir(join5(RSP_DIR, "rules", path));
1032
+ await rmdir(join5(RSP_DIR, "rules"));
1033
+ actions.push("empty rules directory tree removed");
1034
+ if (!options.quiet)
1035
+ console.log(` ${pc.green("\u2713")} empty rules directory tree removed`);
1036
+ updated = true;
1037
+ inspection = await inspectUnsupportedRules();
1038
+ }
1039
+ const residualEntries = inspection.entries.map((entry) => entry.path);
1040
+ if (!options.quiet && inspection.diagnostics.length > 0) {
1041
+ console.log(` ${pc.red("Error:")} migration inspection incomplete:`);
1042
+ for (const diagnostic of inspection.diagnostics)
1043
+ console.log(` ${diagnostic.path}: ${diagnostic.message}`);
1044
+ console.log(` ${pc.dim("Fix access to .rsp/rules/, then run rsp update again.")}`);
1045
+ console.log();
1046
+ }
1047
+ if (!options.quiet && residualEntries.length > 0) {
1048
+ console.log(` ${pc.yellow("Warning:")} these entries are no longer read by RSP:`);
1049
+ for (const path of residualEntries)
1050
+ console.log(` .rsp/rules/${path}`);
1051
+ console.log(` ${pc.dim("Move stable scoped instructions to the nearest project-owned AGENTS.md, then remove the old files.")}`);
1052
+ console.log(` ${pc.dim("Run: rsp doctor")}`);
1053
+ console.log();
1054
+ }
1496
1055
  const projectName = await detectProjectName();
1497
- const baseAgents = existsSync10(agentsPath) ? await readFile8(agentsPath, "utf-8") : `# ${projectName}
1056
+ const baseAgents = agentsInspection.exists ? await readFile6(agentsPath, "utf-8") : `# ${projectName}
1498
1057
 
1499
1058
  `;
1500
1059
  const nextAgents = upsertRspAgentsBlock(baseAgents);
1501
- if (!existsSync10(agentsPath) || nextAgents.changed) {
1502
- await writeFile7(agentsPath, nextAgents.content);
1060
+ if (!agentsInspection.exists || nextAgents.changed) {
1061
+ await writeManagedFile(agentsPath, nextAgents.content, "AGENTS.md");
1503
1062
  actions.push("AGENTS.md managed block refreshed");
1504
1063
  if (!options.quiet)
1505
1064
  console.log(` ${pc.green("\u2713")} AGENTS.md managed block refreshed`);
1506
1065
  updated = true;
1507
1066
  }
1067
+ if (await ensureDecisionRecordsDirectory(decisionRecordsPath)) {
1068
+ actions.push(`Decision Record directory ensured: ${decisionRecordsPath}`);
1069
+ if (!options.quiet)
1070
+ console.log(` ${pc.green("\u2713")} Decision Record directory ensured: ${decisionRecordsPath}`);
1071
+ updated = true;
1072
+ }
1508
1073
  const specsIndexChanged = await buildSpecsIndex({ acquireLock: false, quiet: options.quiet });
1509
1074
  const archiveIndexChanged = await buildArchiveIndex({ acquireLock: false, quiet: options.quiet });
1510
1075
  if (specsIndexChanged || archiveIndexChanged) {
1511
1076
  actions.push("generated indexes rebuilt");
1512
1077
  updated = true;
1513
1078
  }
1514
- if (!options.quiet && !updated) {
1079
+ if (!options.quiet && inspection.diagnostics.length > 0) {
1080
+ console.log(` ${pc.red("Managed update incomplete; migration inspection failed.")}
1081
+ `);
1082
+ console.log(SKILL_REFRESH_HINT);
1083
+ } else if (!options.quiet && residualEntries.length > 0) {
1084
+ console.log(` ${pc.yellow(updated ? "Managed update complete; manual migration remains." : "Managed files are up to date; manual migration remains.")}
1085
+ `);
1086
+ console.log(SKILL_REFRESH_HINT);
1087
+ } else if (!options.quiet && !updated) {
1515
1088
  console.log(` ${pc.dim("Already up to date.")}
1516
1089
  `);
1517
1090
  console.log(SKILL_REFRESH_HINT);
@@ -1520,34 +1093,60 @@ async function updateProject(options = {}) {
1520
1093
  `);
1521
1094
  console.log(SKILL_REFRESH_HINT);
1522
1095
  }
1523
- return actions;
1096
+ return {
1097
+ actions,
1098
+ migration: {
1099
+ inspectionComplete: inspection.diagnostics.length === 0,
1100
+ manualMigrationRequired: inspection.directoryRemaining,
1101
+ residualEntries,
1102
+ diagnostics: inspection.diagnostics
1103
+ }
1104
+ };
1524
1105
  });
1525
1106
  }
1526
1107
 
1527
1108
  // src/commands/doctor.ts
1528
1109
  async function runDoctor(options = {}) {
1529
1110
  const fixed = [];
1530
- if (options.fix && existsSync11(RSP_DIR)) {
1531
- fixed.push(...await updateProject({ quiet: Boolean(options.json) }));
1111
+ let repairIssue = null;
1112
+ if (options.fix && existsSync6(RSP_DIR)) {
1113
+ try {
1114
+ const update = await updateProject({ quiet: Boolean(options.json) });
1115
+ fixed.push(...update.actions);
1116
+ } catch (error) {
1117
+ repairIssue = `safe deterministic repair could not run: ${toErrorMessage(error)}`;
1118
+ }
1532
1119
  }
1533
1120
  const checks = [];
1121
+ if (repairIssue) {
1122
+ checks.push({
1123
+ status: "issue",
1124
+ label: "safe deterministic repairs completed",
1125
+ message: repairIssue,
1126
+ hint: "Resolve the reported configuration or filesystem issue, then run rsp doctor --fix again."
1127
+ });
1128
+ }
1534
1129
  const runtime = [];
1535
1130
  const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
1536
- const rspRulesPath = join11(RSP_DIR, "rules", "rsp-rules.md");
1537
- const designPath = join11(RSP_DIR, "specs", "design.md");
1538
- const specsIndexPath = join11(RSP_DIR, "specs", "INDEX.md");
1539
- const archivesIndexPath = join11(RSP_DIR, "archives", "INDEX.md");
1540
- reportCheck(checks, ".rsp exists", existsSync11(RSP_DIR), "Run: rsp init");
1541
- reportCheck(checks, "rules/rsp-rules.md exists", existsSync11(rspRulesPath), "Run: rsp init");
1542
- reportCheck(checks, "specs/design.md exists", existsSync11(designPath), "Run: rsp init");
1543
- reportCheck(checks, "specs/INDEX.md exists", existsSync11(specsIndexPath), "Run: rsp update");
1544
- reportCheck(checks, "archives/INDEX.md exists", existsSync11(archivesIndexPath), "Run: rsp update");
1131
+ const designPath = join6(RSP_DIR, "specs", "design.md");
1132
+ const specsIndexPath = join6(RSP_DIR, "specs", "INDEX.md");
1133
+ const archivesIndexPath = join6(RSP_DIR, "archives", "INDEX.md");
1134
+ reportCheck(checks, ".rsp exists", existsSync6(RSP_DIR), "Run: rsp init");
1135
+ reportFallbackProtocol(checks);
1136
+ await reportUnsupportedRules(checks, reportRuntime);
1137
+ reportCheck(checks, "specs/design.md exists", existsSync6(designPath), "Run: rsp init");
1138
+ reportCheck(checks, "specs/INDEX.md exists", existsSync6(specsIndexPath), "Run: rsp update");
1139
+ reportCheck(checks, "archives/INDEX.md exists", existsSync6(archivesIndexPath), "Run: rsp update");
1545
1140
  await checkAgents(checks, reportRuntime);
1546
1141
  await checkGeneratedIndex(checks, reportRuntime, specsIndexPath, "specs", "specs/INDEX.md has generated-index metadata", "Run: rsp update");
1547
1142
  await checkGeneratedIndex(checks, reportRuntime, archivesIndexPath, "archives", "archives/INDEX.md has generated-index metadata", "Run: rsp update");
1548
- await checkArchiveNaming(checks, reportRuntime);
1549
- await checkConfigSemantics(checks, reportRuntime);
1550
- await checkActiveChangeConsistency(checks, reportRuntime);
1143
+ await checkArchiveNaming(checks);
1144
+ const decisionRecordsConfigValid = await checkConfigSemantics(checks, reportRuntime);
1145
+ if (decisionRecordsConfigValid) {
1146
+ await checkDecisionRecordsDirectory(checks, reportRuntime);
1147
+ await checkInactiveDefaultDecisionRecords(checks, reportRuntime);
1148
+ }
1149
+ await checkActiveChangeConsistency(checks);
1551
1150
  const result = {
1552
1151
  command: "doctor",
1553
1152
  ok: checks.every((check) => check.status !== "issue"),
@@ -1559,7 +1158,7 @@ async function runDoctor(options = {}) {
1559
1158
  }
1560
1159
  };
1561
1160
  if (options.json) {
1562
- emitJson(result);
1161
+ emitJson(result, options);
1563
1162
  return result;
1564
1163
  }
1565
1164
  console.log();
@@ -1568,21 +1167,72 @@ async function runDoctor(options = {}) {
1568
1167
  if (fixed.length > 0) {
1569
1168
  console.log(` ${pc.green("Fixed:")} ${fixed.join(", ")}`);
1570
1169
  console.log();
1571
- } else if (options.fix) {
1170
+ } else if (options.fix && !repairIssue && existsSync6(RSP_DIR)) {
1572
1171
  console.log(` ${pc.dim("No safe fixes needed.")}`);
1573
1172
  console.log();
1574
1173
  }
1575
- for (const check of checks)
1576
- printDoctorCheck(check);
1577
- if (result.summary.issues === 0)
1578
- console.log(`
1579
- ${pc.green("\u2713")} RSP setup looks healthy.
1580
- `);
1581
- else
1582
- console.log(`
1583
- ${pc.yellow(String(result.summary.issues))} issue(s) detected.
1584
- `);
1585
- return result;
1174
+ for (const check of checks)
1175
+ printDoctorCheck(check);
1176
+ if (result.summary.issues === 0)
1177
+ console.log(`
1178
+ ${pc.green("\u2713")} RSP setup looks healthy.
1179
+ `);
1180
+ else
1181
+ console.log(`
1182
+ ${pc.yellow(String(result.summary.issues))} issue(s) detected.
1183
+ `);
1184
+ return result;
1185
+ }
1186
+ function reportFallbackProtocol(checks) {
1187
+ const canonical = inspectManagedFile(RSP_RULES_PATH, "fallback protocol", { allowMissing: true });
1188
+ const obsoleteExists = existsSync6(OBSOLETE_RSP_RULES_PATH);
1189
+ if (canonical.issue) {
1190
+ checks.push({
1191
+ status: "issue",
1192
+ label: "rsp-rules.md is a regular managed file",
1193
+ message: canonical.issue.message,
1194
+ hint: "Replace the unsupported entry with a project-local regular file, then run: rsp update"
1195
+ });
1196
+ } else if (canonical.exists) {
1197
+ checks.push({ status: "ok", label: "rsp-rules.md exists" });
1198
+ } else if (!obsoleteExists) {
1199
+ checks.push({
1200
+ status: "issue",
1201
+ label: "rsp-rules.md exists",
1202
+ hint: existsSync6(RSP_DIR) ? "Run: rsp update" : "Run: rsp init"
1203
+ });
1204
+ }
1205
+ if (obsoleteExists) {
1206
+ checks.push({
1207
+ status: "issue",
1208
+ label: "obsolete fallback protocol path detected",
1209
+ message: "obsolete fallback protocol path detected",
1210
+ hint: "Run: rsp update"
1211
+ });
1212
+ }
1213
+ }
1214
+ async function reportUnsupportedRules(checks, reportRuntime) {
1215
+ const inspection = await inspectUnsupportedRules();
1216
+ for (const diagnostic of inspection.diagnostics)
1217
+ reportRuntime(diagnostic);
1218
+ if (inspection.diagnostics.length > 0) {
1219
+ checks.push({
1220
+ status: "issue",
1221
+ label: "unable to inspect .rsp/rules/",
1222
+ message: "unable to inspect .rsp/rules/; migration status is unknown",
1223
+ hint: "Fix directory access, then run: rsp doctor"
1224
+ });
1225
+ return;
1226
+ }
1227
+ if (!inspection.directoryRemaining)
1228
+ return;
1229
+ const entries = inspection.entries.map((entry) => entry.path);
1230
+ checks.push({
1231
+ status: "issue",
1232
+ label: "unsupported .rsp/rules/ entries",
1233
+ message: entries.length > 0 ? `unsupported .rsp/rules/ entries: ${entries.join(", ")}` : "unsupported empty .rsp/rules/ directory remains",
1234
+ hint: "Move stable scoped instructions to the nearest project-owned AGENTS.md, remove obsolete entries, then run rsp update"
1235
+ });
1586
1236
  }
1587
1237
  function reportCheck(checks, label, ok, fixHint) {
1588
1238
  if (ok) {
@@ -1607,13 +1257,23 @@ function printDoctorCheck(check) {
1607
1257
  console.log(` ${pc.dim(check.hint)}`);
1608
1258
  }
1609
1259
  async function checkAgents(checks, reportRuntime) {
1610
- if (!existsSync11("AGENTS.md")) {
1260
+ const inspection = inspectManagedFile("AGENTS.md", "AGENTS.md", { allowMissing: true });
1261
+ if (inspection.issue) {
1262
+ checks.push({
1263
+ status: "issue",
1264
+ label: "AGENTS.md is a regular managed file",
1265
+ message: inspection.issue.message,
1266
+ hint: "Replace the unsupported entry with a project-local regular file, then run: rsp update"
1267
+ });
1268
+ return;
1269
+ }
1270
+ if (!inspection.exists) {
1611
1271
  checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing", hint: "Run: rsp init" });
1612
1272
  return;
1613
1273
  }
1614
1274
  let agents;
1615
1275
  try {
1616
- agents = await readFile9("AGENTS.md", "utf-8");
1276
+ agents = await readFile7("AGENTS.md", "utf-8");
1617
1277
  } catch (error) {
1618
1278
  reportRuntime({
1619
1279
  code: "agents_read_failed",
@@ -1631,11 +1291,16 @@ async function checkAgents(checks, reportRuntime) {
1631
1291
  checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing managed RSP block", hint: "Run: rsp init" });
1632
1292
  }
1633
1293
  async function checkGeneratedIndex(checks, reportRuntime, path, expectedIndexType, label, fixHint) {
1634
- if (!existsSync11(path))
1294
+ const indexFile = inspectManagedFile(path, label, { allowMissing: true });
1295
+ if (indexFile.issue) {
1296
+ checks.push({ status: "issue", label, message: indexFile.issue.message, hint: fixHint });
1297
+ return;
1298
+ }
1299
+ if (!indexFile.exists)
1635
1300
  return;
1636
1301
  let content;
1637
1302
  try {
1638
- content = await readFile9(path, "utf-8");
1303
+ content = await readFile7(path, "utf-8");
1639
1304
  } catch (error) {
1640
1305
  reportRuntime({
1641
1306
  code: "generated_index_read_failed",
@@ -1664,10 +1329,20 @@ async function checkGeneratedIndex(checks, reportRuntime, path, expectedIndexTyp
1664
1329
  }
1665
1330
  checks.push({ status: "issue", label, hint: fixHint });
1666
1331
  }
1667
- async function checkArchiveNaming(checks, reportRuntime) {
1668
- const archivesDir = join11(RSP_DIR, "archives");
1669
- const archiveFiles = existsSync11(archivesDir) ? (await walkMarkdownFiles(archivesDir, { onError: reportRuntime })).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
1670
- const invalidFiles = archiveFiles.map((fp) => normalizeLogicalPath(relative5(archivesDir, fp))).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(basename5(fp)) === false);
1332
+ async function checkArchiveNaming(checks) {
1333
+ const archivesDir = join6(RSP_DIR, "archives");
1334
+ const inspection = await inspectArchiveTree({ archivesDir });
1335
+ if (inspection.diagnostics.length > 0) {
1336
+ checks.push({
1337
+ status: "issue",
1338
+ label: "archives use supported managed paths",
1339
+ message: inspection.diagnostics.map((diagnostic) => diagnostic.message).join("; "),
1340
+ hint: "Keep archives flat or one real group directory deep; remove symlinks and unsupported entries."
1341
+ });
1342
+ return;
1343
+ }
1344
+ checks.push({ status: "ok", label: "archives use supported managed paths" });
1345
+ const invalidFiles = inspection.files.map((fp) => normalizeLogicalPath(relative3(archivesDir, fp))).filter((fp) => /^\d{4}-\d{2}-\d{2}_.+\.md$/.test(basename4(fp)) === false);
1671
1346
  if (invalidFiles.length === 0) {
1672
1347
  checks.push({ status: "ok", label: "archived change files follow the date_name.md convention" });
1673
1348
  return;
@@ -1680,69 +1355,172 @@ async function checkArchiveNaming(checks, reportRuntime) {
1680
1355
  });
1681
1356
  }
1682
1357
  async function checkConfigSemantics(checks, reportRuntime) {
1683
- if (!existsSync11(CONFIG_PATH))
1684
- return;
1685
- let content;
1358
+ clearConfigCache();
1359
+ let inspection;
1686
1360
  try {
1687
- content = await readFile9(CONFIG_PATH, "utf-8");
1361
+ inspection = await inspectRspConfig();
1688
1362
  } catch (error) {
1363
+ const message = toErrorMessage(error);
1689
1364
  reportRuntime({
1690
- code: "config_read_failed",
1691
- operation: "readFile",
1692
- path: CONFIG_PATH,
1693
- message: toErrorMessage(error)
1365
+ code: "invalid_config",
1366
+ operation: "inspectRspConfig",
1367
+ path: ".rsp/config.yaml",
1368
+ message
1369
+ });
1370
+ checks.push({
1371
+ status: "issue",
1372
+ code: "invalid_config",
1373
+ label: "config.yaml semantic checks",
1374
+ message,
1375
+ hint: "Fix the reported config file, YAML, or managed-path issue."
1376
+ });
1377
+ return false;
1378
+ }
1379
+ for (const issue of inspection.issues) {
1380
+ checks.push({
1381
+ status: "issue",
1382
+ code: "invalid_config",
1383
+ label: "config.yaml semantic checks",
1384
+ message: issue,
1385
+ hint: issue.includes("decisions") ? "Use .rsp/specs/decisions or one project-relative path outside .rsp/." : "Remove the unsupported field or provide the documented value type."
1694
1386
  });
1695
- checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml could not be read", hint: "Check .rsp/config.yaml permissions or recreate it with rsp init" });
1696
- return;
1697
1387
  }
1698
- let parsed;
1388
+ if (inspection.issues.length === 0 && inspection.config.kinds !== void 0)
1389
+ checks.push({ status: "ok", label: 'config.yaml field "kinds" has valid list semantics' });
1390
+ return inspection.issues.length === 0;
1391
+ }
1392
+ async function checkDecisionRecordsDirectory(checks, reportRuntime) {
1393
+ const decisionRecordsPath = resolveDecisionRecordsPath(await loadRspConfig());
1699
1394
  try {
1700
- parsed = parseYamlText(content);
1395
+ const filesystemError = await validateDecisionRecordsFilesystemPath(decisionRecordsPath);
1396
+ if (filesystemError) {
1397
+ checks.push({
1398
+ status: "issue",
1399
+ label: `${decisionRecordsPath} is the authoritative Decision Record directory`,
1400
+ message: filesystemError,
1401
+ hint: "Choose a project-relative directory that does not escape through a symlink."
1402
+ });
1403
+ return;
1404
+ }
1405
+ const info = await stat(decisionRecordsPath);
1406
+ if (info.isDirectory())
1407
+ await readdir(decisionRecordsPath);
1408
+ reportCheck(
1409
+ checks,
1410
+ `${decisionRecordsPath} is the authoritative Decision Record directory`,
1411
+ info.isDirectory(),
1412
+ "Run: rsp update"
1413
+ );
1701
1414
  } catch (error) {
1702
- const message = error instanceof Error ? error.message : String(error);
1703
- checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml is not valid YAML", hint: message });
1704
- return;
1705
- }
1706
- reportConfigListField(checks, parsed, "kinds");
1707
- if ("required_sections" in parsed) {
1415
+ if (error.code !== "ENOENT") {
1416
+ reportRuntime({
1417
+ code: "decision_records_path_stat_failed",
1418
+ operation: "stat",
1419
+ path: decisionRecordsPath,
1420
+ message: toErrorMessage(error)
1421
+ });
1422
+ }
1708
1423
  checks.push({
1709
1424
  status: "issue",
1710
- label: "config.yaml semantic checks",
1711
- message: 'config.yaml field "required_sections" is no longer supported',
1712
- hint: 'RSP keeps the core change section structure fixed. Remove "required_sections" from .rsp/config.yaml.'
1425
+ label: `${decisionRecordsPath} is the authoritative Decision Record directory`,
1426
+ message: `${decisionRecordsPath} Decision Record directory is missing or unreadable`,
1427
+ hint: "Run: rsp update"
1713
1428
  });
1714
1429
  }
1715
1430
  }
1716
- function reportConfigListField(checks, parsed, key) {
1717
- if (!(key in parsed))
1431
+ async function checkInactiveDefaultDecisionRecords(checks, reportRuntime) {
1432
+ const decisionRecordsPath = resolveDecisionRecordsPath(await loadRspConfig());
1433
+ if (decisionRecordsPath === DEFAULT_DECISION_RECORDS_PATH || !existsSync6(DEFAULT_DECISION_RECORDS_PATH))
1718
1434
  return;
1719
- const value = parsed[key];
1720
- if (!Array.isArray(value)) {
1435
+ let inspectionFailed = false;
1436
+ const inactiveRecords = (await walkMarkdownFiles(DEFAULT_DECISION_RECORDS_PATH, {
1437
+ onError: (diagnostic) => {
1438
+ inspectionFailed = true;
1439
+ reportRuntime(diagnostic);
1440
+ }
1441
+ })).map((path) => normalizeLogicalPath(relative3(DEFAULT_DECISION_RECORDS_PATH, path))).sort();
1442
+ if (inspectionFailed) {
1721
1443
  checks.push({
1722
1444
  status: "issue",
1723
- label: "config.yaml semantic checks",
1724
- message: `config.yaml field "${key}" must be a YAML list`,
1725
- hint: `Update .rsp/config.yaml so "${key}" is a YAML sequence (for example: ${key}: [item])`
1445
+ label: "inactive default Decision Records are inspectable",
1446
+ message: `unable to inspect inactive default Decision Records under ${DEFAULT_DECISION_RECORDS_PATH}`,
1447
+ hint: "Restore directory access, then run rsp doctor again."
1726
1448
  });
1727
1449
  return;
1728
1450
  }
1729
- const items = value.map(String);
1730
- const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
1731
- if (duplicates.length === 0) {
1732
- checks.push({ status: "ok", label: `config.yaml field "${key}" has valid list semantics` });
1451
+ if (inactiveRecords.length === 0)
1733
1452
  return;
1734
- }
1735
1453
  checks.push({
1736
1454
  status: "issue",
1737
- label: "config.yaml semantic checks",
1738
- message: `config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`,
1739
- hint: `Remove duplicate values from .rsp/config.yaml:${key}`
1455
+ label: "inactive default Decision Records are migrated",
1456
+ message: `inactive default Decision Records: ${inactiveRecords.join(", ")}`,
1457
+ hint: `Move lasting rationale to ${decisionRecordsPath}, then remove the inactive files from ${DEFAULT_DECISION_RECORDS_PATH}.`
1740
1458
  });
1741
1459
  }
1742
- async function checkActiveChangeConsistency(checks, reportRuntime) {
1743
- const changeFiles = existsSync11(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1744
- const changeNames = new Set(changeFiles.map((fp) => changeNameFromPath(CHANGES_DIR, fp)));
1745
- const focusNames = await getFocusedChangeNames({ onError: reportRuntime });
1460
+ async function checkActiveChangeConsistency(checks) {
1461
+ const workTree = await inspectWorkTree();
1462
+ const focusTree = await inspectFocusTree();
1463
+ const groupInspection = await inspectChangeGroups({ workTree, focusTree });
1464
+ const dependencyInspection = await inspectChangeDependencies({ workTree });
1465
+ const changeNames = new Set(workTree.changes.map((ref) => ref.name));
1466
+ const structureIssues = workTree.diagnostics.map((diagnostic) => diagnostic.message);
1467
+ structureIssues.push(...focusTree.diagnostics.map((diagnostic) => diagnostic.message));
1468
+ const rawFocusNames = new Set(focusTree.markers.map((marker) => marker.name));
1469
+ const focusNames = /* @__PURE__ */ new Set();
1470
+ for (const name of rawFocusNames) {
1471
+ try {
1472
+ focusNames.add(resolveWorkRef(name, { executable: true }).name);
1473
+ } catch (error) {
1474
+ if (!(error instanceof WorkRefError))
1475
+ throw error;
1476
+ structureIssues.push(`focus.d/${name}: ${error.message}`);
1477
+ }
1478
+ }
1479
+ const uniqueStructureIssues = [...new Set(structureIssues)];
1480
+ if (uniqueStructureIssues.length === 0) {
1481
+ checks.push({ status: "ok", label: "open work uses supported WorkRef shapes" });
1482
+ } else {
1483
+ checks.push({
1484
+ status: "issue",
1485
+ label: "open work uses supported WorkRef shapes",
1486
+ message: uniqueStructureIssues.join("; "),
1487
+ hint: "Keep work flat or use one group directory with direct Change files. Use rsp unfocus <group>/brief to clear an accidental Group Brief marker."
1488
+ });
1489
+ }
1490
+ const groupStructureIssues = workTree.diagnostics.filter((diagnostic) => diagnostic.code === "group_brief_missing" || diagnostic.path.endsWith(`/${GROUP_BRIEF_FILENAME}`)).map((diagnostic) => diagnostic.message);
1491
+ const groupIssues = [
1492
+ ...groupInspection.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message),
1493
+ ...groupStructureIssues
1494
+ ];
1495
+ const groupWarnings = groupInspection.diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
1496
+ if (groupIssues.length === 0) {
1497
+ checks.push({ status: "ok", label: "Change Group contracts are valid" });
1498
+ } else {
1499
+ checks.push({
1500
+ status: "issue",
1501
+ label: "Change Group contracts are valid",
1502
+ message: [...new Set(groupIssues)].join("; "),
1503
+ hint: `Keep one ${GROUP_BRIEF_FILENAME} per group and make its Slices list match direct open or archived child Changes.`
1504
+ });
1505
+ }
1506
+ if (groupWarnings.length > 0) {
1507
+ checks.push({
1508
+ status: "info",
1509
+ label: "Change Group completion guidance",
1510
+ message: [...new Set(groupWarnings.map((diagnostic) => diagnostic.message))].join("; ")
1511
+ });
1512
+ }
1513
+ const dependencyIssues = dependencyInspection.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1514
+ if (dependencyIssues.length === 0) {
1515
+ checks.push({ status: "ok", label: "Change dependency graph is valid" });
1516
+ } else {
1517
+ checks.push({
1518
+ status: "issue",
1519
+ label: "Change dependency graph is valid",
1520
+ message: [...new Set(dependencyIssues.map((diagnostic) => diagnostic.message))].join("; "),
1521
+ hint: "Use exact existing Change WorkRefs, remove self-dependencies, and break dependency cycles."
1522
+ });
1523
+ }
1746
1524
  const missingChangeFiles = [...focusNames].filter((name) => !changeNames.has(name));
1747
1525
  const unfocusedChangeFiles = [...changeNames].filter((name) => !focusNames.has(name));
1748
1526
  if (missingChangeFiles.length === 0) {
@@ -1770,119 +1548,454 @@ async function checkActiveChangeConsistency(checks, reportRuntime) {
1770
1548
  }
1771
1549
 
1772
1550
  // src/commands/focus.ts
1773
- import { existsSync as existsSync12 } from "fs";
1774
- import { mkdir as mkdir7, unlink as unlink3, writeFile as writeFile8 } from "fs/promises";
1775
- import { dirname as dirname8, join as join12 } from "path";
1551
+ import { existsSync as existsSync7 } from "fs";
1552
+ import { mkdir as mkdir6, unlink as unlink5 } from "fs/promises";
1553
+ import { dirname as dirname3 } from "path";
1776
1554
  async function focusChange(name) {
1777
1555
  if (!name) {
1778
1556
  console.error(` ${pc.red("Usage:")} rsp focus <name>`);
1779
1557
  process.exit(1);
1780
1558
  }
1781
- if (!isValidChangeName(name)) {
1782
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1783
- process.exit(1);
1784
- }
1785
1559
  guardRspInitialized();
1786
- const changePath = join12(CHANGES_DIR, `${name}.md`);
1787
- if (!existsSync12(changePath)) {
1788
- console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
1789
- process.exit(1);
1560
+ try {
1561
+ return await withRspLock("focus-change", async () => {
1562
+ const workRef = await resolveExecutableChange(name, { mustExist: true });
1563
+ const focusEntry = resolveFocusMarkerPath(workRef);
1564
+ await mkdir6(FOCUS_DIR, { recursive: true });
1565
+ await mkdir6(dirname3(focusEntry), { recursive: true });
1566
+ await writeManagedFile(focusEntry, "", "focus marker");
1567
+ console.log(` ${pc.green("Focused:")} ${name}`);
1568
+ console.log(` ${pc.dim("focus.d")} \u2192 ${name}`);
1569
+ console.log();
1570
+ });
1571
+ } catch (error) {
1572
+ exitWorkRefError(error);
1790
1573
  }
1791
- return withRspLock("focus-change", async () => {
1792
- await mkdir7(FOCUS_DIR, { recursive: true });
1793
- const focusEntry = join12(FOCUS_DIR, name);
1794
- await mkdir7(dirname8(focusEntry), { recursive: true });
1795
- await writeFile8(focusEntry, "");
1796
- console.log(` ${pc.green("Focused:")} ${name}`);
1797
- console.log(` ${pc.dim("focus.d")} \u2192 ${name}`);
1798
- console.log();
1799
- });
1800
1574
  }
1801
1575
  async function unfocusChange(name) {
1802
1576
  if (!name) {
1803
1577
  console.error(` ${pc.red("Usage:")} rsp unfocus <name>`);
1804
1578
  process.exit(1);
1805
1579
  }
1806
- if (!isValidChangeName(name)) {
1807
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1580
+ guardRspInitialized();
1581
+ try {
1582
+ return await withRspLock("unfocus-change", async () => {
1583
+ const workRef = resolveWorkRef(name, { executable: false, mustExist: false });
1584
+ const focusEntry = resolveFocusMarkerPath(workRef);
1585
+ if (!existsSync7(focusEntry))
1586
+ throw new WorkRefError("focus_marker_not_found", `.rsp/focus.d/${name}`, name);
1587
+ await unlink5(focusEntry);
1588
+ await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
1589
+ console.log(` ${pc.green("Unfocused:")} ${name}`);
1590
+ console.log(` ${pc.dim("focus.d cleared")} \u2192 ${name}`);
1591
+ console.log();
1592
+ });
1593
+ } catch (error) {
1594
+ exitWorkRefError(error);
1595
+ }
1596
+ }
1597
+ function exitWorkRefError(error) {
1598
+ if (error instanceof WorkRefError) {
1599
+ if (error.code === "focus_marker_not_found")
1600
+ console.error(` ${pc.red("Focus marker not found:")} ${error.message}`);
1601
+ else
1602
+ console.error(` ${pc.red("Error:")} ${error.message}`);
1603
+ process.exit(1);
1604
+ }
1605
+ throw error;
1606
+ }
1607
+
1608
+ // src/commands/group.ts
1609
+ import { mkdir as mkdir7, rename as rename2, writeFile as writeFile4 } from "fs/promises";
1610
+ import { join as join7 } from "path";
1611
+ var ChangeGroupError = class extends Error {
1612
+ name = "ChangeGroupError";
1613
+ };
1614
+ async function createChangeGroup(name, goal = "") {
1615
+ if (!name) {
1616
+ console.error(` ${pc.red("Usage:")} rsp group create <name> [goal]`);
1808
1617
  process.exit(1);
1809
1618
  }
1810
1619
  guardRspInitialized();
1811
- const focusEntry = join12(FOCUS_DIR, name);
1812
- if (!existsSync12(focusEntry)) {
1813
- console.error(` ${pc.red("Focus marker not found:")} .rsp/focus.d/${name}`);
1620
+ try {
1621
+ await withRspLock("create-change-group", async () => {
1622
+ const ref = resolveWorkRef(`${name}/brief`);
1623
+ if (ref.kind !== "group-brief")
1624
+ throw new WorkRefError("invalid_work_ref", `invalid Change Group name: ${name}`, name);
1625
+ if (await hasArchivedGroupBrief(name))
1626
+ throw new ChangeGroupError(`archived Change Group cannot be reopened: ${name}`);
1627
+ const groupDirectory = resolveManagedDirectoryChain(CHANGES_DIR, [name], "Change Group path");
1628
+ await mkdir7(groupDirectory, { recursive: true });
1629
+ try {
1630
+ await writeFile4(ref.path, generateGroupBriefContent(name, goal), { flag: "wx" });
1631
+ } catch (error) {
1632
+ if (error.code === "EEXIST")
1633
+ throw new ChangeGroupError(`Change Group already exists: ${name}`);
1634
+ throw error;
1635
+ }
1636
+ console.log(` ${pc.green("Created Change Group:")} ${name}`);
1637
+ console.log(` ${pc.dim("Unfocused Group Brief:")} ${ref.path}`);
1638
+ console.log(` ${pc.cyan("Next:")} fill the brief, then create direct child Changes with rsp create ${name}/<change>
1639
+ `);
1640
+ });
1641
+ } catch (error) {
1642
+ if (error instanceof WorkRefError || error instanceof ChangeGroupError) {
1643
+ console.error(` ${pc.red("Error:")} ${error.message}`);
1644
+ process.exit(1);
1645
+ }
1646
+ throw error;
1647
+ }
1648
+ }
1649
+ async function closeChangeGroup(name) {
1650
+ if (!name) {
1651
+ console.error(` ${pc.red("Usage:")} rsp group close <name>`);
1814
1652
  process.exit(1);
1815
1653
  }
1816
- return withRspLock("unfocus-change", async () => {
1817
- await unlink3(focusEntry);
1818
- await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
1819
- console.log(` ${pc.green("Unfocused:")} ${name}`);
1820
- console.log(` ${pc.dim("focus.d cleared")} \u2192 ${name}`);
1654
+ guardRspInitialized();
1655
+ try {
1656
+ await withRspLock("close-change-group", async () => {
1657
+ const ref = resolveWorkRef(`${name}/brief`, { mustExist: true });
1658
+ if (ref.kind !== "group-brief")
1659
+ throw new ChangeGroupError(`invalid Change Group name: ${name}`);
1660
+ const inspection = await inspectChangeGroups();
1661
+ const group = inspection.groups.find((candidate) => candidate.name === name);
1662
+ if (!group)
1663
+ throw new ChangeGroupError(`Change Group not found: ${name}`);
1664
+ const reasons = collectCloseReasons(group, inspection.diagnostics.filter((diagnostic) => diagnostic.change === name && diagnostic.severity === "error").map((diagnostic) => diagnostic.message));
1665
+ if (reasons.length > 0)
1666
+ throw new ChangeGroupError(`Change Group "${name}" is not ready to close: ${reasons.join("; ")}`);
1667
+ const archiveDirectory = resolveArchiveDirectory(ref);
1668
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1669
+ const archiveName = resolveArchiveName(archiveDirectory, date, "brief");
1670
+ await mkdir7(archiveDirectory, { recursive: true });
1671
+ await rename2(ref.path, join7(archiveDirectory, archiveName));
1672
+ const warnings = [];
1673
+ try {
1674
+ await cleanupEmptyParentDirs(ref.path, CHANGES_DIR);
1675
+ } catch (error) {
1676
+ warnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
1677
+ }
1678
+ try {
1679
+ await buildArchiveIndex({ acquireLock: false });
1680
+ } catch (error) {
1681
+ warnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
1682
+ }
1683
+ console.log(` ${pc.green("Closed Change Group:")} ${name}`);
1684
+ console.log(` ${pc.dim("Archived Group Brief:")} ${join7(archiveDirectory, archiveName)}
1685
+ `);
1686
+ for (const warning of warnings)
1687
+ console.log(` ${pc.yellow("\u26A0")} ${warning}`);
1688
+ if (warnings.length > 0)
1689
+ console.log(` ${pc.yellow("Group close completed, but follow-up cleanup was only partially successful.")}
1690
+ `);
1691
+ });
1692
+ } catch (error) {
1693
+ if (error instanceof WorkRefError || error instanceof ChangeGroupError) {
1694
+ console.error(` ${pc.red("Error:")} ${error.message}`);
1695
+ process.exit(1);
1696
+ }
1697
+ throw error;
1698
+ }
1699
+ }
1700
+ function collectCloseReasons(group, errors) {
1701
+ const reasons = [...errors, ...group.warnings];
1702
+ const open = group.slices.filter((slice) => slice.state === "open").map((slice) => slice.name);
1703
+ const missing = group.slices.filter((slice) => slice.state === "missing").map((slice) => slice.name);
1704
+ if (open.length > 0)
1705
+ reasons.push(`open slices: ${open.join(", ")}`);
1706
+ if (missing.length > 0)
1707
+ reasons.push(`missing slices: ${missing.join(", ")}`);
1708
+ if (group.completion.total === 0)
1709
+ reasons.push("no completion conditions are declared");
1710
+ else if (group.completion.done !== group.completion.total)
1711
+ reasons.push(`completion conditions incomplete: ${group.completion.done}/${group.completion.total}`);
1712
+ if (group.blockers)
1713
+ reasons.push("active group blockers remain");
1714
+ return [...new Set(reasons)];
1715
+ }
1716
+
1717
+ // src/commands/history.ts
1718
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1719
+ var GROUP_RE = /^[a-z0-9-]+$/;
1720
+ var WORK_REF_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
1721
+ async function showHistory(input = {}, options = {}) {
1722
+ guardRspInitialized();
1723
+ const mode = input.workRef ? "detail" : "list";
1724
+ const validation = validateHistoryQuery(input);
1725
+ if (!validation.ok)
1726
+ return emitHistoryError(mode, input, validation.error, [], emptyCollectionSummary(), [], options);
1727
+ const inspection = await inspectArchiveHistory();
1728
+ if (!historyInspectionComplete(inspection)) {
1729
+ return emitHistoryError(mode, input, {
1730
+ code: "archive_inspection_incomplete",
1731
+ message: "archive history inspection is incomplete; resolve the reported diagnostics before querying history"
1732
+ }, inspection.diagnostics, inspection.diagnosticSummary, inspection.runtime, options);
1733
+ }
1734
+ if (input.workRef) {
1735
+ try {
1736
+ const selected = selectArchiveHistoryRecord(inspection.records, { workRef: input.workRef });
1737
+ const record = await readArchiveHistoryDetail(selected);
1738
+ const result2 = {
1739
+ command: "history",
1740
+ ok: true,
1741
+ mode: "detail",
1742
+ query: { workRef: input.workRef },
1743
+ record,
1744
+ diagnostics: [],
1745
+ diagnosticSummary: emptyCollectionSummary(),
1746
+ runtime: inspection.runtime
1747
+ };
1748
+ if (options.json)
1749
+ emitJson(result2, options);
1750
+ else
1751
+ printHistoryDetail(result2);
1752
+ return result2;
1753
+ } catch (error) {
1754
+ if (error instanceof ArchiveHistoryError) {
1755
+ return emitHistoryError(mode, input, {
1756
+ code: error.code,
1757
+ message: error.message,
1758
+ candidates: error.candidates.length > 0 ? error.candidates : void 0,
1759
+ candidateSummary: error.candidateTotal > 0 ? { total: error.candidateTotal, returned: error.candidates.length, hasMore: error.candidatesTruncated } : void 0
1760
+ }, [], emptyCollectionSummary(), inspection.runtime, options);
1761
+ }
1762
+ throw error;
1763
+ }
1764
+ }
1765
+ const query = validation.query;
1766
+ const listed = queryArchiveHistory(inspection.records, query);
1767
+ const result = {
1768
+ command: "history",
1769
+ ok: true,
1770
+ mode: "list",
1771
+ query: {
1772
+ limit: query.limit ?? HISTORY_DEFAULT_LIMIT,
1773
+ since: query.since ?? null,
1774
+ until: query.until ?? null,
1775
+ kind: query.kind ?? null,
1776
+ group: query.group ?? null
1777
+ },
1778
+ records: listed.records,
1779
+ summary: listed.summary,
1780
+ diagnostics: [],
1781
+ diagnosticSummary: emptyCollectionSummary(),
1782
+ runtime: inspection.runtime
1783
+ };
1784
+ if (options.json)
1785
+ emitJson(result, options);
1786
+ else
1787
+ printHistoryList(result);
1788
+ return result;
1789
+ }
1790
+ function validateHistoryQuery(input) {
1791
+ if ((input.positionalCount ?? (input.workRef ? 1 : 0)) > 1)
1792
+ return invalid("history_positional_args_unsupported", "history accepts at most one positional WorkRef");
1793
+ if (input.workRef) {
1794
+ if (!WORK_REF_RE.test(input.workRef) || input.workRef.endsWith("/brief") || input.workRef.endsWith("/00-brief"))
1795
+ return invalid("invalid_history_work_ref", "history WorkRef must be a flat or one-Group-level executable Change identity");
1796
+ if (input.limit !== void 0 || input.since !== void 0 || input.until !== void 0 || input.kind !== void 0 || input.group !== void 0)
1797
+ return invalid("history_detail_filters_unsupported", "list filters cannot be combined with an exact history detail lookup");
1798
+ return { ok: true, query: {} };
1799
+ }
1800
+ const limit = input.limit === void 0 ? HISTORY_DEFAULT_LIMIT : Number(input.limit);
1801
+ if (input.limit !== void 0 && !/^[1-9]\d*$/.test(input.limit) || !Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
1802
+ return invalid("invalid_history_limit", `--limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
1803
+ if (input.since && !isCalendarDate(input.since))
1804
+ return invalid("invalid_history_since", "--since must be a valid YYYY-MM-DD date");
1805
+ if (input.until && !isCalendarDate(input.until))
1806
+ return invalid("invalid_history_until", "--until must be a valid YYYY-MM-DD date");
1807
+ if (input.since && input.until && input.since > input.until)
1808
+ return invalid("invalid_history_date_range", "--since must be on or before --until");
1809
+ if (input.kind !== void 0 && input.kind.trim() === "")
1810
+ return invalid("invalid_history_kind", "--kind must be a non-empty exact historical kind");
1811
+ if (input.group !== void 0 && !GROUP_RE.test(input.group))
1812
+ return invalid("invalid_history_group", "--group must be one lowercase kebab-case Group name");
1813
+ return {
1814
+ ok: true,
1815
+ query: {
1816
+ limit,
1817
+ since: input.since,
1818
+ until: input.until,
1819
+ kind: input.kind?.trim(),
1820
+ group: input.group
1821
+ }
1822
+ };
1823
+ }
1824
+ function invalid(code, message) {
1825
+ return { ok: false, error: { code, message } };
1826
+ }
1827
+ function isCalendarDate(value) {
1828
+ if (!DATE_RE.test(value))
1829
+ return false;
1830
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1831
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1832
+ }
1833
+ function emitHistoryError(mode, query, error, diagnostics, diagnosticSummary, runtime, options) {
1834
+ const result = {
1835
+ command: "history",
1836
+ ok: false,
1837
+ mode,
1838
+ query,
1839
+ records: [],
1840
+ record: null,
1841
+ diagnostics,
1842
+ diagnosticSummary,
1843
+ runtime,
1844
+ error: {
1845
+ code: error.code,
1846
+ message: error.message,
1847
+ ...error.candidates && { candidates: error.candidates },
1848
+ ...error.candidateSummary && { candidateSummary: error.candidateSummary }
1849
+ }
1850
+ };
1851
+ if (options.json) {
1852
+ emitJson(result, options);
1853
+ } else {
1854
+ console.error(` ${pc.red("Error:")} ${error.message}`);
1855
+ for (const diagnostic of diagnostics)
1856
+ console.error(` ${pc.dim(`${diagnostic.path ?? "archive"} \u2014 ${diagnostic.message}`)}`);
1857
+ if (diagnosticSummary.hasMore)
1858
+ console.error(` ${pc.dim(`${diagnosticSummary.total - diagnosticSummary.returned} additional diagnostic(s) omitted`)}`);
1859
+ for (const candidate of error.candidates ?? [])
1860
+ console.error(` ${pc.dim(candidate)}`);
1861
+ if (error.candidateSummary?.hasMore)
1862
+ console.error(` ${pc.dim(`${error.candidateSummary.total - error.candidateSummary.returned} additional candidate(s) omitted`)}`);
1863
+ }
1864
+ return result;
1865
+ }
1866
+ function emptyCollectionSummary() {
1867
+ return { total: 0, returned: 0, hasMore: false };
1868
+ }
1869
+ function printHistoryList(result) {
1870
+ console.log();
1871
+ console.log(` ${pc.bold("Archived Changes")}`);
1872
+ console.log();
1873
+ if (result.records.length === 0) {
1874
+ console.log(` ${pc.dim("No archived Changes match the query.")}
1875
+ `);
1876
+ return;
1877
+ }
1878
+ for (const record of result.records) {
1879
+ console.log(` ${pc.dim(record.date)} ${pc.cyan(record.workRef)} ${pc.dim(`[${record.kind}]`)}`);
1880
+ console.log(` ${record.summary}${record.summaryTruncated ? "\u2026" : ""}`);
1881
+ console.log(` ${pc.dim(record.path)}`);
1882
+ }
1883
+ console.log();
1884
+ console.log(` ${pc.dim(`${result.summary.returned}/${result.summary.matched} matching archive(s) returned${result.summary.hasMore ? "; narrow filters or raise --limit for more" : ""}.`)}
1885
+ `);
1886
+ }
1887
+ function printHistoryDetail(result) {
1888
+ const record = result.record;
1889
+ console.log();
1890
+ console.log(` ${pc.bold("Archived Change:")} ${pc.cyan(record.workRef)}`);
1891
+ console.log(` ${pc.dim("Date:")} ${record.date}`);
1892
+ console.log(` ${pc.dim("Kind:")} ${record.kind}`);
1893
+ console.log(` ${pc.dim("Path:")} ${record.path}`);
1894
+ console.log(` ${pc.dim("Summary:")} ${record.summary}${record.summaryTruncated ? "\u2026" : ""}`);
1895
+ console.log(` ${pc.dim("Scenarios:")} ${record.scenarioCount}`);
1896
+ console.log(` ${pc.dim("Tasks:")} ${record.checkboxes.tasks.done}/${record.checkboxes.tasks.total}`);
1897
+ console.log(` ${pc.dim("Verify:")} ${record.checkboxes.verify.done}/${record.checkboxes.verify.total}`);
1898
+ for (const [label, evidence] of Object.entries(record.evidence)) {
1821
1899
  console.log();
1822
- });
1900
+ console.log(` ${pc.bold(`${label[0].toUpperCase()}${label.slice(1)}:`)}`);
1901
+ if (evidence.items.length === 0)
1902
+ console.log(` ${pc.dim("none")}`);
1903
+ else
1904
+ evidence.items.forEach((item) => console.log(` ${item}`));
1905
+ if (evidence.truncated)
1906
+ console.log(` ${pc.dim("(truncated)")}`);
1907
+ }
1908
+ console.log();
1823
1909
  }
1824
1910
 
1825
1911
  // src/commands/init.ts
1826
- import { existsSync as existsSync13 } from "fs";
1827
- import { mkdir as mkdir8, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
1828
- import { join as join13 } from "path";
1912
+ import { existsSync as existsSync8 } from "fs";
1913
+ import { mkdir as mkdir8, readFile as readFile8 } from "fs/promises";
1914
+ import { join as join8 } from "path";
1829
1915
  function generateConfigTemplate() {
1830
1916
  const fmtList = (items, indent) => items.map((i) => `${" ".repeat(indent)}# - ${i}`).join("\n");
1831
1917
  return `# RSP project configuration
1832
- # Uncomment any section below to customize classification values.
1918
+ # Omit kinds or use [] to retain the built-in defaults.
1919
+ # A non-empty kinds list replaces the built-in defaults; it does not extend them.
1920
+ # Every entry must be a unique non-empty string.
1833
1921
  #
1834
1922
  # Built-in defaults:
1835
1923
  # kinds: ${VALID_KINDS.join(", ")}
1836
1924
  #
1837
1925
  # kinds:
1838
1926
  ${fmtList(VALID_KINDS, 2)}
1927
+
1928
+ # Decision Records default to .rsp/specs/decisions.
1929
+ # Set exactly one project-relative authoritative directory when the Host Project already owns Decision Records elsewhere.
1930
+ # decisions:
1931
+ # path: docs/adr
1839
1932
  `;
1840
1933
  }
1841
1934
  async function ensureFile(path, content) {
1842
- if (existsSync13(path))
1843
- return false;
1844
- await writeFile9(path, content);
1845
- return true;
1935
+ return ensureManagedFile(path, content, "managed file");
1846
1936
  }
1847
1937
  function toTitle(projectName) {
1848
1938
  return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
1849
1939
  }
1850
1940
  async function initProject(args = {}) {
1851
- const isNew = !existsSync13(RSP_DIR);
1941
+ const isNew = !existsSync8(RSP_DIR);
1852
1942
  const projectName = await detectProjectName();
1853
1943
  const mode = args.agentsMode ?? "managed";
1854
1944
  if (mode !== "managed" && mode !== "print") {
1855
1945
  console.error(` ${pc.red("Error:")} unsupported --agents-mode "${mode}". Use: managed or print`);
1856
1946
  process.exit(1);
1857
1947
  }
1948
+ const agentsPath = "AGENTS.md";
1949
+ const initialAgentsInspection = inspectManagedFile(agentsPath, "AGENTS.md", { allowMissing: true });
1950
+ if (initialAgentsInspection.issue)
1951
+ throw initialAgentsInspection.issue;
1858
1952
  return withRspLock("init", async () => {
1953
+ const agentsInspection = inspectManagedFile(agentsPath, "AGENTS.md", { allowMissing: true });
1954
+ if (agentsInspection.issue)
1955
+ throw agentsInspection.issue;
1956
+ if (existsSync8(CONFIG_PATH)) {
1957
+ clearConfigCache();
1958
+ const existingConfig = await inspectRspConfig();
1959
+ if (existingConfig.issues.length > 0)
1960
+ throw new Error(existingConfig.issues.join("; "));
1961
+ const existingDecisionRecordsPath = resolveDecisionRecordsPath(existingConfig.config);
1962
+ const filesystemIssue = await validateDecisionRecordsFilesystemPath(existingDecisionRecordsPath);
1963
+ if (filesystemIssue)
1964
+ throw new Error(filesystemIssue);
1965
+ }
1859
1966
  const dirs = [
1860
- join13(RSP_DIR, "rules"),
1861
- join13(RSP_DIR, "specs"),
1862
- CHANGES_DIR,
1863
- FOCUS_DIR,
1864
- join13(RSP_DIR, "archives")
1967
+ { path: join8(RSP_DIR, "specs"), label: "specs root" },
1968
+ { path: CHANGES_DIR, label: "open work root" },
1969
+ { path: FOCUS_DIR, label: "focus root" },
1970
+ { path: join8(RSP_DIR, "archives"), label: "archive root" }
1865
1971
  ];
1866
- for (const d of dirs)
1867
- await mkdir8(d, { recursive: true });
1972
+ for (const directory of dirs)
1973
+ requireManagedDirectory(directory.path, directory.label, { allowMissing: true });
1974
+ for (const directory of dirs)
1975
+ await mkdir8(directory.path, { recursive: true });
1868
1976
  let created = false;
1869
- const bundledRules = await readFile10(join13(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1870
- created = await ensureFile(join13(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
1871
- created = await ensureFile(join13(RSP_DIR, "config.yaml"), generateConfigTemplate()) || created;
1872
- const createdSpecsIndex = await ensureFile(join13(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Additional project-level specs beyond `design.md`._\n");
1977
+ const bundledRules = await readFile8(join8(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1978
+ created = await ensureFile(RSP_RULES_PATH, bundledRules) || created;
1979
+ created = await ensureFile(join8(RSP_DIR, "config.yaml"), generateConfigTemplate()) || created;
1980
+ clearConfigCache();
1981
+ const configInspection = await inspectRspConfig();
1982
+ if (configInspection.issues.length > 0)
1983
+ throw new Error(configInspection.issues.join("; "));
1984
+ const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
1985
+ created = await ensureDecisionRecordsDirectory(decisionRecordsPath) || created;
1986
+ const createdSpecsIndex = await ensureFile(join8(RSP_DIR, "specs", "INDEX.md"), "# Specs Index\n\n_Additional project-level specs beyond `design.md`._\n");
1873
1987
  created = createdSpecsIndex || created;
1874
- const createdDesign = await ensureFile(join13(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1988
+ const createdDesign = await ensureFile(join8(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1875
1989
  created = createdDesign || created;
1876
- const createdArchivesIndex = await ensureFile(join13(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1990
+ const createdArchivesIndex = await ensureFile(join8(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1877
1991
  created = createdArchivesIndex || created;
1878
- created = await ensureFile(join13(CHANGES_DIR, ".gitkeep"), "") || created;
1879
- created = await ensureFile(join13(FOCUS_DIR, ".gitkeep"), "") || created;
1880
- created = await ensureFile(join13(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1881
- if (args.withProjectRules)
1882
- created = await ensureFile(join13(RSP_DIR, "rules", "project-rules.md"), generateProjectRulesContent(projectName)) || created;
1992
+ created = await ensureFile(join8(CHANGES_DIR, ".gitkeep"), "") || created;
1993
+ created = await ensureFile(join8(FOCUS_DIR, ".gitkeep"), "") || created;
1994
+ created = await ensureFile(join8(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1883
1995
  if (args.withProjectSetup) {
1884
- const changePath = join13(CHANGES_DIR, "project-setup.md");
1885
- const focusMarker = join13(FOCUS_DIR, "project-setup");
1996
+ const projectSetupRef = resolveWorkRef("project-setup", { executable: true });
1997
+ const changePath = projectSetupRef.path;
1998
+ const focusMarker = resolveFocusMarkerPath(projectSetupRef);
1886
1999
  created = await ensureFile(changePath, generateChangeContent("project-setup")) || created;
1887
2000
  created = await ensureFile(focusMarker, "") || created;
1888
2001
  }
@@ -1890,27 +2003,26 @@ async function initProject(args = {}) {
1890
2003
  await buildSpecsIndex({ acquireLock: false });
1891
2004
  if (createdArchivesIndex)
1892
2005
  await buildArchiveIndex({ acquireLock: false });
1893
- const agentsPath = "AGENTS.md";
1894
2006
  const title = toTitle(projectName);
1895
2007
  let agentsUpdated = false;
1896
- if (!existsSync13(agentsPath)) {
1897
- await writeFile9(agentsPath, `# ${title}
2008
+ if (!agentsInspection.exists) {
2009
+ await writeManagedFile(agentsPath, `# ${title}
1898
2010
 
1899
2011
  ${upsertRspAgentsBlock("").content}
1900
- `);
2012
+ `, "AGENTS.md");
1901
2013
  created = true;
1902
2014
  agentsUpdated = true;
1903
2015
  } else {
1904
- const existing = await readFile10(agentsPath, "utf-8");
2016
+ const existing = await readFile8(agentsPath, "utf-8");
1905
2017
  const next = upsertRspAgentsBlock(existing);
1906
2018
  if (next.changed) {
1907
- await writeFile9(agentsPath, next.content);
2019
+ await writeManagedFile(agentsPath, next.content, "AGENTS.md");
1908
2020
  created = true;
1909
2021
  agentsUpdated = true;
1910
2022
  }
1911
2023
  }
1912
2024
  if (mode === "print") {
1913
- const managed = await readFile10(agentsPath, "utf-8");
2025
+ const managed = await readFile8(agentsPath, "utf-8");
1914
2026
  console.log(`
1915
2027
  ${managed}
1916
2028
  `);
@@ -1922,8 +2034,9 @@ ${managed}
1922
2034
  console.log(`
1923
2035
  ${pc.green("RSP scaffolded.")}
1924
2036
  `);
1925
- console.log(` Created: .rsp/rules/
2037
+ console.log(` Created: .rsp/rsp-rules.md
1926
2038
  .rsp/specs/
2039
+ ${decisionRecordsPath}/
1927
2040
  .rsp/changes/
1928
2041
  .rsp/focus.d/
1929
2042
  .rsp/archives/
@@ -1948,33 +2061,50 @@ ${managed}
1948
2061
  }
1949
2062
 
1950
2063
  // src/commands/ready.ts
1951
- import { existsSync as existsSync14 } from "fs";
1952
- import { readFile as readFile11 } from "fs/promises";
1953
- import { join as join14 } from "path";
2064
+ import { readFile as readFile9 } from "fs/promises";
2065
+ function exitReadyError(name, error, options) {
2066
+ if (options.json) {
2067
+ emitJson({
2068
+ command: "ready",
2069
+ ok: false,
2070
+ change: name || null,
2071
+ path: null,
2072
+ warnings: [],
2073
+ runtime: [],
2074
+ error
2075
+ }, options);
2076
+ } else {
2077
+ console.error(` ${pc.red("Error:")} ${error.message}`);
2078
+ }
2079
+ process.exit(1);
2080
+ }
1954
2081
  async function showReady(name, options = {}) {
1955
2082
  if (!name) {
1956
2083
  console.error(` ${pc.red("Usage:")} rsp ready <name>`);
1957
2084
  process.exit(1);
1958
2085
  }
1959
- if (!isValidChangeName(name)) {
1960
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1961
- process.exit(1);
1962
- }
1963
2086
  guardRspInitialized();
1964
- const srcPath = join14(CHANGES_DIR, `${name}.md`);
1965
- if (!existsSync14(srcPath)) {
1966
- console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
1967
- process.exit(1);
2087
+ let workRef;
2088
+ try {
2089
+ workRef = await resolveExecutableChange(name, { mustExist: true });
2090
+ } catch (error) {
2091
+ if (error instanceof WorkRefError)
2092
+ exitReadyError(name, { code: error.code, message: error.message }, options);
2093
+ throw error;
1968
2094
  }
2095
+ const srcPath = workRef.path;
1969
2096
  const runtime = [];
1970
2097
  let content;
1971
2098
  try {
1972
- content = await readFile11(srcPath, "utf-8");
2099
+ content = await readFile9(srcPath, "utf-8");
1973
2100
  } catch {
1974
2101
  console.error(` ${pc.red("Error:")} unable to read .rsp/changes/${name}.md`);
1975
2102
  process.exit(1);
1976
2103
  }
1977
- const readinessDetails = collectArchiveReadiness(content);
2104
+ const dependencyInspection = await inspectChangeDependencies();
2105
+ const readinessDetails = collectArchiveReadiness(content, {
2106
+ activeBlockers: dependencyInspection.activeBlockers.get(name)
2107
+ });
1978
2108
  const checklist = readinessDetails.warnings;
1979
2109
  const readiness = {
1980
2110
  incompleteTasks: readinessDetails.taskTodos.length,
@@ -1985,7 +2115,19 @@ async function showReady(name, options = {}) {
1985
2115
  semantic: readinessDetails.semantic,
1986
2116
  archiveReady: readinessDetails.archiveReady
1987
2117
  };
1988
- const durableReview = buildDurableReviewGuidance(getReadyDurableReviewCandidateTargets());
2118
+ let decisionRecordsPath;
2119
+ try {
2120
+ const configInspection = await inspectRspConfig();
2121
+ if (configInspection.issues.length > 0)
2122
+ exitReadyError(name, { code: "invalid_config", message: configInspection.issues.join("; ") }, options);
2123
+ decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
2124
+ const filesystemIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath);
2125
+ if (filesystemIssue)
2126
+ exitReadyError(name, { code: "invalid_decision_records_path", message: filesystemIssue }, options);
2127
+ } catch (error) {
2128
+ exitReadyError(name, { code: "invalid_config", message: `.rsp/config.yaml could not be parsed: ${toErrorMessage(error)}` }, options);
2129
+ }
2130
+ const durableReview = buildDurableReviewGuidance(getDurableReviewCandidateTargets(), decisionRecordsPath);
1989
2131
  const result = {
1990
2132
  command: "ready",
1991
2133
  ok: true,
@@ -1997,7 +2139,7 @@ async function showReady(name, options = {}) {
1997
2139
  runtime
1998
2140
  };
1999
2141
  if (options.json) {
2000
- emitJson(result);
2142
+ emitJson(result, options);
2001
2143
  return result;
2002
2144
  }
2003
2145
  console.log();
@@ -2011,25 +2153,20 @@ async function showReady(name, options = {}) {
2011
2153
  console.log(` ${pc.yellow("\u26A0")} ${line}`);
2012
2154
  console.log();
2013
2155
  console.log(` ${pc.dim("Review the warnings above before treating this work as fully closed.")}`);
2014
- console.log(` ${pc.dim("Run:")} rsp archive ${name}
2015
- `);
2016
2156
  }
2017
2157
  console.log(` ${pc.dim("Deterministic readiness:")} ${readiness.deterministic === "pass" ? pc.green("pass") : pc.yellow("warnings")}`);
2018
2158
  console.log(` ${pc.dim("Semantic review:")} ${pc.yellow("needed")}`);
2019
2159
  console.log(` ${pc.dim("Archive ready:")} ${formatArchiveReady(readiness.archiveReady)}
2020
2160
  `);
2021
2161
  console.log(` ${pc.bold("Durable review:")}`);
2022
- console.log(` ${pc.dim("Decision options:")} ${durableReview.decisions.join(" | ")}`);
2023
- console.log(` ${pc.dim("Candidate targets:")} ${durableReview.candidateTargets.join(", ")}`);
2162
+ console.log(` ${pc.dim("Current-fact options:")} ${durableReview.factDecisions.join(" | ")}`);
2163
+ console.log(` ${pc.dim("Rationale options:")} ${durableReview.rationaleDecisions.join(" | ")}`);
2164
+ console.log(` ${pc.dim("Current-fact targets:")} ${durableReview.factCandidateTargets.join(", ")}`);
2165
+ console.log(` ${pc.dim("Decision Record path:")} ${durableReview.decisionRecordsPath}`);
2024
2166
  console.log(` ${pc.dim(durableReview.note)}
2025
2167
  `);
2026
2168
  return result;
2027
2169
  }
2028
- function getReadyDurableReviewCandidateTargets() {
2029
- return getDurableReviewCandidateTargets({
2030
- projectRulesExists: existsSync14(join14(RSP_DIR, "rules", "project-rules.md"))
2031
- });
2032
- }
2033
2170
  function formatArchiveReady(value) {
2034
2171
  switch (value) {
2035
2172
  case "yes":
@@ -2042,9 +2179,7 @@ function formatArchiveReady(value) {
2042
2179
  }
2043
2180
 
2044
2181
  // src/commands/show.ts
2045
- import { existsSync as existsSync15 } from "fs";
2046
- import { readFile as readFile12 } from "fs/promises";
2047
- import { join as join15 } from "path";
2182
+ import { readFile as readFile10 } from "fs/promises";
2048
2183
  function exitShowError(error, options) {
2049
2184
  const nextActions = error.code === "no_focused_change" ? ["Run: rsp status", "Run: rsp focus <name>", "Or run: rsp create <name>"] : [];
2050
2185
  if (options.json) {
@@ -2056,7 +2191,7 @@ function exitShowError(error, options) {
2056
2191
  runtime: [],
2057
2192
  nextActions,
2058
2193
  error
2059
- });
2194
+ }, options);
2060
2195
  } else {
2061
2196
  console.error(` ${pc.red("Error:")} ${error.message}`);
2062
2197
  for (const action of nextActions)
@@ -2068,9 +2203,14 @@ async function showChange(nameOrFocused, options = {}) {
2068
2203
  const runtime = [];
2069
2204
  const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
2070
2205
  guardRspInitialized();
2206
+ const focusTree = await inspectFocusTree();
2207
+ if (focusTree.diagnostics.length > 0) {
2208
+ const diagnostic = focusTree.diagnostics[0];
2209
+ exitShowError({ code: diagnostic.code, message: diagnostic.message }, options);
2210
+ }
2211
+ const focused = new Set(focusTree.markers.map((marker) => marker.name));
2071
2212
  let name;
2072
2213
  if (options.focused) {
2073
- const focused = await getFocusedChangeNames({ onError: reportRuntime });
2074
2214
  if (focused.size === 0) {
2075
2215
  exitShowError({ code: "no_focused_change", message: "no focused change exists" }, options);
2076
2216
  }
@@ -2082,23 +2222,22 @@ async function showChange(nameOrFocused, options = {}) {
2082
2222
  }
2083
2223
  name = [...focused][0];
2084
2224
  } else if (nameOrFocused) {
2085
- if (!isValidChangeName(nameOrFocused)) {
2086
- exitShowError({
2087
- code: "invalid_change_name",
2088
- message: "change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)"
2089
- }, options);
2090
- }
2091
2225
  name = nameOrFocused;
2092
2226
  } else {
2093
2227
  exitShowError({ code: "missing_change_name", message: "Usage: rsp show <name|--focused> [--json] [--verbose]" }, options);
2094
2228
  }
2095
- const srcPath = join15(CHANGES_DIR, `${name}.md`);
2096
- if (!existsSync15(srcPath)) {
2097
- exitShowError({ code: "change_not_found", message: `.rsp/changes/${name}.md not found` }, options);
2229
+ let workRef;
2230
+ try {
2231
+ workRef = await resolveExecutableChange(name, { mustExist: true });
2232
+ } catch (error) {
2233
+ if (error instanceof WorkRefError)
2234
+ exitShowError({ code: error.code, message: error.message }, options);
2235
+ throw error;
2098
2236
  }
2237
+ const srcPath = workRef.path;
2099
2238
  let content;
2100
2239
  try {
2101
- content = await readFile12(srcPath, "utf-8");
2240
+ content = await readFile10(srcPath, "utf-8");
2102
2241
  } catch {
2103
2242
  exitShowError({ code: "change_read_failed", message: `unable to read .rsp/changes/${name}.md` }, options);
2104
2243
  }
@@ -2115,12 +2254,12 @@ async function showChange(nameOrFocused, options = {}) {
2115
2254
  message: toErrorMessage(error)
2116
2255
  });
2117
2256
  }
2118
- const focusedSet = await getFocusedChangeNames({ onError: reportRuntime });
2119
- const isFocused = focusedSet.has(name);
2257
+ const isFocused = focused.has(name);
2258
+ const dependencyInspection = await inspectChangeDependencies();
2120
2259
  const cb = countCheckboxes(content);
2121
- const blockers = hasMeaningfulBlockers(content);
2260
+ const blockers = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
2122
2261
  const scenarios = parseScenarios(content);
2123
- const readinessDetails = collectArchiveReadiness(content);
2262
+ const readinessDetails = collectArchiveReadiness(content, { activeBlockers: blockers });
2124
2263
  const readiness = {
2125
2264
  incompleteTasks: readinessDetails.taskTodos.length,
2126
2265
  incompleteVerify: readinessDetails.verifyTodos.length,
@@ -2130,10 +2269,22 @@ async function showChange(nameOrFocused, options = {}) {
2130
2269
  semantic: readinessDetails.semantic,
2131
2270
  archiveReady: readinessDetails.archiveReady
2132
2271
  };
2133
- const contextPaths = getDurableReviewCandidateTargets({
2134
- projectRulesExists: existsSync15(join15(RSP_DIR, "rules", "project-rules.md"))
2135
- });
2136
- const durableReview = buildDurableReviewGuidance(contextPaths);
2272
+ const factCandidateTargets = getDurableReviewCandidateTargets();
2273
+ let decisionRecordsPath;
2274
+ try {
2275
+ const configInspection = await inspectRspConfig();
2276
+ if (configInspection.issues.length > 0)
2277
+ exitShowError({ code: "invalid_config", message: configInspection.issues.join("; ") }, options);
2278
+ decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
2279
+ const filesystemIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath);
2280
+ if (filesystemIssue)
2281
+ exitShowError({ code: "invalid_decision_records_path", message: filesystemIssue }, options);
2282
+ } catch (error) {
2283
+ exitShowError({ code: "invalid_config", message: `.rsp/config.yaml could not be parsed: ${toErrorMessage(error)}` }, options);
2284
+ }
2285
+ const groupContextPaths = workRef.group ? [normalizeLogicalPath(resolveWorkRef(`${workRef.group}/brief`, { mustExist: true }).path)] : [];
2286
+ const contextPaths = [...groupContextPaths, ...factCandidateTargets, decisionRecordsPath];
2287
+ const durableReview = buildDurableReviewGuidance(factCandidateTargets, decisionRecordsPath);
2137
2288
  const result = {
2138
2289
  command: "show",
2139
2290
  ok: true,
@@ -2152,7 +2303,7 @@ async function showChange(nameOrFocused, options = {}) {
2152
2303
  runtime
2153
2304
  };
2154
2305
  if (options.json) {
2155
- emitJson(result);
2306
+ emitJson(result, options);
2156
2307
  return result;
2157
2308
  }
2158
2309
  console.log();
@@ -2178,8 +2329,10 @@ async function showChange(nameOrFocused, options = {}) {
2178
2329
  console.log(` ${pc.dim(cp)}`);
2179
2330
  console.log();
2180
2331
  console.log(` ${pc.bold("Durable review:")}`);
2181
- console.log(` ${pc.dim("Decision options:")} ${durableReview.decisions.join(" | ")}`);
2182
- console.log(` ${pc.dim("Candidate targets:")} ${durableReview.candidateTargets.join(", ")}`);
2332
+ console.log(` ${pc.dim("Current-fact options:")} ${durableReview.factDecisions.join(" | ")}`);
2333
+ console.log(` ${pc.dim("Rationale options:")} ${durableReview.rationaleDecisions.join(" | ")}`);
2334
+ console.log(` ${pc.dim("Current-fact targets:")} ${durableReview.factCandidateTargets.join(", ")}`);
2335
+ console.log(` ${pc.dim("Decision Record path:")} ${durableReview.decisionRecordsPath}`);
2183
2336
  console.log(` ${pc.dim(durableReview.note)}`);
2184
2337
  console.log();
2185
2338
  return result;
@@ -2195,191 +2348,286 @@ function formatArchiveReady2(value) {
2195
2348
  }
2196
2349
  }
2197
2350
 
2198
- // src/commands/status.ts
2199
- import { existsSync as existsSync16 } from "fs";
2200
- import { readFile as readFile13, stat } from "fs/promises";
2201
- import { join as join16 } from "path";
2202
- var COL_CHANGE = 32;
2203
- var COL_KIND = 10;
2204
- var COL_AGE = 7;
2205
- var COL_PROGRESS = 10;
2206
- async function showStatus(options = {}, runOptions = {}) {
2207
- const runtime = [];
2208
- const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(runOptions.verbose) && !runOptions.json);
2209
- const focusedSet = await getFocusedChangeNames({ onError: reportRuntime });
2210
- const changeFiles = existsSync16(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
2211
- const changeMap = {};
2212
- for (const fp of changeFiles)
2213
- changeMap[changeNameFromPath(CHANGES_DIR, fp)] = fp;
2214
- const allNames = [.../* @__PURE__ */ new Set([...Object.keys(changeMap), ...focusedSet])].sort();
2215
- const records = [];
2216
- const outputRecords = [];
2217
- for (const name of allNames) {
2218
- const fp = changeMap[name];
2219
- let kind = "\u2014";
2220
- let progress = "\u2014";
2221
- let ageDays = null;
2222
- const isFocused = focusedSet.has(name);
2223
- let isBlocked = false;
2224
- let done = 0;
2225
- let total = 0;
2226
- if (fp) {
2351
+ // src/commands/skills.ts
2352
+ import { randomUUID } from "crypto";
2353
+ import { lstatSync } from "fs";
2354
+ import { chmod, copyFile, mkdir as mkdir9, readdir as readdir2, readFile as readFile11, rename as rename3, rm as rm2 } from "fs/promises";
2355
+ import { dirname as dirname4, join as join9, relative as relative4 } from "path";
2356
+ function unsupportedEntry(path, label) {
2357
+ return new ManagedPathError(path, `unsupported entry in the ${label} tree: ${path}`);
2358
+ }
2359
+ function inspectDirectoryIdentity(path, label) {
2360
+ const inspection = inspectManagedDirectory(path, label);
2361
+ if (inspection.issue)
2362
+ throw inspection.issue;
2363
+ const stats = lstatSync(path);
2364
+ return { path, dev: stats.dev, ino: stats.ino };
2365
+ }
2366
+ function assertDirectoryIdentities(identities, label) {
2367
+ for (const identity of identities) {
2368
+ const current = inspectDirectoryIdentity(identity.path, label);
2369
+ if (current.dev !== identity.dev || current.ino !== identity.ino)
2370
+ throw new ManagedPathError(identity.path, `${label} changed after validation: ${identity.path}`);
2371
+ }
2372
+ }
2373
+ async function prepareTargetRoot(projectRoot, targetRoot) {
2374
+ const projectIdentity = inspectDirectoryIdentity(projectRoot, "project Skills root");
2375
+ const agentsRoot = join9(projectRoot, ".agents");
2376
+ const identities = [projectIdentity];
2377
+ assertDirectoryIdentities(identities, "project Skills root");
2378
+ await mkdir9(agentsRoot).catch((error) => {
2379
+ if (error.code !== "EEXIST")
2380
+ throw error;
2381
+ });
2382
+ assertDirectoryIdentities(identities, "project Skills root");
2383
+ identities.push(inspectDirectoryIdentity(agentsRoot, "project Skills root"));
2384
+ assertDirectoryIdentities(identities, "project Skills root");
2385
+ await mkdir9(targetRoot).catch((error) => {
2386
+ if (error.code !== "EEXIST")
2387
+ throw error;
2388
+ });
2389
+ assertDirectoryIdentities(identities, "project Skills root");
2390
+ identities.push(inspectDirectoryIdentity(targetRoot, "project Skills root"));
2391
+ return identities;
2392
+ }
2393
+ async function inspectPackagedSkills(packageRoot) {
2394
+ const skillsRoot = join9(packageRoot, "skills");
2395
+ const rootInspection = inspectManagedDirectory(skillsRoot, "packaged Skills root");
2396
+ if (rootInspection.issue)
2397
+ throw rootInspection.issue;
2398
+ const entries = await readdir2(skillsRoot, { withFileTypes: true });
2399
+ const skills = [];
2400
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
2401
+ if (entry.name.startsWith("."))
2402
+ continue;
2403
+ const root = join9(skillsRoot, entry.name);
2404
+ if (!entry.isDirectory())
2405
+ throw unsupportedEntry(root, "packaged Skills");
2406
+ const inspection = await inspectManagedFileTree(root, `packaged Skill ${entry.name}`);
2407
+ if (inspection.issues.length > 0)
2408
+ throw inspection.issues[0];
2409
+ skills.push({
2410
+ name: entry.name,
2411
+ root,
2412
+ files: inspection.files.map((path) => relative4(root, path))
2413
+ });
2414
+ }
2415
+ return skills;
2416
+ }
2417
+ function inspectTarget(path, name) {
2418
+ try {
2419
+ const stats = lstatSync(path);
2420
+ if (stats.isDirectory())
2421
+ return "directory";
2422
+ throw unsupportedEntry(path, `installed Skill ${name}`);
2423
+ } catch (error) {
2424
+ if (error.code === "ENOENT")
2425
+ return "missing";
2426
+ throw error;
2427
+ }
2428
+ }
2429
+ async function treesEqual(source, targetRoot) {
2430
+ const target = await inspectManagedFileTree(targetRoot, `installed Skill ${source.name}`);
2431
+ if (target.issues.length > 0)
2432
+ throw target.issues[0];
2433
+ const targetFiles = target.files.map((path) => relative4(targetRoot, path));
2434
+ if (source.files.length !== targetFiles.length || source.files.some((path, index) => path !== targetFiles[index]))
2435
+ return false;
2436
+ for (const path of source.files) {
2437
+ const sourcePath = join9(source.root, path);
2438
+ const targetPath = join9(targetRoot, path);
2439
+ const [sourceStats, targetStats] = [lstatSync(sourcePath), lstatSync(targetPath)];
2440
+ if (!sourceStats.isFile() || !targetStats.isFile())
2441
+ throw unsupportedEntry(!sourceStats.isFile() ? sourcePath : targetPath, `Skill ${source.name}`);
2442
+ if ((sourceStats.mode & 511) !== (targetStats.mode & 511))
2443
+ return false;
2444
+ const [sourceContent, targetContent] = await Promise.all([readFile11(sourcePath), readFile11(targetPath)]);
2445
+ if (!sourceContent.equals(targetContent))
2446
+ return false;
2447
+ }
2448
+ return true;
2449
+ }
2450
+ async function copySkillTree(source, targetRoot) {
2451
+ await mkdir9(targetRoot, { recursive: true });
2452
+ for (const path of source.files) {
2453
+ const sourcePath = join9(source.root, path);
2454
+ const targetPath = join9(targetRoot, path);
2455
+ const stats = lstatSync(sourcePath);
2456
+ if (!stats.isFile())
2457
+ throw unsupportedEntry(sourcePath, `packaged Skill ${source.name}`);
2458
+ await mkdir9(dirname4(targetPath), { recursive: true });
2459
+ await copyFile(sourcePath, targetPath);
2460
+ await chmod(targetPath, stats.mode & 511);
2461
+ }
2462
+ }
2463
+ async function installPackagedSkills(args = {}, options = {}) {
2464
+ const packageRoot = options.packageRoot ?? PKG_ROOT;
2465
+ const projectRoot = options.projectRoot ?? process.cwd();
2466
+ const renamePath = options.renamePath ?? rename3;
2467
+ const skills = await inspectPackagedSkills(packageRoot);
2468
+ const targetRoot = resolveManagedDirectoryChain(projectRoot, [".agents", "skills"], "project Skills root");
2469
+ const result = { installed: [], unchanged: [], replaced: [] };
2470
+ const conflicts = [];
2471
+ const targetTreeIdentities = /* @__PURE__ */ new Map();
2472
+ for (const skill of skills) {
2473
+ const target = join9(targetRoot, skill.name);
2474
+ if (inspectTarget(target, skill.name) === "missing") {
2475
+ result.installed.push(skill.name);
2476
+ continue;
2477
+ }
2478
+ const targetIdentity = inspectDirectoryIdentity(target, `installed Skill ${skill.name}`);
2479
+ if (await treesEqual(skill, target)) {
2480
+ result.unchanged.push(skill.name);
2481
+ continue;
2482
+ }
2483
+ assertDirectoryIdentities([targetIdentity], `installed Skill ${skill.name}`);
2484
+ if (args.force) {
2485
+ result.replaced.push(skill.name);
2486
+ targetTreeIdentities.set(skill.name, targetIdentity);
2487
+ } else {
2488
+ conflicts.push(skill.name);
2489
+ }
2490
+ }
2491
+ if (conflicts.length > 0)
2492
+ throw new Error(`conflicting packaged Skills: ${conflicts.join(", ")}; rerun with --force to replace only these package-owned directories`);
2493
+ if (args.dryRun)
2494
+ return result;
2495
+ await options.onMutationStep?.({ phase: "before-target-root-mutation", targetRoot });
2496
+ const targetIdentities = await prepareTargetRoot(projectRoot, targetRoot);
2497
+ const stagingRoot = join9(targetRoot, `.rsp-skills-install-${randomUUID()}`);
2498
+ const nextRoot = join9(stagingRoot, "next");
2499
+ const previousRoot = join9(stagingRoot, "previous");
2500
+ const failedRoot = join9(stagingRoot, "failed");
2501
+ const movedPrevious = [];
2502
+ const activated = [];
2503
+ let removeStaging = true;
2504
+ try {
2505
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2506
+ await mkdir9(nextRoot, { recursive: true });
2507
+ await mkdir9(previousRoot);
2508
+ await mkdir9(failedRoot);
2509
+ for (const skill of skills) {
2510
+ if (result.unchanged.includes(skill.name))
2511
+ continue;
2512
+ await copySkillTree(skill, join9(nextRoot, skill.name));
2513
+ }
2514
+ for (const name of result.installed) {
2515
+ const target = join9(targetRoot, name);
2516
+ if (inspectTarget(target, name) !== "missing")
2517
+ throw new ManagedPathError(target, `installed Skill changed after preflight: ${target}`);
2518
+ }
2519
+ for (const name of result.replaced) {
2520
+ assertDirectoryIdentities([targetTreeIdentities.get(name)], `installed Skill ${name}`);
2521
+ }
2522
+ for (const name of result.replaced) {
2523
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2524
+ assertDirectoryIdentities([targetTreeIdentities.get(name)], `installed Skill ${name}`);
2525
+ await renamePath(join9(targetRoot, name), join9(previousRoot, name));
2526
+ movedPrevious.push(name);
2527
+ }
2528
+ for (const name of [...result.installed, ...result.replaced].sort()) {
2529
+ const target = join9(targetRoot, name);
2530
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2531
+ await options.onMutationStep?.({ phase: "before-activate", name, target });
2532
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2533
+ if (inspectTarget(target, name) !== "missing")
2534
+ throw new ManagedPathError(target, `installed Skill changed after preflight: ${target}`);
2535
+ await renamePath(join9(nextRoot, name), target);
2536
+ activated.push(name);
2537
+ }
2538
+ } catch (error) {
2539
+ const rollbackErrors = [];
2540
+ for (const name of activated.reverse()) {
2541
+ const target = join9(targetRoot, name);
2227
2542
  try {
2228
- const content = await readFile13(fp, "utf-8");
2229
- try {
2230
- const fm = parseFrontmatter(content);
2231
- kind = fm?.kind ? String(fm.kind) : "\u2014";
2232
- } catch (error) {
2233
- kind = "(invalid)";
2234
- reportRuntime({
2235
- code: "frontmatter_parse_failed",
2236
- operation: "parseFrontmatter",
2237
- path: fp,
2238
- message: toErrorMessage(error)
2239
- });
2240
- }
2241
- isBlocked = hasMeaningfulBlockers(content);
2242
- const cb = countCheckboxes(content);
2243
- done = cb.done;
2244
- total = cb.total;
2245
- progress = `${done}/${total}`;
2246
- } catch (error) {
2247
- reportRuntime({
2248
- code: "change_read_failed",
2249
- operation: "readFile",
2250
- path: fp,
2251
- message: toErrorMessage(error)
2252
- });
2543
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2544
+ if (inspectTarget(target, name) === "directory")
2545
+ await renamePath(target, join9(failedRoot, name));
2546
+ } catch (rollbackError) {
2547
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
2253
2548
  }
2549
+ }
2550
+ for (const name of movedPrevious.reverse()) {
2551
+ const target = join9(targetRoot, name);
2552
+ const previous = join9(previousRoot, name);
2254
2553
  try {
2255
- const s = await stat(fp);
2256
- ageDays = Math.floor((Date.now() - s.mtime.getTime()) / (1e3 * 60 * 60 * 24));
2257
- } catch (error) {
2258
- reportRuntime({
2259
- code: "change_stat_failed",
2260
- operation: "stat",
2261
- path: fp,
2262
- message: toErrorMessage(error)
2263
- });
2554
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2555
+ await options.onMutationStep?.({ phase: "before-rollback-restore", name, target, previous });
2556
+ assertDirectoryIdentities(targetIdentities, "project Skills root");
2557
+ if (inspectTarget(target, name) !== "missing")
2558
+ throw new ManagedPathError(target, `cannot restore installed Skill because the target is occupied: ${target}`);
2559
+ await renamePath(previous, target);
2560
+ } catch (rollbackError) {
2561
+ rollbackErrors.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
2264
2562
  }
2265
- } else {
2266
- kind = "(missing)";
2267
- reportRuntime({
2268
- code: "focused_change_missing",
2269
- operation: "resolveChange",
2270
- path: join16(CHANGES_DIR, `${name}.md`),
2271
- message: "focus marker points to a missing change file"
2272
- });
2273
2563
  }
2274
- const age = ageDays === null ? "\u2014" : String(ageDays);
2275
- records.push({ name, kind, progress, age, isFocused, isBlocked });
2276
- outputRecords.push({
2277
- name,
2278
- kind,
2279
- progress: { done, total },
2280
- ageDays,
2281
- isFocused,
2282
- isBlocked,
2283
- path: fp ? normalizeLogicalPath(fp) : null
2284
- });
2564
+ if (rollbackErrors.length > 0) {
2565
+ removeStaging = false;
2566
+ const message = error instanceof Error ? error.message : String(error);
2567
+ throw new Error(`${message}; rollback incomplete; recover original Skills from ${previousRoot}; ${rollbackErrors.join("; ")}`);
2568
+ }
2569
+ throw error;
2570
+ } finally {
2571
+ if (removeStaging)
2572
+ await rm2(stagingRoot, { recursive: true, force: true });
2285
2573
  }
2286
- const filteredRecords = records.filter((record) => {
2287
- if (options.focused && !record.isFocused)
2288
- return false;
2289
- if (options.blocked && !record.isBlocked)
2574
+ return result;
2575
+ }
2576
+ function printSkillInstallResult(result, dryRun = false) {
2577
+ const prefix = dryRun ? "would be " : "";
2578
+ for (const status of ["installed", "unchanged", "replaced"]) {
2579
+ if (result[status].length > 0)
2580
+ console.log(` ${prefix}${status}: ${result[status].join(", ")}`);
2581
+ }
2582
+ }
2583
+
2584
+ // src/status/derive.ts
2585
+ function deriveStatusView(snapshot, options = {}) {
2586
+ const query = {
2587
+ focused: Boolean(options.focused),
2588
+ blocked: Boolean(options.blocked),
2589
+ stale: typeof options.stale === "number" ? options.stale : null
2590
+ };
2591
+ const records = snapshot.records.filter(({ output: record }) => {
2592
+ if (query.focused && !record.isFocused)
2290
2593
  return false;
2291
- if (typeof options.stale === "number") {
2292
- const ageDays = Number(record.age);
2293
- if (!Number.isFinite(ageDays) || ageDays < options.stale)
2294
- return false;
2295
- }
2296
- return true;
2297
- });
2298
- const filteredOutputRecords = outputRecords.filter((record) => {
2299
- if (options.focused && !record.isFocused)
2594
+ if (query.blocked && !record.isBlocked)
2300
2595
  return false;
2301
- if (options.blocked && !record.isBlocked)
2596
+ if (query.stale !== null && (record.ageDays === null || record.ageDays < query.stale))
2302
2597
  return false;
2303
- if (typeof options.stale === "number") {
2304
- if (record.ageDays === null || record.ageDays < options.stale)
2305
- return false;
2306
- }
2307
2598
  return true;
2308
2599
  });
2309
- const blockedCount = filteredOutputRecords.filter((r) => r.isBlocked).length;
2310
- const nextActions = buildStatusNextActions(focusedSet.size, Object.keys(changeMap).sort(), Boolean(options.focused || options.blocked || typeof options.stale === "number"));
2311
- const statusResult = {
2312
- command: "status",
2313
- ok: true,
2314
- filters: {
2315
- focused: Boolean(options.focused),
2316
- blocked: Boolean(options.blocked),
2317
- stale: typeof options.stale === "number" ? options.stale : null
2318
- },
2319
- focused: [...focusedSet].sort(),
2320
- records: filteredOutputRecords,
2600
+ const filtered = query.focused || query.blocked || query.stale !== null;
2601
+ const ok = snapshot.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
2602
+ const nextActions = ok ? buildStatusNextActions(snapshot.focused.length, snapshot.records.map((record) => record.output), snapshot.groups, filtered) : ["Run: rsp doctor"];
2603
+ return {
2604
+ query,
2605
+ focused: snapshot.focused,
2606
+ records,
2607
+ groups: snapshot.groups,
2608
+ plan: filterDependencyPlan(snapshot.plan, new Set(records.map((record) => record.output.name))),
2321
2609
  summary: {
2322
- total: filteredOutputRecords.length,
2323
- focused: filteredOutputRecords.filter((r) => r.isFocused).length,
2324
- blocked: blockedCount
2610
+ total: records.length,
2611
+ focused: records.filter((record) => record.output.isFocused).length,
2612
+ blocked: records.filter((record) => record.output.isBlocked).length
2325
2613
  },
2326
2614
  nextActions,
2327
- archiveTrend: await readArchiveTrend(runtime, runOptions),
2328
- runtime
2615
+ archiveTrend: snapshot.archiveTrend,
2616
+ diagnostics: snapshot.diagnostics,
2617
+ runtime: snapshot.runtime,
2618
+ hasExecutableChanges: snapshot.records.length > 0,
2619
+ ok
2329
2620
  };
2330
- if (runOptions.json) {
2331
- emitJson(statusResult);
2332
- return statusResult;
2333
- }
2334
- console.log();
2335
- console.log(` ${pc.bold("RSP status")}`);
2336
- console.log();
2337
- if (focusedSet.size > 0) {
2338
- console.log(` ${pc.cyan("Focused:")} ${[...focusedSet].join(", ")}`);
2339
- console.log();
2340
- } else {
2341
- console.log(` ${pc.dim("No focused change.")}`);
2342
- for (const action of nextActions)
2343
- console.log(` ${pc.dim(action)}`);
2344
- console.log();
2345
- }
2346
- if (allNames.length === 0) {
2347
- console.log(` ${pc.dim("No changes found.")} Run: rsp create <name>
2348
- `);
2349
- return statusResult;
2350
- }
2351
- if (filteredRecords.length === 0) {
2352
- console.log(` ${pc.dim("No changes match the current filters.")}
2353
- `);
2354
- return statusResult;
2355
- }
2356
- const pad = (s, w) => s.padEnd(w);
2357
- console.log(` ${pad("Change", COL_CHANGE)} ${pad("Kind", COL_KIND)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)}`);
2358
- console.log(` ${"\u2500".repeat(COL_CHANGE)} ${"\u2500".repeat(COL_KIND)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)}`);
2359
- for (const r of filteredRecords) {
2360
- const marker = r.isFocused ? pc.cyan("*") : " ";
2361
- const namePadded = r.name.padEnd(COL_CHANGE - 1);
2362
- const kindPadded = r.kind.padEnd(COL_KIND);
2363
- const agePadded = r.age.padEnd(COL_AGE);
2364
- const progressPadded = r.progress.padEnd(COL_PROGRESS);
2365
- const nameDisplay = r.isBlocked ? pc.yellow(namePadded) : namePadded;
2366
- const ageDisplay = r.isFocused ? pc.cyan(agePadded) : agePadded;
2367
- console.log(` ${marker}${nameDisplay} ${kindPadded} ${ageDisplay} ${progressPadded}`);
2368
- }
2369
- console.log();
2370
- console.log(` ${pc.bold("Summary:")} ${statusResult.summary.total} change(s), ${statusResult.summary.focused} focused, ${pc.yellow(String(statusResult.summary.blocked))} blocked`);
2371
- console.log();
2372
- printArchiveTrend(statusResult.archiveTrend);
2373
- if (statusResult.summary.blocked > 0) {
2374
- const blocked = filteredRecords.filter((r) => r.isBlocked);
2375
- console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
2376
- console.log();
2377
- }
2378
- return statusResult;
2379
2621
  }
2380
- function buildStatusNextActions(focusedCount, openChanges, filtered) {
2622
+ function buildStatusNextActions(focusedCount, records, groups, filtered) {
2623
+ const openChanges = records.map((record) => record.name).sort();
2381
2624
  if (focusedCount > 0)
2382
2625
  return [];
2626
+ const readyGroup = groups.find((group) => group.readyToClose);
2627
+ if (readyGroup)
2628
+ return [`Run: rsp group close ${readyGroup.name}`];
2629
+ if (openChanges.length === 0 && groups.length > 0)
2630
+ return [`Review Group Brief: ${groups[0].path}`];
2383
2631
  if (openChanges.length === 0)
2384
2632
  return ["Run: rsp create <name>"];
2385
2633
  if (filtered) {
@@ -2391,38 +2639,165 @@ function buildStatusNextActions(focusedCount, openChanges, filtered) {
2391
2639
  }
2392
2640
  const firstChanges = openChanges.slice(0, 3).join(", ");
2393
2641
  const extra = openChanges.length > 3 ? ` (+${openChanges.length - 3} more)` : "";
2642
+ const recommendation = recommendOpenChange(records, groups);
2394
2643
  return [
2395
2644
  `Open changes: ${firstChanges}${extra}`,
2396
- `Run: rsp focus ${openChanges[0]}`,
2645
+ recommendation ? `Run: rsp focus ${recommendation}` : "Resolve blockers before focusing an open change.",
2397
2646
  "Or run: rsp create <name>"
2398
2647
  ];
2399
2648
  }
2400
- async function readArchiveTrend(runtime, runOptions) {
2401
- const indexPath = join16(ARCHIVES_DIR, "INDEX.md");
2402
- if (!existsSync16(indexPath))
2403
- return [];
2404
- try {
2405
- const content = await readFile13(indexPath, "utf-8");
2406
- const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
2407
- const counts = {};
2408
- let match;
2409
- while ((match = dateRe.exec(content)) !== null) {
2410
- const month = match[1];
2411
- counts[month] = (counts[month] || 0) + 1;
2412
- }
2413
- const months = Object.keys(counts).sort();
2414
- if (months.length === 0)
2415
- return [];
2416
- return months.map((month) => ({ month, count: counts[month] }));
2417
- } catch (error) {
2418
- recordRuntimeDiagnostic(runtime, {
2419
- code: "archive_index_read_failed",
2420
- operation: "readFile",
2421
- path: indexPath,
2422
- message: toErrorMessage(error)
2423
- }, Boolean(runOptions.verbose) && !runOptions.json);
2424
- return [];
2649
+ function recommendOpenChange(records, groups) {
2650
+ const recordsByName = new Map(records.map((record) => [record.name, record]));
2651
+ const candidates = records.filter((record) => !record.name.includes("/") && !record.isBlocked).map((record) => ({ name: record.name, owner: record.name }));
2652
+ for (const group of groups) {
2653
+ if (group.blockers)
2654
+ continue;
2655
+ const slice = group.slices.find((candidate) => {
2656
+ const record = recordsByName.get(candidate.name);
2657
+ return candidate.state === "open" && record !== void 0 && !record.isBlocked;
2658
+ });
2659
+ if (slice)
2660
+ candidates.push({ name: slice.name, owner: group.name });
2661
+ }
2662
+ return candidates.sort((left, right) => left.owner.localeCompare(right.owner) || left.name.localeCompare(right.name))[0]?.name ?? null;
2663
+ }
2664
+ function filterDependencyPlan(plan, names) {
2665
+ const included = new Set(names);
2666
+ let changed = true;
2667
+ while (changed) {
2668
+ changed = false;
2669
+ for (const edge of plan.edges) {
2670
+ if (included.has(edge.change) && !included.has(edge.requires)) {
2671
+ included.add(edge.requires);
2672
+ changed = true;
2673
+ }
2674
+ }
2675
+ }
2676
+ const waves = plan.waves.map((wave) => wave.filter((name) => included.has(name))).filter((wave) => wave.length > 0);
2677
+ return {
2678
+ nodes: plan.nodes.filter((node) => included.has(node.name)).map((node) => ({ ...node, selection: names.has(node.name) ? "selected" : "prerequisite" })),
2679
+ ready: waves[0] ?? [],
2680
+ edges: plan.edges.filter((edge) => included.has(edge.change)),
2681
+ blocked: plan.blocked.filter((blocker) => included.has(blocker.change)),
2682
+ waves
2683
+ };
2684
+ }
2685
+
2686
+ // src/status/plain.ts
2687
+ var COL_CHANGE = 32;
2688
+ var COL_KIND = 10;
2689
+ var COL_AGE = 7;
2690
+ var COL_PROGRESS = 10;
2691
+ function printStatusRuntimeDiagnostics(runtime) {
2692
+ for (const diagnostic of runtime)
2693
+ console.error(` ${pc.dim(`[verbose] ${diagnostic.operation} ${diagnostic.path}: ${diagnostic.message}`)}`);
2694
+ }
2695
+ function printStatusPlain(view) {
2696
+ console.log();
2697
+ console.log(` ${pc.bold("RSP status")}`);
2698
+ console.log();
2699
+ for (const diagnostic of view.diagnostics) {
2700
+ const label = diagnostic.change ?? diagnostic.path;
2701
+ console.log(` ${pc.red("\u2717")} ${label ? `${label} \u2014 ` : ""}${diagnostic.message}`);
2702
+ }
2703
+ if (view.diagnostics.length > 0)
2704
+ console.log();
2705
+ if (view.focused.length > 0) {
2706
+ console.log(` ${pc.cyan("Focused:")} ${view.focused.join(", ")}`);
2707
+ console.log();
2708
+ } else {
2709
+ console.log(` ${pc.dim("No focused change.")}`);
2710
+ for (const action of view.nextActions)
2711
+ console.log(` ${pc.dim(action)}`);
2712
+ console.log();
2713
+ }
2714
+ printChangeGroups(view.groups);
2715
+ printDependencyPlan(view.plan, view.records);
2716
+ if (!view.hasExecutableChanges) {
2717
+ if (view.ok)
2718
+ console.log(` ${pc.dim("No executable changes found.")} ${view.groups.length === 0 ? "Run: rsp create <name>" : "Review the Change Group guidance above."}
2719
+ `);
2720
+ else
2721
+ console.log(` ${pc.dim("No executable changes can be shown until the work-tree issues are resolved.")}
2722
+ `);
2723
+ return;
2724
+ }
2725
+ if (view.records.length === 0) {
2726
+ console.log(` ${pc.dim("No changes match the current filters.")}
2727
+ `);
2728
+ return;
2729
+ }
2730
+ if ((process.stdout.columns ?? 100) < 88 || view.records.some((record) => record.output.name.length >= COL_CHANGE)) {
2731
+ for (const { output: record, progressKnown } of view.records) {
2732
+ const state = [record.isFocused ? "focused" : "open", record.isBlocked ? "blocked" : null].filter(Boolean).join(" \xB7 ");
2733
+ console.log(` ${pc.bold("Change:")} ${record.isBlocked ? pc.yellow(record.name) : record.name}`);
2734
+ console.log(` ${state} \xB7 ${record.kind} \xB7 age ${record.ageDays ?? "\u2014"}d \xB7 progress ${progressKnown ? `${record.progress.done}/${record.progress.total}` : "\u2014"}`);
2735
+ }
2736
+ } else {
2737
+ const pad = (value, width) => value.padEnd(width);
2738
+ console.log(` ${pad("Change", COL_CHANGE)} ${pad("Kind", COL_KIND)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)}`);
2739
+ console.log(` ${"\u2500".repeat(COL_CHANGE)} ${"\u2500".repeat(COL_KIND)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)}`);
2740
+ for (const { output: record, progressKnown } of view.records) {
2741
+ const marker = record.isFocused ? pc.cyan("*") : " ";
2742
+ const namePadded = record.name.padEnd(COL_CHANGE - 1);
2743
+ const kindPadded = record.kind.padEnd(COL_KIND);
2744
+ const agePadded = (record.ageDays === null ? "\u2014" : String(record.ageDays)).padEnd(COL_AGE);
2745
+ const progressPadded = (progressKnown ? `${record.progress.done}/${record.progress.total}` : "\u2014").padEnd(COL_PROGRESS);
2746
+ const nameDisplay = record.isBlocked ? pc.yellow(namePadded) : namePadded;
2747
+ const ageDisplay = record.isFocused ? pc.cyan(agePadded) : agePadded;
2748
+ console.log(` ${marker}${nameDisplay} ${kindPadded} ${ageDisplay} ${progressPadded}`);
2749
+ }
2750
+ }
2751
+ console.log();
2752
+ console.log(` ${pc.bold("Summary:")} ${view.summary.total} change(s), ${view.summary.focused} focused, ${pc.yellow(String(view.summary.blocked))} blocked`);
2753
+ console.log();
2754
+ printArchiveTrend(view.archiveTrend);
2755
+ }
2756
+ function printChangeGroups(groups) {
2757
+ if (groups.length === 0)
2758
+ return;
2759
+ console.log(` ${pc.bold("Change Groups")}`);
2760
+ for (const group of groups) {
2761
+ const archived = group.slices.filter((slice) => slice.state === "archived").length;
2762
+ const readiness = group.readyToClose ? pc.green("ready to close") : pc.dim("not ready");
2763
+ const blocked = group.blockers ? ` ${pc.yellow("blocked")}` : "";
2764
+ console.log(` ${group.name}: ${archived}/${group.slices.length} archived, completion ${group.completion.done}/${group.completion.total}, ${readiness}${blocked}`);
2425
2765
  }
2766
+ console.log();
2767
+ }
2768
+ function printDependencyPlan(plan, records) {
2769
+ console.log(` ${pc.bold("Dependency graph")}`);
2770
+ console.log(` ${pc.dim("(parent requires children)")}`);
2771
+ const roots = projectDependencyForest(plan);
2772
+ const detailColumn = Math.max(40, ...plan.nodes.map((node) => node.name.length + 8));
2773
+ const renderNode = (node, prefix, last) => {
2774
+ const connector = last === null ? "" : last ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2775
+ const symbol = node.selection === "selected" ? "\u25CE" : node.state === "ready" ? "\u25CF" : node.state === "archived" ? "\u2713" : node.state === "missing" || node.state === "blocked" ? "!" : "\u25CB";
2776
+ const identity = `${prefix}${connector}${symbol} ${node.name}`.padEnd(detailColumn);
2777
+ const record = records.find((candidate) => candidate.output.name === node.name);
2778
+ const ownership = node.selection === "prerequisite" ? "prerequisite" : record?.output.isFocused ? "focused" : "open";
2779
+ const state = node.state === "archived" ? "resolved" : node.state;
2780
+ console.log(` ${identity} ${ownership} \xB7 ${state}${node.shared ? " \xB7 \u21A9 shared" : ""}${node.reason ? ` \u2014 ${node.reason}` : ""}`);
2781
+ const childPrefix = prefix + (last === null ? "" : last ? " " : "\u2502 ");
2782
+ for (const [index, child] of node.children.entries())
2783
+ renderNode(child, childPrefix, index === node.children.length - 1);
2784
+ };
2785
+ if (roots.length === 0)
2786
+ console.log(` ${pc.dim("none")}`);
2787
+ else
2788
+ roots.forEach((node) => renderNode(node, "", null));
2789
+ const external = plan.blocked.filter((blocker) => blocker.external).map((blocker) => blocker.change);
2790
+ if (external.length > 0)
2791
+ console.log(` ${pc.dim("External blockers:")} ${external.join(", ")}`);
2792
+ const implementationReady = plan.ready.filter((name) => {
2793
+ const record = records.find((candidate) => candidate.output.name === name);
2794
+ return !record || record.readiness.incompleteTasks > 0 || record.readiness.incompleteVerify > 0;
2795
+ });
2796
+ const completedReady = plan.ready.filter((name) => !implementationReady.includes(name));
2797
+ const next = implementationReady.length > 0 ? implementationReady.join(", ") : completedReady.length > 0 ? `review for durable update/archive: ${completedReady.join(", ")}` : pc.dim("none");
2798
+ console.log(` ${pc.dim("Next action:")} ${next}`);
2799
+ console.log(` ${pc.dim("Legend:")} \u25CE focused/open \u25CF ready \u25CB waiting \u2713 resolved prerequisite ! blocked`);
2800
+ console.log();
2426
2801
  }
2427
2802
  function printArchiveTrend(trend) {
2428
2803
  if (trend.length === 0)
@@ -2433,7 +2808,102 @@ function printArchiveTrend(trend) {
2433
2808
  console.log();
2434
2809
  }
2435
2810
 
2811
+ // src/status/v3-json.ts
2812
+ function toStatusJson(view) {
2813
+ return {
2814
+ command: "status",
2815
+ ok: view.ok,
2816
+ filters: view.query,
2817
+ focused: view.focused,
2818
+ records: view.records.map((record) => record.output),
2819
+ groups: view.groups,
2820
+ plan: view.plan,
2821
+ summary: view.summary,
2822
+ nextActions: view.nextActions,
2823
+ archiveTrend: view.archiveTrend,
2824
+ diagnostics: view.diagnostics,
2825
+ runtime: view.runtime
2826
+ };
2827
+ }
2828
+ function toStatusJsonError(error, options) {
2829
+ return {
2830
+ command: "status",
2831
+ ok: false,
2832
+ filters: {
2833
+ focused: options.focused,
2834
+ blocked: options.blocked,
2835
+ stale: null
2836
+ },
2837
+ focused: [],
2838
+ records: [],
2839
+ groups: [],
2840
+ plan: {
2841
+ nodes: [],
2842
+ ready: [],
2843
+ edges: [],
2844
+ blocked: [],
2845
+ waves: []
2846
+ },
2847
+ summary: {
2848
+ total: 0,
2849
+ focused: 0,
2850
+ blocked: 0
2851
+ },
2852
+ archiveTrend: [],
2853
+ nextActions: [],
2854
+ diagnostics: [],
2855
+ runtime: [],
2856
+ error
2857
+ };
2858
+ }
2859
+
2860
+ // src/commands/status.ts
2861
+ async function showStatus(options = {}, runOptions = {}) {
2862
+ const snapshot = await inspectProjectStatus();
2863
+ if (runOptions.verbose && !runOptions.json)
2864
+ printStatusRuntimeDiagnostics(snapshot.runtime);
2865
+ const view = deriveStatusView(snapshot, options);
2866
+ const result = toStatusJson(view);
2867
+ if (runOptions.json)
2868
+ emitJson(result, runOptions);
2869
+ else
2870
+ printStatusPlain(view);
2871
+ return result;
2872
+ }
2873
+
2874
+ // src/tui/route.ts
2875
+ function isInteractiveTerminal(environment) {
2876
+ return environment.stdinTty && environment.stdoutTty && environment.term !== "dumb";
2877
+ }
2878
+ function shouldAutoLaunchUi(args, environment) {
2879
+ return args.length === 0 && isInteractiveTerminal(environment) && (environment.ci === void 0 || environment.ci === "false");
2880
+ }
2881
+ function validateUiArgs(args) {
2882
+ let lang = "auto";
2883
+ for (let index = 0; index < args.length; index += 1) {
2884
+ const argument = args[index];
2885
+ if (argument !== "--lang")
2886
+ throw new Error(`Unknown rsp ui option: ${argument}`);
2887
+ const value = args[index + 1];
2888
+ if (value !== "auto" && value !== "en" && value !== "zh-CN")
2889
+ throw new Error("--lang must be auto, en, or zh-CN");
2890
+ lang = value;
2891
+ index += 1;
2892
+ }
2893
+ return { lang };
2894
+ }
2895
+
2436
2896
  // src/cli.ts
2897
+ var COMPACT_JSON_COMMANDS = /* @__PURE__ */ new Set(["status", "show", "ready", "check", "doctor", "history"]);
2898
+ function validateCompactInvocation(rawArgs) {
2899
+ if (!rawArgs.includes("--compact"))
2900
+ return;
2901
+ const command = rawArgs[0];
2902
+ if (!command || !COMPACT_JSON_COMMANDS.has(command))
2903
+ throw new Error(`--compact is unsupported for rsp${command ? ` ${command}` : ""}`);
2904
+ if (!rawArgs.includes("--json"))
2905
+ throw new Error("--compact requires --json");
2906
+ }
2437
2907
  var initCommand = defineCommand({
2438
2908
  meta: {
2439
2909
  name: "init",
@@ -2445,11 +2915,6 @@ var initCommand = defineCommand({
2445
2915
  description: "How to handle AGENTS.md output: managed or print",
2446
2916
  default: "managed"
2447
2917
  },
2448
- "with-project-rules": {
2449
- type: "boolean",
2450
- description: "Create .rsp/rules/project-rules.md",
2451
- default: false
2452
- },
2453
2918
  "with-project-setup": {
2454
2919
  type: "boolean",
2455
2920
  description: "Create .rsp/changes/project-setup.md and focus it",
@@ -2459,7 +2924,6 @@ var initCommand = defineCommand({
2459
2924
  async run({ args }) {
2460
2925
  await initProject({
2461
2926
  agentsMode: args["agents-mode"] === "print" ? "print" : "managed",
2462
- withProjectRules: Boolean(args["with-project-rules"]),
2463
2927
  withProjectSetup: Boolean(args["with-project-setup"])
2464
2928
  });
2465
2929
  }
@@ -2490,20 +2954,46 @@ var createCommand = defineCommand({
2490
2954
  await createChange(args.name, summary, args.kind, { lite: Boolean(args.lite) });
2491
2955
  }
2492
2956
  });
2493
- var addRulesCommand = defineCommand({
2957
+ var groupCreateCommand = defineCommand({
2494
2958
  meta: {
2495
- name: "rules",
2496
- description: "Create .rsp/rules/<name>.md"
2959
+ name: "create",
2960
+ description: "Create an unfocused .rsp/changes/<group>/00-brief.md"
2497
2961
  },
2498
2962
  args: {
2499
2963
  name: {
2500
2964
  type: "positional",
2501
- description: "Rules file name",
2965
+ description: "Change Group name",
2502
2966
  required: true
2503
2967
  }
2504
2968
  },
2505
2969
  async run({ args }) {
2506
- await addRules(args.name);
2970
+ const goal = Array.isArray(args._) && args._.length > 1 ? args._.slice(1).join(" ") : "";
2971
+ await createChangeGroup(args.name, goal);
2972
+ }
2973
+ });
2974
+ var groupCommand = defineCommand({
2975
+ meta: {
2976
+ name: "group",
2977
+ description: "Create and close shallow Change Groups"
2978
+ },
2979
+ subCommands: {
2980
+ create: groupCreateCommand,
2981
+ close: defineCommand({
2982
+ meta: {
2983
+ name: "close",
2984
+ description: "Archive a completed Group Brief after all child Changes are archived"
2985
+ },
2986
+ args: {
2987
+ name: {
2988
+ type: "positional",
2989
+ description: "Change Group name",
2990
+ required: true
2991
+ }
2992
+ },
2993
+ async run({ args }) {
2994
+ await closeChangeGroup(args.name);
2995
+ }
2996
+ })
2507
2997
  }
2508
2998
  });
2509
2999
  var addSpecCommand = defineCommand({
@@ -2525,10 +3015,9 @@ var addSpecCommand = defineCommand({
2525
3015
  var addCommand = defineCommand({
2526
3016
  meta: {
2527
3017
  name: "add",
2528
- description: "Add optional rules or spec files"
3018
+ description: "Add an optional durable spec file"
2529
3019
  },
2530
3020
  subCommands: {
2531
- rules: addRulesCommand,
2532
3021
  spec: addSpecCommand
2533
3022
  }
2534
3023
  });
@@ -2610,6 +3099,11 @@ var statusCommand = defineCommand({
2610
3099
  description: "Print machine-readable JSON output",
2611
3100
  default: false
2612
3101
  },
3102
+ compact: {
3103
+ type: "boolean",
3104
+ description: "Print JSON without indentation (requires --json)",
3105
+ default: false
3106
+ },
2613
3107
  verbose: {
2614
3108
  type: "boolean",
2615
3109
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2618,28 +3112,31 @@ var statusCommand = defineCommand({
2618
3112
  },
2619
3113
  async run({ args }) {
2620
3114
  const stale = args.stale === void 0 ? void 0 : Number(args.stale);
2621
- if (args.stale !== void 0 && (Number.isFinite(stale) === false || Number.isInteger(stale) === false || stale < 0)) {
3115
+ if (stale !== void 0 && (Number.isFinite(stale) === false || Number.isInteger(stale) === false || stale < 0)) {
2622
3116
  if (args.json) {
2623
- emitStatusJsonError({
3117
+ emitJson(toStatusJsonError({
2624
3118
  code: "invalid_stale_filter",
2625
3119
  message: "--stale must be a non-negative integer number of days"
2626
3120
  }, {
2627
3121
  focused: Boolean(args.focused),
2628
3122
  blocked: Boolean(args.blocked)
2629
- });
3123
+ }), { compact: Boolean(args.compact) });
2630
3124
  } else {
2631
3125
  console.error(` Error: --stale must be a non-negative integer number of days`);
2632
3126
  }
2633
3127
  process.exit(1);
2634
3128
  }
2635
- await showStatus({
3129
+ const result = await showStatus({
2636
3130
  focused: Boolean(args.focused),
2637
3131
  blocked: Boolean(args.blocked),
2638
3132
  stale
2639
3133
  }, {
2640
3134
  json: Boolean(args.json),
3135
+ compact: Boolean(args.compact),
2641
3136
  verbose: Boolean(args.verbose)
2642
3137
  });
3138
+ if (!result.ok)
3139
+ process.exit(1);
2643
3140
  }
2644
3141
  });
2645
3142
  var checkCommand = defineCommand({
@@ -2658,6 +3155,11 @@ var checkCommand = defineCommand({
2658
3155
  description: "Print machine-readable JSON output",
2659
3156
  default: false
2660
3157
  },
3158
+ compact: {
3159
+ type: "boolean",
3160
+ description: "Print JSON without indentation (requires --json)",
3161
+ default: false
3162
+ },
2661
3163
  verbose: {
2662
3164
  type: "boolean",
2663
3165
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2668,6 +3170,7 @@ var checkCommand = defineCommand({
2668
3170
  const result = await runCheck({
2669
3171
  focused: Boolean(args.focused),
2670
3172
  json: Boolean(args.json),
3173
+ compact: Boolean(args.compact),
2671
3174
  verbose: Boolean(args.verbose)
2672
3175
  });
2673
3176
  if (!result.ok)
@@ -2677,10 +3180,46 @@ var checkCommand = defineCommand({
2677
3180
  var updateCommand = defineCommand({
2678
3181
  meta: {
2679
3182
  name: "update",
2680
- description: "Refresh RSP project structure after upgrade (rules, AGENTS, indices)"
3183
+ description: "Refresh RSP project structure after upgrade (fallback protocol, AGENTS, indices)"
2681
3184
  },
2682
3185
  async run() {
2683
- await updateProject();
3186
+ const result = await updateProject();
3187
+ if (!result.migration.inspectionComplete)
3188
+ process.exit(1);
3189
+ }
3190
+ });
3191
+ var skillsInstallCommand = defineCommand({
3192
+ meta: {
3193
+ name: "install",
3194
+ description: "Install this package's bundled Skills into .agents/skills"
3195
+ },
3196
+ args: {
3197
+ "dry-run": {
3198
+ type: "boolean",
3199
+ description: "Preflight all packaged Skills without changing files",
3200
+ default: false
3201
+ },
3202
+ "force": {
3203
+ type: "boolean",
3204
+ description: "Replace divergent package-owned Skill directories",
3205
+ default: false
3206
+ }
3207
+ },
3208
+ async run({ args }) {
3209
+ const result = await installPackagedSkills({
3210
+ dryRun: Boolean(args["dry-run"]),
3211
+ force: Boolean(args.force)
3212
+ });
3213
+ printSkillInstallResult(result, Boolean(args["dry-run"]));
3214
+ }
3215
+ });
3216
+ var skillsCommand = defineCommand({
3217
+ meta: {
3218
+ name: "skills",
3219
+ description: "Manage package-bundled project Skills"
3220
+ },
3221
+ subCommands: {
3222
+ install: skillsInstallCommand
2684
3223
  }
2685
3224
  });
2686
3225
  var doctorCommand = defineCommand({
@@ -2694,6 +3233,11 @@ var doctorCommand = defineCommand({
2694
3233
  description: "Print machine-readable JSON output",
2695
3234
  default: false
2696
3235
  },
3236
+ compact: {
3237
+ type: "boolean",
3238
+ description: "Print JSON without indentation (requires --json)",
3239
+ default: false
3240
+ },
2697
3241
  verbose: {
2698
3242
  type: "boolean",
2699
3243
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2706,7 +3250,7 @@ var doctorCommand = defineCommand({
2706
3250
  }
2707
3251
  },
2708
3252
  async run({ args }) {
2709
- const result = await runDoctor({ json: Boolean(args.json), verbose: Boolean(args.verbose), fix: Boolean(args.fix) });
3253
+ const result = await runDoctor({ json: Boolean(args.json), compact: Boolean(args.compact), verbose: Boolean(args.verbose), fix: Boolean(args.fix) });
2710
3254
  if (!result.ok)
2711
3255
  process.exit(1);
2712
3256
  }
@@ -2727,6 +3271,11 @@ var readyCommand = defineCommand({
2727
3271
  description: "Print machine-readable JSON output",
2728
3272
  default: false
2729
3273
  },
3274
+ compact: {
3275
+ type: "boolean",
3276
+ description: "Print JSON without indentation (requires --json)",
3277
+ default: false
3278
+ },
2730
3279
  verbose: {
2731
3280
  type: "boolean",
2732
3281
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2734,7 +3283,7 @@ var readyCommand = defineCommand({
2734
3283
  }
2735
3284
  },
2736
3285
  async run({ args }) {
2737
- await showReady(args.name, { json: Boolean(args.json), verbose: Boolean(args.verbose) });
3286
+ await showReady(args.name, { json: Boolean(args.json), compact: Boolean(args.compact), verbose: Boolean(args.verbose) });
2738
3287
  }
2739
3288
  });
2740
3289
  var showCommand = defineCommand({
@@ -2758,6 +3307,11 @@ var showCommand = defineCommand({
2758
3307
  description: "Print machine-readable JSON output",
2759
3308
  default: false
2760
3309
  },
3310
+ compact: {
3311
+ type: "boolean",
3312
+ description: "Print JSON without indentation (requires --json)",
3313
+ default: false
3314
+ },
2761
3315
  verbose: {
2762
3316
  type: "boolean",
2763
3317
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2768,43 +3322,131 @@ var showCommand = defineCommand({
2768
3322
  await showChange(args.name, {
2769
3323
  focused: Boolean(args.focused),
2770
3324
  json: Boolean(args.json),
3325
+ compact: Boolean(args.compact),
2771
3326
  verbose: Boolean(args.verbose)
2772
3327
  });
2773
3328
  }
2774
3329
  });
3330
+ var historyCommand = defineCommand({
3331
+ meta: {
3332
+ name: "history",
3333
+ description: "Query bounded archived Change summaries or one exact archived WorkRef"
3334
+ },
3335
+ args: {
3336
+ name: {
3337
+ type: "positional",
3338
+ description: "Optional exact archived Change WorkRef for detail",
3339
+ required: false
3340
+ },
3341
+ limit: {
3342
+ type: "string",
3343
+ description: "Maximum list records from 1 through 100 (default: 20)"
3344
+ },
3345
+ since: {
3346
+ type: "string",
3347
+ description: "Include archives on or after YYYY-MM-DD"
3348
+ },
3349
+ until: {
3350
+ type: "string",
3351
+ description: "Include archives on or before YYYY-MM-DD"
3352
+ },
3353
+ kind: {
3354
+ type: "string",
3355
+ description: "Match one exact historical Change kind"
3356
+ },
3357
+ group: {
3358
+ type: "string",
3359
+ description: "Match one exact Change Group"
3360
+ },
3361
+ json: {
3362
+ type: "boolean",
3363
+ description: "Print machine-readable JSON output",
3364
+ default: false
3365
+ },
3366
+ compact: {
3367
+ type: "boolean",
3368
+ description: "Print JSON without indentation (requires --json)",
3369
+ default: false
3370
+ }
3371
+ },
3372
+ async run({ args }) {
3373
+ const result = await showHistory({
3374
+ workRef: args.name,
3375
+ limit: args.limit,
3376
+ since: args.since,
3377
+ until: args.until,
3378
+ kind: args.kind,
3379
+ group: args.group,
3380
+ positionalCount: args._?.length ?? (args.name ? 1 : 0)
3381
+ }, { json: Boolean(args.json), compact: Boolean(args.compact) });
3382
+ if (!result.ok)
3383
+ process.exitCode = 1;
3384
+ }
3385
+ });
2775
3386
  async function runCli(rawArgs = process.argv.slice(2)) {
3387
+ const terminalEnvironment = {
3388
+ stdinTty: Boolean(process.stdin.isTTY),
3389
+ stdoutTty: Boolean(process.stdout.isTTY),
3390
+ term: process.env.TERM,
3391
+ ci: process.env.CI
3392
+ };
3393
+ const explicitUi = rawArgs[0] === "ui";
3394
+ if (explicitUi || shouldAutoLaunchUi(rawArgs, terminalEnvironment)) {
3395
+ const { lang } = validateUiArgs(explicitUi ? rawArgs.slice(1) : []);
3396
+ if (!isInteractiveTerminal(terminalEnvironment)) {
3397
+ if (explicitUi)
3398
+ throw new Error("rsp ui requires an interactive terminal; use rsp status or rsp status --json instead");
3399
+ } else {
3400
+ const { resolveUiLocale } = await import("./locale-HT44UIUP.mjs");
3401
+ const locale = resolveUiLocale(lang, process.env.RSP_UI_LANG, process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG ?? Intl.DateTimeFormat().resolvedOptions().locale);
3402
+ const { runTui } = await import("./entry-LAPR3TNI.mjs");
3403
+ const exitCode = await runTui(locale);
3404
+ if (exitCode !== 0)
3405
+ process.exitCode = exitCode;
3406
+ return;
3407
+ }
3408
+ }
3409
+ validateCompactInvocation(rawArgs);
2776
3410
  const version = await getVersion();
2777
3411
  const main = defineCommand({
2778
3412
  meta: {
2779
3413
  name: "rsp",
2780
3414
  version,
2781
- description: "RSP (Rules, Specs, Plans) workflow for lightweight AI-assisted change management"
3415
+ description: "RSP (Reliable Software Practice), a repository-native engineering workflow for humans and AI agents"
2782
3416
  },
2783
3417
  subCommands: {
2784
3418
  init: initCommand,
2785
3419
  add: addCommand,
2786
3420
  create: createCommand,
3421
+ group: groupCommand,
2787
3422
  focus: focusCommand,
2788
3423
  unfocus: unfocusCommand,
2789
3424
  archive: archiveCommand,
2790
3425
  ready: readyCommand,
2791
3426
  show: showCommand,
3427
+ history: historyCommand,
2792
3428
  status: statusCommand,
2793
3429
  check: checkCommand,
2794
3430
  update: updateCommand,
2795
- doctor: doctorCommand
3431
+ doctor: doctorCommand,
3432
+ skills: skillsCommand
2796
3433
  }
2797
3434
  });
2798
3435
  await runMain(main, { rawArgs });
2799
3436
  }
2800
- var isMain = process.argv[1] && fileURLToPath2(import.meta.url) === resolve(process.argv[1]);
2801
- if (isMain) {
2802
- runCli().catch((err) => {
2803
- const message = err instanceof Error ? err.message : String(err);
3437
+ async function runCliMain(rawArgs = process.argv.slice(2)) {
3438
+ try {
3439
+ await runCli(rawArgs);
3440
+ } catch (error) {
3441
+ const message = error instanceof Error ? error.message : String(error);
2804
3442
  console.error(` Error: ${message}`);
2805
- process.exit(1);
2806
- });
3443
+ process.exitCode = 1;
3444
+ }
2807
3445
  }
3446
+ var isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
3447
+ if (isMain)
3448
+ await runCliMain();
2808
3449
  export {
2809
- runCli
3450
+ runCli,
3451
+ runCliMain
2810
3452
  };