@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/dash.js ADDED
@@ -0,0 +1,220 @@
1
+ // forge dash — the local dashboard (docs/plans/substrate-v2/08-dashboard-ux.md, P7):
2
+ // a read-mostly lens on the .forge/ stores. DATA (dashData → one JSON payload) is
3
+ // separated from SERVING (serve → a node:http stdlib server) so the payload is
4
+ // testable without sockets. One self-contained HTML page (src/dash.html — inline
5
+ // CSS/JS, no CDN, no framework, no build step), localhost-only by default, zero
6
+ // runtime deps. Exactly the two writes the spec names (§2) exist — POST /api/ratify
7
+ // (human-only ḥikma promotion, mints a decision claim) and POST /api/retract
8
+ // (tombstone with a reason). Both are append-only, so the dashboard can never
9
+ // corrupt the ledger; everything else stays read-only.
10
+ import { readFileSync } from "node:fs";
11
+ import { createServer } from "node:http";
12
+ import { basename, dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { impact, load as loadAtlas } from "./atlas.js";
15
+ import { estimateSpendFromLogs } from "./cost_report.js";
16
+ import { authorTrust, claimText, val, validOutcome } from "./ledger.js";
17
+ import {
18
+ getClaimByPrefix,
19
+ loadClaims,
20
+ ratify,
21
+ repoLedger,
22
+ stats,
23
+ tombstone,
24
+ } from "./ledger_store.js";
25
+ import { read as readMetrics, summarize } from "./metrics.js";
26
+ import { epochDay, gitAuthor } from "./util.js";
27
+
28
+ /** A claim is contested when its val sits in this band AND it carries ≥1 oracle
29
+ * contradiction — genuinely disputed, not merely fresh (a fresh claim is 0.5 with
30
+ * no evidence at all). Spec: 08-dashboard-ux.md §1, Ledger panel. */
31
+ export const CONTESTED_BAND = [0.4, 0.6];
32
+
33
+ /** Payload caps — the dashboard is a lens, not an export format. */
34
+ const CLAIM_CAP = 200;
35
+ const RECENT_CAP = 20;
36
+
37
+ const emptyLedger = () => ({
38
+ stats: { total: 0, tombstoned: 0, byKind: {}, val: { dormant: 0, uncertain: 0, trusted: 0 } },
39
+ claims: [],
40
+ contested: [],
41
+ trust: {},
42
+ });
43
+
44
+ /** The row shape every ledger table in the UI renders — id8 is the handle the
45
+ * provenance affordance (`forge ledger blame <id8>`) is built from. */
46
+ const claimRow = (c, nowDay) => ({
47
+ id8: c.id.slice(0, 8),
48
+ kind: c.kind,
49
+ val: Number(val(c, nowDay).toFixed(3)),
50
+ evidenceCount: (c.evidence ?? []).filter(validOutcome).length,
51
+ author: c.provenance?.author ?? "",
52
+ tombstoned: Boolean(c.tombstone),
53
+ text: claimText(c).slice(0, 140),
54
+ });
55
+
56
+ function ledgerSection(root, nowDay) {
57
+ const dir = repoLedger(root);
58
+ const all = loadClaims(dir);
59
+ const claims = all
60
+ .map((c) => claimRow(c, nowDay))
61
+ .sort((a, b) => b.val - a.val || (a.id8 < b.id8 ? -1 : 1))
62
+ .slice(0, CLAIM_CAP);
63
+ const contested = all
64
+ .filter((c) => {
65
+ if (c.tombstone) return false;
66
+ const v = val(c, nowDay);
67
+ if (v < CONTESTED_BAND[0] || v > CONTESTED_BAND[1]) return false;
68
+ return (c.evidence ?? []).some((e) => validOutcome(e) && e.result === "contradict");
69
+ })
70
+ .map((c) => claimRow(c, nowDay));
71
+ return { stats: stats(dir, nowDay), claims, contested, trust: authorTrust(all) };
72
+ }
73
+
74
+ function metricsSection(root) {
75
+ const recent = readMetrics(root)
76
+ .slice(-RECENT_CAP)
77
+ .map((e) => ({
78
+ t: e.t ?? 0,
79
+ stage: e.stage ?? "",
80
+ outcome: e.outcome ?? "",
81
+ savedEstimate: Number.isFinite(e.savedEstimate) ? e.savedEstimate : 0,
82
+ }));
83
+ return { stages: summarize(root), recent };
84
+ }
85
+
86
+ /**
87
+ * One JSON payload for the whole page — everything /api/data serves. Corrupt or
88
+ * missing stores degrade to empty sections; this NEVER throws (a broken .forge/
89
+ * must not take down the lens that would let you see it's broken).
90
+ * @param {string} root
91
+ * @param {{nowDay?: number}} [opts]
92
+ */
93
+ export function dashData(root, { nowDay = epochDay() } = {}) {
94
+ let ledger = emptyLedger();
95
+ try {
96
+ ledger = ledgerSection(root, nowDay);
97
+ } catch {}
98
+ let metrics = { stages: {}, recent: [] };
99
+ try {
100
+ metrics = metricsSection(root);
101
+ } catch {}
102
+ let atlas = { built: false, symbols: 0, files: 0 };
103
+ try {
104
+ const a = loadAtlas(root);
105
+ if (a) atlas = { built: true, symbols: a.symbols?.length ?? 0, files: a.files ?? 0 };
106
+ } catch {}
107
+ let spend = null;
108
+ try {
109
+ spend = estimateSpendFromLogs();
110
+ } catch {}
111
+ return { repo: basename(root), nowDay, ledger, metrics, atlas, spend };
112
+ }
113
+
114
+ const HTML_PATH = join(dirname(fileURLToPath(import.meta.url)), "dash.html");
115
+
116
+ const sendJson = (res, code, body) => {
117
+ res.writeHead(code, {
118
+ "content-type": "application/json; charset=utf-8",
119
+ "cache-control": "no-store",
120
+ });
121
+ res.end(JSON.stringify(body));
122
+ };
123
+
124
+ /** Small JSON body reader for the two POSTs — capped so a runaway client can't buffer
125
+ * unbounded bytes into a localhost convenience server. */
126
+ const BODY_CAP = 64 * 1024;
127
+ const readBody = (req) =>
128
+ new Promise((resolve, reject) => {
129
+ let body = "";
130
+ req.setEncoding("utf8");
131
+ req.on("data", (chunk) => {
132
+ body += chunk;
133
+ if (body.length > BODY_CAP) {
134
+ reject(new Error("body too large"));
135
+ req.destroy();
136
+ }
137
+ });
138
+ req.on("end", () => resolve(body));
139
+ req.on("error", reject);
140
+ });
141
+
142
+ /** The two writes (08-dashboard-ux.md §2), both append-only: ratify mints a decision
143
+ * claim with the HUMAN as author (gitAuthor — nothing auto-ratifies), retract appends
144
+ * a tombstone record with a reason. Body: {id: <prefix ≥2 chars>, reason?}. */
145
+ async function handleWrite(root, pathname, req, res) {
146
+ let body;
147
+ try {
148
+ body = JSON.parse(await readBody(req));
149
+ } catch {
150
+ return sendJson(res, 400, { error: "bad JSON body" });
151
+ }
152
+ const id = typeof body?.id === "string" ? body.id.trim() : "";
153
+ if (id.length < 2)
154
+ return sendJson(res, 400, { error: 'body must be {"id": "<claim id-prefix (≥2 chars)>"}' });
155
+ const dir = repoLedger(root);
156
+ const author = gitAuthor();
157
+ const t = epochDay();
158
+ if (pathname === "/api/ratify") {
159
+ const r = ratify(dir, id, { author, t });
160
+ return sendJson(res, r.ok ? 200 : 404, r);
161
+ }
162
+ const hit = getClaimByPrefix(dir, id);
163
+ if (!hit) return sendJson(res, 404, { ok: false, reason: `no claim matching ${id}` });
164
+ const reason = typeof body.reason === "string" ? body.reason : "";
165
+ const r = tombstone(dir, hit.id, { author, reason, t });
166
+ return sendJson(res, r.ok ? 200 : 400, { ...r, id: hit.id });
167
+ }
168
+
169
+ const WRITE_ROUTES = new Set(["/api/ratify", "/api/retract"]);
170
+
171
+ /**
172
+ * The dashboard server: GET / → the page, GET /api/data → dashData, GET
173
+ * /api/impact?target=X → blast radius (when an atlas exists), POST /api/ratify and
174
+ * POST /api/retract → the spec's two append-only writes. Everything else 404.
175
+ * Localhost-only by default — pass a host explicitly to expose it, on your own head.
176
+ * @param {string} root
177
+ * @param {{port?: number, host?: string}} [opts]
178
+ * @returns {import("node:http").Server}
179
+ */
180
+ export function serve(root, { port = 4242, host = "127.0.0.1" } = {}) {
181
+ const html = readFileSync(HTML_PATH, "utf8"); // read once at startup, self-contained
182
+ const server = createServer((req, res) => {
183
+ const url = new URL(req.url ?? "/", "http://localhost");
184
+ if (req.method === "POST" && WRITE_ROUTES.has(url.pathname)) {
185
+ handleWrite(root, url.pathname, req, res).catch(() =>
186
+ sendJson(res, 400, { error: "bad request body" }),
187
+ );
188
+ return;
189
+ }
190
+ if (req.method !== "GET")
191
+ return sendJson(res, 404, {
192
+ error: "GET only — the two writes are POST /api/ratify and POST /api/retract",
193
+ });
194
+ if (url.pathname === "/") {
195
+ res.writeHead(200, {
196
+ "content-type": "text/html; charset=utf-8",
197
+ "cache-control": "no-store",
198
+ });
199
+ return res.end(html);
200
+ }
201
+ if (url.pathname === "/api/data") return sendJson(res, 200, dashData(root));
202
+ if (url.pathname === "/api/spend") {
203
+ const spend = estimateSpendFromLogs();
204
+ return sendJson(res, 200, spend || { totalCost: 0, sessions: 0, byModel: [] });
205
+ }
206
+ if (url.pathname === "/api/impact") {
207
+ const target = url.searchParams.get("target");
208
+ if (!target) return sendJson(res, 400, { error: "usage: /api/impact?target=<symbol|file>" });
209
+ let atlas = null;
210
+ try {
211
+ atlas = loadAtlas(root);
212
+ } catch {}
213
+ if (!atlas) return sendJson(res, 404, { error: "no atlas — run `forge atlas build` first" });
214
+ return sendJson(res, 200, impact(atlas, target));
215
+ }
216
+ return sendJson(res, 404, { error: "not found" });
217
+ });
218
+ server.listen(port, host);
219
+ return server;
220
+ }
Binary file
package/src/doctor.js ADDED
@@ -0,0 +1,315 @@
1
+ // forge doctor — turn silent misconfiguration into an actionable pass/fail list
2
+ // (chezmoi-doctor pattern). Exits non-zero only on hard failures, not warnings.
3
+ import { accessSync, constants, existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { isStale, load as loadAtlas } from "./atlas.js";
7
+ import { BRAND } from "./brand.js";
8
+ import { summary as cortexSummary } from "./cortex.js";
9
+ import { extractHash, hashContent } from "./emit/_shared.js";
10
+ import { verify as ledgerVerify, repoLedger } from "./ledger_store.js";
11
+ import { PRICING_VERIFIED } from "./model_tiers.js";
12
+ import { activeProvider } from "./providers.js";
13
+ import { canonical } from "./sync.js";
14
+
15
+ const ok = (label, note = "") => ({ status: "ok", label, note });
16
+ const warn = (label, note = "") => ({ status: "warn", label, note });
17
+ const fail = (label, note = "") => ({ status: "fail", label, note });
18
+
19
+ import { hasBin } from "./util.js";
20
+
21
+ const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
22
+
23
+ // External tools the guards/commands depend on. jq is the important one — several guards
24
+ // (secret-redact, protect-paths) degrade to a naive parse or a no-op without it.
25
+ function checkTooling(out) {
26
+ out.push(
27
+ hasBin("jq")
28
+ ? ok("jq", "found — guards parse hook JSON safely")
29
+ : warn("jq", "not found — secret-redact/protect-paths degrade without it; install jq"),
30
+ );
31
+ out.push(
32
+ hasBin("git") ? ok("git", "found") : warn("git", "not found — churn/impact/anchor need it"),
33
+ );
34
+ }
35
+
36
+ // Every guard the manifests reference must exist and be executable, or a hook silently no-ops.
37
+ function checkGuardsExecutable(out) {
38
+ const dir = join(BRAND.root, "global", "guards");
39
+ if (!existsSync(dir)) return; // absence is already reported by checkLayers
40
+ const scripts = readdirSync(dir).filter((f) => f.endsWith(".sh"));
41
+ const notExec = scripts.filter((f) => {
42
+ try {
43
+ accessSync(join(dir, f), constants.X_OK);
44
+ return false;
45
+ } catch {
46
+ return true;
47
+ }
48
+ });
49
+ out.push(
50
+ notExec.length
51
+ ? warn(
52
+ "guards exec",
53
+ `${notExec.length} not executable (chmod +x): ${notExec.slice(0, 3).join(", ")}`,
54
+ )
55
+ : ok("guards exec", `${scripts.length} guard(s) executable`),
56
+ );
57
+ }
58
+
59
+ // Model prices drift; a stale table quietly misinforms the cost/route commands.
60
+ function checkPricing(out) {
61
+ const days = Math.floor((Date.now() - Date.parse(`${PRICING_VERIFIED}T00:00:00Z`)) / 86400000);
62
+ out.push(
63
+ Number.isFinite(days) && days > 90
64
+ ? warn(
65
+ "model pricing",
66
+ `verified ${PRICING_VERIFIED} (${days}d ago) — re-verify via dev-radar`,
67
+ )
68
+ : ok("model pricing", `verified ${PRICING_VERIFIED}`),
69
+ );
70
+ }
71
+
72
+ // The atlas backs impact/verify. A missing or STALE graph gives wrong blast-radius / hallucination
73
+ // results silently — surface it so the user rebuilds.
74
+ function checkAtlas(out, targetRoot) {
75
+ const atlas = loadAtlas(targetRoot);
76
+ if (!atlas) {
77
+ out.push(ok("atlas", "not built — run `forge atlas build` for impact/verify"));
78
+ return;
79
+ }
80
+ out.push(
81
+ isStale(targetRoot, atlas)
82
+ ? warn("atlas", "stale (files changed since build) — run `forge atlas build`")
83
+ : ok("atlas", `${atlas.symbols?.length ?? 0} symbols, fresh`),
84
+ );
85
+ }
86
+
87
+ function checkNode(out) {
88
+ const major = Number(process.versions.node.split(".")[0]);
89
+ out.push(
90
+ major >= 18
91
+ ? ok("node", `v${process.versions.node}`)
92
+ : fail("node", `v${process.versions.node} < 18`),
93
+ );
94
+ }
95
+
96
+ function checkBrandConsistency(out) {
97
+ try {
98
+ const plugin = readJson(join(BRAND.root, ".claude-plugin/plugin.json"));
99
+ out.push(
100
+ plugin.name === BRAND.pkg
101
+ ? ok("brand↔plugin", `${plugin.name} v${plugin.version}`)
102
+ : warn("brand↔plugin", `plugin.json name "${plugin.name}" != brand pkg "${BRAND.pkg}"`),
103
+ );
104
+ } catch {
105
+ out.push(warn("brand↔plugin", "plugin.json missing or invalid"));
106
+ }
107
+ }
108
+
109
+ function checkLayers(out) {
110
+ for (const layer of ["tools", "crew", "guards"]) {
111
+ const dir = join(BRAND.root, "global", layer);
112
+ if (!existsSync(dir)) {
113
+ out.push(fail(layer, "missing"));
114
+ continue;
115
+ }
116
+ out.push(ok(layer, `${readdirSync(dir).length} item(s)`));
117
+ }
118
+ }
119
+
120
+ function commandScriptFromPluginRoot(command) {
121
+ const marker = '"$' + '{CLAUDE_PLUGIN_ROOT}"/';
122
+ const i = command.indexOf(marker);
123
+ if (i === -1) return null;
124
+ const rest = command.slice(i + marker.length);
125
+ const script = rest.split(/\s+/)[0]?.replace(/^['"]|['"]$/g, "");
126
+ return script || null;
127
+ }
128
+
129
+ // Plugin/hook compatibility: Forge should be additive and self-contained. Claude Code
130
+ // composes plugin hook arrays, so the main risk is a stale manifest path or a hook command
131
+ // that references a missing/non-executable guard and silently degrades beside other plugins.
132
+ function checkPluginCompatibility(out) {
133
+ try {
134
+ const plugin = readJson(join(BRAND.root, ".claude-plugin", "plugin.json"));
135
+ const hookRel = plugin.hooks;
136
+ const hookPath = hookRel ? join(BRAND.root, hookRel) : "";
137
+ if (!hookRel || !existsSync(hookPath)) {
138
+ out.push(warn("Claude plugin hooks", "manifest hooks path missing or invalid"));
139
+ } else {
140
+ const manifest = readJson(hookPath);
141
+ const hooks = manifest.hooks && typeof manifest.hooks === "object" ? manifest.hooks : {};
142
+ const commands = Object.values(hooks)
143
+ .flatMap((entries) => (Array.isArray(entries) ? entries : []))
144
+ .flatMap((entry) => (Array.isArray(entry.hooks) ? entry.hooks : []))
145
+ .map((h) => h.command)
146
+ .filter(Boolean);
147
+ const missing = [];
148
+ const notExec = [];
149
+ for (const command of commands) {
150
+ const rel = commandScriptFromPluginRoot(command);
151
+ if (!rel) continue;
152
+ const abs = join(BRAND.root, rel);
153
+ if (!existsSync(abs)) {
154
+ missing.push(rel);
155
+ continue;
156
+ }
157
+ try {
158
+ accessSync(abs, constants.X_OK);
159
+ } catch {
160
+ notExec.push(rel);
161
+ }
162
+ }
163
+ if (missing.length || notExec.length) {
164
+ out.push(
165
+ warn(
166
+ "Claude plugin hooks",
167
+ `${missing.length} missing, ${notExec.length} not executable — other plugins may still load but Forge hooks degrade`,
168
+ ),
169
+ );
170
+ } else {
171
+ out.push(
172
+ ok(
173
+ "Claude plugin hooks",
174
+ `${commands.length} additive hook command(s), all local/executable`,
175
+ ),
176
+ );
177
+ }
178
+ }
179
+ } catch {
180
+ out.push(warn("Claude plugin hooks", "plugin or hooks manifest missing/invalid"));
181
+ }
182
+
183
+ try {
184
+ const codex = readJson(join(BRAND.root, ".codex-plugin", "plugin.json"));
185
+ const skillsPath = codex.skills ? join(BRAND.root, codex.skills) : "";
186
+ const mcpPath = codex.mcpServers ? join(BRAND.root, codex.mcpServers) : "";
187
+ const issues = [];
188
+ if (!codex.name) issues.push("missing name");
189
+ if (!skillsPath || !existsSync(skillsPath)) issues.push("skills path missing");
190
+ if (!mcpPath || !existsSync(mcpPath)) issues.push("mcpServers path missing");
191
+ out.push(
192
+ issues.length
193
+ ? warn(
194
+ "Codex plugin",
195
+ `${issues.join("; ")} — plugin may not install cleanly beside others`,
196
+ )
197
+ : ok("Codex plugin", "manifest paths resolve; no repo-level hook takeover"),
198
+ );
199
+ } catch {
200
+ out.push(warn("Codex plugin", "plugin manifest missing/invalid"));
201
+ }
202
+ }
203
+
204
+ function checkInstall(out) {
205
+ const forgeHome = join(homedir(), ".forge");
206
+ out.push(
207
+ existsSync(forgeHome)
208
+ ? ok("~/.forge", "linked")
209
+ : warn("~/.forge", "not installed — run install.sh or the plugin"),
210
+ );
211
+ }
212
+
213
+ function checkDrift(out, targetRoot) {
214
+ const agents = join(targetRoot, "AGENTS.md");
215
+ if (!existsSync(agents)) {
216
+ out.push(warn("AGENTS.md", "not emitted here — run `forge sync`"));
217
+ return;
218
+ }
219
+ const current = hashContent(canonical(targetRoot));
220
+ const onDisk = extractHash(readFileSync(agents, "utf8"));
221
+ out.push(
222
+ current === onDisk ? ok("AGENTS.md", "in sync") : warn("AGENTS.md", "stale — run `forge sync`"),
223
+ );
224
+ }
225
+
226
+ // MCP hygiene: past ~6 servers, tool-selection accuracy drops and the context bloats.
227
+ function checkMcp(out, targetRoot) {
228
+ const path = join(targetRoot, ".mcp.json");
229
+ if (!existsSync(path)) return;
230
+ let servers = {};
231
+ try {
232
+ servers = JSON.parse(readFileSync(path, "utf8")).mcpServers || {};
233
+ } catch {
234
+ return;
235
+ }
236
+ const n = Object.keys(servers).length;
237
+ out.push(
238
+ n > 6
239
+ ? warn(
240
+ "MCP servers",
241
+ `${n} in .mcp.json — over ~6; tool-selection accuracy drops, trim or defer`,
242
+ )
243
+ : ok("MCP servers", `${n} in .mcp.json`),
244
+ );
245
+ }
246
+
247
+ // Cortex: report the self-correcting memory's state for this repo (always informational).
248
+ function checkCortex(out, targetRoot) {
249
+ const s = cortexSummary(targetRoot, Math.floor(Date.now() / 86400000));
250
+ out.push(
251
+ s.total === 0
252
+ ? ok("cortex", "no lessons yet — learns from corrections as you work")
253
+ : ok(
254
+ "cortex",
255
+ `${s.active} active · ${s.candidate} candidate · ${s.quarantined} quarantined · ${s.retired} retired`,
256
+ ),
257
+ );
258
+ }
259
+
260
+ // PCM ledger: a populated ledger with no union-merge driver WILL conflict the first
261
+ // time two teammates append to the same evidence log — the exact failure the ledger's
262
+ // design promises away. Also surface normal-form issues (forged/corrupt records).
263
+ function checkLedger(out, targetRoot) {
264
+ const dir = repoLedger(targetRoot);
265
+ if (!existsSync(join(dir, "claims"))) {
266
+ out.push(ok("ledger", "empty — claims appear as cortex/recall learn (`forge ledger`)"));
267
+ return;
268
+ }
269
+ const attrs = join(targetRoot, ".gitattributes");
270
+ const hasRule = existsSync(attrs) && readFileSync(attrs, "utf8").includes(".forge/ledger/");
271
+ out.push(
272
+ hasRule
273
+ ? ok("ledger merge", "union-merge driver present in .gitattributes")
274
+ : warn(
275
+ "ledger merge",
276
+ "no union-merge rule — run `forge init` or teammate merges will conflict",
277
+ ),
278
+ );
279
+ const v = ledgerVerify(dir);
280
+ out.push(
281
+ v.ok
282
+ ? ok("ledger", `${v.claims} claim(s), ${v.outcomes} outcome(s) — normal form`)
283
+ : warn("ledger", `${v.issues.length} issue(s) — run \`forge ledger verify\` to list them`),
284
+ );
285
+ }
286
+
287
+ function checkProvider(out, targetRoot) {
288
+ const prov = activeProvider(targetRoot);
289
+ if (prov._autoDetected) {
290
+ out.push(ok("provider", `${prov.name} (auto-detected from ${prov._source})`));
291
+ } else if (prov.envKey && !process.env[prov.envKey]) {
292
+ out.push(warn("provider", `${prov.name} — ${prov.envKey} is NOT set`));
293
+ } else {
294
+ out.push(ok("provider", `${prov.name} (configured)`));
295
+ }
296
+ }
297
+
298
+ export function doctor({ targetRoot = process.cwd() } = {}) {
299
+ const results = [];
300
+ checkNode(results);
301
+ checkProvider(results, targetRoot);
302
+ checkBrandConsistency(results);
303
+ checkLayers(results);
304
+ checkGuardsExecutable(results);
305
+ checkPluginCompatibility(results);
306
+ checkTooling(results);
307
+ checkInstall(results);
308
+ checkDrift(results, targetRoot);
309
+ checkAtlas(results, targetRoot);
310
+ checkPricing(results);
311
+ checkMcp(results, targetRoot);
312
+ checkCortex(results, targetRoot);
313
+ checkLedger(results, targetRoot);
314
+ return { results, failed: results.filter((r) => r.status === "fail").length };
315
+ }