@oevery/rsp 3.1.0-beta.3 → 3.1.0-beta.5

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.
@@ -2,24 +2,34 @@
2
2
  import {
3
3
  ARCHIVES_DIR,
4
4
  CHANGES_DIR,
5
+ CHANGE_DOCUMENT_SCHEMA,
5
6
  CONFIG_PATH,
6
7
  FOCUS_DIR,
8
+ GROUP_BRIEF_DOCUMENT_SCHEMA,
7
9
  ManagedPathError,
8
10
  collectArchiveReadiness,
9
11
  countCheckboxes,
10
12
  extractBlockerLines,
11
- extractSection,
13
+ getCanonicalSectionHeadings,
14
+ getDocumentSectionBody,
15
+ getDocumentTitle,
16
+ getDocumentTitles,
17
+ hasMeaningfulBlockerBody,
12
18
  hasMeaningfulBlockers,
13
19
  inspectManagedDirectory,
14
20
  inspectRspConfig,
15
21
  normalizeLogicalPath,
16
22
  parseFrontmatter,
23
+ parseRspDocument,
17
24
  parseScenarios,
25
+ renderDocumentSectionHeading,
26
+ renderDocumentTitle,
18
27
  requireManagedDirectory,
19
28
  requireManagedFile,
29
+ resolveLanguagePolicy,
20
30
  resolveManagePolicy,
21
31
  toErrorMessage
22
- } from "./chunk-OTJFL6XH.mjs";
32
+ } from "./chunk-UVYEXKYS.mjs";
23
33
 
24
34
  // src/status/inspect.ts
25
35
  import { readFile as readFile5, stat } from "fs/promises";
@@ -29,10 +39,13 @@ import { readFile } from "fs/promises";
29
39
  import { dirname, join as join2, relative as relative2 } from "path";
30
40
 
31
41
  // src/core/work-ref.ts
32
- import { lstatSync } from "fs";
42
+ import { Buffer } from "buffer";
43
+ import { lstatSync, readdirSync } from "fs";
33
44
  import { readdir } from "fs/promises";
34
45
  import { isAbsolute, join, relative } from "path";
35
- var WORK_SEGMENT_RE = /^[a-z0-9-]+$/;
46
+ 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
+ var WORK_SEGMENT_MAX_CODE_POINTS = 100;
48
+ var WORK_SEGMENT_MAX_UTF8_BYTES = 200;
36
49
  var GROUP_BRIEF_FILENAME = "00-brief.md";
37
50
  var WorkRefError = class extends Error {
38
51
  constructor(code, message, input) {
@@ -44,35 +57,51 @@ var WorkRefError = class extends Error {
44
57
  code;
45
58
  input;
46
59
  };
47
- function resolveWorkRef(name, options = {}) {
48
- const changesDir = options.changesDir ?? CHANGES_DIR;
49
- const segments = name.split("/");
50
- const isGroupBrief = segments.length === 2 && segments[1] === "brief";
51
- if (segments.length > 2) {
52
- throw new WorkRefError(
53
- "unsupported_work_depth",
54
- `work identity "${name}" exceeds the supported one-level Change Group depth`,
55
- name
56
- );
57
- }
58
- if (!name || segments.some((segment) => !WORK_SEGMENT_RE.test(segment))) {
60
+ function normalizeWorkRefSegment(segment) {
61
+ const normalized = segment.normalize("NFC");
62
+ if (!WORK_SEGMENT_RE.test(normalized) || [...normalized].length > WORK_SEGMENT_MAX_CODE_POINTS || Buffer.byteLength(normalized, "utf8") > WORK_SEGMENT_MAX_UTF8_BYTES) {
59
63
  throw new WorkRefError(
60
64
  "invalid_work_ref",
61
- `work identity "${name}" must contain one or two lowercase kebab-case segments`,
62
- name
65
+ `work identity segment "${segment}" must contain at most ${WORK_SEGMENT_MAX_CODE_POINTS} lowercase or caseless Unicode letters, marks, or decimal numbers separated only by internal hyphens and fit within ${WORK_SEGMENT_MAX_UTF8_BYTES} UTF-8 bytes`,
66
+ segment
63
67
  );
64
68
  }
65
- if (segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME.slice(0, -3)) {
69
+ return normalized;
70
+ }
71
+ function normalizeExecutableWorkRef(name) {
72
+ return normalizeWorkRefName(name, false);
73
+ }
74
+ function isCanonicalExecutableWorkRef(name) {
75
+ try {
76
+ return normalizeExecutableWorkRef(name) === name;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ function isCanonicalWorkRefSegment(segment) {
82
+ try {
83
+ return normalizeWorkRefSegment(segment) === segment;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ function resolveWorkRef(name, options = {}) {
89
+ const changesDir = options.changesDir ?? CHANGES_DIR;
90
+ const normalizedName = normalizeWorkRefName(name, true);
91
+ if (options.canonical && normalizedName !== name) {
66
92
  throw new WorkRefError(
67
93
  "invalid_work_ref",
68
- `work identity "${name}" uses the reserved Group Brief filename`,
94
+ `stored work identity "${name}" must use Unicode NFC normalization`,
69
95
  name
70
96
  );
71
97
  }
72
- assertValidWorkRoot(changesDir, name);
73
- assertNoIdentityCollision(changesDir, segments, name, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
98
+ const segments = normalizedName.split("/");
99
+ const isGroupBrief = segments.length === 2 && segments[1] === "brief";
100
+ assertValidWorkRoot(changesDir, normalizedName);
101
+ assertNoNormalizationCollision(changesDir, segments, normalizedName, isGroupBrief);
102
+ assertNoIdentityCollision(changesDir, segments, normalizedName, isGroupBrief ? GROUP_BRIEF_FILENAME : void 0);
74
103
  const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
75
- const ref = segments.length === 1 ? { kind: "change", name, path, group: null } : isGroupBrief ? { kind: "group-brief", name, path, group: segments[0] } : { kind: "group-change", name, path, group: segments[0] };
104
+ 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] };
76
105
  if (ref.kind === "group-change" && options.executable !== false)
77
106
  assertGroupBrief(changesDir, ref);
78
107
  if (options.executable && ref.kind === "group-brief") {
@@ -147,7 +176,7 @@ function resolveWorkRefPath(path, options = {}) {
147
176
  );
148
177
  }
149
178
  const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
150
- return resolveWorkRef(name, options);
179
+ return resolveWorkRef(name, { ...options, canonical: true });
151
180
  }
152
181
  function isExecutableWorkRef(ref) {
153
182
  return ref.kind !== "group-brief";
@@ -155,6 +184,7 @@ function isExecutableWorkRef(ref) {
155
184
  function resolveFocusMarkerPath(ref, options = {}) {
156
185
  const focusDir = options.focusDir ?? FOCUS_DIR;
157
186
  assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
187
+ assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
158
188
  if (ref.group) {
159
189
  assertOptionalManagedDirectory(
160
190
  join(focusDir, ref.group),
@@ -178,6 +208,7 @@ function resolveArchiveDirectory(ref, options = {}) {
178
208
  assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
179
209
  if (!ref.group)
180
210
  return archivesDir;
211
+ assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
181
212
  const groupPath = join(archivesDir, ref.group);
182
213
  assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
183
214
  return groupPath;
@@ -201,10 +232,10 @@ async function inspectFocusTree(options = {}) {
201
232
  if (entry.name === ".gitkeep" && entry.isFile())
202
233
  continue;
203
234
  if (entry.isDirectory()) {
204
- if (!WORK_SEGMENT_RE.test(entry.name)) {
235
+ if (!isCanonicalWorkRefSegment(entry.name)) {
205
236
  addDiagnostic(result, new WorkRefError(
206
237
  "invalid_work_ref",
207
- `work identity "${entry.name}" must contain one or two lowercase kebab-case segments`,
238
+ `stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
208
239
  entry.name
209
240
  ), path);
210
241
  continue;
@@ -244,7 +275,7 @@ async function inspectArchiveTree(options = {}) {
244
275
  if (entry.name === "INDEX.md" && entry.isFile())
245
276
  continue;
246
277
  if (entry.isDirectory()) {
247
- if (!WORK_SEGMENT_RE.test(entry.name)) {
278
+ if (!isCanonicalWorkRefSegment(entry.name)) {
248
279
  addArchivePathDiagnostic(result, entry.name, path);
249
280
  continue;
250
281
  }
@@ -282,10 +313,10 @@ async function inspectWorkTree(options = {}) {
282
313
  if (entry.name === ".gitkeep" && entry.isFile())
283
314
  continue;
284
315
  if (entry.isDirectory()) {
285
- if (!WORK_SEGMENT_RE.test(entry.name)) {
316
+ if (!isCanonicalWorkRefSegment(entry.name)) {
286
317
  addDiagnostic(result, new WorkRefError(
287
318
  "invalid_work_ref",
288
- `work identity "${entry.name}" must contain one or two lowercase kebab-case segments`,
319
+ `stored work identity "${entry.name}" must be a safe Unicode NFC segment`,
289
320
  entry.name
290
321
  ), path);
291
322
  continue;
@@ -400,7 +431,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
400
431
  }
401
432
  function collectFocusMarker(input, path, changesDir, result) {
402
433
  try {
403
- const ref = resolveWorkRef(input, { changesDir, executable: true });
434
+ const ref = resolveWorkRef(input, { changesDir, canonical: true, executable: true });
404
435
  result.markers.push({ name: ref.name, path });
405
436
  } catch (error) {
406
437
  if (!(error instanceof WorkRefError))
@@ -448,6 +479,98 @@ function addDiagnostic(result, error, path) {
448
479
  return;
449
480
  result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
450
481
  }
482
+ function normalizeWorkRefName(name, allowGroupBrief) {
483
+ const segments = name.split("/");
484
+ if (segments.length > 2) {
485
+ throw new WorkRefError(
486
+ "unsupported_work_depth",
487
+ `work identity "${name}" exceeds the supported one-level Change Group depth`,
488
+ name
489
+ );
490
+ }
491
+ if (!name || segments.some((segment) => !segment)) {
492
+ throw new WorkRefError(
493
+ "invalid_work_ref",
494
+ `work identity "${name}" must contain one or two safe Unicode segments`,
495
+ name
496
+ );
497
+ }
498
+ const normalizedSegments = segments.map(normalizeWorkRefSegment);
499
+ const child = normalizedSegments[1];
500
+ if (segments.length === 2 && child === GROUP_BRIEF_FILENAME.slice(0, -3)) {
501
+ throw new WorkRefError(
502
+ "invalid_work_ref",
503
+ `work identity "${name}" uses the reserved Group Brief filename`,
504
+ name
505
+ );
506
+ }
507
+ if (segments.length === 2 && child === "brief" && !allowGroupBrief) {
508
+ throw new WorkRefError(
509
+ "non_executable_work_ref",
510
+ `work identity "${name}" is a Group Brief and cannot be used as an executable Change`,
511
+ name
512
+ );
513
+ }
514
+ return normalizedSegments.join("/");
515
+ }
516
+ function assertNoNormalizationCollision(changesDir, segments, input, isGroupBrief) {
517
+ for (let depth = 0; depth < segments.length; depth++) {
518
+ const parent = join(changesDir, ...segments.slice(0, depth));
519
+ const parentKind = getPathKind(parent, input);
520
+ if (parentKind === "missing")
521
+ return;
522
+ if (parentKind !== "directory")
523
+ continue;
524
+ let entries;
525
+ try {
526
+ entries = readdirSync(parent, { withFileTypes: true });
527
+ } catch (error) {
528
+ throw asReadFailure(parent, input, error);
529
+ }
530
+ const expected = segments[depth];
531
+ for (const entry of entries) {
532
+ let candidate = null;
533
+ if (entry.isDirectory())
534
+ candidate = entry.name;
535
+ else if (entry.isFile() && entry.name.endsWith(".md"))
536
+ candidate = entry.name.slice(0, -3);
537
+ if (depth === segments.length - 1 && isGroupBrief && entry.name === GROUP_BRIEF_FILENAME)
538
+ candidate = "brief";
539
+ if (candidate && candidate !== expected && candidate.normalize("NFC") === expected) {
540
+ throw new WorkRefError(
541
+ "work_ref_collision",
542
+ `work identity "${input}" collides with non-canonical stored identity "${candidate}" after Unicode NFC normalization`,
543
+ input
544
+ );
545
+ }
546
+ }
547
+ }
548
+ }
549
+ function assertNoManagedNormalizationCollision(root, segments, input, code) {
550
+ for (let depth = 0; depth < segments.length; depth++) {
551
+ const parent = join(root, ...segments.slice(0, depth));
552
+ const parentKind = getPathKind(parent, input);
553
+ if (parentKind === "missing")
554
+ return;
555
+ if (parentKind !== "directory")
556
+ continue;
557
+ let entries;
558
+ try {
559
+ entries = readdirSync(parent, { withFileTypes: true });
560
+ } catch (error) {
561
+ throw asReadFailure(parent, input, error);
562
+ }
563
+ const expected = segments[depth];
564
+ const alias = entries.find((entry) => entry.name !== expected && entry.name.normalize("NFC") === expected);
565
+ if (alias) {
566
+ throw new WorkRefError(
567
+ code,
568
+ `managed identity "${input}" collides with non-canonical stored entry "${alias.name}" after Unicode NFC normalization`,
569
+ input
570
+ );
571
+ }
572
+ }
573
+ }
451
574
  function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
452
575
  for (let depth = 1; depth <= segments.length; depth++) {
453
576
  const identityPath = join(changesDir, ...segments.slice(0, depth));
@@ -542,7 +665,8 @@ function normalizeLogicalPath2(path) {
542
665
  }
543
666
 
544
667
  // src/core/change-group.ts
545
- var GROUP_BRIEF_SECTIONS = ["Goal", "Scope", "Shared Constraints", "Slices", "Completion Conditions", "Durable Outcomes", "Blockers"];
668
+ var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
669
+ var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
546
670
  async function resolveExecutableChange(name, options = {}) {
547
671
  const ref = resolveWorkRef(name, { ...options, executable: true });
548
672
  if (ref.kind === "group-change")
@@ -550,32 +674,33 @@ async function resolveExecutableChange(name, options = {}) {
550
674
  return ref;
551
675
  }
552
676
  function generateGroupBriefContent(name, goal = "") {
677
+ const placeholder = "<\u2026>";
553
678
  return `---
554
679
  kind: group
555
680
  ---
556
681
 
557
- # Change Group: ${name}
682
+ ${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
558
683
 
559
- ## Goal
560
- - ${goal || "<what shared outcome this group must deliver>"}
684
+ ${groupSectionHeading("goal")}
685
+ - ${goal || placeholder}
561
686
 
562
- ## Scope
563
- - <what is coordinated by this group>
687
+ ${groupSectionHeading("scope")}
688
+ - ${placeholder}
564
689
 
565
- ## Shared Constraints
566
- - <constraint shared by every child Change>
690
+ ${groupSectionHeading("sharedConstraints")}
691
+ - ${placeholder}
567
692
 
568
- ## Slices
569
- - \`${name}/<change>\`: <independently executable boundary>
693
+ ${groupSectionHeading("slices")}
694
+ - \`${name}/<change>\`: ${placeholder}
570
695
 
571
- ## Completion Conditions
572
- - [ ] <end-to-end condition beyond individual child verification>
696
+ ${groupSectionHeading("completionConditions")}
697
+ - [ ] ${placeholder}
573
698
 
574
- ## Durable Outcomes
575
- - Current facts: <Spec, CONTEXT.md, scoped instruction target, or none>
576
- - Lasting rationale: <Decision Record target, or none>
699
+ ${groupSectionHeading("durableOutcomes")}
700
+ - Current facts: ${placeholder}
701
+ - Lasting rationale: ${placeholder}
577
702
 
578
- ## Blockers
703
+ ${groupSectionHeading("blockers")}
579
704
  - none
580
705
  `;
581
706
  }
@@ -587,6 +712,8 @@ async function inspectChangeGroups(options = {}) {
587
712
  const groups = [];
588
713
  const archivedSlicesByGroup = /* @__PURE__ */ new Map();
589
714
  const archivedGroups = /* @__PURE__ */ new Set();
715
+ const archivedGroupReopenEvidence = /* @__PURE__ */ new Map();
716
+ const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
590
717
  const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
591
718
  for (const path of archiveTree.files) {
592
719
  const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
@@ -595,10 +722,10 @@ async function inspectChangeGroups(options = {}) {
595
722
  continue;
596
723
  try {
597
724
  const content = await readFile(path, "utf-8");
598
- const heading = content.match(/^# Change:\s+(\S+)\s*$/m);
599
- if (heading?.[1]?.includes("/")) {
600
- const identity = heading[1];
601
- if (!new RegExp(`^${escapeRegExp(archiveGroup)}/[a-z0-9-]+$`).test(identity)) {
725
+ const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
726
+ const identity = getDocumentTitle(archivedChange, "identity");
727
+ if (identity?.includes("/")) {
728
+ if (!isCanonicalExecutableWorkRef(identity) || !identity.startsWith(`${archiveGroup}/`)) {
602
729
  diagnostics.push({
603
730
  severity: "error",
604
731
  code: "group_archive_identity_mismatch",
@@ -612,8 +739,16 @@ async function inspectChangeGroups(options = {}) {
612
739
  archivedSlicesByGroup.set(archiveGroup, groupSlices);
613
740
  }
614
741
  }
615
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(archiveGroup)}\\s*$`, "m").test(content))
742
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup) {
616
743
  archivedGroups.add(archiveGroup);
744
+ const evidence = archivedGroupReopenEvidence.get(archiveGroup) ?? /* @__PURE__ */ new Set();
745
+ for (const item of getGroupReopenEvidence(content))
746
+ evidence.add(item.key);
747
+ archivedGroupReopenEvidence.set(archiveGroup, evidence);
748
+ const retainedPaths = archivedGroupBriefPaths.get(archiveGroup) ?? /* @__PURE__ */ new Set();
749
+ retainedPaths.add(`.rsp/archives/${archivePath}`);
750
+ archivedGroupBriefPaths.set(archiveGroup, retainedPaths);
751
+ }
617
752
  } catch (error) {
618
753
  diagnostics.push({
619
754
  severity: "error",
@@ -640,7 +775,11 @@ async function inspectChangeGroups(options = {}) {
640
775
  }
641
776
  const parsed = parseGroupBrief(brief.group, content);
642
777
  diagnostics.push(...parsed.diagnostics.map((diagnostic) => ({ ...diagnostic, path: brief.path })));
643
- if (archivedGroups.has(brief.group)) {
778
+ if (archivedGroups.has(brief.group) && !hasExplicitGroupReopenEvidence(
779
+ content,
780
+ archivedGroupReopenEvidence.get(brief.group) ?? /* @__PURE__ */ new Set(),
781
+ archivedGroupBriefPaths.get(brief.group) ?? /* @__PURE__ */ new Set()
782
+ )) {
644
783
  diagnostics.push({
645
784
  severity: "error",
646
785
  code: "group_identity_reopened",
@@ -731,6 +870,16 @@ async function inspectChangeGroups(options = {}) {
731
870
  groups.sort((left, right) => left.name.localeCompare(right.name));
732
871
  return { groups, diagnostics };
733
872
  }
873
+ function getGroupReopenEvidence(content) {
874
+ const completion = getDocumentSectionBody(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "completionConditions");
875
+ return completion.split("\n").flatMap((line) => {
876
+ const match = line.trim().match(/^-\s+\[([ /xX-])\]\s+(Resolve reopened concern from `(\.rsp\/archives\/[^`]+)`: \S.*)$/);
877
+ return match ? [{ active: match[1] !== "-", key: match[2], sourcePath: match[3] }] : [];
878
+ });
879
+ }
880
+ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroupBriefPaths) {
881
+ return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
882
+ }
734
883
  async function requireDeclaredGroupSlice(ref) {
735
884
  const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
736
885
  let content;
@@ -774,13 +923,14 @@ async function hasArchivedGroupBrief(group) {
774
923
  const message = error instanceof Error ? error.message : String(error);
775
924
  throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
776
925
  }
777
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
926
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
778
927
  return true;
779
928
  }
780
929
  return false;
781
930
  }
782
931
  function parseGroupBrief(group, content) {
783
932
  const diagnostics = [];
933
+ const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
784
934
  let frontmatter;
785
935
  try {
786
936
  frontmatter = parseFrontmatter(content);
@@ -789,15 +939,15 @@ function parseGroupBrief(group, content) {
789
939
  }
790
940
  if (frontmatter?.kind !== "group")
791
941
  diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
792
- if (!new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
942
+ if (getDocumentTitle(document, "identity") !== group)
793
943
  diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
794
- for (const section of GROUP_BRIEF_SECTIONS) {
795
- if (!extractSection(content, section))
796
- diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${section}` });
944
+ for (const definition of GROUP_BRIEF_DOCUMENT_SCHEMA.sections) {
945
+ if (!getDocumentSectionBody(document, definition.id))
946
+ diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${definition.heading}` });
797
947
  }
798
948
  const slices = [];
799
949
  const seen = /* @__PURE__ */ new Set();
800
- for (const line of extractSection(content, "Slices").split("\n")) {
950
+ for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
801
951
  const trimmed = line.trim();
802
952
  const identity = trimmed.match(/^-\s+`(\S+)`/);
803
953
  if (!identity || identity[1].includes("<"))
@@ -813,7 +963,7 @@ function parseGroupBrief(group, content) {
813
963
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
814
964
  continue;
815
965
  }
816
- if (!new RegExp(`^${escapeRegExp(group)}/[a-z0-9-]+$`).test(name) || name === `${group}/brief`) {
966
+ if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
817
967
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
818
968
  continue;
819
969
  }
@@ -826,107 +976,477 @@ function parseGroupBrief(group, content) {
826
976
  }
827
977
  if (slices.length < 2)
828
978
  diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
829
- const completion = countCheckboxes(extractSection(content, "Completion Conditions"));
979
+ const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
830
980
  if (completion.total === 0)
831
981
  diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
832
982
  return {
833
983
  slices,
834
984
  completion: { done: completion.done + completion.dropped, total: completion.total },
835
- blockers: hasMeaningfulBlockers(content),
985
+ blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
836
986
  diagnostics
837
987
  };
838
988
  }
839
- function escapeRegExp(value) {
840
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
989
+ function groupSectionHeading(sectionId) {
990
+ return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
841
991
  }
842
992
 
843
993
  // src/core/dependency-plan.ts
994
+ import { readFile as readFile4 } from "fs/promises";
995
+
996
+ // src/history/query.ts
997
+ import { readFile as readFile3 } from "fs/promises";
998
+
999
+ // src/history/inspect.ts
844
1000
  import { readFile as readFile2 } from "fs/promises";
845
- var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
846
- var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
847
- var EXECUTABLE_WORK_REF_RE = /^[a-z0-9-]+(?:\/(?!(?:brief|00-brief)$)[a-z0-9-]+)?$/;
848
- async function inspectChangeDependencies(options = {}) {
849
- const workTree = options.workTree ?? await inspectWorkTree();
850
- const archiveTree = options.archiveTree ?? await inspectArchiveTree();
851
- const openNames = new Set(workTree.changes.map((ref) => ref.name));
852
- const archivedNames = /* @__PURE__ */ new Set();
853
- const parsedByName = /* @__PURE__ */ new Map();
854
- const blockedGroups = /* @__PURE__ */ new Set();
855
- const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
1001
+ import { basename, dirname as dirname2, relative as relative3 } from "path";
1002
+
1003
+ // src/core/issue-relationship.ts
1004
+ var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
1005
+ var ISSUE_FIELDS = /* @__PURE__ */ new Set(["url", "relation"]);
1006
+ var IssueRelationshipError = class extends Error {
1007
+ constructor(code, message) {
1008
+ super(message);
1009
+ this.code = code;
1010
+ this.name = "IssueRelationshipError";
1011
+ }
1012
+ code;
1013
+ };
1014
+ function parseIssueRelationships(frontmatter) {
1015
+ if (!frontmatter || !("issues" in frontmatter))
1016
+ return [];
1017
+ if (!Array.isArray(frontmatter.issues))
1018
+ throw new IssueRelationshipError("invalid_issue_shape", 'frontmatter field "issues" must be a list');
1019
+ const seen = /* @__PURE__ */ new Set();
1020
+ return frontmatter.issues.map((value, index) => {
1021
+ if (!isPlainObject(value))
1022
+ throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must be a mapping with url and relation`);
1023
+ const fields = Object.keys(value);
1024
+ const unsupported = fields.filter((field) => !ISSUE_FIELDS.has(field));
1025
+ if (unsupported.length > 0)
1026
+ throw new IssueRelationshipError("unsupported_issue_field", `issues[${index}] contains unsupported field(s): ${unsupported.sort().join(", ")}`);
1027
+ if (fields.length !== 2 || !("url" in value) || !("relation" in value))
1028
+ throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must contain exactly url and relation`);
1029
+ if (typeof value.url !== "string")
1030
+ throw new IssueRelationshipError("invalid_issue_url", `issues[${index}].url must be an absolute http or https URL`);
1031
+ if (typeof value.relation !== "string" || !ISSUE_RELATIONS.has(value.relation))
1032
+ throw new IssueRelationshipError("unsupported_issue_relation", `issues[${index}].relation must be relates or closes`);
1033
+ const url = normalizeIssueUrl(value.url, `issues[${index}].url`);
1034
+ if (seen.has(url))
1035
+ throw new IssueRelationshipError("duplicate_issue_url", `issues contains duplicate normalized URL: ${url}`);
1036
+ seen.add(url);
1037
+ return { url, relation: value.relation };
1038
+ });
1039
+ }
1040
+ function createIssueRelationship(url, relation = "relates") {
1041
+ return parseIssueRelationships({ issues: [{ url, relation }] })[0];
1042
+ }
1043
+ function normalizeIssueUrl(raw, label) {
1044
+ if (raw.trim() !== raw || raw === "")
1045
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL without surrounding whitespace`);
1046
+ let parsed;
1047
+ try {
1048
+ parsed = new URL(raw);
1049
+ } catch {
1050
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL`);
1051
+ }
1052
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
1053
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must use http or https`);
1054
+ if (!parsed.hostname)
1055
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must include a host`);
1056
+ if (parsed.username || parsed.password)
1057
+ throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain credentials`);
1058
+ if (parsed.hash)
1059
+ throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain a fragment`);
1060
+ return parsed.toString();
1061
+ }
1062
+ function isPlainObject(value) {
1063
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1064
+ }
1065
+
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
+ // src/history/inspect.ts
1090
+ var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1091
+ async function inspectArchiveHistory(options = {}) {
1092
+ const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1093
+ const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
1094
+ const diagnostics = tree.diagnostics.map((diagnostic) => ({
856
1095
  severity: "error",
857
1096
  code: diagnostic.code,
858
- path: diagnostic.path,
1097
+ path: normalizeOutputPath(diagnostic.path, archivesDir),
859
1098
  message: diagnostic.message
860
1099
  }));
861
- for (const path of archiveTree.files) {
1100
+ const runtime = [];
1101
+ const records = [];
1102
+ const groupBriefs = [];
1103
+ if (!tree.rootExists && tree.diagnostics.length === 0) {
1104
+ diagnostics.push({
1105
+ severity: "error",
1106
+ code: "archive_root_missing",
1107
+ path: ".rsp/archives",
1108
+ message: "archive root does not exist; run rsp init or rsp update before querying history"
1109
+ });
1110
+ }
1111
+ for (const sourcePath of tree.files) {
1112
+ const path = archiveOutputPath(sourcePath, archivesDir);
1113
+ let content;
862
1114
  try {
863
- const heading = (await readFile2(path, "utf-8")).match(/^# Change:\s+(\S+)\s*$/m);
864
- if (heading)
865
- archivedNames.add(heading[1]);
1115
+ content = await readFile2(sourcePath, "utf-8");
866
1116
  } catch (error) {
867
- diagnostics.push({
868
- severity: "error",
869
- code: "dependency_archive_read_failed",
870
- path,
871
- message: error instanceof Error ? error.message : String(error)
872
- });
1117
+ const message = toErrorMessage(error);
1118
+ diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1119
+ continue;
873
1120
  }
1121
+ const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1122
+ if (parsed.diagnostic)
1123
+ diagnostics.push(parsed.diagnostic);
1124
+ if (parsed.record)
1125
+ records.push(parsed.record);
1126
+ if (parsed.groupBrief)
1127
+ groupBriefs.push(parsed.groupBrief);
874
1128
  }
875
- for (const brief of workTree.briefs) {
876
- try {
877
- if (hasMeaningfulBlockers(await readFile2(brief.path, "utf-8")))
878
- blockedGroups.add(brief.group);
879
- } catch (error) {
880
- diagnostics.push({
881
- severity: "error",
882
- code: "group_brief_read_failed",
883
- change: brief.group,
884
- path: brief.path,
885
- message: error instanceof Error ? error.message : String(error)
886
- });
1129
+ records.sort(compareHistoryRecords);
1130
+ groupBriefs.sort(compareArchivedGroupBriefs);
1131
+ diagnostics.sort(compareDiagnostics);
1132
+ const diagnosticTotal = diagnostics.length;
1133
+ const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
1134
+ return {
1135
+ rootExists: tree.rootExists,
1136
+ records,
1137
+ groupBriefs,
1138
+ diagnostics: boundedDiagnostics,
1139
+ diagnosticSummary: {
1140
+ total: diagnosticTotal,
1141
+ returned: boundedDiagnostics.length,
1142
+ hasMore: diagnosticTotal > boundedDiagnostics.length
1143
+ },
1144
+ runtime
1145
+ };
1146
+ }
1147
+ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1148
+ const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
1149
+ if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1150
+ return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
1151
+ }
1152
+ const date = nameMatch[1];
1153
+ const archiveStem = nameMatch[2];
1154
+ const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1155
+ const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1156
+ if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
1157
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
1158
+ const changeDocument = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1159
+ const groupDocument = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
1160
+ const changeHeadings = getDocumentTitles(changeDocument, "identity");
1161
+ const groupHeadings = getDocumentTitles(groupDocument, "identity");
1162
+ if (changeHeadings.length + groupHeadings.length !== 1) {
1163
+ return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
1164
+ }
1165
+ let frontmatter;
1166
+ try {
1167
+ frontmatter = parseFrontmatter(content);
1168
+ } catch (error) {
1169
+ return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
1170
+ }
1171
+ if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
1172
+ return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
1173
+ if (groupHeadings.length === 1) {
1174
+ const group = groupHeadings[0];
1175
+ if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
1176
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
887
1177
  }
1178
+ return { groupBrief: { date, group, path, sourcePath } };
888
1179
  }
889
- for (const ref of workTree.changes) {
1180
+ let issues;
1181
+ if ("issues" in frontmatter) {
890
1182
  try {
891
- const parsed = parseDependencyBlockers(await readFile2(ref.path, "utf-8"));
892
- if (ref.group && blockedGroups.has(ref.group))
893
- parsed.external = true;
894
- parsedByName.set(ref.name, parsed);
895
- for (const line of parsed.malformed) {
896
- diagnostics.push({
897
- severity: "error",
898
- code: "dependency_syntax_invalid",
899
- change: ref.name,
900
- path: ref.path,
901
- message: `invalid dependency blocker syntax: ${line}`,
902
- hint: "Use: - requires `<change-work-ref>`: <reason>"
903
- });
904
- }
905
- } catch (error) {
906
- diagnostics.push({
907
- severity: "error",
908
- code: "change_read_failed",
909
- change: ref.name,
910
- path: ref.path,
911
- message: error instanceof Error ? error.message : String(error)
912
- });
1183
+ issues = parseIssueRelationships(frontmatter);
1184
+ } catch {
1185
+ issues = void 0;
913
1186
  }
914
1187
  }
915
- const edges = [];
916
- for (const [change, blockers] of parsedByName) {
917
- for (const requirement of blockers.requires) {
918
- const required = requirement.name;
919
- const edge = {
920
- change,
921
- requires: required,
922
- reason: requirement.reason,
923
- state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
924
- };
925
- edges.push(edge);
926
- if (!EXECUTABLE_WORK_REF_RE.test(required)) {
927
- diagnostics.push({
928
- severity: "error",
929
- code: "dependency_target_invalid",
1188
+ const workRef = changeHeadings[0];
1189
+ const segments = workRef.split("/");
1190
+ if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
1191
+ return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
1192
+ }
1193
+ const expectedGroup = segments.length === 2 ? segments[0] : null;
1194
+ const expectedBase = segments.at(-1);
1195
+ if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1196
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1197
+ }
1198
+ const rawSummary = extractSummary(content, changeDocument);
1199
+ if (!rawSummary)
1200
+ return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1201
+ const summary = boundText(rawSummary);
1202
+ return {
1203
+ record: {
1204
+ date,
1205
+ workRef,
1206
+ group: expectedGroup,
1207
+ kind: frontmatter.kind.trim(),
1208
+ summary: summary.value,
1209
+ summaryTruncated: summary.truncated,
1210
+ path,
1211
+ ...issues === void 0 ? {} : { issues },
1212
+ sourcePath,
1213
+ searchSummary: rawSummary
1214
+ }
1215
+ };
1216
+ }
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
+ function boundText(value) {
1230
+ const codePoints = [...value];
1231
+ return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
1232
+ }
1233
+ function matchesArchiveStem(stem, identityBase) {
1234
+ return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1235
+ }
1236
+ function isCalendarDate(value) {
1237
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1238
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1239
+ }
1240
+ function archiveOutputPath(sourcePath, archivesDir) {
1241
+ return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1242
+ }
1243
+ function normalizeOutputPath(path, archivesDir) {
1244
+ const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1245
+ return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1246
+ }
1247
+ function errorDiagnostic(code, path, message) {
1248
+ return { severity: "error", code, path, message };
1249
+ }
1250
+ function escapeRegExp(value) {
1251
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1252
+ }
1253
+ function compareHistoryRecords(left, right) {
1254
+ return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1255
+ }
1256
+ function compareArchivedGroupBriefs(left, right) {
1257
+ return right.date.localeCompare(left.date) || left.group.localeCompare(right.group) || left.path.localeCompare(right.path);
1258
+ }
1259
+ function compareDiagnostics(left, right) {
1260
+ return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
1261
+ }
1262
+
1263
+ // src/history/query.ts
1264
+ function queryArchiveHistory(records, query = {}) {
1265
+ validateArchiveHistoryQuery(query);
1266
+ const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
1267
+ const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1268
+ const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!group || record.group === group) && (!query.search || `${record.workRef}
1269
+ ${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
1270
+ const selected = matched.slice(0, limit);
1271
+ return {
1272
+ records: selected.map(toOutputRecord),
1273
+ summary: {
1274
+ matched: matched.length,
1275
+ returned: selected.length,
1276
+ hasMore: matched.length > selected.length
1277
+ }
1278
+ };
1279
+ }
1280
+ function validateArchiveHistoryQuery(query) {
1281
+ const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1282
+ if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
1283
+ throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
1284
+ if (query.since && !isCalendarDate2(query.since))
1285
+ throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
1286
+ if (query.until && !isCalendarDate2(query.until))
1287
+ throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
1288
+ if (query.since && query.until && query.since > query.until)
1289
+ throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
1290
+ if (query.kind !== void 0 && query.kind.trim() === "")
1291
+ throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
1292
+ if (query.group !== void 0) {
1293
+ try {
1294
+ normalizeWorkRefSegment(query.group);
1295
+ } catch {
1296
+ throw new ArchiveHistoryError("invalid_history_group", "history group must be one safe Unicode segment");
1297
+ }
1298
+ }
1299
+ if (query.search !== void 0 && query.search.trim() === "")
1300
+ throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
1301
+ }
1302
+ function selectArchiveHistoryRecord(records, selector) {
1303
+ const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
1304
+ const identity = "path" in selector ? selector.path : selector.workRef;
1305
+ if (matches.length === 0)
1306
+ throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
1307
+ if (matches.length > 1) {
1308
+ const candidates = matches.map((record) => record.path).sort();
1309
+ throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
1310
+ }
1311
+ return matches[0];
1312
+ }
1313
+ function selectArchivedGroupBrief(records, selector) {
1314
+ const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.group === selector.group);
1315
+ const identity = "path" in selector ? selector.path : selector.group;
1316
+ if (matches.length === 0)
1317
+ throw new ArchiveHistoryError("archive_not_found", `archived Change Group not found: ${identity}`);
1318
+ if (matches.length > 1) {
1319
+ const candidates = matches.map((record) => record.path).sort();
1320
+ throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match Change Group ${identity}`, candidates);
1321
+ }
1322
+ return matches[0];
1323
+ }
1324
+ async function readArchiveHistoryDetail(record) {
1325
+ let content;
1326
+ try {
1327
+ content = await readFile3(record.sourcePath, "utf-8");
1328
+ } catch (error) {
1329
+ throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1330
+ }
1331
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1332
+ const tasks = getDocumentSectionBody(document, "tasks");
1333
+ const verify = getDocumentSectionBody(document, "verify");
1334
+ const blockers = getDocumentSectionBody(document, "blockers");
1335
+ return {
1336
+ ...toOutputRecord(record),
1337
+ scenarioCount: parseScenarios(content).length,
1338
+ checkboxes: {
1339
+ tasks: countCheckboxes(tasks),
1340
+ verify: countCheckboxes(verify)
1341
+ },
1342
+ evidence: {
1343
+ tasks: boundEvidence(tasks),
1344
+ verify: boundEvidence(verify),
1345
+ blockers: boundEvidence(blockers, true)
1346
+ }
1347
+ };
1348
+ }
1349
+ function historyInspectionComplete(inspection) {
1350
+ return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1351
+ }
1352
+ function toOutputRecord(record) {
1353
+ const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
1354
+ return output;
1355
+ }
1356
+ function boundEvidence(section, omitNone = false) {
1357
+ const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
1358
+ const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
1359
+ return {
1360
+ items: boundedItems.map((item) => item.value),
1361
+ truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
1362
+ };
1363
+ }
1364
+ function isCalendarDate2(value) {
1365
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
1366
+ return false;
1367
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1368
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1369
+ }
1370
+ function compareHistoryRecords2(left, right) {
1371
+ return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1372
+ }
1373
+
1374
+ // src/core/dependency-plan.ts
1375
+ var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
1376
+ var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
1377
+ async function inspectChangeDependencies(options = {}) {
1378
+ const workTree = options.workTree ?? await inspectWorkTree();
1379
+ const archiveTree = options.archiveTree ?? await inspectArchiveTree();
1380
+ const archiveHistory = archiveTree.rootExists ? options.archiveHistory ?? await inspectArchiveHistory({ archiveTree }) : null;
1381
+ const openNames = new Set(workTree.changes.map((ref) => ref.name));
1382
+ const archivedNames = new Set(
1383
+ archiveHistory && historyInspectionComplete(archiveHistory) ? archiveHistory.records.map((record) => record.workRef) : []
1384
+ );
1385
+ const parsedByName = /* @__PURE__ */ new Map();
1386
+ const blockedGroups = /* @__PURE__ */ new Set();
1387
+ const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
1388
+ severity: "error",
1389
+ code: diagnostic.code,
1390
+ path: diagnostic.path,
1391
+ message: diagnostic.message
1392
+ }));
1393
+ if (archiveHistory)
1394
+ diagnostics.push(...archiveHistory.diagnostics);
1395
+ for (const brief of workTree.briefs) {
1396
+ try {
1397
+ if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
1398
+ blockedGroups.add(brief.group);
1399
+ } catch (error) {
1400
+ diagnostics.push({
1401
+ severity: "error",
1402
+ code: "group_brief_read_failed",
1403
+ change: brief.group,
1404
+ path: brief.path,
1405
+ message: error instanceof Error ? error.message : String(error)
1406
+ });
1407
+ }
1408
+ }
1409
+ for (const ref of workTree.changes) {
1410
+ try {
1411
+ const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
1412
+ if (ref.group && blockedGroups.has(ref.group))
1413
+ parsed.external = true;
1414
+ parsedByName.set(ref.name, parsed);
1415
+ for (const line of parsed.malformed) {
1416
+ diagnostics.push({
1417
+ severity: "error",
1418
+ code: "dependency_syntax_invalid",
1419
+ change: ref.name,
1420
+ path: ref.path,
1421
+ message: `invalid dependency blocker syntax: ${line}`,
1422
+ hint: "Use: - requires `<change-work-ref>`: <reason>"
1423
+ });
1424
+ }
1425
+ } catch (error) {
1426
+ diagnostics.push({
1427
+ severity: "error",
1428
+ code: "change_read_failed",
1429
+ change: ref.name,
1430
+ path: ref.path,
1431
+ message: error instanceof Error ? error.message : String(error)
1432
+ });
1433
+ }
1434
+ }
1435
+ const edges = [];
1436
+ for (const [change, blockers] of parsedByName) {
1437
+ for (const requirement of blockers.requires) {
1438
+ const required = requirement.name;
1439
+ const edge = {
1440
+ change,
1441
+ requires: required,
1442
+ reason: requirement.reason,
1443
+ state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
1444
+ };
1445
+ edges.push(edge);
1446
+ if (!isCanonicalExecutableWorkRef(required)) {
1447
+ diagnostics.push({
1448
+ severity: "error",
1449
+ code: "dependency_target_invalid",
930
1450
  change,
931
1451
  message: `dependency target must be an executable Change WorkRef: ${required}`
932
1452
  });
@@ -1102,295 +1622,17 @@ function findCycleDiagnostics(edges) {
1102
1622
  return diagnostics;
1103
1623
  }
1104
1624
 
1105
- // src/history/query.ts
1106
- import { readFile as readFile4 } from "fs/promises";
1107
-
1108
- // src/history/inspect.ts
1109
- import { readFile as readFile3 } from "fs/promises";
1110
- import { basename, dirname as dirname2, relative as relative3 } from "path";
1111
-
1112
- // src/history/model.ts
1113
- var HISTORY_DEFAULT_LIMIT = 20;
1114
- var HISTORY_MAX_LIMIT = 100;
1115
- var HISTORY_MAX_TEXT_CODE_POINTS = 500;
1116
- var HISTORY_MAX_EVIDENCE_ITEMS = 20;
1117
- var HISTORY_MAX_DIAGNOSTICS = 20;
1118
- var HISTORY_MAX_CANDIDATES = 20;
1119
- var ArchiveHistoryError = class extends Error {
1120
- constructor(code, message, candidates = []) {
1121
- super(message);
1122
- this.code = code;
1123
- this.name = "ArchiveHistoryError";
1124
- const ordered = [...candidates].sort();
1125
- this.candidateTotal = ordered.length;
1126
- this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
1127
- this.candidatesTruncated = this.candidateTotal > this.candidates.length;
1128
- }
1129
- code;
1130
- candidates;
1131
- candidateTotal;
1132
- candidatesTruncated;
1133
- };
1134
-
1135
- // src/history/inspect.ts
1136
- var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
1137
- var GROUP_NAME_RE = /^[a-z0-9-]+$/;
1138
- var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1139
- async function inspectArchiveHistory(options = {}) {
1140
- const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1141
- const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
1142
- const diagnostics = tree.diagnostics.map((diagnostic) => ({
1143
- severity: "error",
1144
- code: diagnostic.code,
1145
- path: normalizeOutputPath(diagnostic.path, archivesDir),
1146
- message: diagnostic.message
1147
- }));
1148
- const runtime = [];
1149
- const records = [];
1150
- if (!tree.rootExists && tree.diagnostics.length === 0) {
1151
- diagnostics.push({
1152
- severity: "error",
1153
- code: "archive_root_missing",
1154
- path: ".rsp/archives",
1155
- message: "archive root does not exist; run rsp init or rsp update before querying history"
1156
- });
1157
- }
1158
- for (const sourcePath of tree.files) {
1159
- const path = archiveOutputPath(sourcePath, archivesDir);
1160
- let content;
1161
- try {
1162
- content = await readFile3(sourcePath, "utf-8");
1163
- } catch (error) {
1164
- const message = toErrorMessage(error);
1165
- diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1166
- continue;
1167
- }
1168
- const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1169
- if (parsed.diagnostic)
1170
- diagnostics.push(parsed.diagnostic);
1171
- if (parsed.record)
1172
- records.push(parsed.record);
1173
- }
1174
- records.sort(compareHistoryRecords);
1175
- diagnostics.sort(compareDiagnostics);
1176
- const diagnosticTotal = diagnostics.length;
1177
- const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
1178
- return {
1179
- rootExists: tree.rootExists,
1180
- records,
1181
- diagnostics: boundedDiagnostics,
1182
- diagnosticSummary: {
1183
- total: diagnosticTotal,
1184
- returned: boundedDiagnostics.length,
1185
- hasMore: diagnosticTotal > boundedDiagnostics.length
1186
- },
1187
- runtime
1188
- };
1189
- }
1190
- function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1191
- const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
1192
- if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1193
- return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
1194
- }
1195
- const date = nameMatch[1];
1196
- const archiveStem = nameMatch[2];
1197
- const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1198
- const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1199
- if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
1200
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
1201
- const changeHeadings = [...content.matchAll(/^# Change:\s+(\S+)\s*$/gm)].map((match) => match[1]);
1202
- const groupHeadings = [...content.matchAll(/^# Change Group:\s+(\S+)\s*$/gm)].map((match) => match[1]);
1203
- if (changeHeadings.length + groupHeadings.length !== 1) {
1204
- return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
1205
- }
1206
- let frontmatter;
1207
- try {
1208
- frontmatter = parseFrontmatter(content);
1209
- } catch (error) {
1210
- return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
1211
- }
1212
- if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
1213
- return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
1214
- if (groupHeadings.length === 1) {
1215
- const group = groupHeadings[0];
1216
- if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
1217
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
1218
- }
1219
- return {};
1220
- }
1221
- const workRef = changeHeadings[0];
1222
- const segments = workRef.split("/");
1223
- if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
1224
- return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
1225
- }
1226
- const expectedGroup = segments.length === 2 ? segments[0] : null;
1227
- const expectedBase = segments.at(-1);
1228
- if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1229
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1230
- }
1231
- const rawSummary = extractSummary(content);
1232
- if (!rawSummary)
1233
- return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1234
- const summary = boundText(rawSummary);
1235
- return {
1236
- record: {
1237
- date,
1238
- workRef,
1239
- group: expectedGroup,
1240
- kind: frontmatter.kind.trim(),
1241
- summary: summary.value,
1242
- summaryTruncated: summary.truncated,
1243
- path,
1244
- sourcePath,
1245
- searchSummary: rawSummary
1246
- }
1247
- };
1248
- }
1249
- function extractSummary(content) {
1250
- const frontmatter = parseFrontmatter(content);
1251
- if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
1252
- return frontmatter.summary.trim();
1253
- const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
1254
- for (const prefix of ["- Outcome:", "- Summary:"]) {
1255
- const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
1256
- if (line && line.slice(prefix.length).trim())
1257
- return line.slice(prefix.length).trim();
1258
- }
1259
- return "";
1260
- }
1261
- function boundText(value) {
1262
- const codePoints = [...value];
1263
- return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
1264
- }
1265
- function matchesArchiveStem(stem, identityBase) {
1266
- return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1267
- }
1268
- function isCalendarDate(value) {
1269
- const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1270
- return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1271
- }
1272
- function archiveOutputPath(sourcePath, archivesDir) {
1273
- return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1274
- }
1275
- function normalizeOutputPath(path, archivesDir) {
1276
- const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1277
- return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1278
- }
1279
- function errorDiagnostic(code, path, message) {
1280
- return { severity: "error", code, path, message };
1281
- }
1282
- function escapeRegExp2(value) {
1283
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1284
- }
1285
- function compareHistoryRecords(left, right) {
1286
- return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1287
- }
1288
- function compareDiagnostics(left, right) {
1289
- return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
1290
- }
1291
-
1292
- // src/history/query.ts
1293
- function queryArchiveHistory(records, query = {}) {
1294
- validateArchiveHistoryQuery(query);
1295
- const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1296
- const matched = records.filter((record) => (!query.since || record.date >= query.since) && (!query.until || record.date <= query.until) && (!query.kind || record.kind === query.kind) && (!query.group || record.group === query.group) && (!query.search || `${record.workRef}
1297
- ${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
1298
- const selected = matched.slice(0, limit);
1299
- return {
1300
- records: selected.map(toOutputRecord),
1301
- summary: {
1302
- matched: matched.length,
1303
- returned: selected.length,
1304
- hasMore: matched.length > selected.length
1305
- }
1306
- };
1307
- }
1308
- function validateArchiveHistoryQuery(query) {
1309
- const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1310
- if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
1311
- throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
1312
- if (query.since && !isCalendarDate2(query.since))
1313
- throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
1314
- if (query.until && !isCalendarDate2(query.until))
1315
- throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
1316
- if (query.since && query.until && query.since > query.until)
1317
- throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
1318
- if (query.kind !== void 0 && query.kind.trim() === "")
1319
- throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
1320
- if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
1321
- throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
1322
- if (query.search !== void 0 && query.search.trim() === "")
1323
- throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
1324
- }
1325
- function selectArchiveHistoryRecord(records, selector) {
1326
- const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
1327
- const identity = "path" in selector ? selector.path : selector.workRef;
1328
- if (matches.length === 0)
1329
- throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
1330
- if (matches.length > 1) {
1331
- const candidates = matches.map((record) => record.path).sort();
1332
- throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
1333
- }
1334
- return matches[0];
1335
- }
1336
- async function readArchiveHistoryDetail(record) {
1337
- let content;
1338
- try {
1339
- content = await readFile4(record.sourcePath, "utf-8");
1340
- } catch (error) {
1341
- throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1342
- }
1343
- const tasks = extractSection(content, "Tasks");
1344
- const verify = extractSection(content, "Verify");
1345
- const blockers = extractSection(content, "Blockers");
1346
- return {
1347
- ...toOutputRecord(record),
1348
- scenarioCount: parseScenarios(content).length,
1349
- checkboxes: {
1350
- tasks: countCheckboxes(tasks),
1351
- verify: countCheckboxes(verify)
1352
- },
1353
- evidence: {
1354
- tasks: boundEvidence(tasks),
1355
- verify: boundEvidence(verify),
1356
- blockers: boundEvidence(blockers, true)
1357
- }
1358
- };
1359
- }
1360
- function historyInspectionComplete(inspection) {
1361
- return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1362
- }
1363
- function toOutputRecord(record) {
1364
- const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
1365
- return output;
1366
- }
1367
- function boundEvidence(section, omitNone = false) {
1368
- const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
1369
- const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
1370
- return {
1371
- items: boundedItems.map((item) => item.value),
1372
- truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
1373
- };
1374
- }
1375
- function isCalendarDate2(value) {
1376
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
1377
- return false;
1378
- const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1379
- return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1380
- }
1381
- function compareHistoryRecords2(left, right) {
1382
- return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1383
- }
1384
-
1385
1625
  // src/status/inspect.ts
1386
1626
  async function inspectProjectStatus(options = {}) {
1387
1627
  const runtime = [];
1388
1628
  const diagnostics = [];
1389
1629
  let manage = resolveManagePolicy({});
1630
+ let language = resolveLanguagePolicy({});
1390
1631
  try {
1391
1632
  const configInspection = await inspectRspConfig();
1392
1633
  const configValid = configInspection.issues.length === 0;
1393
1634
  manage = resolveManagePolicy(configInspection.config, { configValid });
1635
+ language = resolveLanguagePolicy(configInspection.config, { configValid });
1394
1636
  if (!configValid) {
1395
1637
  diagnostics.push({
1396
1638
  severity: "error",
@@ -1401,6 +1643,7 @@ async function inspectProjectStatus(options = {}) {
1401
1643
  }
1402
1644
  } catch (error) {
1403
1645
  manage = resolveManagePolicy({}, { configValid: false });
1646
+ language = resolveLanguagePolicy({}, { configValid: false });
1404
1647
  diagnostics.push({
1405
1648
  severity: "error",
1406
1649
  code: "invalid_config",
@@ -1453,6 +1696,7 @@ async function inspectProjectStatus(options = {}) {
1453
1696
  const dependencyInspection = await inspectChangeDependencies({
1454
1697
  workTree,
1455
1698
  archiveTree,
1699
+ archiveHistory: historyInspection,
1456
1700
  preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
1457
1701
  });
1458
1702
  diagnostics.push(...dependencyInspection.diagnostics);
@@ -1472,6 +1716,7 @@ async function inspectProjectStatus(options = {}) {
1472
1716
  let done = 0;
1473
1717
  let total = 0;
1474
1718
  let progressKnown = false;
1719
+ let issues = [];
1475
1720
  if (path) {
1476
1721
  try {
1477
1722
  const content = await readFile5(path, "utf-8");
@@ -1480,10 +1725,21 @@ async function inspectProjectStatus(options = {}) {
1480
1725
  try {
1481
1726
  const frontmatter = parseFrontmatter(content);
1482
1727
  kind = frontmatter?.kind ? String(frontmatter.kind) : "\u2014";
1728
+ issues = parseIssueRelationships(frontmatter);
1483
1729
  } catch (error) {
1484
1730
  kind = "(invalid)";
1731
+ const issueError = error instanceof IssueRelationshipError;
1732
+ if (issueError) {
1733
+ diagnostics.push({
1734
+ severity: "error",
1735
+ code: error.code,
1736
+ change: name,
1737
+ path,
1738
+ message: error.message
1739
+ });
1740
+ }
1485
1741
  runtime.push({
1486
- code: "frontmatter_parse_failed",
1742
+ code: issueError ? error.code : "frontmatter_parse_failed",
1487
1743
  operation: "parseFrontmatter",
1488
1744
  path,
1489
1745
  message: toErrorMessage(error)
@@ -1564,7 +1820,8 @@ async function inspectProjectStatus(options = {}) {
1564
1820
  ageDays,
1565
1821
  isFocused,
1566
1822
  isBlocked,
1567
- path: path ? normalizeLogicalPath(path) : null
1823
+ path: path ? normalizeLogicalPath(path) : null,
1824
+ issues
1568
1825
  },
1569
1826
  progressKnown,
1570
1827
  title,
@@ -1574,6 +1831,7 @@ async function inspectProjectStatus(options = {}) {
1574
1831
  }
1575
1832
  return {
1576
1833
  manage,
1834
+ language,
1577
1835
  focused: [...focusedSet].sort(),
1578
1836
  records,
1579
1837
  groups: groupInspection.groups,
@@ -1595,11 +1853,11 @@ function emptyReadiness(activeBlockers) {
1595
1853
  };
1596
1854
  }
1597
1855
  function extractChangeTitle(content) {
1598
- const heading = content.split(/\r?\n/).find((line) => line.startsWith("# Change: "));
1599
- return heading?.slice("# Change: ".length).trim() || null;
1856
+ return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
1600
1857
  }
1601
1858
  function extractBlockerEntries(content) {
1602
- return extractSection(content, "Blockers").split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter((line) => line.toLowerCase() !== "none");
1859
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1860
+ return getDocumentSectionBody(document, "blockers").split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+/.test(line)).map((line) => line.replace(/^[-*]\s+/, "").trim()).filter((line) => line.toLowerCase() !== "none");
1603
1861
  }
1604
1862
  function deriveArchiveTrend(records) {
1605
1863
  const counts = /* @__PURE__ */ new Map();
@@ -1648,25 +1906,33 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
1648
1906
  export {
1649
1907
  GROUP_BRIEF_FILENAME,
1650
1908
  WorkRefError,
1909
+ normalizeWorkRefSegment,
1910
+ normalizeExecutableWorkRef,
1651
1911
  resolveWorkRef,
1652
1912
  resolveFocusMarkerPath,
1653
1913
  resolveArchiveDirectory,
1654
1914
  inspectFocusTree,
1655
1915
  inspectArchiveTree,
1656
1916
  inspectWorkTree,
1917
+ GROUP_REOPEN_COMPLETION_PREFIX,
1657
1918
  resolveExecutableChange,
1658
1919
  generateGroupBriefContent,
1659
1920
  inspectChangeGroups,
1921
+ getGroupReopenEvidence,
1660
1922
  hasArchivedGroupBrief,
1661
- inspectChangeDependencies,
1923
+ IssueRelationshipError,
1924
+ parseIssueRelationships,
1925
+ createIssueRelationship,
1662
1926
  HISTORY_DEFAULT_LIMIT,
1663
1927
  HISTORY_MAX_LIMIT,
1664
1928
  ArchiveHistoryError,
1665
1929
  inspectArchiveHistory,
1666
1930
  queryArchiveHistory,
1667
1931
  selectArchiveHistoryRecord,
1932
+ selectArchivedGroupBrief,
1668
1933
  readArchiveHistoryDetail,
1669
1934
  historyInspectionComplete,
1935
+ inspectChangeDependencies,
1670
1936
  inspectProjectStatus,
1671
1937
  projectDependencyForest
1672
1938
  };