@oevery/rsp 3.1.0-beta.4 → 3.1.1

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.
@@ -29,7 +29,7 @@ import {
29
29
  resolveLanguagePolicy,
30
30
  resolveManagePolicy,
31
31
  toErrorMessage
32
- } from "./chunk-FJVH7ZYU.mjs";
32
+ } from "./chunk-WJXKLLIF.mjs";
33
33
 
34
34
  // src/status/inspect.ts
35
35
  import { readFile as readFile5, stat } from "fs/promises";
@@ -666,6 +666,7 @@ function normalizeLogicalPath2(path) {
666
666
 
667
667
  // src/core/change-group.ts
668
668
  var GROUP_BRIEF_SECTIONS = getCanonicalSectionHeadings(GROUP_BRIEF_DOCUMENT_SCHEMA);
669
+ var GROUP_REOPEN_COMPLETION_PREFIX = "Resolve reopened concern from";
669
670
  async function resolveExecutableChange(name, options = {}) {
670
671
  const ref = resolveWorkRef(name, { ...options, executable: true });
671
672
  if (ref.kind === "group-change")
@@ -711,6 +712,8 @@ async function inspectChangeGroups(options = {}) {
711
712
  const groups = [];
712
713
  const archivedSlicesByGroup = /* @__PURE__ */ new Map();
713
714
  const archivedGroups = /* @__PURE__ */ new Set();
715
+ const archivedGroupReopenEvidence = /* @__PURE__ */ new Map();
716
+ const archivedGroupBriefPaths = /* @__PURE__ */ new Map();
714
717
  const openGroupNames = new Set(workTree.briefs.map((brief) => brief.group));
715
718
  for (const path of archiveTree.files) {
716
719
  const archivePath = normalizeLogicalPath(relative2(ARCHIVES_DIR, path));
@@ -736,8 +739,16 @@ async function inspectChangeGroups(options = {}) {
736
739
  archivedSlicesByGroup.set(archiveGroup, groupSlices);
737
740
  }
738
741
  }
739
- if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup)
742
+ if (getDocumentTitle(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "identity") === archiveGroup) {
740
743
  archivedGroups.add(archiveGroup);
744
+ const evidence = archivedGroupReopenEvidence.get(archiveGroup) ?? /* @__PURE__ */ new Set();
745
+ for (const item of getGroupReopenEvidence(content))
746
+ evidence.add(item.key);
747
+ archivedGroupReopenEvidence.set(archiveGroup, evidence);
748
+ const retainedPaths = archivedGroupBriefPaths.get(archiveGroup) ?? /* @__PURE__ */ new Set();
749
+ retainedPaths.add(`.rsp/archives/${archivePath}`);
750
+ archivedGroupBriefPaths.set(archiveGroup, retainedPaths);
751
+ }
741
752
  } catch (error) {
742
753
  diagnostics.push({
743
754
  severity: "error",
@@ -764,7 +775,11 @@ async function inspectChangeGroups(options = {}) {
764
775
  }
765
776
  const parsed = parseGroupBrief(brief.group, content);
766
777
  diagnostics.push(...parsed.diagnostics.map((diagnostic) => ({ ...diagnostic, path: brief.path })));
767
- if (archivedGroups.has(brief.group)) {
778
+ if (archivedGroups.has(brief.group) && !hasExplicitGroupReopenEvidence(
779
+ content,
780
+ archivedGroupReopenEvidence.get(brief.group) ?? /* @__PURE__ */ new Set(),
781
+ archivedGroupBriefPaths.get(brief.group) ?? /* @__PURE__ */ new Set()
782
+ )) {
768
783
  diagnostics.push({
769
784
  severity: "error",
770
785
  code: "group_identity_reopened",
@@ -855,6 +870,16 @@ async function inspectChangeGroups(options = {}) {
855
870
  groups.sort((left, right) => left.name.localeCompare(right.name));
856
871
  return { groups, diagnostics };
857
872
  }
873
+ function getGroupReopenEvidence(content) {
874
+ const completion = getDocumentSectionBody(parseRspDocument(content, GROUP_BRIEF_DOCUMENT_SCHEMA), "completionConditions");
875
+ return completion.split("\n").flatMap((line) => {
876
+ const match = line.trim().match(/^-\s+\[([ /xX-])\]\s+(Resolve reopened concern from `(\.rsp\/archives\/[^`]+)`: \S.*)$/);
877
+ return match ? [{ active: match[1] !== "-", key: match[2], sourcePath: match[3] }] : [];
878
+ });
879
+ }
880
+ function hasExplicitGroupReopenEvidence(content, retainedEvidence, retainedGroupBriefPaths) {
881
+ return getGroupReopenEvidence(content).some((item) => item.active && retainedGroupBriefPaths.has(item.sourcePath) && !retainedEvidence.has(item.key));
882
+ }
858
883
  async function requireDeclaredGroupSlice(ref) {
859
884
  const briefPath = join2(dirname(ref.path), GROUP_BRIEF_FILENAME);
860
885
  let content;
@@ -975,6 +1000,69 @@ import { readFile as readFile3 } from "fs/promises";
975
1000
  import { readFile as readFile2 } from "fs/promises";
976
1001
  import { basename, dirname as dirname2, relative as relative3 } from "path";
977
1002
 
1003
+ // src/core/issue-relationship.ts
1004
+ var ISSUE_RELATIONS = /* @__PURE__ */ new Set(["relates", "closes"]);
1005
+ var ISSUE_FIELDS = /* @__PURE__ */ new Set(["url", "relation"]);
1006
+ var IssueRelationshipError = class extends Error {
1007
+ constructor(code, message) {
1008
+ super(message);
1009
+ this.code = code;
1010
+ this.name = "IssueRelationshipError";
1011
+ }
1012
+ code;
1013
+ };
1014
+ function parseIssueRelationships(frontmatter) {
1015
+ if (!frontmatter || !("issues" in frontmatter))
1016
+ return [];
1017
+ if (!Array.isArray(frontmatter.issues))
1018
+ throw new IssueRelationshipError("invalid_issue_shape", 'frontmatter field "issues" must be a list');
1019
+ const seen = /* @__PURE__ */ new Set();
1020
+ return frontmatter.issues.map((value, index) => {
1021
+ if (!isPlainObject(value))
1022
+ throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must be a mapping with url and relation`);
1023
+ const fields = Object.keys(value);
1024
+ const unsupported = fields.filter((field) => !ISSUE_FIELDS.has(field));
1025
+ if (unsupported.length > 0)
1026
+ throw new IssueRelationshipError("unsupported_issue_field", `issues[${index}] contains unsupported field(s): ${unsupported.sort().join(", ")}`);
1027
+ if (fields.length !== 2 || !("url" in value) || !("relation" in value))
1028
+ throw new IssueRelationshipError("invalid_issue_shape", `issues[${index}] must contain exactly url and relation`);
1029
+ if (typeof value.url !== "string")
1030
+ throw new IssueRelationshipError("invalid_issue_url", `issues[${index}].url must be an absolute http or https URL`);
1031
+ if (typeof value.relation !== "string" || !ISSUE_RELATIONS.has(value.relation))
1032
+ throw new IssueRelationshipError("unsupported_issue_relation", `issues[${index}].relation must be relates or closes`);
1033
+ const url = normalizeIssueUrl(value.url, `issues[${index}].url`);
1034
+ if (seen.has(url))
1035
+ throw new IssueRelationshipError("duplicate_issue_url", `issues contains duplicate normalized URL: ${url}`);
1036
+ seen.add(url);
1037
+ return { url, relation: value.relation };
1038
+ });
1039
+ }
1040
+ function createIssueRelationship(url, relation = "relates") {
1041
+ return parseIssueRelationships({ issues: [{ url, relation }] })[0];
1042
+ }
1043
+ function normalizeIssueUrl(raw, label) {
1044
+ if (raw.trim() !== raw || raw === "")
1045
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL without surrounding whitespace`);
1046
+ let parsed;
1047
+ try {
1048
+ parsed = new URL(raw);
1049
+ } catch {
1050
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must be an absolute http or https URL`);
1051
+ }
1052
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
1053
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must use http or https`);
1054
+ if (!parsed.hostname)
1055
+ throw new IssueRelationshipError("invalid_issue_url", `${label} must include a host`);
1056
+ if (parsed.username || parsed.password)
1057
+ throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain credentials`);
1058
+ if (parsed.hash)
1059
+ throw new IssueRelationshipError("unsafe_issue_url", `${label} must not contain a fragment`);
1060
+ return parsed.toString();
1061
+ }
1062
+ function isPlainObject(value) {
1063
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1064
+ }
1065
+
978
1066
  // src/history/model.ts
979
1067
  var HISTORY_DEFAULT_LIMIT = 20;
980
1068
  var HISTORY_MAX_LIMIT = 100;
@@ -1011,6 +1099,7 @@ async function inspectArchiveHistory(options = {}) {
1011
1099
  }));
1012
1100
  const runtime = [];
1013
1101
  const records = [];
1102
+ const groupBriefs = [];
1014
1103
  if (!tree.rootExists && tree.diagnostics.length === 0) {
1015
1104
  diagnostics.push({
1016
1105
  severity: "error",
@@ -1034,14 +1123,18 @@ async function inspectArchiveHistory(options = {}) {
1034
1123
  diagnostics.push(parsed.diagnostic);
1035
1124
  if (parsed.record)
1036
1125
  records.push(parsed.record);
1126
+ if (parsed.groupBrief)
1127
+ groupBriefs.push(parsed.groupBrief);
1037
1128
  }
1038
1129
  records.sort(compareHistoryRecords);
1130
+ groupBriefs.sort(compareArchivedGroupBriefs);
1039
1131
  diagnostics.sort(compareDiagnostics);
1040
1132
  const diagnosticTotal = diagnostics.length;
1041
1133
  const boundedDiagnostics = diagnostics.slice(0, HISTORY_MAX_DIAGNOSTICS);
1042
1134
  return {
1043
1135
  rootExists: tree.rootExists,
1044
1136
  records,
1137
+ groupBriefs,
1045
1138
  diagnostics: boundedDiagnostics,
1046
1139
  diagnosticSummary: {
1047
1140
  total: diagnosticTotal,
@@ -1082,7 +1175,15 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1082
1175
  if (!archiveGroup || group !== archiveGroup || frontmatter.kind !== "group" || !matchesArchiveStem(archiveStem, "brief")) {
1083
1176
  return { diagnostic: errorDiagnostic("archive_identity_mismatch", path, `archived Change Group identity ${group} does not match its archive path`) };
1084
1177
  }
1085
- return {};
1178
+ return { groupBrief: { date, group, path, sourcePath } };
1179
+ }
1180
+ let issues;
1181
+ if ("issues" in frontmatter) {
1182
+ try {
1183
+ issues = parseIssueRelationships(frontmatter);
1184
+ } catch {
1185
+ issues = void 0;
1186
+ }
1086
1187
  }
1087
1188
  const workRef = changeHeadings[0];
1088
1189
  const segments = workRef.split("/");
@@ -1107,6 +1208,7 @@ function parseArchiveEntry(sourcePath, path, archivesDir, content) {
1107
1208
  summary: summary.value,
1108
1209
  summaryTruncated: summary.truncated,
1109
1210
  path,
1211
+ ...issues === void 0 ? {} : { issues },
1110
1212
  sourcePath,
1111
1213
  searchSummary: rawSummary
1112
1214
  }
@@ -1151,6 +1253,9 @@ function escapeRegExp(value) {
1151
1253
  function compareHistoryRecords(left, right) {
1152
1254
  return right.date.localeCompare(left.date) || left.workRef.localeCompare(right.workRef) || left.path.localeCompare(right.path);
1153
1255
  }
1256
+ function compareArchivedGroupBriefs(left, right) {
1257
+ return right.date.localeCompare(left.date) || left.group.localeCompare(right.group) || left.path.localeCompare(right.path);
1258
+ }
1154
1259
  function compareDiagnostics(left, right) {
1155
1260
  return (left.path ?? "").localeCompare(right.path ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message);
1156
1261
  }
@@ -1205,6 +1310,17 @@ function selectArchiveHistoryRecord(records, selector) {
1205
1310
  }
1206
1311
  return matches[0];
1207
1312
  }
1313
+ function selectArchivedGroupBrief(records, selector) {
1314
+ const matches = "path" in selector ? records.filter((record) => record.path === selector.path) : records.filter((record) => record.group === selector.group);
1315
+ const identity = "path" in selector ? selector.path : selector.group;
1316
+ if (matches.length === 0)
1317
+ throw new ArchiveHistoryError("archive_not_found", `archived Change Group not found: ${identity}`);
1318
+ if (matches.length > 1) {
1319
+ const candidates = matches.map((record) => record.path).sort();
1320
+ throw new ArchiveHistoryError("archive_ambiguous", `multiple archives match Change Group ${identity}`, candidates);
1321
+ }
1322
+ return matches[0];
1323
+ }
1208
1324
  async function readArchiveHistoryDetail(record) {
1209
1325
  let content;
1210
1326
  try {
@@ -1600,6 +1716,7 @@ async function inspectProjectStatus(options = {}) {
1600
1716
  let done = 0;
1601
1717
  let total = 0;
1602
1718
  let progressKnown = false;
1719
+ let issues = [];
1603
1720
  if (path) {
1604
1721
  try {
1605
1722
  const content = await readFile5(path, "utf-8");
@@ -1608,10 +1725,21 @@ async function inspectProjectStatus(options = {}) {
1608
1725
  try {
1609
1726
  const frontmatter = parseFrontmatter(content);
1610
1727
  kind = frontmatter?.kind ? String(frontmatter.kind) : "\u2014";
1728
+ issues = parseIssueRelationships(frontmatter);
1611
1729
  } catch (error) {
1612
1730
  kind = "(invalid)";
1731
+ const issueError = error instanceof IssueRelationshipError;
1732
+ if (issueError) {
1733
+ diagnostics.push({
1734
+ severity: "error",
1735
+ code: error.code,
1736
+ change: name,
1737
+ path,
1738
+ message: error.message
1739
+ });
1740
+ }
1613
1741
  runtime.push({
1614
- code: "frontmatter_parse_failed",
1742
+ code: issueError ? error.code : "frontmatter_parse_failed",
1615
1743
  operation: "parseFrontmatter",
1616
1744
  path,
1617
1745
  message: toErrorMessage(error)
@@ -1692,7 +1820,8 @@ async function inspectProjectStatus(options = {}) {
1692
1820
  ageDays,
1693
1821
  isFocused,
1694
1822
  isBlocked,
1695
- path: path ? normalizeLogicalPath(path) : null
1823
+ path: path ? normalizeLogicalPath(path) : null,
1824
+ issues
1696
1825
  },
1697
1826
  progressKnown,
1698
1827
  title,
@@ -1785,16 +1914,22 @@ export {
1785
1914
  inspectFocusTree,
1786
1915
  inspectArchiveTree,
1787
1916
  inspectWorkTree,
1917
+ GROUP_REOPEN_COMPLETION_PREFIX,
1788
1918
  resolveExecutableChange,
1789
1919
  generateGroupBriefContent,
1790
1920
  inspectChangeGroups,
1921
+ getGroupReopenEvidence,
1791
1922
  hasArchivedGroupBrief,
1923
+ IssueRelationshipError,
1924
+ parseIssueRelationships,
1925
+ createIssueRelationship,
1792
1926
  HISTORY_DEFAULT_LIMIT,
1793
1927
  HISTORY_MAX_LIMIT,
1794
1928
  ArchiveHistoryError,
1795
1929
  inspectArchiveHistory,
1796
1930
  queryArchiveHistory,
1797
1931
  selectArchiveHistoryRecord,
1932
+ selectArchivedGroupBrief,
1798
1933
  readArchiveHistoryDetail,
1799
1934
  historyInspectionComplete,
1800
1935
  inspectChangeDependencies,
@@ -472,10 +472,11 @@ function normalizeLogicalPath(pathValue) {
472
472
  function generateChangeContent(name, summary = "", kind, options = {}) {
473
473
  const placeholder = "<\u2026>";
474
474
  const proposalSummary = summary || placeholder;
475
+ const issueFrontmatter = renderIssueFrontmatter(options.issues ?? []);
475
476
  if (name === "project-setup") {
476
477
  return `---
477
478
  kind: ops
478
- ---
479
+ ${issueFrontmatter}---
479
480
 
480
481
  ${renderDocumentTitle(CHANGE_DOCUMENT_SCHEMA, "project-setup")}
481
482
 
@@ -533,11 +534,11 @@ ${changeSectionHeading("blockers")}
533
534
  }
534
535
  const frontmatterKind = kind ?? "<choose: feature | fix | refactor | docs | ops | research>";
535
536
  if (options.lite)
536
- return generateLiteChangeContent(name, proposalSummary, frontmatterKind);
537
+ return generateLiteChangeContent(name, proposalSummary, frontmatterKind, issueFrontmatter);
537
538
  const template = getChangeTemplateByKind(kind);
538
539
  return `---
539
540
  kind: "${frontmatterKind}"
540
- ---
541
+ ${issueFrontmatter}---
541
542
 
542
543
  ${renderDocumentTitle(CHANGE_DOCUMENT_SCHEMA, name)}
543
544
 
@@ -582,11 +583,11 @@ ${changeSectionHeading("blockers")}
582
583
  - none
583
584
  `;
584
585
  }
585
- function generateLiteChangeContent(name, proposalSummary, frontmatterKind) {
586
+ function generateLiteChangeContent(name, proposalSummary, frontmatterKind, issueFrontmatter) {
586
587
  const placeholder = "<\u2026>";
587
588
  return `---
588
589
  kind: "${frontmatterKind}"
589
- ---
590
+ ${issueFrontmatter}---
590
591
 
591
592
  ${renderDocumentTitle(CHANGE_DOCUMENT_SCHEMA, name)}
592
593
 
@@ -635,6 +636,14 @@ ${changeSectionHeading("blockers")}
635
636
  - none
636
637
  `;
637
638
  }
639
+ function renderIssueFrontmatter(issues) {
640
+ if (issues.length === 0)
641
+ return "";
642
+ return `issues:
643
+ ${issues.map((issue) => ` - url: ${JSON.stringify(issue.url)}
644
+ relation: ${issue.relation}`).join("\n")}
645
+ `;
646
+ }
638
647
  function getChangeTemplateByKind(kind) {
639
648
  const placeholder = "<\u2026>";
640
649
  const delta = kind === "feature" || kind === "research" || kind === void 0 ? "ADDED" : "MODIFIED";
@@ -642,8 +651,7 @@ function getChangeTemplateByKind(kind) {
642
651
  why: placeholder,
643
652
  scope: placeholder,
644
653
  nonGoals: placeholder,
645
- specSection: `<!-- ${placeholder} -->
646
- ### ${delta}
654
+ specSection: `### ${delta}
647
655
  - Requirement: ${placeholder}
648
656
  - ${placeholder}`,
649
657
  acceptanceSection: `#### Scenario: ${placeholder}