@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nick848
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,44 @@
1
+ # FET
2
+
3
+ 在项目中以 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 为基座做**工作流编排**:`fet` 命令在前后挂钩子、管理 `openspec/fet-state.json`、生成上下文与工具技能(Cursor / OpenCode),并把 `openspec` CLI 透传执行。
4
+
5
+ ## 要求
6
+
7
+ - Node.js **≥ 18**
8
+ - 已安装 OpenSpec CLI(PATH 中的 `openspec`,否则将尝试 `npx @fission-ai/openspec`)
9
+
10
+ ## 安装
11
+
12
+ ```bash
13
+ npm install -g @nick848/fet
14
+ ```
15
+
16
+ ## 常用命令
17
+
18
+ | 命令 | 说明 |
19
+ |------|------|
20
+ | `fet init` | 初始化 OpenSpec + FET 状态、扫描上下文、生成工具文件(`--tool cursor \| opencode \| both`) |
21
+ | `fet apply` / `fet validate` | 实施循环与任务校验 |
22
+ | `fet verify` | 终验(`--auto` / `--done` 等) |
23
+ | `fet doctor` | 诊断配置与 openspec 解析路径 |
24
+ | `fet update-context` | 刷新 `AGENTS.md` 与 `openspec/config.yaml` 中的 FET 区块 |
25
+
26
+ 其余子命令多为对 `openspec` 的透传,详见 `fet --help`。
27
+
28
+ ## 文档
29
+
30
+ 仓库内 [DESIGN.md](./DESIGN.md) 为完整设计说明(威胁模型、状态机、与 OpenSpec 关系等)。
31
+
32
+ ## 维护者:发布到 npm
33
+
34
+ 1. 登录:`npm login`(首次)
35
+ 2. 确认版本号:编辑 `package.json` 的 `version`(语义化版本)
36
+ 3. 本地校验:`npm run build && npm test`
37
+ 4. 干跑包内容:`npm pack --dry-run`
38
+ 5. 发布(作用域包已设 `publishConfig.access: public`):`npm publish`
39
+
40
+ `prepublishOnly` 会在发布前自动执行 `npm run build && npm test`。
41
+
42
+ ## 开源协议
43
+
44
+ MIT,见 [LICENSE](./LICENSE)。
@@ -0,0 +1 @@
1
+ export declare function runApply(cwd: string): Promise<void>;
package/dist/apply.js ADDED
@@ -0,0 +1,172 @@
1
+ import chokidar from "chokidar";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { ensureAutoRunApproval } from "./approval.js";
5
+ import { writeFileAtomicSameDirTmp } from "./atomic-write.js";
6
+ import { computeCommandFingerprint } from "./fingerprint.js";
7
+ import { FET_VERSION, getOpenSpecLaunch } from "./openspec.js";
8
+ import { applyInstructionsPath, changeStatePath, tasksPath } from "./paths.js";
9
+ import { defaultChangeState, readChangeState, readGlobalState, reconcileStates, writeChangeState, writeGlobalState, } from "./state.js";
10
+ import { firstIncompleteTask } from "./tasks.js";
11
+ import { resolveWatchRoots } from "./watch-paths.js";
12
+ const APPLY_PHASES = new Set(["planned", "proposed", "implement"]);
13
+ function buildApplyInstructions(params) {
14
+ const front = [
15
+ "---",
16
+ `generatedAt: ${new Date().toISOString()}`,
17
+ `fetVersion: ${FET_VERSION}`,
18
+ `changeId: ${params.changeId}`,
19
+ `taskId: ${params.taskId}`,
20
+ "---",
21
+ "",
22
+ ].join("\n");
23
+ const body = [
24
+ `# Apply: ${params.changeId}`,
25
+ "",
26
+ `## Current task (${params.taskId})`,
27
+ "",
28
+ params.taskTitle,
29
+ "",
30
+ "## Required context",
31
+ "",
32
+ "- Read `AGENTS.md` and `openspec/config.yaml` at repo root.",
33
+ `- Read change artifacts under \`openspec/changes/${params.changeId}/\` (proposal, specs, design, tasks).`,
34
+ "",
35
+ "## After coding",
36
+ "",
37
+ "Run in a **second terminal** (this session may be holding the watcher):",
38
+ "",
39
+ "```bash",
40
+ "fet validate",
41
+ "```",
42
+ "",
43
+ ].join("\n");
44
+ return front + body;
45
+ }
46
+ function resolveActiveChangeId(cwd) {
47
+ const g = readGlobalState(cwd);
48
+ if (g.activeChangeId)
49
+ return g.activeChangeId;
50
+ if (g.openChanges.length === 1)
51
+ return g.openChanges[0] ?? null;
52
+ return null;
53
+ }
54
+ export async function runApply(cwd) {
55
+ const launch = getOpenSpecLaunch();
56
+ console.log(`[fet] OpenSpec: ${launch.displayPath}`);
57
+ let global = readGlobalState(cwd);
58
+ const rec = reconcileStates(cwd, global);
59
+ global = rec.global;
60
+ for (const w of rec.warnings)
61
+ console.error(`[fet] ${w}`);
62
+ writeGlobalState(cwd, global);
63
+ const changeId = resolveActiveChangeId(cwd);
64
+ if (!changeId) {
65
+ console.error("[fet] No active change. Set openspec/fet-state.json activeChangeId or ensure a single open change.");
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ let cs = readChangeState(cwd, changeId);
70
+ if (!cs)
71
+ cs = defaultChangeState(changeId);
72
+ const phase = (cs.currentPhase || "implement").toLowerCase();
73
+ if (!APPLY_PHASES.has(phase)) {
74
+ console.error(`[fet] apply requires currentPhase in planned | proposed | implement (got "${cs.currentPhase}").`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ const tp = join(cwd, tasksPath(changeId));
79
+ if (!existsSync(tp)) {
80
+ console.error(`[fet] Missing tasks.md for change ${changeId}`);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ const fpNow = computeCommandFingerprint(cwd);
85
+ if (global.autoRunApproval?.commandFingerprint !== fpNow) {
86
+ global = { ...global, autoRunApproval: undefined };
87
+ writeGlobalState(cwd, global);
88
+ }
89
+ try {
90
+ await ensureAutoRunApproval(cwd);
91
+ }
92
+ catch (e) {
93
+ console.error(e instanceof Error ? e.message : e);
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const md = readFileSync(tp, "utf8");
98
+ const next = firstIncompleteTask(md);
99
+ if (!next) {
100
+ console.log(`[fet] All tasks appear complete for ${changeId}. Run fet verify when ready.`);
101
+ return;
102
+ }
103
+ const instructions = buildApplyInstructions({
104
+ changeId,
105
+ taskId: next.id,
106
+ taskTitle: next.title || "(no title)",
107
+ });
108
+ const outPath = join(cwd, applyInstructionsPath(changeId));
109
+ writeFileAtomicSameDirTmp(outPath, instructions);
110
+ cs = { ...cs, currentTaskId: next.id, currentPhase: phase };
111
+ writeChangeState(cwd, cs);
112
+ console.log(`[fet] Wrote ${applyInstructionsPath(changeId)} (atomic tmp → rename)`);
113
+ console.log(`[fet] Current task: ${next.id} (auto_next=${next.autoNext})`);
114
+ console.log("[fet] Watching for file activity. When all tasks are done (e.g. after validate), this process will exit. Ctrl+C to stop early.");
115
+ const stateFile = join(cwd, changeStatePath(changeId));
116
+ let debounce = null;
117
+ const roots = resolveWatchRoots(cwd);
118
+ const watched = [...roots, stateFile, join(cwd, tasksPath(changeId))];
119
+ const watcher = chokidar.watch(watched, {
120
+ ignored: /(node_modules|\.git|dist|build)/,
121
+ ignoreInitial: true,
122
+ });
123
+ const bump = (path) => {
124
+ if (debounce)
125
+ clearTimeout(debounce);
126
+ debounce = setTimeout(() => {
127
+ console.log(`[fet] (${path}) idle ~3s since last file change`);
128
+ }, 3000);
129
+ };
130
+ let allDone = false;
131
+ const checkAllTasksDone = () => {
132
+ try {
133
+ const t = readFileSync(join(cwd, tasksPath(changeId)), "utf8");
134
+ if (!firstIncompleteTask(t)) {
135
+ console.log("[fet] All tasks complete. Run `fet verify`. Apply watcher exiting.");
136
+ allDone = true;
137
+ }
138
+ }
139
+ catch {
140
+ /* ignore */
141
+ }
142
+ };
143
+ await new Promise((resolve) => {
144
+ let settled = false;
145
+ let poll = null;
146
+ const finish = () => {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ if (poll)
151
+ clearInterval(poll);
152
+ watcher.close().then(() => resolve(), () => resolve());
153
+ };
154
+ process.once("SIGINT", finish);
155
+ poll = setInterval(() => {
156
+ checkAllTasksDone();
157
+ if (allDone)
158
+ finish();
159
+ }, 2000);
160
+ watcher.on("all", (_ev, path) => {
161
+ if (path)
162
+ bump(String(path));
163
+ if (path && String(path).replace(/\\/g, "/").endsWith("tasks.md")) {
164
+ checkAllTasksDone();
165
+ if (allDone)
166
+ finish();
167
+ }
168
+ });
169
+ });
170
+ if (debounce)
171
+ clearTimeout(debounce);
172
+ }
@@ -0,0 +1,2 @@
1
+ /** DESIGN 6.2 / 14.7: shared auto-run approval for apply, validate, verify --auto */
2
+ export declare function ensureAutoRunApproval(cwd: string): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { readGlobalState, writeGlobalState } from "./state.js";
2
+ import { collectScriptFingerprintParts, computeCommandFingerprint } from "./fingerprint.js";
3
+ import { confirmAutoRun } from "./prompt.js";
4
+ /** DESIGN 6.2 / 14.7: shared auto-run approval for apply, validate, verify --auto */
5
+ export async function ensureAutoRunApproval(cwd) {
6
+ let global = readGlobalState(cwd);
7
+ const fp = computeCommandFingerprint(cwd);
8
+ if (global.autoRunApproval?.commandFingerprint === fp)
9
+ return;
10
+ const { parts } = collectScriptFingerprintParts(cwd);
11
+ console.log("[fet] The following may run non-sandboxed scripts (npm test / lint / tsc, etc.):");
12
+ for (const p of parts)
13
+ console.log(` - ${p}`);
14
+ const ok = await confirmAutoRun("[fet] Approve auto-run?");
15
+ if (!ok)
16
+ throw new Error("Approval declined");
17
+ global = {
18
+ ...global,
19
+ autoRunApproval: {
20
+ confirmedAt: new Date().toISOString(),
21
+ commandFingerprint: fp,
22
+ packageManager: "npm",
23
+ },
24
+ };
25
+ writeGlobalState(cwd, global);
26
+ }
@@ -0,0 +1,5 @@
1
+ /** DESIGN 14.5: same-dir `*.tmp` then rename (e.g. apply-instructions.md.tmp). */
2
+ export declare function writeFileAtomicSameDirTmp(targetPath: string, content: string): void;
3
+ /** Write text atomically: temp file, fsync, rename (same directory). */
4
+ export declare function writeFileAtomicSync(targetPath: string, content: string): void;
5
+ export declare function backupIfExists(filePath: string): void;
@@ -0,0 +1,41 @@
1
+ import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, writeSync, } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ /** DESIGN 14.5: same-dir `*.tmp` then rename (e.g. apply-instructions.md.tmp). */
4
+ export function writeFileAtomicSameDirTmp(targetPath, content) {
5
+ const dir = dirname(targetPath);
6
+ const base = basename(targetPath);
7
+ const tmp = join(dir, `${base}.tmp`);
8
+ if (!existsSync(dir))
9
+ mkdirSync(dir, { recursive: true });
10
+ const fd = openSync(tmp, "w");
11
+ try {
12
+ writeSync(fd, content);
13
+ fsyncSync(fd);
14
+ }
15
+ finally {
16
+ closeSync(fd);
17
+ }
18
+ renameSync(tmp, targetPath);
19
+ }
20
+ /** Write text atomically: temp file, fsync, rename (same directory). */
21
+ export function writeFileAtomicSync(targetPath, content) {
22
+ const dir = dirname(targetPath);
23
+ if (!existsSync(dir))
24
+ mkdirSync(dir, { recursive: true });
25
+ const tmp = `${targetPath}.${process.pid}.tmp`;
26
+ const fd = openSync(tmp, "w");
27
+ try {
28
+ writeSync(fd, content);
29
+ fsyncSync(fd);
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ renameSync(tmp, targetPath);
35
+ }
36
+ export function backupIfExists(filePath) {
37
+ if (!existsSync(filePath))
38
+ return;
39
+ const bak = `${filePath}.bak`;
40
+ copyFileSync(filePath, bak);
41
+ }
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,178 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Command } from "commander";
6
+ import { runApply } from "./apply.js";
7
+ import { runDoctor } from "./doctor.js";
8
+ import { assertChangeVerifiedForGate, runOpenSpecWithHooks } from "./hooks.js";
9
+ import { runInit } from "./init.js";
10
+ import { readGlobalState, positionalArgs } from "./state.js";
11
+ import { runUpdateContext } from "./scanner.js";
12
+ import { runValidate } from "./validate.js";
13
+ import { runVerify } from "./verify.js";
14
+ function tailAfter(sub) {
15
+ const a = process.argv;
16
+ const i = a.findIndex((v, idx) => idx >= 2 && v === sub);
17
+ if (i === -1)
18
+ return [];
19
+ return a.slice(i + 1);
20
+ }
21
+ function mapNewArgs(rest) {
22
+ if (rest[0] === "change")
23
+ return ["new", ...rest];
24
+ return ["new", "change", ...rest];
25
+ }
26
+ function readPkgVersion() {
27
+ try {
28
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ const raw = readFileSync(join(root, "package.json"), "utf8");
30
+ const j = JSON.parse(raw);
31
+ return j.version ?? "0.0.0";
32
+ }
33
+ catch {
34
+ return "0.0.0";
35
+ }
36
+ }
37
+ const PROXIES = [
38
+ "explore",
39
+ "propose",
40
+ "continue",
41
+ "ff",
42
+ "onboard",
43
+ "list",
44
+ "view",
45
+ "spec",
46
+ "config",
47
+ "show",
48
+ "update",
49
+ "feedback",
50
+ "status",
51
+ "instructions",
52
+ "templates",
53
+ "schemas",
54
+ "schema",
55
+ "change",
56
+ "completion",
57
+ ];
58
+ const WORKFLOW_HINTS = new Set(["continue", "ff", "onboard"]);
59
+ async function main() {
60
+ const program = new Command();
61
+ program.name("fet").description("FET — OpenSpec workflow orchestration").version(readPkgVersion());
62
+ program
63
+ .command("init")
64
+ .option("--tool <tool>", "Tooling: cursor | opencode | both (OpenCode: .opencode/skills/*/SKILL.md, see opencode.ai/docs/skills)", "cursor")
65
+ .option("--force", "overwrite Cursor skills/rules", false)
66
+ .action(async (opts) => {
67
+ await runInit(process.cwd(), { tool: opts.tool, force: opts.force });
68
+ });
69
+ program.command("doctor").action(() => {
70
+ runDoctor(process.cwd());
71
+ });
72
+ program.command("update-context").action(() => {
73
+ runUpdateContext(process.cwd());
74
+ });
75
+ program.command("apply").action(async () => {
76
+ await runApply(process.cwd());
77
+ });
78
+ program.command("validate").action(async () => {
79
+ await runValidate(process.cwd());
80
+ });
81
+ program
82
+ .command("verify")
83
+ .description("Final verification. Default: honest local trust (DESIGN 14.8). --auto runs validate+lint+tsc+test; not a cryptographic audit.")
84
+ .option("--auto", "run automated verify chain", false)
85
+ .option("--done", "record manual verify complete", false)
86
+ .option("--evidence <path>", "optional evidence file for --done")
87
+ .option("--strict", "strict manual verify (recorded in state)", false)
88
+ .action(async (opts) => {
89
+ await runVerify(process.cwd(), {
90
+ auto: opts.auto,
91
+ done: opts.done,
92
+ evidence: opts.evidence,
93
+ strict: opts.strict,
94
+ });
95
+ });
96
+ program.command("archive").action(async () => {
97
+ try {
98
+ const rest = tailAfter("archive");
99
+ const pos = positionalArgs(rest);
100
+ const cwd = process.cwd();
101
+ const gateId = pos.length ? (pos[pos.length - 1] ?? null) : readGlobalState(cwd).activeChangeId;
102
+ const removeId = gateId ?? readGlobalState(cwd).activeChangeId;
103
+ const code = await runOpenSpecWithHooks({ cwd }, ["archive", ...rest], {
104
+ pre: () => {
105
+ assertChangeVerifiedForGate(cwd, gateId);
106
+ },
107
+ onSuccessRemoveChanges: removeId ? [removeId] : [],
108
+ });
109
+ process.exitCode = code;
110
+ }
111
+ catch (e) {
112
+ console.error(e instanceof Error ? e.message : e);
113
+ process.exitCode = 1;
114
+ }
115
+ });
116
+ program.command("bulk-archive").action(async () => {
117
+ try {
118
+ const rest = tailAfter("bulk-archive");
119
+ const ids = positionalArgs(rest);
120
+ const cwd = process.cwd();
121
+ if (!ids.length) {
122
+ console.error("[fet] bulk-archive requires at least one change id");
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ for (const id of ids) {
127
+ assertChangeVerifiedForGate(cwd, id);
128
+ }
129
+ const code = await runOpenSpecWithHooks({ cwd }, ["bulk-archive", ...rest], { onSuccessRemoveChanges: ids });
130
+ process.exitCode = code;
131
+ }
132
+ catch (e) {
133
+ console.error(e instanceof Error ? e.message : e);
134
+ process.exitCode = 1;
135
+ }
136
+ });
137
+ program.command("sync").action(async () => {
138
+ try {
139
+ if (!process.stdin.isTTY) {
140
+ console.error("[fet] sync requires an interactive terminal for OpenSpec conflict resolution (DESIGN 8.2).");
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ const rest = tailAfter("sync");
145
+ const cwd = process.cwd();
146
+ const pos = positionalArgs(rest);
147
+ const changeId = pos.length ? (pos[pos.length - 1] ?? null) : readGlobalState(cwd).activeChangeId;
148
+ const code = await runOpenSpecWithHooks({ cwd }, ["sync", ...rest], {
149
+ pre: () => {
150
+ assertChangeVerifiedForGate(cwd, changeId);
151
+ },
152
+ });
153
+ process.exitCode = code;
154
+ }
155
+ catch (e) {
156
+ console.error(e instanceof Error ? e.message : e);
157
+ process.exitCode = 1;
158
+ }
159
+ });
160
+ program.command("new").action(async () => {
161
+ const rest = tailAfter("new");
162
+ const args = mapNewArgs(rest);
163
+ const code = await runOpenSpecWithHooks({ cwd: process.cwd() }, args, {});
164
+ process.exitCode = code;
165
+ });
166
+ for (const name of PROXIES) {
167
+ program.command(name).action(async () => {
168
+ const rest = tailAfter(name);
169
+ const code = await runOpenSpecWithHooks({ cwd: process.cwd() }, [name, ...rest], { workflowHint: WORKFLOW_HINTS.has(name) ? name : undefined });
170
+ process.exitCode = code;
171
+ });
172
+ }
173
+ await program.parseAsync(process.argv);
174
+ }
175
+ main().catch((e) => {
176
+ console.error(e);
177
+ process.exitCode = 1;
178
+ });
@@ -0,0 +1 @@
1
+ export declare function runDoctor(cwd: string): void;
package/dist/doctor.js ADDED
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { computeCommandFingerprint } from "./fingerprint.js";
4
+ import { getOpenSpecLaunch, openSpecVersionLine } from "./openspec.js";
5
+ import { CONFIG_FILE, GLOBAL_STATE_FILE, changeDir, tasksPath } from "./paths.js";
6
+ import { readChangeState, readGlobalState, reconcileStates, writeGlobalState } from "./state.js";
7
+ const STALE_MS = 1000 * 60 * 60 * 24 * 30;
8
+ const ARTIFACTS = ["proposal.md", "tasks.md", "design.md"];
9
+ export function runDoctor(cwd) {
10
+ const issues = [];
11
+ const hints = [];
12
+ const launch = getOpenSpecLaunch();
13
+ console.log(`[fet] openspec launch: ${launch.displayPath}`);
14
+ const ver = openSpecVersionLine();
15
+ if (ver)
16
+ console.log(`[fet] openspec version: ${ver}`);
17
+ else
18
+ hints.push("Could not read openspec -V (network or npx may be slow)");
19
+ const gs = join(cwd, GLOBAL_STATE_FILE);
20
+ if (!existsSync(gs))
21
+ issues.push(`missing ${GLOBAL_STATE_FILE}`);
22
+ else {
23
+ try {
24
+ JSON.parse(readFileSync(gs, "utf8"));
25
+ }
26
+ catch {
27
+ issues.push(`${GLOBAL_STATE_FILE} is not valid JSON`);
28
+ }
29
+ }
30
+ const agents = join(cwd, "AGENTS.md");
31
+ if (existsSync(agents)) {
32
+ const mtime = statSync(agents).mtimeMs;
33
+ if (Date.now() - mtime > STALE_MS)
34
+ hints.push("AGENTS.md may be stale — consider fet update-context");
35
+ }
36
+ else
37
+ hints.push("AGENTS.md missing — run fet init / fet update-context");
38
+ const cfg = join(cwd, CONFIG_FILE);
39
+ if (existsSync(cfg)) {
40
+ const mtime = statSync(cfg).mtimeMs;
41
+ if (Date.now() - mtime > STALE_MS)
42
+ hints.push("openspec/config.yaml may be stale — consider fet update-context");
43
+ }
44
+ let g = readGlobalState(cwd);
45
+ const rec = reconcileStates(cwd, g);
46
+ g = rec.global;
47
+ writeGlobalState(cwd, g);
48
+ for (const w of rec.warnings)
49
+ hints.push(w);
50
+ const active = g.activeChangeId;
51
+ if (active) {
52
+ const cs = readChangeState(cwd, active);
53
+ if (cs?.verify?.status !== "done") {
54
+ hints.push(`Active change "${active}" is not verified — archive/sync will be blocked until fet verify --done or --auto`);
55
+ }
56
+ const changeRoot = join(cwd, changeDir(active));
57
+ if (existsSync(changeRoot)) {
58
+ for (const a of ARTIFACTS) {
59
+ const ap = join(changeRoot, a);
60
+ if (!existsSync(ap))
61
+ hints.push(`Change "${active}" missing artifact: ${a} (optional depending on workflow)`);
62
+ }
63
+ const tp = join(cwd, tasksPath(active));
64
+ if (!existsSync(tp))
65
+ hints.push(`Change "${active}" has no tasks.md yet`);
66
+ }
67
+ }
68
+ const fp = computeCommandFingerprint(cwd);
69
+ if (g.autoRunApproval && g.autoRunApproval.commandFingerprint !== fp) {
70
+ hints.push("package.json scripts changed since last auto-run approval — next validate/verify --auto will re-prompt");
71
+ }
72
+ const cursorSkills = join(cwd, ".cursor", "skills");
73
+ if (!existsSync(cursorSkills))
74
+ hints.push("Cursor skills missing — run fet init --tool cursor");
75
+ const opencodeSkills = join(cwd, ".opencode", "skills");
76
+ if (!existsSync(opencodeSkills)) {
77
+ hints.push("OpenCode skills missing — run fet init --tool opencode (see https://opencode.ai/docs/skills )");
78
+ }
79
+ if (issues.length) {
80
+ console.error("[fet] doctor: issues:");
81
+ for (const i of issues)
82
+ console.error(` - ${i}`);
83
+ process.exitCode = 1;
84
+ }
85
+ else {
86
+ console.log("[fet] doctor: no blocking issues.");
87
+ }
88
+ if (hints.length) {
89
+ console.log("[fet] doctor: hints:");
90
+ for (const h of hints)
91
+ console.log(` - ${h}`);
92
+ }
93
+ }
@@ -0,0 +1,6 @@
1
+ /** Collect script literals for validate/verify-auto (DESIGN 14.7, monorepo workspaces). */
2
+ export declare function collectScriptFingerprintParts(cwd: string): {
3
+ parts: string[];
4
+ lockfileHints: string[];
5
+ };
6
+ export declare function computeCommandFingerprint(cwd: string): string;
@@ -0,0 +1,77 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ function stableSerialize(obj) {
5
+ return JSON.stringify(obj, Object.keys(obj).sort());
6
+ }
7
+ function workspacePackageJsonFiles(cwd) {
8
+ const out = [];
9
+ const pkgPath = join(cwd, "package.json");
10
+ if (!existsSync(pkgPath))
11
+ return out;
12
+ try {
13
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
14
+ const patterns = Array.isArray(pkg.workspaces)
15
+ ? pkg.workspaces
16
+ : (pkg.workspaces?.packages ?? []);
17
+ for (const pat of patterns) {
18
+ if (typeof pat !== "string")
19
+ continue;
20
+ if (pat.includes("*")) {
21
+ const packagesDir = join(cwd, "packages");
22
+ if (existsSync(packagesDir)) {
23
+ for (const name of readdirSync(packagesDir)) {
24
+ const p = join(packagesDir, name, "package.json");
25
+ if (existsSync(p))
26
+ out.push(p);
27
+ }
28
+ }
29
+ }
30
+ else {
31
+ const p = join(cwd, pat, "package.json");
32
+ if (existsSync(p))
33
+ out.push(p);
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ /* skip */
39
+ }
40
+ return out;
41
+ }
42
+ function scriptsFingerprintSlice(pkgPath, cwd) {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
45
+ const scripts = pkg.scripts ?? {};
46
+ const keys = ["test", "lint", "build"].filter((k) => scripts[k] != null);
47
+ const ordered = {};
48
+ for (const k of keys.sort())
49
+ ordered[k] = scripts[k] ?? "";
50
+ const rel = relative(cwd, pkgPath).replace(/\\/g, "/");
51
+ return `${rel}:${stableSerialize(ordered)}`;
52
+ }
53
+ catch {
54
+ return `${pkgPath}:{}`;
55
+ }
56
+ }
57
+ /** Collect script literals for validate/verify-auto (DESIGN 14.7, monorepo workspaces). */
58
+ export function collectScriptFingerprintParts(cwd) {
59
+ const parts = [];
60
+ const rootPkg = join(cwd, "package.json");
61
+ if (existsSync(rootPkg)) {
62
+ parts.push(scriptsFingerprintSlice(rootPkg, cwd));
63
+ }
64
+ for (const ws of workspacePackageJsonFiles(cwd)) {
65
+ parts.push(scriptsFingerprintSlice(ws, cwd));
66
+ }
67
+ const lockfiles = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"].filter((f) => existsSync(join(cwd, f)));
68
+ parts.push(...lockfiles.map((f) => `lock:${f}`));
69
+ return { parts, lockfileHints: lockfiles };
70
+ }
71
+ export function computeCommandFingerprint(cwd) {
72
+ const { parts } = collectScriptFingerprintParts(cwd);
73
+ const h = createHash("sha256");
74
+ for (const p of parts.sort())
75
+ h.update(p + "\n");
76
+ return h.digest("hex");
77
+ }