@oevery/rsp 3.2.0 → 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 (37) hide show
  1. package/README.md +4 -3
  2. package/README.zh-CN.md +3 -2
  3. package/dist/{chunk-SEGJLWRE.mjs → chunk-7HXQNBIG.mjs} +447 -734
  4. package/dist/{chunk-JYDEJEM4.mjs → chunk-QM2UVPQK.mjs} +47 -30
  5. package/dist/{chunk-ZORUJTVS.mjs → chunk-TSUDEX5Y.mjs} +1163 -104
  6. package/dist/cli.mjs +5332 -3047
  7. package/dist/entry-GIDNR7CJ.mjs +1846 -0
  8. package/dist/{entry-RG6HREUN.mjs → entry-YAW3PYFA.mjs} +2 -2
  9. package/package.json +12 -7
  10. package/rules/rsp-rules.md +6 -5
  11. package/skills/rsp/SKILL.md +26 -57
  12. package/skills/rsp/references/control-outcome.md +31 -0
  13. package/skills/rsp/references/durable-review.md +18 -1
  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 +9 -8
  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 +24 -9
  20. package/skills/rsp-design/SKILL.md +1 -1
  21. package/skills/rsp-diagnose/SKILL.md +3 -1
  22. package/skills/rsp-implement/SKILL.md +15 -5
  23. package/skills/rsp-manage/SKILL.md +56 -51
  24. package/skills/rsp-manage/references/closeout.md +3 -1
  25. package/skills/rsp-manage/references/delegation.md +47 -0
  26. package/skills/rsp-manage/references/interruption-recovery.md +15 -3
  27. package/skills/rsp-manage/references/review-convergence.md +3 -1
  28. package/skills/rsp-release-docs/SKILL.md +13 -13
  29. package/skills/rsp-release-docs/references/publication-lifecycle.md +4 -0
  30. package/skills/rsp-resolve-findings/SKILL.md +2 -2
  31. package/skills/rsp-review/SKILL.md +9 -12
  32. package/skills/rsp-review/references/code-review.md +3 -2
  33. package/skills/rsp-shape/SKILL.md +1 -1
  34. package/skills/rsp-tdd/SKILL.md +10 -2
  35. package/skills/rsp-verify/SKILL.md +40 -0
  36. package/dist/entry-I22OUIW5.mjs +0 -888
  37. 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-SEGJLWRE.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;
@@ -753,12 +868,12 @@ async function inspectChangeGroups(options = {}) {
753
868
  const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
754
869
  const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
755
870
  for (const path of archiveTree.files) {
756
- const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
871
+ const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, path));
757
872
  const archiveGroup = archivePath.includes("/") ? archivePath.split("/")[0] : null;
758
873
  if (!archiveGroup || !openGroupNames.has(archiveGroup))
759
874
  continue;
760
875
  try {
761
- const content = await readFile(path, "utf-8");
876
+ const content = await readFile2(path, "utf-8");
762
877
  const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
763
878
  const identity = getDocumentTitle(archivedChange, "identity");
764
879
  if (identity?.includes("/")) {
@@ -799,7 +914,7 @@ async function inspectChangeGroups(options = {}) {
799
914
  for (const brief of workTree.briefs) {
800
915
  let content;
801
916
  try {
802
- content = await readFile(brief.path, "utf-8");
917
+ content = await readFile2(brief.path, "utf-8");
803
918
  } catch (error) {
804
919
  diagnostics.push({
805
920
  severity: "error",
@@ -828,7 +943,7 @@ async function inspectChangeGroups(options = {}) {
828
943
  for (const diagnostic of archiveTree.diagnostics.filter((item) => {
829
944
  if (item.code === "invalid_archive_root")
830
945
  return true;
831
- const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, item.path));
946
+ const archivePath = normalizeLogicalPath(relative3(ARCHIVES_DIR, item.path));
832
947
  return archivePath === brief.group || archivePath.startsWith(`${brief.group}/`);
833
948
  })) {
834
949
  diagnostics.push({
@@ -919,10 +1034,10 @@ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroup
919
1034
  return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
920
1035
  }
921
1036
  async function requireDeclaredGroupSlice(ref) {
922
- const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
1037
+ const briefPath = join3(dirname2(ref.path), GROUP_BRIEF_FILENAME);
923
1038
  let content;
924
1039
  try {
925
- content = await readFile(briefPath, "utf-8");
1040
+ content = await readFile2(briefPath, "utf-8");
926
1041
  } catch (error) {
927
1042
  const message = error instanceof Error ? error.message : String(error);
928
1043
  throw new WorkRefError("work_tree_read_failed", `unable to read Group Brief ${briefPath}: ${message}`, ref.name);
@@ -952,11 +1067,11 @@ async function hasArchivedGroupBrief(group) {
952
1067
  }
953
1068
  const groupPrefix = `${group}/`;
954
1069
  for (const path of archiveTree.files) {
955
- if (!normalizeLogicalPath(relative2(ARCHIVES_DIR, path)).startsWith(groupPrefix))
1070
+ if (!normalizeLogicalPath(relative3(ARCHIVES_DIR, path)).startsWith(groupPrefix))
956
1071
  continue;
957
1072
  let content;
958
1073
  try {
959
- content = await readFile(path, "utf-8");
1074
+ content = await readFile2(path, "utf-8");
960
1075
  } catch (error) {
961
1076
  const message = error instanceof Error ? error.message : String(error);
962
1077
  throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
@@ -1029,14 +1144,187 @@ function groupSectionHeading(sectionId) {
1029
1144
  }
1030
1145
 
1031
1146
  // src/core/dependency-plan.ts
1032
- import { readFile as readFile4 } from "fs/promises";
1033
-
1034
- // src/history/query.ts
1035
1147
  import { readFile as readFile3 } from "fs/promises";
1036
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
+
1037
1326
  // src/history/inspect.ts
1038
- import { readFile as readFile2 } from "fs/promises";
1039
- 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";
1040
1328
 
1041
1329
  // src/core/issue-relationship.ts
1042
1330
  var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
@@ -1101,33 +1389,11 @@ function isPlainObject(value) {
1101
1389
  return typeof value === "object" && value !== null && !Array.isArray(value);
1102
1390
  }
1103
1391
 
1104
- // src/history/model.ts
1105
- var HISTORY_DEFAULT_LIMIT = 20;
1106
- var HISTORY_MAX_LIMIT = 100;
1107
- var HISTORY_MAX_TEXT_CODE_POINTS = 500;
1108
- var HISTORY_MAX_EVIDENCE_ITEMS = 20;
1109
- var HISTORY_MAX_DIAGNOSTICS = 20;
1110
- var HISTORY_MAX_CANDIDATES = 20;
1111
- var ArchiveHistoryError = class extends Error {
1112
- constructor(code, message, candidates = []) {
1113
- super(message);
1114
- this.code = code;
1115
- this.name = "ArchiveHistoryError";
1116
- const ordered = [...candidates].sort();
1117
- this.candidateTotal = ordered.length;
1118
- this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
1119
- this.candidatesTruncated = this.candidateTotal > this.candidates.length;
1120
- }
1121
- code;
1122
- candidates;
1123
- candidateTotal;
1124
- candidatesTruncated;
1125
- };
1126
-
1127
1392
  // src/history/inspect.ts
1128
1393
  var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1129
1394
  async function inspectArchiveHistory(options = {}) {
1130
- const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1395
+ const archivesDir = resolve2(options.archivesDir ?? ARCHIVES_DIR);
1396
+ const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
1131
1397
  const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
1132
1398
  const diagnostics = tree.diagnostics.map((diagnostic) => ({
1133
1399
  severity: "error",
@@ -1148,15 +1414,21 @@ async function inspectArchiveHistory(options = {}) {
1148
1414
  }
1149
1415
  for (const sourcePath of tree.files) {
1150
1416
  const path = archiveOutputPath(sourcePath, archivesDir);
1151
- let content;
1417
+ let current;
1152
1418
  try {
1153
- content = await readFile2(sourcePath, "utf-8");
1419
+ current = await readCurrentArchiveFile({
1420
+ sourcePath,
1421
+ archivesDir,
1422
+ projectPath: path,
1423
+ maxFileBytes
1424
+ });
1154
1425
  } catch (error) {
1155
- const message = toErrorMessage(error);
1156
- 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 });
1157
1429
  continue;
1158
1430
  }
1159
- const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1431
+ const parsed = parseArchiveEntry(current, path, maxFileBytes);
1160
1432
  if (parsed.diagnostic)
1161
1433
  diagnostics.push(parsed.diagnostic);
1162
1434
  if (parsed.record)
@@ -1182,14 +1454,15 @@ async function inspectArchiveHistory(options = {}) {
1182
1454
  runtime
1183
1455
  };
1184
1456
  }
1185
- function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1186
- 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);
1187
1460
  if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1188
1461
  return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
1189
1462
  }
1190
1463
  const date = nameMatch[1];
1191
1464
  const archiveStem = nameMatch[2];
1192
- const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1465
+ const relativeDirectory = normalizeLogicalPath(relative5(archivesDir, dirname3(sourcePath)));
1193
1466
  const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1194
1467
  if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
1195
1468
  return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
@@ -1248,6 +1521,9 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1248
1521
  path,
1249
1522
  ...issues === void 0 ? {} : { issues },
1250
1523
  sourcePath,
1524
+ archivesDir,
1525
+ sourceSnapshot: current.snapshot,
1526
+ maxFileBytes,
1251
1527
  searchSummary: rawSummary
1252
1528
  }
1253
1529
  };
@@ -1264,10 +1540,10 @@ function isCalendarDate(value) {
1264
1540
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1265
1541
  }
1266
1542
  function archiveOutputPath(sourcePath, archivesDir) {
1267
- return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1543
+ return `.rsp/archives/${normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(sourcePath)))}`;
1268
1544
  }
1269
1545
  function normalizeOutputPath(path, archivesDir) {
1270
- const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1546
+ const relativePath = normalizeLogicalPath(relative5(resolve2(archivesDir), resolve2(path)));
1271
1547
  return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1272
1548
  }
1273
1549
  function errorDiagnostic(code, path, message) {
@@ -1348,17 +1624,28 @@ function selectArchivedGroupBrief(records, selector) {
1348
1624
  return matches[0];
1349
1625
  }
1350
1626
  async function readArchiveHistoryDetail(record) {
1627
+ return (await readArchiveHistoryDocument(record)).detail;
1628
+ }
1629
+ async function readArchiveHistoryDocument(record) {
1351
1630
  let content;
1352
1631
  try {
1353
- 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;
1354
1639
  } catch (error) {
1640
+ if (error instanceof ArchiveHistoryError)
1641
+ throw error;
1355
1642
  throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1356
1643
  }
1357
1644
  const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1358
1645
  const tasks = getDocumentSectionBody(document, "tasks");
1359
1646
  const verify = getDocumentSectionBody(document, "verify");
1360
1647
  const blockers = getDocumentSectionBody(document, "blockers");
1361
- return {
1648
+ const detail = {
1362
1649
  ...toOutputRecord(record),
1363
1650
  scenarioCount: parseScenarios(content).length,
1364
1651
  checkboxes: {
@@ -1371,12 +1658,20 @@ async function readArchiveHistoryDetail(record) {
1371
1658
  blockers: boundEvidence(blockers, true)
1372
1659
  }
1373
1660
  };
1661
+ return { content, detail };
1374
1662
  }
1375
1663
  function historyInspectionComplete(inspection) {
1376
1664
  return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1377
1665
  }
1378
1666
  function toOutputRecord(record) {
1379
- 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;
1380
1675
  return output;
1381
1676
  }
1382
1677
  function boundEvidence(section, omitNone = false) {
@@ -1398,7 +1693,6 @@ function compareHistoryRecords2(left, right) {
1398
1693
  }
1399
1694
 
1400
1695
  // src/core/dependency-plan.ts
1401
- var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
1402
1696
  var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
1403
1697
  async function inspectChangeDependencies(options = {}) {
1404
1698
  const workTree = options.workTree ?? await inspectWorkTree();
@@ -1420,7 +1714,7 @@ async function inspectChangeDependencies(options = {}) {
1420
1714
  diagnostics.push(...archiveHistory.diagnostics);
1421
1715
  for (const brief of workTree.briefs) {
1422
1716
  try {
1423
- if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
1717
+ if (hasMeaningfulBlockers(await readFile3(brief.path, "utf-8")))
1424
1718
  blockedGroups.add(brief.group);
1425
1719
  } catch (error) {
1426
1720
  diagnostics.push({
@@ -1434,7 +1728,7 @@ async function inspectChangeDependencies(options = {}) {
1434
1728
  }
1435
1729
  for (const ref of workTree.changes) {
1436
1730
  try {
1437
- const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
1731
+ const parsed = parseDependencyBlockers(await readFile3(ref.path, "utf-8"));
1438
1732
  if (ref.group && blockedGroups.has(ref.group))
1439
1733
  parsed.external = true;
1440
1734
  parsedByName.set(ref.name, parsed);
@@ -1540,7 +1834,7 @@ function parseDependencyBlockers(content) {
1540
1834
  let external = false;
1541
1835
  const malformed = [];
1542
1836
  for (const line of extractBlockerLines(content)) {
1543
- if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
1837
+ if (!line || isEmptyBlockerLine(line))
1544
1838
  continue;
1545
1839
  const dependency = line.match(REQUIRES_RE);
1546
1840
  if (dependency) {
@@ -1564,13 +1858,13 @@ function buildWaves(names, edges, blockersByName, preferredOrder) {
1564
1858
  const excluded = new Set(
1565
1859
  [...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
1566
1860
  );
1567
- let changed = true;
1568
- while (changed) {
1569
- changed = false;
1861
+ let changed2 = true;
1862
+ while (changed2) {
1863
+ changed2 = false;
1570
1864
  for (const edge of edges) {
1571
1865
  if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
1572
1866
  excluded.add(edge.change);
1573
- changed = true;
1867
+ changed2 = true;
1574
1868
  }
1575
1869
  }
1576
1870
  }
@@ -1648,6 +1942,82 @@ function findCycleDiagnostics(edges) {
1648
1942
  return diagnostics;
1649
1943
  }
1650
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
+
1651
2021
  // src/status/inspect.ts
1652
2022
  async function inspectProjectStatus(options = {}) {
1653
2023
  const runtime = [];
@@ -1746,7 +2116,7 @@ async function inspectProjectStatus(options = {}) {
1746
2116
  let issues = [];
1747
2117
  if (path) {
1748
2118
  try {
1749
- const content = await readFile5(path, "utf-8");
2119
+ const content = await readFile4(path, "utf-8");
1750
2120
  title = extractChangeTitle(content) ?? name;
1751
2121
  summary = extractChangeSummary(content);
1752
2122
  blockerEntries = extractBlockerEntries(content);
@@ -1774,20 +2144,13 @@ async function inspectProjectStatus(options = {}) {
1774
2144
  });
1775
2145
  }
1776
2146
  isBlocked = dependencyInspection.activeBlockers.get(name) ?? hasMeaningfulBlockers(content);
1777
- const checkboxes = countCheckboxes(content);
1778
- done = checkboxes.done;
1779
- total = checkboxes.total;
1780
- progressKnown = true;
1781
2147
  const details = collectArchiveReadiness(content, { activeBlockers: isBlocked });
1782
- readiness = {
1783
- incompleteTasks: details.taskTodos.length,
1784
- incompleteVerify: details.verifyTodos.length,
1785
- activeBlockers: details.activeBlockers,
1786
- missingScenarios: details.missingScenarios,
1787
- deterministic: details.deterministic,
1788
- semantic: details.semantic,
1789
- archiveReady: details.archiveReady
1790
- };
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);
1791
2154
  } catch (error) {
1792
2155
  diagnostics.push({
1793
2156
  severity: "error",
@@ -1874,6 +2237,13 @@ function emptyReadiness(activeBlockers) {
1874
2237
  return {
1875
2238
  incompleteTasks: 0,
1876
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,
1877
2247
  activeBlockers,
1878
2248
  missingScenarios: false,
1879
2249
  deterministic: "pass",
@@ -1897,6 +2267,670 @@ function deriveArchiveTrend(records) {
1897
2267
  return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
1898
2268
  }
1899
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
+
1900
2934
  // src/status/dependency-forest.ts
1901
2935
  function defaultRoots(plan) {
1902
2936
  const selected = new Set(plan.nodes.filter((node) => node.selection === "selected").map((node) => node.name));
@@ -1933,11 +2967,22 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
1933
2967
  }
1934
2968
 
1935
2969
  export {
2970
+ IssueRelationshipError,
2971
+ parseIssueRelationships,
2972
+ createIssueRelationship,
2973
+ walkMarkdownFiles,
2974
+ inspectUnsupportedRules,
2975
+ normalizeLogicalPath,
2976
+ isValidSpecName,
2977
+ guardRspInitialized,
2978
+ cleanupEmptyParentDirs,
2979
+ detectProjectName,
1936
2980
  GROUP_BRIEF_FILENAME,
1937
2981
  WorkRefError,
1938
2982
  normalizeWorkRefSegment,
1939
2983
  normalizeExecutableWorkRef,
1940
2984
  resolveWorkRef,
2985
+ resolveWorkRefPath,
1941
2986
  resolveFocusMarkerPath,
1942
2987
  resolveArchiveDirectory,
1943
2988
  inspectFocusTree,
@@ -1949,9 +2994,6 @@ export {
1949
2994
  inspectChangeGroups,
1950
2995
  getGroupReopenEvidence,
1951
2996
  hasArchivedGroupBrief,
1952
- IssueRelationshipError,
1953
- parseIssueRelationships,
1954
- createIssueRelationship,
1955
2997
  HISTORY_DEFAULT_LIMIT,
1956
2998
  HISTORY_MAX_LIMIT,
1957
2999
  ArchiveHistoryError,
@@ -1960,8 +3002,25 @@ export {
1960
3002
  selectArchiveHistoryRecord,
1961
3003
  selectArchivedGroupBrief,
1962
3004
  readArchiveHistoryDetail,
3005
+ readArchiveHistoryDocument,
1963
3006
  historyInspectionComplete,
1964
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,
1965
3024
  inspectProjectStatus,
1966
3025
  projectDependencyForest
1967
3026
  };