joycraft 0.6.14 → 0.6.16
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 +31 -13
- package/dist/chunk-34IWIKXS.js +148 -0
- package/dist/chunk-34IWIKXS.js.map +1 -0
- package/dist/{chunk-VIVJUY6J.js → chunk-G6WSFZQG.js} +3 -3
- package/dist/chunk-G6WSFZQG.js.map +1 -0
- package/dist/{chunk-VCLRPD62.js → chunk-UEG5IO6Q.js} +9 -187
- package/dist/chunk-UEG5IO6Q.js.map +1 -0
- package/dist/cli.js +8 -7
- package/dist/cli.js.map +1 -1
- package/dist/{init-OUKVQXNY.js → init-WPKDBQDN.js} +275 -156
- package/dist/init-WPKDBQDN.js.map +1 -0
- package/dist/{init-autofix-YAI6E4VJ.js → init-autofix-ESN27L3W.js} +6 -4
- package/dist/{init-autofix-YAI6E4VJ.js.map → init-autofix-ESN27L3W.js.map} +1 -1
- package/dist/{upgrade-E3VXHORR.js → upgrade-LKX25GTT.js} +59 -29
- package/dist/upgrade-LKX25GTT.js.map +1 -0
- package/dist/{version-2FGZETKD.js → version-OTDHPJBE.js} +4 -2
- package/package.json +1 -1
- package/dist/chunk-TD65VH2W.js +0 -75
- package/dist/chunk-TD65VH2W.js.map +0 -1
- package/dist/chunk-VCLRPD62.js.map +0 -1
- package/dist/chunk-VIVJUY6J.js.map +0 -1
- package/dist/init-OUKVQXNY.js.map +0 -1
- package/dist/upgrade-E3VXHORR.js.map +0 -1
- /package/dist/{version-2FGZETKD.js.map → version-OTDHPJBE.js.map} +0 -0
package/README.md
CHANGED
|
@@ -31,7 +31,15 @@ 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 **Claude Code**, **OpenAI Codex**, and **Pi** out of the box.
|
|
34
|
+
Joycraft supports **Claude Code**, **OpenAI Codex**, and **Pi** out of the box. When you run `npx joycraft init`, it opens with a quick picker — choose any combination of the three, and only the harnesses you select get installed:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Which AI harnesses should Joycraft install?
|
|
38
|
+
claude — Claude Code (.claude/)
|
|
39
|
+
codex — OpenAI Codex (.agents/)
|
|
40
|
+
pi — Pi (.pi/)
|
|
41
|
+
Harnesses [comma-separated, or "all"] (none): claude,pi
|
|
42
|
+
```
|
|
35
43
|
|
|
36
44
|
| Harness | Skills installed to | Invocation |
|
|
37
45
|
|---------|---------------------|------------|
|
|
@@ -39,7 +47,7 @@ Joycraft supports **Claude Code**, **OpenAI Codex**, and **Pi** out of the box.
|
|
|
39
47
|
| Codex | `.agents/skills/` (+ `AGENTS.md`) | `$joycraft-*` |
|
|
40
48
|
| Pi | `.pi/skills/` (+ pipeline runtime, see below) | `/skill:joycraft-*` |
|
|
41
49
|
|
|
42
|
-
All three get the same structured workflows, adapted for each tool's invocation model.
|
|
50
|
+
All three get the same structured workflows, adapted for each tool's invocation model. A single-harness install carries **no footprint from the others** — pick `codex` only and you get `.agents/` with no `.claude/` or `.pi/` in sight. In a non-interactive run (CI, piped, no TTY) `init` installs all three so existing scripts keep working. The shared docs (`CLAUDE.md`, `AGENTS.md`, `docs/`) are written regardless of which harnesses you pick.
|
|
43
51
|
|
|
44
52
|
### Headless spec execution (Pi)
|
|
45
53
|
|
|
@@ -70,16 +78,19 @@ cd /path/to/your/project
|
|
|
70
78
|
npx joycraft init
|
|
71
79
|
```
|
|
72
80
|
|
|
73
|
-
|
|
81
|
+
`init` first asks which harnesses to install (see [Platform support](#platform-support) above), then auto-detects your tech stack and creates:
|
|
74
82
|
|
|
75
83
|
- **CLAUDE.md** with behavioral boundaries (Always / Ask First / Never) and correct build/test/lint commands
|
|
76
|
-
- **AGENTS.md** for Codex compatibility
|
|
77
|
-
- **20 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
|
|
78
|
-
- **Pi pipeline runtime** in `.pi/scripts/joycraft/` (when
|
|
79
|
-
- **
|
|
84
|
+
- **AGENTS.md** for Codex/Pi compatibility
|
|
85
|
+
- **20 skills** installed to the selected harnesses — `.claude/skills/` (Claude Code), `.agents/skills/` (Codex), and/or `.pi/skills/` (Pi) — see [Which skill do I need?](#which-skill-do-i-need) below
|
|
86
|
+
- **Pi pipeline runtime** in `.pi/scripts/joycraft/` (when Pi is selected) — the headless spec-execution driver and its helpers
|
|
87
|
+
- **Agent teams enabled** — when Claude Code is selected, `init` sets `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in `.claude/settings.json` so subagent-driven skills like `/joycraft-research` work out of the box (idempotent — it never clobbers a value you already set)
|
|
88
|
+
- **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. Joycraft's own upgrade state lives hidden at `docs/.joycraft/state.json` (harness-neutral, gitignored — never committed)
|
|
80
89
|
- **Context documents** in `docs/context/`: production map, dangerous assumptions, decision log, institutional knowledge, and troubleshooting guide
|
|
81
90
|
- **Templates** including atomic spec, feature brief, implementation plan, boundary framework, and workflow templates for scenario generation and autofix loops
|
|
82
91
|
|
|
92
|
+
> Pick nothing at the harness prompt and `init` installs nothing — it tells you to re-run and choose at least one harness.
|
|
93
|
+
|
|
83
94
|
### Git tracking: shared vs private
|
|
84
95
|
|
|
85
96
|
By default Joycraft assumes you want to **commit** the harness so your whole team
|
|
@@ -91,21 +102,26 @@ npx joycraft init --gitignore=shared # default — commit .claude/, .agents/,
|
|
|
91
102
|
npx joycraft init --gitignore=private # gitignore them; track only CLAUDE.md, AGENTS.md, docs/
|
|
92
103
|
```
|
|
93
104
|
|
|
94
|
-
Run interactively without the flag and `init`
|
|
95
|
-
`npx joycraft upgrade` re-applies it
|
|
96
|
-
project later (or decide from CI), pass the
|
|
97
|
-
`npx joycraft upgrade --gitignore=private`. `.gitignore`
|
|
98
|
-
append-only — Joycraft never rewrites or removes your existing lines.
|
|
105
|
+
Run interactively without the flag and `init` asks (right after the harness
|
|
106
|
+
picker). The choice is saved, so `npx joycraft upgrade` re-applies it
|
|
107
|
+
automatically. To switch an existing project later (or decide from CI), pass the
|
|
108
|
+
same flag to upgrade: `npx joycraft upgrade --gitignore=private`. `.gitignore`
|
|
109
|
+
edits are append-only — Joycraft never rewrites or removes your existing lines.
|
|
99
110
|
|
|
100
111
|
| Profile | Tracked in git | Gitignored |
|
|
101
112
|
|---------|----------------|------------|
|
|
102
|
-
| `shared` (default) | `CLAUDE.md`, `AGENTS.md`, `docs/`, `.claude/skills/`, `.agents/`, `.pi/` | hidden upgrade state only |
|
|
113
|
+
| `shared` (default) | `CLAUDE.md`, `AGENTS.md`, `docs/`, `.claude/skills/`, `.agents/`, `.pi/` | hidden upgrade state only (`docs/.joycraft/state.json`) |
|
|
103
114
|
| `private` | `CLAUDE.md`, `AGENTS.md`, `docs/` | `.claude/`, `.agents/`, `.pi/` |
|
|
104
115
|
|
|
105
116
|
> Switching an existing project to `private` only updates `.gitignore`. If
|
|
106
117
|
> harness files were already committed, untrack them with
|
|
107
118
|
> `git rm -r --cached .claude .agents .pi` (Joycraft prints this reminder and
|
|
108
119
|
> never runs git for you).
|
|
120
|
+
>
|
|
121
|
+
> Under `private`, the harness dirs aren't committed — so a teammate who clones
|
|
122
|
+
> the repo gets `CLAUDE.md`/`AGENTS.md` but no skills until they run
|
|
123
|
+
> `npx joycraft init` to regenerate them locally. Joycraft adds a one-line
|
|
124
|
+
> reminder to your generated `CLAUDE.md` and `AGENTS.md` for exactly this reason.
|
|
109
125
|
|
|
110
126
|
### Supported Stacks
|
|
111
127
|
|
|
@@ -198,6 +214,8 @@ npx joycraft upgrade
|
|
|
198
214
|
|
|
199
215
|
Joycraft tracks what it installed vs. what you've customized. Unmodified files update automatically. Customized files show a diff and ask before overwriting. Use `--yes` for CI.
|
|
200
216
|
|
|
217
|
+
`upgrade` only refreshes the harnesses you installed at init — a Codex-only project stays Codex-only and never grows a `.claude/` tree. (Projects from before harness selection existed have no recorded choice, so `upgrade` refreshes all three, preserving the old behavior.)
|
|
218
|
+
|
|
201
219
|
> **Note:** If you're upgrading from an early version, deprecated skill directories (e.g., `/joy`, `/joysmith`, `/tune`) are automatically removed during upgrade.
|
|
202
220
|
|
|
203
221
|
## Why This Exists
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
|
|
8
|
+
// src/harness.ts
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
var HARNESSES = ["claude", "codex", "pi"];
|
|
11
|
+
var HARNESS_LABELS = {
|
|
12
|
+
claude: "Claude Code (.claude/)",
|
|
13
|
+
codex: "OpenAI Codex (.agents/)",
|
|
14
|
+
pi: "Pi (.pi/)"
|
|
15
|
+
};
|
|
16
|
+
function isHarness(value) {
|
|
17
|
+
return HARNESSES.includes(value);
|
|
18
|
+
}
|
|
19
|
+
function sanitizeHarnesses(value) {
|
|
20
|
+
if (!Array.isArray(value)) return null;
|
|
21
|
+
const present = new Set(
|
|
22
|
+
value.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase())
|
|
23
|
+
);
|
|
24
|
+
return HARNESSES.filter((h) => present.has(h));
|
|
25
|
+
}
|
|
26
|
+
function parseHarnessSelection(answer) {
|
|
27
|
+
const tokens = answer.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0);
|
|
28
|
+
if (tokens.length === 1 && tokens[0] === "all") {
|
|
29
|
+
return [...HARNESSES];
|
|
30
|
+
}
|
|
31
|
+
const selected = [];
|
|
32
|
+
for (const token of tokens) {
|
|
33
|
+
if (!isHarness(token)) return null;
|
|
34
|
+
if (!selected.includes(token)) selected.push(token);
|
|
35
|
+
}
|
|
36
|
+
return selected;
|
|
37
|
+
}
|
|
38
|
+
async function promptHarnesses() {
|
|
39
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
console.log("\nWhich AI harnesses should Joycraft install?");
|
|
41
|
+
for (const h of HARNESSES) {
|
|
42
|
+
console.log(` ${h.padEnd(7)} \u2014 ${HARNESS_LABELS[h]}`);
|
|
43
|
+
}
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const ask = () => {
|
|
46
|
+
rl.question('Harnesses [comma-separated, or "all"] (none): ', (answer) => {
|
|
47
|
+
const parsed = parseHarnessSelection(answer);
|
|
48
|
+
if (parsed !== null) {
|
|
49
|
+
rl.close();
|
|
50
|
+
resolve(parsed);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log(
|
|
54
|
+
`Unrecognized harness in '${answer.trim()}' \u2014 choose from ${HARNESSES.join(", ")} (comma-separated), or leave empty for none.`
|
|
55
|
+
);
|
|
56
|
+
ask();
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
ask();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function resolveHarnesses(interactive) {
|
|
63
|
+
if (interactive) {
|
|
64
|
+
return promptHarnesses();
|
|
65
|
+
}
|
|
66
|
+
return [...HARNESSES];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/version.ts
|
|
70
|
+
var STATE_PATH = join("docs", ".joycraft", "state.json");
|
|
71
|
+
var LEGACY_VERSION_FILE = ".joycraft-version";
|
|
72
|
+
var LEGACY_CLAUDE_STATE_PATH = join(".claude", ".joycraft", "state.json");
|
|
73
|
+
var HASH_LENGTH = 16;
|
|
74
|
+
var DEFAULT_GITIGNORE_PROFILE = "shared";
|
|
75
|
+
function parseGitignoreProfile(value) {
|
|
76
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : value;
|
|
77
|
+
return v === "shared" || v === "private" ? v : null;
|
|
78
|
+
}
|
|
79
|
+
function hashContent(content) {
|
|
80
|
+
return createHash("sha256").update(content).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
function truncateHash(hash) {
|
|
83
|
+
return hash.slice(0, HASH_LENGTH);
|
|
84
|
+
}
|
|
85
|
+
function readVersion(dir) {
|
|
86
|
+
const filePath = join(dir, STATE_PATH);
|
|
87
|
+
if (!existsSync(filePath)) return null;
|
|
88
|
+
try {
|
|
89
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
if (typeof parsed.version === "string" && typeof parsed.files === "object") {
|
|
92
|
+
const profile = parseGitignoreProfile(parsed.gitignoreProfile);
|
|
93
|
+
const harnesses = sanitizeHarnesses(parsed.harnesses);
|
|
94
|
+
return {
|
|
95
|
+
version: parsed.version,
|
|
96
|
+
files: parsed.files,
|
|
97
|
+
...profile ? { gitignoreProfile: profile } : {},
|
|
98
|
+
...harnesses ? { harnesses } : {}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function writeVersion(dir, version, files, gitignoreProfile, harnesses) {
|
|
107
|
+
const filePath = join(dir, STATE_PATH);
|
|
108
|
+
const existing = readVersion(dir);
|
|
109
|
+
const profile = gitignoreProfile ?? existing?.gitignoreProfile;
|
|
110
|
+
const selectedHarnesses = harnesses ?? existing?.harnesses;
|
|
111
|
+
const truncated = {};
|
|
112
|
+
for (const [path, hash] of Object.entries(files)) {
|
|
113
|
+
truncated[path] = truncateHash(hash);
|
|
114
|
+
}
|
|
115
|
+
const data = {
|
|
116
|
+
version,
|
|
117
|
+
files: truncated,
|
|
118
|
+
...profile ? { gitignoreProfile: profile } : {},
|
|
119
|
+
...selectedHarnesses ? { harnesses: selectedHarnesses } : {}
|
|
120
|
+
};
|
|
121
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
122
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
123
|
+
}
|
|
124
|
+
function getLevel(dir) {
|
|
125
|
+
const hasAutofix = existsSync(join(dir, ".github", "workflows", "autofix.yml"));
|
|
126
|
+
if (!hasAutofix) return 4;
|
|
127
|
+
const claudeMdPath = join(dir, "CLAUDE.md");
|
|
128
|
+
if (!existsSync(claudeMdPath)) return 4;
|
|
129
|
+
const content = readFileSync(claudeMdPath, "utf-8");
|
|
130
|
+
return content.includes("## External Validation") ? 5 : 4;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export {
|
|
134
|
+
HARNESSES,
|
|
135
|
+
sanitizeHarnesses,
|
|
136
|
+
resolveHarnesses,
|
|
137
|
+
STATE_PATH,
|
|
138
|
+
LEGACY_VERSION_FILE,
|
|
139
|
+
LEGACY_CLAUDE_STATE_PATH,
|
|
140
|
+
DEFAULT_GITIGNORE_PROFILE,
|
|
141
|
+
parseGitignoreProfile,
|
|
142
|
+
hashContent,
|
|
143
|
+
truncateHash,
|
|
144
|
+
readVersion,
|
|
145
|
+
writeVersion,
|
|
146
|
+
getLevel
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=chunk-34IWIKXS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/version.ts","../src/harness.ts"],"sourcesContent":["import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport { sanitizeHarnesses, type Harness } from './harness.js';\n\n/**\n * Project-relative path to Joycraft's upgrade-state file.\n *\n * Hidden inside `docs/` — the one directory `init` always creates regardless of\n * which harnesses are selected — so a single-harness install carries no\n * foreign-harness footprint (a Codex-only project has no `.claude/`, etc.). The\n * state is Joycraft's own bookkeeping (version, file hashes, gitignore profile),\n * not a harness artifact, so it lives in a harness-neutral home. Never at the\n * repo root, never committed (init/upgrade gitignore it).\n *\n * Two legacy locations are migrated on upgrade: the original repo-root\n * `.joycraft-version` (see LEGACY_VERSION_FILE) and the later\n * `.claude/.joycraft/state.json` (see LEGACY_CLAUDE_STATE_PATH).\n */\nexport const STATE_PATH = join('docs', '.joycraft', 'state.json');\n\n/** The original repo-root state path. Kept only so `upgrade` can migrate it. */\nexport const LEGACY_VERSION_FILE = '.joycraft-version';\n\n/**\n * The interim `.claude/`-nested state path, used before state moved to a\n * harness-neutral `docs/` home. Kept only so `upgrade` can migrate it (and so a\n * Codex/Pi-only re-init stops leaving a stray `.claude/` behind).\n */\nexport const LEGACY_CLAUDE_STATE_PATH = join('.claude', '.joycraft', 'state.json');\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\n/**\n * How much of the Joycraft harness is tracked in git.\n * - `shared` — commit skills/agents/pi so teammates get the workflow (default).\n * - `private` — gitignore .claude/, .agents/, .pi/; track only CLAUDE.md,\n * AGENTS.md, and docs/.\n */\nexport type GitignoreProfile = 'shared' | 'private';\n\nexport const DEFAULT_GITIGNORE_PROFILE: GitignoreProfile = 'shared';\n\n/**\n * Narrow an arbitrary value to a GitignoreProfile, or null if unrecognized.\n * Strings are normalized (trim + lowercase) here so every call site — flag,\n * prompt, persisted state — gets the same case-insensitivity for free.\n */\nexport function parseGitignoreProfile(value: unknown): GitignoreProfile | null {\n const v = typeof value === 'string' ? value.trim().toLowerCase() : value;\n return v === 'shared' || v === 'private' ? v : null;\n}\n\nexport interface VersionInfo {\n version: string;\n files: Record<string, string>;\n /**\n * The gitignore profile chosen at init/upgrade. Absent on state written by\n * Joycraft versions before this field existed — treat absent as `shared`.\n */\n gitignoreProfile?: GitignoreProfile;\n /**\n * The harnesses installed at init (claude/codex/pi). `upgrade` reads this so\n * it only refreshes the harnesses the project actually uses. Absent on state\n * written before harness selection existed — callers treat absent as \"all\n * three\" for backward compatibility.\n */\n harnesses?: Harness[];\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 // Sanitize the profile: ignore unknown/legacy values rather than\n // returning them (absent or bogus → undefined, callers default to shared).\n const profile = parseGitignoreProfile(parsed.gitignoreProfile);\n // Sanitize harnesses similarly: drop unknown tokens; null (not an array)\n // means \"no recorded selection\" → callers default to all three.\n const harnesses = sanitizeHarnesses(parsed.harnesses);\n return {\n version: parsed.version,\n files: parsed.files,\n ...(profile ? { gitignoreProfile: profile } : {}),\n ...(harnesses ? { harnesses } : {}),\n };\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport function writeVersion(\n dir: string,\n version: string,\n files: Record<string, string>,\n gitignoreProfile?: GitignoreProfile,\n harnesses?: Harness[]\n): void {\n const filePath = join(dir, STATE_PATH);\n // An omitted profile/harness-list means \"no new decision\", not \"clear it\":\n // preserve whatever is already persisted so call sites that only refresh\n // version/hashes can never silently strip a saved choice.\n const existing = readVersion(dir);\n const profile = gitignoreProfile ?? existing?.gitignoreProfile;\n const selectedHarnesses = harnesses ?? existing?.harnesses;\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 = {\n version,\n files: truncated,\n ...(profile ? { gitignoreProfile: profile } : {}),\n ...(selectedHarnesses ? { harnesses: selectedHarnesses } : {}),\n };\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","import { createInterface } from 'node:readline';\n\n/**\n * The AI coding harnesses Joycraft can install into a project. Each maps to a\n * hidden config dir and a skills install path:\n * - claude → .claude/ (Claude Code)\n * - codex → .agents/ (OpenAI Codex)\n * - pi → .pi/ (Pi)\n *\n * Single source of truth: the menu, the parser, and the install gates in\n * init.ts all derive from this list so a new harness can't leave a path stale.\n */\nexport const HARNESSES = ['claude', 'codex', 'pi'] as const;\nexport type Harness = (typeof HARNESSES)[number];\n\n/** Human-readable one-liner per harness, shown in the interactive menu. */\nconst HARNESS_LABELS: Record<Harness, string> = {\n claude: 'Claude Code (.claude/)',\n codex: 'OpenAI Codex (.agents/)',\n pi: 'Pi (.pi/)',\n};\n\nfunction isHarness(value: string): value is Harness {\n return (HARNESSES as readonly string[]).includes(value);\n}\n\n/**\n * Sanitize an arbitrary value (e.g. parsed from state.json) into a harness list,\n * dropping anything unrecognized rather than rejecting the whole list — old or\n * hand-edited state must degrade gracefully. Returns null when the input isn't\n * an array at all (caller treats null as \"no recorded selection\"). Order follows\n * the canonical HARNESSES order and is deduped.\n */\nexport function sanitizeHarnesses(value: unknown): Harness[] | null {\n if (!Array.isArray(value)) return null;\n const present = new Set(\n value.filter((v): v is string => typeof v === 'string').map((v) => v.trim().toLowerCase())\n );\n return HARNESSES.filter((h) => present.has(h));\n}\n\n/**\n * Parse a comma/space-separated answer into a deduped, validated harness list.\n * Returns null when any token is unrecognized so the caller can re-ask rather\n * than silently dropping a typo. An empty answer yields an empty array (the\n * \"install nothing\" case the caller handles explicitly).\n */\nexport function parseHarnessSelection(answer: string): Harness[] | null {\n const tokens = answer\n .split(/[,\\s]+/)\n .map((t) => t.trim().toLowerCase())\n .filter((t) => t.length > 0);\n\n // \"all\" is a convenience alias for every harness.\n if (tokens.length === 1 && tokens[0] === 'all') {\n return [...HARNESSES];\n }\n\n const selected: Harness[] = [];\n for (const token of tokens) {\n if (!isHarness(token)) return null;\n if (!selected.includes(token)) selected.push(token);\n }\n return selected;\n}\n\n/**\n * Interactive multi-select for which harnesses to install. An empty answer is a\n * deliberate \"none\" — the caller prints the run-again message and bails. An\n * unrecognized token re-asks instead of being coerced.\n */\nasync function promptHarnesses(): Promise<Harness[]> {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n console.log('\\nWhich AI harnesses should Joycraft install?');\n for (const h of HARNESSES) {\n console.log(` ${h.padEnd(7)} — ${HARNESS_LABELS[h]}`);\n }\n return new Promise((resolve) => {\n const ask = (): void => {\n rl.question('Harnesses [comma-separated, or \"all\"] (none): ', (answer) => {\n const parsed = parseHarnessSelection(answer);\n if (parsed !== null) {\n rl.close();\n resolve(parsed);\n return;\n }\n console.log(\n `Unrecognized harness in '${answer.trim()}' — choose from ${HARNESSES.join(', ')} (comma-separated), or leave empty for none.`\n );\n ask();\n });\n };\n ask();\n });\n}\n\n/**\n * Resolve which harnesses to install:\n * - interactive (TTY): show the multi-select menu; honor an empty \"none\"\n * - non-interactive: install all three, preserving long-standing init behavior\n * for scripted/CI runs that can't answer a prompt.\n */\nexport async function resolveHarnesses(interactive: boolean): Promise<Harness[]> {\n if (interactive) {\n return promptHarnesses();\n }\n return [...HARNESSES];\n}\n"],"mappings":"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","names":[]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
DEFAULT_GITIGNORE_PROFILE,
|
|
4
4
|
STATE_PATH,
|
|
5
5
|
parseGitignoreProfile
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-34IWIKXS.js";
|
|
7
7
|
|
|
8
8
|
// src/gitignore.ts
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -24,7 +24,7 @@ function ensureGitignoreEntries(targetDir, entries) {
|
|
|
24
24
|
}
|
|
25
25
|
function applyGitignoreProfile(targetDir, profile) {
|
|
26
26
|
if (profile === "private") {
|
|
27
|
-
return ensureGitignoreEntries(targetDir, PRIVATE_PROFILE_IGNORES);
|
|
27
|
+
return ensureGitignoreEntries(targetDir, [...PRIVATE_PROFILE_IGNORES, STATE_PATH]);
|
|
28
28
|
}
|
|
29
29
|
return ensureGitignoreEntries(targetDir, [STATE_PATH]);
|
|
30
30
|
}
|
|
@@ -83,4 +83,4 @@ export {
|
|
|
83
83
|
validateGitignoreFlag,
|
|
84
84
|
resolveGitignoreProfile
|
|
85
85
|
};
|
|
86
|
-
//# sourceMappingURL=chunk-
|
|
86
|
+
//# sourceMappingURL=chunk-G6WSFZQG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/gitignore.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { createInterface } from 'node:readline';\nimport {\n STATE_PATH,\n parseGitignoreProfile,\n DEFAULT_GITIGNORE_PROFILE,\n type GitignoreProfile,\n} from './version.js';\n\n/**\n * The harness directories the `private` profile gitignores. Tracking only\n * CLAUDE.md, AGENTS.md, and docs/ means everything under these three dirs\n * stays local. `.claude/` already covers the hidden state file.\n *\n * Single source of truth: every user-facing string that names these dirs\n * (prompts, summaries, the untrack hint, CLI help) derives from this list via\n * the constants below, so adding a harness dir can't leave stale messages.\n */\nexport const PRIVATE_PROFILE_IGNORES = ['.claude/', '.agents/', '.pi/'];\n\n/** Human-readable list of the private-profile dirs, for prompts and summaries. */\nexport const PRIVATE_DIRS_DISPLAY = PRIVATE_PROFILE_IGNORES.join(', ');\n\n/** Copy-pasteable command to untrack already-committed harness files. */\nexport const PRIVATE_UNTRACK_COMMAND = `git rm -r --cached ${PRIVATE_PROFILE_IGNORES.map((d) => d.replace(/\\/$/, '')).join(' ')}`;\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 appends the entries\n * not already present (matched exactly, after trimming). One read + at most one\n * write per call. Returns the entries actually added (empty when everything was\n * already present).\n */\nexport function ensureGitignoreEntries(targetDir: string, entries: string[]): string[] {\n const gitignorePath = join(targetDir, '.gitignore');\n const current = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';\n const present = new Set(current.split('\\n').map((l) => l.trim()));\n const missing = entries.filter((e) => !present.has(e.trim()));\n if (missing.length === 0) return [];\n\n // Append on their own lines, 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 + missing.join('\\n') + '\\n', 'utf-8');\n return missing;\n}\n\n/** Single-entry convenience wrapper around ensureGitignoreEntries. */\nexport function ensureGitignoreEntry(targetDir: string, line: string): boolean {\n return ensureGitignoreEntries(targetDir, [line]).length > 0;\n}\n\n/**\n * Apply a gitignore profile's entries to the project's .gitignore.\n *\n * The hidden upgrade-state file (`STATE_PATH`) is tool-managed, regenerated on\n * every init/upgrade, and must never be committed — under BOTH profiles. It now\n * lives at `docs/.joycraft/state.json`; since `docs/` is always tracked, the\n * state entry is no longer covered transitively by any harness-dir ignore, so\n * both profiles list it explicitly.\n *\n * - `shared` — ignore only the hidden state file (commit the harness dirs).\n * - `private` — ignore the .claude/, .agents/, .pi/ trees AND the state file.\n *\n * Append-only and idempotent (via ensureGitignoreEntries), so re-running\n * init/upgrade never duplicates entries. Returns the list of lines actually\n * added this call.\n */\nexport function applyGitignoreProfile(targetDir: string, profile: GitignoreProfile): string[] {\n if (profile === 'private') {\n return ensureGitignoreEntries(targetDir, [...PRIVATE_PROFILE_IGNORES, STATE_PATH]);\n }\n // `shared`: only the hidden state file, matching long-standing behavior.\n return ensureGitignoreEntries(targetDir, [STATE_PATH]);\n}\n\n/** A resolved profile plus how it was arrived at. */\nexport interface ResolvedGitignoreProfile {\n profile: GitignoreProfile;\n /**\n * True when the profile is an actual decision: a --gitignore flag, a\n * persisted choice, or an interactive answer. False when it is the\n * non-interactive fallback default — callers must NOT persist a fallback,\n * or the one-time prompt would be permanently suppressed for the project.\n */\n decided: boolean;\n}\n\n/** Validate a raw --gitignore flag value. Throws the user-facing error on unknown values. */\nexport function validateGitignoreFlag(flag: string): GitignoreProfile {\n const parsed = parseGitignoreProfile(flag);\n if (!parsed) {\n throw new Error(`Unknown gitignore profile '${flag}'. Use 'shared' or 'private'.`);\n }\n return parsed;\n}\n\n/**\n * Prompt for a gitignore profile. An empty answer takes the default; an\n * unrecognized answer re-asks instead of being silently coerced — a typo must\n * not get persisted as a permanent choice.\n */\nasync function promptGitignoreProfile(intro: string): Promise<GitignoreProfile> {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n console.log(intro);\n console.log(' shared — commit skills so your team gets the same workflow (default)');\n console.log(` private — gitignore ${PRIVATE_DIRS_DISPLAY}; track only CLAUDE.md, AGENTS.md, docs/`);\n return new Promise((resolve) => {\n const ask = (): void => {\n rl.question(`Profile [shared/private] (${DEFAULT_GITIGNORE_PROFILE}): `, (answer) => {\n if (answer.trim() === '') {\n rl.close();\n resolve(DEFAULT_GITIGNORE_PROFILE);\n return;\n }\n const parsed = parseGitignoreProfile(answer);\n if (parsed) {\n rl.close();\n resolve(parsed);\n return;\n }\n console.log(\n `Unrecognized answer '${answer.trim()}' — type 'shared' or 'private', or press Enter for ${DEFAULT_GITIGNORE_PROFILE}.`\n );\n ask();\n });\n };\n ask();\n });\n}\n\n/**\n * Resolve the gitignore profile by precedence — the single resolver shared by\n * init and upgrade so the two commands can never drift:\n * 1. --gitignore flag (validated; throws on unknown value)\n * 2. profile persisted in state.json (re-init / upgrade keep the prior choice)\n * 3. interactive prompt (the caller decides when prompting is allowed)\n * 4. fallback default `shared`, reported with decided=false\n */\nexport async function resolveGitignoreProfile(opts: {\n /** Raw --gitignore value from the CLI, if provided. */\n flag?: string;\n /** Profile previously persisted in state.json, if any. */\n persisted?: GitignoreProfile;\n /** Whether prompting is allowed (TTY, and the command permits interaction). */\n interactive: boolean;\n /** First line printed above the prompt; init and upgrade word it differently. */\n promptIntro: string;\n}): Promise<ResolvedGitignoreProfile> {\n if (opts.flag !== undefined) {\n return { profile: validateGitignoreFlag(opts.flag), decided: true };\n }\n if (opts.persisted) {\n return { profile: opts.persisted, decided: true };\n }\n if (opts.interactive) {\n return { profile: await promptGitignoreProfile(opts.promptIntro), decided: true };\n }\n return { profile: DEFAULT_GITIGNORE_PROFILE, decided: false };\n}\n"],"mappings":";;;;;;;;AAAA,SAAS,YAAY,cAAc,qBAAqB;AACxD,SAAS,YAAY;AACrB,SAAS,uBAAuB;AAiBzB,IAAM,0BAA0B,CAAC,YAAY,YAAY,MAAM;AAG/D,IAAM,uBAAuB,wBAAwB,KAAK,IAAI;AAG9D,IAAM,0BAA0B,sBAAsB,wBAAwB,IAAI,CAAC,MAAM,EAAE,QAAQ,OAAO,EAAE,CAAC,EAAE,KAAK,GAAG,CAAC;AAWxH,SAAS,uBAAuB,WAAmB,SAA6B;AACrF,QAAM,gBAAgB,KAAK,WAAW,YAAY;AAClD,QAAM,UAAU,WAAW,aAAa,IAAI,aAAa,eAAe,OAAO,IAAI;AACnF,QAAM,UAAU,IAAI,IAAI,QAAQ,MAAM,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAChE,QAAM,UAAU,QAAQ,OAAO,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,KAAK,CAAC,CAAC;AAC5D,MAAI,QAAQ,WAAW,EAAG,QAAO,CAAC;AAGlC,QAAM,MAAM,QAAQ,SAAS,KAAK,CAAC,QAAQ,SAAS,IAAI,IAAI,OAAO;AACnE,gBAAc,eAAe,UAAU,MAAM,QAAQ,KAAK,IAAI,IAAI,MAAM,OAAO;AAC/E,SAAO;AACT;AAuBO,SAAS,sBAAsB,WAAmB,SAAqC;AAC5F,MAAI,YAAY,WAAW;AACzB,WAAO,uBAAuB,WAAW,CAAC,GAAG,yBAAyB,UAAU,CAAC;AAAA,EACnF;AAEA,SAAO,uBAAuB,WAAW,CAAC,UAAU,CAAC;AACvD;AAeO,SAAS,sBAAsB,MAAgC;AACpE,QAAM,SAAS,sBAAsB,IAAI;AACzC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,8BAA8B,IAAI,+BAA+B;AAAA,EACnF;AACA,SAAO;AACT;AAOA,eAAe,uBAAuB,OAA0C;AAC9E,QAAM,KAAK,gBAAgB,EAAE,OAAO,QAAQ,OAAO,QAAQ,QAAQ,OAAO,CAAC;AAC3E,UAAQ,IAAI,KAAK;AACjB,UAAQ,IAAI,8EAAyE;AACrF,UAAQ,IAAI,8BAAyB,oBAAoB,0CAA0C;AACnG,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,UAAM,MAAM,MAAY;AACtB,SAAG,SAAS,6BAA6B,yBAAyB,OAAO,CAAC,WAAW;AACnF,YAAI,OAAO,KAAK,MAAM,IAAI;AACxB,aAAG,MAAM;AACT,kBAAQ,yBAAyB;AACjC;AAAA,QACF;AACA,cAAM,SAAS,sBAAsB,MAAM;AAC3C,YAAI,QAAQ;AACV,aAAG,MAAM;AACT,kBAAQ,MAAM;AACd;AAAA,QACF;AACA,gBAAQ;AAAA,UACN,wBAAwB,OAAO,KAAK,CAAC,2DAAsD,yBAAyB;AAAA,QACtH;AACA,YAAI;AAAA,MACN,CAAC;AAAA,IACH;AACA,QAAI;AAAA,EACN,CAAC;AACH;AAUA,eAAsB,wBAAwB,MASR;AACpC,MAAI,KAAK,SAAS,QAAW;AAC3B,WAAO,EAAE,SAAS,sBAAsB,KAAK,IAAI,GAAG,SAAS,KAAK;AAAA,EACpE;AACA,MAAI,KAAK,WAAW;AAClB,WAAO,EAAE,SAAS,KAAK,WAAW,SAAS,KAAK;AAAA,EAClD;AACA,MAAI,KAAK,aAAa;AACpB,WAAO,EAAE,SAAS,MAAM,uBAAuB,KAAK,WAAW,GAAG,SAAS,KAAK;AAAA,EAClF;AACA,SAAO,EAAE,SAAS,2BAA2B,SAAS,MAAM;AAC9D;","names":[]}
|