diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f6b82b7..50541e1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -11,6 +11,7 @@ "keywords": ["config", "cross-tool", "agents-md", "ai-coding", "claude-code", "cognitive-substrate"], "skills": "./global/tools", "agents": [ + "./global/crew/doc-sync.md", "./global/crew/frontend-verifier.md", "./global/crew/independent-reviewer.md", "./global/crew/scout.md", diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4199386..466f1f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,6 +185,62 @@ compression ladder, and reports the *computed missing set* — the inputs it cou assemble. That missing set is exactly what the substrate pipeline's context stage reads to decide whether an edit is safe to start. Surface: `forge reuse query | mint | stats`. +## 5. The end-to-end reliability layer + +Two failure modes this layer exists to kill: **partial work** (code changes without the +artifacts that depend on it) and **session amnesia** (the next session re-assumes what +this one knew). Instructions raise the *probability* of correct behavior; deterministic +hooks guarantee a *floor* — with per-task miss rate `1−p` and gate catch rate `c`, +silent misses fall to `(1−p)(1−c)`, and every layer here is one more `c`. + +**The completion gate (Stop, `src/gate.js`).** The only Stop-path guard that may answer: +`completion-gate.sh` runs synchronously (the lesson-mining `cortex.sh stop` stays +detached and can never block). The changed set is **session-scoped**: files from commits +whose committer time is ≥ session start, plus working-tree changes minus the dirt +snapshotted at SessionStart — so pre-existing edits, branch switches, and `git pull`s +are never pinned on the agent (adversarial review demonstrated all three false-block +classes). Paths are classified by ONE total function derived from the atlas registries +(`CODE_EXTS`/`DOC_EXTS`/config rules) plus the shared test-file predicate, parsed from +`-z` NUL-separated git output (C-quoted unicode paths classify correctly). Code moved +with no doc/state artifact → block once with the repair checklist as the reason; every +other row allows, every internal error allows (fail-open), the once-per-session marker +is written BEFORE the block (unwritable marker → stand down rather than nag every turn), +a missing `session_id` disables gating (no shared-state leaks between sessions), and +`FORGE_STOPGATE=0` kills it. `.forge/state.md` is gitignored, so its signal is +mtime-vs-baseline (the baseline file's mtime *is* session start). + +**Session anchoring (SessionStart, `src/session.js`).** Records `HEAD` once per session +(`.forge/sessions/.base`; resume keeps it), prunes week-old session artifacts, and +injects: learned lessons, the anchored goal, the handoff snapshot, recent commits, and +uncommitted changes — a fresh session orients on evidence, not priors. + +**The state/decision stores (`src/handoff.js`, `src/decide.js`).** `state.md` is a +bounded REWRITE (snapshot semantics — loader cost stays O(bound) forever); +`decisions.md` is append-only ADR-lite with a machine-readable `decision` ledger twin +(log semantics — supersede, never edit). Both refuse secrets at write. + +**The diff-driven docs sweep (`src/docs_sync.js`).** `docs check` reconciles registries; +`docs sync` answers the diff-shaped question: changed identifiers (paths + definitions + +called symbols, from added AND removed lines, via the same `RULES` grammars the atlas +parses) swept against every doc artifact → UPDATED / STALE (file:line hits) / +VERIFIED-UNAFFECTED with the reason recorded. Pure reporter; the gate provides the teeth. + +**Intent cards (`src/intent.js`).** Prompt → intent by the same exemplar k-NN math as +model routing — a labeled bank (English + Hinglish rows) under overlap similarity with a +confidence gate, NOT a keyword DFA. Note `intentGrams` ≠ `contentGrams`: route.js stops +generic task verbs (`fix`/`add`/`build`) as complexity noise, but they are exactly the +intent signal — same math, different stop-set data. + +**The evidence trail (preflight).** Once a goal is anchored, every prompt appends its +graded `driftScore` to the session log; `cusum` (until now test-only math) accumulates +the series and a sustained alarm rides the gate's block reason. Proceeding under +assumptions appends a record the advisory names and the next handoff surfaces — a guess +can never silently become a fact. + +**Deliberately not wired:** `checkpointCadence` (optimal-stopping check spacing) still +has no runtime step-loop to consume it — wiring it would mean inventing one. It stays +library math with tests until a real consumer exists. + ## Component map — the reuse ledger (30 components) **Reuse (rename + swap brand token, logic unchanged):** diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0af47..534e7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,62 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **The completion gate** — a synchronous Stop hook (`global/guards/completion-gate.sh` + → `src/gate.js`) that blocks a session ONCE when code changed but no doc or state + artifact moved with it, answering with the repair checklist (`forge docs sync`, + `forge handoff`, `forge decide`, plus a CUSUM goal-drift alarm when the session's + recorded drift sustained). Loop-safe (`stop_hook_active` + once-per-session marker), + fail-open on every error path, kill switch `FORGE_STOPGATE=0`. Classification derives + from the atlas registries + the shared test-file predicate — no parallel regex lists. +- **`forge handoff`** — the bounded session snapshot: rewrites `.forge/state.md` + (≤150 lines; goal/phase, acceptance criteria, done, next, gotchas, recorded + assumptions, in-progress git files) and SessionStart re-injects it, so the next + session resumes instead of re-assuming. Refuses secrets like every forge store. +- **`forge decide`** — append-only ADR-lite decision log (`.forge/decisions.md`, + `D-####` numbering) + a machine-readable `decision` ledger twin; bare `forge decide` + lists the last ten. Supersede with a new entry, never an edit. +- **`forge docs sync`** — the diff-driven half of docs↔code alignment: changed + identifiers (paths, definitions, called symbols — from added AND removed lines) swept + against every doc artifact → UPDATED / STALE (file:line hits) / VERIFIED-UNAFFECTED + (reason recorded). Advisory by default, `--strict` for CI, `--base ` to widen; + CHANGELOG and the decision log are exempt (append-only history). +- **Session baseline + rehydration** — SessionStart records the session's git anchor + (`.forge/sessions/.base`; a resume never moves it), prunes week-old session + artifacts, and injects the handoff snapshot + last 10 commits + uncommitted changes. +- **Intent protocol cards** — UserPromptSubmit classifies the prompt with the same + exemplar k-NN math as routing (labeled bank incl. Hinglish rows, overlap similarity, + confidence gate) and injects a bugfix/feature/refactor/release protocol card once per + run of that intent; questions get no ceremony. Kill switch `FORGE_INTENT=0`. +- **Recorded assumptions** — when preflight proceeds without asking, the assumption is + appended to the session log, named in the advisory, and surfaces in the next handoff; + the per-prompt goal-drift score is recorded the same way and feeds the gate's CUSUM. +- **Config artifacts in the atlas** — CI workflows (`.github` is now walked), + manifests, and Dockerfiles become `config:` nodes with `references` edges to the code + paths they name, so `forge impact` lists the configs a change can break (lockfiles + excluded as generated churn). +- **End-to-end skills + agent** — `handoff`, `sync-docs`, and `catchup` skills, a + `doc-sync` crew agent that repairs stale docs in its own context, and an + `end-to-end` rules section (Definition of Done, no silent assumptions, decision log) + compiled into every tool by `forge sync`. + +### Fixed +- **`cortex.sh` hook entry resolution in symlink installs** — `~/.forge/src/…` pointed + at the nonexistent `global/src/`, silently no-opping every cortex hook outside plugin + mode; the shim now resolves through the symlink (`pwd -P`), same as `secret-redact.sh`. +- **Twelve defects found by a two-angle adversarial review of the new layer, all with + regression tests** — the gate no longer attributes pre-session dirt, branch-switch/pull + commits, or vendor trees to the session (session-scoped changed set: committer-time + window + SessionStart dirty snapshot); `-z` NUL parsing keeps unicode/space/arrow paths + correctly classified; an unwritable block-once marker stands down instead of blocking + every turn; a missing `session_id` disables gating instead of sharing `default` state; + a >7-day resume re-anchors instead of losing its baseline to the prune; `readState` no + longer truncates snapshots whose rows contain ``; + 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 }; +} + +// Only the EXACT provenance line is stripped — a naive slice at the first "\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 text = readFileSync(p, "utf8") + .split("\n") + .filter((l) => !PROVENANCE_RE.test(l)) + .join("\n") + .trim(); + return text || null; + } catch { + return null; + } +} + +/** SessionStart injection block — empty string when no snapshot exists (low-nag). */ +export function stateBlock(root, { maxLines = 80 } = {}) { + const text = readState(root); + if (!text) return ""; + const body = text.split("\n").filter((l) => l.trim() !== "# Session state"); + const capped = + body.length > maxLines + ? [...body.slice(0, maxLines), `_(truncated — read \`.forge/state.md\` for the rest)_`] + : body; + return [ + `## Session state (${BRAND.brand} Handoff)`, + ...capped, + `Keep it current: \`${BRAND.cli} handoff "" --next ""\` before stopping.`, + "", + ].join("\n"); +} diff --git a/src/intent.js b/src/intent.js new file mode 100644 index 0000000..470077a --- /dev/null +++ b/src/intent.js @@ -0,0 +1,178 @@ +// forge intent — what KIND of work is this prompt asking for? The reference kit used a +// keyword DFA; here classification is the same exemplar k-NN math as route.js (labeled +// rows + overlap coefficient + confidence gate), so intents are tuned by adding example +// rows — including Hinglish ones — never by editing regexes. NOTE: intentGrams is NOT +// route.js contentGrams. Route's STOP set strips generic task verbs (fix/add/build/…) +// because they carry no COMPLEXITY signal — but they are exactly the INTENT signal. +// Same math, different stop-set data. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { BRAND } from "./brand.js"; +import { sessionPath } from "./cortex_hook.js"; +import { setOverlap } from "./math.js"; + +/** Labeled intent bank — DATA, not decision code. Extend coverage by adding rows. */ +export const INTENT_EXEMPLARS = [ + // question — answered directly, no ceremony (System-1 fast path) + { text: "how does the login flow work", intent: "question" }, + { text: "what does this function do", intent: "question" }, + { text: "why is startup slow", intent: "question" }, + { text: "where is the config loaded from", intent: "question" }, + { text: "explain the difference between these two modules", intent: "question" }, + { text: "is this endpoint rate limited", intent: "question" }, + { text: "what happens when the cache expires", intent: "question" }, + { text: "yeh function kya karta hai", intent: "question" }, + { text: "yeh error kyun aa raha hai", intent: "question" }, + { text: "kaise kaam karta hai yeh module", intent: "question" }, + // bugfix — reproduce → root cause → failing test → fix → sweep + { text: "fix the crash when saving a profile", intent: "bugfix" }, + { text: "the login page throws a 500 error", intent: "bugfix" }, + { text: "tests are failing after the last merge", intent: "bugfix" }, + { text: "the app crashes on startup", intent: "bugfix" }, + { text: "fix the race condition in the queue", intent: "bugfix" }, + { text: "the button does nothing when clicked", intent: "bugfix" }, + { text: "resolve the null pointer in checkout", intent: "bugfix" }, + { text: "fix the regression introduced by the last change", intent: "bugfix" }, + { text: "payment page thik karo crash ho raha hai", intent: "bugfix" }, + { text: "yeh bug thik karo", intent: "bugfix" }, + { text: "error aa raha hai isko sahi karo", intent: "bugfix" }, + { text: "form submit hone par error deta hai", intent: "bugfix" }, + // feature — spec → acceptance criteria → build with docs in the same pass + { text: "add a login page with otp", intent: "feature" }, + { text: "implement dark mode for the dashboard", intent: "feature" }, + { text: "add export to csv", intent: "feature" }, + { text: "build a notification system", intent: "feature" }, + { text: "add pagination to the results list", intent: "feature" }, + { text: "implement password reset flow", intent: "feature" }, + { text: "add support for multiple languages", intent: "feature" }, + { text: "naya dashboard banao", intent: "feature" }, + { text: "ek search feature banao", intent: "feature" }, + { text: "profile page bana do", intent: "feature" }, + // refactor — behavior-preserving, tests first, sweep every old name + { text: "clean up the api module", intent: "refactor" }, + { text: "refactor the payment service into smaller functions", intent: "refactor" }, + { text: "extract the shared logic into a helper", intent: "refactor" }, + { text: "rename the user manager and update the callers", intent: "refactor" }, + { text: "simplify this class hierarchy", intent: "refactor" }, + { text: "restructure the folder layout", intent: "refactor" }, + { text: "reduce duplication across the handlers", intent: "refactor" }, + { text: "modernize this file to async await", intent: "refactor" }, + { text: "code saaf karo is module ka", intent: "refactor" }, + { text: "is code ko behtar banao bina behavior badle", intent: "refactor" }, + // release — gates: changelog, version, tests, docs check + { text: "ship the release", intent: "release" }, + { text: "cut a new version and update the changelog", intent: "release" }, + { text: "prepare the release notes", intent: "release" }, + { text: "publish the package to npm", intent: "release" }, + { text: "bump the version and tag it", intent: "release" }, + { text: "deploy to production", intent: "release" }, + { text: "get this ready to ship", intent: "release" }, + { text: "roll out the new build", intent: "release" }, + { text: "deploy karo", intent: "release" }, + { text: "release kar do production par", intent: "release" }, +]; + +// Function words ONLY (English + Hinglish auxiliaries/pronouns). Task verbs stay — +// they are the intent signal (see module header). +const STOP = new Set( + ( + "a an the in on of to for with and or is are be it its this that as at by from into up out " + + "my your our their please can you i we " + + "hai hain ho raha rahi ka ki ke ko par mein se aur yeh ye iska isko" + ).split(" "), +); + +const stem = (t) => (t.length > 3 && t.endsWith("s") ? t.slice(0, -1) : t); + +/** Same shape as route.js contentGrams — different stop-set (function words only). */ +export function intentGrams(text) { + const toks = String(text) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t && !STOP.has(t)) + .map(stem); + const grams = new Set(toks); + for (let i = 0; i + 1 < toks.length; i++) grams.add(`${toks[i]} ${toks[i + 1]}`); + return grams; +} + +const EXEMPLAR_GRAMS = INTENT_EXEMPLARS.map((e) => ({ ...e, grams: intentGrams(e.text) })); + +/** + * k-NN over the bank: similarity-weighted vote among the top-k neighbors, gated on the + * best similarity — a prompt resembling nothing in the bank is "none", never a guess. + * Neighbors ride along so every classification is attributable to its evidence. + */ +export function classifyIntent(text, { k = 3, minConf = 0.25 } = {}) { + const grams = intentGrams(text); + if (!grams.size) return { intent: "none", confidence: 0, neighbors: [] }; + const sims = EXEMPLAR_GRAMS.map((e) => ({ + text: e.text, + intent: e.intent, + sim: setOverlap(grams, e.grams), + })) + .sort((a, b) => b.sim - a.sim) + .slice(0, k); + const top = sims[0]; + if (!top || top.sim < minConf) + return { intent: "none", confidence: top?.sim ?? 0, neighbors: sims }; + const votes = new Map(); + for (const s of sims) votes.set(s.intent, (votes.get(s.intent) ?? 0) + s.sim); + const [intent] = [...votes.entries()].sort((a, b) => b[1] - a[1])[0]; + return { intent, confidence: Number(top.sim.toFixed(3)), neighbors: sims }; +} + +/** Protocol cards — data. `question`/`none` deliberately have no card (no ceremony). */ +export const PROTOCOL_CARDS = { + bugfix: [ + `## Bugfix protocol (${BRAND.brand})`, + "1. Reproduce first — no fix without a failing observation.", + "2. Trace the CAUSE, not the symptom (the wrong-status UI is rarely a UI bug).", + `3. Write the failing test BEFORE the fix; \`${BRAND.cli} diagnose ""\` if this failure has repeated.`, + `4. \`${BRAND.cli} impact \` — sweep the blast radius (callers, configs, docs).`, + `5. Full suite green, then \`${BRAND.cli} docs sync\` + \`${BRAND.cli} handoff\` — the gate checks.`, + ].join("\n"), + feature: [ + `## Feature protocol (${BRAND.brand})`, + `1. \`${BRAND.cli} preflight ""\` — answer its questions or record the assumptions.`, + `2. State acceptance criteria up front (\`${BRAND.cli} handoff --criteria "…"\`).`, + `3. \`${BRAND.cli} impact\` / \`${BRAND.cli} scope\` before building — know the full artifact list.`, + "4. Docs, tests, and configs move IN THE SAME PASS as the code, not later.", + `5. \`${BRAND.cli} verify\` before claiming done.`, + ].join("\n"), + refactor: [ + `## Refactor protocol (${BRAND.brand})`, + "1. Behavior-preserving: capture current behavior with tests FIRST.", + `2. \`${BRAND.cli} scope \` — find the coupled files you didn't name.`, + `3. Sweep every old name — code AND prose: \`${BRAND.cli} docs sync\` after the rename.`, + `4. \`${BRAND.cli} lean\` — keep the footprint proportional to the ask.`, + ].join("\n"), + release: [ + `## Release protocol (${BRAND.brand})`, + "1. Tests + lint green; CHANGELOG has a real [Unreleased] section.", + `2. \`${BRAND.cli} docs check\` — commands/env/tools tables must match the code.`, + "3. Version bump + tag; release notes from the changelog.", + `4. \`${BRAND.cli} verify\` as the final gate before shipping.`, + ].join("\n"), +}; + +/** + * The UserPromptSubmit hook's entry: classify, dedupe per session (a card is injected + * once per run of the same intent — context economy), kill switch FORGE_INTENT=0. + * Returns "" whenever there is nothing worth saying (low-nag). + */ +export function intentCard(root, sid, text) { + if (process.env.FORGE_INTENT === "0") return ""; + const { intent } = classifyIntent(text); + const card = PROTOCOL_CARDS[intent]; + if (!card) return ""; // question/none: no ceremony + const marker = sessionPath(root, sid, "intent"); + try { + if (readFileSync(marker, "utf8").trim() === intent) return ""; + } catch {} + try { + mkdirSync(dirname(marker), { recursive: true }); + writeFileSync(marker, `${intent}\n`); + } catch {} + return card; +} diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..030cedb --- /dev/null +++ b/src/session.js @@ -0,0 +1,144 @@ +// forge session — per-session git anchoring. Nothing recorded WHERE the repo stood when +// a session began, so "what changed this session" was unanswerable and every diff ran +// against live HEAD. SessionStart records the anchor once (resume keeps it); the +// completion gate diffs against it; the rehydration block tells a fresh session what +// recently happened instead of letting it assume. +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { sessionPath } from "./cortex_hook.js"; + +function git(root, args) { + try { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +// Raw NUL-separated porcelain — the ONLY quote-proof status format (paths with +// spaces/unicode/quotes arrive verbatim, no C-quoting to undo). +function statusPathsZ(root) { + try { + const raw = execFileSync("git", ["status", "--porcelain", "-z", "-uall"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const tokens = raw.split("\0").filter(Boolean); + const paths = []; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i]; + if (t.length < 4 || t[2] !== " ") continue; // defensive: not an "XY path" entry + paths.push(t.slice(3)); + if (/[RC]/.test(t[0])) paths.push(tokens[++i] ?? ""); // rename/copy: next token is the source path + } + return paths.filter(Boolean); + } catch { + return []; + } +} + +/** Record HEAD as this session's baseline — once. An existing file wins (a --resume + * re-fires SessionStart and must NOT move the anchor mid-session). Also snapshots the + * files ALREADY dirty at session start (even on an unborn HEAD), so the completion + * gate never attributes pre-existing dirt to this session. */ +export function recordBaseline(root, sid) { + const head = git(root, ["rev-parse", "HEAD"]); + const dirtyPath = sessionPath(root, sid, "dirty"); + try { + if (git(root, ["rev-parse", "--is-inside-work-tree"]) === "true" && !existsSync(dirtyPath)) { + mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); + writeFileSync(dirtyPath, `${statusPathsZ(root).join("\n")}\n`); + } + } catch {} + if (!head) return { recorded: false, head: null }; // unborn HEAD → dirty snapshot only + const p = sessionPath(root, sid, "base"); + try { + if (existsSync(p)) return { recorded: false, head: readFileSync(p, "utf8").trim() }; + mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); + writeFileSync(p, `${head}\n`); + return { recorded: true, head }; + } catch { + return { recorded: false, head }; + } +} + +/** The set of paths that were already dirty when the session started (empty when the + * snapshot is missing — degraded mode, gate errs toward its other guards). */ +export function readDirtySnapshot(root, sid) { + try { + const p = sessionPath(root, sid, "dirty"); + if (!existsSync(p)) return null; + return new Set(readFileSync(p, "utf8").split("\n").filter(Boolean)); + } catch { + return null; + } +} + +/** The session's anchor: baseline sha + the file's mtime (= when the session started — + * the completion gate compares state.md's mtime against it). Null when never recorded. */ +export function readBaseline(root, sid) { + const p = sessionPath(root, sid, "base"); + try { + if (!existsSync(p)) return null; + return { head: readFileSync(p, "utf8").trim(), t: statSync(p).mtimeMs }; + } catch { + return null; + } +} + +/** Age out stale per-session artifacts (logs, baselines, markers) in one sweep. */ +export function pruneSessions(root, { maxAgeDays = 7, now = Date.now() } = {}) { + const dir = join(root, ".forge", "sessions"); + let removed = 0; + try { + for (const f of readdirSync(dir)) { + try { + if (now - statSync(join(dir, f)).mtimeMs > maxAgeDays * 86_400_000) { + unlinkSync(join(dir, f)); + removed += 1; + } + } catch {} + } + } catch {} + return { removed }; +} + +/** SessionStart injection: recent commits + uncommitted changes — the repo's actual + * recent history, so a fresh session orients on evidence instead of assumptions. + * Empty string outside a git repo (low-nag). */ +export function rehydrationBlock(root, { commits = 10, statusCap = 20 } = {}) { + const log = git(root, ["log", "--oneline", `-${commits}`]) + .split("\n") + .filter(Boolean); + if (!log.length) return ""; + const lines = [ + `## Where this repo stands (${BRAND.brand})`, + "Recent commits:", + ...log.map((l) => `- ${l}`), + ]; + const status = git(root, ["status", "--short"]).split("\n").filter(Boolean); + if (status.length) { + lines.push( + "Uncommitted changes at session start:", + ...status.slice(0, statusCap).map((s) => `- ${s}`), + ); + if (status.length > statusCap) lines.push(`- (+${status.length - statusCap} more)`); + } + lines.push(""); + return lines.join("\n"); +} diff --git a/src/substrate.js b/src/substrate.js index 62ab9d6..35f3c25 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -489,6 +489,17 @@ export function substrateContext(result) { ); if (result.memory.matchingLessons) lines.push(`- ${result.memory.matchingLessons} past lesson(s) match this area (advisory).`); + // I3: proceeding is fine below the ask-threshold, but never SILENTLY — the gaps are + // named here and recorded to the session log (the handoff surfaces them later). + if ( + !result.assumption.shouldAsk && + ((result.assumption.missing?.length ?? 0) > 0 || result.assumption.questions?.length > 0) + ) { + const keys = (result.assumption.missing ?? []).map((m) => m.key); + lines.push( + `- Proceeding without asking under ${keys.length || result.assumption.questions.length} recorded assumption(s)${keys.length ? ` (${keys.join(", ")})` : ""}. Verify them before claiming done.`, + ); + } lines.push(`- Verify with: ${result.verification.checklist.join(" · ")}`); return lines.join("\n"); } diff --git a/test/assumption_trail.test.js b/test/assumption_trail.test.js new file mode 100644 index 0000000..0fc2aaa --- /dev/null +++ b/test/assumption_trail.test.js @@ -0,0 +1,91 @@ +// Wave: recorded assumptions + CUSUM drift — the evidence trail between "the advisory +// said something" and "the session actually recorded it for the gate and the handoff". +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { setGoal } from "../src/goal.js"; + +const ENTRY = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url)); +const feed = (mode, payload) => + spawnSync("node", [ENTRY, mode], { input: JSON.stringify(payload), encoding: "utf8" }); + +function gitFixture() { + const root = mkdtempSync(join(tmpdir(), "forge-trail-")); + const git = (...args) => + execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "tax.js"), "export function computeTax(x){ return x*0.2 }\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "fixture"); + return { root, git }; +} + +const events = (root, sid) => + readFileSync(join(root, ".forge", "sessions", `${sid}.jsonl`), "utf8") + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)); + +test("preflight records a drift score per prompt once a goal is anchored", () => { + const { root } = gitFixture(); + setGoal(root, "improve tax calculation accuracy"); + writeFileSync(join(root, "src", "unrelated.js"), "export const x = 1;\n"); + feed("preflight", { session_id: "t1", cwd: root, prompt: "tweak the logging colors" }); + const drift = events(root, "t1").filter((e) => e.type === "drift"); + assert.equal(drift.length, 1, "one drift observation per prompt"); + assert.ok(drift[0].score > 0, "off-goal change scored above zero"); +}); + +test("proceeding under assumptions is RECORDED and named in the advisory", () => { + const { root } = gitFixture(); + const prompt = "refactor src/tax.js keeping behavior, verify with the test suite"; + const r = feed("preflight", { session_id: "t2", cwd: root, prompt }); + const recorded = events(root, "t2").filter((e) => e.type === "assumption"); + assert.equal(recorded.length, 1, "assumption event persisted for the handoff"); + assert.ok(recorded[0].missing.includes("constraints"), JSON.stringify(recorded[0])); + const ctx = JSON.parse(r.stdout).hookSpecificOutput.additionalContext; + assert.match(ctx, /Proceeding without asking under 1 recorded assumption/, ctx); +}); + +test("sustained drift (CUSUM alarm) is named in the completion-gate block reason", () => { + const { root } = gitFixture(); + feed("session-start", { session_id: "t3", cwd: root }); + mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); + for (let i = 0; i < 4; i += 1) + appendFileSync( + join(root, ".forge", "sessions", "t3.jsonl"), + `${JSON.stringify({ type: "drift", score: 1 })}\n`, + ); + writeFileSync(join(root, "src", "tax.js"), "export function computeTax(x){ return x*0.21 }\n"); + const r = feed("stop-gate", { session_id: "t3", cwd: root }); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, "block"); + assert.match(out.reason, /CUSUM/, "drift alarm rides the repair checklist"); + assert.match(out.reason, /anchor/, "and points back at the goal"); +}); + +test("a session with no goal and no assumptions records nothing extra", () => { + const { root } = gitFixture(); + feed("preflight", { + session_id: "t4", + cwd: root, + prompt: "update computeTax in src/tax.js to round half up", + }); + let evts = []; + try { + evts = events(root, "t4"); + } catch {} + assert.equal(evts.filter((e) => e.type === "drift").length, 0, "no goal → no drift series"); + assert.equal( + evts.filter((e) => e.type === "assumption").length, + 0, + "fully specified → no record", + ); +}); 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"); +}); diff --git a/test/cortex_hook_main.test.js b/test/cortex_hook_main.test.js index 8226036..1824e32 100644 --- a/test/cortex_hook_main.test.js +++ b/test/cortex_hook_main.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -61,3 +61,33 @@ test("session-start on a fresh repo injects nothing (no noise)", () => { assert.equal(r.status, 0); assert.equal(r.stdout.trim(), ""); }); + +test("session-start anchors a git baseline and injects state + repo history", () => { + const root = mkdtempSync(join(tmpdir(), "forge-entry-")); + const git = (...args) => + execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + writeFileSync(join(root, "a.js"), "export const one = 1;\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "anchor fixture"); + const r = feed("session-start", { session_id: "anchor-1", cwd: root }); + assert.equal(r.status, 0); + const base = join(root, ".forge", "sessions", "anchor-1.base"); + assert.ok(existsSync(base), "baseline recorded at session start"); + const sha = readFileSync(base, "utf8").trim(); + assert.match(sha, /^[0-9a-f]{40}$/); + const out = JSON.parse(r.stdout); + assert.match( + out.hookSpecificOutput.additionalContext, + /anchor fixture/, + "recent commits injected", + ); + // Re-fired session-start (resume) keeps the anchor even after a new commit. + writeFileSync(join(root, "a.js"), "export const one = 2;\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "later commit"); + feed("session-start", { session_id: "anchor-1", cwd: root }); + assert.equal(readFileSync(base, "utf8").trim(), sha, "resume never moves the anchor"); +}); diff --git a/test/decide.test.js b/test/decide.test.js new file mode 100644 index 0000000..621b77e --- /dev/null +++ b/test/decide.test.js @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { appendDecision, decisionsPath, listDecisions, parseDecisions } from "../src/decide.js"; +import { loadClaims } from "../src/ledger_store.js"; +import { fakeAnthropic } from "./_fixtures.js"; + +test("appendDecision numbers sequentially and never rewrites prior lines", () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + const r1 = appendDecision(root, "use exemplar k-NN over keyword lists — decisions stay math"); + assert.equal(r1.ok, true); + assert.equal(r1.id, "D-0001"); + const r2 = appendDecision(root, "state.md is rewritten, decisions.md is append-only"); + assert.equal(r2.id, "D-0002"); + const text = readFileSync(decisionsPath(root), "utf8"); + assert.match(text, /D-0001/); + assert.match(text, /D-0002/); + assert.ok(text.indexOf("D-0001") < text.indexOf("D-0002"), "chronological append order"); + assert.match(text, /# Decisions/, "header written once"); + assert.equal((text.match(/# Decisions/g) || []).length, 1); +}); + +test("listDecisions returns the most recent entries; empty on a fresh repo", () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + assert.deepEqual(listDecisions(root), []); + for (let i = 0; i < 12; i += 1) appendDecision(root, `decision number ${i} — because`); + const last = listDecisions(root, { limit: 10 }); + assert.equal(last.length, 10); + assert.match(last[9].text, /number 11/); +}); + +test("appendDecision refuses empty text and secrets", () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + assert.equal(appendDecision(root, " ").ok, false); + const s = appendDecision(root, `store ${fakeAnthropic("AAAAbbbbCCCCddddEEEEffff")} in env`); + assert.equal(s.ok, false); + assert.match(s.reason, /secret/); +}); + +test("a decision mints a machine-readable ledger twin (best-effort)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + const r = appendDecision(root, "gate blocks once per session — repeated nagging trains bypass"); + assert.equal(r.ok, true); + const claims = loadClaims(join(root, ".forge", "ledger")); + const twin = claims.find((c) => c.kind === "decision"); + assert.ok(twin, "decision claim exists"); + assert.match(twin.body.text, /blocks once per session/); +}); + +test("a corrupted decisions file is tolerated — numbering restarts from parseable max", () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + decisionsPath(root), + "# Decisions\n\ngarbage line no entry\n- **D-0007** (2026-01-01): earlier decision\nmore garbage\n", + ); + const r = appendDecision(root, "new decision after corruption"); + assert.equal(r.id, "D-0008", "continues from the max parseable id"); + const parsed = parseDecisions(readFileSync(decisionsPath(root), "utf8")); + assert.equal(parsed.length, 2, "garbage ignored, both real entries parse"); +}); + +test("concurrent appends never duplicate ids or headers (mkdir lock)", async () => { + const root = mkdtempSync(join(tmpdir(), "forge-decide-")); + const { spawn } = await import("node:child_process"); + const one = (i) => + new Promise((resolve) => { + const p = spawn( + "node", + [ + "-e", + `import("${process.cwd()}/src/decide.js").then(m => m.appendDecision(process.argv[1], "parallel decision ${"n"}${i} — race test"))`, + root, + ], + { stdio: "ignore" }, + ); + p.on("exit", resolve); + }); + await Promise.all(Array.from({ length: 8 }, (_, i) => one(i))); + const text = readFileSync(decisionsPath(root), "utf8"); + const ids = [...text.matchAll(/\*\*D-(\d{4,})\*\*/g)].map((m) => m[1]); + assert.equal(ids.length, 8, `all 8 decisions written: ${text}`); + assert.equal(new Set(ids).size, 8, `ids unique: ${ids.join(",")}`); + assert.equal((text.match(/# Decisions/g) || []).length, 1, "exactly one header"); +}); diff --git a/test/docs_sync.test.js b/test/docs_sync.test.js new file mode 100644 index 0000000..68da079 --- /dev/null +++ b/test/docs_sync.test.js @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "../src/atlas.js"; +import { changedIdentifiers, docSet, docsSyncReport } from "../src/docs_sync.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +function gitFixture() { + const root = mkdtempSync(join(tmpdir(), "forge-docsync-")); + const git = (...args) => + execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "val.js"), "export function validateOrder(x){ return !!x }\n"); + writeFileSync( + join(root, "README.md"), + "# app\n\nUse `validateOrder` from `src/val.js` to check orders.\n", + ); + writeFileSync(join(root, "SILENT.md"), "# notes\n\nNothing about that code here.\n"); + writeFileSync(join(root, "CHANGELOG.md"), "# changes\n\n- added validateOrder\n"); + const commit = (m) => { + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", m); + }; + commit("fixture"); + return { root, git, commit }; +} + +test("changedIdentifiers: paths + definitions + calls from added AND removed lines", () => { + const { root } = gitFixture(); + writeFileSync( + join(root, "src", "val.js"), + "export function validateOrderStrict(x){ return checkLimits(x) }\n", + ); + const { identifiers, changedFiles } = changedIdentifiers(root, { base: "HEAD" }); + assert.ok(changedFiles.includes("src/val.js")); + assert.ok(identifiers.includes("src/val.js"), "the changed path is an identifier"); + assert.ok(identifiers.includes("validateOrderStrict"), "new definition captured"); + assert.ok(identifiers.includes("validateOrder"), "REMOVED definition captured too"); + assert.ok(identifiers.includes("checkLimits"), "called symbol captured"); +}); + +test("docsSyncReport: stale doc cited file:line; silent doc verified with reason; CHANGELOG exempt", () => { + const { root } = gitFixture(); + build({ root }); + writeFileSync(join(root, "src", "val.js"), "export function validateOrderV2(x){ return !!x }\n"); + const r = docsSyncReport(root, { base: "HEAD" }); + const stale = r.stale.find((s) => s.file === "README.md"); + assert.ok(stale, `README flagged stale: ${JSON.stringify(r.stale)}`); + assert.ok( + stale.hits.some((h) => h.identifier === "validateOrder" && h.line === 3), + "hit cites the identifier and line", + ); + const silent = r.unaffected.find((v) => v.file === "SILENT.md"); + assert.ok(silent, "silent doc present in unaffected"); + assert.match(silent.reason, /mentions none/, "verified-unaffected records its reason"); + assert.ok( + !r.stale.some((s) => /CHANGELOG/.test(s.file)) && + !r.unaffected.some((v) => /CHANGELOG/.test(v.file)), + "append-only history is not swept", + ); +}); + +test("a doc edited in the same diff reports UPDATED, not STALE", () => { + const { root } = gitFixture(); + build({ root }); + writeFileSync(join(root, "src", "val.js"), "export function validateOrderV2(x){ return !!x }\n"); + writeFileSync( + join(root, "README.md"), + "# app\n\nUse `validateOrderV2` from `src/val.js` to check orders.\n", + ); + const r = docsSyncReport(root, { base: "HEAD" }); + assert.ok( + r.updated.some((u) => u.file === "README.md"), + "doc moved with the change", + ); + assert.ok(!r.stale.some((s) => s.file === "README.md")); +}); + +test("docSet EXCLUDES the state snapshot (machine-written bookkeeping, not prose); non-git root yields a clean report", () => { + const { root } = gitFixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + // handoff writes the changed-file list into state.md BY DESIGN — scanning it would + // flag the sweep's own bookkeeping as stale, an unfixable self-reference. + writeFileSync(join(root, ".forge", "state.md"), "# Session state\n\n- M src/val.js\n"); + build({ root }); + assert.ok(!docSet(root).includes(join(".forge", "state.md"))); + writeFileSync(join(root, "src", "val.js"), "export function validateOrderX(x){ return !!x }\n"); + const r0 = docsSyncReport(root, { base: "HEAD" }); + assert.ok( + !r0.stale.some((s) => s.file.includes("state.md")), + "state snapshot never reported stale", + ); + const bare = mkdtempSync(join(tmpdir(), "forge-docsync-")); + const r = docsSyncReport(bare); + assert.deepEqual(r.changedFiles, []); + assert.deepEqual(r.stale, []); +}); + +test("a renamed symbol stays STALE in a doc that was touched for another reason", () => { + const { root, commit } = gitFixture(); + build({ root }); + commit("atlas"); + // Rename validateOrder → checkOrder, and touch README's intro WITHOUT fixing the mention. + writeFileSync(join(root, "src", "val.js"), "export function checkOrder(x){ return !!x }\n"); + writeFileSync( + join(root, "README.md"), + "# app (new tagline)\n\nUse `validateOrder` from `src/val.js` to check orders.\n", + ); + const r = docsSyncReport(root, { base: "HEAD" }); + const stale = r.stale.find((s) => s.file === "README.md"); + assert.ok(stale, `touched README still flagged: ${JSON.stringify(r)}`); + assert.ok(stale.hits.some((h) => h.identifier === "validateOrder")); + assert.match(stale.note, /REMOVED/, "the reason names the removed-symbol case"); +}); + +test("lowercase symbols scan only inside backticks (code span = code reference)", () => { + const { root, commit } = gitFixture(); + writeFileSync(join(root, "MATHDOC.md"), "# math\n\nThe `cusum` detector accumulates drift.\n"); + writeFileSync( + join(root, "PROSE.md"), + "# prose\n\nWe check the cusum of the queue in plain words here.\n", + ); + commit("docs"); + build({ root }); + commit("atlas"); + writeFileSync(join(root, "src", "val.js"), "export function cusum(s){ return s.length }\n"); + const r = docsSyncReport(root, { base: "HEAD" }); + assert.ok(r.soft.includes("cusum"), `cusum is a soft identifier: ${JSON.stringify(r.soft)}`); + assert.ok( + r.stale.some((s) => s.file === "MATHDOC.md"), + "backticked mention flags", + ); + assert.ok(!r.stale.some((s) => s.file === "PROSE.md"), "plain-prose word does not flag"); +}); + +test("an unknown explicit --base errors instead of silently mislabeling", () => { + const { root } = gitFixture(); + const r = docsSyncReport(root, { base: "totally-bogus-ref" }); + assert.match(r.error, /unknown base ref/); + const cli = spawnSync("node", [CLI, "docs", "sync", "--base", "totally-bogus-ref"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(cli.status, 1, "CLI exits 1 on a bad ref"); + assert.match(cli.stdout, /unknown base ref/); +}); + +test("CLI: docs sync --json reports; --strict exits 1 only when stale docs exist", () => { + const { root, commit } = gitFixture(); + build({ root }); + commit("atlas artifacts"); + writeFileSync(join(root, "src", "val.js"), "export function validateOrderV3(x){ return !!x }\n"); + const strict = spawnSync("node", [CLI, "docs", "sync", "--strict"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(strict.status, 1, `stale docs fail --strict: ${strict.stdout}`); + const lax = spawnSync("node", [CLI, "docs", "sync", "--json"], { cwd: root, encoding: "utf8" }); + assert.equal(lax.status, 0, "advisory by default"); + const parsed = JSON.parse(lax.stdout); + assert.ok(parsed.stale.some((s) => s.file === "README.md")); +}); diff --git a/test/gate.test.js b/test/gate.test.js new file mode 100644 index 0000000..e44c702 --- /dev/null +++ b/test/gate.test.js @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { classifyPath, gateDecision } from "../src/gate.js"; + +test("classifyPath: one total function from the shared registries", () => { + assert.equal(classifyPath(".forge/state.md"), "docs", "state snapshot IS the doc signal"); + assert.equal(classifyPath(".forge/decisions.md"), "docs"); + assert.equal(classifyPath(".forge/lessons/x.md"), "internal"); + assert.equal(classifyPath("AGENTS.md"), "internal", "generated instruction files owe nothing"); + assert.equal(classifyPath("CLAUDE.md"), "internal"); + assert.equal(classifyPath("README.md"), "docs"); + assert.equal(classifyPath("docs/GUIDE.md"), "docs"); + assert.equal(classifyPath("CHANGELOG.md"), "docs", "a changelog entry satisfies the gate"); + assert.equal(classifyPath("src/route.js"), "code"); + assert.equal(classifyPath("lib/store.py"), "code"); + assert.equal(classifyPath("test/route.test.js"), "test"); + assert.equal(classifyPath("src/__tests__/x.jsx"), "test"); + assert.equal(classifyPath("Dockerfile"), "config"); + assert.equal(classifyPath(".github/workflows/ci.yml"), "config"); + assert.equal(classifyPath("vite.config.ts"), "config"); + assert.equal(classifyPath("logo.png"), "other"); + assert.equal(classifyPath("package-lock.json"), "other", "lockfiles are churn, not config"); +}); + +test("gate table: guard rows always allow", () => { + assert.equal(gateDecision({ stopHookActive: true }).row, "stop-hook-active"); + assert.equal(gateDecision({ isRepo: false }).row, "not-a-repo"); + assert.equal(gateDecision({ markerExists: true }).row, "already-blocked"); + assert.equal(gateDecision({ killSwitch: true }).row, "kill-switch"); + for (const r of [ + gateDecision({ stopHookActive: true }), + gateDecision({ isRepo: false }), + gateDecision({ markerExists: true }), + gateDecision({ killSwitch: true }), + ]) + assert.equal(r.allow, true); +}); + +test("gate table: clean and internal-only sessions owe nothing", () => { + assert.equal(gateDecision({ changed: [] }).row, "no-changes"); + const internal = gateDecision({ changed: [".forge/lessons/a.md", "AGENTS.md"] }); + assert.equal(internal.row, "no-changes", "internal artifacts never trigger the gate"); + assert.equal(internal.allow, true); +}); + +test("gate table: THE row — code moved, no doc/state followed → block", () => { + const r = gateDecision({ changed: ["src/route.js", "src/gate.js"] }); + assert.equal(r.allow, false); + assert.equal(r.row, "code-without-docs"); + assert.deepEqual(r.classes.code, ["src/route.js", "src/gate.js"]); +}); + +test("gate table: any doc-class artifact (or a state touch) satisfies the floor", () => { + assert.equal(gateDecision({ changed: ["src/route.js", "README.md"] }).row, "docs-touched"); + assert.equal(gateDecision({ changed: ["src/route.js"], stateTouched: true }).row, "docs-touched"); + assert.equal(gateDecision({ changed: ["docs/GUIDE.md"] }).row, "docs-touched"); + assert.equal( + gateDecision({ changed: ["src/x.js", ".forge/state.md"] }).row, + "docs-touched", + "the gitignore-invisible snapshot counts via the changed set too", + ); +}); + +test("gate table: test-only, config-only, and other-only changes pass (precision rule)", () => { + assert.equal(gateDecision({ changed: ["test/gate.test.js"] }).row, "no-code-class"); + assert.equal(gateDecision({ changed: [".github/workflows/ci.yml"] }).row, "no-code-class"); + assert.equal(gateDecision({ changed: ["assets/logo.png"] }).row, "no-code-class"); + assert.equal( + gateDecision({ changed: ["src/x.js", "test/x.test.js"] }).allow, + false, + "code + tests but NO docs still blocks — tests are not prose", + ); +}); diff --git a/test/handoff.test.js b/test/handoff.test.js new file mode 100644 index 0000000..8b4b45d --- /dev/null +++ b/test/handoff.test.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { gatherGitFacts, readState, stateBlock, statePath, writeState } from "../src/handoff.js"; +import { fakeAnthropic } from "./_fixtures.js"; + +function gitFixture() { + const root = mkdtempSync(join(tmpdir(), "forge-handoff-")); + const git = (...args) => + execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + writeFileSync(join(root, "a.js"), "export const one = 1;\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "fixture"); + return root; +} + +test("writeState produces every section, bounded, and rewrites (never appends)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-handoff-")); + const r = writeState(root, { done: ["built the gate"], next: ["wire manifests"] }); + assert.equal(r.ok, true); + const text = readFileSync(statePath(root), "utf8"); + for (const s of [ + "Goal / Phase", + "Acceptance criteria", + "Done this session", + "Next steps", + "Gotchas", + "Open assumptions", + "In-progress files", + "Decisions", + ]) + assert.match(text, new RegExp(`## ${s}`), `section ${s} present`); + assert.match(text, /built the gate/); + // Second write REPLACES the first — bounded snapshot, not a growing log. + writeState(root, { done: ["second session"] }); + const after = readFileSync(statePath(root), "utf8"); + assert.doesNotMatch(after, /built the gate/, "old rows do not accumulate"); + assert.match(after, /second session/); +}); + +test("writeState caps at maxLines and refuses empty or secret-bearing handoffs", () => { + const root = mkdtempSync(join(tmpdir(), "forge-handoff-")); + const many = Array.from({ length: 400 }, (_, i) => `row ${i}`); + const r = writeState(root, { done: many }, { maxLines: 60 }); + assert.equal(r.ok, true); + const lineCount = readFileSync(statePath(root), "utf8").trimEnd().split("\n").length; + assert.ok(lineCount <= 61, `bounded: got ${lineCount} lines`); + assert.equal(writeState(root, {}).ok, false, "empty handoff refused"); + // Runtime-assembled credential shape — never a literal (gitleaks scans history). + const s = writeState(root, { done: [`the key is ${fakeAnthropic("AAAAbbbbCCCCddddEEEEffff")}`] }); + assert.equal(s.ok, false); + assert.match(s.reason, /secret/); +}); + +test("stateBlock injects the snapshot, empty when none exists, capped when long", () => { + const root = mkdtempSync(join(tmpdir(), "forge-handoff-")); + assert.equal(stateBlock(root), "", "fresh repo injects nothing"); + writeState(root, { done: ["a thing"], next: Array.from({ length: 120 }, (_, i) => `n${i}`) }); + const block = stateBlock(root, { maxLines: 20 }); + assert.match(block, /Session state/); + assert.match(block, /a thing/); + assert.match(block, /truncated/, "overflow becomes a pointer, not silent growth"); +}); + +test("gatherGitFacts: branch + dirty files inside a repo, empty-safe outside", () => { + const root = gitFixture(); + writeFileSync(join(root, "b.js"), "export const two = 2;\n"); + const facts = gatherGitFacts(root); + assert.ok(facts.branch.length > 0, "branch known"); + assert.ok( + facts.status.some((s) => s.includes("b.js")), + "dirty file listed", + ); + const bare = gatherGitFacts(mkdtempSync(join(tmpdir(), "forge-handoff-"))); + assert.equal(bare.branch, ""); + assert.deepEqual(bare.status, []); +}); + +test("writeState surfaces recorded assumption events from the newest session log", () => { + const root = mkdtempSync(join(tmpdir(), "forge-handoff-")); + mkdirSync(join(root, ".forge", "sessions"), { recursive: true }); + writeFileSync( + join(root, ".forge", "sessions", "s1.jsonl"), + `${JSON.stringify({ type: "assumption", missing: ["target_scope"], ambiguous: ["it"] })}\n`, + ); + writeState(root, { done: ["x"] }); + const text = readFileSync(statePath(root), "utf8"); + assert.match(text, /target_scope/, "assumption keys carried into the handoff"); +}); + +test("a row containing '