@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/dist/tasks.js ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ /** Parse OpenSpec-style tasks.md: markdown checkboxes with leading task ids. */
3
+ export function parseTasksMd(content) {
4
+ const lines = content.split(/\r?\n/);
5
+ const tasks = [];
6
+ const boxRe = /^(\s*)-\s*\[([ xX])\]\s*(.+)$/;
7
+ for (let i = 0; i < lines.length; i++) {
8
+ const m = lines[i].match(boxRe);
9
+ if (!m)
10
+ continue;
11
+ const done = m[2].toLowerCase() === "x";
12
+ const rest = m[3].trim();
13
+ const idMatch = rest.match(/^(\d+(?:\.\d+)+)\b\s*(.*)$/);
14
+ const id = idMatch ? idMatch[1] : rest.split(/\s+/)[0] ?? rest;
15
+ const title = idMatch ? idMatch[2].trim() : rest;
16
+ let autoNext = false;
17
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
18
+ const t = lines[j].trim();
19
+ if (!t)
20
+ continue;
21
+ if (/^-\s*\[/.test(lines[j]))
22
+ break;
23
+ if (/auto_next\s*:\s*true/i.test(t)) {
24
+ autoNext = true;
25
+ break;
26
+ }
27
+ if (/auto_next\s*:\s*false/i.test(t)) {
28
+ autoNext = false;
29
+ break;
30
+ }
31
+ }
32
+ if (/auto_next\s*:\s*true/i.test(lines[i]))
33
+ autoNext = true;
34
+ tasks.push({ id, title, done, autoNext, lineIndex: i });
35
+ }
36
+ return tasks;
37
+ }
38
+ export function listCompletedTaskIds(content) {
39
+ return parseTasksMd(content).filter((t) => t.done).map((t) => t.id);
40
+ }
41
+ export function firstIncompleteTask(content) {
42
+ for (const t of parseTasksMd(content)) {
43
+ if (!t.done)
44
+ return t;
45
+ }
46
+ return null;
47
+ }
48
+ export function markTaskDoneInMarkdown(content, taskId) {
49
+ const lines = content.split(/\r?\n/);
50
+ const boxRe = /^(\s*)-\s*\[([ xX])\]\s*(.+)$/;
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const m = lines[i].match(boxRe);
53
+ if (!m)
54
+ continue;
55
+ const rest = m[3].trim();
56
+ const idMatch = rest.match(/^(\d+(?:\.\d+)+)\b/);
57
+ const id = idMatch ? idMatch[1] : null;
58
+ if (id !== taskId)
59
+ continue;
60
+ if (m[2].toLowerCase() === "x")
61
+ return content;
62
+ lines[i] = `${m[1]}- [x] ${m[3]}`;
63
+ return lines.join("\n");
64
+ }
65
+ return content;
66
+ }
67
+ export function readTasksFile(path) {
68
+ return readFileSync(path, "utf8");
69
+ }
@@ -0,0 +1,38 @@
1
+ export interface GlobalFetState {
2
+ version: 1;
3
+ activeChangeId: string | null;
4
+ openChanges: string[];
5
+ /** Auto-run approval for validate / verify --auto (DESIGN 6.2, 14.7) */
6
+ autoRunApproval?: {
7
+ confirmedAt: string;
8
+ commandFingerprint: string;
9
+ packageManager?: string;
10
+ };
11
+ }
12
+ export interface ChangeVerifyState {
13
+ status: "pending" | "done";
14
+ mode?: "manual" | "auto";
15
+ completedAt?: string;
16
+ evidencePath?: string;
17
+ /** DESIGN 14.8: server clock when evidence file was checked */
18
+ evidenceCheckedAt?: string;
19
+ }
20
+ export interface TaskValidationRecord {
21
+ openspec?: string;
22
+ test?: string;
23
+ tsc?: string;
24
+ at: string;
25
+ }
26
+ export interface ChangeFetState {
27
+ version: 1;
28
+ changeId: string;
29
+ /** High-level workflow phase label */
30
+ currentPhase: string;
31
+ tasksCompleted: string[];
32
+ currentTaskId: string | null;
33
+ verify?: ChangeVerifyState;
34
+ /** DESIGN 5.2 / 6.1: per-task validation outcome */
35
+ validationResults?: Record<string, TaskValidationRecord>;
36
+ /** DESIGN 14.8 --strict: user-provided short audit note */
37
+ verifyStrictSummary?: string;
38
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function runValidate(cwd: string): Promise<void>;
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } 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, spawnOpenSpec } from "./openspec.js";
8
+ import { applyInstructionsPath, tasksPath } from "./paths.js";
9
+ import { readChangeState, readGlobalState, reconcileStates, writeChangeState, writeGlobalState, } from "./state.js";
10
+ import { firstIncompleteTask, markTaskDoneInMarkdown, parseTasksMd } from "./tasks.js";
11
+ function npmCmd() {
12
+ return process.platform === "win32" ? "npm.cmd" : "npm";
13
+ }
14
+ function npxCmd() {
15
+ return process.platform === "win32" ? "npx.cmd" : "npx";
16
+ }
17
+ function run(cmd, args, cwd) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(cmd, args, { cwd, stdio: "inherit", shell: false });
20
+ child.on("error", reject);
21
+ child.on("close", (c) => resolve(c ?? 1));
22
+ });
23
+ }
24
+ function resolveActiveChangeId(cwd) {
25
+ const g = readGlobalState(cwd);
26
+ if (g.activeChangeId)
27
+ return g.activeChangeId;
28
+ if (g.openChanges.length === 1)
29
+ return g.openChanges[0] ?? null;
30
+ return null;
31
+ }
32
+ export async function runValidate(cwd) {
33
+ let global = readGlobalState(cwd);
34
+ const rec = reconcileStates(cwd, global);
35
+ global = rec.global;
36
+ writeGlobalState(cwd, global);
37
+ const changeId = resolveActiveChangeId(cwd);
38
+ if (!changeId) {
39
+ console.error("[fet] No active change for validate.");
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ const fpNow = computeCommandFingerprint(cwd);
44
+ if (global.autoRunApproval?.commandFingerprint !== fpNow) {
45
+ global = { ...global, autoRunApproval: undefined };
46
+ writeGlobalState(cwd, global);
47
+ }
48
+ try {
49
+ await ensureAutoRunApproval(cwd);
50
+ }
51
+ catch (e) {
52
+ console.error(e instanceof Error ? e.message : e);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const code1 = await spawnOpenSpec(["validate", "--type", "change", changeId], {
57
+ cwd,
58
+ inheritStdio: true,
59
+ });
60
+ if (code1 !== 0) {
61
+ process.exitCode = code1;
62
+ return;
63
+ }
64
+ let testRan = false;
65
+ let testOk = true;
66
+ const pkgPath = join(cwd, "package.json");
67
+ if (existsSync(pkgPath)) {
68
+ const pkg = readFileSync(pkgPath, "utf8");
69
+ const j = JSON.parse(pkg);
70
+ if (j.scripts?.test) {
71
+ testRan = true;
72
+ const c = await run(npmCmd(), ["test"], cwd);
73
+ testOk = c === 0;
74
+ if (!testOk) {
75
+ process.exitCode = c;
76
+ return;
77
+ }
78
+ }
79
+ }
80
+ let tscRan = false;
81
+ let tscOk = true;
82
+ if (existsSync(join(cwd, "tsconfig.json"))) {
83
+ tscRan = true;
84
+ const c = await run(npxCmd(), ["tsc", "--noEmit"], cwd);
85
+ tscOk = c === 0;
86
+ if (!tscOk) {
87
+ process.exitCode = c;
88
+ return;
89
+ }
90
+ }
91
+ const tp = join(cwd, tasksPath(changeId));
92
+ const md = readFileSync(tp, "utf8");
93
+ const current = firstIncompleteTask(md);
94
+ if (!current) {
95
+ console.log("[fet] validate: no incomplete task found");
96
+ return;
97
+ }
98
+ const updatedMd = markTaskDoneInMarkdown(md, current.id);
99
+ writeFileSync(tp, updatedMd, "utf8");
100
+ let cs = readChangeState(cwd, changeId);
101
+ if (!cs)
102
+ return;
103
+ const doneIds = parseTasksMd(updatedMd)
104
+ .filter((t) => t.done)
105
+ .map((t) => t.id);
106
+ const next = firstIncompleteTask(updatedMd);
107
+ const at = new Date().toISOString();
108
+ const vr = { ...(cs.validationResults ?? {}) };
109
+ vr[current.id] = {
110
+ openspec: "pass",
111
+ test: testRan ? (testOk ? "pass" : "fail") : "skip",
112
+ tsc: tscRan ? (tscOk ? "pass" : "fail") : "skip",
113
+ at,
114
+ };
115
+ cs = {
116
+ ...cs,
117
+ tasksCompleted: doneIds,
118
+ currentTaskId: next?.id ?? null,
119
+ validationResults: vr,
120
+ };
121
+ writeChangeState(cwd, cs);
122
+ if (next) {
123
+ const front = [
124
+ "---",
125
+ `generatedAt: ${new Date().toISOString()}`,
126
+ `fetVersion: ${FET_VERSION}`,
127
+ `changeId: ${changeId}`,
128
+ `taskId: ${next.id}`,
129
+ "---",
130
+ "",
131
+ ].join("\n");
132
+ const body = [
133
+ `# Apply: ${changeId}`,
134
+ "",
135
+ `## Next task (${next.id})`,
136
+ "",
137
+ next.title,
138
+ "",
139
+ "Run `fet apply` again if you need the watcher, or continue in your AI tool. Then `fet validate` after edits.",
140
+ "",
141
+ ].join("\n");
142
+ writeFileAtomicSameDirTmp(join(cwd, applyInstructionsPath(changeId)), front + body);
143
+ if (!current.autoNext) {
144
+ console.log("[fet] Task marked done. auto_next is false: trigger /apply or fet apply before next edits.");
145
+ }
146
+ }
147
+ else {
148
+ console.log("[fet] All tasks validated. Proceed to fet verify.");
149
+ }
150
+ }
@@ -0,0 +1,6 @@
1
+ export declare function runVerify(cwd: string, opts: {
2
+ auto?: boolean;
3
+ done?: boolean;
4
+ evidence?: string;
5
+ strict?: boolean;
6
+ }): Promise<void>;
package/dist/verify.js ADDED
@@ -0,0 +1,193 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import * as readline from "node:readline/promises";
5
+ import { ensureAutoRunApproval } from "./approval.js";
6
+ import { computeCommandFingerprint } from "./fingerprint.js";
7
+ import { spawnOpenSpec } from "./openspec.js";
8
+ import { verifyInstructionsPath } from "./paths.js";
9
+ import { readChangeState, readGlobalState, reconcileStates, writeChangeState, writeGlobalState, } from "./state.js";
10
+ const EVIDENCE_MAX_AGE_MS = 1000 * 60 * 60 * 72; // 72h
11
+ const STRICT_SUMMARY_MAX = 240;
12
+ function npmCmd() {
13
+ return process.platform === "win32" ? "npm.cmd" : "npm";
14
+ }
15
+ function npxCmd() {
16
+ return process.platform === "win32" ? "npx.cmd" : "npx";
17
+ }
18
+ function run(cmd, args, cwd) {
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn(cmd, args, { cwd, stdio: "inherit", shell: false });
21
+ child.on("error", reject);
22
+ child.on("close", (c) => resolve(c ?? 1));
23
+ });
24
+ }
25
+ function resolveActiveChangeId(cwd) {
26
+ const g = readGlobalState(cwd);
27
+ if (g.activeChangeId)
28
+ return g.activeChangeId;
29
+ if (g.openChanges.length === 1)
30
+ return g.openChanges[0] ?? null;
31
+ return null;
32
+ }
33
+ function resolveEvidencePath(cwd, p) {
34
+ if (p.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(p))
35
+ return p;
36
+ return join(cwd, p);
37
+ }
38
+ async function readStrictSummary() {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
+ try {
41
+ const raw = await rl.question(`[fet] --strict: paste a short summary of verification (max ${STRICT_SUMMARY_MAX} chars): `);
42
+ return raw.trim().slice(0, STRICT_SUMMARY_MAX);
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ }
48
+ export async function runVerify(cwd, opts) {
49
+ let global = readGlobalState(cwd);
50
+ const rec = reconcileStates(cwd, global);
51
+ global = rec.global;
52
+ writeGlobalState(cwd, global);
53
+ const changeId = resolveActiveChangeId(cwd);
54
+ if (!changeId) {
55
+ console.error("[fet] No active change for verify.");
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ const fpNow = computeCommandFingerprint(cwd);
60
+ if (global.autoRunApproval?.commandFingerprint !== fpNow) {
61
+ global = { ...global, autoRunApproval: undefined };
62
+ writeGlobalState(cwd, global);
63
+ }
64
+ if (opts.done) {
65
+ let cs = readChangeState(cwd, changeId);
66
+ if (!cs) {
67
+ console.error("[fet] Missing change state");
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+ let evidenceCheckedAt;
72
+ if (opts.evidence) {
73
+ const abs = resolveEvidencePath(cwd, opts.evidence);
74
+ if (!existsSync(abs)) {
75
+ console.error("[fet] evidence file not found");
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ const st = statSync(abs);
80
+ if (st.size === 0) {
81
+ console.error("[fet] evidence file is empty");
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ if (Date.now() - st.mtimeMs > EVIDENCE_MAX_AGE_MS) {
86
+ console.error("[fet] evidence file is older than 72h; provide a fresher log (DESIGN 14.8).");
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ evidenceCheckedAt = new Date().toISOString();
91
+ }
92
+ let verifyStrictSummary;
93
+ if (opts.strict) {
94
+ verifyStrictSummary = await readStrictSummary();
95
+ if (!verifyStrictSummary) {
96
+ console.error("[fet] strict mode requires a non-empty summary");
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ }
101
+ cs = {
102
+ ...cs,
103
+ verify: {
104
+ status: "done",
105
+ mode: "manual",
106
+ completedAt: new Date().toISOString(),
107
+ evidencePath: opts.evidence,
108
+ evidenceCheckedAt,
109
+ },
110
+ verifyStrictSummary,
111
+ };
112
+ writeChangeState(cwd, cs);
113
+ console.log("[fet] Recorded verify --done (honest trust model, DESIGN 14.8). Not a cryptographic audit trail.");
114
+ return;
115
+ }
116
+ if (opts.auto) {
117
+ try {
118
+ await ensureAutoRunApproval(cwd);
119
+ }
120
+ catch (e) {
121
+ console.error(e instanceof Error ? e.message : e);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ // Current OpenSpec CLI has `validate`, not top-level `verify` (DESIGN 7.1 alias).
126
+ const c1 = await spawnOpenSpec(["validate", "--type", "change", changeId], { cwd, inheritStdio: true });
127
+ if (c1 !== 0) {
128
+ process.exitCode = c1;
129
+ return;
130
+ }
131
+ const pkgPath = join(cwd, "package.json");
132
+ if (existsSync(pkgPath)) {
133
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
134
+ if (pkg.scripts?.lint) {
135
+ const c = await run(npmCmd(), ["run", "lint"], cwd);
136
+ if (c !== 0) {
137
+ process.exitCode = c;
138
+ return;
139
+ }
140
+ }
141
+ }
142
+ if (existsSync(join(cwd, "tsconfig.json"))) {
143
+ const c = await run(npxCmd(), ["tsc", "--noEmit"], cwd);
144
+ if (c !== 0) {
145
+ process.exitCode = c;
146
+ return;
147
+ }
148
+ }
149
+ if (existsSync(join(cwd, "package.json"))) {
150
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
151
+ if (pkg.scripts?.test) {
152
+ const c = await run(npmCmd(), ["test"], cwd);
153
+ if (c !== 0) {
154
+ process.exitCode = c;
155
+ return;
156
+ }
157
+ }
158
+ }
159
+ let cs = readChangeState(cwd, changeId);
160
+ if (!cs)
161
+ return;
162
+ cs = {
163
+ ...cs,
164
+ verify: { status: "done", mode: "auto", completedAt: new Date().toISOString() },
165
+ };
166
+ writeChangeState(cwd, cs);
167
+ console.log("[fet] verify --auto completed (spec step: openspec validate).");
168
+ return;
169
+ }
170
+ const txt = [
171
+ `Verify checklist for change: ${changeId}`,
172
+ "",
173
+ "1) Run: openspec validate (via `fet validate` or manually)",
174
+ "2) Run project tests / lint as required by your team",
175
+ "",
176
+ "When finished, run:",
177
+ "",
178
+ " fet verify --done",
179
+ "",
180
+ "Optional (DESIGN 14.8):",
181
+ " fet verify --done --evidence path/to/log.txt # file must exist, be non-empty, mtime < 72h",
182
+ " fet verify --done --strict # prompts for a short audit note",
183
+ "",
184
+ ].join("\n");
185
+ const p = join(cwd, verifyInstructionsPath(changeId));
186
+ writeFileSync(p, txt, "utf8");
187
+ let cs = readChangeState(cwd, changeId);
188
+ if (!cs)
189
+ return;
190
+ cs = { ...cs, verify: { status: "pending", mode: "manual" } };
191
+ writeChangeState(cwd, cs);
192
+ console.log(`[fet] Wrote ${verifyInstructionsPath(changeId)}`);
193
+ }
@@ -0,0 +1,2 @@
1
+ /** DESIGN 6.3: default + fet.watch_directories + monorepo packages */
2
+ export declare function resolveWatchRoots(cwd: string): string[];
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { CONFIG_FILE } from "./paths.js";
5
+ /** DESIGN 6.3: default + fet.watch_directories + monorepo packages */
6
+ export function resolveWatchRoots(cwd) {
7
+ const seen = new Set();
8
+ const add = (p) => {
9
+ if (existsSync(p)) {
10
+ try {
11
+ if (statSync(p).isDirectory())
12
+ seen.add(p);
13
+ }
14
+ catch {
15
+ /* skip */
16
+ }
17
+ }
18
+ };
19
+ for (const rel of ["src", "tests", "test", "lib"]) {
20
+ add(join(cwd, rel));
21
+ }
22
+ const cfgPath = join(cwd, CONFIG_FILE);
23
+ if (existsSync(cfgPath)) {
24
+ try {
25
+ const doc = parseYaml(readFileSync(cfgPath, "utf8"));
26
+ const dirs = doc?.fet?.watch_directories;
27
+ if (Array.isArray(dirs)) {
28
+ for (const d of dirs) {
29
+ if (typeof d === "string" && d.trim())
30
+ add(join(cwd, d.trim()));
31
+ }
32
+ }
33
+ }
34
+ catch {
35
+ /* invalid yaml */
36
+ }
37
+ }
38
+ const pkgPath = join(cwd, "package.json");
39
+ if (existsSync(pkgPath)) {
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
42
+ const patterns = Array.isArray(pkg.workspaces)
43
+ ? pkg.workspaces
44
+ : (pkg.workspaces?.packages ?? []);
45
+ for (const pat of patterns) {
46
+ if (typeof pat !== "string")
47
+ continue;
48
+ if (pat.includes("*")) {
49
+ const packagesDir = join(cwd, "packages");
50
+ if (existsSync(packagesDir)) {
51
+ for (const name of readdirSync(packagesDir)) {
52
+ add(join(packagesDir, name, "src"));
53
+ add(join(packagesDir, name, "tests"));
54
+ }
55
+ }
56
+ }
57
+ else {
58
+ add(join(cwd, pat, "src"));
59
+ add(join(cwd, pat, "tests"));
60
+ }
61
+ }
62
+ }
63
+ catch {
64
+ /* skip */
65
+ }
66
+ }
67
+ if (seen.size === 0)
68
+ add(cwd);
69
+ return [...seen];
70
+ }
@@ -0,0 +1,2 @@
1
+ /** DESIGN 4.2 / 8.2: post-hook style hints after successful workflow commands */
2
+ export declare function printWorkflowHint(kind: string): void;
@@ -0,0 +1,9 @@
1
+ /** DESIGN 4.2 / 8.2: post-hook style hints after successful workflow commands */
2
+ export function printWorkflowHint(kind) {
3
+ if (kind === "continue" || kind === "ff") {
4
+ console.log("[fet] Review generated artifacts under openspec/changes/<id>/, then run `fet continue` or `fet ff` as needed.");
5
+ }
6
+ else if (kind === "onboard") {
7
+ console.log("[fet] To enable FET + OpenSpec in this repo, run: `fet init`");
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nick848/fet",
3
+ "version": "0.1.0",
4
+ "description": "Frontend workflow orchestration around OpenSpec (FET)",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "bin": {
10
+ "fet": "./dist/cli.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
22
+ "build": "npm run clean && tsc",
23
+ "test": "vitest run",
24
+ "prepublishOnly": "npm run build && npm test"
25
+ },
26
+ "keywords": [
27
+ "fet",
28
+ "openspec",
29
+ "cli",
30
+ "workflow",
31
+ "cursor",
32
+ "opencode",
33
+ "spec-driven"
34
+ ],
35
+ "author": "nick848",
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "chokidar": "^4.0.3",
39
+ "commander": "^12.1.0",
40
+ "handlebars": "^4.7.8",
41
+ "json5": "^2.2.3",
42
+ "yaml": "^2.7.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.13.5",
46
+ "typescript": "^5.7.3",
47
+ "vitest": "^3.0.5"
48
+ }
49
+ }