@nick848/fet 0.1.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 +21 -0
- package/README.md +44 -0
- package/dist/apply.d.ts +1 -0
- package/dist/apply.js +172 -0
- package/dist/approval.d.ts +2 -0
- package/dist/approval.js +26 -0
- package/dist/atomic-write.d.ts +5 -0
- package/dist/atomic-write.js +41 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +178 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +93 -0
- package/dist/fingerprint.d.ts +6 -0
- package/dist/fingerprint.js +77 -0
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +47 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +47 -0
- package/dist/opencode-skills.d.ts +3 -0
- package/dist/opencode-skills.js +236 -0
- package/dist/openspec.d.ts +16 -0
- package/dist/openspec.js +73 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +20 -0
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +30 -0
- package/dist/scanner.d.ts +23 -0
- package/dist/scanner.js +352 -0
- package/dist/skills.d.ts +3 -0
- package/dist/skills.js +142 -0
- package/dist/state.d.ts +17 -0
- package/dist/state.js +126 -0
- package/dist/tasks.d.ts +13 -0
- package/dist/tasks.js +69 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +1 -0
- package/dist/validate.js +150 -0
- package/dist/verify.d.ts +6 -0
- package/dist/verify.js +193 -0
- package/dist/watch-paths.d.ts +2 -0
- package/dist/watch-paths.js +70 -0
- package/dist/workflow-hints.d.ts +2 -0
- package/dist/workflow-hints.js +9 -0
- package/package.json +49 -0
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { CONFIG_FILE } from "./paths.js";
|
|
5
|
+
const SENSITIVE = [
|
|
6
|
+
/AKIA[0-9A-Z]{16}/,
|
|
7
|
+
/BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY/,
|
|
8
|
+
/api[_-]?key\s*[:=]\s*['"][^'"]+['"]/i,
|
|
9
|
+
/https?:\/\/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/,
|
|
10
|
+
];
|
|
11
|
+
const AGENTS_MARK_START = "<!-- FET:AUTO_START -->";
|
|
12
|
+
const AGENTS_MARK_END = "<!-- FET:AUTO_END -->";
|
|
13
|
+
const CONFIG_START = "# FET_MANAGED_START";
|
|
14
|
+
const CONFIG_END = "# FET_MANAGED_END";
|
|
15
|
+
const ROUTE_CANDIDATES = [
|
|
16
|
+
"src/router/index.ts",
|
|
17
|
+
"src/router.ts",
|
|
18
|
+
"src/routes.tsx",
|
|
19
|
+
"src/App.tsx",
|
|
20
|
+
"app/router.options.ts",
|
|
21
|
+
"next.config.js",
|
|
22
|
+
"next.config.mjs",
|
|
23
|
+
"next.config.ts",
|
|
24
|
+
"nuxt.config.ts",
|
|
25
|
+
"svelte.config.js",
|
|
26
|
+
"src/routes/+layout.svelte",
|
|
27
|
+
];
|
|
28
|
+
function detectFromDeps(deps) {
|
|
29
|
+
let framework = "[NEEDS LLM INPUT]";
|
|
30
|
+
if (deps.next)
|
|
31
|
+
framework = "Next.js";
|
|
32
|
+
else if (deps.nuxt)
|
|
33
|
+
framework = "Nuxt";
|
|
34
|
+
else if (deps.svelte || deps["@sveltejs/kit"])
|
|
35
|
+
framework = "SvelteKit";
|
|
36
|
+
else if (deps.vue || deps["@vitejs/plugin-vue"])
|
|
37
|
+
framework = "Vue";
|
|
38
|
+
else if (deps.react || deps["@vitejs/plugin-react"])
|
|
39
|
+
framework = "React";
|
|
40
|
+
else if (deps["@angular/core"])
|
|
41
|
+
framework = "Angular";
|
|
42
|
+
const language = deps.typescript || deps["@types/node"] ? "TypeScript" : "JavaScript";
|
|
43
|
+
let bundler = "[NEEDS LLM INPUT]";
|
|
44
|
+
if (deps.vite || deps["@vitejs/plugin-react"] || deps["@vitejs/plugin-vue"])
|
|
45
|
+
bundler = "Vite";
|
|
46
|
+
else if (deps.webpack)
|
|
47
|
+
bundler = "webpack";
|
|
48
|
+
else if (deps["@rspack/core"])
|
|
49
|
+
bundler = "Rspack";
|
|
50
|
+
else if (deps.rollup)
|
|
51
|
+
bundler = "Rollup";
|
|
52
|
+
else if (deps.next || deps.nuxt)
|
|
53
|
+
bundler = "(framework default)";
|
|
54
|
+
let state = "[NEEDS LLM INPUT]";
|
|
55
|
+
if (deps["@reduxjs/toolkit"] || deps.redux)
|
|
56
|
+
state = "Redux";
|
|
57
|
+
else if (deps.pinia)
|
|
58
|
+
state = "Pinia";
|
|
59
|
+
else if (deps["@tanstack/react-query"])
|
|
60
|
+
state = "TanStack Query";
|
|
61
|
+
else if (deps.zustand)
|
|
62
|
+
state = "Zustand";
|
|
63
|
+
else if (deps.mobx)
|
|
64
|
+
state = "MobX";
|
|
65
|
+
let ui = "[NEEDS LLM INPUT]";
|
|
66
|
+
if (deps["@mui/material"])
|
|
67
|
+
ui = "MUI";
|
|
68
|
+
else if (deps["antd"])
|
|
69
|
+
ui = "Ant Design";
|
|
70
|
+
else if (deps["@chakra-ui/react"])
|
|
71
|
+
ui = "Chakra";
|
|
72
|
+
else if (deps["@radix-ui/react-dialog"])
|
|
73
|
+
ui = "Radix";
|
|
74
|
+
else if (deps["element-plus"])
|
|
75
|
+
ui = "Element Plus";
|
|
76
|
+
else if (deps.tailwindcss)
|
|
77
|
+
ui = "Tailwind CSS";
|
|
78
|
+
return { framework, language, bundler, state, ui };
|
|
79
|
+
}
|
|
80
|
+
function shallowTree(cwd, maxDepth, maxEntries) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
const walk = (dir, depth, prefix) => {
|
|
83
|
+
if (lines.length >= maxEntries || depth > maxDepth)
|
|
84
|
+
return;
|
|
85
|
+
let entries = [];
|
|
86
|
+
try {
|
|
87
|
+
entries = readdirSync(dir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const name of entries.sort()) {
|
|
93
|
+
if (lines.length >= maxEntries)
|
|
94
|
+
break;
|
|
95
|
+
if (name === "node_modules" || name === ".git" || name === "dist" || name === "build")
|
|
96
|
+
continue;
|
|
97
|
+
const p = join(dir, name);
|
|
98
|
+
let isDir = false;
|
|
99
|
+
try {
|
|
100
|
+
isDir = statSync(p).isDirectory();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const rel = relative(cwd, p) || ".";
|
|
106
|
+
lines.push(`${prefix}${isDir ? "📁 " : "📄 "}${rel.replace(/\\/g, "/")}`);
|
|
107
|
+
if (isDir && depth < maxDepth)
|
|
108
|
+
walk(p, depth + 1, prefix + " ");
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
walk(cwd, 0, "");
|
|
112
|
+
return lines.length ? lines.join("\n") : "[empty or unreadable]";
|
|
113
|
+
}
|
|
114
|
+
function buildScriptsTable(pkg) {
|
|
115
|
+
const s = pkg.scripts;
|
|
116
|
+
if (!s || !Object.keys(s).length)
|
|
117
|
+
return "_No scripts in package.json_";
|
|
118
|
+
const rows = Object.entries(s)
|
|
119
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
120
|
+
.map(([k, v]) => `| \`${k}\` | \`${String(v).replace(/\|/g, "\\|")}\` |`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
return ["| Script | Command |", "| --- | --- |", rows].join("\n");
|
|
123
|
+
}
|
|
124
|
+
export function scanProject(cwd) {
|
|
125
|
+
const warnings = [];
|
|
126
|
+
const pkgPath = join(cwd, "package.json");
|
|
127
|
+
let projectName = "";
|
|
128
|
+
let description = "";
|
|
129
|
+
let frameworkGuess = "[NEEDS LLM INPUT]";
|
|
130
|
+
let languageGuess = "[NEEDS LLM INPUT]";
|
|
131
|
+
let bundlerGuess = "[NEEDS LLM INPUT]";
|
|
132
|
+
let stateManagementGuess = "[NEEDS LLM INPUT]";
|
|
133
|
+
let uiLibraryGuess = "[NEEDS LLM INPUT]";
|
|
134
|
+
let scriptsTable = "";
|
|
135
|
+
let testCommand = "[NEEDS LLM INPUT]";
|
|
136
|
+
const deps = {};
|
|
137
|
+
if (existsSync(pkgPath)) {
|
|
138
|
+
try {
|
|
139
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
140
|
+
projectName = pkg.name ?? "";
|
|
141
|
+
description = pkg.description ?? "";
|
|
142
|
+
Object.assign(deps, pkg.dependencies, pkg.devDependencies);
|
|
143
|
+
const d = detectFromDeps(deps);
|
|
144
|
+
frameworkGuess = d.framework;
|
|
145
|
+
languageGuess = d.language;
|
|
146
|
+
bundlerGuess = d.bundler;
|
|
147
|
+
stateManagementGuess = d.state;
|
|
148
|
+
uiLibraryGuess = d.ui;
|
|
149
|
+
scriptsTable = buildScriptsTable(pkg);
|
|
150
|
+
if (pkg.scripts?.test)
|
|
151
|
+
testCommand = pkg.scripts.test;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
warnings.push("package.json parse failed");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let readmeExcerpt = "";
|
|
158
|
+
const readme = join(cwd, "README.md");
|
|
159
|
+
if (existsSync(readme)) {
|
|
160
|
+
const text = readFileSync(readme, "utf8");
|
|
161
|
+
for (const re of SENSITIVE) {
|
|
162
|
+
if (re.test(text))
|
|
163
|
+
warnings.push("Possible sensitive pattern in README — review before sharing (DESIGN 14.2).");
|
|
164
|
+
}
|
|
165
|
+
readmeExcerpt = text.split(/\r?\n/).slice(0, 45).join("\n");
|
|
166
|
+
}
|
|
167
|
+
const foundRoutes = [];
|
|
168
|
+
for (const r of ROUTE_CANDIDATES) {
|
|
169
|
+
if (existsSync(join(cwd, r)))
|
|
170
|
+
foundRoutes.push(r);
|
|
171
|
+
}
|
|
172
|
+
const routeFiles = foundRoutes.length > 0
|
|
173
|
+
? foundRoutes.map((r) => `- \`${r}\` (heuristic; verify)\n`).join("")
|
|
174
|
+
: "_No common route entry files detected — [NEEDS LLM INPUT]_";
|
|
175
|
+
const i18nPaths = ["src/locales", "public/locales", "locales", "i18n", "messages"].filter((p) => existsSync(join(cwd, p)));
|
|
176
|
+
const i18nHint = i18nPaths.length > 0
|
|
177
|
+
? `Detected paths: ${i18nPaths.map((p) => `\`${p}\``).join(", ")}`
|
|
178
|
+
: "[NEEDS LLM INPUT] — add i18n dirs or run update-context after setup";
|
|
179
|
+
const eslintHint = existsSync(join(cwd, ".eslintrc.cjs"))
|
|
180
|
+
? "`.eslintrc.cjs` present"
|
|
181
|
+
: existsSync(join(cwd, "eslint.config.js"))
|
|
182
|
+
? "`eslint.config.js` (flat) present"
|
|
183
|
+
: existsSync(join(cwd, ".eslintrc.json"))
|
|
184
|
+
? "`.eslintrc.json` present"
|
|
185
|
+
: "[NEEDS LLM INPUT] — no common ESLint config filename found";
|
|
186
|
+
const tsconfigHint = existsSync(join(cwd, "tsconfig.json"))
|
|
187
|
+
? "`tsconfig.json` present — use `npx tsc --noEmit` for typecheck"
|
|
188
|
+
: "_No root tsconfig — JS project or nested TSconfigs [NEEDS LLM INPUT]_";
|
|
189
|
+
const projectTree = shallowTree(cwd, 2, 80);
|
|
190
|
+
return {
|
|
191
|
+
projectName,
|
|
192
|
+
description,
|
|
193
|
+
frameworkGuess,
|
|
194
|
+
languageGuess,
|
|
195
|
+
bundlerGuess,
|
|
196
|
+
stateManagementGuess,
|
|
197
|
+
uiLibraryGuess,
|
|
198
|
+
readmeExcerpt,
|
|
199
|
+
scriptsTable,
|
|
200
|
+
testCommand,
|
|
201
|
+
eslintHint,
|
|
202
|
+
tsconfigHint,
|
|
203
|
+
routeFiles,
|
|
204
|
+
i18nHint,
|
|
205
|
+
projectTree,
|
|
206
|
+
warnings,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const agentsTpl = Handlebars.compile(`# AGENTS — project context (FET)
|
|
210
|
+
|
|
211
|
+
> Auto-generated section is between HTML comment markers. Do not remove markers.
|
|
212
|
+
|
|
213
|
+
<!-- FET:AUTO_START -->
|
|
214
|
+
## Overview
|
|
215
|
+
| Field | Value |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| Project | {{projectName}} |
|
|
218
|
+
| Description | {{description}} |
|
|
219
|
+
| Framework (guess) | {{frameworkGuess}} |
|
|
220
|
+
| Language (guess) | {{languageGuess}} |
|
|
221
|
+
| Bundler (guess) | {{bundlerGuess}} |
|
|
222
|
+
| State (guess) | {{stateManagementGuess}} |
|
|
223
|
+
| UI (guess) | {{uiLibraryGuess}} |
|
|
224
|
+
|
|
225
|
+
## Run commands (from package.json)
|
|
226
|
+
{{scriptsTable}}
|
|
227
|
+
|
|
228
|
+
**Suggested test command:** \`{{testCommand}}\`
|
|
229
|
+
|
|
230
|
+
## Project tree (depth 2, truncated)
|
|
231
|
+
\`\`\`
|
|
232
|
+
{{projectTree}}
|
|
233
|
+
\`\`\`
|
|
234
|
+
|
|
235
|
+
## README excerpt
|
|
236
|
+
\`\`\`
|
|
237
|
+
{{readmeExcerpt}}
|
|
238
|
+
\`\`\`
|
|
239
|
+
|
|
240
|
+
## Routing (heuristic)
|
|
241
|
+
{{routeFiles}}
|
|
242
|
+
|
|
243
|
+
## i18n
|
|
244
|
+
{{i18nHint}}
|
|
245
|
+
|
|
246
|
+
## Code quality signals
|
|
247
|
+
- ESLint: {{eslintHint}}
|
|
248
|
+
- TypeScript: {{tsconfigHint}}
|
|
249
|
+
|
|
250
|
+
## Terminology / business rules
|
|
251
|
+
[NEEDS LLM INPUT]
|
|
252
|
+
|
|
253
|
+
## Core modules
|
|
254
|
+
[NEEDS LLM INPUT]
|
|
255
|
+
|
|
256
|
+
## Notes
|
|
257
|
+
- FET does not execute LLMs; use your AI tool to fill sections marked [NEEDS LLM INPUT].
|
|
258
|
+
<!-- FET:AUTO_END -->
|
|
259
|
+
|
|
260
|
+
## Human / LLM additions
|
|
261
|
+
[NEEDS LLM INPUT]
|
|
262
|
+
`);
|
|
263
|
+
export function renderAgents(scan) {
|
|
264
|
+
return agentsTpl({
|
|
265
|
+
projectName: scan.projectName || "[unknown]",
|
|
266
|
+
description: scan.description || "[none]",
|
|
267
|
+
frameworkGuess: scan.frameworkGuess,
|
|
268
|
+
languageGuess: scan.languageGuess,
|
|
269
|
+
bundlerGuess: scan.bundlerGuess,
|
|
270
|
+
stateManagementGuess: scan.stateManagementGuess,
|
|
271
|
+
uiLibraryGuess: scan.uiLibraryGuess,
|
|
272
|
+
readmeExcerpt: scan.readmeExcerpt || "[no README.md]",
|
|
273
|
+
scriptsTable: scan.scriptsTable,
|
|
274
|
+
testCommand: scan.testCommand,
|
|
275
|
+
eslintHint: scan.eslintHint,
|
|
276
|
+
tsconfigHint: scan.tsconfigHint,
|
|
277
|
+
routeFiles: scan.routeFiles,
|
|
278
|
+
i18nHint: scan.i18nHint,
|
|
279
|
+
projectTree: scan.projectTree,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
export function writeAgentsMd(cwd, scan) {
|
|
283
|
+
const path = join(cwd, "AGENTS.md");
|
|
284
|
+
const body = renderAgents(scan);
|
|
285
|
+
if (!existsSync(path)) {
|
|
286
|
+
writeFileSync(path, body, "utf8");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const existing = readFileSync(path, "utf8");
|
|
290
|
+
const start = existing.indexOf(AGENTS_MARK_START);
|
|
291
|
+
const end = existing.indexOf(AGENTS_MARK_END);
|
|
292
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
293
|
+
const inner = extractAutoBody(body);
|
|
294
|
+
const innerStart = start + AGENTS_MARK_START.length;
|
|
295
|
+
const merged = `${existing.slice(0, innerStart)}\n${inner}\n${existing.slice(end)}`;
|
|
296
|
+
writeFileSync(path, merged, "utf8");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
writeFileSync(path, `${existing.trimEnd()}\n\n${body}\n`, "utf8");
|
|
300
|
+
}
|
|
301
|
+
function extractAutoBody(full) {
|
|
302
|
+
const a = full.indexOf(AGENTS_MARK_START);
|
|
303
|
+
const b = full.indexOf(AGENTS_MARK_END);
|
|
304
|
+
if (a === -1 || b === -1)
|
|
305
|
+
return full.trim();
|
|
306
|
+
return full.slice(a + AGENTS_MARK_START.length, b).trim();
|
|
307
|
+
}
|
|
308
|
+
export function mergeConfigYaml(cwd, scan) {
|
|
309
|
+
const p = join(cwd, CONFIG_FILE);
|
|
310
|
+
const watchDirs = ["src", "tests"].filter((d) => existsSync(join(cwd, d)));
|
|
311
|
+
const watchYaml = watchDirs.length > 0 ? watchDirs.map((d) => JSON.stringify(d)).join(", ") : "";
|
|
312
|
+
const blockLines = [
|
|
313
|
+
CONFIG_START,
|
|
314
|
+
"# FET managed keys (nested under `fet`) — do not remove markers (DESIGN 3.2.2)",
|
|
315
|
+
"# Source: fet scanner (package.json, filesystem heuristics)",
|
|
316
|
+
"fet:",
|
|
317
|
+
` framework: ${JSON.stringify(scan.frameworkGuess)}`,
|
|
318
|
+
` language: ${JSON.stringify(scan.languageGuess)}`,
|
|
319
|
+
` bundler: ${JSON.stringify(scan.bundlerGuess)}`,
|
|
320
|
+
` state_management: ${JSON.stringify(scan.stateManagementGuess)}`,
|
|
321
|
+
` ui_library: ${JSON.stringify(scan.uiLibraryGuess)}`,
|
|
322
|
+
" # DESIGN 6.3 — directories watched by `fet apply`",
|
|
323
|
+
` watch_directories: ${watchYaml ? `[${watchYaml}]` : "[]"}`,
|
|
324
|
+
" # Test entry (string from package.json scripts.test if any)",
|
|
325
|
+
` test_script: ${JSON.stringify(scan.testCommand)}`,
|
|
326
|
+
" project_layout: |",
|
|
327
|
+
...scan.projectTree.split("\n").map((line) => ` ${line}`),
|
|
328
|
+
CONFIG_END,
|
|
329
|
+
];
|
|
330
|
+
const block = `${blockLines.join("\n")}\n`;
|
|
331
|
+
if (!existsSync(p)) {
|
|
332
|
+
writeFileSync(p, block, "utf8");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const raw = readFileSync(p, "utf8");
|
|
336
|
+
const s = raw.indexOf(CONFIG_START);
|
|
337
|
+
const e = raw.indexOf(CONFIG_END);
|
|
338
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
339
|
+
const merged = `${raw.slice(0, s)}${block}${raw.slice(e + CONFIG_END.length)}`;
|
|
340
|
+
writeFileSync(p, merged, "utf8");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
writeFileSync(p, `${raw.trimEnd()}\n\n${block}`, "utf8");
|
|
344
|
+
}
|
|
345
|
+
export function runUpdateContext(cwd) {
|
|
346
|
+
const scan = scanProject(cwd);
|
|
347
|
+
for (const w of scan.warnings)
|
|
348
|
+
console.warn(`[fet] ${w}`);
|
|
349
|
+
writeAgentsMd(cwd, scan);
|
|
350
|
+
mergeConfigYaml(cwd, scan);
|
|
351
|
+
console.log("[fet] update-context: refreshed AGENTS.md auto block and openspec/config.yaml fet section.");
|
|
352
|
+
}
|
package/dist/skills.d.ts
ADDED
package/dist/skills.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { promptSkillConflict } from "./prompt.js";
|
|
4
|
+
const SKILLS = [
|
|
5
|
+
{
|
|
6
|
+
fileBase: "explore",
|
|
7
|
+
name: "/fet-explore",
|
|
8
|
+
description: "FET: explore (openspec proxy)",
|
|
9
|
+
body: "Terminal: `fet explore`",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
fileBase: "propose",
|
|
13
|
+
name: "/fet-propose",
|
|
14
|
+
description: "FET: propose (openspec proxy)",
|
|
15
|
+
body: "Terminal: `fet propose`",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
fileBase: "new",
|
|
19
|
+
name: "/fet-new",
|
|
20
|
+
description: "FET: new change skeleton",
|
|
21
|
+
body: "Terminal: `fet new <change-id>` (maps to `openspec new change`).",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
fileBase: "continue",
|
|
25
|
+
name: "/fet-continue",
|
|
26
|
+
description: "FET: continue workflow",
|
|
27
|
+
body: "Terminal: `fet continue`",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
fileBase: "ff",
|
|
31
|
+
name: "/fet-ff",
|
|
32
|
+
description: "FET: ff workflow",
|
|
33
|
+
body: "Terminal: `fet ff`",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
fileBase: "apply",
|
|
37
|
+
name: "/apply",
|
|
38
|
+
description: "FET 实施当前任务",
|
|
39
|
+
body: "在终端执行 `fet apply` 以刷新 `openspec/changes/<change-id>/apply-instructions.md`,然后按该文件实施。",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
fileBase: "fet-validate",
|
|
43
|
+
name: "/fet-validate",
|
|
44
|
+
description: "FET: validate current task",
|
|
45
|
+
body: "Terminal: `fet validate`",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
fileBase: "fet-verify",
|
|
49
|
+
name: "/fet-verify",
|
|
50
|
+
description: "FET: final verify",
|
|
51
|
+
body: "Terminal: `fet verify` or `fet verify --auto`",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
fileBase: "fet-sync",
|
|
55
|
+
name: "/fet-sync",
|
|
56
|
+
description: "FET: sync delta specs",
|
|
57
|
+
body: "Terminal: `fet sync` (requires verify; interactive terminal, DESIGN 8.2).",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
fileBase: "fet-archive",
|
|
61
|
+
name: "/fet-archive",
|
|
62
|
+
description: "FET: archive change",
|
|
63
|
+
body: "Terminal: `fet archive <change>` after verify.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
fileBase: "bulk-archive",
|
|
67
|
+
name: "/fet-bulk-archive",
|
|
68
|
+
description: "FET: bulk archive",
|
|
69
|
+
body: "Terminal: `fet bulk-archive <id> [<id> ...]`",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
fileBase: "onboard",
|
|
73
|
+
name: "/fet-onboard",
|
|
74
|
+
description: "FET: onboard",
|
|
75
|
+
body: "Terminal: `fet onboard`",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
fileBase: "update-context",
|
|
79
|
+
name: "/fet-update-context",
|
|
80
|
+
description: "FET: refresh AGENTS / config",
|
|
81
|
+
body: "Terminal: `fet update-context`",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
fileBase: "doctor",
|
|
85
|
+
name: "/fet-doctor",
|
|
86
|
+
description: "FET: doctor",
|
|
87
|
+
body: "Terminal: `fet doctor`",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
function skillMarkdown(s) {
|
|
91
|
+
return [`---`, `name: ${s.name}`, `description: ${s.description}`, `---`, "", s.body, ""].join("\n");
|
|
92
|
+
}
|
|
93
|
+
export async function generateCursorTooling(cwd, opts) {
|
|
94
|
+
const skillsDir = join(cwd, ".cursor", "skills");
|
|
95
|
+
const rulesDir = join(cwd, ".cursor", "rules");
|
|
96
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
97
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
98
|
+
for (const s of SKILLS) {
|
|
99
|
+
const fname = `${s.fileBase}.md`;
|
|
100
|
+
const p = join(skillsDir, fname);
|
|
101
|
+
if (!existsSync(p) || opts.force) {
|
|
102
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
103
|
+
writeFileSync(p, skillMarkdown(s), "utf8");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const existing = readFileSync(p, "utf8");
|
|
107
|
+
if (existing.includes("FET:") || existing.includes("fet ")) {
|
|
108
|
+
console.log(`[fet] skip skill (already FET): ${p}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const choice = await promptSkillConflict(p);
|
|
112
|
+
if (choice === "skip") {
|
|
113
|
+
console.log(`[fet] skipped: ${p}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (choice === "merge") {
|
|
117
|
+
const merged = `${existing.trimEnd()}\n\n---\n\n## FET (merged)\n\n${s.body}\n`;
|
|
118
|
+
writeFileSync(p, merged, "utf8");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(p, skillMarkdown(s), "utf8");
|
|
122
|
+
}
|
|
123
|
+
const rulePath = join(rulesDir, "fet-context.mdc");
|
|
124
|
+
const rule = [
|
|
125
|
+
"---",
|
|
126
|
+
"description: Load FET/OpenSpec context before modifying project files",
|
|
127
|
+
"globs:",
|
|
128
|
+
" - '**/*'",
|
|
129
|
+
"---",
|
|
130
|
+
"",
|
|
131
|
+
"When making substantive code changes, read `AGENTS.md` and `openspec/config.yaml` first.",
|
|
132
|
+
"For trivial Q&A you may skip full loading.",
|
|
133
|
+
"",
|
|
134
|
+
].join("\n");
|
|
135
|
+
if (existsSync(rulePath) && !opts.force) {
|
|
136
|
+
console.warn(`[fet] skip rule (exists): ${rulePath}`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
writeFileSync(rulePath, rule, "utf8");
|
|
140
|
+
}
|
|
141
|
+
console.log(`[fet] Cursor skills written under ${skillsDir}`);
|
|
142
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChangeFetState, GlobalFetState } from "./types.js";
|
|
2
|
+
export declare function defaultGlobalState(): GlobalFetState;
|
|
3
|
+
export declare function defaultChangeState(changeId: string): ChangeFetState;
|
|
4
|
+
export declare function readJson<T>(path: string): T | null;
|
|
5
|
+
export declare function readGlobalState(cwd: string): GlobalFetState;
|
|
6
|
+
export declare function readChangeState(cwd: string, changeId: string): ChangeFetState | null;
|
|
7
|
+
export declare function writeGlobalState(cwd: string, state: GlobalFetState): void;
|
|
8
|
+
export declare function writeChangeState(cwd: string, state: ChangeFetState): void;
|
|
9
|
+
/** Remove ghost changes, align tasksCompleted from tasks.md (DESIGN 5.1). */
|
|
10
|
+
export declare function reconcileStates(cwd: string, global: GlobalFetState): {
|
|
11
|
+
global: GlobalFetState;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function ensureChangeTracked(global: GlobalFetState, changeId: string): GlobalFetState;
|
|
15
|
+
/** DESIGN 5.3: remove archived change ids from global tracking (single write after success). */
|
|
16
|
+
export declare function removeArchivedChangesFromGlobal(global: GlobalFetState, ids: string[]): GlobalFetState;
|
|
17
|
+
export declare function positionalArgs(rest: string[]): string[];
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { backupIfExists, writeFileAtomicSync } from "./atomic-write.js";
|
|
4
|
+
import { CHANGES_DIR, GLOBAL_STATE_FILE, changeStatePath, tasksPath } from "./paths.js";
|
|
5
|
+
import { listCompletedTaskIds, readTasksFile } from "./tasks.js";
|
|
6
|
+
export function defaultGlobalState() {
|
|
7
|
+
return { version: 1, activeChangeId: null, openChanges: [] };
|
|
8
|
+
}
|
|
9
|
+
export function defaultChangeState(changeId) {
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
changeId,
|
|
13
|
+
currentPhase: "implement",
|
|
14
|
+
tasksCompleted: [],
|
|
15
|
+
currentTaskId: null,
|
|
16
|
+
verify: { status: "pending" },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function readJson(path) {
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function readGlobalState(cwd) {
|
|
30
|
+
const p = join(cwd, GLOBAL_STATE_FILE);
|
|
31
|
+
const s = readJson(p);
|
|
32
|
+
if (!s || s.version !== 1)
|
|
33
|
+
return defaultGlobalState();
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
export function readChangeState(cwd, changeId) {
|
|
37
|
+
const p = join(cwd, changeStatePath(changeId));
|
|
38
|
+
const s = readJson(p);
|
|
39
|
+
if (!s || s.version !== 1)
|
|
40
|
+
return null;
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
export function writeGlobalState(cwd, state) {
|
|
44
|
+
const p = join(cwd, GLOBAL_STATE_FILE);
|
|
45
|
+
backupIfExists(p);
|
|
46
|
+
writeFileAtomicSync(p, `${JSON.stringify(state, null, 2)}\n`);
|
|
47
|
+
}
|
|
48
|
+
export function writeChangeState(cwd, state) {
|
|
49
|
+
const p = join(cwd, changeStatePath(state.changeId));
|
|
50
|
+
backupIfExists(p);
|
|
51
|
+
writeFileAtomicSync(p, `${JSON.stringify(state, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
function listChangeIds(cwd) {
|
|
54
|
+
const root = join(cwd, CHANGES_DIR);
|
|
55
|
+
if (!existsSync(root))
|
|
56
|
+
return [];
|
|
57
|
+
return readdirSync(root).filter((name) => {
|
|
58
|
+
try {
|
|
59
|
+
return statSync(join(root, name)).isDirectory();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Remove ghost changes, align tasksCompleted from tasks.md (DESIGN 5.1). */
|
|
67
|
+
export function reconcileStates(cwd, global) {
|
|
68
|
+
const warnings = [];
|
|
69
|
+
const dirs = new Set(listChangeIds(cwd));
|
|
70
|
+
const nextOpen = global.openChanges.filter((id) => {
|
|
71
|
+
if (!dirs.has(id)) {
|
|
72
|
+
warnings.push(`Removed ghost change from openChanges: ${id}`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
let active = global.activeChangeId;
|
|
78
|
+
if (active && !dirs.has(active)) {
|
|
79
|
+
warnings.push(`Active change missing on disk: ${active}; clearing activeChangeId`);
|
|
80
|
+
active = null;
|
|
81
|
+
}
|
|
82
|
+
const g = { ...global, openChanges: nextOpen, activeChangeId: active };
|
|
83
|
+
for (const id of dirs) {
|
|
84
|
+
const tp = join(cwd, tasksPath(id));
|
|
85
|
+
if (!existsSync(tp))
|
|
86
|
+
continue;
|
|
87
|
+
let cs = readChangeState(cwd, id);
|
|
88
|
+
if (!cs) {
|
|
89
|
+
cs = defaultChangeState(id);
|
|
90
|
+
writeChangeState(cwd, cs);
|
|
91
|
+
}
|
|
92
|
+
const md = readTasksFile(tp);
|
|
93
|
+
const fromMd = listCompletedTaskIds(md);
|
|
94
|
+
const merged = [...new Set([...fromMd])].sort();
|
|
95
|
+
const prev = [...cs.tasksCompleted].sort().join(",");
|
|
96
|
+
const next = merged.join(",");
|
|
97
|
+
if (prev !== next) {
|
|
98
|
+
warnings.push(`Aligned tasksCompleted for ${id} from tasks.md`);
|
|
99
|
+
cs = { ...cs, tasksCompleted: merged };
|
|
100
|
+
writeChangeState(cwd, cs);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { global: g, warnings };
|
|
104
|
+
}
|
|
105
|
+
export function ensureChangeTracked(global, changeId) {
|
|
106
|
+
const openChanges = global.openChanges.includes(changeId)
|
|
107
|
+
? global.openChanges
|
|
108
|
+
: [...global.openChanges, changeId];
|
|
109
|
+
return {
|
|
110
|
+
...global,
|
|
111
|
+
openChanges,
|
|
112
|
+
activeChangeId: global.activeChangeId ?? changeId,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** DESIGN 5.3: remove archived change ids from global tracking (single write after success). */
|
|
116
|
+
export function removeArchivedChangesFromGlobal(global, ids) {
|
|
117
|
+
const set = new Set(ids.filter(Boolean));
|
|
118
|
+
const openChanges = global.openChanges.filter((id) => !set.has(id));
|
|
119
|
+
let activeChangeId = global.activeChangeId;
|
|
120
|
+
if (activeChangeId && set.has(activeChangeId))
|
|
121
|
+
activeChangeId = openChanges[0] ?? null;
|
|
122
|
+
return { ...global, openChanges, activeChangeId };
|
|
123
|
+
}
|
|
124
|
+
export function positionalArgs(rest) {
|
|
125
|
+
return rest.filter((a) => !a.startsWith("-"));
|
|
126
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ParsedTask {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
done: boolean;
|
|
5
|
+
autoNext: boolean;
|
|
6
|
+
lineIndex: number;
|
|
7
|
+
}
|
|
8
|
+
/** Parse OpenSpec-style tasks.md: markdown checkboxes with leading task ids. */
|
|
9
|
+
export declare function parseTasksMd(content: string): ParsedTask[];
|
|
10
|
+
export declare function listCompletedTaskIds(content: string): string[];
|
|
11
|
+
export declare function firstIncompleteTask(content: string): ParsedTask | null;
|
|
12
|
+
export declare function markTaskDoneInMarkdown(content: string, taskId: string): string;
|
|
13
|
+
export declare function readTasksFile(path: string): string;
|