From 3de9257a459d2a5d03f2ddb9099145d0ace39671 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 08:18:15 +0000 Subject: [PATCH 01/12] feat(atlas): config artifacts join the impact graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI workflows (.github is now walked), manifests, and Dockerfiles become config: nodes with references edges to the code paths they name — a code change now lists the configs it can break. Lockfiles stay out (generated churn). RULES/DOC_EXTS/CODE_EXTS/CONFIG_* are exported as the ONE classification registry the completion gate and docs sweep reuse. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/atlas.js | 56 ++++++++++++++++++++++++++++++++++++++++++---- test/atlas.test.js | 50 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/atlas.js b/src/atlas.js index 6a1418e..bc27874 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -12,7 +12,7 @@ const JS_RULES = [ { re: /(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/g, kind: "class" }, { re: /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g, kind: "const" }, ]; -const RULES = { +export const RULES = { ".js": JS_RULES, ".jsx": JS_RULES, ".ts": JS_RULES, @@ -38,13 +38,31 @@ const RULES = { // doc node + `references` edges to the code it names), never by the symbol RULES. // This is the missing code→doc half of impact: change a symbol, its docs show up // as dependents. -const DOC_EXTS = new Set([".md"]); +export const DOC_EXTS = new Set([".md"]); // Docs excluded from the graph: generated files the Stop hook rewrites (AGENTS.md // auto-sync would re-stale the atlas after every session) and the changelog, which // churns on every change and whose references describe HISTORY, not current code. const DOC_SKIP = /^(AGENTS|CLAUDE|GEMINI|CHANGELOG)\.md$/i; +// The extensions the symbol RULES parse — exported as the ONE code-class registry so +// the completion gate and docs sweep classify paths from the same table the graph is +// built from, instead of growing their own regex lists. +export const CODE_EXTS = new Set(Object.keys(RULES)); + +// Config artifacts — CI workflows, manifests, build/deploy wiring. They name code +// paths, so a code change must surface the configs that point at it (the missing +// config half of impact). Lockfiles are generated churn, never sources of truth. +export const CONFIG_EXTS = new Set([".json", ".yml", ".yaml", ".toml"]); +export const CONFIG_FILE_RE = /^Dockerfile$|\.config\.[\w.]+$/; +const CONFIG_SKIP = /^package-lock\.json$|[-.]lock(\.[\w]+)?$|\.cache\.json$/i; + +/** True when a basename is a config artifact worth graphing (lockfiles excluded). */ +export function isConfigFile(name) { + if (CONFIG_SKIP.test(name)) return false; + return CONFIG_EXTS.has(extname(name)) || CONFIG_FILE_RE.test(name); +} + const IMPORT_RE = /(?:import\s+(?:[^"'\n]+\s+from\s+)?["']([^"']+)["']|require\(["']([^"']+)["']\)|^\s*(?:from\s+([\w.]+)\s+)?import\s+([\w*,\s]+))/gm; const BUILTINS = new Set([ @@ -96,7 +114,9 @@ function walk(dir, files, cap) { return; } for (const name of entries) { - if (IGNORE_DIRS.has(name) || name.startsWith(".")) continue; + // Dot-entries stay out of the graph — except `.github`, whose workflows are config + // artifacts that name code paths (a CI file IS a dependent of the code it runs). + if (IGNORE_DIRS.has(name) || (name.startsWith(".") && name !== ".github")) continue; const path = join(dir, name); let st; try { @@ -106,7 +126,9 @@ function walk(dir, files, cap) { } if (st.isDirectory()) walk(path, files, cap); else if ( - (RULES[extname(name)] || (DOC_EXTS.has(extname(name)) && !DOC_SKIP.test(name))) && + (RULES[extname(name)] || + (DOC_EXTS.has(extname(name)) && !DOC_SKIP.test(name)) || + isConfigFile(name)) && files.length < cap ) files.push(path); @@ -164,6 +186,31 @@ function extractDoc(rel, text) { return { symbols: [], nodes: [doc], edges, hash: hash(text) }; } +// A config artifact (CI workflow, manifest, Dockerfile) becomes ONE config node whose +// `references` edges point at the code files it names — quoted or bare, since YAML and +// Dockerfiles reference paths without quotes (`run: node src/cli.js`). Reverse-BFS then +// lists the configs a code change can break, closing the config half of blast radius. +function extractConfig(rel, text) { + const cfg = { id: `config:${rel}`, name: rel, kind: "config", file: rel, line: 1 }; + const edges = []; + const seen = new Set(); + for (const m of text.matchAll(/[A-Za-z0-9_.@-]+(?:[/\\][A-Za-z0-9_.@-]+)*/g)) { + const tok = m[0].replace(/^\.\//, ""); + if (!RULES[extname(tok)]) continue; // only path-like tokens ending in a code extension + const target = `module:${moduleId(tok)}`; + if (seen.has(target)) continue; + seen.add(target); + edges.push({ + source: cfg.id, + target, + kind: "references", + confidence: 0.8, + line: lineOf(text, m.index), + }); + } + return { symbols: [], nodes: [cfg], edges, hash: hash(text) }; +} + function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; @@ -177,6 +224,7 @@ function extractFile(path, root, preRead) { } } if (DOC_EXTS.has(ext)) return extractDoc(rel, text); + if (isConfigFile(rel.split(/[/\\]/).pop() || "")) return extractConfig(rel, text); const mod = { id: `module:${moduleId(rel)}`, diff --git a/test/atlas.test.js b/test/atlas.test.js index fb73ce3..b4a5880 100644 --- a/test/atlas.test.js +++ b/test/atlas.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -115,3 +115,51 @@ test("markdown docs become graph nodes whose references make them impact depende const byFile = impact(atlas, "src.js"); assert.ok(byFile.impactedFiles.includes("GUIDE.md"), JSON.stringify(byFile.impactedFiles)); }); + +test("config artifacts become graph nodes; a code change lists its CI/config dependents", () => { + const root = mkdtempSync(join(tmpdir(), "forge-atlas-")); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src", "val.js"), "export function validate(x){ return !!x }\n"); + writeFileSync( + join(root, ".github", "workflows", "ci.yml"), + "jobs:\n test:\n steps:\n - run: node src/val.js\n", + ); + writeFileSync(join(root, "Dockerfile"), "FROM node:20\nCOPY src/val.js /app/val.js\n"); + writeFileSync(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 })); + mkdirSync(join(root, ".hidden")); + writeFileSync(join(root, ".hidden", "x.yml"), "a: src/val.js\n"); + const atlas = build({ root }); + assert.ok( + atlas.nodes.some((n) => n.kind === "config" && n.file === ".github/workflows/ci.yml"), + "workflow is a config node", + ); + assert.ok( + atlas.nodes.some((n) => n.kind === "config" && n.file === "Dockerfile"), + "Dockerfile is a config node", + ); + assert.ok( + !atlas.nodes.some((n) => n.file === "package-lock.json"), + "lockfiles are generated churn, never graphed", + ); + assert.ok( + !atlas.nodes.some((n) => n.file?.startsWith(".hidden")), + "other dot-dirs stay out of the walk", + ); + const r = impact(atlas, "src/val.js"); + assert.ok(r.impactedFiles.includes(".github/workflows/ci.yml"), JSON.stringify(r.impactedFiles)); + assert.ok( + r.impactedFiles.includes("Dockerfile"), + "the Dockerfile copying the file is a dependent", + ); +}); + +test("a config edit flips isStale (configs are tracked like any other artifact)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-atlas-")); + writeFileSync(join(root, "app.js"), "export function main(){}\n"); + writeFileSync(join(root, "deploy.yml"), "run: node app.js\n"); + const atlas = build({ root }); + assert.equal(isStale(root, atlas), false); + writeFileSync(join(root, "deploy.yml"), "run: node app.js --prod\n"); + assert.equal(isStale(root, atlas), true, "config content change detected"); +}); From 7292636a9902c094ff382415a40bec9f33cea0f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 08:18:15 +0000 Subject: [PATCH 02/12] =?UTF-8?q?feat(memory):=20forge=20handoff=20+=20for?= =?UTF-8?q?ge=20decide=20=E2=80=94=20state=20that=20survives=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handoff rewrites .forge/state.md (bounded ≤150 lines: goal/phase, criteria, done, next, gotchas, recorded assumptions, in-progress git files) — snapshot semantics, injected at every session start so the next session resumes instead of re-assuming. decide appends ADR-lite D-#### entries to .forge/decisions.md (log semantics — supersede, never edit) and mints a machine-readable decision ledger twin. Both refuse secrets at write. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/decide.js | 78 +++++++++++++++++++++ src/handoff.js | 161 +++++++++++++++++++++++++++++++++++++++++++ test/decide.test.js | 63 +++++++++++++++++ test/handoff.test.js | 95 +++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 src/decide.js create mode 100644 src/handoff.js create mode 100644 test/decide.test.js create mode 100644 test/handoff.test.js diff --git a/src/decide.js b/src/decide.js new file mode 100644 index 0000000..66dc136 --- /dev/null +++ b/src/decide.js @@ -0,0 +1,78 @@ +// forge decide — the append-only, human-readable decision log. Lessons capture +// corrections and the ledger captures machine claims, but "we picked X over Y because Z" +// had no home the NEXT session reads — so sessions re-decided (or contradicted) settled +// choices. .forge/decisions.md is ADR-lite: one line per decision, append-only. Append +// is one syscall, merge-friendly, and never rewrites history — a decision that stops +// being true gets a new entry, not an edit. +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { mintClaim } from "./ledger.js"; +import { putClaim } from "./ledger_store.js"; +import { hasSecret } from "./secrets.js"; +import { gitAuthor } from "./util.js"; + +export const decisionsPath = (root) => join(root, ".forge", "decisions.md"); + +const LINE_RE = /^- \*\*D-(\d{4,})\*\* \((\d{4}-\d{2}-\d{2})\): (.*)$/; + +/** Parse decision entries out of the log; unparseable lines are ignored, never fatal. */ +export function parseDecisions(text) { + const out = []; + for (const line of String(text ?? "").split("\n")) { + const m = LINE_RE.exec(line.trim()); + if (m) out.push({ n: Number(m[1]), id: `D-${m[1]}`, date: m[2], text: m[3] }); + } + return out; +} + +/** The most recent `limit` decisions, oldest→newest (chronological log order). */ +export function listDecisions(root, { limit = 10 } = {}) { + try { + const p = decisionsPath(root); + if (!existsSync(p)) return []; + return parseDecisions(readFileSync(p, "utf8")).slice(-limit); + } catch { + return []; + } +} + +/** + * Append one decision (""). Refuses secrets (same rule as + * every forge store). Also mints a `decision` ledger claim — the machine-readable twin — + * best-effort only: the markdown line is the source of truth and must never fail + * because the ledger couldn't be written. + */ +export function appendDecision(root, text, { t = Date.now() } = {}) { + const body = String(text ?? "") + .trim() + .replace(/\s+/g, " "); + if (!body) return { ok: false, reason: "empty decision — say what was decided and why" }; + if (hasSecret(body)) + return { ok: false, reason: "refused: decision looks like it contains a secret/credential" }; + mkdirSync(join(root, ".forge"), { recursive: true }); + const p = decisionsPath(root); + let existing = ""; + try { + existing = existsSync(p) ? readFileSync(p, "utf8") : ""; + } catch {} + const prior = parseDecisions(existing); + const n = prior.length ? Math.max(...prior.map((d) => d.n)) + 1 : 1; + const id = `D-${String(n).padStart(4, "0")}`; + const date = new Date(t).toISOString().slice(0, 10); + const header = existing.trim() + ? "" + : `# Decisions\n\nAppend-only — supersede an old choice with a NEW entry (\`${BRAND.cli} decide\`), never an edit.\n\n`; + appendFileSync(p, `${header}- **${id}** (${date}): ${body}\n`); + try { + const minted = mintClaim({ + kind: "decision", + body: { id, text: body }, + scope: { level: "repo" }, + provenance: { author: gitAuthor() }, + t: Math.floor(t / 86_400_000), + }); + if (minted.ok) putClaim(join(root, ".forge", "ledger"), minted.claim); + } catch {} + return { ok: true, id, text: body }; +} diff --git a/src/handoff.js b/src/handoff.js new file mode 100644 index 0000000..559a309 --- /dev/null +++ b/src/handoff.js @@ -0,0 +1,161 @@ +// forge handoff — the bounded session-state checkpoint. goal.md holds the objective and +// lessons hold corrections, but "what got done, what's next, what bit us" died with each +// session — the next one re-derived it or, worse, assumed. state.md is a REWRITTEN +// (never appended) snapshot injected at every session start: bounded compression, so the +// loader's token cost stays constant over the project's life while total knowledge grows. + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { getGoal } from "./goal.js"; +import { hasSecret } from "./secrets.js"; + +export const statePath = (root) => join(root, ".forge", "state.md"); + +function git(root, args) { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +/** Branch, dirty files (capped), recent commits — empty-safe outside a git repo. */ +export function gatherGitFacts(root, { statusCap = 20 } = {}) { + const branch = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]); + const status = git(root, ["status", "--short"]).split("\n").filter(Boolean); + const log = git(root, ["log", "--oneline", "-5"]).split("\n").filter(Boolean); + return { + branch, + status: status.slice(0, statusCap), + overflow: Math.max(0, status.length - statusCap), + log, + }; +} + +/** Assumption events recorded this session (preflight appends them when it proceeds + * without asking) — surfaced here so the handoff carries what was GUESSED, not + * just what was done. Empty-safe when no session log exists. */ +export function gatherAssumptions(root) { + try { + const dir = join(root, ".forge", "sessions"); + const newest = readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ f, m: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.m - a.m)[0]; + if (!newest) return []; + const out = []; + for (const line of readFileSync(join(dir, newest.f), "utf8").split("\n")) { + if (!line.trim()) continue; + try { + const e = JSON.parse(line); + if (e.type === "assumption") out.push(e); + } catch {} + } + return out; + } catch { + return []; + } +} + +const arr = (v) => + (Array.isArray(v) ? v : v ? [v] : []).map((x) => String(x).trim()).filter(Boolean); + +const section = (title, rows, fallback = "- (none)") => [ + `## ${title}`, + ...(rows.length ? rows.map((r) => `- ${r}`) : [fallback]), + "", +]; + +/** + * Rewrite the whole snapshot from this session's fields + auto-gathered git facts. + * Refuses secrets in the human-supplied fields (same rule as every forge store) and + * truncates to `maxLines` so the session-start injection can never balloon. + * @param {string} root + * @param {{done?:string[]|string, next?:string[]|string, gotchas?:string[]|string, + * criteria?:string[]|string, goal?:string, phase?:string}} fields + */ +export function writeState(root, fields = {}, { t = Date.now(), maxLines = 150 } = {}) { + const done = arr(fields.done); + const next = arr(fields.next); + const gotchas = arr(fields.gotchas); + const criteria = arr(fields.criteria); + if (!done.length && !next.length && !gotchas.length && !criteria.length) + return { ok: false, reason: "empty handoff — say what was done and what comes next" }; + const supplied = [...done, ...next, ...gotchas, ...criteria, fields.goal, fields.phase] + .filter(Boolean) + .join("\n"); + if (hasSecret(supplied)) + return { ok: false, reason: "refused: handoff looks like it contains a secret/credential" }; + const goal = fields.goal || getGoal(root) || `(none set — \`${BRAND.cli} anchor set "…"\`)`; + const facts = gatherGitFacts(root); + const assumptions = gatherAssumptions(root).map( + (a) => + `proceeded without asking — missing: ${(a.missing || []).join(", ") || "?"}${ + (a.questions || []).length ? ` (${a.questions[0]})` : "" + }`, + ); + const progress = facts.status.length + ? [...facts.status, ...(facts.overflow ? [`(+${facts.overflow} more dirty files)`] : [])] + : []; + const lines = [ + "# Session state", + "", + ...section("Goal / Phase", [`${goal}${fields.phase ? ` — phase: ${fields.phase}` : ""}`]), + ...section("Acceptance criteria", criteria), + ...section("Done this session", done), + ...section("Next steps", next), + ...section("Gotchas", gotchas), + ...section("Open assumptions", assumptions), + ...section("In-progress files (git, at handoff)", progress, "- (clean tree)"), + "## Decisions", + `- append-only log: \`.forge/decisions.md\` (\`${BRAND.cli} decide\`)`, + "", + ]; + const provenance = ``; + const kept = + lines.length + 1 > maxLines + ? [...lines.slice(0, maxLines - 2), "- (truncated to stay bounded)"] + : lines; + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(statePath(root), [...kept, provenance, ""].join("\n")); + return { ok: true, path: statePath(root), lines: kept.length + 1 }; +} + +/** The snapshot text minus provenance, or null when none exists. */ +export function readState(root) { + const p = statePath(root); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf8"); + const cut = raw.indexOf("\s*$/; + /** The snapshot text minus provenance, or null when none exists. */ export function readState(root) { const p = statePath(root); if (!existsSync(p)) return null; try { - const raw = readFileSync(p, "utf8"); - const cut = raw.indexOf("