@oevery/rsp 3.1.1 → 3.3.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.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/README.zh-CN.md +3 -2
  3. package/dist/{chunk-WJXKLLIF.mjs → chunk-7HXQNBIG.mjs} +447 -734
  4. package/dist/{chunk-3Z4IO2TD.mjs → chunk-QM2UVPQK.mjs} +47 -30
  5. package/dist/{chunk-MUNLLVVH.mjs → chunk-TSUDEX5Y.mjs} +1205 -117
  6. package/dist/cli.mjs +5336 -3045
  7. package/dist/entry-GIDNR7CJ.mjs +1846 -0
  8. package/dist/{entry-KAWA2AS4.mjs → entry-YAW3PYFA.mjs} +4 -4
  9. package/package.json +12 -7
  10. package/rules/rsp-rules.md +30 -58
  11. package/skills/rsp/SKILL.md +28 -45
  12. package/skills/rsp/references/control-outcome.md +31 -0
  13. package/skills/rsp/references/durable-review.md +23 -4
  14. package/skills/rsp/references/focus-continuation.md +9 -0
  15. package/skills/rsp/references/implementation-evidence.md +12 -0
  16. package/skills/rsp/references/managed-routing.md +23 -18
  17. package/skills/rsp/references/response-language.md +11 -0
  18. package/skills/rsp/references/setup-repair.md +3 -3
  19. package/skills/rsp-commit/SKILL.md +26 -9
  20. package/skills/rsp-design/SKILL.md +1 -1
  21. package/skills/rsp-diagnose/SKILL.md +4 -2
  22. package/skills/rsp-implement/SKILL.md +18 -6
  23. package/skills/rsp-manage/SKILL.md +61 -37
  24. package/skills/rsp-manage/references/closeout.md +13 -0
  25. package/skills/rsp-manage/references/delegation.md +47 -0
  26. package/skills/rsp-manage/references/interruption-recovery.md +19 -0
  27. package/skills/rsp-manage/references/review-convergence.md +9 -0
  28. package/skills/rsp-release-docs/SKILL.md +15 -13
  29. package/skills/rsp-release-docs/references/publication-lifecycle.md +4 -0
  30. package/skills/rsp-resolve-findings/SKILL.md +3 -3
  31. package/skills/rsp-review/SKILL.md +10 -36
  32. package/skills/rsp-review/references/code-review.md +15 -0
  33. package/skills/rsp-review/references/document-review.md +15 -0
  34. package/skills/rsp-shape/SKILL.md +14 -4
  35. package/skills/rsp-shape/references/deep-clarification.md +2 -0
  36. package/skills/rsp-structural-audit/SKILL.md +1 -1
  37. package/skills/rsp-tdd/SKILL.md +11 -3
  38. package/skills/rsp-verify/SKILL.md +40 -0
  39. package/dist/entry-IPHJBI53.mjs +0 -882
  40. package/skills/rsp/references/release-operations.md +0 -9
@@ -4,45 +4,160 @@ import {
4
4
  CHANGES_DIR,
5
5
  CHANGE_DOCUMENT_SCHEMA,
6
6
  CONFIG_PATH,
7
+ DEFAULT_DECISION_RECORDS_PATH,
7
8
  FOCUS_DIR,
8
9
  GROUP_BRIEF_DOCUMENT_SCHEMA,
9
10
  ManagedPathError,
10
- collectArchiveReadiness,
11
+ OBSOLETE_RSP_RULES_PATH,
12
+ RSP_DIR,
13
+ RSP_RULES_PATH,
14
+ classifyVerifyCheckboxes,
15
+ collectVerifyCheckboxLines,
11
16
  countCheckboxes,
12
17
  extractBlockerLines,
13
18
  getCanonicalSectionHeadings,
14
19
  getDocumentSectionBody,
15
20
  getDocumentTitle,
16
21
  getDocumentTitles,
22
+ getOpenCheckboxes,
17
23
  hasMeaningfulBlockerBody,
18
24
  hasMeaningfulBlockers,
19
25
  inspectManagedDirectory,
26
+ inspectManagedFile,
27
+ inspectManagedFileTree,
20
28
  inspectRspConfig,
21
- normalizeLogicalPath,
29
+ isEmptyBlockerLine,
22
30
  parseFrontmatter,
23
31
  parseRspDocument,
24
32
  parseScenarios,
33
+ pc,
25
34
  renderDocumentSectionHeading,
26
35
  renderDocumentTitle,
27
36
  requireManagedDirectory,
28
37
  requireManagedFile,
38
+ resolveDecisionRecordsPath,
29
39
  resolveLanguagePolicy,
30
40
  resolveManagePolicy,
31
- toErrorMessage
32
- } from "./chunk-WJXKLLIF.mjs";
41
+ toErrorMessage,
42
+ validateDecisionRecordsFilesystemPath
43
+ } from "./chunk-7HXQNBIG.mjs";
33
44
 
34
45
  // src/status/inspect.ts
35
- import { readFile as readFile5, stat } from "fs/promises";
46
+ import { readFile as readFile4, stat } from "fs/promises";
36
47
 
37
48
  // src/core/change-group.ts
38
- import { readFile } from "fs/promises";
39
- import { dirname, join as join2, relative as relative2 } from "path";
49
+ import { readFile as readFile2 } from "fs/promises";
50
+ import { dirname as dirname2, join as join3, relative as relative3 } from "path";
51
+
52
+ // src/core/filesystem.ts
53
+ import { existsSync } from "fs";
54
+ import { readdir, readFile, rmdir } from "fs/promises";
55
+ import { basename, dirname, join, relative, sep } from "path";
56
+ async function walkMarkdownFiles(dir, options = {}) {
57
+ const all = await walkFiles(dir, options);
58
+ return all.filter((f) => f.endsWith(".md"));
59
+ }
60
+ async function inspectUnsupportedRules() {
61
+ const rulesDir = join(RSP_DIR, "rules");
62
+ const diagnostics = [];
63
+ const rawEntries = await walkAllEntries(rulesDir, diagnostics);
64
+ const entries = rawEntries.filter((entry) => entry.path !== OBSOLETE_RSP_RULES_PATH).map((entry) => ({ path: normalizeLogicalPath(relative(rulesDir, entry.path)), kind: entry.kind }));
65
+ const obsoleteFallbackPresent = existsSync(OBSOLETE_RSP_RULES_PATH);
66
+ return {
67
+ entries,
68
+ diagnostics,
69
+ directoryRemaining: existsSync(rulesDir) && (!obsoleteFallbackPresent || entries.length > 0)
70
+ };
71
+ }
72
+ async function walkAllEntries(dir, diagnostics) {
73
+ const entries = [];
74
+ try {
75
+ const items = await readdir(dir, { withFileTypes: true });
76
+ for (const item of items) {
77
+ const path = join(dir, item.name);
78
+ const kind = item.isDirectory() ? "directory" : "file";
79
+ entries.push({ path, kind });
80
+ if (kind === "directory")
81
+ entries.push(...await walkAllEntries(path, diagnostics));
82
+ }
83
+ } catch (error) {
84
+ if (error.code !== "ENOENT") {
85
+ diagnostics.push({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
86
+ }
87
+ }
88
+ return entries;
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?.({ code: "walk_failed", operation: "readdir", path: dir, message: toErrorMessage(error) });
106
+ }
107
+ }
108
+ return files;
109
+ }
110
+ function normalizeLogicalPath(pathValue) {
111
+ return pathValue.split(sep).join("/").replace(/\\/g, "/");
112
+ }
113
+ var SPEC_NAME_RE = /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
114
+ function isValidSpecName(name) {
115
+ return SPEC_NAME_RE.test(name);
116
+ }
117
+ function guardRspInitialized() {
118
+ const designPath = join(RSP_DIR, "specs", "design.md");
119
+ const rules = inspectManagedFile(RSP_RULES_PATH, "fallback protocol", { allowMissing: true });
120
+ const design = inspectManagedFile(designPath, "design Spec", { allowMissing: true });
121
+ if (rules.issue || design.issue || !rules.exists || !design.exists) {
122
+ const initialized = existsSync(RSP_DIR);
123
+ console.error(` ${pc.red("Error:")} ${initialized ? "RSP project requires an update" : "RSP is not initialized in this project"}`);
124
+ console.error(` ${pc.dim(initialized ? "Run: rsp update" : "Run: rsp init")}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+ async function cleanupEmptyParentDirs(path, stopDir) {
129
+ let parent = dirname(path);
130
+ while (parent !== stopDir) {
131
+ try {
132
+ const remaining = await readdir(parent);
133
+ if (remaining.length === 0)
134
+ await rmdir(parent);
135
+ else
136
+ break;
137
+ } catch {
138
+ break;
139
+ }
140
+ parent = dirname(parent);
141
+ }
142
+ }
143
+ async function detectProjectName() {
144
+ try {
145
+ if (existsSync("package.json")) {
146
+ const raw = await readFile("package.json", "utf-8");
147
+ const pkg = JSON.parse(raw);
148
+ if (pkg.name && pkg.name !== "")
149
+ return pkg.name;
150
+ }
151
+ } catch {
152
+ }
153
+ return basename(process.cwd());
154
+ }
40
155
 
41
156
  // src/core/work-ref.ts
42
157
  import { Buffer } from "buffer";
43
158
  import { lstatSync, readdirSync } from "fs";
44
- import { readdir } from "fs/promises";
45
- import { isAbsolute, join, relative } from "path";
159
+ import { readdir as readdir2 } from "fs/promises";
160
+ import { isAbsolute, join as join2, relative as relative2 } from "path";
46
161
  var WORK_SEGMENT_RE = /^[\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]+(?:-[\p{Ll}\p{Lo}\p{Lm}\p{M}\p{Nd}]+)*$/u;
47
162
  var WORK_SEGMENT_MAX_CODE_POINTS = 100;
48
163
  var WORK_SEGMENT_MAX_UTF8_BYTES = 200;
@@ -100,7 +215,7 @@ function resolveWorkRef(name, options = {}) {
100
215
  assertValidWorkRoot(changesDir, normalizedName);
101
216
  assertNoNormalizationCollision(changesDir, segments, normalizedName, isGroupBrief);
102
217
  assertNoIdentityCollision(changesDir, segments, normalizedName, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
103
- const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
218
+ const path = join2(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
104
219
  const ref = segments.length === 1 ? { kind: "change", name: normalizedName, path, group: null } : isGroupBrief ? { kind: "group-brief", name: normalizedName, path, group: segments[0] } : { kind: "group-change", name: normalizedName, path, group: segments[0] };
105
220
  if (ref.kind === "group-change" && options.executable !== false)
106
221
  assertGroupBrief(changesDir, ref);
@@ -115,7 +230,7 @@ function resolveWorkRef(name, options = {}) {
115
230
  return ref;
116
231
  }
117
232
  function assertGroupBrief(changesDir, ref) {
118
- const briefPath = join(changesDir, ref.group, GROUP_BRIEF_FILENAME);
233
+ const briefPath = join2(changesDir, ref.group, GROUP_BRIEF_FILENAME);
119
234
  const kind = getPathKind(briefPath, ref.name);
120
235
  if (kind === "missing") {
121
236
  throw new WorkRefError(
@@ -159,7 +274,7 @@ function assertRegularWorkFile(ref, mustExist) {
159
274
  }
160
275
  function resolveWorkRefPath(path, options = {}) {
161
276
  const changesDir = options.changesDir ?? CHANGES_DIR;
162
- const logicalPath = normalizeLogicalPath2(relative(changesDir, path));
277
+ const logicalPath = normalizeLogicalPath2(relative2(changesDir, path));
163
278
  if (isAbsolute(logicalPath) || logicalPath === ".." || logicalPath.startsWith("../") || !logicalPath.endsWith(".md")) {
164
279
  throw new WorkRefError(
165
280
  "invalid_work_ref_path",
@@ -187,13 +302,13 @@ function resolveFocusMarkerPath(ref, options = {}) {
187
302
  assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
188
303
  if (ref.group) {
189
304
  assertOptionalManagedDirectory(
190
- join(focusDir, ref.group),
305
+ join2(focusDir, ref.group),
191
306
  ref.name,
192
307
  "invalid_focus_path",
193
308
  "focus path"
194
309
  );
195
310
  }
196
- const markerPath = join(focusDir, ...ref.name.split("/"));
311
+ const markerPath = join2(focusDir, ...ref.name.split("/"));
197
312
  try {
198
313
  requireManagedFile(markerPath, "focus marker", { allowMissing: true });
199
314
  } catch (error) {
@@ -209,7 +324,7 @@ function resolveArchiveDirectory(ref, options = {}) {
209
324
  if (!ref.group)
210
325
  return archivesDir;
211
326
  assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
212
- const groupPath = join(archivesDir, ref.group);
327
+ const groupPath = join2(archivesDir, ref.group);
213
328
  assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
214
329
  return groupPath;
215
330
  }
@@ -228,7 +343,7 @@ async function inspectFocusTree(options = {}) {
228
343
  if (!rootEntries)
229
344
  return result;
230
345
  for (const entry of rootEntries) {
231
- const path = join(focusDir, entry.name);
346
+ const path = join2(focusDir, entry.name);
232
347
  if (entry.name === ".gitkeep" && entry.isFile())
233
348
  continue;
234
349
  if (entry.isDirectory()) {
@@ -271,7 +386,7 @@ async function inspectArchiveTree(options = {}) {
271
386
  if (!rootEntries)
272
387
  return result;
273
388
  for (const entry of rootEntries) {
274
- const path = join(archivesDir, entry.name);
389
+ const path = join2(archivesDir, entry.name);
275
390
  if (entry.name === "INDEX.md" && entry.isFile())
276
391
  continue;
277
392
  if (entry.isDirectory()) {
@@ -309,7 +424,7 @@ async function inspectWorkTree(options = {}) {
309
424
  const rootMarkdownNames = new Set(rootEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name.slice(0, -3)));
310
425
  const collidingGroups = new Set([...rootDirectories].filter((name) => rootMarkdownNames.has(name)));
311
426
  for (const entry of rootEntries) {
312
- const path = join(changesDir, entry.name);
427
+ const path = join2(changesDir, entry.name);
313
428
  if (entry.name === ".gitkeep" && entry.isFile())
314
429
  continue;
315
430
  if (entry.isDirectory()) {
@@ -356,17 +471,17 @@ async function inspectGroupDirectory(group, groupPath, changesDir, result, colli
356
471
  "group_brief_missing",
357
472
  `Change Group "${group}" requires a Group Brief; run: rsp group create ${group}`,
358
473
  group
359
- ), join(groupPath, GROUP_BRIEF_FILENAME));
474
+ ), join2(groupPath, GROUP_BRIEF_FILENAME));
360
475
  } else if (!briefEntry.isFile()) {
361
476
  addDiagnostic(result, new WorkRefError(
362
477
  "work_ref_not_file",
363
- `Group Brief must be a regular file: ${join(groupPath, GROUP_BRIEF_FILENAME)}`,
478
+ `Group Brief must be a regular file: ${join2(groupPath, GROUP_BRIEF_FILENAME)}`,
364
479
  group
365
- ), join(groupPath, GROUP_BRIEF_FILENAME));
480
+ ), join2(groupPath, GROUP_BRIEF_FILENAME));
366
481
  }
367
482
  for (const entry of entries) {
368
483
  const input = `${group}/${entry.name}`;
369
- const path = join(groupPath, entry.name);
484
+ const path = join2(groupPath, entry.name);
370
485
  if (entry.name === GROUP_BRIEF_FILENAME && !entry.isFile())
371
486
  continue;
372
487
  if (entry.name === "brief.md") {
@@ -395,7 +510,7 @@ async function inspectFocusGroup(group, groupPath, changesDir, result) {
395
510
  return;
396
511
  for (const entry of entries) {
397
512
  const input = `${group}/${entry.name}`;
398
- const path = join(groupPath, entry.name);
513
+ const path = join2(groupPath, entry.name);
399
514
  if (entry.isDirectory()) {
400
515
  addDiagnostic(result, new WorkRefError(
401
516
  "unsupported_work_depth",
@@ -421,7 +536,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
421
536
  return;
422
537
  for (const entry of entries) {
423
538
  const input = `${group}/${entry.name}`;
424
- const path = join(groupPath, entry.name);
539
+ const path = join2(groupPath, entry.name);
425
540
  if (!entry.isFile() || !entry.name.endsWith(".md")) {
426
541
  addArchivePathDiagnostic(result, input, path);
427
542
  continue;
@@ -454,7 +569,7 @@ function collectResolvedPath(path, changesDir, result) {
454
569
  }
455
570
  async function readDirectory(path, input, result) {
456
571
  try {
457
- return (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
572
+ return (await readdir2(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
458
573
  } catch (error) {
459
574
  addDiagnostic(result, asReadFailure(path, input, error), path);
460
575
  return null;
@@ -515,7 +630,7 @@ function normalizeWorkRefName(name, allowGroupBrief) {
515
630
  }
516
631
  function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrief) {
517
632
  for (let depth = 0; depth < segments.length; depth++) {
518
- const parent = join(changesDir, ...segments.slice(0, depth));
633
+ const parent = join2(changesDir, ...segments.slice(0, depth));
519
634
  const parentKind = getPathKind(parent, input);
520
635
  if (parentKind === "missing")
521
636
  return;
@@ -548,7 +663,7 @@ function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrie
548
663
  }
549
664
  function assertNoManagedNormalizationCollision(root, segments, input, code) {
550
665
  for (let depth = 0; depth < segments.length; depth++) {
551
- const parent = join(root, ...segments.slice(0, depth));
666
+ const parent = join2(root, ...segments.slice(0, depth));
552
667
  const parentKind = getPathKind(parent, input);
553
668
  if (parentKind === "missing")
554
669
  return;
@@ -573,8 +688,8 @@ function assertNoManagedNormalizationCollision(root, segments, input, code) {
573
688
  }
574
689
  function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
575
690
  for (let depth = 1; depth <= segments.length; depth++) {
576
- const identityPath = join(changesDir, ...segments.slice(0, depth));
577
- const filePath = finalFilename && depth === segments.length ? join(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
691
+ const identityPath = join2(changesDir, ...segments.slice(0, depth));
692
+ const filePath = finalFilename && depth === segments.length ? join2(changesDir, ...segments.slice(0, -1), finalFilename) : `${identityPath}.md`;
578
693
  const fileKind = getPathKind(filePath, input);
579
694
  const identityKind = getPathKind(identityPath, input);
580
695
  const isPrefix = depth < segments.length;
@@ -664,6 +779,43 @@ function normalizeLogicalPath2(path) {
664
779
  return path.split("\\").join("/");
665
780
  }
666
781
 
782
+ // src/core/work-summary.ts
783
+ var PLACEHOLDER_SUMMARY = "<\u2026>";
784
+ function extractChangeSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), options = {}) {
785
+ try {
786
+ const frontmatter = parseFrontmatter(content);
787
+ const frontmatterSummary = meaningfulSummary(frontmatter?.summary, options);
788
+ if (frontmatterSummary)
789
+ return frontmatterSummary;
790
+ } catch {
791
+ }
792
+ const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
793
+ for (const field of ["Outcome", "Summary"]) {
794
+ const prefix = `- ${field}:`;
795
+ for (const line of proposalLines) {
796
+ const summary = line.startsWith(prefix) ? meaningfulSummary(line.slice(prefix.length), options) : null;
797
+ if (summary)
798
+ return summary;
799
+ }
800
+ }
801
+ return null;
802
+ }
803
+ function extractGroupSummary(content, document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA)) {
804
+ for (const line of getDocumentSectionBody(document, "goal").split("\n")) {
805
+ const trimmed = line.trim();
806
+ const summary = trimmed.startsWith("- ") || trimmed.startsWith("* ") ? meaningfulSummary(trimmed.slice(2)) : null;
807
+ if (summary)
808
+ return summary;
809
+ }
810
+ return null;
811
+ }
812
+ function meaningfulSummary(value, options = {}) {
813
+ if (typeof value !== "string")
814
+ return null;
815
+ const summary = value.trim();
816
+ return summary && (options.preservePlaceholder || summary !== PLACEHOLDER_SUMMARY) ? summary : null;
817
+ }
818
+
667
819
  // src/core/change-group.ts
668
820
  var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
669
821
  var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
@@ -716,12 +868,12 @@ async function inspectChangeGroups(options = {}) {
716
868
  const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
717
869
  const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
718
870
  for (const path of archiveTree.files) {
719
- const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
871
+ const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, path));
720
872
  const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
721
873
  if (!archiveGroup || !openGroupNames.has(archiveGroup))
722
874
  continue;
723
875
  try {
724
- const content = await readFile(path, "utf-8");
876
+ const content = await readFile2(path, "utf-8");
725
877
  const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
726
878
  const identity = getDocumentTitle(archivedChange, "identity");
727
879
  if (identity?.includes("/")) {
@@ -762,7 +914,7 @@ async function inspectChangeGroups(options = {}) {
762
914
  for (const brief of workTree.briefs) {
763
915
  let content;
764
916
  try {
765
- content = await readFile(brief.path, "utf-8");
917
+ content = await readFile2(brief.path, "utf-8");
766
918
  } catch (error) {
767
919
  diagnostics.push({
768
920
  severity: "error",
@@ -791,7 +943,7 @@ async function inspectChangeGroups(options = {}) {
791
943
  for (const diagnostic of archiveTree.diagnostics.filter((item) => {
792
944
  if (item.code === "invalid_archive_root")
793
945
  return true;
794
- const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, item.path));
946
+ const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, item.path));
795
947
  return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
796
948
  })) {
797
949
  diagnostics.push({
@@ -859,6 +1011,7 @@ async function inspectChangeGroups(options = {}) {
859
1011
  const readyToClose = diagnostics.every((diagnostic) => diagnostic.change !== brief.group || diagnostic.severity !== "error") && slices.length >= 2 && slices.every((slice) => slice.state === "archived") && parsed.completion.total > 0 && parsed.completion.done === parsed.completion.total && !parsed.blockers;
860
1012
  groups.push({
861
1013
  name: brief.group,
1014
+ summary: extractGroupSummary(content),
862
1015
  path: normalizeLogicalPath(brief.path),
863
1016
  slices,
864
1017
  completion: parsed.completion,
@@ -881,10 +1034,10 @@ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroup
881
1034
  return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
882
1035
  }
883
1036
  async function requireDeclaredGroupSlice(ref) {
884
- const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
1037
+ const briefPath = join3(dirname2(ref.path), GROUP_BRIEF_FILENAME);
885
1038
  let content;
886
1039
  try {
887
- content = await readFile(briefPath, "utf-8");
1040
+ content = await readFile2(briefPath, "utf-8");
888
1041
  } catch (error) {
889
1042
  const message = error instanceof Error ? error.message : String(error);
890
1043
  throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
@@ -914,11 +1067,11 @@ async function hasArchivedGroupBrief(group) {
914
1067
  }
915
1068
  const groupPrefix = `${group}/`;
916
1069
  for (const path of archiveTree.files) {
917
- if (!normalizeLogicalPath(relative2(ARCHIVES_DIR, path)).startsWith(groupPrefix))
1070
+ if (!normalizeLogicalPath(relative3(ARCHIVES_DIR, path)).startsWith(groupPrefix))
918
1071
  continue;
919
1072
  let content;
920
1073
  try {
921
- content = await readFile(path, "utf-8");
1074
+ content = await readFile2(path, "utf-8");
922
1075
  } catch (error) {
923
1076
  const message = error instanceof Error ? error.message : String(error);
924
1077
  throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
@@ -991,14 +1144,187 @@ function groupSectionHeading(sectionId) {
991
1144
  }
992
1145
 
993
1146
  // src/core/dependency-plan.ts
994
- import { readFile as readFile4 } from "fs/promises";
995
-
996
- // src/history/query.ts
997
1147
  import { readFile as readFile3 } from "fs/promises";
998
1148
 
1149
+ // src/history/current-file.ts
1150
+ import { Buffer as Buffer2 } from "buffer";
1151
+ import { constants } from "fs";
1152
+ import { lstat, open, realpath } from "fs/promises";
1153
+ import { isAbsolute as isAbsolute2, relative as relative4, resolve, sep as sep2 } from "path";
1154
+
1155
+ // src/history/model.ts
1156
+ var HISTORY_DEFAULT_LIMIT = 20;
1157
+ var HISTORY_MAX_LIMIT = 100;
1158
+ var HISTORY_MAX_TEXT_CODE_POINTS = 500;
1159
+ var HISTORY_MAX_EVIDENCE_ITEMS = 20;
1160
+ var HISTORY_MAX_DIAGNOSTICS = 20;
1161
+ var HISTORY_MAX_CANDIDATES = 20;
1162
+ var HISTORY_MAX_FILE_BYTES = 512 * 1024;
1163
+ var ArchiveHistoryError = class extends Error {
1164
+ constructor(code, message, candidates = []) {
1165
+ super(message);
1166
+ this.code = code;
1167
+ this.name = "ArchiveHistoryError";
1168
+ const ordered = [...candidates].sort();
1169
+ this.candidateTotal = ordered.length;
1170
+ this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
1171
+ this.candidatesTruncated = this.candidateTotal > this.candidates.length;
1172
+ }
1173
+ code;
1174
+ candidates;
1175
+ candidateTotal;
1176
+ candidatesTruncated;
1177
+ };
1178
+
1179
+ // src/history/current-file.ts
1180
+ var READ_NO_FOLLOW_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
1181
+ async function readCurrentArchiveFile(options) {
1182
+ const sourcePath = resolve(options.sourcePath);
1183
+ const archivesDir = resolve(options.archivesDir);
1184
+ const relativePath = relative4(archivesDir, sourcePath);
1185
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep2}`) || isAbsolute2(relativePath)) {
1186
+ throw changed(options.projectPath);
1187
+ }
1188
+ let inspectedRoot;
1189
+ let inspected;
1190
+ let realArchivesDir;
1191
+ try {
1192
+ inspectedRoot = await lstat(archivesDir, { bigint: true });
1193
+ if (!inspectedRoot.isDirectory())
1194
+ throw changed(options.projectPath);
1195
+ inspected = await lstat(sourcePath, { bigint: true });
1196
+ realArchivesDir = await realpath(archivesDir);
1197
+ const resolvedSource = await realpath(sourcePath);
1198
+ const expectedSource = resolve(realArchivesDir, relativePath);
1199
+ if (!inspected.isFile() || resolvedSource !== expectedSource || !isContained(realArchivesDir, resolvedSource)) {
1200
+ throw changed(options.projectPath);
1201
+ }
1202
+ } catch (error) {
1203
+ if (error instanceof ArchiveHistoryError)
1204
+ throw error;
1205
+ if (isPermissionError(error))
1206
+ throw readFailed(options.projectPath);
1207
+ throw changed(options.projectPath);
1208
+ }
1209
+ let handle;
1210
+ try {
1211
+ handle = await open(sourcePath, READ_NO_FOLLOW_FLAGS);
1212
+ } catch (error) {
1213
+ if (isPermissionError(error))
1214
+ throw readFailed(options.projectPath);
1215
+ throw changed(options.projectPath);
1216
+ }
1217
+ try {
1218
+ const opened = await handle.stat({ bigint: true });
1219
+ const currentRoot = await realpath(archivesDir);
1220
+ const currentSource = await realpath(sourcePath);
1221
+ if (!opened.isFile() || currentRoot !== realArchivesDir || currentSource !== resolve(currentRoot, relativePath) || !isContained(currentRoot, currentSource) || !sameIdentity(inspected, opened) || !sameSnapshot(inspected, opened)) {
1222
+ throw changed(options.projectPath);
1223
+ }
1224
+ if (opened.size > BigInt(options.maxFileBytes))
1225
+ throw tooLarge(options.projectPath, options.maxFileBytes);
1226
+ if (options.expectedSnapshot !== void 0 && !sameFileSnapshot(options.expectedSnapshot, opened)) {
1227
+ throw changed(options.projectPath);
1228
+ }
1229
+ const content = await readBoundedArchive(handle, opened, options.projectPath, options.maxFileBytes);
1230
+ await validateCurrentArchivePath({
1231
+ sourcePath,
1232
+ archivesDir,
1233
+ relativePath,
1234
+ realArchivesDir,
1235
+ inspectedRoot,
1236
+ finalHandle: content.final,
1237
+ projectPath: options.projectPath
1238
+ });
1239
+ return {
1240
+ sourcePath,
1241
+ archivesDir,
1242
+ content: content.content,
1243
+ bytes: content.bytes,
1244
+ snapshot: {
1245
+ device: content.final.dev,
1246
+ inode: content.final.ino,
1247
+ size: content.final.size,
1248
+ mtimeNs: content.final.mtimeNs,
1249
+ ctimeNs: content.final.ctimeNs
1250
+ }
1251
+ };
1252
+ } catch (error) {
1253
+ if (error instanceof ArchiveHistoryError)
1254
+ throw error;
1255
+ throw changed(options.projectPath);
1256
+ } finally {
1257
+ await handle.close();
1258
+ }
1259
+ }
1260
+ async function readBoundedArchive(handle, opened, projectPath, maxFileBytes) {
1261
+ const buffer = Buffer2.allocUnsafe(maxFileBytes + 1);
1262
+ let bytes = 0;
1263
+ while (bytes < buffer.length) {
1264
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
1265
+ if (result.bytesRead === 0)
1266
+ break;
1267
+ bytes += result.bytesRead;
1268
+ }
1269
+ const final = await handle.stat({ bigint: true });
1270
+ if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
1271
+ throw tooLarge(projectPath, maxFileBytes);
1272
+ if (!sameIdentity(opened, final) || !sameSnapshot(opened, final) || final.size !== BigInt(bytes)) {
1273
+ throw changed(projectPath);
1274
+ }
1275
+ return {
1276
+ content: buffer.subarray(0, bytes).toString("utf-8"),
1277
+ bytes,
1278
+ final
1279
+ };
1280
+ }
1281
+ async function validateCurrentArchivePath(options) {
1282
+ const currentRoot = await lstat(options.archivesDir, { bigint: true });
1283
+ const currentSource = await lstat(options.sourcePath, { bigint: true });
1284
+ const realCurrentRoot = await realpath(options.archivesDir);
1285
+ const realCurrentSource = await realpath(options.sourcePath);
1286
+ if (!currentRoot.isDirectory() || !currentSource.isFile() || !sameIdentity(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realArchivesDir || realCurrentSource !== resolve(realCurrentRoot, options.relativePath) || !isContained(realCurrentRoot, realCurrentSource) || !sameIdentity(currentSource, options.finalHandle) || !sameSnapshot(currentSource, options.finalHandle)) {
1287
+ throw changed(options.projectPath);
1288
+ }
1289
+ }
1290
+ function sameIdentity(left, right) {
1291
+ return left.dev === right.dev && left.ino === right.ino;
1292
+ }
1293
+ function sameSnapshot(left, right) {
1294
+ return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
1295
+ }
1296
+ function sameFileSnapshot(left, right) {
1297
+ return left.device === right.dev && left.inode === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
1298
+ }
1299
+ function isContained(root, path) {
1300
+ const candidate = relative4(root, path);
1301
+ return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep2}`) && !isAbsolute2(candidate);
1302
+ }
1303
+ function changed(projectPath) {
1304
+ return new ArchiveHistoryError(
1305
+ "archive_file_changed",
1306
+ `archived Change is no longer the inspected readable regular file: ${projectPath}`
1307
+ );
1308
+ }
1309
+ function tooLarge(projectPath, maxFileBytes) {
1310
+ return new ArchiveHistoryError(
1311
+ "archive_file_too_large",
1312
+ `archived Change exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`
1313
+ );
1314
+ }
1315
+ function readFailed(projectPath) {
1316
+ return new ArchiveHistoryError(
1317
+ "archive_read_failed",
1318
+ `unable to read archived Change ${projectPath}`
1319
+ );
1320
+ }
1321
+ function isPermissionError(error) {
1322
+ const code = error.code;
1323
+ return code === "EACCES" || code === "EPERM";
1324
+ }
1325
+
999
1326
  // src/history/inspect.ts
1000
- import { readFile as readFile2 } from "fs/promises";
1001
- import { basename, dirname as dirname2, relative as relative3 } from "path";
1327
+ import { basename as basename2, dirname as dirname3, relative as relative5, resolve as resolve2 } from "path";
1002
1328
 
1003
1329
  // src/core/issue-relationship.ts
1004
1330
  var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
@@ -1063,33 +1389,11 @@ function isPlainObject(value) {
1063
1389
  return typeof value === "object" && value !== null && !Array.isArray(value);
1064
1390
  }
1065
1391
 
1066
- // src/history/model.ts
1067
- var HISTORY_DEFAULT_LIMIT = 20;
1068
- var HISTORY_MAX_LIMIT = 100;
1069
- var HISTORY_MAX_TEXT_CODE_POINTS = 500;
1070
- var HISTORY_MAX_EVIDENCE_ITEMS = 20;
1071
- var HISTORY_MAX_DIAGNOSTICS = 20;
1072
- var HISTORY_MAX_CANDIDATES = 20;
1073
- var ArchiveHistoryError = class extends Error {
1074
- constructor(code, message, candidates = []) {
1075
- super(message);
1076
- this.code = code;
1077
- this.name = "ArchiveHistoryError";
1078
- const ordered = [...candidates].sort();
1079
- this.candidateTotal = ordered.length;
1080
- this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
1081
- this.candidatesTruncated = this.candidateTotal > this.candidates.length;
1082
- }
1083
- code;
1084
- candidates;
1085
- candidateTotal;
1086
- candidatesTruncated;
1087
- };
1088
-
1089
1392
  // src/history/inspect.ts
1090
1393
  var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1091
1394
  async function inspectArchiveHistory(options = {}) {
1092
- const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1395
+ const archivesDir = resolve2(options.archivesDir ?? ARCHIVES_DIR);
1396
+ const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
1093
1397
  const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
1094
1398
  const diagnostics = tree.diagnostics.map((diagnostic) => ({
1095
1399
  severity: "error",
@@ -1110,15 +1414,21 @@ async function inspectArchiveHistory(options = {}) {
1110
1414
  }
1111
1415
  for (const sourcePath of tree.files) {
1112
1416
  const path = archiveOutputPath(sourcePath, archivesDir);
1113
- let content;
1417
+ let current;
1114
1418
  try {
1115
- content = await readFile2(sourcePath, "utf-8");
1419
+ current = await readCurrentArchiveFile({
1420
+ sourcePath,
1421
+ archivesDir,
1422
+ projectPath: path,
1423
+ maxFileBytes
1424
+ });
1116
1425
  } catch (error) {
1117
- const message = toErrorMessage(error);
1118
- diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1426
+ const code = error instanceof ArchiveHistoryError ? error.code : "archive_read_failed";
1427
+ const message = error instanceof ArchiveHistoryError ? error.message : `unable to read archived work: ${toErrorMessage(error)}`;
1428
+ diagnostics.push({ severity: "error", code, path, message });
1119
1429
  continue;
1120
1430
  }
1121
- const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1431
+ const parsed = parseArchiveEntry(current, path, maxFileBytes);
1122
1432
  if (parsed.diagnostic)
1123
1433
  diagnostics.push(parsed.diagnostic);
1124
1434
  if (parsed.record)
@@ -1144,14 +1454,15 @@ async function inspectArchiveHistory(options = {}) {
1144
1454
  runtime
1145
1455
  };
1146
1456
  }
1147
- function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1148
- const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
1457
+ function parseArchiveEntry(current, path, maxFileBytes) {
1458
+ const { sourcePath, archivesDir, content } = current;
1459
+ const nameMatch = basename2(sourcePath).match(ARCHIVE_NAME_RE);
1149
1460
  if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1150
1461
  return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
1151
1462
  }
1152
1463
  const date = nameMatch[1];
1153
1464
  const archiveStem = nameMatch[2];
1154
- const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1465
+ const relativeDirectory = normalizeLogicalPath(relative5(archivesDir, dirname3(sourcePath)));
1155
1466
  const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1156
1467
  if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
1157
1468
  return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
@@ -1195,7 +1506,7 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1195
1506
  if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1196
1507
  return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1197
1508
  }
1198
- const rawSummary = extractSummary(content, changeDocument);
1509
+ const rawSummary = extractChangeSummary(content, changeDocument, { preservePlaceholder: true });
1199
1510
  if (!rawSummary)
1200
1511
  return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1201
1512
  const summary = boundText(rawSummary);
@@ -1210,22 +1521,13 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1210
1521
  path,
1211
1522
  ...issues === void 0 ? {} : { issues },
1212
1523
  sourcePath,
1524
+ archivesDir,
1525
+ sourceSnapshot: current.snapshot,
1526
+ maxFileBytes,
1213
1527
  searchSummary: rawSummary
1214
1528
  }
1215
1529
  };
1216
1530
  }
1217
- function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
1218
- const frontmatter = parseFrontmatter(content);
1219
- if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
1220
- return frontmatter.summary.trim();
1221
- const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
1222
- for (const prefix of ["- Outcome:", "- Summary:"]) {
1223
- const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
1224
- if (line && line.slice(prefix.length).trim())
1225
- return line.slice(prefix.length).trim();
1226
- }
1227
- return "";
1228
- }
1229
1531
  function boundText(value) {
1230
1532
  const codePoints = [...value];
1231
1533
  return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
@@ -1238,10 +1540,10 @@ function isCalendarDate(value) {
1238
1540
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1239
1541
  }
1240
1542
  function archiveOutputPath(sourcePath, archivesDir) {
1241
- return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1543
+ return `.rsp/archives/${normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(sourcePath)))}`;
1242
1544
  }
1243
1545
  function normalizeOutputPath(path, archivesDir) {
1244
- const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1546
+ const relativePath = normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(path)));
1245
1547
  return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1246
1548
  }
1247
1549
  function errorDiagnostic(code, path, message) {
@@ -1322,17 +1624,28 @@ function selectArchivedGroupBrief(records, selector) {
1322
1624
  return matches[0];
1323
1625
  }
1324
1626
  async function readArchiveHistoryDetail(record) {
1627
+ return (await readArchiveHistoryDocument(record)).detail;
1628
+ }
1629
+ async function readArchiveHistoryDocument(record) {
1325
1630
  let content;
1326
1631
  try {
1327
- content = await readFile3(record.sourcePath, "utf-8");
1632
+ content = (await readCurrentArchiveFile({
1633
+ sourcePath: record.sourcePath,
1634
+ archivesDir: record.archivesDir,
1635
+ projectPath: record.path,
1636
+ maxFileBytes: record.maxFileBytes,
1637
+ expectedSnapshot: record.sourceSnapshot
1638
+ })).content;
1328
1639
  } catch (error) {
1640
+ if (error instanceof ArchiveHistoryError)
1641
+ throw error;
1329
1642
  throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1330
1643
  }
1331
1644
  const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1332
1645
  const tasks = getDocumentSectionBody(document, "tasks");
1333
1646
  const verify = getDocumentSectionBody(document, "verify");
1334
1647
  const blockers = getDocumentSectionBody(document, "blockers");
1335
- return {
1648
+ const detail = {
1336
1649
  ...toOutputRecord(record),
1337
1650
  scenarioCount: parseScenarios(content).length,
1338
1651
  checkboxes: {
@@ -1345,12 +1658,20 @@ async function readArchiveHistoryDetail(record) {
1345
1658
  blockers: boundEvidence(blockers, true)
1346
1659
  }
1347
1660
  };
1661
+ return { content, detail };
1348
1662
  }
1349
1663
  function historyInspectionComplete(inspection) {
1350
1664
  return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1351
1665
  }
1352
1666
  function toOutputRecord(record) {
1353
- const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
1667
+ const {
1668
+ searchSummary: _searchSummary,
1669
+ sourcePath: _sourcePath,
1670
+ archivesDir: _archivesDir,
1671
+ sourceSnapshot: _sourceSnapshot,
1672
+ maxFileBytes: _maxFileBytes,
1673
+ ...output
1674
+ } = record;
1354
1675
  return output;
1355
1676
  }
1356
1677
  function boundEvidence(section, omitNone = false) {
@@ -1372,7 +1693,6 @@ function compareHistoryRecords2(left, right) {
1372
1693
  }
1373
1694
 
1374
1695
  // src/core/dependency-plan.ts
1375
- var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
1376
1696
  var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
1377
1697
  async function inspectChangeDependencies(options = {}) {
1378
1698
  const workTree = options.workTree ?? await inspectWorkTree();
@@ -1394,7 +1714,7 @@ async function inspectChangeDependencies(options = {}) {
1394
1714
  diagnostics.push(...archiveHistory.diagnostics);
1395
1715
  for (const brief of workTree.briefs) {
1396
1716
  try {
1397
- if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
1717
+ if (hasMeaningfulBlockers(await readFile3(brief.path, "utf-8")))
1398
1718
  blockedGroups.add(brief.group);
1399
1719
  } catch (error) {
1400
1720
  diagnostics.push({
@@ -1408,7 +1728,7 @@ async function inspectChangeDependencies(options = {}) {
1408
1728
  }
1409
1729
  for (const ref of workTree.changes) {
1410
1730
  try {
1411
- const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
1731
+ const parsed = parseDependencyBlockers(await readFile3(ref.path, "utf-8"));
1412
1732
  if (ref.group && blockedGroups.has(ref.group))
1413
1733
  parsed.external = true;
1414
1734
  parsedByName.set(ref.name, parsed);
@@ -1514,7 +1834,7 @@ function parseDependencyBlockers(content) {
1514
1834
  let external = false;
1515
1835
  const malformed = [];
1516
1836
  for (const line of extractBlockerLines(content)) {
1517
- if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
1837
+ if (!line || isEmptyBlockerLine(line))
1518
1838
  continue;
1519
1839
  const dependency = line.match(REQUIRES_RE);
1520
1840
  if (dependency) {
@@ -1538,13 +1858,13 @@ function buildWaves(names, edges, blockersByName, preferredOrder) {
1538
1858
  const excluded = new Set(
1539
1859
  [...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
1540
1860
  );
1541
- let changed = true;
1542
- while (changed) {
1543
- changed = false;
1861
+ let changed2 = true;
1862
+ while (changed2) {
1863
+ changed2 = false;
1544
1864
  for (const edge of edges) {
1545
1865
  if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
1546
1866
  excluded.add(edge.change);
1547
- changed = true;
1867
+ changed2 = true;
1548
1868
  }
1549
1869
  }
1550
1870
  }
@@ -1622,6 +1942,82 @@ function findCycleDiagnostics(edges) {
1622
1942
  return diagnostics;
1623
1943
  }
1624
1944
 
1945
+ // src/core/readiness.ts
1946
+ function collectArchiveReadiness(content, options = {}) {
1947
+ const warnings = [];
1948
+ const documentDiagnostics = options.documentDiagnostics ?? [];
1949
+ warnings.push(...documentDiagnostics.map((diagnostic) => diagnostic.message));
1950
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1951
+ const tasksSection = getDocumentSectionBody(document, "tasks");
1952
+ const verifySection = getDocumentSectionBody(document, "verify");
1953
+ const taskTodos = getOpenCheckboxes(tasksSection);
1954
+ if (taskTodos.length > 0)
1955
+ warnings.push(`${taskTodos.length} task item(s) still incomplete`);
1956
+ const verifyCriticality = classifyVerifyCheckboxes(verifySection);
1957
+ const verifyLines = collectVerifyCheckboxLines(verifySection);
1958
+ const requiredVerifyTodos = getOpenCheckboxes([...verifyLines.required, ...verifyLines.unclassified].join("\n"));
1959
+ const optionalVerifyTodos = getOpenCheckboxes(verifyLines.optional.join("\n"));
1960
+ const verifyTodos = [...requiredVerifyTodos, ...optionalVerifyTodos];
1961
+ if (requiredVerifyTodos.length > 0)
1962
+ warnings.push(`${requiredVerifyTodos.length} required Verify item(s) are still incomplete`);
1963
+ if (optionalVerifyTodos.length > 0)
1964
+ warnings.push(`${optionalVerifyTodos.length} optional Verify item(s) are still incomplete`);
1965
+ const activeBlockers = options.activeBlockers ?? hasMeaningfulBlockers(content);
1966
+ if (activeBlockers)
1967
+ warnings.push("active blockers are present in the change file");
1968
+ const scenarios = parseScenarios(content);
1969
+ const missingScenarios = scenarios.length === 0;
1970
+ if (missingScenarios)
1971
+ warnings.push("no Scenario blocks found (some changes do not need them)");
1972
+ const archiveReady = documentDiagnostics.some((diagnostic) => diagnostic.severity === "error") || activeBlockers || taskTodos.length > 0 || requiredVerifyTodos.length > 0 ? "no" : "yes";
1973
+ return {
1974
+ taskTodos,
1975
+ verifyTodos,
1976
+ requiredVerifyTodos,
1977
+ optionalVerifyTodos,
1978
+ verifyCriticality,
1979
+ activeBlockers,
1980
+ scenarioCount: scenarios.length,
1981
+ missingScenarios,
1982
+ deterministic: warnings.length === 0 ? "pass" : "warnings",
1983
+ semantic: "needs-review",
1984
+ archiveReady,
1985
+ warnings,
1986
+ documentDiagnostics
1987
+ };
1988
+ }
1989
+ function toArchiveReadinessOutput(readiness) {
1990
+ return {
1991
+ incompleteTasks: readiness.taskTodos.length,
1992
+ incompleteVerify: readiness.verifyTodos.length,
1993
+ incompleteRequiredVerify: readiness.requiredVerifyTodos.length,
1994
+ incompleteOptionalVerify: readiness.optionalVerifyTodos.length,
1995
+ requiredVerify: readiness.verifyCriticality.required,
1996
+ optionalVerify: readiness.verifyCriticality.optional,
1997
+ legacyVerify: readiness.verifyCriticality.legacy,
1998
+ completionGate: readiness.archiveReady === "no" ? "blocked" : "pass",
1999
+ coverageWarnings: readiness.optionalVerifyTodos.length,
2000
+ activeBlockers: readiness.activeBlockers,
2001
+ missingScenarios: readiness.missingScenarios,
2002
+ deterministic: readiness.deterministic,
2003
+ semantic: readiness.semantic,
2004
+ archiveReady: readiness.archiveReady
2005
+ };
2006
+ }
2007
+ function buildDurableReviewGuidance(factCandidateTargets, decisionRecordsPath) {
2008
+ return {
2009
+ required: true,
2010
+ factDecisions: ["No current-fact update needed", "Update existing spec or scoped instruction", "Create a new durable spec"],
2011
+ rationaleDecisions: ["No Decision Record needed", "Create or update a Decision Record"],
2012
+ factCandidateTargets,
2013
+ decisionRecordsPath,
2014
+ note: "Semantic review decides current-fact and lasting-rationale updates independently. The CLI never infers scoped instruction or Decision Record filenames and never promotes Change content automatically."
2015
+ };
2016
+ }
2017
+ function getDurableReviewCandidateTargets() {
2018
+ return [".rsp/specs/design.md"];
2019
+ }
2020
+
1625
2021
  // src/status/inspect.ts
1626
2022
  async function inspectProjectStatus(options = {}) {
1627
2023
  const runtime = [];
@@ -1707,6 +2103,7 @@ async function inspectProjectStatus(options = {}) {
1707
2103
  for (const name of allNames) {
1708
2104
  const path = changeMap.get(name);
1709
2105
  let kind = "\u2014";
2106
+ let summary = null;
1710
2107
  let title = name;
1711
2108
  let blockerEntries = [];
1712
2109
  let readiness = emptyReadiness(false);
@@ -1719,8 +2116,9 @@ async function inspectProjectStatus(options = {}) {
1719
2116
  let issues = [];
1720
2117
  if (path) {
1721
2118
  try {
1722
- const content = await readFile5(path, "utf-8");
2119
+ const content = await readFile4(path, "utf-8");
1723
2120
  title = extractChangeTitle(content) ?? name;
2121
+ summary = extractChangeSummary(content);
1724
2122
  blockerEntries = extractBlockerEntries(content);
1725
2123
  try {
1726
2124
  const frontmatter = parseFrontmatter(content);
@@ -1746,20 +2144,13 @@ async function inspectProjectStatus(options = {}) {
1746
2144
  });
1747
2145
  }
1748
2146
  isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
1749
- const checkboxes = countCheckboxes(content);
1750
- done = checkboxes.done;
1751
- total = checkboxes.total;
1752
- progressKnown = true;
1753
2147
  const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
1754
- readiness = {
1755
- incompleteTasks: details.taskTodos.length,
1756
- incompleteVerify: details.verifyTodos.length,
1757
- activeBlockers: details.activeBlockers,
1758
- missingScenarios: details.missingScenarios,
1759
- deterministic: details.deterministic,
1760
- semantic: details.semantic,
1761
- archiveReady: details.archiveReady
1762
- };
2148
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
2149
+ const taskCheckboxes = countCheckboxes(getDocumentSectionBody(document, "tasks"));
2150
+ done = taskCheckboxes.done + details.verifyCriticality.required.done;
2151
+ total = taskCheckboxes.total + details.verifyCriticality.required.total;
2152
+ progressKnown = true;
2153
+ readiness = toArchiveReadinessOutput(details);
1763
2154
  } catch (error) {
1764
2155
  diagnostics.push({
1765
2156
  severity: "error",
@@ -1815,6 +2206,7 @@ async function inspectProjectStatus(options = {}) {
1815
2206
  records.push({
1816
2207
  output: {
1817
2208
  name,
2209
+ summary,
1818
2210
  kind,
1819
2211
  progress: { done, total },
1820
2212
  ageDays,
@@ -1845,6 +2237,13 @@ function emptyReadiness(activeBlockers) {
1845
2237
  return {
1846
2238
  incompleteTasks: 0,
1847
2239
  incompleteVerify: 0,
2240
+ incompleteRequiredVerify: 0,
2241
+ incompleteOptionalVerify: 0,
2242
+ requiredVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
2243
+ optionalVerify: { todo: 0, progress: 0, done: 0, dropped: 0, total: 0 },
2244
+ legacyVerify: false,
2245
+ completionGate: activeBlockers ? "blocked" : "pass",
2246
+ coverageWarnings: 0,
1848
2247
  activeBlockers,
1849
2248
  missingScenarios: false,
1850
2249
  deterministic: "pass",
@@ -1868,6 +2267,670 @@ function deriveArchiveTrend(records) {
1868
2267
  return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
1869
2268
  }
1870
2269
 
2270
+ // src/specs/model.ts
2271
+ var SPECS_DEFAULT_RESULT_LIMIT = 20;
2272
+ var SPECS_MAX_RESULT_LIMIT = 100;
2273
+ var SPECS_MAX_CANDIDATES = 1e3;
2274
+ var SPECS_MAX_FILE_BYTES = 512 * 1024;
2275
+ var SPECS_SEARCH_EXCERPT_CODE_POINTS = 240;
2276
+ var SPECS_DETAIL_CONTENT_CODE_POINTS = 12e3;
2277
+ var SPECS_MAX_DIAGNOSTICS = 20;
2278
+
2279
+ // src/specs/query.ts
2280
+ import { Buffer as Buffer3 } from "buffer";
2281
+ import { spawnSync } from "child_process";
2282
+ import { constants as constants2 } from "fs";
2283
+ import { lstat as lstat2, open as open2, realpath as realpath2 } from "fs/promises";
2284
+ import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute3, join as join4, relative as relative6, resolve as resolve3, sep as sep3 } from "path";
2285
+ var SPECS_ROOT = join4(RSP_DIR, "specs");
2286
+ var GENERATED_INDEX_FILENAME = "00-index.md";
2287
+ var LEGACY_GENERATED_INDEX_FILENAME = "INDEX.md";
2288
+ var READ_NO_FOLLOW_FLAGS2 = constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0);
2289
+ var SpecsQueryError = class extends Error {
2290
+ constructor(code, message) {
2291
+ super(message);
2292
+ this.code = code;
2293
+ this.name = "SpecsQueryError";
2294
+ }
2295
+ code;
2296
+ };
2297
+ async function inspectSpecs(options = {}) {
2298
+ const projectRoot = await realpath2(resolve3(options.cwd ?? process.cwd()));
2299
+ const specsRoot = resolve3(projectRoot, SPECS_ROOT);
2300
+ const maxCandidates = options.maxCandidates ?? SPECS_MAX_CANDIDATES;
2301
+ const maxFileBytes = options.maxFileBytes ?? SPECS_MAX_FILE_BYTES;
2302
+ validatePositiveBound(maxCandidates, "maxCandidates");
2303
+ validatePositiveBound(maxFileBytes, "maxFileBytes");
2304
+ const configInspection = await inspectRspConfig(projectRoot);
2305
+ if (configInspection.issues.length > 0)
2306
+ throw new SpecsQueryError("invalid_config", configInspection.issues.join("; "));
2307
+ const decisionRecordsPath = resolveDecisionRecordsPath(configInspection.config);
2308
+ const decisionPathIssue = await validateDecisionRecordsFilesystemPath(decisionRecordsPath, projectRoot);
2309
+ if (decisionPathIssue)
2310
+ throw new SpecsQueryError("invalid_decision_records_path", decisionPathIssue);
2311
+ const decisionRecordsRoot = resolve3(projectRoot, decisionRecordsPath);
2312
+ const accumulator = {
2313
+ documents: [],
2314
+ generatedIndexes: [],
2315
+ diagnostics: [],
2316
+ candidateCount: 0,
2317
+ candidateLimitReported: false
2318
+ };
2319
+ const excludedSpecDirectories = [DEFAULT_DECISION_RECORDS_PATH, decisionRecordsPath].map((path) => resolve3(projectRoot, path)).filter((path) => isInside(specsRoot, path));
2320
+ await inspectDocumentRoot({
2321
+ projectRoot,
2322
+ root: specsRoot,
2323
+ label: "Specs",
2324
+ kind: "spec",
2325
+ excludedDirectories: excludedSpecDirectories,
2326
+ accumulator,
2327
+ maxCandidates,
2328
+ maxFileBytes,
2329
+ allowMissing: false
2330
+ });
2331
+ await inspectDocumentRoot({
2332
+ projectRoot,
2333
+ root: decisionRecordsRoot,
2334
+ label: "Decision Records",
2335
+ kind: "decision-record",
2336
+ excludedDirectories: [],
2337
+ accumulator,
2338
+ maxCandidates,
2339
+ maxFileBytes,
2340
+ allowMissing: options.allowMissingDecisionRecords === true
2341
+ });
2342
+ detectPathCollisions(accumulator);
2343
+ accumulator.documents.sort(compareDocument);
2344
+ accumulator.generatedIndexes.sort((left, right) => left.path.localeCompare(right.path));
2345
+ const boundedDiagnostics = accumulator.diagnostics.slice(0, SPECS_MAX_DIAGNOSTICS);
2346
+ return {
2347
+ source: await inspectSourceIdentity(projectRoot),
2348
+ roots: {
2349
+ specs: toProjectPath(projectRoot, specsRoot),
2350
+ decisions: toProjectPath(projectRoot, decisionRecordsRoot)
2351
+ },
2352
+ tree: buildDirectoryTree(projectRoot, specsRoot, "spec", accumulator.documents),
2353
+ decisionRecords: buildDirectoryTree(projectRoot, decisionRecordsRoot, "decision-record", accumulator.documents),
2354
+ documents: accumulator.documents.map(stripSource),
2355
+ generatedIndexes: accumulator.generatedIndexes,
2356
+ diagnostics: boundedDiagnostics,
2357
+ diagnosticSummary: {
2358
+ total: accumulator.diagnostics.length,
2359
+ returned: boundedDiagnostics.length,
2360
+ hasMore: accumulator.diagnostics.length > boundedDiagnostics.length
2361
+ },
2362
+ runtime: [],
2363
+ limits: {
2364
+ candidates: maxCandidates,
2365
+ fileBytes: maxFileBytes,
2366
+ results: SPECS_MAX_RESULT_LIMIT,
2367
+ searchExcerptCodePoints: SPECS_SEARCH_EXCERPT_CODE_POINTS,
2368
+ detailContentCodePoints: SPECS_DETAIL_CONTENT_CODE_POINTS
2369
+ }
2370
+ };
2371
+ }
2372
+ function specsInspectionComplete(inspection) {
2373
+ return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
2374
+ }
2375
+ async function readSpecsDetail(inspection, projectPath) {
2376
+ const current = await readCurrentSpecsDocument(inspection, projectPath);
2377
+ const bounded = boundCodePoints(current.content, SPECS_DETAIL_CONTENT_CODE_POINTS);
2378
+ return {
2379
+ ...current,
2380
+ content: bounded.value,
2381
+ contentTruncated: bounded.truncated
2382
+ };
2383
+ }
2384
+ async function readCurrentSpecsDocument(inspection, projectPath) {
2385
+ const normalized = normalizeRequestedPath(projectPath);
2386
+ const document = inspection.documents.find((candidate) => candidate.path === normalized);
2387
+ if (!document)
2388
+ throw new SpecsQueryError("specs_document_not_found", `Specs document not found: ${projectPath}`);
2389
+ const sourcePath = resolve3(inspection.source.root, normalized);
2390
+ const current = await readCurrentDocument(
2391
+ sourcePath,
2392
+ inspection.source.root,
2393
+ document.path,
2394
+ inspection.limits.fileBytes
2395
+ );
2396
+ const currentDocument = parseDocumentRecord(current.content, current.bytes, document.path, document.kind);
2397
+ return {
2398
+ ...currentDocument,
2399
+ content: current.content
2400
+ };
2401
+ }
2402
+ async function searchSpecs(inspection, literal, options = {}) {
2403
+ return (await searchSpecsWithSources(inspection, literal, options)).result;
2404
+ }
2405
+ async function searchSpecsWithSources(inspection, literal, options = {}) {
2406
+ const query = literal.trim();
2407
+ if (query === "")
2408
+ throw new SpecsQueryError("invalid_specs_search", "Specs search literal must be non-empty");
2409
+ const limit = options.limit ?? 20;
2410
+ if (!Number.isInteger(limit) || limit < 1 || limit > SPECS_MAX_RESULT_LIMIT)
2411
+ throw new SpecsQueryError("invalid_specs_limit", `Specs search limit must be an integer from 1 through ${SPECS_MAX_RESULT_LIMIT}`);
2412
+ const excerptCodePoints = options.excerptCodePoints ?? SPECS_SEARCH_EXCERPT_CODE_POINTS;
2413
+ if (!Number.isInteger(excerptCodePoints) || excerptCodePoints < 40 || excerptCodePoints > 1e3)
2414
+ throw new SpecsQueryError("invalid_specs_excerpt", "Specs search excerpt must be an integer from 40 through 1000 code points");
2415
+ const lowered = query.toLowerCase();
2416
+ const matches = [];
2417
+ const documents = /* @__PURE__ */ new Map();
2418
+ let matched = 0;
2419
+ for (const document of inspection.documents) {
2420
+ const current = await readCurrentDocument(
2421
+ resolve3(inspection.source.root, document.path),
2422
+ inspection.source.root,
2423
+ document.path,
2424
+ inspection.limits.fileBytes
2425
+ );
2426
+ const currentDocument = parseDocumentRecord(current.content, current.bytes, document.path, document.kind);
2427
+ const lines = current.content.split(/\r?\n/);
2428
+ let heading = null;
2429
+ for (let index = 0; index < lines.length; index++) {
2430
+ const parsedHeading = parseMarkdownHeading(lines[index]);
2431
+ if (parsedHeading)
2432
+ heading = parsedHeading.title;
2433
+ const position = lines[index].toLowerCase().indexOf(lowered);
2434
+ if (position < 0)
2435
+ continue;
2436
+ matched += 1;
2437
+ if (matches.length < limit) {
2438
+ if (!documents.has(document.path)) {
2439
+ documents.set(document.path, {
2440
+ ...currentDocument,
2441
+ content: current.content
2442
+ });
2443
+ }
2444
+ matches.push({
2445
+ path: document.path,
2446
+ kind: document.kind,
2447
+ title: currentDocument.title,
2448
+ heading,
2449
+ line: index + 1,
2450
+ excerpt: excerptAround(
2451
+ lines[index],
2452
+ [...lines[index].slice(0, position)].length,
2453
+ [...query].length,
2454
+ excerptCodePoints
2455
+ )
2456
+ });
2457
+ }
2458
+ }
2459
+ }
2460
+ return {
2461
+ result: {
2462
+ matches,
2463
+ summary: {
2464
+ candidates: inspection.documents.length,
2465
+ searched: inspection.documents.length,
2466
+ matched,
2467
+ returned: matches.length,
2468
+ hasMore: matched > matches.length
2469
+ }
2470
+ },
2471
+ documents
2472
+ };
2473
+ }
2474
+ async function inspectDocumentRoot(options) {
2475
+ const inspection = await inspectManagedFileTree(options.root, options.label, { allowMissing: options.allowMissing });
2476
+ for (const issue of inspection.issues) {
2477
+ options.accumulator.diagnostics.push({
2478
+ severity: "error",
2479
+ code: "specs_tree_invalid",
2480
+ path: toProjectPath(options.projectRoot, issue.path),
2481
+ message: issue.message
2482
+ });
2483
+ }
2484
+ if (!inspection.rootExists)
2485
+ return;
2486
+ for (const path of inspection.files) {
2487
+ if (options.excludedDirectories.some((directory) => isInside(directory, path)))
2488
+ continue;
2489
+ const projectPath = toProjectPath(options.projectRoot, path);
2490
+ const reserved = reservedIndexKind(options.root, path);
2491
+ if (reserved) {
2492
+ await classifyGeneratedIndex(path, options.projectRoot, projectPath, options.maxFileBytes, options.accumulator);
2493
+ continue;
2494
+ }
2495
+ if (!path.endsWith(".md"))
2496
+ continue;
2497
+ options.accumulator.candidateCount += 1;
2498
+ if (options.accumulator.candidateCount > options.maxCandidates) {
2499
+ if (!options.accumulator.candidateLimitReported) {
2500
+ options.accumulator.diagnostics.push({
2501
+ severity: "error",
2502
+ code: "specs_candidate_limit_exceeded",
2503
+ path: projectPath,
2504
+ message: `Specs candidate limit exceeded (${options.maxCandidates})`
2505
+ });
2506
+ options.accumulator.candidateLimitReported = true;
2507
+ }
2508
+ continue;
2509
+ }
2510
+ await inspectDocument(path, options.projectRoot, projectPath, options.kind, options.maxFileBytes, options.accumulator);
2511
+ }
2512
+ }
2513
+ async function classifyGeneratedIndex(path, projectRoot, projectPath, maxFileBytes, accumulator) {
2514
+ try {
2515
+ const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
2516
+ const recognized = isRecognizedGeneratedSpecsIndex(current.content, projectPath);
2517
+ accumulator.generatedIndexes.push({
2518
+ path: projectPath,
2519
+ classification: recognized ? "safe-removal" : "owner-controlled",
2520
+ reason: recognized ? "metadata recognizes the reserved path as a generated Specs index" : "reserved path content is not recognized generated Specs-index output"
2521
+ });
2522
+ if (!recognized) {
2523
+ accumulator.diagnostics.push({
2524
+ severity: "error",
2525
+ code: "unrecognized_reserved_specs_index",
2526
+ path: projectPath,
2527
+ message: `reserved Specs index is owner-controlled and cannot be treated as generated content: ${projectPath}`
2528
+ });
2529
+ }
2530
+ } catch (error) {
2531
+ accumulator.generatedIndexes.push({
2532
+ path: projectPath,
2533
+ classification: "owner-controlled",
2534
+ reason: "reserved path could not be boundedly inspected as generated Specs-index output"
2535
+ });
2536
+ accumulator.diagnostics.push({
2537
+ severity: "error",
2538
+ code: error instanceof SpecsQueryError ? error.code : "specs_index_read_failed",
2539
+ path: projectPath,
2540
+ message: `unable to inspect reserved Specs index ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
2541
+ });
2542
+ }
2543
+ }
2544
+ function isRecognizedGeneratedSpecsIndex(content, projectPath) {
2545
+ const metadata = parseFrontmatter(content);
2546
+ const expectedSourceDirectory = normalizeLogicalPath(dirname4(projectPath));
2547
+ return metadata?.kind === "generated-index" && metadata.index_type === "specs" && metadata.source_dir === expectedSourceDirectory;
2548
+ }
2549
+ async function inspectDocument(path, projectRoot, projectPath, kind, maxFileBytes, accumulator) {
2550
+ try {
2551
+ const current = await readCurrentDocument(path, projectRoot, projectPath, maxFileBytes);
2552
+ const metadata = parseFrontmatter(current.content);
2553
+ if (metadata?.kind === "generated-index")
2554
+ return;
2555
+ accumulator.documents.push({
2556
+ sourcePath: path,
2557
+ ...parseDocumentRecord(current.content, current.bytes, projectPath, kind)
2558
+ });
2559
+ } catch (error) {
2560
+ accumulator.diagnostics.push({
2561
+ severity: "error",
2562
+ code: error instanceof SpecsQueryError ? error.code : "specs_file_read_failed",
2563
+ path: projectPath,
2564
+ message: `unable to read Specs document ${projectPath}: ${error instanceof Error ? error.message : String(error)}`
2565
+ });
2566
+ }
2567
+ }
2568
+ function detectPathCollisions(accumulator) {
2569
+ const seen = /* @__PURE__ */ new Map();
2570
+ for (const document of accumulator.documents) {
2571
+ const canonical = document.path.normalize("NFC");
2572
+ const existing = seen.get(canonical);
2573
+ if (existing && existing !== document.path) {
2574
+ accumulator.diagnostics.push({
2575
+ severity: "error",
2576
+ code: "specs_path_collision",
2577
+ path: document.path,
2578
+ message: `Specs path normalization collision: ${existing} and ${document.path}`
2579
+ });
2580
+ } else {
2581
+ seen.set(canonical, document.path);
2582
+ }
2583
+ if (canonical !== document.path) {
2584
+ accumulator.diagnostics.push({
2585
+ severity: "error",
2586
+ code: "specs_path_not_canonical",
2587
+ path: document.path,
2588
+ message: `Specs path must use canonical NFC form: ${document.path}`
2589
+ });
2590
+ }
2591
+ }
2592
+ }
2593
+ function buildDirectoryTree(projectRoot, root, kind, documents) {
2594
+ const rootPath = toProjectPath(projectRoot, root);
2595
+ const tree = {
2596
+ name: basename3(root),
2597
+ path: rootPath,
2598
+ directories: [],
2599
+ documents: []
2600
+ };
2601
+ for (const document of documents.filter((candidate) => candidate.kind === kind)) {
2602
+ const rel = relative6(root, document.sourcePath);
2603
+ if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
2604
+ continue;
2605
+ const segments = normalizeLogicalPath(rel).split("/");
2606
+ let current = tree;
2607
+ for (const segment of segments.slice(0, -1)) {
2608
+ let child = current.directories.find((directory) => directory.name === segment);
2609
+ if (!child) {
2610
+ child = {
2611
+ name: segment,
2612
+ path: `${current.path}/${segment}`,
2613
+ directories: [],
2614
+ documents: []
2615
+ };
2616
+ current.directories.push(child);
2617
+ current.directories.sort((left, right) => left.name.localeCompare(right.name));
2618
+ }
2619
+ current = child;
2620
+ }
2621
+ current.documents.push(stripSource(document));
2622
+ current.documents.sort(compareDocument);
2623
+ }
2624
+ return tree;
2625
+ }
2626
+ function extractHeadings(content) {
2627
+ const headings = [];
2628
+ const lines = content.split(/\r?\n/);
2629
+ for (let index = 0; index < lines.length; index++) {
2630
+ const heading = parseMarkdownHeading(lines[index]);
2631
+ if (!heading)
2632
+ continue;
2633
+ headings.push({
2634
+ depth: heading.depth,
2635
+ title: heading.title,
2636
+ line: index + 1
2637
+ });
2638
+ }
2639
+ return headings;
2640
+ }
2641
+ function parseMarkdownHeading(line) {
2642
+ let depth = 0;
2643
+ while (depth < 6 && line[depth] === "#")
2644
+ depth += 1;
2645
+ if (depth === 0 || line[depth] === "#" || !isWhitespace(line[depth]))
2646
+ return null;
2647
+ let contentStart = depth;
2648
+ while (contentStart < line.length && isWhitespace(line[contentStart]))
2649
+ contentStart += 1;
2650
+ if (contentStart === line.length)
2651
+ return null;
2652
+ const rawTitle = line.slice(contentStart);
2653
+ let titleEnd = rawTitle.length;
2654
+ while (titleEnd > 0 && isSpaceOrTab(rawTitle[titleEnd - 1]))
2655
+ titleEnd -= 1;
2656
+ let closingStart = titleEnd;
2657
+ while (closingStart > 0 && rawTitle[closingStart - 1] === "#")
2658
+ closingStart -= 1;
2659
+ const closingPredecessor = closingStart === 0 ? line[contentStart - 1] : rawTitle[closingStart - 1];
2660
+ if (closingStart < titleEnd && isSpaceOrTab(closingPredecessor)) {
2661
+ titleEnd = closingStart;
2662
+ while (titleEnd > 0 && isSpaceOrTab(rawTitle[titleEnd - 1]))
2663
+ titleEnd -= 1;
2664
+ }
2665
+ const title = rawTitle.slice(0, titleEnd).trim();
2666
+ return { depth, title };
2667
+ }
2668
+ function isWhitespace(character) {
2669
+ return character !== void 0 && character.trim() === "";
2670
+ }
2671
+ function isSpaceOrTab(character) {
2672
+ return character === " " || character === " ";
2673
+ }
2674
+ function extractSummary(content, frontmatterSummary) {
2675
+ if (typeof frontmatterSummary === "string" && frontmatterSummary.trim() !== "")
2676
+ return frontmatterSummary.trim();
2677
+ const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)\r?\n/);
2678
+ const body = frontmatter ? content.slice(frontmatter[0].length) : content;
2679
+ for (const rawLine of body.split(/\r?\n/)) {
2680
+ const line = rawLine.trim();
2681
+ if (line === "" || line.startsWith("#") || line.startsWith("<!--"))
2682
+ continue;
2683
+ return line.replace(/^[-*]\s*/, "").trim() || null;
2684
+ }
2685
+ return null;
2686
+ }
2687
+ async function inspectSourceIdentity(projectRoot) {
2688
+ const root = normalizeLogicalPath(projectRoot);
2689
+ const head = runGit(["rev-parse", "HEAD"], projectRoot);
2690
+ const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], projectRoot);
2691
+ const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=normal"], {
2692
+ cwd: root,
2693
+ encoding: "utf-8",
2694
+ maxBuffer: 256 * 1024
2695
+ });
2696
+ return {
2697
+ root,
2698
+ gitHead: head,
2699
+ gitBranch: branch,
2700
+ dirty: status.status === 0 ? status.stdout.trim() !== "" : status.error && status.error.code === "ENOBUFS" ? true : null
2701
+ };
2702
+ }
2703
+ function runGit(args, cwd) {
2704
+ const result = spawnSync("git", args, { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 });
2705
+ return result.status === 0 ? result.stdout.trim() || null : null;
2706
+ }
2707
+ function normalizeRequestedPath(pathValue) {
2708
+ const trimmed = pathValue.trim();
2709
+ if (trimmed === "")
2710
+ throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be non-empty");
2711
+ const normalized = normalizeLogicalPath(trimmed.replace(/^\.\//, ""));
2712
+ if (isAbsolute3(trimmed) || normalized === ".." || normalized.startsWith("../"))
2713
+ throw new SpecsQueryError("invalid_specs_path", "Specs detail path must be one project-relative path returned by rsp specs");
2714
+ return normalized;
2715
+ }
2716
+ function reservedIndexKind(root, path) {
2717
+ if (basename3(path) === GENERATED_INDEX_FILENAME)
2718
+ return "current";
2719
+ if (path === join4(root, LEGACY_GENERATED_INDEX_FILENAME))
2720
+ return "legacy";
2721
+ return null;
2722
+ }
2723
+ function excerptAround(line, matchStart, matchLength, limit) {
2724
+ const codePoints = [...line];
2725
+ if (codePoints.length <= limit)
2726
+ return codePoints.join("").trim();
2727
+ const before = Math.max(0, matchStart - Math.floor((limit - matchLength) / 2));
2728
+ const value = codePoints.slice(before, before + limit).join("").trim();
2729
+ return `${before > 0 ? "\u2026" : ""}${value}${before + limit < codePoints.length ? "\u2026" : ""}`;
2730
+ }
2731
+ async function readCurrentDocument(path, root, projectPath, maxFileBytes) {
2732
+ const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
2733
+ const sourcePath = resolve3(path);
2734
+ const sourceRoot = resolve3(root);
2735
+ const relativePath = relative6(sourceRoot, sourcePath);
2736
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep3}`) || isAbsolute3(relativePath)) {
2737
+ throw changed2();
2738
+ }
2739
+ const inspection = inspectManagedFile(sourcePath, "Specs document");
2740
+ if (inspection.issue)
2741
+ throw changed2();
2742
+ let inspectedRoot;
2743
+ let inspected;
2744
+ let realRoot;
2745
+ try {
2746
+ inspectedRoot = await lstat2(sourceRoot, { bigint: true });
2747
+ inspected = await lstat2(sourcePath, { bigint: true });
2748
+ realRoot = await realpath2(sourceRoot);
2749
+ const resolvedPath = await realpath2(sourcePath);
2750
+ if (!inspectedRoot.isDirectory() || !inspected.isFile() || resolvedPath !== resolve3(realRoot, relativePath) || !isContained2(realRoot, resolvedPath)) {
2751
+ throw changed2();
2752
+ }
2753
+ } catch (error) {
2754
+ if (error instanceof SpecsQueryError)
2755
+ throw error;
2756
+ throw changed2();
2757
+ }
2758
+ let handle;
2759
+ try {
2760
+ handle = await open2(sourcePath, READ_NO_FOLLOW_FLAGS2);
2761
+ } catch {
2762
+ throw changed2();
2763
+ }
2764
+ try {
2765
+ const opened = await handle.stat({ bigint: true });
2766
+ const currentRoot = await realpath2(sourceRoot);
2767
+ const currentPath = await realpath2(sourcePath);
2768
+ if (!opened.isFile() || currentRoot !== realRoot || currentPath !== resolve3(currentRoot, relativePath) || !isContained2(currentRoot, currentPath) || !sameIdentity2(inspected, opened) || !sameSnapshot2(inspected, opened)) {
2769
+ throw changed2();
2770
+ }
2771
+ if (opened.size > BigInt(maxFileBytes))
2772
+ throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
2773
+ const current = await readBoundedDocument(handle, opened, projectPath, maxFileBytes);
2774
+ await validateCurrentSpecsPath({
2775
+ sourcePath,
2776
+ sourceRoot,
2777
+ relativePath,
2778
+ realRoot,
2779
+ inspectedRoot,
2780
+ finalHandle: current.final,
2781
+ projectPath
2782
+ });
2783
+ return {
2784
+ content: current.content,
2785
+ bytes: current.bytes
2786
+ };
2787
+ } catch (error) {
2788
+ if (error instanceof SpecsQueryError)
2789
+ throw error;
2790
+ throw changed2();
2791
+ } finally {
2792
+ await handle.close();
2793
+ }
2794
+ }
2795
+ async function readBoundedDocument(handle, opened, projectPath, maxFileBytes) {
2796
+ const buffer = Buffer3.allocUnsafe(maxFileBytes + 1);
2797
+ let bytes = 0;
2798
+ while (bytes < buffer.length) {
2799
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
2800
+ if (result.bytesRead === 0)
2801
+ break;
2802
+ bytes += result.bytesRead;
2803
+ }
2804
+ const final = await handle.stat({ bigint: true });
2805
+ if (bytes > maxFileBytes || final.size > BigInt(maxFileBytes))
2806
+ throw new SpecsQueryError("specs_file_too_large", `Specs document exceeds the ${maxFileBytes}-byte inspection limit: ${projectPath}`);
2807
+ if (!sameIdentity2(opened, final) || !sameSnapshot2(opened, final) || final.size !== BigInt(bytes)) {
2808
+ throw new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${projectPath}`);
2809
+ }
2810
+ return {
2811
+ content: buffer.subarray(0, bytes).toString("utf-8"),
2812
+ bytes,
2813
+ final
2814
+ };
2815
+ }
2816
+ async function validateCurrentSpecsPath(options) {
2817
+ const changed2 = () => new SpecsQueryError("specs_file_changed", `Specs document is no longer a readable regular file: ${options.projectPath}`);
2818
+ try {
2819
+ const currentRoot = await lstat2(options.sourceRoot, { bigint: true });
2820
+ const currentPath = await lstat2(options.sourcePath, { bigint: true });
2821
+ const realCurrentRoot = await realpath2(options.sourceRoot);
2822
+ const realCurrentPath = await realpath2(options.sourcePath);
2823
+ if (!currentRoot.isDirectory() || !currentPath.isFile() || !sameIdentity2(options.inspectedRoot, currentRoot) || realCurrentRoot !== options.realRoot || realCurrentPath !== resolve3(realCurrentRoot, options.relativePath) || !isContained2(realCurrentRoot, realCurrentPath) || !sameIdentity2(currentPath, options.finalHandle) || !sameSnapshot2(currentPath, options.finalHandle)) {
2824
+ throw changed2();
2825
+ }
2826
+ } catch (error) {
2827
+ if (error instanceof SpecsQueryError)
2828
+ throw error;
2829
+ throw changed2();
2830
+ }
2831
+ }
2832
+ function sameIdentity2(left, right) {
2833
+ return left.dev === right.dev && left.ino === right.ino;
2834
+ }
2835
+ function sameSnapshot2(left, right) {
2836
+ return left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
2837
+ }
2838
+ function isContained2(root, path) {
2839
+ const candidate = relative6(root, path);
2840
+ return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${sep3}`) && !isAbsolute3(candidate);
2841
+ }
2842
+ function boundCodePoints(value, limit) {
2843
+ const codePoints = [...value];
2844
+ return {
2845
+ value: codePoints.slice(0, limit).join(""),
2846
+ truncated: codePoints.length > limit
2847
+ };
2848
+ }
2849
+ function stripSource(document) {
2850
+ const { sourcePath: _sourcePath, ...record } = document;
2851
+ return record;
2852
+ }
2853
+ function parseDocumentRecord(content, bytes, path, kind) {
2854
+ const metadata = parseFrontmatter(content);
2855
+ const headings = extractHeadings(content);
2856
+ return {
2857
+ path,
2858
+ kind,
2859
+ title: typeof metadata?.title === "string" && metadata.title.trim() !== "" ? metadata.title.trim() : headings.find((heading) => heading.depth === 1)?.title ?? basename3(path, ".md"),
2860
+ summary: extractSummary(content, metadata?.summary),
2861
+ bytes,
2862
+ headings
2863
+ };
2864
+ }
2865
+ function compareDocument(left, right) {
2866
+ return left.path.localeCompare(right.path);
2867
+ }
2868
+ function validatePositiveBound(value, label) {
2869
+ if (!Number.isInteger(value) || value < 1)
2870
+ throw new SpecsQueryError("invalid_specs_bound", `${label} must be a positive integer`);
2871
+ }
2872
+ function isInside(directory, path) {
2873
+ const rel = relative6(directory, path);
2874
+ return rel === "" || !isAbsolute3(rel) && rel !== ".." && !rel.startsWith(`..${sep3}`);
2875
+ }
2876
+ function toProjectPath(projectRoot, path) {
2877
+ return normalizeLogicalPath(relative6(projectRoot, path)) || ".";
2878
+ }
2879
+
2880
+ // src/specs/projection.ts
2881
+ function projectSpecsTree(inspection) {
2882
+ assertCompleteInspection(inspection);
2883
+ return {
2884
+ ...projectionBase(inspection),
2885
+ mode: "tree",
2886
+ tree: inspection.tree,
2887
+ decisionRecords: inspection.decisionRecords,
2888
+ documents: inspection.documents
2889
+ };
2890
+ }
2891
+ async function projectSpecsDetail(inspection, path) {
2892
+ assertCompleteInspection(inspection);
2893
+ return {
2894
+ ...projectionBase(inspection),
2895
+ mode: "detail",
2896
+ document: await readSpecsDetail(inspection, path)
2897
+ };
2898
+ }
2899
+ async function projectSpecsSearch(inspection, literal, options) {
2900
+ assertCompleteInspection(inspection);
2901
+ const result = await searchSpecs(inspection, literal, options);
2902
+ return {
2903
+ ...projectionBase(inspection),
2904
+ mode: "search",
2905
+ query: {
2906
+ literal,
2907
+ limit: options.limit,
2908
+ excerptCodePoints: options.excerptCodePoints
2909
+ },
2910
+ matches: result.matches,
2911
+ summary: result.summary
2912
+ };
2913
+ }
2914
+ function assertCompleteInspection(inspection) {
2915
+ if (!specsInspectionComplete(inspection)) {
2916
+ throw new SpecsQueryError(
2917
+ "specs_inspection_incomplete",
2918
+ "Specs inspection is incomplete; resolve the reported diagnostics before using the projection"
2919
+ );
2920
+ }
2921
+ }
2922
+ function projectionBase(inspection) {
2923
+ return {
2924
+ source: inspection.source,
2925
+ roots: inspection.roots,
2926
+ generatedIndexes: inspection.generatedIndexes,
2927
+ diagnostics: inspection.diagnostics,
2928
+ diagnosticSummary: inspection.diagnosticSummary,
2929
+ runtime: inspection.runtime,
2930
+ limits: inspection.limits
2931
+ };
2932
+ }
2933
+
1871
2934
  // src/status/dependency-forest.ts
1872
2935
  function defaultRoots(plan) {
1873
2936
  const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
@@ -1904,11 +2967,22 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
1904
2967
  }
1905
2968
 
1906
2969
  export {
2970
+ IssueRelationshipError,
2971
+ parseIssueRelationships,
2972
+ createIssueRelationship,
2973
+ walkMarkdownFiles,
2974
+ inspectUnsupportedRules,
2975
+ normalizeLogicalPath,
2976
+ isValidSpecName,
2977
+ guardRspInitialized,
2978
+ cleanupEmptyParentDirs,
2979
+ detectProjectName,
1907
2980
  GROUP_BRIEF_FILENAME,
1908
2981
  WorkRefError,
1909
2982
  normalizeWorkRefSegment,
1910
2983
  normalizeExecutableWorkRef,
1911
2984
  resolveWorkRef,
2985
+ resolveWorkRefPath,
1912
2986
  resolveFocusMarkerPath,
1913
2987
  resolveArchiveDirectory,
1914
2988
  inspectFocusTree,
@@ -1920,9 +2994,6 @@ export {
1920
2994
  inspectChangeGroups,
1921
2995
  getGroupReopenEvidence,
1922
2996
  hasArchivedGroupBrief,
1923
- IssueRelationshipError,
1924
- parseIssueRelationships,
1925
- createIssueRelationship,
1926
2997
  HISTORY_DEFAULT_LIMIT,
1927
2998
  HISTORY_MAX_LIMIT,
1928
2999
  ArchiveHistoryError,
@@ -1931,8 +3002,25 @@ export {
1931
3002
  selectArchiveHistoryRecord,
1932
3003
  selectArchivedGroupBrief,
1933
3004
  readArchiveHistoryDetail,
3005
+ readArchiveHistoryDocument,
1934
3006
  historyInspectionComplete,
1935
3007
  inspectChangeDependencies,
3008
+ SPECS_DEFAULT_RESULT_LIMIT,
3009
+ SPECS_MAX_RESULT_LIMIT,
3010
+ SPECS_MAX_FILE_BYTES,
3011
+ SPECS_SEARCH_EXCERPT_CODE_POINTS,
3012
+ SPECS_DETAIL_CONTENT_CODE_POINTS,
3013
+ SpecsQueryError,
3014
+ inspectSpecs,
3015
+ specsInspectionComplete,
3016
+ isRecognizedGeneratedSpecsIndex,
3017
+ collectArchiveReadiness,
3018
+ toArchiveReadinessOutput,
3019
+ buildDurableReviewGuidance,
3020
+ getDurableReviewCandidateTargets,
3021
+ projectSpecsTree,
3022
+ projectSpecsDetail,
3023
+ projectSpecsSearch,
1936
3024
  inspectProjectStatus,
1937
3025
  projectDependencyForest
1938
3026
  };