@precode/mcp 0.1.1 → 0.3.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/README.md CHANGED
@@ -7,22 +7,36 @@ Install: [`@precode/mcp` on npm](https://www.npmjs.com/package/@precode/mcp). Do
7
7
  It fixes the loop/goal failure other spec MCPs ship with:
8
8
 
9
9
  - **Goal re-anchoring** — the agent re-reads the goal every phase, so long builds don't drift.
10
- - **Hard definition-of-done** — a task is marked done **only when its checks actually pass**, never when the agent merely claims so.
10
+ - **Hard definition-of-done** — when you declare checks in `.precode/checks.json`, **the MCP runs them itself** and the verdict comes from the real exit codes. A fabricated "it passed" cannot move a task to done.
11
11
  - **Closed verify → fix → re-verify** — gaps come back with fixes and the loop will not advance past them.
12
- - **Deterministic-first** — trusts the build/type-check/lint/test results your agent runs in its terminal.
12
+ - **Honest escapes** — a check that genuinely can't pass here (needs a secret, a deploy, a paid service) is `precode.defer_task`'d with a reason and surfaced in `TODO_FOR_YOU.md`, never silently skipped.
13
13
 
14
14
  At the end it writes an **`IMPLEMENTATION.md`** (what was built) and a **`TODO_FOR_YOU.md`** (what you still need to do — secrets, env, deploy).
15
15
 
16
16
  ## Install
17
17
 
18
- Runs over stdio. Point any MCP host at it.
18
+ Runs over stdio. Point any MCP host at `npx -y @precode/mcp`. Full per-host copy-paste configs: [useprecode.vercel.app/mcp](https://useprecode.vercel.app/mcp).
19
19
 
20
- **Cursor** `.cursor/mcp.json`:
20
+ | Host | Config location | How |
21
+ |------|-----------------|-----|
22
+ | **Cursor** | `.cursor/mcp.json` | JSON — see below |
23
+ | **Claude Code** | `.mcp.json` (project) | `claude mcp add --env PRECODE_TELEMETRY_DISABLED=1 --transport stdio --scope project precode -- npx -y @precode/mcp` |
24
+ | **Claude Desktop** | `~/Library/Application Support/Claude/claude_desktop_config.json` | JSON — same shape as Cursor |
25
+ | **Codex CLI** | `~/.codex/config.toml` | `codex mcp add precode -- npx -y @precode/mcp` |
26
+ | **VS Code** | `.vscode/mcp.json` | JSON — uses `"servers"` not `"mcpServers"` |
27
+ | **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | JSON — same shape as Cursor |
28
+ | **Antigravity** | `~/.gemini/antigravity/mcp_config.json` | Agent panel → Manage MCP Servers → View raw config |
29
+
30
+ **Cursor / Claude Desktop / Windsurf / Antigravity** — `mcpServers` JSON:
21
31
 
22
32
  ```json
23
33
  {
24
34
  "mcpServers": {
25
- "precode": { "command": "npx", "args": ["-y", "@precode/mcp"] }
35
+ "precode": {
36
+ "command": "npx",
37
+ "args": ["-y", "@precode/mcp"],
38
+ "env": { "PRECODE_TELEMETRY_DISABLED": "1" }
39
+ }
26
40
  }
27
41
  }
28
42
  ```
@@ -30,10 +44,30 @@ Runs over stdio. Point any MCP host at it.
30
44
  **Claude Code:**
31
45
 
32
46
  ```bash
33
- claude mcp add precode -- npx -y @precode/mcp
47
+ claude mcp add --env PRECODE_TELEMETRY_DISABLED=1 --transport stdio --scope project precode -- npx -y @precode/mcp
48
+ ```
49
+
50
+ **Codex CLI:**
51
+
52
+ ```bash
53
+ codex mcp add precode -- npx -y @precode/mcp
34
54
  ```
35
55
 
36
- **Codex / Windsurf / VS Code** — add the same `command: npx`, `args: ["-y", "@precode/mcp"]` to their MCP config.
56
+ **VS Code** — note `"servers"` wrapper and optional `"cwd": "${workspaceFolder}"`:
57
+
58
+ ```json
59
+ {
60
+ "servers": {
61
+ "precode": {
62
+ "type": "stdio",
63
+ "command": "npx",
64
+ "args": ["-y", "@precode/mcp"],
65
+ "cwd": "${workspaceFolder}",
66
+ "env": { "PRECODE_TELEMETRY_DISABLED": "1" }
67
+ }
68
+ }
69
+ }
70
+ ```
37
71
 
38
72
  The server reads `.precode/` from the directory the host runs it in (`PRECODE_ROOT` overrides). Local stdio only — the build loop needs your filesystem.
39
73
 
@@ -52,19 +86,44 @@ Telemetry is off unless `PRECODE_TELEMETRY_URL` is set. When enabled, the server
52
86
  npm run test:stdio
53
87
  ```
54
88
 
55
- The smoke test launches the built MCP server over stdio, verifies the tool list, confirms failed checks keep a task open, confirms passing checks mark it done, reads `precode://docs`, and writes the final `TODO_FOR_YOU.md`.
89
+ The smoke test launches the built MCP server over stdio, verifies the tool list, runs a real executable check, confirms a failing check (and a fabricated passing report) keep the task open, confirms the task closes only once the real check passes, reads `precode://docs`, and writes the final `TODO_FOR_YOU.md`. `npm test` runs the full edge-case suite (`test/run.mjs`).
56
90
 
57
91
  ## Tools
58
92
 
59
93
  | Tool | Does |
60
94
  |------|------|
61
95
  | `precode.get_goal` | Re-anchor on the goal + progress |
96
+ | `precode.status` | Read-only snapshot of tasks, checks, and deferred work |
62
97
  | `precode.next_phase` | Alias for the next phased build step |
63
98
  | `precode.next_task` | Pull the next step + acceptance criteria |
64
- | `precode.verify` | Report check results; marks done **only if they pass** |
99
+ | `precode.verify` | **Runs** the task's `checks.json` checks; marks done only on real exit-0 |
100
+ | `precode.defer_task` | Honest escape for a check that can't pass here (logged to TODO) |
65
101
  | `precode.record_implementation` | Append to the implementation ledger |
66
102
  | `precode.finalize` | Write `TODO_FOR_YOU.md` |
67
- | `precode.adopt_spec` | Map a non-PreCode spec into `.precode/` |
103
+ | `precode.adopt_spec` | Map a non-PreCode spec into `.precode/` (+ starter `checks.json`) |
104
+
105
+ ## Checks — the executable done-gate
106
+
107
+ Declare checks in `.precode/checks.json`. Each `auto` check is **run by the MCP** from the project root on every `precode.verify`; the task closes only when its real exit code is `0`.
108
+
109
+ ```json
110
+ {
111
+ "checks": [
112
+ { "id": "typecheck", "name": "Type-check", "kind": "auto", "cmd": "npm run typecheck" },
113
+ { "id": "build", "name": "Build", "kind": "auto", "cmd": "npm run build" },
114
+ { "id": "test", "name": "Tests", "kind": "auto", "cmd": "npm test", "expect": "/\\d+ passing/" },
115
+ { "id": "deploy-task-3", "name": "Smoke route /health", "kind": "auto", "cmd": "curl -fsS localhost:3000/health", "taskIndex": 3 },
116
+ { "id": "secrets", "name": "Prod secrets set in real accounts", "kind": "manual" }
117
+ ]
118
+ }
119
+ ```
120
+
121
+ - `kind: "auto"` — executed; pass = exit 0 (and `expect` substring/`/regex/` matches output if set). Optional `timeoutMs` (default 120s).
122
+ - `kind: "manual"` — never auto-passed; surfaced as a human gate in `TODO_FOR_YOU.md` (a manual-only task closes with its gate routed to you).
123
+ - `taskIndex` — scope a check to one task; omit for a global check that runs on every task.
124
+ - **A task never closes on a self-reported pass.** Reported `checkResults` are advisory only. A check declared with an empty `cmd` is treated as unconfigured and **HOLDs** the task — wire a real command, mark it `manual`, or `precode.defer_task`. `adopt_spec` writes such placeholders on purpose so a fresh spec can't be "verified" until you point the gate at something real.
125
+
126
+ **Security:** the runner only executes commands you put in your own repo's `checks.json` — the same trust level as the agent already running your build. Commands run with a timeout and captured output. Because `checks.json` lives in your repo, gate tampering shows up in your diff.
68
127
 
69
128
  ## Resources
70
129
 
package/dist/checks.js ADDED
@@ -0,0 +1,120 @@
1
+ import { spawn } from "node:child_process";
2
+ const DEFAULT_TIMEOUT_MS = 120_000;
3
+ const OUTPUT_TAIL = 1600;
4
+ function tail(s, n = OUTPUT_TAIL) {
5
+ const trimmed = s.replace(/\s+$/, "");
6
+ return trimmed.length > n ? "…" + trimmed.slice(-n) : trimmed;
7
+ }
8
+ function matchesExpect(output, expect) {
9
+ if (expect.length > 1 && expect.startsWith("/") && expect.endsWith("/")) {
10
+ try {
11
+ return new RegExp(expect.slice(1, -1)).test(output);
12
+ }
13
+ catch {
14
+ // Malformed regex => fall back to substring so a bad spec can't crash the gate.
15
+ return output.includes(expect);
16
+ }
17
+ }
18
+ return output.includes(expect);
19
+ }
20
+ /**
21
+ * Resolve a spec to one of three states:
22
+ * - "manual": an explicit human gate (surfaced, never auto-passed).
23
+ * - "auto": has a runnable command (executed; authoritative).
24
+ * - "skip": an unconfigured placeholder (no command, not manual) — ignored
25
+ * until the user fills in `cmd`, so an empty starter check never gates.
26
+ */
27
+ export function checkKind(spec) {
28
+ if (spec.kind === "manual")
29
+ return "manual";
30
+ if (spec.cmd && spec.cmd.trim())
31
+ return "auto";
32
+ return "skip";
33
+ }
34
+ /** Run a single auto check. Manual checks are returned as skipped (human gate). */
35
+ export function runCheck(spec, root) {
36
+ const kind = checkKind(spec);
37
+ if (kind !== "auto") {
38
+ return Promise.resolve({
39
+ id: spec.id,
40
+ name: spec.name,
41
+ kind: kind === "manual" ? "manual" : "auto",
42
+ passed: false,
43
+ skipped: true,
44
+ exitCode: null,
45
+ timedOut: false,
46
+ detail: kind === "manual"
47
+ ? "manual check — requires human verification, not auto-passed."
48
+ : "unconfigured placeholder — no command set, ignored.",
49
+ });
50
+ }
51
+ const timeoutMs = typeof spec.timeoutMs === "number" && spec.timeoutMs > 0
52
+ ? spec.timeoutMs
53
+ : DEFAULT_TIMEOUT_MS;
54
+ return new Promise((resolve) => {
55
+ let out = "";
56
+ let settled = false;
57
+ let timedOut = false;
58
+ const child = spawn(spec.cmd, {
59
+ cwd: root,
60
+ shell: true,
61
+ env: process.env,
62
+ });
63
+ const timer = setTimeout(() => {
64
+ timedOut = true;
65
+ child.kill("SIGKILL");
66
+ }, timeoutMs);
67
+ const onData = (d) => {
68
+ out += d.toString();
69
+ if (out.length > OUTPUT_TAIL * 4)
70
+ out = out.slice(-OUTPUT_TAIL * 4);
71
+ };
72
+ child.stdout?.on("data", onData);
73
+ child.stderr?.on("data", onData);
74
+ const finish = (exitCode) => {
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ let passed = !timedOut && exitCode === 0;
80
+ let detail;
81
+ if (timedOut) {
82
+ detail = `timed out after ${timeoutMs}ms. ${tail(out)}`;
83
+ }
84
+ else if (exitCode !== 0) {
85
+ detail = `exit ${exitCode}. ${tail(out)}`;
86
+ }
87
+ else if (spec.expect && !matchesExpect(out, spec.expect)) {
88
+ passed = false;
89
+ detail = `exit 0 but output did not match expect=${spec.expect}. ${tail(out)}`;
90
+ }
91
+ else {
92
+ detail = tail(out) || "passed (exit 0).";
93
+ }
94
+ resolve({
95
+ id: spec.id,
96
+ name: spec.name,
97
+ kind,
98
+ passed,
99
+ skipped: false,
100
+ exitCode,
101
+ timedOut,
102
+ detail,
103
+ });
104
+ };
105
+ child.on("error", (err) => {
106
+ // e.g. spawn failure; surface as a real failure rather than throwing.
107
+ finish(null);
108
+ void err;
109
+ });
110
+ child.on("close", (code) => finish(code));
111
+ });
112
+ }
113
+ /** Run checks sequentially for deterministic, reproducible ordering. */
114
+ export async function runChecks(specs, root) {
115
+ const results = [];
116
+ for (const spec of specs) {
117
+ results.push(await runCheck(spec, root));
118
+ }
119
+ return results;
120
+ }
package/dist/index.js CHANGED
@@ -2,8 +2,14 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import { createRequire } from "node:module";
5
6
  import { PrecodeStore, adoptSpec } from "./store.js";
7
+ import { checkKind, runChecks } from "./checks.js";
6
8
  import { recordTelemetry, taskTelemetry } from "./telemetry.js";
9
+ // Single source of truth for the version the handshake reports — read from
10
+ // package.json so it can never drift from the published package again.
11
+ const VERSION = createRequire(import.meta.url)("../package.json")
12
+ .version ?? "0.0.0";
7
13
  /**
8
14
  * @precode/mcp — drives a phased build → recheck → fix loop over a `.precode/`
9
15
  * package. Fixes the loop/goal failure other spec MCPs ship with:
@@ -21,7 +27,15 @@ function text(s) {
21
27
  }
22
28
  const NO_STORE = text("No `.precode/` package found in this directory. Either drop a PreCode export here, " +
23
29
  "or call `precode.adopt_spec` to map a plain SPEC.md / .specify / .spec-workflow folder into one.");
24
- const server = new McpServer({ name: "precode", version: "0.1.0" });
30
+ const server = new McpServer({ name: "precode", version: VERSION }, {
31
+ instructions: "PreCode drives a spec → build → verify loop with a hard, SELF-EXECUTING done-gate. " +
32
+ "Flow: ensure a .precode/ package exists (precode.adopt_spec maps any SPEC.md / .specify / .spec-workflow). " +
33
+ "Then loop: precode.get_goal to anchor on the goal → precode.next_task → build ONLY that task → precode.verify. " +
34
+ "On verify the MCP itself RUNS the commands in .precode/checks.json; a task closes only on real exit-0, so self-reported results cannot fake a pass. " +
35
+ "A failing or unconfigured check keeps the task OPEN with concrete fixes. If a check genuinely cannot pass here (needs a secret/deploy/external service), call precode.defer_task with a reason. " +
36
+ "When every task is done, call precode.finalize. Call precode.status anytime for a read-only progress snapshot. " +
37
+ "Resources precode://goal, ://tasks, ://docs, ://acceptance, ://progress are re-readable each phase.",
38
+ });
25
39
  // --- Tool: get the goal (anti-drift anchor) -------------------------------
26
40
  server.tool("precode.get_goal", "Re-read the project goal and progress. Call this at the start of every phase so the build does not drift from the spec.", {}, async () => {
27
41
  const s = await store();
@@ -30,6 +44,37 @@ server.tool("precode.get_goal", "Re-read the project goal and progress. Call thi
30
44
  await recordTelemetry({ eventName: "mcp_get_goal", root: ROOT });
31
45
  return text(await s.goal());
32
46
  });
47
+ // --- Tool: status (read-only snapshot) ------------------------------------
48
+ server.tool("precode.status", "Read-only snapshot of the build: task counts, the current open task, deferred tasks, and whether real checks are configured. Has no side effects — safe to call anytime.", {}, async () => {
49
+ const s = await store();
50
+ if (!s)
51
+ return NO_STORE;
52
+ const tasks = await s.tasks();
53
+ const deferred = await s.deferredEntries();
54
+ const deferredSet = new Set(deferred.map((e) => e.index));
55
+ const done = tasks.filter((t) => t.done).length;
56
+ const open = tasks.filter((t) => !t.done && !deferredSet.has(t.index));
57
+ const next = open[0];
58
+ const allChecks = await s.checks();
59
+ const autoConfigured = allChecks.filter((c) => checkKind(c) === "auto").length;
60
+ const placeholders = allChecks.filter((c) => checkKind(c) === "skip").length;
61
+ const manual = allChecks.filter((c) => checkKind(c) === "manual").length;
62
+ return text([
63
+ `Tasks: ${done}/${tasks.length} done, ${open.length} open, ${deferred.length} deferred.`,
64
+ next ? `Next open task: #${next.index} — ${next.text}` : "No open tasks — call precode.finalize.",
65
+ `Checks in checks.json: ${autoConfigured} auto (executed), ${manual} manual, ${placeholders} unconfigured placeholder(s).`,
66
+ placeholders > 0
67
+ ? "WARNING: placeholder checks have no command — tasks they apply to will HOLD until you set a real cmd, mark them manual, or defer."
68
+ : autoConfigured === 0 && manual === 0
69
+ ? "WARNING: no checks defined — add an auto check with a real cmd so the gate can verify."
70
+ : "Gate is backed by real executed checks.",
71
+ deferred.length
72
+ ? `Deferred: ${deferred.map((e) => `#${e.index} (${e.reason})`).join("; ")}`
73
+ : "",
74
+ ]
75
+ .filter(Boolean)
76
+ .join("\n"));
77
+ });
33
78
  // --- Tool: next task ------------------------------------------------------
34
79
  server.tool("precode.next_task", "Pull the next unchecked build step plus the goal and acceptance criteria. Build ONLY this task, then call precode.verify. Do not skip ahead.", {}, async () => {
35
80
  const s = await store();
@@ -90,83 +135,146 @@ server.tool("precode.next_phase", "Alias for precode.next_task. Pulls the next p
90
135
  ].join("\n"));
91
136
  });
92
137
  // --- Tool: verify (the hard done-gate + fix loop) -------------------------
93
- server.tool("precode.verify", "Report the results of the deterministic checks you RAN (build/type-check/lint/tests). The task is marked done ONLY if every reported check passed and at least one ran. Otherwise you get the gaps back and the task stays open fix and call precode.verify again.", {
138
+ server.tool("precode.verify", "Close the done-gate for the current task. The MCP RUNS the task's auto checks from .precode/checks.json itself and the verdict comes from real exit codes — your reported results CANNOT fake a pass and never close a task on their own. A task is marked done only when every auto check passes (or it has only manual gates, which route to TODO). If a declared check has no command yet, or no check is defined, you get a HOLD telling you to wire a real command, mark it manual, or precode.defer_task. Fix and call precode.verify again.", {
94
139
  checkResults: z
95
140
  .array(z.object({
96
141
  name: z.string().describe("e.g. 'build', 'typecheck', 'lint', 'smoke test'"),
97
142
  passed: z.boolean(),
98
143
  detail: z.string().optional(),
99
144
  }))
100
- .describe("Results of checks you actually executed in the terminal."),
145
+ .optional()
146
+ .describe("ADVISORY ONLY. Never used to mark a task done — a task closes only when the MCP executes its checks.json checks and they pass (or via a manual gate / precode.defer_task)."),
101
147
  notes: z.string().optional(),
102
148
  task: z.string().optional().describe("Optional host-side task identifier or title."),
103
149
  }, async ({ checkResults, notes, task: taskLabel }) => {
104
150
  const s = await store();
105
151
  if (!s)
106
152
  return NO_STORE;
107
- const task = await s.nextOpenTask();
108
- if (!task)
109
- return text("No open task to verify. Run precode.finalize.");
110
- if (checkResults.length === 0) {
111
- return text(`HOLD. Task #${task.index} cannot be marked done — you reported no checks. ` +
112
- "Run the acceptance checks (build, type-check, lint, smoke test) in your terminal, then report results.");
153
+ if (!(await s.acquireLock())) {
154
+ return text("Another precode.verify is in progress for this package. Wait for it to finish, then retry.");
113
155
  }
114
- const failed = checkResults.filter((c) => !c.passed);
115
- if (failed.length > 0) {
116
- const failureLines = failed.map((c) => `${c.name}: FAILED${c.detail ? ` — ${c.detail}` : ""}`);
117
- const retry = await s.recordVerificationFailure({
118
- task,
119
- failures: failureLines,
120
- });
121
- await recordTelemetry({
122
- eventName: "mcp_verify_fail",
123
- root: ROOT,
124
- task: taskTelemetry(task),
125
- metadata: {
126
- checkCount: checkResults.length,
127
- failedCount: failed.length,
128
- attempts: retry.attempts,
129
- escalated: retry.escalated,
130
- },
131
- });
132
- const gaps = failed
133
- .map((c) => `- ${c.name}: FAILED${c.detail ? ` — ${c.detail}` : ""}`)
134
- .join("\n");
135
- const fixes = failed
136
- .map((c) => `- ${c.name}: ${fixHint(c.name)}`)
137
- .join("\n");
156
+ try {
157
+ const task = await s.nextOpenTask();
158
+ if (!task)
159
+ return text("No open task to verify. Run precode.finalize.");
160
+ const specs = await s.checksForTask(task.index);
161
+ const autoSpecs = specs.filter((c) => checkKind(c) === "auto");
162
+ const manualSpecs = specs.filter((c) => checkKind(c) === "manual");
163
+ const placeholderSpecs = specs.filter((c) => checkKind(c) === "skip");
164
+ // Route manual checks to the human TODO when the task closes — never auto-passed.
165
+ const routeManual = async () => {
166
+ if (!manualSpecs.length)
167
+ return;
168
+ await s.appendTodo([
169
+ `## Task #${task.index} — manual verification required`,
170
+ "",
171
+ ...manualSpecs.map((c) => `- [ ] ${c.name}`),
172
+ ].join("\n"));
173
+ };
174
+ const closeTask = async (verifiedLine) => {
175
+ await s.markTaskDone(task.index);
176
+ await routeManual();
177
+ await s.appendImplementation([
178
+ `## Task #${task.index}: ${task.text}`,
179
+ taskLabel ? `Host task: ${taskLabel}` : "",
180
+ verifiedLine,
181
+ manualSpecs.length
182
+ ? `Manual gates routed to TODO_FOR_YOU.md: ${manualSpecs.map((c) => c.name).join(", ")}`
183
+ : "",
184
+ notes ? `Notes: ${notes}` : "",
185
+ ]
186
+ .filter(Boolean)
187
+ .join("\n"));
188
+ const next = await s.nextOpenTask();
189
+ const manualNote = manualSpecs.length
190
+ ? ` ${manualSpecs.length} manual check(s) routed to TODO_FOR_YOU.md for you.`
191
+ : "";
192
+ return text((next
193
+ ? `PASS. Task #${task.index} marked done.${manualNote} Call precode.next_task for the next step (#${next.index}).`
194
+ : `PASS. Task #${task.index} marked done.${manualNote} All tasks complete — call precode.finalize.`));
195
+ };
196
+ const gateFailure = async (failureLines, fixLines) => {
197
+ const retry = await s.recordVerificationFailure({ task, failures: failureLines });
198
+ await recordTelemetry({
199
+ eventName: "mcp_verify_fail",
200
+ root: ROOT,
201
+ task: taskTelemetry(task),
202
+ metadata: { failedCount: failureLines.length, attempts: retry.attempts, escalated: retry.escalated },
203
+ });
204
+ return text([
205
+ `GATE: Task #${task.index} stays OPEN. ${failureLines.length} check(s) failed:`,
206
+ ...failureLines.map((l) => `- ${l}`),
207
+ "",
208
+ "Concrete next fixes:",
209
+ ...fixLines.map((l) => `- ${l}`),
210
+ "",
211
+ `Verify attempt ${retry.attempts}/3 for this task.`,
212
+ retry.escalated
213
+ ? "Escalated honestly into .precode/progress/TODO_FOR_YOU.md because the retry bound was reached. If a check genuinely cannot pass here (needs a secret, a deploy, an external service), call precode.defer_task with a reason instead of looping."
214
+ : "Fix these, re-run, and call precode.verify again. Do not call precode.next_task until this passes.",
215
+ ].join("\n"));
216
+ };
217
+ // --- Authoritative path: the MCP executes the declared auto checks. ---
218
+ if (autoSpecs.length > 0) {
219
+ const runs = await runChecks(autoSpecs, ROOT);
220
+ const failed = runs.filter((r) => !r.passed);
221
+ if (failed.length > 0) {
222
+ return await gateFailure(failed.map((r) => `${r.name}: FAILED — ${r.detail}`), failed.map((r) => `${r.name}: ${fixHint(r.name)}`));
223
+ }
224
+ await recordTelemetry({
225
+ eventName: "mcp_verify_pass",
226
+ root: ROOT,
227
+ task: taskTelemetry(task),
228
+ metadata: { checkCount: runs.length, executed: true },
229
+ });
230
+ return await closeTask(`Verified by MCP execution: ${runs.map((r) => `${r.name} ✓ (exit ${r.exitCode})`).join(", ")}`);
231
+ }
232
+ // --- No auto check ran. A task NEVER closes on a self-reported pass. ---
233
+ const reported = checkResults ?? [];
234
+ const reportedNote = reported.length
235
+ ? `Agent-reported (advisory, NOT used to close): ${reported
236
+ .map((c) => `${c.name}=${c.passed ? "pass" : "fail"}`)
237
+ .join(", ")}`
238
+ : "";
239
+ // Unconfigured placeholder checks (declared but no command) — hold and
240
+ // tell the agent to wire a real command, mark it manual, or defer.
241
+ if (placeholderSpecs.length > 0) {
242
+ return text([
243
+ `HOLD. Task #${task.index} is NOT done.`,
244
+ `${placeholderSpecs.length} declared check(s) have no command yet: ${placeholderSpecs
245
+ .map((c) => c.name)
246
+ .join(", ")}.`,
247
+ "A task never closes on a self-reported pass. Do ONE of these in .precode/checks.json:",
248
+ "- set a real shell `cmd` for each check (preferred — the gate then RUNS it), or",
249
+ '- set its `kind` to `manual` if only a human can verify it (routed to TODO_FOR_YOU.md), or',
250
+ "- call precode.defer_task if it genuinely cannot be verified in this environment.",
251
+ reportedNote,
252
+ ]
253
+ .filter(Boolean)
254
+ .join("\n"));
255
+ }
256
+ // Only explicit manual gates apply — a legitimate human-verified close.
257
+ if (manualSpecs.length > 0) {
258
+ await recordTelemetry({
259
+ eventName: "mcp_verify_pass",
260
+ root: ROOT,
261
+ task: taskTelemetry(task),
262
+ metadata: { checkCount: manualSpecs.length, executed: false, manualOnly: true },
263
+ });
264
+ return await closeTask("Closed with MANUAL gates only (no automatable check) — routed to TODO_FOR_YOU.md for your verification.");
265
+ }
266
+ // No check defined at all for this task.
138
267
  return text([
139
- `GATE: Task #${task.index} stays OPEN. ${failed.length} check(s) failed:`,
140
- gaps,
141
- "",
142
- "Concrete next fixes:",
143
- fixes,
144
- "",
145
- `Verify attempt ${retry.attempts}/3 for this task.`,
146
- retry.escalated
147
- ? "Escalated honestly into .precode/progress/TODO_FOR_YOU.md because the retry bound was reached."
148
- : "Fix these, re-run the checks, and call precode.verify again. Do not call precode.next_task until this passes.",
149
- ].join("\n"));
268
+ `HOLD. Task #${task.index} has no check defined in .precode/checks.json, so it cannot be verified.`,
269
+ "A task never closes on a self-reported pass. Add at least one check (preferred: an `auto` check with a real `cmd` the gate runs), mark it `manual`, or call precode.defer_task.",
270
+ reportedNote,
271
+ ]
272
+ .filter(Boolean)
273
+ .join("\n"));
274
+ }
275
+ finally {
276
+ await s.releaseLock();
150
277
  }
151
- await s.markTaskDone(task.index);
152
- await recordTelemetry({
153
- eventName: "mcp_verify_pass",
154
- root: ROOT,
155
- task: taskTelemetry(task),
156
- metadata: { checkCount: checkResults.length },
157
- });
158
- await s.appendImplementation([
159
- `## Task #${task.index}: ${task.text}`,
160
- taskLabel ? `Host task: ${taskLabel}` : "",
161
- `Verified: ${checkResults.map((c) => `${c.name} ✓`).join(", ")}`,
162
- notes ? `Notes: ${notes}` : "",
163
- ]
164
- .filter(Boolean)
165
- .join("\n"));
166
- const next = await s.nextOpenTask();
167
- return text(next
168
- ? `PASS. Task #${task.index} marked done. Call precode.next_task for the next step (#${next.index}).`
169
- : `PASS. Task #${task.index} marked done. All tasks complete — call precode.finalize.`);
170
278
  });
171
279
  // --- Tool: record implementation (ledger) ---------------------------------
172
280
  server.tool("precode.record_implementation", "Log what you built for a task and any undocumented decisions, into .precode/progress/IMPLEMENTATION.md.", {
@@ -210,17 +318,29 @@ server.tool("precode.finalize", "Write the handoff: confirm all tasks done and r
210
318
  if (!s)
211
319
  return NO_STORE;
212
320
  const tasks = await s.tasks();
213
- const open = tasks.filter((t) => !t.done);
321
+ const deferred = await s.deferredEntries();
322
+ const deferredSet = new Set(deferred.map((e) => e.index));
323
+ const open = tasks.filter((t) => !t.done && !deferredSet.has(t.index));
324
+ // Manual gates the human still owns, regenerated from checks.json for done tasks.
325
+ const manualGates = [];
326
+ for (const t of tasks.filter((t) => t.done)) {
327
+ const manual = (await s.checksForTask(t.index)).filter((c) => checkKind(c) === "manual");
328
+ for (const c of manual)
329
+ manualGates.push(`Task #${t.index}: ${c.name}`);
330
+ }
214
331
  await recordTelemetry({
215
332
  eventName: "mcp_finalize",
216
333
  root: ROOT,
217
334
  metadata: {
218
335
  taskCount: tasks.length,
219
336
  openTaskCount: open.length,
337
+ deferredCount: deferred.length,
338
+ manualGateCount: manualGates.length,
220
339
  userTodoCount: userTodos.length,
221
340
  unresolvedCount: unresolved?.length ?? 0,
222
341
  },
223
342
  });
343
+ const taskText = (i) => tasks.find((t) => t.index === i)?.text ?? "(unknown)";
224
344
  const body = [
225
345
  "# What you still need to do",
226
346
  "",
@@ -229,6 +349,14 @@ server.tool("precode.finalize", "Write the handoff: confirm all tasks done and r
229
349
  "## Your steps",
230
350
  ...(userTodos.length ? userTodos.map((t) => `- [ ] ${t}`) : ["- [ ] (none reported)"]),
231
351
  "",
352
+ "## Manual verification still required",
353
+ ...(manualGates.length ? manualGates.map((m) => `- [ ] ${m}`) : ["- (none)"]),
354
+ "",
355
+ "## Deferred tasks (could not be auto-verified here)",
356
+ ...(deferred.length
357
+ ? deferred.map((e) => `- [ ] Task #${e.index} (${taskText(e.index)}) — ${e.reason}`)
358
+ : ["- (none)"]),
359
+ "",
232
360
  "## Not yet satisfied by the build",
233
361
  ...(unresolved?.length
234
362
  ? unresolved.map((u) => `- ${u}`)
@@ -238,9 +366,42 @@ server.tool("precode.finalize", "Write the handoff: confirm all tasks done and r
238
366
  "",
239
367
  ].join("\n");
240
368
  await s.writeTodo(body);
241
- return text(`Finalized. ${tasks.length - open.length}/${tasks.length} tasks done. ` +
369
+ const doneCount = tasks.filter((t) => t.done).length;
370
+ const flags = [
371
+ open.length ? `${open.length} task(s) still OPEN` : "",
372
+ deferred.length ? `${deferred.length} deferred` : "",
373
+ manualGates.length ? `${manualGates.length} manual gate(s) for you` : "",
374
+ ].filter(Boolean);
375
+ return text(`Finalized. ${doneCount}/${tasks.length} tasks done. ` +
242
376
  `Wrote .precode/progress/TODO_FOR_YOU.md and IMPLEMENTATION.md. ` +
243
- (open.length ? `WARNING: ${open.length} task(s) still open — surfaced honestly in the TODO.` : "All tasks verified."));
377
+ (flags.length
378
+ ? `Surfaced honestly in the TODO: ${flags.join(", ")}.`
379
+ : "All tasks verified, nothing outstanding."));
380
+ });
381
+ // --- Tool: defer a task that genuinely cannot be auto-verified here --------
382
+ server.tool("precode.defer_task", "Honest escape hatch for the CURRENT open task when an auto check cannot pass in this environment (needs a secret, a real deploy, a paid/external service). Records the reason, skips the task in the loop, and surfaces it in TODO_FOR_YOU.md. Use this instead of looping forever — never to dodge a real, fixable failure.", {
383
+ reason: z
384
+ .string()
385
+ .min(8)
386
+ .describe("Why it cannot be auto-verified here. Be specific and honest."),
387
+ }, async ({ reason }) => {
388
+ const s = await store();
389
+ if (!s)
390
+ return NO_STORE;
391
+ const task = await s.nextOpenTask();
392
+ if (!task)
393
+ return text("No open task to defer. Run precode.finalize.");
394
+ await s.deferTask(task.index, reason);
395
+ await recordTelemetry({
396
+ eventName: "mcp_defer_task",
397
+ root: ROOT,
398
+ task: taskTelemetry(task),
399
+ });
400
+ const next = await s.nextOpenTask();
401
+ return text(`Deferred task #${task.index} and logged it to TODO_FOR_YOU.md: ${reason}\n` +
402
+ (next
403
+ ? `Next open task is #${next.index}. Call precode.next_task.`
404
+ : "No more open tasks — call precode.finalize."));
244
405
  });
245
406
  // --- Tool: adopt any spec -------------------------------------------------
246
407
  server.tool("precode.adopt_spec", "If there is no .precode/ here, map a plain SPEC.md / .specify / .spec-workflow / docs folder into a minimal .precode/ package so this loop works with ANY spec.", { specPath: z.string().optional().describe("Optional path hint to the spec file or folder.") }, async ({ specPath }) => {
package/dist/store.js CHANGED
@@ -65,7 +65,109 @@ export class PrecodeStore {
65
65
  return tasks;
66
66
  }
67
67
  async nextOpenTask() {
68
- return (await this.tasks()).find((t) => !t.done) ?? null;
68
+ const deferred = new Set(await this.deferredIndices());
69
+ return ((await this.tasks()).find((t) => !t.done && !deferred.has(t.index)) ?? null);
70
+ }
71
+ /** Declared, executable checks (`.precode/checks.json`). [] if absent/malformed. */
72
+ async checks() {
73
+ const raw = await this.read("checks.json");
74
+ if (!raw)
75
+ return [];
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(raw);
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const arr = Array.isArray(parsed)
84
+ ? parsed
85
+ : Array.isArray(parsed.checks)
86
+ ? parsed.checks
87
+ : [];
88
+ const specs = [];
89
+ arr.forEach((item, i) => {
90
+ if (!item || typeof item !== "object")
91
+ return;
92
+ const o = item;
93
+ const name = typeof o.name === "string" ? o.name : `check ${i + 1}`;
94
+ specs.push({
95
+ id: typeof o.id === "string" ? o.id : `c${i + 1}`,
96
+ name,
97
+ cmd: typeof o.cmd === "string" ? o.cmd : undefined,
98
+ kind: o.kind === "manual" ? "manual" : o.kind === "auto" ? "auto" : undefined,
99
+ expect: typeof o.expect === "string" ? o.expect : undefined,
100
+ timeoutMs: typeof o.timeoutMs === "number" ? o.timeoutMs : undefined,
101
+ taskIndex: typeof o.taskIndex === "number" ? o.taskIndex : undefined,
102
+ });
103
+ });
104
+ return specs;
105
+ }
106
+ /** Checks that apply to a task: global (no taskIndex) + that task's own. */
107
+ async checksForTask(taskIndex) {
108
+ return (await this.checks()).filter((c) => c.taskIndex === undefined || c.taskIndex === taskIndex);
109
+ }
110
+ // --- Deferred tasks: an honest escape from a check that cannot pass --------
111
+ async deferredEntries() {
112
+ const raw = await this.read("progress/deferred.json");
113
+ if (!raw)
114
+ return [];
115
+ try {
116
+ const v = JSON.parse(raw);
117
+ if (!Array.isArray(v))
118
+ return [];
119
+ return v
120
+ .filter((e) => e && typeof e === "object" && typeof e.index === "number")
121
+ .map((e) => ({ index: e.index, reason: String(e.reason ?? "") }));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ async deferredIndices() {
128
+ return (await this.deferredEntries()).map((e) => e.index);
129
+ }
130
+ async deferTask(taskIndex, reason) {
131
+ await fs.mkdir(this.file("progress"), { recursive: true });
132
+ const cur = await this.deferredEntries();
133
+ if (!cur.some((e) => e.index === taskIndex)) {
134
+ cur.push({ index: taskIndex, reason });
135
+ }
136
+ await fs.writeFile(this.file("progress/deferred.json"), JSON.stringify(cur, null, 2), "utf8");
137
+ const task = (await this.tasks()).find((t) => t.index === taskIndex);
138
+ await this.appendTodo([
139
+ `## Task #${taskIndex} deferred — needs you`,
140
+ "",
141
+ `Task: ${task?.text ?? "(unknown)"}`,
142
+ `Reason it could not be auto-verified: ${reason}`,
143
+ ].join("\n"));
144
+ }
145
+ // --- Verify lock: stop two concurrent verifies racing on tasks.md ----------
146
+ async acquireLock(staleMs = 5 * 60 * 1000) {
147
+ const lock = this.file(".lock");
148
+ try {
149
+ const handle = await fs.open(lock, "wx");
150
+ await handle.writeFile(String(Date.now()));
151
+ await handle.close();
152
+ return true;
153
+ }
154
+ catch {
155
+ // Lock exists — steal it only if it is stale (crashed prior run).
156
+ try {
157
+ const stat = await fs.stat(lock);
158
+ if (Date.now() - stat.mtimeMs > staleMs) {
159
+ await fs.writeFile(lock, String(Date.now()), "utf8");
160
+ return true;
161
+ }
162
+ }
163
+ catch {
164
+ /* race: gone between checks — treat as not acquired */
165
+ }
166
+ return false;
167
+ }
168
+ }
169
+ async releaseLock() {
170
+ await fs.rm(this.file(".lock"), { force: true });
69
171
  }
70
172
  /** Hard done-gate: only flips a task to [x]. Caller must verify checks first. */
71
173
  async markTaskDone(taskIndex) {
@@ -91,10 +193,14 @@ export class PrecodeStore {
91
193
  const project = manifest?.project ?? {};
92
194
  const tasks = await this.tasks();
93
195
  const done = tasks.filter((t) => t.done).length;
196
+ const deferred = await this.deferredIndices();
197
+ const docs = await this.listDocFiles();
94
198
  return [
95
199
  `Project: ${project.name ?? "(unknown)"} (${project.appType ?? "app"})`,
96
- `Progress: ${done}/${tasks.length} tasks complete.`,
97
- "Build strictly to the spec in `.precode/docs/`. Do not invent scope. Re-read the relevant doc before each task.",
200
+ `Progress: ${done}/${tasks.length} tasks complete${deferred.length ? `, ${deferred.length} deferred` : ""}.`,
201
+ docs.length
202
+ ? `Build strictly to the spec docs in .precode/: ${docs.join(", ")}. Re-read the relevant one before each task; do not invent scope.`
203
+ : "No spec docs found in .precode/docs/. Work from the tasks and acceptance criteria; do not invent scope.",
98
204
  ].join("\n");
99
205
  }
100
206
  async appendImplementation(entry) {
@@ -140,6 +246,12 @@ export class PrecodeStore {
140
246
  await fs.mkdir(this.file("progress"), { recursive: true });
141
247
  await fs.writeFile(this.file("progress/TODO_FOR_YOU.md"), content, "utf8");
142
248
  }
249
+ async appendTodo(entry) {
250
+ await fs.mkdir(this.file("progress"), { recursive: true });
251
+ const cur = (await this.read("progress/TODO_FOR_YOU.md")) ??
252
+ "# TODO for you\n\nNo manual follow-up has been recorded yet.\n";
253
+ await fs.writeFile(this.file("progress/TODO_FOR_YOU.md"), `${cur.trimEnd()}\n\n${entry}\n`, "utf8");
254
+ }
143
255
  async listDocFiles() {
144
256
  try {
145
257
  const files = await fs.readdir(this.file("docs"));
@@ -216,18 +328,35 @@ export async function adoptSpec(searchRoot, specPathHint) {
216
328
  error: "No spec found. Provide a SPEC.md, a .specify/.spec-workflow folder, or a docs/ folder of markdown.",
217
329
  };
218
330
  }
219
- // Derive tasks from markdown headings / existing checkboxes.
220
- const tasks = [];
331
+ // Derive tasks, in priority order so a real list is never collapsed into one:
332
+ // 1. explicit checkboxes - [ ] ...
333
+ // 2. plain bullets / numbered items - x * x 1. x
334
+ // 3. section headings ## / ###
335
+ // 4. a single catch-all when the spec has no structure at all.
336
+ const checkboxes = [];
337
+ const bullets = [];
338
+ const headings = [];
221
339
  for (const line of specBody.split("\n")) {
222
340
  const cb = TASK_RE.exec(line);
223
341
  if (cb) {
224
- tasks.push(`- [ ] ${cb[3]}`);
342
+ checkboxes.push(`- [ ] ${cb[3]}`);
343
+ continue;
344
+ }
345
+ const bullet = /^\s*(?:[-*]|\d+[.)])\s+(.*\S)\s*$/.exec(line);
346
+ if (bullet) {
347
+ if (bullets.length < 40)
348
+ bullets.push(`- [ ] ${bullet[1]}`);
225
349
  continue;
226
350
  }
227
351
  const h = /^#{2,3}\s+(.*\S)\s*$/.exec(line);
228
- if (h && tasks.length < 40)
229
- tasks.push(`- [ ] Implement: ${h[1]}`);
352
+ if (h && headings.length < 40)
353
+ headings.push(`- [ ] Implement: ${h[1]}`);
230
354
  }
355
+ const tasks = checkboxes.length
356
+ ? checkboxes
357
+ : bullets.length
358
+ ? bullets
359
+ : headings;
231
360
  if (!tasks.length)
232
361
  tasks.push("- [ ] Implement the full spec, then verify.");
233
362
  await fs.mkdir(path.join(dir, "progress"), { recursive: true });
@@ -254,5 +383,24 @@ export async function adoptSpec(searchRoot, specPathHint) {
254
383
  "",
255
384
  ].join("\n"), "utf8");
256
385
  await fs.writeFile(path.join(dir, "manifest.json"), JSON.stringify({ project: { name: "Adopted spec", appType: "app" }, adoptedFrom: found }, null, 2), "utf8");
386
+ // Starter checks.json. Commands are commented placeholders the user edits to
387
+ // their real build — until then the gate runs nothing auto and stamps passes
388
+ // UNVERIFIED, so it never silently claims a verified build.
389
+ await fs.writeFile(path.join(dir, "checks.json"), JSON.stringify({
390
+ $comment: "Each 'auto' check is RUN by the MCP from the project root; the task is " +
391
+ "done only when its real exit code is 0. Replace cmd values with your " +
392
+ "build. 'manual' checks are surfaced for human verification, never auto-passed.",
393
+ checks: [
394
+ { id: "typecheck", name: "Type-check", kind: "auto", cmd: "" },
395
+ { id: "lint", name: "Lint", kind: "auto", cmd: "" },
396
+ { id: "build", name: "Production build", kind: "auto", cmd: "" },
397
+ { id: "test", name: "Tests", kind: "auto", cmd: "" },
398
+ {
399
+ id: "secrets",
400
+ name: "Secrets & deploy env verified in real accounts",
401
+ kind: "manual",
402
+ },
403
+ ],
404
+ }, null, 2), "utf8");
257
405
  return { ok: true, dir };
258
406
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@precode/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Open, agent-agnostic MCP server that turns a spec (any SPEC.md, best with a PreCode .precode/ package) into a self-correcting, verified build. Drives a phased build → recheck → fix loop with hard definition-of-done gates and an implemented-vs-todo ledger.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://useprecode.vercel.app/mcp",
@@ -17,8 +17,9 @@
17
17
  "dev": "tsc --watch",
18
18
  "start": "node dist/index.js",
19
19
  "test:stdio": "npm run build && node scripts/stdio-smoke.mjs",
20
+ "test": "npm run build && node test/run.mjs",
20
21
  "typecheck": "tsc --noEmit",
21
- "prepublishOnly": "npm run test:stdio"
22
+ "prepublishOnly": "npm run build && node scripts/stdio-smoke.mjs && node test/run.mjs"
22
23
  },
23
24
  "keywords": [
24
25
  "mcp",