reffy-cli 0.7.0 → 1.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.
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,76 @@
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
+ const DEFAULT_PROJECT_CONTEXT_TEMPLATE = `# Project Context
5
+
6
+ ## Purpose
7
+ [Describe your project's purpose and goals]
8
+
9
+ ## Tech Stack
10
+ - [List your primary technologies]
11
+ - [e.g., TypeScript, React, Node.js]
12
+
13
+ ## Project Conventions
14
+
15
+ ### Code Style
16
+ [Describe your code style preferences, formatting rules, and naming conventions]
17
+
18
+ ### Architecture Patterns
19
+ [Document your architectural decisions and patterns]
20
+
21
+ ### Testing Strategy
22
+ [Explain your testing approach and requirements]
23
+
24
+ ### Git Workflow
25
+ [Describe your branching strategy and commit conventions]
26
+
27
+ ## Domain Context
28
+ [Add domain-specific knowledge that AI assistants need to understand]
29
+
30
+ ## Important Constraints
31
+ [List any technical, business, or regulatory constraints]
32
+
33
+ ## External Dependencies
34
+ [Document key external services, APIs, or systems]
35
+ `;
36
+ async function pathExists(targetPath) {
37
+ try {
38
+ await fs.access(targetPath);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function ensureCanonicalPlanningStructure(repoRoot) {
46
+ const planningDir = resolveCanonicalPlanningDir(repoRoot);
47
+ const projectContextPath = path.join(planningDir, "project.md");
48
+ let created = false;
49
+ if (!(await pathExists(planningDir))) {
50
+ created = true;
51
+ }
52
+ await fs.mkdir(path.join(planningDir, "changes", "archive"), { recursive: true });
53
+ await fs.mkdir(path.join(planningDir, "specs"), { recursive: true });
54
+ if (!(await pathExists(projectContextPath))) {
55
+ await fs.writeFile(projectContextPath, DEFAULT_PROJECT_CONTEXT_TEMPLATE, "utf8");
56
+ }
57
+ return created;
58
+ }
59
+ export async function prepareCanonicalPlanningLayout(repoRoot) {
60
+ const current = detectPlanningState(repoRoot);
61
+ if (current.mode === "legacy") {
62
+ await fs.rename(current.legacyDir, current.canonicalDir);
63
+ return {
64
+ state: detectPlanningState(repoRoot),
65
+ migrated: true,
66
+ created: false,
67
+ message: `Migrated ${LEGACY_PLANNING_DIRNAME}/ to ${DEFAULT_PLANNING_DIRNAME}/`,
68
+ };
69
+ }
70
+ const created = await ensureCanonicalPlanningStructure(repoRoot);
71
+ return {
72
+ state: detectPlanningState(repoRoot),
73
+ migrated: false,
74
+ created,
75
+ };
76
+ }
@@ -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;