@oevery/rsp 3.1.0-beta.2 → 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,39 +2,50 @@
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
- import { existsSync } from "fs";
26
- import { readFile as readFile3, stat } from "fs/promises";
27
- import { join as join3 } from "path";
35
+ import { readFile as readFile5, stat } from "fs/promises";
28
36
 
29
37
  // src/core/change-group.ts
30
38
  import { readFile } from "fs/promises";
31
39
  import { dirname, join as join2, relative as relative2 } from "path";
32
40
 
33
41
  // src/core/work-ref.ts
34
- import { lstatSync } from "fs";
42
+ import { Buffer } from "buffer";
43
+ import { lstatSync, readdirSync } from "fs";
35
44
  import { readdir } from "fs/promises";
36
45
  import { isAbsolute, join, relative } from "path";
37
- 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;
38
49
  var GROUP_BRIEF_FILENAME = "00-brief.md";
39
50
  var WorkRefError = class extends Error {
40
51
  constructor(code, message, input) {
@@ -46,35 +57,51 @@ var WorkRefError = class extends Error {
46
57
  code;
47
58
  input;
48
59
  };
49
- function resolveWorkRef(name, options = {}) {
50
- const changesDir = options.changesDir ?? CHANGES_DIR;
51
- const segments = name.split("/");
52
- const isGroupBrief = segments.length === 2 && segments[1] === "brief";
53
- if (segments.length > 2) {
54
- throw new WorkRefError(
55
- "unsupported_work_depth",
56
- `work identity "${name}" exceeds the supported one-level Change Group depth`,
57
- name
58
- );
59
- }
60
- 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) {
61
63
  throw new WorkRefError(
62
64
  "invalid_work_ref",
63
- `work identity "${name}" must contain one or two lowercase kebab-case segments`,
64
- 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
65
67
  );
66
68
  }
67
- 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) {
68
92
  throw new WorkRefError(
69
93
  "invalid_work_ref",
70
- `work identity "${name}" uses the reserved Group Brief filename`,
94
+ `stored work identity "${name}" must use Unicode NFC normalization`,
71
95
  name
72
96
  );
73
97
  }
74
- assertValidWorkRoot(changesDir, name);
75
- 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);
76
103
  const path = join(changesDir, ...segments.slice(0, -1), isGroupBrief ? GROUP_BRIEF_FILENAME : `${segments.at(-1)}.md`);
77
- 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] };
78
105
  if (ref.kind === "group-change" && options.executable !== false)
79
106
  assertGroupBrief(changesDir, ref);
80
107
  if (options.executable && ref.kind === "group-brief") {
@@ -149,7 +176,7 @@ function resolveWorkRefPath(path, options = {}) {
149
176
  );
150
177
  }
151
178
  const name = segments.length === 2 && segments[1] === GROUP_BRIEF_FILENAME ? `${segments[0]}/brief` : logicalPath.slice(0, -3);
152
- return resolveWorkRef(name, options);
179
+ return resolveWorkRef(name, { ...options, canonical: true });
153
180
  }
154
181
  function isExecutableWorkRef(ref) {
155
182
  return ref.kind !== "group-brief";
@@ -157,6 +184,7 @@ function isExecutableWorkRef(ref) {
157
184
  function resolveFocusMarkerPath(ref, options = {}) {
158
185
  const focusDir = options.focusDir ?? FOCUS_DIR;
159
186
  assertManagedRoot(focusDir, ref.name, "invalid_focus_root", "focus root", true);
187
+ assertNoManagedNormalizationCollision(focusDir, ref.name.split("/"), ref.name, "invalid_focus_path");
160
188
  if (ref.group) {
161
189
  assertOptionalManagedDirectory(
162
190
  join(focusDir, ref.group),
@@ -180,6 +208,7 @@ function resolveArchiveDirectory(ref, options = {}) {
180
208
  assertManagedRoot(archivesDir, ref.name, "invalid_archive_root", "archive root", true);
181
209
  if (!ref.group)
182
210
  return archivesDir;
211
+ assertNoManagedNormalizationCollision(archivesDir, [ref.group], ref.name, "invalid_archive_path");
183
212
  const groupPath = join(archivesDir, ref.group);
184
213
  assertOptionalManagedDirectory(groupPath, ref.name, "invalid_archive_path", "archive path");
185
214
  return groupPath;
@@ -203,10 +232,10 @@ async function inspectFocusTree(options = {}) {
203
232
  if (entry.name === ".gitkeep" && entry.isFile())
204
233
  continue;
205
234
  if (entry.isDirectory()) {
206
- if (!WORK_SEGMENT_RE.test(entry.name)) {
235
+ if (!isCanonicalWorkRefSegment(entry.name)) {
207
236
  addDiagnostic(result, new WorkRefError(
208
237
  "invalid_work_ref",
209
- `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`,
210
239
  entry.name
211
240
  ), path);
212
241
  continue;
@@ -246,7 +275,7 @@ async function inspectArchiveTree(options = {}) {
246
275
  if (entry.name === "INDEX.md" && entry.isFile())
247
276
  continue;
248
277
  if (entry.isDirectory()) {
249
- if (!WORK_SEGMENT_RE.test(entry.name)) {
278
+ if (!isCanonicalWorkRefSegment(entry.name)) {
250
279
  addArchivePathDiagnostic(result, entry.name, path);
251
280
  continue;
252
281
  }
@@ -284,10 +313,10 @@ async function inspectWorkTree(options = {}) {
284
313
  if (entry.name === ".gitkeep" && entry.isFile())
285
314
  continue;
286
315
  if (entry.isDirectory()) {
287
- if (!WORK_SEGMENT_RE.test(entry.name)) {
316
+ if (!isCanonicalWorkRefSegment(entry.name)) {
288
317
  addDiagnostic(result, new WorkRefError(
289
318
  "invalid_work_ref",
290
- `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`,
291
320
  entry.name
292
321
  ), path);
293
322
  continue;
@@ -402,7 +431,7 @@ async function inspectArchiveGroup(group, groupPath, result) {
402
431
  }
403
432
  function collectFocusMarker(input, path, changesDir, result) {
404
433
  try {
405
- const ref = resolveWorkRef(input, { changesDir, executable: true });
434
+ const ref = resolveWorkRef(input, { changesDir, canonical: true, executable: true });
406
435
  result.markers.push({ name: ref.name, path });
407
436
  } catch (error) {
408
437
  if (!(error instanceof WorkRefError))
@@ -450,6 +479,98 @@ function addDiagnostic(result, error, path) {
450
479
  return;
451
480
  result.diagnostics.push({ code: error.code, input: error.input, path, message: error.message });
452
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
+ }
453
574
  function assertNoIdentityCollision(changesDir, segments, input, finalFilename) {
454
575
  for (let depth = 1; depth <= segments.length; depth++) {
455
576
  const identityPath = join(changesDir, ...segments.slice(0, depth));
@@ -544,7 +665,7 @@ function normalizeLogicalPath2(path) {
544
665
  }
545
666
 
546
667
  // src/core/change-group.ts
547
- var GROUP_BRIEF_SECTIONS = ["Goal", "Scope", "Shared Constraints", "Slices", "Completion Conditions", "Durable Outcomes", "Blockers"];
668
+ var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
548
669
  async function resolveExecutableChange(name, options = {}) {
549
670
  const ref = resolveWorkRef(name, { ...options, executable: true });
550
671
  if (ref.kind === "group-change")
@@ -552,32 +673,33 @@ async function resolveExecutableChange(name, options = {}) {
552
673
  return ref;
553
674
  }
554
675
  function generateGroupBriefContent(name, goal = "") {
676
+ const placeholder = "<\u2026>";
555
677
  return `---
556
678
  kind: group
557
679
  ---
558
680
 
559
- # Change Group: ${name}
681
+ ${renderDocumentTitle(GROUP_BRIEF_DOCUMENT_SCHEMA, name)}
560
682
 
561
- ## Goal
562
- - ${goal || "<what shared outcome this group must deliver>"}
683
+ ${groupSectionHeading("goal")}
684
+ - ${goal || placeholder}
563
685
 
564
- ## Scope
565
- - <what is coordinated by this group>
686
+ ${groupSectionHeading("scope")}
687
+ - ${placeholder}
566
688
 
567
- ## Shared Constraints
568
- - <constraint shared by every child Change>
689
+ ${groupSectionHeading("sharedConstraints")}
690
+ - ${placeholder}
569
691
 
570
- ## Slices
571
- - \`${name}/<change>\`: <independently executable boundary>
692
+ ${groupSectionHeading("slices")}
693
+ - \`${name}/<change>\`: ${placeholder}
572
694
 
573
- ## Completion Conditions
574
- - [ ] <end-to-end condition beyond individual child verification>
695
+ ${groupSectionHeading("completionConditions")}
696
+ - [ ] ${placeholder}
575
697
 
576
- ## Durable Outcomes
577
- - Current facts: <Spec, CONTEXT.md, scoped instruction target, or none>
578
- - Lasting rationale: <Decision Record target, or none>
698
+ ${groupSectionHeading("durableOutcomes")}
699
+ - Current facts: ${placeholder}
700
+ - Lasting rationale: ${placeholder}
579
701
 
580
- ## Blockers
702
+ ${groupSectionHeading("blockers")}
581
703
  - none
582
704
  `;
583
705
  }
@@ -597,10 +719,10 @@ async function inspectChangeGroups(options = {}) {
597
719
  continue;
598
720
  try {
599
721
  const content = await readFile(path, "utf-8");
600
- const heading = content.match(/^# Change:\s+(\S+)\s*$/m);
601
- if (heading?.[1]?.includes("/")) {
602
- const identity = heading[1];
603
- 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}/`)) {
604
726
  diagnostics.push({
605
727
  severity: "error",
606
728
  code: "group_archive_identity_mismatch",
@@ -614,7 +736,7 @@ async function inspectChangeGroups(options = {}) {
614
736
  archivedSlicesByGroup.set(archiveGroup, groupSlices);
615
737
  }
616
738
  }
617
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(archiveGroup)}\\s*$`, "m").test(content))
739
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup)
618
740
  archivedGroups.add(archiveGroup);
619
741
  } catch (error) {
620
742
  diagnostics.push({
@@ -776,13 +898,14 @@ async function hasArchivedGroupBrief(group) {
776
898
  const message = error instanceof Error ? error.message : String(error);
777
899
  throw new WorkRefError("work_tree_read_failed", `unable to inspect archived Change Group identity at ${path}: ${message}`, group);
778
900
  }
779
- if (new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
901
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === group)
780
902
  return true;
781
903
  }
782
904
  return false;
783
905
  }
784
906
  function parseGroupBrief(group, content) {
785
907
  const diagnostics = [];
908
+ const document = parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA);
786
909
  let frontmatter;
787
910
  try {
788
911
  frontmatter = parseFrontmatter(content);
@@ -791,15 +914,15 @@ function parseGroupBrief(group, content) {
791
914
  }
792
915
  if (frontmatter?.kind !== "group")
793
916
  diagnostics.push({ severity: "error", code: "group_kind_invalid", change: group, message: "Group Brief frontmatter must declare kind: group" });
794
- if (!new RegExp(`^# Change Group:\\s+${escapeRegExp(group)}\\s*$`, "m").test(content))
917
+ if (getDocumentTitle(document, "identity") !== group)
795
918
  diagnostics.push({ severity: "error", code: "group_heading_mismatch", change: group, message: `Group Brief heading must be: # Change Group: ${group}` });
796
- for (const section of GROUP_BRIEF_SECTIONS) {
797
- if (!extractSection(content, section))
798
- 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}` });
799
922
  }
800
923
  const slices = [];
801
924
  const seen = /* @__PURE__ */ new Set();
802
- for (const line of extractSection(content, "Slices").split("\n")) {
925
+ for (const line of getDocumentSectionBody(document, "slices").split("\n")) {
803
926
  const trimmed = line.trim();
804
927
  const identity = trimmed.match(/^-\s+`(\S+)`/);
805
928
  if (!identity || identity[1].includes("<"))
@@ -815,7 +938,7 @@ function parseGroupBrief(group, content) {
815
938
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `group slice requires a boundary: ${name}` });
816
939
  continue;
817
940
  }
818
- if (!new RegExp(`^${escapeRegExp(group)}/[a-z0-9-]+$`).test(name) || name === `${group}/brief`) {
941
+ if (!isCanonicalExecutableWorkRef(name) || !name.startsWith(`${group}/`)) {
819
942
  diagnostics.push({ severity: "error", code: "group_slice_invalid", change: group, message: `invalid direct child identity in Group Brief: ${name}` });
820
943
  continue;
821
944
  }
@@ -828,90 +951,369 @@ function parseGroupBrief(group, content) {
828
951
  }
829
952
  if (slices.length < 2)
830
953
  diagnostics.push({ severity: "warning", code: "group_slices_incomplete", change: group, message: "Change Group must declare at least two direct child slices" });
831
- const completion = countCheckboxes(extractSection(content, "Completion Conditions"));
954
+ const completion = countCheckboxes(getDocumentSectionBody(document, "completionConditions"));
832
955
  if (completion.total === 0)
833
956
  diagnostics.push({ severity: "warning", code: "group_completion_missing", change: group, message: "Change Group must declare at least one completion condition" });
834
957
  return {
835
958
  slices,
836
959
  completion: { done: completion.done + completion.dropped, total: completion.total },
837
- blockers: hasMeaningfulBlockers(content),
960
+ blockers: hasMeaningfulBlockerBody(getDocumentSectionBody(document, "blockers")),
838
961
  diagnostics
839
962
  };
840
963
  }
841
- function escapeRegExp(value) {
842
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
964
+ function groupSectionHeading(sectionId) {
965
+ return renderDocumentSectionHeading(GROUP_BRIEF_DOCUMENT_SCHEMA, sectionId);
843
966
  }
844
967
 
845
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
846
975
  import { readFile as readFile2 } from "fs/promises";
847
- var NONE_BLOCKER_RE = /^[-*]\s*(?:none)?$/i;
848
- var REQUIRES_RE = /^[-*]\s+requires\s+`([^`]+)`:[ \t]*(\S.*)$/i;
849
- var EXECUTABLE_WORK_REF_RE = /^[a-z0-9-]+(?:\/(?!(?:brief|00-brief)$)[a-z0-9-]+)?$/;
850
- async function inspectChangeDependencies(options = {}) {
851
- const workTree = options.workTree ?? await inspectWorkTree();
852
- const archiveTree = options.archiveTree ?? await inspectArchiveTree();
853
- const openNames = new Set(workTree.changes.map((ref) => ref.name));
854
- const archivedNames = /* @__PURE__ */ new Set();
855
- const parsedByName = /* @__PURE__ */ new Map();
856
- const blockedGroups = /* @__PURE__ */ new Set();
857
- 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) => ({
858
1007
  severity: "error",
859
1008
  code: diagnostic.code,
860
- path: diagnostic.path,
1009
+ path: normalizeOutputPath(diagnostic.path, archivesDir),
861
1010
  message: diagnostic.message
862
1011
  }));
863
- for (const path of archiveTree.files) {
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
+ });
1021
+ }
1022
+ for (const sourcePath of tree.files) {
1023
+ const path = archiveOutputPath(sourcePath, archivesDir);
1024
+ let content;
864
1025
  try {
865
- const heading = (await readFile2(path, "utf-8")).match(/^# Change:\s+(\S+)\s*$/m);
866
- if (heading)
867
- archivedNames.add(heading[1]);
1026
+ content = await readFile2(sourcePath, "utf-8");
868
1027
  } catch (error) {
869
- diagnostics.push({
870
- severity: "error",
871
- code: "dependency_archive_read_failed",
872
- path,
873
- message: error instanceof Error ? error.message : String(error)
874
- });
1028
+ const message = toErrorMessage(error);
1029
+ diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1030
+ continue;
875
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);
876
1037
  }
877
- for (const brief of workTree.briefs) {
878
- try {
879
- if (hasMeaningfulBlockers(await readFile2(brief.path, "utf-8")))
880
- blockedGroups.add(brief.group);
881
- } catch (error) {
882
- diagnostics.push({
883
- severity: "error",
884
- code: "group_brief_read_failed",
885
- change: brief.group,
886
- path: brief.path,
887
- message: error instanceof Error ? error.message : String(error)
888
- });
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") };
1058
+ }
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") };
1071
+ }
1072
+ let frontmatter;
1073
+ try {
1074
+ frontmatter = parseFrontmatter(content);
1075
+ } catch (error) {
1076
+ return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
1077
+ }
1078
+ if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
1079
+ return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
1080
+ if (groupHeadings.length === 1) {
1081
+ const group = groupHeadings[0];
1082
+ if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
1083
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
889
1084
  }
1085
+ return {};
890
1086
  }
891
- for (const ref of workTree.changes) {
892
- try {
893
- const parsed = parseDependencyBlockers(await readFile2(ref.path, "utf-8"));
894
- if (ref.group && blockedGroups.has(ref.group))
895
- parsed.external = true;
896
- parsedByName.set(ref.name, parsed);
897
- for (const line of parsed.malformed) {
898
- diagnostics.push({
899
- severity: "error",
900
- code: "dependency_syntax_invalid",
901
- change: ref.name,
902
- path: ref.path,
903
- message: `invalid dependency blocker syntax: ${line}`,
904
- hint: "Use: - requires `<change-work-ref>`: <reason>"
905
- });
906
- }
907
- } catch (error) {
908
- diagnostics.push({
909
- severity: "error",
910
- code: "change_read_failed",
911
- change: ref.name,
912
- path: ref.path,
913
- message: error instanceof Error ? error.message : String(error)
914
- });
1087
+ const workRef = changeHeadings[0];
1088
+ const segments = workRef.split("/");
1089
+ if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
1090
+ return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
1091
+ }
1092
+ const expectedGroup = segments.length === 2 ? segments[0] : null;
1093
+ const expectedBase = segments.at(-1);
1094
+ if (!isCanonicalExecutableWorkRef(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1095
+ return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1096
+ }
1097
+ const rawSummary = extractSummary(content, changeDocument);
1098
+ if (!rawSummary)
1099
+ return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1100
+ const summary = boundText(rawSummary);
1101
+ return {
1102
+ record: {
1103
+ date,
1104
+ workRef,
1105
+ group: expectedGroup,
1106
+ kind: frontmatter.kind.trim(),
1107
+ summary: summary.value,
1108
+ summaryTruncated: summary.truncated,
1109
+ path,
1110
+ sourcePath,
1111
+ searchSummary: rawSummary
1112
+ }
1113
+ };
1114
+ }
1115
+ function extractSummary(content, document = parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA)) {
1116
+ const frontmatter = parseFrontmatter(content);
1117
+ if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
1118
+ return frontmatter.summary.trim();
1119
+ const proposalLines = getDocumentSectionBody(document, "proposal").split("\n").map((line) => line.trim());
1120
+ for (const prefix of ["- Outcome:", "- Summary:"]) {
1121
+ const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
1122
+ if (line && line.slice(prefix.length).trim())
1123
+ return line.slice(prefix.length).trim();
1124
+ }
1125
+ return "";
1126
+ }
1127
+ function boundText(value) {
1128
+ const codePoints = [...value];
1129
+ return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
1130
+ }
1131
+ function matchesArchiveStem(stem, identityBase) {
1132
+ return stem === identityBase || new RegExp(`^${escapeRegExp(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1133
+ }
1134
+ function isCalendarDate(value) {
1135
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1136
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1137
+ }
1138
+ function archiveOutputPath(sourcePath, archivesDir) {
1139
+ return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1140
+ }
1141
+ function normalizeOutputPath(path, archivesDir) {
1142
+ const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1143
+ return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1144
+ }
1145
+ function errorDiagnostic(code, path, message) {
1146
+ return { severity: "error", code, path, message };
1147
+ }
1148
+ function escapeRegExp(value) {
1149
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1150
+ }
1151
+ function compareHistoryRecords(left, right) {
1152
+ return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1153
+ }
1154
+ function compareDiagnostics(left, right) {
1155
+ return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
1156
+ }
1157
+
1158
+ // src/history/query.ts
1159
+ function queryArchiveHistory(records, query = {}) {
1160
+ validateArchiveHistoryQuery(query);
1161
+ const group = query.group === void 0 ? void 0 : normalizeWorkRefSegment(query.group);
1162
+ const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
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}
1164
+ ${record.searchSummary ?? record.summary}`.toLowerCase().includes(query.search.toLowerCase()))).sort(compareHistoryRecords2);
1165
+ const selected = matched.slice(0, limit);
1166
+ return {
1167
+ records: selected.map(toOutputRecord),
1168
+ summary: {
1169
+ matched: matched.length,
1170
+ returned: selected.length,
1171
+ hasMore: matched.length > selected.length
1172
+ }
1173
+ };
1174
+ }
1175
+ function validateArchiveHistoryQuery(query) {
1176
+ const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1177
+ if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
1178
+ throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
1179
+ if (query.since && !isCalendarDate2(query.since))
1180
+ throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
1181
+ if (query.until && !isCalendarDate2(query.until))
1182
+ throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
1183
+ if (query.since && query.until && query.since > query.until)
1184
+ throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
1185
+ if (query.kind !== void 0 && query.kind.trim() === "")
1186
+ throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
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
+ }
1194
+ if (query.search !== void 0 && query.search.trim() === "")
1195
+ throw new ArchiveHistoryError("invalid_history_search", "history search must be non-empty");
1196
+ }
1197
+ function selectArchiveHistoryRecord(records, selector) {
1198
+ const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
1199
+ const identity = "path" in selector ? selector.path : selector.workRef;
1200
+ if (matches.length === 0)
1201
+ throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
1202
+ if (matches.length > 1) {
1203
+ const candidates = matches.map((record) => record.path).sort();
1204
+ throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
1205
+ }
1206
+ return matches[0];
1207
+ }
1208
+ async function readArchiveHistoryDetail(record) {
1209
+ let content;
1210
+ try {
1211
+ content = await readFile3(record.sourcePath, "utf-8");
1212
+ } catch (error) {
1213
+ throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1214
+ }
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");
1219
+ return {
1220
+ ...toOutputRecord(record),
1221
+ scenarioCount: parseScenarios(content).length,
1222
+ checkboxes: {
1223
+ tasks: countCheckboxes(tasks),
1224
+ verify: countCheckboxes(verify)
1225
+ },
1226
+ evidence: {
1227
+ tasks: boundEvidence(tasks),
1228
+ verify: boundEvidence(verify),
1229
+ blockers: boundEvidence(blockers, true)
1230
+ }
1231
+ };
1232
+ }
1233
+ function historyInspectionComplete(inspection) {
1234
+ return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1235
+ }
1236
+ function toOutputRecord(record) {
1237
+ const { searchSummary: _searchSummary, sourcePath: _sourcePath, ...output } = record;
1238
+ return output;
1239
+ }
1240
+ function boundEvidence(section, omitNone = false) {
1241
+ const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
1242
+ const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
1243
+ return {
1244
+ items: boundedItems.map((item) => item.value),
1245
+ truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
1246
+ };
1247
+ }
1248
+ function isCalendarDate2(value) {
1249
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
1250
+ return false;
1251
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1252
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1253
+ }
1254
+ function compareHistoryRecords2(left, right) {
1255
+ return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1256
+ }
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
+ });
915
1317
  }
916
1318
  }
917
1319
  const edges = [];
@@ -925,7 +1327,7 @@ async function inspectChangeDependencies(options = {}) {
925
1327
  state: openNames.has(required) ? "open" : archivedNames.has(required) ? "archived" : "missing"
926
1328
  };
927
1329
  edges.push(edge);
928
- if (!EXECUTABLE_WORK_REF_RE.test(required)) {
1330
+ if (!isCanonicalExecutableWorkRef(required)) {
929
1331
  diagnostics.push({
930
1332
  severity: "error",
931
1333
  code: "dependency_target_invalid",
@@ -967,7 +1369,7 @@ async function inspectChangeDependencies(options = {}) {
967
1369
  const executable = new Set(graphValid ? waves.flat() : []);
968
1370
  const nodes = [.../* @__PURE__ */ new Set([...openNames, ...edges.map((edge) => edge.requires)])].sort().map((name) => {
969
1371
  let state;
970
- if (archivedNames.has(name))
1372
+ if (!openNames.has(name) && archivedNames.has(name))
971
1373
  state = "archived";
972
1374
  else if (!openNames.has(name))
973
1375
  state = "missing";
@@ -1109,10 +1511,12 @@ async function inspectProjectStatus(options = {}) {
1109
1511
  const runtime = [];
1110
1512
  const diagnostics = [];
1111
1513
  let manage = resolveManagePolicy({});
1514
+ let language = resolveLanguagePolicy({});
1112
1515
  try {
1113
1516
  const configInspection = await inspectRspConfig();
1114
1517
  const configValid = configInspection.issues.length === 0;
1115
1518
  manage = resolveManagePolicy(configInspection.config, { configValid });
1519
+ language = resolveLanguagePolicy(configInspection.config, { configValid });
1116
1520
  if (!configValid) {
1117
1521
  diagnostics.push({
1118
1522
  severity: "error",
@@ -1123,6 +1527,7 @@ async function inspectProjectStatus(options = {}) {
1123
1527
  }
1124
1528
  } catch (error) {
1125
1529
  manage = resolveManagePolicy({}, { configValid: false });
1530
+ language = resolveLanguagePolicy({}, { configValid: false });
1126
1531
  diagnostics.push({
1127
1532
  severity: "error",
1128
1533
  code: "invalid_config",
@@ -1161,6 +1566,8 @@ async function inspectProjectStatus(options = {}) {
1161
1566
  }
1162
1567
  }
1163
1568
  const workTree = await inspectWorkTree();
1569
+ const archiveTree = await inspectArchiveTree();
1570
+ const historyInspection = await inspectArchiveHistory({ archiveTree });
1164
1571
  diagnostics.push(...workTree.diagnostics.map((diagnostic) => ({
1165
1572
  severity: "error",
1166
1573
  code: diagnostic.code,
@@ -1168,10 +1575,12 @@ async function inspectProjectStatus(options = {}) {
1168
1575
  path: diagnostic.path,
1169
1576
  message: diagnostic.message
1170
1577
  })));
1171
- const groupInspection = await inspectChangeGroups({ workTree, focusTree });
1578
+ const groupInspection = await inspectChangeGroups({ workTree, focusTree, archiveTree });
1172
1579
  diagnostics.push(...groupInspection.diagnostics);
1173
1580
  const dependencyInspection = await inspectChangeDependencies({
1174
1581
  workTree,
1582
+ archiveTree,
1583
+ archiveHistory: historyInspection,
1175
1584
  preferredOrder: groupInspection.groups.flatMap((group) => group.slices.map((slice) => slice.name))
1176
1585
  });
1177
1586
  diagnostics.push(...dependencyInspection.diagnostics);
@@ -1193,7 +1602,7 @@ async function inspectProjectStatus(options = {}) {
1193
1602
  let progressKnown = false;
1194
1603
  if (path) {
1195
1604
  try {
1196
- const content = await readFile3(path, "utf-8");
1605
+ const content = await readFile5(path, "utf-8");
1197
1606
  title = extractChangeTitle(content) ?? name;
1198
1607
  blockerEntries = extractBlockerEntries(content);
1199
1608
  try {
@@ -1293,13 +1702,14 @@ async function inspectProjectStatus(options = {}) {
1293
1702
  }
1294
1703
  return {
1295
1704
  manage,
1705
+ language,
1296
1706
  focused: [...focusedSet].sort(),
1297
1707
  records,
1298
1708
  groups: groupInspection.groups,
1299
1709
  plan: dependencyInspection.plan,
1300
- archiveTrend: await readArchiveTrend(runtime),
1301
- diagnostics,
1302
- runtime
1710
+ archiveTrend: historyInspectionComplete(historyInspection) ? deriveArchiveTrend(historyInspection.records) : [],
1711
+ diagnostics: [...diagnostics, ...historyInspection.rootExists ? historyInspection.diagnostics : []],
1712
+ runtime: [...runtime, ...historyInspection.runtime]
1303
1713
  };
1304
1714
  }
1305
1715
  function emptyReadiness(activeBlockers) {
@@ -1314,307 +1724,19 @@ function emptyReadiness(activeBlockers) {
1314
1724
  };
1315
1725
  }
1316
1726
  function extractChangeTitle(content) {
1317
- const heading = content.split(/\r?\n/).find((line) => line.startsWith("# Change: "));
1318
- return heading?.slice("# Change: ".length).trim() || null;
1727
+ return getDocumentTitle(parseRspDocument(content, CHANGE_DOCUMENT_SCHEMA), "spaced");
1319
1728
  }
1320
1729
  function extractBlockerEntries(content) {
1321
- 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");
1322
1732
  }
1323
- async function readArchiveTrend(runtime) {
1324
- const indexPath = join3(ARCHIVES_DIR, "INDEX.md");
1325
- if (!existsSync(indexPath))
1326
- return [];
1327
- try {
1328
- const content = await readFile3(indexPath, "utf-8");
1329
- const dateRe = /^\| (\d{4}-\d{2})-\d{2} \|/gm;
1330
- const counts = {};
1331
- let match;
1332
- while ((match = dateRe.exec(content)) !== null) {
1333
- const month = match[1];
1334
- counts[month] = (counts[month] || 0) + 1;
1335
- }
1336
- return Object.keys(counts).sort().map((month) => ({ month, count: counts[month] }));
1337
- } catch (error) {
1338
- runtime.push({
1339
- code: "archive_index_read_failed",
1340
- operation: "readFile",
1341
- path: indexPath,
1342
- message: toErrorMessage(error)
1343
- });
1344
- return [];
1733
+ function deriveArchiveTrend(records) {
1734
+ const counts = /* @__PURE__ */ new Map();
1735
+ for (const record of records) {
1736
+ const month = record.date.slice(0, 7);
1737
+ counts.set(month, (counts.get(month) ?? 0) + 1);
1345
1738
  }
1346
- }
1347
-
1348
- // src/history/model.ts
1349
- var HISTORY_DEFAULT_LIMIT = 20;
1350
- var HISTORY_MAX_LIMIT = 100;
1351
- var HISTORY_MAX_TEXT_CODE_POINTS = 500;
1352
- var HISTORY_MAX_EVIDENCE_ITEMS = 20;
1353
- var HISTORY_MAX_DIAGNOSTICS = 20;
1354
- var HISTORY_MAX_CANDIDATES = 20;
1355
- var ArchiveHistoryError = class extends Error {
1356
- constructor(code, message, candidates = []) {
1357
- super(message);
1358
- this.code = code;
1359
- this.name = "ArchiveHistoryError";
1360
- const ordered = [...candidates].sort();
1361
- this.candidateTotal = ordered.length;
1362
- this.candidates = ordered.slice(0, HISTORY_MAX_CANDIDATES);
1363
- this.candidatesTruncated = this.candidateTotal > this.candidates.length;
1364
- }
1365
- code;
1366
- candidates;
1367
- candidateTotal;
1368
- candidatesTruncated;
1369
- };
1370
-
1371
- // src/history/inspect.ts
1372
- import { readFile as readFile4 } from "fs/promises";
1373
- import { basename, dirname as dirname2, relative as relative3 } from "path";
1374
- var EXECUTABLE_WORK_REF_RE2 = /^[a-z0-9-]+(?:\/[a-z0-9-]+)?$/;
1375
- var GROUP_NAME_RE = /^[a-z0-9-]+$/;
1376
- var ARCHIVE_NAME_RE = /^(\d{4}-\d{2}-\d{2})_(.+)\.md$/;
1377
- async function inspectArchiveHistory(options = {}) {
1378
- const archivesDir = options.archivesDir ?? ARCHIVES_DIR;
1379
- const tree = await inspectArchiveTree({ archivesDir });
1380
- const diagnostics = tree.diagnostics.map((diagnostic) => ({
1381
- severity: "error",
1382
- code: diagnostic.code,
1383
- path: normalizeOutputPath(diagnostic.path, archivesDir),
1384
- message: diagnostic.message
1385
- }));
1386
- const runtime = [];
1387
- const records = [];
1388
- if (!tree.rootExists && tree.diagnostics.length === 0) {
1389
- diagnostics.push({
1390
- severity: "error",
1391
- code: "archive_root_missing",
1392
- path: ".rsp/archives",
1393
- message: "archive root does not exist; run rsp init or rsp update before querying history"
1394
- });
1395
- }
1396
- for (const sourcePath of tree.files) {
1397
- const path = archiveOutputPath(sourcePath, archivesDir);
1398
- let content;
1399
- try {
1400
- content = await readFile4(sourcePath, "utf-8");
1401
- } catch (error) {
1402
- const message = toErrorMessage(error);
1403
- diagnostics.push({ severity: "error", code: "archive_read_failed", path, message: `unable to read archived work: ${message}` });
1404
- continue;
1405
- }
1406
- const parsed = parseArchiveEntry(sourcePath, path, archivesDir, content);
1407
- if (parsed.diagnostic)
1408
- diagnostics.push(parsed.diagnostic);
1409
- if (parsed.record)
1410
- records.push(parsed.record);
1411
- }
1412
- records.sort(compareHistoryRecords);
1413
- diagnostics.sort(compareDiagnostics);
1414
- const diagnosticTotal = diagnostics.length;
1415
- const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
1416
- return {
1417
- rootExists: tree.rootExists,
1418
- records,
1419
- diagnostics: boundedDiagnostics,
1420
- diagnosticSummary: {
1421
- total: diagnosticTotal,
1422
- returned: boundedDiagnostics.length,
1423
- hasMore: diagnosticTotal > boundedDiagnostics.length
1424
- },
1425
- runtime
1426
- };
1427
- }
1428
- function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1429
- const nameMatch = basename(sourcePath).match(ARCHIVE_NAME_RE);
1430
- if (!nameMatch || !isCalendarDate(nameMatch[1])) {
1431
- return { diagnostic: errorDiagnostic("archive_name_invalid", path, "archive filename must use a valid YYYY-MM-DD_name.md date") };
1432
- }
1433
- const date = nameMatch[1];
1434
- const archiveStem = nameMatch[2];
1435
- const relativeDirectory = normalizeLogicalPath(relative3(archivesDir, dirname2(sourcePath)));
1436
- const archiveGroup = relativeDirectory === "." || relativeDirectory === "" ? null : relativeDirectory;
1437
- if (archiveGroup && !GROUP_NAME_RE.test(archiveGroup))
1438
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, "archive group path is not a valid one-segment Group identity") };
1439
- const changeHeadings = [...content.matchAll(/^# Change:\s+(\S+)\s*$/gm)].map((match) => match[1]);
1440
- const groupHeadings = [...content.matchAll(/^# Change Group:\s+(\S+)\s*$/gm)].map((match) => match[1]);
1441
- if (changeHeadings.length + groupHeadings.length !== 1) {
1442
- return { diagnostic: errorDiagnostic("archive_heading_invalid", path, "archive must contain exactly one Change or Change Group identity heading") };
1443
- }
1444
- let frontmatter;
1445
- try {
1446
- frontmatter = parseFrontmatter(content);
1447
- } catch (error) {
1448
- return { diagnostic: errorDiagnostic("archive_frontmatter_invalid", path, `archive frontmatter could not be parsed: ${toErrorMessage(error)}`) };
1449
- }
1450
- if (typeof frontmatter?.kind !== "string" || frontmatter.kind.trim() === "")
1451
- return { diagnostic: errorDiagnostic("archive_kind_invalid", path, "archive frontmatter must declare a non-empty kind") };
1452
- if (groupHeadings.length === 1) {
1453
- const group = groupHeadings[0];
1454
- if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
1455
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
1456
- }
1457
- return {};
1458
- }
1459
- const workRef = changeHeadings[0];
1460
- const segments = workRef.split("/");
1461
- if (segments.length === 2 && (segments[1] === "brief" || segments[1] === "00-brief")) {
1462
- return { diagnostic: errorDiagnostic("archive_work_ref_invalid", path, `archived executable Change uses reserved Group Brief identity: ${workRef}`) };
1463
- }
1464
- const expectedGroup = segments.length === 2 ? segments[0] : null;
1465
- const expectedBase = segments.at(-1);
1466
- if (!EXECUTABLE_WORK_REF_RE2.test(workRef) || expectedGroup !== archiveGroup || !matchesArchiveStem(archiveStem, expectedBase)) {
1467
- return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change identity ${workRef} does not match its archive path`) };
1468
- }
1469
- const rawSummary = extractSummary(content);
1470
- if (!rawSummary)
1471
- return { diagnostic: errorDiagnostic("archive_summary_missing", path, "archived Change must contain frontmatter summary, Proposal Outcome, or Proposal Summary") };
1472
- const summary = boundText(rawSummary);
1473
- return {
1474
- record: {
1475
- date,
1476
- workRef,
1477
- group: expectedGroup,
1478
- kind: frontmatter.kind.trim(),
1479
- summary: summary.value,
1480
- summaryTruncated: summary.truncated,
1481
- path,
1482
- sourcePath
1483
- }
1484
- };
1485
- }
1486
- function extractSummary(content) {
1487
- const frontmatter = parseFrontmatter(content);
1488
- if (typeof frontmatter?.summary === "string" && frontmatter.summary.trim())
1489
- return frontmatter.summary.trim();
1490
- const proposalLines = extractSection(content, "Proposal").split("\n").map((line) => line.trim());
1491
- for (const prefix of ["- Outcome:", "- Summary:"]) {
1492
- const line = proposalLines.find((candidate) => candidate.startsWith(prefix));
1493
- if (line && line.slice(prefix.length).trim())
1494
- return line.slice(prefix.length).trim();
1495
- }
1496
- return "";
1497
- }
1498
- function boundText(value) {
1499
- const codePoints = [...value];
1500
- return codePoints.length > HISTORY_MAX_TEXT_CODE_POINTS ? { value: codePoints.slice(0, HISTORY_MAX_TEXT_CODE_POINTS).join(""), truncated: true } : { value, truncated: false };
1501
- }
1502
- function matchesArchiveStem(stem, identityBase) {
1503
- return stem === identityBase || new RegExp(`^${escapeRegExp2(identityBase)}-(?:[2-9]|[1-9]\\d+)$`).test(stem);
1504
- }
1505
- function isCalendarDate(value) {
1506
- const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1507
- return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1508
- }
1509
- function archiveOutputPath(sourcePath, archivesDir) {
1510
- return `.rsp/archives/${normalizeLogicalPath(relative3(archivesDir, sourcePath))}`;
1511
- }
1512
- function normalizeOutputPath(path, archivesDir) {
1513
- const relativePath = normalizeLogicalPath(relative3(archivesDir, path));
1514
- return relativePath === "" ? ".rsp/archives" : `.rsp/archives/${relativePath}`;
1515
- }
1516
- function errorDiagnostic(code, path, message) {
1517
- return { severity: "error", code, path, message };
1518
- }
1519
- function escapeRegExp2(value) {
1520
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1521
- }
1522
- function compareHistoryRecords(left, right) {
1523
- return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1524
- }
1525
- function compareDiagnostics(left, right) {
1526
- return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
1527
- }
1528
-
1529
- // src/history/query.ts
1530
- import { readFile as readFile5 } from "fs/promises";
1531
- function queryArchiveHistory(records, query = {}) {
1532
- validateArchiveHistoryQuery(query);
1533
- const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1534
- 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)).sort(compareHistoryRecords2);
1535
- const selected = matched.slice(0, limit);
1536
- return {
1537
- records: selected.map(toOutputRecord),
1538
- summary: {
1539
- matched: matched.length,
1540
- returned: selected.length,
1541
- hasMore: matched.length > selected.length
1542
- }
1543
- };
1544
- }
1545
- function validateArchiveHistoryQuery(query) {
1546
- const limit = query.limit ?? HISTORY_DEFAULT_LIMIT;
1547
- if (!Number.isInteger(limit) || limit < 1 || limit > HISTORY_MAX_LIMIT)
1548
- throw new ArchiveHistoryError("invalid_history_limit", `history limit must be an integer from 1 through ${HISTORY_MAX_LIMIT}`);
1549
- if (query.since && !isCalendarDate2(query.since))
1550
- throw new ArchiveHistoryError("invalid_history_since", "history since must be a valid YYYY-MM-DD date");
1551
- if (query.until && !isCalendarDate2(query.until))
1552
- throw new ArchiveHistoryError("invalid_history_until", "history until must be a valid YYYY-MM-DD date");
1553
- if (query.since && query.until && query.since > query.until)
1554
- throw new ArchiveHistoryError("invalid_history_date_range", "history since must be on or before until");
1555
- if (query.kind !== void 0 && query.kind.trim() === "")
1556
- throw new ArchiveHistoryError("invalid_history_kind", "history kind must be non-empty");
1557
- if (query.group !== void 0 && !/^[a-z0-9-]+$/.test(query.group))
1558
- throw new ArchiveHistoryError("invalid_history_group", "history group must be one lowercase kebab-case segment");
1559
- }
1560
- function selectArchiveHistoryRecord(records, selector) {
1561
- const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.workRef === selector.workRef);
1562
- const identity = "path" in selector ? selector.path : selector.workRef;
1563
- if (matches.length === 0)
1564
- throw new ArchiveHistoryError("archive_not_found", `archived Change not found: ${identity}`);
1565
- if (matches.length > 1) {
1566
- const candidates = matches.map((record) => record.path).sort();
1567
- throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match WorkRef ${identity}`, candidates);
1568
- }
1569
- return matches[0];
1570
- }
1571
- async function readArchiveHistoryDetail(record) {
1572
- let content;
1573
- try {
1574
- content = await readFile5(record.sourcePath, "utf-8");
1575
- } catch (error) {
1576
- throw new ArchiveHistoryError("archive_read_failed", `unable to read archived Change ${record.path}: ${error instanceof Error ? error.message : String(error)}`);
1577
- }
1578
- const tasks = extractSection(content, "Tasks");
1579
- const verify = extractSection(content, "Verify");
1580
- const blockers = extractSection(content, "Blockers");
1581
- return {
1582
- ...toOutputRecord(record),
1583
- scenarioCount: parseScenarios(content).length,
1584
- checkboxes: {
1585
- tasks: countCheckboxes(tasks),
1586
- verify: countCheckboxes(verify)
1587
- },
1588
- evidence: {
1589
- tasks: boundEvidence(tasks),
1590
- verify: boundEvidence(verify),
1591
- blockers: boundEvidence(blockers, true)
1592
- }
1593
- };
1594
- }
1595
- function historyInspectionComplete(inspection) {
1596
- return inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error");
1597
- }
1598
- function toOutputRecord(record) {
1599
- const { sourcePath: _sourcePath, ...output } = record;
1600
- return output;
1601
- }
1602
- function boundEvidence(section, omitNone = false) {
1603
- const rawItems = section.split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !omitNone || !/^[-*]\s*(?:none)?$/i.test(line) && !/^none$/i.test(line));
1604
- const boundedItems = rawItems.slice(0, HISTORY_MAX_EVIDENCE_ITEMS).map((item) => boundText(item));
1605
- return {
1606
- items: boundedItems.map((item) => item.value),
1607
- truncated: rawItems.length > HISTORY_MAX_EVIDENCE_ITEMS || boundedItems.some((item) => item.truncated)
1608
- };
1609
- }
1610
- function isCalendarDate2(value) {
1611
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
1612
- return false;
1613
- const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
1614
- return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
1615
- }
1616
- function compareHistoryRecords2(left, right) {
1617
- return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1739
+ return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([month, count]) => ({ month, count }));
1618
1740
  }
1619
1741
 
1620
1742
  // src/status/dependency-forest.ts
@@ -1655,6 +1777,8 @@ function projectDependencyForest(plan, requestedRoots = defaultRoots(plan)) {
1655
1777
  export {
1656
1778
  GROUP_BRIEF_FILENAME,
1657
1779
  WorkRefError,
1780
+ normalizeWorkRefSegment,
1781
+ normalizeExecutableWorkRef,
1658
1782
  resolveWorkRef,
1659
1783
  resolveFocusMarkerPath,
1660
1784
  resolveArchiveDirectory,
@@ -1665,7 +1789,6 @@ export {
1665
1789
  generateGroupBriefContent,
1666
1790
  inspectChangeGroups,
1667
1791
  hasArchivedGroupBrief,
1668
- inspectChangeDependencies,
1669
1792
  HISTORY_DEFAULT_LIMIT,
1670
1793
  HISTORY_MAX_LIMIT,
1671
1794
  ArchiveHistoryError,
@@ -1674,6 +1797,7 @@ export {
1674
1797
  selectArchiveHistoryRecord,
1675
1798
  readArchiveHistoryDetail,
1676
1799
  historyInspectionComplete,
1800
+ inspectChangeDependencies,
1677
1801
  inspectProjectStatus,
1678
1802
  projectDependencyForest
1679
1803
  };