reffy-cli 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roberto L. Delgado
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # reffy
2
+
3
+ Reffy is intended as an ideation layer for spec-driven development (SDD) in straightforward, version controlled and agent-friendly markdown files.
4
+
5
+ ## Install
6
+
7
+ Recommended usage in any repo:
8
+
9
+ ```bash
10
+ npm install -g reffy-cli
11
+ ```
12
+
13
+ The install runs this package's `prepare` step, which builds `dist/` automatically.
14
+
15
+ ## Quickstart (CLI-only)
16
+
17
+ Inside your project:
18
+
19
+ ```bash
20
+ reffy init
21
+ reffy bootstrap
22
+ reffy doctor
23
+ reffy reindex
24
+ reffy validate
25
+ reffy summarize
26
+ ```
27
+
28
+ Command summary:
29
+
30
+ - `reffy init`: idempotently creates/updates root `AGENTS.md` managed block and `.references/AGENTS.md`.
31
+ - `reffy bootstrap`: idempotently runs `init`, ensures `.references/` structure exists, then reindexes artifacts.
32
+ - `reffy doctor`: diagnoses required Reffy setup and optional tool availability.
33
+ - `reffy reindex`: reconciles `.references/manifest.json` with `.references/artifacts` by adding missing files and removing stale entries.
34
+ - `reffy validate`: validates `.references/manifest.json` against manifest v1 contract.
35
+ - `reffy summarize`: generates a read-only handoff summary from indexed artifacts.
36
+
37
+ Output modes:
38
+
39
+ - `--output text` (default)
40
+ - `--output json`
41
+ - `--json` (shortcut for `--output json`)
42
+
43
+ Examples:
44
+
45
+ ```bash
46
+ reffy reindex --output json
47
+ reffy validate --repo .
48
+ reffy doctor --output text
49
+ reffy doctor --output json
50
+ reffy summarize --output text
51
+ reffy summarize --output json
52
+ ```
53
+
54
+ ## Using Reffy With SDD Frameworks (OpenSpec Example)
55
+
56
+ `reffy` is designed to complement spec-driven development workflows rather than replace them. A common pattern is:
57
+
58
+ 1. Use Reffy for ideation and context capture in `.references/`.
59
+ 2. Use an SDD framework (for example [OpenSpec](https://github.com/Fission-AI/OpenSpec)) for formal proposals/specs/tasks.
60
+ 3. Keep a clear handoff from exploratory artifacts to formal specs.
61
+
62
+ Reference implementation in this repo:
63
+
64
+ - `AGENTS.md`: contains both managed instruction blocks and encodes sequencing.
65
+ - `AGENTS.md`: Reffy block routes ideation/exploration requests to `@/.references/AGENTS.md`.
66
+ - `AGENTS.md`: OpenSpec block routes planning/proposal/spec requests to `@/openspec/AGENTS.md`.
67
+ - `.references/AGENTS.md`: defines Reffy as the ideation/context layer and documents handoff expectations to OpenSpec.
68
+ - `openspec/AGENTS.md`: defines OpenSpec as the formal planning/specification workflow after ideation is stable.
69
+ - `src/cli.ts`: `reffy init`/`reffy bootstrap` enforce this integration by idempotently writing the managed Reffy guidance into `AGENTS.md` and `.references/AGENTS.md`.
70
+
71
+ Practical connection pattern for any repo:
72
+
73
+ 1. Run `reffy init` to install/refresh the Reffy instruction layer.
74
+ 2. Keep your SDD framework instructions (for example OpenSpec) in the same root `AGENTS.md`.
75
+ 3. During planning, cite only relevant Reffy artifacts from `.references/artifacts/` in your proposal/spec docs.
76
+ 4. Continue implementation in your SDD framework's normal review/approval process.
77
+
78
+ ## Develop
79
+
80
+ For local development of this repo:
81
+
82
+ ```bash
83
+ npm install
84
+ npm run build
85
+ npm run check
86
+ npm test
87
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env node
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { runDoctor } from "./doctor.js";
5
+ import { ReferencesStore } from "./storage.js";
6
+ import { summarizeArtifacts } from "./summarize.js";
7
+ const REFFY_ASCII = [
8
+ " __ __ ",
9
+ " _ __ ___ / _|/ _|_ _",
10
+ "| '__/ _ \\| |_| |_| | | |",
11
+ "| | | __/| _| _| |_| |",
12
+ "|_| \\___||_| |_| \\__, |",
13
+ " |___/ ",
14
+ ].join("\n");
15
+ const REFFY_BLOCK = `<!-- REFFY:START -->
16
+ # Reffy Instructions
17
+
18
+ These instructions are for AI assistants working in this project.
19
+
20
+ Always open \`@/.references/AGENTS.md\` when the request:
21
+ - Mentions early-stage ideation, exploration, brainstorming, or raw notes
22
+ - Needs context before drafting specs or proposals
23
+ - Refers to "reffy", "references", "explore", or "context layer"
24
+
25
+ Use \`@/.references/AGENTS.md\` to learn:
26
+ - Reffy workflow and artifact conventions
27
+ - How Reffy and OpenSpec should be sequenced
28
+ - How to store and consume ideation context in \`.references/\`
29
+
30
+ Keep this managed block so \`reffy init\` can refresh the instructions.
31
+
32
+ <!-- REFFY:END -->`;
33
+ const REFFY_AGENTS_RELATIVE = path.join(".references", "AGENTS.md");
34
+ const REFFY_AGENTS_CONTENT = `# Reffy Instructions
35
+
36
+ These instructions are for AI assistants working in this project.
37
+
38
+ ## TL;DR Checklist
39
+
40
+ - Decide whether Reffy ideation is needed for this request.
41
+ - If needed, read existing context in \`.references/artifacts/\`.
42
+ - Add/update exploratory artifacts and keep them concise.
43
+ - Run \`reffy reindex\` and \`reffy validate\` after artifact changes.
44
+ - After ideation approval, run \`reffy summarize --output json\` and pick only directly relevant artifacts for proposal citations.
45
+
46
+ ## When To Use Reffy
47
+
48
+ Use Reffy first when the request:
49
+ - Mentions early-stage ideation, exploration, brainstorming, or raw notes
50
+ - Needs context gathering before drafting a concrete implementation plan
51
+ - Refers to "reffy", "references", "explore", "context layer", or research artifacts
52
+
53
+ ## When To Skip Reffy
54
+
55
+ You can skip Reffy when the request is:
56
+ - A narrow bug fix that does not need exploratory context
57
+ - A small refactor with no requirement/design ambiguity
58
+ - A formatting, typing, or tooling-only update with clear scope
59
+
60
+ ## Reffy Workflow
61
+
62
+ 1. Read existing artifacts in \`.references/artifacts/\`.
63
+ 2. Add or update artifacts to capture exploratory context.
64
+ 3. Run \`reffy reindex\` to index newly added files into \`.references/manifest.json\`.
65
+ 4. Run \`reffy validate\` to verify manifest contract compliance.
66
+
67
+ ## Relationship To OpenSpec
68
+
69
+ - Reffy is the ideation/context layer.
70
+ - OpenSpec is the formal planning/spec layer.
71
+ - After ideation stabilizes, hand off to OpenSpec by following \`@/openspec/AGENTS.md\`.
72
+ - Do not duplicate full proposal/spec content in Reffy artifacts; summarize and link to OpenSpec outputs.
73
+
74
+ ## OpenSpec Citation Rules
75
+
76
+ When an OpenSpec proposal is informed by Reffy artifacts:
77
+ - After ideation approval, run \`reffy summarize --output json\` to shortlist candidate artifacts.
78
+ - Include a short "Reffy References" subsection in \`proposal.md\` (or design notes if more appropriate).
79
+ - Cite only artifact filenames that directly informed the proposal's problem, scope, decisions, or constraints.
80
+ - Cite artifact filenames and intent, for example:
81
+ - \`testing.md\` - early constraints and tradeoffs for manifest validation
82
+ - Do not include generic process artifacts or unrelated notes just because they exist.
83
+ - Keep citations at proposal/design level; task-by-task traceability is optional unless the change is high risk.
84
+ - If no Reffy artifacts informed the change, explicitly state "No Reffy references used."
85
+
86
+ ### Reusable Proposal Snippet
87
+
88
+ Use this in \`openspec/changes/<change-id>/proposal.md\`:
89
+
90
+ \`\`\`md
91
+ ## Reffy References
92
+ - \`artifact-name.md\` - short note about how it informed this proposal
93
+ \`\`\`
94
+
95
+ If none were used:
96
+
97
+ \`\`\`md
98
+ ## Reffy References
99
+ No Reffy references used.
100
+ \`\`\`
101
+
102
+ ## Artifact Conventions
103
+
104
+ - Treat \`.references/\` as a repository-local guidance and ideation context layer.
105
+ - Keep artifact names clear and stable.
106
+ - Prefer markdown notes for exploratory content.
107
+ - Keep manifests machine-readable and schema-compliant (version 1).
108
+ `;
109
+ const REFFY_START = "<!-- REFFY:START -->";
110
+ const REFFY_END = "<!-- REFFY:END -->";
111
+ const OPENSPEC_START = "<!-- OPENSPEC:START -->";
112
+ function upsertReffyBlock(content) {
113
+ if (content.includes(REFFY_START) && content.includes(REFFY_END)) {
114
+ const prefix = content.split(REFFY_START)[0] ?? "";
115
+ const suffix = content.split(REFFY_END, 2)[1] ?? "";
116
+ const trimmedSuffix = suffix.trimStart();
117
+ return trimmedSuffix.length > 0 ? `${prefix}${REFFY_BLOCK}\n\n${trimmedSuffix}` : `${prefix}${REFFY_BLOCK}\n`;
118
+ }
119
+ if (content.includes(OPENSPEC_START)) {
120
+ const [before, after] = content.split(OPENSPEC_START, 2);
121
+ return `${before.trimEnd()}\n\n${REFFY_BLOCK}\n\n${OPENSPEC_START}${after}`;
122
+ }
123
+ return content.trim().length > 0 ? `${REFFY_BLOCK}\n\n${content.trimStart()}` : `${REFFY_BLOCK}\n`;
124
+ }
125
+ async function initAgents(repoRoot) {
126
+ const agentsPath = path.join(repoRoot, "AGENTS.md");
127
+ const reffyAgentsPath = path.join(repoRoot, REFFY_AGENTS_RELATIVE);
128
+ let content = "";
129
+ try {
130
+ content = await fs.readFile(agentsPath, "utf8");
131
+ }
132
+ catch {
133
+ content = "";
134
+ }
135
+ const updated = upsertReffyBlock(content);
136
+ await fs.mkdir(path.dirname(reffyAgentsPath), { recursive: true });
137
+ await fs.writeFile(agentsPath, updated, "utf8");
138
+ await fs.writeFile(reffyAgentsPath, REFFY_AGENTS_CONTENT, "utf8");
139
+ return { root_agents_path: agentsPath, reffy_agents_path: reffyAgentsPath };
140
+ }
141
+ function parseRepoArg(argv) {
142
+ for (let i = 0; i < argv.length; i += 1) {
143
+ const arg = argv[i];
144
+ if (arg === "--repo") {
145
+ const value = argv[i + 1];
146
+ if (!value)
147
+ throw new Error("--repo requires a path");
148
+ return path.resolve(value);
149
+ }
150
+ if (arg.startsWith("--repo=")) {
151
+ const value = arg.split("=", 2)[1];
152
+ if (!value)
153
+ throw new Error("--repo requires a path");
154
+ return path.resolve(value);
155
+ }
156
+ }
157
+ return process.cwd();
158
+ }
159
+ function parseOutputMode(argv) {
160
+ for (let i = 0; i < argv.length; i += 1) {
161
+ const arg = argv[i];
162
+ if (arg === "--json")
163
+ return "json";
164
+ if (arg === "--output") {
165
+ const value = argv[i + 1];
166
+ if (!value)
167
+ throw new Error("--output requires a value: text|json");
168
+ if (value !== "text" && value !== "json")
169
+ throw new Error(`Unsupported output mode: ${value}`);
170
+ return value;
171
+ }
172
+ if (arg.startsWith("--output=")) {
173
+ const value = arg.split("=", 2)[1];
174
+ if (value !== "text" && value !== "json")
175
+ throw new Error(`Unsupported output mode: ${value}`);
176
+ return value;
177
+ }
178
+ }
179
+ return "text";
180
+ }
181
+ function printResult(mode, payload) {
182
+ if (mode === "json") {
183
+ console.log(JSON.stringify(payload, null, 2));
184
+ }
185
+ }
186
+ function printBanner(mode) {
187
+ if (mode === "text") {
188
+ console.log(REFFY_ASCII);
189
+ console.log("");
190
+ }
191
+ }
192
+ function usage() {
193
+ return [
194
+ "Usage: reffy <command> [--repo PATH] [--output text|json]",
195
+ "",
196
+ "Commands:",
197
+ " init Ensure root AGENTS.md block and .references/AGENTS.md are up to date.",
198
+ " bootstrap Run init, ensure .references structure exists, then reindex artifacts.",
199
+ " doctor Diagnose required Reffy setup and optional tool availability.",
200
+ " reindex Scan .references/artifacts and add missing files to manifest.",
201
+ " validate Validate .references/manifest.json against manifest v1 contract.",
202
+ " summarize Generate a read-only summary of indexed Reffy artifacts.",
203
+ ].join("\n");
204
+ }
205
+ function printSection(title, values) {
206
+ console.log(`${title}:`);
207
+ if (values.length === 0) {
208
+ console.log("- (none)");
209
+ return;
210
+ }
211
+ for (const value of values) {
212
+ console.log(`- ${value}`);
213
+ }
214
+ }
215
+ async function main() {
216
+ const [, , command, ...rest] = process.argv;
217
+ const output = parseOutputMode(rest);
218
+ printBanner(output);
219
+ if (!command) {
220
+ console.error(usage());
221
+ return 1;
222
+ }
223
+ if (command === "init") {
224
+ const repoRoot = parseRepoArg(rest);
225
+ const agents = await initAgents(repoRoot);
226
+ const payload = { status: "ok", command: "init", ...agents };
227
+ if (output === "json") {
228
+ printResult(output, payload);
229
+ }
230
+ else {
231
+ console.log(`Updated ${agents.root_agents_path}`);
232
+ console.log(`Updated ${agents.reffy_agents_path}`);
233
+ }
234
+ return 0;
235
+ }
236
+ if (command === "bootstrap") {
237
+ const repoRoot = parseRepoArg(rest);
238
+ const agents = await initAgents(repoRoot);
239
+ const store = new ReferencesStore(repoRoot);
240
+ const reindex = await store.reindexArtifacts();
241
+ const payload = {
242
+ status: "ok",
243
+ command: "bootstrap",
244
+ ...agents,
245
+ refs_dir: store.refsDir,
246
+ manifest_path: store.manifestPath,
247
+ reindex,
248
+ };
249
+ if (output === "json") {
250
+ printResult(output, payload);
251
+ }
252
+ else {
253
+ console.log(`Bootstrapped ${store.refsDir}`);
254
+ console.log(`Updated ${agents.root_agents_path}`);
255
+ console.log(`Updated ${agents.reffy_agents_path}`);
256
+ console.log(`Reindex: added=${String(reindex.added)} removed=${String(reindex.removed)} total=${String(reindex.total)}`);
257
+ }
258
+ return 0;
259
+ }
260
+ if (command === "reindex") {
261
+ const repoRoot = parseRepoArg(rest);
262
+ const store = new ReferencesStore(repoRoot);
263
+ const reindex = await store.reindexArtifacts();
264
+ const payload = { status: "ok", command: "reindex", ...reindex };
265
+ if (output === "json") {
266
+ printResult(output, payload);
267
+ }
268
+ else {
269
+ console.log(`Reindex complete: added=${String(reindex.added)} removed=${String(reindex.removed)} total=${String(reindex.total)}`);
270
+ }
271
+ return 0;
272
+ }
273
+ if (command === "doctor") {
274
+ const repoRoot = parseRepoArg(rest);
275
+ const report = await runDoctor(repoRoot);
276
+ const status = report.summary.required_failed > 0 ? "error" : "ok";
277
+ const payload = { status, command: "doctor", ...report };
278
+ if (output === "json") {
279
+ printResult(output, payload);
280
+ }
281
+ else {
282
+ const required = report.checks.filter((check) => check.level === "required");
283
+ const optional = report.checks.filter((check) => check.level === "optional");
284
+ console.log("Required Checks:");
285
+ for (const check of required) {
286
+ console.log(`- ${check.ok ? "PASS" : "FAIL"} ${check.id}: ${check.message}`);
287
+ }
288
+ console.log("");
289
+ console.log("Optional Checks:");
290
+ for (const check of optional) {
291
+ console.log(`- ${check.ok ? "PASS" : "WARN"} ${check.id}: ${check.message}`);
292
+ }
293
+ console.log("");
294
+ console.log(`Summary: required_failed=${String(report.summary.required_failed)} optional_failed=${String(report.summary.optional_failed)}`);
295
+ }
296
+ return status === "ok" ? 0 : 1;
297
+ }
298
+ if (command === "validate") {
299
+ const repoRoot = parseRepoArg(rest);
300
+ const store = new ReferencesStore(repoRoot);
301
+ const result = await store.validateManifest();
302
+ const payload = { status: result.ok ? "ok" : "error", command: "validate", ...result };
303
+ if (output === "json") {
304
+ printResult(output, payload);
305
+ }
306
+ else if (result.ok) {
307
+ console.log(`Manifest valid: artifacts=${String(result.artifact_count)}`);
308
+ if (result.warnings.length > 0) {
309
+ for (const warning of result.warnings) {
310
+ console.log(`warn: ${warning}`);
311
+ }
312
+ }
313
+ }
314
+ else {
315
+ console.error(`Manifest invalid: ${String(result.errors.length)} error(s)`);
316
+ for (const error of result.errors) {
317
+ console.error(`error: ${error}`);
318
+ }
319
+ for (const warning of result.warnings) {
320
+ console.error(`warn: ${warning}`);
321
+ }
322
+ }
323
+ return result.ok ? 0 : 1;
324
+ }
325
+ if (command === "summarize") {
326
+ const repoRoot = parseRepoArg(rest);
327
+ const store = new ReferencesStore(repoRoot);
328
+ const validation = await store.validateManifest();
329
+ if (!validation.ok) {
330
+ const payload = { status: "error", command: "summarize", ...validation };
331
+ if (output === "json") {
332
+ printResult(output, payload);
333
+ }
334
+ else {
335
+ console.error(`Cannot summarize: manifest invalid (${String(validation.errors.length)} error(s))`);
336
+ for (const error of validation.errors) {
337
+ console.error(`error: ${error}`);
338
+ }
339
+ }
340
+ return 1;
341
+ }
342
+ const summary = await summarizeArtifacts(store);
343
+ const payload = { status: "ok", command: "summarize", ...summary };
344
+ if (output === "json") {
345
+ printResult(output, payload);
346
+ }
347
+ else {
348
+ printSection("Themes", summary.themes);
349
+ console.log("");
350
+ printSection("Open Questions", summary.open_questions);
351
+ console.log("");
352
+ printSection("Candidate Changes", summary.candidate_changes);
353
+ console.log("");
354
+ console.log("Suggested Reffy References:");
355
+ if (summary.suggested_reffy_references.length === 0) {
356
+ console.log("- (none)");
357
+ }
358
+ else {
359
+ for (const reference of summary.suggested_reffy_references) {
360
+ console.log(`- ${reference.filename} - ${reference.reason}`);
361
+ }
362
+ }
363
+ }
364
+ return 0;
365
+ }
366
+ console.error(`Unknown command: ${command}`);
367
+ console.error(usage());
368
+ return 1;
369
+ }
370
+ void main().then((code) => {
371
+ process.exitCode = code;
372
+ }, (error) => {
373
+ console.error(String(error));
374
+ process.exitCode = 1;
375
+ });
@@ -0,0 +1,21 @@
1
+ type CheckLevel = "required" | "optional";
2
+ export interface DoctorCheck {
3
+ id: string;
4
+ level: CheckLevel;
5
+ ok: boolean;
6
+ message: string;
7
+ }
8
+ export interface DoctorReport {
9
+ checks: DoctorCheck[];
10
+ summary: {
11
+ required_total: number;
12
+ required_failed: number;
13
+ optional_total: number;
14
+ optional_failed: number;
15
+ };
16
+ }
17
+ export interface DoctorOptions {
18
+ checkOpenSpec?: () => boolean;
19
+ }
20
+ export declare function runDoctor(repoRoot: string, options?: DoctorOptions): Promise<DoctorReport>;
21
+ export {};
package/dist/doctor.js ADDED
@@ -0,0 +1,92 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { validateManifest } from "./manifest.js";
5
+ async function pathExists(targetPath) {
6
+ try {
7
+ await fs.access(targetPath);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function defaultOpenSpecCheck() {
15
+ const result = spawnSync("openspec", ["--version"], { stdio: "ignore" });
16
+ return result.error === undefined;
17
+ }
18
+ function summarizeChecks(checks) {
19
+ const required = checks.filter((check) => check.level === "required");
20
+ const optional = checks.filter((check) => check.level === "optional");
21
+ return {
22
+ required_total: required.length,
23
+ required_failed: required.filter((check) => !check.ok).length,
24
+ optional_total: optional.length,
25
+ optional_failed: optional.filter((check) => !check.ok).length,
26
+ };
27
+ }
28
+ export async function runDoctor(repoRoot, options) {
29
+ const checks = [];
30
+ const refsDir = path.join(repoRoot, ".references");
31
+ const artifactsDir = path.join(refsDir, "artifacts");
32
+ const manifestPath = path.join(refsDir, "manifest.json");
33
+ const rootAgentsPath = path.join(repoRoot, "AGENTS.md");
34
+ const refsAgentsPath = path.join(refsDir, "AGENTS.md");
35
+ const refsDirExists = await pathExists(refsDir);
36
+ checks.push({
37
+ id: "refs_dir_exists",
38
+ level: "required",
39
+ ok: refsDirExists,
40
+ message: refsDirExists ? ".references directory found" : ".references directory is missing",
41
+ });
42
+ const artifactsDirExists = await pathExists(artifactsDir);
43
+ checks.push({
44
+ id: "artifacts_dir_exists",
45
+ level: "required",
46
+ ok: artifactsDirExists,
47
+ message: artifactsDirExists ? ".references/artifacts directory found" : ".references/artifacts directory is missing",
48
+ });
49
+ const rootAgentsExists = await pathExists(rootAgentsPath);
50
+ checks.push({
51
+ id: "root_agents_exists",
52
+ level: "required",
53
+ ok: rootAgentsExists,
54
+ message: rootAgentsExists ? "AGENTS.md found" : "AGENTS.md is missing",
55
+ });
56
+ const refsAgentsExists = await pathExists(refsAgentsPath);
57
+ checks.push({
58
+ id: "refs_agents_exists",
59
+ level: "required",
60
+ ok: refsAgentsExists,
61
+ message: refsAgentsExists ? ".references/AGENTS.md found" : ".references/AGENTS.md is missing",
62
+ });
63
+ const manifestExists = await pathExists(manifestPath);
64
+ if (!manifestExists) {
65
+ checks.push({
66
+ id: "manifest_valid",
67
+ level: "required",
68
+ ok: false,
69
+ message: ".references/manifest.json is missing",
70
+ });
71
+ }
72
+ else {
73
+ const manifestResult = await validateManifest(manifestPath, artifactsDir);
74
+ checks.push({
75
+ id: "manifest_valid",
76
+ level: "required",
77
+ ok: manifestResult.ok,
78
+ message: manifestResult.ok
79
+ ? `.references/manifest.json is valid (artifacts=${String(manifestResult.artifact_count)})`
80
+ : `manifest invalid: ${manifestResult.errors.join("; ")}`,
81
+ });
82
+ }
83
+ const checkOpenSpec = options?.checkOpenSpec ?? defaultOpenSpecCheck;
84
+ const hasOpenSpec = checkOpenSpec();
85
+ checks.push({
86
+ id: "openspec_available",
87
+ level: "optional",
88
+ ok: hasOpenSpec,
89
+ message: hasOpenSpec ? "openspec command is available" : "openspec command not found on PATH",
90
+ });
91
+ return { checks, summary: summarizeChecks(checks) };
92
+ }
@@ -0,0 +1,3 @@
1
+ export { ReferencesStore } from "./storage.js";
2
+ export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
3
+ export { runDoctor } from "./doctor.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ReferencesStore } from "./storage.js";
2
+ export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
3
+ export { runDoctor } from "./doctor.js";
@@ -0,0 +1,15 @@
1
+ import type { Manifest } from "./types.js";
2
+ export declare const MANIFEST_VERSION = 1;
3
+ export declare function allowedKindExtensions(): Record<string, string[]>;
4
+ export declare function inferArtifactType(filePath: string): {
5
+ kind: string;
6
+ mime_type: string;
7
+ };
8
+ export interface ManifestValidationResult {
9
+ ok: boolean;
10
+ errors: string[];
11
+ warnings: string[];
12
+ artifact_count: number;
13
+ }
14
+ export declare function validateManifest(manifestPath: string, artifactsDir: string): Promise<ManifestValidationResult>;
15
+ export declare function isManifest(value: unknown): value is Manifest;
@@ -0,0 +1,161 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { lookup as mimeLookup } from "mime-types";
4
+ export const MANIFEST_VERSION = 1;
5
+ const KIND_EXTENSIONS = {
6
+ note: [".md"],
7
+ json: [".json"],
8
+ diagram: [".excalidraw"],
9
+ image: [".png", ".jpg", ".jpeg"],
10
+ html: [".html", ".htm"],
11
+ pdf: [".pdf"],
12
+ doc: [".doc", ".docx"],
13
+ file: [],
14
+ };
15
+ function isObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function isIsoDate(value) {
19
+ if (typeof value !== "string")
20
+ return false;
21
+ return !Number.isNaN(Date.parse(value));
22
+ }
23
+ function relativePathSafe(filename) {
24
+ if (path.isAbsolute(filename))
25
+ return false;
26
+ const normalized = path.normalize(filename);
27
+ return !normalized.startsWith("..") && !normalized.includes(`${path.sep}..${path.sep}`);
28
+ }
29
+ export function allowedKindExtensions() {
30
+ return Object.fromEntries(Object.entries(KIND_EXTENSIONS).map(([kind, extensions]) => [kind, [...extensions]]));
31
+ }
32
+ export function inferArtifactType(filePath) {
33
+ const ext = path.extname(filePath).toLowerCase();
34
+ if (ext === ".excalidraw")
35
+ return { kind: "diagram", mime_type: "application/json" };
36
+ if (ext === ".png")
37
+ return { kind: "image", mime_type: "image/png" };
38
+ if (ext === ".jpg" || ext === ".jpeg")
39
+ return { kind: "image", mime_type: "image/jpeg" };
40
+ if (ext === ".html" || ext === ".htm")
41
+ return { kind: "html", mime_type: "text/html" };
42
+ if (ext === ".pdf")
43
+ return { kind: "pdf", mime_type: "application/pdf" };
44
+ if (ext === ".doc")
45
+ return { kind: "doc", mime_type: "application/msword" };
46
+ if (ext === ".docx") {
47
+ return {
48
+ kind: "doc",
49
+ mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
50
+ };
51
+ }
52
+ if (ext === ".json")
53
+ return { kind: "json", mime_type: "application/json" };
54
+ if (ext === ".md")
55
+ return { kind: "note", mime_type: "text/markdown" };
56
+ const guessed = mimeLookup(path.basename(filePath)) || "application/octet-stream";
57
+ return { kind: "file", mime_type: guessed.toString() };
58
+ }
59
+ function validateArtifactShape(value, index, errors) {
60
+ if (!isObject(value)) {
61
+ errors.push(`artifacts[${index}] must be an object`);
62
+ return false;
63
+ }
64
+ const requiredStringFields = ["id", "name", "filename", "kind", "mime_type", "created_at", "updated_at"];
65
+ for (const field of requiredStringFields) {
66
+ if (typeof value[field] !== "string" || value[field].length === 0) {
67
+ errors.push(`artifacts[${index}].${field} must be a non-empty string`);
68
+ }
69
+ }
70
+ if (typeof value.size_bytes !== "number" || Number.isNaN(value.size_bytes) || value.size_bytes < 0) {
71
+ errors.push(`artifacts[${index}].size_bytes must be a non-negative number`);
72
+ }
73
+ if (!Array.isArray(value.tags) || value.tags.some((tag) => typeof tag !== "string")) {
74
+ errors.push(`artifacts[${index}].tags must be an array of strings`);
75
+ }
76
+ if (!isIsoDate(value.created_at)) {
77
+ errors.push(`artifacts[${index}].created_at must be an ISO timestamp`);
78
+ }
79
+ if (!isIsoDate(value.updated_at)) {
80
+ errors.push(`artifacts[${index}].updated_at must be an ISO timestamp`);
81
+ }
82
+ return true;
83
+ }
84
+ export async function validateManifest(manifestPath, artifactsDir) {
85
+ const errors = [];
86
+ const warnings = [];
87
+ let raw;
88
+ try {
89
+ raw = JSON.parse(await fs.readFile(manifestPath, "utf8"));
90
+ }
91
+ catch (error) {
92
+ return {
93
+ ok: false,
94
+ errors: [`manifest read/parse failed: ${String(error)}`],
95
+ warnings,
96
+ artifact_count: 0,
97
+ };
98
+ }
99
+ if (!isObject(raw)) {
100
+ return { ok: false, errors: ["manifest root must be an object"], warnings, artifact_count: 0 };
101
+ }
102
+ if (raw.version !== MANIFEST_VERSION) {
103
+ errors.push(`version must be ${String(MANIFEST_VERSION)}`);
104
+ }
105
+ if (!isIsoDate(raw.created_at)) {
106
+ errors.push("created_at must be an ISO timestamp");
107
+ }
108
+ if (!isIsoDate(raw.updated_at)) {
109
+ errors.push("updated_at must be an ISO timestamp");
110
+ }
111
+ if (!Array.isArray(raw.artifacts)) {
112
+ errors.push("artifacts must be an array");
113
+ }
114
+ const artifacts = Array.isArray(raw.artifacts) ? raw.artifacts : [];
115
+ const seenIds = new Set();
116
+ const seenFiles = new Set();
117
+ const kinds = new Set(Object.keys(KIND_EXTENSIONS));
118
+ for (let index = 0; index < artifacts.length; index += 1) {
119
+ const item = artifacts[index];
120
+ if (!validateArtifactShape(item, index, errors))
121
+ continue;
122
+ if (seenIds.has(item.id))
123
+ errors.push(`duplicate artifact id: ${item.id}`);
124
+ seenIds.add(item.id);
125
+ if (seenFiles.has(item.filename))
126
+ errors.push(`duplicate artifact filename: ${item.filename}`);
127
+ seenFiles.add(item.filename);
128
+ if (!relativePathSafe(item.filename)) {
129
+ errors.push(`artifacts[${index}].filename must be a safe relative path`);
130
+ continue;
131
+ }
132
+ if (!kinds.has(item.kind)) {
133
+ errors.push(`artifacts[${index}].kind must be one of: ${Array.from(kinds).join(", ")}`);
134
+ }
135
+ else {
136
+ const ext = path.extname(item.filename).toLowerCase();
137
+ const allowed = KIND_EXTENSIONS[item.kind];
138
+ if (allowed.length > 0 && !allowed.includes(ext)) {
139
+ errors.push(`artifacts[${index}] kind "${item.kind}" requires one of: ${allowed.join(", ")}`);
140
+ }
141
+ }
142
+ const artifactPath = path.join(artifactsDir, item.filename);
143
+ const stat = await fs.stat(artifactPath).catch(() => null);
144
+ if (!stat || !stat.isFile()) {
145
+ errors.push(`artifacts[${index}] file is missing: ${item.filename}`);
146
+ continue;
147
+ }
148
+ if (stat.size !== item.size_bytes) {
149
+ warnings.push(`artifacts[${index}] size_bytes (${String(item.size_bytes)}) differs from disk size (${String(stat.size)})`);
150
+ }
151
+ }
152
+ return {
153
+ ok: errors.length === 0,
154
+ errors,
155
+ warnings,
156
+ artifact_count: artifacts.length,
157
+ };
158
+ }
159
+ export function isManifest(value) {
160
+ return isObject(value) && Array.isArray(value.artifacts) && value.version === MANIFEST_VERSION;
161
+ }
@@ -0,0 +1,38 @@
1
+ import type { Artifact } from "./types.js";
2
+ export declare class ReferencesStore {
3
+ readonly repoRoot: string;
4
+ readonly refsDir: string;
5
+ readonly artifactsDir: string;
6
+ readonly manifestPath: string;
7
+ constructor(repoRoot: string);
8
+ private ensureStructure;
9
+ private emptyManifest;
10
+ private readManifest;
11
+ private writeManifest;
12
+ listArtifacts(): Promise<Artifact[]>;
13
+ getArtifact(artifactId: string): Promise<Artifact | null>;
14
+ getArtifactPath(artifact: Artifact): string;
15
+ private slugify;
16
+ private uniqueFilename;
17
+ createArtifact(input: {
18
+ name: string;
19
+ content?: string | null;
20
+ kind?: string | null;
21
+ mime_type?: string | null;
22
+ tags?: string[] | null;
23
+ }): Promise<Artifact>;
24
+ reindexArtifacts(): Promise<{
25
+ added: number;
26
+ removed: number;
27
+ total: number;
28
+ }>;
29
+ validateManifest(): Promise<import("./manifest.js").ManifestValidationResult>;
30
+ updateArtifact(artifactId: string, input: {
31
+ name?: string | null;
32
+ content?: string | null;
33
+ kind?: string | null;
34
+ mime_type?: string | null;
35
+ tags?: string[] | null;
36
+ }): Promise<Artifact | null>;
37
+ deleteArtifact(artifactId: string): Promise<boolean>;
38
+ }
@@ -0,0 +1,238 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { inferArtifactType, MANIFEST_VERSION, validateManifest } from "./manifest.js";
6
+ function utcNow() {
7
+ return new Date().toISOString();
8
+ }
9
+ function isObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ export class ReferencesStore {
13
+ repoRoot;
14
+ refsDir;
15
+ artifactsDir;
16
+ manifestPath;
17
+ constructor(repoRoot) {
18
+ this.repoRoot = repoRoot;
19
+ this.refsDir = path.join(repoRoot, ".references");
20
+ this.artifactsDir = path.join(this.refsDir, "artifacts");
21
+ this.manifestPath = path.join(this.refsDir, "manifest.json");
22
+ this.ensureStructure();
23
+ }
24
+ ensureStructure() {
25
+ mkdirSync(this.refsDir, { recursive: true });
26
+ mkdirSync(this.artifactsDir, { recursive: true });
27
+ void fs.access(this.manifestPath).catch(async () => {
28
+ await this.writeManifest(this.emptyManifest());
29
+ });
30
+ }
31
+ emptyManifest() {
32
+ const now = utcNow();
33
+ return {
34
+ version: MANIFEST_VERSION,
35
+ created_at: now,
36
+ updated_at: now,
37
+ artifacts: [],
38
+ };
39
+ }
40
+ async readManifest() {
41
+ let raw;
42
+ try {
43
+ const text = await fs.readFile(this.manifestPath, "utf8");
44
+ raw = JSON.parse(text);
45
+ }
46
+ catch {
47
+ return this.emptyManifest();
48
+ }
49
+ if (Array.isArray(raw)) {
50
+ return {
51
+ version: 0,
52
+ created_at: utcNow(),
53
+ updated_at: utcNow(),
54
+ artifacts: raw,
55
+ };
56
+ }
57
+ if (!isObject(raw)) {
58
+ return this.emptyManifest();
59
+ }
60
+ const artifacts = Array.isArray(raw.artifacts) ? raw.artifacts : [];
61
+ return {
62
+ version: typeof raw.version === "number" ? raw.version : MANIFEST_VERSION,
63
+ created_at: typeof raw.created_at === "string" ? raw.created_at : utcNow(),
64
+ updated_at: typeof raw.updated_at === "string" ? raw.updated_at : utcNow(),
65
+ artifacts,
66
+ };
67
+ }
68
+ async writeManifest(manifest) {
69
+ await fs.writeFile(this.manifestPath, JSON.stringify(manifest, null, 2));
70
+ }
71
+ async listArtifacts() {
72
+ const manifest = await this.readManifest();
73
+ return manifest.artifacts;
74
+ }
75
+ async getArtifact(artifactId) {
76
+ const manifest = await this.readManifest();
77
+ return manifest.artifacts.find((item) => item.id === artifactId) ?? null;
78
+ }
79
+ getArtifactPath(artifact) {
80
+ return path.join(this.artifactsDir, artifact.filename);
81
+ }
82
+ slugify(name) {
83
+ const cleaned = name
84
+ .split("")
85
+ .filter((ch) => /[\w\- ]/.test(ch))
86
+ .join("")
87
+ .trim()
88
+ .replace(/\s+/g, "-")
89
+ .toLowerCase();
90
+ return cleaned || "untitled";
91
+ }
92
+ async uniqueFilename(base, ext = ".md") {
93
+ let candidate = `${base}${ext}`;
94
+ let counter = 2;
95
+ while (true) {
96
+ try {
97
+ await fs.access(path.join(this.artifactsDir, candidate));
98
+ candidate = `${base}-${counter}${ext}`;
99
+ counter += 1;
100
+ }
101
+ catch {
102
+ return candidate;
103
+ }
104
+ }
105
+ }
106
+ async createArtifact(input) {
107
+ const id = randomUUID();
108
+ const safe = this.slugify(input.name);
109
+ const filename = await this.uniqueFilename(safe, ".md");
110
+ const now = utcNow();
111
+ const artifactPath = path.join(this.artifactsDir, filename);
112
+ if (input.content !== undefined && input.content !== null) {
113
+ await fs.writeFile(artifactPath, input.content, "utf8");
114
+ }
115
+ let sizeBytes = 0;
116
+ try {
117
+ const stat = await fs.stat(artifactPath);
118
+ sizeBytes = stat.size;
119
+ }
120
+ catch {
121
+ sizeBytes = 0;
122
+ }
123
+ const artifact = {
124
+ id,
125
+ name: input.name,
126
+ filename,
127
+ kind: input.kind ?? "note",
128
+ mime_type: input.mime_type ?? "text/markdown",
129
+ size_bytes: sizeBytes,
130
+ tags: input.tags ?? [],
131
+ created_at: now,
132
+ updated_at: now,
133
+ };
134
+ const manifest = await this.readManifest();
135
+ manifest.updated_at = utcNow();
136
+ manifest.artifacts.push(artifact);
137
+ await this.writeManifest(manifest);
138
+ return artifact;
139
+ }
140
+ async reindexArtifacts() {
141
+ const manifest = await this.readManifest();
142
+ const entries = await fs.readdir(this.artifactsDir, { withFileTypes: true });
143
+ const filesOnDisk = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
144
+ const beforeCount = manifest.artifacts.length;
145
+ manifest.artifacts = manifest.artifacts.filter((artifact) => filesOnDisk.has(artifact.filename));
146
+ const removed = beforeCount - manifest.artifacts.length;
147
+ const artifactsByFilename = new Map(manifest.artifacts.map((artifact) => [artifact.filename, artifact]));
148
+ const known = new Set(manifest.artifacts.map((a) => a.filename));
149
+ let added = 0;
150
+ let updated = 0;
151
+ for (const entry of entries) {
152
+ if (!entry.isFile())
153
+ continue;
154
+ const filePath = path.join(this.artifactsDir, entry.name);
155
+ const stats = await fs.stat(filePath);
156
+ const inferred = inferArtifactType(filePath);
157
+ if (known.has(entry.name)) {
158
+ const artifact = artifactsByFilename.get(entry.name);
159
+ if (!artifact)
160
+ continue;
161
+ const previousUpdated = Date.parse(artifact.updated_at);
162
+ const changedOnDisk = artifact.size_bytes !== stats.size ||
163
+ artifact.kind !== inferred.kind ||
164
+ artifact.mime_type !== inferred.mime_type ||
165
+ Number.isNaN(previousUpdated) ||
166
+ previousUpdated < stats.mtimeMs;
167
+ if (changedOnDisk) {
168
+ artifact.size_bytes = stats.size;
169
+ artifact.kind = inferred.kind;
170
+ artifact.mime_type = inferred.mime_type;
171
+ artifact.updated_at = stats.mtime.toISOString();
172
+ updated += 1;
173
+ }
174
+ continue;
175
+ }
176
+ const now = utcNow();
177
+ const artifact = {
178
+ id: randomUUID(),
179
+ name: path.basename(entry.name, path.extname(entry.name)).replace(/-/g, " ").trim() || "untitled",
180
+ filename: entry.name,
181
+ kind: inferred.kind,
182
+ mime_type: inferred.mime_type,
183
+ size_bytes: stats.size,
184
+ tags: [],
185
+ created_at: now,
186
+ updated_at: now,
187
+ };
188
+ manifest.artifacts.push(artifact);
189
+ added += 1;
190
+ }
191
+ if (added > 0 || removed > 0 || updated > 0) {
192
+ manifest.updated_at = utcNow();
193
+ await this.writeManifest(manifest);
194
+ }
195
+ return { added, removed, total: manifest.artifacts.length };
196
+ }
197
+ async validateManifest() {
198
+ return validateManifest(this.manifestPath, this.artifactsDir);
199
+ }
200
+ async updateArtifact(artifactId, input) {
201
+ const manifest = await this.readManifest();
202
+ const index = manifest.artifacts.findIndex((item) => item.id === artifactId);
203
+ if (index === -1)
204
+ return null;
205
+ const item = manifest.artifacts[index];
206
+ if (input.name !== undefined && input.name !== null)
207
+ item.name = input.name;
208
+ if (input.kind !== undefined && input.kind !== null)
209
+ item.kind = input.kind;
210
+ if (input.mime_type !== undefined && input.mime_type !== null)
211
+ item.mime_type = input.mime_type;
212
+ if (input.tags !== undefined && input.tags !== null)
213
+ item.tags = input.tags;
214
+ if (input.content !== undefined && input.content !== null) {
215
+ const artifactPath = this.getArtifactPath(item);
216
+ await fs.writeFile(artifactPath, input.content, "utf8");
217
+ const stat = await fs.stat(artifactPath);
218
+ item.size_bytes = stat.size;
219
+ }
220
+ item.updated_at = utcNow();
221
+ manifest.updated_at = utcNow();
222
+ manifest.artifacts[index] = item;
223
+ await this.writeManifest(manifest);
224
+ return item;
225
+ }
226
+ async deleteArtifact(artifactId) {
227
+ const manifest = await this.readManifest();
228
+ const index = manifest.artifacts.findIndex((item) => item.id === artifactId);
229
+ if (index === -1)
230
+ return false;
231
+ const [removed] = manifest.artifacts.splice(index, 1);
232
+ const artifactPath = this.getArtifactPath(removed);
233
+ await fs.rm(artifactPath, { force: true });
234
+ manifest.updated_at = utcNow();
235
+ await this.writeManifest(manifest);
236
+ return true;
237
+ }
238
+ }
@@ -0,0 +1,17 @@
1
+ import type { Artifact } from "./types.js";
2
+ export interface SuggestedReference {
3
+ filename: string;
4
+ reason: string;
5
+ }
6
+ export interface ArtifactSummary {
7
+ themes: string[];
8
+ open_questions: string[];
9
+ candidate_changes: string[];
10
+ suggested_reffy_references: SuggestedReference[];
11
+ }
12
+ interface StoreReader {
13
+ listArtifacts(): Promise<Artifact[]>;
14
+ getArtifactPath(artifact: Artifact): string;
15
+ }
16
+ export declare function summarizeArtifacts(store: StoreReader): Promise<ArtifactSummary>;
17
+ export {};
@@ -0,0 +1,107 @@
1
+ import { promises as fs } from "node:fs";
2
+ const GENERIC_HEADINGS = new Set([
3
+ "problem",
4
+ "proposed feature",
5
+ "scope",
6
+ "scope (small)",
7
+ "why it fits reffy",
8
+ "ux sketch",
9
+ "acceptance criteria",
10
+ "follow-up",
11
+ "follow-up (optional)",
12
+ ]);
13
+ function normalizeLine(line) {
14
+ return line.replace(/`/g, "").replace(/\s+/g, " ").trim();
15
+ }
16
+ function pushUnique(list, value) {
17
+ const next = normalizeLine(value);
18
+ if (next.length === 0)
19
+ return;
20
+ if (!list.includes(next))
21
+ list.push(next);
22
+ }
23
+ function isLikelyNaturalLanguageQuestion(line) {
24
+ if (!line.includes("?"))
25
+ return false;
26
+ if (/[{}\[\]]/.test(line))
27
+ return false;
28
+ if (/["']\s*:/.test(line))
29
+ return false;
30
+ return true;
31
+ }
32
+ function headingValue(line) {
33
+ const match = line.match(/^#{1,6}\s+(.+)$/);
34
+ if (!match)
35
+ return null;
36
+ return normalizeLine(match[1] ?? "");
37
+ }
38
+ function summarizeArtifactContent(artifact, content, themeOut, questionsOut, changesOut) {
39
+ let currentSection = "";
40
+ let hasQuestion = false;
41
+ const lines = content.split(/\r?\n/);
42
+ for (const rawLine of lines) {
43
+ const line = rawLine.trim();
44
+ if (line.length === 0)
45
+ continue;
46
+ const heading = headingValue(line);
47
+ if (heading) {
48
+ currentSection = heading.toLowerCase();
49
+ const generic = GENERIC_HEADINGS.has(currentSection);
50
+ if (!generic) {
51
+ const cleaned = heading.replace(/^feature idea:\s*/i, "");
52
+ pushUnique(themeOut, cleaned);
53
+ }
54
+ continue;
55
+ }
56
+ if (isLikelyNaturalLanguageQuestion(line)) {
57
+ hasQuestion = true;
58
+ pushUnique(questionsOut, line.replace(/^[*-]\s*/, ""));
59
+ }
60
+ const commandMatches = line.match(/`(reffy [^`]+)`/g) ?? [];
61
+ for (const cmd of commandMatches) {
62
+ const cleanCmd = cmd.replace(/`/g, "");
63
+ pushUnique(changesOut, `Introduce ${cleanCmd}`);
64
+ }
65
+ if (currentSection.includes("proposed feature") && line.startsWith("- ") && !line.includes("`reffy ")) {
66
+ pushUnique(changesOut, line.replace(/^-+\s*/, ""));
67
+ }
68
+ }
69
+ if (themeOut.length === 0) {
70
+ pushUnique(themeOut, artifact.name);
71
+ }
72
+ if (content.toLowerCase().includes("feature idea")) {
73
+ return "feature ideation and rationale";
74
+ }
75
+ if (hasQuestion) {
76
+ return "open questions and constraints";
77
+ }
78
+ return "exploratory context note";
79
+ }
80
+ export async function summarizeArtifacts(store) {
81
+ const artifacts = await store.listArtifacts();
82
+ const themes = [];
83
+ const openQuestions = [];
84
+ const candidateChanges = [];
85
+ const references = [];
86
+ for (const artifact of artifacts) {
87
+ const path = store.getArtifactPath(artifact);
88
+ let content = "";
89
+ try {
90
+ content = await fs.readFile(path, "utf8");
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ const reason = summarizeArtifactContent(artifact, content, themes, openQuestions, candidateChanges);
96
+ references.push({
97
+ filename: artifact.filename,
98
+ reason,
99
+ });
100
+ }
101
+ return {
102
+ themes: themes.slice(0, 8),
103
+ open_questions: openQuestions.slice(0, 8),
104
+ candidate_changes: candidateChanges.slice(0, 8),
105
+ suggested_reffy_references: references,
106
+ };
107
+ }
@@ -0,0 +1,17 @@
1
+ export interface Artifact {
2
+ id: string;
3
+ name: string;
4
+ filename: string;
5
+ kind: string;
6
+ mime_type: string;
7
+ size_bytes: number;
8
+ tags: string[];
9
+ created_at: string;
10
+ updated_at: string;
11
+ }
12
+ export interface Manifest {
13
+ version: number;
14
+ created_at: string;
15
+ updated_at: string;
16
+ artifacts: Artifact[];
17
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "reffy-cli",
3
+ "version": "0.4.0",
4
+ "description": "CLI-first, framework-agnostic references workflow for any repo (TypeScript)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "files": [
9
+ "dist/",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "bin": {
14
+ "reffy": "dist/cli.js"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepare": "npm run build",
19
+ "dev": "tsx watch src/cli.ts",
20
+ "check": "tsc --noEmit",
21
+ "test": "npm run build && vitest run --coverage",
22
+ "test:watch": "vitest"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "dependencies": {
28
+ "mime-types": "^2.1.35",
29
+ "uuid": "^11.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/mime-types": "^2.1.4",
33
+ "@types/node": "^24.3.0",
34
+ "@vitest/coverage-v8": "^3.2.4",
35
+ "tsx": "^4.20.5",
36
+ "typescript": "^5.9.2",
37
+ "vitest": "^3.2.4"
38
+ }
39
+ }