@precode/mcp 0.3.0 → 0.3.1

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
@@ -121,7 +121,8 @@ Declare checks in `.precode/checks.json`. Each `auto` check is **run by the MCP*
121
121
  - `kind: "auto"` — executed; pass = exit 0 (and `expect` substring/`/regex/` matches output if set). Optional `timeoutMs` (default 120s).
122
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
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.
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`.
125
+ - **`adopt_spec` infers real commands** from your project (`package.json` scripts, `Cargo.toml`, `go.mod`) so a fresh spec is verifiable out of the box. Only when nothing is detected does it write empty placeholders that HOLD until you fill them. Failure advice references the actual command that ran, and `defer_task` is refused if the task's checks actually pass (so it can't be used to dodge verifiable work). Global `manual` gates are listed once in `TODO_FOR_YOU.md`, not repeated per task.
125
126
 
126
127
  **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.
127
128
 
package/dist/index.js CHANGED
@@ -161,14 +161,16 @@ server.tool("precode.verify", "Close the done-gate for the current task. The MCP
161
161
  const autoSpecs = specs.filter((c) => checkKind(c) === "auto");
162
162
  const manualSpecs = specs.filter((c) => checkKind(c) === "manual");
163
163
  const placeholderSpecs = specs.filter((c) => checkKind(c) === "skip");
164
- // Route manual checks to the human TODO when the task closes — never auto-passed.
164
+ // Only TASK-SCOPED manual gates are routed per task. Global (project-level)
165
+ // manual gates are listed once by precode.finalize — not repeated per task.
166
+ const taskManual = manualSpecs.filter((c) => c.taskIndex === task.index);
165
167
  const routeManual = async () => {
166
- if (!manualSpecs.length)
168
+ if (!taskManual.length)
167
169
  return;
168
170
  await s.appendTodo([
169
171
  `## Task #${task.index} — manual verification required`,
170
172
  "",
171
- ...manualSpecs.map((c) => `- [ ] ${c.name}`),
173
+ ...taskManual.map((c) => `- [ ] ${c.name}`),
172
174
  ].join("\n"));
173
175
  };
174
176
  const closeTask = async (verifiedLine) => {
@@ -178,16 +180,16 @@ server.tool("precode.verify", "Close the done-gate for the current task. The MCP
178
180
  `## Task #${task.index}: ${task.text}`,
179
181
  taskLabel ? `Host task: ${taskLabel}` : "",
180
182
  verifiedLine,
181
- manualSpecs.length
182
- ? `Manual gates routed to TODO_FOR_YOU.md: ${manualSpecs.map((c) => c.name).join(", ")}`
183
+ taskManual.length
184
+ ? `Manual gates routed to TODO_FOR_YOU.md: ${taskManual.map((c) => c.name).join(", ")}`
183
185
  : "",
184
186
  notes ? `Notes: ${notes}` : "",
185
187
  ]
186
188
  .filter(Boolean)
187
189
  .join("\n"));
188
190
  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
+ const manualNote = taskManual.length
192
+ ? ` ${taskManual.length} manual check(s) routed to TODO_FOR_YOU.md for you.`
191
193
  : "";
192
194
  return text((next
193
195
  ? `PASS. Task #${task.index} marked done.${manualNote} Call precode.next_task for the next step (#${next.index}).`
@@ -219,7 +221,16 @@ server.tool("precode.verify", "Close the done-gate for the current task. The MCP
219
221
  const runs = await runChecks(autoSpecs, ROOT);
220
222
  const failed = runs.filter((r) => !r.passed);
221
223
  if (failed.length > 0) {
222
- return await gateFailure(failed.map((r) => `${r.name}: FAILED — ${r.detail}`), failed.map((r) => `${r.name}: ${fixHint(r.name)}`));
224
+ const cmdById = new Map(autoSpecs.map((c) => [c.id, c.cmd ?? ""]));
225
+ return await gateFailure(failed.map((r) => `${r.name}: FAILED — ${r.detail}`),
226
+ // Advice references the ACTUAL command that ran, never a guess from
227
+ // the check's display name (which may not match the command).
228
+ failed.map((r) => {
229
+ const cmd = cmdById.get(r.id);
230
+ return cmd
231
+ ? `${r.name}: read the output above, fix the cause, then re-run \`${cmd}\`.`
232
+ : `${r.name}: read the output above and fix the failure.`;
233
+ }));
223
234
  }
224
235
  await recordTelemetry({
225
236
  eventName: "mcp_verify_pass",
@@ -322,11 +333,15 @@ server.tool("precode.finalize", "Write the handoff: confirm all tasks done and r
322
333
  const deferredSet = new Set(deferred.map((e) => e.index));
323
334
  const open = tasks.filter((t) => !t.done && !deferredSet.has(t.index));
324
335
  // Manual gates the human still owns, regenerated from checks.json for done tasks.
325
- const manualGates = [];
336
+ // Global (project-level) manual gates appear ONCE; task-scoped ones per task.
337
+ const allChecks = await s.checks();
338
+ const manualGates = allChecks
339
+ .filter((c) => checkKind(c) === "manual" && c.taskIndex === undefined)
340
+ .map((c) => c.name);
326
341
  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)
342
+ for (const c of allChecks.filter((c) => checkKind(c) === "manual" && c.taskIndex === t.index)) {
329
343
  manualGates.push(`Task #${t.index}: ${c.name}`);
344
+ }
330
345
  }
331
346
  await recordTelemetry({
332
347
  eventName: "mcp_finalize",
@@ -391,6 +406,17 @@ server.tool("precode.defer_task", "Honest escape hatch for the CURRENT open task
391
406
  const task = await s.nextOpenTask();
392
407
  if (!task)
393
408
  return text("No open task to defer. Run precode.finalize.");
409
+ // Anti-dodge guard: if the task's real auto checks PASS right now, it is
410
+ // verifiable here — refuse the defer and make the agent verify instead.
411
+ const autoSpecs = (await s.checksForTask(task.index)).filter((c) => checkKind(c) === "auto");
412
+ if (autoSpecs.length > 0) {
413
+ const runs = await runChecks(autoSpecs, ROOT);
414
+ if (runs.every((r) => r.passed)) {
415
+ return text(`REFUSED. Task #${task.index} is verifiable here — its checks pass (${runs
416
+ .map((r) => `${r.name} ✓`)
417
+ .join(", ")}). Call precode.verify to close it; defer_task is only for checks that genuinely cannot run in this environment.`);
418
+ }
419
+ }
394
420
  await s.deferTask(task.index, reason);
395
421
  await recordTelemetry({
396
422
  eventName: "mcp_defer_task",
@@ -468,19 +494,3 @@ main().catch((err) => {
468
494
  console.error("[precode-mcp] fatal:", err);
469
495
  process.exit(1);
470
496
  });
471
- function fixHint(checkName) {
472
- const lower = checkName.toLowerCase();
473
- if (lower.includes("type") || lower.includes("tsc")) {
474
- return "open the TypeScript diagnostics, fix the reported type errors, then rerun the same type-check command.";
475
- }
476
- if (lower.includes("lint") || lower.includes("eslint")) {
477
- return "fix the reported lint violations without changing spec scope, then rerun lint.";
478
- }
479
- if (lower.includes("build")) {
480
- return "inspect the build error, fix the first failing route/module/env issue, then rerun the production build.";
481
- }
482
- if (lower.includes("test") || lower.includes("smoke") || lower.includes("e2e")) {
483
- return "reproduce the failing flow, align behavior to the acceptance criteria, then rerun the test.";
484
- }
485
- return "inspect the failing command output, make the smallest spec-aligned fix, then rerun this exact check.";
486
- }
package/dist/store.js CHANGED
@@ -383,18 +383,25 @@ export async function adoptSpec(searchRoot, specPathHint) {
383
383
  "",
384
384
  ].join("\n"), "utf8");
385
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: [
386
+ // checks.json: infer REAL commands from the project (package.json scripts,
387
+ // Cargo, Go) so a fresh spec is verifiable out of the box. Only fall back to
388
+ // empty placeholders (which HOLD a task until filled) when nothing is found.
389
+ const inferred = await inferAutoChecks(searchRoot);
390
+ const autoChecks = inferred.length
391
+ ? inferred
392
+ : [
394
393
  { id: "typecheck", name: "Type-check", kind: "auto", cmd: "" },
395
394
  { id: "lint", name: "Lint", kind: "auto", cmd: "" },
396
395
  { id: "build", name: "Production build", kind: "auto", cmd: "" },
397
396
  { id: "test", name: "Tests", kind: "auto", cmd: "" },
397
+ ];
398
+ await fs.writeFile(path.join(dir, "checks.json"), JSON.stringify({
399
+ $comment: "Each 'auto' check is RUN by the MCP from the project root; a task is done " +
400
+ "only when the real exit code is 0. Commands below were inferred from your " +
401
+ "project — edit/remove as needed. Empty cmd = unconfigured (the task HOLDs " +
402
+ "until you fill it, mark it manual, or defer). 'manual' = human gate, never auto-passed.",
403
+ checks: [
404
+ ...autoChecks,
398
405
  {
399
406
  id: "secrets",
400
407
  name: "Secrets & deploy env verified in real accounts",
@@ -404,3 +411,46 @@ export async function adoptSpec(searchRoot, specPathHint) {
404
411
  }, null, 2), "utf8");
405
412
  return { ok: true, dir };
406
413
  }
414
+ /**
415
+ * Best-effort detection of real verification commands for the adopted project,
416
+ * so checks.json ships runnable instead of empty. Covers the common stacks.
417
+ */
418
+ async function inferAutoChecks(root) {
419
+ const checks = [];
420
+ const has = async (rel) => fs.stat(path.join(root, rel)).then(() => true, () => false);
421
+ if (await has("package.json")) {
422
+ try {
423
+ const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
424
+ const scripts = pkg.scripts ?? {};
425
+ const pick = (...names) => names.find((n) => typeof scripts[n] === "string");
426
+ const tc = pick("typecheck", "type-check", "tsc", "types");
427
+ if (tc)
428
+ checks.push({ id: "typecheck", name: "Type-check", kind: "auto", cmd: `npm run ${tc}` });
429
+ const lint = pick("lint", "eslint");
430
+ if (lint)
431
+ checks.push({ id: "lint", name: "Lint", kind: "auto", cmd: `npm run ${lint}` });
432
+ const build = pick("build", "compile");
433
+ if (build)
434
+ checks.push({ id: "build", name: "Build", kind: "auto", cmd: `npm run ${build}` });
435
+ const test = pick("test", "tests");
436
+ if (test)
437
+ checks.push({ id: "test", name: "Tests", kind: "auto", cmd: `npm test` });
438
+ }
439
+ catch {
440
+ /* malformed package.json — fall through to whatever else we detect */
441
+ }
442
+ return checks;
443
+ }
444
+ if (await has("Cargo.toml")) {
445
+ checks.push({ id: "build", name: "Cargo build", kind: "auto", cmd: "cargo build" });
446
+ checks.push({ id: "test", name: "Cargo test", kind: "auto", cmd: "cargo test" });
447
+ return checks;
448
+ }
449
+ if (await has("go.mod")) {
450
+ checks.push({ id: "build", name: "Go build", kind: "auto", cmd: "go build ./..." });
451
+ checks.push({ id: "vet", name: "Go vet", kind: "auto", cmd: "go vet ./..." });
452
+ checks.push({ id: "test", name: "Go test", kind: "auto", cmd: "go test ./..." });
453
+ return checks;
454
+ }
455
+ return checks;
456
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@precode/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",