@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/substrate.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// forge substrate — one pre-action surface for the cognitive substrate described in
|
|
2
|
+
// the paper: gate assumptions, route model effort, inspect scope/impact, surface memory,
|
|
3
|
+
// and produce an external verification checklist. Deterministic where possible;
|
|
4
|
+
// advisory where the paper marks the research edge.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { buildRunner, llmEnabled } from "./adjudicate.js";
|
|
9
|
+
import { goalDrift } from "./anchor.js";
|
|
10
|
+
import { build as buildAtlas, impact as impactGraph, load as loadAtlas } from "./atlas.js";
|
|
11
|
+
import { assemble as assembleContext } from "./context.js";
|
|
12
|
+
import { matchingLessons } from "./cortex.js";
|
|
13
|
+
import { recordGate } from "./cost_report.js";
|
|
14
|
+
import { leanRepo } from "./lean.js";
|
|
15
|
+
import { mergedLessons } from "./ledger_read.js";
|
|
16
|
+
import { clarifyBlock, preflightRepo, referencedEntities } from "./preflight.js";
|
|
17
|
+
import { reusePeek, reuseQuery } from "./reuse.js";
|
|
18
|
+
import { meterRoute, routeTask } from "./route.js";
|
|
19
|
+
import { decompose } from "./scope.js";
|
|
20
|
+
import { epochDay } from "./util.js";
|
|
21
|
+
|
|
22
|
+
function loadSubstrateSpec() {
|
|
23
|
+
const path = join(dirname(dirname(fileURLToPath(import.meta.url))), "source", "substrate.json");
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function verificationChecklist(root) {
|
|
32
|
+
const checks = [];
|
|
33
|
+
if (existsSync(join(root, "package.json"))) {
|
|
34
|
+
checks.push("npm test");
|
|
35
|
+
checks.push("npm run typecheck");
|
|
36
|
+
checks.push("npm run lint");
|
|
37
|
+
}
|
|
38
|
+
if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "pytest.ini")))
|
|
39
|
+
checks.push("pytest -q");
|
|
40
|
+
checks.push("review impacted files before editing");
|
|
41
|
+
checks.push("run the narrowest affected test first, then the broader suite");
|
|
42
|
+
return [...new Set(checks)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function minimalityWarnings(task, route, preflight) {
|
|
46
|
+
const warnings = [];
|
|
47
|
+
const text = String(task).toLowerCase();
|
|
48
|
+
if (
|
|
49
|
+
/\b(refactor|rewrite|clean|redesign|optimi[sz]e|improve)\b/.test(text) &&
|
|
50
|
+
preflight.entities.files.length === 0
|
|
51
|
+
) {
|
|
52
|
+
warnings.push(
|
|
53
|
+
"High-risk broad change with no target files named; ask for scope before editing.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (route.score >= 0.55 && preflight.assumption.completeness < 0.7) {
|
|
57
|
+
warnings.push(
|
|
58
|
+
"Complex task with medium/low specification completeness; clarify before spending a premium model.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
/\b(add authentication|payment|migration|distributed|concurrent|production)\b/.test(text) &&
|
|
63
|
+
!/\btest|acceptance|rollback|constraint|must|should\b/.test(text)
|
|
64
|
+
) {
|
|
65
|
+
warnings.push("Production-sensitive task lacks explicit constraints or acceptance criteria.");
|
|
66
|
+
}
|
|
67
|
+
return warnings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const TEST_FILE_RE =
|
|
71
|
+
/(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[jt]sx?$|_test\.(py|go|rs)$|(^|\/)test_[^/]+\.py$/i;
|
|
72
|
+
|
|
73
|
+
export const isTestFile = (f) => TEST_FILE_RE.test(String(f));
|
|
74
|
+
|
|
75
|
+
// Candidate sibling-test paths for a source file: foo.js → foo.test.js / foo.spec.js /
|
|
76
|
+
// __tests__/foo.js / test(s)/foo.js, and foo.py → test_foo.py / tests/test_foo.py.
|
|
77
|
+
function siblingTestCandidates(file) {
|
|
78
|
+
const s = String(file);
|
|
79
|
+
const slash = s.lastIndexOf("/");
|
|
80
|
+
const dir = slash >= 0 ? s.slice(0, slash + 1) : "";
|
|
81
|
+
const nameExt = s.slice(slash + 1);
|
|
82
|
+
const dot = nameExt.lastIndexOf(".");
|
|
83
|
+
const base = dot > 0 ? nameExt.slice(0, dot) : nameExt;
|
|
84
|
+
const ext = dot > 0 ? nameExt.slice(dot) : "";
|
|
85
|
+
if (ext === ".py")
|
|
86
|
+
return [`${dir}test_${base}.py`, `${dir}tests/test_${base}.py`, `tests/test_${base}.py`];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const suf of [".test", ".spec"]) out.push(`${dir}${base}${suf}${ext}`);
|
|
89
|
+
for (const d of ["__tests__/", "test/", "tests/"])
|
|
90
|
+
out.push(`${dir}${d}${base}${ext}`, `${d}${base}${ext}`);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Predict the tests likely to fail if the impacted files change (impacted tests + siblings). */
|
|
95
|
+
export function predictFailingTests(root, impactedFiles) {
|
|
96
|
+
const out = new Set();
|
|
97
|
+
for (const f of impactedFiles) {
|
|
98
|
+
if (isTestFile(f)) {
|
|
99
|
+
out.add(f);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const c of siblingTestCandidates(f)) if (existsSync(join(root, c))) out.add(c);
|
|
103
|
+
}
|
|
104
|
+
return [...out].sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Grep-style verify for the LLM impact pass: a proposed dependent is only kept if the target
|
|
108
|
+
// symbol/file name actually appears in the candidate file's source. External check, not trust.
|
|
109
|
+
function makeImpactVerify(root) {
|
|
110
|
+
const base = (t) =>
|
|
111
|
+
String(t)
|
|
112
|
+
.split(/[/\\]/)
|
|
113
|
+
.pop()
|
|
114
|
+
.replace(/\.[^.]+$/, "");
|
|
115
|
+
return (file, target) => {
|
|
116
|
+
try {
|
|
117
|
+
const src = readFileSync(join(root, file), "utf8");
|
|
118
|
+
const name = base(target);
|
|
119
|
+
return name.length > 1 && new RegExp(`\\b${name.replace(/[^\w$]/g, "")}\\b`).test(src);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} root
|
|
128
|
+
* @param {string} target
|
|
129
|
+
* @param {object} [opts]
|
|
130
|
+
* @param {number} [opts.threshold]
|
|
131
|
+
* @param {boolean} [opts.llm]
|
|
132
|
+
* @param {string} [opts.model]
|
|
133
|
+
* @param {number} [opts.timeoutMs]
|
|
134
|
+
*/
|
|
135
|
+
export function predictImpact(root, target, { threshold = 0.1, llm, model, timeoutMs } = {}) {
|
|
136
|
+
const atlas = loadAtlas(root) || buildAtlas({ root });
|
|
137
|
+
const useLLM = llmEnabled({ llm });
|
|
138
|
+
return impactGraph(atlas, target, {
|
|
139
|
+
threshold,
|
|
140
|
+
llm: useLLM,
|
|
141
|
+
run: useLLM ? buildRunner({ model, timeoutMs }) : undefined,
|
|
142
|
+
verify: makeImpactVerify(root),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} root
|
|
148
|
+
* @param {string} task
|
|
149
|
+
* @param {object} [opts]
|
|
150
|
+
* @param {number} [opts.threshold]
|
|
151
|
+
* @param {number} [opts.askThreshold]
|
|
152
|
+
* @param {boolean} [opts.allowBuild]
|
|
153
|
+
* @param {boolean} [opts.llm]
|
|
154
|
+
* @param {string} [opts.model]
|
|
155
|
+
* @param {number} [opts.timeoutMs]
|
|
156
|
+
* @param {boolean} [opts.bidirectional]
|
|
157
|
+
*/
|
|
158
|
+
export function substrateCheck(
|
|
159
|
+
root,
|
|
160
|
+
task,
|
|
161
|
+
{
|
|
162
|
+
threshold = 0.1,
|
|
163
|
+
askThreshold = 0.6,
|
|
164
|
+
allowBuild = true,
|
|
165
|
+
llm,
|
|
166
|
+
model,
|
|
167
|
+
timeoutMs,
|
|
168
|
+
bidirectional,
|
|
169
|
+
} = {},
|
|
170
|
+
) {
|
|
171
|
+
const text = String(task || "");
|
|
172
|
+
const spec = loadSubstrateSpec();
|
|
173
|
+
// LLM adjudication is opt-in. On the ambient hook path (allowBuild:false) it stays OFF unless
|
|
174
|
+
// FORGE_LLM_AMBIENT=1, so the per-prompt hook never pays model latency by default. An explicit
|
|
175
|
+
// `llm` option always wins. Every faculty is fail-safe: a null proposal keeps the rubric.
|
|
176
|
+
const useLLM =
|
|
177
|
+
typeof llm === "boolean"
|
|
178
|
+
? llm
|
|
179
|
+
: allowBuild
|
|
180
|
+
? llmEnabled()
|
|
181
|
+
: process.env.FORGE_LLM_AMBIENT === "1";
|
|
182
|
+
// Bidirectional (clear-a-false-ask / route-down, within rails) follows the JSON default unless
|
|
183
|
+
// the caller overrides it. The numeric bands/floor come from the same config block.
|
|
184
|
+
const bi =
|
|
185
|
+
typeof bidirectional === "boolean" ? bidirectional : (spec?.llm?.bidirectional ?? true);
|
|
186
|
+
const llmOpts = {
|
|
187
|
+
llm: useLLM,
|
|
188
|
+
model,
|
|
189
|
+
timeoutMs,
|
|
190
|
+
bidirectional: bi,
|
|
191
|
+
band: spec?.llm?.band,
|
|
192
|
+
routingBand: spec?.llm?.routingBand,
|
|
193
|
+
signalFloor: spec?.llm?.signalFloor,
|
|
194
|
+
};
|
|
195
|
+
const entities = referencedEntities(text);
|
|
196
|
+
const preflight = preflightRepo(root, text, { askThreshold, allowBuild, ...llmOpts });
|
|
197
|
+
// P8 gate metering: one metrics line per explicit gate decision (halt = spend avoided).
|
|
198
|
+
// Same write contract as reuseQuery vs reusePeek below — the ambient hook path
|
|
199
|
+
// (allowBuild:false) never appends. Best-effort: measurement must never block the gate.
|
|
200
|
+
if (allowBuild) {
|
|
201
|
+
try {
|
|
202
|
+
recordGate(root, { halted: preflight.assumption.shouldAsk });
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
// Reuse the gap preflight already computed — routeTask would otherwise recompute it (and, with
|
|
206
|
+
// FORGE_LLM on, fire a second, redundant assumption model call whose result it discards).
|
|
207
|
+
const route = routeTask(root, text, { ...llmOpts, ambiguity: preflight.gap });
|
|
208
|
+
// P8 route metering, same write contract as recordGate above: the explicit gate
|
|
209
|
+
// meters, the ambient hook path (allowBuild:false) never appends. meterRoute is
|
|
210
|
+
// itself best-effort (try/catch inside), so measurement can never block routing.
|
|
211
|
+
if (allowBuild) meterRoute(root, text, route);
|
|
212
|
+
// allowBuild:false (ambient hooks) uses the atlas only if one is already cached — never
|
|
213
|
+
// builds or writes .forge/atlas.json from a hook. Impact is then best-effort.
|
|
214
|
+
const atlas = loadAtlas(root) || (allowBuild ? buildAtlas({ root }) : null);
|
|
215
|
+
const impactTargets = [...new Set([...entities.symbols, ...entities.files])].slice(0, 8);
|
|
216
|
+
const impactRun = useLLM ? buildRunner({ model, timeoutMs }) : undefined;
|
|
217
|
+
const impactVerify = makeImpactVerify(root);
|
|
218
|
+
const impacts = atlas
|
|
219
|
+
? impactTargets.map((target) =>
|
|
220
|
+
impactGraph(atlas, target, {
|
|
221
|
+
threshold,
|
|
222
|
+
llm: useLLM,
|
|
223
|
+
run: impactRun,
|
|
224
|
+
verify: impactVerify,
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
: [];
|
|
228
|
+
const impactedFiles = [...new Set(impacts.flatMap((r) => r.impactedFiles || []))].sort();
|
|
229
|
+
// Consequence simulation (Eq 4), class "failing tests": which tests likely break if the
|
|
230
|
+
// impacted files change — the impacted files that ARE tests, plus each impacted source file's
|
|
231
|
+
// sibling test. Cheap, exact-ish, and surfaced BEFORE the edit (not after, like verify).
|
|
232
|
+
const predictedTests = predictFailingTests(root, impactedFiles);
|
|
233
|
+
// P3 reuse stage: has this team already built (and verified) this? The explicit gate
|
|
234
|
+
// meters + writes evidence (reuseQuery); the ambient hook path stays read-only
|
|
235
|
+
// (reusePeek) so a per-prompt hook never appends to the ledger or metrics.
|
|
236
|
+
const reuse = (() => {
|
|
237
|
+
try {
|
|
238
|
+
const opts = { atlas, nowDay: epochDay() };
|
|
239
|
+
const r = allowBuild ? reuseQuery(root, text, opts) : reusePeek(root, text, opts);
|
|
240
|
+
return {
|
|
241
|
+
tier: r.tier,
|
|
242
|
+
artifact: r.artifact
|
|
243
|
+
? { id: r.artifact.id, path: r.artifact.body.code?.path, form: r.artifact.body.form }
|
|
244
|
+
: undefined,
|
|
245
|
+
jaccard: r.jaccard,
|
|
246
|
+
};
|
|
247
|
+
} catch {
|
|
248
|
+
return { tier: "miss" }; // cache trouble must never block the gate
|
|
249
|
+
}
|
|
250
|
+
})();
|
|
251
|
+
// P4 context assembly: what the edit REQUIRES to be known (defs, dependents, tests,
|
|
252
|
+
// trusted lessons) vs what can be supplied — missing becomes derived questions, not
|
|
253
|
+
// assumptions. Explicit gate only (file reads are too heavy for the per-prompt hook).
|
|
254
|
+
const context = allowBuild
|
|
255
|
+
? (() => {
|
|
256
|
+
try {
|
|
257
|
+
return assembleContext(root, text, { atlas, nowDay: epochDay() });
|
|
258
|
+
} catch {
|
|
259
|
+
return null; // assembly trouble must never block the gate
|
|
260
|
+
}
|
|
261
|
+
})()
|
|
262
|
+
: null;
|
|
263
|
+
const scopedFiles = [...new Set([...entities.files, ...impactedFiles])];
|
|
264
|
+
const scope = scopedFiles.length
|
|
265
|
+
? decompose(root, scopedFiles)
|
|
266
|
+
: { clusters: [], independentGroups: 0 };
|
|
267
|
+
// Merged view (P2 read flip): a teammate's merged lesson counts in the advisory too.
|
|
268
|
+
const lessons = matchingLessons(mergedLessons(root, epochDay()), {
|
|
269
|
+
files: scopedFiles,
|
|
270
|
+
symbols: entities.symbols,
|
|
271
|
+
});
|
|
272
|
+
const result = {
|
|
273
|
+
okToProceed: !preflight.assumption.shouldAsk,
|
|
274
|
+
task: text,
|
|
275
|
+
assumption: preflight.assumption,
|
|
276
|
+
clarify: clarifyBlock(preflight),
|
|
277
|
+
route,
|
|
278
|
+
entities,
|
|
279
|
+
reuse,
|
|
280
|
+
context: context && {
|
|
281
|
+
ok: context.ok,
|
|
282
|
+
tokens: context.tokens,
|
|
283
|
+
budget: context.budget,
|
|
284
|
+
required: context.required.length,
|
|
285
|
+
missing: context.missing,
|
|
286
|
+
questions: context.questions,
|
|
287
|
+
},
|
|
288
|
+
impact: { targets: impactTargets, reports: impacts, impactedFiles, predictedTests },
|
|
289
|
+
scope,
|
|
290
|
+
memory: {
|
|
291
|
+
matchingLessons: lessons.length,
|
|
292
|
+
advisory: lessons.slice(0, 5).map((lesson) => ({
|
|
293
|
+
id: lesson.id,
|
|
294
|
+
status: lesson.status,
|
|
295
|
+
scope: lesson.scope,
|
|
296
|
+
})),
|
|
297
|
+
},
|
|
298
|
+
// M5 anti-over-engineering: the pre-action keyword heuristics PLUS a measured footprint check
|
|
299
|
+
// (φ(y) − φ*(x)) against the working diff once one exists — abstractions/files/lines the task
|
|
300
|
+
// never asked for. `lean` is diff-based, so it's quiet until there's something to measure.
|
|
301
|
+
minimality: (() => {
|
|
302
|
+
const pre = minimalityWarnings(text, route, preflight);
|
|
303
|
+
const lean = allowBuild ? leanRepo(root, text) : { warnings: [], footprint: null };
|
|
304
|
+
return { warnings: [...pre, ...lean.warnings], footprint: lean.footprint };
|
|
305
|
+
})(),
|
|
306
|
+
// M4 goal-anchoring: re-read the stated goal against files already changed this session.
|
|
307
|
+
// Quiet pre-action (clean tree → no drift); speaks mid-session when work wandered off-goal.
|
|
308
|
+
goalAnchor: goalDrift(root, text, llmOpts),
|
|
309
|
+
verification: { checklist: verificationChecklist(root) },
|
|
310
|
+
substrate: loadSubstrateSpec(),
|
|
311
|
+
// Which faculties, if any, had a model proposal survive external verification this run, and
|
|
312
|
+
// which direction it moved (…-cleared / …-tightened for the gate, …-raised / …-lowered for
|
|
313
|
+
// routing). Every non-deterministic value was checked before it counted.
|
|
314
|
+
llm: {
|
|
315
|
+
enabled: useLLM,
|
|
316
|
+
bidirectional: bi,
|
|
317
|
+
provenance: {
|
|
318
|
+
assumption: preflight.assumption.provenance?.path ?? "deterministic",
|
|
319
|
+
route: route.provenance?.path ?? "deterministic",
|
|
320
|
+
impact: impacts.some((r) => (r.llmVerified || []).length)
|
|
321
|
+
? "llm-verified"
|
|
322
|
+
: "deterministic",
|
|
323
|
+
goalAnchor: undefined, // set below once goalAnchor is in scope
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
guarantees: {
|
|
327
|
+
deterministic: [
|
|
328
|
+
"assumption rubric",
|
|
329
|
+
"repo symbol/file grounding",
|
|
330
|
+
"model routing rubric",
|
|
331
|
+
"impact graph traversal",
|
|
332
|
+
"scope decomposition",
|
|
333
|
+
],
|
|
334
|
+
// Proposed by a model, then checked against the repo/graph/tests before it could move a
|
|
335
|
+
// verdict — safe to surface, never blindly trusted (whitepaper tabayyun gate).
|
|
336
|
+
llmVerified: [
|
|
337
|
+
"assumption refinement (bounded ±band; clears a false ask only past the no-anchor + repo-grounding floors)",
|
|
338
|
+
"routing (free raise; bounded lower, never below strong-signal floor)",
|
|
339
|
+
"impact edges (graph + grep verified)",
|
|
340
|
+
"goal-drift rescue (off→on, goal-referenced)",
|
|
341
|
+
],
|
|
342
|
+
advisory: [
|
|
343
|
+
"model capability fit",
|
|
344
|
+
"scope minimality",
|
|
345
|
+
"goal-drift check",
|
|
346
|
+
"memory/learning relevance",
|
|
347
|
+
"verification completeness",
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
result.llm.provenance.goalAnchor = result.goalAnchor?.provenance?.path ?? "deterministic";
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Opt-in mandatory gate (the paper's Eq 5 / M2 "halt on insufficient input"). Turns the advisory
|
|
357
|
+
* assumption gate into an actual BLOCK — but only on the strongest, lowest-false-positive signals,
|
|
358
|
+
* so it halts a vacuous prompt ("fix it", "make it better") or an edit into a very large blast
|
|
359
|
+
* radius, and never a specified task. Off unless `FORGE_ENFORCE=1` (or `enforce:true`); default
|
|
360
|
+
* behaviour is unchanged. `reason` is written to be shown to the agent.
|
|
361
|
+
* @param {object} result - substrateCheck() result
|
|
362
|
+
* @param {object} [opts]
|
|
363
|
+
* @param {boolean} [opts.enforce]
|
|
364
|
+
* @param {number} [opts.blastThreshold]
|
|
365
|
+
*/
|
|
366
|
+
export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) {
|
|
367
|
+
const on = typeof enforce === "boolean" ? enforce : process.env.FORGE_ENFORCE === "1";
|
|
368
|
+
if (!on || !result) return { block: false };
|
|
369
|
+
const tail = "\n(Set FORGE_ENFORCE=0 to make Forge advisory again.)";
|
|
370
|
+
if (result.assumption?.hardUnderspecified) {
|
|
371
|
+
const qs = (result.assumption.questions || []).map((q) => ` • ${q}`).join("\n");
|
|
372
|
+
return {
|
|
373
|
+
block: true,
|
|
374
|
+
reason: `Forge gate (enforcing): this task has no concrete anchor to act on — clarify before I start:\n${qs}${tail}`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// P4 completeness gate: the task names things the repo cannot supply — the questions
|
|
378
|
+
// are DERIVED from the missing-knowledge set, so acting now means acting on a guess.
|
|
379
|
+
if (result.context && !result.context.ok && result.context.questions.length) {
|
|
380
|
+
const qs = result.context.questions.map((q) => ` • ${q}`).join("\n");
|
|
381
|
+
return {
|
|
382
|
+
block: true,
|
|
383
|
+
reason: `Forge gate (enforcing): the required context can't be assembled from this repo — resolve before I edit:\n${qs}${tail}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const blast = result.impact?.impactedFiles?.length ?? 0;
|
|
387
|
+
if (blast >= blastThreshold) {
|
|
388
|
+
return {
|
|
389
|
+
block: true,
|
|
390
|
+
reason: `Forge gate (enforcing): this touches a large blast radius (${blast} files predicted). Review the impacted files (or narrow the change) before editing.${tail}`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return { block: false };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function renderSubstrate(result) {
|
|
397
|
+
const lines = ["Forge substrate — pre-action check", ""];
|
|
398
|
+
lines.push(` proceed: ${result.okToProceed ? "yes" : "ASK FIRST"}`);
|
|
399
|
+
lines.push(
|
|
400
|
+
` assumption: ${result.assumption.risk} risk · completeness ${result.assumption.completeness.toFixed(2)}`,
|
|
401
|
+
);
|
|
402
|
+
if (result.assumption.questions.length) {
|
|
403
|
+
lines.push("", " clarify:");
|
|
404
|
+
for (const q of result.assumption.questions) lines.push(` - ${q}`);
|
|
405
|
+
}
|
|
406
|
+
lines.push(
|
|
407
|
+
"",
|
|
408
|
+
` route: ${result.route.model.name} (${result.route.tier}) · complexity ${result.route.score.toFixed(2)}`,
|
|
409
|
+
);
|
|
410
|
+
if (result.route.reasons.length) lines.push(` driven by: ${result.route.reasons.join(", ")}`);
|
|
411
|
+
if (result.reuse && result.reuse.tier !== "miss") {
|
|
412
|
+
const a = result.reuse.artifact;
|
|
413
|
+
lines.push(
|
|
414
|
+
"",
|
|
415
|
+
` reuse: ${result.reuse.tier.toUpperCase()} hit — verified ${a?.form ?? "artifact"}${a?.path ? ` at ${a.path}` : ""} (\`forge ledger show ${a?.id.slice(0, 8)}\`) — start from it, don't regenerate`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (result.context) {
|
|
419
|
+
lines.push(
|
|
420
|
+
"",
|
|
421
|
+
` context: ${result.context.ok ? "complete" : "INCOMPLETE"} — ${result.context.required} required item(s), ${result.context.tokens}/${result.context.budget} tokens (\`forge context\` for the assembly)`,
|
|
422
|
+
);
|
|
423
|
+
for (const q of result.context.questions ?? []) lines.push(` ? ${q}`);
|
|
424
|
+
}
|
|
425
|
+
lines.push("", ` impact: ${result.impact.impactedFiles.length} file(s) predicted`);
|
|
426
|
+
for (const file of result.impact.impactedFiles.slice(0, 10)) lines.push(` - ${file}`);
|
|
427
|
+
if (result.impact.impactedFiles.length > 10)
|
|
428
|
+
lines.push(` … ${result.impact.impactedFiles.length - 10} more`);
|
|
429
|
+
const tests = result.impact.predictedTests || [];
|
|
430
|
+
if (tests.length) {
|
|
431
|
+
lines.push("", ` likely-affected tests (${tests.length}) — run these first:`);
|
|
432
|
+
for (const t of tests.slice(0, 8)) lines.push(` - ${t}`);
|
|
433
|
+
}
|
|
434
|
+
if (result.minimality.warnings.length) {
|
|
435
|
+
lines.push("", " minimality warnings:");
|
|
436
|
+
for (const w of result.minimality.warnings) lines.push(` - ${w}`);
|
|
437
|
+
}
|
|
438
|
+
if (result.goalAnchor?.drift) {
|
|
439
|
+
lines.push(
|
|
440
|
+
"",
|
|
441
|
+
` goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal:`,
|
|
442
|
+
);
|
|
443
|
+
for (const f of result.goalAnchor.offGoal.slice(0, 8)) lines.push(` - ${f}`);
|
|
444
|
+
}
|
|
445
|
+
lines.push("", " verify:");
|
|
446
|
+
for (const c of result.verification.checklist) lines.push(` - ${c}`);
|
|
447
|
+
return lines.join("\n");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Compact advisory for AMBIENT injection (Claude Code UserPromptSubmit additionalContext).
|
|
451
|
+
// Returns "" unless there is something worth surfacing — never nags on a well-specified,
|
|
452
|
+
// low-impact task. Gated on: must-ask assumptions, a premium model recommendation,
|
|
453
|
+
// predicted blast radius, or a minimality warning.
|
|
454
|
+
export function substrateContext(result) {
|
|
455
|
+
const worthSaying =
|
|
456
|
+
result.assumption.shouldAsk ||
|
|
457
|
+
result.impact.impactedFiles.length > 0 ||
|
|
458
|
+
result.minimality.warnings.length > 0 ||
|
|
459
|
+
result.goalAnchor?.drift ||
|
|
460
|
+
["opus", "fable"].includes(result.route.key);
|
|
461
|
+
if (!worthSaying) return "";
|
|
462
|
+
const lines = ["Forge substrate — pre-action advisory (advisory, never blocks):"];
|
|
463
|
+
if (result.assumption.shouldAsk) {
|
|
464
|
+
lines.push(
|
|
465
|
+
`- Under-specified (${result.assumption.risk} risk). Ask before editing:`,
|
|
466
|
+
...result.assumption.questions.map((q) => ` • ${q}`),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
lines.push(
|
|
470
|
+
`- Suggested model: ${result.route.model.name} (${result.route.tier}); escalate only on a verifier failure.`,
|
|
471
|
+
);
|
|
472
|
+
if (result.impact.impactedFiles.length) {
|
|
473
|
+
const files = result.impact.impactedFiles;
|
|
474
|
+
lines.push(
|
|
475
|
+
`- Predicted blast radius (${files.length}): ${files.slice(0, 8).join(", ")}${files.length > 8 ? " …" : ""}. Review these before editing.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const predTests = result.impact.predictedTests || [];
|
|
479
|
+
if (predTests.length)
|
|
480
|
+
lines.push(
|
|
481
|
+
`- Likely-affected tests (${predTests.length}): ${predTests.slice(0, 6).join(", ")}${predTests.length > 6 ? " …" : ""}. Run these first.`,
|
|
482
|
+
);
|
|
483
|
+
for (const w of result.minimality.warnings) lines.push(`- Minimality: ${w}`);
|
|
484
|
+
if (result.goalAnchor?.drift)
|
|
485
|
+
lines.push(
|
|
486
|
+
`- Goal drift: ${result.goalAnchor.offGoal.length} changed file(s) off the stated goal (${result.goalAnchor.offGoal.slice(0, 5).join(", ")}). Intended, or wandering?`,
|
|
487
|
+
);
|
|
488
|
+
if (result.memory.matchingLessons)
|
|
489
|
+
lines.push(`- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`);
|
|
490
|
+
lines.push(`- Verify with: ${result.verification.checklist.join(" · ")}`);
|
|
491
|
+
return lines.join("\n");
|
|
492
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// forge sync — compile the one canonical source (source/rules.json, plus an
|
|
2
|
+
// optional per-repo .forge/rules.json) into every tool's native config target.
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { brainBlock } from "./brain.js";
|
|
6
|
+
import { BRAND } from "./brand.js";
|
|
7
|
+
import { cortexBlock } from "./cortex.js";
|
|
8
|
+
import * as shared from "./emit/_shared.js";
|
|
9
|
+
import aider from "./emit/aider.js";
|
|
10
|
+
import claude from "./emit/claude.js";
|
|
11
|
+
import codex from "./emit/codex.js";
|
|
12
|
+
import continueTool from "./emit/continue.js";
|
|
13
|
+
import copilot from "./emit/copilot.js";
|
|
14
|
+
import cursor from "./emit/cursor.js";
|
|
15
|
+
import gemini from "./emit/gemini.js";
|
|
16
|
+
import { emitMcp } from "./emit/mcp.js";
|
|
17
|
+
import windsurf from "./emit/windsurf.js";
|
|
18
|
+
import zed from "./emit/zed.js";
|
|
19
|
+
|
|
20
|
+
const MODULES = [codex, cursor, copilot, windsurf, zed, claude, gemini, aider, continueTool];
|
|
21
|
+
|
|
22
|
+
// Soft budget: Codex hard-truncates at 32 KiB, Windsurf caps ~12k chars. Warn early.
|
|
23
|
+
const SIZE_BUDGET_BYTES = 12 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Turn the rules object into the canonical AGENTS.md markdown body. */
|
|
26
|
+
export function assemble(rules) {
|
|
27
|
+
const out = [`# AGENTS.md — ${rules.title || "engineering rules"}`, ""];
|
|
28
|
+
if (rules.intro) out.push(rules.intro, "");
|
|
29
|
+
for (const section of rules.sections || []) {
|
|
30
|
+
out.push(`## ${section.title}`);
|
|
31
|
+
for (const rule of section.rules || []) out.push(`- ${rule}`);
|
|
32
|
+
out.push("");
|
|
33
|
+
}
|
|
34
|
+
return out.join("\n").trimEnd() + "\n";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadRules(targetRoot) {
|
|
38
|
+
const base = JSON.parse(readFileSync(join(BRAND.root, "source/rules.json"), "utf8"));
|
|
39
|
+
const override = join(targetRoot, ".forge/rules.json");
|
|
40
|
+
if (existsSync(override)) {
|
|
41
|
+
const extra = JSON.parse(readFileSync(override, "utf8"));
|
|
42
|
+
base.sections = [...(base.sections || []), ...(extra.sections || [])];
|
|
43
|
+
}
|
|
44
|
+
return base;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Full canonical body: rules + portable memory + learned lessons — one source of truth. */
|
|
48
|
+
function buildCanonical(targetRoot) {
|
|
49
|
+
const rules = loadRules(targetRoot);
|
|
50
|
+
const brain = brainBlock(targetRoot); // durable facts (forge brain)
|
|
51
|
+
const lessons = cortexBlock(targetRoot); // learned corrections (forge cortex)
|
|
52
|
+
return assemble(rules) + (brain ? `\n${brain}` : "") + (lessons ? `\n${lessons}` : "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sync({ targetRoot = process.cwd() } = {}) {
|
|
56
|
+
// Inline portable memory + learned lessons so every AGENTS.md-reading tool shares them.
|
|
57
|
+
const canonical = buildCanonical(targetRoot);
|
|
58
|
+
const hash = shared.hashContent(canonical);
|
|
59
|
+
const bytes = Buffer.byteLength(canonical);
|
|
60
|
+
|
|
61
|
+
// The shared AGENTS.md — read directly by Codex, Cursor, Copilot, Windsurf, Zed.
|
|
62
|
+
// If the repo already has a hand-written (unmanaged) AGENTS.md, never destroy it
|
|
63
|
+
// silently — back it up first so no rules are lost when adopting Forge.
|
|
64
|
+
const agentsPath = join(targetRoot, "AGENTS.md");
|
|
65
|
+
const existingAgents = shared.readIfExists(agentsPath);
|
|
66
|
+
const backedUp = existingAgents !== null && !shared.isManaged(existingAgents);
|
|
67
|
+
if (backedUp) writeFileSync(`${agentsPath}.forge-bak`, existingAgents);
|
|
68
|
+
const agentsAction = shared.writeManaged(agentsPath, shared.mdHeader(hash), canonical);
|
|
69
|
+
|
|
70
|
+
const ctx = {
|
|
71
|
+
targetRoot,
|
|
72
|
+
canonical,
|
|
73
|
+
hash,
|
|
74
|
+
bytes,
|
|
75
|
+
chars: canonical.length,
|
|
76
|
+
agentsPath,
|
|
77
|
+
shared,
|
|
78
|
+
join,
|
|
79
|
+
};
|
|
80
|
+
const report = [
|
|
81
|
+
{
|
|
82
|
+
tool: "shared source",
|
|
83
|
+
target: "AGENTS.md",
|
|
84
|
+
action: agentsAction,
|
|
85
|
+
note: `${bytes} B`,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
for (const mod of MODULES) {
|
|
89
|
+
try {
|
|
90
|
+
report.push(mod.emit(ctx));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
report.push({
|
|
93
|
+
tool: mod.tool,
|
|
94
|
+
target: "-",
|
|
95
|
+
action: "error",
|
|
96
|
+
note: err.message,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MCP servers — emit the canonical set into each tool's MCP config (real formats).
|
|
102
|
+
const mcpFile = join(BRAND.root, "source", "mcp.json");
|
|
103
|
+
if (existsSync(mcpFile)) {
|
|
104
|
+
try {
|
|
105
|
+
const servers = JSON.parse(readFileSync(mcpFile, "utf8"));
|
|
106
|
+
for (const row of emitMcp({ targetRoot, servers })) report.push(row);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
report.push({
|
|
109
|
+
tool: "MCP",
|
|
110
|
+
target: "-",
|
|
111
|
+
action: "error",
|
|
112
|
+
note: err.message,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const warnings = [];
|
|
118
|
+
if (backedUp)
|
|
119
|
+
warnings.push(
|
|
120
|
+
"existing AGENTS.md was not Forge-managed — backed up to AGENTS.md.forge-bak; move any custom rules into source/rules.json or a per-repo .forge/rules.json",
|
|
121
|
+
);
|
|
122
|
+
if (bytes > SIZE_BUDGET_BYTES)
|
|
123
|
+
warnings.push(
|
|
124
|
+
`canonical is ${bytes} B (> ${SIZE_BUDGET_BYTES} B budget) — trim source/rules.json`,
|
|
125
|
+
);
|
|
126
|
+
return { hash, bytes, report, warnings, backedUp };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The assembled canonical body for a repo — same builder sync writes, so drift-check matches. */
|
|
130
|
+
export function canonical(targetRoot = process.cwd()) {
|
|
131
|
+
return buildCanonical(targetRoot);
|
|
132
|
+
}
|
package/src/taste.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// forge taste — a MENU of visual directions. Each repo pins exactly ONE
|
|
2
|
+
// (consistency) chosen from the menu (optionality) by writing a managed DESIGN.md.
|
|
3
|
+
// The shared rules already tell every tool to "follow DESIGN.md", so one choice
|
|
4
|
+
// steers Claude, Cursor, Codex, Gemini, and the rest.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { BRAND } from "./brand.js";
|
|
8
|
+
import {
|
|
9
|
+
hashContent,
|
|
10
|
+
isManaged,
|
|
11
|
+
markerString,
|
|
12
|
+
readIfExists,
|
|
13
|
+
writeIfChanged,
|
|
14
|
+
} from "./emit/_shared.js";
|
|
15
|
+
|
|
16
|
+
const stylesDir = () => join(BRAND.root, "global", "taste");
|
|
17
|
+
|
|
18
|
+
export function list() {
|
|
19
|
+
const dir = stylesDir();
|
|
20
|
+
if (!existsSync(dir)) return [];
|
|
21
|
+
return readdirSync(dir)
|
|
22
|
+
.filter((f) => f.endsWith(".md"))
|
|
23
|
+
.map((f) => f.replace(/\.md$/, ""))
|
|
24
|
+
.sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function spec(style) {
|
|
28
|
+
const p = join(stylesDir(), `${style}.md`);
|
|
29
|
+
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function apply(style, targetRoot = process.cwd()) {
|
|
33
|
+
const body = spec(style);
|
|
34
|
+
if (!body)
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: `unknown style "${style}" — run \`forge taste\` to list them`,
|
|
38
|
+
};
|
|
39
|
+
const dest = join(targetRoot, "DESIGN.md");
|
|
40
|
+
const existing = readIfExists(dest);
|
|
41
|
+
if (existing !== null && !isManaged(existing)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason:
|
|
45
|
+
"DESIGN.md exists and isn't Forge-managed — edit it yourself, or delete it to let forge taste manage it",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const hash = hashContent(body);
|
|
49
|
+
const header = `<!-- Forge taste: ${style} — regenerate with \`forge taste ${style}\`. ${markerString(hash)} -->`;
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
style,
|
|
53
|
+
action: writeIfChanged(dest, `${header}\n${body}`),
|
|
54
|
+
};
|
|
55
|
+
}
|