@codewithjuber/forgekit 0.8.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 (140) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.codex-plugin/plugin.json +29 -0
  4. package/.mcp.json +8 -0
  5. package/ARCHITECTURE.md +314 -0
  6. package/CHANGELOG.md +467 -0
  7. package/LICENSE +21 -0
  8. package/ONBOARDING.md +180 -0
  9. package/README.md +286 -0
  10. package/bin/claude-init.sh +90 -0
  11. package/bin/claude-taste.sh +33 -0
  12. package/bin/learn-consolidate.sh +51 -0
  13. package/brand.json +15 -0
  14. package/global/CLAUDE.md +31 -0
  15. package/global/crew/frontend-verifier.md +36 -0
  16. package/global/crew/independent-reviewer.md +29 -0
  17. package/global/crew/scout.md +24 -0
  18. package/global/crew/verifier.md +28 -0
  19. package/global/guards/_guardlib.sh +36 -0
  20. package/global/guards/cortex.sh +14 -0
  21. package/global/guards/cost-budget.sh +41 -0
  22. package/global/guards/doom-loop.sh +25 -0
  23. package/global/guards/format-on-edit.sh +32 -0
  24. package/global/guards/lean-guard.sh +20 -0
  25. package/global/guards/protect-paths.sh +45 -0
  26. package/global/guards/recall-load.sh +22 -0
  27. package/global/guards/secret-redact.sh +18 -0
  28. package/global/guards/session-learner.sh +72 -0
  29. package/global/recall/MEMORY.md +7 -0
  30. package/global/rules/self-correction.md +17 -0
  31. package/global/rules/stack-notes.md +24 -0
  32. package/global/rules/tech-currency.md +19 -0
  33. package/global/settings.template.json +183 -0
  34. package/global/statusline.sh +51 -0
  35. package/global/taste/brutalist.json +9 -0
  36. package/global/taste/brutalist.md +19 -0
  37. package/global/taste/corporate.json +9 -0
  38. package/global/taste/corporate.md +19 -0
  39. package/global/taste/editorial.json +9 -0
  40. package/global/taste/editorial.md +19 -0
  41. package/global/taste/minimalist.json +9 -0
  42. package/global/taste/minimalist.md +20 -0
  43. package/global/taste/playful.json +9 -0
  44. package/global/taste/playful.md +19 -0
  45. package/global/tools/atlas/SKILL.md +27 -0
  46. package/global/tools/code-modernization/SKILL.md +275 -0
  47. package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
  48. package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
  49. package/global/tools/code-modernization/references/research-protocol.md +42 -0
  50. package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
  51. package/global/tools/cognitive-substrate/SKILL.md +56 -0
  52. package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
  53. package/global/tools/cost-guard/SKILL.md +50 -0
  54. package/global/tools/design-md/SKILL.md +54 -0
  55. package/global/tools/dev-radar/SKILL.md +56 -0
  56. package/global/tools/explore-plan-code/SKILL.md +24 -0
  57. package/global/tools/lean/SKILL.md +41 -0
  58. package/global/tools/recall/SKILL.md +31 -0
  59. package/global/tools/reuse-first/SKILL.md +64 -0
  60. package/global/tools/self-improve/SKILL.md +44 -0
  61. package/global/tools/taste/SKILL.md +26 -0
  62. package/global/tools/tech-selector/SKILL.md +35 -0
  63. package/global/tools/ui-workflow/SKILL.md +44 -0
  64. package/hooks/hooks.json +107 -0
  65. package/install.sh +88 -0
  66. package/package.json +93 -0
  67. package/public/index.html +45 -0
  68. package/scripts/build-pages.mjs +180 -0
  69. package/scripts/bump.mjs +322 -0
  70. package/skills/cognitive-substrate/SKILL.md +56 -0
  71. package/skills/cognitive-substrate/references/capability-map.md +17 -0
  72. package/source/mcp.json +10 -0
  73. package/source/rules.json +106 -0
  74. package/source/substrate.json +41 -0
  75. package/src/adjudicate.js +84 -0
  76. package/src/anchor.js +210 -0
  77. package/src/atlas.js +487 -0
  78. package/src/brain.js +84 -0
  79. package/src/brand.js +25 -0
  80. package/src/cli.js +1509 -0
  81. package/src/context.js +273 -0
  82. package/src/cortex.js +251 -0
  83. package/src/cortex_distill.js +55 -0
  84. package/src/cortex_features.js +81 -0
  85. package/src/cortex_hook.js +197 -0
  86. package/src/cortex_hook_main.js +139 -0
  87. package/src/cortex_mcp.js +352 -0
  88. package/src/cost_report.js +271 -0
  89. package/src/dash.html +396 -0
  90. package/src/dash.js +220 -0
  91. package/src/diagnose.js +0 -0
  92. package/src/doctor.js +315 -0
  93. package/src/embed.js +244 -0
  94. package/src/emit/_shared.js +39 -0
  95. package/src/emit/aider.js +22 -0
  96. package/src/emit/claude.js +44 -0
  97. package/src/emit/codex.js +17 -0
  98. package/src/emit/continue.js +28 -0
  99. package/src/emit/copilot.js +12 -0
  100. package/src/emit/cursor.js +23 -0
  101. package/src/emit/gemini.js +40 -0
  102. package/src/emit/mcp.js +94 -0
  103. package/src/emit/windsurf.js +22 -0
  104. package/src/emit/zed.js +34 -0
  105. package/src/eval.js +47 -0
  106. package/src/extract.js +82 -0
  107. package/src/harden.js +44 -0
  108. package/src/imagine.js +301 -0
  109. package/src/init.js +178 -0
  110. package/src/lean.js +149 -0
  111. package/src/ledger.js +475 -0
  112. package/src/ledger_bridge.js +279 -0
  113. package/src/ledger_read.js +152 -0
  114. package/src/ledger_store.js +360 -0
  115. package/src/lessons.js +185 -0
  116. package/src/lessons_store.js +137 -0
  117. package/src/metrics.js +54 -0
  118. package/src/model_tiers.js +17 -0
  119. package/src/model_tiers.json +39 -0
  120. package/src/predictor.js +143 -0
  121. package/src/preflight.js +410 -0
  122. package/src/providers.js +320 -0
  123. package/src/recall.js +103 -0
  124. package/src/reuse.js +0 -0
  125. package/src/route.js +323 -0
  126. package/src/scope.js +122 -0
  127. package/src/skillgate.js +89 -0
  128. package/src/speclock.js +64 -0
  129. package/src/substrate.js +492 -0
  130. package/src/sync.js +132 -0
  131. package/src/taste.js +55 -0
  132. package/src/uicheck.js +96 -0
  133. package/src/uifingerprint.js +861 -0
  134. package/src/uivisual.js +334 -0
  135. package/src/util.js +71 -0
  136. package/src/verify.js +117 -0
  137. package/templates/project-layer/.claude/settings.json +22 -0
  138. package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
  139. package/templates/project-layer/AGENTS.md +28 -0
  140. package/templates/project-layer/CLAUDE.md +40 -0
package/src/extract.js ADDED
@@ -0,0 +1,82 @@
1
+ // forge extract — the ONE call-site extractor, shared so atlas.js and verify.js can't drift.
2
+ // `CALL_RE` matches a called identifier while skipping `.method(` member calls; `CALL_IGNORE` is
3
+ // the language/runtime builtins that are never project symbols. Kept dependency-free and pure.
4
+
5
+ export const CALL_RE = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
6
+
7
+ // Builtins/keywords that look like calls but are never a project-defined symbol. Superset of the
8
+ // two sets atlas.js and verify.js used to keep separately (so neither under-ignores).
9
+ export const CALL_IGNORE = new Set([
10
+ "if",
11
+ "for",
12
+ "while",
13
+ "switch",
14
+ "catch",
15
+ "function",
16
+ "return",
17
+ "typeof",
18
+ "await",
19
+ "new",
20
+ "delete",
21
+ "void",
22
+ "yield",
23
+ "import",
24
+ "export",
25
+ "require",
26
+ "super",
27
+ "console",
28
+ "Math",
29
+ "JSON",
30
+ "Object",
31
+ "Array",
32
+ "String",
33
+ "Number",
34
+ "Boolean",
35
+ "Promise",
36
+ "Set",
37
+ "Map",
38
+ "WeakMap",
39
+ "Date",
40
+ "Error",
41
+ "RegExp",
42
+ "Symbol",
43
+ "Buffer",
44
+ "parseInt",
45
+ "parseFloat",
46
+ "isNaN",
47
+ "isFinite",
48
+ "setTimeout",
49
+ "setInterval",
50
+ "clearTimeout",
51
+ "clearInterval",
52
+ "fetch",
53
+ "structuredClone",
54
+ "process",
55
+ "assert",
56
+ // Python-ish builtins seen in mixed repos
57
+ "print",
58
+ "range",
59
+ "len",
60
+ "int",
61
+ "str",
62
+ "float",
63
+ "dict",
64
+ "list",
65
+ "set",
66
+ "tuple",
67
+ "bool",
68
+ ]);
69
+
70
+ /** Pure: the called identifiers in a block of source (member calls `.foo(` are skipped). */
71
+ export function extractCalledSymbols(text, ignore = CALL_IGNORE) {
72
+ const found = new Set();
73
+ for (const line of String(text).split("\n")) {
74
+ CALL_RE.lastIndex = 0;
75
+ let m;
76
+ while ((m = CALL_RE.exec(line))) {
77
+ const name = m[1];
78
+ if (!ignore.has(name)) found.add(name);
79
+ }
80
+ }
81
+ return [...found];
82
+ }
package/src/harden.js ADDED
@@ -0,0 +1,44 @@
1
+ // forge harden — WIRE the mature security controls Anthropic/the community already
2
+ // own: enable the Claude Code sandbox (84% fewer prompts, per Anthropic) and install
3
+ // a Gitleaks pre-commit hook. We never auto-edit ~/.claude/settings.json (no clobber)
4
+ // — we write the sandbox block for the user to merge, and only touch the repo's own hooks.
5
+
6
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { hasBin as have } from "./util.js";
9
+
10
+ // The sandbox config to merge into settings — deny the credential dirs an agent should never read.
11
+ const SANDBOX = {
12
+ sandbox: { enabled: true, allowUnsandboxedCommands: false },
13
+ credentials: { deny: ["~/.aws", "~/.ssh", "GITHUB_TOKEN", "NPM_TOKEN"] },
14
+ };
15
+
16
+ export function harden({ targetRoot = process.cwd() } = {}) {
17
+ const report = {};
18
+
19
+ // Gitleaks pre-commit (WIRE) — only in a git repo, only if gitleaks is installed.
20
+ if (!existsSync(join(targetRoot, ".git"))) {
21
+ report.gitleaks = "not a git repo — skipped";
22
+ } else if (!have("gitleaks")) {
23
+ report.gitleaks = "gitleaks not installed — `brew install gitleaks` then re-run";
24
+ } else {
25
+ const hooks = join(targetRoot, ".git", "hooks");
26
+ mkdirSync(hooks, { recursive: true });
27
+ writeFileSync(
28
+ join(hooks, "pre-commit"),
29
+ "#!/usr/bin/env bash\n# installed by forge harden\nexec gitleaks protect --staged --redact -v\n",
30
+ );
31
+ try {
32
+ chmodSync(join(hooks, "pre-commit"), 0o755);
33
+ } catch {
34
+ /* best effort */
35
+ }
36
+ report.gitleaks = "installed pre-commit";
37
+ }
38
+
39
+ // Sandbox settings (WIRE — Anthropic owns the sandbox; we write the block to merge).
40
+ mkdirSync(join(targetRoot, ".forge"), { recursive: true });
41
+ writeFileSync(join(targetRoot, ".forge", "sandbox.json"), JSON.stringify(SANDBOX, null, 2));
42
+ report.sandbox = "written to .forge/sandbox.json";
43
+ return report;
44
+ }
package/src/imagine.js ADDED
@@ -0,0 +1,301 @@
1
+ // forge imagine — the consequence simulator ĉ = g(a, C) (paper Eq. 4;
2
+ // docs/plans/substrate-v2/06-faculties-and-mechanisms.md §2). The atlas gives the
3
+ // exact-structure half: entities → blast radius → predicted breaks with confidence.
4
+ // This module adds impacted-test SELECTION — the minimal dry-run suite that makes
5
+ // pre-action simulation affordable at all (minutes → seconds vs "run everything") —
6
+ // and the sandboxed worktree runner (spec §2.2) that EXECUTES that suite: dryRun()
7
+ // checks out HEAD into an ephemeral `git worktree`, runs the suite there, parses the
8
+ // TAP summary, and always discards the sandbox. Selection stays useful on its own as
9
+ // "run these, in this order"; dryRun turns the prediction into measured evidence.
10
+ import { execFileSync, spawnSync } from "node:child_process";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { build as buildAtlas, impact, load as loadAtlas } from "./atlas.js";
15
+ import { referencedEntities } from "./preflight.js";
16
+ import { isTestFile, predictFailingTests } from "./substrate.js";
17
+ import { hasBin } from "./util.js";
18
+
19
+ /**
20
+ * Weighted greedy set cover over the covers(test, source) relation: candidates are
21
+ * substrate's predicted-failing tests plus any test files among the impacted set
22
+ * (those cover themselves — they are predicted to break, so the suite must include
23
+ * them). Weight = file size, a duration proxy until measured runtimes exist (P8).
24
+ * Each round picks the test minimizing weight / newly-covered — the classical
25
+ * greedy H(n) ≈ ln n approximation to weighted set cover (Chvátal 1979), and exact
26
+ * on the tiny instances a single edit produces. Impacted sources with no covering
27
+ * test simply can't constrain the suite; the gaps surface via selectTestsReport.
28
+ * @param {string} root
29
+ * @param {string[]} impactedFiles
30
+ * @returns {string[]} minimal ordered dry-run suite (best value first)
31
+ */
32
+ export function selectTests(root, impactedFiles) {
33
+ return selectTestsReport(root, impactedFiles).tests;
34
+ }
35
+
36
+ /** selectTests plus the sources no known test covers (the honest gap in the cover). */
37
+ export function selectTestsReport(root, impactedFiles) {
38
+ const files = [...new Set(impactedFiles.map(String))];
39
+ const covers = new Map(); // test → Set of covered elements (sources + itself)
40
+ const addCover = (test, el) => {
41
+ if (!covers.has(test)) covers.set(test, new Set());
42
+ covers.get(test).add(el);
43
+ };
44
+ for (const f of files) {
45
+ if (isTestFile(f)) addCover(f, f);
46
+ else for (const t of predictFailingTests(root, [f])) addCover(t, f);
47
+ }
48
+ const universe = new Set();
49
+ for (const els of covers.values()) for (const el of els) universe.add(el);
50
+ const weights = new Map(
51
+ [...covers.keys()].map((t) => {
52
+ // Unreadable candidate → weight 1, never a throw: selection must not die on a
53
+ // just-deleted test file; running it later fails honestly instead.
54
+ let w = 1;
55
+ try {
56
+ w = Math.max(1, statSync(join(root, t)).size);
57
+ } catch {}
58
+ return [t, w];
59
+ }),
60
+ );
61
+ const tests = [];
62
+ const covered = new Set();
63
+ while (covered.size < universe.size) {
64
+ let best = null;
65
+ let bestRatio = Infinity;
66
+ for (const [t, els] of covers) {
67
+ let gain = 0;
68
+ for (const el of els) if (!covered.has(el)) gain++;
69
+ if (!gain) continue; // also skips already-chosen tests (their gain is 0)
70
+ const ratio = weights.get(t) / gain;
71
+ // Deterministic tie-break by path so the suite order is stable across runs.
72
+ if (ratio < bestRatio || (ratio === bestRatio && best !== null && t < best)) {
73
+ best = t;
74
+ bestRatio = ratio;
75
+ }
76
+ }
77
+ if (best === null) break;
78
+ tests.push(best);
79
+ for (const el of covers.get(best)) covered.add(el);
80
+ }
81
+ const uncovered = files.filter((f) => !isTestFile(f) && !covered.has(f)).sort();
82
+ return { tests, uncovered };
83
+ }
84
+
85
+ /** Strip a TAP `location: '/abs/file.js:1:2'` diagnostic down to its file path. */
86
+ const locFile = (line) => {
87
+ const m = /^\s+location:\s*'(.+):\d+:\d+'/.exec(line);
88
+ return m ? m[1] : null;
89
+ };
90
+
91
+ /**
92
+ * @typedef {{ok: boolean, reason?: string, passed?: number, failed?: number,
93
+ * perFile?: Record<string, "pass"|"fail">, durationMs?: number, runner?: string,
94
+ * output?: string, worktree?: string}} DryRunVerdict
95
+ */
96
+
97
+ /**
98
+ * Sandboxed dry-run of a selected suite — the simulation half of ĉ = g(a, C)
99
+ * (spec §2.2): run the tests in an EPHEMERAL `git worktree` of HEAD and discard it.
100
+ * The worktree is HEAD, not the working tree — worktrees share the object store,
101
+ * never uncommitted files — so callers MUST surface that a dirty tree dry-runs the
102
+ * last commit, not the in-flight proposal (the CLI refuses dirty trees by default).
103
+ * ok means the RUN completed and produced a verdict; failing tests are ok:true with
104
+ * failed>0 — that IS the imagined consequence, delivered as evidence. Never throws:
105
+ * unmet preconditions return { ok:false, reason }.
106
+ * @param {string} root
107
+ * @param {{tests?: string[], timeoutMs?: number}} [opts] repo-relative test paths
108
+ * @returns {DryRunVerdict}
109
+ */
110
+ export function dryRun(root, { tests, timeoutMs = 120000 } = {}) {
111
+ if (!Array.isArray(tests) || tests.length === 0)
112
+ return { ok: false, reason: "no tests selected — nothing to dry-run" };
113
+ if (!hasBin("git")) return { ok: false, reason: "git not found — the sandbox is a git worktree" };
114
+ try {
115
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "ignore" });
116
+ } catch {
117
+ return { ok: false, reason: `not a git repository: ${root}` };
118
+ }
119
+ // mkdtemp reserves a unique parent; the worktree goes one level down because
120
+ // `git worktree add` wants to create its target path itself.
121
+ const parent = mkdtempSync(join(tmpdir(), "forge-dryrun-"));
122
+ const wt = join(parent, "wt");
123
+ const started = Date.now();
124
+ // The body runs in a closure so every exit path — verdict, timeout, crash — flows
125
+ // through the same finally-cleanup AND the same verified `worktree` stamp below.
126
+ /** @returns {DryRunVerdict} */
127
+ const body = () => {
128
+ try {
129
+ execFileSync("git", ["worktree", "add", "--detach", wt, "HEAD"], {
130
+ cwd: root,
131
+ stdio: "pipe",
132
+ });
133
+ } catch (e) {
134
+ const msg = /** @type {{stderr?: Buffer}} */ (e).stderr?.toString().trim() || String(e);
135
+ return { ok: false, reason: `git worktree add failed: ${msg}` };
136
+ }
137
+ // Runner policy: always `node --test <files...>` — a custom package test script
138
+ // (jest, vitest, …) is a WHOLE-SUITE command that can't be scoped per-file safely,
139
+ // which would defeat minimal selection. We still run node --test and say so, so a
140
+ // surprising verdict is attributable to the runner mismatch.
141
+ let runner = "node --test";
142
+ try {
143
+ const pkg = JSON.parse(readFileSync(join(wt, "package.json"), "utf8"));
144
+ const script = pkg?.scripts?.test;
145
+ if (script && !/\bnode\s+(--[\w-]+\s+)*--test\b/.test(script))
146
+ runner = `node --test (package.json test script is custom: ${String(script).slice(0, 60)})`;
147
+ } catch {} // no/unreadable package.json → default runner
148
+ // TAP reporter is forced: the default reporter depends on TTY-ness, and the
149
+ // `# pass/# fail` summary below is the contract this parser relies on. The env
150
+ // must NOT leak a parent test-runner's context — when dryRun itself runs under
151
+ // `node --test` (our own tests, or an agent's), an inherited NODE_TEST_CONTEXT
152
+ // makes the child speak the runner's internal protocol instead of TAP.
153
+ const env = { ...process.env };
154
+ delete env.NODE_TEST_CONTEXT;
155
+ const run = spawnSync(
156
+ process.execPath,
157
+ ["--test", "--test-reporter=tap", ...tests.map(String)],
158
+ {
159
+ cwd: wt,
160
+ env,
161
+ encoding: "utf8",
162
+ timeout: timeoutMs,
163
+ killSignal: "SIGKILL", // node --test forks workers; SIGKILL is the reliable stop
164
+ maxBuffer: 16 * 1024 * 1024,
165
+ },
166
+ );
167
+ const combined = `${run.stdout ?? ""}${run.stderr ? `\n${run.stderr}` : ""}`.trim();
168
+ const output = combined.length > 2000 ? `…${combined.slice(-2000)}` : combined;
169
+ const durationMs = Date.now() - started;
170
+ if (run.error || run.signal) {
171
+ const reason =
172
+ /** @type {{code?: string}} */ (run.error)?.code === "ETIMEDOUT" || run.signal
173
+ ? `dry-run timed out after ${timeoutMs}ms (killed with ${run.signal ?? "SIGKILL"})`
174
+ : `runner failed to start: ${run.error}`;
175
+ return { ok: false, reason, durationMs, runner, output };
176
+ }
177
+ const mPass = /^# pass (\d+)$/m.exec(run.stdout ?? "");
178
+ const mFail = /^# fail (\d+)$/m.exec(run.stdout ?? "");
179
+ if (!mPass || !mFail) {
180
+ // Non-zero exit with no TAP summary = the RUN failed (crash, bad flags), which
181
+ // is a different fact than "the tests failed" — report it as one.
182
+ return {
183
+ ok: false,
184
+ reason: `runner exited ${run.status} without a TAP summary`,
185
+ durationMs,
186
+ runner,
187
+ output,
188
+ };
189
+ }
190
+ // perFile is best-effort: node ≥20 TAP flattens per-TEST (no per-file points), but
191
+ // every failure block carries a `location: '<abs path>:l:c'` diagnostic — map those
192
+ // back to the requested files; anything never implicated passed. Omitted when a
193
+ // failure can't be attributed (partial attribution would misassign blame).
194
+ /** @type {Record<string, "pass"|"fail">} */
195
+ const perFile = Object.fromEntries(tests.map((t) => [String(t), "pass"]));
196
+ let attributable = true;
197
+ for (const block of (run.stdout ?? "").split(/^not ok /m).slice(1)) {
198
+ const file = block.split("\n").map(locFile).find(Boolean);
199
+ const t = file && tests.find((c) => file === join(wt, String(c)));
200
+ if (t) perFile[String(t)] = "fail";
201
+ else attributable = false;
202
+ }
203
+ return {
204
+ ok: true,
205
+ passed: Number(mPass[1]),
206
+ failed: Number(mFail[1]),
207
+ ...(attributable ? { perFile } : {}),
208
+ durationMs,
209
+ runner,
210
+ output,
211
+ };
212
+ };
213
+ let result;
214
+ try {
215
+ result = body();
216
+ } finally {
217
+ // ALWAYS discard the sandbox — a leaked worktree pins refs and litters
218
+ // `git worktree list` forever. remove --force, prune the bookkeeping, then
219
+ // belt-and-braces rm of the parent; the verdict below verifies, never assumes.
220
+ try {
221
+ execFileSync("git", ["worktree", "remove", "--force", wt], { cwd: root, stdio: "ignore" });
222
+ } catch {}
223
+ try {
224
+ execFileSync("git", ["worktree", "prune"], { cwd: root, stdio: "ignore" });
225
+ } catch {}
226
+ try {
227
+ rmSync(parent, { recursive: true, force: true });
228
+ } catch {}
229
+ }
230
+ result.worktree = existsSync(wt) ? "leaked" : "removed";
231
+ return result;
232
+ }
233
+
234
+ /**
235
+ * Imagine the consequences of a task before acting: entities → impact() blast
236
+ * radius → predicted breaks (per-file max confidence across targets), the minimal
237
+ * dry-run suite, and riskScore = Σ confidence — the number spec §2.3 thresholds to
238
+ * decide whether the (follow-up) sandboxed dry-run is worth paying for.
239
+ * @param {string} root
240
+ * @param {string} task
241
+ * @param {{atlas?: object, threshold?: number}} [opts] inject `atlas` to skip the build.
242
+ */
243
+ export function imagineTask(root, task, { atlas, threshold = 0.1 } = {}) {
244
+ const graph = atlas || loadAtlas(root) || buildAtlas({ root });
245
+ const entities = referencedEntities(task);
246
+ const targets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8);
247
+ const reports = targets.map((t) => impact(graph, t, { threshold }));
248
+ const byFile = new Map();
249
+ for (const r of reports) {
250
+ for (const x of r.impacted) {
251
+ const f = x.node?.file;
252
+ if (f) byFile.set(f, Math.max(byFile.get(f) ?? 0, x.confidence));
253
+ }
254
+ }
255
+ const predictedBreaks = [...byFile]
256
+ .map(([file, confidence]) => ({ file, confidence }))
257
+ .sort((a, b) => b.confidence - a.confidence || (a.file < b.file ? -1 : 1));
258
+ const { tests, uncovered } = selectTestsReport(
259
+ root,
260
+ predictedBreaks.map((b) => b.file),
261
+ );
262
+ return {
263
+ task: String(task),
264
+ targets,
265
+ found: reports.some((r) => r.found),
266
+ predictedBreaks,
267
+ tests,
268
+ uncovered,
269
+ riskScore: Number(predictedBreaks.reduce((s, b) => s + b.confidence, 0).toFixed(4)),
270
+ };
271
+ }
272
+
273
+ /** @param {ReturnType<typeof imagineTask>} r
274
+ * @param {{footer?: boolean}} [opts] footer=false when the caller runs the dry-run itself */
275
+ export function renderImagine(r, { footer = true } = {}) {
276
+ const lines = ["Forge imagine — consequence simulation (pre-action)", ""];
277
+ lines.push(` targets: ${r.targets.length ? r.targets.join(", ") : "(none named)"}`);
278
+ if (!r.found) {
279
+ lines.push("", " nothing in the code graph matches this task — no consequences to predict.");
280
+ return lines.join("\n");
281
+ }
282
+ lines.push(` risk score: ${r.riskScore} (Σ confidence over predicted breaks)`);
283
+ lines.push("", ` predicted breaks (${r.predictedBreaks.length}):`);
284
+ for (const b of r.predictedBreaks.slice(0, 12))
285
+ lines.push(` ${b.confidence.toFixed(2)} ${b.file}`);
286
+ if (r.predictedBreaks.length > 12) lines.push(` … ${r.predictedBreaks.length - 12} more`);
287
+ if (r.tests.length) {
288
+ lines.push("", ` minimal dry-run suite (${r.tests.length}) — run these, in this order:`);
289
+ for (const t of r.tests) lines.push(` - ${t}`);
290
+ } else {
291
+ lines.push("", " no covering tests found for the predicted breaks.");
292
+ }
293
+ if (r.uncovered.length)
294
+ lines.push(
295
+ "",
296
+ ` ! no test covers: ${r.uncovered.slice(0, 6).join(", ")}${r.uncovered.length > 6 ? " …" : ""}`,
297
+ );
298
+ if (footer)
299
+ lines.push("", " (measure it: re-run with --run — sandboxed worktree dry-run of HEAD)");
300
+ return lines.join("\n");
301
+ }
package/src/init.js ADDED
@@ -0,0 +1,178 @@
1
+ // forge init / catalog — the onboarding surface. init gets a repo to a working
2
+ // state in one command; catalog is the "Start Here" index of everything active.
3
+ import {
4
+ appendFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { BRAND } from "./brand.js";
14
+ import { GITATTRIBUTES_RULE } from "./ledger_store.js";
15
+ import { autoDetectProvider } from "./providers.js";
16
+ import { sync } from "./sync.js";
17
+ import { list as tasteList } from "./taste.js";
18
+
19
+ /** Without the union merge driver, two teammates appending to the same ledger log get
20
+ * a git conflict — the exact thing the ledger's design promises can't happen
21
+ * (docs/plans/substrate-v2/02-team-memory.md §1). Idempotent append. */
22
+ export function ensureLedgerGitattributes(targetRoot = process.cwd()) {
23
+ const path = join(targetRoot, ".gitattributes");
24
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
25
+ if (existing.includes(".forge/ledger/")) return { written: false };
26
+ appendFileSync(
27
+ path,
28
+ `${existing && !existing.endsWith("\n") ? "\n" : ""}${GITATTRIBUTES_RULE}\n`,
29
+ );
30
+ return { written: true };
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Settings merge — auto-install Forge hooks + permissions into the user's
35
+ // ~/.claude/settings.json without clobbering existing entries.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const FORGE_SETTINGS_MARKER = "forge-managed";
39
+
40
+ function loadTemplate() {
41
+ const path = join(BRAND.root, "global", "settings.template.json");
42
+ return JSON.parse(readFileSync(path, "utf8"));
43
+ }
44
+
45
+ function readJsonSafe(path) {
46
+ try {
47
+ return JSON.parse(readFileSync(path, "utf8"));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Deduplicated union of two string arrays. */
54
+ function unionStrings(a = [], b = []) {
55
+ const set = new Set(a);
56
+ for (const s of b) set.add(s);
57
+ return [...set];
58
+ }
59
+
60
+ /** Merge Forge hook entries into existing hook arrays, matching by command to avoid duplicates. */
61
+ function mergeHooks(existing = {}, template = {}) {
62
+ const merged = { ...existing };
63
+ for (const [event, entries] of Object.entries(template)) {
64
+ const existingEntries = merged[event] || [];
65
+ const existingCommands = new Set(
66
+ existingEntries.flatMap((e) => (e.hooks || []).map((h) => h.command)).filter(Boolean),
67
+ );
68
+ const newEntries = [];
69
+ for (const entry of entries) {
70
+ const hooks = (entry.hooks || []).filter((h) => !existingCommands.has(h.command));
71
+ if (hooks.length) {
72
+ newEntries.push({ ...entry, hooks });
73
+ }
74
+ }
75
+ merged[event] = [...existingEntries, ...newEntries];
76
+ }
77
+ return merged;
78
+ }
79
+
80
+ /**
81
+ * Merge Forge settings (hooks, permissions, statusline) into the user's
82
+ * ~/.claude/settings.json. Preserves all existing entries. Idempotent.
83
+ * @param {{settingsPath?: string, noSettings?: boolean}} [opts]
84
+ */
85
+ export function mergeSettings({ settingsPath, noSettings } = {}) {
86
+ if (noSettings) return { action: "skipped", reason: "--no-settings" };
87
+ const target = settingsPath || join(homedir(), ".claude", "settings.json");
88
+ const template = loadTemplate();
89
+ const existing = readJsonSafe(target) || {};
90
+ const report = { added: [], unchanged: [], path: target };
91
+
92
+ // Hooks
93
+ if (template.hooks) {
94
+ const before = JSON.stringify(existing.hooks || {});
95
+ existing.hooks = mergeHooks(existing.hooks, template.hooks);
96
+ if (JSON.stringify(existing.hooks) !== before) report.added.push("hooks");
97
+ else report.unchanged.push("hooks");
98
+ }
99
+
100
+ // Permissions
101
+ if (template.permissions) {
102
+ const ep = existing.permissions || {};
103
+ for (const level of ["allow", "ask", "deny"]) {
104
+ if (template.permissions[level]) {
105
+ const before = (ep[level] || []).length;
106
+ ep[level] = unionStrings(ep[level], template.permissions[level]);
107
+ if (ep[level].length > before) report.added.push(`permissions.${level}`);
108
+ else report.unchanged.push(`permissions.${level}`);
109
+ }
110
+ }
111
+ if (!ep.defaultMode) ep.defaultMode = template.permissions.defaultMode || "default";
112
+ existing.permissions = ep;
113
+ }
114
+
115
+ // Statusline — set only if not already configured
116
+ if (template.statusLine && !existing.statusLine) {
117
+ existing.statusLine = template.statusLine;
118
+ report.added.push("statusLine");
119
+ } else if (template.statusLine) {
120
+ report.unchanged.push("statusLine");
121
+ }
122
+
123
+ // Schema
124
+ if (template.$schema && !existing.$schema) existing.$schema = template.$schema;
125
+
126
+ // Mark as forge-managed (metadata, won't affect Claude Code)
127
+ existing._forge = FORGE_SETTINGS_MARKER;
128
+
129
+ // Write back
130
+ const dir = dirname(target);
131
+ mkdirSync(dir, { recursive: true });
132
+ writeFileSync(target, `${JSON.stringify(existing, null, 2)}\n`);
133
+
134
+ return {
135
+ action: report.added.length ? "merged" : "unchanged",
136
+ ...report,
137
+ };
138
+ }
139
+
140
+ /** Scaffold this repo's cross-tool config (emit every tool) in one step. */
141
+ export function init({ targetRoot = process.cwd(), noSettings = false } = {}) {
142
+ const r = sync({ targetRoot });
143
+ ensureLedgerGitattributes(targetRoot);
144
+ const settings = mergeSettings({ noSettings });
145
+ const detected = autoDetectProvider();
146
+ return { ...r, settings, detected };
147
+ }
148
+
149
+ function skillDescription(dir) {
150
+ try {
151
+ const match = readFileSync(join(dir, "SKILL.md"), "utf8").match(/description:\s*(.*)/);
152
+ return match ? match[1].trim() : "";
153
+ } catch {
154
+ return "";
155
+ }
156
+ }
157
+
158
+ /** Everything Forge makes available, grouped by layer, for the Start-Here list. */
159
+ export function catalog() {
160
+ const g = join(BRAND.root, "global");
161
+ const dir = (p) => (existsSync(p) ? readdirSync(p) : []);
162
+ return {
163
+ tools: dir(join(g, "tools"))
164
+ .filter((name) => existsSync(join(g, "tools", name, "SKILL.md")))
165
+ .map((name) => ({ name, why: skillDescription(join(g, "tools", name)) })),
166
+ crew: dir(join(g, "crew"))
167
+ .filter((f) => f.endsWith(".md"))
168
+ .map((f) => f.replace(/\.md$/, "")),
169
+ guards: dir(join(g, "guards"))
170
+ .filter((f) => f.endsWith(".sh") && !f.startsWith("_"))
171
+ .map((f) => f.replace(/\.sh$/, "")),
172
+ taste: tasteList(),
173
+ cortex:
174
+ "self-correcting project memory — learns from your mistakes on this repo (`forge cortex`)",
175
+ preflight:
176
+ "size the work before spending tokens — assumption-check / model-route / decompose (`forge preflight` · `route` · `scope`)",
177
+ };
178
+ }