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/README.md +35 -24
- package/dist/cli.js +539 -36
- package/dist/diagram.js +2 -2
- package/dist/doctor.d.ts +1 -4
- package/dist/doctor.js +8 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest.js +13 -0
- package/dist/plan-archive.d.ts +8 -0
- package/dist/plan-archive.js +169 -0
- package/dist/plan-runtime.d.ts +35 -0
- package/dist/plan-runtime.js +218 -0
- package/dist/plan.d.ts +25 -0
- package/dist/plan.js +298 -0
- package/dist/planning-paths.d.ts +18 -0
- package/dist/planning-paths.js +56 -0
- package/dist/planning-workspace.d.ts +8 -0
- package/dist/planning-workspace.js +76 -0
- package/dist/refs-paths.d.ts +13 -0
- package/dist/refs-paths.js +34 -9
- package/dist/spec-runtime.d.ts +14 -0
- package/dist/spec-runtime.js +82 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +50 -0
- package/dist/types.d.ts +3 -0
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +51 -0
- package/package.json +14 -2
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
|
+
}
|
package/dist/refs-paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/refs-paths.js
CHANGED
|
@@ -11,19 +11,44 @@ function isDirectory(targetPath) {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
export function resolveRefsDirName(repoRoot) {
|
|
14
|
-
|
|
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;
|