Skip to content

Latest commit

 

History

History

codedirector

README.md

Code Director logo

Code Director (cdir)

Say what you want done — and what must stay untouched. cdir runs the work, watches what moves, and tells you what it checked — and what it couldn't.

What is this?

You write down the job: the goal, the files in scope, and the promises that must hold — "these APIs don't change," "these tests still pass," "no new dependencies." That record is a Lock: a plain YAML file in your repo, one you can read and edit.

Then the work happens inside it — a script, a coding agent, your own edits. cdir watches every file the work touches, blocks nothing on its own, and afterwards shows you exactly what happened: what changed, what stayed inside the agreement, which promises it could verify, and which nobody could verify. The answer comes in plain English first; the evidence sits underneath for when you want it.

cdir lock new "make the preview feel instant, don't touch export"
cdir run IL-0001 -- <the command that does the work>
cdir report IL-0001

It is for anyone who lets an agent — or a teammate, or themselves on a tired Tuesday — edit a repository, and wants the scope kept and the claims shown, not narrated.

The principle

You state what must be true. cdir finds out whether it is. It does not take an agent's word for it: it maps the repository, computes what a change could affect, holds the work to the scope you approved, and marks every claim with how it was checked.

What it costs (read this first)

Code Director uses more tokens than a raw agent. In the pilot benchmark (9 paired tasks), directed runs cost +72% total tokens and bought 9/9 correctness against 7/9 raw — two tasks the unguided agent genuinely misunderstood. The premium is prompt-side: skill instructions, the scope draft, richer working context. The fence itself — hooks, locks, verification, reports — is deterministic code and costs zero tokens.

This is a correctness-for-tokens trade, not a free lunch. For trivial one-file edits the skill's fast lane keeps the ceremony small; for anything that would hurt if built wrong, one avoided misunderstanding usually costs more than the premium. Data and its limits: intent-experiment/EXPERIMENT.md.

Evidence classes

Every claim the system makes is one of:

Class Meaning Produced by
Measured Observed differentially, pre and post, same harness test runs, output stdout hashes, verifyCommand
Proven Structurally guaranteed, no execution needed signature hash match, manifest/lockfile diff, clean typecheck
Asserted Claimed without a differential check — always labeled findings, any claim that lost its artifact reference
Unchecked No check exists or none could run — named, with the reason custom clauses, missing baselines, unavailable compilers

Conservatism is policy: anything not differentially observed is Asserted at best. A claim without an artifact reference is automatically Asserted — enforced in code (enforceArtifactRule), not by discipline. The Unchecked bucket is always rendered, even when empty; a report that hides it is lying.

Two honesty limits worth knowing:

  • Signature evidence is a syntactic declaration surface — export and visibility status, type parameters, the parameter list, return-type annotations, and a type's member surface (methods with their visibility; fields by annotation or initializer KIND). Unannotated arrow consts carry their initializer's kind, so () => 1 → () => "1" moves the hash while () => 1 → () => 2 stays held; formatting-only edits (whitespace) never move it. Plain value consts still exclude the value, by design. (PARSER_VERSION was bumped, so old indexes re-parse.)
  • Baselines are tamper-evident, not tamper-proof. The baseline file's sha256 is recorded in the run record at capture; verification re-hashes and, on mismatch, marks every baseline-dependent check Unchecked — "baseline modified during execution — run invalid" — and fails the run. But .codedirector/baselines/ is still inside the tree the executed command can write: a command that rewrites the baseline and the record could evade this. True oracle separation (baseline outside the writable tree / read-only verifier process) remains a later phase.

Install

npm install
npm run build
npm link        # optional: puts `cdir` on your PATH

Requires Node ≥ 20. No native builds: parsing is pure WASM.

Use it with an AI agent

Installing cdir puts the tools on your machine — it does not tell any AI to use them. An agent only knows what its own instructions and conversation contain, so you must attach one of these hooks (any one works; together is best):

1. MCP — the toolbox. Run cdir mcp as an MCP server in your agent's config. The agent gains nine tools (repo_map, blast_radius, lock_draft, lock_amend, lock_check, lock_activate, run_locked, report, undo). lock_draft and lock_amend take the promises with the scope — KEEP clauses, verifyCommand, accept, budget and deny — so an MCP-driven task carries the same contract as the CLI. Every call leaves a line in .codedirector/runs/mcp.log — when it started, which tool, which root, how long it took, and how it ended — so a call that appears to hang can be read about afterwards instead of guessed at. Arguments are not recorded. The log rotates at 5 MB, keeping one previous file.

Long run_locked commands and checks, and standalone report verification, run in worker threads. Ping and tool listing remain available. Calls carrying an MCP progress token receive elapsed-time liveness immediately and every five seconds, without a completion percentage. Clients must opt into resetting their request timeout on progress (SDK: resetTimeoutOnProgress: true); progress cannot extend an absolute deadline or a client that ignores it.

Each server permits one tool call per canonical Git worktree at a time; competing calls return “repository busy” promptly, including undo and lock changes. Symlinks and project subfolders share that guard; other worktrees remain available. This guard does not coordinate separate servers or CLI processes. Cancellation or client timeout stops waiting, not the command: the guard stays held until completion and the run record remains available through report. Do not retry a timed-out command blindly. Closing input cleanly drains active work before exit; forcibly killing the server cannot promise completion or recovery. The locked command has no server-imposed deadline: if it never exits, its guard and clean-disconnect drain wait indefinitely. Check timeouts still apply to verification probes. Worker errors return errors, never success. Other tools still run on the parent thread; a large indexing request in another worktree can delay liveness.

Agent CLI CLI setup — add to ~/.agents/mcp.json:

{
  "mcpServers": {
    "codedirector": {
      "command": "cdir",
      "args": ["mcp"]
    }
  }
}

Other MCP-capable agents (Claude Code, Gemini CLI, …) take the same cdir mcp command in their own MCP config format. Step-by-step setup guides: Gemini CLI · Grok Build (official xAI CLI) · Grok CLI (community build; contracts align).

2. Skill — the rulebook. Copy skills/codedirector/SKILL.md into your agent's skills directory (e.g. .claude/skills/codedirector/ for Claude Code, or your agent's equivalent). The agent reads it at session start and follows the workflow without being reminded.

3. One-off — just tell it. No setup at all; paste this into the chat:

This project has cdir installed. Read its README first, then follow its
workflow for my request: draft a Vibe Check and wait for my approval
before editing, work inside the approved scope, finish with cdir report.

Whichever hook you use, the flow from your side stays the same: you say what you want, the agent shows you the scope before touching code, and you get a Change Report afterwards.

Apple projects

Swift is indexed, not merely fenced off: .swift files are walked and parsed like any other source, so anchors, the repo map and blast radius see an Apple project's own symbols. A 56k-line macOS app reads as 294 files, 213 of them Swift, in about a second and a half. Cross-file call edges are weaker there than in TypeScript — every Swift import names a module rather than a path, so import-based resolution cannot fire and same-file and unique-name resolution carry it.

Swift / SwiftUI repos also get a draft preset: cdir lock new "<words>" --profile apple. The profile denies vendor dirs and dependency lockfiles (Pods, Carthage, SPM, Bundler), signing assets, and DerivedData; under XcodeGen or Tuist it also denies the generated .xcodeproj / .xcworkspace so nobody hand-edits them. When Package.swift exists it sets verifyCommand: swift test with a 15-minute verifyTimeoutMs (override with --test-timeout on run/verify). The matching agent rulebook: skills/codedirector-apple/SKILL.md.

Command reference

cdir index [--root DIR]

Build or refresh the structural index at .codedirector/index.json. Incremental: each file's sha256 is stored; only new/changed files are reparsed. Honored skips: .git, node_modules, dist, .codedirector, plus root .gitignore / .ignore patterns (nested .gitignore files are not yet honored).

cdir's ignore rules for its working state (index, baselines, runs, checkpoints) live in .codedirector/.gitignore; locks stay commitable. The project's own .gitignore is never edited. Older versions added a # BEGIN cdir … # END cdir block there; where one exists, it is left as it is and still works.

cdir map "<query>" [--top N] [--max-tokens N]

Prints a ranked repo map: symbols most relevant to the query, ordered by personalized PageRank over the symbol graph (restart vector biased to the anchor symbols that matched the query), packed to a token budget (1 token ≈ 4 chars). Deterministic: same index + same query ⇒ same map.

cdir why <symbol> [--json]

Blast radius for a symbol — genuinely useful with no AI involved:

  • where it is defined (file:lines, signature)
  • direct callers (reverse call edges)
  • transitive callers to depth 3, with per-depth counts
  • which test files reference it (*.test.* / *.spec.* / __tests__)
  • historical co-change coupling (top files committed together with the defining file, from git log --name-only; degrades gracefully outside a git repo). Skipped with a printed reason on shallow clones (git rev-parse --is-shallow-repository) and on histories under 30 commits — on a --depth 1 clone the single commit touches every file, so the coupling report would be pure noise.

--json emits the same report as deterministic JSON (sorted keys).

Exit codes: 0 success · 1 failure (e.g. symbol not found) · 2 usage error.

cdir lock new "<utterance>" [--goal TEXT] [--keep SPEC] [--deny GLOB] [--budget-files F] [--profile NAME] [--verify-timeout MS]

Drafts an Vibe Check — a compiled, enforceable contract, human-readable YAML versioned at .codedirector/locks/IL-<NNNN>-<slug>.yaml. The command is interactive-free: it resolves anchor symbols from your words, computes blast radius, and PROPOSES budget.files (defining files of the top-ranked symbols) plus a suggested deny list (dependency manifests, unrelated test files). Anchor matching is tiered — exact name, then name-token, then lexical fuzz — and when only lexical fuzz matches, the draft abstains: empty budget plus a guidance assumption, rather than a confidently wrong one. Deny suggestions are compressed to directory globs and capped at 8 entries; any omission is counted in the lock's assumptions, never silent. The draft is written with status: draft — edit it, then cdir lock check IL-XXXX and cdir lock activate IL-XXXX.

Lock numbers are unique across every worktree of a clone. Inside git, each number is reserved as an empty file in the clone's git common dir (<common dir>/cdir/lock-ids/IL-NNNN, never deleted), which all worktrees share; the next number is one above the highest lock file in this worktree and the highest number the clone has handed out. Outside git, or when that folder cannot be written, the reservation is a file in the locks folder, as before. Ids order by their number, so IL-10000 comes after IL-9999.

The Lock schema (v1): utterance (your exact words, immutable), goal, interpretation (the system's operationalization — editable), keep[], deny[], change, verifyCommand (optional user test harness, e.g. npm test — run as measured evidence), verifyTimeoutMs (timeout for it and tests-pass runs; the draft estimates it from the command, and a lock carrying a verifyCommand never falls back to 60s — --verify-timeout on lock new and --test-timeout on run/verify override it), verifyCovers[] (optional; languages you assert verifyCommand exercises — see below), budget {files, symbols, maxFiles, maxLines}, accept[], assumptions[].

cdir lock check refuses a Lock whose verifyCommand demonstrably cannot reach the languages its budget touches — a budget of Swift files behind a TypeScript-only check is not verified, it is unexamined. The command's reach is read from the toolchains it names (swift, xcodebuild, tsc, node --test, npm/yarn/pnpm, vitest/jest/mocha, pytest, go, cargo, gradle/maven). A command naming none of them warns rather than refusing, since refusing every bespoke harness would be worse than the hole; and verifyCovers is how you say what a harness of your own actually runs. Files whose extension names no language — .md, .gitignore — are not counted against a Lock. KEEP clause kinds:

Kind Payload Checked how
api-unchanged symbol ids ✓ signature hash, diffed against the pre-change baseline (proven)
no-new-dependency — ✓ dependency maps in package.json + manifest/lockfile hashes (npm, SPM, CocoaPods, Carthage) vs baseline (proven)
output-unchanged entry command + fixtures ✓ stdout and stderr sha256, captured at baseline, re-run at verify (measured)
tests-pass test glob ✓ node --test <glob> (measured); empty glob is a violation
custom free text ? not machine-checkable — always Unchecked, human judges

A Lock rejects clauses it cannot even in principle check, unless kind: custom — and custom clauses are always flagged as human-judged.

--keep SPECs: api-unchanged:<file>#<symbol> (comma-separated ids), tests-pass:<glob>, output-unchanged:<command>, no-new-dependency, custom:<text>.

cdir lock ls · cdir lock show <id> · cdir lock check <id> · cdir lock activate <id> · cdir lock abandon <id> --reason "<text>" · cdir lock rebase <id> --accept-current

show renders the Lock with per-clause checkability markers (✓ machine-checkable / ? human judges). check validates against the current index — budget files exist, symbols resolve, deny globs valid, maxFiles >= files.length — and exits non-zero on invalid. activate moves draft → active only when check passes; it is also the re-approval of an active, verified or failed Lock whose content changed (it re-seals). An abandoned Lock cannot be activated.

abandon closes a Lock for good — from any status except abandoned. It sets status: abandoned, records the required --reason as a confirmed user assumption (abandoned: <reason>), and re-pins the seal; an abandoned Lock never runs again, and a report on it does not reopen it. It never touches the work tree: if the Lock's last run changed files that are still uncommitted, it lists them and says how to keep (commit) or drop (git checkout -- <files>, cdir undo) them. It is a CLI command only; the MCP tool list does not change.

Work left behind. A new Lock takes the tree as it stands as "already there", so an earlier Lock's uncommitted changes would go unmentioned in its report. lock new and lock activate (and the MCP lock_draft and lock_activate) therefore warn — never refuse — about every other Lock that is failed, incomplete, active or verified and whose latest run changed files that are still uncommitted (differ from HEAD, or untracked), newest first, at most five files named. A file is blamed on the newest Lock, of any status, whose latest run changed it — an older Lock is not named for a file a later one changed again. A failed, incomplete or active one "left changes in the tree"; a verified one "is verified but not committed". Abandoned Locks are never listed.

rebase --accept-current moves the lock's task baseline to the present tree — the explicit revision, for a legacy lock or a reference that must change. The replaced baseline is archived beside the new one; nothing moves silently, and re-running never moves it.

cdir checks [paths...] [--json]

Reads the optional .codedirector/checks.yaml — which checks each area of the repo needs — and says what it requires. Drafting fills the verifyCommand from it, lock check refuses a verifyCommand that leaves a needed check out, and a run or report fails on one (a CHECKS violation): see docs/checks.md.

cdir checkpoint [<lock-id>] · cdir undo [--force] [--keep-untracked]

Git-native safety net (blueprint §19): checkpoint records a lightweight tag cdir/ckpt-<timestamp> at HEAD plus a byte snapshot of dirty / skip-worktree files under .codedirector/ckpt-blobs/. With a lock id — cdir checkpoint IL-XXXX — it also captures that lock's task baseline once (the pre-change reference every later attempt is judged against; see cdir run). undo resets to the tagged ref and restores that snapshot — including a dirty tree as of checkpoint time (--force) and skip-worktree files that git reset --hard would otherwise leave. Honest limit: git cannot undo external side effects.

⚠ Behavior change from v0.1.0: untracked files created after the checkpoint are deleted by undo (v0.1.0 left them in place). The files are enumerated and printed to stderr before deletion, and the deleted list is shown again in the undo output. Pass --keep-untracked to preserve them.

cdir run <lock-id> [--allow-expand] [--no-report] [--test-timeout MS] -- <command...>

Verified execution inside an active Lock:

  1. auto-checkpoint (git tag) before anything;
  2. resolve the lock's task baseline — one immutable reference per lock (signature hashes of every api-unchanged symbol, dependency fingerprints, stdout+stderr hashes of every output-unchanged command — probes are isolated so they cannot mutate the tree — plus HEAD / dirty hashes / skip-worktree state) at .codedirector/baselines/<id>-task.json. Captured by the first run (or cdir checkpoint IL-XXXX); a retry REUSES it, so a broken promise cannot be laundered by running again;
  3. run the command (spawned, stdio inherited);
  4. classify every file the command actually touched against the Lock — delta vs the task baseline, not vs current HEAD, so git commit, git mv, and skip-worktree cannot hide a deny. Writes outside --root are out-of-budget. Pre-existing dirt is not billed to the command — but an earlier attempt's change IS. Denied or out-of-budget changes are violations; nothing is auto-reverted (cdir undo is offered). --allow-expand is the logged override for scope growth only — a broken KEEP clause still fails;
  5. run the verification ladder (see below) against the baseline;
  6. write the run record (including the full verification result) to .codedirector/runs/, update the lock status (verified / failed / incomplete), and emit the Change Report (--no-report suppresses the rendering, not the verification).

A declared check that could not run — a verifyCommand or tests-pass timeout, a clause whose baseline surface never captured — makes the status incomplete, never verified; circumstantial skips (no tsconfig.json, no compiler) stay informational.

Exit code: 0 only if the command succeeded AND no violations AND no declared check went unrunnable. cdir run without a lock id is refused — ad-hoc mode is a later phase; the whole point is the contract.

The verification ladder

After execution, every KEEP clause and every lock-level claim is verified as far as possible, in ladder order (cheapest/strongest first):

  1. Structural (proven) — re-index; each api-unchanged symbol's signature hash compared pre/post (declaration surface: export status, parameters, annotations, member surfaces; whitespace-insensitive); no-new-dependency diffs dependency maps in package.json plus lockfile hashes. Both produce proven held or violated.
  2. Typecheck (proven when clean) — if tsconfig.json exists and a compiler is available (node_modules/typescript, else npx --no-install tsc), run tsc --noEmit with a 120s timeout. Clean → proven. The baseline records the errors that already existed before the run (file, code and message, without positions), and only errors the run added are a measured violation; errors that were all there before → held, measured. No compiler or no tsconfig → Unchecked with the reason named.
  3. Tests (measured) — each tests-pass glob is expanded and run via node --test with a timeout; pass → measured, fail → measured violation with the failing test names, empty glob → violated (fail closed). node --test runs JavaScript and TypeScript only: lock check refuses a glob that names another file type (Tests/**/*.swift), and a glob that reaches such a file anyway is Unchecked with the reason, never "failing". Other suites belong in verifyCommand. A Lock-level verifyCommand (e.g. npm test) runs the same way. Probes are isolated and strip NODE_TEST_CONTEXT. Afterwards the tree is put back as it was, but only the files a probe changed are written back, so every other file keeps its time. What a probe changed or added is moved to .codedirector/runs/put-back/ and named in the Change Report: a probe can't tell its own writes from another session's, so nothing is lost.
  4. Output (measured) — each output-unchanged command is re-run in isolation and its stdout and stderr sha256 compared to the baseline capture. If the lock was edited after the baseline was captured, the clause is Unchecked — "no pre-change baseline" — never silently held.
  5. Custom (unchecked) — always unchecked — human judges, listed by text. This honesty is the feature.

The verifier writes nothing to the repository (the index refresh touches only .codedirector/). The probed commands themselves run with normal repo permissions — a fully sandboxed verifier is a later phase.

cdir verify <lock-id> [--test-timeout MS]

Re-runs the ladder without re-running the change command: the lock's task baseline, fresh index, all rungs. It also recomputes the scope delta from the current tree against that baseline — a file changed after the run is part of the receipt. Without a task baseline, structural and output checks report Unchecked ("no pre-change baseline") while tests and typecheck still run, and the verdict is incomplete — never verified. Updates the lock status; exit 0 only when verified.

Editing a Lock breaks its seal, so re-approval (cdir lock check then cdir lock activate) is still a human act; moving the task baseline is cdir lock rebase --accept-current, an explicit act of its own.

--test-timeout MS (on both run and verify) sets the timeout for tests-pass runs and the lock's verifyCommand, overriding the lock's verifyTimeoutMs (estimated from the command when the draft omitted it; a lock carrying a verifyCommand never falls back to 60s).

cdir hook

PreToolUse bridge for agent lifecycle hooks (agent lifecycle hooks and compatible systems). Reads the agent's hook payload JSON from stdin (tool_name, tool_input, cwd) and decides whether an edit-ish tool call may proceed against the active Lock: deny-listed and out-of-budget files are blocked (exit 2, reason on stderr — the agent receives it as a failed tool result); edits with no active Lock are allowed with a workflow reminder (exit 0, JSON message on stdout); non-edit tools, unknown payload shapes, and any internal error fail open (exit 0). Paths under .codedirector/ are always allowed — the layer manages its own state. Per-edit enforcement covers file membership only; maxFiles / maxLines stay with the run-time classifier, and the hook cannot see inside shell commands — cdir run verification remains the hard floor.

cdir report <lock-id> [--format=terminal|md|json] [--attempt N]

Renders the Change Report — the product's signature artifact. It judges the latest attempt against the current tree: the scope delta from the task baseline is recomputed (a post-run edit is not invisible) and the ladder re-runs against the task baseline. The recorded command outcome is kept as that attempt's history and decides the verdict only for the attempt it belongs to, so a fixed re-verification is never left reading FAILED. The verdict is verified, failed, or incomplete — the last when there is no task baseline to judge scope against, or a declared check could not run, and it is never written as verified. --attempt N renders a recorded attempt as it was, labeled with its id, and writes nothing onto the lock: an old attempt is history, not the verdict.

A lock answers for the tree only until the next lock starts. Once another lock's task baseline has been captured after this lock's last attempt, the live tree holds that lock's work too, and judging the older lock against it would blame it for changes it never made. So for a lock that a later one has closed, cdir report — and cdir verify, and the MCP report tool — show its last attempt as recorded, name the lock that closed it ("Closed by IL-0042"), re-run nothing, and leave its status as it was. cdir lock rebase opens it again, on the present tree. Two things this rule does not reach: two locks running at the same time still see each other's files, and an edit made between one lock's last attempt and the next lock's start is not judged by any report once that lock exists.

The report opens with a plain-language summary generated from the same data as the detail ("Done — verified. Only src/preview.ts changed, within the agreed scope. 2 promises held (checked). 1 thing needs your judgment…" — or, on failure, "Blocked: src/export.ts was outside the agreed scope. Nothing was reverted — run cdir undo to restore."). The summary never says "verified" when violations exist; the detail follows underneath:

  • lock id + the original utterance, verbatim and immutable;
  • changed files with classification (in-budget / out-of-budget / denied) and the budget they were measured against;
  • every KEEP clause and lock-level check with verdict, evidence class, and artifact reference (which baseline file, which command, which exit code) — violations first, then verified-held, then the Unchecked bucket (always visible, each item with its reason);
  • acceptance criteria (human judges), rendered beside the Unchecked bucket and counted in the summary's judgment line;
  • findings: incidental observations (e.g. "src/x.ts changed in-budget but no test file references it — behavioral coverage unknown"), always Asserted and labeled. Two more never change the verdict: "Verified, not committed yet" (a verified report of the current tree whose changed files are still uncommitted; not added to the report that ends a live run) and "The command changed no file" (a run that exited 0 and changed nothing — a patch applied from the wrong folder is skipped silently, and the run proves nothing);
  • a footer with counts per evidence class.

--format=md is PR-postable markdown; --format=json is deterministic (stableStringify). The report builder downgrades any held claim lacking an artifact reference to Asserted — schema-enforced, per the blueprint. Report verdicts update the lock status, so cdir lock show reflects them.

Sample (clean run):

CHANGE REPORT · IL-0001 · verdict: VERIFIED
Said   "make the preview feel instant"
Goal   input-to-paint under 50ms during drag
Run    node scripts/inline-debounce.js · record .codedirector/runs/IL-0001-….json

Changed files (1 changed · budget 1/2 files, 3/400 lines):
  ✓ src/preview.ts  in-budget

Violations: none

Checks (violations first):
  ✓ proven   api-unchanged · src/pipeline.ts#ImagePipeline.renderExport — signature unchanged
             artifact: .codedirector/baselines/IL-0001-….json#signatures[src/pipeline.ts#…]
  ✓ measured tests-pass · test/*.test.ts — 1 test file(s) pass under node --test
             artifact: node --test test/*.test.ts (1 file(s)) → exit 0

Unchecked (1) — named, not silently dropped:
  ? custom · feels right above 8K sources — not machine-checkable — human judges

Counts: proven 1 · measured 1 · asserted 0 · unchecked 1

Design principles

  1. Human states what must be true; the system finds out whether it is.
  2. Evidence, not assertion. Every claim carries an evidence class and an artifact reference.
  3. Determinism. Index, map, and report outputs are byte-stable for identical inputs (sorted keys, stable ordering). The Change Report stage depends on this.
  4. Useful before it is smart. cdir why works in minute one, with no configuration and no model in the loop.
  5. Local-first. The index lives in the repo at .codedirector/; nothing leaves the machine.

Eval harness

npm run eval runs the cases in eval/cases/*.yaml against the real CLI in temp git repos and scores them against explicit expectations (exit codes, violation substrings, per-clause evidence class + verdict, minimum Unchecked bucket size). It is a regression gate: any failed case exits non-zero. It is deliberately not more than that — no LLM judge, no statistics, not blind. See eval/README.md.

What this is NOT yet

Per the blueprint's phasing, honestly:

  • No LLM agent loop. cdir run wraps a command you (or your agent) provide; it does not plan, edit, or repair by itself.
  • No sandboxed verifier. Probed commands run with repo permissions; our code writes nothing, but a compromised test script could. Baselines are tamper-EVIDENT (capture-time sha256 re-checked at verify; a mismatch invalidates the run), not tamper-proof — full sandboxing and moving the baseline outside the writable tree are later phases.
  • No characterization generation. output-unchanged pins the output of commands you name; it does not auto-generate behavior-pinning tests over the KEEP surface (blueprint phase 1.5+, with a mutation-testing gate).
  • No performance measurement rung. Latency/memory claims are not measurable yet — such clauses belong in custom (human judges) for now.
  • No symbol-level budget enforcement. budget.symbols is advisory; enforcement is file-level.
  • Heuristic parsing. Call edges are name-resolved, not type-resolved; compiler-grade precision (LSP) is a later phase.

Where we deliberately stop

cdir is a contract checker, not a sandbox. Known evasion paths are instrumented and reported, and every success claim carries its evidence class — but we do not claim "the agent couldn't have cheated," only "if it had, you would see it." Kernel-level isolation, a read-only verifier process, and merge-only-in-budget worktrees are real options, and we have chosen not to build them yet. The next layer gets picked by evidence, not by anxiety: we consolidate what exists, let users hit the referee in daily work, and let their complaints — not our imagination — decide what hardens next. The name "lock" belongs to the YAML contract; it is not a security claim.

Architecture

src/
  cli.ts            arg parsing, commands, exit codes
  index.ts          public API barrel — later stages import from here
  core/
    types.ts        RepoIndex / FileIndex / SymbolInfo / CallSite / CallEdge
    walk.ts         repo walker (.gitignore-aware, fixed skip set)
    parser.ts       web-tree-sitter WASM extraction (symbols, imports, calls)
    builder.ts      incremental index builder (sha256 content hash per file)
    store.ts        .codedirector/index.json persistence (deterministic JSON)
    graph.ts        symbol graph: call-edge resolution, callers, tests
    rank.ts         personalized PageRank over the symbol graph
    map.ts          token-budgeted ranked repo map
    why.ts          blast-radius report (composed from graph + git)
    git.ts          co-change mining via git log --name-only
  lock/
    types.ts        VibeCheck / KeepClause / LockBudget / schema v1
    yaml.ts         stable YAML round-trip + strict validation
    glob.ts         glob matching/validation for deny, budget, test globs
    store.ts        .codedirector/locks/IL-NNNN-slug.yaml persistence
    leftovers.ts    earlier locks' uncommitted changes — the warnings when work starts
    draft.ts        lock new — anchors + blast radius propose budget/deny
    check.ts        lock check — validation + signature-hash primitive
    show.ts         lock show — blueprint-style rendering, ✓ / ~ / ? markers
  checkpoint/
    index.ts        git tag checkpoints, one-command undo, dirty-tree guard
  run/
    baseline.ts     pre-run KEEP surface capture (outside the source tree)
    classify.ts     changed-file classification: denied/out-of-budget/in-budget
    run.ts          cdir run — checkpoint → baseline → execute → enforce → verify → record
    probe.ts        read-only command probes (bounded timeout, sanitized env)
  verify/
    types.ts        EvidenceClass / VerificationItem / artifact-reference rule
    verify.ts       the ladder: structural → typecheck → tests → output → custom
  report/
    report.ts       Change Report builder + findings + lock status transitions
    format.ts       terminal / markdown / deterministic-JSON renderers
eval/
  cases/*.yaml      regression-gate cases (repo setup, lock, command, expect)
  run.ts            npm run eval — executes cases against the real CLI, gates

Parsing choice

Parsing uses web-tree-sitter (pure WASM, no native compilation) with the official tree-sitter-typescript / tree-sitter-javascript npm packages, which ship prebuilt .wasm grammars. This was the first-choice option and installed cleanly; no fallback was needed.

Swift arrives the same way, from tree-sitter-wasms, which publishes prebuilt grammars for 36 languages. The obvious package, tree-sitter-swift, was rejected on inspection: it ships native bindings (node-gyp-build plus per-platform prebuilds) and no .wasm at all, which would have cost this project the promise the paragraph above opens with. Swift extraction lives in its own module rather than branching inside the TypeScript one — the node types share almost no names, and one function switching on language in four places is where that code rots.

Extraction is syntax-level and heuristic — call edges are resolved by name (same-file → import → unique name), not by types. Method calls (obj.m()) are best-effort. This is documented behavior, not a bug: compiler-grade precision is a later phase (LSP integration).

Internal API surface (for Stage 2+)

Import from the package root (codedirector / dist/src/index.js):

  • buildIndex(rootDir, opts?) → { index: RepoIndex, stats: BuildStats } — incremental build; stats.filesParsed is the reparse counter.
  • loadIndex(rootDir) → RepoIndex | null, saveIndex(rootDir, index), stableStringify(value) — persistence + deterministic JSON.
  • buildGraph(index) → SymbolGraph — resolved edges, directCallers / directCallees / transitiveCallers(graph, id, depth), testFilesFor(index, symbol), resolveModule(fromFile, specifier, index).
  • rankSymbols(graph, anchorIds) → RankedSymbol[] — personalized PageRank (damping 0.85, 50 iterations, call weight 1.0, same-file weight 0.15).
  • buildRepoMap(index, query, { top, maxTokens }), formatRepoMap(...).
  • blastRadius(rootDir, index, query) → BlastRadiusReport, formatBlastRadius(...), findSymbols(graph, query).
  • coChange(rootDir, relFile), isGitRepo(rootDir).
  • Types: RepoIndex, FileIndex, SymbolInfo, CallSite, CallEdge, BlastRadiusReport, INDEX_SCHEMA_VERSION, PARSER_VERSION.

Stage 2 additions (for Stage 3 — verification + Change Report):

  • Vibe Check: loadLock, listLocks, saveLock, nextLockId, draftLock, parseKeepClause, checkLock, signatureHash, lockToYaml / lockFromYaml (stable YAML round-trip), formatLock; types VibeCheck, KeepClause, KeepClauseKind, LockBudget, LockAssumption, LockStatus, ClauseCheckability; glob helpers matchPath, validateGlob, globToRegExp.
  • Checkpoint/undo: createCheckpoint, latestCheckpoint, undo, gitStatusPorcelain, workTreeStatusPorcelain, dirtyPaths, gitPrefix, toRootRelative; types Checkpoint, UndoRecord, UndoResult.
  • Run: captureBaseline(rootDir, lock, index) + saveBaseline, classifyChanges(rootDir, lock), changedFiles, changedLineCount, runWithLock(rootDir, lockId, command, opts), formatRunReport; types Baseline, RunRecord, RunOutcome, ClassifiedChange, KeepResult, BudgetStats.

Stage 3 additions (verification + Change Report + eval):

  • Verify: verifyLock(rootDir, lockId, opts), verifyWithBaseline(rootDir, lock, baseline, baselineRel, index, opts), enforceArtifactRule(items) (a held claim without an artifactRef becomes Asserted), runShellProbe / runArgvProbe (read-only probes), loadBaseline, latestBaselinePath; types EvidenceClass, Verdict, VerificationItem, VerificationReport, VerifyOptions.
  • Report: buildReport(rootDir, lockId, opts), finalizeLockStatus, latestRunRecord, formatReport / formatReportMarkdown / formatReportJson; types ChangeReport, Finding, BuildReportOptions.
  • Lock schema gained an optional verifyCommand field (user harness, run as measured evidence); Baseline gained optional outputs (stdout hashes of output-unchanged commands, captured pre-change).

Schema law for later stages: derived structures (edges, rankings, reports) are computed from RepoIndex and never persisted, so a partial re-index can never leave stale derived state. Keep it that way.

Development

npm run build     # tsc → dist/
npm test          # build + node --test dist/test/
npm run eval      # build + regression gate over eval/cases/*.yaml
node dist/src/cli.js why ImagePipeline --root examples/demo-repo

examples/demo-repo/ is a tiny TypeScript project (slider → pipeline → export, an image-preview toy) used by the tests and by later stages as a stable fixture.

Disclaimer

This software is provided "as is", without warranty of any kind, express or implied. The author and contributors assume no responsibility or liability for any errors, issues, data loss, or consequences resulting from its use. Users are solely responsible for evaluating, testing, and verifying any operations or changes made with this tool.

License

MIT. See LICENSE.