joycraft 0.6.6 → 0.6.8

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
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## What is Joycraft?
10
10
 
11
- Joycraft is a CLI tool that installs structured development skills into [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [OpenAI Codex](https://openai.com/codex), along with behavioral boundaries, templates, and documentation structure. It takes any project from unstructured prompting to autonomous spec-driven development.
11
+ Joycraft is a CLI tool that installs structured development skills into [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenAI Codex](https://openai.com/codex), and [Pi](https://github.com/earendil-works/pi-coding-agent), along with behavioral boundaries, templates, and documentation structure. It takes any project from unstructured prompting to autonomous spec-driven development — and on Pi, to fully headless spec execution.
12
12
 
13
13
  ### The core idea
14
14
 
@@ -31,7 +31,27 @@ Most developers plateau at Level 2. Joycraft's job is to move you up.
31
31
 
32
32
  ### Platform support
33
33
 
34
- Joycraft supports both **Claude Code** and **OpenAI Codex** out of the box. Running `npx joycraft init` installs skills to both `.claude/skills/` and `.agents/skills/` — no flags, no configuration. Both platforms get the same structured workflows, adapted for each tool's invocation model (`/joycraft-*` for Claude Code, `$joycraft-*` for Codex).
34
+ Joycraft supports **Claude Code**, **OpenAI Codex**, and **Pi** out of the box. Running `npx joycraft init` auto-detects which harnesses your project uses and installs the matching skills — no flags, no configuration:
35
+
36
+ | Harness | Skills installed to | Invocation |
37
+ |---------|---------------------|------------|
38
+ | Claude Code | `.claude/skills/` | `/joycraft-*` |
39
+ | Codex | `.agents/skills/` (+ `AGENTS.md`) | `$joycraft-*` |
40
+ | Pi | `.pi/skills/` (+ pipeline runtime, see below) | `/skill:joycraft-*` |
41
+
42
+ All three get the same structured workflows, adapted for each tool's invocation model. Codex and Pi surfaces install when their directory (`.agents/` or `.pi/`) is present in the project.
43
+
44
+ ### Headless spec execution (Pi)
45
+
46
+ Pi is the one harness where the workflow can run **fully autonomously** — no human keystrokes between specs. Beyond the skills, `init` installs a pipeline runtime to `.pi/scripts/joycraft/` whose driver, `joycraft-implement-loop`, runs an entire feature's spec queue end to end:
47
+
48
+ ```
49
+ next-spec → pi -p "/skill:joycraft-implement <spec>" → pi -p "/skill:joycraft-spec-done <spec>" → repeat
50
+ ```
51
+
52
+ Each spec runs in **one fresh OS process** (`pi -p`), so the context isolation is the process boundary itself — verified, not in-conversation trickery. The loop is fail-fast (stops and names the failing spec) and runs `session-end` exactly once when the queue is exhausted.
53
+
54
+ This is what Claude Code and Codex can't do out of the box: an unattended `interview → PR` line where the machine does everything convergent in between. It is Pi-specific by design — the driver targets Pi with a BYO API key or open-weight model (Commercial/API terms, no automation restriction); pointing a consumer Claude/ChatGPT *subscription* at an automated loop would violate those tools' terms.
35
55
 
36
56
  ## Quick Start
37
57
 
@@ -52,7 +72,8 @@ Joycraft auto-detects your tech stack and creates:
52
72
 
53
73
  - **CLAUDE.md** with behavioral boundaries (Always / Ask First / Never) and correct build/test/lint commands
54
74
  - **AGENTS.md** for Codex compatibility
55
- - **15 skills** installed to `.claude/skills/` (Claude Code) and `.agents/skills/` (Codex) — see [Which skill do I need?](#which-skill-do-i-need) below
75
+ - **19 skills** installed to `.claude/skills/` (Claude Code), `.agents/skills/` (Codex), and `.pi/skills/` (Pi) — see [Which skill do I need?](#which-skill-do-i-need) below
76
+ - **Pi pipeline runtime** in `.pi/scripts/joycraft/` (when `.pi/` is present) — the headless spec-execution driver and its helpers
56
77
  - **docs/** structure: `docs/context/` is created up front; feature work lands in `docs/features/<slug>/{brief.md, research.md, design.md, specs/}` and deferred work in `docs/backlog/` — these are created lazily by the skills that write to them
57
78
  - **Context documents** in `docs/context/`: production map, dangerous assumptions, decision log, institutional knowledge, and troubleshooting guide
58
79
  - **Templates** including atomic spec, feature brief, implementation plan, boundary framework, and workflow templates for scenario generation and autofix loops
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/version.ts
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ import { createHash } from "crypto";
7
+ var STATE_PATH = join(".claude", ".joycraft", "state.json");
8
+ var LEGACY_VERSION_FILE = ".joycraft-version";
9
+ var HASH_LENGTH = 16;
10
+ function hashContent(content) {
11
+ return createHash("sha256").update(content).digest("hex");
12
+ }
13
+ function truncateHash(hash) {
14
+ return hash.slice(0, HASH_LENGTH);
15
+ }
16
+ function readVersion(dir) {
17
+ const filePath = join(dir, STATE_PATH);
18
+ if (!existsSync(filePath)) return null;
19
+ try {
20
+ const raw = readFileSync(filePath, "utf-8");
21
+ const parsed = JSON.parse(raw);
22
+ if (typeof parsed.version === "string" && typeof parsed.files === "object") {
23
+ return parsed;
24
+ }
25
+ return null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ function writeVersion(dir, version, files) {
31
+ const filePath = join(dir, STATE_PATH);
32
+ const truncated = {};
33
+ for (const [path, hash] of Object.entries(files)) {
34
+ truncated[path] = truncateHash(hash);
35
+ }
36
+ const data = { version, files: truncated };
37
+ mkdirSync(dirname(filePath), { recursive: true });
38
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
39
+ }
40
+ function getLevel(dir) {
41
+ const hasAutofix = existsSync(join(dir, ".github", "workflows", "autofix.yml"));
42
+ if (!hasAutofix) return 4;
43
+ const claudeMdPath = join(dir, "CLAUDE.md");
44
+ if (!existsSync(claudeMdPath)) return 4;
45
+ const content = readFileSync(claudeMdPath, "utf-8");
46
+ return content.includes("## External Validation") ? 5 : 4;
47
+ }
48
+
49
+ export {
50
+ STATE_PATH,
51
+ LEGACY_VERSION_FILE,
52
+ hashContent,
53
+ truncateHash,
54
+ readVersion,
55
+ writeVersion,
56
+ getLevel
57
+ };
58
+ //# sourceMappingURL=chunk-63OWWRAJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { createHash } from 'node:crypto';\n\n/**\n * Project-relative path to Joycraft's upgrade-state file.\n *\n * Hidden inside `.claude/` — the dir `init` always creates for every harness —\n * directly analogous to npm's own hidden lockfile at\n * `node_modules/.package-lock.json`. Never at the repo root, never committed\n * (init/upgrade gitignore it). The old root location was `.joycraft-version`\n * (see LEGACY_VERSION_FILE); `upgrade` migrates it on first run.\n */\nexport const STATE_PATH = join('.claude', '.joycraft', 'state.json');\n\n/** The pre-relocation root path. Kept only so `upgrade` can migrate it. */\nexport const LEGACY_VERSION_FILE = '.joycraft-version';\n\n/**\n * Length we truncate stored hashes to. Full SHA-256 is 64 hex chars; 16 hex\n * (64 bits) is ample to detect customization across ~100 managed files and\n * shrinks the state ~4×. Compare truncated-on-both-sides (see upgrade.ts).\n */\nconst HASH_LENGTH = 16;\n\nexport interface VersionInfo {\n version: string;\n files: Record<string, string>;\n}\n\nexport function hashContent(content: string): string {\n return createHash('sha256').update(content).digest('hex');\n}\n\n/** Truncate a (full) content hash to the stored length. Idempotent for already-short input. */\nexport function truncateHash(hash: string): string {\n return hash.slice(0, HASH_LENGTH);\n}\n\nexport function readVersion(dir: string): VersionInfo | null {\n const filePath = join(dir, STATE_PATH);\n if (!existsSync(filePath)) return null;\n try {\n const raw = readFileSync(filePath, 'utf-8');\n const parsed = JSON.parse(raw);\n if (typeof parsed.version === 'string' && typeof parsed.files === 'object') {\n return parsed as VersionInfo;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport function writeVersion(dir: string, version: string, files: Record<string, string>): void {\n const filePath = join(dir, STATE_PATH);\n // Store truncated hashes — single source of truth for the on-disk shape.\n const truncated: Record<string, string> = {};\n for (const [path, hash] of Object.entries(files)) {\n truncated[path] = truncateHash(hash);\n }\n const data: VersionInfo = { version, files: truncated };\n mkdirSync(dirname(filePath), { recursive: true });\n writeFileSync(filePath, JSON.stringify(data, null, 2) + '\\n', 'utf-8');\n}\n\n/**\n * Detect the current Joycraft harness level for a project directory.\n * Returns 5 if Level 5 artifacts (autofix workflow + External Validation) are present, 4 otherwise.\n */\nexport function getLevel(dir: string): number {\n const hasAutofix = existsSync(join(dir, '.github', 'workflows', 'autofix.yml'));\n if (!hasAutofix) return 4;\n const claudeMdPath = join(dir, 'CLAUDE.md');\n if (!existsSync(claudeMdPath)) return 4;\n const content = readFileSync(claudeMdPath, 'utf-8');\n return content.includes('## External Validation') ? 5 : 4;\n}\n"],"mappings":";;;AAAA,SAAS,cAAc,eAAe,YAAY,iBAAiB;AACnE,SAAS,MAAM,eAAe;AAC9B,SAAS,kBAAkB;AAWpB,IAAM,aAAa,KAAK,WAAW,aAAa,YAAY;AAG5D,IAAM,sBAAsB;AAOnC,IAAM,cAAc;AAOb,SAAS,YAAY,SAAyB;AACnD,SAAO,WAAW,QAAQ,EAAE,OAAO,OAAO,EAAE,OAAO,KAAK;AAC1D;AAGO,SAAS,aAAa,MAAsB;AACjD,SAAO,KAAK,MAAM,GAAG,WAAW;AAClC;AAEO,SAAS,YAAY,KAAiC;AAC3D,QAAM,WAAW,KAAK,KAAK,UAAU;AACrC,MAAI,CAAC,WAAW,QAAQ,EAAG,QAAO;AAClC,MAAI;AACF,UAAM,MAAM,aAAa,UAAU,OAAO;AAC1C,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,OAAO,OAAO,YAAY,YAAY,OAAO,OAAO,UAAU,UAAU;AAC1E,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,aAAa,KAAa,SAAiB,OAAqC;AAC9F,QAAM,WAAW,KAAK,KAAK,UAAU;AAErC,QAAM,YAAoC,CAAC;AAC3C,aAAW,CAAC,MAAM,IAAI,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAU,IAAI,IAAI,aAAa,IAAI;AAAA,EACrC;AACA,QAAM,OAAoB,EAAE,SAAS,OAAO,UAAU;AACtD,YAAU,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;AAChD,gBAAc,UAAU,KAAK,UAAU,MAAM,MAAM,CAAC,IAAI,MAAM,OAAO;AACvE;AAMO,SAAS,SAAS,KAAqB;AAC5C,QAAM,aAAa,WAAW,KAAK,KAAK,WAAW,aAAa,aAAa,CAAC;AAC9E,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,eAAe,KAAK,KAAK,WAAW;AAC1C,MAAI,CAAC,WAAW,YAAY,EAAG,QAAO;AACtC,QAAM,UAAU,aAAa,cAAc,OAAO;AAClD,SAAO,QAAQ,SAAS,wBAAwB,IAAI,IAAI;AAC1D;","names":[]}
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/gitignore.ts
4
+ import { existsSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+ function ensureGitignoreEntry(targetDir, line) {
7
+ const gitignorePath = join(targetDir, ".gitignore");
8
+ if (!existsSync(gitignorePath)) {
9
+ writeFileSync(gitignorePath, line + "\n", "utf-8");
10
+ return true;
11
+ }
12
+ const current = readFileSync(gitignorePath, "utf-8");
13
+ const already = current.split("\n").some((l) => l.trim() === line.trim());
14
+ if (already) return false;
15
+ const sep = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
16
+ writeFileSync(gitignorePath, current + sep + line + "\n", "utf-8");
17
+ return true;
18
+ }
19
+
20
+ // src/package-version.ts
21
+ import { readFileSync as readFileSync2 } from "fs";
22
+ import { fileURLToPath } from "url";
23
+ import { dirname, join as join2 } from "path";
24
+ var __dirname = dirname(fileURLToPath(import.meta.url));
25
+ function getPackageVersion() {
26
+ const pkgPath = join2(__dirname, "..", "package.json");
27
+ const raw = readFileSync2(pkgPath, "utf-8");
28
+ const pkg = JSON.parse(raw);
29
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
30
+ throw new Error(`Joycraft package.json at ${pkgPath} is missing a version field`);
31
+ }
32
+ return pkg.version;
33
+ }
34
+
35
+ export {
36
+ ensureGitignoreEntry,
37
+ getPackageVersion
38
+ };
39
+ //# sourceMappingURL=chunk-JVRMYMBC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/gitignore.ts","../src/package-version.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\n/**\n * Append-only, create-if-absent, idempotent .gitignore writer.\n *\n * Mirrors the \"append over modify when touching user files\" principle: it never\n * rewrites, reorders, or removes existing lines — it only adds `line` if no\n * existing line matches it exactly (after trimming). Returns true if it wrote.\n *\n * Used by both `init` (to gitignore the relocated state file) and `upgrade`'s\n * legacy-migration step (so migrated projects also stop committing the state).\n */\nexport function ensureGitignoreEntry(targetDir: string, line: string): boolean {\n const gitignorePath = join(targetDir, '.gitignore');\n\n if (!existsSync(gitignorePath)) {\n writeFileSync(gitignorePath, line + '\\n', 'utf-8');\n return true;\n }\n\n const current = readFileSync(gitignorePath, 'utf-8');\n const already = current.split('\\n').some((l) => l.trim() === line.trim());\n if (already) return false;\n\n // Append on its own line, tolerating a file that may or may not end in \\n.\n const sep = current.length > 0 && !current.endsWith('\\n') ? '\\n' : '';\n writeFileSync(gitignorePath, current + sep + line + '\\n', 'utf-8');\n return true;\n}\n","import { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport function getPackageVersion(): string {\n const pkgPath = join(__dirname, '..', 'package.json');\n const raw = readFileSync(pkgPath, 'utf-8');\n const pkg = JSON.parse(raw) as { version?: unknown };\n if (typeof pkg.version !== 'string' || pkg.version.length === 0) {\n throw new Error(`Joycraft package.json at ${pkgPath} is missing a version field`);\n }\n return pkg.version;\n}\n"],"mappings":";;;AAAA,SAAS,YAAY,cAAc,qBAAqB;AACxD,SAAS,YAAY;AAYd,SAAS,qBAAqB,WAAmB,MAAuB;AAC7E,QAAM,gBAAgB,KAAK,WAAW,YAAY;AAElD,MAAI,CAAC,WAAW,aAAa,GAAG;AAC9B,kBAAc,eAAe,OAAO,MAAM,OAAO;AACjD,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,aAAa,eAAe,OAAO;AACnD,QAAM,UAAU,QAAQ,MAAM,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,MAAM,KAAK,KAAK,CAAC;AACxE,MAAI,QAAS,QAAO;AAGpB,QAAM,MAAM,QAAQ,SAAS,KAAK,CAAC,QAAQ,SAAS,IAAI,IAAI,OAAO;AACnE,gBAAc,eAAe,UAAU,MAAM,OAAO,MAAM,OAAO;AACjE,SAAO;AACT;;;AC7BA,SAAS,gBAAAA,qBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,SAAS,QAAAC,aAAY;AAE9B,IAAM,YAAY,QAAQ,cAAc,YAAY,GAAG,CAAC;AAEjD,SAAS,oBAA4B;AAC1C,QAAM,UAAUA,MAAK,WAAW,MAAM,cAAc;AACpD,QAAM,MAAMD,cAAa,SAAS,OAAO;AACzC,QAAM,MAAM,KAAK,MAAM,GAAG;AAC1B,MAAI,OAAO,IAAI,YAAY,YAAY,IAAI,QAAQ,WAAW,GAAG;AAC/D,UAAM,IAAI,MAAM,4BAA4B,OAAO,6BAA6B;AAAA,EAClF;AACA,SAAO,IAAI;AACb;","names":["readFileSync","join"]}