@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.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- 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
|
+
}
|