@oevery/rsp 2.0.3 → 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,609 +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) {
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
- const template = getChangeTemplateByKind(kind);
188
- return `---
189
- kind: "${frontmatterKind}"
190
- ---
191
-
192
- # Change: ${name}
193
-
194
- ## Proposal
195
- - Summary: ${proposalSummary}
196
- - Why:
197
- - ${template.why}
198
- - Scope:
199
- - ${template.scope}
200
- - Non-goals:
201
- - ${template.nonGoals}
202
-
203
- ## Spec
204
- ${template.specSection}
205
-
206
- ### Acceptance
207
- ${template.acceptanceSection}
208
-
209
- ## Design
210
- - Approach:
211
- - ${template.approach}
212
- - Affected areas:
213
- - ${template.affectedArea1}
214
- - ${template.affectedArea2}
215
- - Constraints:
216
- - ${template.constraints}
217
-
218
- ## Tasks
219
- - [ ] Finalize the proposal, spec, and design details for this change
220
- - [ ] ${template.task}
221
- - [ ] Verify the result and update any required durable docs or rules
222
-
223
- ## Verify
224
- - Automated:
225
- - [ ] ${template.automatedVerify}
226
- - Manual:
227
- - [ ] ${template.manualVerify}
228
- - Durable updates:
229
- - [ ] Decide whether this change produced durable knowledge that belongs in \`.rsp/specs/\` or \`.rsp/rules/\`
230
- - [ ] 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
231
-
232
- ## Blockers
233
- - none
234
- `;
235
- }
236
- function getChangeTemplateByKind(kind) {
237
- switch (kind) {
238
- case "feature":
239
- return {
240
- why: "<what user need or capability gap this addresses>",
241
- scope: "<what new behavior or capability will be delivered>",
242
- nonGoals: "<what related capabilities are explicitly out of scope>",
243
- specSection: "### ADDED\n- Requirement: <new user-facing behavior>\n - <verifiable requirement for the new capability>",
244
- acceptanceSection: "#### Scenario: user exercises the new capability\n- GIVEN <context>\n- WHEN <user action>\n- THEN <expected new behavior>",
245
- approach: "<how the new capability will be implemented>",
246
- affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
247
- affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
248
- constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
249
- task: "<implement the new behavior and its acceptance scenario in the affected areas>",
250
- automatedVerify: "<exact test, lint, build, or check command for the new behavior>",
251
- manualVerify: "<exact end-to-end user scenario to validate the new capability>"
252
- };
253
- case "fix":
254
- return {
255
- why: "<what current behavior is wrong or broken>",
256
- scope: "<what specific defect is being corrected>",
257
- nonGoals: "<what related but separate issues are out of scope>",
258
- specSection: "### MODIFIED\n- Requirement: correct behavior\n - <expected correct behavior after the fix>",
259
- 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",
260
- approach: "<root cause analysis and fix strategy>",
261
- affectedArea1: "<concrete file path, directory, module, or subsystem containing the defect>",
262
- affectedArea2: "<secondary path, dependency, or test file if needed>",
263
- constraints: "<regression, backward-compatibility, safety, or scope constraint that must hold>",
264
- task: "<identify the root cause and apply the minimal fix>",
265
- automatedVerify: "<exact regression test, lint, build, or check command that proves the fix>",
266
- manualVerify: "<exact steps to reproduce the original issue and confirm it no longer occurs>"
267
- };
268
- case "refactor":
269
- return {
270
- why: "<what maintainability, readability, or structural issue motivates this>",
271
- scope: "<what code areas are being restructured>",
272
- nonGoals: "<no behavior change is expected or intended>",
273
- specSection: "### MODIFIED\n- Requirement: internal structure improvement\n - <desired structural outcome without observable behavior change>",
274
- 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",
275
- approach: "<how the code will be restructured (rename, extract, move, etc.)>",
276
- affectedArea1: "<concrete file path, directory, module, or subsystem being refactored>",
277
- affectedArea2: "<secondary path, dependency, or test file if needed>",
278
- constraints: "<observable behavior must remain unchanged; compatibility, performance, and safety constraints must still hold>",
279
- task: "<restructure the code while keeping behavior identical>",
280
- automatedVerify: "<exact existing test, lint, build, or check command that must continue to pass>",
281
- manualVerify: "<exact scenario to spot-check that behavior is unchanged after the refactor>"
282
- };
283
- case "docs":
284
- return {
285
- why: "<why the documentation change matters>",
286
- scope: "<which docs, readers, or workflows will be updated>",
287
- nonGoals: "<what documentation behavior or audience is out of scope>",
288
- specSection: "### MODIFIED\n- Requirement: documentation accuracy\n - <what durable reader-facing behavior or explanation changes>",
289
- 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",
290
- approach: "<how the documentation will be updated and organized>",
291
- affectedArea1: "<concrete doc path, directory, module doc, or documentation surface 1>",
292
- affectedArea2: "<secondary doc path or reference file if needed>",
293
- constraints: "<durable wording, consistency, scope, or audience constraint that must hold>",
294
- task: "<update the target documentation and supporting references>",
295
- automatedVerify: "<exact markdown, docs, link, lint, build, or check command if applicable>",
296
- manualVerify: "<exact reader or operator scenario to confirm the updated guidance is accurate>"
297
- };
298
- case "research":
299
- return {
300
- why: "<what question or uncertainty this research resolves>",
301
- scope: "<what system area, option set, or hypothesis is being examined>",
302
- nonGoals: "<what implementation work is explicitly out of scope>",
303
- specSection: "### ADDED\n- Requirement: research outcome recording\n - <what finding, option, or decision must be captured clearly>",
304
- 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",
305
- approach: "<how the investigation will be performed and what evidence will be gathered>",
306
- affectedArea1: "<concrete file path, directory, module, subsystem, or source under investigation>",
307
- affectedArea2: "<secondary path, dependency, environment, or evidence source if needed>",
308
- constraints: "<time, evidence, safety, or scope constraint for the investigation>",
309
- task: "<gather evidence, record the recommendation or finding, and note any follow-up implementation work if needed>",
310
- automatedVerify: "<exact command, script, query, or check used to confirm the observed behavior>",
311
- manualVerify: "<exact review of the findings to confirm they answer the original question and identify follow-up work if needed>"
312
- };
313
- case "ops":
314
- return {
315
- why: "<why this operational or environment change matters>",
316
- scope: "<what environment, workflow, or operational path changes>",
317
- nonGoals: "<what product or feature behavior will not change>",
318
- specSection: "### MODIFIED\n- Requirement: operational behavior\n - <what reliable operational outcome should change or stay true>",
319
- 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",
320
- approach: "<how the operational change will be applied safely>",
321
- affectedArea1: "<concrete script, config path, workflow, environment path, or operational surface 1>",
322
- affectedArea2: "<secondary script, config path, dependency, or environment surface if needed>",
323
- constraints: "<rollback, safety, reliability, environment, or scope constraint that must hold>",
324
- task: "<apply and verify the operational change>",
325
- automatedVerify: "<exact operational validation, lint, build, deploy, or check command>",
326
- manualVerify: "<exact operator workflow or environment scenario to confirm the operational result>"
327
- };
328
- default:
329
- return {
330
- why: "<why this change matters>",
331
- scope: "<what this change will do>",
332
- nonGoals: "<what this change will not do>",
333
- specSection: "### ADDED\n- Requirement: <new or updated behavior>\n - <verifiable requirement>",
334
- acceptanceSection: "#### Scenario: <name>\n- GIVEN <context>\n- WHEN <action>\n- THEN <expected outcome>",
335
- approach: "<how the change will be implemented>",
336
- affectedArea1: "<concrete file path, directory, module, or subsystem 1>",
337
- affectedArea2: "<concrete file path, directory, module, or subsystem 2 if needed>",
338
- constraints: "<behavior, compatibility, performance, safety, or scope constraint that must hold>",
339
- task: "<implement the core change in the affected areas>",
340
- automatedVerify: "<exact test, lint, build, or check command>",
341
- manualVerify: "<exact end-to-end scenario to validate>"
342
- };
343
- }
344
- }
345
- function renderRspAgentsBlock() {
346
- return `${RSP_AGENTS_BEGIN}
347
- ## RSP Entry
348
-
349
- RSP keeps durable rules, specs, and current work under \`.rsp/\`.
350
- Treat AGENTS.md as navigation only; keep durable rules and design in \`.rsp/\`.
351
-
352
- Read in order:
353
- 1. .rsp/rules/rsp-rules.md
354
- 2. .rsp/focus.d/
355
- 3. matching .rsp/changes/*.md for the focused entries
356
- 4. .rsp/specs/design.md
357
- 5. .rsp/specs/INDEX.md
358
- 6. only the relevant additional .rsp/rules/*.md and .rsp/specs/*.md files
359
-
360
- If \`.rsp/focus.d/\` is empty, ask what to work on or suggest \`npx -y @oevery/rsp create <name>\`.
361
- If your agent supports Agent Skills, load \`rsp\` for setup, repair, and durable-decision tasks.
362
- ${RSP_AGENTS_END}`;
363
- }
364
- function upsertRspAgentsBlock(content) {
365
- const block = renderRspAgentsBlock();
366
- const managedRe = /<!-- rsp:begin -->[\s\S]*?<!-- rsp:end -->/;
367
- if (managedRe.test(content)) {
368
- const next2 = content.replace(managedRe, block);
369
- return { content: next2, changed: next2 !== content };
370
- }
371
- const trimmed = content.trimStart();
372
- const next = trimmed ? `${block}
373
-
374
- ${trimmed}` : `${block}
375
- `;
376
- return { content: next, changed: true };
377
- }
378
- function hasRspAgentsBlock(content) {
379
- return content.includes(RSP_AGENTS_BEGIN) && content.includes(RSP_AGENTS_END);
380
- }
381
- var CHANGE_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
382
- function isValidChangeName(name) {
383
- return CHANGE_NAME_RE.test(name);
384
- }
385
- function guardRspInitialized() {
386
- const rspRulesPath = join(RSP_DIR, "rules", "rsp-rules.md");
387
- const designPath = join(RSP_DIR, "specs", "design.md");
388
- if (!existsSync(rspRulesPath) || !existsSync(designPath)) {
389
- console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
390
- console.error(` ${pc.dim("Run: rsp init")}`);
391
- process.exit(1);
392
- }
393
- }
394
- async function getFocusedChangeNames(options = {}) {
395
- const names = /* @__PURE__ */ new Set();
396
- const focusDir = join(RSP_DIR, "focus.d");
397
- if (!existsSync(focusDir))
398
- return names;
399
- const entries = await walkFiles(focusDir, options);
400
- for (const entryPath of entries)
401
- names.add(normalizeLogicalPath(relative(focusDir, entryPath)));
402
- return names;
403
- }
404
- function getOpenCheckboxes(sectionText) {
405
- return sectionText.split("\n").map((line) => line.trim()).filter((line) => /^- \[[ /]\]/.test(line));
406
- }
407
- async function cleanupEmptyParentDirs(path, stopDir) {
408
- let parent = dirname(path);
409
- while (parent !== stopDir) {
410
- try {
411
- const remaining = await readdir(parent);
412
- if (remaining.length === 0)
413
- await rmdir(parent);
414
- else
415
- break;
416
- } catch {
417
- break;
418
- }
419
- parent = dirname(parent);
420
- }
421
- }
422
- async function detectProjectName() {
423
- try {
424
- if (existsSync("package.json")) {
425
- const raw = await readFile("package.json", "utf-8");
426
- const pkg = JSON.parse(raw);
427
- if (pkg.name && pkg.name !== "")
428
- return pkg.name;
429
- }
430
- } catch {
431
- }
432
- return basename(process.cwd());
433
- }
434
- function generateDesignContent(projectName) {
435
- return `# Project Design: ${projectName}
436
-
437
- ## Purpose
438
- - <what this project is responsible for>
439
- - <who or what it serves>
440
-
441
- ## Stable Facts
442
- - <durable fact that future agents or developers must know>
443
- - <key architectural invariant>
444
-
445
- ## Boundaries
446
- - In scope:
447
- - <major capability or boundary>
448
- - Out of scope:
449
- - <intentional non-goal>
450
-
451
- ## Structure
452
- - <important directory or subsystem> \u2014 <responsibility>
453
-
454
- ## Constraints
455
- - <cross-cutting technical or operational constraint, when it affects architecture>
456
- `;
457
- }
458
- function generateProjectRulesContent(projectName) {
459
- return `---
460
- name: project-rules
461
- description: Project-specific rules for ${projectName}
462
- ---
463
-
464
- # Project Rules
465
-
466
- ## Scope
467
- - <stable local rules, workflow constraints, or validation expectations>
468
- - Do not put temporary debugging steps here.
469
-
470
- ## Validation
471
- - <preferred stable validation commands, not temporary troubleshooting steps>
472
-
473
- ## Conventions
474
- - <project-specific conventions>
475
- `;
476
- }
477
- function generateRulesContent(name) {
478
- const title = toTitleCase(name);
479
- return `---
480
- name: ${name}
481
- description: Durable rules for ${title}
482
- ---
483
-
484
- # ${title}
485
-
486
- ## Scope
487
- - <what this rules file governs>
488
-
489
- ## Rules
490
- - <stable local rule>
491
-
492
- ## Validation
493
- - <relevant validation command or check>
494
- `;
495
- }
496
- function generateSpecContent(name) {
497
- const title = toTitleCase(name);
498
- return `# ${title}
499
-
500
- ## Purpose
501
- - <why this project-level spec exists>
502
-
503
- ## Stable Facts
504
- - <durable fact that future work must know>
505
-
506
- ## Boundaries
507
- - In scope:
508
- - <what this spec covers>
509
- - Out of scope:
510
- - <what this spec does not cover>
511
-
512
- ## Constraints
513
- - <stable constraint, if any>
514
- `;
515
- }
516
- function toTitleCase(value) {
517
- return value.split(/[-/]/g).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
518
- }
519
- function extractSection(content, heading) {
520
- const lines = content.split("\n");
521
- const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
522
- if (start === -1)
523
- return "";
524
- const body = [];
525
- for (let index = start + 1; index < lines.length; index++) {
526
- const line = lines[index];
527
- if (line.startsWith("## "))
528
- break;
529
- body.push(line);
530
- }
531
- return body.join("\n").trim();
532
- }
533
- function hasMeaningfulBlockers(content) {
534
- const blockers = extractSection(content, "Blockers");
535
- if (!blockers)
536
- return false;
537
- const lines = blockers.split("\n").map((line) => line.trim()).filter(Boolean);
538
- if (lines.length === 0)
539
- return false;
540
- return lines.some((line) => !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
541
- }
542
- function detectDeltaSections(content) {
543
- const body = extractSection(content, "Spec");
544
- return {
545
- added: /^###\s*ADDED/im.test(body),
546
- modified: /^###\s*MODIFIED/im.test(body),
547
- removed: /^###\s*REMOVED/im.test(body)
548
- };
549
- }
550
- function parseScenarios(content) {
551
- const scenarios = [];
552
- const regex = /^#{3,4}\s*Scenario:\s*(.+)$/gim;
553
- let match;
554
- while ((match = regex.exec(content)) !== null) {
555
- const heading = match[1].trim();
556
- const startPos = match.index + match[0].length;
557
- const restContent = content.slice(startPos);
558
- const nextSection = restContent.match(/^#{3,4}\s/m);
559
- const block = nextSection ? restContent.slice(0, nextSection.index) : restContent;
560
- 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*/, ""));
561
- if (steps.length > 0)
562
- scenarios.push({ heading, steps });
563
- }
564
- return scenarios;
565
- }
85
+ import { defineCommand, runMain } from "citty";
566
86
 
567
- // src/core/config.ts
568
- var __dirname = dirname2(fileURLToPath(import.meta.url));
569
- var PKG_ROOT = join2(__dirname, "..");
570
- var RSP_DIR = ".rsp";
571
- var CHANGES_DIR = join2(RSP_DIR, "changes");
572
- var FOCUS_DIR = join2(RSP_DIR, "focus.d");
573
- var ARCHIVES_DIR = join2(RSP_DIR, "archives");
574
- var LOCK_PATH = join2(RSP_DIR, ".lock");
575
- var CONFIG_PATH = join2(RSP_DIR, "config.yaml");
576
- var VALID_KINDS = ["feature", "fix", "refactor", "docs", "ops", "research"];
577
- var DEFAULT_REQUIRED_SECTIONS = ["Proposal", "Spec", "Design", "Tasks", "Verify", "Blockers"];
578
- var _configCache = null;
579
- async function loadRspConfig() {
580
- if (_configCache)
581
- return _configCache;
582
- if (!existsSync2(CONFIG_PATH)) {
583
- _configCache = {};
584
- return _configCache;
585
- }
586
- const raw = await readFile2(CONFIG_PATH, "utf-8");
587
- const parsed = parseYamlText(raw);
588
- _configCache = {
589
- kinds: Array.isArray(parsed.kinds) ? parsed.kinds.map(String) : void 0
590
- };
591
- return _configCache;
592
- }
593
- function resolveKinds(config) {
594
- return config.kinds && config.kinds.length > 0 ? config.kinds : [...VALID_KINDS];
595
- }
596
- function resolveRequiredSections(_config) {
597
- return [...DEFAULT_REQUIRED_SECTIONS];
598
- }
599
- async function getVersion() {
600
- const { version } = JSON.parse(await readFile2(join2(PKG_ROOT, "package.json"), "utf-8"));
601
- return version;
602
- }
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";
603
90
 
604
91
  // src/core/lock.ts
605
- import { mkdir, readFile as readFile3, unlink, writeFile } from "fs/promises";
92
+ import { mkdir, readFile, unlink, writeFile } from "fs/promises";
606
93
  async function withRspLock(operation, fn) {
94
+ requireManagedDirectory(RSP_DIR, "RSP root", { allowMissing: true });
607
95
  await mkdir(RSP_DIR, { recursive: true });
608
96
  try {
609
97
  await writeFile(LOCK_PATH, `${process.pid}
@@ -626,7 +114,7 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
626
114
  }
627
115
  async function handleStaleLock(operation) {
628
116
  try {
629
- const content = await readFile3(LOCK_PATH, "utf-8");
117
+ const content = await readFile(LOCK_PATH, "utf-8");
630
118
  const lines = content.trim().split("\n");
631
119
  const pid = Number(lines[0]);
632
120
  if (pid && Number.isFinite(pid)) {
@@ -661,51 +149,41 @@ ${(/* @__PURE__ */ new Date()).toISOString()}`, { flag: "wx" });
661
149
  }
662
150
  }
663
151
 
664
- // src/commands/add-rules.ts
665
- async function addRules(name, projectName = "project") {
666
- if (!name || !isValidChangeName(name)) {
667
- console.error(` ${pc.red("Error:")} rule name must be kebab-case with optional subdirectory`);
668
- process.exit(1);
669
- }
670
- guardRspInitialized();
671
- const path = join3(RSP_DIR, "rules", `${name}.md`);
672
- if (existsSync3(path)) {
673
- console.error(` ${pc.red("Error:")} rule file already exists: .rsp/rules/${name}.md`);
674
- process.exit(1);
675
- }
676
- return withRspLock("add-rules", async () => {
677
- await mkdir2(dirname3(path), { recursive: true });
678
- const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
679
- const content = name === "project-rules" ? generateProjectRulesContent(resolvedProjectName) : generateRulesContent(name);
680
- await writeFile2(path, content);
681
- console.log(` ${pc.green("Created:")} .rsp/rules/${name}.md
682
- `);
683
- });
684
- }
685
-
686
- // src/commands/add-spec.ts
687
- import { existsSync as existsSync5 } from "fs";
688
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
689
- import { dirname as dirname4, join as join5 } from "path";
690
-
691
152
  // src/commands/specs-index.ts
692
- import { existsSync as existsSync4 } from "fs";
693
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
694
- import { basename as basename2, join as join4, relative as relative2 } from "path";
695
- async function buildSpecsIndex({ acquireLock = true } = {}) {
153
+ import { existsSync } from "fs";
154
+ import { readFile as readFile2 } from "fs/promises";
155
+ import { basename, isAbsolute, join, relative, sep } from "path";
156
+ async function buildSpecsIndex({ acquireLock = true, quiet = false } = {}) {
696
157
  if (acquireLock)
697
- return withRspLock("specs-index", async () => buildSpecsIndex({ acquireLock: false }));
698
- const specsDir = join4(RSP_DIR, "specs");
699
- if (!existsSync4(specsDir)) {
700
- console.log(` ${pc.dim("No specs directory.")}
158
+ return withRspLock("specs-index", async () => buildSpecsIndex({ acquireLock: false, quiet }));
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) {
168
+ if (!quiet)
169
+ console.log(` ${pc.dim("No specs directory.")}
701
170
  `);
702
- return;
171
+ return false;
703
172
  }
704
- const specFiles = (await walkMarkdownFiles(specsDir)).filter((fp) => {
705
- const name = basename2(fp);
706
- 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);
707
176
  }).sort();
708
177
  const lines = [];
178
+ lines.push("---");
179
+ lines.push("title: Specs Index");
180
+ lines.push("summary: Additional project-level specs beyond design.md.");
181
+ lines.push("kind: generated-index");
182
+ lines.push("index_type: specs");
183
+ lines.push("source_dir: .rsp/specs");
184
+ lines.push(`entry_count: ${specFiles.length}`);
185
+ lines.push("---");
186
+ lines.push("");
709
187
  lines.push("# Specs Index");
710
188
  lines.push("");
711
189
  lines.push("_Additional project-level specs beyond `design.md`._");
@@ -716,8 +194,8 @@ async function buildSpecsIndex({ acquireLock = true } = {}) {
716
194
  lines.push("| File | Title | Summary |");
717
195
  lines.push("|------|-------|---------|");
718
196
  for (const fp of specFiles) {
719
- const rel = normalizeLogicalPath(relative2(specsDir, fp));
720
- const content = await readFile4(fp, "utf-8");
197
+ const rel = normalizeLogicalPath(relative(specsDir, fp));
198
+ const content = await readFile2(fp, "utf-8");
721
199
  const fm = parseFrontmatter(content);
722
200
  const fmTitle = fm && typeof fm.title === "string" ? fm.title : "";
723
201
  const title = fmTitle || extractTitle(content) || rel.replace(/\.md$/, "");
@@ -725,11 +203,20 @@ async function buildSpecsIndex({ acquireLock = true } = {}) {
725
203
  lines.push(`| ${escapeCell(rel)} | ${escapeCell(title)} | ${escapeCell(summary || "\u2014")} |`);
726
204
  }
727
205
  }
728
- lines.push("");
729
- lines.push("_Auto-generated by `rsp update`._");
730
- await writeFile3(join4(specsDir, "INDEX.md"), lines.join("\n"));
731
- console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
206
+ const indexPath = join(specsDir, "INDEX.md");
207
+ const nextContent = lines.join("\n");
208
+ const existingContent = existsSync(indexPath) ? await readFile2(indexPath, "utf-8") : null;
209
+ const changed = existingContent !== nextContent;
210
+ if (changed)
211
+ await writeManagedFile(indexPath, nextContent, "Specs index");
212
+ if (!quiet)
213
+ console.log(` ${pc.green("INDEX.md updated:")} ${specFiles.length} spec file(s).
732
214
  `);
215
+ return changed;
216
+ }
217
+ function isInside(directory, filePath) {
218
+ const rel = relative(directory, filePath);
219
+ return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
733
220
  }
734
221
  function extractTitle(content) {
735
222
  for (const rawLine of content.split("\n")) {
@@ -766,249 +253,384 @@ function escapeCell(value) {
766
253
 
767
254
  // src/commands/add-spec.ts
768
255
  async function addSpec(name, projectName = "project") {
769
- if (!name || !isValidChangeName(name)) {
256
+ if (!name || !isValidSpecName(name)) {
770
257
  console.error(` ${pc.red("Error:")} spec name must be kebab-case with optional subdirectory`);
771
258
  process.exit(1);
772
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
+ }
773
264
  guardRspInitialized();
774
- const path = join5(RSP_DIR, "specs", `${name}.md`);
775
- if (existsSync5(path)) {
776
- 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)}`);
777
273
  process.exit(1);
778
274
  }
779
275
  return withRspLock("add-spec", async () => {
780
- 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 });
781
284
  const resolvedProjectName = projectName === "project" ? await detectProjectName() : projectName;
782
285
  const content = name === "design" ? generateDesignContent(resolvedProjectName) : generateSpecContent(name);
783
- await writeFile4(path, content);
784
- 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
+ }
785
303
  console.log(` ${pc.green("Created:")} .rsp/specs/${name}.md
786
304
  `);
787
305
  });
788
306
  }
789
307
 
790
308
  // src/commands/archive.ts
791
- import { existsSync as existsSync7 } from "fs";
792
- import { mkdir as mkdir4, readFile as readFile6, rename, unlink as unlink2 } from "fs/promises";
793
- 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";
794
312
 
795
313
  // src/commands/archive-index.ts
796
- import { existsSync as existsSync6 } from "fs";
797
- import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
798
- import { basename as basename3, dirname as dirname5, join as join6, relative as relative3 } from "path";
799
- async function buildArchiveIndex({ acquireLock = true } = {}) {
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";
317
+ async function buildArchiveIndex({ acquireLock = true, quiet = false } = {}) {
800
318
  if (acquireLock)
801
- return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false }));
802
- if (!existsSync6(ARCHIVES_DIR)) {
803
- console.log(` ${pc.dim("No archives directory.")}
319
+ return withRspLock("archive-index", async () => buildArchiveIndex({ acquireLock: false, quiet }));
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) {
326
+ if (!quiet)
327
+ console.log(` ${pc.dim("No archives directory.")}
804
328
  `);
805
- return;
329
+ return false;
806
330
  }
807
- const archiveFiles = (await walkMarkdownFiles(ARCHIVES_DIR)).filter((fp) => basename3(fp) !== "INDEX.md").sort();
331
+ const archiveFiles = inspection.files;
808
332
  const lines = [];
809
- lines.push("# Archive Index");
333
+ lines.push("---");
334
+ lines.push("title: Archive Index");
335
+ lines.push("summary: Completed RSP changes.");
336
+ lines.push("kind: generated-index");
337
+ lines.push("index_type: archives");
338
+ lines.push("source_dir: .rsp/archives");
339
+ lines.push(`entry_count: ${archiveFiles.length}`);
340
+ lines.push("---");
810
341
  lines.push("");
811
- lines.push(`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
342
+ lines.push("# Archive Index");
812
343
  lines.push("");
813
344
  if (archiveFiles.length === 0) {
814
345
  lines.push("_No archived changes yet._");
815
346
  } else {
816
- lines.push("| Date | Change | Summary |");
817
- lines.push("|------|--------|---------|");
347
+ lines.push("| Date | Change | Kind | Summary |");
348
+ lines.push("|------|--------|------|---------|");
818
349
  }
819
350
  for (const fp of archiveFiles) {
820
- const base = basename3(fp);
821
- const archiveRel = normalizeLogicalPath(relative3(ARCHIVES_DIR, fp));
822
- const archiveDirPart = dirname5(archiveRel);
351
+ const base = basename2(fp);
352
+ const archiveRel = normalizeLogicalPath(relative2(ARCHIVES_DIR, fp));
353
+ const archiveDirPart = dirname(archiveRel);
823
354
  const dateMatch = base.match(/^(\d{4}-\d{2}-\d{2})_(.+)\.md$/);
824
355
  const date = dateMatch ? dateMatch[1] : "\u2014";
825
356
  const baseName = dateMatch ? dateMatch[2] : base.replace(/\.md$/, "");
826
357
  const changeName = archiveDirPart !== "." ? `${archiveDirPart}/${baseName}` : baseName;
827
358
  let summary = "";
359
+ let kind = "";
828
360
  try {
829
- const content = await readFile5(fp, "utf-8");
361
+ const content = await readFile3(fp, "utf-8");
362
+ const fm = parseFrontmatter(content);
363
+ if (typeof fm?.kind === "string")
364
+ kind = fm.kind;
365
+ if (typeof fm?.summary === "string")
366
+ summary = fm.summary.trim();
830
367
  const proposal = extractSection(content, "Proposal");
831
- const summaryLine = proposal.split("\n").map((line) => line.trim()).find((line) => line.startsWith("- Summary:"));
832
- if (summaryLine)
833
- 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();
834
374
  } catch {
835
375
  }
836
- const summaryCell = summary ? summary.replace(/\|/g, "\\|") : "\u2014";
837
- lines.push(`| ${date} | ${changeName} | ${summaryCell} |`);
838
- }
839
- lines.push("");
840
- lines.push("_Auto-generated by `rsp update`._");
841
- const indexPath = join6(ARCHIVES_DIR, "INDEX.md");
842
- await writeFile5(indexPath, lines.join("\n"));
843
- console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived change(s).
376
+ lines.push(`| ${date} | ${escapeCell2(changeName)} | ${escapeCell2(kind || "\u2014")} | ${escapeCell2(summary || "\u2014")} |`);
377
+ }
378
+ const indexPath = join3(ARCHIVES_DIR, "INDEX.md");
379
+ const nextContent = lines.join("\n");
380
+ const existingContent = existsSync2(indexPath) ? await readFile3(indexPath, "utf-8") : null;
381
+ const changed = existingContent !== nextContent;
382
+ if (changed)
383
+ await writeManagedFile(indexPath, nextContent, "archive index");
384
+ if (!quiet)
385
+ console.log(` ${pc.green("INDEX.md updated:")} ${archiveFiles.length} archived change(s).
844
386
  `);
387
+ return changed;
388
+ }
389
+ function escapeCell2(value) {
390
+ return value.replace(/\|/g, "\\|");
845
391
  }
846
392
 
847
393
  // src/commands/archive.ts
848
- async function archiveChange(name) {
394
+ async function archiveChange(name, options = {}) {
849
395
  if (!name) {
850
396
  console.error(` ${pc.red("Usage:")} rsp archive <name>`);
851
397
  process.exit(1);
852
398
  }
853
- if (!isValidChangeName(name)) {
854
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
855
- process.exit(1);
856
- }
857
399
  guardRspInitialized();
858
- const srcPath = join7(CHANGES_DIR, `${name}.md`);
859
- if (!existsSync7(srcPath)) {
860
- console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
861
- process.exit(1);
400
+ if (options.dryRun) {
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
+ });
407
+ console.log();
408
+ console.log(` ${pc.bold("Archive dry-run for")} ${pc.cyan(name)}`);
409
+ console.log();
410
+ if (checklist.length === 0) {
411
+ console.log(` ${pc.green("\u2713")} Ready to archive. No deterministic warnings found.
412
+ `);
413
+ } else {
414
+ for (const line of checklist)
415
+ console.log(` ${pc.yellow("\u26A0")} ${line}`);
416
+ console.log();
417
+ console.log(` ${pc.dim("Review the warnings above before treating this work as fully closed.")}
418
+ `);
419
+ }
420
+ return;
862
421
  }
863
- return withRspLock("archive-change", async () => {
864
- const content = await readFile6(srcPath, "utf-8");
865
- const checklist = collectArchiveChecklist(content);
866
- for (const line of checklist)
867
- console.log(` ${pc.yellow("\u26A0")} ${line}`);
868
- if (checklist.length > 0)
869
- 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.")}
870
437
  `);
871
- const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
872
- const dir = dirname6(name);
873
- const base = basename4(name);
874
- const archiveSubdir = dir !== "." ? join7(ARCHIVES_DIR, dir) : ARCHIVES_DIR;
875
- await mkdir4(archiveSubdir, { recursive: true });
876
- const archiveName = resolveArchiveName(archiveSubdir, date, base);
877
- await rename(srcPath, join7(archiveSubdir, archiveName));
878
- const postArchiveWarnings = [];
879
- let focusCleared = false;
880
- const focusEntry = join7(FOCUS_DIR, name);
881
- 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
+ }
882
454
  try {
883
- await unlink2(focusEntry);
884
- await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
885
- focusCleared = true;
455
+ await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
886
456
  } catch (error) {
887
- postArchiveWarnings.push(`focus marker cleanup failed: ${toErrorMessage(error)}`);
457
+ postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
888
458
  }
889
- }
890
- try {
891
- await cleanupEmptyParentDirs(srcPath, CHANGES_DIR);
892
- } catch (error) {
893
- postArchiveWarnings.push(`changes directory cleanup failed: ${toErrorMessage(error)}`);
894
- }
895
- const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
459
+ const clearedMsg = focusCleared ? ` ${pc.dim("focus marker cleared")}
896
460
  ` : "";
897
- console.log(` ${pc.green("Archived:")} ${archiveName}
461
+ console.log(` ${pc.green("Archived:")} ${archiveName}
898
462
  ${clearedMsg}`);
899
- try {
900
- await buildArchiveIndex({ acquireLock: false });
901
- } catch (error) {
902
- postArchiveWarnings.push(`archive index rebuild failed: ${toErrorMessage(error)}`);
903
- }
904
- for (const warning of postArchiveWarnings)
905
- console.log(` ${pc.yellow("\u26A0")} ${warning}`);
906
- if (postArchiveWarnings.length > 0)
907
- 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.")}
908
472
  `);
909
- if (existsSync7(".git")) {
910
- const archiveRelPath = dir !== "." ? join7(dir, archiveName) : archiveName;
911
- console.log(` ${pc.cyan("Git workflow:")}
473
+ if (existsSync3(".git")) {
474
+ console.log(` ${pc.cyan("Git delivery:")}
912
475
  `);
913
- console.log(` git add .rsp/archives/${archiveRelPath}`);
914
- console.log(` git commit -m "feat: archive ${name}"`);
915
- 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);
916
485
  }
917
- });
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
+ }
918
502
  }
919
503
  function resolveArchiveName(archiveSubdir, date, base) {
920
504
  const initialName = `${date}_${base}.md`;
921
- if (!existsSync7(join7(archiveSubdir, initialName)))
505
+ if (!existsSync3(join4(archiveSubdir, initialName)))
922
506
  return initialName;
923
507
  const MAX_SUFFIX = 9999;
924
508
  for (let index = 2; index <= MAX_SUFFIX; index++) {
925
509
  const candidate = `${date}_${base}-${index}.md`;
926
- if (!existsSync7(join7(archiveSubdir, candidate)))
510
+ if (!existsSync3(join4(archiveSubdir, candidate)))
927
511
  return candidate;
928
512
  }
929
- console.error(` ${pc.red("Error:")} archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`);
930
- process.exit(1);
931
- }
932
- function collectArchiveChecklist(content) {
933
- const warnings = [];
934
- const tasksSection = extractSection(content, "Tasks");
935
- const verifySection = extractSection(content, "Verify");
936
- const taskTodos = getOpenCheckboxes(tasksSection);
937
- if (taskTodos.length > 0)
938
- warnings.push(`${taskTodos.length} task item(s) still incomplete`);
939
- const verifyTodos = getOpenCheckboxes(verifySection);
940
- if (verifyTodos.length > 0)
941
- warnings.push(`${verifyTodos.length} Verify checklist item(s) are still incomplete`);
942
- if (hasMeaningfulBlockers(content))
943
- warnings.push("active blockers are present in the change file");
944
- const scenarios = parseScenarios(content);
945
- if (scenarios.length === 0)
946
- warnings.push("no Scenario blocks found (some changes do not need them)");
947
- return warnings;
513
+ throw new WorkRefError(
514
+ "archive_name_exhausted",
515
+ `archive name collision exceeded ${MAX_SUFFIX} attempts for ${base}`,
516
+ base
517
+ );
948
518
  }
949
519
 
950
520
  // src/commands/check.ts
951
- import { existsSync as existsSync8 } from "fs";
952
- import { readFile as readFile7 } from "fs/promises";
953
- import { join as join8, relative as relative4 } from "path";
521
+ import { readFile as readFile5 } from "fs/promises";
954
522
  async function runCheck(options = {}) {
955
- 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
+ }
956
543
  const validKinds = resolveKinds(config);
957
544
  const requiredSections = resolveRequiredSections(config);
958
545
  const choosePlaceholderRe = /^<choose:/i;
959
- const diagnostics = [];
960
- const runtime = [];
961
546
  const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
962
547
  const addDiagnostic = (diagnostic) => diagnostics.push(diagnostic);
963
- if (existsSync8(FOCUS_DIR)) {
964
- const entries = await walkFiles(FOCUS_DIR, { onError: reportRuntime });
965
- for (const entryPath of entries) {
966
- const entryContent = normalizeLogicalPath(relative4(FOCUS_DIR, entryPath));
967
- let markerContent = "";
968
- try {
969
- markerContent = (await readFile7(entryPath, "utf-8")).trim();
970
- } catch (error) {
971
- addDiagnostic({
972
- severity: "error",
973
- code: "focus_marker_read_failed",
974
- path: `focus.d/${entryContent}`,
975
- change: entryContent,
976
- message: "unable to read focus marker file",
977
- hint: toErrorMessage(error)
978
- });
979
- reportRuntime({
980
- code: "focus_marker_read_failed",
981
- operation: "readFile",
982
- path: entryPath,
983
- message: toErrorMessage(error)
984
- });
985
- continue;
986
- }
987
- if (markerContent) {
988
- addDiagnostic({
989
- severity: "warning",
990
- code: "focus_marker_not_empty",
991
- path: `focus.d/${entryContent}`,
992
- message: "should be an empty marker file; path is the source of truth"
993
- });
994
- }
995
- const fp = join8(CHANGES_DIR, `${entryContent}.md`);
996
- if (!existsSync8(fp)) {
997
- addDiagnostic({
998
- severity: "error",
999
- code: "focused_change_missing",
1000
- path: `focus.d/${entryContent}`,
1001
- change: entryContent,
1002
- message: `focuses "${entryContent}" but changes/${entryContent}.md not found`
1003
- });
1004
- }
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
+ });
557
+ }
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
+ });
1005
615
  }
1006
616
  }
1007
- const changeFiles = existsSync8(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1008
- 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) {
1009
630
  const result2 = {
1010
631
  command: "check",
1011
632
  ok: diagnostics.every((d) => d.severity !== "error"),
633
+ focused: Boolean(options.focused),
1012
634
  diagnostics,
1013
635
  runtime,
1014
636
  summary: {
@@ -1018,11 +640,13 @@ async function runCheck(options = {}) {
1018
640
  }
1019
641
  };
1020
642
  if (options.json) {
1021
- emitJson(result2);
643
+ emitJson(result2, options);
1022
644
  return result2;
1023
645
  }
1024
646
  console.log();
1025
647
  console.log(` ${pc.bold("RSP check")}`);
648
+ if (options.focused)
649
+ console.log(` ${pc.dim("(focused only)")}`);
1026
650
  console.log();
1027
651
  if (diagnostics.length === 0) {
1028
652
  console.log(` ${pc.dim("No change files to check.")}
@@ -1044,11 +668,11 @@ async function runCheck(options = {}) {
1044
668
  `);
1045
669
  return result2;
1046
670
  }
1047
- for (const fp of changeFiles) {
1048
- const name = changeNameFromPath(CHANGES_DIR, fp);
671
+ for (const ref of changeRefs) {
672
+ const { name, path: fp } = ref;
1049
673
  let content;
1050
674
  try {
1051
- content = await readFile7(fp, "utf-8");
675
+ content = await readFile5(fp, "utf-8");
1052
676
  } catch (error) {
1053
677
  addDiagnostic({
1054
678
  severity: "error",
@@ -1159,6 +783,30 @@ async function runCheck(options = {}) {
1159
783
  message: `delta markers found: ${parts.join(", ")}`
1160
784
  });
1161
785
  }
786
+ const placeholderLines = findUnfinishedTemplateLines(content);
787
+ if (placeholderLines.length > 0) {
788
+ addDiagnostic({
789
+ severity: "warning",
790
+ code: "unfinished_template_placeholders",
791
+ change: name,
792
+ path: fp,
793
+ message: "unfinished template placeholders found",
794
+ details: placeholderLines,
795
+ hint: "Replace angle-bracket template text with concrete change details before archiving."
796
+ });
797
+ }
798
+ const clarificationLines = findUnresolvedClarificationLines(content);
799
+ if (clarificationLines.length > 0) {
800
+ addDiagnostic({
801
+ severity: "warning",
802
+ code: "unresolved_clarifications",
803
+ change: name,
804
+ path: fp,
805
+ message: "unresolved clarification markers found",
806
+ details: clarificationLines,
807
+ hint: "Resolve or remove clarification markers once the open question is answered."
808
+ });
809
+ }
1162
810
  const scenarios = parseScenarios(content);
1163
811
  if (scenarios.length > 0) {
1164
812
  const scenarioIssues = [];
@@ -1191,20 +839,23 @@ async function runCheck(options = {}) {
1191
839
  const result = {
1192
840
  command: "check",
1193
841
  ok: diagnostics.every((d) => d.severity !== "error"),
842
+ focused: Boolean(options.focused),
1194
843
  diagnostics,
1195
844
  runtime,
1196
845
  summary: {
1197
- changeFiles: changeFiles.length,
846
+ changeFiles: changeRefs.length,
1198
847
  errors: diagnostics.filter((d) => d.severity === "error").length,
1199
848
  warnings: diagnostics.filter((d) => d.severity === "warning").length
1200
849
  }
1201
850
  };
1202
851
  if (options.json) {
1203
- emitJson(result);
852
+ emitJson(result, options);
1204
853
  return result;
1205
854
  }
1206
855
  console.log();
1207
856
  console.log(` ${pc.bold("RSP check")}`);
857
+ if (options.focused)
858
+ console.log(` ${pc.dim("(focused only)")}`);
1208
859
  console.log();
1209
860
  for (const diagnostic of diagnostics) {
1210
861
  const icon = diagnostic.severity === "error" ? pc.red("\u2717") : diagnostic.severity === "warning" ? pc.yellow("\u26A0") : pc.dim("\u2139");
@@ -1218,77 +869,288 @@ async function runCheck(options = {}) {
1218
869
  }
1219
870
  console.log();
1220
871
  if (result.summary.errors === 0 && result.summary.warnings === 0)
1221
- 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.
1222
873
  `);
1223
874
  else
1224
- 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).
1225
876
  `);
1226
877
  return result;
1227
878
  }
879
+ function findUnfinishedTemplateLines(content) {
880
+ const placeholderRe = /<[^>\n]*(?:choose:|one-line|what |why |which |who |how |new |updated |expected |actual |specific |concrete |exact |verifiable |user |capability |behavior |context |action |path|directory|module|subsystem|command|scenario|constraint|requirement|question|uncertainty|implementation)[^>\n]*>/i;
881
+ return collectMatchingLines(content, (line) => {
882
+ const trimmed = stripInlineCodeSpans(line).trim();
883
+ return trimmed.startsWith("<!--") === false && placeholderRe.test(trimmed);
884
+ });
885
+ }
886
+ function findUnresolvedClarificationLines(content) {
887
+ return collectMatchingLines(content, (line) => /\[(?:NEEDS|TODO|TBD)\s+CLARIFICATION(?::[^\]]*)?\]/i.test(stripInlineCodeSpans(line)));
888
+ }
889
+ function stripInlineCodeSpans(line) {
890
+ return line.replace(/`[^`]*`/g, "");
891
+ }
892
+ function collectMatchingLines(content, predicate) {
893
+ const matches = [];
894
+ const lines = content.split("\n");
895
+ for (let index = 0; index < lines.length; index++) {
896
+ const line = lines[index];
897
+ if (predicate(line))
898
+ matches.push(`line ${index + 1}: ${line.trim()}`);
899
+ }
900
+ return matches.slice(0, 10);
901
+ }
1228
902
 
1229
903
  // src/commands/create.ts
1230
- import { existsSync as existsSync9 } from "fs";
1231
- import { mkdir as mkdir5, writeFile as writeFile6 } from "fs/promises";
1232
- import { dirname as dirname7, join as join9 } from "path";
1233
- async function createChange(name, summary = "", kind) {
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";
907
+ async function createChange(name, summary = "", kind, options = {}) {
1234
908
  if (!name) {
1235
909
  console.error(` ${pc.red("Usage:")} rsp create <name> [summary]`);
1236
910
  process.exit(1);
1237
911
  }
1238
- if (!isValidChangeName(name)) {
1239
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
912
+ guardRspInitialized();
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"}
947
+ `);
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
+ }
956
+ }
957
+
958
+ // src/commands/doctor.ts
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";
962
+
963
+ // src/commands/update.ts
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";
968
+ async function updateProject(options = {}) {
969
+ if (!existsSync5(RSP_DIR)) {
970
+ console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
971
+ console.error(` ${pc.dim("Run: rsp init")}`);
1240
972
  process.exit(1);
1241
973
  }
1242
- guardRspInitialized();
1243
- return withRspLock("create-change", async () => {
1244
- const changePath = join9(CHANGES_DIR, `${name}.md`);
1245
- await mkdir5(dirname7(changePath), { recursive: true });
1246
- const existed = existsSync9(changePath);
1247
- if (!existed) {
1248
- const content = generateChangeContent(name, summary, kind);
1249
- await writeFile6(changePath, content);
1250
- await mkdir5(FOCUS_DIR, { recursive: true });
1251
- const focusEntry = join9(FOCUS_DIR, name);
1252
- await mkdir5(dirname7(focusEntry), { recursive: true });
1253
- await writeFile6(focusEntry, "");
1254
- }
1255
- const label = existed ? "Using" : pc.green("Created");
1256
- console.log(` ${label}: ${changePath}`);
1257
- if (existed)
1258
- console.log(` ${pc.dim("Unchanged focus.")} Run: rsp focus ${name}`);
1259
- else
1260
- console.log(` ${pc.dim("focused via focus.d")} \u2192 ${name}`);
1261
- console.log(` ${pc.cyan("Next:")} fill proposal/spec/design first, then implement and complete the tasks
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;
979
+ let updated = false;
980
+ const actions = [];
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;
1005
+ if (existingRules !== bundledRules) {
1006
+ await writeManagedFile(RSP_RULES_PATH, bundledRules, "fallback protocol");
1007
+ actions.push("rsp-rules.md updated");
1008
+ if (!options.quiet)
1009
+ console.log(` ${pc.green("\u2713")} rsp-rules.md updated`);
1010
+ updated = true;
1011
+ }
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
+ }
1055
+ const projectName = await detectProjectName();
1056
+ const baseAgents = agentsInspection.exists ? await readFile6(agentsPath, "utf-8") : `# ${projectName}
1057
+
1058
+ `;
1059
+ const nextAgents = upsertRspAgentsBlock(baseAgents);
1060
+ if (!agentsInspection.exists || nextAgents.changed) {
1061
+ await writeManagedFile(agentsPath, nextAgents.content, "AGENTS.md");
1062
+ actions.push("AGENTS.md managed block refreshed");
1063
+ if (!options.quiet)
1064
+ console.log(` ${pc.green("\u2713")} AGENTS.md managed block refreshed`);
1065
+ updated = true;
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
+ }
1073
+ const specsIndexChanged = await buildSpecsIndex({ acquireLock: false, quiet: options.quiet });
1074
+ const archiveIndexChanged = await buildArchiveIndex({ acquireLock: false, quiet: options.quiet });
1075
+ if (specsIndexChanged || archiveIndexChanged) {
1076
+ actions.push("generated indexes rebuilt");
1077
+ updated = true;
1078
+ }
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) {
1088
+ console.log(` ${pc.dim("Already up to date.")}
1089
+ `);
1090
+ console.log(SKILL_REFRESH_HINT);
1091
+ } else if (!options.quiet) {
1092
+ console.log(` ${pc.green("Update complete.")}
1262
1093
  `);
1094
+ console.log(SKILL_REFRESH_HINT);
1095
+ }
1096
+ return {
1097
+ actions,
1098
+ migration: {
1099
+ inspectionComplete: inspection.diagnostics.length === 0,
1100
+ manualMigrationRequired: inspection.directoryRemaining,
1101
+ residualEntries,
1102
+ diagnostics: inspection.diagnostics
1103
+ }
1104
+ };
1263
1105
  });
1264
1106
  }
1265
1107
 
1266
1108
  // src/commands/doctor.ts
1267
- import { existsSync as existsSync10 } from "fs";
1268
- import { readFile as readFile8 } from "fs/promises";
1269
- import { basename as basename5, join as join10, relative as relative5 } from "path";
1270
1109
  async function runDoctor(options = {}) {
1110
+ const fixed = [];
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
+ }
1119
+ }
1271
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
+ }
1272
1129
  const runtime = [];
1273
1130
  const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
1274
- const rspRulesPath = join10(RSP_DIR, "rules", "rsp-rules.md");
1275
- const designPath = join10(RSP_DIR, "specs", "design.md");
1276
- const specsIndexPath = join10(RSP_DIR, "specs", "INDEX.md");
1277
- const archivesIndexPath = join10(RSP_DIR, "archives", "INDEX.md");
1278
- reportCheck(checks, ".rsp exists", existsSync10(RSP_DIR), "Run: rsp init");
1279
- reportCheck(checks, "rules/rsp-rules.md exists", existsSync10(rspRulesPath), "Run: rsp init");
1280
- reportCheck(checks, "specs/design.md exists", existsSync10(designPath), "Run: rsp init");
1281
- reportCheck(checks, "specs/INDEX.md exists", existsSync10(specsIndexPath), "Run: rsp update");
1282
- reportCheck(checks, "archives/INDEX.md exists", existsSync10(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");
1283
1140
  await checkAgents(checks, reportRuntime);
1284
- await checkGeneratedIndex(checks, reportRuntime, specsIndexPath, "_Auto-generated by `rsp update`._", "specs/INDEX.md has generated signature", "Run: rsp update");
1285
- await checkGeneratedIndex(checks, reportRuntime, archivesIndexPath, "_Auto-generated by `rsp update`._", "archives/INDEX.md has generated signature", "Run: rsp update");
1286
- await checkArchiveNaming(checks, reportRuntime);
1287
- await checkConfigSemantics(checks, reportRuntime);
1288
- await checkActiveChangeConsistency(checks, reportRuntime);
1141
+ await checkGeneratedIndex(checks, reportRuntime, specsIndexPath, "specs", "specs/INDEX.md has generated-index metadata", "Run: rsp update");
1142
+ await checkGeneratedIndex(checks, reportRuntime, archivesIndexPath, "archives", "archives/INDEX.md has generated-index metadata", "Run: rsp update");
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);
1289
1150
  const result = {
1290
1151
  command: "doctor",
1291
1152
  ok: checks.every((check) => check.status !== "issue"),
1153
+ fixed,
1292
1154
  checks,
1293
1155
  runtime,
1294
1156
  summary: {
@@ -1296,12 +1158,19 @@ async function runDoctor(options = {}) {
1296
1158
  }
1297
1159
  };
1298
1160
  if (options.json) {
1299
- emitJson(result);
1161
+ emitJson(result, options);
1300
1162
  return result;
1301
1163
  }
1302
1164
  console.log();
1303
1165
  console.log(` ${pc.bold("RSP doctor")}`);
1304
1166
  console.log();
1167
+ if (fixed.length > 0) {
1168
+ console.log(` ${pc.green("Fixed:")} ${fixed.join(", ")}`);
1169
+ console.log();
1170
+ } else if (options.fix && !repairIssue && existsSync6(RSP_DIR)) {
1171
+ console.log(` ${pc.dim("No safe fixes needed.")}`);
1172
+ console.log();
1173
+ }
1305
1174
  for (const check of checks)
1306
1175
  printDoctorCheck(check);
1307
1176
  if (result.summary.issues === 0)
@@ -1314,6 +1183,57 @@ async function runDoctor(options = {}) {
1314
1183
  `);
1315
1184
  return result;
1316
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
+ });
1236
+ }
1317
1237
  function reportCheck(checks, label, ok, fixHint) {
1318
1238
  if (ok) {
1319
1239
  checks.push({ status: "ok", label });
@@ -1337,13 +1257,23 @@ function printDoctorCheck(check) {
1337
1257
  console.log(` ${pc.dim(check.hint)}`);
1338
1258
  }
1339
1259
  async function checkAgents(checks, reportRuntime) {
1340
- if (!existsSync10("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) {
1341
1271
  checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing", hint: "Run: rsp init" });
1342
1272
  return;
1343
1273
  }
1344
1274
  let agents;
1345
1275
  try {
1346
- agents = await readFile8("AGENTS.md", "utf-8");
1276
+ agents = await readFile7("AGENTS.md", "utf-8");
1347
1277
  } catch (error) {
1348
1278
  reportRuntime({
1349
1279
  code: "agents_read_failed",
@@ -1360,12 +1290,17 @@ async function checkAgents(checks, reportRuntime) {
1360
1290
  }
1361
1291
  checks.push({ status: "issue", label: "AGENTS.md contains managed RSP block", message: "AGENTS.md missing managed RSP block", hint: "Run: rsp init" });
1362
1292
  }
1363
- async function checkGeneratedIndex(checks, reportRuntime, path, signature, label, fixHint) {
1364
- if (!existsSync10(path))
1293
+ async function checkGeneratedIndex(checks, reportRuntime, path, expectedIndexType, label, fixHint) {
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)
1365
1300
  return;
1366
1301
  let content;
1367
1302
  try {
1368
- content = await readFile8(path, "utf-8");
1303
+ content = await readFile7(path, "utf-8");
1369
1304
  } catch (error) {
1370
1305
  reportRuntime({
1371
1306
  code: "generated_index_read_failed",
@@ -1376,16 +1311,38 @@ async function checkGeneratedIndex(checks, reportRuntime, path, signature, label
1376
1311
  checks.push({ status: "issue", label, message: `${label} could not be read`, hint: fixHint });
1377
1312
  return;
1378
1313
  }
1379
- if (content.includes(signature)) {
1380
- checks.push({ status: "ok", label });
1314
+ try {
1315
+ const fm = parseFrontmatter(content);
1316
+ if (fm?.kind === "generated-index" && fm.index_type === expectedIndexType) {
1317
+ checks.push({ status: "ok", label });
1318
+ return;
1319
+ }
1320
+ } catch (error) {
1321
+ reportRuntime({
1322
+ code: "generated_index_frontmatter_parse_failed",
1323
+ operation: "parseFrontmatter",
1324
+ path,
1325
+ message: toErrorMessage(error)
1326
+ });
1327
+ checks.push({ status: "issue", label, message: `${label} could not parse metadata`, hint: fixHint });
1381
1328
  return;
1382
1329
  }
1383
1330
  checks.push({ status: "issue", label, hint: fixHint });
1384
1331
  }
1385
- async function checkArchiveNaming(checks, reportRuntime) {
1386
- const archivesDir = join10(RSP_DIR, "archives");
1387
- const archiveFiles = existsSync10(archivesDir) ? (await walkMarkdownFiles(archivesDir, { onError: reportRuntime })).filter((fp) => relative5(archivesDir, fp) !== "INDEX.md") : [];
1388
- 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);
1389
1346
  if (invalidFiles.length === 0) {
1390
1347
  checks.push({ status: "ok", label: "archived change files follow the date_name.md convention" });
1391
1348
  return;
@@ -1398,69 +1355,172 @@ async function checkArchiveNaming(checks, reportRuntime) {
1398
1355
  });
1399
1356
  }
1400
1357
  async function checkConfigSemantics(checks, reportRuntime) {
1401
- if (!existsSync10(CONFIG_PATH))
1402
- return;
1403
- let content;
1358
+ clearConfigCache();
1359
+ let inspection;
1404
1360
  try {
1405
- content = await readFile8(CONFIG_PATH, "utf-8");
1361
+ inspection = await inspectRspConfig();
1406
1362
  } catch (error) {
1363
+ const message = toErrorMessage(error);
1407
1364
  reportRuntime({
1408
- code: "config_read_failed",
1409
- operation: "readFile",
1410
- path: CONFIG_PATH,
1411
- 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."
1412
1386
  });
1413
- 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" });
1414
- return;
1415
1387
  }
1416
- 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());
1417
1394
  try {
1418
- 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
+ );
1419
1414
  } catch (error) {
1420
- const message = error instanceof Error ? error.message : String(error);
1421
- checks.push({ status: "issue", label: "config.yaml semantic checks", message: "config.yaml is not valid YAML", hint: message });
1422
- return;
1423
- }
1424
- reportConfigListField(checks, parsed, "kinds");
1425
- 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
+ }
1426
1423
  checks.push({
1427
1424
  status: "issue",
1428
- label: "config.yaml semantic checks",
1429
- message: 'config.yaml field "required_sections" is no longer supported',
1430
- 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"
1431
1428
  });
1432
1429
  }
1433
1430
  }
1434
- function reportConfigListField(checks, parsed, key) {
1435
- 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))
1436
1434
  return;
1437
- const value = parsed[key];
1438
- 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) {
1439
1443
  checks.push({
1440
1444
  status: "issue",
1441
- label: "config.yaml semantic checks",
1442
- message: `config.yaml field "${key}" must be a YAML list`,
1443
- 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."
1444
1448
  });
1445
1449
  return;
1446
1450
  }
1447
- const items = value.map(String);
1448
- const duplicates = [...new Set(items.filter((item, index) => items.indexOf(item) !== index))];
1449
- if (duplicates.length === 0) {
1450
- checks.push({ status: "ok", label: `config.yaml field "${key}" has valid list semantics` });
1451
+ if (inactiveRecords.length === 0)
1451
1452
  return;
1452
- }
1453
1453
  checks.push({
1454
1454
  status: "issue",
1455
- label: "config.yaml semantic checks",
1456
- message: `config.yaml field "${key}" contains duplicate entries: ${duplicates.join(", ")}`,
1457
- 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}.`
1458
1458
  });
1459
1459
  }
1460
- async function checkActiveChangeConsistency(checks, reportRuntime) {
1461
- const changeFiles = existsSync10(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1462
- const changeNames = new Set(changeFiles.map((fp) => changeNameFromPath(CHANGES_DIR, fp)));
1463
- 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
+ }
1464
1524
  const missingChangeFiles = [...focusNames].filter((name) => !changeNames.has(name));
1465
1525
  const unfocusedChangeFiles = [...changeNames].filter((name) => !focusNames.has(name));
1466
1526
  if (missingChangeFiles.length === 0) {
@@ -1488,119 +1548,454 @@ async function checkActiveChangeConsistency(checks, reportRuntime) {
1488
1548
  }
1489
1549
 
1490
1550
  // src/commands/focus.ts
1491
- import { existsSync as existsSync11 } from "fs";
1492
- import { mkdir as mkdir6, unlink as unlink3, writeFile as writeFile7 } from "fs/promises";
1493
- import { dirname as dirname8, join as join11 } 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";
1494
1554
  async function focusChange(name) {
1495
1555
  if (!name) {
1496
1556
  console.error(` ${pc.red("Usage:")} rsp focus <name>`);
1497
1557
  process.exit(1);
1498
1558
  }
1499
- if (!isValidChangeName(name)) {
1500
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1501
- process.exit(1);
1502
- }
1503
1559
  guardRspInitialized();
1504
- const changePath = join11(CHANGES_DIR, `${name}.md`);
1505
- if (!existsSync11(changePath)) {
1506
- console.error(` ${pc.red("Change not found:")} .rsp/changes/${name}.md`);
1507
- 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);
1508
1573
  }
1509
- return withRspLock("focus-change", async () => {
1510
- await mkdir6(FOCUS_DIR, { recursive: true });
1511
- const focusEntry = join11(FOCUS_DIR, name);
1512
- await mkdir6(dirname8(focusEntry), { recursive: true });
1513
- await writeFile7(focusEntry, "");
1514
- console.log(` ${pc.green("Focused:")} ${name}`);
1515
- console.log(` ${pc.dim("focus.d")} \u2192 ${name}`);
1516
- console.log();
1517
- });
1518
1574
  }
1519
1575
  async function unfocusChange(name) {
1520
1576
  if (!name) {
1521
1577
  console.error(` ${pc.red("Usage:")} rsp unfocus <name>`);
1522
1578
  process.exit(1);
1523
1579
  }
1524
- if (!isValidChangeName(name)) {
1525
- console.error(` ${pc.red("Error:")} change name must be kebab-case with optional subdirectory (lowercase, digits, hyphens, slashes)`);
1526
- process.exit(1);
1527
- }
1528
1580
  guardRspInitialized();
1529
- const focusEntry = join11(FOCUS_DIR, name);
1530
- if (!existsSync11(focusEntry)) {
1531
- console.error(` ${pc.red("Focus marker not found:")} .rsp/focus.d/${name}`);
1532
- process.exit(1);
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);
1533
1595
  }
1534
- return withRspLock("unfocus-change", async () => {
1535
- await unlink3(focusEntry);
1536
- await cleanupEmptyParentDirs(focusEntry, FOCUS_DIR);
1537
- console.log(` ${pc.green("Unfocused:")} ${name}`);
1538
- console.log(` ${pc.dim("focus.d cleared")} \u2192 ${name}`);
1539
- console.log();
1540
- });
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]`);
1617
+ process.exit(1);
1618
+ }
1619
+ guardRspInitialized();
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>`);
1652
+ process.exit(1);
1653
+ }
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)) {
1899
+ console.log();
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();
1541
1909
  }
1542
1910
 
1543
1911
  // src/commands/init.ts
1544
- import { existsSync as existsSync12 } from "fs";
1545
- import { mkdir as mkdir7, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
1546
- import { join as join12 } 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";
1547
1915
  function generateConfigTemplate() {
1548
1916
  const fmtList = (items, indent) => items.map((i) => `${" ".repeat(indent)}# - ${i}`).join("\n");
1549
1917
  return `# RSP project configuration
1550
- # 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.
1551
1921
  #
1552
1922
  # Built-in defaults:
1553
1923
  # kinds: ${VALID_KINDS.join(", ")}
1554
1924
  #
1555
1925
  # kinds:
1556
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
1557
1932
  `;
1558
1933
  }
1559
1934
  async function ensureFile(path, content) {
1560
- if (existsSync12(path))
1561
- return false;
1562
- await writeFile8(path, content);
1563
- return true;
1935
+ return ensureManagedFile(path, content, "managed file");
1564
1936
  }
1565
1937
  function toTitle(projectName) {
1566
1938
  return projectName.startsWith("@") ? projectName.split("/")[1] || projectName : projectName;
1567
1939
  }
1568
1940
  async function initProject(args = {}) {
1569
- const isNew = !existsSync12(RSP_DIR);
1941
+ const isNew = !existsSync8(RSP_DIR);
1570
1942
  const projectName = await detectProjectName();
1571
1943
  const mode = args.agentsMode ?? "managed";
1572
1944
  if (mode !== "managed" && mode !== "print") {
1573
1945
  console.error(` ${pc.red("Error:")} unsupported --agents-mode "${mode}". Use: managed or print`);
1574
1946
  process.exit(1);
1575
1947
  }
1948
+ const agentsPath = "AGENTS.md";
1949
+ const initialAgentsInspection = inspectManagedFile(agentsPath, "AGENTS.md", { allowMissing: true });
1950
+ if (initialAgentsInspection.issue)
1951
+ throw initialAgentsInspection.issue;
1576
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
+ }
1577
1966
  const dirs = [
1578
- join12(RSP_DIR, "rules"),
1579
- join12(RSP_DIR, "specs"),
1580
- CHANGES_DIR,
1581
- FOCUS_DIR,
1582
- join12(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" }
1583
1971
  ];
1584
- for (const d of dirs)
1585
- await mkdir7(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 });
1586
1976
  let created = false;
1587
- const bundledRules = await readFile9(join12(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1588
- created = await ensureFile(join12(RSP_DIR, "rules", "rsp-rules.md"), bundledRules) || created;
1589
- created = await ensureFile(join12(RSP_DIR, "config.yaml"), generateConfigTemplate()) || created;
1590
- const createdSpecsIndex = await ensureFile(join12(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");
1591
1987
  created = createdSpecsIndex || created;
1592
- const createdDesign = await ensureFile(join12(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1988
+ const createdDesign = await ensureFile(join8(RSP_DIR, "specs", "design.md"), generateDesignContent(projectName));
1593
1989
  created = createdDesign || created;
1594
- const createdArchivesIndex = await ensureFile(join12(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1990
+ const createdArchivesIndex = await ensureFile(join8(RSP_DIR, "archives", "INDEX.md"), "# Archive Index\n");
1595
1991
  created = createdArchivesIndex || created;
1596
- created = await ensureFile(join12(CHANGES_DIR, ".gitkeep"), "") || created;
1597
- created = await ensureFile(join12(FOCUS_DIR, ".gitkeep"), "") || created;
1598
- created = await ensureFile(join12(RSP_DIR, ".gitignore"), "# Transient files that should not be committed\n.lock\n") || created;
1599
- if (args.withProjectRules)
1600
- created = await ensureFile(join12(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;
1601
1995
  if (args.withProjectSetup) {
1602
- const changePath = join12(CHANGES_DIR, "project-setup.md");
1603
- const focusMarker = join12(FOCUS_DIR, "project-setup");
1996
+ const projectSetupRef = resolveWorkRef("project-setup", { executable: true });
1997
+ const changePath = projectSetupRef.path;
1998
+ const focusMarker = resolveFocusMarkerPath(projectSetupRef);
1604
1999
  created = await ensureFile(changePath, generateChangeContent("project-setup")) || created;
1605
2000
  created = await ensureFile(focusMarker, "") || created;
1606
2001
  }
@@ -1608,27 +2003,26 @@ async function initProject(args = {}) {
1608
2003
  await buildSpecsIndex({ acquireLock: false });
1609
2004
  if (createdArchivesIndex)
1610
2005
  await buildArchiveIndex({ acquireLock: false });
1611
- const agentsPath = "AGENTS.md";
1612
2006
  const title = toTitle(projectName);
1613
2007
  let agentsUpdated = false;
1614
- if (!existsSync12(agentsPath)) {
1615
- await writeFile8(agentsPath, `# ${title}
2008
+ if (!agentsInspection.exists) {
2009
+ await writeManagedFile(agentsPath, `# ${title}
1616
2010
 
1617
2011
  ${upsertRspAgentsBlock("").content}
1618
- `);
2012
+ `, "AGENTS.md");
1619
2013
  created = true;
1620
2014
  agentsUpdated = true;
1621
2015
  } else {
1622
- const existing = await readFile9(agentsPath, "utf-8");
2016
+ const existing = await readFile8(agentsPath, "utf-8");
1623
2017
  const next = upsertRspAgentsBlock(existing);
1624
2018
  if (next.changed) {
1625
- await writeFile8(agentsPath, next.content);
2019
+ await writeManagedFile(agentsPath, next.content, "AGENTS.md");
1626
2020
  created = true;
1627
2021
  agentsUpdated = true;
1628
2022
  }
1629
2023
  }
1630
2024
  if (mode === "print") {
1631
- const managed = await readFile9(agentsPath, "utf-8");
2025
+ const managed = await readFile8(agentsPath, "utf-8");
1632
2026
  console.log(`
1633
2027
  ${managed}
1634
2028
  `);
@@ -1640,8 +2034,9 @@ ${managed}
1640
2034
  console.log(`
1641
2035
  ${pc.green("RSP scaffolded.")}
1642
2036
  `);
1643
- console.log(` Created: .rsp/rules/
2037
+ console.log(` Created: .rsp/rsp-rules.md
1644
2038
  .rsp/specs/
2039
+ ${decisionRecordsPath}/
1645
2040
  .rsp/changes/
1646
2041
  .rsp/focus.d/
1647
2042
  .rsp/archives/
@@ -1665,210 +2060,744 @@ ${managed}
1665
2060
  });
1666
2061
  }
1667
2062
 
1668
- // src/commands/status.ts
1669
- import { existsSync as existsSync13 } from "fs";
1670
- import { readFile as readFile10, stat } from "fs/promises";
1671
- import { join as join13 } from "path";
1672
- var COL_CHANGE = 32;
1673
- var COL_KIND = 10;
1674
- var COL_AGE = 7;
1675
- var COL_PROGRESS = 10;
1676
- async function showStatus(options = {}, runOptions = {}) {
2063
+ // src/commands/ready.ts
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
+ }
2081
+ async function showReady(name, options = {}) {
2082
+ if (!name) {
2083
+ console.error(` ${pc.red("Usage:")} rsp ready <name>`);
2084
+ process.exit(1);
2085
+ }
2086
+ guardRspInitialized();
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;
2094
+ }
2095
+ const srcPath = workRef.path;
1677
2096
  const runtime = [];
1678
- const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(runOptions.verbose) && !runOptions.json);
1679
- const focusedSet = await getFocusedChangeNames({ onError: reportRuntime });
1680
- const changeFiles = existsSync13(CHANGES_DIR) ? await walkMarkdownFiles(CHANGES_DIR, { onError: reportRuntime }) : [];
1681
- const changeMap = {};
1682
- for (const fp of changeFiles)
1683
- changeMap[changeNameFromPath(CHANGES_DIR, fp)] = fp;
1684
- const allNames = [.../* @__PURE__ */ new Set([...Object.keys(changeMap), ...focusedSet])].sort();
1685
- const records = [];
1686
- const outputRecords = [];
1687
- for (const name of allNames) {
1688
- const fp = changeMap[name];
1689
- let kind = "\u2014";
1690
- let progress = "\u2014";
1691
- let ageDays = null;
1692
- const isFocused = focusedSet.has(name);
1693
- let isBlocked = false;
1694
- let done = 0;
1695
- let total = 0;
1696
- if (fp) {
1697
- try {
1698
- const content = await readFile10(fp, "utf-8");
1699
- try {
1700
- const fm = parseFrontmatter(content);
1701
- kind = fm?.kind ? String(fm.kind) : "\u2014";
1702
- } catch (error) {
1703
- kind = "(invalid)";
1704
- reportRuntime({
1705
- code: "frontmatter_parse_failed",
1706
- operation: "parseFrontmatter",
1707
- path: fp,
1708
- message: toErrorMessage(error)
1709
- });
1710
- }
1711
- isBlocked = hasMeaningfulBlockers(content);
1712
- const cb = countCheckboxes(content);
1713
- done = cb.done;
1714
- total = cb.total;
1715
- progress = `${done}/${total}`;
1716
- } catch (error) {
1717
- reportRuntime({
1718
- code: "change_read_failed",
1719
- operation: "readFile",
1720
- path: fp,
1721
- message: toErrorMessage(error)
1722
- });
1723
- }
1724
- try {
1725
- const s = await stat(fp);
1726
- ageDays = Math.floor((Date.now() - s.mtime.getTime()) / (1e3 * 60 * 60 * 24));
1727
- } catch (error) {
1728
- reportRuntime({
1729
- code: "change_stat_failed",
1730
- operation: "stat",
1731
- path: fp,
1732
- message: toErrorMessage(error)
1733
- });
1734
- }
1735
- } else {
1736
- kind = "(missing)";
1737
- reportRuntime({
1738
- code: "focused_change_missing",
1739
- operation: "resolveChange",
1740
- path: join13(CHANGES_DIR, `${name}.md`),
1741
- message: "focus marker points to a missing change file"
1742
- });
2097
+ let content;
2098
+ try {
2099
+ content = await readFile9(srcPath, "utf-8");
2100
+ } catch {
2101
+ console.error(` ${pc.red("Error:")} unable to read .rsp/changes/${name}.md`);
2102
+ process.exit(1);
2103
+ }
2104
+ const dependencyInspection = await inspectChangeDependencies();
2105
+ const readinessDetails = collectArchiveReadiness(content, {
2106
+ activeBlockers: dependencyInspection.activeBlockers.get(name)
2107
+ });
2108
+ const checklist = readinessDetails.warnings;
2109
+ const readiness = {
2110
+ incompleteTasks: readinessDetails.taskTodos.length,
2111
+ incompleteVerify: readinessDetails.verifyTodos.length,
2112
+ activeBlockers: readinessDetails.activeBlockers,
2113
+ missingScenarios: readinessDetails.missingScenarios,
2114
+ deterministic: readinessDetails.deterministic,
2115
+ semantic: readinessDetails.semantic,
2116
+ archiveReady: readinessDetails.archiveReady
2117
+ };
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);
2131
+ const result = {
2132
+ command: "ready",
2133
+ ok: true,
2134
+ change: name,
2135
+ path: normalizeLogicalPath(srcPath),
2136
+ readiness,
2137
+ durableReview,
2138
+ warnings: checklist,
2139
+ runtime
2140
+ };
2141
+ if (options.json) {
2142
+ emitJson(result, options);
2143
+ return result;
2144
+ }
2145
+ console.log();
2146
+ console.log(` ${pc.bold("Archive readiness for")} ${pc.cyan(name)}`);
2147
+ console.log();
2148
+ if (checklist.length === 0) {
2149
+ console.log(` ${pc.green("\u2713")} Ready to archive. No deterministic warnings found.
2150
+ `);
2151
+ } else {
2152
+ for (const line of checklist)
2153
+ console.log(` ${pc.yellow("\u26A0")} ${line}`);
2154
+ console.log();
2155
+ console.log(` ${pc.dim("Review the warnings above before treating this work as fully closed.")}`);
2156
+ }
2157
+ console.log(` ${pc.dim("Deterministic readiness:")} ${readiness.deterministic === "pass" ? pc.green("pass") : pc.yellow("warnings")}`);
2158
+ console.log(` ${pc.dim("Semantic review:")} ${pc.yellow("needed")}`);
2159
+ console.log(` ${pc.dim("Archive ready:")} ${formatArchiveReady(readiness.archiveReady)}
2160
+ `);
2161
+ console.log(` ${pc.bold("Durable review:")}`);
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}`);
2166
+ console.log(` ${pc.dim(durableReview.note)}
2167
+ `);
2168
+ return result;
2169
+ }
2170
+ function formatArchiveReady(value) {
2171
+ switch (value) {
2172
+ case "yes":
2173
+ return pc.green("yes");
2174
+ case "no":
2175
+ return pc.yellow("no");
2176
+ case "judgment":
2177
+ return pc.yellow("judgment");
2178
+ }
2179
+ }
2180
+
2181
+ // src/commands/show.ts
2182
+ import { readFile as readFile10 } from "fs/promises";
2183
+ function exitShowError(error, options) {
2184
+ const nextActions = error.code === "no_focused_change" ? ["Run: rsp status", "Run: rsp focus <name>", "Or run: rsp create <name>"] : [];
2185
+ if (options.json) {
2186
+ emitJson({
2187
+ command: "show",
2188
+ ok: false,
2189
+ change: null,
2190
+ contextPaths: [],
2191
+ runtime: [],
2192
+ nextActions,
2193
+ error
2194
+ }, options);
2195
+ } else {
2196
+ console.error(` ${pc.red("Error:")} ${error.message}`);
2197
+ for (const action of nextActions)
2198
+ console.error(` ${pc.dim(action)}`);
2199
+ }
2200
+ process.exit(1);
2201
+ }
2202
+ async function showChange(nameOrFocused, options = {}) {
2203
+ const runtime = [];
2204
+ const reportRuntime = (diagnostic) => recordRuntimeDiagnostic(runtime, diagnostic, Boolean(options.verbose) && !options.json);
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));
2212
+ let name;
2213
+ if (options.focused) {
2214
+ if (focused.size === 0) {
2215
+ exitShowError({ code: "no_focused_change", message: "no focused change exists" }, options);
1743
2216
  }
1744
- const age = ageDays === null ? "\u2014" : String(ageDays);
1745
- records.push({ name, kind, progress, age, isFocused, isBlocked });
1746
- outputRecords.push({
2217
+ if (focused.size > 1) {
2218
+ exitShowError({
2219
+ code: "multiple_focused_changes",
2220
+ message: `multiple focused changes exist (${[...focused].join(", ")}). Specify a name or focus exactly one change.`
2221
+ }, options);
2222
+ }
2223
+ name = [...focused][0];
2224
+ } else if (nameOrFocused) {
2225
+ name = nameOrFocused;
2226
+ } else {
2227
+ exitShowError({ code: "missing_change_name", message: "Usage: rsp show <name|--focused> [--json] [--verbose]" }, options);
2228
+ }
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;
2236
+ }
2237
+ const srcPath = workRef.path;
2238
+ let content;
2239
+ try {
2240
+ content = await readFile10(srcPath, "utf-8");
2241
+ } catch {
2242
+ exitShowError({ code: "change_read_failed", message: `unable to read .rsp/changes/${name}.md` }, options);
2243
+ }
2244
+ let kind = "\u2014";
2245
+ try {
2246
+ const fm = parseFrontmatter(content);
2247
+ kind = fm?.kind ? String(fm.kind) : "\u2014";
2248
+ } catch (error) {
2249
+ kind = "(invalid)";
2250
+ reportRuntime({
2251
+ code: "frontmatter_parse_failed",
2252
+ operation: "parseFrontmatter",
2253
+ path: srcPath,
2254
+ message: toErrorMessage(error)
2255
+ });
2256
+ }
2257
+ const isFocused = focused.has(name);
2258
+ const dependencyInspection = await inspectChangeDependencies();
2259
+ const cb = countCheckboxes(content);
2260
+ const blockers = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
2261
+ const scenarios = parseScenarios(content);
2262
+ const readinessDetails = collectArchiveReadiness(content, { activeBlockers: blockers });
2263
+ const readiness = {
2264
+ incompleteTasks: readinessDetails.taskTodos.length,
2265
+ incompleteVerify: readinessDetails.verifyTodos.length,
2266
+ activeBlockers: readinessDetails.activeBlockers,
2267
+ missingScenarios: readinessDetails.missingScenarios,
2268
+ deterministic: readinessDetails.deterministic,
2269
+ semantic: readinessDetails.semantic,
2270
+ archiveReady: readinessDetails.archiveReady
2271
+ };
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);
2288
+ const result = {
2289
+ command: "show",
2290
+ ok: true,
2291
+ change: {
1747
2292
  name,
2293
+ path: normalizeLogicalPath(srcPath),
1748
2294
  kind,
1749
- progress: { done, total },
1750
- ageDays,
1751
2295
  isFocused,
1752
- isBlocked,
1753
- path: fp ? normalizeLogicalPath(fp) : null
2296
+ progress: { done: cb.done, total: cb.total },
2297
+ blockers,
2298
+ scenarioCount: scenarios.length,
2299
+ readiness
2300
+ },
2301
+ contextPaths,
2302
+ durableReview,
2303
+ runtime
2304
+ };
2305
+ if (options.json) {
2306
+ emitJson(result, options);
2307
+ return result;
2308
+ }
2309
+ console.log();
2310
+ console.log(` ${pc.bold("Change:")} ${pc.cyan(name)}`);
2311
+ console.log(` ${pc.dim("Path:")} ${normalizeLogicalPath(srcPath)}`);
2312
+ console.log(` ${pc.dim("Kind:")} ${kind}`);
2313
+ console.log(` ${pc.dim("Focused:")} ${isFocused ? pc.green("yes") : pc.dim("no")}`);
2314
+ console.log(` ${pc.dim("Progress:")} ${cb.done}/${cb.total}`);
2315
+ console.log(` ${pc.dim("Blockers:")} ${blockers ? pc.yellow("yes") : pc.green("no")}`);
2316
+ console.log(` ${pc.dim("Scenarios:")} ${scenarios.length}`);
2317
+ console.log();
2318
+ console.log(` ${pc.bold("Readiness:")}`);
2319
+ console.log(` ${pc.dim("Incomplete tasks:")} ${readiness.incompleteTasks > 0 ? pc.yellow(String(readiness.incompleteTasks)) : pc.green("0")}`);
2320
+ console.log(` ${pc.dim("Incomplete verify:")} ${readiness.incompleteVerify > 0 ? pc.yellow(String(readiness.incompleteVerify)) : pc.green("0")}`);
2321
+ console.log(` ${pc.dim("Active blockers:")} ${readiness.activeBlockers ? pc.yellow("yes") : pc.green("no")}`);
2322
+ console.log(` ${pc.dim("Missing scenarios:")} ${readiness.missingScenarios ? pc.yellow("yes") : pc.green("no")}`);
2323
+ console.log(` ${pc.dim("Deterministic:")} ${readiness.deterministic === "pass" ? pc.green("pass") : pc.yellow("warnings")}`);
2324
+ console.log(` ${pc.dim("Semantic review:")} ${pc.yellow("needed")}`);
2325
+ console.log(` ${pc.dim("Archive ready:")} ${formatArchiveReady2(readiness.archiveReady)}`);
2326
+ console.log();
2327
+ console.log(` ${pc.bold("Context paths:")}`);
2328
+ for (const cp of contextPaths)
2329
+ console.log(` ${pc.dim(cp)}`);
2330
+ console.log();
2331
+ console.log(` ${pc.bold("Durable review:")}`);
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}`);
2336
+ console.log(` ${pc.dim(durableReview.note)}`);
2337
+ console.log();
2338
+ return result;
2339
+ }
2340
+ function formatArchiveReady2(value) {
2341
+ switch (value) {
2342
+ case "yes":
2343
+ return pc.green("yes");
2344
+ case "no":
2345
+ return pc.yellow("no");
2346
+ case "judgment":
2347
+ return pc.yellow("judgment");
2348
+ }
2349
+ }
2350
+
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))
1754
2413
  });
1755
2414
  }
1756
- const filteredRecords = records.filter((record) => {
1757
- if (options.focused && !record.isFocused)
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))
1758
2443
  return false;
1759
- if (options.blocked && !record.isBlocked)
2444
+ const [sourceContent, targetContent] = await Promise.all([readFile11(sourcePath), readFile11(targetPath)]);
2445
+ if (!sourceContent.equals(targetContent))
1760
2446
  return false;
1761
- if (typeof options.stale === "number") {
1762
- const ageDays = Number(record.age);
1763
- if (!Number.isFinite(ageDays) || ageDays < options.stale)
1764
- 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;
1765
2477
  }
1766
- return true;
1767
- });
1768
- const filteredOutputRecords = outputRecords.filter((record) => {
1769
- if (options.focused && !record.isFocused)
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);
2542
+ try {
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));
2548
+ }
2549
+ }
2550
+ for (const name of movedPrevious.reverse()) {
2551
+ const target = join9(targetRoot, name);
2552
+ const previous = join9(previousRoot, name);
2553
+ try {
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));
2562
+ }
2563
+ }
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 });
2573
+ }
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)
1770
2593
  return false;
1771
- if (options.blocked && !record.isBlocked)
2594
+ if (query.blocked && !record.isBlocked)
2595
+ return false;
2596
+ if (query.stale !== null && (record.ageDays === null || record.ageDays < query.stale))
1772
2597
  return false;
1773
- if (typeof options.stale === "number") {
1774
- if (record.ageDays === null || record.ageDays < options.stale)
1775
- return false;
1776
- }
1777
2598
  return true;
1778
2599
  });
1779
- const blockedCount = filteredOutputRecords.filter((r) => r.isBlocked).length;
1780
- const statusResult = {
1781
- command: "status",
1782
- ok: true,
1783
- filters: {
1784
- focused: Boolean(options.focused),
1785
- blocked: Boolean(options.blocked),
1786
- stale: typeof options.stale === "number" ? options.stale : null
1787
- },
1788
- focused: [...focusedSet].sort(),
1789
- 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))),
1790
2609
  summary: {
1791
- total: filteredOutputRecords.length,
1792
- focused: filteredOutputRecords.filter((r) => r.isFocused).length,
1793
- blocked: blockedCount
2610
+ total: records.length,
2611
+ focused: records.filter((record) => record.output.isFocused).length,
2612
+ blocked: records.filter((record) => record.output.isBlocked).length
1794
2613
  },
1795
- archiveTrend: await readArchiveTrend(runtime, runOptions),
1796
- runtime
2614
+ nextActions,
2615
+ archiveTrend: snapshot.archiveTrend,
2616
+ diagnostics: snapshot.diagnostics,
2617
+ runtime: snapshot.runtime,
2618
+ hasExecutableChanges: snapshot.records.length > 0,
2619
+ ok
1797
2620
  };
1798
- if (runOptions.json) {
1799
- emitJson(statusResult);
1800
- return statusResult;
2621
+ }
2622
+ function buildStatusNextActions(focusedCount, records, groups, filtered) {
2623
+ const openChanges = records.map((record) => record.name).sort();
2624
+ if (focusedCount > 0)
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}`];
2631
+ if (openChanges.length === 0)
2632
+ return ["Run: rsp create <name>"];
2633
+ if (filtered) {
2634
+ return [
2635
+ "Run: rsp status",
2636
+ "Run: rsp focus <name>",
2637
+ "Or run: rsp create <name>"
2638
+ ];
1801
2639
  }
2640
+ const firstChanges = openChanges.slice(0, 3).join(", ");
2641
+ const extra = openChanges.length > 3 ? ` (+${openChanges.length - 3} more)` : "";
2642
+ const recommendation = recommendOpenChange(records, groups);
2643
+ return [
2644
+ `Open changes: ${firstChanges}${extra}`,
2645
+ recommendation ? `Run: rsp focus ${recommendation}` : "Resolve blockers before focusing an open change.",
2646
+ "Or run: rsp create <name>"
2647
+ ];
2648
+ }
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) {
1802
2696
  console.log();
1803
2697
  console.log(` ${pc.bold("RSP status")}`);
1804
2698
  console.log();
1805
- if (focusedSet.size > 0) {
1806
- console.log(` ${pc.cyan("Focused:")} ${[...focusedSet].join(", ")}`);
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(", ")}`);
1807
2707
  console.log();
1808
2708
  } else {
1809
- console.log(` ${pc.dim("No focused change.")} Run: rsp create <name> or rsp focus <name>`);
2709
+ console.log(` ${pc.dim("No focused change.")}`);
2710
+ for (const action of view.nextActions)
2711
+ console.log(` ${pc.dim(action)}`);
1810
2712
  console.log();
1811
2713
  }
1812
- if (allNames.length === 0) {
1813
- console.log(` ${pc.dim("No changes found.")} Run: rsp create <name>
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.")}
1814
2722
  `);
1815
- return statusResult;
2723
+ return;
1816
2724
  }
1817
- if (filteredRecords.length === 0) {
2725
+ if (view.records.length === 0) {
1818
2726
  console.log(` ${pc.dim("No changes match the current filters.")}
1819
2727
  `);
1820
- return statusResult;
1821
- }
1822
- const pad = (s, w) => s.padEnd(w);
1823
- console.log(` ${pad("Change", COL_CHANGE)} ${pad("Kind", COL_KIND)} ${pad("Age(d)", COL_AGE)} ${pad("Progress", COL_PROGRESS)}`);
1824
- console.log(` ${"\u2500".repeat(COL_CHANGE)} ${"\u2500".repeat(COL_KIND)} ${"\u2500".repeat(COL_AGE)} ${"\u2500".repeat(COL_PROGRESS)}`);
1825
- for (const r of filteredRecords) {
1826
- const marker = r.isFocused ? pc.cyan("*") : " ";
1827
- const namePadded = r.name.padEnd(COL_CHANGE - 1);
1828
- const kindPadded = r.kind.padEnd(COL_KIND);
1829
- const agePadded = r.age.padEnd(COL_AGE);
1830
- const progressPadded = r.progress.padEnd(COL_PROGRESS);
1831
- const nameDisplay = r.isBlocked ? pc.yellow(namePadded) : namePadded;
1832
- const ageDisplay = r.isFocused ? pc.cyan(agePadded) : agePadded;
1833
- console.log(` ${marker}${nameDisplay} ${kindPadded} ${ageDisplay} ${progressPadded}`);
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
+ }
1834
2750
  }
1835
2751
  console.log();
1836
- console.log(` ${pc.bold("Summary:")} ${statusResult.summary.total} change(s), ${statusResult.summary.focused} focused, ${pc.yellow(String(statusResult.summary.blocked))} blocked`);
2752
+ console.log(` ${pc.bold("Summary:")} ${view.summary.total} change(s), ${view.summary.focused} focused, ${pc.yellow(String(view.summary.blocked))} blocked`);
1837
2753
  console.log();
1838
- printArchiveTrend(statusResult.archiveTrend);
1839
- if (statusResult.summary.blocked > 0) {
1840
- const blocked = filteredRecords.filter((r) => r.isBlocked);
1841
- console.log(` ${pc.yellow("Blocked:")} ${blocked.map((r) => r.name).join(", ")}`);
1842
- console.log();
1843
- }
1844
- return statusResult;
2754
+ printArchiveTrend(view.archiveTrend);
1845
2755
  }
1846
- async function readArchiveTrend(runtime, runOptions) {
1847
- const indexPath = join13(ARCHIVES_DIR, "INDEX.md");
1848
- if (!existsSync13(indexPath))
1849
- return [];
1850
- try {
1851
- const content = await readFile10(indexPath, "utf-8");
1852
- const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
1853
- const counts = {};
1854
- let match;
1855
- while ((match = dateRe.exec(content)) !== null) {
1856
- const month = match[1];
1857
- counts[month] = (counts[month] || 0) + 1;
1858
- }
1859
- const months = Object.keys(counts).sort();
1860
- if (months.length === 0)
1861
- return [];
1862
- return months.map((month) => ({ month, count: counts[month] }));
1863
- } catch (error) {
1864
- recordRuntimeDiagnostic(runtime, {
1865
- code: "archive_index_read_failed",
1866
- operation: "readFile",
1867
- path: indexPath,
1868
- message: toErrorMessage(error)
1869
- }, Boolean(runOptions.verbose) && !runOptions.json);
1870
- return [];
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}`);
1871
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();
1872
2801
  }
1873
2802
  function printArchiveTrend(trend) {
1874
2803
  if (trend.length === 0)
@@ -1879,54 +2808,102 @@ function printArchiveTrend(trend) {
1879
2808
  console.log();
1880
2809
  }
1881
2810
 
1882
- // src/commands/update.ts
1883
- import { existsSync as existsSync14 } from "fs";
1884
- import { mkdir as mkdir8, readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
1885
- import { join as join14 } from "path";
1886
- var SKILL_REFRESH_HINT = " Note: if you use the published RSP skill, refresh it too:\n npx skills add oevery/rsp\n";
1887
- async function updateProject() {
1888
- if (!existsSync14(RSP_DIR)) {
1889
- console.error(` ${pc.red("Error:")} RSP is not initialized in this project`);
1890
- console.error(` ${pc.dim("Run: rsp init")}`);
1891
- process.exit(1);
1892
- }
1893
- return withRspLock("update", async () => {
1894
- let updated = false;
1895
- const bundledRules = await readFile11(join14(PKG_ROOT, "rules", "rsp-rules.md"), "utf-8");
1896
- const rulesPath = join14(RSP_DIR, "rules", "rsp-rules.md");
1897
- await mkdir8(join14(RSP_DIR, "rules"), { recursive: true });
1898
- const existingRules = existsSync14(rulesPath) ? await readFile11(rulesPath, "utf-8") : null;
1899
- if (existingRules !== bundledRules) {
1900
- await writeFile9(rulesPath, bundledRules);
1901
- console.log(` ${pc.green("\u2713")} rules/rsp-rules.md updated`);
1902
- updated = true;
1903
- }
1904
- const agentsPath = "AGENTS.md";
1905
- const projectName = await detectProjectName();
1906
- const baseAgents = existsSync14(agentsPath) ? await readFile11(agentsPath, "utf-8") : `# ${projectName}
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
+ }
1907
2859
 
1908
- `;
1909
- const nextAgents = upsertRspAgentsBlock(baseAgents);
1910
- if (!existsSync14(agentsPath) || nextAgents.changed) {
1911
- await writeFile9(agentsPath, nextAgents.content);
1912
- console.log(` ${pc.green("\u2713")} AGENTS.md managed block refreshed`);
1913
- updated = true;
1914
- }
1915
- await buildSpecsIndex({ acquireLock: false });
1916
- await buildArchiveIndex({ acquireLock: false });
1917
- if (!updated) {
1918
- console.log(` ${pc.dim("Already up to date.")}
1919
- `);
1920
- console.log(SKILL_REFRESH_HINT);
1921
- } else {
1922
- console.log(` ${pc.green("Update complete.")}
1923
- `);
1924
- console.log(SKILL_REFRESH_HINT);
1925
- }
1926
- });
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 };
1927
2894
  }
1928
2895
 
1929
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
+ }
1930
2907
  var initCommand = defineCommand({
1931
2908
  meta: {
1932
2909
  name: "init",
@@ -1938,11 +2915,6 @@ var initCommand = defineCommand({
1938
2915
  description: "How to handle AGENTS.md output: managed or print",
1939
2916
  default: "managed"
1940
2917
  },
1941
- "with-project-rules": {
1942
- type: "boolean",
1943
- description: "Create .rsp/rules/project-rules.md",
1944
- default: false
1945
- },
1946
2918
  "with-project-setup": {
1947
2919
  type: "boolean",
1948
2920
  description: "Create .rsp/changes/project-setup.md and focus it",
@@ -1952,7 +2924,6 @@ var initCommand = defineCommand({
1952
2924
  async run({ args }) {
1953
2925
  await initProject({
1954
2926
  agentsMode: args["agents-mode"] === "print" ? "print" : "managed",
1955
- withProjectRules: Boolean(args["with-project-rules"]),
1956
2927
  withProjectSetup: Boolean(args["with-project-setup"])
1957
2928
  });
1958
2929
  }
@@ -1971,27 +2942,58 @@ var createCommand = defineCommand({
1971
2942
  kind: {
1972
2943
  type: "string",
1973
2944
  description: "Optional kind for a kind-aware template (feature, fix, refactor, docs, ops, research)"
2945
+ },
2946
+ lite: {
2947
+ type: "boolean",
2948
+ description: "Use a shorter change template while keeping the required RSP sections",
2949
+ default: false
1974
2950
  }
1975
2951
  },
1976
2952
  async run({ args }) {
1977
2953
  const summary = Array.isArray(args._) && args._.length > 1 ? args._.slice(1).join(" ") : "";
1978
- await createChange(args.name, summary, args.kind);
2954
+ await createChange(args.name, summary, args.kind, { lite: Boolean(args.lite) });
1979
2955
  }
1980
2956
  });
1981
- var addRulesCommand = defineCommand({
2957
+ var groupCreateCommand = defineCommand({
1982
2958
  meta: {
1983
- name: "rules",
1984
- description: "Create .rsp/rules/<name>.md"
2959
+ name: "create",
2960
+ description: "Create an unfocused .rsp/changes/<group>/00-brief.md"
1985
2961
  },
1986
2962
  args: {
1987
2963
  name: {
1988
2964
  type: "positional",
1989
- description: "Rules file name",
2965
+ description: "Change Group name",
1990
2966
  required: true
1991
2967
  }
1992
2968
  },
1993
2969
  async run({ args }) {
1994
- 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
+ })
1995
2997
  }
1996
2998
  });
1997
2999
  var addSpecCommand = defineCommand({
@@ -2013,10 +3015,9 @@ var addSpecCommand = defineCommand({
2013
3015
  var addCommand = defineCommand({
2014
3016
  meta: {
2015
3017
  name: "add",
2016
- description: "Add optional rules or spec files"
3018
+ description: "Add an optional durable spec file"
2017
3019
  },
2018
3020
  subCommands: {
2019
- rules: addRulesCommand,
2020
3021
  spec: addSpecCommand
2021
3022
  }
2022
3023
  });
@@ -2026,14 +3027,19 @@ var archiveCommand = defineCommand({
2026
3027
  description: "Archive an open change into .rsp/archives/"
2027
3028
  },
2028
3029
  args: {
2029
- name: {
3030
+ "name": {
2030
3031
  type: "positional",
2031
3032
  description: "Change name",
2032
3033
  required: true
3034
+ },
3035
+ "dry-run": {
3036
+ type: "boolean",
3037
+ description: "Preview archive readiness without moving the change",
3038
+ default: false
2033
3039
  }
2034
3040
  },
2035
3041
  async run({ args }) {
2036
- await archiveChange(args.name);
3042
+ await archiveChange(args.name, { dryRun: Boolean(args["dry-run"]) });
2037
3043
  }
2038
3044
  });
2039
3045
  var focusCommand = defineCommand({
@@ -2093,6 +3099,11 @@ var statusCommand = defineCommand({
2093
3099
  description: "Print machine-readable JSON output",
2094
3100
  default: false
2095
3101
  },
3102
+ compact: {
3103
+ type: "boolean",
3104
+ description: "Print JSON without indentation (requires --json)",
3105
+ default: false
3106
+ },
2096
3107
  verbose: {
2097
3108
  type: "boolean",
2098
3109
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2101,41 +3112,54 @@ var statusCommand = defineCommand({
2101
3112
  },
2102
3113
  async run({ args }) {
2103
3114
  const stale = args.stale === void 0 ? void 0 : Number(args.stale);
2104
- 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)) {
2105
3116
  if (args.json) {
2106
- emitStatusJsonError({
3117
+ emitJson(toStatusJsonError({
2107
3118
  code: "invalid_stale_filter",
2108
3119
  message: "--stale must be a non-negative integer number of days"
2109
3120
  }, {
2110
3121
  focused: Boolean(args.focused),
2111
3122
  blocked: Boolean(args.blocked)
2112
- });
3123
+ }), { compact: Boolean(args.compact) });
2113
3124
  } else {
2114
3125
  console.error(` Error: --stale must be a non-negative integer number of days`);
2115
3126
  }
2116
3127
  process.exit(1);
2117
3128
  }
2118
- await showStatus({
3129
+ const result = await showStatus({
2119
3130
  focused: Boolean(args.focused),
2120
3131
  blocked: Boolean(args.blocked),
2121
3132
  stale
2122
3133
  }, {
2123
3134
  json: Boolean(args.json),
3135
+ compact: Boolean(args.compact),
2124
3136
  verbose: Boolean(args.verbose)
2125
3137
  });
3138
+ if (!result.ok)
3139
+ process.exit(1);
2126
3140
  }
2127
3141
  });
2128
3142
  var checkCommand = defineCommand({
2129
3143
  meta: {
2130
3144
  name: "check",
2131
- description: "Validate changes (frontmatter, sections, deltas, scenarios)"
3145
+ description: "Validate changes (frontmatter, sections, template text, deltas, scenarios)"
2132
3146
  },
2133
3147
  args: {
3148
+ focused: {
3149
+ type: "boolean",
3150
+ description: "Only validate currently focused changes",
3151
+ default: false
3152
+ },
2134
3153
  json: {
2135
3154
  type: "boolean",
2136
3155
  description: "Print machine-readable JSON output",
2137
3156
  default: false
2138
3157
  },
3158
+ compact: {
3159
+ type: "boolean",
3160
+ description: "Print JSON without indentation (requires --json)",
3161
+ default: false
3162
+ },
2139
3163
  verbose: {
2140
3164
  type: "boolean",
2141
3165
  description: "Print runtime diagnostics for suppressed I/O issues",
@@ -2143,7 +3167,12 @@ var checkCommand = defineCommand({
2143
3167
  }
2144
3168
  },
2145
3169
  async run({ args }) {
2146
- const result = await runCheck({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
3170
+ const result = await runCheck({
3171
+ focused: Boolean(args.focused),
3172
+ json: Boolean(args.json),
3173
+ compact: Boolean(args.compact),
3174
+ verbose: Boolean(args.verbose)
3175
+ });
2147
3176
  if (!result.ok)
2148
3177
  process.exit(1);
2149
3178
  }
@@ -2151,10 +3180,46 @@ var checkCommand = defineCommand({
2151
3180
  var updateCommand = defineCommand({
2152
3181
  meta: {
2153
3182
  name: "update",
2154
- description: "Refresh RSP project structure after upgrade (rules, AGENTS, indices)"
3183
+ description: "Refresh RSP project structure after upgrade (fallback protocol, AGENTS, indices)"
2155
3184
  },
2156
3185
  async run() {
2157
- 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
2158
3223
  }
2159
3224
  });
2160
3225
  var doctorCommand = defineCommand({
@@ -2168,49 +3233,220 @@ var doctorCommand = defineCommand({
2168
3233
  description: "Print machine-readable JSON output",
2169
3234
  default: false
2170
3235
  },
3236
+ compact: {
3237
+ type: "boolean",
3238
+ description: "Print JSON without indentation (requires --json)",
3239
+ default: false
3240
+ },
2171
3241
  verbose: {
2172
3242
  type: "boolean",
2173
3243
  description: "Print runtime diagnostics for suppressed I/O issues",
2174
3244
  default: false
3245
+ },
3246
+ fix: {
3247
+ type: "boolean",
3248
+ description: "Run safe deterministic repairs before reporting diagnostics",
3249
+ default: false
2175
3250
  }
2176
3251
  },
2177
3252
  async run({ args }) {
2178
- const result = await runDoctor({ json: Boolean(args.json), verbose: Boolean(args.verbose) });
3253
+ const result = await runDoctor({ json: Boolean(args.json), compact: Boolean(args.compact), verbose: Boolean(args.verbose), fix: Boolean(args.fix) });
2179
3254
  if (!result.ok)
2180
3255
  process.exit(1);
2181
3256
  }
2182
3257
  });
3258
+ var readyCommand = defineCommand({
3259
+ meta: {
3260
+ name: "ready",
3261
+ description: "Preview archive readiness for a change without moving it"
3262
+ },
3263
+ args: {
3264
+ name: {
3265
+ type: "positional",
3266
+ description: "Change name",
3267
+ required: true
3268
+ },
3269
+ json: {
3270
+ type: "boolean",
3271
+ description: "Print machine-readable JSON output",
3272
+ default: false
3273
+ },
3274
+ compact: {
3275
+ type: "boolean",
3276
+ description: "Print JSON without indentation (requires --json)",
3277
+ default: false
3278
+ },
3279
+ verbose: {
3280
+ type: "boolean",
3281
+ description: "Print runtime diagnostics for suppressed I/O issues",
3282
+ default: false
3283
+ }
3284
+ },
3285
+ async run({ args }) {
3286
+ await showReady(args.name, { json: Boolean(args.json), compact: Boolean(args.compact), verbose: Boolean(args.verbose) });
3287
+ }
3288
+ });
3289
+ var showCommand = defineCommand({
3290
+ meta: {
3291
+ name: "show",
3292
+ description: "Show change context with readiness signals and relevant paths"
3293
+ },
3294
+ args: {
3295
+ name: {
3296
+ type: "positional",
3297
+ description: "Change name (use --focused instead to show the currently focused change)",
3298
+ required: false
3299
+ },
3300
+ focused: {
3301
+ type: "boolean",
3302
+ description: "Show the currently focused change",
3303
+ default: false
3304
+ },
3305
+ json: {
3306
+ type: "boolean",
3307
+ description: "Print machine-readable JSON output",
3308
+ default: false
3309
+ },
3310
+ compact: {
3311
+ type: "boolean",
3312
+ description: "Print JSON without indentation (requires --json)",
3313
+ default: false
3314
+ },
3315
+ verbose: {
3316
+ type: "boolean",
3317
+ description: "Print runtime diagnostics for suppressed I/O issues",
3318
+ default: false
3319
+ }
3320
+ },
3321
+ async run({ args }) {
3322
+ await showChange(args.name, {
3323
+ focused: Boolean(args.focused),
3324
+ json: Boolean(args.json),
3325
+ compact: Boolean(args.compact),
3326
+ verbose: Boolean(args.verbose)
3327
+ });
3328
+ }
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
+ });
2183
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);
2184
3410
  const version = await getVersion();
2185
3411
  const main = defineCommand({
2186
3412
  meta: {
2187
3413
  name: "rsp",
2188
3414
  version,
2189
- 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"
2190
3416
  },
2191
3417
  subCommands: {
2192
3418
  init: initCommand,
2193
3419
  add: addCommand,
2194
3420
  create: createCommand,
3421
+ group: groupCommand,
2195
3422
  focus: focusCommand,
2196
3423
  unfocus: unfocusCommand,
2197
3424
  archive: archiveCommand,
3425
+ ready: readyCommand,
3426
+ show: showCommand,
3427
+ history: historyCommand,
2198
3428
  status: statusCommand,
2199
3429
  check: checkCommand,
2200
3430
  update: updateCommand,
2201
- doctor: doctorCommand
3431
+ doctor: doctorCommand,
3432
+ skills: skillsCommand
2202
3433
  }
2203
3434
  });
2204
3435
  await runMain(main, { rawArgs });
2205
3436
  }
2206
- var isMain = process.argv[1] && fileURLToPath2(import.meta.url) === resolve(process.argv[1]);
2207
- if (isMain) {
2208
- runCli().catch((err) => {
2209
- 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);
2210
3442
  console.error(` Error: ${message}`);
2211
- process.exit(1);
2212
- });
3443
+ process.exitCode = 1;
3444
+ }
2213
3445
  }
3446
+ var isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
3447
+ if (isMain)
3448
+ await runCliMain();
2214
3449
  export {
2215
- runCli
3450
+ runCli,
3451
+ runCliMain
2216
3452
  };