diff --git a/.gitignore b/.gitignore index cc8e48d67..3855a1edb 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ packages/junior-evals/.vitest-evals/recordings/**/*.json packages/junior-evals/vitest-results*.json packages/junior-evals/eval-results/ .env* + +# Swamp local runtime (Garfield MVP and other dev automation) +.swamp/ +.swamp-sources.yaml diff --git a/.swamp.yaml b/.swamp.yaml new file mode 100644 index 000000000..f1b5d2120 --- /dev/null +++ b/.swamp.yaml @@ -0,0 +1,5 @@ +swampVersion: 20260801.231848.0 +initializedAt: "2026-08-02T02:53:03.465Z" +repoId: 30afd46c-7497-4c4c-b0a6-1571ff3f9917 +tools: [] +gitignoreManaged: false diff --git a/AGENTS.md b/AGENTS.md index 421acd3d8..595beaed7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,5 +61,18 @@ Use **pnpm**: `pnpm install`, `pnpm dev`, `pnpm test`, `pnpm typecheck`, `pnpm s | Testing and evals | `policies/testing.md`, `policies/evals.md`, `packages/junior/tests/README.md`, `packages/junior-evals/README.md` | | Local agent validation | `packages/docs/src/content/docs/contribute/local-agent-validation.md` | | Temporary plans | `openspec/changes//` | +| Garfield dev loop | `skills/garfield/SKILL.md`, `swamp/README.md`, `scripts/garfield/run.mjs` | Feature architecture and non-obvious invariants belong in the owning package or module `README.md`. Code, schemas, exported types, and tests are authoritative. Plans cannot override policy; update the policy for an exception. + +## Garfield Dev Loop (optional) + +When the user asks to run Garfield on a Junior worktree, load `skills/garfield/SKILL.md` and own the full loop. Do not hand back a manual findings checklist. + +```bash +node scripts/garfield/run.mjs --goal '...' +# review lanes, write findings, fix, then: +node scripts/garfield/run.mjs --finalize +``` + +Docs: `swamp/README.md`. This is development tooling, not product runtime. diff --git a/package.json b/package.json index 11737157e..304ba55da 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,11 @@ "evals:record": "pnpm --filter @sentry/junior-evals evals:record", "typecheck": "pnpm -r run typecheck", "skills:check": "pnpm --filter @sentry/junior skills:check", - "test:ci": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test:coverage && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test:coverage" + "test:ci": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test:coverage && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test:coverage", + "garfield:test": "node --test scripts/garfield/lib.test.mjs", + "garfield:bundle": "node scripts/garfield/build-bundle.mjs", + "garfield": "node scripts/garfield/run.mjs", + "garfield:finalize": "node scripts/garfield/run.mjs --finalize" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/scripts/garfield/build-bundle.mjs b/scripts/garfield/build-bundle.mjs new file mode 100755 index 000000000..e767a2ee5 --- /dev/null +++ b/scripts/garfield/build-bundle.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import path from "node:path"; +import { + buildBundle, + createRunId, + defaultRunDir, + ensureDir, + parseArgs, + resolveRepoRoot, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error(`Usage: node scripts/garfield/build-bundle.mjs --goal [options] + +Options: + --goal Required slice goal / user intent + --non-goal Repeatable non-goal + --base Diff base (default: merge-base with origin/main) + --run-id Stable run id (default: generated) + --run-dir Output directory (default: .swamp/garfield/) + --repo-root Repo root (default: discover from cwd) +`); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (!args.goal || args.goal === true) usage(1); + +const repoRoot = resolveRepoRoot( + typeof args["repo-root"] === "string" && args["repo-root"] + ? args["repo-root"] + : process.cwd(), +); +const nonGoals = [] + .concat(args["non-goal"] ?? []) + .concat(args["non-goals"] ?? []) + .flatMap((value) => { + if (Array.isArray(value)) return value; + if (typeof value === "string") { + return value.includes(",") + ? value.split(",").map((part) => part.trim()) + : [value]; + } + return []; + }) + .filter((value) => typeof value === "string" && value.length > 0); + +const runId = + typeof args["run-id"] === "string" && args["run-id"] + ? args["run-id"] + : createRunId(); +const runDir = + typeof args["run-dir"] === "string" && args["run-dir"] + ? path.resolve(args["run-dir"]) + : defaultRunDir(repoRoot, runId); + +ensureDir(runDir); + +const bundle = buildBundle({ + repoRoot, + goal: String(args.goal), + nonGoals, + baseRef: + typeof args.base === "string" && args.base ? args.base : undefined, + runId, +}); + +const bundlePath = path.join(runDir, "bundle.json"); +writeJson(bundlePath, bundle); +writeJson(path.join(runDir, "run.json"), { + runId, + runDir, + bundlePath, + createdAt: bundle.createdAt, +}); + +console.log( + JSON.stringify( + { + ok: true, + runId, + runDir, + bundlePath, + changedFiles: bundle.changedFiles.length, + packages: bundle.packages, + contentHash: bundle.contentHash, + }, + null, + 2, + ), +); diff --git a/scripts/garfield/capture-run-dir.mjs b/scripts/garfield/capture-run-dir.mjs new file mode 100755 index 000000000..7c98fe34c --- /dev/null +++ b/scripts/garfield/capture-run-dir.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { ensureDir, parseArgs, resolveRepoRoot, writeJson } from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/capture-run-dir.mjs --from ", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args.from !== "string") usage(1); + +const repoRoot = resolveRepoRoot(process.cwd()); +const fromPath = path.resolve(args.from); +const built = JSON.parse(fs.readFileSync(fromPath, "utf8")); +if (!built.runDir || !built.runId) { + console.error("build output missing runDir/runId", built); + process.exit(1); +} + +const pointerDir = path.join(repoRoot, ".swamp", "garfield"); +ensureDir(pointerDir); +const pointerPath = path.join(pointerDir, "last-run.json"); +writeJson(pointerPath, { + ok: true, + runId: built.runId, + runDir: built.runDir, + bundlePath: built.bundlePath, + capturedAt: new Date().toISOString(), +}); + +console.log( + JSON.stringify( + { + ok: true, + runId: built.runId, + runDir: built.runDir, + pointerPath, + }, + null, + 2, + ), +); diff --git a/scripts/garfield/classify-lanes.mjs b/scripts/garfield/classify-lanes.mjs new file mode 100755 index 000000000..2b8a83380 --- /dev/null +++ b/scripts/garfield/classify-lanes.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import path from "node:path"; +import { + classifyLanes, + parseArgs, + readJson, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/classify-lanes.mjs --run-dir [--profile core|full]", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args["run-dir"] !== "string") usage(1); + +const runDir = path.resolve(args["run-dir"]); +const profile = args.profile === "full" ? "full" : "core"; +const bundle = readJson(path.join(runDir, "bundle.json")); +const lanes = classifyLanes(bundle.changedFiles, bundle.policies, { profile }); +const applicable = lanes.filter((lane) => lane.status === "applicable"); +const skipped = lanes.filter((lane) => lane.status === "skipped"); + +const plan = { + version: 1, + runId: bundle.runId, + contentHash: bundle.contentHash, + profile, + lanes, + applicableLaneIds: applicable.map((lane) => lane.id), + skippedLaneIds: skipped.map((lane) => lane.id), +}; + +writeJson(path.join(runDir, "lane-plan.json"), plan); + +console.log( + JSON.stringify( + { + ok: true, + runId: bundle.runId, + profile, + applicable: applicable.length, + skipped: skipped.length, + applicableLaneIds: plan.applicableLaneIds, + planPath: path.join(runDir, "lane-plan.json"), + }, + null, + 2, + ), +); diff --git a/scripts/garfield/lib.mjs b/scripts/garfield/lib.mjs new file mode 100644 index 000000000..8dbf4b270 --- /dev/null +++ b/scripts/garfield/lib.mjs @@ -0,0 +1,841 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const NATIVE_LANES = [ + { + id: "behavior-spec", + title: "Behavior/spec", + always: true, + card: "skills/garfield/references/review-lanes.md#behavior-spec", + }, + { + id: "repository-instructions", + title: "Repository instructions", + always: true, + card: "skills/garfield/references/review-lanes.md#repository-instructions", + }, + { + id: "validation-sufficiency", + title: "Validation sufficiency", + always: true, + card: "skills/garfield/references/review-lanes.md#validation-sufficiency", + }, + { + id: "specs-docs", + title: "Specs/docs", + always: false, + card: "skills/garfield/references/review-lanes.md#specsdocs", + match: (ctx) => + matchesAny(ctx.changedFiles, [ + /(^|\/)(README|AGENTS|CONTRIBUTING|TERMINOLOGY|TELEMETRY)\.md$/i, + /(^|\/)docs\//, + /(^|\/)packages\/docs\//, + /\.mdx?$/, + /SPEC\.md$/i, + /SOURCES\.md$/i, + ]) || ctx.touchesBehaviorishCode, + }, + { + id: "dead-code", + title: "Dead code", + always: false, + card: "skills/garfield/references/review-lanes.md#dead-code", + match: (ctx) => + ctx.statusLetters.some((letter) => /[DTRC]/.test(letter)) || + ctx.touchesCode, + }, + { + id: "delayering", + title: "Delayering", + always: false, + card: "skills/garfield/references/review-lanes.md#delayering", + match: (ctx) => ctx.touchesCode, + }, + { + id: "type-boundaries", + title: "Type boundaries", + always: false, + card: "skills/garfield/references/review-lanes.md#type-boundaries", + match: (ctx) => + matchesAny(ctx.changedFiles, [/\.tsx?$/, /\.jsx?$/, /\.mjs$/, /\.cjs$/]), + }, + { + id: "generated-dependencies", + title: "Generated/dependencies", + always: false, + card: "skills/garfield/references/review-lanes.md#generateddependencies", + match: (ctx) => + matchesAny(ctx.changedFiles, [ + /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json)$/, + /(^|\/)migrations?\//, + /\.sql$/, + /schema/, + /generated\//, + /\.craft\.yml$/, + /vitest-evals\/recordings\//, + ]), + }, + { + id: "code-comments", + title: "Code comments", + always: false, + card: "skills/garfield/references/code-comments.md", + match: (ctx) => ctx.touchesCode, + }, + { + id: "implementation-minimalism", + title: "Implementation minimalism", + always: false, + card: "skills/garfield/references/implementation-minimalism.md", + match: (ctx) => ctx.touchesCode, + }, + { + id: "interface-design", + title: "Interface design", + always: false, + card: "skills/garfield/references/interface-design.md", + match: (ctx) => + matchesAny(ctx.changedFiles, [ + /\.[cm]?[jt]sx?$/, + /plugin\.yaml$/, + /SKILL\.md$/, + ]), + }, + { + id: "test-quality", + title: "Test quality", + always: false, + card: "skills/garfield/references/test-quality.md", + match: (ctx) => + matchesAny(ctx.changedFiles, [ + /\.test\.[cm]?[jt]sx?$/, + /\.spec\.[cm]?[jt]sx?$/, + /(^|\/)tests?\//, + /(^|\/)__tests__\//, + /\.eval\.ts$/, + ]) || ctx.touchesBehaviorishCode, + }, +]; + +const CODE_RE = /\.(?:[cm]?[jt]sx?|mjs|cjs)$/; +const BEHAVIOR_CODE_RE = + /\.(?:[cm]?[jt]sx?)$|SKILL\.md$|plugin\.yaml$|\/chat\/|\/runtime\/|\/tools\//; + +/** Resolve the monorepo root from this file or an explicit override. */ +export function resolveRepoRoot(cwd = process.cwd()) { + let dir = path.resolve(cwd); + while (true) { + if ( + fs.existsSync(path.join(dir, "pnpm-workspace.yaml")) && + fs.existsSync(path.join(dir, "package.json")) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + return path.resolve(cwd); + } + dir = parent; + } +} + +/** Parse CLI flags of the form --key value and --key=value. Repeated keys become arrays. */ +export function parseArgs(argv) { + const out = { _: [] }; + const assign = (key, value) => { + if (out[key] === undefined) { + out[key] = value; + return; + } + if (Array.isArray(out[key])) { + out[key].push(value); + return; + } + out[key] = [out[key], value]; + }; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--") { + out._.push(...argv.slice(i + 1)); + break; + } + if (!token.startsWith("--")) { + out._.push(token); + continue; + } + const body = token.slice(2); + const eq = body.indexOf("="); + if (eq >= 0) { + assign(body.slice(0, eq), body.slice(eq + 1)); + continue; + } + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) { + assign(body, true); + continue; + } + assign(body, next); + i += 1; + } + return out; +} + +/** Ensure a directory exists. */ +export function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +/** Read JSON or throw a short error. */ +export function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +/** Write JSON with a trailing newline. */ +export function writeJson(filePath, value) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +/** Stable sha256 of text. */ +export function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +/** Run a git command and return stdout, or throw. */ +export function git(repoRoot, args, options = {}) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + ...options, + }); + if (result.status !== 0) { + const stderr = (result.stderr || result.stdout || "").trim(); + throw new Error( + `git ${args.join(" ")} failed (${result.status}): ${stderr || "no output"}`, + ); + } + return (result.stdout || "").replace(/\n$/, ""); +} + +/** Resolve the merge-base style review base for a dirty or clean worktree. */ +export function resolveBaseRef(repoRoot, explicitBase) { + if (explicitBase) { + return explicitBase; + } + if (process.env.GARFIELD_BASE) { + return process.env.GARFIELD_BASE; + } + try { + return git(repoRoot, ["merge-base", "HEAD", "origin/main"]); + } catch { + try { + return git(repoRoot, ["merge-base", "HEAD", "main"]); + } catch { + return git(repoRoot, ["rev-parse", "HEAD^"]); + } + } +} + +/** Collect changed files between base and the current worktree, including unstaged. */ +export function collectChangedFiles(repoRoot, baseRef) { + const names = new Map(); + + const addLines = (text, defaultStatus = "M") => { + for (const line of text.split("\n")) { + if (!line.trim()) continue; + // --name-status can be "M\tpath" or "R100\told\tnew" + const parts = line.split("\t"); + if (parts.length === 1) { + names.set(parts[0], defaultStatus); + continue; + } + const status = parts[0]; + const filePath = parts[parts.length - 1]; + names.set(filePath, status); + } + }; + + addLines(git(repoRoot, ["diff", "--name-status", `${baseRef}...HEAD`])); + addLines(git(repoRoot, ["diff", "--name-status", "HEAD"])); + addLines(git(repoRoot, ["ls-files", "--others", "--exclude-standard"]), "A"); + + return [...names.entries()] + .map(([filePath, status]) => ({ path: filePath, status })) + .sort((a, b) => a.path.localeCompare(b.path)); +} + +/** List policy files under policies/, excluding README and templates. */ +export function listPolicies(repoRoot) { + const policiesDir = path.join(repoRoot, "policies"); + if (!fs.existsSync(policiesDir)) { + return []; + } + return fs + .readdirSync(policiesDir) + .filter( + (name) => + name.endsWith(".md") && + name !== "README.md" && + name !== "policy-template.md", + ) + .sort() + .map((name) => `policies/${name}`); +} + +/** Map changed paths onto pnpm package filters. */ +export function packagesForFiles(repoRoot, files) { + const packages = new Set(); + for (const file of files) { + const match = file.path.match(/^packages\/([^/]+)\//); + if (!match) continue; + const dir = match[1]; + const packageJsonPath = path.join( + repoRoot, + "packages", + dir, + "package.json", + ); + if (!fs.existsSync(packageJsonPath)) continue; + const pkg = readJson(packageJsonPath); + if (pkg.name) packages.add(pkg.name); + } + return [...packages].sort(); +} + +/** Build deterministic validation commands for the slice. */ +export function buildValidationCommands(packages) { + const commands = [ + { + id: "file-length", + command: "pnpm file-length:check", + required: true, + }, + { + id: "test-architecture", + command: "pnpm test-architecture:check", + required: true, + }, + ]; + + if (packages.length > 0) { + for (const pkg of packages) { + commands.push({ + id: `typecheck:${pkg}`, + command: `pnpm --filter ${pkg} run typecheck`, + required: false, + }); + if (pkg === "@sentry/junior") { + commands.push({ + id: `skills-check`, + command: "pnpm skills:check", + required: false, + }); + } + } + } else { + commands.push({ + id: "typecheck", + command: "pnpm typecheck", + required: false, + }); + } + + return commands; +} + +function matchesAny(files, patterns) { + return files.some((file) => patterns.some((re) => re.test(file))); +} + +/** Build the slice context used by lane classification. */ +export function buildSliceContext(changedFiles) { + const paths = changedFiles.map((file) => file.path); + const statusLetters = changedFiles.map((file) => file.status[0] || "M"); + return { + changedFiles: paths, + statusLetters, + touchesCode: paths.some((filePath) => CODE_RE.test(filePath)), + touchesBehaviorishCode: paths.some((filePath) => + BEHAVIOR_CODE_RE.test(filePath), + ), + }; +} + +/** + * Classify native lanes plus optional source policies. + * + * Profiles: + * - core (default): always-on lanes + matched natives. Policies are skipped and + * covered by repository-instructions, keeping the agent loop cheap. + * - full: also opens one lane per source-app policy. + */ +export function classifyLanes(changedFiles, policies, options = {}) { + const profile = options.profile === "full" ? "full" : "core"; + const ctx = buildSliceContext(changedFiles); + const lanes = []; + + for (const lane of NATIVE_LANES) { + if (lane.always || lane.match?.(ctx)) { + lanes.push({ + id: lane.id, + title: lane.title, + kind: "native", + status: "applicable", + reason: lane.always + ? "always applicable" + : "matched current diff signals", + card: lane.card, + modelHint: modelHintForLane(lane.id), + }); + } else { + lanes.push({ + id: lane.id, + title: lane.title, + kind: "native", + status: "skipped", + reason: "no diff signal for this lane", + card: lane.card, + modelHint: modelHintForLane(lane.id), + }); + } + } + + for (const policyPath of policies) { + const id = `policy:${policyPath}`; + const title = path.basename(policyPath, ".md"); + if (profile === "full") { + lanes.push({ + id, + title: `Policy: ${title}`, + kind: "policy", + status: "applicable", + reason: "full profile reviews each source-app policy", + card: policyPath, + modelHint: "cheap", + }); + continue; + } + lanes.push({ + id, + title: `Policy: ${title}`, + kind: "policy", + status: "skipped", + reason: + "core profile defers policies to repository-instructions; use --profile full", + card: policyPath, + modelHint: "cheap", + }); + } + + return lanes; +} + +/** Stable filesystem name for a lane id. */ +export function laneFileStem(laneId) { + return laneId.replace(/\.md$/i, "").replace(/[^a-zA-Z0-9._-]+/g, "__"); +} + +/** Write the agent brief that Junior should execute next. */ +export function writeAgentBrief({ bundle, plan, runDir }) { + const applicable = plan.lanes.filter((lane) => lane.status === "applicable"); + const lines = [ + "# Garfield agent brief", + "", + "You own this loop end-to-end. Do not ask the user to fill findings files.", + "Do not stop after prepare. Complete review → fix → validate → report.", + "", + `runDir: ${runDir}`, + `runId: ${bundle.runId}`, + `goal: ${bundle.goal}`, + `base: ${bundle.baseRef}`, + `head: ${bundle.headSha}`, + `changed files: ${bundle.changedFiles.length}`, + `applicable lanes: ${applicable.length}`, + "", + "## Non-goals", + ...(bundle.nonGoals.length + ? bundle.nonGoals.map((item) => `- ${item}`) + : ["- (none)"]), + "", + "## Changed files", + ...(bundle.changedFiles.length + ? bundle.changedFiles.map((file) => `- ${file.status} ${file.path}`) + : ["- (none)"]), + "", + "## Lane queue (max 3 in flight conceptually; work serially if alone)", + ]; + + for (const [index, lane] of applicable.entries()) { + const stem = laneFileStem(lane.id); + lines.push( + `${index + 1}. **${lane.title}** (${lane.modelHint})`, + ` - prompt: prompts/${stem}.md`, + ` - write findings to: findings/${stem}.txt`, + ` - card: ${lane.card}`, + ); + } + + lines.push( + "", + "## Required procedure", + "1. For each applicable lane, read only that lane prompt + relevant changed files.", + "2. Write `none` or Garfield finding lines into the matching findings file.", + "3. Run: `node scripts/garfield/merge-findings.mjs --run-dir `", + "4. Fix current-diff blocker/high findings with the smallest intent-preserving edits.", + "5. Re-run affected lanes only when a fix changes their scope; update findings files.", + "6. Run: `node scripts/garfield/validate.mjs --run-dir `", + "7. Fix required validation failures, then validate again.", + "8. Run: `node scripts/garfield/report.mjs --run-dir `", + "9. Reply with the report status, residual deferred concerns, and validation results.", + "", + "## Finding format", + "```", + "[severity][evidence: ;cause:introduced|worsened|stale|missing-required] path:line - concern. impact: . fix: .", + "```", + "If no findings: exactly `none`.", + "", + "## Stop conditions", + "- success: no blocker/high left, required validation passes, report is `garfield: pass`", + "- blocked: same concern twice, needs clarification, or fix would expand intent", + "", + ); + + const briefPath = path.join(runDir, "agent-brief.md"); + fs.writeFileSync(briefPath, lines.join("\n"), "utf8"); + + const todoPath = path.join(runDir, "agent-todo.json"); + writeJson(todoPath, { + runId: bundle.runId, + runDir, + goal: bundle.goal, + mode: "agent", + applicableLanes: applicable.map((lane) => ({ + id: lane.id, + title: lane.title, + modelHint: lane.modelHint, + card: lane.card, + promptPath: path.join(runDir, "prompts", `${laneFileStem(lane.id)}.md`), + findingPath: path.join(runDir, "findings", `${laneFileStem(lane.id)}.txt`), + })), + commands: { + merge: `node scripts/garfield/merge-findings.mjs --run-dir ${runDir}`, + validate: `node scripts/garfield/validate.mjs --run-dir ${runDir}`, + report: `node scripts/garfield/report.mjs --run-dir ${runDir}`, + finalize: `node scripts/garfield/run.mjs --finalize --run-dir ${runDir}`, + }, + }); + + return { briefPath, todoPath, applicable }; +} + +function modelHintForLane(id) { + if ( + id === "behavior-spec" || + id === "validation-sufficiency" || + id === "interface-design" || + id === "test-quality" + ) { + return "strong"; + } + return "cheap"; +} + +/** Default run directory under .swamp/garfield/. */ +export function defaultRunDir(repoRoot, runId) { + return path.join(repoRoot, ".swamp", "garfield", runId); +} + +/** Create a short run id. */ +export function createRunId() { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); + return `run-${stamp}-${sha256(String(Math.random())).slice(0, 8)}`; +} + +/** Build the review bundle artifact. */ +export function buildBundle({ + repoRoot, + goal, + nonGoals = [], + baseRef, + headRef = "WORKTREE", + runId, +}) { + const resolvedBase = resolveBaseRef(repoRoot, baseRef); + const changedFiles = collectChangedFiles(repoRoot, resolvedBase); + const policies = listPolicies(repoRoot); + const packages = packagesForFiles(repoRoot, changedFiles); + const validationCommands = buildValidationCommands(packages); + const status = git(repoRoot, ["status", "--short"]); + const headSha = git(repoRoot, ["rev-parse", "HEAD"]); + + const bundle = { + version: 1, + runId, + createdAt: new Date().toISOString(), + goal, + nonGoals, + repoRoot, + baseRef: resolvedBase, + headRef, + headSha, + statusShort: status, + changedFiles, + packages, + policies, + validationCommands, + contentHash: "", + }; + + bundle.contentHash = sha256( + JSON.stringify({ + goal: bundle.goal, + nonGoals: bundle.nonGoals, + baseRef: bundle.baseRef, + headSha: bundle.headSha, + changedFiles: bundle.changedFiles, + policies: bundle.policies, + }), + ); + + return bundle; +} + +/** Parse Garfield finding lines and freeform none. */ +export function parseFindings(text, laneId) { + const trimmed = text.trim(); + if (!trimmed || trimmed === "none" || /^none\b/i.test(trimmed)) { + return []; + } + + const findings = []; + const lineRe = + /^\[(?blocker|high|medium|low)\]\[evidence:(?[^\]]+)\]\s+(?\S+)\s+-\s+(?.*?)\s+impact:\s+(?.*?)\s+fix:\s+(?.*?)\s*$/i; + + for (const line of trimmed.split("\n")) { + const candidate = line.trim(); + if (!candidate) continue; + const match = candidate.match(lineRe); + if (!match?.groups) { + findings.push({ + laneId, + severity: "low", + evidence: ["inferred"], + locator: "unparsed", + concern: candidate, + impact: "parser could not read structured finding line", + fix: "rewrite using the Garfield finding format", + raw: candidate, + valid: false, + }); + continue; + } + const causeMatch = match.groups.evidence.match(/;cause:([a-z-]+)/i); + const evidencePart = match.groups.evidence.split(";")[0]; + const evidenceBits = evidencePart.split(/\s+/); + const labels = evidenceBits[0].split(",").map((part) => part.trim()); + findings.push({ + laneId, + severity: match.groups.severity.toLowerCase(), + evidence: labels, + evidenceLocator: evidenceBits.slice(1).join(" ") || undefined, + cause: causeMatch?.[1]?.toLowerCase(), + locator: match.groups.locator, + concern: match.groups.concern.trim(), + impact: match.groups.impact.trim(), + fix: match.groups.fix.trim(), + raw: candidate, + valid: true, + }); + } + return findings; +} + +/** Cluster findings by locator + normalized concern. */ +export function mergeFindings(findings) { + const clusters = new Map(); + for (const finding of findings) { + const key = `${finding.locator}::${normalizeConcern(finding.concern)}`; + const existing = clusters.get(key); + if (!existing) { + clusters.set(key, { + key, + locator: finding.locator, + concern: finding.concern, + severity: finding.severity, + impact: finding.impact, + fix: finding.fix, + lanes: [finding.laneId], + evidence: [...finding.evidence], + findings: [finding], + }); + continue; + } + existing.lanes.push(finding.laneId); + existing.evidence.push(...finding.evidence); + existing.findings.push(finding); + existing.severity = worseSeverity(existing.severity, finding.severity); + } + return [...clusters.values()].sort((a, b) => { + const rank = severityRank(b.severity) - severityRank(a.severity); + if (rank !== 0) return rank; + return a.locator.localeCompare(b.locator); + }); +} + +function normalizeConcern(text) { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function severityRank(severity) { + switch (severity) { + case "blocker": + return 4; + case "high": + return 3; + case "medium": + return 2; + case "low": + return 1; + default: + return 0; + } +} + +function worseSeverity(a, b) { + return severityRank(a) >= severityRank(b) ? a : b; +} + +/** Render a no-edit reviewer prompt for one lane. */ +export function renderLanePrompt({ bundle, lane, runDir }) { + const files = bundle.changedFiles + .map((file) => `- ${file.status} ${file.path}`) + .join("\n"); + const nonGoals = + bundle.nonGoals.length > 0 + ? bundle.nonGoals.map((item) => `- ${item}`).join("\n") + : "- (none provided)"; + + return `# Garfield lane: ${lane.title} + +You are a no-edit reviewer. Do not modify files. Return findings only. + +## Goal +${bundle.goal} + +## Non-goals +${nonGoals} + +## Slice +- base: ${bundle.baseRef} +- head: ${bundle.headRef} (${bundle.headSha}) +- run: ${bundle.runId} +- bundle: ${path.join(runDir, "bundle.json")} + +## Changed files +${files || "- (none)"} + +## Your ownership +- kind: ${lane.kind} +- card/policy: ${lane.card} +- model hint: ${lane.modelHint} + +Read only your card/policy plus the slice context above. Do not review unrelated concerns. + +## Output contract +If no current-diff-caused findings exist, return exactly: +none + +Otherwise return one finding per line: +[severity][evidence: ;cause:introduced|worsened|stale|missing-required] path:line - concern. impact: . fix: . + +Rules: +- severity is blocker|high|medium|low +- never accept inferred alone as a blocker +- only report defects caused or made material by the current diff +- no speculative hardening, API expansion, or unrelated cleanup +`; +} + +/** Build the final markdown/json report. */ +export function buildReport({ + bundle, + lanes, + clusters, + validationResults, + status, +}) { + const applicable = lanes.filter((lane) => lane.status === "applicable"); + const skipped = lanes.filter((lane) => lane.status === "skipped"); + const open = clusters.filter((cluster) => + ["blocker", "high"].includes(cluster.severity), + ); + + const lines = [ + `# garfield: ${status}`, + "", + `run: ${bundle.runId}`, + `goal: ${bundle.goal}`, + `base: ${bundle.baseRef}`, + `head: ${bundle.headSha}`, + `changed files: ${bundle.changedFiles.length}`, + `applicable lanes: ${applicable.length}`, + `skipped lanes: ${skipped.length}`, + `clusters: ${clusters.length}`, + "", + "## Validation", + ]; + + if (validationResults.length === 0) { + lines.push("- (none run)"); + } else { + for (const result of validationResults) { + lines.push( + `- ${result.id}: ${result.ok ? "pass" : "fail"} (\`${result.command}\`)`, + ); + } + } + + lines.push("", "## Findings"); + if (clusters.length === 0) { + lines.push("- none"); + } else { + for (const cluster of clusters) { + lines.push( + `- [${cluster.severity}] ${cluster.locator} — ${cluster.concern} (lanes: ${cluster.lanes.join(", ")})`, + ); + } + } + + if (open.length > 0) { + lines.push("", "## Residual blocker/high"); + for (const cluster of open) { + lines.push(`- ${cluster.locator}: ${cluster.concern}`); + } + } + + lines.push(""); + return { + markdown: lines.join("\n"), + json: { + status, + runId: bundle.runId, + goal: bundle.goal, + baseRef: bundle.baseRef, + headSha: bundle.headSha, + applicableLanes: applicable.map((lane) => lane.id), + skippedLanes: skipped.map((lane) => lane.id), + clusters, + validationResults, + openBlockerHigh: open, + }, + }; +} + +/** Convenience for scripts that live beside this module. */ +export function scriptsDir() { + return path.dirname(fileURLToPath(import.meta.url)); +} diff --git a/scripts/garfield/lib.test.mjs b/scripts/garfield/lib.test.mjs new file mode 100644 index 000000000..b731cf9a6 --- /dev/null +++ b/scripts/garfield/lib.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + classifyLanes, + mergeFindings, + parseArgs, + parseFindings, + packagesForFiles, + buildValidationCommands, + laneFileStem, +} from "./lib.mjs"; + +test("parseArgs keeps repeated flags", () => { + const args = parseArgs([ + "--goal", + "ship mvp", + "--non-goal", + "rewrite runtime", + "--non-goal", + "touch product code", + ]); + assert.equal(args.goal, "ship mvp"); + assert.deepEqual(args["non-goal"], [ + "rewrite runtime", + "touch product code", + ]); +}); + +test("classifyLanes core profile skips source-app policies", () => { + const lanes = classifyLanes( + [{ path: "packages/junior/src/chat/foo.ts", status: "M" }], + ["policies/testing.md", "policies/security.md"], + ); + const byId = Object.fromEntries(lanes.map((lane) => [lane.id, lane])); + assert.equal(byId["behavior-spec"].status, "applicable"); + assert.equal(byId["repository-instructions"].status, "applicable"); + assert.equal(byId["validation-sufficiency"].status, "applicable"); + assert.equal(byId["type-boundaries"].status, "applicable"); + assert.equal(byId["generated-dependencies"].status, "skipped"); + assert.equal(byId["policy:policies/testing.md"].status, "skipped"); + assert.equal(byId["policy:policies/security.md"].status, "skipped"); + assert.equal(byId["policy:policies/security.md"].modelHint, "cheap"); +}); + +test("classifyLanes full profile opens each source-app policy", () => { + const lanes = classifyLanes( + [{ path: "packages/junior/src/chat/foo.ts", status: "M" }], + ["policies/testing.md", "policies/security.md"], + { profile: "full" }, + ); + const byId = Object.fromEntries(lanes.map((lane) => [lane.id, lane])); + assert.equal(byId["policy:policies/testing.md"].status, "applicable"); + assert.equal(byId["policy:policies/security.md"].status, "applicable"); + assert.equal(byId["policy:policies/security.md"].modelHint, "cheap"); +}); + +test("parseFindings accepts none and structured lines", () => { + assert.deepEqual(parseFindings("none\n", "behavior-spec"), []); + const findings = parseFindings( + "[high][evidence:direct path:12;cause:introduced] packages/junior/src/a.ts:12 - leaks null. impact: crash. fix: narrow type.", + "type-boundaries", + ); + assert.equal(findings.length, 1); + assert.equal(findings[0].severity, "high"); + assert.equal(findings[0].laneId, "type-boundaries"); + assert.equal(findings[0].locator, "packages/junior/src/a.ts:12"); + assert.equal(findings[0].valid, true); +}); + +test("mergeFindings clusters by locator and concern", () => { + const clusters = mergeFindings([ + { + laneId: "a", + severity: "medium", + evidence: ["direct"], + locator: "x.ts:1", + concern: "dup logic", + impact: "drift", + fix: "delete copy", + }, + { + laneId: "b", + severity: "high", + evidence: ["spec"], + locator: "x.ts:1", + concern: "Dup logic", + impact: "drift", + fix: "delete copy", + }, + ]); + assert.equal(clusters.length, 1); + assert.equal(clusters[0].severity, "high"); + assert.deepEqual(clusters[0].lanes, ["a", "b"]); +}); + +test("packagesForFiles reads package names", () => { + const packages = packagesForFiles(process.cwd(), [ + { path: "packages/junior/src/chat/a.ts", status: "M" }, + { path: "packages/junior-github/src/b.ts", status: "A" }, + { path: "README.md", status: "M" }, + ]); + assert.ok(packages.includes("@sentry/junior")); + assert.ok(packages.includes("@sentry/junior-github")); +}); + +test("buildValidationCommands prefers package filters", () => { + const commands = buildValidationCommands(["@sentry/junior"]); + assert.ok(commands.some((item) => item.id === "file-length")); + assert.ok( + commands.some( + (item) => item.command === "pnpm --filter @sentry/junior run typecheck", + ), + ); + assert.ok(commands.some((item) => item.id === "skills-check")); +}); + +test("laneFileStem stabilizes policy ids", () => { + assert.equal(laneFileStem("behavior-spec"), "behavior-spec"); + assert.equal( + laneFileStem("policy:policies/testing.md"), + "policy__policies__testing", + ); +}); diff --git a/scripts/garfield/merge-findings.mjs b/scripts/garfield/merge-findings.mjs new file mode 100755 index 000000000..ffdc57fbd --- /dev/null +++ b/scripts/garfield/merge-findings.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { + mergeFindings, + parseArgs, + parseFindings, + readJson, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/merge-findings.mjs --run-dir ", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args["run-dir"] !== "string") usage(1); + +const runDir = path.resolve(args["run-dir"]); +const plan = readJson(path.join(runDir, "lane-plan.json")); +const findingsDir = path.join(runDir, "findings"); +const all = []; +const perLane = []; + +for (const lane of plan.lanes) { + if (lane.status !== "applicable") continue; + const safeName = lane.id + .replace(/\.md$/i, "") + .replace(/[^a-zA-Z0-9._-]+/g, "__"); + const findingPath = path.join(findingsDir, `${safeName}.txt`); + if (!fs.existsSync(findingPath)) { + perLane.push({ + laneId: lane.id, + status: "missing", + findingPath, + findings: [], + }); + continue; + } + const text = fs.readFileSync(findingPath, "utf8"); + const placeholder = text.trim().startsWith("# Replace this file"); + if (placeholder || !text.trim()) { + perLane.push({ + laneId: lane.id, + status: "pending", + findingPath, + findings: [], + }); + continue; + } + const findings = parseFindings(text, lane.id); + all.push(...findings); + perLane.push({ + laneId: lane.id, + status: "ready", + findingPath, + findings, + }); +} + +const clusters = mergeFindings(all.filter((finding) => finding.valid !== false)); +const pending = perLane.filter((lane) => lane.status !== "ready"); + +writeJson(path.join(runDir, "findings.json"), { + runId: plan.runId, + pendingLaneIds: pending.map((lane) => lane.laneId), + lanes: perLane, + findings: all, + clusters, +}); + +console.log( + JSON.stringify( + { + ok: pending.length === 0, + runId: plan.runId, + pending: pending.length, + findingCount: all.length, + clusterCount: clusters.length, + findingsPath: path.join(runDir, "findings.json"), + }, + null, + 2, + ), +); + +if (pending.length > 0) { + process.exitCode = 2; +} diff --git a/scripts/garfield/report.mjs b/scripts/garfield/report.mjs new file mode 100755 index 000000000..5b09a6b6a --- /dev/null +++ b/scripts/garfield/report.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { + buildReport, + parseArgs, + readJson, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/report.mjs --run-dir [--status pass|blocked]", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args["run-dir"] !== "string") usage(1); + +const runDir = path.resolve(args["run-dir"]); +const bundle = readJson(path.join(runDir, "bundle.json")); +const plan = readJson(path.join(runDir, "lane-plan.json")); + +let findings = { clusters: [], pendingLaneIds: [] }; +const findingsPath = path.join(runDir, "findings.json"); +if (fs.existsSync(findingsPath)) { + findings = readJson(findingsPath); +} + +let validationResults = []; +const validationPath = path.join(runDir, "validation.json"); +if (fs.existsSync(validationPath)) { + validationResults = readJson(validationPath).results || []; +} + +const open = (findings.clusters || []).filter((cluster) => + ["blocker", "high"].includes(cluster.severity), +); +const failedRequired = validationResults.filter( + (item) => item.required && !item.ok, +); +const pending = findings.pendingLaneIds || []; + +let status = typeof args.status === "string" ? args.status : undefined; +if (!status) { + status = + pending.length === 0 && open.length === 0 && failedRequired.length === 0 + ? "pass" + : "blocked"; +} + +const report = buildReport({ + bundle, + lanes: plan.lanes, + clusters: findings.clusters || [], + validationResults, + status, +}); + +const reportMdPath = path.join(runDir, "report.md"); +const reportJsonPath = path.join(runDir, "report.json"); +fs.writeFileSync(reportMdPath, `${report.markdown}\n`, "utf8"); +writeJson(reportJsonPath, report.json); + +console.log(report.markdown); +console.log( + JSON.stringify( + { + ok: status === "pass", + status, + reportMdPath, + reportJsonPath, + }, + null, + 2, + ), +); + +if (status !== "pass") { + process.exitCode = 1; +} diff --git a/scripts/garfield/run.mjs b/scripts/garfield/run.mjs new file mode 100755 index 000000000..1ce92e23a --- /dev/null +++ b/scripts/garfield/run.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +/** + * Agent-native Garfield entrypoint. + * + * Default (`prepare`): build the slice, classify lanes, write prompts + agent + * brief. The calling agent owns review → fix → validate → report after this. + * + * `--finalize`: merge findings, run validation, emit the pass/blocked report. + * + * This is the non-manual path. Prefer it over the Swamp suspend/approve flow + * when an agent is driving the loop end-to-end. + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + buildBundle, + classifyLanes, + createRunId, + defaultRunDir, + ensureDir, + laneFileStem, + parseArgs, + readJson, + renderLanePrompt, + resolveRepoRoot, + writeAgentBrief, + writeJson, +} from "./lib.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function usage(exitCode = 1) { + console.error(`Usage: + node scripts/garfield/run.mjs --goal [options] + node scripts/garfield/run.mjs --finalize --run-dir [options] + +Prepare options: + --goal Required slice goal / user intent + --non-goal Repeatable non-goal + --base Diff base (default: merge-base with origin/main) + --run-id Stable run id (default: generated) + --run-dir Output directory (default: .swamp/garfield/) + --repo-root Repo root (default: discover from cwd) + --profile core|full Lane profile (default: core) + +Finalize options: + --run-dir Required unless .swamp/garfield/last-run.json exists + --only-required Only run required validation commands + --skip-validate Skip validation (report findings only) +`); + process.exit(exitCode); +} + +function normalizeNonGoals(args) { + return [] + .concat(args["non-goal"] ?? []) + .concat(args["non-goals"] ?? []) + .flatMap((value) => { + if (Array.isArray(value)) return value; + if (typeof value === "string") { + return value.includes(",") + ? value.split(",").map((part) => part.trim()) + : [value]; + } + return []; + }) + .filter((value) => typeof value === "string" && value.length > 0); +} + +function runNodeScript(repoRoot, scriptName, scriptArgs, options = {}) { + const scriptPath = path.join(HERE, scriptName); + const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + maxBuffer: 20 * 1024 * 1024, + }); + const allowed = new Set(options.allowStatuses || [0]); + if (!allowed.has(result.status ?? 1)) { + const stderr = (result.stderr || result.stdout || "").trim(); + throw new Error( + `${scriptName} failed (${result.status}): ${stderr || "no output"}`, + ); + } + return { + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + +function parseJsonOutput(stdout) { + const text = stdout.trim(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + // Some scripts print markdown then a JSON trailer. + const jsonStart = text.lastIndexOf("\n{"); + if (jsonStart >= 0) { + try { + return JSON.parse(text.slice(jsonStart + 1)); + } catch { + return null; + } + } + const firstBrace = text.indexOf("{"); + if (firstBrace >= 0) { + try { + return JSON.parse(text.slice(firstBrace)); + } catch { + return null; + } + } + return null; + } +} + +function resolveRunDir(repoRoot, args) { + if (typeof args["run-dir"] === "string" && args["run-dir"]) { + return path.resolve(args["run-dir"]); + } + const pointerPath = path.join(repoRoot, ".swamp", "garfield", "last-run.json"); + if (fs.existsSync(pointerPath)) { + const pointer = readJson(pointerPath); + if (pointer.runDir) return pointer.runDir; + } + return null; +} + +function writePromptsAndStubs(runDir, bundle, plan) { + const promptsDir = path.join(runDir, "prompts"); + const findingsDir = path.join(runDir, "findings"); + ensureDir(promptsDir); + ensureDir(findingsDir); + + const written = []; + for (const lane of plan.lanes) { + if (lane.status !== "applicable") continue; + const stem = laneFileStem(lane.id); + const promptPath = path.join(promptsDir, `${stem}.md`); + const findingPath = path.join(findingsDir, `${stem}.txt`); + fs.writeFileSync( + promptPath, + renderLanePrompt({ bundle, lane, runDir }), + "utf8", + ); + // Agent path: leave findings empty so the agent must write them. + // Do not plant the manual-flow placeholder. + if (!fs.existsSync(findingPath)) { + fs.writeFileSync(findingPath, "", "utf8"); + } + written.push({ + laneId: lane.id, + title: lane.title, + modelHint: lane.modelHint, + promptPath, + findingPath, + }); + } + + writeJson(path.join(runDir, "lane-prompts.json"), { + runId: bundle.runId, + mode: "agent", + lanes: written, + }); + return written; +} + +function prepare(args) { + if (!args.goal || args.goal === true) usage(1); + + const repoRoot = resolveRepoRoot( + typeof args["repo-root"] === "string" && args["repo-root"] + ? args["repo-root"] + : process.cwd(), + ); + const profile = args.profile === "full" ? "full" : "core"; + const runId = + typeof args["run-id"] === "string" && args["run-id"] + ? args["run-id"] + : createRunId(); + const runDir = + typeof args["run-dir"] === "string" && args["run-dir"] + ? path.resolve(args["run-dir"]) + : defaultRunDir(repoRoot, runId); + + ensureDir(runDir); + + const bundle = buildBundle({ + repoRoot, + goal: String(args.goal), + nonGoals: normalizeNonGoals(args), + baseRef: + typeof args.base === "string" && args.base ? args.base : undefined, + runId, + }); + + const lanes = classifyLanes(bundle.changedFiles, bundle.policies, { + profile, + }); + const applicable = lanes.filter((lane) => lane.status === "applicable"); + const skipped = lanes.filter((lane) => lane.status === "skipped"); + const plan = { + version: 1, + runId: bundle.runId, + contentHash: bundle.contentHash, + profile, + mode: "agent", + lanes, + applicableLaneIds: applicable.map((lane) => lane.id), + skippedLaneIds: skipped.map((lane) => lane.id), + }; + + writeJson(path.join(runDir, "bundle.json"), bundle); + writeJson(path.join(runDir, "run.json"), { + runId, + runDir, + bundlePath: path.join(runDir, "bundle.json"), + createdAt: bundle.createdAt, + mode: "agent", + profile, + }); + writeJson(path.join(runDir, "lane-plan.json"), plan); + writePromptsAndStubs(runDir, bundle, plan); + const brief = writeAgentBrief({ bundle, plan, runDir }); + + const pointerDir = path.join(repoRoot, ".swamp", "garfield"); + ensureDir(pointerDir); + writeJson(path.join(pointerDir, "last-run.json"), { + ok: true, + runId, + runDir, + bundlePath: path.join(runDir, "bundle.json"), + mode: "agent", + profile, + capturedAt: new Date().toISOString(), + }); + + const summary = { + ok: true, + mode: "agent", + phase: "prepare", + runId, + runDir, + profile, + goal: bundle.goal, + changedFiles: bundle.changedFiles.length, + packages: bundle.packages, + applicable: applicable.length, + skipped: skipped.length, + applicableLaneIds: plan.applicableLaneIds, + briefPath: brief.briefPath, + todoPath: brief.todoPath, + next: [ + "Read agent-brief.md in the run dir.", + "Review each applicable lane and write findings/*.txt (`none` or finding lines).", + "Fix blocker/high findings with the smallest intent-preserving edits.", + `node scripts/garfield/run.mjs --finalize --run-dir ${runDir}`, + "Reply with garfield: pass|blocked plus residual deferred concerns.", + ], + }; + + console.log(JSON.stringify(summary, null, 2)); + console.error(`\ngarfield prepare ready: ${runDir}`); + console.error(`agent brief: ${brief.briefPath}`); + console.error( + `applicable lanes (${applicable.length}): ${plan.applicableLaneIds.join(", ")}`, + ); + return 0; +} + +function finalize(args) { + const repoRoot = resolveRepoRoot( + typeof args["repo-root"] === "string" && args["repo-root"] + ? args["repo-root"] + : process.cwd(), + ); + const runDir = resolveRunDir(repoRoot, args); + if (!runDir) { + console.error( + "Missing --run-dir and no .swamp/garfield/last-run.json pointer.", + ); + return 1; + } + if (!fs.existsSync(path.join(runDir, "bundle.json"))) { + console.error(`No bundle.json in ${runDir}`); + return 1; + } + + const mergeResult = runNodeScript( + repoRoot, + "merge-findings.mjs", + ["--run-dir", runDir], + { allowStatuses: [0, 2] }, + ); + const merge = parseJsonOutput(mergeResult.stdout) || { + ok: false, + raw: mergeResult.stdout, + }; + if (mergeResult.status === 2 || merge.ok === false || (merge.pending ?? 0) > 0) { + console.error( + JSON.stringify( + { + ok: false, + phase: "finalize", + error: "findings incomplete", + runDir, + pending: merge.pending, + merge, + }, + null, + 2, + ), + ); + return 2; + } + + let validation = { ok: true, skipped: true, results: [] }; + if (!args["skip-validate"]) { + const validateArgs = ["--run-dir", runDir]; + if (args["only-required"]) validateArgs.push("--only-required"); + // validate exits 1 when required checks fail; still want the JSON payload. + const validateResult = runNodeScript(repoRoot, "validate.mjs", validateArgs, { + allowStatuses: [0, 1], + }); + validation = parseJsonOutput(validateResult.stdout) || { + ok: false, + raw: validateResult.stdout, + }; + } + + const reportResult = runNodeScript( + repoRoot, + "report.mjs", + ["--run-dir", runDir], + { allowStatuses: [0, 1] }, + ); + const report = parseJsonOutput(reportResult.stdout) || { + ok: false, + raw: reportResult.stdout, + }; + + const summary = { + ok: Boolean(report.ok), + mode: "agent", + phase: "finalize", + runDir, + status: report.status, + reportMdPath: report.reportMdPath, + reportJsonPath: report.reportJsonPath, + merge: { + findingCount: merge.findingCount, + clusterCount: merge.clusterCount, + }, + validation: { + ok: validation.ok, + failedRequired: validation.failedRequired || [], + results: validation.results || [], + }, + }; + + console.log(JSON.stringify(summary, null, 2)); + if (fs.existsSync(path.join(runDir, "report.md"))) { + console.error(`\n${fs.readFileSync(path.join(runDir, "report.md"), "utf8")}`); + } + return summary.ok ? 0 : 1; +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); + +const code = args.finalize ? finalize(args) : prepare(args); +process.exit(code); diff --git a/scripts/garfield/validate.mjs b/scripts/garfield/validate.mjs new file mode 100755 index 000000000..bb3f21974 --- /dev/null +++ b/scripts/garfield/validate.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { + parseArgs, + readJson, + resolveRepoRoot, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/validate.mjs --run-dir [--only-required]", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args["run-dir"] !== "string") usage(1); + +const runDir = path.resolve(args["run-dir"]); +const bundle = readJson(path.join(runDir, "bundle.json")); +const repoRoot = resolveRepoRoot(bundle.repoRoot || process.cwd()); +const onlyRequired = Boolean(args["only-required"]); + +const commands = onlyRequired + ? bundle.validationCommands.filter((item) => item.required) + : bundle.validationCommands; + +const results = []; +for (const item of commands) { + const started = Date.now(); + const result = spawnSync(item.command, { + cwd: repoRoot, + encoding: "utf8", + shell: true, + env: process.env, + maxBuffer: 20 * 1024 * 1024, + }); + results.push({ + id: item.id, + command: item.command, + required: Boolean(item.required), + ok: result.status === 0, + status: result.status, + durationMs: Date.now() - started, + stdout: (result.stdout || "").slice(0, 4000), + stderr: (result.stderr || "").slice(0, 4000), + }); +} + +writeJson(path.join(runDir, "validation.json"), { + runId: bundle.runId, + results, +}); + +const failedRequired = results.filter((item) => item.required && !item.ok); +console.log( + JSON.stringify( + { + ok: failedRequired.length === 0, + runId: bundle.runId, + ran: results.length, + failedRequired: failedRequired.map((item) => item.id), + results: results.map((item) => ({ + id: item.id, + ok: item.ok, + command: item.command, + })), + validationPath: path.join(runDir, "validation.json"), + }, + null, + 2, + ), +); + +if (failedRequired.length > 0) { + process.exitCode = 1; +} diff --git a/scripts/garfield/with-run-dir.mjs b/scripts/garfield/with-run-dir.mjs new file mode 100644 index 000000000..4cf20cab5 --- /dev/null +++ b/scripts/garfield/with-run-dir.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Resolve the active Garfield run directory and exec a command against it. + * + * Usage: + * node scripts/garfield/with-run-dir.mjs [script args...] + * + * Looks up `.swamp/garfield/last-run.json` written by capture-run-dir. + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib.mjs"; + +const repoRoot = resolveRepoRoot(process.cwd()); +const pointerPath = path.join(repoRoot, ".swamp", "garfield", "last-run.json"); +if (!fs.existsSync(pointerPath)) { + console.error(`Missing run pointer: ${pointerPath}`); + process.exit(1); +} + +const pointer = JSON.parse(fs.readFileSync(pointerPath, "utf8")); +if (!pointer.runDir) { + console.error(`Run pointer missing runDir: ${pointerPath}`); + process.exit(1); +} + +const [scriptName, ...rest] = process.argv.slice(2); +if (!scriptName) { + console.error( + "Usage: node scripts/garfield/with-run-dir.mjs [args...]", + ); + process.exit(1); +} + +const scriptPath = path.isAbsolute(scriptName) + ? scriptName + : path.join(path.dirname(fileURLToPath(import.meta.url)), scriptName); + +const result = spawnSync( + process.execPath, + [scriptPath, "--run-dir", pointer.runDir, ...rest], + { + cwd: repoRoot, + stdio: "inherit", + env: process.env, + }, +); + +process.exit(result.status ?? 1); diff --git a/scripts/garfield/write-lane-prompts.mjs b/scripts/garfield/write-lane-prompts.mjs new file mode 100644 index 000000000..8b5048ace --- /dev/null +++ b/scripts/garfield/write-lane-prompts.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { + ensureDir, + parseArgs, + readJson, + renderLanePrompt, + writeJson, +} from "./lib.mjs"; + +function usage(exitCode = 1) { + console.error( + "Usage: node scripts/garfield/write-lane-prompts.mjs --run-dir ", + ); + process.exit(exitCode); +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) usage(0); +if (typeof args["run-dir"] !== "string") usage(1); + +const runDir = path.resolve(args["run-dir"]); +const bundle = readJson(path.join(runDir, "bundle.json")); +const plan = readJson(path.join(runDir, "lane-plan.json")); +const promptsDir = path.join(runDir, "prompts"); +const findingsDir = path.join(runDir, "findings"); +ensureDir(promptsDir); +ensureDir(findingsDir); + +const written = []; +for (const lane of plan.lanes) { + if (lane.status !== "applicable") continue; + const safeName = lane.id + .replace(/\.md$/i, "") + .replace(/[^a-zA-Z0-9._-]+/g, "__"); + const promptPath = path.join(promptsDir, `${safeName}.md`); + const findingPath = path.join(findingsDir, `${safeName}.txt`); + fs.writeFileSync(promptPath, renderLanePrompt({ bundle, lane, runDir }), "utf8"); + if (!fs.existsSync(findingPath)) { + // Empty stub: agent/manual reviewer must write `none` or finding lines. + // merge-findings treats empty/placeholder as pending. + fs.writeFileSync(findingPath, "", "utf8"); + } + written.push({ + laneId: lane.id, + title: lane.title, + modelHint: lane.modelHint, + promptPath, + findingPath, + }); +} + +writeJson(path.join(runDir, "lane-prompts.json"), { + runId: bundle.runId, + lanes: written, +}); + +console.log( + JSON.stringify( + { + ok: true, + runId: bundle.runId, + promptCount: written.length, + promptsDir, + findingsDir, + lanes: written.map((lane) => ({ + id: lane.laneId, + modelHint: lane.modelHint, + promptPath: lane.promptPath, + findingPath: lane.findingPath, + })), + }, + null, + 2, + ), +); diff --git a/skills/garfield/SKILL.md b/skills/garfield/SKILL.md new file mode 100644 index 000000000..57adbbdaf --- /dev/null +++ b/skills/garfield/SKILL.md @@ -0,0 +1,99 @@ +--- +name: garfield +description: Run the Junior-repo Garfield review-fix-verify loop end-to-end after a meaningful implementation slice. Use when the user explicitly asks to run Garfield, harden a slice, or finish a change with multi-lane review before handoff. Do not use for standalone PR review requests, brainstorming, CI-only iteration, or skill authoring. +disable-model-invocation: true +--- + +# Garfield (Junior dev) + +Own the full loop. The user should only say "run garfield" (optionally with a goal). Do **not** ask them to fill findings files, approve Swamp gates, or run multi-step manual resume commands. + +This is **repository development tooling**, not Junior product runtime. + +## One-command contract + +From the Junior monorepo root: + +```bash +node scripts/garfield/run.mjs --goal '' +# ...you review, fix, write findings... +node scripts/garfield/run.mjs --finalize --run-dir +``` + +Or finalize the latest prepare via the pointer: + +```bash +node scripts/garfield/run.mjs --finalize +``` + +Optional flags on prepare: `--non-goal '...'` (repeatable), `--base `, `--profile full` (include every source-app policy lane; default is `core`). + +## Required procedure + +1. **Frame the slice** + - Infer goal from the user request + current dirty/committed diff if they did not spell one out. + - State non-goals when obvious (no product-runtime changes, no drive-by refactors, etc.). +2. **Prepare (deterministic)** + - Run `node scripts/garfield/run.mjs --goal '...'` (add non-goals/base/profile as needed). + - Read the printed `runDir` and `agent-brief.md`. That brief is authoritative for this run. +3. **Review every applicable lane yourself** + - Work the lane queue from `agent-todo.json` / `agent-brief.md`. + - For each lane: read only that lane's `prompts/.md` plus the card/policy it names and the relevant changed files. + - Native cards live in `skills/garfield/references/`. Source-app policies live in `policies/`. + - Write the matching `findings/.txt` with exactly `none` or Garfield finding lines. + - Prefer serial review when alone. At most three conceptual lanes in flight. +4. **Merge + triage** + - Run `node scripts/garfield/merge-findings.mjs --run-dir ` (or go straight to finalize after findings are written). + - Treat findings as advice. Cluster by locator + concern. Accept blocker/high when the smallest fix preserves intent. Defer pre-existing debt and intent-expanding work. +5. **Repair** + - Apply only the smallest accepted fixes to current-diff defects. + - Re-review only lanes affected by the repair delta; update their findings files. +6. **Finalize (deterministic)** + - `node scripts/garfield/run.mjs --finalize --run-dir ` + - This merges (if needed path), runs targeted validation, and writes `report.md` / `report.json`. + - Fix required validation failures, then finalize again. +7. **Handoff** + - Reply with: + - `garfield: pass` or `garfield: blocked` + - validation commands/results + - residual accepted/deferred blocker/high/medium concerns + - deferred adjacent improvements + - Omit cycle logs and low-severity bookkeeping. + +## Finding format + +```text +[severity][evidence: ;cause:introduced|worsened|stale|missing-required] path:line - concern. impact: . fix: . +``` + +If no findings for a lane: exactly `none`. + +Evidence labels: `direct`, `spec`, `policy`, `test`, `validation`, `missing`, `inferred`. Never accept `inferred` alone as a blocker. + +## Profiles + +- `core` (default): always-on lanes + matched native lanes. Source-app policies are skipped and covered by the repository-instructions lane. Cheaper default for agent loops. +- `full`: also opens one lane per `policies/*.md` file. + +## Stop conditions + +- **pass:** no blocker/high left, required validation passes, report is `garfield: pass` +- **blocked:** same concern twice, three cycles without material progress, needs clarification, or the fix would expand core intent + +## What not to do + +- Do not stop after prepare and hand the user a manual findings checklist. +- Do not use the Swamp `await-findings` approve/resume path unless the user explicitly wants the human-gated workflow. +- Do not spawn product-runtime Junior jobs; this skill runs local scripts in the checkout. +- Do not expand scope into unrelated cleanup, API changes, or speculative hardening. + +## References + +| Need | Read | +| --- | --- | +| Native lane cards | [references/review-lanes.md](references/review-lanes.md) | +| Code comments | [references/code-comments.md](references/code-comments.md) | +| Implementation minimalism | [references/implementation-minimalism.md](references/implementation-minimalism.md) | +| Interface design | [references/interface-design.md](references/interface-design.md) | +| Test quality | [references/test-quality.md](references/test-quality.md) | +| Script/workflow details | `swamp/README.md` | diff --git a/skills/garfield/references/code-comments.md b/skills/garfield/references/code-comments.md new file mode 100644 index 000000000..c68fad917 --- /dev/null +++ b/skills/garfield/references/code-comments.md @@ -0,0 +1,20 @@ +# Code Comments Policy + +## Intent + +Comments preserve durable facts that a maintainer cannot recover reliably from code, types, names, or tests. They should not narrate implementation. + +## Policy + +- Comment non-obvious ownership, invariant lifetime, lock or transaction ordering, post-commit behavior, compatibility constraints, and counterintuitive tradeoffs. +- Record each fact once at the highest stable boundary that owns it. Do not repeat one lifecycle invariant in module, wrapper, and helper comments. +- Add JSDoc to an exported or private function only when its contract contains a durable fact not recoverable from its signature, name, owning module, or tests. +- Keep comments short, concrete, and current. +- Rewrite or remove stale, ambiguous, temporary-plan, and overclaiming comments touched or invalidated by the slice. + +## Exceptions + +- Do not comment obvious transformations or control flow. +- Do not add comments that simply restate the code in English. +- Do not require a comment solely because a module is important or a symbol is exported, private, or newly added. +- Prefer clearer names, types, or boundaries when they can make the same fact evident without prose. diff --git a/skills/garfield/references/implementation-minimalism.md b/skills/garfield/references/implementation-minimalism.md new file mode 100644 index 000000000..1f8b38dc9 --- /dev/null +++ b/skills/garfield/references/implementation-minimalism.md @@ -0,0 +1,20 @@ +# Implementation Minimalism Policy + +## Intent + +Implementation slices should solve the requested problem without accumulating speculative guardrails, fallbacks, abstractions, or tests for unlikely states. Defensive code is useful at real boundaries; inside established invariants it often hides defects by converting failures into plausible success. + +## Policy + +- Implement the smallest clear behavior that satisfies the user goal, existing contract, and validation evidence. +- Do not add defensive checks, broad catches, fallback/default values, retries, compatibility shims, abstractions, or normalization for hypothetical states. +- Trust an established type, validated input, lock, query, or ownership boundary only when the code can identify what establishes the invariant and how long it remains valid. +- Do not turn missing required data, invariant violations, or unexpected failures into empty, default, stale, or otherwise successful results unless the contract requires that fallback. +- Remove current-diff guards and adapters that duplicate upstream validation, are unreachable, or only support imagined callers or states. +- Do not add tests solely to justify speculative guardrails. Tests should prove requested behavior, existing contracts, real regressions, or realistic boundary failures. + +## Exceptions + +- Validate untrusted input and external-system output at the earliest practical trust boundary; avoid repeating the same validation downstream. +- Preserve checks required by the user goal, product spec, public API, permission or security boundary, idempotency contract, lock order, durable integrity rule, migration compatibility, or a concrete regression. Do not remove one merely because current repository callers or writers appear to make it redundant. +- A cheap local assertion is appropriate when it makes a critical invariant fail visibly. At security, transaction, and persistence boundaries, prefer a named assertion or explicit error over a non-null assertion. diff --git a/skills/garfield/references/interface-design.md b/skills/garfield/references/interface-design.md new file mode 100644 index 000000000..5edbde1a2 --- /dev/null +++ b/skills/garfield/references/interface-design.md @@ -0,0 +1,30 @@ +# Interface Design Policy + +## Intent + +Interfaces should expose the smallest useful capability while keeping ownership, lifecycle, security, and coupling boundaries obvious. + +## Policy + +- Report only material interface defects: unclear ownership, incomplete lifecycle, security exposure, unstable coupling, or a contract that permits realistic caller misuse. Do not flag naming or shape preferences without one of those consequences. +- Prefer narrow capability methods over broad dependency bags or access to underlying services. +- Expose lifecycle-oriented operations, such as `dispatch` and `get`, instead of raw runners, clients, routes, or storage adapters. +- Return projections by default. Do not expose full internal records when callers only need status, ids, or summaries. +- Make ownership explicit in the API boundary. A caller should only read or mutate records it owns unless cross-owner access is the feature. +- Keep platform details inside the layer that owns the platform. Do not leak Slack clients, Vercel primitives, Redis clients, or model-runtime internals through feature interfaces. +- Require idempotency keys for APIs that create durable work from retryable contexts. +- Use one domain noun for one concept across types, fields, functions, storage keys, and docs. Spend module and parent-object context instead of repeating technical role or call-path qualifiers. +- Name modules and durable keys by the domain concern or data contract they own, not an incidental adapter, mechanism, or current consumer. +- Keep exported interfaces role-shaped and small. `SessionLogStore` with `read` and `append` is clearer than a broad adapter that exposes unrelated state, Redis, or queue details. +- When a term is overloaded in the product or platform, define it once in the owning spec and avoid using it for nearby concepts. +- Add an interface, wrapper, or dependency parameter only when it removes real coupling or represents a stable boundary. Narrow dependency injection is healthy for an external or platform capability; a production seam used only to unit-test a local helper is not. +- Prefer the real SDK surface, a protocol boundary, or an existing narrow adapter in tests. Do not default to whole-module mocks to avoid designing a healthy boundary. +- Flag only new or changed boundaries, or names made misleading by this slice. Defer naming-only concerns unless they obscure ownership, lifecycle, security, or call-site meaning. +- Keep this lane to interface defects. Leave missing tests, comments, validation commands, and bookkeeping to their owning review tasks unless they directly prove the interface is misleading or unsafe. + +## Exceptions + +- Test fixtures may expose narrower construction seams when the production interface remains small. +- Low-level infrastructure modules may expose mechanism-specific APIs inside their own ownership boundary. +- Generic names are acceptable inside a tightly scoped module when the import path supplies the missing context. Use longer names only when two imported roles would otherwise collide at common call sites. +- Compatibility names may remain at external boundaries or legacy storage keys. New internal names should still normalize to the current domain term. diff --git a/skills/garfield/references/review-lanes.md b/skills/garfield/references/review-lanes.md new file mode 100644 index 000000000..a651133c9 --- /dev/null +++ b/skills/garfield/references/review-lanes.md @@ -0,0 +1,129 @@ +# Native Review Lanes + +Use these cards to keep one owner per concern. Corroborate an existing concern +instead of restating it from another lane. + +## Behavior/spec + +- **Owns:** Demonstrable incorrect behavior, unsafe design, or mismatch with the + requested behavior, established contract, invariant, compatibility + expectation, or stated non-goal. +- **Does not own:** Missing proof without a demonstrated defect; maintained + prose drift; mechanical policy compliance; generated-artifact parity. +- **Quality brake:** State the defect and impact independently from the remedy. + Prefer the lowest-maintenance boundary, type, or transaction fix. Before + proposing a persistent mechanism such as a custom validator, trigger, cache, + compatibility layer, or state field, compare it with a narrower fix and + explain why the mechanism is necessary. Do not infer an expensive + implementation mechanically from a spec. + +## Repository instructions + +- **Owns:** Applicable local rules, ranked in this order: data integrity, + security, architecture, and operational invariants; then public API and test + contracts; then naming, comments, exact helpers, and command bookkeeping. +- **Does not own:** Stale maintained prose, missing validation evidence, comment + quality already owned by its policy lane, or low-consequence stylistic + compliance presented as an architectural defect. +- **Quality brake:** Enforce the consequence behind a rule, not its wording + alone. A local rule may be stale or overfit; do not prescribe its named + pattern when the diff has a simpler design that satisfies the same invariant. + +## Validation sufficiency + +- **Owns:** Missing or brittle proof, stale validation, and checks that cannot + detect a realistic regression in the changed behavior. +- **Does not own:** The underlying behavior defect; a test per branch or + adapter; assertions whose primary subject is an internal call, tool name, mock + sequence, or styling class unless that surface is the contract. +- **Quality brake:** Admit a test request only when all three have answers: + (1) what realistic regression can pass now, (2) what highest stable public or + owned boundary detects it, and (3) why an existing table, fixture, or contract + case cannot simply be extended. Prefer one representative test per invariant. + Group stale commands into one readiness check. Record unavailable live, + browser, credentialed, or external checks as **unverified**, not as code + defects. + +## Specs/docs + +- **Owns:** Drift in maintained normative specs and public or operational + contracts, especially setup, migrations, configuration, privacy, routing, + ownership, and API behavior. +- **Does not own:** The implementation defect itself, generic repository-policy + compliance, incidental test comments, or non-authoritative wording presented + as a product contract. +- **Quality brake:** Group edits by one user or operator contract. When + implementation and a normative source disagree, report the conflict without + assuming which side should change; request/task authority or behavior/spec + must resolve it before this lane rewrites the normative source. +- **Applicability:** Run when behavior or a documented contract changed, or the + slice should have changed maintained specs or docs. + +## Dead code + +- **Owns:** Proven unreachable branches, unused private helpers and fields, + orphaned compatibility paths, duplicate APIs after a hard cut, and stale + members whose owning runtime workflow was removed. +- **Does not own:** Generated or packed artifacts; naming-only cleanup; an + exported symbol declared dead solely because repository search finds no + consumer. +- **Quality brake:** Before removing an export, require private/module-local + status, an explicit hard cut, authorized breaking change, or evidence that it + was never supported public API. Deduplicate by family: one concern for the + obsolete workflow suppresses separate concerns for each member it already + names. +- **Applicability:** Run when paths were removed, replaced, narrowed, or + refactored. + +## Delayering + +- **Owns:** Duplicate ownership of parsing, validation, normalization, + serialization, queries, locks, error capture, state, or decisions; parallel + representations; and pass-through layers with no policy or transformation. +- **Does not own:** Healthy seams for dependency injection, composition, + lifecycle, supported public API, domain vocabulary, compatibility, or test + clocks merely because their implementation is thin. +- **Quality brake:** Remove a layer only when it carries no policy, side-effect + seam, compatibility commitment, independent lifecycle, or useful domain + meaning. Never replace narrow dependency injection with global or module + mocking. Require semantic-equivalence tests before merging recurrence, + serialization, or validation engines; require lock-order and concurrency + evidence before collapsing transactional paths. +- **Applicability:** Run when wrappers, flags, adapters, abstractions, + indirection, representations, or ownership changed. + +## Type boundaries + +- **Owns:** Real trust boundaries: untrusted, durable, provider, model, database, + and public-constructor input; impossible states; nullability; schema/type + drift; unsafe assertions; and types disconnected from their runtime or + generated owner. +- **Does not own:** Terminology-only polish, changing a framework-normalized + value to `unknown` without runtime benefit, unused exports, or a generic-heavy + type framework whose only purpose is removing one cast. +- **Quality brake:** Parse and normalize once at the nearest owner, then keep + downstream types narrow. Use the smallest discriminated union, derived type, + or registry that prevents the demonstrated invalid state. Preserve public + compatibility through one coherent extended API; do not create indefinite + parallel old/new options or shims. +- **Applicability:** Run when typed interfaces, casts, nullable values, `any`, + `unknown`, serialization, schemas, or public constructors changed. + +## Generated/dependencies + +- **Owns:** Missing, stale, or inconsistent generated/reference artifacts, + schema and migration history, generated clients or API docs, manifests, + lockfiles, dependency metadata, packed output, and eval recordings. This lane + is the sole owner of stale generated or packed artifacts. +- **Does not own:** A generated delta merely because it differs from history, + or an intentional stronger invariant automatically treated as compatibility + drift. Dead code must not duplicate artifact findings from this lane. +- **Quality brake:** First classify the delta as intentional contract change or + accidental drift. For an intentional change, verify runtime schema, generated + schema, tests, migration/package evidence, and compatibility docs agree. Never + recommend weakening a valid invariant solely to restore historical validation + behavior. +- **Applicability:** Run only when the diff touches or should touch an API or + storage schema, migration sequence, generated/reference output, manifest, + lockfile, dependency, package artifact, CI dependency surface, or eval + recording. diff --git a/skills/garfield/references/test-quality.md b/skills/garfield/references/test-quality.md new file mode 100644 index 000000000..60e984ff8 --- /dev/null +++ b/skills/garfield/references/test-quality.md @@ -0,0 +1,25 @@ +# Test Quality Policy + +## Intent + +Tests should prove real contracts with the least brittle machinery. Prefer moving, narrowing, or deleting weak tests over adding low-fidelity coverage. + +## Policy + +- Name the distinct regression each proposed test would catch. Strengthen an existing higher-fidelity test before adding another test, and delete lower-fidelity duplicates that prove no separate contract. +- Use the highest-fidelity deterministic layer: end-to-end for user behavior, integration for wiring and external boundaries, component for cross-module contracts, unit for local invariants. Do not default to unit tests for product behavior. +- Use real modules, shared fixtures, in-memory adapters, test clients/servers, and protocol-level HTTP interception before mocks. Mock or fake one explicit boundary only. +- Treat broad module mocks, global `fetch` mocks, singleton seams, generic dependency bags, and production dependency parameters for local helpers as signs the test is in the wrong layer. +- Name harness ports for real boundaries: model replies, queue wakeups, state storage, HTTP, sandbox execution, external delivery. +- Assert outcomes, durable state, external payloads, or user-visible behavior. Do not assert internal calls, call counts, prompt prose, or implementation identifiers. Minimize logs, spans, Sentry events, metrics, analytics, tracing, and telemetry assertions; use them only when instrumentation output is the requested contract, and prefer spying on or capturing the real delivery path over mocking telemetry modules. +- Use representative data that would fail if the contract were miswired: non-null and plural fields, distinct identities, negative cases, or realistic boundary failures. Do not build an exhaustive branch matrix when one representative case proves the invariant. +- Centralize recurring setup in shared fixtures with narrow read-only inspection, such as outboxes or captured deliveries. Do not expose broad mutable internals. +- Reuse a shared deterministic multi-session harness for lock, retry, and concurrency contracts. Add bespoke raw-client polling or lock orchestration only when the contract is critical, simpler layers cannot prove it, and the maintenance cost is proportionate. +- When tests, fixtures, scripts, or boundary checks change, verify commands, coverage scripts, and generated test artifacts still point at the new names. +- Add tests only for requested behavior, existing contracts, real regressions, or realistic boundaries touched by the slice. +- Leave missing command execution to validation review and comment-only concerns to code-comments review. + +## Exceptions + +- Local fakes or module mocks are acceptable for one explicit boundary in a pure unit or component invariant when no shared adapter expresses the case clearly. +- Third-party SDK clients and nondeterministic system boundaries may be mocked when a protocol-level interceptor or shared test adapter is impractical. diff --git a/swamp/README.md b/swamp/README.md new file mode 100644 index 000000000..33d78cc45 --- /dev/null +++ b/swamp/README.md @@ -0,0 +1,128 @@ +# Swamp + Garfield (Junior dev) + +Local-first development automation for running a cheaper Garfield-style +review-fix-verify loop on Junior worktrees. + +This is **dev tooling only**. It is not part of Junior product runtime. + +## Default path: agent owns the loop + +Tell the coding agent to run Garfield. It should prepare, review, fix, validate, +and report without asking you to fill findings files. + +```bash +# agent runs this +node scripts/garfield/run.mjs --goal 'Harden the scheduler credential binding' \ + --non-goal 'no product runtime changes' + +# agent reviews lanes, writes findings, fixes issues, then: +node scripts/garfield/run.mjs --finalize +``` + +What `run.mjs` does: + +1. **prepare** — bundle + core lane plan + prompts + `agent-brief.md` +2. **agent judgment** — review each applicable lane, write `findings/*.txt`, fix +3. **finalize** — merge findings, run targeted validation, emit `garfield: pass|blocked` + +Skill entrypoint for agents: `skills/garfield/SKILL.md`. + +### Profiles + +| Profile | Behavior | +| --- | --- | +| `core` (default) | Always-on + matched native lanes. Source-app policies deferred to repository-instructions. | +| `full` | Also opens one lane per `policies/*.md`. | + +```bash +node scripts/garfield/run.mjs --goal '...' --profile full +``` + +## Optional: Swamp human-gated path + +If you want a suspend/approve workflow instead of an agent-owned loop: + +```bash +curl -fsSL https://swamp-club.com/install.sh | sh +swamp --version + +swamp workflow run garfield-slice \ + --input goal='Harden the scheduler credential binding' \ + --input nonGoals='no product runtime changes' +``` + +1. **prepare** builds `.swamp/garfield//` +2. workflow **suspends** on `await-findings` +3. fill `findings/*.txt` manually (or with a separate reviewer) +4. resume: + +```bash +swamp workflow approve garfield-slice await-findings +swamp workflow resume garfield-slice +``` + +Runtime data lives in `.swamp/` and is gitignored. + +## Artifacts + +- `.swamp/garfield//agent-brief.md` — what the agent must do next +- `.swamp/garfield//agent-todo.json` — machine-readable lane queue + commands +- `.swamp/garfield//report.md` / `report.json` +- `.swamp/garfield/last-run.json` pointer + +## Reviewer protocol + +Each applicable lane gets: + +- `prompts/.md` — bounded no-edit instructions +- `findings/.txt` — write `none` or Garfield finding lines + +Finding line format: + +```text +[severity][evidence: ;cause:introduced|worsened|stale|missing-required] path:line - concern. impact: . fix: . +``` + +`lane-prompts.json` includes `modelHint`: + +- `cheap` — narrow policy / mechanical lanes +- `strong` — behavior/spec, validation sufficiency, interface design, test quality + +## Scripts + +| Script | Purpose | +| --- | --- | +| `scripts/garfield/run.mjs` | **Preferred** agent prepare + finalize | +| `scripts/garfield/build-bundle.mjs` | Slice snapshot + validation inventory | +| `scripts/garfield/classify-lanes.mjs` | Deterministic lane applicability | +| `scripts/garfield/write-lane-prompts.mjs` | Prompt + finding stubs | +| `scripts/garfield/merge-findings.mjs` | Parse/cluster findings | +| `scripts/garfield/validate.mjs` | Targeted pnpm checks | +| `scripts/garfield/report.mjs` | `garfield: pass\|blocked` report | + +## Direct spine (no run.mjs wrapper) + +```bash +node scripts/garfield/build-bundle.mjs --goal '...' --run-id demo +node scripts/garfield/classify-lanes.mjs --run-dir .swamp/garfield/demo --profile core +node scripts/garfield/write-lane-prompts.mjs --run-dir .swamp/garfield/demo +# agent writes findings +node scripts/garfield/merge-findings.mjs --run-dir .swamp/garfield/demo +node scripts/garfield/validate.mjs --run-dir .swamp/garfield/demo --only-required +node scripts/garfield/report.mjs --run-dir .swamp/garfield/demo +``` + +## Tests + +```bash +node --test scripts/garfield/lib.test.mjs +# or +pnpm garfield:test +``` + +## Out of scope (still) + +- auto-spawning separate Codex/Claude subagent processes +- remote Swamp workers / `swamp serve` +- shared S3 datastore +- product-runtime integration diff --git a/workflows/workflow-30afd46c-7497-4c4c-b0a6-1571ff3f9917.yaml b/workflows/workflow-30afd46c-7497-4c4c-b0a6-1571ff3f9917.yaml new file mode 100644 index 000000000..f7755871c --- /dev/null +++ b/workflows/workflow-30afd46c-7497-4c4c-b0a6-1571ff3f9917.yaml @@ -0,0 +1,171 @@ +id: 30afd46c-7497-4c4c-b0a6-1571ff3f9917 +name: garfield-slice +description: > + MVP Garfield loop for Junior development. Builds a typed review bundle, + classifies lanes, writes no-edit prompts, waits for reviewer findings, + merges them, runs deterministic validation, and emits a pass/blocked report. +tags: + purpose: garfield + scope: junior-dev +inputs: + type: object + properties: + goal: + type: string + description: Slice goal / user or PR intent + nonGoals: + type: string + description: Optional comma-separated non-goals + default: "" + base: + type: string + description: Optional git base ref (default merge-base with origin/main) + default: "" + runId: + type: string + description: Optional stable run id + default: "" + required: + - goal +jobs: + - name: prepare + description: Materialize bundle, lane plan, and reviewer prompts + steps: + - name: build-bundle + description: Snapshot the slice into .swamp/garfield//bundle.json + task: + type: model_method + modelType: command/shell + modelName: garfield-build-bundle + methodName: execute + inputs: + run: node scripts/garfield/build-bundle.mjs --goal "$GARFIELD_GOAL" --non-goals "$GARFIELD_NON_GOALS" --base "$GARFIELD_BASE" --run-id "$GARFIELD_RUN_ID" | tee /tmp/garfield-build-bundle.json + env: + GARFIELD_GOAL: ${{ inputs.goal }} + GARFIELD_NON_GOALS: ${{ inputs.nonGoals }} + GARFIELD_BASE: ${{ inputs.base }} + GARFIELD_RUN_ID: ${{ inputs.runId }} + dependsOn: [] + weight: 0 + allowFailure: false + - name: capture-run-dir + description: Persist runDir pointer for later steps + task: + type: model_method + modelType: command/shell + modelName: garfield-capture-run-dir + methodName: execute + inputs: + run: node scripts/garfield/capture-run-dir.mjs --from /tmp/garfield-build-bundle.json + dependsOn: + - step: build-bundle + condition: + type: succeeded + weight: 0 + allowFailure: false + - name: classify-lanes + description: Deterministically classify native + policy lanes + task: + type: model_method + modelType: command/shell + modelName: garfield-classify-lanes + methodName: execute + inputs: + run: node scripts/garfield/with-run-dir.mjs classify-lanes.mjs + dependsOn: + - step: capture-run-dir + condition: + type: succeeded + weight: 0 + allowFailure: false + - name: write-lane-prompts + description: Write no-edit prompts and empty finding stubs + task: + type: model_method + modelType: command/shell + modelName: garfield-write-lane-prompts + methodName: execute + inputs: + run: node scripts/garfield/with-run-dir.mjs write-lane-prompts.mjs + dependsOn: + - step: classify-lanes + condition: + type: succeeded + weight: 0 + allowFailure: false + dependsOn: [] + weight: 0 + + - name: review + description: Human/agent fills finding files, then merge + dependsOn: + - job: prepare + condition: + type: succeeded + steps: + - name: await-findings + description: Pause until reviewer outputs are written into findings/*.txt + task: + type: manual_approval + prompt: > + Fill each findings file under .swamp/garfield//findings/ with + either `none` or Garfield finding lines. Use cheap models for + modelHint=cheap lanes and a strong model for modelHint=strong. + Then approve this step and resume the workflow. + timeout: 86400 + dependsOn: [] + weight: 0 + allowFailure: false + - name: merge-findings + description: Parse and cluster reviewer outputs + task: + type: model_method + modelType: command/shell + modelName: garfield-merge-findings + methodName: execute + inputs: + run: node scripts/garfield/with-run-dir.mjs merge-findings.mjs + dependsOn: + - step: await-findings + condition: + type: succeeded + weight: 0 + allowFailure: false + weight: 0 + + - name: verify + description: Deterministic validation + final report + dependsOn: + - job: review + condition: + type: succeeded + steps: + - name: validate + description: Run required deterministic checks for the slice + task: + type: model_method + modelType: command/shell + modelName: garfield-validate + methodName: execute + inputs: + run: node scripts/garfield/with-run-dir.mjs validate.mjs --only-required + dependsOn: [] + weight: 0 + allowFailure: false + - name: report + description: Emit garfield pass/blocked report + task: + type: model_method + modelType: command/shell + modelName: garfield-report + methodName: execute + inputs: + run: node scripts/garfield/with-run-dir.mjs report.mjs + dependsOn: + - step: validate + condition: + type: completed + weight: 0 + allowFailure: false + weight: 0 +version: 1