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

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-FJVH7ZYU.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,7 @@ 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);
546
669
  async function resolveExecutableChange(name, options = {}) {
547
670
  const ref = resolveWorkRef(name, { ...options, executable: true });
548
671
  if (ref.kind === "group-change")
@@ -550,32 +673,33 @@ async function resolveExecutableChange(name, options = {}) {
550
673
  return ref;
551
674
  }
552
675
  function generateGroupBriefContent(name, goal = "") {
676
+ const placeholder = "<\u2026>";
553
677
  return `---
554
678
  kind: group
555
679
  ---
556
680
 
557
- # Change Group: ${name}
681
+ ${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
558
682
 
559
- ## Goal
560
- - ${goal || "<what shared outcome this group must deliver>"}
683
+ ${groupSectionHeading("goal")}
684
+ - ${goal || placeholder}
561
685
 
562
- ## Scope
563
- - <what is coordinated by this group>
686
+ ${groupSectionHeading("scope")}
687
+ - ${placeholder}
564
688
 
565
- ## Shared Constraints
566
- - <constraint shared by every child Change>
689
+ ${groupSectionHeading("sharedConstraints")}
690
+ - ${placeholder}
567
691
 
568
- ## Slices
569
- - \`${name}/<change>\`: <independently executable boundary>
692
+ ${groupSectionHeading("slices")}
693
+ - \`${name}/<change>\`: ${placeholder}
570
694
 
571
- ## Completion Conditions
572
- - [ ] <end-to-end condition beyond individual child verification>
695
+ ${groupSectionHeading("completionConditions")}
696
+ - [ ] ${placeholder}
573
697
 
574
- ## Durable Outcomes
575
- - Current facts: <Spec, CONTEXT.md, scoped instruction target, or none>
576
- - Lasting rationale: <Decision Record target, or none>
698
+ ${groupSectionHeading("durableOutcomes")}
699
+ - Current facts: ${placeholder}
700
+ - Lasting rationale: ${placeholder}
577
701
 
578
- ## Blockers
702
+ ${groupSectionHeading("blockers")}
579
703
  - none
580
704
  `;
581
705
  }
@@ -595,10 +719,10 @@ async function inspectChangeGroups(options = {}) {
595
719
  continue;
596
720
  try {
597
721
  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)) {
722
+ const archivedChange = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
723
+ const identity = getDocumentTitle(archivedChange, "identity");
724
+ if (identity?.includes("/")) {
725
+ if (!isCanonicalExecutableWorkRef(identity) || !identity.startsWith(`${archiveGroup}/`)) {
602
726
  diagnostics.push({
603
727
  severity: "error",
604
728
  code: "group_archive_identity_mismatch",
@@ -612,7 +736,7 @@ async function inspectChangeGroups(options = {}) {
612
736
  archivedSlicesByGroup.set(archiveGroup, groupSlices);
613
737
  }
614
738
  }
615
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(archiveGroup)}\\s*$`, "m").test(content))
739
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup)
616
740
  archivedGroups.add(archiveGroup);
617
741
  } catch (error) {
618
742
  diagnostics.push({
@@ -774,13 +898,14 @@ async function hasArchivedGroupBrief(group) {
774
898
  const message = error instanceof Error ? error.message : String(error);
775
899
  throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
776
900
  }
777
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
901
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
778
902
  return true;
779
903
  }
780
904
  return false;
781
905
  }
782
906
  function parseGroupBrief(group, content) {
783
907
  const diagnostics = [];
908
+ const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
784
909
  let frontmatter;
785
910
  try {
786
911
  frontmatter = parseFrontmatter(content);
@@ -789,15 +914,15 @@ function parseGroupBrief(group, content) {
789
914
  }
790
915
  if (frontmatter?.kind !== "group")
791
916
  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))
917
+ if (getDocumentTitle(document, "identity") !== group)
793
918
  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}` });
919
+ for (const definition of GROUP_BRIEF_DOCUMENT_SCHEMA.sections) {
920
+ if (!getDocumentSectionBody(document, definition.id))
921
+ diagnostics.push({ severity: "error", code: "group_section_missing", change: group, message: `Group Brief missing required section: ${definition.heading}` });
797
922
  }
798
923
  const slices = [];
799
924
  const seen = /* @__PURE__ */ new Set();
800
- for (const line of extractSection(content, "Slices").split("\n")) {
925
+ for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
801
926
  const trimmed = line.trim();
802
927
  const identity = trimmed.match(/^-\s+`(\S+)`/);
803
928
  if (!identity || identity[1].includes("<"))
@@ -813,7 +938,7 @@ function parseGroupBrief(group, content) {
813
938
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
814
939
  continue;
815
940
  }
816
- if (!new RegExp(`^${escapeRegExp(group)}/[a-z0-9-]+$`).test(name) || name === `${group}/brief`) {
941
+ if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
817
942
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
818
943
  continue;
819
944
  }
@@ -826,382 +951,123 @@ function parseGroupBrief(group, content) {
826
951
  }
827
952
  if (slices.length < 2)
828
953
  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"));
954
+ const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
830
955
  if (completion.total === 0)
831
956
  diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
832
957
  return {
833
958
  slices,
834
959
  completion: { done: completion.done + completion.dropped, total: completion.total },
835
- blockers: hasMeaningfulBlockers(content),
960
+ blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
836
961
  diagnostics
837
962
  };
838
963
  }
839
- function escapeRegExp(value) {
840
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
964
+ function groupSectionHeading(sectionId) {
965
+ return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
841
966
  }
842
967
 
843
968
  // src/core/dependency-plan.ts
969
+ import { readFile as readFile4 } from "fs/promises";
970
+
971
+ // src/history/query.ts
972
+ import { readFile as readFile3 } from "fs/promises";
973
+
974
+ // src/history/inspect.ts
844
975
  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) => ({
976
+ import { basename, dirname as dirname2, relative as relative3 } from "path";
977
+
978
+ // src/history/model.ts
979
+ var HISTORY_DEFAULT_LIMIT = 20;
980
+ var HISTORY_MAX_LIMIT = 100;
981
+ var HISTORY_MAX_TEXT_CODE_POINTS = 500;
982
+ var HISTORY_MAX_EVIDENCE_ITEMS = 20;
983
+ var HISTORY_MAX_DIAGNOSTICS = 20;
984
+ var HISTORY_MAX_CANDIDATES = 20;
985
+ var ArchiveHistoryError = class extends Error {
986
+ constructor(code, message, candidates = []) {
987
+ super(message);
988
+ this.code = code;
989
+ this.name = "ArchiveHistoryError";
990
+ const ordered = [...candidates].sort();
991
+ this.candidateTotal = ordered.length;
992
+ this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
993
+ this.candidatesTruncated = this.candidateTotal > this.candidates.length;
994
+ }
995
+ code;
996
+ candidates;
997
+ candidateTotal;
998
+ candidatesTruncated;
999
+ };
1000
+
1001
+ // src/history/inspect.ts
1002
+ var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1003
+ async function inspectArchiveHistory(options = {}) {
1004
+ const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1005
+ const tree = options.archiveTree ?? await inspectArchiveTree({ archivesDir });
1006
+ const diagnostics = tree.diagnostics.map((diagnostic) => ({
856
1007
  severity: "error",
857
1008
  code: diagnostic.code,
858
- path: diagnostic.path,
1009
+ path: normalizeOutputPath(diagnostic.path, archivesDir),
859
1010
  message: diagnostic.message
860
1011
  }));
861
- for (const path of archiveTree.files) {
862
- try {
863
- const heading = (await readFile2(path, "utf-8")).match(/^# Change:\s+(\S+)\s*$/m);
864
- if (heading)
865
- archivedNames.add(heading[1]);
866
- } 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
- });
873
- }
1012
+ const runtime = [];
1013
+ const records = [];
1014
+ if (!tree.rootExists && tree.diagnostics.length === 0) {
1015
+ diagnostics.push({
1016
+ severity: "error",
1017
+ code: "archive_root_missing",
1018
+ path: ".rsp/archives",
1019
+ message: "archive root does not exist; run rsp init or rsp update before querying history"
1020
+ });
874
1021
  }
875
- for (const brief of workTree.briefs) {
1022
+ for (const sourcePath of tree.files) {
1023
+ const path = archiveOutputPath(sourcePath, archivesDir);
1024
+ let content;
876
1025
  try {
877
- if (hasMeaningfulBlockers(await readFile2(brief.path, "utf-8")))
878
- blockedGroups.add(brief.group);
1026
+ content = await readFile2(sourcePath, "utf-8");
879
1027
  } 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
- });
1028
+ const message = toErrorMessage(error);
1029
+ diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1030
+ continue;
887
1031
  }
1032
+ const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1033
+ if (parsed.diagnostic)
1034
+ diagnostics.push(parsed.diagnostic);
1035
+ if (parsed.record)
1036
+ records.push(parsed.record);
888
1037
  }
889
- for (const ref of workTree.changes) {
890
- 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
- });
913
- }
1038
+ records.sort(compareHistoryRecords);
1039
+ diagnostics.sort(compareDiagnostics);
1040
+ const diagnosticTotal = diagnostics.length;
1041
+ const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
1042
+ return {
1043
+ rootExists: tree.rootExists,
1044
+ records,
1045
+ diagnostics: boundedDiagnostics,
1046
+ diagnosticSummary: {
1047
+ total: diagnosticTotal,
1048
+ returned: boundedDiagnostics.length,
1049
+ hasMore: diagnosticTotal > boundedDiagnostics.length
1050
+ },
1051
+ runtime
1052
+ };
1053
+ }
1054
+ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1055
+ const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
1056
+ if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1057
+ return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
914
1058
  }
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",
930
- change,
931
- message: `dependency target must be an executable Change WorkRef: ${required}`
932
- });
933
- } else if (required === change) {
934
- diagnostics.push({
935
- severity: "error",
936
- code: "dependency_self_reference",
937
- change,
938
- message: "Change cannot require itself"
939
- });
940
- } else if (edge.state === "missing") {
941
- diagnostics.push({
942
- severity: "error",
943
- code: "dependency_target_missing",
944
- change,
945
- message: `dependency target is neither open nor archived: ${required}`
946
- });
947
- }
948
- }
949
- }
950
- edges.sort(compareEdges);
951
- diagnostics.push(...findCycleDiagnostics(edges));
952
- const activeBlockers = /* @__PURE__ */ new Map();
953
- const blocked = [...parsedByName].map(([change, blockers]) => {
954
- const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
955
- activeBlockers.set(change, blockers.external || requires.length > 0);
956
- return { change, requires, external: blockers.external };
957
- }).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
958
- const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
959
- const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
960
- if (!graphValid) {
961
- for (const name of openNames)
962
- activeBlockers.set(name, true);
963
- }
964
- const ready = graphValid ? waves[0] ?? [] : [];
965
- const executable = new Set(graphValid ? waves.flat() : []);
966
- const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
967
- let state;
968
- if (!openNames.has(name) && archivedNames.has(name))
969
- state = "archived";
970
- else if (!openNames.has(name))
971
- state = "missing";
972
- else if (ready.includes(name))
973
- state = "ready";
974
- else if (executable.has(name))
975
- state = "waiting";
976
- else
977
- state = "blocked";
978
- return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
979
- });
980
- return {
981
- plan: {
982
- nodes,
983
- ready,
984
- edges,
985
- blocked,
986
- waves: graphValid ? waves : []
987
- },
988
- activeBlockers,
989
- diagnostics
990
- };
991
- }
992
- function parseDependencyBlockers(content) {
993
- const requires = /* @__PURE__ */ new Map();
994
- let external = false;
995
- const malformed = [];
996
- for (const line of extractBlockerLines(content)) {
997
- if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
998
- continue;
999
- const dependency = line.match(REQUIRES_RE);
1000
- if (dependency) {
1001
- if (!requires.has(dependency[1]))
1002
- requires.set(dependency[1], dependency[2]);
1003
- } else if (/^[-*]\s+requires\b/i.test(line)) {
1004
- malformed.push(line);
1005
- external = true;
1006
- } else {
1007
- external = true;
1008
- }
1009
- }
1010
- return {
1011
- requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
1012
- external,
1013
- malformed
1014
- };
1015
- }
1016
- function buildWaves(names, edges, blockersByName, preferredOrder) {
1017
- const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
1018
- const excluded = new Set(
1019
- [...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
1020
- );
1021
- let changed = true;
1022
- while (changed) {
1023
- changed = false;
1024
- for (const edge of edges) {
1025
- if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
1026
- excluded.add(edge.change);
1027
- changed = true;
1028
- }
1029
- }
1030
- }
1031
- const remaining = new Set(names.filter((name) => !excluded.has(name)));
1032
- const waves = [];
1033
- while (remaining.size > 0) {
1034
- const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
1035
- if (wave.length === 0)
1036
- break;
1037
- waves.push(wave);
1038
- for (const name of wave)
1039
- remaining.delete(name);
1040
- }
1041
- return waves;
1042
- }
1043
- function comparePlanNames(left, right, orderIndex) {
1044
- const leftGroup = left.includes("/") ? left.split("/")[0] : null;
1045
- const rightGroup = right.includes("/") ? right.split("/")[0] : null;
1046
- if (leftGroup && leftGroup === rightGroup) {
1047
- const leftIndex = orderIndex.get(left);
1048
- const rightIndex = orderIndex.get(right);
1049
- if (leftIndex !== void 0 && rightIndex !== void 0)
1050
- return leftIndex - rightIndex;
1051
- }
1052
- return left.localeCompare(right);
1053
- }
1054
- function compareEdges(left, right) {
1055
- return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
1056
- }
1057
- function findCycleDiagnostics(edges) {
1058
- const adjacency = /* @__PURE__ */ new Map();
1059
- for (const edge of edges) {
1060
- if (edge.state !== "open" || edge.change === edge.requires)
1061
- continue;
1062
- const targets = adjacency.get(edge.change) ?? [];
1063
- targets.push(edge.requires);
1064
- adjacency.set(edge.change, targets.sort());
1065
- }
1066
- const visited = /* @__PURE__ */ new Set();
1067
- const active = /* @__PURE__ */ new Set();
1068
- const stack = [];
1069
- const reported = /* @__PURE__ */ new Set();
1070
- const diagnostics = [];
1071
- const visit = (name) => {
1072
- if (active.has(name)) {
1073
- const start = stack.indexOf(name);
1074
- const cycle = [...stack.slice(start), name];
1075
- const key = [...new Set(cycle)].sort().join("|");
1076
- if (!reported.has(key)) {
1077
- reported.add(key);
1078
- const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
1079
- for (const participant of [...new Set(cycle)].sort()) {
1080
- diagnostics.push({
1081
- severity: "error",
1082
- code: "dependency_cycle",
1083
- change: participant,
1084
- message
1085
- });
1086
- }
1087
- }
1088
- return;
1089
- }
1090
- if (visited.has(name))
1091
- return;
1092
- visited.add(name);
1093
- active.add(name);
1094
- stack.push(name);
1095
- for (const target of adjacency.get(name) ?? [])
1096
- visit(target);
1097
- stack.pop();
1098
- active.delete(name);
1099
- };
1100
- for (const name of [...adjacency.keys()].sort())
1101
- visit(name);
1102
- return diagnostics;
1103
- }
1104
-
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") };
1059
+ const date = nameMatch[1];
1060
+ const archiveStem = nameMatch[2];
1061
+ const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1062
+ const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1063
+ if (archiveGroup && !isCanonicalWorkRefSegment(archiveGroup))
1064
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
1065
+ const changeDocument = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1066
+ const groupDocument = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
1067
+ const changeHeadings = getDocumentTitles(changeDocument, "identity");
1068
+ const groupHeadings = getDocumentTitles(groupDocument, "identity");
1069
+ if (changeHeadings.length + groupHeadings.length !== 1) {
1070
+ return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
1205
1071
  }
1206
1072
  let frontmatter;
1207
1073
  try {
@@ -1225,10 +1091,10 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1225
1091
  }
1226
1092
  const expectedGroup = segments.length === 2 ? segments[0] : null;
1227
1093
  const expectedBase = segments.at(-1);
1228
- if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1094
+ if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1229
1095
  return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1230
1096
  }
1231
- const rawSummary = extractSummary(content);
1097
+ const rawSummary = extractSummary(content, changeDocument);
1232
1098
  if (!rawSummary)
1233
1099
  return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1234
1100
  const summary = boundText(rawSummary);
@@ -1246,11 +1112,11 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1246
1112
  }
1247
1113
  };
1248
1114
  }
1249
- function extractSummary(content) {
1115
+ function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
1250
1116
  const frontmatter = parseFrontmatter(content);
1251
1117
  if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
1252
1118
  return frontmatter.summary.trim();
1253
- const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
1119
+ const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
1254
1120
  for (const prefix of ["- Outcome:", "- Summary:"]) {
1255
1121
  const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
1256
1122
  if (line && line.slice(prefix.length).trim())
@@ -1263,7 +1129,7 @@ function boundText(value) {
1263
1129
  return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
1264
1130
  }
1265
1131
  function matchesArchiveStem(stem, identityBase) {
1266
- return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1132
+ return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1267
1133
  }
1268
1134
  function isCalendarDate(value) {
1269
1135
  const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
@@ -1279,7 +1145,7 @@ function normalizeOutputPath(path, archivesDir) {
1279
1145
  function errorDiagnostic(code, path, message) {
1280
1146
  return { severity: "error", code, path, message };
1281
1147
  }
1282
- function escapeRegExp2(value) {
1148
+ function escapeRegExp(value) {
1283
1149
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1284
1150
  }
1285
1151
  function compareHistoryRecords(left, right) {
@@ -1292,8 +1158,9 @@ function compareDiagnostics(left, right) {
1292
1158
  // src/history/query.ts
1293
1159
  function queryArchiveHistory(records, query = {}) {
1294
1160
  validateArchiveHistoryQuery(query);
1161
+ const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
1295
1162
  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}
1163
+ 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}
1297
1164
  ${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
1298
1165
  const selected = matched.slice(0, limit);
1299
1166
  return {
@@ -1317,8 +1184,13 @@ function validateArchiveHistoryQuery(query) {
1317
1184
  throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
1318
1185
  if (query.kind !== void 0 && query.kind.trim() === "")
1319
1186
  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");
1187
+ if (query.group !== void 0) {
1188
+ try {
1189
+ normalizeWorkRefSegment(query.group);
1190
+ } catch {
1191
+ throw new ArchiveHistoryError("invalid_history_group", "history group must be one safe Unicode segment");
1192
+ }
1193
+ }
1322
1194
  if (query.search !== void 0 && query.search.trim() === "")
1323
1195
  throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
1324
1196
  }
@@ -1336,13 +1208,14 @@ function selectArchiveHistoryRecord(records, selector) {
1336
1208
  async function readArchiveHistoryDetail(record) {
1337
1209
  let content;
1338
1210
  try {
1339
- content = await readFile4(record.sourcePath, "utf-8");
1211
+ content = await readFile3(record.sourcePath, "utf-8");
1340
1212
  } catch (error) {
1341
1213
  throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1342
1214
  }
1343
- const tasks = extractSection(content, "Tasks");
1344
- const verify = extractSection(content, "Verify");
1345
- const blockers = extractSection(content, "Blockers");
1215
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1216
+ const tasks = getDocumentSectionBody(document, "tasks");
1217
+ const verify = getDocumentSectionBody(document, "verify");
1218
+ const blockers = getDocumentSectionBody(document, "blockers");
1346
1219
  return {
1347
1220
  ...toOutputRecord(record),
1348
1221
  scenarioCount: parseScenarios(content).length,
@@ -1382,15 +1255,268 @@ function compareHistoryRecords2(left, right) {
1382
1255
  return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1383
1256
  }
1384
1257
 
1258
+ // src/core/dependency-plan.ts
1259
+ var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
1260
+ var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
1261
+ async function inspectChangeDependencies(options = {}) {
1262
+ const workTree = options.workTree ?? await inspectWorkTree();
1263
+ const archiveTree = options.archiveTree ?? await inspectArchiveTree();
1264
+ const archiveHistory = archiveTree.rootExists ? options.archiveHistory ?? await inspectArchiveHistory({ archiveTree }) : null;
1265
+ const openNames = new Set(workTree.changes.map((ref) => ref.name));
1266
+ const archivedNames = new Set(
1267
+ archiveHistory && historyInspectionComplete(archiveHistory) ? archiveHistory.records.map((record) => record.workRef) : []
1268
+ );
1269
+ const parsedByName = /* @__PURE__ */ new Map();
1270
+ const blockedGroups = /* @__PURE__ */ new Set();
1271
+ const diagnostics = archiveTree.diagnostics.map((diagnostic) => ({
1272
+ severity: "error",
1273
+ code: diagnostic.code,
1274
+ path: diagnostic.path,
1275
+ message: diagnostic.message
1276
+ }));
1277
+ if (archiveHistory)
1278
+ diagnostics.push(...archiveHistory.diagnostics);
1279
+ for (const brief of workTree.briefs) {
1280
+ try {
1281
+ if (hasMeaningfulBlockers(await readFile4(brief.path, "utf-8")))
1282
+ blockedGroups.add(brief.group);
1283
+ } catch (error) {
1284
+ diagnostics.push({
1285
+ severity: "error",
1286
+ code: "group_brief_read_failed",
1287
+ change: brief.group,
1288
+ path: brief.path,
1289
+ message: error instanceof Error ? error.message : String(error)
1290
+ });
1291
+ }
1292
+ }
1293
+ for (const ref of workTree.changes) {
1294
+ try {
1295
+ const parsed = parseDependencyBlockers(await readFile4(ref.path, "utf-8"));
1296
+ if (ref.group && blockedGroups.has(ref.group))
1297
+ parsed.external = true;
1298
+ parsedByName.set(ref.name, parsed);
1299
+ for (const line of parsed.malformed) {
1300
+ diagnostics.push({
1301
+ severity: "error",
1302
+ code: "dependency_syntax_invalid",
1303
+ change: ref.name,
1304
+ path: ref.path,
1305
+ message: `invalid dependency blocker syntax: ${line}`,
1306
+ hint: "Use: - requires `<change-work-ref>`: <reason>"
1307
+ });
1308
+ }
1309
+ } catch (error) {
1310
+ diagnostics.push({
1311
+ severity: "error",
1312
+ code: "change_read_failed",
1313
+ change: ref.name,
1314
+ path: ref.path,
1315
+ message: error instanceof Error ? error.message : String(error)
1316
+ });
1317
+ }
1318
+ }
1319
+ const edges = [];
1320
+ for (const [change, blockers] of parsedByName) {
1321
+ for (const requirement of blockers.requires) {
1322
+ const required = requirement.name;
1323
+ const edge = {
1324
+ change,
1325
+ requires: required,
1326
+ reason: requirement.reason,
1327
+ state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
1328
+ };
1329
+ edges.push(edge);
1330
+ if (!isCanonicalExecutableWorkRef(required)) {
1331
+ diagnostics.push({
1332
+ severity: "error",
1333
+ code: "dependency_target_invalid",
1334
+ change,
1335
+ message: `dependency target must be an executable Change WorkRef: ${required}`
1336
+ });
1337
+ } else if (required === change) {
1338
+ diagnostics.push({
1339
+ severity: "error",
1340
+ code: "dependency_self_reference",
1341
+ change,
1342
+ message: "Change cannot require itself"
1343
+ });
1344
+ } else if (edge.state === "missing") {
1345
+ diagnostics.push({
1346
+ severity: "error",
1347
+ code: "dependency_target_missing",
1348
+ change,
1349
+ message: `dependency target is neither open nor archived: ${required}`
1350
+ });
1351
+ }
1352
+ }
1353
+ }
1354
+ edges.sort(compareEdges);
1355
+ diagnostics.push(...findCycleDiagnostics(edges));
1356
+ const activeBlockers = /* @__PURE__ */ new Map();
1357
+ const blocked = [...parsedByName].map(([change, blockers]) => {
1358
+ const requires = edges.filter((edge) => edge.change === change && edge.state !== "archived").map((edge) => edge.requires).sort();
1359
+ activeBlockers.set(change, blockers.external || requires.length > 0);
1360
+ return { change, requires, external: blockers.external };
1361
+ }).filter((blocker) => blocker.external || blocker.requires.length > 0).sort((left, right) => left.change.localeCompare(right.change));
1362
+ const waves = buildWaves([...openNames], edges, parsedByName, options.preferredOrder ?? []);
1363
+ const graphValid = diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1364
+ if (!graphValid) {
1365
+ for (const name of openNames)
1366
+ activeBlockers.set(name, true);
1367
+ }
1368
+ const ready = graphValid ? waves[0] ?? [] : [];
1369
+ const executable = new Set(graphValid ? waves.flat() : []);
1370
+ const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
1371
+ let state;
1372
+ if (!openNames.has(name) && archivedNames.has(name))
1373
+ state = "archived";
1374
+ else if (!openNames.has(name))
1375
+ state = "missing";
1376
+ else if (ready.includes(name))
1377
+ state = "ready";
1378
+ else if (executable.has(name))
1379
+ state = "waiting";
1380
+ else
1381
+ state = "blocked";
1382
+ return { name, selection: openNames.has(name) ? "selected" : "prerequisite", state };
1383
+ });
1384
+ return {
1385
+ plan: {
1386
+ nodes,
1387
+ ready,
1388
+ edges,
1389
+ blocked,
1390
+ waves: graphValid ? waves : []
1391
+ },
1392
+ activeBlockers,
1393
+ diagnostics
1394
+ };
1395
+ }
1396
+ function parseDependencyBlockers(content) {
1397
+ const requires = /* @__PURE__ */ new Map();
1398
+ let external = false;
1399
+ const malformed = [];
1400
+ for (const line of extractBlockerLines(content)) {
1401
+ if (!line || NONE_BLOCKER_RE.test(line) || /^none$/i.test(line))
1402
+ continue;
1403
+ const dependency = line.match(REQUIRES_RE);
1404
+ if (dependency) {
1405
+ if (!requires.has(dependency[1]))
1406
+ requires.set(dependency[1], dependency[2]);
1407
+ } else if (/^[-*]\s+requires\b/i.test(line)) {
1408
+ malformed.push(line);
1409
+ external = true;
1410
+ } else {
1411
+ external = true;
1412
+ }
1413
+ }
1414
+ return {
1415
+ requires: [...requires].map(([name, reason]) => ({ name, reason })).sort((left, right) => left.name.localeCompare(right.name)),
1416
+ external,
1417
+ malformed
1418
+ };
1419
+ }
1420
+ function buildWaves(names, edges, blockersByName, preferredOrder) {
1421
+ const orderIndex = new Map(preferredOrder.map((name, index) => [name, index]));
1422
+ const excluded = new Set(
1423
+ [...blockersByName].filter(([, blockers]) => blockers.external).map(([name]) => name)
1424
+ );
1425
+ let changed = true;
1426
+ while (changed) {
1427
+ changed = false;
1428
+ for (const edge of edges) {
1429
+ if ((edge.state === "missing" || excluded.has(edge.requires)) && !excluded.has(edge.change)) {
1430
+ excluded.add(edge.change);
1431
+ changed = true;
1432
+ }
1433
+ }
1434
+ }
1435
+ const remaining = new Set(names.filter((name) => !excluded.has(name)));
1436
+ const waves = [];
1437
+ while (remaining.size > 0) {
1438
+ const wave = [...remaining].filter((name) => !edges.some((edge) => edge.change === name && edge.state === "open" && remaining.has(edge.requires))).sort((left, right) => comparePlanNames(left, right, orderIndex));
1439
+ if (wave.length === 0)
1440
+ break;
1441
+ waves.push(wave);
1442
+ for (const name of wave)
1443
+ remaining.delete(name);
1444
+ }
1445
+ return waves;
1446
+ }
1447
+ function comparePlanNames(left, right, orderIndex) {
1448
+ const leftGroup = left.includes("/") ? left.split("/")[0] : null;
1449
+ const rightGroup = right.includes("/") ? right.split("/")[0] : null;
1450
+ if (leftGroup && leftGroup === rightGroup) {
1451
+ const leftIndex = orderIndex.get(left);
1452
+ const rightIndex = orderIndex.get(right);
1453
+ if (leftIndex !== void 0 && rightIndex !== void 0)
1454
+ return leftIndex - rightIndex;
1455
+ }
1456
+ return left.localeCompare(right);
1457
+ }
1458
+ function compareEdges(left, right) {
1459
+ return left.change.localeCompare(right.change) || left.requires.localeCompare(right.requires);
1460
+ }
1461
+ function findCycleDiagnostics(edges) {
1462
+ const adjacency = /* @__PURE__ */ new Map();
1463
+ for (const edge of edges) {
1464
+ if (edge.state !== "open" || edge.change === edge.requires)
1465
+ continue;
1466
+ const targets = adjacency.get(edge.change) ?? [];
1467
+ targets.push(edge.requires);
1468
+ adjacency.set(edge.change, targets.sort());
1469
+ }
1470
+ const visited = /* @__PURE__ */ new Set();
1471
+ const active = /* @__PURE__ */ new Set();
1472
+ const stack = [];
1473
+ const reported = /* @__PURE__ */ new Set();
1474
+ const diagnostics = [];
1475
+ const visit = (name) => {
1476
+ if (active.has(name)) {
1477
+ const start = stack.indexOf(name);
1478
+ const cycle = [...stack.slice(start), name];
1479
+ const key = [...new Set(cycle)].sort().join("|");
1480
+ if (!reported.has(key)) {
1481
+ reported.add(key);
1482
+ const message = `dependency cycle detected: ${cycle.join(" -> ")}`;
1483
+ for (const participant of [...new Set(cycle)].sort()) {
1484
+ diagnostics.push({
1485
+ severity: "error",
1486
+ code: "dependency_cycle",
1487
+ change: participant,
1488
+ message
1489
+ });
1490
+ }
1491
+ }
1492
+ return;
1493
+ }
1494
+ if (visited.has(name))
1495
+ return;
1496
+ visited.add(name);
1497
+ active.add(name);
1498
+ stack.push(name);
1499
+ for (const target of adjacency.get(name) ?? [])
1500
+ visit(target);
1501
+ stack.pop();
1502
+ active.delete(name);
1503
+ };
1504
+ for (const name of [...adjacency.keys()].sort())
1505
+ visit(name);
1506
+ return diagnostics;
1507
+ }
1508
+
1385
1509
  // src/status/inspect.ts
1386
1510
  async function inspectProjectStatus(options = {}) {
1387
1511
  const runtime = [];
1388
1512
  const diagnostics = [];
1389
1513
  let manage = resolveManagePolicy({});
1514
+ let language = resolveLanguagePolicy({});
1390
1515
  try {
1391
1516
  const configInspection = await inspectRspConfig();
1392
1517
  const configValid = configInspection.issues.length === 0;
1393
1518
  manage = resolveManagePolicy(configInspection.config, { configValid });
1519
+ language = resolveLanguagePolicy(configInspection.config, { configValid });
1394
1520
  if (!configValid) {
1395
1521
  diagnostics.push({
1396
1522
  severity: "error",
@@ -1401,6 +1527,7 @@ async function inspectProjectStatus(options = {}) {
1401
1527
  }
1402
1528
  } catch (error) {
1403
1529
  manage = resolveManagePolicy({}, { configValid: false });
1530
+ language = resolveLanguagePolicy({}, { configValid: false });
1404
1531
  diagnostics.push({
1405
1532
  severity: "error",
1406
1533
  code: "invalid_config",
@@ -1453,6 +1580,7 @@ async function inspectProjectStatus(options = {}) {
1453
1580
  const dependencyInspection = await inspectChangeDependencies({
1454
1581
  workTree,
1455
1582
  archiveTree,
1583
+ archiveHistory: historyInspection,
1456
1584
  preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
1457
1585
  });
1458
1586
  diagnostics.push(...dependencyInspection.diagnostics);
@@ -1574,6 +1702,7 @@ async function inspectProjectStatus(options = {}) {
1574
1702
  }
1575
1703
  return {
1576
1704
  manage,
1705
+ language,
1577
1706
  focused: [...focusedSet].sort(),
1578
1707
  records,
1579
1708
  groups: groupInspection.groups,
@@ -1595,11 +1724,11 @@ function emptyReadiness(activeBlockers) {
1595
1724
  };
1596
1725
  }
1597
1726
  function extractChangeTitle(content) {
1598
- const heading = content.split(/\r?\n/).find((line) => line.startsWith("# Change: "));
1599
- return heading?.slice("# Change: ".length).trim() || null;
1727
+ return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
1600
1728
  }
1601
1729
  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");
1730
+ const document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA);
1731
+ 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
1732
  }
1604
1733
  function deriveArchiveTrend(records) {
1605
1734
  const counts = /* @__PURE__ */ new Map();
@@ -1648,6 +1777,8 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
1648
1777
  export {
1649
1778
  GROUP_BRIEF_FILENAME,
1650
1779
  WorkRefError,
1780
+ normalizeWorkRefSegment,
1781
+ normalizeExecutableWorkRef,
1651
1782
  resolveWorkRef,
1652
1783
  resolveFocusMarkerPath,
1653
1784
  resolveArchiveDirectory,
@@ -1658,7 +1789,6 @@ export {
1658
1789
  generateGroupBriefContent,
1659
1790
  inspectChangeGroups,
1660
1791
  hasArchivedGroupBrief,
1661
- inspectChangeDependencies,
1662
1792
  HISTORY_DEFAULT_LIMIT,
1663
1793
  HISTORY_MAX_LIMIT,
1664
1794
  ArchiveHistoryError,
@@ -1667,6 +1797,7 @@ export {
1667
1797
  selectArchiveHistoryRecord,
1668
1798
  readArchiveHistoryDetail,
1669
1799
  historyInspectionComplete,
1800
+ inspectChangeDependencies,
1670
1801
  inspectProjectStatus,
1671
1802
  projectDependencyForest
1672
1803
  };