Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .codearbiter/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ python .github/scripts/test_readinjectlib.py
# pre-read hook entry point — governed-file injection, dedup, fail-open, dormancy
# (pre-read.py): AC-03/09/10/12, miss, and dormant-repo paths
python .github/scripts/test_pre_read.py

# plugins/ca/hooks/tests/ — the hook enforcement + helper logic unittest suite
# (statusline, _ledgerlib, guards, etc.); run in CI via unittest discover
python -m unittest discover -s plugins/ca/hooks/tests -p "test_*.py"
```

Only when `plugins/ca/tools/**` changed:
Expand Down
51 changes: 51 additions & 0 deletions .github/scripts/test_provenancelib.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,57 @@ def test_round_trip(self):
)


class WriteProvenanceAtomicTest(unittest.TestCase):
"""reliability-016: write_provenance is routed through the temp-file +
os.replace atomic-write pattern, so a crash mid-write never truncates or
corrupts a previously-written provenance record."""

def test_crash_mid_write_leaves_previous_record_intact(self):
from unittest.mock import patch

record_v1 = pl.new_record("tech-stack", created="2026-06-26")
record_v2 = pl.new_record("tech-stack", created="2026-07-01")

with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, ".codearbiter", ".provenance", "tech-stack.json")
pl.write_provenance(path, record_v1)
self.assertEqual(pl.read_provenance(path), record_v1)

# Simulate a crash between the temp-file write and the atomic
# rename (the exact window write_text_atomic exists to close).
with patch("os.replace", side_effect=OSError("simulated crash")):
with self.assertRaises(OSError):
pl.write_provenance(path, record_v2)

# The destination must still hold the PRIOR, complete record —
# never truncated, never partially overwritten.
self.assertEqual(pl.read_provenance(path), record_v1)

# No stray .tmp file should linger in the provenance directory.
leftovers = [
n for n in os.listdir(os.path.dirname(path)) if n != "tech-stack.json"
]
self.assertEqual(leftovers, [], f"no temp file should linger: {leftovers}")

def test_crash_during_json_serialization_leaves_previous_record_intact(self):
"""A crash while dumping JSON (before the atomic write even starts)
must also never touch the previously-written file."""
from unittest.mock import patch

record_v1 = pl.new_record("tech-stack", created="2026-06-26")

with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, ".codearbiter", ".provenance", "tech-stack.json")
pl.write_provenance(path, record_v1)
self.assertEqual(pl.read_provenance(path), record_v1)

with patch("json.dumps", side_effect=ValueError("simulated crash")):
with self.assertRaises(ValueError):
pl.write_provenance(path, pl.new_record("tech-stack", created="2026-07-01"))

self.assertEqual(pl.read_provenance(path), record_v1)


class BatchHashTest(unittest.TestCase):
"""AC-02: batch_hash issues a single git hash-object --stdin-paths call, order-preserving."""

Expand Down
20 changes: 20 additions & 0 deletions .github/scripts/test_taskwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,26 @@ def _failing_replace(src, dst):
self.assertEqual(f.read(), original_content,
"board was truncated/corrupted by an interrupted write")

def test_missing_board_returns_1_no_file_created(self):
"""coverage-004: an uninitialized repo (no .codearbiter/open-tasks.md)
must exit 1 with the 'no board at' stderr message and must not create
any file as a side effect."""
import io
import tempfile
import contextlib
import taskwrite

d = tempfile.mkdtemp() # deliberately no .codearbiter/ dir at all
taskwrite.project_root = lambda: d

stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
rc = taskwrite.main(["add", "x"])

self.assertEqual(rc, 1)
self.assertIn("no board at", stderr.getvalue())
self.assertEqual(os.listdir(d), [], "no file should be created for an uninitialized repo")


if __name__ == "__main__":
unittest.main()
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,15 @@ jobs:
ver=$(node -p "JSON.parse(require('fs').readFileSync('plugins/ca-sandbox/.claude-plugin/plugin.json','utf8')).version")
# First introduction of the plugin: plugin.json does not exist on base.
# That is never a silent-staleness trap (nothing is published yet), so
# allow it - without this, `git show` errors and `bash -e` kills the step.
base_json=$(git show "origin/$BASE:plugins/ca-sandbox/.claude-plugin/plugin.json" 2>/dev/null || true)
if [ -z "$base_json" ]; then
# allow it - but only when the file is genuinely absent at $BASE. Any
# other git failure (transient fetch error, corrupted object store,
# a race on origin/$BASE) must fail the step, not be swallowed as
# "first introduction".
if ! git cat-file -e "origin/$BASE:plugins/ca-sandbox/.claude-plugin/plugin.json" 2>/dev/null; then
echo "ca-sandbox is new on $BASE (no prior plugin.json) - first introduction, version bump not required"
exit 0
fi
base_json=$(git show "origin/$BASE:plugins/ca-sandbox/.claude-plugin/plugin.json")
base_ver=$(printf '%s' "$base_json" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")
if [ "$ver" != "$base_ver" ]; then
echo "payload changed and version bumped: $base_ver -> $ver"
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ jobs:

- name: Resolve and verify version against the manifest
id: v
env:
CONFIRM: ${{ github.event.inputs.confirm }}
run: |
set -euo pipefail
MANIFEST=$(node -p "require('./plugins/ca/.claude-plugin/plugin.json').version")
REQ="${{ github.event.inputs.confirm }}"
REQ="$CONFIRM"
if [ "$REQ" != "$MANIFEST" ]; then
echo "::error::requested '$REQ' does not equal plugin.json version '$MANIFEST' — bump/align first"
exit 1
Expand Down Expand Up @@ -67,11 +69,12 @@ jobs:
- name: Create the tag and GitHub Release (idempotent)
env:
GH_TOKEN: ${{ github.token }}
SUMMARY: ${{ github.event.inputs.summary }}
run: |
set -euo pipefail
TAG="${{ steps.v.outputs.tag }}"
VER="${{ steps.v.outputs.version }}"
SUM="${{ github.event.inputs.summary }}"
SUM="$SUMMARY"
if [ -n "$SUM" ]; then TITLE="codeArbiter $VER: $SUM"; else TITLE="codeArbiter $VER"; fi

# An annotated tag needs a committer identity; the runner has none by default.
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ legacy/
# never shared. Each developer's own .claude/settings.json wires the plugin hooks.
.claude/settings.json

# Local Claude Code overrides (may carry machine-local secrets like FARM_API_KEY
# injected via `env`). Repo-tracked guardrail so the exclusion travels with the
# repo instead of relying on each developer's global gitignore.
.claude/settings.local.json

# Transient ADR-authoring marker under the root-level state dir (set by /ca:adr).
# Not project state.
.codearbiter/.markers/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Every intent routes through a gated skill or reviewer agent. Nothing commits until the gates are green. Decisions go through SMARTS. The audit trail is append-only.

<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
<img alt="version 2.8.0" src="https://img.shields.io/badge/version-2.8.0-2b7489">
<img alt="version 2.8.1" src="https://img.shields.io/badge/version-2.8.1-2b7489">
<img alt="commands" src="https://img.shields.io/badge/commands-39-555">
<img alt="skills" src="https://img.shields.io/badge/skills-22-555">
<img alt="agents" src="https://img.shields.io/badge/agents-28-555">
Expand Down
2 changes: 1 addition & 1 deletion plugins/ca-sandbox/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "ca-sandbox",
"displayName": "codeArbiter Sandbox",
"description": "Locally-hosted Codespace equivalent for codeArbiter. Pulls an untrusted repo into an ephemeral, isolated Docker container with no host-filesystem access and configurable egress, caches dependencies by content hash, then tears the box down. Requires Docker and nixpacks on PATH.",
"version": "0.1.1",
"version": "0.1.2",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
Expand Down
10 changes: 8 additions & 2 deletions plugins/ca-sandbox/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
* cache/tag/relocation logic is unit-testable without real docker, while the
* docker-gated test drives the real defaults.
*/
import { spawn } from "node:child_process";
import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -66,7 +66,13 @@ const DOCKER_ENV = { ...process.env, MSYS_NO_PATHCONV: "1" };
// explicitly so the build is robust regardless of the daemon default.
const BUILD_ENV = { ...DOCKER_ENV, DOCKER_BUILDKIT: "1" };

function run(cmd: string, args: string[], opts: object = {}): Promise<RunResult> {
// `opts.env`, when supplied, is an explicit typed override (e.g. BUILD_ENV
// below) rather than an unconstrained spread — a future opts literal is now
// compiler-checked to stay a well-formed env override (must preserve
// MSYS_NO_PATHCONV explicitly, not drop it by accident).
type RunOpts = { env?: NodeJS.ProcessEnv } & Omit<SpawnOptionsWithoutStdio, "env" | "shell">;

function run(cmd: string, args: string[], opts: RunOpts = {}): Promise<RunResult> {
return new Promise<RunResult>((resolve) => {
const c = spawn(cmd, args, { env: DOCKER_ENV, ...opts });
let stdout = "";
Expand Down
2 changes: 1 addition & 1 deletion plugins/ca/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "ca",
"displayName": "codeArbiter",
"description": "Orchestration layer for Claude Code. Routes every intent through gated skills and reviewer agents, drives spec-driven TDD, mechanically enforces the commit and audit-trail gates, decides via SMARTS, and keeps an append-only audit trail. Requires Python 3 on PATH. Dormant until you opt a repo in; run /ca:init to activate.",
"version": "2.8.0",
"version": "2.8.1",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
Expand Down
10 changes: 7 additions & 3 deletions plugins/ca/hooks/_hooklib.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,18 +563,22 @@ def is_deploy_path(rel, root):
return path_in_globs(rel, root, DEPLOY_DEFAULT_GLOBS, _DEPLOY_DECL_RE)


def write_text_atomic(path, text):
def write_text_atomic(path, text, newline=None):
"""Write `text` to `path` atomically: a sibling temp file in the same dir,
then os.replace() into place (atomic on POSIX; a same-volume rename on
Windows). A crash between open() and the rename never leaves a half-written
file at `path`. The gate-marker writers (migration-pass / security-pass) rely
on this so a partial digest set can't be read back as an unrecognized token
and force a spurious gate re-run (migration-002). On any failure the temp
file is cleaned up and the original `path` is left untouched."""
file is cleaned up and the original `path` is left untouched.

`newline` is passed through to open()/fdopen() unchanged (default None keeps
the prior text-mode translation behaviour for existing callers); pass "\\n"
to force LF output regardless of platform (e.g. for a canonical-EOL file)."""
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=os.path.basename(path) + ".", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
with os.fdopen(fd, "w", encoding="utf-8", newline=newline) as f:
f.write(text)
os.replace(tmp, path)
except Exception:
Expand Down
13 changes: 8 additions & 5 deletions plugins/ca/hooks/_provenancelib.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
import re
import subprocess

import _hooklib

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -206,18 +208,19 @@ def new_record(doc, *, interview_derived=False, entries=None, created=None):


def write_provenance(path, record):
"""Write `record` as pretty JSON to `path`.
"""Write `record` as pretty JSON to `path`, atomically.

Format: indent=2, sorted keys, ensure_ascii=False, trailing newline, utf-8,
LF line endings (canonical EOL for this repo). Creates the parent directory
if it does not exist.
if it does not exist. Routed through _hooklib.write_text_atomic (sibling
temp file + os.replace) so a crash mid-write leaves the previous provenance
record intact instead of a truncated/corrupt file (reliability-016).
"""
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(record, f, indent=2, sort_keys=True, ensure_ascii=False)
f.write("\n")
text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
_hooklib.write_text_atomic(path, text, newline="\n")


def read_provenance(path):
Expand Down
16 changes: 5 additions & 11 deletions plugins/ca/hooks/statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,7 @@
import _ledgerlib
ledger_update = _ledgerlib.ledger_update
_tx_accumulate = _ledgerlib._tx_accumulate
_agg_reqs = _ledgerlib._agg_reqs
_totals = _ledgerlib._totals
api_cost = _ledgerlib.api_cost
price_for = _ledgerlib.price_for
ledger_path = _ledgerlib.ledger_path
persist_sess_start = _ledgerlib.persist_sess_start
API_PRICES = _ledgerlib.API_PRICES
except Exception: # pragma: no cover — never let an import break the statusline
_ledgerlib = None

Expand Down Expand Up @@ -490,11 +484,11 @@ def _arbiter_state_uncached(cad):

# --------------------------------------------------------------------------- ledger
# The token/cost ledger (pricing table, transcript accumulation, JSON persistence,
# burn samples) lives in _ledgerlib (extracted T-12). Its functions — ledger_update,
# _tx_accumulate, _agg_reqs, _totals, api_cost, price_for, ledger_path, API_PRICES —
# are bound into this module at import time (see the guarded import at the top), so
# the cost segment and the test suite reach them unchanged. Only burn_spark stays
# here: it is the render bridge that turns the lib's numeric samples into a colored
# burn samples) lives in _ledgerlib (extracted T-12). Its functions actually used
# from this module — ledger_update, _tx_accumulate, persist_sess_start — are bound
# into this module at import time (see the guarded import at the top), so the cost
# segment and the test suite reach them unchanged. Only burn_spark stays here: it
# is the render bridge that turns the lib's numeric samples into a colored
# sparkline, so it keeps the ANSI dependency out of the lib.
def burn_spark(rec):
"""Sparkline of recent per-message token burn — real per-API-call totals
Expand Down
7 changes: 4 additions & 3 deletions plugins/ca/hooks/tests/test_statusline.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,10 @@ def test_render_is_deterministic_for_same_input(self):

def test_ledger_functions_reachable_via_statusline(self):
# The unmodified test suite reaches these via sl.*; the extraction must keep
# them bound on the statusline module.
for name in ("ledger_update", "_tx_accumulate", "_agg_reqs", "_totals",
"api_cost", "price_for", "ledger_path", "API_PRICES"):
# them bound on the statusline module. Only the names this module (and its
# test suite) actually use are re-bound — the rest were dead re-binds
# removed as part of architecture-002.
for name in ("ledger_update", "_tx_accumulate", "persist_sess_start"):
self.assertTrue(hasattr(sl, name), f"sl.{name} missing after extraction")

def test_burn_spark_returns_string(self):
Expand Down
2 changes: 1 addition & 1 deletion plugins/ca/includes/routing-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ command is **invoked**; the orchestrator **routes** to a skill; a skill **dispat
| Autonomous sprint | `/sprint` → `SPRINT.md` (brainstorm → plan → `subagent-driven-development`) | per-task impl authors + reviewers | One interactive spec gate; hard gates never auto-decided; every auto-decision logged |
| Bug fix | `/fix` → `tdd` (bug variant) | impl author | Failing regression test before any fix code |
| Docs edit / dep bump / revert | `/chore` (type-scaled gates) | `dependency-reviewer` for deps | No behavioral code; suite green for deps/revert; exits via `commit-gate` |
| Exploratory throwaway spike | `/spike` → `spike` skill → `spike/<slug>` branch | — | Never merges or PRs; `commit-gate`-exempt (nothing on the branch can land); exits to a findings note or `/feature` |
| Exploratory throwaway spike | `/spike` (self-contained command) → `spike/<slug>` branch | — | Never merges or PRs; `commit-gate`-exempt (nothing on the branch can land); exits to a findings note or `/feature` |
| Behavior-preserving restructure | `/refactor` → `refactor` skill | `tdd` Phase 1 (new seams only) | No refactor without parity-coverage proof |
| Unknown defect / investigation | `/debug` → `debug` skill | — | No code change in the skill; one named exit |
| Commit | `/commit` → `commit-gate` | — | No commit without all nine gates green |
Expand Down
11 changes: 9 additions & 2 deletions plugins/ca/tools/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* mutation.ts -> exec.ts, never back). This is a move, not a rewrite: behaviour
* is identical to the prior in-farm.ts definitions.
*/
import { spawn } from "node:child_process";
import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";

Expand Down Expand Up @@ -73,6 +73,13 @@ export function scrubbedEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env;
}

// `opts` excludes `env`, `cwd`, and `shell` so a caller can never re-introduce
// a raw env (and thus silently override scrubbedEnv()'s CodeQL #5 scrub),
// shadow the explicit `cwd` param, or opt into shell interpolation through the
// spread below — the compiler now enforces the single-scrub-path and
// argv-array invariants the header comment describes.
type RunOpts = Omit<SpawnOptionsWithoutStdio, "env" | "cwd" | "shell">;

// `timeoutMs` (opts) bounds the child's wall-clock; 0/undefined disables the
// timeout (used by git, which must not be killed mid-operation). On timeout the
// child tree is killed and a RunResult tagged `timedOut` resolves, so the caller
Expand All @@ -81,7 +88,7 @@ export function run(
cmd: string,
args: string[],
cwd?: string,
opts: object = {},
opts: RunOpts = {},
timeoutMs = 0,
): Promise<RunResult> {
return new Promise<RunResult>((resolve) => {
Expand Down
Loading