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.
- package/.claude/skills/leancraft/SKILL.md +90 -0
- package/.cursor/rules/leancraft.mdc +70 -0
- package/.leancraft/README.md +26 -0
- package/.leancraft/SKILL.md +90 -0
- package/.leancraft/agent/context.md +37 -0
- package/.leancraft/agent/proposals/README.md +24 -0
- package/.leancraft/agent/status.md +31 -0
- package/.leancraft/config.json +357 -0
- package/.leancraft/human/definition/features/042-example-feature.md +32 -0
- package/.leancraft/human/definition/guardrails.md +37 -0
- package/.leancraft/human/definition/intent.md +31 -0
- package/.leancraft/human/definition/system.md +48 -0
- package/.leancraft/human/discovery/00-index.md +40 -0
- package/.leancraft/human/discovery/00-overview.md +31 -0
- package/.leancraft/human/discovery/01-vision.md +29 -0
- package/.leancraft/human/discovery/02-research.md +104 -0
- package/.leancraft/human/discovery/03-competitive.md +28 -0
- package/.leancraft/human/discovery/04-personas.md +24 -0
- package/.leancraft/human/discovery/05-jtbd.md +27 -0
- package/.leancraft/human/discovery/06-prd.md +101 -0
- package/.leancraft/human/sprint/map.md +22 -0
- package/.leancraft/joint/decisions.log.md +21 -0
- package/.leancraft/joint/ledger.jsonl +3 -0
- package/.leancraft/joint/test-report.md +20 -0
- package/.leancraft/templates/definition/feature.md +32 -0
- package/.leancraft/templates/definition/guardrails.md +39 -0
- package/.leancraft/templates/definition/intent.md +33 -0
- package/.leancraft/templates/definition/system.md +47 -0
- package/.leancraft/templates/discovery/00-index.md +40 -0
- package/.leancraft/templates/discovery/00-overview.md +31 -0
- package/.leancraft/templates/discovery/01-vision.md +28 -0
- package/.leancraft/templates/discovery/02-research.md +104 -0
- package/.leancraft/templates/discovery/03-competitive.md +27 -0
- package/.leancraft/templates/discovery/04-personas.md +26 -0
- package/.leancraft/templates/discovery/05-jtbd.md +27 -0
- package/.leancraft/templates/discovery/06-prd.md +102 -0
- package/.leancraft/templates/sprint/map.md +23 -0
- package/.mcp.json +10 -0
- package/AGENTS.md +70 -0
- package/CLAUDE.md +5 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/bin/leancraft.js +128 -0
- package/package.json +55 -0
- package/scripts/install-hooks.mjs +27 -0
- package/scripts/leancraft-lock.mjs +30 -0
- package/scripts/leancraft-sync.mjs +105 -0
- package/scripts/leancraft-validate.mjs +159 -0
- package/scripts/mcp-server.mjs +122 -0
package/bin/leancraft.js
ADDED
|
@@ -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
|
+
}
|