reffy-cli 0.7.0 → 1.0.0

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/dist/plan.js ADDED
@@ -0,0 +1,298 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_PLANNING_DIRNAME } from "./planning-paths.js";
4
+ function normalizeKebab(value) {
5
+ return value
6
+ .trim()
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/^-+|-+$/g, "");
10
+ }
11
+ function deriveCapabilityId(changeId) {
12
+ return changeId.replace(/^(add|update|remove|refactor)-/, "") || changeId;
13
+ }
14
+ function deriveTitle(changeId) {
15
+ return changeId
16
+ .split("-")
17
+ .filter(Boolean)
18
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
19
+ .join(" ");
20
+ }
21
+ function deriveRequirementTitle(title) {
22
+ return title.replace(/^Add\s+/i, "").replace(/^Update\s+/i, "").replace(/^Refactor\s+/i, "").trim() || title;
23
+ }
24
+ function pushUnique(list, value) {
25
+ const next = value.trim().replace(/\s+/g, " ");
26
+ if (!next)
27
+ return;
28
+ if (!list.includes(next))
29
+ list.push(next);
30
+ }
31
+ function collectSignals(artifactInputs) {
32
+ const signals = {
33
+ problems: [],
34
+ proposedFeatures: [],
35
+ openQuestions: [],
36
+ acceptanceCriteria: [],
37
+ };
38
+ for (const artifact of artifactInputs) {
39
+ for (const item of artifact.problem)
40
+ pushUnique(signals.problems, item);
41
+ for (const item of artifact.proposedFeatures)
42
+ pushUnique(signals.proposedFeatures, item);
43
+ for (const item of artifact.openQuestions)
44
+ pushUnique(signals.openQuestions, item);
45
+ for (const item of artifact.acceptanceCriteria)
46
+ pushUnique(signals.acceptanceCriteria, item);
47
+ }
48
+ return signals;
49
+ }
50
+ function lowerInitial(text) {
51
+ if (!text)
52
+ return text;
53
+ return `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
54
+ }
55
+ function toShallStatement(value) {
56
+ const trimmed = value.trim().replace(/\.$/, "");
57
+ if (!trimmed) {
58
+ return "The system SHALL implement the approved behavior described by this change.";
59
+ }
60
+ if (/^the system shall\b/i.test(trimmed)) {
61
+ return trimmed.replace(/^the system shall\b/i, "The system SHALL").replace(/\.$/, "") + ".";
62
+ }
63
+ return `The system SHALL ${lowerInitial(trimmed)}.`;
64
+ }
65
+ function buildWhyText(signals, artifactCount) {
66
+ if (signals.problems.length === 0) {
67
+ return "This change is being scaffolded from indexed Reffy artifacts so the implementation plan stays connected to the ideation context captured in `.reffy/`.";
68
+ }
69
+ const summary = signals.problems.slice(0, 3).join(" ");
70
+ if (artifactCount > 1) {
71
+ return `${summary} This proposal synthesizes planning context from ${String(artifactCount)} related Reffy artifacts.`;
72
+ }
73
+ return summary;
74
+ }
75
+ function buildImpactText(capabilityId, signals) {
76
+ const codeAreas = [];
77
+ for (const item of signals.proposedFeatures) {
78
+ const lowered = item.toLowerCase();
79
+ if (lowered.includes("proposal"))
80
+ pushUnique(codeAreas, "planning scaffold generation");
81
+ if (lowered.includes("task"))
82
+ pushUnique(codeAreas, "task scaffold generation");
83
+ if (lowered.includes("traceability") || lowered.includes("manifest"))
84
+ pushUnique(codeAreas, "manifest traceability");
85
+ if (lowered.includes("design"))
86
+ pushUnique(codeAreas, "design scaffold generation");
87
+ }
88
+ const affectedCode = codeAreas.length > 0 ? codeAreas.join(", ") : "to be filled in during proposal refinement";
89
+ return `## Impact
90
+ - Affected specs: \`${capabilityId}\`
91
+ - Affected code: ${affectedCode}`;
92
+ }
93
+ function normalizeHeading(value) {
94
+ return value.trim().toLowerCase().replace(/[^a-z0-9 ]+/g, "");
95
+ }
96
+ function extractMarkdownSections(content) {
97
+ const sections = {};
98
+ let current = "root";
99
+ sections[current] = [];
100
+ for (const rawLine of content.split(/\r?\n/)) {
101
+ const heading = rawLine.match(/^#{1,6}\s+(.+)$/)?.[1];
102
+ if (heading) {
103
+ current = normalizeHeading(heading);
104
+ sections[current] ??= [];
105
+ continue;
106
+ }
107
+ const line = rawLine.trim();
108
+ if (!line)
109
+ continue;
110
+ if (line.startsWith("- ") || line.startsWith("* ")) {
111
+ sections[current].push(line.slice(2).trim());
112
+ }
113
+ else {
114
+ sections[current].push(line);
115
+ }
116
+ }
117
+ return sections;
118
+ }
119
+ async function loadArtifactPlanningInputs(store, artifacts) {
120
+ const inputs = [];
121
+ for (const artifact of artifacts) {
122
+ let content = "";
123
+ try {
124
+ content = await fs.readFile(store.getArtifactPath(artifact), "utf8");
125
+ }
126
+ catch {
127
+ content = "";
128
+ }
129
+ const sections = extractMarkdownSections(content);
130
+ inputs.push({
131
+ filename: artifact.filename,
132
+ name: artifact.name,
133
+ problem: [...(sections["problem"] ?? []), ...(sections["why"] ?? [])],
134
+ proposedFeatures: [...(sections["proposed feature"] ?? []), ...(sections["what changes"] ?? [])],
135
+ openQuestions: [...(sections["open questions"] ?? []), ...(sections["questions"] ?? [])],
136
+ acceptanceCriteria: [...(sections["acceptance criteria"] ?? []), ...(sections["success criteria"] ?? [])],
137
+ });
138
+ }
139
+ return inputs;
140
+ }
141
+ function buildProposal(title, capabilityId, artifactInputs) {
142
+ const signals = collectSignals(artifactInputs);
143
+ const references = artifactInputs.length === 0
144
+ ? "No Reffy references used."
145
+ : artifactInputs.map((artifact) => `- \`${artifact.filename}\` - planning input artifact`).join("\n");
146
+ const changeBullets = signals.proposedFeatures.length > 0
147
+ ? signals.proposedFeatures.map((item) => `- ${item}`).join("\n")
148
+ : `- Create or update the \`${capabilityId}\` capability.\n- Generate initial proposal, tasks, and spec delta scaffolds from selected Reffy artifacts.\n- Preserve explicit traceability back to the source planning artifacts.`;
149
+ return `# Change: ${title}
150
+
151
+ ## Why
152
+ ${buildWhyText(signals, artifactInputs.length)}
153
+
154
+ ## What Changes
155
+ ${changeBullets}
156
+
157
+ ${buildImpactText(capabilityId, signals)}
158
+
159
+ ## Reffy References
160
+ ${references}
161
+ `;
162
+ }
163
+ function buildTasks(artifactInputs) {
164
+ const signals = collectSignals(artifactInputs);
165
+ const implementation = signals.proposedFeatures.length > 0
166
+ ? signals.proposedFeatures.map((item, index) => `- [ ] 1.${index + 1} ${item}`).join("\n")
167
+ : `- [ ] 1.1 Review selected Reffy artifacts and confirm proposal scope.
168
+ - [ ] 1.2 Refine proposal details and technical design decisions.
169
+ - [ ] 1.3 Implement the scoped behavior changes.
170
+ - [ ] 1.4 Add or update tests for the changed behavior.`;
171
+ const verification = signals.acceptanceCriteria.length > 0
172
+ ? signals.acceptanceCriteria.map((item, index) => `- [ ] 2.${index + 1} Verify ${item}`).join("\n")
173
+ : `- [ ] 2.1 Run the relevant automated checks.
174
+ - [ ] 2.2 Validate the change with \`reffy plan validate <change-id>\`.
175
+ - [ ] 2.3 Review the generated ReffySpec files for correctness.`;
176
+ return `## 1. Implementation
177
+ ${implementation}
178
+
179
+ ## 2. Verification
180
+ ${verification}
181
+ `;
182
+ }
183
+ function buildDesign(artifactInputs) {
184
+ const signals = collectSignals(artifactInputs);
185
+ const refs = artifactInputs.length === 0 ? "- None yet." : artifactInputs.map((artifact) => `- ${artifact.filename}`).join("\n");
186
+ const goalText = signals.proposedFeatures.length > 0
187
+ ? signals.proposedFeatures.map((goal) => ` - ${goal}`).join("\n")
188
+ : " - Refine the implementation approach for this change.";
189
+ const questionText = signals.openQuestions.length > 0 ? signals.openQuestions.map((question) => `- ${question}`).join("\n") : "- None yet.";
190
+ const contextText = signals.problems.length > 0
191
+ ? signals.problems.slice(0, 4).map((problem) => `- ${problem}`).join("\n")
192
+ : "- Planning context will be refined during proposal review.";
193
+ const decisionText = signals.proposedFeatures.length > 0
194
+ ? signals.proposedFeatures.slice(0, 3).map((feature) => `- Decision: Prioritize ${lowerInitial(feature)}.\n - Rationale: This came directly from the approved Reffy artifact set.`).join("\n")
195
+ : "- Decision: Scope refinement pending proposal review.\n - Rationale: This file is an initial scaffold.";
196
+ return `## Context
197
+ This design scaffold was generated from Reffy artifacts to keep planning traceable to upstream ideation instead of treating the artifacts as detached brainstorming notes.
198
+
199
+ ### Problem Summary
200
+ ${contextText}
201
+
202
+ ## Goals / Non-Goals
203
+ - Goals:
204
+ ${goalText}
205
+ - Preserve traceability to the selected Reffy artifacts.
206
+ - Non-Goals:
207
+ - Finalize every implementation detail before proposal review.
208
+
209
+ ## Decisions
210
+ ${decisionText}
211
+
212
+ ## Reffy Inputs
213
+ ${refs}
214
+
215
+ ## Open Questions
216
+ ${questionText}
217
+ `;
218
+ }
219
+ function buildSpecDelta(requirementTitle, artifactInputs) {
220
+ const signals = collectSignals(artifactInputs);
221
+ const requirementText = signals.proposedFeatures[0]?.trim();
222
+ const scenarioWhen = signals.acceptanceCriteria[0]?.trim();
223
+ return `## ADDED Requirements
224
+ ### Requirement: ${requirementTitle}
225
+ ${requirementText ? toShallStatement(requirementText) : "The system SHALL implement the approved behavior described by this change."}
226
+
227
+ #### Scenario: Planned change is delivered
228
+ - **WHEN** ${scenarioWhen ? scenarioWhen.replace(/\.$/, "") : "the implementation for this change is completed"}
229
+ - **THEN** the system behavior matches the approved proposal
230
+ - **AND** the updated behavior is covered by verification steps
231
+ `;
232
+ }
233
+ async function ensureEmptyChangeDir(changeDir, overwrite) {
234
+ try {
235
+ const existing = await fs.readdir(changeDir);
236
+ if (existing.length > 0 && !overwrite) {
237
+ throw new Error(`Change directory already exists and is not empty: ${changeDir}`);
238
+ }
239
+ }
240
+ catch (error) {
241
+ if (error.code !== "ENOENT") {
242
+ throw error;
243
+ }
244
+ }
245
+ }
246
+ export async function createPlanScaffold(store, input) {
247
+ const artifacts = await store.listArtifacts();
248
+ const requested = new Set((input.artifactFilters ?? []).map((entry) => entry.trim()).filter(Boolean));
249
+ const selected = input.includeAllArtifacts || requested.size === 0
250
+ ? artifacts
251
+ : artifacts.filter((artifact) => requested.has(artifact.filename) || requested.has(artifact.id));
252
+ if (artifacts.length > 0 && selected.length === 0) {
253
+ throw new Error("No indexed artifacts matched the requested filters");
254
+ }
255
+ const changeId = normalizeKebab(input.changeId);
256
+ if (!changeId) {
257
+ throw new Error("change id must contain at least one alphanumeric character");
258
+ }
259
+ const title = input.title?.trim() || deriveTitle(changeId);
260
+ const capabilityId = normalizeKebab(deriveCapabilityId(changeId));
261
+ const artifactInputs = await loadArtifactPlanningInputs(store, selected);
262
+ const changeDir = path.join(store.repoRoot, DEFAULT_PLANNING_DIRNAME, "changes", changeId);
263
+ const specDir = path.join(changeDir, "specs", capabilityId);
264
+ await ensureEmptyChangeDir(changeDir, input.overwrite ?? false);
265
+ await fs.mkdir(specDir, { recursive: true });
266
+ const files = [
267
+ {
268
+ rel: path.join(DEFAULT_PLANNING_DIRNAME, "changes", changeId, "proposal.md"),
269
+ content: buildProposal(title, capabilityId, artifactInputs),
270
+ },
271
+ {
272
+ rel: path.join(DEFAULT_PLANNING_DIRNAME, "changes", changeId, "tasks.md"),
273
+ content: buildTasks(artifactInputs),
274
+ },
275
+ {
276
+ rel: path.join(DEFAULT_PLANNING_DIRNAME, "changes", changeId, "design.md"),
277
+ content: buildDesign(artifactInputs),
278
+ },
279
+ {
280
+ rel: path.join(DEFAULT_PLANNING_DIRNAME, "changes", changeId, "specs", capabilityId, "spec.md"),
281
+ content: buildSpecDelta(deriveRequirementTitle(title), artifactInputs),
282
+ },
283
+ ];
284
+ for (const file of files) {
285
+ const abs = path.join(store.repoRoot, file.rel);
286
+ await fs.mkdir(path.dirname(abs), { recursive: true });
287
+ await fs.writeFile(abs, file.content, "utf8");
288
+ }
289
+ const writtenFiles = files.map((file) => file.rel.split(path.sep).join("/"));
290
+ const linkResult = await store.linkPlanningOutputs(selected.map((artifact) => artifact.filename), writtenFiles, changeId);
291
+ return {
292
+ change_id: changeId,
293
+ change_dir: changeDir,
294
+ selected_artifacts: selected.map((artifact) => artifact.filename),
295
+ written_files: writtenFiles,
296
+ linked_artifacts: linkResult.linked,
297
+ };
298
+ }
@@ -0,0 +1,18 @@
1
+ export declare const DEFAULT_PLANNING_DIRNAME = "reffyspec";
2
+ export declare const LEGACY_PLANNING_DIRNAME = "openspec";
3
+ export type PlanningMode = "canonical" | "legacy" | "dual" | "new";
4
+ export interface PlanningState {
5
+ canonicalDir: string;
6
+ legacyDir: string;
7
+ canonicalExists: boolean;
8
+ legacyExists: boolean;
9
+ mode: PlanningMode;
10
+ activeDirName: string;
11
+ activeDir: string;
12
+ }
13
+ export declare function resolveCanonicalPlanningDir(repoRoot: string): string;
14
+ export declare function resolveLegacyPlanningDir(repoRoot: string): string;
15
+ export declare function detectPlanningState(repoRoot: string): PlanningState;
16
+ export declare function resolvePlanningDirName(repoRoot: string): string;
17
+ export declare function resolvePlanningDir(repoRoot: string): string;
18
+ export declare function looksLikePlanningDir(targetPath: string): boolean;
@@ -0,0 +1,56 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const DEFAULT_PLANNING_DIRNAME = "reffyspec";
4
+ export const LEGACY_PLANNING_DIRNAME = "openspec";
5
+ function isDirectory(targetPath) {
6
+ try {
7
+ return statSync(targetPath).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export function resolveCanonicalPlanningDir(repoRoot) {
14
+ return path.join(repoRoot, DEFAULT_PLANNING_DIRNAME);
15
+ }
16
+ export function resolveLegacyPlanningDir(repoRoot) {
17
+ return path.join(repoRoot, LEGACY_PLANNING_DIRNAME);
18
+ }
19
+ export function detectPlanningState(repoRoot) {
20
+ const canonicalDir = resolveCanonicalPlanningDir(repoRoot);
21
+ const legacyDir = resolveLegacyPlanningDir(repoRoot);
22
+ const canonicalExists = isDirectory(canonicalDir);
23
+ const legacyExists = isDirectory(legacyDir);
24
+ let mode = "new";
25
+ if (canonicalExists && legacyExists) {
26
+ mode = "dual";
27
+ }
28
+ else if (canonicalExists) {
29
+ mode = "canonical";
30
+ }
31
+ else if (legacyExists) {
32
+ mode = "legacy";
33
+ }
34
+ const activeDirName = mode === "legacy" ? LEGACY_PLANNING_DIRNAME : DEFAULT_PLANNING_DIRNAME;
35
+ return {
36
+ canonicalDir,
37
+ legacyDir,
38
+ canonicalExists,
39
+ legacyExists,
40
+ mode,
41
+ activeDirName,
42
+ activeDir: path.join(repoRoot, activeDirName),
43
+ };
44
+ }
45
+ export function resolvePlanningDirName(repoRoot) {
46
+ return detectPlanningState(repoRoot).activeDirName;
47
+ }
48
+ export function resolvePlanningDir(repoRoot) {
49
+ return path.join(repoRoot, resolvePlanningDirName(repoRoot));
50
+ }
51
+ export function looksLikePlanningDir(targetPath) {
52
+ const base = path.basename(targetPath);
53
+ return ((base === DEFAULT_PLANNING_DIRNAME || base === LEGACY_PLANNING_DIRNAME) &&
54
+ existsSync(path.join(targetPath, "changes")) &&
55
+ existsSync(path.join(targetPath, "specs")));
56
+ }
@@ -0,0 +1,8 @@
1
+ import { type PlanningState } from "./planning-paths.js";
2
+ export interface PlanningPreparationResult {
3
+ state: PlanningState;
4
+ migrated: boolean;
5
+ created: boolean;
6
+ message?: string;
7
+ }
8
+ export declare function prepareCanonicalPlanningLayout(repoRoot: string): Promise<PlanningPreparationResult>;
@@ -0,0 +1,40 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_PLANNING_DIRNAME, LEGACY_PLANNING_DIRNAME, detectPlanningState, resolveCanonicalPlanningDir, } from "./planning-paths.js";
4
+ async function pathExists(targetPath) {
5
+ try {
6
+ await fs.access(targetPath);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ async function ensureCanonicalPlanningStructure(repoRoot) {
14
+ const planningDir = resolveCanonicalPlanningDir(repoRoot);
15
+ let created = false;
16
+ if (!(await pathExists(planningDir))) {
17
+ created = true;
18
+ }
19
+ await fs.mkdir(path.join(planningDir, "changes", "archive"), { recursive: true });
20
+ await fs.mkdir(path.join(planningDir, "specs"), { recursive: true });
21
+ return created;
22
+ }
23
+ export async function prepareCanonicalPlanningLayout(repoRoot) {
24
+ const current = detectPlanningState(repoRoot);
25
+ if (current.mode === "legacy") {
26
+ await fs.rename(current.legacyDir, current.canonicalDir);
27
+ return {
28
+ state: detectPlanningState(repoRoot),
29
+ migrated: true,
30
+ created: false,
31
+ message: `Migrated ${LEGACY_PLANNING_DIRNAME}/ to ${DEFAULT_PLANNING_DIRNAME}/`,
32
+ };
33
+ }
34
+ const created = await ensureCanonicalPlanningStructure(repoRoot);
35
+ return {
36
+ state: detectPlanningState(repoRoot),
37
+ migrated: false,
38
+ created,
39
+ };
40
+ }
@@ -1,5 +1,18 @@
1
1
  export declare const DEFAULT_REFS_DIRNAME = ".reffy";
2
2
  export declare const LEGACY_REFS_DIRNAME = ".references";
3
+ export type WorkspaceMode = "canonical" | "legacy" | "dual" | "new";
4
+ export interface WorkspaceState {
5
+ canonicalDir: string;
6
+ legacyDir: string;
7
+ canonicalExists: boolean;
8
+ legacyExists: boolean;
9
+ mode: WorkspaceMode;
10
+ activeDirName: string;
11
+ activeDir: string;
12
+ }
3
13
  export declare function resolveRefsDirName(repoRoot: string): string;
4
14
  export declare function resolveRefsDir(repoRoot: string): string;
15
+ export declare function resolveCanonicalRefsDir(repoRoot: string): string;
16
+ export declare function resolveLegacyRefsDir(repoRoot: string): string;
17
+ export declare function detectWorkspaceState(repoRoot: string): WorkspaceState;
5
18
  export declare function looksLikeRefsDir(targetPath: string): boolean;
@@ -11,19 +11,44 @@ function isDirectory(targetPath) {
11
11
  }
12
12
  }
13
13
  export function resolveRefsDirName(repoRoot) {
14
- const reffyDir = path.join(repoRoot, DEFAULT_REFS_DIRNAME);
15
- if (isDirectory(reffyDir)) {
16
- return DEFAULT_REFS_DIRNAME;
17
- }
18
- const legacyDir = path.join(repoRoot, LEGACY_REFS_DIRNAME);
19
- if (isDirectory(legacyDir)) {
20
- return LEGACY_REFS_DIRNAME;
21
- }
22
- return DEFAULT_REFS_DIRNAME;
14
+ return detectWorkspaceState(repoRoot).activeDirName;
23
15
  }
24
16
  export function resolveRefsDir(repoRoot) {
25
17
  return path.join(repoRoot, resolveRefsDirName(repoRoot));
26
18
  }
19
+ export function resolveCanonicalRefsDir(repoRoot) {
20
+ return path.join(repoRoot, DEFAULT_REFS_DIRNAME);
21
+ }
22
+ export function resolveLegacyRefsDir(repoRoot) {
23
+ return path.join(repoRoot, LEGACY_REFS_DIRNAME);
24
+ }
25
+ export function detectWorkspaceState(repoRoot) {
26
+ const canonicalDir = resolveCanonicalRefsDir(repoRoot);
27
+ const legacyDir = resolveLegacyRefsDir(repoRoot);
28
+ const canonicalExists = isDirectory(canonicalDir);
29
+ const legacyExists = isDirectory(legacyDir);
30
+ let mode = "new";
31
+ if (canonicalExists && legacyExists) {
32
+ mode = "dual";
33
+ }
34
+ else if (canonicalExists) {
35
+ mode = "canonical";
36
+ }
37
+ else if (legacyExists) {
38
+ mode = "legacy";
39
+ }
40
+ const activeDirName = mode === "legacy" ? LEGACY_REFS_DIRNAME : DEFAULT_REFS_DIRNAME;
41
+ const activeDir = path.join(repoRoot, activeDirName);
42
+ return {
43
+ canonicalDir,
44
+ legacyDir,
45
+ canonicalExists,
46
+ legacyExists,
47
+ mode,
48
+ activeDirName,
49
+ activeDir,
50
+ };
51
+ }
27
52
  export function looksLikeRefsDir(targetPath) {
28
53
  const base = path.basename(targetPath);
29
54
  return ((base === DEFAULT_REFS_DIRNAME || base === LEGACY_REFS_DIRNAME) &&
@@ -0,0 +1,14 @@
1
+ export interface SpecSummary {
2
+ id: string;
3
+ title: string;
4
+ spec_path: string;
5
+ design_path?: string;
6
+ purpose?: string;
7
+ requirement_count: number;
8
+ }
9
+ export interface SpecShowResult extends SpecSummary {
10
+ spec: string;
11
+ design?: string;
12
+ }
13
+ export declare function listSpecs(repoRoot: string): Promise<SpecSummary[]>;
14
+ export declare function showSpec(repoRoot: string, specId: string): Promise<SpecShowResult>;
@@ -0,0 +1,82 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_PLANNING_DIRNAME } from "./planning-paths.js";
4
+ const SPEC_HEADING_SUFFIX = " Specification";
5
+ const PURPOSE_HEADING = "## Purpose";
6
+ const REQUIREMENT_HEADING_PATTERN = /^###\s+Requirement:\s+(.+)$/;
7
+ async function pathExists(targetPath) {
8
+ try {
9
+ await fs.access(targetPath);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function listDirectories(dir) {
17
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
18
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
19
+ }
20
+ function countRequirements(content) {
21
+ return content.split(/\r?\n/).filter((line) => REQUIREMENT_HEADING_PATTERN.test(line)).length;
22
+ }
23
+ function extractSpecTitle(content, fallback) {
24
+ for (const line of content.split(/\r?\n/)) {
25
+ if (line.startsWith("# ")) {
26
+ const title = line.slice(2).trim();
27
+ return title.endsWith(SPEC_HEADING_SUFFIX) ? title.slice(0, -SPEC_HEADING_SUFFIX.length) : title || fallback;
28
+ }
29
+ }
30
+ return fallback;
31
+ }
32
+ function extractPurpose(content) {
33
+ const lines = content.split(/\r?\n/);
34
+ const start = lines.findIndex((line) => line.trim() === PURPOSE_HEADING);
35
+ if (start === -1)
36
+ return undefined;
37
+ const purposeLines = [];
38
+ for (let i = start + 1; i < lines.length; i += 1) {
39
+ const line = lines[i]?.trim() ?? "";
40
+ if (!line)
41
+ continue;
42
+ if (line.startsWith("## "))
43
+ break;
44
+ purposeLines.push(line);
45
+ }
46
+ return purposeLines.length > 0 ? purposeLines.join(" ") : undefined;
47
+ }
48
+ async function buildSpecSummary(repoRoot, specId) {
49
+ const specDir = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "specs", specId);
50
+ const specPath = path.join(specDir, "spec.md");
51
+ const designPath = path.join(specDir, "design.md");
52
+ const content = await fs.readFile(specPath, "utf8");
53
+ return {
54
+ id: specId,
55
+ title: extractSpecTitle(content, specId),
56
+ spec_path: specPath,
57
+ design_path: (await pathExists(designPath)) ? designPath : undefined,
58
+ purpose: extractPurpose(content),
59
+ requirement_count: countRequirements(content),
60
+ };
61
+ }
62
+ export async function listSpecs(repoRoot) {
63
+ const specsRoot = path.join(repoRoot, DEFAULT_PLANNING_DIRNAME, "specs");
64
+ const specIds = await listDirectories(specsRoot);
65
+ const summaries = [];
66
+ for (const specId of specIds) {
67
+ const specPath = path.join(specsRoot, specId, "spec.md");
68
+ if (!(await pathExists(specPath)))
69
+ continue;
70
+ summaries.push(await buildSpecSummary(repoRoot, specId));
71
+ }
72
+ return summaries.sort((a, b) => a.id.localeCompare(b.id));
73
+ }
74
+ export async function showSpec(repoRoot, specId) {
75
+ const summary = await buildSpecSummary(repoRoot, specId);
76
+ const design = summary.design_path ? await fs.readFile(summary.design_path, "utf8").catch(() => undefined) : undefined;
77
+ return {
78
+ ...summary,
79
+ spec: await fs.readFile(summary.spec_path, "utf8"),
80
+ design,
81
+ };
82
+ }
package/dist/storage.d.ts CHANGED
@@ -27,6 +27,12 @@ export declare class ReferencesStore {
27
27
  total: number;
28
28
  }>;
29
29
  validateManifest(): Promise<import("./manifest.js").ManifestValidationResult>;
30
+ linkPlanningOutputs(artifactFilenames: string[], outputPaths: string[], changeId: string): Promise<{
31
+ linked: number;
32
+ }>;
33
+ rewriteDerivedOutputPaths(pathMap: Record<string, string>): Promise<{
34
+ updated: number;
35
+ }>;
30
36
  updateArtifact(artifactId: string, input: {
31
37
  name?: string | null;
32
38
  content?: string | null;
package/dist/storage.js CHANGED
@@ -198,6 +198,56 @@ export class ReferencesStore {
198
198
  async validateManifest() {
199
199
  return validateManifest(this.manifestPath, this.artifactsDir);
200
200
  }
201
+ async linkPlanningOutputs(artifactFilenames, outputPaths, changeId) {
202
+ const manifest = await this.readManifest();
203
+ const targets = new Set(artifactFilenames);
204
+ let linked = 0;
205
+ for (const artifact of manifest.artifacts) {
206
+ if (!targets.has(artifact.filename))
207
+ continue;
208
+ artifact.related_changes = Array.from(new Set([...(artifact.related_changes ?? []), changeId]));
209
+ artifact.derived_outputs = Array.from(new Set([...(artifact.derived_outputs ?? []), ...outputPaths]));
210
+ artifact.updated_at = utcNow();
211
+ linked += 1;
212
+ }
213
+ if (linked > 0) {
214
+ manifest.updated_at = utcNow();
215
+ await this.writeManifest(manifest);
216
+ }
217
+ return { linked };
218
+ }
219
+ async rewriteDerivedOutputPaths(pathMap) {
220
+ const replacements = Object.entries(pathMap);
221
+ if (replacements.length === 0) {
222
+ return { updated: 0 };
223
+ }
224
+ const manifest = await this.readManifest();
225
+ let updated = 0;
226
+ for (const artifact of manifest.artifacts) {
227
+ const outputs = artifact.derived_outputs ?? [];
228
+ if (outputs.length === 0)
229
+ continue;
230
+ let changed = false;
231
+ const nextOutputs = outputs.map((outputPath) => {
232
+ const replacement = pathMap[outputPath];
233
+ if (!replacement || replacement === outputPath) {
234
+ return outputPath;
235
+ }
236
+ changed = true;
237
+ return replacement;
238
+ });
239
+ if (!changed)
240
+ continue;
241
+ artifact.derived_outputs = Array.from(new Set(nextOutputs));
242
+ artifact.updated_at = utcNow();
243
+ updated += 1;
244
+ }
245
+ if (updated > 0) {
246
+ manifest.updated_at = utcNow();
247
+ await this.writeManifest(manifest);
248
+ }
249
+ return { updated };
250
+ }
201
251
  async updateArtifact(artifactId, input) {
202
252
  const manifest = await this.readManifest();
203
253
  const index = manifest.artifacts.findIndex((item) => item.id === artifactId);
package/dist/types.d.ts CHANGED
@@ -6,6 +6,9 @@ export interface Artifact {
6
6
  mime_type: string;
7
7
  size_bytes: number;
8
8
  tags: string[];
9
+ status?: string;
10
+ related_changes?: string[];
11
+ derived_outputs?: string[];
9
12
  created_at: string;
10
13
  updated_at: string;
11
14
  }