leancraft 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.
Files changed (49) hide show
  1. package/.claude/skills/leancraft/SKILL.md +90 -0
  2. package/.cursor/rules/leancraft.mdc +70 -0
  3. package/.leancraft/README.md +26 -0
  4. package/.leancraft/SKILL.md +90 -0
  5. package/.leancraft/agent/context.md +37 -0
  6. package/.leancraft/agent/proposals/README.md +24 -0
  7. package/.leancraft/agent/status.md +31 -0
  8. package/.leancraft/config.json +357 -0
  9. package/.leancraft/human/definition/features/042-example-feature.md +32 -0
  10. package/.leancraft/human/definition/guardrails.md +37 -0
  11. package/.leancraft/human/definition/intent.md +31 -0
  12. package/.leancraft/human/definition/system.md +48 -0
  13. package/.leancraft/human/discovery/00-index.md +40 -0
  14. package/.leancraft/human/discovery/00-overview.md +31 -0
  15. package/.leancraft/human/discovery/01-vision.md +29 -0
  16. package/.leancraft/human/discovery/02-research.md +104 -0
  17. package/.leancraft/human/discovery/03-competitive.md +28 -0
  18. package/.leancraft/human/discovery/04-personas.md +24 -0
  19. package/.leancraft/human/discovery/05-jtbd.md +27 -0
  20. package/.leancraft/human/discovery/06-prd.md +101 -0
  21. package/.leancraft/human/sprint/map.md +22 -0
  22. package/.leancraft/joint/decisions.log.md +21 -0
  23. package/.leancraft/joint/ledger.jsonl +3 -0
  24. package/.leancraft/joint/test-report.md +20 -0
  25. package/.leancraft/templates/definition/feature.md +32 -0
  26. package/.leancraft/templates/definition/guardrails.md +39 -0
  27. package/.leancraft/templates/definition/intent.md +33 -0
  28. package/.leancraft/templates/definition/system.md +47 -0
  29. package/.leancraft/templates/discovery/00-index.md +40 -0
  30. package/.leancraft/templates/discovery/00-overview.md +31 -0
  31. package/.leancraft/templates/discovery/01-vision.md +28 -0
  32. package/.leancraft/templates/discovery/02-research.md +104 -0
  33. package/.leancraft/templates/discovery/03-competitive.md +27 -0
  34. package/.leancraft/templates/discovery/04-personas.md +26 -0
  35. package/.leancraft/templates/discovery/05-jtbd.md +27 -0
  36. package/.leancraft/templates/discovery/06-prd.md +102 -0
  37. package/.leancraft/templates/sprint/map.md +23 -0
  38. package/.mcp.json +10 -0
  39. package/AGENTS.md +70 -0
  40. package/CLAUDE.md +5 -0
  41. package/LICENSE +21 -0
  42. package/README.md +190 -0
  43. package/bin/leancraft.js +128 -0
  44. package/package.json +55 -0
  45. package/scripts/install-hooks.mjs +27 -0
  46. package/scripts/leancraft-lock.mjs +30 -0
  47. package/scripts/leancraft-sync.mjs +105 -0
  48. package/scripts/leancraft-validate.mjs +159 -0
  49. package/scripts/mcp-server.mjs +122 -0
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // leancraft CLI — unified entry for npx leancraft
3
+ // Usage: npx leancraft init | validate | sync | lock | unlock | status | --help
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+ import { execSync } from "child_process";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const pkgRoot = path.resolve(__dirname, "..");
12
+
13
+ const args = process.argv.slice(2);
14
+ const cmd = args[0];
15
+
16
+ function help() {
17
+ console.log(`
18
+ leancraft — doc-first verified agency
19
+
20
+ Usage:
21
+ npx leancraft init [--force] Scaffold .leancraft/ + hooks + skills in current repo
22
+ npx leancraft validate Check registry (docs, sections, min counts, URLs, TODO)
23
+ npx leancraft sync Regenerate agent/context.md + status.md + ledger
24
+ npx leancraft lock Lock human/** (validate must PASS first)
25
+ npx leancraft unlock Unlock human/** for agent drafting
26
+ npx leancraft status Show lock state + validate summary
27
+ npx leancraft mcp Print MCP config snippet
28
+
29
+ See: https://github.com/sree-pm/leancraft
30
+ `);
31
+ }
32
+
33
+ function runScript(name) {
34
+ const p = path.join(pkgRoot, "scripts", name);
35
+ execSync(`node "${p}" ${args.slice(1).join(" ")}`, { stdio: "inherit" });
36
+ }
37
+
38
+ async function init() {
39
+ const force = args.includes("--force");
40
+ const dest = process.cwd();
41
+ const src = path.join(pkgRoot, ".leancraft");
42
+
43
+ // Copy .leancraft if not exists
44
+ if (fs.existsSync(path.join(dest, ".leancraft")) && !force) {
45
+ console.log("`.leancraft/` already exists. Use --force to overwrite.");
46
+ } else {
47
+ console.log("Scaffolding .leancraft/...");
48
+ execSync(`node -e "const fs=require('fs'),p=require('path');function cp(s,d){fs.mkdirSync(d,{recursive:true});for(const f of fs.readdirSync(s)){const a=p.join(s,f),b=p.join(d,f);if(fs.statSync(a).isDirectory())cp(a,b);else if(!fs.existsSync(b)||process.argv.includes('--force'))fs.copyFileSync(a,b)} } cp(process.argv[1],process.argv[2])" "${src}" "${path.join(dest, ".leancraft")}" ${force ? "--force" : ""}`, { stdio: "inherit" });
49
+ }
50
+
51
+ // Copy skills
52
+ const skills = [
53
+ [".claude/skills/leancraft/SKILL.md", ".claude/skills/leancraft/SKILL.md"],
54
+ [".cursor/rules/leancraft.mdc", ".cursor/rules/leancraft.mdc"],
55
+ ];
56
+ for (const [s, d] of skills) {
57
+ const sp = path.join(pkgRoot, s);
58
+ const dp = path.join(dest, d);
59
+ if (fs.existsSync(sp)) {
60
+ fs.mkdirSync(path.dirname(dp), { recursive: true });
61
+ if (!fs.existsSync(dp) || force) {
62
+ fs.copyFileSync(sp, dp);
63
+ console.log(`+ ${d}`);
64
+ }
65
+ }
66
+ }
67
+ // AGENTS.md + CLAUDE.md at root
68
+ for (const f of ["AGENTS.md", "CLAUDE.md"]) {
69
+ const sp = path.join(pkgRoot, f);
70
+ const dp = path.join(dest, f);
71
+ if (fs.existsSync(sp) && (!fs.existsSync(dp) || force)) {
72
+ fs.copyFileSync(sp, dp);
73
+ console.log(`+ ${f}`);
74
+ }
75
+ }
76
+ // .mcp.json merge
77
+ const mcpSrc = path.join(pkgRoot, ".mcp.json");
78
+ const mcpDest = path.join(dest, ".mcp.json");
79
+ if (fs.existsSync(mcpSrc) && !fs.existsSync(mcpDest)) {
80
+ fs.copyFileSync(mcpSrc, mcpDest);
81
+ console.log(`+ .mcp.json`);
82
+ }
83
+ // .github
84
+ if (!fs.existsSync(path.join(dest, ".github/CODEOWNERS")) && fs.existsSync(path.join(pkgRoot, ".github/CODEOWNERS"))) {
85
+ fs.mkdirSync(path.join(dest, ".github"), { recursive: true });
86
+ fs.copyFileSync(path.join(pkgRoot, ".github/CODEOWNERS"), path.join(dest, ".github/CODEOWNERS"));
87
+ console.log(`+ .github/CODEOWNERS`);
88
+ }
89
+ // hooks
90
+ try {
91
+ execSync(`node "${path.join(pkgRoot, "scripts/install-hooks.mjs")}"`, { stdio: "inherit" });
92
+ } catch {}
93
+
94
+ console.log(`
95
+ ✓ leancraft init done.
96
+
97
+ Next:
98
+ 1. Fill .leancraft/human/discovery/* (from idea + research)
99
+ 2. npm run leancraft:validate → must PASS
100
+ 3. npx leancraft lock → human owns, agent read-only
101
+ 4. Agent now works via agent/proposals/ + auto agent/status.md
102
+
103
+ MCP: add .mcp.json to your agent (Claude Code / Cursor / Opencode already reads it).
104
+ Docs: https://github.com/sree-pm/leancraft
105
+ `);
106
+ }
107
+
108
+ if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") help();
109
+ else if (cmd === "init") await init();
110
+ else if (cmd === "validate") runScript("leancraft-validate.mjs");
111
+ else if (cmd === "sync") runScript("leancraft-sync.mjs");
112
+ else if (cmd === "lock") runScript("leancraft-lock.mjs");
113
+ else if (cmd === "unlock") execSync(`node "${path.join(pkgRoot, "scripts/leancraft-lock.mjs")}" unlock`, { stdio: "inherit" });
114
+ else if (cmd === "status") {
115
+ try {
116
+ const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".leancraft/config.json"), "utf8"));
117
+ console.log(`locked: ${cfg.locked}`);
118
+ } catch { console.log("No .leancraft/config.json — run npx leancraft init"); }
119
+ runScript("leancraft-validate.mjs");
120
+ }
121
+ else if (cmd === "mcp") {
122
+ console.log(fs.readFileSync(path.join(pkgRoot, ".mcp.json"), "utf8"));
123
+ }
124
+ else {
125
+ console.error(`Unknown command: ${cmd}`);
126
+ help();
127
+ process.exit(1);
128
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "leancraft",
3
+ "version": "0.1.0",
4
+ "description": "Doc-first verified agency — stop vibe coding, start owning. Tight registry + locked human docs + auto agent context. For Claude Code, Cursor, Opencode, Windsurf.",
5
+ "type": "module",
6
+ "bin": {
7
+ "leancraft": "bin/leancraft.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "scripts/",
12
+ ".leancraft/",
13
+ ".claude/",
14
+ ".cursor/",
15
+ "AGENTS.md",
16
+ "CLAUDE.md",
17
+ ".mcp.json"
18
+ ],
19
+ "scripts": {
20
+ "leancraft:validate": "node scripts/leancraft-validate.mjs",
21
+ "leancraft:sync": "node scripts/leancraft-sync.mjs",
22
+ "leancraft:lock": "node scripts/leancraft-lock.mjs lock",
23
+ "leancraft:unlock": "node scripts/leancraft-lock.mjs unlock",
24
+ "leancraft:security": "echo 'TODO: wire semgrep + axe + codeql' && npm run leancraft:validate",
25
+ "prepare": "node scripts/install-hooks.mjs || true",
26
+ "test": "node scripts/leancraft-validate.mjs"
27
+ },
28
+ "keywords": [
29
+ "ai",
30
+ "agentic-coding",
31
+ "vibe-coding",
32
+ "docs-first",
33
+ "claude-code",
34
+ "cursor",
35
+ "opencode",
36
+ "mcp",
37
+ "spec-driven"
38
+ ],
39
+ "author": "sree-pm",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/sree-pm/leancraft.git"
44
+ },
45
+ "homepage": "https://github.com/sree-pm/leancraft#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/sree-pm/leancraft/issues"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Installs git hook that enforces human/** READ-ONLY when locked + runs sync
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ const hook = `#!/bin/sh
7
+ # leancraft pre-commit hook — enforces ownership + runs validate
8
+ set -e
9
+ LOCKED=$(node -p "JSON.parse(require('fs').readFileSync('.leancraft/config.json','utf8')).locked" 2>/dev/null || echo "false")
10
+ if [ "$LOCKED" = "true" ] && git diff --cached --name-only | grep -q "^.leancraft/human/"; then
11
+ if [ "$LEANCRAFT_HUMAN" != "1" ]; then
12
+ echo "✖ BLOCKED: .leancraft/human/** is LOCKED. Agent must write to .leancraft/agent/proposals/ instead."
13
+ echo " Human unlock: npx leancraft unlock (or chat [🔓 Unlock])"
14
+ exit 1
15
+ fi
16
+ fi
17
+ # Always run validate as warning (don't block commit, just inform)
18
+ node scripts/leancraft-validate.mjs || echo "⚠ validate failed — fix before Lock"
19
+ `;
20
+
21
+ const hookPath = path.join(process.cwd(), ".git/hooks/pre-commit");
22
+ try {
23
+ fs.writeFileSync(hookPath, hook, { mode: 0o755 });
24
+ console.log("✓ pre-commit hook installed");
25
+ } catch (e) {
26
+ console.log("Note: could not install hook (no .git/hooks):", e.message);
27
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // leancraft lock/unlock — flips config.json locked + enforces via hook
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { execSync } from "child_process";
6
+
7
+ const action = process.argv[2]; // lock | unlock
8
+ if (!["lock", "unlock"].includes(action)) {
9
+ console.error("Usage: node scripts/leancraft-lock.mjs [lock|unlock]");
10
+ process.exit(1);
11
+ }
12
+ const configPath = path.join(process.cwd(), ".leancraft/config.json");
13
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
14
+
15
+ if (action === "lock") {
16
+ // validate first
17
+ try {
18
+ execSync("node scripts/leancraft-validate.mjs", { stdio: "inherit" });
19
+ } catch {
20
+ console.error("\n✖ Cannot lock: validate FAILED. Fix errors first.");
21
+ process.exit(1);
22
+ }
23
+ cfg.locked = true;
24
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
25
+ console.log("🔒 Locked. human/** is now READ-ONLY for agent. Agent must use agent/proposals/.");
26
+ } else {
27
+ cfg.locked = false;
28
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
29
+ console.log("🔓 Unlocked. Agent may draft human/**. Remember to re-lock after review.");
30
+ }
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // leancraft sync — regenerates agent/context.md + agent/status.md + ledger
3
+ // Run: node scripts/leancraft-sync.mjs
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { execSync } from "child_process";
8
+
9
+ const root = process.cwd();
10
+ const lcRoot = path.join(root, ".leancraft");
11
+
12
+ function read(p) { try { return fs.readFileSync(p, "utf8"); } catch { return ""; } }
13
+ function nowISO() { return new Date().toISOString(); }
14
+ function gitCommit() {
15
+ try { return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); } catch { return "no-git"; }
16
+ }
17
+
18
+ const commit = gitCommit();
19
+ const intent = read(path.join(lcRoot, "human/definition/intent.md")).slice(0, 200).replace(/\n/g, " ");
20
+ const system = read(path.join(lcRoot, "human/definition/system.md")).slice(0, 200).replace(/\n/g, " ");
21
+
22
+ // Update agent/context.md
23
+ const contextPath = path.join(lcRoot, "agent/context.md");
24
+ let context = `# Agent Context — Compressed Repo Memory (AUTO)
25
+
26
+ > AUTO-GENERATED by \`leancraft sync\` at ${nowISO()}. Commit ${commit}. Human READ-ONLY. Max 500 lines. Agent MUST read this + human/definition/intent.md first.
27
+
28
+ ## Repo Map (auto-scanned)
29
+
30
+ - Human intent: ${intent || "(empty — fill human/definition/intent.md)"}
31
+ - System: ${system || "(empty)"}
32
+ - Key dirs: ${fs.existsSync(path.join(root, "src")) ? "src/" : ""} ${fs.existsSync(path.join(root, "app")) ? "app/" : ""} ${fs.existsSync(path.join(root, "lib")) ? "lib/" : ""}
33
+
34
+ ## Last Sync
35
+
36
+ - Commit: ${commit}
37
+ - Time: ${nowISO()}
38
+ - Config locked: ${JSON.parse(read(path.join(lcRoot, "config.json")) || '{"locked":false}').locked}
39
+
40
+ ## Open Invariants (from intent.md)
41
+
42
+ ${read(path.join(lcRoot, "human/definition/intent.md")).match(/\*\*I\d+:.*/g)?.join("\n") || "- (none yet — fill intent.md)"}
43
+
44
+ ## Recent Decisions (from joint/decisions.log.md)
45
+
46
+ ${read(path.join(lcRoot, "joint/decisions.log.md")).split("\n").slice(-10).join("\n")}
47
+
48
+ ---
49
+ *Overwritten by \`npm run leancraft:sync\`. Do not hand-edit. Max 500 lines.*
50
+ `;
51
+ fs.writeFileSync(contextPath, context);
52
+ console.log(`✓ agent/context.md updated (${commit})`);
53
+
54
+ // Update agent/status.md
55
+ const statusPath = path.join(lcRoot, "agent/status.md");
56
+ const validateExists = fs.existsSync(path.join(root, "scripts/leancraft-validate.mjs"));
57
+ let validateStatus = "not run — run npm run leancraft:validate";
58
+ try {
59
+ execSync("node scripts/leancraft-validate.mjs", { stdio: "pipe" });
60
+ validateStatus = "PASS";
61
+ } catch (e) {
62
+ validateStatus = "FAIL — fix errors before Lock";
63
+ }
64
+
65
+ let status = `# Agent Status — Live Tracker (AUTO)
66
+
67
+ > AUTO-GENERATED at ${nowISO()}, commit ${commit}. Human READ-ONLY.
68
+
69
+ ## Last Sync
70
+
71
+ - Time: ${nowISO()}
72
+ - Commit: ${commit}
73
+ - Validate: ${validateStatus}
74
+
75
+ ## Done
76
+
77
+ - (auto — fill after features ship)
78
+
79
+ ## Todo
80
+
81
+ - [ ] Fill human/discovery/* (see validate errors above)
82
+ - [ ] Distill human/definition/intent.md
83
+ - [ ] Run \`npm run leancraft:validate\` → PASS → ask to Lock
84
+
85
+ ## Blocked
86
+
87
+ - ${validateStatus === "PASS" ? "none" : "validate FAIL — see errors"}
88
+
89
+ ## Budget per run
90
+
91
+ - Used: 0 / $5.00 (tracked in joint/ledger.jsonl)
92
+ - Calls: 0 / 25
93
+
94
+ ---
95
+ *Overwritten by sync. Human never edits.*
96
+ `;
97
+ fs.writeFileSync(statusPath, status);
98
+ console.log(`✓ agent/status.md updated`);
99
+
100
+ // Append ledger
101
+ const ledgerPath = path.join(lcRoot, "joint/ledger.jsonl");
102
+ const entry = JSON.stringify({ ts: nowISO(), commit, event: "sync", validate: validateStatus }) + "\n";
103
+ fs.appendFileSync(ledgerPath, entry);
104
+ console.log(`✓ joint/ledger.jsonl appended`);
105
+ console.log(`\nSync done.`);
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // leancraft validate — checks docRegistry tightness, no placeholders, min counts, URLs, sections
3
+ // Run: node scripts/leancraft-validate.mjs (or npm run leancraft:validate)
4
+ // Exit 0 = PASS (Lock allowed), 1 = FAIL (Lock blocked)
5
+
6
+ import fs from "fs";
7
+ import path from "path";
8
+
9
+ const root = path.resolve(process.cwd());
10
+ const lcRoot = path.join(root, ".leancraft");
11
+ const configPath = path.join(lcRoot, "config.json");
12
+
13
+ function read(p) {
14
+ try { return fs.readFileSync(p, "utf8"); } catch { return null; }
15
+ }
16
+ function exists(p) { return fs.existsSync(p); }
17
+
18
+ const config = JSON.parse(read(configPath));
19
+ if (!config) { console.error("Missing .leancraft/config.json"); process.exit(1); }
20
+
21
+ let errors = [];
22
+ let warnings = [];
23
+ let checked = 0;
24
+
25
+ function assert(cond, msg, isWarning = false) {
26
+ checked++;
27
+ if (!cond) (isWarning ? warnings : errors).push(msg);
28
+ }
29
+
30
+ function checkNoPlaceholders(file, content) {
31
+ const bad = /TODO|TBD|\[TODO|\[TBD/i;
32
+ if (bad.test(content)) errors.push(`${file}: contains TODO/TBD placeholder`);
33
+ // check for empty template brackets still present
34
+ if (/\[TODO:/.test(content)) errors.push(`${file}: unfilled template [TODO:`);
35
+ }
36
+
37
+ function checkSections(file, content, requiredSections) {
38
+ if (!requiredSections) return;
39
+ for (const sec of requiredSections) {
40
+ const needle = sec.toLowerCase();
41
+ const has = content.toLowerCase().includes(needle);
42
+ if (!has) errors.push(`${file}: missing required section "${sec}"`);
43
+ }
44
+ }
45
+
46
+ function countMatches(content, regex) {
47
+ return (content.match(regex) || []).length;
48
+ }
49
+
50
+ // Validate discovery + definition
51
+ for (const [group, docs] of Object.entries(config.docRegistry)) {
52
+ if (group === "conditional") continue;
53
+ for (const [doc, spec] of Object.entries(docs)) {
54
+ if (doc.includes("*")) {
55
+ // features/*.md — check at least one exists, but not required
56
+ const dir = path.join(lcRoot, "human", "definition", "features");
57
+ if (exists(dir)) {
58
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".md"));
59
+ // Don't fail if no features yet — it's ephemeral
60
+ if (files.length === 0) warnings.push(`human/definition/features/*.md: no features yet (ok, ephemeral)`);
61
+ for (const f of files) {
62
+ const content = read(path.join(dir, f));
63
+ checkNoPlaceholders(`human/definition/features/${f}`, content);
64
+ checkSections(`human/definition/features/${f}`, content, spec.sections);
65
+ }
66
+ }
67
+ continue;
68
+ }
69
+ // handle sprint map etc — group is sprint, files are in human/sprint
70
+ let filePath;
71
+ if (group === "sprint") filePath = path.join(lcRoot, "human", "sprint", doc);
72
+ else if (group === "discovery") filePath = path.join(lcRoot, "human", "discovery", doc);
73
+ else if (group === "definition") filePath = path.join(lcRoot, "human", "definition", doc);
74
+ else filePath = path.join(lcRoot, doc);
75
+
76
+ const content = read(filePath);
77
+ if (!content) {
78
+ if (spec.required) errors.push(`${group}/${doc}: MISSING (required)`);
79
+ else warnings.push(`${group}/${doc}: missing (optional)`);
80
+ continue;
81
+ }
82
+ checkNoPlaceholders(`${group}/${doc}`, content);
83
+ checkSections(`${group}/${doc}`, content, spec.sections);
84
+
85
+ // Specific rules
86
+ if (spec.rules) {
87
+ if (spec.rules.minPainThemes) {
88
+ const count = countMatches(content, /^###\s+P\d+/gm);
89
+ assert(count >= spec.rules.minPainThemes, `${group}/${doc}: needs ${spec.rules.minPainThemes} pain themes (P1-P12), found ${count}`);
90
+ }
91
+ if (spec.rules.minSources) {
92
+ const urlCount = countMatches(content, /https?:\/\//g);
93
+ assert(urlCount >= spec.rules.minSources, `${group}/${doc}: needs ≥${spec.rules.minSources} URLs, found ${urlCount}`);
94
+ }
95
+ if (spec.rules.minCompetitors) {
96
+ const hasTable = content.includes("|") && content.toLowerCase().includes("competitor");
97
+ assert(hasTable, `${group}/${doc}: missing competitor table`);
98
+ const rows = content.split("\n").filter(l => l.trim().startsWith("|") && !l.toLowerCase().includes("competitor") && !l.includes("---")).length;
99
+ assert(rows >= spec.rules.minCompetitors, `${group}/${doc}: needs ≥${spec.rules.minCompetitors} competitors, found ~${rows}`);
100
+ }
101
+ if (spec.rules.minWedges) {
102
+ const wedgeCount = countMatches(content, /^\d+\./gm);
103
+ assert(wedgeCount >= spec.rules.minWedges, `${group}/${doc}: needs ≥${spec.rules.minWedges} wedges, found ${wedgeCount}`, true);
104
+ }
105
+ if (spec.rules.minPersonas) {
106
+ const personaCount = countMatches(content, /^##\s+Persona/mg) || countMatches(content, /Persona \d/mg);
107
+ assert(personaCount >= spec.rules.minPersonas, `${group}/${doc}: needs ≥${spec.rules.minPersonas} personas, found ${personaCount}`);
108
+ }
109
+ if (spec.rules.minUserFlows) {
110
+ const flowCount = countMatches(content, /^###\s+Flow/mg);
111
+ assert(flowCount >= spec.rules.minUserFlows, `${group}/${doc}: needs ≥${spec.rules.minUserFlows} user flows, found ${flowCount}`);
112
+ }
113
+ if (spec.rules.minFunctionalReqs) {
114
+ const frCount = countMatches(content, /FR-\d+/g);
115
+ assert(frCount >= spec.rules.minFunctionalReqs, `${group}/${doc}: needs ≥${spec.rules.minFunctionalReqs} FRs (FR-01...), found ${frCount}`);
116
+ }
117
+ if (spec.rules.minInvariants) {
118
+ const invCount = countMatches(content, /\*\*I\d+:/g);
119
+ assert(invCount >= spec.rules.minInvariants, `${group}/${doc}: needs ≥${spec.rules.minInvariants} invariants (I1:), found ${invCount}`);
120
+ }
121
+ if (spec.rules.minOracles) {
122
+ const oracleCount = countMatches(content, /\*\*O\d+:/g);
123
+ assert(oracleCount >= spec.rules.minOracles, `${group}/${doc}: needs ≥${spec.rules.minOracles} oracles (O1:), found ${oracleCount}`);
124
+ }
125
+ if (spec.rules.requireQuoteAndURL) {
126
+ const hasQuote = content.includes("Quote:");
127
+ const hasURL = content.includes("https://");
128
+ assert(hasQuote && hasURL, `${group}/${doc}: each pain needs Quote + Source URL`);
129
+ }
130
+ if (spec.rules.requirePricingWithCurrency) {
131
+ const hasCurrency = /\$|€|£/.test(content);
132
+ assert(hasCurrency, `${group}/${doc}: pricing table must have $/€/£`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // Check joint files exist
139
+ for (const jf of ["joint/decisions.log.md", "joint/ledger.jsonl", "agent/context.md", "agent/status.md"]) {
140
+ if (!exists(path.join(lcRoot, jf))) warnings.push(`${jf}: missing (auto, will be generated on sync)`);
141
+ }
142
+
143
+ // Output
144
+ console.log("─ Leancraft Validate ─");
145
+ console.log(`Checked: ${checked} assertions`);
146
+ if (warnings.length) {
147
+ console.log(`\nWarnings (${warnings.length}):`);
148
+ warnings.forEach(w => console.log(` ⚠ ${w}`));
149
+ }
150
+ if (errors.length) {
151
+ console.log(`\nErrors (${errors.length}):`);
152
+ errors.forEach(e => console.log(` ✖ ${e}`));
153
+ console.log(`\nResult: FAIL — Lock blocked. Fix errors, then re-run.`);
154
+ process.exit(1);
155
+ } else {
156
+ console.log(`\nResult: PASS — Lock allowed. (warnings: ${warnings.length})`);
157
+ if (config.locked) console.log("Note: config.json locked=true — human/** is READ-ONLY for agent.");
158
+ process.exit(0);
159
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Minimal MCP server for Leancraft — enforces ownership, exposes read_intent, propose_change, get_context
3
+ // Speak MCP JSON-RPC over stdio. Works with Claude Code, Cursor, Opencode.
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { execSync } from "child_process";
8
+
9
+ const root = process.cwd();
10
+ const configPath = path.join(root, ".leancraft/config.json");
11
+
12
+ function readConfig() {
13
+ try { return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return { locked: false, denyAgentWrite: ["human/**"] }; }
14
+ }
15
+
16
+ const tools = [
17
+ {
18
+ name: "leancraft_read_intent",
19
+ description: "MUST call first. Reads config + intent + guardrails + context. Returns the contract agent must obey.",
20
+ inputSchema: { type: "object", properties: {} }
21
+ },
22
+ {
23
+ name: "leancraft_get_context",
24
+ description: "Returns compressed repo memory (agent/context.md, 500 lines). Use to avoid context rot.",
25
+ inputSchema: { type: "object", properties: {} }
26
+ },
27
+ {
28
+ name: "leancraft_propose_change",
29
+ description: "When locked and you need to change human/**, write a proposal to agent/proposals/ instead. This is the ONLY way.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ target: { type: "string", description: "human file you want to change, e.g., human/definition/intent.md" },
34
+ reason: { type: "string" },
35
+ diff: { type: "string", description: "unified diff" }
36
+ },
37
+ required: ["target", "reason", "diff"]
38
+ }
39
+ },
40
+ {
41
+ name: "leancraft_validate",
42
+ description: "Runs the registry validator. Call before asking to Lock.",
43
+ inputSchema: { type: "object", properties: {} }
44
+ }
45
+ ];
46
+
47
+ function handleTool(name, args) {
48
+ const cfg = readConfig();
49
+ if (name === "leancraft_read_intent") {
50
+ const intent = fs.readFileSync(path.join(root, ".leancraft/human/definition/intent.md"), "utf8").slice(0, 4000);
51
+ const guardrails = fs.readFileSync(path.join(root, ".leancraft/human/definition/guardrails.md"), "utf8").slice(0, 2000);
52
+ const context = fs.readFileSync(path.join(root, ".leancraft/agent/context.md"), "utf8").slice(0, 2000);
53
+ return { locked: cfg.locked, intent, guardrails, context, budget: cfg.budget, docRegistry: cfg.docRegistry };
54
+ }
55
+ if (name === "leancraft_get_context") {
56
+ return fs.readFileSync(path.join(root, ".leancraft/agent/context.md"), "utf8");
57
+ }
58
+ if (name === "leancraft_propose_change") {
59
+ if (!cfg.locked) return { error: "Not locked — you may edit human/** directly. No proposal needed." };
60
+ // enforce human/** check
61
+ if (!args.target.startsWith("human/")) return { error: "target must be human/**" };
62
+ const id = Date.now().toString().slice(-6);
63
+ const p = path.join(root, `.leancraft/agent/proposals/${id}-${args.target.replace(/\//g, "-")}.md`);
64
+ fs.mkdirSync(path.dirname(p), { recursive: true });
65
+ fs.writeFileSync(p, `# Proposal: ${args.target}\n\n**Reason:** ${args.reason}\n\n**Diff:**\n\`\`\`diff\n${args.diff}\n\`\`\`\n\n> Human: merge this file to apply, or [❌ Don't].\n`);
66
+ return { ok: true, proposal: p, message: "Proposal written. Ask human: [🔓 Unlock / ❌ Don't]" };
67
+ }
68
+ if (name === "leancraft_validate") {
69
+ try {
70
+ const out = execSync("node scripts/leancraft-validate.mjs", { encoding: "utf8" });
71
+ return { pass: true, output: out };
72
+ } catch (e) {
73
+ return { pass: false, output: e.stdout?.toString() || e.message };
74
+ }
75
+ }
76
+ return { error: "unknown tool" };
77
+ }
78
+
79
+ // Minimal JSON-RPC loop
80
+ let buf = "";
81
+ process.stdin.on("data", chunk => {
82
+ buf += chunk.toString();
83
+ let idx;
84
+ while ((idx = buf.indexOf("\n")) !== -1) {
85
+ const line = buf.slice(0, idx).trim();
86
+ buf = buf.slice(idx + 1);
87
+ if (!line) continue;
88
+ try {
89
+ const msg = JSON.parse(line);
90
+ if (msg.method === "initialize") {
91
+ respond(msg.id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "leancraft", version: "1.0.0" } });
92
+ } else if (msg.method === "tools/list") {
93
+ respond(msg.id, { tools });
94
+ } else if (msg.method === "tools/call") {
95
+ const result = handleTool(msg.params.name, msg.params.arguments || {});
96
+ // handle async validate
97
+ if (result && typeof result.then === "function") {
98
+ result.then(r => respond(msg.id, { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] }));
99
+ } else {
100
+ respond(msg.id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] });
101
+ }
102
+ } else if (msg.method === "notifications/initialized") {
103
+ // no-op
104
+ } else {
105
+ respond(msg.id, { error: { code: -32601, message: "Method not found: " + msg.method } });
106
+ }
107
+ } catch (e) {
108
+ // ignore parse errors
109
+ }
110
+ }
111
+ });
112
+
113
+ function respond(id, result) {
114
+ if (id == null) return;
115
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
116
+ }
117
+
118
+ // also support simple test mode
119
+ if (process.argv.includes("--test")) {
120
+ console.log(JSON.stringify(handleTool("leancraft_read_intent", {}), null, 2));
121
+ process.exit(0);
122
+ }