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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,17 +34,17 @@ RSP derives the next action from the selected Change, repository evidence, verif
34
34
  ## Quick start
35
35
 
36
36
  ```bash
37
- npx -y @oevery/rsp@3.1.0-beta.4 init
38
- npx -y @oevery/rsp@3.1.0-beta.4 doctor
37
+ npx -y @oevery/rsp@3.1.0-beta.5 init
38
+ npx -y @oevery/rsp@3.1.0-beta.5 doctor
39
39
  ```
40
40
 
41
41
  Recommended bootstrap flow:
42
42
 
43
43
  ```bash
44
- npx -y @oevery/rsp@3.1.0-beta.4 init --with-project-setup
44
+ npx -y @oevery/rsp@3.1.0-beta.5 init --with-project-setup
45
45
  # fill .rsp/changes/project-setup.md
46
46
  # fill .rsp/specs/design.md
47
- npx -y @oevery/rsp@3.1.0-beta.4 doctor
47
+ npx -y @oevery/rsp@3.1.0-beta.5 doctor
48
48
  ```
49
49
 
50
50
  ## Artifact foundation
@@ -89,15 +89,16 @@ npx -y @oevery/rsp@3.1.0-beta.4 doctor
89
89
  - An exact blocker line, `- requires \`<change-work-ref>\`: <reason>`, declares a dependency on another executable Change. Free-form blockers stay external; RSP never guesses edges from prose. Archived prerequisites resolve automatically.
90
90
  - Change names are either flat (`<change>`) or one direct grouped child (`<group>/<change>`). Recursive work directories are rejected.
91
91
  - A Change Group is optional and is the only composite work shape. Its non-executable, non-focusable logical identity `<group>/brief`, physically stored as `<group>/00-brief.md`, owns the shared goal, constraints, declared slices, completion conditions, durable outcomes, and blockers for at least two direct child Changes.
92
- - Create a group before its children. Every grouped Change must be declared by the brief, is focused and archived independently, and includes the brief in its context. With no focus, `status` uses Brief declaration order and current blockers to recommend the first executable slice. `status`, `check`, and `doctor` derive group health and completion without persisting another state; `rsp group close` archives only a completed brief. Archived Group identities cannot be reopened.
92
+ - Create a group before its children. Every grouped Change must be declared by the brief, is focused and archived independently, and includes the brief in its context. With no focus, `status` uses Brief declaration order and current blockers to recommend the first executable slice. `status`, `check`, and `doctor` derive group health and completion without persisting another state; `rsp group close` archives only a completed brief. `rsp group create` never reuses an archived identity; `rsp group reopen <group> --reason <text>` explicitly restores one retained Brief snapshot as unfinished, unfocused continuation of that same Group.
93
93
  - A Group Brief blocker is inherited by its direct children as an external blocker in the derived execution plan; it does not create guessed dependency edges.
94
94
  - A Markdown file and directory cannot claim the same work identity.
95
95
  - `rsp status`, `rsp check`, and `rsp doctor` inspect the same complete work tree and dependency facts. `status` derives exact edges with their reasons, ready work, blockers, and stable waves without a graph file; `check` and `doctor` reject incomplete archive inspection, malformed or missing targets, self-dependencies, and cycles. The `changes/` root must exist as a real directory, and existing focus/archive roots and group prefixes must also be real directories. Unsupported directories, non-Markdown entries, symlinks, missing or unreadable current work, incomplete reads, and identity collisions are visible errors. `status` exits non-zero instead of hiding invalid work.
96
96
  - `rsp init`, `rsp update`, `rsp add spec`, and the generated Specs index builder apply the same no-follow managed-path checks. Archive discovery accepts only flat archive files or one real group directory; recursively organized Specs accept only real directories and regular files.
97
97
  - Final managed files—such as the project `AGENTS.md`, focus markers, fallback/config files, the generated Specs index, and placeholders—must also be regular files; RSP rejects static symlink targets before reading or writing them.
98
98
  - Completed changes move to `archives/`. Stable current facts belong in Specs; lasting rationale belongs in Decision Records; stable scoped operating instructions belong in nearest project-owned `AGENTS.md`.
99
- - When later evidence shows that the original acceptance was not actually met, `rsp reopen <work-ref> --reason <text>` restores the same executable WorkRef as focused open work and retains the selected archive as history. Multiple archives require an exact `--from .rsp/archives/...` path. Use a new corrective Change for genuinely new scope or an independently delivered correction; reopening never rewrites Git or published history.
99
+ - When later evidence shows that the original acceptance was not actually met, `rsp reopen <work-ref> --reason <text>` restores the same executable WorkRef as focused open work and retains the selected archive as history. If its Group is closed, first use `rsp group reopen <group> --reason <text>`, then reopen only the incomplete child. Multiple matching Change or Group archives require an exact `--from .rsp/archives/...` path. Group reopen creates no focus and never reopens children or dependents. Use a new corrective Change for genuinely new scope or an independently delivered correction; reopening never rewrites Git or published history.
100
100
  - Do not promote task history, debugging notes, or one-off implementation context into Specs, Decision Records, or project instructions.
101
+ - Managed progress questions report current evidence and do not implicitly pause an authorized run. Explicit pause confirms active work has stopped but preserves the focused WorkRef; only explicit release or `rsp unfocus` clears selection. Blocked or paused runs return a response-only continuation, and resume rereads authority, status, diff, blockers, and verification before requalifying Manage. RSP persists no paused or controller state.
101
102
  - Change `Spec` delta markers (`### ADDED`, `### MODIFIED`, `### REMOVED`) are planning aids only. `rsp archive` does not automatically promote them into Specs or Decision Records. Durable writeback remains an explicit semantic decision.
102
103
  - `rsp check` performs deterministic hygiene checks. It warns about unfinished template placeholders and unresolved clarification markers, but those warnings do not replace the semantic durable-update decision.
103
104
 
@@ -204,7 +205,7 @@ New `rsp init` config templates use the shown `auto` plus `lifecycle` policy. If
204
205
 
205
206
  Humans should start with this README. Agents should follow the nearest `AGENTS.md`, prefer `skills/rsp/SKILL.md`, and use `.rsp/rsp-rules.md` only when the Skill is unavailable.
206
207
 
207
- When this README shows `rsp <command>`, it assumes the command is already available in your environment. For opt-in beta evaluation, pin the exact prerelease identity, such as `npx -y @oevery/rsp@3.1.0-beta.4 <command>`; stable users can keep using the unversioned package entrypoint for npm `latest`.
208
+ When this README shows `rsp <command>`, it assumes the command is already available in your environment. For opt-in beta evaluation, pin the exact prerelease identity, such as `npx -y @oevery/rsp@3.1.0-beta.5 <command>`; stable users can keep using the unversioned package entrypoint for npm `latest`.
208
209
 
209
210
  Install the exact package's default lifecycle suite into the current project:
210
211
 
@@ -232,8 +233,8 @@ rsp skills install rsp-structural-audit
232
233
  Both forms install from the package that invoked `rsp`, so prerelease dogfooding can pin one exact npm identity:
233
234
 
234
235
  ```bash
235
- npx -y @oevery/rsp@3.1.0-beta.4 skills install --dry-run
236
- npx -y @oevery/rsp@3.1.0-beta.4 skills install
236
+ npx -y @oevery/rsp@3.1.0-beta.5 skills install --dry-run
237
+ npx -y @oevery/rsp@3.1.0-beta.5 skills install
237
238
  ```
238
239
 
239
240
  Exact prerelease identities avoid depending on a moving dist-tag. `rsp update` refreshes RSP-managed project files only; run `rsp skills install` separately to refresh the package-owned Skill suite.
@@ -287,8 +288,8 @@ Durable review makes two independent semantic choices: whether current facts or
287
288
 
288
289
  New project:
289
290
 
290
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init`
291
- 2. Prefer `npx -y @oevery/rsp@3.1.0-beta.4 init --with-project-setup`, or run `rsp create project-setup` manually
291
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init`
292
+ 2. Prefer `npx -y @oevery/rsp@3.1.0-beta.5 init --with-project-setup`, or run `rsp create project-setup` manually
292
293
  3. Fill `.rsp/specs/design.md`
293
294
  4. Use `rsp add spec <name>` only when a new durable project doc is needed
294
295
  5. Keep lasting rationale in the configured Decision Record directory and stable scoped operating instructions in the nearest project-owned `AGENTS.md`
@@ -302,14 +303,14 @@ New project:
302
303
 
303
304
  Existing project with a rich `AGENTS.md`:
304
305
 
305
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init`
306
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init`
306
307
  2. Keep the managed block thin
307
308
  3. Keep project-wide boundaries and navigation in `.rsp/specs/design.md`, and route cohesive durable facts to the smallest domain Spec
308
309
  4. Use `rsp add spec <name>` only when another durable current-fact document is needed
309
310
 
310
311
  AI-assisted setup:
311
312
 
312
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init --agents-mode print --with-project-setup`
313
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init --agents-mode print --with-project-setup`
313
314
  2. Keep the managed block as generated and adapt only the surrounding human-owned content if needed
314
315
  3. Have the AI review and fill `.rsp/changes/project-setup.md`
315
316
  4. Have the AI fill `.rsp/specs/design.md`
@@ -327,9 +328,11 @@ rsp skills list [--json] List bundled Skills and exact project installati
327
328
  rsp skills install [name] [--dry-run] [--force]
328
329
  Install the default eleven Skills or one exact optional Skill into .agents/skills
329
330
  rsp add spec <name> Create .rsp/specs/<name>.md and rebuild specs index
330
- rsp create <name> [summary] Create .rsp/changes/<name>.md; add --lite for a shorter template
331
+ rsp create <name> [summary] Create .rsp/changes/<name>.md; add --lite or --issue <url> [--issue-relation relates|closes]
331
332
  rsp group create <name> [goal] Create an unfocused .rsp/changes/<name>/00-brief.md
332
333
  rsp group close <name> Archive a completed Group Brief after every child is archived
334
+ rsp group reopen <name> --reason <text> [--from <archive-path>]
335
+ Restore the same Group Brief as unfinished, unfocused open work and retain history
333
336
  rsp focus <name> Mark an open change as currently focused
334
337
  rsp unfocus <name> Remove an open change from the current focus set
335
338
  rsp archive <name> Archive to .rsp/archives/
@@ -378,6 +381,8 @@ The JSON-producing inspection commands `status`, `show`, `ready`, `check`, `doct
378
381
 
379
382
  `rsp create --lite` is a shorter template for explicitly tracked small changes; simple current-session tasks should not create RSP changes unless tracking is intentionally needed.
380
383
 
384
+ `rsp create <name> --issue <url>` attaches one validated external issue without network access and defaults to `relation: relates`; `--issue-relation closes` must be explicit and requires `--issue`. Issue options never update an existing Change: that invocation fails without changing the Change or focus. RSP stores only the canonical URL and relationship, never credentials, fetched title/body, or retrieval state. `status --json`, `show`, and `history` preserve valid relationships; immutable archives with older issue metadata remain readable but omit that opaque legacy projection. Shape may use available host access to retrieve real issue content, but failed access degrades to login retry, pasted source data, or a link-only draft rather than fabrication. Commit may add a closing keyword only for a terminal commit with completed acceptance and an owned `closes` relationship; otherwise it keeps a non-closing canonical URL reference.
385
+
381
386
  `rsp doctor --fix` runs only safe deterministic repairs. Its `fixed` JSON entries report actual filesystem changes; a healthy project returns `fixed: []` and the human output says no safe fixes are needed.
382
387
 
383
388
  ## Platform-agnostic
package/README.zh-CN.md CHANGED
@@ -34,17 +34,17 @@ RSP 根据 selected Change、仓库证据、验证结果和 blockers 推导下
34
34
  ## 快速开始
35
35
 
36
36
  ```bash
37
- npx -y @oevery/rsp@3.1.0-beta.4 init
38
- npx -y @oevery/rsp@3.1.0-beta.4 doctor
37
+ npx -y @oevery/rsp@3.1.0-beta.5 init
38
+ npx -y @oevery/rsp@3.1.0-beta.5 doctor
39
39
  ```
40
40
 
41
41
  推荐启动流程:
42
42
 
43
43
  ```bash
44
- npx -y @oevery/rsp@3.1.0-beta.4 init --with-project-setup
44
+ npx -y @oevery/rsp@3.1.0-beta.5 init --with-project-setup
45
45
  # 填写 .rsp/changes/project-setup.md
46
46
  # 填写 .rsp/specs/design.md
47
- npx -y @oevery/rsp@3.1.0-beta.4 doctor
47
+ npx -y @oevery/rsp@3.1.0-beta.5 doctor
48
48
  ```
49
49
 
50
50
  ## Artifact foundation
@@ -203,7 +203,7 @@ Change 粒度由一个可观察结果及其共享的 acceptance、verification
203
203
 
204
204
  人类应从本 README 开始;agent 应遵循 nearest `AGENTS.md`,优先加载 `skills/rsp/SKILL.md`,仅在 Skill 不可用时读取 `.rsp/rsp-rules.md`。
205
205
 
206
- 如果文档中写的是 `rsp <command>`,默认前提是你的环境里已经能直接运行 `rsp`。进行 opt-in beta 评估时,应固定精确 prerelease 身份,例如 `npx -y @oevery/rsp@3.1.0-beta.4 <command>`;stable 用户仍可使用不带版本的入口获取 npm `latest`。
206
+ 如果文档中写的是 `rsp <command>`,默认前提是你的环境里已经能直接运行 `rsp`。进行 opt-in beta 评估时,应固定精确 prerelease 身份,例如 `npx -y @oevery/rsp@3.1.0-beta.5 <command>`;stable 用户仍可使用不带版本的入口获取 npm `latest`。
207
207
 
208
208
  将当前精确包内置的默认生命周期 Skill 套件安装到当前项目:
209
209
 
@@ -231,8 +231,8 @@ rsp skills install rsp-structural-audit
231
231
  两种形式都从调用 `rsp` 的同一个包安装,因此 beta 评估可以固定精确 npm 身份:
232
232
 
233
233
  ```bash
234
- npx -y @oevery/rsp@3.1.0-beta.4 skills install --dry-run
235
- npx -y @oevery/rsp@3.1.0-beta.4 skills install
234
+ npx -y @oevery/rsp@3.1.0-beta.5 skills install --dry-run
235
+ npx -y @oevery/rsp@3.1.0-beta.5 skills install
236
236
  ```
237
237
 
238
238
  精确 prerelease 身份可以避免依赖持续移动的 dist-tag。`rsp update` 只刷新由 RSP 管理的项目文件;刷新 package-owned Skill 套件需要单独运行 `rsp skills install`。
@@ -286,8 +286,8 @@ durable review 包含两个独立语义判断:是否更新当前事实或作
286
286
 
287
287
  新项目:
288
288
 
289
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init`
290
- 2. 优先使用 `npx -y @oevery/rsp@3.1.0-beta.4 init --with-project-setup`,或手动执行 `rsp create project-setup`
289
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init`
290
+ 2. 优先使用 `npx -y @oevery/rsp@3.1.0-beta.5 init --with-project-setup`,或手动执行 `rsp create project-setup`
291
291
  3. 填写 `.rsp/specs/design.md`
292
292
  4. 仅在需要新的长期项目文档时使用 `rsp add spec <name>`
293
293
  5. 将长期理由写入配置的 Decision Record 目录,将稳定的作用域操作指令写入 nearest project-owned `AGENTS.md`
@@ -300,14 +300,14 @@ durable review 包含两个独立语义判断:是否更新当前事实或作
300
300
 
301
301
  已有复杂 `AGENTS.md` 的项目:
302
302
 
303
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init`
303
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init`
304
304
  2. 保持受管块尽量薄
305
305
  3. 将项目级边界与导航放在 `.rsp/specs/design.md`,把成组的长期事实放入最小领域 Spec
306
306
  4. 只有需要额外的 durable current-fact 文档时才使用 `rsp add spec <name>`
307
307
 
308
308
  AI 协助接入:
309
309
 
310
- 1. `npx -y @oevery/rsp@3.1.0-beta.4 init --agents-mode print --with-project-setup`
310
+ 1. `npx -y @oevery/rsp@3.1.0-beta.5 init --agents-mode print --with-project-setup`
311
311
  2. 保持受管块原样,只在需要时调整周围由人维护的内容
312
312
  3. 让 AI 审阅并填写 `.rsp/changes/project-setup.md`
313
313
  4. 让 AI 填写 `.rsp/specs/design.md`
@@ -5,7 +5,7 @@ import {
5
5
  inspectManagedDirectory,
6
6
  inspectManagedFileTree,
7
7
  resolveManagedDirectoryChain
8
- } from "./chunk-FJVH7ZYU.mjs";
8
+ } from "./chunk-UVYEXKYS.mjs";
9
9
 
10
10
  // src/commands/skills.ts
11
11
  import { randomUUID } from "crypto";
@@ -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";
@@ -29,7 +29,7 @@ import {
29
29
  resolveLanguagePolicy,
30
30
  resolveManagePolicy,
31
31
  toErrorMessage
32
- } from "./chunk-FJVH7ZYU.mjs";
32
+ } from "./chunk-UVYEXKYS.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,