mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(skills): GitNexus Engineering Tool Kits (#2566)
* feat(skills): add ce-plan — GitNexus+PDG implementation-planning skill Adds .claude/skills/ce-plan: a planning-only skill that builds implementation-ready plans from GitNexus graph navigation (query/context/ impact/trace), bounded statement-level PDG slices (pdg_query, impact mode:pdg, explain), and targeted source verification, with a context ledger to prevent repeated reads and a machine-readable implementation context pack (stable contract for a future ce-implement). Whitelisted in .gitignore and registered in AGENTS.md and CLAUDE.md outside the auto-managed gitnexus block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): apply ce-plan validation findings (tool contract, consistency, conventions) Tool contract: impact mode:'pdg' shape now includes the schema-required direction param; CDG branch sense documented as the result 'label' field (reason is cypher/raw-edge only); explain caveats corrected to its real false-negative classes (cross-function TAINT_PATH is modeled). Consistency: PDG slice homed in working memory (ledger keeps one-liners); depth knob defined and category-overrides-baseline ordering stated; call_depth (consumed by nothing) and content-hash bookkeeping dropped; Never section folded into Hard rules; Phase 3 deduplicated to a pointer; allowed-repeat escalations defined; budget/discard accounting clarified; verification-commands gathering added to Phase 4; open_questions added to the context pack. From scenario runs: plans now pin the verified-at HEAD commit and index freshness in a header, tag claims [verified]/[graph]/[inferred]/[assumed], quote load-bearing tool output, prefer pre-hook-carrying npm scripts, and support an out:<path> destination override; output path defined as the Phase 1 target repo root. Conventions: AGENTS.md 1.9.0 / CLAUDE.md 1.4.0 changelog rows + metadata bumps; future ce-implement qualified as future. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): rename ce-plan → gitnexus-plan; add cross-CLI (Codex) entrypoints Renames the skill dir, frontmatter, output filename convention, plan H1 (GitNexus Engineering Plan), the future executor handle (gitnexus-implement), the .gitignore whitelist entry, and all AGENTS.md/CLAUDE.md references. Follows the pr-swarm-review cross-CLI pattern: SKILL.md is the canonical CLI-neutral spec, AGENTS.md § Engineering planning is the Codex/any-agent entrypoint, and the README documents the optional user-level ~/.codex/prompts/gitnexus-plan.md slash command plus an invocation matrix. Skill prose de-branded from Claude Code (agent-neutral verification layer). Also fixes two post-review README contradictions: the anti-reread claim now names the ledger's allowed escalations, and 'read-only by contract' is now 'planning-only' (the skill writes exactly one repo file — the plan); the scope-creep rule and template §12 now agree on where deferred follow-ups land. Drops the stale plugin-collision limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skills): document Codex user-level install path for gitnexus-plan Codex discovers SKILL.md skills from ~/.agents/skills (same path the other gitnexus-* skills install to); README now documents the cp install plus the optional ~/.codex/prompts slash-command file, with the prompt body preferring the repo copy and falling back to the user-level install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): gitnexus-plan freshness gate + active PDG-layer refresh Freshness is now a Phase 1 gate, not advisory: under the default freshness:strict, a stale index is refreshed once per planning session via node .gitnexus/run.cjs analyze --index-only (appending --pdg when the task will reach the PDG phase), then the context resource is re-read. A missing PDG layer likewise triggers the one permitted --index-only --pdg refresh and re-probe instead of a passive recommendation. freshness:accept (or a failed/impractical refresh) preserves the old behavior: plan on the stale graph, source-weighted, labelled in the plan header. --index-only is the load-bearing flag choice — it suppresses all file generation, so the planning-only contract holds (only the .gitnexus store changes). Ledger gains an index_refresh record; plan header states fresh / refreshed / refresh-skipped-with-reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): gitnexus-plan runner build check before freshness refresh When the target repo builds the analyzer from its own source (bin → dist/ mapping, as gitnexus/ does), the Phase 1 freshness gate now verifies dist/ is current before running the analyze refresh — rebuilding via the package's build script when any analyzer source file is newer than the built entrypoint — and prefers that freshly built CLI. Otherwise a stale dist re-indexes with outdated extraction logic and the 'fresh' index lies. Rebuilds are recorded in the ledger's index_refresh; the PDG-phase refresh inherits the same check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): add gitnexus-work executor and gitnexus-lfg pipeline gitnexus-work executes a gitnexus-plan as verified atomic commits: consumes the §11 implementation_context pack, drift-checks the plan's evidence pin against HEAD, re-verifies assumptions before relying on them, runs impact before every symbol edit and detect_changes before every commit (repo mandates), builds tests from the plan's scenarios, and routes structural drift back to gitnexus-plan Deepen mode instead of coding around it. gitnexus-lfg is a thin orchestrator: gitnexus-plan → blocking user gate (deepen / proceed / stop, deepen loops allowed) → gitnexus-work → review via the existing gitnexus-pr-review skill (open PR, else branch diff vs default). One bounded fix cycle for review findings; never pushes or opens a PR on its own. gitnexus-plan gains a Deepen mode (re-run freshness gate, escalate to depth:deep, re-verify graph/inferred/assumed claims toward verified, rewrite the same file); its 'future gitnexus-implement' placeholder is retired in favor of gitnexus-work. Registered via .gitignore whitelists, AGENTS.md 1.10.0 (section renamed to Engineering planning & execution), CLAUDE.md 1.5.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): apply cross-skill review findings to the gitnexus skill family Two P1s: gitnexus-plan Deepen mode now re-anchors before re-pinning (diffs the old evidence pin over every [verified]-claim file and re-reads or downgrades before the header moves — moving the pin without this laundered stale claims as verified); the index-refresh budget is stated once in Phase 1 (one --index-only refresh plus at most one Phase 3 --pdg upgrade per session, Deepen = its own session) with ledger and pdg-slice deferring to it. Contract fixes: gitnexus-work's drift check now covers every file the pack cites (not just files_to_modify) and parses the full pack incl. primary/related symbols and acceptance_criteria (walked in Phase 4 alongside §13); a pre-completed check skips §7 steps already landed and Deepen gains a reconcile-execution-state step, closing the mid-execution route-back loop; pack assumptions must name what to check and how. lfg: Lane 4 passes the merge-base to detect_changes compare (two-dot diff misattributes upstream commits when default advanced), branch-diff is the stated normal case, oversized review findings route to the plan gate instead of overflowing direct mode, the one-fix-cycle cap is explicit on re-run, and headless runs end at the plan gate with the plan as deliverable. work: blank mode narrowed to *gitnexus-plan*.md with a re-execution guard, direct-mode discipline spelled out, branch meaningfulness defined against the plan slug, and the plan document is committed as the branch's docs commit (review diff includes it). Planning-only contract now names the dist/ rebuild as the second permitted state change; Phase 5.1 names the four claim tags; stale AGENTS.md anchors fixed. Known latent issue left untouched: gitnexus/gitnexus-pr-review pairs a three-dot example with a two-dot detect_changes compare — that skill is also shipped by the plugin, so fixing it here would drift the copies; lfg compensates by passing the merge-base. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): ship the engineering skill family with the gitnexus package npm i -g gitnexus users now get gitnexus-plan / gitnexus-work / gitnexus-lfg: the three skills are added to gitnexus/skills/ in directory form (SKILL.md + references/), which installSkillsTo already enumerates dynamically and copies recursively to every editor target (~/.agents/skills for Codex, Cursor, OpenCode, Qoder, ...) on gitnexus setup — uninstall enumerates the same root, so removal stays clean. The Claude Code plugin channel (gitnexus-claude-plugin/skills/) carries the same copies plus the standard per-skill mcp.json. Global-install support in the skill text: gitnexus-plan Phase 1 now resolves the analyzer runner explicitly — node .gitnexus/run.cjs analyze when the project has a runner, else gitnexus analyze (installed CLI), else npx gitnexus analyze — and all analyze mentions route through it, satisfying the skills-steering policy (#1939/#1945) which sweeps the plugin copies. New drift guard test/unit/shipped-skills-sync.test.ts asserts the npm and plugin copies stay byte-identical to the canonical .claude/skills/ family (plugin = canonical + mcp.json), same discipline as run.cjs ↔ resolve-invocation.ts. skills-steering + shipped-skills-sync: 11/11 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): workflow_bench — measure the skill workflow's token savings Benchmarks gitnexus-plan → gitnexus-work against a baseline agent (--disallowedTools Skill) on identical tasks, in fresh detached worktrees, using real headless Claude Code sessions; every number comes from the CLI's --output-format json usage report (field names validated against a live 2.1.207 session). Reports per-arm medians (input/cache/output tokens, cost, wall time, turns), a savings row, and resolve status from a per-task verify command — savings on failed tasks are flagged, not celebrated. Per-task setup hook prepares fresh worktrees (deps); --permission-mode bypassPermissions (default) lets sessions run unattended in the throwaway trees. Free-model support: --base-url/--auth-token/--model route headless sessions through any Anthropic-compatible endpoint; free-model.litellm.yaml is a ready litellm-proxy template for OpenRouter :free variants or local Ollama, so benchmarking burns no paid tokens (README documents rate limits and the small-model skill-following caveat). Harness validated end-to-end with a stub CLI (worktree lifecycle, both arms, plan→work chaining, verify, aggregation, report) and 4 pytest units for the pure aggregation/savings/report helpers. AGENTS.md 1.11.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record first workflow_bench calibration run Trivial-task calibration (add -V alias): both arms resolved; workflow arm ~4.3x baseline cost — the documented overhead-dominated regime, recorded so the regime boundary is empirical rather than asserted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): workflow_bench scenario matrix — arm variants, task classes, churn Ground-base measurement across scenarios: tasks.scenarios.yaml spans four labeled classes (trivial → investigation-bug → investigation-feature → cross-module) with deterministic verifies (prescribed test files). New arms: workflow_direct (gitnexus-work direct mode — the middle option that locates the routing boundary lfg's gate and work's triage encode) and baseline_nomcp (no skills AND no graph tools — separates workflow-discipline value from GitNexus-tool value; off by default). Records now carry task class and diff churn (files/+ins/−del vs the starting commit) as an over-engineering proxy; the report renders a class column and per-arm savings rows vs baseline. 5 pytest units + stub-CLI e2e of the full three-arm matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record workflow_bench ground base; fix churn measurement bias Ground base (3 classes x 3 arms, n=1/cell): every arm resolved every task — pass/fail quality saturates at this difficulty, making the comparison pure cost. Full plan→work never amortized its ~$9-11 fixed cost on tasks a baseline finishes in ≤35 turns (−211% to −333% cost); workflow_direct sits near baseline (−15% to −55%, once faster wall) with more test coverage. Routing implication recorded: direct mode/plain agent below this scale, full workflow for cross-module / multi-session / plan-as-deliverable work. The cross-module cell and multi-run variance are the next measurements. Churn fix: git add --intent-to-add -A before diffing (arms that never commit no longer undercount new files) and :(exclude)docs/plans (the committed plan doc no longer inflates workflow churn); this run's churn numbers predate the fix and are omitted from the recorded table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(skills): cost-optimize the workflow from measured ground base Every optimization targets a measured fixed-cost component (eval/workflow_bench ground base: workflow arm −211% to −333% vs baseline, all tasks resolved): - Plan form is category-priced: compact form (core sections w/ § anchors preserved, ≤80 lines excl. pack, mini-pack subset of the context pack) for narrow/default categories; the full 13 sections only for deep work (refactor/security/performance/concurrency/architecture). A compact plan outgrowing its cap reclassifies to full rather than overflowing. - Freshness gate is category-priced: compact categories default to accept (source-weighted, refresh only when a graph claim becomes load-bearing); strict stays the default for full-plan categories — the rebuild+re-index was the largest single fixed cost. - Turn economy: per-category tool-call budgets (~10 to ~45; architecture uncapped); budget exhaustion routes open questions to §12 instead of more digging. - gitnexus-work fast path: HEAD == evidence pin → skip all citation re-reading (the pin's entire point); mini-pack fields tolerated. - lfg Lane 1 boundary triage: tasks below the measured ~35-turn boundary get offered gitnexus-work direct mode before the plan lane is spent. Copies re-synced (npm skills/, plugin, ~/.agents); steering + sync guards green. Re-measurement of the workflow arm follows to verify the numbers actually improve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): record optimization re-measurement — inv-bug workflow cell −20% cost Same task, same conditions, post-830a0459 skills: $14.56→$11.70 (−20%), 83→72 turns, cache_read −24%; verified in-transcript that the compact form, turn budget, and skipped rebuild/re-index all fired. Wall +15% from a work- session test-debugging tail (n=1 variance). Regime unchanged (~3.5x baseline on this class) — routing rule stands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): per-arm clone isolation — worktree ref-namespace leak contaminated an arm The cross-module workflow_direct cell reported an impossible 28-turn solve with churn byte-identical to the workflow arm: git worktree add shares the repo's ref namespace, so the workflow arm's slug branch (created by gitnexus-work Phase 2) survived worktree removal and the direct arm found and adopted the completed work. Arms now get isolated git clone --shared copies (object store via alternates, refs clone-local — agent branches and stashes die with the clone; origin/<ref> fallback for non-default refs). Leaked branch deleted; baseline arm verified clean (0 branch references in its transcript); cell marked invalidated pending re-run. Records the valid cross-module cells: workflow $18.32 vs baseline $18.03 (premium −1.6%, vs −211%..−333% on smaller classes) — fixed costs amortize at this scale, with a less destructive diff and a plan artifact as bonus; resolve rate still tied. Churn fingerprinting is what caught the contamination — noted in the README as an integrity check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(eval): complete cross-module cell — direct mode wins 47% cost / 56% wall Clean clone-isolated re-run: workflow_direct resolved the hardest class at $9.53/52 turns/15m vs $18.03/98/34m baseline and $18.32/107/37m full workflow. The measured story across all four classes: the execution discipline (gitnexus-work) is the consistent sweet spot and delivers real token savings on hard tasks; the planning pass buys its artifact, not same-session savings. Resolve rate tied everywhere (n=1/cell caveat). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): add trajectory-gated skill evolution (#2431) - Pair prompt candidates with incumbent workflow arms - Gate promotions on pinned-model quality and efficiency - Expire router evidence and document its lifecycle * fix(eval): allow pr-review skill candidates * feat(skills): rename and generalize GitNexus review * feat(eval): external-comparator and review arms for workflow_bench - ce_workflow / ce_workflow_direct: compound-engineering ce-plan/ce-work arms prompted with the same structure as the gitnexus arms - review / ce_review: gitnexus-review vs ce-code-review on an identical diff applied by the task's setup - plan handoff is snapshot-based: committed example plans in docs/plans/ tie on clone mtimes and broke the name-glob pick (executed a stale plan) - verify output tail is recorded per run and the final working-tree patch is kept, so failed rows are diagnosable after the clone is destroyed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills,eval): address #2431 review — data-safe rename migration, fail-closed bench evidence - setup: never delete a legacy renamed skill dir — the installer cannot prove ownership (users customize or hand-write skills under these names); warn with the path instead, and the test now asserts survival - workflow_bench: fail closed when a session's --output-format json report is empty, malformed, or missing usage fields — an exit-0 shell with no parseable usage no longer counts as measured evidence (5 parametrized regression tests) - workflow_bench: document the trust model prominently (task setup/verify are shell-executed, sessions run bypassPermissions with the parent env, candidate overlays are prompt injection surface) in README + docstring - free-model.litellm.yaml: master_key from LITELLM_MASTER_KEY env instead of a static token; loopback-binding warning - ci: run the eval workflow_bench pytest suite on ubuntu (pytest+pyyaml only — no full eval stack) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): demand observed foreground verification in headless work-arm prompts In a headless -p session there is no later turn: a work arm backgrounded its slow test run, scheduled wakeups that can never fire, and reported done while two of its tests failed. All four work-arm prompts (both skill families, symmetric) now require verification output to be observed inside the session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): ask plan depth up front instead of offering deepen afterwards gitnexus-plan Phase 0 now asks one blocking question in interactive sessions — quick / standard / deep, mapped onto the existing depth/form/ freshness knobs — when the invocation carries no explicit depth signal. Explicit knobs and headless runs skip the question (category posture unchanged, so benchmarks and automation behave as before). gitnexus-lfg's plan gate slims to proceed/stop: depth was already the user's up-front choice, so deepening is no longer offered by default — an explicit deepen request at the gate and executor route-backs still run Deepen mode, which remains the mechanism for strengthening an existing plan document. All shipped copies resynced (npm skills/, Claude plugin); AGENTS.md 1.13.0 and CLAUDE.md 1.7.0 pointers updated, including the analyzer's regenerated index-stats block at this branch's head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): taint pass, expert lenses, and post-work index refresh gitnexus-review gains a PDG-backed taint-and-dependence pass (explain + pdg_query, --pdg folded into the stale refresh on trust-boundary diffs) and an Expert lenses section: domain reviewers derived from the graph's clusters plus four cross-cutting lenses (architectural fit, language conformance per the repo's own contract, Definition of Done, simplicity), dispatched once after the evidence-gathering steps and scaled to the diff. gitnexus-work Phase 4 now refreshes the knowledge graph after the DoD walk via the resolved-runner ladder with analyze --index-only, so the lfg review lane and later sessions query the finished work without dirtying the tree. lfg's threshold-governance paragraph moves to its README; eval citations are tagged as measured in the GitNexus repo. All shipped copies re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): remove legacy gitnexus-pr-review on uninstall; cover the rename migration uninstall's removal set now includes LEGACY_SKILL_DIR_NAMES derived from RENAMED_SKILL_DIRS, so a pre-rename install is cleaned up instead of orphaned. The rename warning gains behavioral coverage (fires with a legacy dir present, silent without), and shipped-skills-sync asserts legacy names stay absent from every shipped tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(eval): metric provenance, error-kind rows, skill-invocation verification, gate noise floor The promotion gate defaults to cost_usd (the only metric that includes subagent spend); token metrics carry an explicit main-loop-only warning in the report and promotion.json. Rows are classified by error_kind (session-error / verify-failed / infra-error), excluded from efficiency medians, and the gate requires equal valid-run counts. Each session's transcript is scanned for the expected Skill invocation and fails closed on a verified miss; a one-run resolution edge no longer promotes (noise floor). Per-run timeouts and setup failures record an infra-error row instead of aborting the sweep. Overlays touching skills no candidate arm exercises are rejected up front. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix skill routing paths, version headers, and skill rosters Routing tables point at the tracked direct skill paths (matching the post-#2434 generator output), AGENTS.md/CLAUDE.md headers match their latest changelog rows, the 1.12.0 row describes what the migration actually does, package/cursor READMEs list the full shipped skill roster, and the swarm READMEs describe /gitnexus-review's expert lenses instead of calling it single-agent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: drift-guard workflow for skill copies; pin eval pip deps; track docs/plans ci.yml ignores '**.md', so an md-only skill edit would merge without the shipped-skills-sync test running — skill-sync.yml triggers exactly on the guarded trees. The eval job's pip install is version-pinned, and docs/plans/ is unignored so gitnexus-plan output can be committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): keep the runner-invocation literal in gitnexus-review; add concurrency block to skill-sync skills-steering requires skills with a stale-index hint to carry the exact 'node .gitnexus/run.cjs analyze' form — restore it with the fallback ladder as a parenthetical instead of replacing it. skill-sync.yml gains the top-level concurrency block the workflow-convention check enforces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): token-economy guidance for expert lenses Merge lenses that ground in the same material into one reviewer, and use cheaper model/effort tiers for mechanical lenses where the harness offers them, reserving the strongest engine for adversarial judgment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(eval): isolate transcript home on Windows Ensure workflow_bench transcript tests set USERPROFILE alongside HOME so Path.home() resolves to the temporary test home on Windows. * docs(skills): fold PR #2522 execution learnings into review/work/plan Eight incident-backed hardenings from running the full skill cycle (review -> plan -> work, 28-finding fix series) on PR #2522: gitnexus-review: - Expert lenses execute the code under review on candidate failing shapes (empirical probe outranks source reading — every HIGH the language lenses found came from a probe, not a read). - Step 7 re-runs the exact CI check for refreshed baselines/fingerprints (a stale committed artifact is invisible in the diff; caught a red benchmarks arm). - Step 8 treats version/invalidation constants as review surface (INCREMENTAL_SCHEMA_VERSION class recurred verbatim from #2494). gitnexus-work: - Step 4 proves regression tests discriminate against the pre-fix tree. - Step 5 rebuilds executed build output before every verification run (parse workers load dist/; a correct fix 'failed' until rebuilt). - Step 6 makes stage -> detect_changes -> commit one unbroken sequence. gitnexus-plan: - Phase 0 seeded-evidence mode: plan FROM a completed review's verified findings instead of re-running the graph ladder. - Template §7: fingerprint/golden-guarded output rebaselines once, at the series tip. All distribution copies resynced; shipped-skills-sync + skills-steering 24/24 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(eval): close the skill-evolution loop with an automated proposer driver workflow_bench.evolve adds the three arrows the README described as manual: a proposer session that turns loser trajectories (results.jsonl rows, transcripts, patches, the learning queue) into ONE bounded candidate overlay, a driver that iterates propose -> paired benchmark -> deterministic gate up to --generations, and an --apply step that copies a promoted overlay onto the canonical skills and shipped mirrors as a working-tree diff. The trust boundary is unchanged: overlays re-validate through candidate_overlay_files before any benchmark or apply consumes them, and committing, CI, and the PR merge stay human. learnings.jsonl is gitignored: it is machine-local evidence, like the session transcripts it complements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(skills): route live-task friction into the evolution learning queue Each family skill gains a short 'Skill feedback' section: on friction with the skill's own instructions, append one JSON line to eval/workflow_bench/learnings.jsonl (GitNexus repo only) — never self-edit the skill from a live task. The proposer in workflow_bench.evolve consumes the queue as hints; a learning reaches a shipped skill only by beating the incumbent on the paired benchmark. All shipped mirrors re-copied byte- identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(tests): run the evolve helper tests in the eval pytest job test_evolve.py needs only pytest+pyyaml, same as the harness tests the job already runs — without this line the new module had no CI coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): comment-triggered GitNexus review agent for PRs '@gitnexus review' from a maintainer (OWNER/MEMBER/COLLABORATOR; the action re-validates write access) runs the repo's gitnexus-review skill headlessly against the PR and posts the review as a sticky comment — remote triggering with no local setup. Read-only by construction: contents: read token, Write/Edit and web tools disallowed, Bash allowlisted to git reads and the gitnexus CLI; analyze parses PR code with tree-sitter, never executes it. Requires the ANTHROPIC_API_KEY repository secret; activates once the file is on the default branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): dispatch lane + existing OAuth secret for the review agent Align with claude.yml: same action pin and the CLAUDE_CODE_OAUTH_TOKEN secret the repo already carries — no new secret to configure. Add a workflow_dispatch lane (PR number input) so the agent can be triggered from the Actions UI and tested before the issue_comment trigger reaches the default branch. Allowlist gh pr view/diff and gh api, which the review skill uses to pin PR SHAs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): close a fork-PR RCE vector in the review agent's tool allowlist A live headless run of the exact workflow session against PR #2431 (66 turns, full gitnexus-review pass) surfaced a real HIGH-severity confused deputy: .gitnexus/ is gitignored, not blocked — a fork PR can commit its own .gitnexus/run.cjs, issue_comment checks out PR-head content, and the skill's runner ladder tries 'node .gitnexus/run.cjs analyze' first. That would execute fork-controlled JS inside a job holding CLAUDE_CODE_OAUTH_TOKEN and a write-scoped GITHUB_TOKEN — the opposite of the 'PR code is read, never executed' claim in the workflow's own header. Fix: drop the run.cjs allowlist entry so analyze always resolves through npx gitnexus (npm registry, not the checked-out tree); the skill's documented fallback mode covers the resulting graceful degradation. Also drop 'gh api' (not read-only — accepts -X POST/PATCH/DELETE) and downgrade pull-requests: write to read (comment posting only needs issues: write; the prompt already forbids formal review submission). Same session flagged a latent evolve.py bug: select_evidence's cost sort used dict.get's missing-key default, which doesn't cover an explicit JSON null in a foreign --seed-results row and crashes proposer setup with TypeError. Guarded with 'or 0.0' and added a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: harden PR review and evolution trust boundaries * ci: follow workflow concurrency convention * fix(eval): make terminating error paths explicit * fix: unblock hardened review runtime checks * test: make containment canaries deterministic * test: expose Claude canary tool failures * fix: adapt clean shell environment for Claude * fix(eval): accept the runner's transcript source key in evidence preflight The proposer evidence preflight required transcript-artifact metadata to be exactly {path, sha256, bytes}, but the runner stamps a fourth provenance key (source=parent-captured-stream-json). Any --seed-results or generation>=2 run therefore aborted with SandboxError before proposing or promoting. Pin the producer literal as PARENT_EVENT_STREAM_SOURCE and validate it in the metadata check, and round-trip real producer output through sum_sessions into the preflight so the schema can't drift again. * fix(eval): treat an unmeasured session cost as unavailable, not $0 well_formed validated only the nested usage block, so an otherwise-successful session missing total_cost_usd was recorded as cost_usd=0.0 — and cost_usd is the default promotion metric (lower wins), so a cost-less session scored as free and could win promotion it never earned. Extract cost via measured_cost() (None on absent/garbage, a measured 0.0 preserved), propagate None through sum_sessions/aggregate/savings/report, and have the gate refuse to rank on a metric that was not measured on every run in both arms. * fix(eval): warn when ranking on the main-loop-only num_turns metric num_turns comes from the CLI's top-level usage (main-loop session only), like output_tokens, but selecting it emitted no metric_warning — so a subagent-heavy candidate could look artificially efficient. Add num_turns to MAIN_LOOP_ONLY_METRICS and broaden the warning to cover turns. * fix(eval): fail closed when an overlay adds a file with no committed base An overlay adding a new .md under gitnexus-{plan,work} passes the structural overlay checks but has no committed base for committed_destination_base_digests to bind against, so it raised an uncaught ValueError that crashed the evolve driver (and runner --candidate-overlay) mid-run. Catch it at both call sites: evolve reports NOT PROMOTED and exits, runner routes it through parser.error. * feat(eval): circuit-break the runner sweep on a systemic outage A sustained upstream outage used to pay out every remaining --timeout window one session at a time. Track consecutive session/infra/cleanup failures via a pure systemic_outage_streak helper; after --outage-streak (default 5) in a row, stop the sweep, still write report.md/promotion.json from partial evidence, and exit non-zero so evolve.py halts instead of proposing from truncated evidence. A task's own resolved=False never trips the breaker. * fix(cli): report a dirty working tree as stale in gitnexus status status --json (and the human output) computed up-to-date from commit + runner identity + completeness only, so a repo with uncommitted source changes at a matching HEAD was reported up-to-date while analyze would still re-index it. A graph-backed agent gating on that JSON could skip re-analysis on a stale graph. Extract analyze's dirty-tree check into a shared isWorkingTreeDirty() in storage/git and fold it into the status freshness decision. * fix(ci): use single-slash deny globs in the review agent's disallowedTools github.workspace already expands to an absolute path, so Read(/${{ github.workspace }}/**) and Read(//proc/**),(//sys/**),(//dev/**) produced double-slash patterns that a normalizing matcher may not match — silently no-opping the deny layer. Not exploitable (the allowlist is the primary control and never grants those paths), but the globs should be well-formed. Update the pinned test strings. * ci: install gitnexus-shared with npm ci from the committed lockfile The gitnexus-shared build floated its deps via npm install in three workflows (skill-sync, ci-tests, and — most importantly — the release publish.yml) while every other install step uses npm ci. The lockfile is committed and in sync, so switch all three to npm ci for reproducible, locked installs. * test(cli): make the shipped-skills drift guard reject symlinks listFilesRecursive walked with readdirSync and snapshotDir read with readFileSync, both of which follow symlinks — so a mirror file symlinked to the canonical tree passed the byte-compare (and a symlinked mirror dir would be followed too). Reject a symlinked root via lstat and any symlinked entry via Dirent.isSymbolicLink, with negative tests (skipped on Windows). * test(eval): guard the candidate-skill vs mirror-root coverage invariant MIRROR_SKILL_ROOTS omits the Cursor tree, safe only because no candidate skill is cursor-shipped. Pin that invariant: every CANDIDATE_SKILLS entry must exist under canonical + every mirror root and must not ship to Cursor, so adding a cursor-shipped skill to the candidate set (the PR #2488 asymmetric-sync class) fails loudly instead of syncing three of four trees. * docs(ci): describe the review agent's staged post-merge rollout The DoD asked for a dry-run or triggered run before merge, but an issue_comment (or newly added workflow_dispatch) workflow only ever executes the default-branch copy, so it cannot be exercised from the PR that introduces it. Reword the DoD and the activation checklist to a staged rollout: merge registered-but-disabled, validate same-repo and fork execution post-merge, then enable the variable. * fix: pin plugin skill mcp.json to the release version via #2445 tooling The ten plugin skill mcp.json launched `npx -y gitnexus@latest mcp` on every skill connect — non-reproducible and a supply-chain surface, and (unlike the persisted setup config) never pinned. Extend sync-plugin-manifests.mjs with an mcp surface kind that stamps the gitnexus@<version> launch arg, pin all ten to 1.6.9 now, and keep them byte-identical so the drift guard stays green. The release lifecycle + publish.yml --check now re-stamp them like the four manifest surfaces; only READMEs stay on @latest as docs. * test(eval): prove the proposer's built-in file tools are confined The real-Claude canary only exercised Bash + MCP, so it proved process/MCP containment but not that the proposer's built-in file tools stay inside their mounts. Add a canary over the exact PROPOSER_ALLOWED_TOOLS surface and the same read-only /evidence mount as run_proposer (allowlist extracted to a shared constant so it can't drift): Read reaches /evidence, a Write into the read-only evidence mount is denied, and a Write lands in the output tree. * fix(eval): apply the candidate overlay after task setup for fair arms The candidate overlay was applied before the task's untrusted setup ran, so setup could observe candidate prose and the incumbent/candidate arms started from different pre-overlay state. Reorder within the sandbox: capture the base (pre-overlay) skill digest, run setup against the base skills, verify setup did not tamper them, then apply the overlay and capture the post-overlay digest the model must preserve. apply_candidate_overlay stages path-specific overlay files, so setup's uncommitted changes stay out of the baseline and churn is unchanged. Graph freshness for the review arm is handled by the status dirty-tree fix plus the review skill's stale-triggered re-index, not by reordering the cached per-task-sha graph materialization (which is mechanically blocked). * test(eval): end-to-end containment proof of the autonomous proposer Drives the real run_proposer through bubblewrap with a deterministic scripted model (no paid API): it reads the read-only evidence bundle and writes a candidate gitnexus-plan skill edit plus a rationale into the sandbox output tree; run_proposer enforces the trust boundary and copies only the validated overlay + proposal out. This exercises the autonomous-proposal stage of the self-evolution loop end-to-end in the eval/containment CI job (the gate and apply stages are covered by test_workflow_bench_evolution and test_promotion_apply). Env-gated on GITNEXUS_REQUIRE_CLAUDE_CANARY, so it runs only where the pinned Claude binary and user namespaces are available. * fix(eval): let the proposer author its overlay via Bash Running the end-to-end proposer canary in the containment CI job surfaced a real bug: run_proposer starts the session with --bare, which hard-disables the Write/Edit tools ("Write exists but is not enabled in this context"), yet allowlisted Edit/Write and omitted Bash. The proposer therefore had no working way to write its candidate overlay — the self-evolution loop could never produce a candidate. The sandbox settings already pre-authorize Bash (autoAllowBashIfSandboxed) and confine writes to workspace/tmp/home, so switch PROPOSER_ALLOWED_TOOLS to Read/Grep/Glob/Bash and tell the proposer to author files with Bash. The end-to-end test now drives the real run_proposer through bubblewrap and asserts a validated overlay + proposal are produced (this also replaces the earlier file-tool canary, whose Write/Edit premise was moot). * test(eval): author the proposer overlay with newline-free Bash content The nested shell-sandbox prefix mangles embedded newlines, so the multi-line overlay content never landed. Use single-line content for the deterministic proposer canary. * test(eval): drop the unverifiable end-to-end proposer canary The scripted proposer overlay never materialized in the containment job across runs, and the model tool-result content is not visible in CI logs, so the test cannot be finalized without an environment where the sandbox can actually run. Keep the verified production fix (Bash-authoring in run_proposer); the proposer sandbox/containment stays covered by the existing Bash+MCP and process-tree canaries. * test(cli): drop run-analyze.ts from the windowsHide spawn-family list U7 moved run-analyze.ts's only child_process call (the git status --porcelain dirty check) into storage/git.ts (already covered by this test, with windowsHide). run-analyze.ts no longer imports a spawn-family function, so the windowsHide-regression test's 'must have >=1 spawn call' invariant failed for it. Remove it from SRC_FILES. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Zander Raycraft <zanderjraycraft@gmail.com> Co-authored-by: Azizur Rahman <azizur100389@gmail.com>
This commit is contained in:
parent
12600000e3
commit
8b5057f325
152 changed files with 51191 additions and 905 deletions
|
|
@ -37,7 +37,9 @@ Edit review behavior in the canonical files under `pr-swarm-review/` (orchestrat
|
|||
personas), **not** in these wrappers. After adding or editing files in `.claude/agents/`,
|
||||
restart Claude Code so it reloads the agent definitions.
|
||||
|
||||
## Relationship to `/gitnexus-pr-review`
|
||||
## Relationship to `/gitnexus-review`
|
||||
|
||||
Coexists with the single-agent `/gitnexus-pr-review` skill (a linear checklist using GitNexus
|
||||
MCP tools). This swarm is the multi-persona deep production-readiness review.
|
||||
Coexists with the `/gitnexus-review` skill (reviews PRs, branches, ranges, or
|
||||
local changes using GitNexus MCP tools, scaling from one pass to per-domain
|
||||
expert lenses derived from the graph's clusters). This swarm is the
|
||||
fixed-roster, multi-persona deep production-readiness review.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ For any task involving code understanding, debugging, impact analysis, or refact
|
|||
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
|
||||
| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` |
|
||||
| `check` | Check graph invariants such as circular imports |
|
||||
| `route_map` | API route map — which components/hooks fetch which endpoints, and the handler files that serve them |
|
||||
| `shape_check` | Response-shape drift — keys each route returns vs keys its consumers access (flags MISMATCH) |
|
||||
| `api_impact` | Pre-change report for an API route — consumers, middleware, shape mismatches, risk level |
|
||||
| `tool_map` | MCP/RPC tool definitions and the files that handle them |
|
||||
| `group_list` | List configured multi-repo groups, or one group's config |
|
||||
| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index |
|
||||
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
|
||||
|
||||
### Paginating `list_repos`
|
||||
|
|
@ -77,13 +83,13 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
|
|||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
||||
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
|
||||
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
|
||||
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
|
||||
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
|
||||
|
||||
### Control & data dependence (`pdg_query`)
|
||||
|
||||
|
|
@ -104,6 +110,8 @@ A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown
|
|||
|
||||
Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
|
||||
|
||||
Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one** `ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`.
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
|
@ -119,8 +127,10 @@ Lightweight reads (~100-500 tokens) for navigation:
|
|||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
|
||||
|
||||
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
|
|
|
|||
55
.claude/skills/gitnexus-lfg/README.md
Normal file
55
.claude/skills/gitnexus-lfg/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin pipeline orchestrator over three existing skills: `gitnexus-plan`
|
||||
produces the plan (asking up front how deep to go), the user chooses at a
|
||||
blocking gate to proceed or stop (an explicit deepen request is still
|
||||
honored), `gitnexus-work` executes it as verified atomic commits, and
|
||||
`gitnexus-review` reviews the result (the open PR if one exists, else the
|
||||
branch diff against the default branch). One bounded fix cycle for review
|
||||
findings, then a final report. It never pushes or opens a PR on its own.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke |
|
||||
|-----|---------------|
|
||||
| **Claude Code** | `/gitnexus-lfg <task description>` or `/gitnexus-lfg docs/plans/<plan>.md` |
|
||||
| **Codex CLI** | Ask: "run the gitnexus pipeline on <task>" (Codex reads `AGENTS.md`), or install the skill user-level (below) |
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-lfg ~/.agents/skills/gitnexus-lfg
|
||||
```
|
||||
|
||||
Optionally, for an explicit slash command, create
|
||||
`~/.codex/prompts/gitnexus-lfg.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: GitNexus pipeline — plan (depth asked up front), user gate, work, PR review
|
||||
argument-hint: <task description or plan path>
|
||||
---
|
||||
Use the gitnexus-lfg skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-lfg/SKILL.md` (prefer the repo copy at
|
||||
`.claude/skills/gitnexus-lfg/SKILL.md` when present) and follow its lanes in
|
||||
order, invoking the real gitnexus-plan / gitnexus-work / gitnexus-review
|
||||
skills for each lane. Stop at the plan gate for the user's choice.
|
||||
```
|
||||
|
||||
## The three lanes
|
||||
|
||||
| Lane | Skill | Gate |
|
||||
|------|-------|------|
|
||||
| Plan | `gitnexus-plan` (`.claude/skills/gitnexus-plan/`) | Depth asked up front; blocking gate: proceed / stop |
|
||||
| Work | `gitnexus-work` (`.claude/skills/gitnexus-work/`) | Structural drift routes back to the plan gate |
|
||||
| Review | `gitnexus-review` (`.claude/skills/gitnexus-review/`) | One fix cycle max, then report |
|
||||
|
||||
## Threshold governance (maintainers)
|
||||
|
||||
The Lane 1 planning boundary (~35 turns) is a promoted benchmark policy from
|
||||
the GitNexus repository's `eval/workflow_bench/` paired candidate loop.
|
||||
Re-evaluate it offline whenever the named model or tool harness changes, and
|
||||
at least every 90 days; update the SKILL.md threshold only after the
|
||||
deterministic promotion gate shows no quality regression. Reading agents
|
||||
never self-edit it from a live task.
|
||||
86
.claude/skills/gitnexus-lfg/SKILL.md
Normal file
86
.claude/skills/gitnexus-lfg/SKILL.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
name: gitnexus-lfg
|
||||
description: "Use when the user wants the GitNexus engineering pipeline run end-to-end on a task: gitnexus-plan (plan depth chosen up front), a blocking gate to execute with gitnexus-work or stop, finishing with a gitnexus-review of the result. Examples: \"/gitnexus-lfg Add retry support to the ingestion pipeline\", \"run the gitnexus pipeline on this\", \"plan, build and review this feature\"."
|
||||
---
|
||||
|
||||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin orchestrator over three existing skills. It adds no engineering logic of
|
||||
its own — it sequences `gitnexus-plan`, `gitnexus-work`, and
|
||||
`gitnexus-review`, with the user deciding at the plan gate. Run every lane
|
||||
by actually invoking the named skill (read its SKILL.md and follow it);
|
||||
never inline a summary of what the skill would have done.
|
||||
|
||||
```
|
||||
/gitnexus-lfg <task description>
|
||||
/gitnexus-lfg docs/plans/<existing-plan>.md # skip lane 1, start at the gate
|
||||
```
|
||||
|
||||
## Lane 1 — Plan
|
||||
|
||||
**Boundary triage first.** If the task is plainly below the planning
|
||||
boundary — trivial or small-bounded work an agent finishes in well under ~35
|
||||
turns (the measured regime where a planning pass costs more than it returns;
|
||||
measured in the GitNexus repository's `eval/workflow_bench/`) — say so and
|
||||
offer `gitnexus-work` direct mode as an alternative to the full pipeline
|
||||
before spending the plan lane. Honor the user's choice.
|
||||
|
||||
The threshold is a promoted benchmark policy measured offline, not a
|
||||
timeless heuristic — never self-edit it from a live task. Its re-evaluation
|
||||
governance lives in this skill's README.
|
||||
|
||||
Otherwise invoke `gitnexus-plan` with the task (knob overrides pass through
|
||||
verbatim; `gitnexus-plan` owns the up-front depth question — never ask it
|
||||
again here). If the input is already a plan file path, skip to Lane 2. The
|
||||
plan lands in `docs/plans/` — record its path; every later lane consumes it.
|
||||
|
||||
## Lane 2 — The plan gate (user choice, blocking)
|
||||
|
||||
Present the plan's chat summary (objective, proposed changes, sequence, top
|
||||
risks, open questions, plan path), then ask the user — as a blocking
|
||||
question (`AskUserQuestion` in Claude Code; a numbered list in chat on CLIs
|
||||
without a blocking tool):
|
||||
|
||||
1. **Proceed to work** — continue to Lane 3.
|
||||
2. **Stop here** — the plan file is the deliverable; end the pipeline.
|
||||
|
||||
Depth was the user's up-front choice in Lane 1, so deepening is not offered
|
||||
by default — but honor an explicit request for it at the gate: run
|
||||
`gitnexus-plan` Deepen mode on the plan file and return here with the
|
||||
strengthened plan, as many times as the user asks. Do not proceed past the
|
||||
gate without an explicit choice — the gate is the pipeline's only checkpoint
|
||||
and exists precisely because execution is expensive to unwind.
|
||||
|
||||
**Headless / non-interactive runs:** no one can answer the gate, so end the
|
||||
pipeline after Lane 1 — the plan file is the deliverable (gate option 2) —
|
||||
and say so in the final report. Never auto-proceed to execution.
|
||||
|
||||
## Lane 3 — Work
|
||||
|
||||
Invoke `gitnexus-work` with the plan path. It re-anchors the plan at HEAD,
|
||||
executes the Implementation Sequence as verified atomic commits, refreshes
|
||||
the knowledge graph when done (its Phase 4), and reports deviations. If it routes back for re-planning (structural drift), run the
|
||||
Deepen pass and return to the Lane 2 gate rather than pushing through.
|
||||
|
||||
## Lane 4 — Review
|
||||
|
||||
Invoke `gitnexus-review` on the completed work. Pass an open PR URL/number
|
||||
when one exists; otherwise pass the current branch. The review skill owns
|
||||
target resolution, exact-SHA checkout/index alignment, and merge-base
|
||||
selection. Do not duplicate that logic here. If work left local changes,
|
||||
pass `local` as a second, separately labeled review surface.
|
||||
|
||||
Surface the review verdict and findings to the user. Findings the user
|
||||
wants fixed: those within `gitnexus-work`'s direct-mode bounds (1–2 files,
|
||||
no architectural decisions) → hand to `gitnexus-work` direct mode; anything
|
||||
larger → offer the plan gate instead (Deepen the plan with the findings, or
|
||||
stop). Then re-run this lane's review once. On that re-run, do not start
|
||||
another fix cycle even if findings remain — report them and point the user
|
||||
at `/gitnexus-work` (or the plan gate) to continue deliberately.
|
||||
|
||||
## Final report
|
||||
|
||||
One message: plan path, deepen cycles run, commits produced, verification
|
||||
status, review verdict with unresolved findings, and what (if anything) was
|
||||
explicitly left undone. The pipeline does not push or open a PR on its own —
|
||||
offer both as next steps.
|
||||
142
.claude/skills/gitnexus-plan/README.md
Normal file
142
.claude/skills/gitnexus-plan/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# gitnexus-plan — implementation-ready engineering plans
|
||||
|
||||
Generates deep, implementation-ready engineering plans by combining GitNexus
|
||||
repository intelligence, statement-level Program Dependence Graph analysis,
|
||||
and the agent's native targeted source verification.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke | Adapter file |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------- |
|
||||
| **Claude Code** | `/gitnexus-plan <task>` | `.claude/skills/gitnexus-plan/SKILL.md` |
|
||||
| **Codex CLI** | Ask: "run gitnexus-plan for <task>" (Codex reads `AGENTS.md`) — or install the user-level prompt below | `AGENTS.md` § Engineering planning & execution |
|
||||
| **Any AGENTS.md-aware agent** | Ask it to "read `.claude/skills/gitnexus-plan/SKILL.md` and follow it for <task>" | `AGENTS.md` § Engineering planning & execution |
|
||||
|
||||
```
|
||||
/gitnexus-plan Add retry support to the ingestion pipeline
|
||||
/gitnexus-plan Fix the stale warm-cache invalidation bug in exportedTypeMap
|
||||
/gitnexus-plan depth:deep impact_depth:3 Migrate the emit phase to streaming COPY
|
||||
```
|
||||
|
||||
Output: `docs/plans/YYYY-MM-DD-gitnexus-plan-<slug>.md` — a 13-section plan whose
|
||||
section 11 is a machine-readable **implementation context pack** that a
|
||||
follow-up agent can consume without re-investigating the repository. Compact
|
||||
and full packs both include versioned evidence provenance: a canonical global
|
||||
dirty digest and a sorted, per-layer cited-path manifest. An npm-dependency-free,
|
||||
versioned Node helper shared byte-for-byte with `gitnexus-work` is the only
|
||||
supported serializer, so planner and executor hash identical bytes. The same
|
||||
helper is the only supported existing-plan reader and plan writer. Its
|
||||
descriptor-anchored `read-plan` receipt binds the canonical path, exact base64
|
||||
bytes, and SHA-256 digest before Deepen or execution. The writer accepts a repo-relative
|
||||
`docs/plans/<date>-gitnexus-plan-<slug>.md` destination, rejects symlink
|
||||
traversal and accidental replacement, and publishes the verified UTF-8
|
||||
document through a descriptor-anchored atomic no-replace move. Deepen first
|
||||
requires the exact canonical path and digest from one read receipt, preserves
|
||||
the prior plan in a verified Git-admin backup, and also publishes without replacement. A safe read/write
|
||||
failure blocks the operation; there is no
|
||||
external-output or read-only-checkout fallback.
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
Codex discovers SKILL.md skills from `~/.agents/skills/` (the same path the
|
||||
other `gitnexus-*` skills install to). To make this skill auto-discoverable in
|
||||
every Codex session:
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-plan ~/.agents/skills/gitnexus-plan
|
||||
```
|
||||
|
||||
Codex prompts are user-level only (not repo-shareable). Optionally, for an
|
||||
explicit `/gitnexus-plan` slash command, also create
|
||||
`~/.codex/prompts/gitnexus-plan.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Implementation-ready engineering plan via GitNexus + PDG + source verification
|
||||
argument-hint: <task description>
|
||||
---
|
||||
|
||||
Use the gitnexus-plan skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-plan/SKILL.md` (if this repo has its own copy at
|
||||
`.claude/skills/gitnexus-plan/SKILL.md`, prefer that one) and follow its phases in
|
||||
order, loading its `references/` files at the phases that call for them. Planning
|
||||
only — never edit code; the only repo file you write is the plan document.
|
||||
```
|
||||
|
||||
## Architecture note: how GitNexus and the agent interact
|
||||
|
||||
Three layers, strictly ordered:
|
||||
|
||||
1. **GitNexus navigates** (`query` → `context` → `impact`/`trace` →
|
||||
`cypher` last-resort). The graph answers _where to look_ and _what is
|
||||
connected_: execution flows, callers/callees, blast radius, related tests.
|
||||
Every call must answer a named planning question.
|
||||
2. **PDG constrains** (`pdg_query` controls/flows, `impact {mode:"pdg",
|
||||
direction, line}` statement slices, `explain` for taint). The
|
||||
statement-level layers
|
||||
answer _what gates and feeds the behavior_ inside the few functions the
|
||||
change centers on. Results are filtered into a bounded slice
|
||||
(`references/pdg-slice.md`), never dumped.
|
||||
3. **The agent verifies** (targeted line-range reads). Current source is
|
||||
authoritative; graph results are navigation hints until verified. On
|
||||
disagreement: trust source, record the discrepancy, recommend re-indexing.
|
||||
|
||||
Token efficiency comes from the **context ledger**
|
||||
(`references/context-ledger.md`): every query and read is recorded with the
|
||||
question it answered, and nothing is re-fetched unless the source changed, a
|
||||
contradiction surfaced, or one of the ledger's defined escalations applies
|
||||
(summary→detail drill-down, ambiguity narrowing, a changed parameter answering
|
||||
a new question). The ledger also enforces symbol budgets (5 primary /
|
||||
20 related by default), pins dirty working-tree evidence as well as HEAD, and
|
||||
uses progressive disclosure to keep the big schemas out of context until the
|
||||
phase that needs them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `SKILL.md` | The skill: phases 0–5, hard rules, config, fallback |
|
||||
| `references/pdg-slice.md` | PDG slice construction: tools, inclusion criteria, schema, security/performance modes |
|
||||
| `references/context-ledger.md` | Ledger schema + anti-reread rules |
|
||||
| `references/plan-template.md` | The 13-section plan document template |
|
||||
| `references/context-pack.md` | Implementation context pack schema + stability contract |
|
||||
| `references/evidence-provenance.md` | Versioned byte contract for dirty-tree evidence |
|
||||
| `scripts/evidence-provenance.mjs` | Snapshot serializer plus descriptor-anchored plan reader/writer |
|
||||
|
||||
## Requirements and graceful degradation
|
||||
|
||||
- Requires a GitNexus index; statement-level sections additionally require the
|
||||
`--pdg` layers.
|
||||
- Freshness is a gate, priced by category: full-plan categories (refactor,
|
||||
security, performance, concurrency, architecture) default to
|
||||
`freshness: strict` — a stale index (or missing PDG layer) is refreshed once with
|
||||
`analyze --index-only [--pdg]` — run via `node .gitnexus/run.cjs` when the
|
||||
project has one, else the installed `gitnexus` CLI
|
||||
(`npm install -g gitnexus`), else `npx gitnexus` — before the graph is relied
|
||||
on, but only when that runner's provenance is known-current.
|
||||
Compact-plan categories default to `accept` (source-weighted, refresh only
|
||||
if a graph claim becomes load-bearing). `--index-only` touches only the
|
||||
`.gitnexus` store, never repo files. Stale analyzer provenance is a
|
||||
disclosed **source-weighted limitation**: planning does not rebuild analyzer
|
||||
output, and it does not use that graph for load-bearing claims.
|
||||
- PDG layer still unavailable after that → the plan says so and skips
|
||||
statement-level claims (never reconstructs fake edges).
|
||||
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
|
||||
labelled **source-derived**, with a recommendation to index.
|
||||
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
|
||||
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
|
||||
writable target repository, and a shared filesystem for the plan and
|
||||
Git-admin vault. The writer fails closed when those guarantees are
|
||||
unavailable; it never redirects the plan elsewhere.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `pdg_query` is intra-procedural; cross-function flow comes from `explain`
|
||||
(taint) or `impact {mode:"pdg"}` inter-procedural reach.
|
||||
- The skill is planning-only by contract: the only repository file it writes
|
||||
is the plan document, and the only other state it may touch is the
|
||||
`.gitnexus` index store for a freshness refresh. It must not build
|
||||
analyzer `dist/` output or mutate source, tests, configuration, benchmark,
|
||||
or evaluation files. Instruction feedback is chat-only.
|
||||
348
.claude/skills/gitnexus-plan/SKILL.md
Normal file
348
.claude/skills/gitnexus-plan/SKILL.md
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
---
|
||||
name: gitnexus-plan
|
||||
description: 'Use when you need a deep, implementation-ready engineering plan for a code change — built from GitNexus graph intelligence, statement-level PDG analysis, and targeted source verification, compact enough that an implementation agent can start without re-investigating. Also strengthens existing plans via Deepen mode. Examples: "/gitnexus-plan Add retry support to the ingestion pipeline", "/gitnexus-plan deepen docs/plans/<plan>.md", "plan this change using the knowledge graph".'
|
||||
---
|
||||
|
||||
# gitnexus-plan — implementation-ready engineering plans
|
||||
|
||||
Produce an implementation-ready plan for an engineering task. GitNexus is the
|
||||
navigation layer (where to look), statement-level PDG is the constraint layer
|
||||
(what gates and feeds the behavior), and your native targeted source reads are
|
||||
the verification layer (what is actually true right now). The output is a plan
|
||||
document plus a compact, machine-readable **implementation context pack**
|
||||
that a follow-up implementation agent (`gitnexus-work`, or any executor) can
|
||||
consume without repeating the investigation.
|
||||
|
||||
```
|
||||
/gitnexus-plan <task description>
|
||||
/gitnexus-plan impact_depth:3 depth:deep <task description> # knob overrides, see Configuration
|
||||
```
|
||||
|
||||
**This skill plans. It never implements.** Do not modify production code,
|
||||
tests, or configuration while running it. The only repository file it writes
|
||||
is the plan document (a working ledger kept outside the repo is fine). The
|
||||
only other permitted state change is an index refresh via
|
||||
`analyze --index-only`, which writes only the `.gitnexus` index store. It
|
||||
must not build analyzer `dist/` output and must not mutate source, tests,
|
||||
configuration, or evaluation data. Stale analyzer provenance is disclosed as
|
||||
a source-weighted limitation, never repaired by a planning run.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Ledger first.** Before every GitNexus call and every repo file read, check
|
||||
the context ledger. Never repeat a query or reread an unchanged range that
|
||||
already answered the same question (allowed repeats are defined in
|
||||
`references/context-ledger.md`; this skill's own reference files are exempt
|
||||
from ledger bookkeeping).
|
||||
- **Every graph query answers a named planning question.** Record the question
|
||||
and the conclusion in the ledger. No exploratory dredging.
|
||||
- **Source beats graph.** The graph navigates; current source is authoritative.
|
||||
Verify before asserting (see Phase 4). Comments are the weakest evidence —
|
||||
never stronger than executable code.
|
||||
- **No fabrication.** Never invent symbols, filenames, test names, tool
|
||||
results, or PDG edges. Unknowns go to _Assumptions and Open Questions_.
|
||||
- **No scope creep.** Adjacent refactors the task didn't ask for go to plan
|
||||
§12 as explicitly-deferred follow-ups, not into Proposed Changes.
|
||||
- **Pin working-tree evidence, not only HEAD.** Every plan form carries the
|
||||
versioned global dirty digest and sorted cited-path manifest defined in
|
||||
`references/context-ledger.md`. Generate it only with the portable helper
|
||||
and byte contract in `scripts/evidence-provenance.mjs` and
|
||||
`references/evidence-provenance.md`; never reimplement the digest.
|
||||
- **Write the plan only through the helper.** The generated-plan path is a
|
||||
normalized repo-relative
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-slug>.md` path. Compose the
|
||||
complete UTF-8 document in memory or in a scratchpad outside the target
|
||||
repo, then pass it on stdin to the helper's `write-plan` command. Never
|
||||
write the destination directly or fall back to an external output path when
|
||||
the safe writer fails.
|
||||
- **Read an existing plan only through the helper.** Deepen must invoke
|
||||
`scripts/evidence-provenance.mjs read-plan`, parse the exact decoded
|
||||
`plan_bytes_base64` from its descriptor-anchored receipt, and retain that
|
||||
receipt's canonical `generated_plan_path` and `plan_digest` as one binding.
|
||||
Never parse a direct lexical-path read or apply one plan's digest to another
|
||||
path.
|
||||
- **Stop when you have enough.** Sufficient evidence ends exploration; plans
|
||||
do not improve monotonically with tokens spent.
|
||||
|
||||
## Phase 0 — Parse and classify
|
||||
|
||||
Read `references/context-ledger.md` and open the ledger with the task:
|
||||
original request, interpreted goal, acceptance criteria. Classify the task:
|
||||
|
||||
| Category | Posture (depth · plan form · tool-call budget · freshness) |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| Bug fix (local) | Narrow, 1–2 primary symbols, `impact_depth` 1 · compact · ~15 · accept |
|
||||
| Feature | Default knobs · compact · ~30 · accept |
|
||||
| Refactor / shared API change | Impact mandatory, `impact_depth` 3 · full · ~45 · strict |
|
||||
| Performance | Default + performance PDG mode (`references/pdg-slice.md`) · full · ~45 · strict |
|
||||
| Security | Default + security PDG mode + `explain` taint findings · full · ~45 · strict |
|
||||
| Dependency upgrade / migration | Impact + compatibility focus; PDG rarely needed · compact · ~20 · accept |
|
||||
| Concurrency / transactional | Control-flow + state-mutation PDG focus · full · ~45 · strict |
|
||||
| Test improvement / docs | Narrowest: usually no impact or PDG pass · compact · ~10 · accept |
|
||||
| Architecture change / spike | Widest: clusters + processes first · full · no cap · strict |
|
||||
|
||||
The category posture overrides the Configuration baseline; explicit `key:value`
|
||||
invocation knobs override both. A task matching several rows combines them:
|
||||
take the widest depth, union the focus areas.
|
||||
|
||||
**Seeded evidence.** When a completed investigation already supplies
|
||||
verified findings — a finished review, a triage document with `path:line`
|
||||
anchors and named failing scenarios — open the ledger FROM it: cite the
|
||||
source document as the opening ledger entries and plan directly against
|
||||
them instead of re-running the graph ladder over ground it already covers.
|
||||
Re-deriving what the evidence proves is budget spent against the
|
||||
turn-economy rule. Phase 4 still source-verifies whatever Proposed Changes
|
||||
will cite, at the pinned commit — seeding replaces exploration, never
|
||||
verification.
|
||||
|
||||
**Depth is the user's decision, asked once, up front.** In an interactive
|
||||
session, when the invocation carries no explicit depth signal (no `depth:`,
|
||||
`form:`, or `freshness:` knob, and not Deepen mode), ask one blocking
|
||||
question before Phase 1 — how deep should this plan go?
|
||||
|
||||
1. **Quick** — `depth:narrow form:compact freshness:accept`. Fastest useful
|
||||
plan: 1–2 primary symbols, minimal graph work, core sections only.
|
||||
2. **Standard** — the category posture above, unchanged. Recommend this
|
||||
unless the classification argues otherwise.
|
||||
3. **Deep** — `depth:deep form:full freshness:strict`. All 13 sections,
|
||||
`impact_depth` 3, clusters/processes read, PDG slices for the central
|
||||
functions.
|
||||
|
||||
The answer sets the knobs exactly as if they had been typed in the
|
||||
invocation; explicit knobs win and skip the question. Headless runs never
|
||||
ask — the category posture applies unchanged. Asking up front replaces
|
||||
offering to deepen a finished plan afterwards: Deepen mode (below) remains
|
||||
the mechanism for strengthening an existing plan document — a later session,
|
||||
review findings, an executor route-back — not a default follow-up question.
|
||||
|
||||
**Turn economy is a deliverable.** The plan is judged on decision quality per
|
||||
token, not thoroughness theater (measured: a 63-turn plan for a two-line
|
||||
change — the GitNexus repo's `eval/workflow_bench/`). Stay within the category's tool-call
|
||||
budget; when the budget runs out with questions still open, record them in
|
||||
§12 instead of digging further — the executor re-verifies cheaply anyway.
|
||||
|
||||
## Phase 1 — Anchor and freshness
|
||||
|
||||
1. Resolve the target repo: `list_repos` if in doubt, else the indexed repo
|
||||
covering the working directory. Pass `repo` explicitly on every call when
|
||||
more than one repo is indexed.
|
||||
2. Record the repo's current HEAD commit in the ledger — every line-number
|
||||
citation in the plan is pinned to it.
|
||||
3. **Resolve and record the analyzer runner** (used by every `analyze`
|
||||
command in this skill): `node .gitnexus/run.cjs analyze …` when the
|
||||
project has a runner (a previous analyze dropped it next to the index),
|
||||
else `gitnexus analyze …` (installed CLI — `npm install -g gitnexus`),
|
||||
else `npx gitnexus analyze …`. Record its path/version and any available
|
||||
source/build identity; do not manufacture provenance from timestamps.
|
||||
4. Read `gitnexus://repo/{name}/context` — codebase overview + staleness check.
|
||||
**Freshness gate.** Plans built on a stale graph make stale blast-radius
|
||||
claims — but a re-index is the largest fixed cost a planning session
|
||||
carries, so the gate is category-priced:
|
||||
- Compact-plan categories default to `freshness: accept`: plan on the
|
||||
current graph with source verification weighted higher — their plans
|
||||
cite little graph evidence. Escalate to a refresh mid-plan only when a
|
||||
graph claim becomes load-bearing (e.g. Proposed Changes rest on a d=1
|
||||
dependent list), and only then.
|
||||
- Full-plan categories default to `freshness: strict`, and under it:
|
||||
- **Analyzer provenance check — before any refresh.** Compare the resolved
|
||||
runner identity with the index metadata and, in an analyzer-source
|
||||
checkout, with current analyzer source. If identity is stale or unknown,
|
||||
do not build output and do not make that graph load-bearing. Record a
|
||||
**stale analyzer provenance — source-weighted limitation** in
|
||||
`index_refresh`, the plan header, and §12; rely on targeted source reads
|
||||
or hand execution to `gitnexus-work`, which owns the build-current gate.
|
||||
- Stale index → run `analyze --index-only` via the resolved runner
|
||||
(append `--pdg` when the task category will reach Phase 3) and re-read
|
||||
the context resource **only when runner provenance is known-current**.
|
||||
Refresh budget, stated once here: at most one `--index-only` refresh in
|
||||
Phase 1 **plus** at most one later `--pdg` upgrade in Phase 3 (only when
|
||||
Phase 1's refresh lacked `--pdg`) per planning session — a Deepen run is
|
||||
its own session. Record each command, runner identity, and outcome in the
|
||||
ledger's `index_refresh`.
|
||||
- Refresh failed or impractical (no write access to the index, prohibitive
|
||||
repo size), or `freshness: accept` was passed → proceed on the stale
|
||||
graph, weight source verification higher, and state the staleness and
|
||||
the skipped refresh in the plan header and Assumptions.
|
||||
- Resources unreadable but tools working → proceed on tools alone, treat
|
||||
freshness as unknown (weight source higher), and note it in the plan.
|
||||
- GitNexus unavailable entirely → switch to **Fallback mode** (below).
|
||||
5. For architecture-scale tasks only, also read
|
||||
`gitnexus://repo/{name}/clusters` and `.../processes`.
|
||||
|
||||
## Phase 2 — Graph navigation ladder
|
||||
|
||||
Use the narrowest operation that answers the current ledger question, in this
|
||||
order. Budgets: at most `max_primary_symbols` (5) primary symbols and
|
||||
`max_related_symbols` (20) related symbols active in the ledger.
|
||||
|
||||
1. `query {search_query, task_context}` — locate concepts, execution flows,
|
||||
modules, and related tests for the task.
|
||||
2. `context {name}` — 360° view of each candidate primary symbol: callers,
|
||||
callees, categorized refs, processes. Promote to primary or discard. An
|
||||
`ambiguous` result (ranked candidates) is answered by one retry narrowed
|
||||
with `kind` / `file_path` / uid — that retry is an allowed repeat.
|
||||
3. `impact {target, direction}` — upstream/downstream blast radius for shared
|
||||
or high-connectivity symbols (`maxDepth` = `impact_depth`; `summaryOnly:
|
||||
true` first for hub symbols, then drill in — an allowed repeat). Record the
|
||||
d=1 items — the **direct (depth-1) dependents** — the plan must account
|
||||
for every one of them.
|
||||
4. `trace {from, to}` — when the task hinges on _how A reaches B_, one call
|
||||
instead of chained context hops.
|
||||
5. Statement-level PDG — Phase 3, for the functions the change centers on.
|
||||
6. `cypher` — last resort, only for a precise graph question the tools above
|
||||
cannot express. Read `gitnexus://repo/{name}/schema` first; anchor and
|
||||
LIMIT every query.
|
||||
7. `detect_changes {scope}` — only when planning against existing uncommitted
|
||||
or branch work.
|
||||
|
||||
Do not run every tool by default. A local test fix may finish the ladder at
|
||||
step 2.
|
||||
|
||||
## Phase 3 — Statement-level PDG slice
|
||||
|
||||
For the 1–3 functions most central to the change, build a bounded **PDG
|
||||
context slice**. Read `references/pdg-slice.md` and follow it — it owns the
|
||||
tool calls, inclusion criteria, depth bounds, slice schema, the security and
|
||||
performance modes, and the no-PDG-layer fallback.
|
||||
|
||||
## Phase 4 — Targeted source verification
|
||||
|
||||
GitNexus said where to look; now confirm what is there. Using ordinary file
|
||||
reads (exact line ranges, not whole files unless genuinely required):
|
||||
|
||||
- Read every source range the plan will cite: signatures, branch conditions,
|
||||
state mutations, error paths, nearby comments that change behavior. Compact
|
||||
plans cite less — verify what they cite, don't expand the citation set to
|
||||
have more to verify.
|
||||
- Read the tests GitNexus associated with the primary symbols; never claim a
|
||||
test exists without having located it.
|
||||
- Verify the build/test commands the plan will name actually exist
|
||||
(package.json scripts / CI workflows), and prefer the script form that
|
||||
carries its prerequisites (pre-hooks) over invoking underlying binaries
|
||||
directly.
|
||||
- Check repo conventions that constrain the change (AGENTS.md, GUARDRAILS.md,
|
||||
lint/build config) — only the parts the change touches.
|
||||
- Mark each ledger symbol `source_verified: true` as you go. **A symbol that
|
||||
is named in Proposed Changes must be source-verified.**
|
||||
- On graph/source disagreement: trust source, record the discrepancy in the
|
||||
ledger and the plan, recommend re-indexing. Never present stale graph data
|
||||
as fact.
|
||||
- Immediately before composition, recompute the versioned
|
||||
`evidence_provenance` snapshot by invoking
|
||||
`scripts/evidence-provenance.mjs` exactly as specified in
|
||||
`references/evidence-provenance.md`: the
|
||||
canonical global dirty digest over all dirty paths and the sorted manifest
|
||||
of every cited path, including object kind and
|
||||
HEAD/index/worktree/untracked layer digests. Re-read any citation that
|
||||
changed during planning. Exclude only the generated plan path.
|
||||
|
||||
Evidence hierarchy, strongest first: current source and config → current tests
|
||||
and executable behavior → compiler/build/lint output → GitNexus graph and PDG
|
||||
→ documentation and comments.
|
||||
|
||||
## Phase 5 — Compose the plan
|
||||
|
||||
1. Read `references/plan-template.md` and fill the category's form — compact
|
||||
(core sections, ≤80 lines excluding the pack) or full (all 13 sections) —
|
||||
from the ledger, tagging claims with the template's four classes —
|
||||
`[verified]`, `[graph]`, `[inferred]`, `[assumed]` — and routing open
|
||||
questions to §12.
|
||||
2. Build the implementation context pack per `references/context-pack.md`
|
||||
(this is section 11 of the plan), including mandatory
|
||||
`evidence_provenance` in compact and full forms.
|
||||
3. Set `generated_plan_path` to
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<slug>.md` under the root of the repo
|
||||
being planned (the Phase 1 target repo, not necessarily the cwd); use a
|
||||
3–5-word kebab-case slug and repo-relative paths inside the document.
|
||||
Compose the complete document without creating that destination, then
|
||||
pipe its exact UTF-8 bytes to `scripts/evidence-provenance.mjs write-plan`
|
||||
as specified in `references/evidence-provenance.md`. The helper safely
|
||||
creates missing parent directories. Initial planning must not pass
|
||||
`--replace`. A safe-write failure blocks plan publication: report it and
|
||||
do not write directly, choose an external destination, or weaken the
|
||||
repo-relative provenance contract. The snapshot and writer commands apply
|
||||
the same strict generated-plan filename/date validator; do not substitute a
|
||||
source, `.git`, or arbitrary `docs/plans/` path in either invocation.
|
||||
4. Present in chat: objective, proposed-changes summary, implementation
|
||||
sequence, top risks, open questions, and the plan file path. Do not paste
|
||||
the whole document into chat.
|
||||
|
||||
## Deepen mode
|
||||
|
||||
`/gitnexus-plan deepen <plan-path>` strengthens an existing plan in place
|
||||
instead of creating a new one:
|
||||
|
||||
1. Resolve the target repository and normalized repo-relative plan candidate,
|
||||
then load it with `scripts/evidence-provenance.mjs read-plan --repo <root>
|
||||
--generated-plan <candidate>` exactly as specified in
|
||||
`references/evidence-provenance.md`. Reject a missing, external, escaping,
|
||||
symlinked, or differently scoped path. Decode and parse only the receipt's
|
||||
exact `plan_bytes_base64`; retain its canonical `generated_plan_path` and
|
||||
`plan_digest` unchanged for the entire Deepen session.
|
||||
2. Re-run Phase 1 in full — analyzer provenance check and freshness gate (a
|
||||
Deepen run is its own session, with its own refresh budget).
|
||||
3. **Re-anchor before re-pinning.** Recompute the plan's global dirty digest
|
||||
and cited-path manifest as well as comparing its old HEAD pin with current
|
||||
HEAD. Changed, renamed, deleted, mixed, or newly absent cited paths get
|
||||
their ranges re-read — or the claim downgraded — _before_ the pin and
|
||||
provenance snapshot move. Moving only the commit pin silently launders
|
||||
dirty or stale claims as verified.
|
||||
4. Escalate to `depth: deep` (impact_depth 3, clusters/processes read)
|
||||
unless the invocation overrides knobs explicitly.
|
||||
5. Seed the ledger from the plan's §11 pack, then re-verify: every
|
||||
`[graph]`/`[inferred]` claim gets a targeted pass toward `[verified]`;
|
||||
every `[assumed]` claim is resolved or kept with its reason; direct
|
||||
(d=1) dependent accounting is re-checked against the refreshed graph;
|
||||
PDG slices are built or expanded for the central functions when the
|
||||
layer is present.
|
||||
6. **Reconcile execution state.** If `gitnexus-work` already landed commits
|
||||
for this plan (a mid-execution route-back), mark the §7 steps present at
|
||||
HEAD as completed and re-sequence the remainder — the rewritten plan must
|
||||
be executable from the top without redoing landed steps.
|
||||
7. Strengthen whatever the deeper pass showed thin — test scenarios, risks,
|
||||
Definition of Done — and carry claim-tag upgrades through the prose.
|
||||
8. Rewrite the **same canonical file** through
|
||||
`scripts/evidence-provenance.mjs write-plan --replace
|
||||
--expected-plan-path <retained-read-plan-path>
|
||||
--expected-plan-digest <retained-read-plan-digest>`: same 13 sections,
|
||||
context pack kept in sync, evidence header updated. `--replace` is reserved
|
||||
for Deepen mode, and both expected values must come from the same read-plan
|
||||
receipt; any digest/path mismatch blocks publication. Retain the successful receipt's
|
||||
`prior_plan_backup_git_path`; it names the verified Git-admin backup of the
|
||||
displaced plan. Summarize the delta in chat: claims upgraded, claims that
|
||||
failed re-verification, sections changed, and that backup path.
|
||||
|
||||
## Configuration
|
||||
|
||||
Baseline defaults — the Phase 0 category posture overrides them, and inline
|
||||
`key:value` tokens before the task text override both (the repo has no
|
||||
skill-config file mechanism; invocation args are the mechanism):
|
||||
|
||||
| Knob | Default | Meaning |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `depth` | by category | `narrow` = `impact_depth` 1, PDG only if one function is clearly central; `default` = this table; `deep` = `impact_depth` 3 + clusters/processes read |
|
||||
| `form` | by category | `compact` (core sections + mini-pack, ≤80 lines excl. pack — see `references/plan-template.md`) or `full` (all 13 sections) |
|
||||
| `impact_depth` | 2 | `maxDepth` for `impact` |
|
||||
| `pdg_data_depth` | 2 | Data-dependence hops in the PDG slice |
|
||||
| `pdg_control_depth` | 2 | Control-dependence hops in the PDG slice |
|
||||
| `max_primary_symbols` | 5 | Ledger budget (active symbols; discards don't count) |
|
||||
| `max_related_symbols` | 20 | Ledger budget (active symbols; discards don't count) |
|
||||
| `max_snippet_lines` | 30 | Longest source excerpt quoted in the plan |
|
||||
| `freshness` | by category | `strict` (full-plan categories) = refresh a stale index (and a missing PDG layer) with `analyze --index-only [--pdg]` before relying on the graph; `accept` (compact categories) = plan on the current graph, source-weighted and labelled, refreshing only if a graph claim becomes load-bearing |
|
||||
|
||||
## Fallback mode (GitNexus or PDG unavailable)
|
||||
|
||||
1. Say so, first thing, in chat and in the plan.
|
||||
2. Use targeted repo exploration (grep/glob/reads) to approximate callers,
|
||||
dependencies, execution flow, state changes, and related tests.
|
||||
3. Label every such finding **source-derived** in the plan — never present it
|
||||
as graph-derived, and never fabricate statement-level edges.
|
||||
4. Recommend `analyze --index-only` (add `--pdg` for the PDG layers) via
|
||||
the resolved runner — `node .gitnexus/run.cjs`, installed `gitnexus`, or
|
||||
`npx gitnexus` — when it would materially raise confidence.
|
||||
|
||||
## Skill feedback
|
||||
|
||||
If this run exposed friction in the instructions, include concise feedback in
|
||||
the final response. Feedback is chat-only: do not append evaluation learnings,
|
||||
edit benchmark data, or modify this skill during a live planning task.
|
||||
137
.claude/skills/gitnexus-plan/references/context-ledger.md
Normal file
137
.claude/skills/gitnexus-plan/references/context-ledger.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Context ledger
|
||||
|
||||
The ledger is gitnexus-plan's working memory. It exists to make repeated
|
||||
investigation impossible-by-discipline: **before every GitNexus call and
|
||||
every repo file read, check it.** Keep it as structured notes in your working
|
||||
context (or a scratchpad file _outside the repo_ for very long sessions); it
|
||||
is never published verbatim — the plan and context pack are distilled from
|
||||
it. This skill's own reference files are exempt from ledger bookkeeping.
|
||||
|
||||
## Schema
|
||||
|
||||
```yaml
|
||||
context_ledger:
|
||||
task:
|
||||
original_request: ''
|
||||
interpreted_goal: ''
|
||||
category: '' # Phase 0 classification
|
||||
acceptance_criteria: []
|
||||
|
||||
verified_at_commit:
|
||||
'' # target repo HEAD, recorded once in Phase 1;
|
||||
# every line citation in the plan pins to it
|
||||
|
||||
evidence_provenance: {} # required immutable working snapshot; populate
|
||||
# exactly from context-pack.md's normative schema
|
||||
|
||||
index_refresh:
|
||||
'' # analyze --index-only runs: command + outcome
|
||||
# (or "skipped: <reason>"). Budget is
|
||||
# owned by SKILL.md Phase 1: one refresh plus
|
||||
# at most one Phase 3 --pdg upgrade per session
|
||||
|
||||
established_facts: [] # each with its evidence source
|
||||
|
||||
symbols: # budgets count active (primary/related) only;
|
||||
# discards are free — but on budget overflow,
|
||||
# discard something before promoting
|
||||
- name: ''
|
||||
kind: ''
|
||||
file: ''
|
||||
relevance: 'primary | related | discarded'
|
||||
source_verified: false # flipped in Phase 4; required before naming in Proposed Changes
|
||||
|
||||
files_read:
|
||||
- file: ''
|
||||
ranges: [] # e.g. ["120-188"]
|
||||
purpose: ''
|
||||
|
||||
gitnexus_queries:
|
||||
- query: '' # tool + args
|
||||
purpose: '' # the planning question it answers
|
||||
conclusion: '' # one line; details stay in working memory
|
||||
key_output: '' # one-line raw quote when the plan leans on this result
|
||||
|
||||
pdg_slices:
|
||||
- symbol: ''
|
||||
purpose: ''
|
||||
conclusion: ''
|
||||
|
||||
unresolved_questions: []
|
||||
assumptions: [] # explicit, carried into plan §12
|
||||
decisions: [] # with rationale, carried into plan §6/§7
|
||||
```
|
||||
|
||||
## Evidence provenance
|
||||
|
||||
`context-pack.md` is the sole normative emitted field schema, and
|
||||
`evidence-provenance.md` plus `../scripts/evidence-provenance.mjs` are the
|
||||
normative byte contract and implementation. Keep the helper's exact schema-2
|
||||
output in the ledger; do not redefine, abbreviate, or independently reproduce
|
||||
its canonicalization here.
|
||||
|
||||
Build `evidence_provenance` immediately before composing the plan, after all
|
||||
source verification, by invoking the helper exactly as described in
|
||||
`evidence-provenance.md`. It is a versioned, canonical snapshot of both the
|
||||
whole working tree and every path that supports a plan citation:
|
||||
|
||||
- `global_dirty_digest` is SHA-256 over the helper's versioned, NUL-framed
|
||||
records for
|
||||
**every dirty repo-relative path**, not only cited paths. Each record includes
|
||||
path, state, object kind, every available layer digest, and both endpoints of
|
||||
a rename. Overlapping porcelain facts for one path are merged; for example,
|
||||
a staged deletion plus a recreated untracked file is `mixed` and retains
|
||||
both its Git-backed and untracked layers. States are `staged`, `unstaged`,
|
||||
`untracked`, `deleted`, `renamed`, or `mixed`. Exclude only this run's
|
||||
normalized repo-relative generated plan path so writing the plan cannot
|
||||
invalidate its own evidence; do not exclude the rest of `docs/plans/`.
|
||||
- `cited_path_manifest` is sorted by normalized repo-relative path and
|
||||
includes every path cited by a `[verified]` claim or named as evidence in
|
||||
the context pack. Record clean paths too. A path entry has this shape:
|
||||
|
||||
```yaml
|
||||
- path: 'src/example.ts'
|
||||
object_kind: # each layer: regular | symlink | gitlink | directory | absent
|
||||
head: 'regular'
|
||||
index: 'regular'
|
||||
worktree: 'regular'
|
||||
untracked: 'absent'
|
||||
state: 'clean | staged | unstaged | untracked | deleted | renamed | mixed | absent'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:<hex> | absent'
|
||||
index_digest: 'sha256:<hex> | absent'
|
||||
worktree_digest: 'sha256:<hex> | absent'
|
||||
untracked_digest: 'sha256:<hex> | absent'
|
||||
```
|
||||
|
||||
Use Git object contents for HEAD and index digests and filesystem bytes for
|
||||
worktree/untracked digests; never confuse an absent layer with an empty file.
|
||||
Hash symlink targets as link text and gitlinks as object IDs. If a cited path
|
||||
cannot be classified or read, the plan must mark the evidence unavailable
|
||||
instead of emitting a digest it did not prove.
|
||||
|
||||
## Reread rules
|
||||
|
||||
Do **not** repeat a query or reread a source range unless one of:
|
||||
|
||||
- the previous result was incomplete for the question at hand;
|
||||
- the source is known to have changed (an edit happened);
|
||||
- validation exposed a contradiction between graph and source.
|
||||
|
||||
**Allowed repeats** (deliberate escalations, not violations):
|
||||
|
||||
- `summaryOnly: true` → full drill-down on the same `impact` target;
|
||||
- an `ambiguous` result retried once with `kind` / `file_path` / uid narrowing;
|
||||
- the same tool re-run with a changed parameter that answers a _new_ planning
|
||||
question (e.g. `pdg_query` `controls` then `flows` on one function).
|
||||
|
||||
When a repeat is justified, note in the ledger _why_ the earlier entry was
|
||||
insufficient. A ledger full of near-duplicate queries is the failure signal —
|
||||
stop and plan with what is established.
|
||||
|
||||
## Discarding
|
||||
|
||||
Symbols and queries that turned out irrelevant stay in the ledger marked
|
||||
`discarded` with a one-line reason. That is what prevents re-walking dead
|
||||
ends later in the session.
|
||||
126
.claude/skills/gitnexus-plan/references/context-pack.md
Normal file
126
.claude/skills/gitnexus-plan/references/context-pack.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Implementation context pack
|
||||
|
||||
Section 11 of the plan. The stable, machine-readable contract a follow-up
|
||||
implementation agent (`gitnexus-work`, or any executor) consumes to start
|
||||
work **without repeating the investigation**. Distilled from the ledger;
|
||||
every entry traceable to verified evidence.
|
||||
|
||||
**Compact plans emit the mini-pack** — only: `task_summary`,
|
||||
`evidence_provenance`, `files_to_modify`, `tests`,
|
||||
`verification_commands`, `pdg_constraints` (only when a slice actually
|
||||
ran), `assumptions`, `open_questions`, `avoid`. Full plans emit every
|
||||
field. Field semantics are identical in both; `evidence_provenance` is
|
||||
mandatory in both forms. `gitnexus-work` treats absent optional fields as
|
||||
empty, not as errors.
|
||||
|
||||
## Schema
|
||||
|
||||
This is the sole normative emitted `evidence_provenance` field schema. The
|
||||
portable byte contract and executable serializer live in
|
||||
`evidence-provenance.md` and `../scripts/evidence-provenance.mjs`; sibling
|
||||
documents must reference them rather than reimplementing canonical bytes.
|
||||
|
||||
```yaml
|
||||
implementation_context:
|
||||
task_summary: ''
|
||||
acceptance_criteria: []
|
||||
|
||||
evidence_provenance:
|
||||
schema_version: 2
|
||||
head_commit: '' # full commit SHA that source citations pin to
|
||||
# normalized repo-relative docs/plans/<date>-gitnexus-plan-<3-5-word-slug>.md;
|
||||
# safely written; exact path excluded from global_dirty_digest
|
||||
generated_plan_path: ''
|
||||
global_dirty_digest:
|
||||
algorithm: 'sha256'
|
||||
canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records'
|
||||
value: '' # digest only; do not embed the whole dirty-path manifest
|
||||
cited_path_manifest: # sorted by normalized repo-relative path
|
||||
- path: ''
|
||||
object_kind: # per layer: regular | symlink | gitlink | directory | absent
|
||||
head: ''
|
||||
index: ''
|
||||
worktree: ''
|
||||
untracked: ''
|
||||
state: 'clean | staged | unstaged | untracked | deleted | renamed | mixed | absent'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:<hex> | absent'
|
||||
index_digest: 'sha256:<hex> | absent'
|
||||
worktree_digest: 'sha256:<hex> | absent'
|
||||
untracked_digest: 'sha256:<hex> | absent'
|
||||
|
||||
primary_symbols:
|
||||
- symbol: ''
|
||||
file: ''
|
||||
lines: ''
|
||||
role: ''
|
||||
|
||||
related_symbols:
|
||||
- symbol: ''
|
||||
relationship: '' # CALLS / IMPORTS / EXTENDS / test-of / ...
|
||||
relevance: ''
|
||||
|
||||
execution_path: [] # ordered prose steps, from §2/§5
|
||||
|
||||
pdg_constraints: # from the PDG slice; empty + note if no layer
|
||||
- description: ''
|
||||
affected_statements: [] # "<file>:<line>" refs
|
||||
implementation_consequence: ''
|
||||
|
||||
architectural_patterns:
|
||||
- pattern: ''
|
||||
example_location: '' # repo-relative file (+ symbol)
|
||||
usage_guidance: ''
|
||||
|
||||
files_to_modify:
|
||||
- file: ''
|
||||
symbols: []
|
||||
intended_change: ''
|
||||
|
||||
tests:
|
||||
- file: '' # existing file to update, or new path to create
|
||||
scenarios: [] # input → action → expected outcome
|
||||
|
||||
verification_commands: [] # real commands verified to exist AND be runnable —
|
||||
# prefer npm/CI scripts that carry their pre-hooks
|
||||
|
||||
risks: []
|
||||
assumptions: [] # faithful condensation of plan §12 assumptions;
|
||||
# each entry names WHAT to check and HOW —
|
||||
# gitnexus-work re-verifies them before executing
|
||||
open_questions: [] # faithful condensation of plan §12 open questions
|
||||
|
||||
avoid:
|
||||
- 'Do not repeat full repository discovery'
|
||||
- 'Do not replace established patterns without evidence'
|
||||
# + task-specific prohibitions discovered during planning
|
||||
```
|
||||
|
||||
## Must not contain
|
||||
|
||||
- full files;
|
||||
- the repository-wide raw dirty-path manifest (store only its canonical
|
||||
`global_dirty_digest`; detailed entries are bounded to cited paths);
|
||||
- large raw GitNexus responses;
|
||||
- unfiltered PDG dumps;
|
||||
- duplicate code excerpts (cite `file:line`, don't re-quote);
|
||||
- speculative implementation details presented as facts.
|
||||
|
||||
## Stability contract
|
||||
|
||||
Field names above are the interface consumed by `gitnexus-work` (fields it
|
||||
does not act on directly travel as executor context). Add fields
|
||||
freely; do not rename or repurpose existing ones. `assumptions` and `avoid`
|
||||
are load-bearing: an executor treats `assumptions` as things to re-verify
|
||||
cheaply before relying on them, and `avoid` as hard constraints.
|
||||
`evidence_provenance` is also load-bearing: its version, global digest, and
|
||||
sorted cited-path manifest let the executor distinguish commit drift from
|
||||
staged, unstaged, untracked, deleted, renamed, mixed, or absent working-tree
|
||||
evidence. Legacy packs that lack it or use schema 1 require a conservative
|
||||
schema-2 re-anchor; they are not interpreted as a clean tree.
|
||||
`generated_plan_path` is always normalized, relative to the target repo, and
|
||||
scoped to the generated-plan filename shape under `docs/plans/`; schema 2 has
|
||||
no external-output representation. An executor must load the plan with the
|
||||
helper's descriptor-anchored `read-plan` command and require this field to
|
||||
equal the receipt's canonical target-repo-relative path byte-for-byte.
|
||||
272
.claude/skills/gitnexus-plan/references/evidence-provenance.md
Normal file
272
.claude/skills/gitnexus-plan/references/evidence-provenance.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Evidence provenance serializer v2 and safe plan writer
|
||||
|
||||
This file is the normative byte contract for `evidence_provenance` schema 2.
|
||||
The adjacent `scripts/evidence-provenance.mjs` is its executable definition.
|
||||
`gitnexus-plan` and `gitnexus-work` carry byte-identical copies so either skill
|
||||
can produce the same snapshot without relying on the other skill's install.
|
||||
It is also the only supported write boundary for a generated plan. Never
|
||||
recreate the digest with an ad-hoc shell pipeline or write the plan destination
|
||||
directly.
|
||||
|
||||
## Invocation
|
||||
|
||||
From the target repository root, run the helper belonging to the active skill:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs read-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md
|
||||
```
|
||||
|
||||
`read-plan` is the only supported way to load an existing plan for Deepen or
|
||||
execution. It emits a JSON receipt with the canonical `generated_plan_path`,
|
||||
`bytes_read`, exact `plan_bytes_base64`, and `plan_digest` (`sha256:<hex>`).
|
||||
Decode and consume those exact bytes; do not reopen the lexical path. Retain
|
||||
the canonical path and digest together for the complete Deepen session; a
|
||||
receipt for one path never authorizes another, even when their bytes match.
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs snapshot \
|
||||
--repo "$PWD" \
|
||||
--schema-version 2 \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--cited src/one.ts \
|
||||
--cited test/one.test.ts
|
||||
```
|
||||
|
||||
Pass one `--cited` argument for every cited path. The helper emits the complete
|
||||
JSON value for `evidence_provenance`; copy that value without rewriting fields.
|
||||
`gitnexus-work` passes the plan's `schema_version`, `generated_plan_path`, and
|
||||
every path in `cited_path_manifest`. Schema 1 is legacy and deliberately
|
||||
rejected, so the executor must conservatively re-anchor it under schema 2.
|
||||
|
||||
After the snapshot is in the fully composed document, publish its exact UTF-8
|
||||
bytes through the same helper:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
For Deepen only:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--replace \
|
||||
--expected-plan-path docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--expected-plan-digest 'sha256:<digest-from-read-plan>' \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
Initial planning never passes `--replace`; an existing destination is an
|
||||
error. Deepen mode rewrites the same path by adding `--replace`,
|
||||
`--expected-plan-path <generated_plan_path-from-read-plan>`, and
|
||||
`--expected-plan-digest <plan_digest-from-that-same-receipt>`. Standard input must be
|
||||
valid UTF-8 and at most 16 MiB. A successful write prints a JSON receipt with
|
||||
the normalized `generated_plan_path` and `bytes_written`. A successful Deepen
|
||||
write also returns `prior_plan_backup_git_path`, a durable Git-admin path for
|
||||
the displaced plan. The CLI rejects every option that does not apply to its
|
||||
selected command; the direct API likewise requires literal booleans and exact
|
||||
digest strings rather than truthy coercion.
|
||||
|
||||
## Path contract
|
||||
|
||||
Every Git path and CLI path must be valid UTF-8, already normalized to Unicode
|
||||
NFC, and a nonempty POSIX repo-relative path. NUL, backslash, absolute/drive
|
||||
paths, empty components, and `.` or `..` components are rejected. The helper
|
||||
does not silently repair or alias them. Invalid UTF-8 from Git, non-NFC names,
|
||||
unmerged index stages, unsupported Git modes, sockets/devices/FIFOs, unreadable
|
||||
objects, symlink traversal in a parent path component, or a repository mutation
|
||||
observed during the snapshot fail closed.
|
||||
|
||||
The generated-plan path is always repo-relative under schema 2. Snapshot
|
||||
exclusion and writing require exactly
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-kebab-slug>.md`, including a
|
||||
valid calendar date; they cannot target `.git`, source, configuration, or an
|
||||
arbitrary repo file. For compatibility with documented and legacy plans,
|
||||
`read-plan` accepts normalized files matching `docs/plans/*gitnexus-plan*.md`,
|
||||
while retaining the same descriptor-anchored containment checks. That read
|
||||
compatibility does not widen the writer. External output has no schema-2
|
||||
representation. The snapshot exclusion is one exact normalized path
|
||||
comparison. No glob, directory, basename, or `docs/plans/`-wide exclusion is
|
||||
permitted. If the exact path is a rename endpoint, only that endpoint record is
|
||||
excluded.
|
||||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
requires valid UTF-8, hashes the exact bytes, then proves both the parent chain
|
||||
and lexical leaf still name the same held objects before returning its receipt.
|
||||
Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
||||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
parents relative to those descriptors, and proves the descriptor and lexical
|
||||
chains still identify the same directories at the write boundary. A symlink
|
||||
or non-directory parent, an escaping resolved path, a symlink/non-regular final
|
||||
target, or a parent swap is an error.
|
||||
|
||||
The writer creates a random exclusive temporary file relative to the held final
|
||||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
`read-plan` receipt. The expected path must exactly equal the write
|
||||
destination, so identical bytes from one plan cannot authorize another plan.
|
||||
Immediately before
|
||||
preservation, the writer hashes the still-held prior-plan fd and rejects any
|
||||
digest, inode, or path mismatch, including same-inode edits and changes between
|
||||
read and write. It then atomically moves the current destination without
|
||||
replacement to a random `gitnexus-plan-backups/` file under the resolved
|
||||
Git-admin directory and verifies the moved inode and digest against that held
|
||||
fd. Only then does it publish the new plan with the same atomic no-replace
|
||||
primitive. A destination that reappears at either boundary is left untouched.
|
||||
|
||||
Every newly created plan or vault directory is fsynced and then fsynced into
|
||||
its containing directory. Every cross-directory preservation move fsyncs both
|
||||
its source and destination directories before success or a recovery path is
|
||||
reported. After temporary bytes exist, a failed publication or verification preserves
|
||||
every available prior, displaced, unpublished, or intended plan in that
|
||||
Git-admin vault before reporting failure. Each reported recovery is reopened
|
||||
from a freshly resolved Git root and verified before the error names it as
|
||||
`git-path:gitnexus-plan-backups/<random-name>`. Resolve that value with
|
||||
`git rev-parse --git-path gitnexus-plan-backups/<random-name>`; never interpret
|
||||
it as a repo-relative working-tree path. This remains valid if the held plan
|
||||
parent was renamed after publication. The writer never reports recovery
|
||||
through a stale lexical parent and never performs an identity-check-then-unlink
|
||||
rollback that could delete a racer's replacement. Read-only or unsupported
|
||||
checkouts produce a blocking error. Callers must not bypass the helper,
|
||||
redirect to an external path, or weaken these checks.
|
||||
|
||||
## Canonical bytes
|
||||
|
||||
The `global_dirty_digest.value` is lowercase SHA-256 (without a `sha256:`
|
||||
prefix) over this byte stream. All textual values are their exact UTF-8 bytes.
|
||||
`NUL` below is one `0x00` byte.
|
||||
|
||||
1. Prefix fields, each followed by NUL, then one additional NUL:
|
||||
`gitnexus-evidence-provenance`, `schema_version`, `2`.
|
||||
2. Zero or more records sorted by unsigned lexicographic comparison of the
|
||||
normalized path's UTF-8 bytes. Locale and filesystem order are forbidden.
|
||||
3. Each record is `record` + NUL, then the following fixed-order sequence of
|
||||
`field-name` + NUL + `field-value` + NUL pairs, then one additional NUL:
|
||||
`path`, `state`, `head_kind`, `index_kind`, `worktree_kind`,
|
||||
`untracked_kind`, `rename_from`, `rename_to`, `head_digest`,
|
||||
`index_digest`, `worktree_digest`, `untracked_digest`.
|
||||
4. The literal `absent` represents every unavailable rename endpoint, object
|
||||
kind, and layer digest in canonical bytes. It is never an empty string.
|
||||
|
||||
The schema's canonicalization literal is exactly
|
||||
`gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records`. The fixed field
|
||||
count plus the extra NUL after prefix/record makes framing unambiguous; values
|
||||
cannot contain NUL. Duplicate normalized paths are rejected.
|
||||
|
||||
## Records, renames, and states
|
||||
|
||||
The raw dirty set comes from Git porcelain v2 with NUL termination, all
|
||||
untracked files, submodule inspection enabled, a fixed 50% rename threshold,
|
||||
and both `diff.renameLimit=0` and `status.renameLimit=0`, so repository config
|
||||
cannot cap rename candidates. Raw porcelain facts that share a path are merged
|
||||
into one canonical record. A rename contributes two endpoint facts:
|
||||
|
||||
- old endpoint: `path=<old>`, `rename_from=absent`, `rename_to=<new>`;
|
||||
- new endpoint: `path=<new>`, `rename_from=<old>`, `rename_to=absent`.
|
||||
|
||||
Both normally have state `renamed`; record sorting, not old/new role,
|
||||
determines order. A worktree-dirty rename destination or any endpoint that also
|
||||
has another fact is `mixed`, with rename metadata retained. When either endpoint
|
||||
is cited, the cited manifest expands to include both.
|
||||
|
||||
Ordinary `XY` status maps to `mixed` when index and worktree columns are both
|
||||
dirty, otherwise `deleted` for a deletion, `staged` for index-only change, and
|
||||
`unstaged` for worktree-only change. `?` is `untracked`. Multiple distinct
|
||||
facts for the same path become `mixed`; a staged deletion plus a recreated file
|
||||
therefore retains HEAD/index facts while the filesystem object is recorded in
|
||||
the untracked layer. `? child/` is Git's embedded-directory marker: the trailing
|
||||
slash is removed before path normalization and `child` is materialized as one
|
||||
bounded directory object. A cited path outside the dirty set is `clean`,
|
||||
`untracked` when it exists only outside Git layers, or `absent` when no layer
|
||||
exists.
|
||||
|
||||
## Object and digest rules
|
||||
|
||||
Every present layer digest is `sha256:<lowercase-hex>`:
|
||||
|
||||
- HEAD regular/symlink: SHA-256 of the exact Git blob bytes. HEAD directory:
|
||||
SHA-256 of the exact raw Git tree bytes. HEAD gitlink: SHA-256 of the ASCII
|
||||
object ID stored by the tree.
|
||||
- Index regular/symlink: SHA-256 of the stage-0 Git blob bytes. Index gitlink:
|
||||
SHA-256 of its ASCII object ID. The index has no directory layer. Any
|
||||
non-stage-0 entry is rejected.
|
||||
- Tracked worktree regular: raw file bytes, opened without following symlinks.
|
||||
Symlink: raw link-target bytes. Gitlink: ASCII object ID at the checked-out
|
||||
nested HEAD, but only after `rev-parse --show-toplevel` proves that the
|
||||
directory itself is the nested repository root, `HEAD` resolves there, and
|
||||
porcelain v2 reports no staged, unstaged, untracked, or ignored nested changes. The
|
||||
same root, HEAD, and clean-status proof is repeated by the mutation guard. A
|
||||
dirty, empty, uninitialized, or parent-falling-through gitlink fails closed.
|
||||
Directory: the v1 directory stream described below.
|
||||
- A path absent from both HEAD and index places the filesystem object in the
|
||||
`untracked` layer and marks `worktree` absent. A Git-backed path places it in
|
||||
`worktree` and marks `untracked` absent. A missing layer uses literal
|
||||
`absent` for both kind and digest; an empty file is the SHA-256 of zero bytes.
|
||||
|
||||
Filesystem directory bytes use prefix fields
|
||||
`gitnexus-evidence-directory`, `schema_version`, `1`, the same NUL framing,
|
||||
and recursive entries sorted by unsigned UTF-8 relative-path bytes. Each entry
|
||||
has fixed fields `path`, `kind`, `digest`. A single bottom-up filesystem walk
|
||||
visits each node once and returns each child digest plus the flattened subtree
|
||||
needed to preserve those canonical bytes; links are never followed. When the
|
||||
directory is proven to be an exact nested Git top-level, only its administrative
|
||||
`.git` entry is excluded. Every other child, including working files and nested
|
||||
directories, remains evidence.
|
||||
|
||||
Each directory object is bounded to 10,000 visited entries, depth 256, and 256
|
||||
MiB of regular-file content. Exceeding a bound fails closed. These bounds apply
|
||||
independently to each top-level directory object materialized by a record.
|
||||
|
||||
HEAD objects are read only from the full object ID captured at snapshot start;
|
||||
the symbolic `HEAD` name is never re-resolved for layers. Index layers are
|
||||
parsed from one captured stage-0 listing. The helper guards the corresponding
|
||||
HEAD/ref/reflog controls and raw index file, compares the captured listing at
|
||||
the end, and rejects ordinary A-to-B-to-A mutations instead of accepting
|
||||
mixed-era layers.
|
||||
|
||||
Regular files are read through an `O_NOFOLLOW` descriptor with before/after
|
||||
identity checks. Symlinks use lstat/readlink/lstat; directories record identity
|
||||
before and after their inventory. The helper also compares raw porcelain-v2
|
||||
status and HEAD at the start and end, then rechecks filesystem guards. An
|
||||
absent cited path holds a no-follow descriptor for the nearest existing parent
|
||||
and records the first missing component or leaf; that anchored absence is
|
||||
checked both before and after the final Git status pass, so a newly created
|
||||
ignored path cannot evade porcelain. Any observed race rejects the snapshot
|
||||
rather than emitting mixed-era evidence.
|
||||
109
.claude/skills/gitnexus-plan/references/pdg-slice.md
Normal file
109
.claude/skills/gitnexus-plan/references/pdg-slice.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Building the PDG context slice
|
||||
|
||||
Statement-level evidence for the 1–3 functions most central to the change.
|
||||
Goal: a compact slice the planning LLM can hold, never a graph dump.
|
||||
|
||||
## Tools (all verified against `gitnexus/src/mcp/tools.ts`)
|
||||
|
||||
| Question | Call |
|
||||
| --- | --- |
|
||||
| Under what condition does X run? Guards? | `pdg_query {mode: "controls", target}` |
|
||||
| Where does variable Y flow inside the function? | `pdg_query {mode: "flows", target, variable}` |
|
||||
| What depends on the statement at line N? | `impact {mode: "pdg", target, direction: "upstream", line: N}` |
|
||||
| Source→sink taint paths (security mode) | `explain {target}` |
|
||||
|
||||
Contract caveats that shape interpretation:
|
||||
|
||||
- `impact` requires `direction` in every mode, `mode: "pdg"` included —
|
||||
`"upstream"` for "what depends on this statement", `"downstream"` for what
|
||||
it depends on. Omitting it fails schema validation.
|
||||
- CDG branch sense is `'T'`/`'F'` in the result's `label` field; a guard's
|
||||
sense depends on its predicate (`if (!ok) return;` rides `'T'`) — never
|
||||
filter guards by a fixed label. Early return/throw edges carry `guard:
|
||||
true`. (The raw edge stores the sense in `reason`, visible only via
|
||||
`cypher`.)
|
||||
- `pdg_query` is intra-procedural and always anchored. Cross-function flow is
|
||||
taint's domain (`explain`) or `impact {mode:"pdg"}`'s inter-procedural reach.
|
||||
- Every `switch` case arm is `'T'` (per-case conditions not distinguished).
|
||||
- No `--pdg` layer → the tools return a "no PDG layer" note, not an error.
|
||||
The note is repo-wide: one probe settles it — do not re-probe per function.
|
||||
Under `freshness: strict` (default), run `analyze --index-only --pdg` via
|
||||
the runner resolved in SKILL.md Phase 1 — this is the one `--pdg` upgrade
|
||||
Phase 1's refresh budget allows (skip it if Phase 1 already refreshed
|
||||
with `--pdg`; apply the runner build check first) — then re-probe. If the refresh failed, is impractical, or `freshness: accept` was
|
||||
passed: record "PDG unavailable" in the ledger, skip the slice, say so in
|
||||
plan §5, and recommend the command. Never reconstruct edges from source by
|
||||
hand.
|
||||
|
||||
## Inclusion criteria
|
||||
|
||||
A statement enters the slice only if it is at least one of:
|
||||
|
||||
- directly matched to the task;
|
||||
- a data-flow predecessor or successor of a relevant statement (within
|
||||
`pdg_data_depth`, default 2);
|
||||
- a control dependency of a relevant statement (within `pdg_control_depth`,
|
||||
default 2);
|
||||
- a state mutation affecting the requested behavior;
|
||||
- an external call on the execution path;
|
||||
- an error-handling or fallback branch;
|
||||
- part of an affected return value;
|
||||
- required to explain a test assertion.
|
||||
|
||||
Everything else is cut. If the slice exceeds ~15 statements per function,
|
||||
tighten relevance rather than raising depth.
|
||||
|
||||
## Slice representation
|
||||
|
||||
Working-memory material: keep the full slice in working context while
|
||||
planning, summarize it into the ledger's one-line `pdg_slices` entries, and
|
||||
distill it into plan §5.
|
||||
|
||||
```yaml
|
||||
pdg_context:
|
||||
entry_symbol: "processFileGroup"
|
||||
source: { file: "gitnexus/src/core/ingestion/worker.ts", start_line: 120, end_line: 188 }
|
||||
relevant_statements:
|
||||
- id: "stmt-12" # stable id or "<file>:<line>"
|
||||
lines: "128-130"
|
||||
type: "condition | call | mutation | return | throw"
|
||||
code: "if (request.retryable) {"
|
||||
relevance: "Controls whether retry scheduling is entered"
|
||||
defines: []
|
||||
uses: ["request.retryable"]
|
||||
control_dependencies: ["stmt-4"]
|
||||
data_dependencies: []
|
||||
execution_flow: # ordered, prose steps
|
||||
- "Validate request"
|
||||
- "Schedule retry"
|
||||
critical_dependencies:
|
||||
- { from: "stmt-7", to: "stmt-18", type: "data", explanation: "Validated request becomes scheduler input" }
|
||||
behavioural_observations:
|
||||
- "Persistence occurs before scheduler invocation"
|
||||
planning_implications:
|
||||
- "Changes to scheduling must account for partial failure"
|
||||
```
|
||||
|
||||
Adapt field names to what the tools actually returned; keep it
|
||||
machine-readable and short. `behavioural_observations` are confirmed facts;
|
||||
`planning_implications` are inferences — keep the distinction.
|
||||
|
||||
## Security mode (task category: security)
|
||||
|
||||
Additionally identify and record: untrusted inputs, validation points,
|
||||
sanitisation points, authn/authz checks, privilege boundaries, sensitive data,
|
||||
persistence operations, network calls, dangerous sinks, and error paths that
|
||||
bypass validation. Run `explain {target}` for persisted source→sink taint
|
||||
paths (intra-procedural TAINTED edges and cross-function TAINT_PATH flows)
|
||||
and include the hop paths for findings relevant to the task. Absence of a
|
||||
taint finding is **not** proof of safety — closure/callback flows,
|
||||
property/field flows, and implicit flows are not modeled, and guard-style
|
||||
sanitizers may be missed — say so when it matters.
|
||||
|
||||
## Performance mode (task category: performance)
|
||||
|
||||
Additionally scan the slice for: loops, repeated calls, blocking operations,
|
||||
network calls, database calls, allocation-heavy paths, caching boundaries,
|
||||
concurrency, fan-out, repeated data transformations. State likely hot-path
|
||||
implications as inferences; never claim measured improvements without
|
||||
benchmark evidence.
|
||||
201
.claude/skills/gitnexus-plan/references/plan-template.md
Normal file
201
.claude/skills/gitnexus-plan/references/plan-template.md
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# Plan document template
|
||||
|
||||
Two forms, chosen by the Phase 0 category (`form` knob overrides): **compact**
|
||||
for narrow/default work, **full** for deep work. Repo-relative paths for all
|
||||
repo artifacts in both.
|
||||
|
||||
## Compact form
|
||||
|
||||
Same evidence header, then only the load-bearing sections — keep the §
|
||||
numbers in the headings so `gitnexus-work`'s § references resolve:
|
||||
|
||||
```markdown
|
||||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: <one line>
|
||||
> Evidence verified at commit <sha>; GitNexus index <...>.
|
||||
> Evidence provenance schema 2; global dirty digest <sha256>; cited-path manifest <count> sorted entries; exact generated plan path excluded.
|
||||
|
||||
## Objective (§1)
|
||||
|
||||
## Current Behaviour (§2–3) — ≤10 lines, architecture folded in
|
||||
|
||||
## Findings (§4–5) — only load-bearing, each tagged + tool-named
|
||||
|
||||
## Proposed Changes (§6)
|
||||
|
||||
## Implementation Sequence (§7) — risks inline as step notes
|
||||
|
||||
## Test Strategy (§8)
|
||||
|
||||
## Implementation Context (§11) — the mini-pack (see context-pack.md)
|
||||
|
||||
## Assumptions and Open Questions (§12)
|
||||
|
||||
## Definition of Done (§13)
|
||||
```
|
||||
|
||||
Hard cap: **80 lines excluding the §11 pack**. Anything cut that still
|
||||
matters becomes one line in §12 — never padded prose. A compact plan that
|
||||
outgrows the cap is a signal the task was misclassified: reclassify to full
|
||||
rather than overflowing.
|
||||
|
||||
## Full form
|
||||
|
||||
Fill every section below. If a section is genuinely empty for this task
|
||||
(e.g. no PDG layer indexed), keep the heading and state why in one line —
|
||||
never silently drop it.
|
||||
|
||||
**Claim tagging.** Tag every load-bearing claim with its evidence class:
|
||||
`[verified]` (source-read at the pinned commit), `[graph]` (GitNexus/PDG
|
||||
output, not source-confirmed), `[inferred]` (evidence-backed reasoning),
|
||||
`[assumed]` (unverified — must also appear in §12). Untagged prose is
|
||||
narrative, not evidence.
|
||||
|
||||
```markdown
|
||||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: <one line>
|
||||
> Evidence verified at commit <HEAD sha>; GitNexus index <fresh | refreshed this session (--index-only [--pdg]) | N commits behind, refresh skipped: <reason> | not used>.
|
||||
> Evidence provenance schema 2; global dirty digest <sha256>; cited-path manifest <count> sorted entries; exact generated plan path excluded.
|
||||
|
||||
## 1. Objective
|
||||
|
||||
A concise description of the requested outcome.
|
||||
|
||||
## 2. Current Behaviour
|
||||
|
||||
Describe the current implementation and execution path.
|
||||
|
||||
Include the most relevant symbols, files, and statement-level observations.
|
||||
|
||||
## 3. Relevant Architecture
|
||||
|
||||
Explain the involved modules, boundaries, dependencies, and established patterns.
|
||||
|
||||
## 4. GitNexus Findings
|
||||
|
||||
Summarise:
|
||||
|
||||
- primary symbols;
|
||||
- callers and callees;
|
||||
- impact radius;
|
||||
- related implementations;
|
||||
- related tests;
|
||||
- important cross-module relationships.
|
||||
|
||||
## 5. Statement-Level PDG Findings
|
||||
|
||||
For each critical symbol, explain:
|
||||
|
||||
- relevant statements;
|
||||
- control dependencies;
|
||||
- data dependencies;
|
||||
- state mutations;
|
||||
- error branches;
|
||||
- side effects;
|
||||
- ordering constraints;
|
||||
- planning implications.
|
||||
|
||||
Do not paste an unfiltered graph dump.
|
||||
|
||||
## 6. Proposed Changes
|
||||
|
||||
For every proposed change include:
|
||||
|
||||
- file;
|
||||
- symbol;
|
||||
- exact responsibility;
|
||||
- intended behavioural change;
|
||||
- dependencies;
|
||||
- constraints;
|
||||
- implementation notes.
|
||||
|
||||
## 7. Implementation Sequence
|
||||
|
||||
Provide an ordered sequence of implementation steps.
|
||||
|
||||
Each step must be independently actionable.
|
||||
|
||||
## 8. Test Strategy
|
||||
|
||||
Describe:
|
||||
|
||||
- tests to add;
|
||||
- tests to update;
|
||||
- edge cases;
|
||||
- failure paths;
|
||||
- regression coverage;
|
||||
- integration boundaries;
|
||||
- relevant verification commands.
|
||||
|
||||
## 9. Risk and Impact Analysis
|
||||
|
||||
Include:
|
||||
|
||||
- high-risk symbols;
|
||||
- downstream consumers;
|
||||
- compatibility concerns;
|
||||
- performance concerns;
|
||||
- concurrency or transaction risks;
|
||||
- migration risks;
|
||||
- observability requirements.
|
||||
|
||||
## 10. Files Expected to Change
|
||||
|
||||
| File | Symbols | Reason |
|
||||
| ---- | ------- | ------ |
|
||||
|
||||
## 11. Reusable Implementation Context
|
||||
|
||||
The machine-readable context pack — see `context-pack.md`. Its mandatory
|
||||
`evidence_provenance` field carries the full pinned commit, canonical
|
||||
repository-wide dirty digest, and sorted cited-path manifest.
|
||||
|
||||
## 12. Assumptions and Open Questions
|
||||
|
||||
Clearly separate assumptions from confirmed facts. Explicitly-deferred
|
||||
follow-up suggestions (adjacent work the task didn't ask for) land here too.
|
||||
|
||||
## 13. Definition of Done
|
||||
|
||||
Concrete, testable completion criteria.
|
||||
```
|
||||
|
||||
Composition notes:
|
||||
|
||||
- Immediately before composition, emit `evidence_provenance.schema_version`,
|
||||
the full HEAD commit, the canonical `global_dirty_digest`, and the
|
||||
`cited_path_manifest` sorted by normalized repo-relative path. Include
|
||||
object kinds, rename endpoints, and HEAD/index/worktree/untracked layer
|
||||
digests. Exclude only the generated plan path from the global digest.
|
||||
- Invoke `scripts/evidence-provenance.mjs` per `evidence-provenance.md` and
|
||||
copy its schema-2 JSON; never recreate canonical records in prose or shell.
|
||||
- Publish the fully composed UTF-8 plan only with that helper's `write-plan`
|
||||
command. Initial planning must not replace an existing file; Deepen rewrites
|
||||
the same repo-relative path with `write-plan --replace
|
||||
--expected-plan-path <path-from-read-plan>
|
||||
--expected-plan-digest <digest-from-read-plan>`, which preserves the prior
|
||||
plan in the receipt's `prior_plan_backup_git_path`. Both expected values must
|
||||
come from the same receipt. Deepen must load and bind that canonical path and
|
||||
those original bytes through `read-plan` first. Snapshot, read, and
|
||||
publication must pass the same strict generated-plan filename/date validator.
|
||||
- §2/§5 quote source excerpts at most `max_snippet_lines` (30) lines each, and
|
||||
only when the excerpt carries the argument.
|
||||
- §4 findings each name the tool call they came from (tool + key args), plus a
|
||||
one-line quote of the result when the plan leans on it — that is what makes
|
||||
a tool claim auditable later. Stale-index or fallback-mode findings are
|
||||
labelled as such.
|
||||
- §6 changes may only name symbols the ledger marks `source_verified`.
|
||||
- §7 steps are ordered by dependency and independently actionable — an
|
||||
executor can stop after any step with the tree still coherent. Steps that
|
||||
change output guarded by fingerprints, goldens, or recorded baselines
|
||||
regenerate those artifacts ONCE, in the final step of the sequence — CI
|
||||
judges only the tip, and per-step refreshes churn every intermediate
|
||||
commit and re-drift as later steps land.
|
||||
- §8 names real, located test files for updates; new tests get concrete
|
||||
scenario lists (input → action → expected outcome). Verification commands
|
||||
must exist AND be runnable: prefer the npm/CI script form that carries its
|
||||
prerequisites (pre-hooks, builds) over invoking underlying binaries directly.
|
||||
- §9 must account for every direct (depth-1) dependent the impact pass
|
||||
reported.
|
||||
2084
.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs
Normal file
2084
.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
|
||||
```
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
|
|
@ -66,7 +66,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ 10 graph edits (high confidence), 2 text_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
|
|
@ -107,10 +107,10 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ 12 edits: 10 graph (safe), 2 text_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
2. Review text_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
|
|
|||
228
.claude/skills/gitnexus-review/SKILL.md
Normal file
228
.claude/skills/gitnexus-review/SKILL.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
name: gitnexus-review
|
||||
description: 'Review code changes with GitNexus from a GitHub PR URL or number, a branch/ref or commit range, or local staged, unstaged, and untracked changes. Use when the user asks for a code review, merge-risk assessment, regression hunt, missing-test analysis, or a verdict on whether a PR, branch, commit range, or local diff is safe.'
|
||||
---
|
||||
|
||||
# GitNexus review
|
||||
|
||||
Review the requested change surface without editing source, committing, pushing,
|
||||
posting, or resolving threads. A later explicit request may authorize those
|
||||
actions. Use GitNexus for structural evidence and source inspection for proof;
|
||||
neither substitutes for the other.
|
||||
|
||||
## Resolve the target
|
||||
|
||||
Accept these forms:
|
||||
|
||||
| Input | Review surface |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| PR URL, `owner/repo#42`, `#42`, or bare number | GitHub PR |
|
||||
| `base...head` | Merge-base range |
|
||||
| `base..head` | Exact two-dot range |
|
||||
| Branch, tag, or commit | Ref against the repository default branch |
|
||||
| `local`, `staged`, `unstaged`, or working-tree wording | Local changes |
|
||||
| No target | Current branch's open PR; otherwise local changes; otherwise current branch |
|
||||
|
||||
An explicit target always wins. Interpret a bare number as a PR only in a
|
||||
GitHub repository with working `gh` authentication; otherwise ask for a ref or
|
||||
URL. If implicit mode finds both branch commits and local changes, review them
|
||||
as two labeled surfaces rather than silently dropping or blending either one.
|
||||
|
||||
Record the resolved target kind, repository root, default branch, base SHA,
|
||||
head SHA, merge-base when applicable, and included local states. Resolve the
|
||||
default branch from remote metadata (`refs/remotes/<remote>/HEAD` or GitHub
|
||||
repository metadata); use `main` or `master` only as an explicit fallback and
|
||||
say when doing so.
|
||||
|
||||
### PR
|
||||
|
||||
Use `gh pr view`/`gh api` to pin the PR number, repository, title, URL, base
|
||||
ref, base SHA, head ref, and head SHA. Fetch those exact commits without
|
||||
switching the user's branch. Compute `git merge-base <base> <head>` and use
|
||||
that SHA as the review base: GitHub PR diffs are merge-base diffs, while
|
||||
`detect_changes(scope: "compare")` is a two-dot comparison.
|
||||
|
||||
Use the local `git diff <merge-base> <head>` as the complete diff source of
|
||||
truth; use GitHub metadata for PR facts and review state. For fork PRs, fetch
|
||||
the pull ref or the contributor remote instead of assuming the head branch
|
||||
exists on `origin`.
|
||||
|
||||
### Branch, ref, or range
|
||||
|
||||
Resolve every ref to a commit before reviewing. For a branch or `A...B`, use
|
||||
the merge-base as the comparison base. For an explicit `A..B`, honor `A` as
|
||||
the exact base. Do not compare a feature branch directly with a moving default
|
||||
branch tip when merge-base semantics were intended.
|
||||
|
||||
### Local changes
|
||||
|
||||
Inspect `git status --short`, the staged diff, the unstaged diff, and every
|
||||
untracked file. Use `detect_changes` with `staged`, `unstaged`, or `all` as
|
||||
requested. Untracked files are not guaranteed to appear in Git diff or graph
|
||||
mapping, so read them directly and list them in the review provenance.
|
||||
|
||||
## Align the checkout and index
|
||||
|
||||
The graph and diff must describe the same head. Reuse an existing worktree only
|
||||
when it is at the exact target SHA. Otherwise create a temporary detached
|
||||
worktree for the PR/ref head, review there, and remove only that temporary
|
||||
worktree afterward. Never switch or reset the user's current worktree.
|
||||
|
||||
Check GitNexus status in the target worktree. If stale, run
|
||||
`node .gitnexus/run.cjs analyze --index-only` before trusting graph results
|
||||
(temporary worktrees never carry the gitignored `run.cjs` — fall back to the
|
||||
installed `gitnexus` CLI, then `npx gitnexus`), and include `--pdg` in that
|
||||
same refresh when the diff plausibly touches trust or data-flow boundaries,
|
||||
so the taint pass below doesn't pay a second full analyze. Taint and
|
||||
dependence evidence needs that PDG layer: when the workflow's taint pass
|
||||
finds it missing, rebuild with `analyze --pdg --index-only` and record the
|
||||
rebuild in provenance. For local changes, refresh the index so new or
|
||||
modified source is represented.
|
||||
If an exact target checkout/index cannot be established, state the limitation
|
||||
and do not claim a complete graph-backed review.
|
||||
|
||||
## Review workflow
|
||||
|
||||
1. Read the full diff and changed-file list. Separate generated files,
|
||||
dependency churn, tests, and behavior changes.
|
||||
2. Run `detect_changes` against the exact surface:
|
||||
- PR/branch/`...`: `scope: "compare"`, `base_ref: <merge-base SHA>`.
|
||||
- Explicit `A..B`: `scope: "compare"`, `base_ref: <A SHA>` from a worktree
|
||||
at `B`.
|
||||
- Local: `scope: "staged"`, `"unstaged"`, or `"all"`.
|
||||
Pass `worktree` when the MCP server is attached elsewhere.
|
||||
3. Run upstream `impact` with `includeTests: true` for each behaviorally changed
|
||||
symbol. Prioritize public contracts, shared types, control flow, persistence,
|
||||
security boundaries, and error handling; skip mechanical/generated changes.
|
||||
4. Inspect every direct (`d=1`) dependent that is outside the diff. A dependent
|
||||
outside the diff is a lead, not automatically a bug—verify the changed
|
||||
contract and caller behavior in source.
|
||||
5. Use `context` on key or ambiguous symbols and inspect affected execution
|
||||
flows. Read the surrounding implementation and tests at cited locations.
|
||||
6. **Taint and dependence pass.** For changed code on trust or data-flow
|
||||
boundaries — external input, persistence, process execution, network,
|
||||
auth — run `explain` on the changed files or symbols and judge its
|
||||
source→sink taint findings against the diff: a flow the change
|
||||
introduces, or a sanitizer/guard the change removes, is a finding; a
|
||||
pre-existing flow is context, not a defect of this change. When the
|
||||
change claims to guard or sanitize something, verify with `pdg_query`:
|
||||
what controls the changed statement, and where its values flow. This
|
||||
needs a `--pdg` index; if one cannot be built, state that the taint pass
|
||||
was skipped rather than implying coverage.
|
||||
7. Check whether tests exercise the changed behavior, boundary conditions, and
|
||||
affected flows. Run focused read-only validation when practical. When the
|
||||
diff refreshes a committed baseline, fingerprint, or golden, re-run the
|
||||
exact CI check command against the head instead of trusting the committed
|
||||
value — a stale artifact is invisible in the diff and fails only in CI.
|
||||
8. Reconcile graph evidence with the raw diff. New files, dynamic dispatch,
|
||||
configuration, reflection, and untracked content may require direct review
|
||||
even when graph results are empty. Version and invalidation constants are
|
||||
review surface: when the diff changes what gets emitted or persisted,
|
||||
verify every schema/version constant gating caches, incremental
|
||||
writebacks, and fingerprint baselines was bumped or regenerated — in
|
||||
GitNexus itself, for example: `INCREMENTAL_SCHEMA_VERSION` (the
|
||||
incremental write set covers only changed files, so new cross-file edges
|
||||
never reach an existing index without the bump), the parse-store
|
||||
`SCHEMA_BUMP`, and both bench fingerprint sets.
|
||||
|
||||
## Expert lenses
|
||||
|
||||
Depth comes from matching reviewers to what actually changed, not from one
|
||||
generalist pass. After workflow step 2, group the changed files and symbols
|
||||
by the functional areas the graph already knows — the index's cluster
|
||||
listing; `context` names each symbol's cluster — and give each touched area
|
||||
an expert lens: a reviewer charged with that domain's contracts, invariants,
|
||||
and failure modes, grounded in the repo's own material (architecture docs,
|
||||
agent rules, the domain's tests) before judging the diff. A lens verifies,
|
||||
not just reads: when the changed code is a pure function reachable from the
|
||||
repo's own toolchain — parsers, extractors, capture emitters, formatters —
|
||||
execute it on the candidate failing shape (a scratch probe, deleted
|
||||
afterward) and cite the observed output. An empirical probe outranks source
|
||||
reading in the evidence hierarchy; role swaps, dead branches, and
|
||||
error-recovery-dependent behavior repeatedly pass a reading and fail a
|
||||
ten-line probe. The numbered
|
||||
workflow runs exactly once; dispatch the lens passes after step 6, handing
|
||||
each lens the evidence already collected rather than letting lenses repeat
|
||||
the `impact`, `context`, or taint calls. In GitNexus
|
||||
itself, for example: shared ingestion-pipeline changes get an ingestion
|
||||
expert plus one language expert per changed language extractor; embeddings
|
||||
changes an embeddings expert; LadybugDB/storage changes a Ladybug expert.
|
||||
|
||||
Four cross-cutting lenses run regardless of domain:
|
||||
|
||||
- **Architectural fit** — the change lands where the architecture says the
|
||||
concern lives, reuses existing seams, and adds no parallel structure.
|
||||
- **Language conformance** — the repo's own type/lint/test contract as
|
||||
configured (tsconfig strictness, lint rules, test conventions); in a
|
||||
strict TypeScript repo, for example: strictness intact, no `any`/`as any`
|
||||
escapes, module boundaries typed. Judge by the repo's contract, never a
|
||||
universal style bar.
|
||||
- **Definition of Done** — changed behavior has tests, docs the change makes
|
||||
stale are updated, and sync/drift guards (shipped copies, manifests,
|
||||
changelogs) still hold.
|
||||
- **Simplicity** — YAGNI and clear-code check: flag speculative abstraction,
|
||||
unused knobs, and overengineering; the smallest diff that meets the
|
||||
Definition of Done is the standard.
|
||||
|
||||
Scale effort to the surface: a single-domain change of a few files gets one
|
||||
combined pass covering its domain lens plus the four cross-cutting checks;
|
||||
a multi-domain change gets one lens per touched area — run as parallel
|
||||
subagents where the harness supports them, each scoped to its own files
|
||||
plus the shared graph evidence, and as sequential passes otherwise. Never
|
||||
spawn a lens for a domain the diff does not touch. Merge lenses that ground
|
||||
in the same material — two lenses reading the same files pay twice for one
|
||||
read's coverage, so give one reviewer both charges. Where the harness
|
||||
offers model or effort tiers, run mechanical lenses (rename sweeps,
|
||||
doc-consistency checks) on a cheaper tier and reserve the strongest engine
|
||||
for adversarial judgment. Every lens reports
|
||||
through the Finding standard below; merge and dedup before the verdict,
|
||||
dropping anything without a concrete failing scenario.
|
||||
|
||||
## Finding standard
|
||||
|
||||
Report a finding only when the reviewed change introduces a concrete defect,
|
||||
regression, security issue, compatibility break, material coverage gap, or a
|
||||
maintainability cost with a concrete carrying scenario (a dead knob, a
|
||||
duplicated contract, a drift-prone copy).
|
||||
Each finding must include:
|
||||
|
||||
- severity and a precise `path:line` anchor;
|
||||
- the failing scenario or contract;
|
||||
- GitNexus evidence (dependent symbol/process) when applicable;
|
||||
- why existing code or tests do not mitigate it;
|
||||
- a concise remediation or missing test.
|
||||
|
||||
Do not report style preferences, pre-existing issues, raw risk counts, or
|
||||
speculation as defects. Do not infer safety from zero graph hits. Calibrate
|
||||
overall risk from consequence, reachability, reversibility, and test evidence,
|
||||
not from the number of changed symbols alone.
|
||||
|
||||
## Output
|
||||
|
||||
Lead with findings in severity order. If there are none, say so explicitly.
|
||||
Then provide:
|
||||
|
||||
```markdown
|
||||
## Review: <target>
|
||||
|
||||
### Findings
|
||||
|
||||
- [HIGH|MEDIUM|LOW] `path:line` — <problem, evidence, impact, remediation>
|
||||
|
||||
### Change and blast-radius summary
|
||||
|
||||
- Target/base/head/merge-base and local states reviewed
|
||||
- Changed symbols and affected execution flows
|
||||
|
||||
### Coverage and residual risk
|
||||
|
||||
- Tests present, tests missing, graph/diff limitations
|
||||
|
||||
### Verdict
|
||||
|
||||
APPROVE | REQUEST CHANGES | NEEDS DISCUSSION
|
||||
```
|
||||
|
||||
For a branch or local review, use `READY`, `NOT READY`, or `NEEDS DISCUSSION`
|
||||
instead of a PR approval action. Include the exact target SHAs so a later run
|
||||
can tell whether the evidence is stale.
|
||||
71
.claude/skills/gitnexus-work/README.md
Normal file
71
.claude/skills/gitnexus-work/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# gitnexus-work — execute a gitnexus-plan
|
||||
|
||||
The executor counterpart to `gitnexus-plan`: consumes a plan's §11
|
||||
implementation context pack and ships it as verified atomic commits, with
|
||||
GitNexus discipline baked in — `impact` before every symbol edit,
|
||||
`detect_changes` before every commit, tests from the plan's scenarios, and a
|
||||
two-layer drift check that re-anchors both commit and dirty working-tree
|
||||
evidence before relying on it.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude Code** | `/gitnexus-work [plan path]` (blank → newest `docs/plans/*gitnexus-plan*.md` in this repo) |
|
||||
| **Codex CLI** | Ask: "run gitnexus-work on <plan path>" (Codex reads `AGENTS.md`), or install the skill user-level (below) |
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-work ~/.agents/skills/gitnexus-work
|
||||
```
|
||||
|
||||
Optionally, for an explicit slash command, create
|
||||
`~/.codex/prompts/gitnexus-work.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Execute a gitnexus-plan as verified atomic commits (impact-checked, detect_changes-gated)
|
||||
argument-hint: <plan path, or blank for the newest plan>
|
||||
---
|
||||
|
||||
Use the gitnexus-work skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-work/SKILL.md` (prefer the repo copy at
|
||||
`.claude/skills/gitnexus-work/SKILL.md` when present) and follow its phases in
|
||||
order. This skill edits code; honor its impact-before-edit and
|
||||
detect_changes-before-commit rules without exception.
|
||||
```
|
||||
|
||||
## Contract with gitnexus-plan
|
||||
|
||||
- Input: the 13-section plan document; §11's `implementation_context` fields
|
||||
are the machine-readable interface (see
|
||||
`../gitnexus-plan/references/context-pack.md` for the stability contract).
|
||||
- `evidence_provenance` is mandatory in compact and full plans. Work always
|
||||
loads the plan only through its byte-identical helper's descriptor-anchored
|
||||
`read-plan` command, consumes the exact base64 bytes from that receipt, and
|
||||
recomputes the global dirty digest and sorted cited-path manifest even at
|
||||
the same HEAD. Schema-2 `generated_plan_path` is a normalized
|
||||
repo-relative `docs/plans/<date>-gitnexus-plan-<slug>.md` path; external,
|
||||
escaping, or differently scoped values are invalid. It must also equal the
|
||||
read receipt's canonical target-repo-relative path byte-for-byte.
|
||||
Missing or schema-1 evidence re-anchors under schema 2.
|
||||
- The plan is never mutated; deviations are recorded in commit messages and
|
||||
the final report.
|
||||
- Changed citations are re-read, new uncited dirty paths are assessed for
|
||||
scope, and unreadable evidence blocks dependent work. Deepen is reserved
|
||||
for drift that invalidates scope, requirements, a key technical decision,
|
||||
or the planned seam.
|
||||
|
||||
## Graph freshness
|
||||
|
||||
One fail-closed **Build-current/index-current procedure** runs before every
|
||||
graph-dependent impact query and again before final graph verification. It
|
||||
compares indexed commit and the schema-4 runner identity (including its
|
||||
`gitnexus-analyzer-dependency-runtime-v4` dependency payload/runtime digest),
|
||||
requires no incomplete-index recovery markers, invalidates on
|
||||
relationship-affecting committed or uncommitted edits, builds and invokes the
|
||||
current local analyzer with PDG indexing when needed, and treats timestamps
|
||||
only as a conservative trigger. Build, refresh, or identity failures block
|
||||
impact and completion; the executor never falls back to a stale runner.
|
||||
269
.claude/skills/gitnexus-work/SKILL.md
Normal file
269
.claude/skills/gitnexus-work/SKILL.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
---
|
||||
name: gitnexus-work
|
||||
description: 'Use when executing an engineering plan produced by gitnexus-plan (or a small bounded task directly) — implements step by step with GitNexus impact checks before every symbol edit, tests from the plan''s scenarios, and detect_changes gating every commit. Examples: "/gitnexus-work docs/plans/2026-07-11-gitnexus-plan-ingestion-retry.md", "/gitnexus-work" (latest plan), "execute the plan".'
|
||||
---
|
||||
|
||||
# gitnexus-work — execute a gitnexus-plan
|
||||
|
||||
Execute an implementation plan produced by `gitnexus-plan`, shipping it as a
|
||||
sequence of verified, atomic commits. The plan's section 11
|
||||
(`implementation_context` pack) is the primary machine-readable input; the
|
||||
prose sections are its rationale. This skill **does** edit code — it is the
|
||||
executor counterpart to the planning-only `gitnexus-plan`.
|
||||
|
||||
```
|
||||
/gitnexus-work <plan path> # execute this plan
|
||||
/gitnexus-work # newest docs/plans/*gitnexus-plan*.md here
|
||||
/gitnexus-work <small task text> # direct mode, see Input triage
|
||||
```
|
||||
|
||||
## Input triage
|
||||
|
||||
- **Plan path** (or blank → the newest `docs/plans/*gitnexus-plan*.md` under
|
||||
the current repo root): the normal mode; continue to Phase 1. Schema-2
|
||||
plans have a normalized repo-relative
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-slug>.md`
|
||||
`generated_plan_path`. Resolve only a lexical candidate, then invoke
|
||||
`scripts/evidence-provenance.mjs read-plan --repo <root> --generated-plan
|
||||
<candidate>` and load only the exact bytes in its descriptor-anchored
|
||||
receipt. Require the receipt's canonical repo-relative path to equal the
|
||||
document's `generated_plan_path` byte-for-byte;
|
||||
reject an external, escaping, differently scoped, or mismatched value. A
|
||||
plan in another target repo may still be passed by explicit path. If Phase 1's
|
||||
pre-completed check finds every §7 step of the newest plan already landed,
|
||||
stop and ask instead of re-executing it.
|
||||
- **Bare task text**: trivial and bounded (1–2 files, no architectural
|
||||
decisions) → implement directly with the same discipline: `impact` before
|
||||
every symbol edit, minimal change, tests when behavior changes,
|
||||
verification commands taken from the repo's own scripts (package.json /
|
||||
CI), `detect_changes` before every commit, and the shared
|
||||
Build-current/index-current procedure before graph-dependent impact and
|
||||
final verification. Anything larger → recommend running
|
||||
`/gitnexus-plan` first; honor the user's choice if they decline.
|
||||
|
||||
## Phase 1 — Load and re-anchor the plan
|
||||
|
||||
1. Resolve the target repo and normalized plan candidate, then invoke this
|
||||
skill's descriptor-anchored `scripts/evidence-provenance.mjs read-plan`
|
||||
command exactly as
|
||||
specified in `references/evidence-provenance.md`. Reject a missing,
|
||||
external, escaping, symlinked, or differently scoped path. Decode and read
|
||||
the receipt's exact `plan_bytes_base64` completely; never read or reopen the
|
||||
lexical path directly. It is a decision artifact, not a script: scope
|
||||
boundaries and `avoid` entries bind you; exact code is yours to write.
|
||||
Retain the receipt's canonical `generated_plan_path` and `plan_digest` in
|
||||
session state. Never edit the plan body.
|
||||
2. Parse the §11 `implementation_context` pack: `acceptance_criteria`,
|
||||
`evidence_provenance`, `primary_symbols`, `related_symbols`,
|
||||
`files_to_modify`, `execution_path`, `pdg_constraints`,
|
||||
`architectural_patterns`, `tests`, `verification_commands`, `risks`,
|
||||
`assumptions`, `open_questions`, `avoid`. Compact plans carry the
|
||||
mini-pack subset — absent optional fields are empty, not errors.
|
||||
`evidence_provenance` is mandatory: absence or schema 1 means a legacy
|
||||
plan, not a clean tree. Before relying on it, require exact byte-for-byte
|
||||
equality between the read-plan receipt's canonical `generated_plan_path`
|
||||
and `evidence_provenance.generated_plan_path`.
|
||||
3. **Two-layer drift check — always recompute.** Even when current HEAD is the
|
||||
same HEAD as the plan pin, recompute both the canonical global dirty digest
|
||||
and the sorted cited-path manifest. Read
|
||||
`references/evidence-provenance.md`, then invoke this skill's
|
||||
`scripts/evidence-provenance.mjs` with the plan's exact
|
||||
`generated_plan_path`, every cited manifest path, and schema version 2.
|
||||
Never recreate its bytes in shell or prose. Schema 1 cannot be recomputed
|
||||
unambiguously and requires conservative re-anchoring. Include
|
||||
object kind plus HEAD/index/worktree/untracked layer digests, and classify
|
||||
`staged`, `unstaged`, `untracked`, `deleted`, `renamed`, `mixed`,
|
||||
and `absent` evidence. Honor the generated-plan exclusion exactly; do not
|
||||
exclude all plans.
|
||||
4. **Re-anchor on either mismatch.** Missing or legacy provenance, a HEAD
|
||||
mismatch, or a global dirty digest mismatch requires a conservative
|
||||
re-anchor before work:
|
||||
- Diff every cited-path manifest entry. Changed cited paths — including
|
||||
staged-only, unstaged-only, deleted, both rename endpoints, mixed
|
||||
staged+unstaged, and disappeared untracked paths — get their cited ranges
|
||||
re-read before reliance.
|
||||
- Compare the current whole-tree dirty set with the pinned global digest.
|
||||
New uncited dirty paths get a scope assessment: determine whether they
|
||||
overlap the plan, requirements, tests, or a key technical decision; do not
|
||||
silently ignore them merely because they are uncited.
|
||||
- Unreadable or unclassifiable cited evidence blocks every dependent step
|
||||
until it can be restored, read, or resolved with the user. Never substitute
|
||||
an invented digest or treat absence as an empty file.
|
||||
- Keep the re-anchor result in session state; never mutate the plan body.
|
||||
Use Deepen only if reconciliation invalidates scope, requirements, a key
|
||||
technical decision (KTD), or the planned implementation seam. Ordinary
|
||||
byte drift that leaves those decisions valid is re-verified locally.
|
||||
5. **Re-verify `assumptions` cheaply** (each one names what to check).
|
||||
A failed assumption is a stop-and-replan signal for the steps that
|
||||
depend on it, not something to code around silently.
|
||||
6. Note `open_questions` — if one blocks a step and the answer materially
|
||||
changes the work, ask the user before that step, not after.
|
||||
7. **Pre-completed check.** If commits for this plan already exist on the
|
||||
branch (a prior partial run, or a post-route-back Deepen cycle), verify
|
||||
which §7 steps have landed at HEAD: those are skipped and reported as
|
||||
pre-completed, and execution resumes at the first unlanded step. All
|
||||
steps landed → report that and stop.
|
||||
|
||||
## Phase 2 — Environment
|
||||
|
||||
- On the default branch → create a feature branch named from the plan slug.
|
||||
On a feature branch already → stay only if it is meaningful _for this
|
||||
plan_ (name matches the plan slug, or the user confirms); otherwise
|
||||
branch from here with the slug name.
|
||||
- If the plan document is not yet committed, commit it now
|
||||
(`docs(plans): add <slug> plan`) — the plan travels with the work it
|
||||
drives, and the final review diff then includes it.
|
||||
- Confirm the `verification_commands` from the pack actually run in this
|
||||
checkout (dependencies installed, builds present) before starting, not
|
||||
after the last step.
|
||||
|
||||
### Build-current/index-current procedure
|
||||
|
||||
This is the single graph-freshness procedure owned by `gitnexus-work`; it
|
||||
applies in plan mode and direct mode. Before every graph-dependent `impact`
|
||||
query, run the Build-current/index-current procedure. Before final graph
|
||||
verification, run the same Build-current/index-current procedure again.
|
||||
|
||||
1. Capture current HEAD and working-tree provenance. Read
|
||||
`gitnexus://repo/<name>/context` and use its typed `index.commit` and
|
||||
`index.runner_identity` receipt — never infer analyzer identity from prose,
|
||||
timestamps, or a path alone. Compare `index.commit` with current HEAD. A
|
||||
current receipt has `schemaVersion: 4`, resolved runtime path/version, CLI
|
||||
version, invoked-artifact path/digest, build
|
||||
kind/root/canonicalization/digest, and dependency-runtime
|
||||
manifest/lockfile/canonicalization/package-count/artifact-count/digest. Its
|
||||
dependency canonicalization is
|
||||
`gitnexus-analyzer-dependency-runtime-v4`. The dependency-runtime digest
|
||||
covers resolved package metadata and complete loadable package payloads,
|
||||
including JavaScript, JSON, native, Wasm, and parser artifacts; schema-1,
|
||||
schema-2, and schema-3 receipts are legacy/stale (the MCP context labels
|
||||
them `runner_identity_schema_status: legacy-or-unknown`). Require MCP
|
||||
`index.incomplete_reasons: []`. Run the exact candidate CLI's
|
||||
`status --json` command and require `index.runnerIdentityStatus: current`,
|
||||
`index.incompleteReasons: []`, and top-level `status: up-to-date`. The
|
||||
status comparator checks every semantic field while deliberately excluding
|
||||
only diagnostic `invokedArtifact`; a worker-authored persisted receipt and
|
||||
the CLI's live receipt may therefore differ in that field without becoming
|
||||
stale. Missing, malformed, differently versioned, semantically unequal, or
|
||||
incomplete receipts are unknown/stale, not a match.
|
||||
2. Relationship-affecting committed and uncommitted edits invalidate
|
||||
freshness after the last successful procedure run. This includes staged,
|
||||
unstaged, untracked, deleted, or renamed analyzer/source/config changes
|
||||
that can alter symbols or edges. Any such edit between steps requires an
|
||||
inter-step refresh before the next graph query, even when HEAD did not move.
|
||||
3. If the typed runner receipt is stale or unknown in an analyzer-source
|
||||
checkout, build current local source using the verified package script. In
|
||||
this repo: `cd gitnexus && npm run build`. Resolve the package's `bin`
|
||||
target and run that exact artifact's `status --json` command to capture its
|
||||
current receipt. Source/build timestamps are a conservative rebuild
|
||||
trigger, not proof that an artifact is current.
|
||||
4. Invoke that exact freshly built local CLI from the target repo root with
|
||||
PDG layers enabled. In this repo:
|
||||
`node gitnexus/dist/cli/index.js analyze --index-only --pdg`.
|
||||
Add `--force` when the persisted receipt was absent, malformed,
|
||||
differently versioned, or unequal so an already-up-to-date fast path cannot
|
||||
leave legacy/stale provenance in place. The usual project-runner form,
|
||||
`node .gitnexus/run.cjs analyze`, is acceptable only when its proven runner
|
||||
identity resolves to that same freshly built artifact. Do not fall back to
|
||||
an older project runner, global install, or package download after
|
||||
resolving/building the local artifact.
|
||||
5. Re-read index context, rerun the exact invoked CLI's `status --json`, and
|
||||
prove the post-refresh `index.commit` equals current HEAD, MCP
|
||||
`index.incomplete_reasons` is empty, and its complete
|
||||
`index.runner_identity` equals status `index.runnerIdentity` (the persisted
|
||||
receipt). Require status `index.runnerIdentityStatus: current`, empty
|
||||
`index.incompleteReasons`, and top-level `status: up-to-date`; do not require
|
||||
raw equality with `current.runnerIdentity` because `invokedArtifact` is a
|
||||
diagnostic entrypoint deliberately excluded from semantic freshness.
|
||||
Record the dirty-state digest indexed in this procedure so same-HEAD
|
||||
uncommitted edits can invalidate it later.
|
||||
6. Any build, refresh, metadata-read, or identity-verification failure blocks
|
||||
graph-dependent impact work and final completion. Report the failing
|
||||
command and evidence; do not continue on an older graph.
|
||||
|
||||
## Phase 3 — Execute the Implementation Sequence
|
||||
|
||||
Work through plan §7 step by step, in order. For each step:
|
||||
|
||||
1. **Fresh impact before editing.** Run the Build-current/index-current
|
||||
procedure immediately before every graph-dependent
|
||||
`impact {target, direction: "upstream"}` query. Then account for every
|
||||
direct (d=1) dependent. HIGH or CRITICAL risk → surface it to the user
|
||||
with the blast radius before proceeding (repo mandate — see AGENTS.md
|
||||
GitNexus rules).
|
||||
2. **Honor the constraints.** `pdg_constraints` entries state ordering and
|
||||
dependence facts the change must preserve; `avoid` entries are hard
|
||||
prohibitions; `architectural_patterns` name the shape to mirror (read the
|
||||
example location before inventing one).
|
||||
3. **Implement minimally.** The smallest change that completes the step,
|
||||
following the surrounding code's conventions.
|
||||
4. **Test from the plan's scenarios.** Each `tests[]` scenario (input →
|
||||
action → expected outcome) becomes a real test in the named file. Add
|
||||
coverage the plan missed if the step's behavior demands it; never delete
|
||||
or weaken an assertion to make a step pass. Prove a new regression test
|
||||
discriminates: when the failure mode is subtle, run it once against the
|
||||
pre-fix tree (write the test before the fix, or stash the fix) and watch
|
||||
it fail — a test that passes both ways pins nothing.
|
||||
5. **Verify.** Run the step-relevant `verification_commands` (they carry
|
||||
their build prerequisites; use them as written). If any part of the
|
||||
change executes from build output — worker entrypoints, dist-shipped
|
||||
CLIs, bundled assets — rebuild that output before every verification
|
||||
run: a pass or fail against outdated build output is noise, and "the
|
||||
fix doesn't work" is more often "the fix never loaded".
|
||||
6. **Commit atomically.** `detect_changes {scope: "staged"}` before every
|
||||
commit to confirm only the expected symbols and flows are affected
|
||||
(repo mandate); then one conventional commit per step. Run stage →
|
||||
`detect_changes` → commit as one unbroken sequence from the repository
|
||||
root — interleaving other work between the gate and the commit is how
|
||||
the gate gets skipped. Unexpected
|
||||
affected flows → investigate before committing, not after.
|
||||
|
||||
A relationship-affecting implementation edit or commit invalidates the
|
||||
procedure's prior proof. The next step must perform the required inter-step
|
||||
refresh before its impact query; final verification refreshes again after the
|
||||
last edit.
|
||||
|
||||
Steps are independently actionable: after any commit the tree is coherent.
|
||||
If a step reveals the plan is wrong, stop that step, re-verify the affected
|
||||
claims at HEAD, and either adapt (small, in-scope deviation — record it in
|
||||
the commit message and final summary) or route back to `gitnexus-plan`
|
||||
Deepen mode (structural miss) — with a one-line ask to the user when the
|
||||
choice isn't obvious.
|
||||
|
||||
## Phase 4 — Finish
|
||||
|
||||
1. Run the full `verification_commands` suite once, at the end, even if
|
||||
every step already passed individually.
|
||||
2. Walk plan §13 (Definition of Done) and the pack's `acceptance_criteria`
|
||||
item by item; anything unmet is either finished now or reported as
|
||||
explicitly unmet — never silently dropped.
|
||||
3. **Verify the final knowledge graph.** Before final graph verification, run
|
||||
the same Build-current/index-current procedure after the last edit, even
|
||||
when no commit landed or HEAD still equals the original pin. Then run
|
||||
`detect_changes {scope: "all"}` (or the repo's equivalent final graph
|
||||
check) against that proven-current index and account for every unexpected
|
||||
symbol or flow. A procedure failure blocks completion.
|
||||
4. Report: steps completed, commits made, deviations from the plan (with
|
||||
why), assumptions that failed re-verification, DoD status, final indexed
|
||||
commit and runner identity, and anything deferred. Test failures are
|
||||
reported with their output, not smoothed over.
|
||||
|
||||
## Never
|
||||
|
||||
- Skip the Phase 3 gates: no symbol edit without `impact`, no commit without
|
||||
`detect_changes`.
|
||||
- Expand scope beyond the plan — §12's deferred follow-ups stay deferred.
|
||||
- Mutate the plan body (committing the file verbatim in Phase 2 is not
|
||||
mutation), weaken failing tests, or present unverified work as verified.
|
||||
|
||||
## Skill feedback (GitNexus repo only)
|
||||
|
||||
If this run exposed friction in this skill's own instructions — wrong or
|
||||
missing guidance, a wasted tool budget, a phase that misrouted — and the repo
|
||||
carries `eval/workflow_bench/`, append one JSON line to
|
||||
`eval/workflow_bench/learnings.jsonl` (create the file if absent):
|
||||
`{"skill": "gitnexus-work", "date": "YYYY-MM-DD", "task": "<one line>", "friction": "<one line>", "suggestion": "<one line>"}`.
|
||||
Never edit this skill file itself from a live task: improvements go through
|
||||
the offline candidate loop (`eval/workflow_bench/README.md` § Prompt and
|
||||
skill evolution loop), where a candidate must beat the incumbent on the
|
||||
paired benchmark before a human merges it.
|
||||
272
.claude/skills/gitnexus-work/references/evidence-provenance.md
Normal file
272
.claude/skills/gitnexus-work/references/evidence-provenance.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Evidence provenance serializer v2 and safe plan writer
|
||||
|
||||
This file is the normative byte contract for `evidence_provenance` schema 2.
|
||||
The adjacent `scripts/evidence-provenance.mjs` is its executable definition.
|
||||
`gitnexus-plan` and `gitnexus-work` carry byte-identical copies so either skill
|
||||
can produce the same snapshot without relying on the other skill's install.
|
||||
It is also the only supported write boundary for a generated plan. Never
|
||||
recreate the digest with an ad-hoc shell pipeline or write the plan destination
|
||||
directly.
|
||||
|
||||
## Invocation
|
||||
|
||||
From the target repository root, run the helper belonging to the active skill:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs read-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md
|
||||
```
|
||||
|
||||
`read-plan` is the only supported way to load an existing plan for Deepen or
|
||||
execution. It emits a JSON receipt with the canonical `generated_plan_path`,
|
||||
`bytes_read`, exact `plan_bytes_base64`, and `plan_digest` (`sha256:<hex>`).
|
||||
Decode and consume those exact bytes; do not reopen the lexical path. Retain
|
||||
the canonical path and digest together for the complete Deepen session; a
|
||||
receipt for one path never authorizes another, even when their bytes match.
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs snapshot \
|
||||
--repo "$PWD" \
|
||||
--schema-version 2 \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--cited src/one.ts \
|
||||
--cited test/one.test.ts
|
||||
```
|
||||
|
||||
Pass one `--cited` argument for every cited path. The helper emits the complete
|
||||
JSON value for `evidence_provenance`; copy that value without rewriting fields.
|
||||
`gitnexus-work` passes the plan's `schema_version`, `generated_plan_path`, and
|
||||
every path in `cited_path_manifest`. Schema 1 is legacy and deliberately
|
||||
rejected, so the executor must conservatively re-anchor it under schema 2.
|
||||
|
||||
After the snapshot is in the fully composed document, publish its exact UTF-8
|
||||
bytes through the same helper:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
For Deepen only:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--replace \
|
||||
--expected-plan-path docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--expected-plan-digest 'sha256:<digest-from-read-plan>' \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
Initial planning never passes `--replace`; an existing destination is an
|
||||
error. Deepen mode rewrites the same path by adding `--replace`,
|
||||
`--expected-plan-path <generated_plan_path-from-read-plan>`, and
|
||||
`--expected-plan-digest <plan_digest-from-that-same-receipt>`. Standard input must be
|
||||
valid UTF-8 and at most 16 MiB. A successful write prints a JSON receipt with
|
||||
the normalized `generated_plan_path` and `bytes_written`. A successful Deepen
|
||||
write also returns `prior_plan_backup_git_path`, a durable Git-admin path for
|
||||
the displaced plan. The CLI rejects every option that does not apply to its
|
||||
selected command; the direct API likewise requires literal booleans and exact
|
||||
digest strings rather than truthy coercion.
|
||||
|
||||
## Path contract
|
||||
|
||||
Every Git path and CLI path must be valid UTF-8, already normalized to Unicode
|
||||
NFC, and a nonempty POSIX repo-relative path. NUL, backslash, absolute/drive
|
||||
paths, empty components, and `.` or `..` components are rejected. The helper
|
||||
does not silently repair or alias them. Invalid UTF-8 from Git, non-NFC names,
|
||||
unmerged index stages, unsupported Git modes, sockets/devices/FIFOs, unreadable
|
||||
objects, symlink traversal in a parent path component, or a repository mutation
|
||||
observed during the snapshot fail closed.
|
||||
|
||||
The generated-plan path is always repo-relative under schema 2. Snapshot
|
||||
exclusion and writing require exactly
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-kebab-slug>.md`, including a
|
||||
valid calendar date; they cannot target `.git`, source, configuration, or an
|
||||
arbitrary repo file. For compatibility with documented and legacy plans,
|
||||
`read-plan` accepts normalized files matching `docs/plans/*gitnexus-plan*.md`,
|
||||
while retaining the same descriptor-anchored containment checks. That read
|
||||
compatibility does not widen the writer. External output has no schema-2
|
||||
representation. The snapshot exclusion is one exact normalized path
|
||||
comparison. No glob, directory, basename, or `docs/plans/`-wide exclusion is
|
||||
permitted. If the exact path is a rename endpoint, only that endpoint record is
|
||||
excluded.
|
||||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
requires valid UTF-8, hashes the exact bytes, then proves both the parent chain
|
||||
and lexical leaf still name the same held objects before returning its receipt.
|
||||
Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
||||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
parents relative to those descriptors, and proves the descriptor and lexical
|
||||
chains still identify the same directories at the write boundary. A symlink
|
||||
or non-directory parent, an escaping resolved path, a symlink/non-regular final
|
||||
target, or a parent swap is an error.
|
||||
|
||||
The writer creates a random exclusive temporary file relative to the held final
|
||||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
`read-plan` receipt. The expected path must exactly equal the write
|
||||
destination, so identical bytes from one plan cannot authorize another plan.
|
||||
Immediately before
|
||||
preservation, the writer hashes the still-held prior-plan fd and rejects any
|
||||
digest, inode, or path mismatch, including same-inode edits and changes between
|
||||
read and write. It then atomically moves the current destination without
|
||||
replacement to a random `gitnexus-plan-backups/` file under the resolved
|
||||
Git-admin directory and verifies the moved inode and digest against that held
|
||||
fd. Only then does it publish the new plan with the same atomic no-replace
|
||||
primitive. A destination that reappears at either boundary is left untouched.
|
||||
|
||||
Every newly created plan or vault directory is fsynced and then fsynced into
|
||||
its containing directory. Every cross-directory preservation move fsyncs both
|
||||
its source and destination directories before success or a recovery path is
|
||||
reported. After temporary bytes exist, a failed publication or verification preserves
|
||||
every available prior, displaced, unpublished, or intended plan in that
|
||||
Git-admin vault before reporting failure. Each reported recovery is reopened
|
||||
from a freshly resolved Git root and verified before the error names it as
|
||||
`git-path:gitnexus-plan-backups/<random-name>`. Resolve that value with
|
||||
`git rev-parse --git-path gitnexus-plan-backups/<random-name>`; never interpret
|
||||
it as a repo-relative working-tree path. This remains valid if the held plan
|
||||
parent was renamed after publication. The writer never reports recovery
|
||||
through a stale lexical parent and never performs an identity-check-then-unlink
|
||||
rollback that could delete a racer's replacement. Read-only or unsupported
|
||||
checkouts produce a blocking error. Callers must not bypass the helper,
|
||||
redirect to an external path, or weaken these checks.
|
||||
|
||||
## Canonical bytes
|
||||
|
||||
The `global_dirty_digest.value` is lowercase SHA-256 (without a `sha256:`
|
||||
prefix) over this byte stream. All textual values are their exact UTF-8 bytes.
|
||||
`NUL` below is one `0x00` byte.
|
||||
|
||||
1. Prefix fields, each followed by NUL, then one additional NUL:
|
||||
`gitnexus-evidence-provenance`, `schema_version`, `2`.
|
||||
2. Zero or more records sorted by unsigned lexicographic comparison of the
|
||||
normalized path's UTF-8 bytes. Locale and filesystem order are forbidden.
|
||||
3. Each record is `record` + NUL, then the following fixed-order sequence of
|
||||
`field-name` + NUL + `field-value` + NUL pairs, then one additional NUL:
|
||||
`path`, `state`, `head_kind`, `index_kind`, `worktree_kind`,
|
||||
`untracked_kind`, `rename_from`, `rename_to`, `head_digest`,
|
||||
`index_digest`, `worktree_digest`, `untracked_digest`.
|
||||
4. The literal `absent` represents every unavailable rename endpoint, object
|
||||
kind, and layer digest in canonical bytes. It is never an empty string.
|
||||
|
||||
The schema's canonicalization literal is exactly
|
||||
`gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records`. The fixed field
|
||||
count plus the extra NUL after prefix/record makes framing unambiguous; values
|
||||
cannot contain NUL. Duplicate normalized paths are rejected.
|
||||
|
||||
## Records, renames, and states
|
||||
|
||||
The raw dirty set comes from Git porcelain v2 with NUL termination, all
|
||||
untracked files, submodule inspection enabled, a fixed 50% rename threshold,
|
||||
and both `diff.renameLimit=0` and `status.renameLimit=0`, so repository config
|
||||
cannot cap rename candidates. Raw porcelain facts that share a path are merged
|
||||
into one canonical record. A rename contributes two endpoint facts:
|
||||
|
||||
- old endpoint: `path=<old>`, `rename_from=absent`, `rename_to=<new>`;
|
||||
- new endpoint: `path=<new>`, `rename_from=<old>`, `rename_to=absent`.
|
||||
|
||||
Both normally have state `renamed`; record sorting, not old/new role,
|
||||
determines order. A worktree-dirty rename destination or any endpoint that also
|
||||
has another fact is `mixed`, with rename metadata retained. When either endpoint
|
||||
is cited, the cited manifest expands to include both.
|
||||
|
||||
Ordinary `XY` status maps to `mixed` when index and worktree columns are both
|
||||
dirty, otherwise `deleted` for a deletion, `staged` for index-only change, and
|
||||
`unstaged` for worktree-only change. `?` is `untracked`. Multiple distinct
|
||||
facts for the same path become `mixed`; a staged deletion plus a recreated file
|
||||
therefore retains HEAD/index facts while the filesystem object is recorded in
|
||||
the untracked layer. `? child/` is Git's embedded-directory marker: the trailing
|
||||
slash is removed before path normalization and `child` is materialized as one
|
||||
bounded directory object. A cited path outside the dirty set is `clean`,
|
||||
`untracked` when it exists only outside Git layers, or `absent` when no layer
|
||||
exists.
|
||||
|
||||
## Object and digest rules
|
||||
|
||||
Every present layer digest is `sha256:<lowercase-hex>`:
|
||||
|
||||
- HEAD regular/symlink: SHA-256 of the exact Git blob bytes. HEAD directory:
|
||||
SHA-256 of the exact raw Git tree bytes. HEAD gitlink: SHA-256 of the ASCII
|
||||
object ID stored by the tree.
|
||||
- Index regular/symlink: SHA-256 of the stage-0 Git blob bytes. Index gitlink:
|
||||
SHA-256 of its ASCII object ID. The index has no directory layer. Any
|
||||
non-stage-0 entry is rejected.
|
||||
- Tracked worktree regular: raw file bytes, opened without following symlinks.
|
||||
Symlink: raw link-target bytes. Gitlink: ASCII object ID at the checked-out
|
||||
nested HEAD, but only after `rev-parse --show-toplevel` proves that the
|
||||
directory itself is the nested repository root, `HEAD` resolves there, and
|
||||
porcelain v2 reports no staged, unstaged, untracked, or ignored nested changes. The
|
||||
same root, HEAD, and clean-status proof is repeated by the mutation guard. A
|
||||
dirty, empty, uninitialized, or parent-falling-through gitlink fails closed.
|
||||
Directory: the v1 directory stream described below.
|
||||
- A path absent from both HEAD and index places the filesystem object in the
|
||||
`untracked` layer and marks `worktree` absent. A Git-backed path places it in
|
||||
`worktree` and marks `untracked` absent. A missing layer uses literal
|
||||
`absent` for both kind and digest; an empty file is the SHA-256 of zero bytes.
|
||||
|
||||
Filesystem directory bytes use prefix fields
|
||||
`gitnexus-evidence-directory`, `schema_version`, `1`, the same NUL framing,
|
||||
and recursive entries sorted by unsigned UTF-8 relative-path bytes. Each entry
|
||||
has fixed fields `path`, `kind`, `digest`. A single bottom-up filesystem walk
|
||||
visits each node once and returns each child digest plus the flattened subtree
|
||||
needed to preserve those canonical bytes; links are never followed. When the
|
||||
directory is proven to be an exact nested Git top-level, only its administrative
|
||||
`.git` entry is excluded. Every other child, including working files and nested
|
||||
directories, remains evidence.
|
||||
|
||||
Each directory object is bounded to 10,000 visited entries, depth 256, and 256
|
||||
MiB of regular-file content. Exceeding a bound fails closed. These bounds apply
|
||||
independently to each top-level directory object materialized by a record.
|
||||
|
||||
HEAD objects are read only from the full object ID captured at snapshot start;
|
||||
the symbolic `HEAD` name is never re-resolved for layers. Index layers are
|
||||
parsed from one captured stage-0 listing. The helper guards the corresponding
|
||||
HEAD/ref/reflog controls and raw index file, compares the captured listing at
|
||||
the end, and rejects ordinary A-to-B-to-A mutations instead of accepting
|
||||
mixed-era layers.
|
||||
|
||||
Regular files are read through an `O_NOFOLLOW` descriptor with before/after
|
||||
identity checks. Symlinks use lstat/readlink/lstat; directories record identity
|
||||
before and after their inventory. The helper also compares raw porcelain-v2
|
||||
status and HEAD at the start and end, then rechecks filesystem guards. An
|
||||
absent cited path holds a no-follow descriptor for the nearest existing parent
|
||||
and records the first missing component or leaf; that anchored absence is
|
||||
checked both before and after the final Git status pass, so a newly created
|
||||
ignored path cannot evade porcelain. Any observed race rejects the snapshot
|
||||
rather than emitting mixed-era evidence.
|
||||
2084
.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs
Normal file
2084
.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,163 +0,0 @@
|
|||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
145
.github/claude-canary-runtime/package-lock.json
generated
vendored
Normal file
145
.github/claude-canary-runtime/package-lock.json
generated
vendored
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
{
|
||||
"name": "gitnexus-claude-canary-runtime",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus-claude-canary-runtime",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.214"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.214.tgz",
|
||||
"integrity": "sha512-Gf8XbPHBacVqBlxx8sMnKWPEU6AvRNUcjD0FS6zhD44fCgCHcpbpxwSoTbHlLTqKsr/0S7wdfhjjOIq8WlYbng==",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"bin": {
|
||||
"claude": "bin/claude.exe"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-code-darwin-arm64": "2.1.214",
|
||||
"@anthropic-ai/claude-code-darwin-x64": "2.1.214",
|
||||
"@anthropic-ai/claude-code-linux-arm64": "2.1.214",
|
||||
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.214",
|
||||
"@anthropic-ai/claude-code-linux-x64": "2.1.214",
|
||||
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.214",
|
||||
"@anthropic-ai/claude-code-win32-arm64": "2.1.214",
|
||||
"@anthropic-ai/claude-code-win32-x64": "2.1.214"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.214.tgz",
|
||||
"integrity": "sha512-z99kjSImARBWdE6lGoCXSi83tbiabtIv7vtFyuwrHD56WZTFSguedBb9F8wlUncEEfUVtqHKa9nCZ55j6spiIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.214.tgz",
|
||||
"integrity": "sha512-rmETY21bPyPPyPCd4UnOnLLBOyQCSQtIjjBb26dBtqh6mLjA5qZKOMv+Uta+GBzpAWd+nxA8oro28QUVT8CGYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.214.tgz",
|
||||
"integrity": "sha512-WqNC8frNnFfNU6pFUilEk6bRWFjVI//iyZzB4VT4k9jRVJCsF4j2mrpu3AcDHbtVUqiBYsjfGXGjHmXtdhzZNw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.214.tgz",
|
||||
"integrity": "sha512-UNWeKtEqB2J8m2Eb33LjhMmghjtLr4zg1b1U09xp9/3f/QQlj1lJdvka2PjtQWzr1zt0rgh6JbKKAgLSiggIrg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.214.tgz",
|
||||
"integrity": "sha512-NSQjXX8QjjjYdDlYbPvlse5yQ3UwsmV2vuPNR3eFaXnGVv7ymFHvDSMIkTFRLXQlmPjp+tvAN5fbH3e1C38SOw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.214.tgz",
|
||||
"integrity": "sha512-mpImiNlou+uQax/ZY8ktacgTbtsP9r7V8vQ5xzD36hTu3U+rKi3IisUPDUfyNs2mxdLq51xt27Oc9+k7ONN/YQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.214.tgz",
|
||||
"integrity": "sha512-aSxjth4QhmxDZlK3bLhSs689RSiciK3WNX5ZTVjXfQgIUn9zZ8TaFreV4nHAmIKGh3AM1s30IXABiinTR8MrwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-x64": {
|
||||
"version": "2.1.214",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.214.tgz",
|
||||
"integrity": "sha512-iK9gLQSs2+bJuRV2qdrYQ4bj7VVZQKp2+TXzI89WMsxwuot0ZyY59Ei3lJ7bMfeIOAUaRFLqYFq36QMg4Cnddw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
.github/claude-canary-runtime/package.json
vendored
Normal file
11
.github/claude-canary-runtime/package.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "gitnexus-claude-canary-runtime",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.214"
|
||||
}
|
||||
}
|
||||
3676
.github/gitnexus-review-runtime/package-lock.json
generated
vendored
Normal file
3676
.github/gitnexus-review-runtime/package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load diff
14
.github/gitnexus-review-runtime/package.json
vendored
Normal file
14
.github/gitnexus-review-runtime/package.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "gitnexus-review-runtime",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "22.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus": "1.6.9"
|
||||
},
|
||||
"overrides": {
|
||||
"adm-zip": "0.6.0"
|
||||
}
|
||||
}
|
||||
118
.github/workflows/ci-tests.yml
vendored
118
.github/workflows/ci-tests.yml
vendored
|
|
@ -401,7 +401,7 @@ jobs:
|
|||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
run: npm ci && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
- name: Install and build gitnexus
|
||||
shell: bash
|
||||
|
|
@ -520,3 +520,119 @@ jobs:
|
|||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises
|
||||
# exactly the dependency graph developers run from eval/uv.lock.
|
||||
eval-tests:
|
||||
name: eval / locked pytest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: eval/uv.lock
|
||||
- run: uv run --locked --extra dev python -m pytest tests -q
|
||||
working-directory: eval
|
||||
|
||||
# Native Linux ownership and Bubblewrap boundary. The environment flag makes
|
||||
# the real namespace test mandatory; a missing/blocked bwrap is a failure.
|
||||
eval-containment-linux:
|
||||
name: eval / containment (ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
GITNEXUS_REQUIRE_BWRAP_CANARY: '1'
|
||||
GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22.16.0'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
gitnexus/package-lock.json
|
||||
gitnexus-shared/package-lock.json
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: eval/uv.lock
|
||||
- name: Install sandbox runtime and pinned Claude CLI
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes --no-install-recommends bubblewrap socat
|
||||
apparmor_userns=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
|
||||
if [[ -r "${apparmor_userns}" ]] && [[ "$(<"${apparmor_userns}")" == '1' ]]; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
canary_runtime="${RUNNER_TEMP}/claude-canary"
|
||||
install -d -m 0700 "${canary_runtime}"
|
||||
install -m 0600 \
|
||||
.github/claude-canary-runtime/package.json \
|
||||
"${canary_runtime}/package.json"
|
||||
install -m 0600 \
|
||||
.github/claude-canary-runtime/package-lock.json \
|
||||
"${canary_runtime}/package-lock.json"
|
||||
npm ci \
|
||||
--prefix "${canary_runtime}" \
|
||||
--ignore-scripts=false \
|
||||
--audit=false \
|
||||
--fund=false
|
||||
node -e \
|
||||
"const p=require(process.argv[1]); if(p.version!=='2.1.214') process.exit(1)" \
|
||||
"${canary_runtime}/node_modules/@anthropic-ai/claude-code/package.json"
|
||||
test "$("${canary_runtime}/node_modules/@anthropic-ai/claude-code-linux-x64/claude" --version)" = \
|
||||
'2.1.214 (Claude Code)'
|
||||
- name: Build pinned shared runtime
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: gitnexus-shared
|
||||
- name: Install and build pinned GitNexus runtime
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: gitnexus
|
||||
- name: Prove process-tree and sandbox containment
|
||||
env:
|
||||
CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude
|
||||
run: >-
|
||||
uv run --locked --extra dev python -m pytest
|
||||
tests/test_process_control.py
|
||||
tests/test_proposer_sandbox.py
|
||||
tests/test_workflow_bench_sessions.py
|
||||
tests/test_ce_plugin_runtime.py -q
|
||||
working-directory: eval
|
||||
|
||||
# Native Windows Job Object canary. POSIX-only tests skip by platform, while
|
||||
# the grandchild delayed-write test must execute and pass on this runner.
|
||||
eval-containment-windows:
|
||||
name: eval / containment (windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: '0.11.23'
|
||||
python-version: '3.13'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: eval/uv.lock
|
||||
- name: Prove Windows process-tree ownership
|
||||
run: >-
|
||||
uv run --locked --extra dev python -m pytest
|
||||
tests/test_process_control.py -q
|
||||
working-directory: eval
|
||||
|
|
|
|||
2188
.github/workflows/gitnexus-review-agent.yml
vendored
Normal file
2188
.github/workflows/gitnexus-review-agent.yml
vendored
Normal file
File diff suppressed because it is too large
Load diff
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -397,7 +397,7 @@ jobs:
|
|||
package-manager-cache: false
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
run: npm ci && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install gitnexus dependencies
|
||||
|
|
|
|||
71
.github/workflows/skill-sync.yml
vendored
Normal file
71
.github/workflows/skill-sync.yml
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Drift guard for the shipped engineering-skill copies (#2431).
|
||||
# ci.yml carries `paths-ignore: ['**.md', ...]`, so an md-only skill edit —
|
||||
# the most common future edit to these trees — would otherwise merge without
|
||||
# gitnexus/test/unit/shipped-skills-sync.test.ts ever running, and the drift
|
||||
# would first surface in someone else's CI run. This workflow triggers
|
||||
# exactly on the guarded trees.
|
||||
name: Skill copy sync
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.claude/skills/gitnexus-*/**'
|
||||
- '.claude/skills/gitnexus/**'
|
||||
- 'gitnexus/skills/**'
|
||||
- 'gitnexus-claude-plugin/skills/**'
|
||||
- 'gitnexus-cursor-integration/skills/**'
|
||||
- 'gitnexus/test/unit/shipped-skills-sync.test.ts'
|
||||
- 'gitnexus/test/unit/skills-steering.test.ts'
|
||||
- 'gitnexus/test/unit/engineering-skills-contract.test.ts'
|
||||
- 'gitnexus/test/unit/evidence-provenance-helper.test.ts'
|
||||
- '.github/workflows/skill-sync.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.claude/skills/gitnexus-*/**'
|
||||
- '.claude/skills/gitnexus/**'
|
||||
- 'gitnexus/skills/**'
|
||||
- 'gitnexus-claude-plugin/skills/**'
|
||||
- 'gitnexus-cursor-integration/skills/**'
|
||||
- 'gitnexus/test/unit/shipped-skills-sync.test.ts'
|
||||
- 'gitnexus/test/unit/skills-steering.test.ts'
|
||||
- 'gitnexus/test/unit/engineering-skills-contract.test.ts'
|
||||
- 'gitnexus/test/unit/evidence-provenance-helper.test.ts'
|
||||
- '.github/workflows/skill-sync.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
skill-sync:
|
||||
name: shipped skills drift guard
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — runs a read-only test, never pushes.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- name: Build gitnexus-shared
|
||||
run: npm ci && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
- name: Install gitnexus
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
- name: Run distribution, steering, and engineering-contract guards
|
||||
run: >-
|
||||
npx vitest run
|
||||
test/unit/shipped-skills-sync.test.ts
|
||||
test/unit/skills-steering.test.ts
|
||||
test/unit/engineering-skills-contract.test.ts
|
||||
test/unit/evidence-provenance-helper.test.ts
|
||||
working-directory: gitnexus
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -68,8 +68,9 @@ gitnexus-web/test-results/
|
|||
eval/.coverage
|
||||
eval/.hypothesis/
|
||||
|
||||
# Local docs
|
||||
docs/
|
||||
# Local docs (docs/plans/ stays tracked — gitnexus-plan output travels with the work)
|
||||
docs/*
|
||||
!docs/plans/
|
||||
|
||||
gitnexus/test/fixtures/mini-repo/*.md
|
||||
gitnexus/test/fixtures/mini-repo/.claude
|
||||
|
|
@ -104,6 +105,10 @@ gitnexus/vendor/**/node_modules/
|
|||
!.claude/skills/gitnexus-impact-analysis/
|
||||
!.claude/skills/gitnexus-refactoring/
|
||||
!.claude/skills/gitnexus-pr-swarm-review/
|
||||
!.claude/skills/gitnexus-review/
|
||||
!.claude/skills/gitnexus-plan/
|
||||
!.claude/skills/gitnexus-work/
|
||||
!.claude/skills/gitnexus-lfg/
|
||||
|
||||
.history/
|
||||
|
||||
|
|
@ -121,3 +126,6 @@ local_docs/
|
|||
!.agents/plugins/marketplace.json
|
||||
.context/
|
||||
gitnexus/web/
|
||||
|
||||
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
|
||||
eval/workflow_bench/learnings.jsonl
|
||||
|
|
|
|||
66
AGENTS.md
66
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- version: 1.7.0 -->
|
||||
<!-- Last updated: 2026-04-23 -->
|
||||
<!-- version: 1.13.0 -->
|
||||
<!-- Last updated: 2026-07-16 -->
|
||||
|
||||
Last reviewed: 2026-04-23
|
||||
Last reviewed: 2026-07-16
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -55,10 +55,45 @@ listed in [`pr-swarm-review/README.md`](pr-swarm-review/README.md); edit review
|
|||
in the canonical files, never in the wrappers. The review is read-only — it never edits,
|
||||
commits, or posts.
|
||||
|
||||
## Engineering planning & execution (`/gitnexus-plan` · `/gitnexus-work` · `/gitnexus-review` · `/gitnexus-lfg`)
|
||||
|
||||
Four canonical, CLI-neutral skill specs under `.claude/skills/` (Claude Code invokes
|
||||
them as slash commands; Codex or any other agent reading this file should read the
|
||||
named SKILL.md and follow it directly — user-level Codex prompts are documented in the
|
||||
plan/work/lfg skill READMEs):
|
||||
|
||||
- **`gitnexus-plan/SKILL.md`** — deep, implementation-ready plan for a code change:
|
||||
GitNexus graph intelligence for navigation, statement-level PDG slices for behavioral
|
||||
constraints, targeted source reads for verification. Output lands in `docs/plans/`
|
||||
with a reusable implementation context pack (section 11). Planning-only — it never
|
||||
edits code (index freshness refreshes via `analyze --index-only` are the one
|
||||
permitted state change). Interactive runs ask up front how deep to go
|
||||
(quick / standard / deep); Deepen mode strengthens an existing plan in place.
|
||||
- **`gitnexus-work/SKILL.md`** — executes a gitnexus-plan as verified atomic commits:
|
||||
drift-checks the plan's evidence pin against HEAD, `impact` before every symbol
|
||||
edit, tests from the plan's scenarios, `detect_changes` before every commit.
|
||||
- **`gitnexus-review/SKILL.md`** — read-only GitNexus review of a PR URL/number,
|
||||
branch or commit range, or local staged/unstaged/untracked changes. It pins exact
|
||||
SHAs, aligns the graph and checkout, runs a PDG-backed taint pass on trust-boundary
|
||||
diffs, scales to per-domain expert lenses from the graph's clusters, and reports
|
||||
evidence-backed findings.
|
||||
- **`gitnexus-lfg/SKILL.md`** — pipeline orchestrator: plan (depth asked up front) →
|
||||
blocking user gate (proceed or stop) → work → `gitnexus-review`.
|
||||
|
||||
The family ships with the npm package (`gitnexus/skills/`, installed to editor targets
|
||||
by `gitnexus setup`) and the Claude Code plugin; review also has a standalone Cursor
|
||||
mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Token savings of the workflow are measurable with
|
||||
`eval/workflow_bench/` (real headless CLI runs, free-model routing supported — see its README).
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). |
|
||||
| 2026-07-16 | 1.12.0 | Renamed `gitnexus-pr-review` to `gitnexus-review`; added PR URL/number, branch/range, and local-change targets plus install migration (setup warns on a legacy `gitnexus-pr-review` dir and leaves it in place; uninstall removes it). |
|
||||
| 2026-07-11 | 1.11.0 | Skill family shipped via npm skills/ + plugin (sync-guarded); added eval/workflow_bench token-savings benchmark. |
|
||||
| 2026-07-11 | 1.10.0 | Added `gitnexus-work` (plan executor) and `gitnexus-lfg` (plan → deepen/work gate → review pipeline) skills; section renamed to Engineering planning & execution. |
|
||||
| 2026-07-11 | 1.9.0 | Added Engineering planning (`/gitnexus-plan`) section; registered the `gitnexus-plan` skill (`.claude/skills/gitnexus-plan/`). |
|
||||
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
|
||||
|
|
@ -74,17 +109,18 @@ commits, or posts.
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (20319 symbols, 54304 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
||||
## Never Do
|
||||
|
||||
|
|
@ -112,26 +148,6 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
|||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/gitnexus-area-ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/gitnexus-area-extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/gitnexus-area-components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/gitnexus-area-lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/gitnexus-area-group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/gitnexus-area-cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/gitnexus-area-configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/gitnexus-area-type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/gitnexus-area-hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/gitnexus-area-unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/gitnexus-area-cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/gitnexus-area-scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/gitnexus-area-server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/gitnexus-area-local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/gitnexus-area-wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/gitnexus-area-workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/gitnexus-area-embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/gitnexus-area-typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/gitnexus-area-storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/gitnexus-area-php/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
|
|
|
|||
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -1,10 +1,10 @@
|
|||
<!-- version: 1.3.0 -->
|
||||
<!-- version: 1.7.0 -->
|
||||
<!--
|
||||
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
|
||||
Last updated: 2026-03-22
|
||||
Last updated: 2026-07-16
|
||||
-->
|
||||
|
||||
Last reviewed: 2026-04-13
|
||||
Last reviewed: 2026-07-16
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -37,11 +37,16 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
|||
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
|
||||
- **Call & inheritance resolution:** See ARCHITECTURE.md § Scope-Resolution Pipeline. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` / `ScopeResolver` hooks instead (see AGENTS.md). (The legacy call-resolution DAG was removed in #942.)
|
||||
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below.
|
||||
- **Engineering plans, execution & review:** `/gitnexus-plan <task>` (implementation-ready plans via GitNexus + statement-level PDG + source verification; Deepen mode for existing plans), `/gitnexus-work [plan]` (executes a plan as impact-checked, detect_changes-gated atomic commits), `/gitnexus-review [PR|branch|range|local]` (read-only graph-backed review), `/gitnexus-lfg <task>` (plan with depth asked up front → proceed/stop gate → work → review pipeline). Specs in `.claude/skills/gitnexus-{plan,work,review,lfg}/SKILL.md` (see AGENTS.md § Engineering planning & execution).
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-07-16 | 1.7.0 | `/gitnexus-plan` asks depth up front in interactive runs; `/gitnexus-lfg` gate slimmed to proceed/stop. |
|
||||
| 2026-07-16 | 1.6.0 | Renamed `/gitnexus-pr-review` to `/gitnexus-review` and added PR, branch/range, and local-change targets. |
|
||||
| 2026-07-11 | 1.5.0 | Added `/gitnexus-work` and `/gitnexus-lfg` to the engineering plans & execution pointer. |
|
||||
| 2026-07-11 | 1.4.0 | Added `/gitnexus-plan` pointer to Reference Documentation. |
|
||||
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
|
||||
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
|
||||
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
|
||||
|
|
@ -56,17 +61,18 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (20319 symbols, 54304 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
||||
## Never Do
|
||||
|
||||
|
|
@ -94,25 +100,5 @@ This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relati
|
|||
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/gitnexus-area-ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/gitnexus-area-extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/gitnexus-area-components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/gitnexus-area-lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/gitnexus-area-group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/gitnexus-area-cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/gitnexus-area-configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/gitnexus-area-type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/gitnexus-area-hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/gitnexus-area-unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/gitnexus-area-cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/gitnexus-area-scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/gitnexus-area-server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/gitnexus-area-local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/gitnexus-area-wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/gitnexus-area-workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/gitnexus-area-embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/gitnexus-area-typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/gitnexus-area-storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/gitnexus-area-php/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
2
DoD.md
2
DoD.md
|
|
@ -134,7 +134,7 @@ Run the commands relevant to the touched area. If something cannot be run in the
|
|||
|
||||
### 4.5 If CI workflows or release pipelines changed
|
||||
|
||||
- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly.
|
||||
- [ ] The workflow passes a dry-run or triggered run; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly. Workflows that only execute once registered on the default branch (an `issue_comment` trigger, or a newly added `workflow_dispatch`) cannot be dry-run pre-merge — merge them **registered but disabled**, then validate same-repo and fork execution post-merge before enabling.
|
||||
- [ ] `CHANGELOG.md` is **not** edited here — it is owned by the release process.
|
||||
|
||||
## 5. Review Gates
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ flowchart TB
|
|||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
### 6 agent skills installed to `.claude/skills/` automatically
|
||||
### Agent skills installed to `.claude/skills/` automatically
|
||||
|
||||
- **Exploring** — navigate unfamiliar code using the knowledge graph
|
||||
- **Debugging** — trace bugs through call chains
|
||||
|
|
@ -189,6 +189,12 @@ flowchart TB
|
|||
- **Refactoring** — plan safe refactors using dependency mapping
|
||||
- **Guide** — GitNexus tool/resource/schema reference for the agent
|
||||
- **CLI** — run analyze/status/clean/wiki commands on request
|
||||
- **PDG Query** — statement-level control/data dependence queries (`--pdg` index)
|
||||
- **Taint Analysis** — source→sink data-flow findings (`--pdg` index)
|
||||
- **Plan** (`/gitnexus-plan`) — implementation-ready engineering plans backed by the graph and PDG slices
|
||||
- **Work** (`/gitnexus-work`) — executes a plan as impact-checked, `detect_changes`-gated atomic commits
|
||||
- **Review** (`/gitnexus-review`) — graph-backed review of a PR, branch, range, or local diff, with taint pass and per-domain expert lenses
|
||||
- **LFG** (`/gitnexus-lfg`) — the full pipeline: plan → user gate → work → review
|
||||
|
||||
**Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-<name>/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current.
|
||||
|
||||
|
|
|
|||
267
eval/tests/test_ce_plugin_runtime.py
Normal file
267
eval/tests/test_ce_plugin_runtime.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""Security and provenance contracts for the CE comparator plugin runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import runner, runner_sessions, runtime_mounts
|
||||
from workflow_bench.process_control import ManagedProcessResult
|
||||
from workflow_bench.proposer_sandbox import SandboxError, prepare_sandbox
|
||||
from workflow_bench.runtime_mounts import (
|
||||
CE_PLUGIN_MANIFEST_SCHEMA_VERSION,
|
||||
SANDBOX_CE_PLUGIN,
|
||||
ce_plugin_dir_for_arm,
|
||||
ce_plugin_mounts_for_arm,
|
||||
staged_ce_plugin_snapshot,
|
||||
validate_ce_plugin_inputs,
|
||||
)
|
||||
|
||||
PLUGIN_VERSION = "3.19.0"
|
||||
|
||||
|
||||
def make_plugin(root: Path, *, version: str = PLUGIN_VERSION, noise: bool = False) -> Path:
|
||||
manifest_dir = root / ".claude-plugin"
|
||||
manifest_dir.mkdir(parents=True)
|
||||
(manifest_dir / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "compound-engineering",
|
||||
"version": version,
|
||||
"description": "Comparator canary",
|
||||
"author": {"name": "GitNexus tests"},
|
||||
}
|
||||
)
|
||||
)
|
||||
for name in ("ce-plan", "ce-work", "ce-code-review"):
|
||||
skill = root / "skills" / name / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text(f"---\nname: {name}\ndescription: Comparator canary\n---\n\n# {name}\n")
|
||||
script = root / "scripts" / "helper.sh"
|
||||
script.parent.mkdir()
|
||||
script.write_text("#!/bin/sh\nexit 0\n")
|
||||
script.chmod(0o755)
|
||||
asset = root / "assets" / "icon.txt"
|
||||
asset.parent.mkdir()
|
||||
asset.write_text("icon\n")
|
||||
|
||||
if noise:
|
||||
(manifest_dir / "CHANGELOG.md").write_text("not runtime input\n")
|
||||
for relative in (".git/config", "tests/test_plugin.py", "docs/notes.md", "src/internal.py"):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("excluded\n")
|
||||
for relative in (".env", ".npmrc", "skills/ce-plan/api-token.txt"):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("super-secret\n")
|
||||
return root
|
||||
|
||||
|
||||
def test_parser_exposes_explicit_ce_plugin_inputs(tmp_path: Path) -> None:
|
||||
args = runner.build_parser().parse_args(
|
||||
[
|
||||
"--tasks",
|
||||
str(tmp_path / "tasks.yaml"),
|
||||
"--model",
|
||||
"claude-sonnet-4-20250514",
|
||||
"--arms",
|
||||
"ce_workflow",
|
||||
"--ce-plugin-dir",
|
||||
str(tmp_path / "plugin"),
|
||||
"--ce-plugin-version",
|
||||
PLUGIN_VERSION,
|
||||
]
|
||||
)
|
||||
assert args.ce_plugin_dir == tmp_path / "plugin"
|
||||
assert args.ce_plugin_version == PLUGIN_VERSION
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arms", "plugin_dir", "version", "message"),
|
||||
[
|
||||
(("ce_workflow",), None, None, "require both"),
|
||||
(("ce_review",), Path("plugin"), None, "require both"),
|
||||
(("baseline",), Path("plugin"), PLUGIN_VERSION, "require at least one"),
|
||||
(("ce_workflow_direct",), Path("plugin"), "latest", "exact semantic version"),
|
||||
(("ce_workflow_direct",), Path("plugin"), "^3.19.0", "exact semantic version"),
|
||||
],
|
||||
)
|
||||
def test_ce_plugin_preflight_rejects_unpinned_or_misapplied_inputs(
|
||||
arms: tuple[str, ...],
|
||||
plugin_dir: Path | None,
|
||||
version: str | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
with pytest.raises((ValueError, SandboxError), match=message):
|
||||
validate_ce_plugin_inputs(arms, plugin_dir, version)
|
||||
|
||||
|
||||
def test_ce_plugin_preflight_accepts_only_matching_explicit_source(tmp_path: Path) -> None:
|
||||
source = make_plugin(tmp_path / "plugin")
|
||||
config = validate_ce_plugin_inputs(("baseline", "ce_review"), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
assert config.source == source
|
||||
assert config.version == PLUGIN_VERSION
|
||||
assert validate_ce_plugin_inputs(("baseline",), None, None) is None
|
||||
|
||||
|
||||
def test_ce_plugin_snapshot_is_exact_bounded_and_secret_free(tmp_path: Path) -> None:
|
||||
source = make_plugin(tmp_path / "operator-plugin", noise=True)
|
||||
config = validate_ce_plugin_inputs(("ce_review",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path) as first:
|
||||
assert first is not None
|
||||
expected = {
|
||||
".claude-plugin/plugin.json",
|
||||
"skills/ce-plan/SKILL.md",
|
||||
"skills/ce-work/SKILL.md",
|
||||
"skills/ce-code-review/SKILL.md",
|
||||
"scripts/helper.sh",
|
||||
"assets/icon.txt",
|
||||
}
|
||||
actual = {path.relative_to(first.root).as_posix() for path in first.root.rglob("*") if path.is_file()}
|
||||
assert actual == expected
|
||||
assert first.root != source
|
||||
assert first.mount.target == SANDBOX_CE_PLUGIN
|
||||
assert first.mount.source == first.root
|
||||
assert first.provenance == {
|
||||
"name": "compound-engineering",
|
||||
"version": PLUGIN_VERSION,
|
||||
"manifest_schema_version": CE_PLUGIN_MANIFEST_SCHEMA_VERSION,
|
||||
"manifest_digest": first.manifest_digest,
|
||||
"file_count": len(expected),
|
||||
"total_bytes": first.total_bytes,
|
||||
}
|
||||
assert all(not path.is_symlink() for path in first.root.rglob("*"))
|
||||
assert all(not (path.stat().st_mode & stat.S_IWUSR) for path in first.root.rglob("*"))
|
||||
first_digest = first.manifest_digest
|
||||
assert not first.root.exists()
|
||||
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path) as second:
|
||||
assert second is not None
|
||||
assert second.manifest_digest == first_digest
|
||||
|
||||
|
||||
def test_ce_plugin_snapshot_rejects_version_drift_and_symlinks(tmp_path: Path) -> None:
|
||||
source = make_plugin(tmp_path / "plugin", version="3.18.0")
|
||||
config = validate_ce_plugin_inputs(("ce_workflow",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
with pytest.raises(SandboxError, match="version mismatch"):
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path):
|
||||
pass
|
||||
|
||||
source = make_plugin(tmp_path / "symlinked-plugin")
|
||||
target = source / "real-reference.md"
|
||||
target.write_text("reference\n")
|
||||
(source / "skills" / "ce-plan" / "linked.md").symlink_to(target)
|
||||
config = validate_ce_plugin_inputs(("ce_workflow",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
with pytest.raises(SandboxError, match="must not be symlinks"):
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_ce_plugin_snapshot_enforces_total_byte_bound(monkeypatch, tmp_path: Path) -> None:
|
||||
source = make_plugin(tmp_path / "plugin")
|
||||
config = validate_ce_plugin_inputs(("ce_review",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
monkeypatch.setattr(runtime_mounts, "MAX_CE_PLUGIN_TOTAL_BYTES", 1)
|
||||
with pytest.raises(SandboxError, match="total byte limit"):
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path):
|
||||
pass
|
||||
|
||||
|
||||
def test_ce_plugin_mount_and_flag_are_ce_arm_only(tmp_path: Path) -> None:
|
||||
source = make_plugin(tmp_path / "plugin")
|
||||
config = validate_ce_plugin_inputs(("ce_review",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path) as snapshot:
|
||||
assert snapshot is not None
|
||||
assert ce_plugin_mounts_for_arm("ce_review", snapshot) == (snapshot.mount,)
|
||||
assert ce_plugin_dir_for_arm("ce_review", snapshot) == SANDBOX_CE_PLUGIN
|
||||
assert ce_plugin_mounts_for_arm("review", snapshot) == ()
|
||||
assert ce_plugin_dir_for_arm("review", snapshot) is None
|
||||
with pytest.raises(SandboxError, match="no staged"):
|
||||
ce_plugin_mounts_for_arm("ce_workflow", None)
|
||||
|
||||
|
||||
def _valid_cli_result() -> ManagedProcessResult:
|
||||
report = json.dumps(
|
||||
{
|
||||
"session_id": "session",
|
||||
"num_turns": 1,
|
||||
"total_cost_usd": 0,
|
||||
"duration_ms": 1,
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
return ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=0,
|
||||
stdout_tail=report,
|
||||
stderr_tail="",
|
||||
duration_s=0.001,
|
||||
)
|
||||
|
||||
|
||||
def test_run_claude_passes_plugin_dir_explicitly_under_bare(monkeypatch, tmp_path: Path) -> None:
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
commands.append(command)
|
||||
return _valid_cli_result()
|
||||
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
|
||||
runner.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
bare=True,
|
||||
plugin_dirs=(SANDBOX_CE_PLUGIN,),
|
||||
)
|
||||
runner.run_claude("task", tmp_path, claude_bin="claude", timeout=5, bare=True)
|
||||
|
||||
assert "--bare" in commands[0]
|
||||
assert commands[0][commands[0].index("--plugin-dir") + 1] == SANDBOX_CE_PLUGIN
|
||||
assert "--plugin-dir" not in commands[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1",
|
||||
reason="real Bubblewrap/Claude plugin canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_claude_strictly_validates_staged_plugin(tmp_path: Path) -> None:
|
||||
"""Validate plugin discovery under Bubblewrap without contacting a model."""
|
||||
|
||||
claude = Path(os.environ["CLAUDE_CANARY_BIN"]).resolve()
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
source = make_plugin(tmp_path / "plugin")
|
||||
config = validate_ce_plugin_inputs(("ce_review",), source, PLUGIN_VERSION)
|
||||
assert config is not None
|
||||
|
||||
with staged_ce_plugin_snapshot(config, destination_parent=tmp_path) as snapshot:
|
||||
assert snapshot is not None
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=claude,
|
||||
read_only_mounts=ce_plugin_mounts_for_arm("ce_review", snapshot),
|
||||
preflight=True,
|
||||
) as sandbox:
|
||||
result = sandbox.run(
|
||||
[sandbox.claude_bin, "plugin", "validate", "--strict", SANDBOX_CE_PLUGIN],
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
assert result.ok, result.stderr_tail
|
||||
772
eval/tests/test_evolve.py
Normal file
772
eval/tests/test_evolve.py
Normal file
|
|
@ -0,0 +1,772 @@
|
|||
"""Unit tests for the pure evidence/apply/driver helpers of workflow_bench.evolve."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import evolve
|
||||
from workflow_bench.runner_sessions import PARENT_EVENT_STREAM_SOURCE
|
||||
from workflow_bench.evolve import (
|
||||
build_parser,
|
||||
build_proposer_prompt,
|
||||
generation_timeout_seconds,
|
||||
load_jsonl,
|
||||
proposer_evidence_entries,
|
||||
read_learnings,
|
||||
resolve_incumbent_arms,
|
||||
runner_argv,
|
||||
select_evidence,
|
||||
summarize_gate,
|
||||
validate_promotion_for_apply,
|
||||
)
|
||||
from workflow_bench.process_control import run_managed
|
||||
from workflow_bench.proposer_sandbox import pid_namespace_command, preflight_bubblewrap
|
||||
|
||||
|
||||
def row(**overrides):
|
||||
base = {
|
||||
"task": "demo-task",
|
||||
"class": "trivial",
|
||||
"arm": "workflow",
|
||||
"run": 0,
|
||||
"resolved": True,
|
||||
"error_kind": None,
|
||||
"cost_usd": 1.0,
|
||||
"num_turns": 10,
|
||||
"output_tokens": 400,
|
||||
"session_ids": ["sess-1"],
|
||||
"verify_output": "ok",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_select_evidence_puts_unresolved_before_expensive_resolved():
|
||||
rows = [
|
||||
row(task="cheap", resolved=True, cost_usd=0.5),
|
||||
row(task="fail", resolved=False, error_kind="verify-failed"),
|
||||
row(task="pricey", resolved=True, cost_usd=9.0),
|
||||
]
|
||||
picked = select_evidence(rows)
|
||||
assert [r["task"] for r in picked] == ["fail", "pricey", "cheap"]
|
||||
|
||||
|
||||
def test_select_evidence_excludes_infra_error_rows_and_caps():
|
||||
rows = [
|
||||
row(task="harness-died", resolved=False, error_kind="infra-error"),
|
||||
row(task="session-died", resolved=False, error_kind="session-error"),
|
||||
row(
|
||||
task="missing-transcript",
|
||||
resolved=False,
|
||||
error_kind="evidence-unverified",
|
||||
),
|
||||
]
|
||||
rows += [row(task=f"t{i}", cost_usd=float(i)) for i in range(20)]
|
||||
picked = select_evidence(rows, max_rows=5)
|
||||
assert len(picked) == 5
|
||||
assert all(r["error_kind"] != "infra-error" for r in picked)
|
||||
assert [r["task"] for r in picked] == ["t19", "t18", "t17", "t16", "t15"]
|
||||
|
||||
|
||||
def test_select_evidence_tolerates_an_explicit_null_cost():
|
||||
# A foreign --seed-results row (e.g. hand-edited or from another tool)
|
||||
# can carry an explicit JSON null rather than omitting the key; .get's
|
||||
# default only covers the missing-key case, so this must not raise.
|
||||
rows = [row(task="no-cost", resolved=True, cost_usd=None), row(task="priced", cost_usd=5.0)]
|
||||
picked = select_evidence(rows)
|
||||
assert [r["task"] for r in picked] == ["priced", "no-cost"]
|
||||
|
||||
|
||||
def test_load_jsonl_skips_blank_and_malformed_lines(tmp_path):
|
||||
path = tmp_path / "learnings.jsonl"
|
||||
path.write_text('{"skill": "gitnexus-plan"}\n\nnot json\n[1, 2]\n{"skill": "gitnexus-work"}\n')
|
||||
assert load_jsonl(path) == [{"skill": "gitnexus-plan"}, {"skill": "gitnexus-work"}]
|
||||
|
||||
|
||||
def test_load_jsonl_missing_file_is_empty(tmp_path):
|
||||
assert load_jsonl(tmp_path / "absent.jsonl") == []
|
||||
|
||||
|
||||
def test_read_learnings_keeps_the_most_recent_entries(tmp_path):
|
||||
path = tmp_path / "learnings.jsonl"
|
||||
rows = [{"skill": "gitnexus-work", "n": i} for i in range(10)] + [
|
||||
{"skill": "gitnexus-review", "n": 10},
|
||||
{"skill": "gitnexus-lfg", "n": 11},
|
||||
]
|
||||
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
|
||||
assert read_learnings(path, cap=3) == [
|
||||
{"skill": "gitnexus-work", "n": 7},
|
||||
{"skill": "gitnexus-work", "n": 8},
|
||||
{"skill": "gitnexus-work", "n": 9},
|
||||
]
|
||||
|
||||
|
||||
def test_summarize_gate_one_line_per_decision():
|
||||
promotion = {
|
||||
"decisions": [
|
||||
{
|
||||
"candidate_arm": "candidate_workflow",
|
||||
"decision": "keep_incumbent",
|
||||
"reasons": ["a", "b", "c", "d"],
|
||||
}
|
||||
]
|
||||
}
|
||||
lines = summarize_gate(promotion)
|
||||
assert lines == ["candidate_workflow: keep_incumbent — a; b; c"]
|
||||
|
||||
|
||||
def test_build_proposer_prompt_carries_evidence_constraints_and_paths(tmp_path):
|
||||
prompt = build_proposer_prompt(
|
||||
results_dir=tmp_path / "bench",
|
||||
evidence=[row(task="fail", resolved=False, error_kind="verify-failed")],
|
||||
learnings=[{"skill": "gitnexus-work", "friction": "budget blown on reruns"}],
|
||||
gate_summary=["candidate_workflow: keep_incumbent — quality regressed"],
|
||||
overlay_dir=tmp_path / "overlay",
|
||||
proposal_path=tmp_path / "proposal.md",
|
||||
incumbent_arms=["workflow"],
|
||||
)
|
||||
assert str(tmp_path / "overlay") in prompt
|
||||
assert str(tmp_path / "proposal.md") in prompt
|
||||
assert "gitnexus-plan, gitnexus-work" in prompt
|
||||
assert "node .gitnexus/run.cjs analyze" in prompt
|
||||
assert "1 row(s) in /evidence/learnings.json" in prompt
|
||||
assert "1 selected row(s) in /evidence/selected-rows.json" in prompt
|
||||
assert "1 decision(s) in /evidence/gate-summary.json" in prompt
|
||||
assert "budget blown on reruns" not in prompt
|
||||
assert "verify-failed" not in prompt
|
||||
assert "~/.claude/projects" not in prompt
|
||||
|
||||
|
||||
def test_build_proposer_prompt_first_generation_has_no_results_dir(tmp_path):
|
||||
prompt = build_proposer_prompt(
|
||||
results_dir=None,
|
||||
evidence=[],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
overlay_dir=tmp_path / "overlay",
|
||||
proposal_path=tmp_path / "proposal.md",
|
||||
incumbent_arms=["workflow_direct"],
|
||||
)
|
||||
assert "none (first generation)" in prompt
|
||||
assert "none yet — use the incumbent skills and staged learning queue" in prompt
|
||||
|
||||
|
||||
def test_proposer_reads_only_digest_bound_transcripts_below_results(tmp_path, monkeypatch):
|
||||
results = tmp_path / "results"
|
||||
transcripts = results / "transcripts"
|
||||
transcripts.mkdir(parents=True, mode=0o700)
|
||||
transcripts.chmod(0o700)
|
||||
payload = b'{"message":{"content":[{"type":"text","text":"bound transcript"}]}}\n'
|
||||
artifact = transcripts / "task-workflow-run0-session.jsonl"
|
||||
artifact.write_bytes(payload)
|
||||
artifact.chmod(0o600)
|
||||
metadata = {
|
||||
"path": "transcripts/task-workflow-run0-session.jsonl",
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"bytes": len(payload),
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
foreign_home = tmp_path / "foreign-home"
|
||||
foreign = foreign_home / ".claude" / "projects" / "other" / "private.jsonl"
|
||||
foreign.parent.mkdir(parents=True)
|
||||
foreign.write_text("foreign host transcript")
|
||||
monkeypatch.setenv("HOME", str(foreign_home))
|
||||
|
||||
entries = proposer_evidence_entries(
|
||||
results_dir=results,
|
||||
evidence=[row(session_ids=["**/*"], transcript_artifacts=[metadata])],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
assert entries["transcript-0-0.jsonl"] == payload.decode()
|
||||
assert "foreign host transcript" not in json.dumps(entries)
|
||||
|
||||
bad_digest = {**metadata, "sha256": "0" * 64}
|
||||
with pytest.raises(evolve.SandboxError, match="digest does not match"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=results,
|
||||
evidence=[row(transcript_artifacts=[bad_digest])],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="transcript symlink containment is POSIX-only")
|
||||
def test_proposer_rejects_symlink_and_foreign_transcript_artifacts(tmp_path):
|
||||
results = tmp_path / "results"
|
||||
transcripts = results / "transcripts"
|
||||
transcripts.mkdir(parents=True, mode=0o700)
|
||||
transcripts.chmod(0o700)
|
||||
outside = tmp_path / "outside.jsonl"
|
||||
outside.write_text("outside")
|
||||
linked = transcripts / "linked.jsonl"
|
||||
linked.symlink_to(outside)
|
||||
link_metadata = {
|
||||
"path": "transcripts/linked.jsonl",
|
||||
"sha256": hashlib.sha256(outside.read_bytes()).hexdigest(),
|
||||
"bytes": outside.stat().st_size,
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
|
||||
with pytest.raises(evolve.SandboxError, match="regular non-symlink"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=results,
|
||||
evidence=[row(transcript_artifacts=[link_metadata])],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
with pytest.raises(evolve.SandboxError, match="unsafe results artifact path"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=results,
|
||||
evidence=[
|
||||
row(
|
||||
transcript_artifacts=[
|
||||
{
|
||||
"path": "../outside.jsonl",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 0,
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
|
||||
def test_proposer_rejects_duplicate_transcript_metadata_before_materializing():
|
||||
metadata = {
|
||||
"path": "transcripts/repeated.jsonl",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 0,
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
|
||||
with pytest.raises(evolve.SandboxError, match="duplicate transcript artifact path"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=None,
|
||||
evidence=[
|
||||
row(
|
||||
transcript_artifacts=[
|
||||
metadata,
|
||||
{**metadata, "path": "transcripts//repeated.jsonl"},
|
||||
]
|
||||
)
|
||||
],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
|
||||
def test_proposer_bounds_transcript_metadata_per_row_and_globally_before_materializing():
|
||||
def metadata(index):
|
||||
return {
|
||||
"path": f"transcripts/session-{index}.jsonl",
|
||||
"sha256": "0" * 64,
|
||||
"bytes": 0,
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
|
||||
with pytest.raises(evolve.SandboxError, match="per-row session limit"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=None,
|
||||
evidence=[row(transcript_artifacts=[metadata(index) for index in range(3)])],
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
rows = [
|
||||
row(
|
||||
run=index,
|
||||
transcript_artifacts=[metadata(2 * index), metadata(2 * index + 1)],
|
||||
)
|
||||
for index in range(evolve.MAX_EVIDENCE_ROWS + 1)
|
||||
]
|
||||
with pytest.raises(evolve.SandboxError, match="global evidence limit"):
|
||||
proposer_evidence_entries(
|
||||
results_dir=None,
|
||||
evidence=rows,
|
||||
learnings=[],
|
||||
gate_summary=[],
|
||||
)
|
||||
|
||||
|
||||
def test_parser_defaults_match_the_gate_minimums():
|
||||
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
|
||||
assert args.runs == 3
|
||||
assert args.generations == 1
|
||||
assert args.arms is None
|
||||
assert args.apply is False
|
||||
assert args.learnings.name == "learnings.jsonl"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[
|
||||
["--model", "Auto"],
|
||||
["--model", "provider/latest"],
|
||||
["--model", "pinned-model", "--proposer-model", "vendor@LATEST"],
|
||||
],
|
||||
)
|
||||
def test_evolve_rejects_mutable_model_aliases(monkeypatch, tmp_path, capsys, arguments):
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["workflow_bench.evolve", "--tasks", str(tmp_path / "missing.yaml"), *arguments],
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
evolve.main()
|
||||
assert "mutable auto/latest" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_evolve_proposer_failure_returns_nonzero(monkeypatch, tmp_path):
|
||||
tasks = tmp_path / "tasks.yaml"
|
||||
tasks.write_text(
|
||||
"""tasks:
|
||||
- id: demo
|
||||
class: test
|
||||
repo: .
|
||||
prompt: implement
|
||||
verify: "true"
|
||||
oracle:
|
||||
command: "true"
|
||||
files:
|
||||
- source: hidden.test.ts
|
||||
target: hidden.test.ts
|
||||
"""
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"workflow_bench.evolve",
|
||||
"--tasks",
|
||||
str(tasks),
|
||||
"--model",
|
||||
"pinned-model",
|
||||
"--out-root",
|
||||
str(tmp_path / "out"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(evolve.runner, "selected_task_bindings", lambda _tasks: [{"id": "demo"}])
|
||||
monkeypatch.setattr(evolve, "preflight_bubblewrap", lambda: tmp_path / "bwrap")
|
||||
monkeypatch.setattr(evolve, "require_claude_sandbox_helpers", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
evolve,
|
||||
"run_proposer",
|
||||
lambda *args, **kwargs: {"ok": False, "error_detail": "proposer failed"},
|
||||
)
|
||||
|
||||
assert evolve.main() == 1
|
||||
|
||||
|
||||
def test_runner_argv_pairs_each_incumbent_with_its_candidate(tmp_path):
|
||||
args = build_parser().parse_args(
|
||||
[
|
||||
"--tasks",
|
||||
"t.yaml",
|
||||
"--model",
|
||||
"pinned",
|
||||
"--arms",
|
||||
"workflow",
|
||||
"--include-expensive",
|
||||
]
|
||||
)
|
||||
overlay = tmp_path / "overlay"
|
||||
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text("candidate")
|
||||
task_bindings = [{"id": "task", "resolved_sha": "a" * 40}]
|
||||
target_bases = {".claude/skills/gitnexus-plan/SKILL.md": "b" * 64}
|
||||
argv = runner_argv(
|
||||
args,
|
||||
tmp_path / "bench",
|
||||
overlay,
|
||||
task_bindings=task_bindings,
|
||||
target_base_digests=target_bases,
|
||||
proposer_model="pinned",
|
||||
)
|
||||
arms = argv[argv.index("--arms") + 1 : argv.index("--promotion-metric")]
|
||||
assert arms == ["workflow", "candidate_workflow"]
|
||||
assert str(overlay) in argv
|
||||
assert str(tmp_path / "bench") in argv
|
||||
assert "pinned" in argv
|
||||
assert argv[argv.index("--proposer-model") + 1] == "pinned"
|
||||
assert "--include-expensive" in argv
|
||||
assert json.loads(argv[argv.index("--task-bindings-json") + 1]) == task_bindings
|
||||
assert json.loads(argv[argv.index("--promotion-target-bases-json") + 1]) == target_bases
|
||||
|
||||
|
||||
def test_runner_argv_omits_proposer_for_manual_overlay(tmp_path):
|
||||
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
|
||||
overlay = tmp_path / "overlay"
|
||||
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text("candidate")
|
||||
|
||||
argv = runner_argv(
|
||||
args,
|
||||
tmp_path / "bench",
|
||||
overlay,
|
||||
task_bindings=[{"id": "task"}],
|
||||
target_base_digests={},
|
||||
proposer_model=None,
|
||||
)
|
||||
|
||||
assert "--proposer-model" not in argv
|
||||
|
||||
|
||||
def test_runner_argv_keeps_task_commit_pinned_when_ref_moves(tmp_path):
|
||||
repo = tmp_path / "task-repo"
|
||||
repo.mkdir()
|
||||
|
||||
def git(*arguments):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "Workflow Bench Test")
|
||||
git("config", "user.email", "workflow-bench@example.invalid")
|
||||
tracked = repo / "tracked.txt"
|
||||
tracked.write_text("one")
|
||||
git("add", "tracked.txt")
|
||||
git("commit", "-m", "first")
|
||||
first_sha = git("rev-parse", "HEAD")
|
||||
task = {
|
||||
"id": "moving-ref",
|
||||
"class": "test",
|
||||
"repo": str(repo),
|
||||
"ref": "main",
|
||||
"prompt": "test prompt",
|
||||
"verify": "true",
|
||||
"oracle": {
|
||||
"command": "true",
|
||||
"files": [
|
||||
{
|
||||
"source": "trivial-version-alias.oracle.test.ts",
|
||||
"target": "oracle.test.ts",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
bindings = evolve.runner.selected_task_bindings([task])
|
||||
|
||||
tracked.write_text("two")
|
||||
git("commit", "-am", "second")
|
||||
assert git("rev-parse", "main") != first_sha
|
||||
|
||||
args = build_parser().parse_args(["--tasks", "t.yaml", "--model", "pinned"])
|
||||
overlay = tmp_path / "overlay"
|
||||
skill = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text("candidate")
|
||||
argv = runner_argv(
|
||||
args,
|
||||
tmp_path / "bench",
|
||||
overlay,
|
||||
task_bindings=bindings,
|
||||
target_base_digests={},
|
||||
)
|
||||
forwarded = json.loads(argv[argv.index("--task-bindings-json") + 1])
|
||||
|
||||
assert forwarded[0]["resolved_sha"] == first_sha
|
||||
assert evolve.runner.resolve_task_bindings([task], forwarded)[0]["resolved_sha"] == first_sha
|
||||
|
||||
|
||||
def test_generation_timeout_budgets_three_task_workflow_pair():
|
||||
timeout = generation_timeout_seconds(
|
||||
task_count=3,
|
||||
runs=3,
|
||||
session_timeout=3600,
|
||||
incumbent_arms=["workflow"],
|
||||
)
|
||||
|
||||
per_task_preparation = (
|
||||
evolve.TASK_BINDING_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
|
||||
+ 2 * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
|
||||
+ evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
|
||||
+ evolve.GRAPH_SOURCE_PREPARATION_TIMEOUT_SECONDS
|
||||
+ evolve.GRAPH_BUILD_TIMEOUT_SECONDS
|
||||
+ 2 * evolve.GRAPH_QUERY_TIMEOUT_SECONDS
|
||||
+ evolve.CLEANUP_TIMEOUT_SECONDS
|
||||
)
|
||||
paired_arm_cells = 2
|
||||
session_slots = 4
|
||||
workspace_snapshot_slots = 4
|
||||
per_task_run = session_slots * (3600 + evolve.SESSION_FINALIZATION_TIMEOUT_SECONDS) + paired_arm_cells * (
|
||||
evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
|
||||
+ evolve.ARM_ASSET_MATERIALIZATION_PHASES * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
|
||||
+ evolve.SETUP_TIMEOUT_SECONDS
|
||||
+ 2 * 3600
|
||||
+ evolve.ARM_EVIDENCE_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
|
||||
+ evolve.CLEANUP_TIMEOUT_SECONDS
|
||||
)
|
||||
per_task_run += workspace_snapshot_slots * evolve.TASK_SNAPSHOT_TIMEOUT_SECONDS
|
||||
per_task_run += evolve.CANDIDATE_OVERLAY_GIT_PHASES * evolve.GIT_COMMAND_TIMEOUT_SECONDS
|
||||
|
||||
assert timeout == (
|
||||
evolve.PROMOTION_BASE_TIMEOUT_SECONDS
|
||||
+ 3 * (per_task_preparation + 3 * per_task_run)
|
||||
+ evolve.DRIVER_OVERHEAD_SECONDS
|
||||
)
|
||||
# The old deadline omitted clone sanitization entirely. Every graph seed
|
||||
# and every paired arm cell must now receive the full bounded envelope.
|
||||
assert timeout >= 3 * (1 + 3 * paired_arm_cells) * evolve.WORKTREE_PREPARATION_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Bubblewrap PID namespaces require Linux")
|
||||
def test_outer_runner_pid_namespace_kills_setsid_descendant(tmp_path):
|
||||
try:
|
||||
bwrap = preflight_bubblewrap()
|
||||
except evolve.SandboxError as exc:
|
||||
pytest.skip(str(exc))
|
||||
raise AssertionError("pytest.skip() returned unexpectedly")
|
||||
sentinel = tmp_path / "escaped"
|
||||
child = (
|
||||
"import os,subprocess,sys,time; "
|
||||
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"],preexec_fn=os.setsid); "
|
||||
"time.sleep(10)"
|
||||
)
|
||||
result = run_managed(
|
||||
pid_namespace_command([sys.executable, "-c", child], bwrap_bin=bwrap),
|
||||
timeout=0.15,
|
||||
terminate_grace=0.1,
|
||||
require_pid_namespace=True,
|
||||
)
|
||||
time.sleep(1.1)
|
||||
|
||||
assert not result.ok
|
||||
assert result.state in {"timeout", "forced-kill"}
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
def test_resolve_incumbent_arms_rejects_incomplete_and_extra_explicit_sets(tmp_path):
|
||||
plan = tmp_path / "plan"
|
||||
plan_skill = plan / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
plan_skill.parent.mkdir(parents=True)
|
||||
plan_skill.write_text("plan")
|
||||
assert resolve_incumbent_arms(plan, None) == ["workflow"]
|
||||
with pytest.raises(ValueError, match="exactly"):
|
||||
resolve_incumbent_arms(plan, ["workflow", "workflow_direct"])
|
||||
|
||||
work = tmp_path / "work"
|
||||
work_skill = work / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
|
||||
work_skill.parent.mkdir(parents=True)
|
||||
work_skill.write_text("work")
|
||||
assert resolve_incumbent_arms(work, None) == ["workflow", "workflow_direct"]
|
||||
with pytest.raises(ValueError, match="exactly"):
|
||||
resolve_incumbent_arms(work, ["workflow"])
|
||||
|
||||
|
||||
def bound_task_fixture():
|
||||
return {
|
||||
"id": "task",
|
||||
"prompt_digest": "prompt",
|
||||
"oracle_digest": "a" * 64,
|
||||
"oracle_command_digest": "b" * 64,
|
||||
"oracle_manifest_digest": "c" * 64,
|
||||
"sandbox_dependency_content_digest": "e" * 64,
|
||||
"sandbox_dependency_manifest_digest": "f" * 64,
|
||||
"oracle_files": [{"target": "oracle.test.ts", "sha256": "d" * 64, "size": 10}],
|
||||
}
|
||||
|
||||
|
||||
def promotion_fixture(*, decisions=None, expires_delta=timedelta(days=1)):
|
||||
now = datetime.now(UTC)
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"generated_at": now.isoformat(),
|
||||
"evidence_expires_at": (now + expires_delta).isoformat(),
|
||||
"benchmark_model": "bench-model",
|
||||
"proposer_model": "proposer-model",
|
||||
"candidate_origin": "model-proposer",
|
||||
"candidate_overlay_digest": "digest",
|
||||
"target_base_digests": {"path": "base"},
|
||||
"required_candidate_arms": ["candidate_workflow"],
|
||||
"selected_tasks": [bound_task_fixture()],
|
||||
"policy": {
|
||||
"metric": "cost_usd",
|
||||
"min_runs": 3,
|
||||
"min_improvement_pct": 5.0,
|
||||
"max_task_regression_pct": 20.0,
|
||||
},
|
||||
"decisions": (
|
||||
decisions
|
||||
if decisions is not None
|
||||
else [
|
||||
{
|
||||
"incumbent_arm": "workflow",
|
||||
"candidate_arm": "candidate_workflow",
|
||||
"decision": "promote",
|
||||
"metric": "cost_usd",
|
||||
}
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def validate_fixture(promotion):
|
||||
return validate_promotion_for_apply(
|
||||
promotion,
|
||||
overlay_digest="digest",
|
||||
benchmark_model="bench-model",
|
||||
proposer_model="proposer-model",
|
||||
selected_tasks=[bound_task_fixture()],
|
||||
target_base_digests={"path": "base"},
|
||||
required_candidate_arms=["candidate_workflow"],
|
||||
policy={
|
||||
"metric": "cost_usd",
|
||||
"min_runs": 3,
|
||||
"min_improvement_pct": 5.0,
|
||||
"max_task_regression_pct": 20.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_promotion_apply_requires_one_promote_for_every_bound_arm():
|
||||
assert [d["candidate_arm"] for d in validate_fixture(promotion_fixture())] == ["candidate_workflow"]
|
||||
|
||||
for decisions in (
|
||||
[],
|
||||
[
|
||||
{
|
||||
"incumbent_arm": "workflow",
|
||||
"candidate_arm": "candidate_workflow",
|
||||
"decision": "keep_incumbent",
|
||||
"metric": "cost_usd",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"incumbent_arm": "workflow",
|
||||
"candidate_arm": "candidate_workflow",
|
||||
"decision": "promote",
|
||||
"metric": "cost_usd",
|
||||
},
|
||||
{
|
||||
"incumbent_arm": "workflow",
|
||||
"candidate_arm": "candidate_workflow",
|
||||
"decision": "promote",
|
||||
"metric": "cost_usd",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
"incumbent_arm": "workflow_direct",
|
||||
"candidate_arm": "candidate_workflow_direct",
|
||||
"decision": "promote",
|
||||
"metric": "cost_usd",
|
||||
}
|
||||
],
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
validate_fixture(promotion_fixture(decisions=decisions))
|
||||
|
||||
|
||||
def test_manual_initial_overlay_has_no_fictitious_proposer_model():
|
||||
promotion = promotion_fixture()
|
||||
promotion["proposer_model"] = None
|
||||
promotion["candidate_origin"] = "manual-initial-overlay"
|
||||
|
||||
decisions = validate_promotion_for_apply(
|
||||
promotion,
|
||||
overlay_digest="digest",
|
||||
benchmark_model="bench-model",
|
||||
proposer_model=None,
|
||||
selected_tasks=[bound_task_fixture()],
|
||||
target_base_digests={"path": "base"},
|
||||
required_candidate_arms=["candidate_workflow"],
|
||||
policy=promotion["policy"],
|
||||
)
|
||||
|
||||
assert decisions[0]["decision"] == "promote"
|
||||
|
||||
|
||||
def test_promotion_apply_rejects_pre_oracle_schema_and_missing_oracle_bindings():
|
||||
legacy = promotion_fixture()
|
||||
legacy["schema_version"] = 2
|
||||
with pytest.raises(ValueError, match="unsupported schema"):
|
||||
validate_fixture(legacy)
|
||||
|
||||
weak_task = {"id": "task", "prompt_digest": "prompt"}
|
||||
weak = promotion_fixture()
|
||||
weak["selected_tasks"] = [weak_task]
|
||||
with pytest.raises(ValueError, match="hidden-oracle or dependency digests"):
|
||||
validate_promotion_for_apply(
|
||||
weak,
|
||||
overlay_digest="digest",
|
||||
benchmark_model="bench-model",
|
||||
proposer_model="proposer-model",
|
||||
selected_tasks=[weak_task],
|
||||
target_base_digests={"path": "base"},
|
||||
required_candidate_arms=["candidate_workflow"],
|
||||
policy={
|
||||
"metric": "cost_usd",
|
||||
"min_runs": 3,
|
||||
"min_improvement_pct": 5.0,
|
||||
"max_task_regression_pct": 20.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("benchmark_model", "other"),
|
||||
("proposer_model", "other"),
|
||||
("candidate_overlay_digest", "other"),
|
||||
("target_base_digests", {"path": "other"}),
|
||||
("required_candidate_arms", ["candidate_workflow_direct"]),
|
||||
("selected_tasks", [{"id": "other", "prompt_digest": "prompt"}]),
|
||||
(
|
||||
"policy",
|
||||
{
|
||||
"metric": "cost_usd",
|
||||
"min_runs": 4,
|
||||
"min_improvement_pct": 5.0,
|
||||
"max_task_regression_pct": 20.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_promotion_apply_rejects_mismatched_evidence_bindings(field, value):
|
||||
promotion = promotion_fixture()
|
||||
promotion[field] = value
|
||||
with pytest.raises(ValueError, match="binding"):
|
||||
validate_fixture(promotion)
|
||||
|
||||
|
||||
def test_promotion_apply_rejects_expired_evidence():
|
||||
with pytest.raises(ValueError, match="expired"):
|
||||
validate_fixture(promotion_fixture(expires_delta=timedelta(seconds=-1)))
|
||||
|
||||
|
||||
def test_promotion_apply_rejects_extended_or_future_dated_evidence():
|
||||
with pytest.raises(ValueError, match="expired"):
|
||||
validate_fixture(promotion_fixture(expires_delta=timedelta(days=91)))
|
||||
|
||||
promotion = promotion_fixture()
|
||||
future = datetime.now(UTC) + timedelta(days=1)
|
||||
promotion["generated_at"] = future.isoformat()
|
||||
promotion["evidence_expires_at"] = (future + timedelta(days=1)).isoformat()
|
||||
with pytest.raises(ValueError, match="future"):
|
||||
validate_fixture(promotion)
|
||||
|
||||
|
||||
def test_promotion_apply_rejects_decision_metric_mismatch():
|
||||
promotion = promotion_fixture()
|
||||
promotion["decisions"][0]["metric"] = "output_tokens"
|
||||
with pytest.raises(ValueError, match="metric mismatch"):
|
||||
validate_fixture(promotion)
|
||||
494
eval/tests/test_oracle_assets.py
Normal file
494
eval/tests/test_oracle_assets.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
"""Hidden-oracle capture, staging, and promotion-boundary regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import oracle_assets, runner
|
||||
from workflow_bench.evolution import evaluate_candidate
|
||||
from workflow_bench.oracle_assets import capture_task_oracle, staged_task_oracle
|
||||
|
||||
|
||||
def oracle_task(*, command: str = "true", source: str = "oracle.test.ts") -> dict[str, object]:
|
||||
return {
|
||||
"id": "hidden",
|
||||
"oracle": {
|
||||
"command": command,
|
||||
"files": [{"source": source, "target": "nested/oracle.test.ts"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_oracle(root: Path, payload: bytes = b"hidden behavior") -> None:
|
||||
root.mkdir()
|
||||
(root / "oracle.test.ts").write_bytes(payload)
|
||||
|
||||
|
||||
def session_record() -> dict[str, object]:
|
||||
return {
|
||||
"input_tokens": 1,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 1,
|
||||
"cost_usd": 0.1,
|
||||
"duration_s": 1.0,
|
||||
"num_turns": 1,
|
||||
"ok": True,
|
||||
"session_id": "s",
|
||||
"error_kind": None,
|
||||
"error_detail": None,
|
||||
}
|
||||
|
||||
|
||||
def bench_args() -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
model="pinned-model",
|
||||
base_url=None,
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
|
||||
def sandbox(tmp_path: Path) -> SimpleNamespace:
|
||||
calls: list[dict[str, object]] = []
|
||||
private_root = tmp_path / "sandbox-private"
|
||||
private_root.mkdir(exist_ok=True)
|
||||
instance = SimpleNamespace(
|
||||
claude_bin="claude",
|
||||
clone=tmp_path,
|
||||
private_root=private_root,
|
||||
command_prefix=[],
|
||||
command_prefix_calls=calls,
|
||||
settings_json="{}",
|
||||
transcript_projects=tmp_path / "transcripts",
|
||||
)
|
||||
instance.command_prefix_for = lambda **kwargs: calls.append(dict(kwargs)) or []
|
||||
return instance
|
||||
|
||||
|
||||
def test_capture_digest_binds_command_targets_and_raw_bytes(tmp_path: Path) -> None:
|
||||
root = tmp_path / "oracles"
|
||||
write_oracle(root)
|
||||
original = capture_task_oracle(oracle_task(), root=root)
|
||||
same = capture_task_oracle(oracle_task(), root=root)
|
||||
changed_command = capture_task_oracle(oracle_task(command="false"), root=root)
|
||||
(root / "oracle.test.ts").write_bytes(b"changed behavior")
|
||||
changed_bytes = capture_task_oracle(oracle_task(), root=root)
|
||||
|
||||
assert same == original
|
||||
assert changed_command.digest != original.digest
|
||||
assert changed_command.command_digest != original.command_digest
|
||||
assert changed_bytes.digest != original.digest
|
||||
assert changed_bytes.manifest_digest != original.manifest_digest
|
||||
assert original.binding["oracle_files"] == [
|
||||
{
|
||||
"target": "nested/oracle.test.ts",
|
||||
"sha256": hashlib.sha256(b"hidden behavior").hexdigest(),
|
||||
"size": len(b"hidden behavior"),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_clone_sanitization_prunes_harness_checkout_and_recoverable_history(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
pytest.fail(f"git {' '.join(args)} failed: {result.stderr}")
|
||||
return result
|
||||
|
||||
git(source, "init", "--quiet", "--initial-branch=main")
|
||||
git(source, "config", "user.name", "Oracle Test")
|
||||
git(source, "config", "user.email", "oracle-test.invalid")
|
||||
(source / "visible.txt").write_text("model-visible source\n")
|
||||
hidden = source / "eval" / "workflow_bench" / "oracles"
|
||||
hidden.mkdir(parents=True)
|
||||
(hidden / "secret.oracle.test.ts").write_text("unique hidden behavioral assertion\n")
|
||||
(source / "eval" / "workflow_bench" / "tasks.scenarios.yaml").write_text("secret command\n")
|
||||
git(source, "add", "--all")
|
||||
git(source, "commit", "--quiet", "-m", "fixture with hidden oracle")
|
||||
git(source, "tag", "oracle-backup")
|
||||
|
||||
clone = tmp_path / "clone"
|
||||
clone_result = subprocess.run(
|
||||
["git", "clone", "--no-local", "--no-hardlinks", "--quiet", str(source), str(clone)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert clone_result.returncode == 0, clone_result.stderr
|
||||
original_head = git(clone, "rev-parse", "HEAD").stdout.strip()
|
||||
hidden_tree = git(clone, "rev-parse", "HEAD:eval/workflow_bench").stdout.strip()
|
||||
|
||||
sanitized_head = oracle_assets.sanitize_clone_for_hidden_oracles(clone)
|
||||
|
||||
assert sanitized_head != original_head
|
||||
assert (clone / "visible.txt").read_text() == "model-visible source\n"
|
||||
assert not (clone / "eval" / "workflow_bench").exists()
|
||||
assert git(clone, "show", f"{original_head}:eval/workflow_bench/tasks.scenarios.yaml", check=False).returncode != 0
|
||||
assert git(clone, "cat-file", "-e", original_head, check=False).returncode != 0
|
||||
assert git(clone, "cat-file", "-e", hidden_tree, check=False).returncode != 0
|
||||
assert git(clone, "for-each-ref", "--format=%(refname)").stdout == ""
|
||||
assert git(clone, "show", "-s", "--format=%P", "HEAD").stdout.strip() == ""
|
||||
assert git(clone, "status", "--porcelain=v1", "--untracked-files=all").stdout == ""
|
||||
|
||||
|
||||
def test_clone_sanitization_prunes_remote_history_when_head_never_had_harness(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
pytest.fail(f"git {' '.join(args)} failed: {result.stderr}")
|
||||
return result
|
||||
|
||||
git(source, "init", "--quiet", "--initial-branch=main")
|
||||
git(source, "config", "user.name", "Oracle Test")
|
||||
git(source, "config", "user.email", "oracle-test.invalid")
|
||||
(source / "visible.txt").write_text("old task snapshot\n")
|
||||
git(source, "add", "--all")
|
||||
git(source, "commit", "--quiet", "-m", "old snapshot without harness")
|
||||
old_head = git(source, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
hidden = source / "eval" / "workflow_bench" / "oracles"
|
||||
hidden.mkdir(parents=True)
|
||||
secret = hidden / "future-secret.test.ts"
|
||||
secret.write_text("UNRECOVERABLE_REMOTE_ORACLE_BYTES\n")
|
||||
git(source, "add", "--all")
|
||||
git(source, "commit", "--quiet", "-m", "future remote-only oracle")
|
||||
future_head = git(source, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
sanitized_heads: list[str] = []
|
||||
for name in ("clone-one", "clone-two"):
|
||||
clone = tmp_path / name
|
||||
subprocess.run(
|
||||
["git", "clone", "--no-local", "--no-hardlinks", "--quiet", str(source), str(clone)],
|
||||
check=True,
|
||||
)
|
||||
git(clone, "checkout", "--detach", "--quiet", old_head)
|
||||
assert git(clone, "show", f"{future_head}:eval/workflow_bench/oracles/future-secret.test.ts").stdout == (
|
||||
"UNRECOVERABLE_REMOTE_ORACLE_BYTES\n"
|
||||
)
|
||||
|
||||
sanitized_heads.append(oracle_assets.sanitize_clone_for_hidden_oracles(clone))
|
||||
|
||||
assert git(clone, "remote").stdout == ""
|
||||
assert git(clone, "for-each-ref", "--format=%(refname)").stdout == ""
|
||||
assert git(clone, "cat-file", "-e", future_head, check=False).returncode != 0
|
||||
assert (
|
||||
git(
|
||||
clone, "show", f"{future_head}:eval/workflow_bench/oracles/future-secret.test.ts", check=False
|
||||
).returncode
|
||||
!= 0
|
||||
)
|
||||
assert git(clone, "fsck", "--full", "--no-reflogs", "--unreachable").stdout == ""
|
||||
assert git(clone, "show", "-s", "--format=%P", "HEAD").stdout.strip() == ""
|
||||
|
||||
assert sanitized_heads[0] == sanitized_heads[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink contract is POSIX-specific")
|
||||
def test_capture_rejects_symlinked_sources_and_parents(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "oracle.test.ts").write_text("secret")
|
||||
|
||||
source_link_root = tmp_path / "source-link-root"
|
||||
source_link_root.mkdir()
|
||||
(source_link_root / "oracle.test.ts").symlink_to(outside / "oracle.test.ts")
|
||||
with pytest.raises(ValueError, match="regular non-symlink"):
|
||||
capture_task_oracle(oracle_task(), root=source_link_root)
|
||||
|
||||
parent_link_root = tmp_path / "parent-link-root"
|
||||
parent_link_root.mkdir()
|
||||
(parent_link_root / "linked").symlink_to(outside, target_is_directory=True)
|
||||
task = oracle_task(source="linked/oracle.test.ts")
|
||||
with pytest.raises(ValueError, match="parents must be real"):
|
||||
capture_task_oracle(task, root=parent_link_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("constant", "value", "expected"),
|
||||
[
|
||||
("MAX_ORACLE_FILE_BYTES", 4, "bounded regular"),
|
||||
("MAX_ORACLE_TOTAL_BYTES", 4, "total byte limit"),
|
||||
("MAX_ORACLE_PATH_BYTES", 4, "bounded portable path"),
|
||||
],
|
||||
)
|
||||
def test_capture_enforces_file_total_and_path_bounds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
constant: str,
|
||||
value: int,
|
||||
expected: str,
|
||||
) -> None:
|
||||
root = tmp_path / "oracles"
|
||||
write_oracle(root, b"12345")
|
||||
monkeypatch.setattr(oracle_assets, constant, value)
|
||||
with pytest.raises(ValueError, match=expected):
|
||||
capture_task_oracle(oracle_task(), root=root)
|
||||
|
||||
|
||||
def test_oracle_is_staged_privately_then_removed_and_mutation_is_rejected(tmp_path: Path) -> None:
|
||||
source = tmp_path / "oracles"
|
||||
write_oracle(source)
|
||||
snapshot = capture_task_oracle(oracle_task(), root=source)
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
|
||||
with staged_task_oracle(worktree, snapshot) as stage:
|
||||
assert stage.parent == worktree
|
||||
assert stage.name.startswith(".wfbench-oracle-")
|
||||
staged = stage / "nested" / "oracle.test.ts"
|
||||
assert staged.read_bytes() == b"hidden behavior"
|
||||
assert not any(path.name == "oracle.test.ts" for path in worktree.iterdir())
|
||||
assert not list(worktree.glob(".wfbench-oracle-*"))
|
||||
|
||||
with pytest.raises(ValueError, match="changed during verification"):
|
||||
with staged_task_oracle(worktree, snapshot) as stage:
|
||||
staged = stage / "nested" / "oracle.test.ts"
|
||||
staged.chmod(0o600)
|
||||
staged.write_bytes(b"weakened")
|
||||
assert not list(worktree.glob(".wfbench-oracle-*"))
|
||||
|
||||
|
||||
def test_vacuous_authored_test_cannot_self_certify_resolution(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "oracles"
|
||||
write_oracle(source)
|
||||
snapshot = capture_task_oracle(oracle_task(), root=source)
|
||||
monkeypatch.setattr(runner, "run_claude", lambda *args, **kwargs: session_record())
|
||||
outcomes = iter([(True, "authored test passed"), (False, "hidden behavior failed")])
|
||||
monkeypatch.setattr(runner, "run_verify", lambda *args, **kwargs: next(outcomes))
|
||||
|
||||
record = runner.run_arm(
|
||||
"baseline",
|
||||
{"prompt": "implement behavior", "verify": "true"},
|
||||
tmp_path,
|
||||
bench_args(),
|
||||
sandbox=sandbox(tmp_path),
|
||||
oracle_snapshot=snapshot,
|
||||
)
|
||||
|
||||
assert record["authored_tests_passed"] is True
|
||||
assert record["oracle_passed"] is False
|
||||
assert record["resolved"] is False
|
||||
assert record["error_kind"] == "oracle-failed"
|
||||
|
||||
|
||||
def test_oracle_path_and_bytes_appear_only_after_the_model_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "oracles"
|
||||
write_oracle(source)
|
||||
snapshot = capture_task_oracle(oracle_task(), root=source)
|
||||
stages_seen: list[list[Path]] = []
|
||||
|
||||
def fake_session(*args, **kwargs):
|
||||
assert not list(tmp_path.glob(".wfbench-oracle-*"))
|
||||
assert b"hidden behavior" not in b"".join(path.read_bytes() for path in tmp_path.glob("*.test.ts"))
|
||||
return session_record()
|
||||
|
||||
def fake_verify(*args, **kwargs):
|
||||
stages_seen.append(list(tmp_path.glob(".wfbench-oracle-*")))
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setattr(runner, "run_claude", fake_session)
|
||||
monkeypatch.setattr(runner, "run_verify", fake_verify)
|
||||
record = runner.run_arm(
|
||||
"baseline",
|
||||
{"prompt": "implement behavior", "verify": "true"},
|
||||
tmp_path,
|
||||
bench_args(),
|
||||
sandbox=sandbox(tmp_path),
|
||||
oracle_snapshot=snapshot,
|
||||
)
|
||||
|
||||
assert stages_seen[0] == [] # authored tests run before hidden files are staged
|
||||
assert len(stages_seen[1]) == 1
|
||||
assert record["resolved"] is True
|
||||
assert not list(tmp_path.glob(".wfbench-oracle-*"))
|
||||
|
||||
|
||||
def test_hidden_oracle_uses_digest_bound_staged_config_not_candidate_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "oracles"
|
||||
source.mkdir()
|
||||
hidden_config = b"export default { test: { passWithNoTests: false, setupFiles: [] } };\n"
|
||||
(source / "vitest.config.mts").write_bytes(hidden_config)
|
||||
(source / "oracle.test.ts").write_text("hidden test")
|
||||
task = oracle_task(
|
||||
command=(
|
||||
'npx vitest run --config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts" '
|
||||
'"$GITNEXUS_BENCH_ORACLE_ROOT/nested/oracle.test.ts"'
|
||||
)
|
||||
)
|
||||
task["oracle"]["files"].insert( # type: ignore[index]
|
||||
0,
|
||||
{"source": "vitest.config.mts", "target": "vitest.config.mts"},
|
||||
)
|
||||
snapshot = capture_task_oracle(task, root=source)
|
||||
candidate_config = tmp_path / "vitest.config.ts"
|
||||
candidate_config.write_text("export default { test: { passWithNoTests: true } };\n")
|
||||
calls = 0
|
||||
|
||||
sandbox_instance = sandbox(tmp_path)
|
||||
|
||||
def fake_verify(command, *args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return True, "authored"
|
||||
assert command == snapshot.command
|
||||
oracle_env_root = kwargs["env"][oracle_assets.ORACLE_ENV_VAR]
|
||||
assert oracle_env_root.startswith("/workspace/.wfbench-oracle-")
|
||||
assert Path(oracle_env_root).parent == Path("/workspace")
|
||||
# A hidden test's ../gitnexus import must resolve to the credited
|
||||
# candidate checkout, not to an unrelated /opt/gitnexus tree.
|
||||
assert Path(oracle_env_root).parent / "gitnexus" == Path("/workspace/gitnexus")
|
||||
prefix_options = sandbox_instance.command_prefix_calls[-1]
|
||||
assert prefix_options["read_only_workspace"] is True
|
||||
assert prefix_options["unshare_network"] is True
|
||||
oracle_mount = prefix_options["extra_read_only_mounts"][0]
|
||||
assert oracle_mount.target == oracle_env_root
|
||||
oracle_root = oracle_mount.source
|
||||
assert oracle_root.is_relative_to(sandbox_instance.private_root)
|
||||
assert (oracle_root / "vitest.config.mts").read_bytes() == hidden_config
|
||||
assert (oracle_root / "vitest.config.mts").read_bytes() != candidate_config.read_bytes()
|
||||
return True, "hidden"
|
||||
|
||||
monkeypatch.setattr(runner, "run_claude", lambda *args, **kwargs: session_record())
|
||||
monkeypatch.setattr(runner, "run_verify", fake_verify)
|
||||
record = runner.run_arm(
|
||||
"baseline",
|
||||
{"prompt": "implement behavior", "verify": "true"},
|
||||
tmp_path,
|
||||
bench_args(),
|
||||
sandbox=sandbox_instance,
|
||||
oracle_snapshot=snapshot,
|
||||
)
|
||||
|
||||
assert calls == 2
|
||||
assert record["resolved"] is True
|
||||
assert snapshot.command_digest == hashlib.sha256(snapshot.command.encode()).hexdigest()
|
||||
assert any(item.target == "vitest.config.mts" for item in snapshot.files)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Vitest module-resolution fixture uses symlinks")
|
||||
def test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout(tmp_path: Path) -> None:
|
||||
"""Exercise the shipped config with the same sibling layout used by bwrap."""
|
||||
|
||||
repository_root = Path(__file__).resolve().parents[2]
|
||||
vitest = repository_root / "gitnexus" / "node_modules" / ".bin" / "vitest"
|
||||
if not vitest.is_file():
|
||||
pytest.skip("GitNexus Vitest dependencies are not installed")
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
candidate = workspace / "gitnexus"
|
||||
candidate.mkdir(parents=True)
|
||||
(candidate / "candidate.ts").write_text("export const candidateValue = 'candidate-workspace';\n")
|
||||
|
||||
# The test file is a workspace sibling, so bare `vitest` imports resolve
|
||||
# through this harness dependency link while candidate-relative imports
|
||||
# resolve through ../gitnexus exactly as they do in the sandbox.
|
||||
(workspace / "node_modules").symlink_to(repository_root / "gitnexus" / "node_modules", target_is_directory=True)
|
||||
(candidate / "node_modules").symlink_to(repository_root / "gitnexus" / "node_modules", target_is_directory=True)
|
||||
|
||||
hidden = workspace / ".wfbench-oracle-smoke"
|
||||
hidden.mkdir()
|
||||
shipped_config = repository_root / "eval" / "workflow_bench" / "oracles" / "vitest.config.mts"
|
||||
config = hidden / "vitest.config.mts"
|
||||
config.write_bytes(shipped_config.read_bytes())
|
||||
sentinel = tmp_path / "oracle-ran.txt"
|
||||
oracle = hidden / "candidate-import.oracle.test.ts"
|
||||
oracle.write_text(
|
||||
"import { writeFileSync } from 'node:fs';\n"
|
||||
"import { expect, test } from 'vitest';\n"
|
||||
"import { candidateValue } from '../gitnexus/candidate';\n"
|
||||
"test('uses the credited candidate checkout', () => {\n"
|
||||
" expect(candidateValue).toBe('candidate-workspace');\n"
|
||||
" writeFileSync(process.env.ORACLE_SENTINEL!, `ran:${candidateValue}`);\n"
|
||||
"});\n"
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment["ORACLE_SENTINEL"] = str(sentinel)
|
||||
|
||||
completed = subprocess.run(
|
||||
[str(vitest), "run", "--config", str(config), str(oracle)],
|
||||
cwd=candidate,
|
||||
env=environment,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
assert sentinel.read_text() == "ran:candidate-workspace"
|
||||
|
||||
|
||||
def test_weakened_authored_tests_cannot_produce_a_promotion_decision() -> None:
|
||||
incumbent = runner.aggregate([{"class": "demo", "resolved": True, **_metrics()} for _ in range(3)])
|
||||
candidate = runner.aggregate(
|
||||
[
|
||||
{
|
||||
"class": "demo",
|
||||
"resolved": False,
|
||||
"authored_tests_passed": True,
|
||||
"oracle_passed": False,
|
||||
**_metrics(cost_usd=0.01),
|
||||
}
|
||||
for _ in range(3)
|
||||
]
|
||||
)
|
||||
decision = evaluate_candidate(
|
||||
{"task": {"workflow": incumbent, "candidate_workflow": candidate}},
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
min_runs=3,
|
||||
)
|
||||
|
||||
assert decision["decision"] == "keep_incumbent"
|
||||
assert any("resolution regressed" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def _metrics(*, cost_usd: float = 1.0) -> dict[str, object]:
|
||||
return {
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"output_tokens": 5,
|
||||
"cost_usd": cost_usd,
|
||||
"duration_s": 1.0,
|
||||
"num_turns": 1,
|
||||
"diff_files": 1,
|
||||
"diff_insertions": 1,
|
||||
"diff_deletions": 0,
|
||||
}
|
||||
455
eval/tests/test_process_control.py
Normal file
455
eval/tests/test_process_control.py
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
"""Process-tree ownership contracts for the workflow benchmark harness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import process_control
|
||||
from workflow_bench.process_control import (
|
||||
MAX_TAIL_BYTES,
|
||||
ManagedProcessResult,
|
||||
mark_cleanup_failure,
|
||||
run_managed,
|
||||
)
|
||||
|
||||
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
def test_managed_process_captures_normal_exit() -> None:
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", "import sys; print('out'); print('err', file=sys.stderr)"],
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert result.state == "exited"
|
||||
assert result.returncode == 0
|
||||
assert result.stdout_tail.strip() == "out"
|
||||
assert result.stderr_tail.strip() == "err"
|
||||
assert not result.timed_out
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group canary")
|
||||
def test_timeout_kills_term_ignoring_descendants_before_they_write(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "late-write"
|
||||
script = """
|
||||
import os, signal, subprocess, sys, time
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
subprocess.Popen([
|
||||
sys.executable, '-c',
|
||||
"import signal,time,pathlib; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1); pathlib.Path(%r).write_text('escaped')"
|
||||
])
|
||||
while True:
|
||||
print('still-running', flush=True)
|
||||
time.sleep(0.01)
|
||||
""" % str(sentinel)
|
||||
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", script],
|
||||
timeout=0.15,
|
||||
terminate_grace=0.1,
|
||||
)
|
||||
time.sleep(1.1)
|
||||
|
||||
assert result.state == "forced-kill"
|
||||
assert result.timed_out
|
||||
assert result.forced_kill
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX cooperative-TERM canary")
|
||||
def test_timeout_reports_cooperative_term_without_false_forced_kill() -> None:
|
||||
started = time.monotonic()
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", "import time; time.sleep(10)"],
|
||||
timeout=0.15,
|
||||
terminate_grace=0.8,
|
||||
)
|
||||
|
||||
assert result.state == "timeout"
|
||||
assert result.timed_out
|
||||
assert not result.forced_kill
|
||||
assert result.returncode == -15
|
||||
assert time.monotonic() - started < 0.6
|
||||
|
||||
|
||||
def test_stdout_and_stderr_are_bounded_while_the_process_runs() -> None:
|
||||
script = """\
|
||||
import os
|
||||
for _ in range(40):
|
||||
os.write(1, b'o' * 8192)
|
||||
os.write(2, b'e' * 8192)
|
||||
os.write(1, b'OUT-END')
|
||||
os.write(2, b'ERR-END')
|
||||
"""
|
||||
result = run_managed([PYTHON, "-c", script], timeout=5)
|
||||
|
||||
assert result.state == "exited"
|
||||
assert len(result.stdout_tail.encode()) <= MAX_TAIL_BYTES
|
||||
assert len(result.stderr_tail.encode()) <= MAX_TAIL_BYTES
|
||||
assert result.stdout_tail.endswith("OUT-END")
|
||||
assert result.stderr_tail.endswith("ERR-END")
|
||||
|
||||
|
||||
def test_parent_can_capture_one_complete_bounded_stdout_stream() -> None:
|
||||
payload = b"event-one\nevent-two\n"
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", f"import os; os.write(1, {payload!r})"],
|
||||
timeout=5,
|
||||
capture_stdout_bytes=len(payload),
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout_capture == payload
|
||||
assert result.stdout_capture_overflow is False
|
||||
|
||||
|
||||
def test_parent_stdout_capture_reports_overflow_without_stopping_drain() -> None:
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", "import os; os.write(1, b'x' * 1024); os.write(1, b'END')"],
|
||||
timeout=5,
|
||||
capture_stdout_bytes=64,
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout_capture == b"x" * 64
|
||||
assert result.stdout_capture_overflow is True
|
||||
assert result.stdout_tail.endswith("END")
|
||||
|
||||
|
||||
def test_incomplete_stdin_delivery_cannot_report_success() -> None:
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", "import os,time; os.close(0); time.sleep(0.05)"],
|
||||
timeout=5,
|
||||
stdin_data=b"x" * (4 * 1024 * 1024),
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.state == "input-failure"
|
||||
assert not result.ok
|
||||
assert "stdin write failed" in (result.detail or "")
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX inherited-pipe canary")
|
||||
def test_exited_parent_cannot_leave_an_inherited_pipe_descendant(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "orphan-write"
|
||||
child = (
|
||||
"import subprocess,sys; "
|
||||
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(2);pathlib.Path({str(sentinel)!r}).touch()\"]); "
|
||||
"print('parent-done')"
|
||||
)
|
||||
|
||||
result = run_managed([PYTHON, "-c", child], timeout=5, terminate_grace=0.1)
|
||||
time.sleep(2.1)
|
||||
|
||||
assert result.state == "forced-kill"
|
||||
assert result.forced_kill
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group canary")
|
||||
def test_exited_parent_cannot_leave_a_quiet_descendant(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "quiet-orphan-write"
|
||||
child = (
|
||||
"import subprocess,sys; "
|
||||
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"], "
|
||||
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
"print('parent-done')"
|
||||
)
|
||||
|
||||
result = run_managed([PYTHON, "-c", child], timeout=5, terminate_grace=0.1)
|
||||
time.sleep(1.1)
|
||||
|
||||
assert result.state == "forced-kill"
|
||||
assert result.forced_kill
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
def test_required_pid_namespace_fails_before_plain_command_starts(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "started"
|
||||
|
||||
result = run_managed(
|
||||
[PYTHON, "-c", f"from pathlib import Path; Path({str(sentinel)!r}).touch()"],
|
||||
timeout=5,
|
||||
require_pid_namespace=True,
|
||||
)
|
||||
|
||||
assert result.state == "ownership-failure"
|
||||
assert result.returncode is None
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
def test_cleanup_failure_preserves_the_primary_process_state() -> None:
|
||||
primary = ManagedProcessResult(
|
||||
state="forced-kill",
|
||||
returncode=-9,
|
||||
stdout_tail="out",
|
||||
stderr_tail="err",
|
||||
duration_s=1.0,
|
||||
timed_out=True,
|
||||
forced_kill=True,
|
||||
)
|
||||
|
||||
combined = mark_cleanup_failure(primary, OSError("clone busy"))
|
||||
|
||||
assert combined.state == "cleanup-failure"
|
||||
assert combined.primary_state == "forced-kill"
|
||||
assert "clone busy" in (combined.detail or "")
|
||||
assert combined.stdout_tail == "out"
|
||||
|
||||
|
||||
def test_keyboard_interrupt_reaps_owned_process_and_propagates(monkeypatch) -> None:
|
||||
class FakeJob:
|
||||
def __init__(self) -> None:
|
||||
self.terminated = False
|
||||
self.closed = False
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
class InterruptingProcess:
|
||||
pid = 424242
|
||||
returncode = None
|
||||
stdin = None
|
||||
stdout = io.BytesIO()
|
||||
stderr = io.BytesIO()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.waits = 0
|
||||
self.killed = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waits += 1
|
||||
if self.waits == 1:
|
||||
raise KeyboardInterrupt
|
||||
self.returncode = -9
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
process = InterruptingProcess()
|
||||
job = FakeJob() if os.name == "nt" else None
|
||||
killed_groups: list[tuple[int, int]] = []
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control._spawn",
|
||||
lambda *_args, **_kwargs: (
|
||||
process,
|
||||
job,
|
||||
"windows-job" if os.name == "nt" else "posix-process-group",
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control.os.killpg",
|
||||
lambda pgid, sig: killed_groups.append((pgid, sig)),
|
||||
)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_managed([PYTHON, "-c", "pass"], timeout=5)
|
||||
|
||||
if job is not None:
|
||||
assert job.terminated
|
||||
assert job.closed
|
||||
else:
|
||||
assert killed_groups == [(process.pid, 9)]
|
||||
assert process.waits == 2
|
||||
|
||||
|
||||
def test_pre_wait_keyboard_interrupt_reaps_owned_process_and_propagates(monkeypatch) -> None:
|
||||
class FakeJob:
|
||||
def __init__(self) -> None:
|
||||
self.terminated = False
|
||||
self.closed = False
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
class SpawnedProcess:
|
||||
pid = 434343
|
||||
returncode = None
|
||||
stdin = None
|
||||
stdout = io.BytesIO()
|
||||
stderr = io.BytesIO()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.waits = 0
|
||||
self.killed = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waits += 1
|
||||
self.returncode = -9
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
process = SpawnedProcess()
|
||||
job = FakeJob() if os.name == "nt" else None
|
||||
killed_groups: list[tuple[int, int]] = []
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control._spawn",
|
||||
lambda *_args, **_kwargs: (
|
||||
process,
|
||||
job,
|
||||
"windows-job" if os.name == "nt" else "posix-process-group",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control.threading.Thread",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()),
|
||||
)
|
||||
if os.name != "nt":
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control.os.killpg",
|
||||
lambda pgid, sig: killed_groups.append((pgid, sig)),
|
||||
)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_managed([PYTHON, "-c", "pass"], timeout=5)
|
||||
|
||||
if job is not None:
|
||||
assert job.terminated
|
||||
assert job.closed
|
||||
else:
|
||||
assert killed_groups == [(process.pid, signal.SIGKILL)]
|
||||
assert process.waits == 1
|
||||
assert process.stdout.closed
|
||||
assert process.stderr.closed
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX Popen registration path")
|
||||
def test_interrupt_after_spawn_return_uses_internal_ownership_registration(monkeypatch) -> None:
|
||||
class SpawnedProcess:
|
||||
pid = 444444
|
||||
returncode = None
|
||||
stdin = None
|
||||
stdout = io.BytesIO()
|
||||
stderr = io.BytesIO()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.waits = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waits += 1
|
||||
self.returncode = -9
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.returncode = -9
|
||||
|
||||
process = SpawnedProcess()
|
||||
killed_groups: list[tuple[int, int]] = []
|
||||
real_spawn = process_control._spawn
|
||||
|
||||
def interrupt_after_registered_spawn(*args, **kwargs):
|
||||
real_spawn(*args, **kwargs)
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(process_control.subprocess, "Popen", lambda *_args, **_kwargs: process)
|
||||
monkeypatch.setattr(process_control, "_spawn", interrupt_after_registered_spawn)
|
||||
monkeypatch.setattr(process_control.os, "killpg", lambda pgid, sig: killed_groups.append((pgid, sig)))
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_managed([PYTHON, "-c", "pass"], timeout=5)
|
||||
|
||||
assert killed_groups == [(process.pid, signal.SIGKILL)]
|
||||
assert process.waits == 1
|
||||
assert process.stdout.closed
|
||||
assert process.stderr.closed
|
||||
|
||||
|
||||
def test_post_wait_keyboard_interrupt_reaps_job_and_propagates(monkeypatch) -> None:
|
||||
class CompletedProcess:
|
||||
pid = 515151
|
||||
returncode = None
|
||||
stdin = None
|
||||
stdout = io.BytesIO()
|
||||
stderr = io.BytesIO()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.waits = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.waits += 1
|
||||
self.returncode = 0 if self.waits == 1 else -9
|
||||
return self.returncode
|
||||
|
||||
def kill(self) -> None:
|
||||
self.returncode = -9
|
||||
|
||||
class InterruptingJob:
|
||||
def __init__(self) -> None:
|
||||
self.terminated = False
|
||||
self.closed = False
|
||||
|
||||
def active_processes(self) -> int:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
process = CompletedProcess()
|
||||
job = InterruptingJob()
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.process_control._spawn",
|
||||
lambda *_args, **_kwargs: (process, job, "windows-job"),
|
||||
)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_managed([PYTHON, "-c", "pass"], timeout=5)
|
||||
|
||||
assert job.terminated
|
||||
assert job.closed
|
||||
assert process.waits == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="native Windows Job Object canary")
|
||||
def test_windows_job_kills_grandchild_before_delayed_write(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "late-write"
|
||||
child = (
|
||||
"import subprocess,sys,time; "
|
||||
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"]); "
|
||||
"time.sleep(10)"
|
||||
)
|
||||
|
||||
result = run_managed([PYTHON, "-c", child], timeout=0.15, terminate_grace=0.1)
|
||||
time.sleep(1.1)
|
||||
|
||||
assert result.state == "forced-kill"
|
||||
assert result.ownership == "windows-job"
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="native Windows Job Object canary")
|
||||
def test_windows_normal_parent_with_grandchild_is_not_successful_evidence(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "quiet-late-write"
|
||||
parent = (
|
||||
"import subprocess,sys; "
|
||||
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"], "
|
||||
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)"
|
||||
)
|
||||
|
||||
result = run_managed([PYTHON, "-c", parent], timeout=5, terminate_grace=0.1)
|
||||
time.sleep(1.1)
|
||||
|
||||
assert result.state == "forced-kill"
|
||||
assert result.forced_kill
|
||||
assert not result.ok
|
||||
assert not sentinel.exists()
|
||||
826
eval/tests/test_promotion_apply.py
Normal file
826
eval/tests/test_promotion_apply.py
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
"""Tests for evidence-bound, transactional promotion application."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import evolve, promotion_apply
|
||||
from workflow_bench.evolution import (
|
||||
CANDIDATE_SKILLS,
|
||||
MAX_CANDIDATE_OVERLAY_BYTES,
|
||||
candidate_overlay_digest,
|
||||
candidate_overlay_payload,
|
||||
)
|
||||
from workflow_bench.promotion_apply import (
|
||||
apply_promoted_overlay,
|
||||
committed_destination_base_digests,
|
||||
destination_base_digests,
|
||||
freeze_overlay,
|
||||
mirror_targets,
|
||||
)
|
||||
|
||||
|
||||
def _git(repo: Path, *arguments: str) -> str:
|
||||
return (
|
||||
__import__("subprocess")
|
||||
.run(
|
||||
["git", "-C", str(repo), *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
.stdout.strip()
|
||||
)
|
||||
|
||||
|
||||
def test_evolve_reexports_public_promotion_helpers():
|
||||
assert evolve.mirror_targets is mirror_targets
|
||||
assert evolve.freeze_overlay is freeze_overlay
|
||||
assert evolve.destination_base_digests is destination_base_digests
|
||||
assert evolve.apply_promoted_overlay is apply_promoted_overlay
|
||||
|
||||
|
||||
def test_mirror_targets_cover_canonical_and_shipped_copies():
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
assert targets == [
|
||||
PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"),
|
||||
PurePosixPath("gitnexus/skills/gitnexus-plan/SKILL.md"),
|
||||
PurePosixPath("gitnexus-claude-plugin/skills/gitnexus-plan/SKILL.md"),
|
||||
]
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_writes_all_mirrors(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("evolved plan skill")
|
||||
repo = tmp_path / "repo"
|
||||
expected_targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in expected_targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
written = apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert written == [
|
||||
".claude/skills/gitnexus-plan/SKILL.md",
|
||||
"gitnexus/skills/gitnexus-plan/SKILL.md",
|
||||
"gitnexus-claude-plugin/skills/gitnexus-plan/SKILL.md",
|
||||
]
|
||||
contents = {(repo / path).read_text() for path in written}
|
||||
assert contents == {"evolved plan skill"}
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rejects_destination_drift(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(f"old:{target}")
|
||||
expected_bases = destination_base_digests(overlay, repo_root=repo)
|
||||
drifted = repo / targets[1]
|
||||
drifted.write_text("concurrent edit")
|
||||
|
||||
with pytest.raises(ValueError, match="drifted=.*gitnexus-plan/SKILL.md"):
|
||||
apply_promoted_overlay(
|
||||
overlay,
|
||||
repo_root=repo,
|
||||
expected_target_bases=expected_bases,
|
||||
)
|
||||
|
||||
assert drifted.read_text() == "concurrent edit"
|
||||
assert (repo / targets[0]).read_text() == f"old:{targets[0]}"
|
||||
assert (repo / targets[2]).read_text() == f"old:{targets[2]}"
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_preserves_edit_racing_atomic_exchange(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
raced = False
|
||||
|
||||
def edit_before_exchange(parent_descriptor, source, destination):
|
||||
nonlocal raced
|
||||
if not raced:
|
||||
raced = True
|
||||
(repo / targets[0]).write_text("concurrent edit")
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", edit_before_exchange)
|
||||
with pytest.raises(RuntimeError, match="rolled back") as raised:
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert "atomic overlay exchange parity check failed" in str(raised.value.__cause__)
|
||||
assert (repo / targets[0]).read_text() == "concurrent edit"
|
||||
assert [(repo / target).read_text() for target in targets[1:]] == ["incumbent", "incumbent"]
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rolls_back_raced_edit_when_exchange_then_raises(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
exchanges = 0
|
||||
|
||||
def edit_exchange_then_raise(parent_descriptor, source, destination):
|
||||
nonlocal exchanges
|
||||
exchanges += 1
|
||||
if exchanges == 1:
|
||||
(repo / targets[0]).write_text("concurrent edit")
|
||||
real_exchange(parent_descriptor, source, destination)
|
||||
raise OSError("injected post-exchange failure")
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", edit_exchange_then_raise)
|
||||
with pytest.raises(RuntimeError, match="rolled back"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert (repo / targets[0]).read_text() == "concurrent edit"
|
||||
assert [(repo / target).read_text() for target in targets[1:]] == ["incumbent", "incumbent"]
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_preserves_second_edit_racing_rollback(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
exchanges = 0
|
||||
|
||||
def edit_before_publication_and_rollback(parent_descriptor, source, destination):
|
||||
nonlocal exchanges
|
||||
exchanges += 1
|
||||
if exchanges == 1:
|
||||
(repo / targets[0]).write_text("first concurrent edit")
|
||||
elif exchanges == 2:
|
||||
(repo / targets[0]).write_text("second concurrent edit")
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", edit_before_publication_and_rollback)
|
||||
with pytest.raises(RuntimeError, match="rollback was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["transaction_state"] == "rollback-incomplete"
|
||||
raced_target = next(entry for entry in recovery["backups"] if entry["target"] == targets[0].as_posix())
|
||||
assert Path(raced_target["candidate"]).read_text() == "second concurrent edit"
|
||||
assert (repo / targets[0]).read_text() == "first concurrent edit"
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_preserves_mode_change_racing_exchange(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
destination.chmod(0o644)
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
raced = False
|
||||
|
||||
def chmod_before_exchange(parent_descriptor, source, destination):
|
||||
nonlocal raced
|
||||
if not raced:
|
||||
raced = True
|
||||
(repo / targets[0]).chmod(0o600)
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", chmod_before_exchange)
|
||||
with pytest.raises(RuntimeError, match="rolled back"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert (repo / targets[0]).read_text() == "incumbent"
|
||||
assert stat.S_IMODE((repo / targets[0]).stat().st_mode) == 0o600
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_treats_candidate_hardlinked_at_both_names_as_incomplete(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
linked = False
|
||||
|
||||
def hardlink_candidate_before_exchange(parent_descriptor, source, destination):
|
||||
nonlocal linked
|
||||
if not linked:
|
||||
linked = True
|
||||
os.unlink(destination, dir_fd=parent_descriptor)
|
||||
os.link(
|
||||
source,
|
||||
destination,
|
||||
src_dir_fd=parent_descriptor,
|
||||
dst_dir_fd=parent_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", hardlink_candidate_before_exchange)
|
||||
with pytest.raises(RuntimeError, match="rollback was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert (repo / targets[0]).read_text() == "candidate"
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["transaction_state"] == "rollback-incomplete"
|
||||
assert "linked at both" in recovery["rollback_failures"][0]
|
||||
assert Path(recovery["backups"][0]["backup"]).read_text() == "incumbent"
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rolls_back_every_completed_replace(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(f"old:{target}")
|
||||
originals = {target: (repo / target).read_bytes() for target in targets}
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
replacements = 0
|
||||
|
||||
def fail_second_exchange(parent_descriptor, source, destination):
|
||||
nonlocal replacements
|
||||
replacements += 1
|
||||
if replacements == 2:
|
||||
raise OSError("injected replacement failure")
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", fail_second_exchange)
|
||||
with pytest.raises(RuntimeError, match="rolled back"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert {target: (repo / target).read_bytes() for target in targets} == originals
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rolls_back_when_replace_lands_then_interrupts(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(f"old:{target}")
|
||||
originals = {target: (repo / target).read_bytes() for target in targets}
|
||||
expected_bases = destination_base_digests(overlay, repo_root=repo)
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
apply_replacements = 0
|
||||
|
||||
def interrupt_after_second_landed_exchange(parent_descriptor, source, destination):
|
||||
nonlocal apply_replacements
|
||||
result = real_exchange(parent_descriptor, source, destination)
|
||||
if destination == "SKILL.md":
|
||||
apply_replacements += 1
|
||||
if apply_replacements == 2:
|
||||
raise KeyboardInterrupt("injected post-replace interruption")
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", interrupt_after_second_landed_exchange)
|
||||
with pytest.raises(KeyboardInterrupt, match="post-replace"):
|
||||
apply_promoted_overlay(
|
||||
overlay,
|
||||
repo_root=repo,
|
||||
expected_target_bases=expected_bases,
|
||||
)
|
||||
|
||||
assert {target: (repo / target).read_bytes() for target in targets} == originals
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_prevalidates_all_targets_before_staging(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
first = repo / mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))[0]
|
||||
first.parent.mkdir(parents=True)
|
||||
first.write_text("incumbent")
|
||||
|
||||
with pytest.raises(ValueError, match="destination (parent is unavailable|must already be a regular file)"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert first.read_text() == "incumbent"
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rejects_internal_symlink_ancestor(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in (targets[0], targets[2]):
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
redirected = repo / "redirected"
|
||||
redirected.mkdir(parents=True)
|
||||
(redirected / "SKILL.md").write_text("must-not-change")
|
||||
symlink_parent = repo / targets[1].parent
|
||||
symlink_parent.parent.mkdir(parents=True, exist_ok=True)
|
||||
symlink_parent.symlink_to(redirected, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="must not be a symlink"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert (redirected / "SKILL.md").read_text() == "must-not-change"
|
||||
assert (repo / targets[0]).read_text() == "incumbent"
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rejects_repository_swap_during_root_open(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
replacement_repo = tmp_path / "replacement-repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for root, content in ((repo, "incumbent"), (replacement_repo, "replacement")):
|
||||
for target in targets:
|
||||
destination = root / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content)
|
||||
|
||||
detached_repo = tmp_path / "detached-repo"
|
||||
real_open = promotion_apply.os.open
|
||||
swapped = False
|
||||
|
||||
def swap_before_root_open(path, flags, mode=0o777, *, dir_fd=None):
|
||||
nonlocal swapped
|
||||
if not swapped and dir_fd is None and Path(path) == repo and flags & os.O_DIRECTORY:
|
||||
swapped = True
|
||||
repo.rename(detached_repo)
|
||||
replacement_repo.rename(repo)
|
||||
return real_open(path, flags, mode, dir_fd=dir_fd)
|
||||
|
||||
monkeypatch.setattr(promotion_apply.os, "open", swap_before_root_open)
|
||||
with pytest.raises(ValueError, match="repository root changed while opening"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert [(detached_repo / target).read_text() for target in targets] == ["incumbent"] * 3
|
||||
assert [(repo / target).read_text() for target in targets] == ["replacement"] * 3
|
||||
assert list(tmp_path.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rejects_detached_parent_after_preparation(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
lexical_parent = (repo / targets[0]).parent
|
||||
detached_parent = lexical_parent.with_name("gitnexus-plan-detached")
|
||||
real_stage = promotion_apply._stage_replacement_at
|
||||
replaced = False
|
||||
|
||||
def replace_parent_before_staging(parent_descriptor, content, mode):
|
||||
nonlocal replaced
|
||||
if not replaced:
|
||||
replaced = True
|
||||
lexical_parent.rename(detached_parent)
|
||||
lexical_parent.mkdir()
|
||||
(lexical_parent / "SKILL.md").write_text("incumbent")
|
||||
return real_stage(parent_descriptor, content, mode)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_stage_replacement_at", replace_parent_before_staging)
|
||||
|
||||
with pytest.raises(RuntimeError, match="rolled back") as raised:
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert "destination parent changed" in str(raised.value.__cause__)
|
||||
assert (lexical_parent / "SKILL.md").read_text() == "incumbent"
|
||||
assert (detached_parent / "SKILL.md").read_text() == "incumbent"
|
||||
assert [(repo / target).read_text() for target in targets[1:]] == ["incumbent", "incumbent"]
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_committed_destination_bases_ignore_and_reject_live_target_edits(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-b", "main")
|
||||
_git(repo, "config", "user.name", "Workflow Bench Test")
|
||||
_git(repo, "config", "user.email", "workflow-bench@example.invalid")
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(f"committed:{target}")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "incumbent")
|
||||
|
||||
expected = committed_destination_base_digests(overlay, repo_root=repo)
|
||||
dirty = repo / targets[1]
|
||||
dirty.write_text("user edit")
|
||||
|
||||
assert destination_base_digests(overlay, repo_root=repo) != expected
|
||||
with pytest.raises(ValueError, match="drifted"):
|
||||
apply_promoted_overlay(
|
||||
overlay,
|
||||
repo_root=repo,
|
||||
expected_target_bases=expected,
|
||||
)
|
||||
assert dirty.read_text() == "user edit"
|
||||
|
||||
|
||||
def test_mirror_roots_cover_every_candidate_skill_and_omit_none_that_ships_to_cursor():
|
||||
# promotion_apply.mirror_targets writes canonical + MIRROR_SKILL_ROOTS, which
|
||||
# today omits the Cursor tree. That is only safe because no candidate skill is
|
||||
# cursor-shipped. If a future edit adds a cursor-shipped skill (e.g.
|
||||
# gitnexus-review) to CANDIDATE_SKILLS, apply_promoted_overlay would rewrite
|
||||
# the other trees and silently skip Cursor — the PR #2488 asymmetric-sync bug
|
||||
# class. Pin the invariant to the filesystem, the source of truth the TS drift
|
||||
# guard already enforces.
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
cursor_root = repo_root / "gitnexus-cursor-integration" / "skills"
|
||||
for skill in sorted(CANDIDATE_SKILLS):
|
||||
canonical = repo_root / ".claude" / "skills" / skill
|
||||
assert canonical.is_dir(), f"candidate skill {skill} has no canonical .claude/skills dir"
|
||||
for target in mirror_targets(PurePosixPath(".claude", "skills", skill, "SKILL.md")):
|
||||
assert (repo_root / target).is_file(), f"candidate skill mirror missing on disk: {target}"
|
||||
assert not (cursor_root / skill).exists(), (
|
||||
f"candidate skill {skill} ships to Cursor, but MIRROR_SKILL_ROOTS does not cover "
|
||||
"gitnexus-cursor-integration/skills — promotion would sync it asymmetrically"
|
||||
)
|
||||
|
||||
|
||||
def test_committed_destination_bases_reject_overlay_adding_uncommitted_target(tmp_path):
|
||||
# An overlay that adds a file absent at HEAD has no committed base to bind
|
||||
# against and raises ValueError — evolve.run / runner.main now catch that as
|
||||
# NOT PROMOTED / a clean CLI error instead of an uncaught traceback.
|
||||
overlay = tmp_path / "overlay"
|
||||
new_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "NEW.md"
|
||||
new_md.parent.mkdir(parents=True)
|
||||
new_md.write_text("brand new candidate file")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-b", "main")
|
||||
_git(repo, "config", "user.name", "Workflow Bench Test")
|
||||
_git(repo, "config", "user.email", "workflow-bench@example.invalid")
|
||||
(repo / "README.md").write_text("seed")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "seed")
|
||||
|
||||
with pytest.raises(ValueError, match="committed overlay destination is unavailable"):
|
||||
committed_destination_base_digests(overlay, repo_root=repo)
|
||||
|
||||
|
||||
def test_stage_replacement_removes_partial_file_when_fsync_fails(monkeypatch, tmp_path):
|
||||
destination = tmp_path / "SKILL.md"
|
||||
|
||||
def fail_fsync(_descriptor):
|
||||
raise OSError("injected fsync failure")
|
||||
|
||||
monkeypatch.setattr(promotion_apply.os, "fsync", fail_fsync)
|
||||
with pytest.raises(OSError, match="injected fsync failure"):
|
||||
promotion_apply._stage_replacement(destination, b"partial candidate", 0o644)
|
||||
|
||||
assert list(tmp_path.glob(".wfevolve-*")) == []
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_names_recovery_state_if_rollback_fails(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
replacements = 0
|
||||
|
||||
def fail_apply_and_rollback(parent_descriptor, source, destination):
|
||||
nonlocal replacements
|
||||
replacements += 1
|
||||
if replacements >= 2:
|
||||
raise OSError("injected persistent replacement failure")
|
||||
return real_exchange(parent_descriptor, source, destination)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", fail_apply_and_rollback)
|
||||
with pytest.raises(RuntimeError, match="rollback was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["rollback_failures"]
|
||||
assert any(Path(entry["backup"]).exists() for entry in recovery["backups"])
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_writes_recovery_into_held_root_after_relocation(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
replacement_repo = tmp_path / "replacement-repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for root, content in ((repo, "incumbent"), (replacement_repo, "replacement")):
|
||||
for target in targets:
|
||||
destination = root / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content)
|
||||
|
||||
detached_repo = tmp_path / "detached-repo"
|
||||
real_exchange = promotion_apply._exchange_at
|
||||
exchanges = 0
|
||||
|
||||
def relocate_after_exchange_then_fail(parent_descriptor, source, destination):
|
||||
nonlocal exchanges
|
||||
exchanges += 1
|
||||
if exchanges == 1:
|
||||
real_exchange(parent_descriptor, source, destination)
|
||||
repo.rename(detached_repo)
|
||||
replacement_repo.rename(repo)
|
||||
raise OSError("injected post-exchange relocation")
|
||||
raise OSError("injected rollback failure")
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_exchange_at", relocate_after_exchange_then_fail)
|
||||
with pytest.raises(RuntimeError, match=r"recovery: .*detached-repo"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert list(repo.glob(".wfbench-overlay-recovery-*.json")) == []
|
||||
recovery_files = list(detached_repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["rollback_failures"]
|
||||
assert recovery["backups"]
|
||||
assert all(str(detached_repo) in entry["backup"] for entry in recovery["backups"])
|
||||
assert all(Path(entry["backup"]).exists() for entry in recovery["backups"])
|
||||
assert [(repo / target).read_text() for target in targets] == ["replacement"] * 3
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_reports_published_state_and_closes_descriptors_on_cleanup_failure(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
captured_descriptors: list[int] = []
|
||||
real_prepare = promotion_apply._prepare_targets
|
||||
|
||||
def capture_descriptors(payload, repo_root):
|
||||
root, root_descriptor, prepared = real_prepare(payload, repo_root)
|
||||
captured_descriptors.extend([root_descriptor, *(item["parent_descriptor"] for item in prepared)])
|
||||
return root, root_descriptor, prepared
|
||||
|
||||
def fail_cleanup(_parent_descriptor, _name):
|
||||
raise OSError("injected cleanup failure")
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_prepare_targets", capture_descriptors)
|
||||
monkeypatch.setattr(promotion_apply, "_unlink_temporary", fail_cleanup)
|
||||
with pytest.raises(RuntimeError, match="transaction is published.*cleanup was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert [(repo / target).read_text() for target in targets] == ["candidate"] * 3
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["transaction_state"] == "published"
|
||||
assert recovery["backups"]
|
||||
for descriptor in captured_descriptors:
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(descriptor)
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_tracks_candidate_when_backup_staging_and_cleanup_fail(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_stage = promotion_apply._stage_replacement_at
|
||||
stages = 0
|
||||
|
||||
def fail_backup_stage(parent_descriptor, content, mode):
|
||||
nonlocal stages
|
||||
stages += 1
|
||||
if stages == 2:
|
||||
raise OSError("injected backup staging failure")
|
||||
return real_stage(parent_descriptor, content, mode)
|
||||
|
||||
def fail_candidate_cleanup(_parent_descriptor, _name):
|
||||
raise OSError("injected candidate cleanup failure")
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_stage_replacement_at", fail_backup_stage)
|
||||
monkeypatch.setattr(promotion_apply, "_unlink_temporary", fail_candidate_cleanup)
|
||||
with pytest.raises(RuntimeError, match="transaction is rolled-back.*cleanup was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["transaction_state"] == "rolled-back"
|
||||
assert len(recovery["backups"]) == 1
|
||||
assert recovery["backups"][0]["candidate_exists"]
|
||||
assert recovery["backups"][0]["backup"] is None
|
||||
assert Path(recovery["backups"][0]["candidate"]).exists()
|
||||
assert [(repo / target).read_text() for target in targets] == ["incumbent"] * 3
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_tracks_candidate_before_identity_capture(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_identity = promotion_apply._entry_identity_at
|
||||
identities = 0
|
||||
|
||||
def fail_candidate_identity(parent_descriptor, name):
|
||||
nonlocal identities
|
||||
identities += 1
|
||||
if identities == 1:
|
||||
raise OSError("injected candidate identity failure")
|
||||
return real_identity(parent_descriptor, name)
|
||||
|
||||
monkeypatch.setattr(promotion_apply, "_entry_identity_at", fail_candidate_identity)
|
||||
with pytest.raises(RuntimeError, match="rolled back"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
assert list(repo.rglob(".wfevolve-*")) == []
|
||||
assert [(repo / target).read_text() for target in targets] == ["incumbent"] * 3
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_tracks_stage_name_when_parent_fsync_and_unlink_fail(monkeypatch, tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
skill_md = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
skill_md.parent.mkdir(parents=True)
|
||||
skill_md.write_text("candidate")
|
||||
repo = tmp_path / "repo"
|
||||
targets = mirror_targets(PurePosixPath(".claude/skills/gitnexus-plan/SKILL.md"))
|
||||
for target in targets:
|
||||
destination = repo / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text("incumbent")
|
||||
|
||||
real_fsync = promotion_apply.os.fsync
|
||||
real_unlink = promotion_apply.os.unlink
|
||||
failed_parent_fsync = False
|
||||
|
||||
def fail_first_parent_fsync(descriptor):
|
||||
nonlocal failed_parent_fsync
|
||||
if not failed_parent_fsync and stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||
failed_parent_fsync = True
|
||||
raise OSError("injected parent fsync failure")
|
||||
return real_fsync(descriptor)
|
||||
|
||||
def fail_staging_unlink(path, *args, **kwargs):
|
||||
if str(path).startswith(".wfevolve-"):
|
||||
raise OSError("injected staging unlink failure")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(promotion_apply.os, "fsync", fail_first_parent_fsync)
|
||||
monkeypatch.setattr(promotion_apply.os, "unlink", fail_staging_unlink)
|
||||
with pytest.raises(RuntimeError, match="transaction is rolled-back.*cleanup was incomplete; recovery:"):
|
||||
apply_promoted_overlay(overlay, repo_root=repo)
|
||||
|
||||
recovery_files = list(repo.glob(".wfbench-overlay-recovery-*.json"))
|
||||
assert len(recovery_files) == 1
|
||||
recovery = json.loads(recovery_files[0].read_text())
|
||||
assert recovery["transaction_state"] == "rolled-back"
|
||||
assert len(recovery["backups"]) == 1
|
||||
assert recovery["backups"][0]["candidate_exists"]
|
||||
assert Path(recovery["backups"][0]["candidate"]).exists()
|
||||
assert [(repo / target).read_text() for target in targets] == ["incumbent"] * 3
|
||||
|
||||
|
||||
def test_freeze_overlay_detaches_authorized_bytes_from_mutable_input(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
source = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("authorized")
|
||||
frozen = tmp_path / "frozen"
|
||||
|
||||
digest = freeze_overlay(overlay, frozen)
|
||||
source.write_text("mutated later")
|
||||
|
||||
assert candidate_overlay_digest(frozen) == digest
|
||||
assert (frozen / source.relative_to(overlay)).read_text() == "authorized"
|
||||
|
||||
|
||||
def test_freeze_overlay_matches_canonical_payload_digest_and_byte_boundary(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
source = overlay / ".claude" / "skills" / "gitnexus-plan" / "SKILL.md"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_bytes(b"x" * MAX_CANDIDATE_OVERLAY_BYTES)
|
||||
|
||||
digest, payload = candidate_overlay_payload(overlay)
|
||||
assert candidate_overlay_digest(overlay) == digest
|
||||
|
||||
frozen = tmp_path / "frozen"
|
||||
assert freeze_overlay(overlay, frozen) == digest
|
||||
assert candidate_overlay_payload(frozen) == (digest, payload)
|
||||
|
||||
source.write_bytes(b"x" * (MAX_CANDIDATE_OVERLAY_BYTES + 1))
|
||||
with pytest.raises(ValueError, match="bounded evidence limit"):
|
||||
candidate_overlay_digest(overlay)
|
||||
|
||||
rejected = tmp_path / "rejected"
|
||||
with pytest.raises(ValueError, match="bounded evidence limit"):
|
||||
freeze_overlay(overlay, rejected)
|
||||
assert not rejected.exists()
|
||||
|
||||
|
||||
def test_apply_promoted_overlay_rejects_out_of_boundary_files(tmp_path):
|
||||
overlay = tmp_path / "overlay"
|
||||
rogue = overlay / ".claude" / "skills" / "not-a-family-skill" / "SKILL.md"
|
||||
rogue.parent.mkdir(parents=True)
|
||||
rogue.write_text("smuggled")
|
||||
|
||||
with pytest.raises(ValueError, match="may only contain Markdown files"):
|
||||
apply_promoted_overlay(overlay, repo_root=tmp_path / "repo")
|
||||
746
eval/tests/test_proposer_sandbox.py
Normal file
746
eval/tests/test_proposer_sandbox.py
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
"""Fail-closed containment contracts for proposer and candidate sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import runner
|
||||
|
||||
from workflow_bench.process_control import ManagedProcessResult, run_managed
|
||||
from workflow_bench.proposer_sandbox import (
|
||||
MAX_BUNDLE_BYTES,
|
||||
MAX_EVIDENCE_FILE_BYTES,
|
||||
SANDBOX_SHELL_PREFIX,
|
||||
SANDBOX_USER_SKILLS,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
build_claude_settings,
|
||||
build_sandbox_environment,
|
||||
prepare_sandbox,
|
||||
preflight_bubblewrap,
|
||||
stage_evidence_bundle,
|
||||
stage_task_assets,
|
||||
)
|
||||
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets as stage_immutable_task_assets
|
||||
|
||||
|
||||
def test_environment_is_allowlisted_and_shell_children_are_credential_free(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "cloud-secret")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "github-secret")
|
||||
monkeypatch.setenv("SSH_AUTH_SOCK", "/tmp/agent.sock")
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.invalid")
|
||||
|
||||
env = build_sandbox_environment(
|
||||
auth_token="model-secret",
|
||||
base_url="https://model.example.test/v1",
|
||||
)
|
||||
|
||||
assert env["ANTHROPIC_API_KEY"] == "model-secret"
|
||||
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
||||
assert env["ANTHROPIC_BASE_URL"] == "https://model.example.test/v1"
|
||||
assert env["CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"] == "1"
|
||||
assert env["CLAUDE_CODE_DONT_INHERIT_ENV"] == "1"
|
||||
assert env["CLAUDE_CODE_SHELL_PREFIX"] == SANDBOX_SHELL_PREFIX
|
||||
assert "model-secret" not in env["CLAUDE_CODE_SHELL_PREFIX"]
|
||||
assert not ({"AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN", "SSH_AUTH_SOCK", "HTTPS_PROXY"} & env.keys())
|
||||
|
||||
settings = json.loads(build_claude_settings())
|
||||
assert settings["sandbox"]["enabled"] is True
|
||||
assert settings["sandbox"]["failIfUnavailable"] is True
|
||||
assert settings["sandbox"]["allowUnsandboxedCommands"] is False
|
||||
assert settings["sandbox"]["network"]["deniedDomains"] == ["*"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url",
|
||||
["https://user:secret@example.test", "https://example.test/path?token=x", "file:///tmp/model"],
|
||||
)
|
||||
def test_environment_rejects_credential_bearing_or_non_http_endpoints(bad_url: str) -> None:
|
||||
with pytest.raises(SandboxError, match="base URL"):
|
||||
build_sandbox_environment(auth_token="token", base_url=bad_url)
|
||||
|
||||
|
||||
def test_evidence_bundle_is_private_bounded_and_structured(tmp_path: Path) -> None:
|
||||
bundle = stage_evidence_bundle(
|
||||
tmp_path / "bundle",
|
||||
{
|
||||
"rows.json": [{"task": "t", "verify_tail": "ok"}],
|
||||
"gate.json": {"decision": "keep_incumbent"},
|
||||
"patch.diff": "diff --git a/a b/a\n",
|
||||
},
|
||||
secrets=["never-retain-me"],
|
||||
)
|
||||
|
||||
assert stat.S_IMODE(bundle.stat().st_mode) == 0o700
|
||||
assert all(stat.S_IMODE(path.stat().st_mode) == 0o600 for path in bundle.iterdir())
|
||||
assert sum(path.stat().st_size for path in bundle.iterdir()) <= MAX_BUNDLE_BYTES
|
||||
assert "never-retain-me" not in "".join(path.read_text() for path in bundle.iterdir())
|
||||
|
||||
|
||||
def test_evidence_bundle_rejects_paths_symlinks_special_files_and_limits(tmp_path: Path) -> None:
|
||||
with pytest.raises(SandboxError, match="simple relative"):
|
||||
stage_evidence_bundle(tmp_path / "traversal", {"../escape": "x"})
|
||||
with pytest.raises(SandboxError, match="per-file"):
|
||||
stage_evidence_bundle(
|
||||
tmp_path / "large",
|
||||
{"large.txt": "x" * (MAX_EVIDENCE_FILE_BYTES + 1)},
|
||||
)
|
||||
|
||||
source = tmp_path / "source"
|
||||
source.write_text("ok")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(source)
|
||||
with pytest.raises(SandboxError, match="regular non-symlink"):
|
||||
stage_evidence_bundle(tmp_path / "links", {"link.txt": link})
|
||||
|
||||
|
||||
def test_evidence_bundle_rejects_aggregate_limit_and_removes_partial_bundle(tmp_path: Path) -> None:
|
||||
destination = tmp_path / "aggregate-overflow"
|
||||
entry_count = MAX_BUNDLE_BYTES // MAX_EVIDENCE_FILE_BYTES + 1
|
||||
entries = {f"part-{index}.txt": b"x" * MAX_EVIDENCE_FILE_BYTES for index in range(entry_count)}
|
||||
|
||||
with pytest.raises(SandboxError, match="total byte limit"):
|
||||
stage_evidence_bundle(destination, entries)
|
||||
|
||||
assert not destination.exists()
|
||||
|
||||
|
||||
def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
claude = tmp_path / "claude"
|
||||
claude.write_text("#!/bin/sh\nexit 0\n")
|
||||
claude.chmod(0o755)
|
||||
bwrap = tmp_path / "bwrap"
|
||||
bwrap.write_text("#!/bin/sh\nexit 0\n")
|
||||
bwrap.chmod(0o755)
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=claude,
|
||||
bwrap_bin=bwrap,
|
||||
preflight=False,
|
||||
) as sandbox:
|
||||
argv = sandbox.command_prefix
|
||||
pairs = list(zip(argv, argv[1:]))
|
||||
assert "--unshare-pid" in argv
|
||||
assert "--unshare-ipc" in argv
|
||||
assert "--unshare-uts" in argv
|
||||
assert "--die-with-parent" in argv
|
||||
assert ("--ro-bind", "/") not in pairs
|
||||
assert str(clone.resolve()) in argv
|
||||
assert "/workspace" in argv
|
||||
assert sandbox.claude_bin == "/opt/claude/claude"
|
||||
assert sandbox.transcript_projects.parent.name == ".claude"
|
||||
shell_prefix_index = argv.index(SANDBOX_SHELL_PREFIX)
|
||||
assert argv[shell_prefix_index - 2] == "--ro-bind"
|
||||
shell_prefix = Path(argv[shell_prefix_index - 1])
|
||||
assert stat.S_IMODE(shell_prefix.stat().st_mode) == 0o500
|
||||
probe = subprocess.run(
|
||||
[
|
||||
shell_prefix,
|
||||
'test -z "${ANTHROPIC_API_KEY:-}" && test -z "${GITHUB_TOKEN:-}" && printf "%s" "$HOME|$PATH"',
|
||||
],
|
||||
env={"ANTHROPIC_API_KEY": "model-secret", "GITHUB_TOKEN": "github-secret"},
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert probe.returncode == 0, probe.stderr
|
||||
assert probe.stdout == "/home/agent|/opt/claude:/usr/local/bin:/usr/bin:/bin"
|
||||
assert SANDBOX_USER_SKILLS in argv
|
||||
user_skills_index = argv.index(SANDBOX_USER_SKILLS)
|
||||
assert argv[user_skills_index - 2] == "--ro-bind"
|
||||
private_root = sandbox.private_root
|
||||
assert not private_root.exists()
|
||||
|
||||
|
||||
def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
skill = clone / ".claude" / "skills" / "gitnexus-work"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("trusted")
|
||||
executable = tmp_path / "executable"
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=executable,
|
||||
bwrap_bin=executable,
|
||||
preflight=False,
|
||||
) as sandbox:
|
||||
prefix = sandbox.command_prefix_for(
|
||||
read_only_paths=(skill,),
|
||||
unshare_network=True,
|
||||
)
|
||||
|
||||
assert "--unshare-net" in prefix
|
||||
skill_target = "/workspace/.claude/skills/gitnexus-work"
|
||||
target_index = prefix.index(skill_target)
|
||||
assert prefix[target_index - 2 : target_index + 1] == ["--ro-bind", str(skill), skill_target]
|
||||
user_index = prefix.index(SANDBOX_USER_SKILLS)
|
||||
assert prefix[user_index - 2] == "--ro-bind"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_blocks_repo_skill_edits_and_home_shadowing(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
skill = clone / ".claude" / "skills" / "gitnexus-work"
|
||||
skill.mkdir(parents=True)
|
||||
prompt = skill / "SKILL.md"
|
||||
prompt.write_text("trusted")
|
||||
|
||||
script = """
|
||||
from pathlib import Path
|
||||
targets = [
|
||||
Path('/workspace/.claude/skills/gitnexus-work/SKILL.md'),
|
||||
Path('/home/agent/.claude/skills/gitnexus-work/SKILL.md'),
|
||||
Path('/opt/claude/shell-prefix'),
|
||||
]
|
||||
for target in targets:
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text('shadowed')
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit(f'writable skill path: {target}')
|
||||
Path('/workspace/unrelated-write').write_text('ok')
|
||||
"""
|
||||
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
|
||||
result = run_managed(
|
||||
[*sandbox.command_prefix_for(read_only_paths=(skill,)), "/usr/bin/python3", "-c", script],
|
||||
timeout=10,
|
||||
env=sandbox.environment(),
|
||||
require_pid_namespace=True,
|
||||
)
|
||||
|
||||
assert result.ok, result.stderr_tail
|
||||
assert prompt.read_text() == "trusted"
|
||||
assert (clone / "unrelated-write").read_text() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_verifier_cannot_rewrite_credited_source_or_oracle(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
implementation = clone / "implementation.py"
|
||||
implementation.write_text("trusted\n")
|
||||
oracle = tmp_path / "oracle"
|
||||
oracle.mkdir()
|
||||
hidden = oracle / "hidden.test"
|
||||
hidden.write_text("secret\n")
|
||||
oracle_mountpoint = clone / ".wfbench-oracle-canary"
|
||||
oracle_mountpoint.mkdir()
|
||||
|
||||
script = """
|
||||
import socket
|
||||
from pathlib import Path
|
||||
for target in (
|
||||
Path('/workspace/implementation.py'),
|
||||
Path('/workspace/oracle-leak.txt'),
|
||||
Path('/workspace/.wfbench-oracle-canary/hidden.test'),
|
||||
):
|
||||
try:
|
||||
target.write_text('tampered')
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit(f'writable verifier target: {target}')
|
||||
Path('/tmp/verifier-scratch').write_text('ok')
|
||||
probe = socket.socket()
|
||||
probe.settimeout(0.2)
|
||||
try:
|
||||
probe.connect(('1.1.1.1', 53))
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit('verifier retained external network access')
|
||||
finally:
|
||||
probe.close()
|
||||
"""
|
||||
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
|
||||
prefix = sandbox.command_prefix_for(
|
||||
read_only_workspace=True,
|
||||
unshare_network=True,
|
||||
extra_read_only_mounts=(ReadOnlyMount(source=oracle, target="/workspace/.wfbench-oracle-canary"),),
|
||||
)
|
||||
assert "--unshare-net" in prefix
|
||||
result = run_managed(
|
||||
[*prefix, "/usr/bin/python3", "-c", script],
|
||||
timeout=10,
|
||||
env=sandbox.environment(),
|
||||
require_pid_namespace=True,
|
||||
)
|
||||
|
||||
assert result.ok, result.stderr_tail
|
||||
assert implementation.read_text() == "trusted\n"
|
||||
assert hidden.read_text() == "secret\n"
|
||||
assert not (clone / "oracle-leak.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink creation may require elevated Windows privileges")
|
||||
@pytest.mark.parametrize("operation", ["stage", "sandbox"])
|
||||
def test_clone_root_symlink_is_rejected_before_host_access(tmp_path: Path, operation: str) -> None:
|
||||
real_clone = tmp_path / "real-clone"
|
||||
real_clone.mkdir()
|
||||
linked_clone = tmp_path / "linked-clone"
|
||||
linked_clone.symlink_to(real_clone, target_is_directory=True)
|
||||
|
||||
if operation == "stage":
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
source = repo / "asset"
|
||||
source.write_text("payload")
|
||||
with pytest.raises(SandboxError, match="real directory"):
|
||||
stage_task_assets(
|
||||
{"sandbox_copy": ["asset"]},
|
||||
repo=repo,
|
||||
clone=linked_clone,
|
||||
)
|
||||
assert not (real_clone / "asset").exists()
|
||||
return
|
||||
|
||||
claude = tmp_path / "claude"
|
||||
claude.write_text("#!/bin/sh\nexit 0\n")
|
||||
claude.chmod(0o755)
|
||||
bwrap = tmp_path / "bwrap"
|
||||
bwrap.write_text("#!/bin/sh\nexit 0\n")
|
||||
bwrap.chmod(0o755)
|
||||
with pytest.raises(SandboxError, match="real directory"):
|
||||
with prepare_sandbox(
|
||||
clone=linked_clone,
|
||||
claude_bin=claude,
|
||||
bwrap_bin=bwrap,
|
||||
preflight=False,
|
||||
):
|
||||
pytest.fail("a linked clone root must never enter the sandbox")
|
||||
|
||||
|
||||
def test_preflight_failure_is_returned_before_a_model_command(monkeypatch, tmp_path: Path) -> None:
|
||||
bwrap = tmp_path / "bwrap"
|
||||
bwrap.write_text("#!/bin/sh\nexit 1\n")
|
||||
bwrap.chmod(0o755)
|
||||
calls: list[list[str]] = []
|
||||
runtime_mounts = ["--ro-bind", "/runtime", "/runtime"]
|
||||
|
||||
def fail(command, **_kwargs):
|
||||
calls.append(list(command))
|
||||
return ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=1,
|
||||
stdout_tail="",
|
||||
stderr_tail="namespace denied",
|
||||
duration_s=0.1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("workflow_bench.proposer_sandbox.run_managed", fail)
|
||||
monkeypatch.setattr(
|
||||
"workflow_bench.proposer_sandbox._runtime_mount_args",
|
||||
lambda: runtime_mounts,
|
||||
)
|
||||
with pytest.raises(SandboxError, match="preflight"):
|
||||
preflight_bubblewrap(bwrap)
|
||||
assert len(calls) == 1
|
||||
mount_index = calls[0].index("--new-session") + 1
|
||||
assert calls[0][mount_index : mount_index + len(runtime_mounts)] == runtime_mounts
|
||||
pairs = list(zip(calls[0], calls[0][1:]))
|
||||
assert ("--bind", "/") not in pairs
|
||||
assert ("--ro-bind", "/") not in pairs
|
||||
assert "claude" not in " ".join(calls[0])
|
||||
|
||||
|
||||
def test_task_assets_are_copied_or_bound_without_symlink_escape(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
clone = tmp_path / "clone"
|
||||
(repo / ".gitnexus").mkdir(parents=True)
|
||||
clone.mkdir()
|
||||
source = repo / ".gitnexus" / "meta.json"
|
||||
source.write_text("{}")
|
||||
deps = repo / "node_modules"
|
||||
deps.mkdir()
|
||||
|
||||
task = {
|
||||
"sandbox_copy": [".gitnexus/meta.json"],
|
||||
"sandbox_dependencies": [{"source": "node_modules", "target": "node_modules"}],
|
||||
}
|
||||
with TaskAssetCache(tmp_path / "asset-cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha="a" * 40)
|
||||
mounts = stage_immutable_task_assets(
|
||||
task,
|
||||
repo=repo,
|
||||
clone=clone,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
copied = clone / ".gitnexus" / "meta.json"
|
||||
assert copied.read_text() == "{}"
|
||||
assert copied.stat().st_ino != source.stat().st_ino
|
||||
assert mounts[0].source != deps.resolve()
|
||||
assert mounts[0].target == "/workspace/node_modules"
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(repo / "escape").symlink_to(outside, target_is_directory=True)
|
||||
with pytest.raises(SandboxError, match="symlink"):
|
||||
cache.prepare(
|
||||
{"sandbox_dependencies": [{"source": "escape", "target": "deps"}]},
|
||||
repo=repo,
|
||||
resolved_sha="a" * 40,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="dirfd no-follow target canary is POSIX-only")
|
||||
@pytest.mark.parametrize("kind", ["copy", "dependency"])
|
||||
def test_task_asset_targets_never_follow_clone_symlink_parents(tmp_path: Path, kind: str) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
clone = tmp_path / "clone"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
clone.mkdir()
|
||||
outside.mkdir()
|
||||
(clone / "escape").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
if kind == "copy":
|
||||
(repo / "escape").mkdir()
|
||||
(repo / "escape" / "host-write").write_text("payload")
|
||||
task = {"sandbox_copy": ["escape/host-write"]}
|
||||
else:
|
||||
dependency = repo / "dependency"
|
||||
dependency.write_text("payload")
|
||||
task = {"sandbox_dependencies": [{"source": "dependency", "target": "escape/host-write"}]}
|
||||
|
||||
with pytest.raises(SandboxError, match="symlink parent"):
|
||||
if kind == "copy":
|
||||
stage_task_assets(task, repo=repo, clone=clone)
|
||||
else:
|
||||
with TaskAssetCache(tmp_path / "asset-cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha="a" * 40)
|
||||
stage_immutable_task_assets(
|
||||
task,
|
||||
repo=repo,
|
||||
clone=clone,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
assert not (outside / "host-write").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_bubblewrap_denies_parent_read_and_allows_clone_write(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
parent_secret = tmp_path / "parent-secret"
|
||||
parent_secret.write_text("secret")
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=Path(sys.executable),
|
||||
preflight=True,
|
||||
) as sandbox:
|
||||
result = sandbox.run(
|
||||
[
|
||||
"/usr/bin/python3",
|
||||
"-c",
|
||||
("from pathlib import Path; assert not Path(%r).exists(); Path('/workspace/allowed').write_text('ok')")
|
||||
% str(parent_secret),
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert (clone / "allowed").read_text() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
|
||||
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_clone_controlled_mcp_replacement_is_never_executed_or_credentialed(tmp_path: Path) -> None:
|
||||
clone = tmp_path / "clone"
|
||||
(clone / ".gitnexus").mkdir(parents=True)
|
||||
replacement = clone / ".gitnexus" / "run.cjs"
|
||||
replacement.write_text(
|
||||
"const fs=require('fs');"
|
||||
"let observed='no-key';"
|
||||
"for(const pid of fs.readdirSync('/proc')){"
|
||||
"try{const env=fs.readFileSync('/proc/'+pid+'/environ','utf8');"
|
||||
"if(env.includes('clone-mcp-canary-secret')) observed='credential-observed';}catch{}}"
|
||||
"fs.writeFileSync('/workspace/clone-mcp-ran', observed);"
|
||||
)
|
||||
|
||||
trusted_runtime = tmp_path / "trusted-runtime"
|
||||
trusted_entrypoint = trusted_runtime / "dist" / "cli" / "index.js"
|
||||
trusted_entrypoint.parent.mkdir(parents=True)
|
||||
trusted_entrypoint.write_text(
|
||||
"process.stdout.write(process.env.ANTHROPIC_API_KEY ? 'credential-leaked' : 'credential-absent');"
|
||||
)
|
||||
mount = ReadOnlyMount(source=trusted_runtime, target=runner.SANDBOX_GITNEXUS)
|
||||
server = json.loads(runner.sandbox_mcp_config())["mcpServers"]["gitnexus"]
|
||||
|
||||
with prepare_sandbox(
|
||||
clone=clone,
|
||||
claude_bin=Path(sys.executable),
|
||||
read_only_mounts=[mount],
|
||||
preflight=True,
|
||||
) as sandbox:
|
||||
result = sandbox.run(
|
||||
[server["command"], *server["args"]],
|
||||
timeout=10,
|
||||
env=sandbox.environment(auth_token="clone-mcp-canary-secret"),
|
||||
)
|
||||
|
||||
assert result.ok, result.stderr_tail
|
||||
assert result.stdout_tail == "credential-absent"
|
||||
assert not (clone / "clone-mcp-ran").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("GITNEXUS_REQUIRE_CLAUDE_CANARY") != "1",
|
||||
reason="real Claude/Bash/MCP canary is mandatory in the named Ubuntu CI job",
|
||||
)
|
||||
def test_real_claude_bare_auth_inner_sandbox_and_mcp_permissions(tmp_path: Path) -> None:
|
||||
"""Exercise the exact CLI boundary without contacting a paid model."""
|
||||
|
||||
claude = Path(os.environ["CLAUDE_CANARY_BIN"]).resolve()
|
||||
assert claude.is_file()
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
fake_mcp = clone / "fake_mcp.py"
|
||||
fake_mcp.write_text(
|
||||
"""import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
for line in sys.stdin:
|
||||
request = json.loads(line)
|
||||
method = request.get("method")
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "initialize":
|
||||
result = {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "canary", "version": "1"},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
result = {
|
||||
"tools": [{
|
||||
"name": "list_repos",
|
||||
"description": "record the permission canary",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
}]
|
||||
}
|
||||
elif method == "tools/call":
|
||||
Path("/workspace/mcp-called").write_text("ok")
|
||||
result = {"content": [{"type": "text", "text": "repository list ready"}]}
|
||||
else:
|
||||
result = {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": request.get("id"), "result": result}), flush=True)
|
||||
"""
|
||||
)
|
||||
fake_mcp.chmod(0o500)
|
||||
|
||||
observed_tool_results: dict[str, dict] = {}
|
||||
|
||||
class ModelHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
return
|
||||
|
||||
def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler contract
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
request = json.loads(self.rfile.read(length))
|
||||
tool_result_ids = {
|
||||
block.get("tool_use_id")
|
||||
for message in request.get("messages", [])
|
||||
if isinstance(message, dict) and isinstance(message.get("content"), list)
|
||||
for block in message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
}
|
||||
observed_tool_results.update(
|
||||
{
|
||||
block["tool_use_id"]: block
|
||||
for message in request.get("messages", [])
|
||||
if isinstance(message, dict) and isinstance(message.get("content"), list)
|
||||
for block in message["content"]
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") == "tool_result"
|
||||
and isinstance(block.get("tool_use_id"), str)
|
||||
}
|
||||
)
|
||||
if "toolu_mcp_canary" not in tool_result_ids:
|
||||
blocks = [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_mcp_canary",
|
||||
"name": "mcp__gitnexus__list_repos",
|
||||
"input": {},
|
||||
}
|
||||
]
|
||||
stop_reason = "tool_use"
|
||||
elif "toolu_bash_canary" not in tool_result_ids:
|
||||
blocks = [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_bash_canary",
|
||||
"name": "Bash",
|
||||
"input": {
|
||||
"command": ('test -z "${ANTHROPIC_API_KEY:-}" && printf canary > /workspace/bash-called')
|
||||
},
|
||||
}
|
||||
]
|
||||
stop_reason = "tool_use"
|
||||
else:
|
||||
blocks = [{"type": "text", "text": "canary complete"}]
|
||||
stop_reason = "end_turn"
|
||||
|
||||
events = [
|
||||
(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_canary",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": request.get("model", "claude-canary"),
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
for index, block in enumerate(blocks):
|
||||
if block["type"] == "text":
|
||||
start = {"type": "text", "text": ""}
|
||||
delta = {"type": "text_delta", "text": block["text"]}
|
||||
else:
|
||||
start = {
|
||||
"type": "tool_use",
|
||||
"id": block["id"],
|
||||
"name": block["name"],
|
||||
"input": {},
|
||||
}
|
||||
delta = {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(block["input"]),
|
||||
}
|
||||
events.extend(
|
||||
[
|
||||
(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": index, "content_block": start},
|
||||
),
|
||||
(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": index, "delta": delta},
|
||||
),
|
||||
("content_block_stop", {"type": "content_block_stop", "index": index}),
|
||||
]
|
||||
)
|
||||
events.extend(
|
||||
[
|
||||
(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
||||
"usage": {"output_tokens": 1},
|
||||
},
|
||||
),
|
||||
("message_stop", {"type": "message_stop"}),
|
||||
]
|
||||
)
|
||||
payload = "".join(f"event: {event}\ndata: {json.dumps(data)}\n\n" for event, data in events).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/event-stream")
|
||||
self.send_header("content-length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), ModelHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
mcp_config = json.dumps(
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/bin/env",
|
||||
"args": [
|
||||
"-i",
|
||||
"HOME=/home/agent",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||
"/usr/bin/python3",
|
||||
"/workspace/fake_mcp.py",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
with prepare_sandbox(clone=clone, claude_bin=claude, preflight=True) as sandbox:
|
||||
result = sandbox.run(
|
||||
[
|
||||
sandbox.claude_bin,
|
||||
"-p",
|
||||
"--input-format",
|
||||
"text",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--bare",
|
||||
"--settings",
|
||||
sandbox.settings_json,
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
mcp_config,
|
||||
"--permission-mode",
|
||||
"dontAsk",
|
||||
"--model",
|
||||
"claude-canary-20260718",
|
||||
"--allowedTools",
|
||||
"Bash",
|
||||
"mcp__gitnexus__list_repos",
|
||||
],
|
||||
timeout=60,
|
||||
env=sandbox.environment(
|
||||
auth_token="offline-canary-key",
|
||||
base_url=f"http://127.0.0.1:{server.server_port}",
|
||||
),
|
||||
stdin_data=b"Use both available tools, then finish.",
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert result.ok, result.stderr_tail + result.stdout_tail
|
||||
report = json.loads(result.stdout_tail)
|
||||
assert report["subtype"] == "success" and report["is_error"] is False, report
|
||||
bash_result = observed_tool_results["toolu_bash_canary"]
|
||||
assert bash_result.get("is_error") is not True, bash_result
|
||||
assert (clone / "bash-called").read_text() == "canary"
|
||||
assert (clone / "mcp-called").read_text() == "ok"
|
||||
|
||||
264
eval/tests/test_runner_hardening.py
Normal file
264
eval/tests/test_runner_hardening.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Regression tests for benchmark evidence and phase-boundary hardening."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import runner, runner_artifacts, runner_sessions
|
||||
from workflow_bench.evolution import skill_fingerprint
|
||||
from workflow_bench.process_control import ManagedProcessError, ManagedProcessResult
|
||||
|
||||
|
||||
def _report(**overrides) -> str:
|
||||
payload = {
|
||||
"type": "result",
|
||||
"session_id": "s",
|
||||
"num_turns": 3,
|
||||
"total_cost_usd": 0.1,
|
||||
"duration_ms": 1000,
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"cache_creation_input_tokens": 2,
|
||||
"cache_read_input_tokens": 3,
|
||||
"output_tokens": 4,
|
||||
},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def _stream(*, secret: str = "", **report_overrides: object) -> str:
|
||||
events = []
|
||||
if secret:
|
||||
events.append(
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {"content": [{"type": "text", "text": secret}]},
|
||||
}
|
||||
)
|
||||
events.append(json.loads(_report(**report_overrides)))
|
||||
return "\n".join(json.dumps(event) for event in events) + "\n"
|
||||
|
||||
|
||||
def test_sandboxed_verifier_does_not_execute_candidate_login_profile(tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
profile_sentinel = tmp_path / "profile-ran"
|
||||
(home / ".profile").write_text(f"touch '{profile_sentinel}'\nexit 97\n")
|
||||
|
||||
passed, output = runner_artifacts.run_verify(
|
||||
"printf verified",
|
||||
tmp_path,
|
||||
5,
|
||||
command_prefix=["/usr/bin/env"],
|
||||
env={"HOME": str(home), "PATH": "/usr/local/bin:/usr/bin:/bin"},
|
||||
)
|
||||
|
||||
assert passed is True
|
||||
assert output.strip() == "verified"
|
||||
assert not profile_sentinel.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state",
|
||||
[
|
||||
"input-failure",
|
||||
"timeout",
|
||||
"forced-kill",
|
||||
"ownership-failure",
|
||||
"spawn-failure",
|
||||
"reap-failure",
|
||||
"cleanup-failure",
|
||||
],
|
||||
)
|
||||
def test_verifier_infrastructure_states_are_not_candidate_quality(state):
|
||||
process = ManagedProcessResult(
|
||||
state=state,
|
||||
returncode=None,
|
||||
stdout_tail="",
|
||||
stderr_tail="hidden oracle secret",
|
||||
duration_s=0.1,
|
||||
)
|
||||
result = runner_artifacts.VerificationResult(
|
||||
command=["verify"],
|
||||
process=process,
|
||||
output="hidden oracle secret",
|
||||
)
|
||||
|
||||
with pytest.raises(ManagedProcessError) as caught:
|
||||
runner._verification_outcome(result)
|
||||
assert "hidden oracle secret" not in str(caught.value)
|
||||
|
||||
|
||||
def test_verifier_normal_nonzero_exit_remains_candidate_quality():
|
||||
process = ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=1,
|
||||
stdout_tail="",
|
||||
stderr_tail="assertion failed",
|
||||
duration_s=0.1,
|
||||
)
|
||||
result = runner_artifacts.VerificationResult(
|
||||
command=["verify"],
|
||||
process=process,
|
||||
output="assertion failed",
|
||||
)
|
||||
|
||||
assert runner._verification_outcome(result) == (False, "assertion failed")
|
||||
|
||||
|
||||
def test_review_skill_fingerprint_rejects_setup_and_review_phase_replacement(tmp_path):
|
||||
skill = tmp_path / ".claude" / "skills" / "gitnexus-review" / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text("trusted review prompt")
|
||||
expected = skill_fingerprint(tmp_path, "review")
|
||||
assert expected is not None
|
||||
|
||||
skill.write_text("replaced during task setup")
|
||||
with pytest.raises(ValueError, match="task setup changed the evaluated skill fingerprint"):
|
||||
runner_artifacts.require_skill_fingerprint(tmp_path, "review", expected, phase="task setup")
|
||||
|
||||
skill.write_text("trusted review prompt")
|
||||
expected = skill_fingerprint(tmp_path, "review")
|
||||
skill.write_text("replaced during review")
|
||||
with pytest.raises(ValueError, match="review changed the evaluated skill fingerprint"):
|
||||
runner_artifacts.require_skill_fingerprint(tmp_path, "review", expected, phase="review")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "returncode", "report_overrides"),
|
||||
[
|
||||
("exited", 1, {}),
|
||||
("timeout", None, {}),
|
||||
("exited", 0, {"is_error": True}),
|
||||
],
|
||||
)
|
||||
def test_failed_session_still_persists_redacted_transcript(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
state,
|
||||
returncode,
|
||||
report_overrides,
|
||||
):
|
||||
secret = "sk-ant-postmortem-secret"
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
stream = _stream(secret=secret, **report_overrides)
|
||||
result = ManagedProcessResult(
|
||||
state=state,
|
||||
returncode=returncode,
|
||||
stdout_tail=stream,
|
||||
stderr_tail="primary failure",
|
||||
duration_s=0.1,
|
||||
timed_out=state == "timeout",
|
||||
stdout_capture=stream.encode(),
|
||||
)
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", lambda *args, **kwargs: result)
|
||||
|
||||
record = runner_sessions.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
transcript_output_dir=output,
|
||||
transcript_output_prefix="failed-run",
|
||||
transcript_secrets=(secret,),
|
||||
)
|
||||
|
||||
artifact = output / record["transcript_artifact"]["path"]
|
||||
assert record["ok"] is False
|
||||
assert record["error_kind"] == "session-error"
|
||||
assert record["error_detail"]["process_state"] == state
|
||||
assert artifact.is_file()
|
||||
assert secret not in artifact.read_text()
|
||||
assert record["transcript_artifact"]["sha256"] == hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def test_failed_session_keeps_primary_error_when_transcript_persistence_fails(monkeypatch, tmp_path):
|
||||
stream = _stream()
|
||||
result = ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=1,
|
||||
stdout_tail=stream,
|
||||
stderr_tail="primary failure",
|
||||
duration_s=0.1,
|
||||
stdout_capture=stream.encode(),
|
||||
)
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", lambda *args, **kwargs: result)
|
||||
|
||||
record = runner_sessions.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
transcript_output_dir=tmp_path / "missing-output-root",
|
||||
)
|
||||
|
||||
assert record["error_kind"] == "session-error"
|
||||
assert record["error_detail"]["stderr_tail"] == "primary failure"
|
||||
assert any("event-stream persistence" in item for item in record["evidence_diagnostics"])
|
||||
|
||||
|
||||
def test_timed_out_session_never_trusts_writable_home_without_parent_result(monkeypatch, tmp_path):
|
||||
projects = tmp_path / "projects"
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
def timeout_after_writing_transcript(*args, **kwargs):
|
||||
forged = projects / "some-slug" / "timeout-session.jsonl"
|
||||
forged.parent.mkdir(parents=True)
|
||||
forged.write_text(_stream())
|
||||
return ManagedProcessResult(
|
||||
state="timeout",
|
||||
returncode=None,
|
||||
stdout_tail="",
|
||||
stderr_tail="timed out",
|
||||
duration_s=5.0,
|
||||
timed_out=True,
|
||||
stdout_capture=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runner_sessions, "run_managed", timeout_after_writing_transcript)
|
||||
record = runner_sessions.run_claude(
|
||||
"task",
|
||||
tmp_path,
|
||||
claude_bin="claude",
|
||||
timeout=5,
|
||||
transcript_projects=projects,
|
||||
transcript_output_dir=output,
|
||||
transcript_output_prefix="timeout-run",
|
||||
)
|
||||
|
||||
assert record["error_kind"] == "session-error"
|
||||
assert record["session_id"] is None
|
||||
assert "transcript_artifact" not in record
|
||||
assert record["transcript_missing"] is True
|
||||
|
||||
|
||||
def test_phase_workspace_rejects_unchanged_preseeded_review_output(tmp_path):
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("preseeded output")
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="did not create or change"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_rejects_symlink_review_output(tmp_path):
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
outside = tmp_path.parent / f"{tmp_path.name}-outside-review.md"
|
||||
outside.write_text("outside")
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.symlink_to(outside)
|
||||
|
||||
with pytest.raises(ValueError, match="regular non-symlink"):
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
|
||||
|
||||
def test_phase_workspace_accepts_new_regular_review_output(tmp_path):
|
||||
before = runner_artifacts.workspace_snapshot(tmp_path)
|
||||
artifact = tmp_path / "review-output.md"
|
||||
artifact.write_text("new review")
|
||||
|
||||
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
|
||||
197
eval/tests/test_sanitized_graph.py
Normal file
197
eval/tests/test_sanitized_graph.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Sanitized, offline GitNexus graph preparation contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench import sanitized_graph
|
||||
from workflow_bench.proposer_sandbox import ReadOnlyMount, SandboxError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task",
|
||||
[
|
||||
{"sandbox_copy": [".gitnexus/lbug"]},
|
||||
{"sandbox_copy": ["eval/workflow_bench/oracles"]},
|
||||
{"sandbox_dependencies": [{"source": ".gitnexus", "target": "graph"}]},
|
||||
{"sandbox_dependencies": [{"source": "safe", "target": "eval/workflow_bench"}]},
|
||||
],
|
||||
)
|
||||
def test_prebuilt_graph_and_harness_assets_are_rejected(task):
|
||||
with pytest.raises(SandboxError, match="prebuilt graph or harness"):
|
||||
sanitized_graph.validate_no_prebuilt_graph_assets(task)
|
||||
|
||||
|
||||
def test_graph_environment_is_offline_deterministic_and_ignores_target_gitignore():
|
||||
env = sanitized_graph._graph_environment()
|
||||
|
||||
assert env["GITNEXUS_HOME"] == "/home/agent/.gitnexus-index"
|
||||
assert env["GITNEXUS_NO_GITIGNORE"] == "1"
|
||||
assert env["GITNEXUS_WORKER_POOL_SIZE"] == "1"
|
||||
assert env["GITNEXUS_PARSE_CHUNK_CONCURRENCY"] == "1"
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
def test_graph_scrub_checks_whole_node_and_relation_payloads(monkeypatch):
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run(_prefix, arguments, *, timeout, capture_stdout=False):
|
||||
del timeout
|
||||
calls.append(tuple(arguments))
|
||||
return b'{"markdown":"| n |\\n| --- |","row_count":0}' if capture_stdout else None
|
||||
|
||||
monkeypatch.setattr(sanitized_graph, "_run_graph_cli", fake_run)
|
||||
sanitized_graph._scrub_and_verify_graph(["sandbox"])
|
||||
|
||||
statements = [call[1] for call in calls]
|
||||
assert len(statements) == 2
|
||||
assert all("RETURN" in statement and "LIMIT 1" in statement for statement in statements)
|
||||
assert all("CAST(n AS STRING)" in statement for statement in statements if "(n)" in statement)
|
||||
assert all("CAST(r AS STRING)" in statement for statement in statements if "[r]" in statement)
|
||||
for marker in sanitized_graph.GRAPH_MARKERS:
|
||||
assert any(marker in statement for statement in statements)
|
||||
|
||||
|
||||
def test_source_scrub_covers_paths_and_stored_content_without_following_large_inputs(tmp_path: Path):
|
||||
safe = tmp_path / "safe.py"
|
||||
safe.write_text("print('safe')\n")
|
||||
content_reference = tmp_path / "docs.md"
|
||||
content_reference.write_text("See eval/workflow_bench for the answer\n")
|
||||
path_reference = tmp_path / "nested" / "tasks.scenarios.yaml.copy"
|
||||
path_reference.parent.mkdir()
|
||||
path_reference.write_text("opaque\n")
|
||||
large = tmp_path / "large.py"
|
||||
large.write_bytes(b"x" * (sanitized_graph.MAX_GRAPH_SCRUB_FILE_BYTES + 1))
|
||||
|
||||
removed = sanitized_graph._scrub_source_references(tmp_path)
|
||||
|
||||
assert removed == ("docs.md", "nested/tasks.scenarios.yaml.copy")
|
||||
assert safe.exists()
|
||||
assert large.exists()
|
||||
assert not content_reference.exists()
|
||||
assert not path_reference.exists()
|
||||
|
||||
|
||||
def test_prepare_sanitized_graph_builds_once_from_parentless_tree_and_caches_only_curated_assets(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
):
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
(seed / ".git").mkdir()
|
||||
old_index = seed / ".gitnexus"
|
||||
old_index.mkdir()
|
||||
(old_index / "lbug").write_text("UNSANITIZED")
|
||||
(seed / ".gitnexusrc").write_text('{"embeddings":true,"pdg":false}\n')
|
||||
(seed / ".gitnexusignore").write_text("eval/**\n")
|
||||
sanitized_head = "a" * 40
|
||||
prefix_options: list[dict[str, object]] = []
|
||||
graph_calls: list[tuple[str, ...]] = []
|
||||
removed: list[Path] = []
|
||||
|
||||
class FakeSandbox:
|
||||
def command_prefix_for(self, **kwargs):
|
||||
prefix_options.append(dict(kwargs))
|
||||
return ["sandbox-prefix"]
|
||||
|
||||
@contextmanager
|
||||
def fake_prepare_sandbox(**kwargs):
|
||||
assert kwargs["clone"] == seed
|
||||
assert kwargs["preflight"] is False
|
||||
yield FakeSandbox()
|
||||
|
||||
def fake_graph_cli(_prefix, arguments, *, timeout, capture_stdout=False):
|
||||
del timeout
|
||||
graph_calls.append(tuple(arguments))
|
||||
if arguments[0] == "analyze":
|
||||
index = seed / ".gitnexus"
|
||||
index.mkdir()
|
||||
metadata = {
|
||||
"indexedAt": "2026-07-18T00:00:00Z",
|
||||
"lastCommit": sanitized_head,
|
||||
"pdg": {"hasCallSummary": True},
|
||||
}
|
||||
(index / "gitnexus.json").write_text(json.dumps(metadata))
|
||||
(index / "meta.json").write_text(json.dumps(metadata))
|
||||
(index / "lbug").write_bytes(b"SANITIZED")
|
||||
(index / "run.cjs").write_text("must not be cached")
|
||||
if capture_stdout:
|
||||
return b'{"markdown":"| n |\\n| --- |","row_count":0}'
|
||||
return None
|
||||
|
||||
asset = SimpleNamespace(
|
||||
digest="graph-digest",
|
||||
manifest_digest="graph-manifest",
|
||||
materialize=lambda clone: None,
|
||||
)
|
||||
|
||||
class FakeCache:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def prepare(self, task, *, repo, resolved_sha):
|
||||
self.calls.append((task, repo, resolved_sha))
|
||||
return asset
|
||||
|
||||
cache = FakeCache()
|
||||
monkeypatch.setattr(sanitized_graph, "make_worktree", lambda *args, **kwargs: seed)
|
||||
monkeypatch.setattr(
|
||||
sanitized_graph,
|
||||
"sanitize_clone_for_hidden_oracles",
|
||||
lambda clone: sanitized_head,
|
||||
)
|
||||
monkeypatch.setattr(sanitized_graph, "prepare_sandbox", fake_prepare_sandbox)
|
||||
monkeypatch.setattr(sanitized_graph, "_run_graph_cli", fake_graph_cli)
|
||||
monkeypatch.setattr(sanitized_graph, "remove_clone", lambda clone: removed.append(clone))
|
||||
|
||||
snapshot = sanitized_graph.prepare_sanitized_graph(
|
||||
{},
|
||||
repo=tmp_path,
|
||||
resolved_sha="b" * 40,
|
||||
parent=tmp_path,
|
||||
cache=cache, # type: ignore[arg-type]
|
||||
claude_bin="claude",
|
||||
bwrap_bin="bwrap",
|
||||
runtime_mounts=(ReadOnlyMount(source=tmp_path, target="/opt/runtime"),),
|
||||
)
|
||||
|
||||
analyze = graph_calls[0]
|
||||
assert analyze[:2] == ("analyze", "/workspace")
|
||||
for flag in ("--force", "--pdg", "--index-only", "--no-stats"):
|
||||
assert flag in analyze
|
||||
assert prefix_options == [{"unshare_network": True}]
|
||||
assert (seed / ".gitnexusrc").read_text() == "{}\n"
|
||||
assert (seed / ".gitnexusignore").read_text() == ""
|
||||
assert cache.calls == [
|
||||
(
|
||||
{"sandbox_copy": list(sanitized_graph.GRAPH_ASSET_PATHS)},
|
||||
seed,
|
||||
sanitized_head,
|
||||
)
|
||||
]
|
||||
cached_paths = cache.calls[0][0]["sandbox_copy"]
|
||||
assert ".gitnexus/run.cjs" not in cached_paths
|
||||
assert all("parse-cache" not in path for path in cached_paths)
|
||||
assert snapshot.sanitized_head == sanitized_head
|
||||
assert snapshot.digest == "graph-digest"
|
||||
assert removed == [seed]
|
||||
|
||||
|
||||
def test_graph_snapshot_rejects_arm_sanitization_identity_drift(tmp_path: Path):
|
||||
assets = SimpleNamespace(
|
||||
digest="digest",
|
||||
manifest_digest="manifest",
|
||||
materialize=lambda clone: pytest.fail("drift must fail before materialization"),
|
||||
)
|
||||
snapshot = sanitized_graph.SanitizedGraphSnapshot(
|
||||
assets=assets, # type: ignore[arg-type]
|
||||
sanitized_head="a" * 40,
|
||||
)
|
||||
|
||||
with pytest.raises(SandboxError, match="identity drifted"):
|
||||
snapshot.materialize(tmp_path, sanitized_head="b" * 40)
|
||||
391
eval/tests/test_task_assets.py
Normal file
391
eval/tests/test_task_assets.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"""Copy-on-write task-asset snapshot contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench.proposer_sandbox import SandboxError
|
||||
from workflow_bench.oracle_assets import TaskOracleSnapshot
|
||||
from workflow_bench.runner_tasks import resolve_task_bindings
|
||||
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
|
||||
from workflow_bench import task_assets
|
||||
|
||||
|
||||
SHA = "a" * 40
|
||||
|
||||
|
||||
def _repo_and_task(tmp_path: Path, files: dict[str, bytes]) -> tuple[Path, dict[str, object]]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
for relative, payload in files.items():
|
||||
target = repo / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(payload)
|
||||
return repo, {"sandbox_copy": sorted(files), "sandbox_dependencies": []}
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def test_snapshot_is_reused_frozen_and_isolates_arm_writes(monkeypatch, tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"assets/index": b"original"})
|
||||
clone_a = tmp_path / "clone-a"
|
||||
clone_b = tmp_path / "clone-b"
|
||||
clone_a.mkdir()
|
||||
clone_b.mkdir()
|
||||
reflink_calls: list[tuple[int, int]] = []
|
||||
|
||||
def fake_reflink(source: int, destination: int) -> bool:
|
||||
reflink_calls.append((source, destination))
|
||||
while chunk := os.read(source, 1024):
|
||||
os.write(destination, chunk)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(task_assets, "_try_reflink", fake_reflink)
|
||||
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 0)
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
assert cache.prepare(task, repo=repo, resolved_sha=SHA) is snapshot
|
||||
snapshot_file = snapshot.root / "sandbox-copy" / "assets" / "index"
|
||||
assert stat.S_IMODE(snapshot.root.stat().st_mode) == 0o500
|
||||
assert stat.S_IMODE(snapshot_file.stat().st_mode) == 0o400
|
||||
|
||||
stage_task_assets(task, repo=repo, clone=clone_a, snapshot=snapshot)
|
||||
stage_task_assets(task, repo=repo, clone=clone_b, snapshot=snapshot)
|
||||
(clone_a / "assets" / "index").write_bytes(b"arm-a")
|
||||
|
||||
assert snapshot_file.read_bytes() == b"original"
|
||||
assert (clone_b / "assets" / "index").read_bytes() == b"original"
|
||||
assert (clone_a / "assets" / "index").stat().st_ino != snapshot_file.stat().st_ino
|
||||
assert (clone_b / "assets" / "index").stat().st_ino != snapshot_file.stat().st_ino
|
||||
assert len(reflink_calls) == 2
|
||||
|
||||
|
||||
def test_snapshot_digest_binds_content_declaration_repo_and_sha(tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"one": b"1", "two": b"2"})
|
||||
with TaskAssetCache(tmp_path / "cache-a") as cache:
|
||||
original = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
other_sha = cache.prepare(task, repo=repo, resolved_sha="b" * 40)
|
||||
reordered = cache.prepare(
|
||||
{"sandbox_copy": ["two", "one"]},
|
||||
repo=repo,
|
||||
resolved_sha=SHA,
|
||||
)
|
||||
with TaskAssetCache(tmp_path / "cache-b") as cache:
|
||||
identical = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
(repo / "one").write_bytes(b"changed")
|
||||
with TaskAssetCache(tmp_path / "cache-c") as cache:
|
||||
changed = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
|
||||
assert identical.digest == original.digest
|
||||
assert identical.manifest_digest == original.manifest_digest
|
||||
assert other_sha.digest != original.digest
|
||||
assert other_sha.manifest_digest == original.manifest_digest
|
||||
assert reordered.digest != original.digest
|
||||
assert changed.digest != original.digest
|
||||
assert changed.manifest_digest != original.manifest_digest
|
||||
|
||||
|
||||
def test_small_assets_use_a_bounded_buffered_fallback(monkeypatch, tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"first": b"abc", "second": b"def"})
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
|
||||
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 6)
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
snapshot.materialize(clone)
|
||||
|
||||
assert (clone / "first").read_bytes() == b"abc"
|
||||
assert (clone / "second").read_bytes() == b"def"
|
||||
|
||||
|
||||
def test_large_asset_without_reflink_fails_before_publish_and_cleans_staging(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"large": b"12345"})
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
monkeypatch.setattr(task_assets, "_try_reflink", lambda *_args: False)
|
||||
monkeypatch.setattr(task_assets, "MAX_BUFFERED_FALLBACK_BYTES", 4)
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
with pytest.raises(SandboxError, match="cannot reflink"):
|
||||
snapshot.materialize(clone)
|
||||
|
||||
assert not (clone / "large").exists()
|
||||
assert not list(tmp_path.glob(".wfbench-assets-*"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink and FIFO contracts are POSIX-only")
|
||||
@pytest.mark.parametrize("kind", ["symlink", "fifo"])
|
||||
def test_snapshot_rejects_links_and_special_files(tmp_path: Path, kind: str) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
assets = repo / "assets"
|
||||
assets.mkdir(parents=True)
|
||||
if kind == "symlink":
|
||||
(repo / "outside").write_text("secret")
|
||||
(assets / "bad").symlink_to(repo / "outside")
|
||||
else:
|
||||
os.mkfifo(assets / "bad")
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
with pytest.raises(SandboxError, match="regular files and directories|symlink"):
|
||||
cache.prepare({"sandbox_copy": ["assets"]}, repo=repo, resolved_sha=SHA)
|
||||
|
||||
|
||||
def test_snapshot_rejects_a_file_mutated_during_capture(monkeypatch, tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"asset": b"original"})
|
||||
original_read = task_assets._read_source_chunk
|
||||
changed = False
|
||||
|
||||
def mutate_after_first_read(descriptor: int, size: int) -> bytes:
|
||||
nonlocal changed
|
||||
chunk = original_read(descriptor, size)
|
||||
if chunk and not changed:
|
||||
changed = True
|
||||
with (repo / "asset").open("ab") as source:
|
||||
source.write(b"!")
|
||||
return chunk
|
||||
|
||||
monkeypatch.setattr(task_assets, "_read_source_chunk", mutate_after_first_read)
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
with pytest.raises(SandboxError, match="changed while snapshotting"):
|
||||
cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("limit", "value", "task_files", "message"),
|
||||
[
|
||||
("MAX_TASK_ASSET_ENTRIES", 1, {"nested/file": b"x"}, "entry limit"),
|
||||
("MAX_TASK_ASSET_PATH_BYTES", 4, {"long-name": b"x"}, "path byte limit"),
|
||||
("MAX_TASK_ASSET_BYTES", 4, {"asset": b"12345"}, "total byte limit"),
|
||||
],
|
||||
)
|
||||
def test_snapshot_enforces_hard_walk_limits(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
limit: str,
|
||||
value: int,
|
||||
task_files: dict[str, bytes],
|
||||
message: str,
|
||||
) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, task_files)
|
||||
monkeypatch.setattr(task_assets, limit, value)
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
with pytest.raises(SandboxError, match=message):
|
||||
cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
|
||||
|
||||
def test_snapshot_rejects_overlapping_declarations(tmp_path: Path) -> None:
|
||||
repo, _task = _repo_and_task(tmp_path, {"assets/index": b"index"})
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
with pytest.raises(SandboxError, match="overlap"):
|
||||
cache.prepare(
|
||||
{"sandbox_copy": ["assets", "assets/index"]},
|
||||
repo=repo,
|
||||
resolved_sha=SHA,
|
||||
)
|
||||
|
||||
|
||||
def test_directory_materialization_removes_stale_children_exactly(tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"assets/current": b"captured"})
|
||||
task["sandbox_copy"] = ["assets"]
|
||||
clone = tmp_path / "clone"
|
||||
(clone / "assets" / "nested").mkdir(parents=True)
|
||||
(clone / "assets" / "current").write_bytes(b"old")
|
||||
(clone / "assets" / "stale").write_bytes(b"stale")
|
||||
(clone / "assets" / "nested" / "stale").write_bytes(b"stale")
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
snapshot.materialize(clone)
|
||||
|
||||
assert sorted(path.relative_to(clone).as_posix() for path in clone.rglob("*")) == [
|
||||
"assets",
|
||||
"assets/current",
|
||||
]
|
||||
assert (clone / "assets" / "current").read_bytes() == b"captured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_kind", ["file", "directory"])
|
||||
def test_materialization_replaces_file_directory_type_conflicts(
|
||||
tmp_path: Path,
|
||||
source_kind: str,
|
||||
) -> None:
|
||||
if source_kind == "file":
|
||||
repo, task = _repo_and_task(tmp_path, {"asset": b"file"})
|
||||
else:
|
||||
repo, task = _repo_and_task(tmp_path, {"asset/child": b"directory"})
|
||||
task["sandbox_copy"] = ["asset"]
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
if source_kind == "file":
|
||||
(clone / "asset").mkdir()
|
||||
(clone / "asset" / "stale").write_bytes(b"stale")
|
||||
else:
|
||||
(clone / "asset").write_bytes(b"stale file")
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
cache.prepare(task, repo=repo, resolved_sha=SHA).materialize(clone)
|
||||
|
||||
if source_kind == "file":
|
||||
assert (clone / "asset").is_file()
|
||||
assert (clone / "asset").read_bytes() == b"file"
|
||||
else:
|
||||
assert (clone / "asset").is_dir()
|
||||
assert (clone / "asset" / "child").read_bytes() == b"directory"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink containment is POSIX-only")
|
||||
def test_exact_tree_removal_does_not_follow_stale_child_symlinks(tmp_path: Path) -> None:
|
||||
repo, task = _repo_and_task(tmp_path, {"assets/current": b"captured"})
|
||||
task["sandbox_copy"] = ["assets"]
|
||||
clone = tmp_path / "clone"
|
||||
outside = tmp_path / "outside"
|
||||
(clone / "assets").mkdir(parents=True)
|
||||
outside.mkdir()
|
||||
(outside / "canary").write_bytes(b"outside")
|
||||
(clone / "assets" / "stale-link").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
cache.prepare(task, repo=repo, resolved_sha=SHA).materialize(clone)
|
||||
|
||||
assert (outside / "canary").read_bytes() == b"outside"
|
||||
assert not (clone / "assets" / "stale-link").exists()
|
||||
|
||||
|
||||
def test_dependency_snapshot_mounts_bound_bytes_and_rejects_later_live_drift(tmp_path: Path) -> None:
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
|
||||
task = {
|
||||
"sandbox_copy": [],
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "node_modules/dependency"}],
|
||||
}
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache-a") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
binding = snapshot.dependency_binding
|
||||
mounts = stage_task_assets(task, repo=repo, clone=clone, snapshot=snapshot)
|
||||
assert mounts[0].source != (repo / "dependency").resolve()
|
||||
assert (mounts[0].source / "package.json").read_bytes() == b'{"version":1}'
|
||||
|
||||
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
|
||||
with TaskAssetCache(tmp_path / "cache-b") as cache:
|
||||
with pytest.raises(SandboxError, match="changed after task binding"):
|
||||
cache.prepare(
|
||||
task,
|
||||
repo=repo,
|
||||
resolved_sha=SHA,
|
||||
expected_dependency_binding=binding,
|
||||
)
|
||||
|
||||
|
||||
def test_dependency_content_and_manifest_digests_bind_distinct_contracts(tmp_path: Path) -> None:
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/file": b"one"})
|
||||
task = {
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
|
||||
}
|
||||
retargeted = {
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "vendor/dependency"}],
|
||||
}
|
||||
with TaskAssetCache(tmp_path / "cache-a") as cache:
|
||||
original = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
changed_target = cache.prepare(retargeted, repo=repo, resolved_sha=SHA)
|
||||
|
||||
assert changed_target.dependency_content_digest == original.dependency_content_digest
|
||||
assert changed_target.dependency_manifest_digest != original.dependency_manifest_digest
|
||||
(repo / "dependency" / "file").write_bytes(b"two")
|
||||
with TaskAssetCache(tmp_path / "cache-b") as cache:
|
||||
changed_content = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
assert changed_content.dependency_content_digest != original.dependency_content_digest
|
||||
assert changed_content.dependency_manifest_digest != original.dependency_manifest_digest
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="dependency symlink fixtures are POSIX-only")
|
||||
def test_dependency_snapshot_preserves_internal_symlinks_and_executable_files(tmp_path: Path) -> None:
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/package/bin": b"#!/bin/sh\nexit 0\n"})
|
||||
executable = repo / "dependency" / "package" / "bin"
|
||||
executable.chmod(0o755)
|
||||
(repo / "dependency" / ".bin").mkdir()
|
||||
(repo / "dependency" / ".bin" / "tool").symlink_to("../package/bin")
|
||||
task = {
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
|
||||
}
|
||||
clone = tmp_path / "clone"
|
||||
clone.mkdir()
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
mount = stage_task_assets(task, repo=repo, clone=clone, snapshot=snapshot)[0]
|
||||
link = mount.source / ".bin" / "tool"
|
||||
captured_executable = mount.source / "package" / "bin"
|
||||
assert link.is_symlink()
|
||||
assert os.readlink(link) == "../package/bin"
|
||||
assert stat.S_IMODE(captured_executable.stat().st_mode) == 0o500
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="dependency symlink fixtures are POSIX-only")
|
||||
def test_dependency_snapshot_rejects_links_that_escape_the_sandbox_workspace(tmp_path: Path) -> None:
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/kept": b"payload"})
|
||||
(repo / "dependency" / "escape").symlink_to("../../../../outside")
|
||||
task = {
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
|
||||
}
|
||||
|
||||
with TaskAssetCache(tmp_path / "cache") as cache:
|
||||
with pytest.raises(SandboxError, match="escapes the sandbox workspace"):
|
||||
cache.prepare(task, repo=repo, resolved_sha=SHA)
|
||||
|
||||
|
||||
def test_resolved_task_binding_carries_dependency_digests_and_rejects_live_drift(tmp_path: Path) -> None:
|
||||
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
|
||||
_git(repo, "init", "--quiet")
|
||||
_git(repo, "config", "user.name", "Workflow Bench Test")
|
||||
_git(repo, "config", "user.email", "workflow-bench@example.invalid")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "--quiet", "-m", "fixture")
|
||||
task = {
|
||||
"id": "dependency-binding",
|
||||
"class": "test",
|
||||
"repo": str(repo),
|
||||
"prompt": "inspect dependency",
|
||||
"verify": "true",
|
||||
"sandbox_copy": [],
|
||||
"sandbox_dependencies": [{"source": "dependency", "target": "dependency"}],
|
||||
}
|
||||
oracle = TaskOracleSnapshot(
|
||||
command="true",
|
||||
command_digest="1" * 64,
|
||||
manifest_digest="2" * 64,
|
||||
digest="3" * 64,
|
||||
files=(),
|
||||
)
|
||||
with TaskAssetCache(tmp_path / "binding-cache") as cache:
|
||||
binding = resolve_task_bindings(
|
||||
[task],
|
||||
oracle_snapshots=[oracle],
|
||||
task_asset_cache=cache,
|
||||
)[0]
|
||||
|
||||
assert len(binding["sandbox_dependency_content_digest"]) == 64
|
||||
assert len(binding["sandbox_dependency_manifest_digest"]) == 64
|
||||
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
|
||||
with pytest.raises(ValueError, match="definition drifted"):
|
||||
resolve_task_bindings([task], [binding], oracle_snapshots=[oracle])
|
||||
370
eval/tests/test_workflow_bench.py
Normal file
370
eval/tests/test_workflow_bench.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"""Unit tests for workflow benchmark aggregation, reporting, task, and CI contracts."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from workflow_bench.runner import (
|
||||
aggregate,
|
||||
build_parser,
|
||||
infra_error_record,
|
||||
normalized_model_identifier,
|
||||
parse_shortstat,
|
||||
render_report,
|
||||
savings,
|
||||
select_tasks,
|
||||
systemic_outage_streak,
|
||||
)
|
||||
|
||||
|
||||
def record(**overrides):
|
||||
base = {
|
||||
"input_tokens": 1000,
|
||||
"cache_creation_input_tokens": 200,
|
||||
"cache_read_input_tokens": 5000,
|
||||
"output_tokens": 400,
|
||||
"cost_usd": 0.5,
|
||||
"duration_s": 60.0,
|
||||
"num_turns": 10,
|
||||
"diff_files": 2,
|
||||
"diff_insertions": 30,
|
||||
"diff_deletions": 5,
|
||||
"class": "demo",
|
||||
"resolved": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_aggregate_takes_medians_and_counts_resolved():
|
||||
records = [
|
||||
record(input_tokens=1000, resolved=True),
|
||||
record(input_tokens=3000, resolved=False),
|
||||
record(input_tokens=2000, resolved=True),
|
||||
]
|
||||
agg = aggregate(records)
|
||||
assert agg == {
|
||||
"input_tokens": 2000,
|
||||
"cache_creation_input_tokens": 200,
|
||||
"cache_read_input_tokens": 5000,
|
||||
"output_tokens": 400,
|
||||
"cost_usd": 0.5,
|
||||
"duration_s": 60.0,
|
||||
"num_turns": 10,
|
||||
"diff_files": 2,
|
||||
"diff_insertions": 30,
|
||||
"diff_deletions": 5,
|
||||
"class": "demo",
|
||||
"resolved": 2,
|
||||
"runs": 3,
|
||||
"valid_runs": 3,
|
||||
"excluded_runs": 0,
|
||||
"transcripts_missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_savings_is_positive_when_workflow_is_cheaper():
|
||||
baseline = aggregate([record(input_tokens=2000, output_tokens=800, cost_usd=1.0)])
|
||||
workflow = aggregate([record(input_tokens=1000, output_tokens=400, cost_usd=0.4)])
|
||||
s = savings(baseline, workflow)
|
||||
assert s["input_tokens"] == 50.0
|
||||
assert s["output_tokens"] == 50.0
|
||||
assert s["cost_usd"] == 60.0
|
||||
|
||||
|
||||
def task_row(task_id: str, **overrides):
|
||||
task = {
|
||||
"id": task_id,
|
||||
"class": "demo",
|
||||
"repo": "/repo",
|
||||
"prompt": "do it",
|
||||
"verify": "true",
|
||||
"oracle": {
|
||||
"command": "true",
|
||||
"files": [
|
||||
{
|
||||
"source": "trivial-version-alias.oracle.test.ts",
|
||||
"target": "oracle.test.ts",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
task.update(overrides)
|
||||
return task
|
||||
|
||||
|
||||
def test_expensive_tasks_are_opt_in_and_reported_as_skipped():
|
||||
tasks = [task_row("default"), task_row("large", expensive=True)]
|
||||
selected, skipped = select_tasks(tasks, include_expensive=False)
|
||||
assert [task["id"] for task in selected] == ["default"]
|
||||
assert skipped == ["large"]
|
||||
|
||||
selected, skipped = select_tasks(tasks, include_expensive=True)
|
||||
assert [task["id"] for task in selected] == ["default", "large"]
|
||||
assert skipped == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["true", 1, None, [], {}])
|
||||
def test_expensive_metadata_must_be_boolean(value):
|
||||
with pytest.raises(ValueError, match="expensive.*boolean"):
|
||||
select_tasks([task_row("bad", expensive=value)], include_expensive=False)
|
||||
|
||||
|
||||
def test_task_selection_rejects_duplicate_ids_and_empty_selection():
|
||||
with pytest.raises(ValueError, match="duplicate task id"):
|
||||
select_tasks([task_row("same"), task_row("same")], include_expensive=True)
|
||||
with pytest.raises(ValueError, match="no tasks selected"):
|
||||
select_tasks([task_row("large", expensive=True)], include_expensive=False)
|
||||
|
||||
|
||||
def test_runner_requires_a_named_model_and_supports_expensive_opt_in():
|
||||
with pytest.raises(SystemExit):
|
||||
build_parser().parse_args(["--tasks", "tasks.yaml"])
|
||||
args = build_parser().parse_args(
|
||||
[
|
||||
"--tasks",
|
||||
"tasks.yaml",
|
||||
"--model",
|
||||
"claude-sonnet-4-20250514",
|
||||
"--include-expensive",
|
||||
]
|
||||
)
|
||||
assert args.include_expensive is True
|
||||
with pytest.raises(ValueError, match="nonblank"):
|
||||
normalized_model_identifier(" ")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
["Auto", "AUTO", "latest", "provider/latest", "provider:Latest", "provider@LATEST"],
|
||||
)
|
||||
def test_runner_rejects_mutable_model_aliases(alias):
|
||||
with pytest.raises(ValueError, match="mutable auto/latest"):
|
||||
normalized_model_identifier(alias)
|
||||
assert normalized_model_identifier("free-coder") == "free-coder"
|
||||
assert normalized_model_identifier("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
workflow = (repo_root / ".github" / "workflows" / "ci-tests.yml").read_text()
|
||||
workflow_document = yaml.safe_load(workflow)
|
||||
containment = workflow_document["jobs"]["eval-containment-linux"]
|
||||
containment_steps = {step.get("name"): step for step in containment["steps"] if "name" in step}
|
||||
containment_node_setup = next(
|
||||
step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@")
|
||||
)
|
||||
claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text())
|
||||
setup_uv = "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990"
|
||||
assert workflow.count(setup_uv) >= 3
|
||||
assert workflow.count("version: '0.11.23'") >= 3
|
||||
assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3
|
||||
assert "eval-containment-linux:" in workflow
|
||||
assert "GITNEXUS_REQUIRE_BWRAP_CANARY: '1'" in workflow
|
||||
assert "GITNEXUS_REQUIRE_CLAUDE_CANARY: '1'" in workflow
|
||||
assert containment["env"] == {
|
||||
"GITNEXUS_REQUIRE_BWRAP_CANARY": "1",
|
||||
"GITNEXUS_REQUIRE_CLAUDE_CANARY": "1",
|
||||
}
|
||||
assert containment["timeout-minutes"] == 20
|
||||
assert containment_node_setup["with"] == {
|
||||
"node-version": "22.16.0",
|
||||
"cache": "npm",
|
||||
"cache-dependency-path": "gitnexus/package-lock.json\ngitnexus-shared/package-lock.json\n",
|
||||
}
|
||||
assert (
|
||||
"CLAUDE_CANARY_BIN: ${{ runner.temp }}/claude-canary/node_modules/@anthropic-ai/claude-code-linux-x64/claude"
|
||||
in workflow
|
||||
)
|
||||
assert ".github/claude-canary-runtime/package-lock.json" in workflow
|
||||
assert "npm ci" in workflow
|
||||
assert "--package-lock=false" not in workflow
|
||||
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["version"] == "2.1.214"
|
||||
assert claude_lock["packages"]["node_modules/@anthropic-ai/claude-code"]["integrity"].startswith("sha512-")
|
||||
assert "if(p.version!=='2.1.214') process.exit(1)" in workflow
|
||||
assert "'2.1.214 (Claude Code)'" in workflow
|
||||
assert containment_steps["Build pinned shared runtime"]["working-directory"] == "gitnexus-shared"
|
||||
assert containment_steps["Build pinned shared runtime"]["run"].splitlines() == [
|
||||
"npm ci",
|
||||
"npm run build",
|
||||
]
|
||||
assert containment_steps["Install and build pinned GitNexus runtime"]["working-directory"] == "gitnexus"
|
||||
assert containment_steps["Install and build pinned GitNexus runtime"]["run"].splitlines() == [
|
||||
"npm ci",
|
||||
"npm run build",
|
||||
]
|
||||
selected_containment_tests = containment_steps["Prove process-tree and sandbox containment"]["run"].split()
|
||||
assert selected_containment_tests == [
|
||||
"uv",
|
||||
"run",
|
||||
"--locked",
|
||||
"--extra",
|
||||
"dev",
|
||||
"python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/test_process_control.py",
|
||||
"tests/test_proposer_sandbox.py",
|
||||
"tests/test_workflow_bench_sessions.py",
|
||||
"tests/test_ce_plugin_runtime.py",
|
||||
"-q",
|
||||
]
|
||||
bwrap_canary_marker = re.compile(
|
||||
r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)',
|
||||
re.MULTILINE,
|
||||
)
|
||||
bwrap_canary_files = sorted(
|
||||
path.name
|
||||
for path in (repo_root / "eval" / "tests").glob("test_*.py")
|
||||
if bwrap_canary_marker.search(path.read_text())
|
||||
)
|
||||
assert bwrap_canary_files == ["test_proposer_sandbox.py", "test_workflow_bench_sessions.py"]
|
||||
assert all(f"tests/{name}" in selected_containment_tests for name in bwrap_canary_files)
|
||||
assert "eval-containment-windows:" in workflow
|
||||
|
||||
|
||||
def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets():
|
||||
task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml"
|
||||
tasks = yaml.safe_load(task_file.read_text())["tasks"]
|
||||
selected, skipped = select_tasks(tasks, include_expensive=False)
|
||||
assert [task["id"] for task in selected] == [
|
||||
"trivial-version-alias",
|
||||
"inv-bug-pdg-note",
|
||||
"inv-feature-list-repos-filter",
|
||||
]
|
||||
assert skipped == ["cross-module-parse-retry"]
|
||||
assert all(not task.get("sandbox_copy") for task in tasks)
|
||||
assert all(task["sandbox_dependencies"] for task in tasks)
|
||||
assert all(task["oracle"]["command"] and task["oracle"]["files"] for task in tasks)
|
||||
assert all("./node_modules/.bin/vitest run" in task["oracle"]["command"] for task in tasks)
|
||||
assert all("npx vitest" not in task["oracle"]["command"] for task in tasks)
|
||||
assert all(
|
||||
'--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"' in task["oracle"]["command"] for task in tasks
|
||||
)
|
||||
assert all({item["target"] for item in task["oracle"]["files"]} >= {"vitest.config.mts"} for task in tasks)
|
||||
|
||||
|
||||
def test_savings_handles_zero_baseline_without_dividing():
|
||||
baseline = aggregate([record(cost_usd=0.0)])
|
||||
workflow = aggregate([record(cost_usd=0.0)])
|
||||
assert savings(baseline, workflow)["cost_usd"] == 0.0
|
||||
|
||||
|
||||
def test_parse_shortstat_full_and_empty():
|
||||
full = parse_shortstat(" 3 files changed, 120 insertions(+), 7 deletions(-)")
|
||||
assert full == {"diff_files": 3, "diff_insertions": 120, "diff_deletions": 7}
|
||||
assert parse_shortstat("") == {
|
||||
"diff_files": 0,
|
||||
"diff_insertions": 0,
|
||||
"diff_deletions": 0,
|
||||
}
|
||||
singular = parse_shortstat(" 1 file changed, 1 insertion(+)")
|
||||
assert singular == {"diff_files": 1, "diff_insertions": 1, "diff_deletions": 0}
|
||||
|
||||
|
||||
def test_render_report_emits_arm_rows_and_per_arm_savings_rows():
|
||||
results = {
|
||||
"demo-task": {
|
||||
"workflow": aggregate([record(input_tokens=1000)]),
|
||||
"workflow_direct": aggregate([record(input_tokens=1500)]),
|
||||
"baseline": aggregate([record(input_tokens=2000)]),
|
||||
}
|
||||
}
|
||||
report = render_report(results)
|
||||
assert "| demo-task | demo | workflow | 1/1 | 1000 |" in report
|
||||
assert "| demo-task | demo | baseline | 1/1 | 2000 |" in report
|
||||
assert "| demo-task | demo | **workflow savings %** | — | 50.0 |" in report
|
||||
assert "| demo-task | demo | **workflow_direct savings %** | — | 25.0 |" in report
|
||||
assert "2/+30/−5" in report
|
||||
assert "results.jsonl" in report
|
||||
assert "subagent spend" in report # token columns are main-loop-only
|
||||
|
||||
|
||||
def test_aggregate_excludes_session_error_rows_from_medians():
|
||||
records = [
|
||||
record(cost_usd=1.0),
|
||||
record(cost_usd=3.0, transcript_missing=True),
|
||||
record(cost_usd=100.0, resolved=False, error_kind="session-error"),
|
||||
]
|
||||
agg = aggregate(records)
|
||||
assert agg["cost_usd"] == 2.0
|
||||
assert agg["runs"] == 3
|
||||
assert agg["valid_runs"] == 2
|
||||
assert agg["excluded_runs"] == 1
|
||||
assert agg["transcripts_missing"] == 1
|
||||
assert agg["resolved"] == 2
|
||||
|
||||
|
||||
def test_aggregate_excludes_unverified_transcript_evidence():
|
||||
agg = aggregate(
|
||||
[
|
||||
record(cost_usd=1.0),
|
||||
record(
|
||||
cost_usd=100.0,
|
||||
resolved=False,
|
||||
error_kind="evidence-unverified",
|
||||
transcript_missing=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
assert agg["cost_usd"] == 1.0
|
||||
assert agg["valid_runs"] == 1
|
||||
assert agg["excluded_runs"] == 1
|
||||
|
||||
|
||||
def test_render_report_surfaces_excluded_and_unverified_runs():
|
||||
results = {
|
||||
"t": {
|
||||
"workflow": aggregate(
|
||||
[
|
||||
record(transcript_missing=True),
|
||||
record(resolved=False, error_kind="session-error"),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
report = render_report(results)
|
||||
assert "| t | demo | workflow | 1/1 (1 excluded) |" in report
|
||||
assert "session/infra errors" in report
|
||||
assert "no locatable session transcript" in report
|
||||
|
||||
|
||||
def test_infra_error_record_captures_the_failure_and_is_excluded():
|
||||
exc = subprocess.TimeoutExpired(cmd="claude -p", timeout=5)
|
||||
rec = infra_error_record(exc)
|
||||
assert rec["ok"] is False
|
||||
assert rec["resolved"] is False
|
||||
assert rec["error_kind"] == "infra-error"
|
||||
assert "TimeoutExpired" in rec["error_detail"]
|
||||
assert rec["output_tokens"] == 0
|
||||
agg = aggregate([record(cost_usd=2.0), rec])
|
||||
assert agg["cost_usd"] == 2.0
|
||||
assert agg["valid_runs"] == 1
|
||||
assert agg["excluded_runs"] == 1
|
||||
|
||||
|
||||
def test_systemic_outage_streak_counts_consecutive_systemic_failures():
|
||||
# session/infra/cleanup failures accumulate; a cleanup-failure that masked a
|
||||
# session-error still counts toward the streak.
|
||||
streak = 0
|
||||
for kind in ("session-error", "infra-error", "cleanup-failure"):
|
||||
streak = systemic_outage_streak(kind, streak)
|
||||
assert streak == 3
|
||||
assert systemic_outage_streak("cleanup-failure", 4) == 5
|
||||
|
||||
|
||||
def test_systemic_outage_streak_resets_on_non_outage():
|
||||
# A real task failure (resolved=False → error_kind None) or an unverifiable
|
||||
# evidence run is not an outage and resets the streak.
|
||||
assert systemic_outage_streak(None, 4) == 0
|
||||
assert systemic_outage_streak("evidence-unverified", 4) == 0
|
||||
|
||||
|
||||
def test_outage_streak_flag_defaults_and_disables():
|
||||
base = ["--tasks", "tasks.yaml", "--model", "claude-sonnet-4-20250514"]
|
||||
assert build_parser().parse_args(base).outage_streak == 5
|
||||
assert build_parser().parse_args([*base, "--outage-streak", "0"]).outage_streak == 0
|
||||
555
eval/tests/test_workflow_bench_evolution.py
Normal file
555
eval/tests/test_workflow_bench_evolution.py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
"""Unit tests for workflow benchmark candidate evolution and promotion gates."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from workflow_bench.evolution import (
|
||||
MAX_CANDIDATE_ENTRIES,
|
||||
apply_candidate_overlay,
|
||||
candidate_overlay_digest,
|
||||
evaluate_candidate,
|
||||
required_candidate_arms,
|
||||
skill_fingerprint,
|
||||
unexercised_overlay_skills,
|
||||
)
|
||||
from workflow_bench.process_control import ManagedProcessResult
|
||||
from workflow_bench.runner import aggregate, build_parser
|
||||
|
||||
|
||||
def record(**overrides):
|
||||
base = {
|
||||
"input_tokens": 1000,
|
||||
"cache_creation_input_tokens": 200,
|
||||
"cache_read_input_tokens": 5000,
|
||||
"output_tokens": 400,
|
||||
"cost_usd": 0.5,
|
||||
"duration_s": 60.0,
|
||||
"num_turns": 10,
|
||||
"diff_files": 2,
|
||||
"diff_insertions": 30,
|
||||
"diff_deletions": 5,
|
||||
"class": "demo",
|
||||
"resolved": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def write_overlay_skill(overlay: Path, skill: str) -> None:
|
||||
path = overlay / ".claude" / "skills" / skill / "SKILL.md"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(f"{skill} candidate\n")
|
||||
|
||||
|
||||
def test_candidate_gate_promotes_quality_preserving_efficiency_gain():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(output_tokens=1000) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(output_tokens=880) for _ in range(3)]),
|
||||
},
|
||||
"task-b": {
|
||||
"workflow_direct": aggregate([record(output_tokens=800) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(output_tokens=720) for _ in range(3)]),
|
||||
},
|
||||
}
|
||||
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
metric="output_tokens",
|
||||
)
|
||||
|
||||
assert decision["decision"] == "promote"
|
||||
assert decision["median_improvement_pct"] == 11.0
|
||||
assert "subagent" in decision["metric_warning"]
|
||||
|
||||
|
||||
def test_num_turns_metric_carries_main_loop_only_warning():
|
||||
# num_turns is a main-loop-only count like output_tokens, so selecting it
|
||||
# must warn that subagent turns are invisible.
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(num_turns=10) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(num_turns=8) for _ in range(3)]),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
metric="num_turns",
|
||||
)
|
||||
assert decision["metric"] == "num_turns"
|
||||
assert decision["metric_warning"] is not None
|
||||
assert "subagent" in decision["metric_warning"]
|
||||
cost_decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
metric="duration_s",
|
||||
)
|
||||
assert cost_decision["metric_warning"] is None
|
||||
|
||||
|
||||
def test_candidate_gate_never_trades_resolution_for_lower_cost():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow": aggregate([record() for _ in range(3)]),
|
||||
"candidate_workflow": aggregate(
|
||||
[
|
||||
record(cost_usd=0.1),
|
||||
record(cost_usd=0.1),
|
||||
record(cost_usd=0.1, resolved=False),
|
||||
]
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
metric="cost_usd",
|
||||
)
|
||||
|
||||
assert decision["decision"] == "keep_incumbent"
|
||||
assert any("resolution regressed" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_gate_requires_repeated_runs_and_a_named_model():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(output_tokens=1000)]),
|
||||
"candidate_workflow_direct": aggregate([record(output_tokens=800)]),
|
||||
}
|
||||
}
|
||||
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model=None,
|
||||
)
|
||||
|
||||
assert decision["decision"] == "insufficient_evidence"
|
||||
assert any("named --model" in reason for reason in decision["reasons"])
|
||||
assert any("at least 3 valid runs" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_gate_caps_large_per_task_efficiency_regressions():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(output_tokens=1000) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(output_tokens=500) for _ in range(3)]),
|
||||
},
|
||||
"task-b": {
|
||||
"workflow_direct": aggregate([record(output_tokens=1000) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(output_tokens=1250) for _ in range(3)]),
|
||||
},
|
||||
}
|
||||
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
metric="output_tokens",
|
||||
)
|
||||
|
||||
assert decision["decision"] == "keep_incumbent"
|
||||
assert any("task cap" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_overlay_is_skill_only_and_content_addressed(tmp_path):
|
||||
overlay = tmp_path / "candidate"
|
||||
skill = overlay / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
|
||||
skill.parent.mkdir(parents=True)
|
||||
skill.write_text("candidate one\n")
|
||||
|
||||
first = candidate_overlay_digest(overlay)
|
||||
skill.write_text("candidate two\n")
|
||||
second = candidate_overlay_digest(overlay)
|
||||
|
||||
assert first != second
|
||||
|
||||
review_overlay = tmp_path / "review-candidate"
|
||||
review_skill = review_overlay / ".claude" / "skills" / "gitnexus-review" / "SKILL.md"
|
||||
review_skill.parent.mkdir(parents=True)
|
||||
review_skill.write_text("review candidate\n")
|
||||
with pytest.raises(ValueError, match="plan,work"):
|
||||
candidate_overlay_digest(review_overlay)
|
||||
|
||||
invalid = tmp_path / "invalid"
|
||||
source = invalid / "gitnexus" / "src" / "cli" / "index.ts"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("gaming the verifier\n")
|
||||
with pytest.raises(ValueError, match="may only contain Markdown files"):
|
||||
candidate_overlay_digest(invalid)
|
||||
|
||||
config_overlay = tmp_path / "config-overlay"
|
||||
config = config_overlay / ".claude" / "skills" / "gitnexus-work" / "mcp.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text("{}\n")
|
||||
with pytest.raises(ValueError, match="may only contain Markdown files"):
|
||||
candidate_overlay_digest(config_overlay)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="overlay symlink coverage is POSIX-only")
|
||||
def test_candidate_overlay_rejects_a_linked_root(tmp_path):
|
||||
real_overlay = tmp_path / "real-overlay"
|
||||
write_overlay_skill(real_overlay, "gitnexus-work")
|
||||
linked_overlay = tmp_path / "linked-overlay"
|
||||
linked_overlay.symlink_to(real_overlay, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="cannot traverse symlinks"):
|
||||
candidate_overlay_digest(linked_overlay)
|
||||
|
||||
|
||||
def test_candidate_overlay_bounds_directory_traversal(tmp_path):
|
||||
overlay = tmp_path / "candidate"
|
||||
write_overlay_skill(overlay, "gitnexus-work")
|
||||
padding = overlay / "padding"
|
||||
padding.mkdir()
|
||||
for index in range(MAX_CANDIDATE_ENTRIES):
|
||||
(padding / f"entry-{index}").mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="entry limit"):
|
||||
candidate_overlay_digest(overlay)
|
||||
|
||||
|
||||
def test_required_candidate_arms_are_minimal_for_touched_skills(tmp_path):
|
||||
plan = tmp_path / "plan"
|
||||
write_overlay_skill(plan, "gitnexus-plan")
|
||||
assert required_candidate_arms(plan) == ["candidate_workflow"]
|
||||
|
||||
work = tmp_path / "work"
|
||||
write_overlay_skill(work, "gitnexus-work")
|
||||
assert required_candidate_arms(work) == [
|
||||
"candidate_workflow",
|
||||
"candidate_workflow_direct",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="candidate overlays require the Linux outer sandbox")
|
||||
def test_apply_candidate_overlay_creates_a_clean_ephemeral_commit(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "--quiet", str(repo)], check=True)
|
||||
incumbent = repo / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
|
||||
incumbent.parent.mkdir(parents=True)
|
||||
incumbent.write_text("incumbent\n")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
"incumbent",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
overlay = tmp_path / "candidate"
|
||||
candidate = overlay / ".claude" / "skills" / "gitnexus-work" / "SKILL.md"
|
||||
candidate.parent.mkdir(parents=True)
|
||||
candidate.write_text("candidate\n")
|
||||
hook_sentinel = tmp_path / "post-commit-ran"
|
||||
post_commit = repo / ".git" / "hooks" / "post-commit"
|
||||
post_commit.write_text(f"#!/bin/sh\ntouch '{hook_sentinel}'\n")
|
||||
post_commit.chmod(0o755)
|
||||
|
||||
class LocalSandbox:
|
||||
def __init__(self):
|
||||
self.clone = repo
|
||||
self.commands: list[list[str]] = []
|
||||
|
||||
def run(self, command, **kwargs):
|
||||
self.commands.append(list(command))
|
||||
if command[0] == "/bin/mkdir":
|
||||
return ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=0,
|
||||
stdout_tail="",
|
||||
stderr_tail="",
|
||||
duration_s=0.0,
|
||||
)
|
||||
translated = [str(repo) if item == "/workspace" else item for item in command]
|
||||
completed = subprocess.run(
|
||||
translated,
|
||||
cwd=repo,
|
||||
env=dict(kwargs["env"]),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return ManagedProcessResult(
|
||||
state="exited",
|
||||
returncode=completed.returncode,
|
||||
stdout_tail=completed.stdout,
|
||||
stderr_tail=completed.stderr,
|
||||
duration_s=0.0,
|
||||
)
|
||||
|
||||
sandbox = LocalSandbox()
|
||||
assert apply_candidate_overlay(
|
||||
overlay,
|
||||
repo,
|
||||
sandbox=sandbox,
|
||||
) == candidate_overlay_digest(overlay)
|
||||
assert incumbent.read_text() == "candidate\n"
|
||||
git_commands = [command for command in sandbox.commands if command[0] == "/usr/bin/git"]
|
||||
assert [command[-1] for command in git_commands[:2]] == [
|
||||
".claude/skills/gitnexus-work/SKILL.md",
|
||||
"--",
|
||||
]
|
||||
assert all("/workspace" in command for command in git_commands)
|
||||
assert all("core.fsmonitor=false" in command for command in git_commands)
|
||||
assert all("core.hooksPath=/tmp/wfbench-empty-hooks" in command for command in git_commands)
|
||||
assert not hook_sentinel.exists()
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(repo), "status", "--porcelain"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert status.stdout == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="candidate overlays require the Linux outer sandbox")
|
||||
def test_candidate_overlay_rejects_linked_destination_parents(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
outside = tmp_path / "outside"
|
||||
(repo / ".claude").mkdir(parents=True)
|
||||
outside.mkdir()
|
||||
(repo / ".claude" / "skills").symlink_to(outside, target_is_directory=True)
|
||||
overlay = tmp_path / "candidate"
|
||||
write_overlay_skill(overlay, "gitnexus-work")
|
||||
sandbox = SimpleNamespace(
|
||||
clone=repo,
|
||||
run=lambda *args, **kwargs: pytest.fail("sandbox git must not run"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="destination parent"):
|
||||
apply_candidate_overlay(overlay, repo, sandbox=sandbox)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="skill links are rejected by the Linux sandbox harness")
|
||||
def test_skill_fingerprint_rejects_linked_skill_roots(tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "SKILL.md").write_text("outside\n")
|
||||
skills = tmp_path / "repo" / ".claude" / "skills"
|
||||
skills.mkdir(parents=True)
|
||||
(skills / "gitnexus-work").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="non-symlink directory"):
|
||||
skill_fingerprint(tmp_path / "repo", "workflow_direct")
|
||||
|
||||
|
||||
def test_cleanup_failures_do_not_count_toward_candidate_evidence():
|
||||
incumbent = aggregate([record(cost_usd=1.0) for _ in range(3)])
|
||||
candidate = aggregate(
|
||||
[
|
||||
record(cost_usd=0.5),
|
||||
record(cost_usd=0.7),
|
||||
record(cost_usd=100.0, resolved=False, error_kind="cleanup-failure"),
|
||||
]
|
||||
)
|
||||
|
||||
assert candidate["cost_usd"] == 0.6
|
||||
assert candidate["valid_runs"] == 2
|
||||
assert candidate["excluded_runs"] == 1
|
||||
decision = evaluate_candidate(
|
||||
{"task": {"workflow": incumbent, "candidate_workflow": candidate}},
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["decision"] == "insufficient_evidence"
|
||||
assert any("needs at least 3 valid runs" in reason for reason in decision["reasons"])
|
||||
assert any("different valid run counts" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_cli_promotion_metric_defaults_to_cost_usd():
|
||||
args = build_parser().parse_args(["--tasks", "tasks.yaml", "--model", "pinned-model"])
|
||||
assert args.promotion_metric == "cost_usd"
|
||||
|
||||
|
||||
def test_candidate_gate_defaults_to_cost_usd_without_a_warning():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(cost_usd=1.0) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate([record(cost_usd=0.5) for _ in range(3)]),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["metric"] == "cost_usd"
|
||||
assert decision["metric_warning"] is None
|
||||
assert decision["decision"] == "promote"
|
||||
|
||||
|
||||
def test_aggregate_cost_unavailable_when_any_run_unmeasured():
|
||||
# One otherwise-valid run whose cost was never measured makes the whole
|
||||
# aggregate cost unavailable, rather than collapsing to a real median.
|
||||
agg = aggregate([record(cost_usd=0.5), record(cost_usd=None), record(cost_usd=0.5)])
|
||||
assert agg["cost_usd"] is None
|
||||
measured = aggregate([record(cost_usd=0.5) for _ in range(3)])
|
||||
assert measured["cost_usd"] == 0.5
|
||||
|
||||
|
||||
def test_candidate_gate_refuses_promotion_on_unmeasured_cost():
|
||||
# Candidate looks cheapest only because one run reported no cost — the gate
|
||||
# must refuse to rank on cost_usd instead of promoting a phantom saving.
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow_direct": aggregate([record(cost_usd=1.0) for _ in range(3)]),
|
||||
"candidate_workflow_direct": aggregate(
|
||||
[record(cost_usd=0.1), record(cost_usd=None), record(cost_usd=0.1)]
|
||||
),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow_direct",
|
||||
candidate_arm="candidate_workflow_direct",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["metric"] == "cost_usd"
|
||||
assert decision["decision"] == "insufficient_evidence"
|
||||
assert any("was not measured on every run" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_gate_requires_equal_valid_run_counts():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow": aggregate([record() for _ in range(4)]),
|
||||
"candidate_workflow": aggregate(
|
||||
[
|
||||
record(),
|
||||
record(),
|
||||
record(),
|
||||
record(resolved=False, error_kind="session-error"),
|
||||
]
|
||||
),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["decision"] == "insufficient_evidence"
|
||||
assert any("different valid run counts" in reason for reason in decision["reasons"])
|
||||
assert decision["tasks"][0]["candidate_excluded_runs"] == 1
|
||||
assert decision["tasks"][0]["incumbent_excluded_runs"] == 0
|
||||
|
||||
|
||||
def test_candidate_gate_rejects_any_excluded_candidate_evidence_even_with_three_clean_successes():
|
||||
incumbent = aggregate([record(cost_usd=1.0) for _ in range(3)])
|
||||
candidate = aggregate(
|
||||
[record(cost_usd=0.01) for _ in range(3)]
|
||||
+ [
|
||||
record(
|
||||
cost_usd=0.0,
|
||||
resolved=False,
|
||||
error_kind="evidence-unverified",
|
||||
)
|
||||
for _ in range(7)
|
||||
]
|
||||
)
|
||||
|
||||
decision = evaluate_candidate(
|
||||
{"task-a": {"workflow": incumbent, "candidate_workflow": candidate}},
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
|
||||
assert candidate["valid_runs"] == 3
|
||||
assert candidate["resolved"] == 3
|
||||
assert decision["decision"] == "insufficient_evidence"
|
||||
assert any("zero excluded runs" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_gate_rejects_a_partial_candidate_even_with_a_resolution_edge():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow": aggregate([record(), record(resolved=False), record(resolved=False)]),
|
||||
"candidate_workflow": aggregate([record(), record(), record(resolved=False)]),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["decision"] == "keep_incumbent"
|
||||
assert any("oracle-backed quality floor" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolved", [0, 2])
|
||||
def test_candidate_gate_never_promotes_zero_or_partial_success_for_efficiency(resolved):
|
||||
incumbent_records = [record(cost_usd=1.0, resolved=index < resolved) for index in range(3)]
|
||||
candidate_records = [record(cost_usd=0.01, resolved=index < resolved) for index in range(3)]
|
||||
decision = evaluate_candidate(
|
||||
{
|
||||
"task-a": {
|
||||
"workflow": aggregate(incumbent_records),
|
||||
"candidate_workflow": aggregate(candidate_records),
|
||||
}
|
||||
},
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
|
||||
assert decision["decision"] == "keep_incumbent"
|
||||
assert decision["tasks"][0]["candidate_quality_floor_met"] is False
|
||||
assert any("oracle-backed quality floor" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_candidate_gate_promotes_on_a_two_run_resolution_margin():
|
||||
results = {
|
||||
"task-a": {
|
||||
"workflow": aggregate([record(), record(resolved=False), record(resolved=False)]),
|
||||
"candidate_workflow": aggregate([record() for _ in range(3)]),
|
||||
}
|
||||
}
|
||||
decision = evaluate_candidate(
|
||||
results,
|
||||
incumbent_arm="workflow",
|
||||
candidate_arm="candidate_workflow",
|
||||
model="pinned-model",
|
||||
)
|
||||
assert decision["decision"] == "promote"
|
||||
assert any("at least 2 required" in reason for reason in decision["reasons"])
|
||||
|
||||
|
||||
def test_overlay_skills_must_be_exercised_by_selected_candidate_arms(tmp_path):
|
||||
plan_overlay = tmp_path / "plan-overlay"
|
||||
write_overlay_skill(plan_overlay, "gitnexus-plan")
|
||||
assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow_direct"]) == ["gitnexus-plan"]
|
||||
assert unexercised_overlay_skills(plan_overlay, ["candidate_workflow"]) == []
|
||||
1014
eval/tests/test_workflow_bench_sessions.py
Normal file
1014
eval/tests/test_workflow_bench_sessions.py
Normal file
File diff suppressed because it is too large
Load diff
428
eval/workflow_bench/README.md
Normal file
428
eval/workflow_bench/README.md
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
# Workflow benchmark — observe the token savings
|
||||
|
||||
Measures whether the `gitnexus-plan` → `gitnexus-work` engineering workflow
|
||||
actually saves tokens versus a baseline agent on the same tasks, using real
|
||||
headless Claude Code sessions. Nothing is estimated: every number comes from
|
||||
the CLI's own final event in its parent-captured `--output-format stream-json`
|
||||
report.
|
||||
|
||||
## What it compares
|
||||
|
||||
| Arm | Sessions | Notes |
|
||||
| --- | --- | --- |
|
||||
| `workflow` | `gitnexus-plan` on the task, then `gitnexus-work` on the produced plan | The skills must be installed (`gitnexus setup`, or repo-local `.claude/skills/`) |
|
||||
| `candidate_workflow` | same sessions as `workflow`, with a candidate skill overlay | Paired with `workflow` on the same task/ref/model |
|
||||
| `workflow_direct` | one `gitnexus-work` direct-mode session | The middle option — execution discipline without a planning pass |
|
||||
| `candidate_workflow_direct` | same session as `workflow_direct`, with a candidate skill overlay | Paired with `workflow_direct` on the same task/ref/model |
|
||||
| `ce_workflow` | `ce-plan` on the task, then `ce-work` on the produced plan | External comparator: the explicitly supplied, pinned compound-engineering plugin's plan→work family |
|
||||
| `ce_workflow_direct` | one `ce-work` direct-mode session | External comparator paired with `workflow_direct` |
|
||||
| `review` | one `gitnexus-review` session over local uncommitted changes | The task's `setup` applies the diff under review; the review is written to `review-output.md` so `verify` can gate on it |
|
||||
| `ce_review` | one `ce-code-review` session over the same changes | External comparator paired with `review` |
|
||||
| `baseline` | one session with the identical task text | `--disallowedTools Skill` so it cannot borrow the workflow; same repo, same MCP tools |
|
||||
| `baseline_nomcp` | like baseline, graph tools also disallowed | Separates the workflow-discipline question from the GitNexus-tools question (off by default) |
|
||||
|
||||
Every arm runs in a fresh detached git worktree of the task's `ref`, once per
|
||||
`--runs`. The model-visible `verify` command is recorded as
|
||||
`authored_tests_passed`, but cannot certify its own solution: `resolved` also
|
||||
requires the task's harness-owned hidden behavioral oracle to pass. Token
|
||||
savings on a failed task are flagged, not celebrated, and diff churn
|
||||
(files/+insertions/−deletions vs the starting commit) is recorded as a cheap
|
||||
over-engineering proxy. Task `class` labels (trivial → investigation →
|
||||
cross-module) make the report readable as a routing table: the boundary where
|
||||
`workflow` starts beating `workflow_direct` and `baseline` is the boundary
|
||||
lfg's gate and work's direct-mode triage should encode.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd eval
|
||||
export GITNEXUS_BENCH_AUTH_TOKEN="$ANTHROPIC_API_KEY"
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks workflow_bench/tasks.scenarios.yaml --runs 3 \
|
||||
--model claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
Scenarios marked `expensive: true` are skipped unless
|
||||
`--include-expensive` is supplied. The report names both selected and skipped
|
||||
tasks so an omitted cell cannot be mistaken for evidence.
|
||||
|
||||
CE comparator arms never discover a user-level plugin. Supply an exact plugin
|
||||
release explicitly; both flags are mandatory whenever any `ce_*` arm is
|
||||
selected:
|
||||
|
||||
```bash
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks workflow_bench/tasks.scenarios.yaml --runs 3 \
|
||||
--model claude-sonnet-4-20250514 \
|
||||
--arms workflow ce_workflow \
|
||||
--ce-plugin-dir /opt/operator-input/compound-engineering-3.19.0 \
|
||||
--ce-plugin-version 3.19.0
|
||||
```
|
||||
|
||||
The runner verifies the manifest version, copies only the plugin manifests,
|
||||
skills, scripts, and assets into a bounded no-symlink snapshot, and mounts
|
||||
that snapshot read-only only for CE arms. Every CE result records its exact
|
||||
plugin version and content-manifest digest.
|
||||
|
||||
Output: `results/wfbench-<timestamp>/results.jsonl` (every run, with session
|
||||
ids for transcript drill-down) and `report.md` (medians per task per arm,
|
||||
plus a savings row: input / cache / output tokens, cost, wall time).
|
||||
|
||||
## Trust model — fail-closed Linux containment
|
||||
|
||||
Task files and candidate prose remain untrusted executable inputs. Every
|
||||
setup, verifier, incumbent, and candidate cell therefore runs in a
|
||||
preflighted Bubblewrap boundary with a private home/config/temp, a
|
||||
self-contained clone, a PID namespace, bounded process-tree ownership, and a
|
||||
deny-by-default environment. Task-declared dependency roots are mounted
|
||||
read-only, while graph assets are rebuilt by the harness as described below.
|
||||
Claude runs in bare,
|
||||
`dontAsk` mode with strict clone-local MCP configuration; Bash children do
|
||||
not inherit the model credential and their network sandbox denies all
|
||||
domains.
|
||||
|
||||
Prebuilt task `.gitnexus` assets are rejected. For each task commit, the
|
||||
harness creates the deterministic parentless snapshot first, removes every
|
||||
analyzer-visible path or stored source reference to the benchmark harness,
|
||||
neutralizes target-controlled GitNexus config/ignore files, and builds one
|
||||
fresh PDG index offline with `--pdg --index-only --no-stats`. It then proves
|
||||
that neither whole graph nodes nor relationships contain a harness marker and
|
||||
caches only the bound metadata/database assets for reuse by paired arms.
|
||||
|
||||
Each selected task also declares a bounded hidden `oracle` command and file
|
||||
set. The harness captures those regular, non-symlink files into an immutable
|
||||
in-memory snapshot before any arm runs and binds the command, paths, sizes, and
|
||||
raw bytes into the task digest. Before any task asset or model session, each
|
||||
disposable clone that contains the benchmark harness is rewritten to a clean,
|
||||
parentless snapshot without `eval/workflow_bench`; all original refs, reflogs,
|
||||
and unreachable Git objects are pruned so `git show` cannot recover the hidden
|
||||
bytes. Only after the model exits (and after the authored-test signal is
|
||||
collected) does the harness materialize the oracle beneath a private host
|
||||
root, mount it read-only at a random workspace sibling, and supply that mount
|
||||
through `GITNEXUS_BENCH_ORACLE_ROOT`. This layout preserves hidden tests'
|
||||
`../gitnexus` imports as the credited candidate checkout. Authored and hidden
|
||||
verifiers run with the complete workspace read-only and networking unshared;
|
||||
hidden stdout/stderr is never persisted. The harness re-checks every oracle
|
||||
byte and erases the mountpoint before churn/patch capture. Shipped Vitest
|
||||
oracles use the staged, digest-bound `vitest.config.mts`; a candidate cannot
|
||||
replace repo test config or setup hooks to make the hidden test vacuously pass.
|
||||
The hidden command invokes the read-only dependency's Vitest binary directly,
|
||||
without an `npx` configuration/resolution layer.
|
||||
|
||||
Every evaluated repo-local skill root is over-mounted read-only for the full
|
||||
model session, and an immutable empty user-level skills directory prevents a
|
||||
writable `$HOME` skill from shadowing it. Skill-use evidence comes only from
|
||||
the bounded stream captured directly from Claude stdout by the parent. The
|
||||
runner parses every event through EOF, requires one final result, correlates
|
||||
an exact Skill request ID with one later successful result, structurally
|
||||
redacts the event objects, and stores the canonical redacted JSONL with a
|
||||
digest. Files written beneath the agent's `$HOME` are never trusted as
|
||||
evidence.
|
||||
|
||||
Bare mode is deliberately non-interactive: it does not consult a stored
|
||||
Claude login/keychain or `ANTHROPIC_AUTH_TOKEN`. Supply one explicit API or
|
||||
proxy key through `GITNEXUS_BENCH_AUTH_TOKEN` (preferred) or `--auth-token`;
|
||||
the harness maps it to `ANTHROPIC_API_KEY` only for the trusted Claude parent
|
||||
and scrubs it from agent-launched tools.
|
||||
|
||||
The trusted Claude CLI still needs outbound access to the explicitly supplied
|
||||
model endpoint. This is not a network broker, so the CLI itself retains that
|
||||
egress; agent-launched tools do not. Missing Bubblewrap, unsupported hosts,
|
||||
invalid mounts, or namespace preflight failure stop before model invocation.
|
||||
Native benchmark execution is therefore Linux/WSL2-only. Evidence assembly
|
||||
and hand-authored overlay preparation can happen elsewhere, but
|
||||
`--initial-overlay` does not bypass containment.
|
||||
|
||||
## Prompt and skill evolution loop
|
||||
|
||||
Prompts age as models and tool harnesses change. Treat the current skills and
|
||||
router thresholds as an incumbent policy, not permanent truth. Candidate
|
||||
changes run offline in the same throwaway clones as the incumbent; production
|
||||
skills never rewrite themselves from a live task.
|
||||
|
||||
Build an overlay that mirrors only the canonical repo-local skill paths:
|
||||
|
||||
```text
|
||||
/tmp/gn-skill-candidate/
|
||||
└── .claude/skills/
|
||||
├── gitnexus-plan/SKILL.md
|
||||
└── gitnexus-work/SKILL.md
|
||||
```
|
||||
|
||||
The overlay may contain Markdown files from either of those two skill
|
||||
trees. The runner rejects every other path, including source, test, and MCP
|
||||
configuration files, so a candidate cannot improve its score by changing the
|
||||
task or verifier. Arm selection is derived from the touched skill and must be
|
||||
exact: a plan-only overlay runs the workflow pair; any work overlay runs both
|
||||
workflow and direct-work pairs. Subsets and unrelated extra pairs fail before
|
||||
paid work. For a work overlay:
|
||||
|
||||
```bash
|
||||
cd eval
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks workflow_bench/tasks.scenarios.yaml \
|
||||
--runs 3 --model claude-sonnet-4-20250514 \
|
||||
--arms workflow candidate_workflow \
|
||||
workflow_direct candidate_workflow_direct \
|
||||
--candidate-overlay /tmp/gn-skill-candidate
|
||||
```
|
||||
|
||||
Candidate runs start from the same task commit, then receive a clean ephemeral
|
||||
commit containing the overlay. `results.jsonl` records the named model, task
|
||||
commit, task-prompt digest, skill digest, overlay digest, hidden-oracle
|
||||
command/manifest/content digests, immutable dependency content/manifest
|
||||
digests, separate authored-test and oracle outcomes,
|
||||
timestamp, local session ids, and digest-bound parent-captured event-stream
|
||||
artifacts. Those artifacts are the trajectory evidence: cluster failures and
|
||||
expensive detours, propose one bounded prompt change, and feed it back as the
|
||||
next overlay.
|
||||
|
||||
When candidate arms are present the runner also writes schema-3
|
||||
`promotion.json`. It
|
||||
binds the immutable overlay digest, benchmark model, truthful candidate origin
|
||||
(a named proposer model or `manual-initial-overlay`), selected
|
||||
task definitions, resolved commits, and exact hidden-oracle bytes/commands,
|
||||
immutable dependency bytes, committed base digest of every apply
|
||||
destination, exact required arms, thresholds, and evidence expiry. Its default
|
||||
deterministic gate is deliberately conservative:
|
||||
|
||||
- at least 3 paired VALID runs per task, zero excluded runs in either arm
|
||||
(session/infra-error rows therefore block promotion), and a named model;
|
||||
- the candidate must pass the hidden oracle on every valid run for every task;
|
||||
- no per-task resolution-rate regression (quality is lexicographically first);
|
||||
- promotion by resolution needs a margin of at least 2 resolved runs —
|
||||
a 1-run difference is noise at this run count and falls through to the
|
||||
efficiency comparison;
|
||||
- with equal quality, at least 5% median improvement on the promotion metric
|
||||
(default `cost_usd` — the only CLI-reported number that includes subagent
|
||||
spend; token metrics count only the main-loop session and flatter
|
||||
subagent-heavy candidates, so selecting one stamps a warning into
|
||||
`promotion.json`);
|
||||
- no individual task may regress the selected efficiency metric by more than
|
||||
20%.
|
||||
|
||||
Tune the efficiency signal with `--promotion-metric` and the three
|
||||
`--promotion-*` thresholds. Applying requires one unique `promote` decision
|
||||
for every bound candidate arm. The driver then stages every canonical and
|
||||
shipped mirror, verifies that all destination bytes still match the bound
|
||||
bases, replaces them as one compare-and-swap set, verifies byte parity, and
|
||||
rolls every landed replacement back on failure or interruption.
|
||||
`keep_incumbent` and
|
||||
`insufficient_evidence` become the next learning queue; their raw
|
||||
`results.jsonl` rows carry the `session_ids` of the trajectories to inspect.
|
||||
|
||||
Re-run the paired suite whenever the named model or tool harness changes, and
|
||||
at least every 90 days otherwise. This is prompt-policy optimization using
|
||||
verified agent trajectories as reward evidence; it is intentionally not
|
||||
online model-weight RL. The same records can feed a later offline RL pipeline
|
||||
without weakening today's deterministic promotion boundary.
|
||||
|
||||
### Closing the loop automatically (`evolve.py`)
|
||||
|
||||
`workflow_bench.evolve` automates the three manual arrows — propose,
|
||||
benchmark, apply — without moving the trust boundary:
|
||||
|
||||
```bash
|
||||
cd eval
|
||||
uv run --locked --extra dev python -m workflow_bench.evolve \
|
||||
--tasks workflow_bench/tasks.scenarios.yaml \
|
||||
--model claude-sonnet-4-20250514 --generations 2 \
|
||||
--seed-results results/wfbench-<prior-run> # optional gen-0 evidence
|
||||
```
|
||||
|
||||
Each generation: a confined **proposer** session reads the incumbent plan/work
|
||||
skills, the prior generation's `results.jsonl`
|
||||
loser rows, their session transcripts and patches, and the learning queue,
|
||||
then writes ONE bounded candidate overlay plus a reviewer-facing
|
||||
`proposal.md`. The overlay is re-validated by `candidate_overlay_files`
|
||||
(same boundary: Markdown under the plan/work trees, nothing else), frozen,
|
||||
and exercised only by its exact required pairs. Task refs are resolved once
|
||||
before generation zero and the immutable task bindings are forwarded to every
|
||||
generated runner invocation, so a moving branch cannot change later evidence.
|
||||
The deterministic gate then decides. Promotion application rejects older
|
||||
pre-oracle evidence schemas. `promote` stops the loop; with `--apply`
|
||||
the authorized frozen bytes
|
||||
are transactionally applied to the canonical
|
||||
`.claude/skills/` trees and their shipped mirrors as an ordinary
|
||||
working-tree diff — committing, CI (`shipped-skills-sync`,
|
||||
`skills-steering`), and the PR merge stay human. `keep_incumbent` feeds that
|
||||
generation's trajectories to the next proposer. `--initial-overlay` skips
|
||||
the generation-0 proposer to benchmark a hand-written candidate;
|
||||
`--proposer-model` upgrades only the diagnosis session.
|
||||
|
||||
**Learning queue.** Live plan/work skill runs never self-edit (see each
|
||||
skill's "Skill feedback" section) — instead they may append one-line JSON notes to
|
||||
`workflow_bench/learnings.jsonl` (gitignored, machine-local like the
|
||||
transcripts they complement). The proposer reads the queue as hints, not
|
||||
ground truth: a learning only reaches a shipped skill by surviving the same
|
||||
paired benchmark as any other candidate. Legacy review/LFG rows are ignored;
|
||||
those skills do not yet have honest candidate lanes or promotion gates.
|
||||
|
||||
Run the driver on the existing re-evaluation triggers (model/harness change,
|
||||
90-day staleness), not on a tight schedule — every generation costs ≥3 paired
|
||||
runs per task, and `--generations` is the only loop bound.
|
||||
|
||||
## Free-model setup (no paid tokens)
|
||||
|
||||
Headless Claude Code honors `ANTHROPIC_BASE_URL`, and litellm (already an
|
||||
eval dependency) can proxy its Anthropic-compatible `/v1/messages` to a model
|
||||
that costs nothing — a hosted OpenRouter `:free` variant or a fully local
|
||||
Ollama model. Config template: `free-model.litellm.yaml`.
|
||||
|
||||
```bash
|
||||
# 1. Choose a proxy master key and start the proxy
|
||||
# (pick/edit a model route in the yaml first; keep the proxy on loopback —
|
||||
# anyone who can reach the port with this key can spend the backend quota)
|
||||
export LITELLM_MASTER_KEY="$(openssl rand -hex 16)"
|
||||
uv run --locked --with 'litellm[proxy]' litellm --config workflow_bench/free-model.litellm.yaml --port 4000
|
||||
|
||||
# 2. Point the benchmark at it
|
||||
uv run --locked --extra dev python -m workflow_bench.runner \
|
||||
--tasks workflow_bench/tasks.scenarios.yaml --runs 3 \
|
||||
--base-url http://localhost:4000 --auth-token "$LITELLM_MASTER_KEY" --model free-coder
|
||||
```
|
||||
|
||||
Caveats, honestly:
|
||||
|
||||
- Both arms run on the same model, so the *comparison* stays fair at any
|
||||
quality level — but small free models follow skills less reliably, so
|
||||
expect lower resolve rates and noisier savings than on frontier models.
|
||||
Treat free-model runs as directional; confirm headline numbers with a
|
||||
small paid run.
|
||||
- Through a proxy `cost_usd` reads ~0, and the CLI's token counts are NOT a
|
||||
substitute "real metric": they cover only the main-loop session, so
|
||||
subagent spend is invisible to both. For efficiency ranking, prefer a paid
|
||||
run gated on `cost_usd`, or sum per-session usage from the transcripts
|
||||
(`~/.claude/projects/<cwd-slug>/<session_id>.jsonl`, deduplicating events
|
||||
that share one `message.id`).
|
||||
- OpenRouter `:free` variants are rate-limited (~50 req/day on a fresh
|
||||
account); local Ollama has no limits.
|
||||
- Codex users: `codex exec --oss` runs local models for free too, but this
|
||||
runner is Claude-Code-first; a codex engine is a straightforward extension
|
||||
(parse its `--json` usage events).
|
||||
|
||||
## Historical ground base (2026-07-11, Claude Code 2.1.207, unnamed model, n=1/cell)
|
||||
|
||||
These figures predate mandatory model provenance and are retained only as
|
||||
historical calibration. They are not eligible promotion evidence and must not
|
||||
be combined with current named-model runs.
|
||||
|
||||
Three task classes × three arms, single-repo (GitNexus itself). **Every arm
|
||||
resolved every task** — at this difficulty, pass/fail quality is saturated
|
||||
and the comparison is pure cost:
|
||||
|
||||
| task (class) | arm | resolved | cost $ | wall | turns | vs baseline cost |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| trivial-version-alias | workflow | 1/1 | 9.16 | 16m | 63 | −333% |
|
||||
| trivial-version-alias | baseline | 1/1 | 2.11 | 2.8m | 16 | — |
|
||||
| inv-bug-pdg-note | workflow | 1/1 | 14.56 | 21m | 83 | −331% |
|
||||
| inv-bug-pdg-note | workflow_direct | 1/1 | 5.23 | 7.5m | 32 | −55% |
|
||||
| inv-bug-pdg-note | baseline | 1/1 | 3.38 | 4.7m | 22 | — |
|
||||
| inv-feature-list-repos-filter | workflow | 1/1 | 13.22 | 19m | 84 | −211% |
|
||||
| inv-feature-list-repos-filter | workflow_direct | 1/1 | 4.87 | 4.8m | 38 | −15% (wall +14% faster) |
|
||||
| inv-feature-list-repos-filter | baseline | 1/1 | 4.25 | 5.5m | 32 | — |
|
||||
|
||||
What the ground base says, honestly:
|
||||
|
||||
- **The full plan→work workflow never paid for itself at this task scale**
|
||||
(tasks a baseline agent finishes in ≤35 turns). Its fixed cost — freshness
|
||||
gate incl. analyzer rebuild + re-index, a full 13-section plan, work-phase
|
||||
re-anchoring — is ~$9–11 per task and needs much larger tasks, plan-reuse
|
||||
(one plan, several executors/sessions), or plan-as-deliverable flows to
|
||||
amortize.
|
||||
- **workflow_direct is close to baseline** (−15% to −55% cost, once slightly
|
||||
faster wall) — the execution discipline (impact-before-edit,
|
||||
detect_changes-before-commit) is cheap. It produced noticeably more test
|
||||
coverage than baseline for near-equal cost on the feature task.
|
||||
- **Quality didn't differentiate because nothing failed.** The regime where
|
||||
the workflow should win on *resolve rate* — cross-module tasks where
|
||||
baselines flail — is the unmeasured cell (`cross-module-parse-retry`), and
|
||||
the next thing to measure, ideally with `--runs 3+` on a free backend.
|
||||
- Caveats: n=1 per cell, one repo, one model; churn numbers from this run
|
||||
predate the intent-to-add/exclude-plans churn fix, so they are not
|
||||
comparable across arms and are omitted above.
|
||||
|
||||
Routing implication (to revisit as cells fill in): for tasks up to this
|
||||
size, `gitnexus-work` direct mode or a plain agent is the cost-optimal
|
||||
route; reserve full `gitnexus-plan` → `gitnexus-work` for cross-module work,
|
||||
multi-session execution, or when the plan document itself is a deliverable.
|
||||
If a future run shows the workflow flattering itself here, distrust the run.
|
||||
|
||||
### Cross-module cell (same day, optimized skills, n=1)
|
||||
|
||||
The hardest class — retry-with-backoff across the worker-pool/pipeline
|
||||
seams, transient-vs-deterministic classification:
|
||||
|
||||
| arm | resolved | cost $ | wall | turns | churn |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| workflow | 1/1 | 18.32 | 37m | 107 | 4/+373/−17 |
|
||||
| **workflow_direct** | 1/1 | **9.53** | **15m** | **52** | 11/+244/−66 |
|
||||
| baseline | 1/1 | 18.03 | 34m | 98 | 6/+345/−69 |
|
||||
|
||||
(The workflow_direct row is the clean re-run under clone isolation — the
|
||||
original was contaminated, see the integrity note below.)
|
||||
|
||||
**This is the cell where the discipline pays.** `workflow_direct` — the
|
||||
execution skill without a planning pass — beat a plain agent by **47% cost
|
||||
and 56% wall time** on the hardest class while resolving: impact-first
|
||||
navigation and gated commits prevented the flailing that baseline's 98
|
||||
turns represent. The full workflow's premium vanished (−1.6% vs baseline;
|
||||
−211%..−333% on smaller classes) — fixed costs amortize here, with a less
|
||||
destructive diff and a durable plan artifact — but it didn't beat direct
|
||||
mode on any measured axis with the plan consumed only once. Resolve rate
|
||||
stayed tied across all cells; the savings story belongs to the execution
|
||||
discipline, and the planning pass is bought for its artifact (multi-session
|
||||
reuse, review, handoff), not for same-session token savings.
|
||||
|
||||
**Benchmark integrity note (why churn earns its keep):** the original
|
||||
`workflow_direct` cell reported an impossible 28-turn/$4.71 solve with churn
|
||||
byte-identical to the workflow arm — because `git worktree add` shares the
|
||||
ref namespace, the workflow arm's slug branch survived worktree removal, and
|
||||
the direct arm found and adopted the finished work. Fixed by giving every
|
||||
arm an isolated `git clone --no-local --no-hardlinks` with no object
|
||||
alternates (agent-created refs and storage die with the clone);
|
||||
the leaked branch was deleted and the cell re-measured. Treat identical
|
||||
churn fingerprints across arms as a contamination alarm.
|
||||
|
||||
### Optimization re-measurement (same day, commit 830a0459)
|
||||
|
||||
After category-priced plan forms (compact ≤80 lines + mini-pack),
|
||||
category-priced freshness (`accept` for compact classes), per-category turn
|
||||
budgets, and the work-phase HEAD==pin fast path, the same
|
||||
`inv-bug-pdg-note` workflow cell re-measured (n=1):
|
||||
|
||||
| | ground base | optimized | delta |
|
||||
| --- | --- | --- | --- |
|
||||
| resolved | ✅ | ✅ | — |
|
||||
| cost $ | 14.56 | 11.70 | **−20%** |
|
||||
| turns | 83 | 72 | −13% |
|
||||
| output tokens | 59,789 | 53,345 | −11% |
|
||||
| cache_read | 6.64M | 5.07M | −24% |
|
||||
| wall | 21m | 25m | +15% |
|
||||
|
||||
Verified in-transcript: the compact form fired (115-line plan vs 209 for a
|
||||
simpler task pre-optimization), the plan session dropped 72→49 turns, and
|
||||
NO analyzer rebuild/re-index executed. All savings came from the plan side;
|
||||
this run's work session drew a long test-debugging tail (hence the wall
|
||||
regression) — single-run variance cuts both ways. The optimizations narrow
|
||||
the gap but do not flip the regime: the workflow remains ~3.5× baseline on
|
||||
this task class, so the routing rule above stands unchanged.
|
||||
|
||||
## Writing good tasks
|
||||
|
||||
See `tasks.scenarios.yaml`. Small enough to finish headless, real enough to
|
||||
require investigation — the workflow's savings come from *not re-reading and
|
||||
not re-investigating*, which trivial tasks never exercise. Keep `verify` as a
|
||||
model-visible authored-test quality signal, and add an independent `oracle`
|
||||
whose source files live under `workflow_bench/oracles/`. Oracle commands must
|
||||
run only files staged beneath `$GITNEXUS_BENCH_ORACLE_ROOT`; for Vitest, include
|
||||
the shared `vitest.config.mts` as an oracle file and pass it explicitly with
|
||||
`--config`. Prefer `verify` commands that use the repo's own npm scripts (they
|
||||
carry build pre-hooks).
|
||||
|
||||
## Relation to the SWE-bench harness
|
||||
|
||||
The rest of `eval/` benchmarks GitNexus *tools* inside a litellm agent loop
|
||||
(baseline vs graph-enhanced). This module benchmarks the *skill workflow*
|
||||
inside the real CLI harness those skills ship for. Different question, same
|
||||
spirit: measure, don't assume.
|
||||
7
eval/workflow_bench/__init__.py
Normal file
7
eval/workflow_bench/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""Benchmark the gitnexus-plan / gitnexus-work engineering workflow.
|
||||
|
||||
Runs real headless Claude Code sessions (``claude -p --output-format json``)
|
||||
in throwaway git worktrees, one arm using the skill workflow and one baseline
|
||||
arm without it, and reports per-arm token usage, cost, wall time, and task
|
||||
resolution so the workflow's token savings are observable rather than assumed.
|
||||
"""
|
||||
645
eval/workflow_bench/evolution.py
Normal file
645
eval/workflow_bench/evolution.py
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
"""Skill-candidate isolation, provenance, and deterministic promotion policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import statistics
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .process_control import ManagedProcessError
|
||||
from .proposer_sandbox import (
|
||||
SANDBOX_TMP,
|
||||
SANDBOX_WORKSPACE,
|
||||
SandboxSession,
|
||||
build_sandbox_environment,
|
||||
)
|
||||
|
||||
CANDIDATE_ARMS = {
|
||||
"candidate_workflow": "workflow",
|
||||
"candidate_workflow_direct": "workflow_direct",
|
||||
}
|
||||
CANDIDATE_SKILLS = {
|
||||
"gitnexus-plan",
|
||||
"gitnexus-work",
|
||||
}
|
||||
# Skills each incumbent arm actually loads in its sessions. An overlay that
|
||||
# only touches other skills would never be exercised — the gate would decide
|
||||
# from noise — so such overlays are rejected up front.
|
||||
ARM_SKILLS = {
|
||||
"workflow": ("gitnexus-plan", "gitnexus-work"),
|
||||
"workflow_direct": ("gitnexus-work",),
|
||||
}
|
||||
# Repo-local prompts whose bytes are evidence for each executed arm. Keep this
|
||||
# distinct from ``ARM_SKILLS``: that mapping defines which skills a promotable
|
||||
# plan/work overlay must exercise, while this mapping also protects read-only
|
||||
# review evaluation from task setup and review-phase prompt replacement.
|
||||
EVALUATED_ARM_SKILLS = {
|
||||
**ARM_SKILLS,
|
||||
"review": ("gitnexus-review",),
|
||||
}
|
||||
PROMOTION_METRICS = ("output_tokens", "cost_usd", "duration_s", "num_turns")
|
||||
# Token/turn metrics come from the CLI's top-level `usage`, which counts ONLY
|
||||
# the main-loop session. `total_cost_usd` is the only reported number that
|
||||
# includes subagent spend.
|
||||
MAIN_LOOP_ONLY_METRICS = frozenset({"output_tokens", "num_turns"})
|
||||
MAIN_LOOP_ONLY_WARNING = (
|
||||
"WARNING: token and turn metrics count only the main-loop session — subagent spend "
|
||||
"is invisible to them and systematically flatters subagent-heavy "
|
||||
"candidates. Prefer cost_usd (the only CLI-reported field that includes "
|
||||
"subagents), or sum usage from the digest-bound transcript_artifacts in "
|
||||
"each run output, deduplicating events "
|
||||
"that share one message.id."
|
||||
)
|
||||
EVIDENCE_MAX_AGE_DAYS = 90
|
||||
MAX_CANDIDATE_OVERLAY_BYTES = 4 * 1024 * 1024
|
||||
MAX_SKILL_FINGERPRINT_BYTES = 4 * 1024 * 1024
|
||||
MAX_CANDIDATE_ENTRIES = 256
|
||||
MAX_CANDIDATE_FILES = 64
|
||||
MAX_CANDIDATE_PATH_BYTES = 512
|
||||
|
||||
|
||||
def _require_real_directory(path: Path, *, label: str) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"{label} is unavailable: {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"{label} must be a real non-symlink directory: {path}")
|
||||
|
||||
|
||||
def _require_directory_chain(root: Path, relative: Path, *, label: str) -> None:
|
||||
"""Validate each lexical directory without erasing links via resolve()."""
|
||||
|
||||
_require_real_directory(root, label=label)
|
||||
current = root
|
||||
for part in relative.parts:
|
||||
if part in {"", ".", ".."}:
|
||||
raise ValueError(f"{label} contains an unsafe path component: {relative}")
|
||||
current /= part
|
||||
_require_real_directory(current, label=label)
|
||||
|
||||
|
||||
def _bounded_regular_bytes(path: Path, *, limit: int, label: str) -> bytes:
|
||||
"""Read one bounded regular file without following its leaf link."""
|
||||
|
||||
try:
|
||||
before = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"{label} is unreadable: {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise ValueError(f"{label} must be a regular non-symlink file: {path}")
|
||||
if before.st_size > limit:
|
||||
raise ValueError(f"{label} exceeds the bounded evidence limit")
|
||||
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or opened.st_dev != before.st_dev or opened.st_ino != before.st_ino:
|
||||
raise ValueError(f"{label} changed while opening: {path}")
|
||||
chunks: list[bytes] = []
|
||||
remaining = limit + 1
|
||||
while remaining > 0:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
content = b"".join(chunks)
|
||||
if len(content) > limit:
|
||||
raise ValueError(f"{label} exceeds the bounded evidence limit")
|
||||
after = os.fstat(descriptor)
|
||||
if (
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
opened.st_size,
|
||||
opened.st_mtime_ns,
|
||||
) != (
|
||||
after.st_dev,
|
||||
after.st_ino,
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
) or len(content) != opened.st_size:
|
||||
raise ValueError(f"{label} changed while being read: {path}")
|
||||
return content
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def candidate_overlay_payload(overlay: Path) -> tuple[str, list[tuple[PurePosixPath, bytes]]]:
|
||||
"""Return the sole validated, bounded candidate payload and its digest."""
|
||||
|
||||
root = overlay.expanduser().absolute()
|
||||
payload: list[tuple[PurePosixPath, bytes]] = []
|
||||
remaining = MAX_CANDIDATE_OVERLAY_BYTES
|
||||
for source in candidate_overlay_files(root):
|
||||
relative = PurePosixPath(source.relative_to(root).as_posix())
|
||||
_require_directory_chain(
|
||||
root,
|
||||
Path(*relative.parent.parts),
|
||||
label="candidate overlay directory",
|
||||
)
|
||||
content = _bounded_regular_bytes(
|
||||
source,
|
||||
limit=remaining,
|
||||
label="candidate overlay file",
|
||||
)
|
||||
remaining -= len(content)
|
||||
payload.append((relative, content))
|
||||
return _fingerprint_payload(payload), payload
|
||||
|
||||
|
||||
def _fingerprint_payload(payload: list[tuple[PurePosixPath, bytes]]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for relative_path, content in payload:
|
||||
relative = relative_path.as_posix().encode()
|
||||
digest.update(len(relative).to_bytes(8, "big"))
|
||||
digest.update(relative)
|
||||
digest.update(len(content).to_bytes(8, "big"))
|
||||
digest.update(content)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _replace_regular_file(root: Path, relative: Path, content: bytes) -> None:
|
||||
"""Replace a clone file through validated directory descriptors."""
|
||||
|
||||
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
|
||||
raise ValueError(f"candidate destination escapes the clone: {relative}")
|
||||
_require_real_directory(root, label="candidate destination root")
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(root, directory_flags)
|
||||
try:
|
||||
for part in relative.parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o700, dir_fd=descriptor)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
child = os.open(part, directory_flags, dir_fd=descriptor)
|
||||
except OSError as exc:
|
||||
raise ValueError(
|
||||
f"candidate destination parent must be a real directory: {relative.parent}: {exc}"
|
||||
) from exc
|
||||
os.close(descriptor)
|
||||
descriptor = child
|
||||
|
||||
leaf = relative.name
|
||||
try:
|
||||
existing = os.stat(leaf, dir_fd=descriptor, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
except OSError as exc:
|
||||
raise ValueError(f"candidate destination is unreadable: {relative}: {exc}") from exc
|
||||
if existing is not None and (stat.S_ISLNK(existing.st_mode) or not stat.S_ISREG(existing.st_mode)):
|
||||
raise ValueError(f"candidate destination must be a regular non-symlink file: {relative}")
|
||||
|
||||
temporary = f".wfbench-overlay-{secrets.token_hex(12)}"
|
||||
temp_descriptor = os.open(
|
||||
temporary,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
dir_fd=descriptor,
|
||||
)
|
||||
try:
|
||||
view = memoryview(content)
|
||||
while view:
|
||||
written = os.write(temp_descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError("short write while staging candidate overlay")
|
||||
view = view[written:]
|
||||
os.fchmod(temp_descriptor, 0o644)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(temp_descriptor)
|
||||
try:
|
||||
os.replace(
|
||||
temporary,
|
||||
leaf,
|
||||
src_dir_fd=descriptor,
|
||||
dst_dir_fd=descriptor,
|
||||
)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sandbox_overlay_git(
|
||||
sandbox: SandboxSession,
|
||||
args: list[str],
|
||||
*,
|
||||
extra_config: tuple[str, ...] = (),
|
||||
) -> Any:
|
||||
hooks = f"{SANDBOX_TMP}/wfbench-empty-hooks"
|
||||
command = [
|
||||
"/usr/bin/git",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
f"core.hooksPath={hooks}",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
]
|
||||
for item in extra_config:
|
||||
command.extend(("-c", item))
|
||||
command.extend(("-C", SANDBOX_WORKSPACE, *args))
|
||||
result = sandbox.run(
|
||||
command,
|
||||
timeout=60,
|
||||
env=build_sandbox_environment(),
|
||||
)
|
||||
return command, result
|
||||
|
||||
|
||||
def candidate_overlay_files(overlay: Path) -> list[Path]:
|
||||
"""Return a candidate's files after enforcing the benchmark trust boundary.
|
||||
|
||||
Candidates may change only the canonical repo-local skill prompts. They
|
||||
cannot modify task code, tests, or verification commands and thereby game
|
||||
the promotion gate.
|
||||
"""
|
||||
overlay = overlay.expanduser().absolute()
|
||||
try:
|
||||
resolved_overlay = overlay.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"candidate overlay is not a directory: {overlay}") from exc
|
||||
if resolved_overlay != overlay:
|
||||
raise ValueError(f"candidate overlay cannot traverse symlinks: {overlay}")
|
||||
_require_real_directory(overlay, label="candidate overlay")
|
||||
|
||||
entries: list[Path] = []
|
||||
pending = [overlay]
|
||||
entry_count = 0
|
||||
while pending:
|
||||
directory = pending.pop()
|
||||
child_directories: list[Path] = []
|
||||
try:
|
||||
iterator = os.scandir(directory)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"candidate overlay directory is unreadable: {directory}: {exc}") from exc
|
||||
with iterator:
|
||||
for item in iterator:
|
||||
entry_count += 1
|
||||
if entry_count > MAX_CANDIDATE_ENTRIES:
|
||||
raise ValueError(f"candidate overlay exceeds the {MAX_CANDIDATE_ENTRIES}-entry limit")
|
||||
path = Path(item.path)
|
||||
relative = path.relative_to(overlay)
|
||||
if len(relative.as_posix().encode()) > MAX_CANDIDATE_PATH_BYTES:
|
||||
raise ValueError(f"candidate overlay path exceeds {MAX_CANDIDATE_PATH_BYTES} bytes: {relative}")
|
||||
if item.is_symlink():
|
||||
raise ValueError(f"candidate overlay cannot contain symlinks: {relative}")
|
||||
if item.is_dir(follow_symlinks=False):
|
||||
child_directories.append(path)
|
||||
continue
|
||||
if not item.is_file(follow_symlinks=False):
|
||||
raise ValueError(f"candidate overlay entries must be regular files: {relative}")
|
||||
entries.append(path)
|
||||
if len(entries) > MAX_CANDIDATE_FILES:
|
||||
raise ValueError(f"candidate overlay exceeds the {MAX_CANDIDATE_FILES}-file limit")
|
||||
pending.extend(child_directories)
|
||||
|
||||
entries.sort(key=lambda path: path.relative_to(overlay).as_posix())
|
||||
if not entries:
|
||||
raise ValueError(f"candidate overlay contains no files: {overlay}")
|
||||
|
||||
for path in entries:
|
||||
relative = path.relative_to(overlay)
|
||||
parts = relative.parts
|
||||
if (
|
||||
len(parts) < 4
|
||||
or parts[:2] != (".claude", "skills")
|
||||
or parts[2] not in CANDIDATE_SKILLS
|
||||
or path.suffix.lower() != ".md"
|
||||
):
|
||||
raise ValueError(
|
||||
"candidate overlays may only contain Markdown files under "
|
||||
".claude/skills/gitnexus-{plan,work}: "
|
||||
f"{relative}"
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def required_candidate_arms(overlay: Path) -> list[str]:
|
||||
"""Return the smallest candidate-arm set that exercises every change.
|
||||
|
||||
Plan prompts are loaded only by the two-session workflow. Work prompts are
|
||||
loaded by both workflow shapes, so a work candidate must prove itself in
|
||||
both rather than inheriting a decision from an untested execution mode.
|
||||
"""
|
||||
overlay = overlay.expanduser().absolute()
|
||||
touched = {path.relative_to(overlay).parts[2] for path in candidate_overlay_files(overlay)}
|
||||
required: list[str] = []
|
||||
if "gitnexus-plan" in touched or "gitnexus-work" in touched:
|
||||
required.append("candidate_workflow")
|
||||
if "gitnexus-work" in touched:
|
||||
required.append("candidate_workflow_direct")
|
||||
return required
|
||||
|
||||
|
||||
def fingerprint_files(root: Path, files: list[Path]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in files:
|
||||
relative = path.relative_to(root).as_posix().encode()
|
||||
content = path.read_bytes()
|
||||
digest.update(len(relative).to_bytes(8, "big"))
|
||||
digest.update(relative)
|
||||
digest.update(len(content).to_bytes(8, "big"))
|
||||
digest.update(content)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def candidate_overlay_digest(overlay: Path) -> str:
|
||||
digest, _ = candidate_overlay_payload(overlay)
|
||||
return digest
|
||||
|
||||
|
||||
def apply_candidate_overlay(
|
||||
overlay: Path,
|
||||
worktree: Path,
|
||||
*,
|
||||
sandbox: SandboxSession,
|
||||
) -> str:
|
||||
"""Safely copy and commit a prompt candidate inside its outer sandbox."""
|
||||
|
||||
overlay = overlay.expanduser().absolute()
|
||||
expected_clone = Path(os.path.abspath(worktree.expanduser()))
|
||||
sandbox_clone = Path(os.path.abspath(sandbox.clone.expanduser()))
|
||||
if sandbox_clone != expected_clone:
|
||||
raise ValueError("candidate sandbox does not bind the requested clone")
|
||||
digest, payload = candidate_overlay_payload(overlay)
|
||||
relative_paths: list[str] = []
|
||||
for relative, content in payload:
|
||||
_replace_regular_file(worktree, relative, content)
|
||||
relative_paths.append(relative.as_posix())
|
||||
|
||||
mkdir_command = ["/bin/mkdir", "-p", f"{SANDBOX_TMP}/wfbench-empty-hooks"]
|
||||
mkdir_result = sandbox.run(
|
||||
mkdir_command,
|
||||
timeout=60,
|
||||
env=build_sandbox_environment(),
|
||||
)
|
||||
if not mkdir_result.ok:
|
||||
raise ManagedProcessError(mkdir_command, mkdir_result)
|
||||
|
||||
command, added = _sandbox_overlay_git(sandbox, ["add", "--", *relative_paths])
|
||||
if not added.ok:
|
||||
raise ManagedProcessError(command, added)
|
||||
command, changed = _sandbox_overlay_git(
|
||||
sandbox,
|
||||
["diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--"],
|
||||
)
|
||||
if changed.returncode == 0:
|
||||
raise ValueError("candidate overlay is byte-identical to the incumbent skills")
|
||||
if changed.returncode != 1:
|
||||
raise ManagedProcessError(command, changed)
|
||||
|
||||
command, committed = _sandbox_overlay_git(
|
||||
sandbox,
|
||||
[
|
||||
"commit",
|
||||
"--quiet",
|
||||
"--no-verify",
|
||||
"-m",
|
||||
"benchmark candidate skill overlay",
|
||||
],
|
||||
extra_config=(
|
||||
"user.name=workflow-bench",
|
||||
"user.email=workflow-bench@invalid",
|
||||
),
|
||||
)
|
||||
if not committed.ok:
|
||||
raise ManagedProcessError(command, committed)
|
||||
return digest
|
||||
|
||||
|
||||
def unexercised_overlay_skills(overlay: Path, candidate_arms: list[str]) -> list[str]:
|
||||
"""Overlay skills that no selected candidate arm would ever load.
|
||||
|
||||
A gitnexus-lfg-only (or gitnexus-review-only) overlay paired with the
|
||||
workflow arms is never read by any benchmarked session, so any promotion
|
||||
decision about it would be noise.
|
||||
"""
|
||||
overlay = overlay.expanduser().absolute()
|
||||
exercised = {skill for arm in candidate_arms for skill in ARM_SKILLS[CANDIDATE_ARMS[arm]]}
|
||||
touched = {path.relative_to(overlay).parts[2] for path in candidate_overlay_files(overlay)}
|
||||
return sorted(touched - exercised)
|
||||
|
||||
|
||||
def skill_fingerprint(worktree: Path, arm: str) -> str | None:
|
||||
skill_names = EVALUATED_ARM_SKILLS.get(arm)
|
||||
if skill_names is None:
|
||||
return None
|
||||
|
||||
worktree = worktree.expanduser().absolute()
|
||||
_require_real_directory(worktree, label="skill fingerprint worktree")
|
||||
_require_directory_chain(
|
||||
worktree,
|
||||
Path(".claude") / "skills",
|
||||
label="skill fingerprint parent",
|
||||
)
|
||||
for skill_name in skill_names:
|
||||
_require_directory_chain(
|
||||
worktree,
|
||||
Path(".claude") / "skills" / skill_name,
|
||||
label="skill fingerprint root",
|
||||
)
|
||||
|
||||
entries = sorted(
|
||||
(path for skill_name in skill_names for path in (worktree / ".claude" / "skills" / skill_name).rglob("*")),
|
||||
key=lambda path: path.relative_to(worktree).as_posix(),
|
||||
)
|
||||
files: list[Path] = []
|
||||
total = 0
|
||||
for path in entries:
|
||||
metadata = path.lstat()
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
continue
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(f"skill fingerprint input must be a regular non-symlink file: {path}")
|
||||
total += metadata.st_size
|
||||
if total > MAX_SKILL_FINGERPRINT_BYTES:
|
||||
raise ValueError("skill fingerprint input exceeds the bounded evidence limit")
|
||||
files.append(path)
|
||||
return fingerprint_files(worktree, files)
|
||||
|
||||
|
||||
def evaluate_candidate(
|
||||
results: dict[str, dict[str, dict[str, Any]]],
|
||||
*,
|
||||
incumbent_arm: str,
|
||||
candidate_arm: str,
|
||||
model: str | None,
|
||||
metric: str = "cost_usd",
|
||||
min_runs: int = 3,
|
||||
min_improvement_pct: float = 5.0,
|
||||
max_task_regression_pct: float = 20.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministically decide whether a prompt candidate is promotable.
|
||||
|
||||
Resolution is lexicographically primary: a cheaper candidate that fails
|
||||
more tasks never wins. With equal quality, the candidate must clear the
|
||||
configured median efficiency gain without a large per-task regression.
|
||||
"""
|
||||
if metric not in PROMOTION_METRICS:
|
||||
raise ValueError(f"unsupported promotion metric: {metric}")
|
||||
|
||||
reasons: list[str] = []
|
||||
task_rows: list[dict[str, Any]] = []
|
||||
insufficient = False
|
||||
quality_regression = False
|
||||
quality_floor_failed = False
|
||||
efficiency_regression = False
|
||||
|
||||
if not model:
|
||||
insufficient = True
|
||||
reasons.append("a named --model is required so prompt evidence cannot drift")
|
||||
|
||||
for task_id, arms in sorted(results.items()):
|
||||
if incumbent_arm not in arms or candidate_arm not in arms:
|
||||
insufficient = True
|
||||
reasons.append(f"{task_id}: both {incumbent_arm} and {candidate_arm} are required")
|
||||
continue
|
||||
|
||||
incumbent = arms[incumbent_arm]
|
||||
candidate = arms[candidate_arm]
|
||||
# Session/infra-error rows carry no measured evidence: only VALID runs
|
||||
# count toward the run minimum, the pairing check, and resolve rates.
|
||||
incumbent_runs = int(incumbent.get("valid_runs", incumbent["runs"]))
|
||||
candidate_runs = int(candidate.get("valid_runs", candidate["runs"]))
|
||||
incumbent_excluded = int(incumbent.get("excluded_runs", 0))
|
||||
candidate_excluded = int(candidate.get("excluded_runs", 0))
|
||||
incumbent_rate = incumbent["resolved"] / incumbent_runs if incumbent_runs else 0.0
|
||||
candidate_rate = candidate["resolved"] / candidate_runs if candidate_runs else 0.0
|
||||
# cost_usd is None when a run's cost was never measured (see
|
||||
# runner_sessions.measured_cost): the arm's aggregate cost is then
|
||||
# unavailable and must not be ranked on, or a candidate could "win"
|
||||
# cheapness it never actually demonstrated.
|
||||
raw_incumbent_metric = incumbent.get(metric)
|
||||
raw_candidate_metric = candidate.get(metric)
|
||||
metric_unavailable = raw_incumbent_metric is None or raw_candidate_metric is None
|
||||
incumbent_metric = None if raw_incumbent_metric is None else float(raw_incumbent_metric)
|
||||
candidate_metric = None if raw_candidate_metric is None else float(raw_candidate_metric)
|
||||
improvement = (
|
||||
round(100 * (incumbent_metric - candidate_metric) / incumbent_metric, 1)
|
||||
if (not metric_unavailable and incumbent_metric)
|
||||
else None
|
||||
)
|
||||
task_rows.append(
|
||||
{
|
||||
"task": task_id,
|
||||
"class": incumbent.get("class", ""),
|
||||
"incumbent_resolved": f"{incumbent['resolved']}/{incumbent_runs}",
|
||||
"candidate_resolved": f"{candidate['resolved']}/{candidate_runs}",
|
||||
"incumbent_excluded_runs": incumbent_excluded,
|
||||
"candidate_excluded_runs": candidate_excluded,
|
||||
"candidate_quality_floor_met": candidate_runs > 0 and candidate["resolved"] == candidate_runs,
|
||||
"incumbent_metric": incumbent_metric,
|
||||
"candidate_metric": candidate_metric,
|
||||
"improvement_pct": improvement,
|
||||
}
|
||||
)
|
||||
|
||||
if incumbent_runs < min_runs or candidate_runs < min_runs:
|
||||
insufficient = True
|
||||
reasons.append(
|
||||
f"{task_id}: needs at least {min_runs} valid runs per arm (got {incumbent_runs}/{candidate_runs})"
|
||||
)
|
||||
if incumbent_excluded or candidate_excluded:
|
||||
insufficient = True
|
||||
reasons.append(
|
||||
f"{task_id}: promotion requires zero excluded runs in both paired arms "
|
||||
f"(got {incumbent_excluded}/{candidate_excluded})"
|
||||
)
|
||||
if incumbent_runs != candidate_runs:
|
||||
insufficient = True
|
||||
reasons.append(
|
||||
f"{task_id}: paired arms have different valid run counts "
|
||||
f"({incumbent_runs}/{candidate_runs} valid; {incumbent_excluded}/{candidate_excluded} excluded)"
|
||||
)
|
||||
if candidate_rate < incumbent_rate:
|
||||
quality_regression = True
|
||||
reasons.append(f"{task_id}: resolution regressed from {incumbent_rate:.0%} to {candidate_rate:.0%}")
|
||||
if candidate_runs > 0 and candidate["resolved"] != candidate_runs:
|
||||
quality_floor_failed = True
|
||||
reasons.append(
|
||||
f"{task_id}: candidate must resolve every valid run for the oracle-backed quality floor "
|
||||
f"(got {candidate['resolved']}/{candidate_runs})"
|
||||
)
|
||||
if metric_unavailable:
|
||||
insufficient = True
|
||||
reasons.append(
|
||||
f"{task_id}: {metric} was not measured on every run in both paired arms; "
|
||||
"cannot rank on it (fix cost capture or choose another metric)"
|
||||
)
|
||||
elif improvement is None:
|
||||
insufficient = True
|
||||
reasons.append(f"{task_id}: incumbent {metric} is zero; choose a metric with signal")
|
||||
elif improvement < -max_task_regression_pct:
|
||||
efficiency_regression = True
|
||||
reasons.append(
|
||||
f"{task_id}: {metric} regressed {-improvement:.1f}%, above the {max_task_regression_pct:.1f}% task cap"
|
||||
)
|
||||
|
||||
if not task_rows:
|
||||
insufficient = True
|
||||
reasons.append("no paired task results were found")
|
||||
|
||||
improvements = [row["improvement_pct"] for row in task_rows if row["improvement_pct"] is not None]
|
||||
median_improvement = round(statistics.median(improvements), 1) if improvements else None
|
||||
incumbent_resolved = sum(
|
||||
arms[incumbent_arm]["resolved"] for arms in results.values() if incumbent_arm in arms and candidate_arm in arms
|
||||
)
|
||||
candidate_resolved = sum(
|
||||
arms[candidate_arm]["resolved"] for arms in results.values() if incumbent_arm in arms and candidate_arm in arms
|
||||
)
|
||||
|
||||
resolution_margin = candidate_resolved - incumbent_resolved
|
||||
if insufficient:
|
||||
decision = "insufficient_evidence"
|
||||
elif quality_regression or quality_floor_failed or efficiency_regression:
|
||||
decision = "keep_incumbent"
|
||||
elif resolution_margin >= 2:
|
||||
decision = "promote"
|
||||
reasons.append(
|
||||
f"candidate improves total task resolution by {resolution_margin} runs "
|
||||
"(at least 2 required) with no task regression"
|
||||
)
|
||||
else:
|
||||
if resolution_margin == 1:
|
||||
reasons.append(
|
||||
"total resolution improved by only 1 run — within the noise floor "
|
||||
"(2 required); deciding on efficiency instead"
|
||||
)
|
||||
if median_improvement is not None and median_improvement >= min_improvement_pct:
|
||||
decision = "promote"
|
||||
reasons.append(
|
||||
f"median {metric} improvement is {median_improvement:.1f}% (required {min_improvement_pct:.1f}%)"
|
||||
)
|
||||
else:
|
||||
decision = "keep_incumbent"
|
||||
reasons.append(
|
||||
f"median {metric} improvement is {median_improvement or 0.0:.1f}% (required {min_improvement_pct:.1f}%)"
|
||||
)
|
||||
|
||||
return {
|
||||
"incumbent_arm": incumbent_arm,
|
||||
"candidate_arm": candidate_arm,
|
||||
"decision": decision,
|
||||
"metric": metric,
|
||||
"metric_warning": (MAIN_LOOP_ONLY_WARNING if metric in MAIN_LOOP_ONLY_METRICS else None),
|
||||
"median_improvement_pct": median_improvement,
|
||||
"reasons": reasons,
|
||||
"tasks": task_rows,
|
||||
}
|
||||
1045
eval/workflow_bench/evolve.py
Normal file
1045
eval/workflow_bench/evolve.py
Normal file
File diff suppressed because it is too large
Load diff
43
eval/workflow_bench/free-model.litellm.yaml
Normal file
43
eval/workflow_bench/free-model.litellm.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# LiteLLM proxy config for running the workflow benchmark on a FREE model.
|
||||
#
|
||||
# The proxy exposes an Anthropic-compatible /v1/messages endpoint that
|
||||
# headless Claude Code can use via ANTHROPIC_BASE_URL, routing to a model
|
||||
# that costs nothing:
|
||||
#
|
||||
# export LITELLM_MASTER_KEY="$(openssl rand -hex 16)"
|
||||
# uv run --with 'litellm[proxy]' litellm --config workflow_bench/free-model.litellm.yaml --port 4000
|
||||
#
|
||||
# uv run python -m workflow_bench.runner \
|
||||
# --tasks workflow_bench/tasks.scenarios.yaml \
|
||||
# --base-url http://localhost:4000 --auth-token "$LITELLM_MASTER_KEY" \
|
||||
# --model free-coder
|
||||
#
|
||||
# Keep the proxy on loopback (litellm's default host). Anyone who can reach
|
||||
# the port with the master key can spend the configured backend's quota.
|
||||
#
|
||||
# Pick ONE of the model routes below (or add your own — anything litellm
|
||||
# supports works):
|
||||
|
||||
model_list:
|
||||
# Hosted free tier: needs only a free OpenRouter account key in
|
||||
# OPENROUTER_API_KEY (eval/.env). ":free" variants are rate-limited
|
||||
# (~50 requests/day on a fresh account, 1000/day with a $10 balance) —
|
||||
# fine for a few benchmark tasks, not for large sweeps.
|
||||
- model_name: free-coder
|
||||
litellm_params:
|
||||
model: openrouter/qwen/qwen3-coder:free
|
||||
api_key: os.environ/OPENROUTER_API_KEY
|
||||
|
||||
# Fully local and free: any Ollama model, no API key, no rate limits.
|
||||
# Needs `ollama serve` running and the model pulled
|
||||
# (`ollama pull qwen2.5-coder:14b`). Prefer a coding-tuned model that
|
||||
# handles tool calls; small models follow skills less reliably.
|
||||
- model_name: local-coder
|
||||
litellm_params:
|
||||
model: ollama_chat/qwen2.5-coder:14b
|
||||
api_base: http://localhost:11434
|
||||
|
||||
general_settings:
|
||||
# No static default — export LITELLM_MASTER_KEY before starting the proxy
|
||||
# and pass the same value as --auth-token (see header).
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
544
eval/workflow_bench/oracle_assets.py
Normal file
544
eval/workflow_bench/oracle_assets.py
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
"""Immutable, harness-owned hidden behavioral oracles for workflow tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .process_control import run_checked, run_managed
|
||||
|
||||
|
||||
ORACLE_ROOT = Path(__file__).resolve().parent / "oracles"
|
||||
MAX_ORACLE_FILES = 8
|
||||
MAX_ORACLE_FILE_BYTES = 512 * 1024
|
||||
MAX_ORACLE_TOTAL_BYTES = 2 * 1024 * 1024
|
||||
MAX_ORACLE_PATH_BYTES = 240
|
||||
MAX_ORACLE_COMMAND_BYTES = 8 * 1024
|
||||
ORACLE_ENV_VAR = "GITNEXUS_BENCH_ORACLE_ROOT"
|
||||
HIDDEN_HARNESS_PATH = PurePosixPath("eval/workflow_bench")
|
||||
MAX_CLONE_REFS = 1024
|
||||
MAX_CLONE_REF_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OracleFileSnapshot:
|
||||
"""One bounded oracle file captured by the harness before any model run."""
|
||||
|
||||
target: str
|
||||
payload: bytes
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskOracleSnapshot:
|
||||
"""Immutable oracle bytes and command for one selected task."""
|
||||
|
||||
command: str
|
||||
command_digest: str
|
||||
manifest_digest: str
|
||||
digest: str
|
||||
files: tuple[OracleFileSnapshot, ...]
|
||||
|
||||
@property
|
||||
def binding(self) -> dict[str, Any]:
|
||||
return {
|
||||
"oracle_digest": self.digest,
|
||||
"oracle_command_digest": self.command_digest,
|
||||
"oracle_manifest_digest": self.manifest_digest,
|
||||
"oracle_files": [
|
||||
{"target": item.target, "sha256": item.sha256, "size": len(item.payload)} for item in self.files
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _hash_frames(*frames: bytes) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for frame in frames:
|
||||
digest.update(len(frame).to_bytes(8, "big"))
|
||||
digest.update(frame)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _bounded_relative_path(value: Any, *, label: str) -> PurePosixPath:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{label} must be a nonblank relative path")
|
||||
if "\\" in value or "\x00" in value or len(value.encode()) > MAX_ORACLE_PATH_BYTES:
|
||||
raise ValueError(f"{label} is not a bounded portable path: {value!r}")
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or not relative.parts or any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise ValueError(f"{label} must not be absolute or traverse parents: {value!r}")
|
||||
if relative.parts[0] == ".git":
|
||||
raise ValueError(f"{label} cannot target git metadata: {value!r}")
|
||||
return relative
|
||||
|
||||
|
||||
def validate_oracle_declaration(task: dict[str, Any]) -> None:
|
||||
"""Validate the declarative shape without reading harness-owned files."""
|
||||
|
||||
task_id = str(task.get("id", "<unknown>"))
|
||||
oracle = task.get("oracle")
|
||||
if not isinstance(oracle, dict) or set(oracle) != {"command", "files"}:
|
||||
raise ValueError(f"task {task_id} oracle requires exactly command and files")
|
||||
command = oracle.get("command")
|
||||
if (
|
||||
not isinstance(command, str)
|
||||
or not command.strip()
|
||||
or len(command.encode()) > MAX_ORACLE_COMMAND_BYTES
|
||||
or "\x00" in command
|
||||
):
|
||||
raise ValueError(f"task {task_id} oracle command must be nonblank and bounded")
|
||||
files = oracle.get("files")
|
||||
if not isinstance(files, list) or not files or len(files) > MAX_ORACLE_FILES:
|
||||
raise ValueError(f"task {task_id} oracle files must contain 1..{MAX_ORACLE_FILES} entries")
|
||||
sources: set[str] = set()
|
||||
targets: set[str] = set()
|
||||
for index, declaration in enumerate(files):
|
||||
if not isinstance(declaration, dict) or set(declaration) != {"source", "target"}:
|
||||
raise ValueError(f"task {task_id} oracle file {index} requires exactly source and target")
|
||||
source = _bounded_relative_path(declaration.get("source"), label=f"task {task_id} oracle source")
|
||||
target = _bounded_relative_path(declaration.get("target"), label=f"task {task_id} oracle target")
|
||||
if source.as_posix() in sources:
|
||||
raise ValueError(f"task {task_id} oracle source is duplicated: {source}")
|
||||
if target.as_posix() in targets:
|
||||
raise ValueError(f"task {task_id} oracle target is duplicated: {target}")
|
||||
sources.add(source.as_posix())
|
||||
targets.add(target.as_posix())
|
||||
|
||||
|
||||
def _real_oracle_root(root: Path) -> Path:
|
||||
lexical = root.expanduser().absolute()
|
||||
try:
|
||||
metadata = lexical.lstat()
|
||||
resolved = lexical.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"oracle root is unavailable: {lexical}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode) or resolved != lexical:
|
||||
raise ValueError(f"oracle root must be a real non-symlink directory: {lexical}")
|
||||
return lexical
|
||||
|
||||
|
||||
def _read_oracle_file(root: Path, relative: PurePosixPath) -> bytes:
|
||||
current = root
|
||||
for part in relative.parts[:-1]:
|
||||
current /= part
|
||||
try:
|
||||
metadata = current.lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"oracle parent is unreadable: {relative}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"oracle parents must be real directories: {relative}")
|
||||
|
||||
path = root.joinpath(*relative.parts)
|
||||
try:
|
||||
before = path.lstat()
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise ValueError(f"oracle source must be a bounded regular non-symlink file: {relative}")
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
except ValueError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ValueError(f"oracle source is unreadable: {relative}") from exc
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if (
|
||||
stat.S_ISLNK(before.st_mode)
|
||||
or not stat.S_ISREG(before.st_mode)
|
||||
or not stat.S_ISREG(opened.st_mode)
|
||||
or before.st_dev != opened.st_dev
|
||||
or before.st_ino != opened.st_ino
|
||||
or opened.st_size > MAX_ORACLE_FILE_BYTES
|
||||
):
|
||||
raise ValueError(f"oracle source must be a bounded regular non-symlink file: {relative}")
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_ORACLE_FILE_BYTES + 1
|
||||
while remaining > 0:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
payload = b"".join(chunks)
|
||||
after = os.fstat(descriptor)
|
||||
stable_fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
|
||||
if len(payload) > MAX_ORACLE_FILE_BYTES or any(
|
||||
getattr(opened, field) != getattr(after, field) for field in stable_fields
|
||||
):
|
||||
raise ValueError(f"oracle source changed while being captured: {relative}")
|
||||
return payload
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def capture_task_oracle(task: dict[str, Any], *, root: Path = ORACLE_ROOT) -> TaskOracleSnapshot:
|
||||
"""Capture and digest one task's hidden oracle before a model session."""
|
||||
|
||||
validate_oracle_declaration(task)
|
||||
oracle_root = _real_oracle_root(root)
|
||||
oracle = task["oracle"]
|
||||
command = str(oracle["command"])
|
||||
snapshots: list[OracleFileSnapshot] = []
|
||||
total = 0
|
||||
for declaration in oracle["files"]:
|
||||
source = _bounded_relative_path(declaration["source"], label="oracle source")
|
||||
target = _bounded_relative_path(declaration["target"], label="oracle target").as_posix()
|
||||
payload = _read_oracle_file(oracle_root, source)
|
||||
total += len(payload)
|
||||
if total > MAX_ORACLE_TOTAL_BYTES:
|
||||
raise ValueError(f"task {task['id']} oracle exceeds the total byte limit")
|
||||
snapshots.append(
|
||||
OracleFileSnapshot(
|
||||
target=target,
|
||||
payload=payload,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
)
|
||||
)
|
||||
snapshots.sort(key=lambda item: item.target)
|
||||
command_digest = hashlib.sha256(command.encode()).hexdigest()
|
||||
manifest_frames = [
|
||||
frame
|
||||
for item in snapshots
|
||||
for frame in (item.target.encode(), item.sha256.encode(), str(len(item.payload)).encode())
|
||||
]
|
||||
manifest_digest = _hash_frames(*manifest_frames)
|
||||
digest_frames = [command.encode()]
|
||||
for item in snapshots:
|
||||
digest_frames.extend((item.target.encode(), item.payload))
|
||||
return TaskOracleSnapshot(
|
||||
command=command,
|
||||
command_digest=command_digest,
|
||||
manifest_digest=manifest_digest,
|
||||
digest=_hash_frames(*digest_frames),
|
||||
files=tuple(snapshots),
|
||||
)
|
||||
|
||||
|
||||
def capture_task_oracles(tasks: list[dict[str, Any]], *, root: Path = ORACLE_ROOT) -> list[TaskOracleSnapshot]:
|
||||
return [capture_task_oracle(task, root=root) for task in tasks]
|
||||
|
||||
|
||||
def _git_checked(
|
||||
clone: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
timeout: float = 600,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
result = run_checked(
|
||||
["git", "-C", str(clone), *args],
|
||||
timeout=timeout,
|
||||
tail_bytes=MAX_CLONE_REF_BYTES,
|
||||
env=env,
|
||||
)
|
||||
return result.stdout_tail.strip()
|
||||
|
||||
|
||||
def sanitize_clone_for_hidden_oracles(clone: Path) -> str:
|
||||
"""Remove the harness and its recoverable Git history from a disposable clone.
|
||||
|
||||
A read-only mount over the checked-out harness is insufficient: a model
|
||||
could recover committed oracle bytes with ``git show``. Build a parentless
|
||||
commit from the clone's existing index after removing the complete harness,
|
||||
discard every other reference/reflog, and prune unreachable objects before
|
||||
any task asset, setup command, or model session is allowed to run.
|
||||
"""
|
||||
|
||||
root = clone.expanduser().absolute()
|
||||
try:
|
||||
root_metadata = root.lstat()
|
||||
git_metadata = (root / ".git").lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"oracle sanitization requires a self-contained clone: {root}") from exc
|
||||
if (
|
||||
stat.S_ISLNK(root_metadata.st_mode)
|
||||
or not stat.S_ISDIR(root_metadata.st_mode)
|
||||
or root.resolve(strict=True) != root
|
||||
or stat.S_ISLNK(git_metadata.st_mode)
|
||||
or not stat.S_ISDIR(git_metadata.st_mode)
|
||||
):
|
||||
raise ValueError(f"oracle sanitization requires a real self-contained clone: {root}")
|
||||
|
||||
original_head = _git_checked(root, ["rev-parse", "--verify", "HEAD^{commit}"])
|
||||
if len(original_head) not in {40, 64} or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in original_head
|
||||
):
|
||||
raise ValueError("clone HEAD is not an immutable commit")
|
||||
|
||||
hidden_tree_result = run_managed(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(root),
|
||||
"ls-tree",
|
||||
"-d",
|
||||
"--format=%(objectname)",
|
||||
"HEAD",
|
||||
"--",
|
||||
HIDDEN_HARNESS_PATH.as_posix(),
|
||||
],
|
||||
timeout=60,
|
||||
tail_bytes=1024,
|
||||
)
|
||||
if not hidden_tree_result.ok:
|
||||
raise ValueError("cannot inspect the clone for committed benchmark harness data")
|
||||
hidden_tree = hidden_tree_result.stdout_tail.strip()
|
||||
if hidden_tree and (
|
||||
len(hidden_tree) not in {40, 64} or any(character not in "0123456789abcdefABCDEF" for character in hidden_tree)
|
||||
):
|
||||
raise ValueError("committed benchmark harness is not a single bounded tree")
|
||||
|
||||
current = root.joinpath(*HIDDEN_HARNESS_PATH.parts)
|
||||
if hidden_tree:
|
||||
parent = root
|
||||
for part in HIDDEN_HARNESS_PATH.parts:
|
||||
parent /= part
|
||||
try:
|
||||
metadata = parent.lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError("committed benchmark harness is missing from the clone checkout") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError("benchmark harness checkout must contain only real directories")
|
||||
elif current.exists() or current.is_symlink():
|
||||
raise ValueError("untracked benchmark harness data blocks oracle sanitization")
|
||||
|
||||
_git_checked(
|
||||
root,
|
||||
[
|
||||
"rm",
|
||||
"-r",
|
||||
"--force",
|
||||
"--quiet",
|
||||
"--ignore-unmatch",
|
||||
"--",
|
||||
HIDDEN_HARNESS_PATH.as_posix(),
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
sanitized_tree = _git_checked(root, ["write-tree"], timeout=60)
|
||||
deterministic_git_env = {
|
||||
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_AUTHOR_DATE": "2000-01-01T00:00:00Z",
|
||||
"GIT_COMMITTER_DATE": "2000-01-01T00:00:00Z",
|
||||
}
|
||||
sanitized_head = _git_checked(
|
||||
root,
|
||||
[
|
||||
"-c",
|
||||
"user.name=GitNexus Workflow Benchmark",
|
||||
"-c",
|
||||
"user.email=workflow-bench.invalid",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit-tree",
|
||||
sanitized_tree,
|
||||
"-m",
|
||||
"Sanitized benchmark task snapshot",
|
||||
],
|
||||
timeout=60,
|
||||
env=deterministic_git_env,
|
||||
)
|
||||
_git_checked(
|
||||
root,
|
||||
["update-ref", "--no-deref", "HEAD", sanitized_head, original_head],
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
refs_output = _git_checked(
|
||||
root,
|
||||
["for-each-ref", f"--count={MAX_CLONE_REFS + 1}", "--format=%(refname)"],
|
||||
timeout=60,
|
||||
)
|
||||
refs = refs_output.splitlines() if refs_output else []
|
||||
if len(refs) > MAX_CLONE_REFS:
|
||||
raise ValueError(f"clone has more than {MAX_CLONE_REFS} references; refusing incomplete sanitization")
|
||||
if any(not ref.startswith("refs/") or any(character.isspace() for character in ref) for ref in refs):
|
||||
raise ValueError("clone contains an unsafe reference name")
|
||||
for ref in refs:
|
||||
_git_checked(root, ["update-ref", "--no-deref", "-d", ref], timeout=60)
|
||||
|
||||
remote_output = _git_checked(root, ["remote"], timeout=60)
|
||||
remotes = remote_output.splitlines() if remote_output else []
|
||||
if len(remotes) > MAX_CLONE_REFS or any(
|
||||
re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,255}", remote) is None or ".." in remote for remote in remotes
|
||||
):
|
||||
raise ValueError("clone contains unsafe or unbounded remote metadata")
|
||||
for remote in remotes:
|
||||
_git_checked(root, ["remote", "remove", remote], timeout=60)
|
||||
|
||||
_git_checked(
|
||||
root,
|
||||
["reflog", "expire", "--expire=now", "--expire-unreachable=now", "--all"],
|
||||
timeout=60,
|
||||
)
|
||||
git_dir = root / ".git"
|
||||
for pseudo_ref in (
|
||||
"AUTO_MERGE",
|
||||
"BISECT_START",
|
||||
"CHERRY_PICK_HEAD",
|
||||
"FETCH_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"ORIG_HEAD",
|
||||
"REBASE_HEAD",
|
||||
"REVERT_HEAD",
|
||||
"shallow",
|
||||
):
|
||||
path = git_dir / pseudo_ref
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(f"unsafe Git metadata blocks oracle sanitization: {pseudo_ref}")
|
||||
path.unlink()
|
||||
|
||||
logs = git_dir / "logs"
|
||||
if logs.exists() or logs.is_symlink():
|
||||
logs_metadata = logs.lstat()
|
||||
if stat.S_ISLNK(logs_metadata.st_mode) or not stat.S_ISDIR(logs_metadata.st_mode):
|
||||
raise ValueError("unsafe Git reflog metadata blocks oracle sanitization")
|
||||
shutil.rmtree(logs)
|
||||
|
||||
_git_checked(root, ["repack", "-A", "-d"], timeout=600)
|
||||
_git_checked(root, ["prune", "--expire=now"], timeout=600)
|
||||
_git_checked(root, ["prune-packed"], timeout=600)
|
||||
|
||||
remaining_refs = _git_checked(root, ["for-each-ref", "--format=%(refname)"], timeout=60)
|
||||
if remaining_refs:
|
||||
raise ValueError("oracle sanitization left clone references recoverable")
|
||||
fsck = run_checked(
|
||||
["git", "-C", str(root), "fsck", "--full", "--no-progress", "--no-reflogs", "--unreachable"],
|
||||
timeout=600,
|
||||
tail_bytes=MAX_CLONE_REF_BYTES,
|
||||
)
|
||||
if fsck.stdout_tail.strip() or fsck.stderr_tail.strip():
|
||||
raise ValueError("oracle sanitization left unreachable Git objects recoverable")
|
||||
|
||||
forbidden_objects: list[tuple[str, str]] = []
|
||||
if original_head != sanitized_head:
|
||||
forbidden_objects.append((original_head, "original commit"))
|
||||
if hidden_tree:
|
||||
forbidden_objects.append((hidden_tree, "hidden harness tree"))
|
||||
for forbidden_object, label in forbidden_objects:
|
||||
probe = run_managed(
|
||||
["git", "-C", str(root), "cat-file", "-e", forbidden_object],
|
||||
timeout=60,
|
||||
)
|
||||
if probe.ok:
|
||||
raise ValueError(f"oracle sanitization left the {label} recoverable")
|
||||
if probe.state != "exited" or probe.returncode not in {1, 128}:
|
||||
raise ValueError(f"oracle sanitization could not verify removal of the {label}")
|
||||
|
||||
hidden_listing = _git_checked(
|
||||
root,
|
||||
["ls-tree", "-r", "--name-only", "HEAD", "--", HIDDEN_HARNESS_PATH.as_posix()],
|
||||
timeout=60,
|
||||
)
|
||||
if hidden_listing or current.exists() or current.is_symlink():
|
||||
raise ValueError("oracle sanitization left the benchmark harness visible")
|
||||
if _git_checked(root, ["status", "--porcelain=v1", "--untracked-files=all"], timeout=60):
|
||||
raise ValueError("oracle sanitization did not produce a clean task snapshot")
|
||||
if _git_checked(root, ["rev-parse", "--verify", "HEAD^{commit}"], timeout=60) != sanitized_head:
|
||||
raise ValueError("oracle sanitization did not retain its parentless task snapshot")
|
||||
parents = _git_checked(root, ["show", "-s", "--format=%P", "HEAD"], timeout=60)
|
||||
if parents:
|
||||
raise ValueError("oracle sanitization snapshot unexpectedly retained parent history")
|
||||
if _git_checked(root, ["remote"], timeout=60):
|
||||
raise ValueError("oracle sanitization retained a repository remote")
|
||||
if logs.exists() or logs.is_symlink():
|
||||
raise ValueError("oracle sanitization retained reflog metadata")
|
||||
return sanitized_head
|
||||
|
||||
|
||||
def _write_stage_file(stage_root: Path, item: OracleFileSnapshot) -> None:
|
||||
destination = stage_root.joinpath(*PurePosixPath(item.target).parts)
|
||||
destination.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
current = stage_root
|
||||
for part in PurePosixPath(item.target).parts[:-1]:
|
||||
current /= part
|
||||
metadata = current.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"oracle stage parent must be a real directory: {item.target}")
|
||||
current.chmod(0o700)
|
||||
descriptor = os.open(
|
||||
destination,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o400,
|
||||
)
|
||||
try:
|
||||
view = memoryview(item.payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError("short write while staging oracle")
|
||||
view = view[written:]
|
||||
os.fchmod(descriptor, 0o400)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _verify_staged_oracle(stage_root: Path, snapshot: TaskOracleSnapshot) -> None:
|
||||
root_metadata = stage_root.lstat()
|
||||
if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode):
|
||||
raise ValueError("oracle stage root changed during verification")
|
||||
for item in snapshot.files:
|
||||
relative = PurePosixPath(item.target)
|
||||
current = stage_root
|
||||
for part in relative.parts[:-1]:
|
||||
current /= part
|
||||
metadata = current.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"oracle stage parent changed during verification: {item.target}")
|
||||
observed = _read_oracle_file(stage_root, relative)
|
||||
if observed != item.payload:
|
||||
raise ValueError(f"oracle file changed during verification: {item.target}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def staged_task_oracle(worktree: Path, snapshot: TaskOracleSnapshot) -> Iterator[Path]:
|
||||
"""Materialize a private random oracle root only after the model exits."""
|
||||
|
||||
root = worktree.expanduser().absolute()
|
||||
metadata = root.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode) or root.resolve(strict=True) != root:
|
||||
raise ValueError(f"oracle worktree must be a real non-symlink directory: {root}")
|
||||
stage_root = root / f".wfbench-oracle-{secrets.token_hex(16)}"
|
||||
stage_root.mkdir(mode=0o700)
|
||||
stage_root.chmod(0o700)
|
||||
primary: BaseException | None = None
|
||||
try:
|
||||
for item in snapshot.files:
|
||||
_write_stage_file(stage_root, item)
|
||||
yield stage_root
|
||||
_verify_staged_oracle(stage_root, snapshot)
|
||||
except BaseException as exc:
|
||||
primary = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
mode = stage_root.lstat().st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
stage_root.unlink()
|
||||
elif stat.S_ISDIR(mode):
|
||||
shutil.rmtree(stage_root)
|
||||
else:
|
||||
stage_root.unlink()
|
||||
except FileNotFoundError:
|
||||
cleanup = ValueError("oracle stage root was removed during verification")
|
||||
if primary is None:
|
||||
raise cleanup
|
||||
primary.add_note(str(cleanup))
|
||||
except OSError as cleanup:
|
||||
if primary is None:
|
||||
raise
|
||||
primary.add_note(f"oracle stage cleanup also failed: {cleanup}")
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { dispatchChunkParse } from '../gitnexus/src/core/ingestion/parsing-processor.js';
|
||||
import {
|
||||
WorkerPoolInitializationError,
|
||||
type WorkerPool,
|
||||
} from '../gitnexus/src/core/ingestion/workers/worker-pool.js';
|
||||
|
||||
const files = [{ path: 'src/retry.ts', content: 'export const retry = true;\n' }];
|
||||
|
||||
function startupFailure(crashClass: 'transient-exhausted' | 'deterministic-startup') {
|
||||
return new WorkerPoolInitializationError('hidden oracle worker failure', [], [], crashClass);
|
||||
}
|
||||
|
||||
describe('hidden oracle: bounded parse-worker retry', () => {
|
||||
it('retries a transient dispatch twice and returns the recovered result', async () => {
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(startupFailure('transient-exhausted'))
|
||||
.mockRejectedValueOnce(startupFailure('transient-exhausted'))
|
||||
.mockResolvedValueOnce([]);
|
||||
const pool = { size: 1, dispatch, terminate: vi.fn() } as unknown as WorkerPool;
|
||||
|
||||
await expect(dispatchChunkParse(files, pool)).resolves.toEqual([]);
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not retry a deterministic parse-worker failure', async () => {
|
||||
const failure = startupFailure('deterministic-startup');
|
||||
const dispatch = vi.fn().mockRejectedValue(failure);
|
||||
const pool = { size: 1, dispatch, terminate: vi.fn() } as unknown as WorkerPool;
|
||||
|
||||
await expect(dispatchChunkParse(files, pool)).rejects.toBe(failure);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
30
eval/workflow_bench/oracles/inv-bug-pdg-note.oracle.test.ts
Normal file
30
eval/workflow_bench/oracles/inv-bug-pdg-note.oracle.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../gitnexus/src/mcp/local/pdg-impact.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, pdgStampForMode: vi.fn().mockResolvedValue(false) };
|
||||
});
|
||||
|
||||
import { LocalBackend } from '../gitnexus/src/mcp/local/local-backend.js';
|
||||
|
||||
describe('hidden oracle: pdg_query missing sub-layer note', () => {
|
||||
it.each([
|
||||
['controls', 'CDG'],
|
||||
['flows', 'REACHING_DEF'],
|
||||
] as const)("names the %s mode's %s sub-layer", async (mode, expectedLayer) => {
|
||||
const backend = Object.create(LocalBackend.prototype) as LocalBackend & {
|
||||
ensureInitialized: () => Promise<void>;
|
||||
_pdgQueryImpl: (repo: unknown, params: unknown) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
backend.ensureInitialized = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await backend._pdgQueryImpl(
|
||||
{ lbugPath: '/unreachable-hidden-oracle-db' },
|
||||
{ mode, target: 'src/example.ts' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ mode, results: [], total: 0 });
|
||||
expect(String(result.note)).toContain(expectedLayer);
|
||||
expect(String(result.note)).toContain('gitnexus analyze --pdg');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LocalBackend } from '../gitnexus/src/mcp/local/local-backend.js';
|
||||
import { MCP_TOOLS } from '../gitnexus/src/mcp/tools.js';
|
||||
|
||||
const repositories = [
|
||||
{ name: 'Alpha', path: '/repos/z-alpha' },
|
||||
{ name: 'alphabet', path: '/repos/a-alphabet' },
|
||||
{ name: 'Beta', path: '/repos/beta' },
|
||||
];
|
||||
|
||||
describe('hidden oracle: list_repos name_contains', () => {
|
||||
it('filters case-insensitively before pagination and reports filtered totals', async () => {
|
||||
const backend = Object.create(LocalBackend.prototype) as LocalBackend & {
|
||||
listRepos: () => Promise<typeof repositories>;
|
||||
};
|
||||
backend.listRepos = vi.fn().mockResolvedValue(repositories.map((repo) => ({ ...repo })));
|
||||
|
||||
const first = await backend.listReposPage({
|
||||
name_contains: 'ALP',
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
} as never);
|
||||
expect(first.repositories.map((repo) => repo.name)).toEqual(['Alpha']);
|
||||
expect(first.pagination).toMatchObject({
|
||||
total: 2,
|
||||
returned: 1,
|
||||
hasMore: true,
|
||||
nextOffset: 1,
|
||||
});
|
||||
|
||||
const second = await backend.listReposPage({
|
||||
name_contains: 'alp',
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
} as never);
|
||||
expect(second.repositories.map((repo) => repo.name)).toEqual(['alphabet']);
|
||||
expect(second.pagination).toMatchObject({ total: 2, returned: 1, hasMore: false });
|
||||
expect(second.pagination).not.toHaveProperty('nextOffset');
|
||||
});
|
||||
|
||||
it('advertises the optional filter on the MCP tool schema', () => {
|
||||
const tool = MCP_TOOLS.find((candidate) => candidate.name === 'list_repos');
|
||||
expect(tool?.inputSchema.properties).toHaveProperty('name_contains');
|
||||
expect(tool?.inputSchema.required ?? []).not.toContain('name_contains');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import pkg from '../gitnexus/package.json';
|
||||
|
||||
describe('hidden oracle: -V version alias', () => {
|
||||
it('prints exactly the installed GitNexus version and exits successfully', () => {
|
||||
const result = spawnSync(path.resolve('node_modules/.bin/tsx'), ['src/cli/index.ts', '-V'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NO_COLOR: '1' },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe(pkg.version);
|
||||
expect(result.stderr.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
14
eval/workflow_bench/oracles/vitest.config.mts
Normal file
14
eval/workflow_bench/oracles/vitest.config.mts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Harness-owned config: benchmark candidates must not be able to weaken test
|
||||
// discovery, setup hooks, or pass-with-no-tests behavior through repo config.
|
||||
export default {
|
||||
root: process.cwd(),
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
setupFiles: [],
|
||||
globalSetup: [],
|
||||
passWithNoTests: false,
|
||||
include: ['../.wfbench-oracle-*/*.oracle.test.ts'],
|
||||
exclude: [],
|
||||
},
|
||||
};
|
||||
732
eval/workflow_bench/process_control.py
Normal file
732
eval/workflow_bench/process_control.py
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
"""Bounded, owned subprocess execution for the workflow benchmark.
|
||||
|
||||
The benchmark runs model sessions and task-authored commands that may create
|
||||
descendants. ``subprocess.run(..., timeout=...)`` kills only the immediate
|
||||
process and buffers output without a bound, so it is not an ownership boundary.
|
||||
This module centralizes the lifecycle and makes every terminal state explicit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Literal
|
||||
|
||||
|
||||
MAX_TAIL_BYTES = 64 * 1024
|
||||
DEFAULT_TERMINATE_GRACE = 5.0
|
||||
|
||||
ProcessState = Literal[
|
||||
"exited",
|
||||
"input-failure",
|
||||
"timeout",
|
||||
"forced-kill",
|
||||
"ownership-failure",
|
||||
"spawn-failure",
|
||||
"reap-failure",
|
||||
"cleanup-failure",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedProcessResult:
|
||||
"""Complete, bounded evidence for one owned process tree."""
|
||||
|
||||
state: ProcessState
|
||||
returncode: int | None
|
||||
stdout_tail: str
|
||||
stderr_tail: str
|
||||
duration_s: float
|
||||
timed_out: bool = False
|
||||
forced_kill: bool = False
|
||||
ownership: str | None = None
|
||||
detail: str | None = None
|
||||
primary_state: ProcessState | None = None
|
||||
# Complete parent-captured stdout for callers that explicitly request a
|
||||
# bounded evidence stream. Unlike files under the child HOME, these bytes
|
||||
# never enter the child's mount namespace and therefore cannot be forged by
|
||||
# an agent-launched tool.
|
||||
stdout_capture: bytes | None = None
|
||||
stdout_capture_overflow: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.state == "exited" and self.returncode == 0
|
||||
|
||||
|
||||
class ManagedProcessError(RuntimeError):
|
||||
"""Raised by ``run_checked`` while preserving the terminal evidence."""
|
||||
|
||||
def __init__(self, command: Sequence[str] | str, result: ManagedProcessResult) -> None:
|
||||
self.command = command
|
||||
self.result = result
|
||||
super().__init__(
|
||||
f"managed command failed ({result.state}, exit={result.returncode}): "
|
||||
f"{result.detail or result.stderr_tail[-1000:]}"
|
||||
)
|
||||
|
||||
|
||||
class _TailBuffer:
|
||||
def __init__(self, limit: int) -> None:
|
||||
self._limit = limit
|
||||
self._value = bytearray()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, chunk: bytes) -> None:
|
||||
if not chunk:
|
||||
return
|
||||
with self._lock:
|
||||
if len(chunk) >= self._limit:
|
||||
self._value[:] = chunk[-self._limit :]
|
||||
return
|
||||
overflow = len(self._value) + len(chunk) - self._limit
|
||||
if overflow > 0:
|
||||
del self._value[:overflow]
|
||||
self._value.extend(chunk)
|
||||
|
||||
def text(self) -> str:
|
||||
with self._lock:
|
||||
return bytes(self._value).decode(errors="replace")
|
||||
|
||||
|
||||
class _BoundedCapture:
|
||||
"""Capture a complete byte stream up to a hard limit while still draining."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self._limit = limit
|
||||
self._value = bytearray()
|
||||
self._overflow = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, chunk: bytes) -> None:
|
||||
if not chunk:
|
||||
return
|
||||
with self._lock:
|
||||
remaining = self._limit - len(self._value)
|
||||
if remaining > 0:
|
||||
self._value.extend(chunk[:remaining])
|
||||
if len(chunk) > remaining:
|
||||
self._overflow = True
|
||||
|
||||
def result(self) -> tuple[bytes, bool]:
|
||||
with self._lock:
|
||||
return bytes(self._value), self._overflow
|
||||
|
||||
|
||||
def _drain(
|
||||
pipe: BinaryIO,
|
||||
tail: _TailBuffer,
|
||||
capture: _BoundedCapture | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
while chunk := pipe.read(8192):
|
||||
tail.append(chunk)
|
||||
if capture is not None:
|
||||
capture.append(chunk)
|
||||
except (OSError, ValueError):
|
||||
# A forced close is part of the reap path. The terminal result records
|
||||
# an actual reap failure; a reader seeing the close is not one itself.
|
||||
return
|
||||
|
||||
|
||||
def _write_stdin(pipe: BinaryIO, payload: bytes, errors: list[str]) -> None:
|
||||
try:
|
||||
pipe.write(payload)
|
||||
pipe.flush()
|
||||
except (BrokenPipeError, OSError, ValueError) as exc:
|
||||
errors.append(f"stdin write failed: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
try:
|
||||
pipe.close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _pid_namespace_wrapper(command: Sequence[str] | str, shell: bool) -> bool:
|
||||
"""Accept only the trusted Bubblewrap ownership shape.
|
||||
|
||||
A process group cannot discover a descendant that calls ``setsid()``. The
|
||||
caller may claim PID-namespace ownership only when the command itself is a
|
||||
direct Bubblewrap invocation with the required namespace/lifetime flags.
|
||||
"""
|
||||
|
||||
if shell or isinstance(command, str) or not command:
|
||||
return False
|
||||
executable = shutil.which(os.fspath(command[0]))
|
||||
if executable is None or Path(executable).name != "bwrap":
|
||||
return False
|
||||
args = {os.fspath(part) for part in command[1:]}
|
||||
return "--unshare-pid" in args and "--die-with-parent" in args
|
||||
|
||||
|
||||
def _group_exists(pgid: int) -> bool:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
# The group exists but ownership is unexpectedly insufficient. Keep
|
||||
# the conservative path and attempt the terminating signal.
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
class _WindowsJob:
|
||||
"""Kill-on-close Job Object assigned before the child resumes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
process: subprocess.Popen[bytes],
|
||||
ownership_slot: list[tuple[subprocess.Popen[bytes], _WindowsJob | None, int | None]],
|
||||
) -> None:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class IO_COUNTERS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ReadOperationCount", ctypes.c_ulonglong),
|
||||
("WriteOperationCount", ctypes.c_ulonglong),
|
||||
("OtherOperationCount", ctypes.c_ulonglong),
|
||||
("ReadTransferCount", ctypes.c_ulonglong),
|
||||
("WriteTransferCount", ctypes.c_ulonglong),
|
||||
("OtherTransferCount", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||
("LimitFlags", wintypes.DWORD),
|
||||
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||
("ActiveProcessLimit", wintypes.DWORD),
|
||||
("Affinity", ctypes.c_size_t),
|
||||
("PriorityClass", wintypes.DWORD),
|
||||
("SchedulingClass", wintypes.DWORD),
|
||||
]
|
||||
|
||||
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
||||
("IoInfo", IO_COUNTERS),
|
||||
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||
("JobMemoryLimit", ctypes.c_size_t),
|
||||
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
ntdll = ctypes.WinDLL("ntdll")
|
||||
kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
||||
kernel32.SetInformationJobObject.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
||||
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||
kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
||||
kernel32.QueryInformationJobObject.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
]
|
||||
kernel32.QueryInformationJobObject.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE]
|
||||
ntdll.NtResumeProcess.restype = wintypes.LONG
|
||||
|
||||
handle = kernel32.CreateJobObjectW(None, None)
|
||||
if not handle:
|
||||
raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
|
||||
self._kernel32 = kernel32
|
||||
self._handle = handle
|
||||
ownership_slot[-1] = (process, self, None)
|
||||
try:
|
||||
limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
||||
limits.BasicLimitInformation.LimitFlags = 0x00002000 # KILL_ON_JOB_CLOSE
|
||||
if not kernel32.SetInformationJobObject(handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
|
||||
raise OSError(ctypes.get_last_error(), "SetInformationJobObject failed")
|
||||
process_handle = wintypes.HANDLE(int(process._handle)) # type: ignore[attr-defined]
|
||||
if not kernel32.AssignProcessToJobObject(handle, process_handle):
|
||||
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
|
||||
status = int(ntdll.NtResumeProcess(process_handle))
|
||||
if status != 0:
|
||||
raise OSError(status, "NtResumeProcess failed")
|
||||
except BaseException:
|
||||
# The child is still suspended when assignment fails. Kill it
|
||||
# before releasing any handle; never retry with job breakaway.
|
||||
process.kill()
|
||||
process.wait()
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def terminate(self) -> None:
|
||||
import ctypes
|
||||
|
||||
if self._handle and not self._kernel32.TerminateJobObject(self._handle, 1):
|
||||
raise OSError(ctypes.get_last_error(), "TerminateJobObject failed")
|
||||
|
||||
def active_processes(self) -> int:
|
||||
"""Return live members so closing the job cannot hide forced cleanup."""
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("TotalUserTime", ctypes.c_longlong),
|
||||
("TotalKernelTime", ctypes.c_longlong),
|
||||
("ThisPeriodTotalUserTime", ctypes.c_longlong),
|
||||
("ThisPeriodTotalKernelTime", ctypes.c_longlong),
|
||||
("TotalPageFaultCount", wintypes.DWORD),
|
||||
("TotalProcesses", wintypes.DWORD),
|
||||
("ActiveProcesses", wintypes.DWORD),
|
||||
("TotalTerminatedProcesses", wintypes.DWORD),
|
||||
]
|
||||
|
||||
info = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION()
|
||||
returned = wintypes.DWORD()
|
||||
if not self._handle or not self._kernel32.QueryInformationJobObject(
|
||||
self._handle,
|
||||
1, # JobObjectBasicAccountingInformation
|
||||
ctypes.byref(info),
|
||||
ctypes.sizeof(info),
|
||||
ctypes.byref(returned),
|
||||
):
|
||||
raise OSError(ctypes.get_last_error(), "QueryInformationJobObject failed")
|
||||
return int(info.ActiveProcesses)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._handle:
|
||||
self._kernel32.CloseHandle(self._handle)
|
||||
self._handle = None
|
||||
|
||||
|
||||
def _spawn(
|
||||
command: Sequence[str] | str,
|
||||
*,
|
||||
cwd: Path | str | None,
|
||||
env: Mapping[str, str] | None,
|
||||
shell: bool,
|
||||
pipe_stdin: bool,
|
||||
ownership_slot: list[tuple[subprocess.Popen[bytes], _WindowsJob | None, int | None]],
|
||||
) -> tuple[subprocess.Popen[bytes], _WindowsJob | None, str]:
|
||||
flags = 0
|
||||
kwargs: dict[str, object] = {}
|
||||
ownership = "posix-process-group"
|
||||
if os.name == "nt":
|
||||
flags = 0x00000004 | 0x00000200 # CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP
|
||||
ownership = "windows-job"
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=dict(env) if env is not None else None,
|
||||
shell=shell,
|
||||
stdin=subprocess.PIPE if pipe_stdin else subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=flags,
|
||||
**kwargs,
|
||||
)
|
||||
finally:
|
||||
if process is not None:
|
||||
ownership_slot.append((process, None, process.pid if os.name != "nt" else None))
|
||||
job = None
|
||||
if os.name == "nt":
|
||||
job = _WindowsJob(process, ownership_slot)
|
||||
return process, job, ownership
|
||||
|
||||
|
||||
def _empty_result(state: ProcessState, started: float, detail: str) -> ManagedProcessResult:
|
||||
return ManagedProcessResult(
|
||||
state=state,
|
||||
returncode=None,
|
||||
stdout_tail="",
|
||||
stderr_tail="",
|
||||
duration_s=round(time.monotonic() - started, 3),
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _abort_owned_process(
|
||||
process: subprocess.Popen[bytes],
|
||||
job: _WindowsJob | None,
|
||||
owned_pgid: int | None,
|
||||
) -> None:
|
||||
"""Best-effort synchronous cleanup while preserving caller cancellation."""
|
||||
|
||||
if getattr(process, "_workflow_bench_abort_started", False):
|
||||
return
|
||||
setattr(process, "_workflow_bench_abort_started", True)
|
||||
try:
|
||||
if job is not None:
|
||||
job.terminate()
|
||||
elif owned_pgid is not None:
|
||||
os.killpg(owned_pgid, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except BaseException:
|
||||
try:
|
||||
process.kill()
|
||||
except BaseException:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except BaseException:
|
||||
pass
|
||||
for pipe in (process.stdin, process.stdout, process.stderr):
|
||||
if pipe is not None:
|
||||
try:
|
||||
pipe.close()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if job is not None:
|
||||
try:
|
||||
job.close()
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
def _run_managed_inner(
|
||||
command: Sequence[str] | str,
|
||||
*,
|
||||
cwd: Path | str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
shell: bool = False,
|
||||
timeout: float,
|
||||
terminate_grace: float = DEFAULT_TERMINATE_GRACE,
|
||||
tail_bytes: int = MAX_TAIL_BYTES,
|
||||
require_pid_namespace: bool = False,
|
||||
stdin_data: bytes | None = None,
|
||||
capture_stdout_bytes: int | None = None,
|
||||
_ownership_slot: list[tuple[subprocess.Popen[bytes], _WindowsJob | None, int | None]],
|
||||
) -> ManagedProcessResult:
|
||||
"""Implementation registered with an outer post-spawn ownership guard."""
|
||||
|
||||
started = time.monotonic()
|
||||
if timeout <= 0 or terminate_grace < 0 or tail_bytes <= 0:
|
||||
raise ValueError("timeout and tail_bytes must be positive; terminate_grace must be non-negative")
|
||||
if capture_stdout_bytes is not None and capture_stdout_bytes <= 0:
|
||||
raise ValueError("capture_stdout_bytes must be positive when supplied")
|
||||
if require_pid_namespace:
|
||||
if os.name == "nt":
|
||||
return _empty_result("ownership-failure", started, "PID-namespace execution is not supported on Windows")
|
||||
if not _pid_namespace_wrapper(command, shell):
|
||||
return _empty_result(
|
||||
"ownership-failure",
|
||||
started,
|
||||
"required Bubblewrap --unshare-pid/--die-with-parent ownership is absent",
|
||||
)
|
||||
|
||||
try:
|
||||
process, job, ownership = _spawn(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
shell=shell,
|
||||
pipe_stdin=stdin_data is not None,
|
||||
ownership_slot=_ownership_slot,
|
||||
)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if _ownership_slot:
|
||||
_abort_owned_process(*_ownership_slot[0])
|
||||
state: ProcessState = "ownership-failure" if os.name == "nt" else "spawn-failure"
|
||||
return _empty_result(state, started, f"{type(exc).__name__}: {exc}")
|
||||
|
||||
owned_pgid = process.pid if os.name != "nt" else None
|
||||
if not _ownership_slot:
|
||||
# Compatibility for injected test doubles that replace _spawn.
|
||||
_ownership_slot.append((process, job, owned_pgid))
|
||||
if require_pid_namespace:
|
||||
ownership = "bwrap-pid-namespace"
|
||||
assert process.stdout is not None and process.stderr is not None
|
||||
stdout = _TailBuffer(tail_bytes)
|
||||
stderr = _TailBuffer(tail_bytes)
|
||||
stdout_capture = _BoundedCapture(capture_stdout_bytes) if capture_stdout_bytes is not None else None
|
||||
readers = [
|
||||
threading.Thread(target=_drain, args=(process.stdout, stdout, stdout_capture), daemon=True),
|
||||
threading.Thread(target=_drain, args=(process.stderr, stderr), daemon=True),
|
||||
]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
stdin_errors: list[str] = []
|
||||
writer = None
|
||||
if stdin_data is not None:
|
||||
assert process.stdin is not None
|
||||
writer = threading.Thread(
|
||||
target=_write_stdin,
|
||||
args=(process.stdin, stdin_data, stdin_errors),
|
||||
daemon=True,
|
||||
)
|
||||
writer.start()
|
||||
|
||||
state: ProcessState = "exited"
|
||||
detail = None
|
||||
timed_out = False
|
||||
forced_kill = False
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
state = "timeout"
|
||||
try:
|
||||
if job is not None:
|
||||
# Job Object termination is the Windows tree-wide primitive;
|
||||
# there is no safe cooperative group signal equivalent.
|
||||
job.terminate()
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
else:
|
||||
assert owned_pgid is not None
|
||||
pgid = owned_pgid
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
deadline = time.monotonic() + terminate_grace
|
||||
while time.monotonic() < deadline and _group_exists(pgid):
|
||||
# Reap an exited group leader while waiting. An unreaped
|
||||
# zombie keeps killpg(..., 0) true and used to make every
|
||||
# cooperative SIGTERM look like a forced SIGKILL.
|
||||
process.poll()
|
||||
if not _group_exists(pgid):
|
||||
break
|
||||
time.sleep(min(0.02, max(0.0, deadline - time.monotonic())))
|
||||
if _group_exists(pgid):
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
if process.returncode is None:
|
||||
process.wait(timeout=max(1.0, terminate_grace))
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
except BaseException as exc:
|
||||
state = "reap-failure"
|
||||
detail = f"{type(exc).__name__}: {exc}"
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except BaseException as reap_exc:
|
||||
detail += f"; final reap failed: {type(reap_exc).__name__}: {reap_exc}"
|
||||
except BaseException as exc:
|
||||
# If the parent raises after spawn, ownership still has to terminate
|
||||
# before the exception is represented in the result.
|
||||
detail = f"parent wait failed: {type(exc).__name__}: {exc}"
|
||||
try:
|
||||
if job is not None:
|
||||
job.terminate()
|
||||
else:
|
||||
assert owned_pgid is not None
|
||||
os.killpg(owned_pgid, signal.SIGKILL)
|
||||
process.wait(timeout=1)
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
except BaseException as reap_exc:
|
||||
state = "reap-failure"
|
||||
detail += f"; reap failed: {type(reap_exc).__name__}: {reap_exc}"
|
||||
|
||||
# KILL_ON_JOB_CLOSE is real termination, not a successful exit. Inspect
|
||||
# membership before closing the handle so a quiet Windows grandchild
|
||||
# cannot be killed while the benchmark row remains eligible evidence.
|
||||
if state == "exited" and job is not None:
|
||||
try:
|
||||
if job.active_processes() > 0:
|
||||
job.terminate()
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
detail = "parent exited while Windows Job Object still owned descendants"
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
except BaseException as exc:
|
||||
try:
|
||||
job.terminate()
|
||||
except BaseException as terminate_exc:
|
||||
detail = f"job membership query failed: {exc}; termination failed: {terminate_exc}"
|
||||
state = "reap-failure"
|
||||
else:
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
detail = f"job membership query failed; conservatively terminated job: {exc}"
|
||||
|
||||
# A parent can exit successfully after spawning a quiet child that closes
|
||||
# every inherited pipe. Pipe draining alone cannot reveal that descendant,
|
||||
# so explicitly close the owned POSIX process group before returning.
|
||||
try:
|
||||
quiet_descendant_exists = state == "exited" and owned_pgid is not None and _group_exists(owned_pgid)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
if quiet_descendant_exists:
|
||||
try:
|
||||
os.killpg(owned_pgid, signal.SIGKILL)
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
except BaseException as exc:
|
||||
state = "reap-failure"
|
||||
detail = f"quiet-descendant kill failed: {type(exc).__name__}: {exc}"
|
||||
|
||||
for reader in readers:
|
||||
try:
|
||||
reader.join(timeout=1.0)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
if any(reader.is_alive() for reader in readers):
|
||||
# A descendant can outlive an exited parent while holding inherited
|
||||
# pipe handles. Treat that as owned work, terminate the tree, then
|
||||
# drain again instead of merely closing our side of the pipes.
|
||||
try:
|
||||
if job is not None:
|
||||
job.terminate()
|
||||
else:
|
||||
assert owned_pgid is not None
|
||||
os.killpg(owned_pgid, signal.SIGKILL)
|
||||
forced_kill = True
|
||||
state = "forced-kill"
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
except BaseException as exc:
|
||||
state = "reap-failure"
|
||||
detail = (detail + "; " if detail else "") + f"pipe-owner kill failed: {exc}"
|
||||
for reader in readers:
|
||||
try:
|
||||
reader.join(timeout=max(1.0, terminate_grace))
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
if any(reader.is_alive() for reader in readers):
|
||||
state = "reap-failure"
|
||||
detail = (detail + "; " if detail else "") + "output pipes remained open after tree termination"
|
||||
for pipe in (process.stdout, process.stderr):
|
||||
try:
|
||||
pipe.close()
|
||||
except OSError:
|
||||
pass
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.join(timeout=1.0)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
if writer.is_alive():
|
||||
state = "reap-failure"
|
||||
stdin_errors.append("stdin writer remained blocked after tree termination")
|
||||
if stdin_errors:
|
||||
detail = (detail + "; " if detail else "") + "; ".join(stdin_errors)
|
||||
if state == "exited":
|
||||
state = "input-failure"
|
||||
|
||||
if job is not None:
|
||||
try:
|
||||
job.close()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
_abort_owned_process(process, job, owned_pgid)
|
||||
raise
|
||||
|
||||
captured_stdout, capture_overflow = stdout_capture.result() if stdout_capture is not None else (None, False)
|
||||
return ManagedProcessResult(
|
||||
state=state,
|
||||
returncode=process.returncode,
|
||||
stdout_tail=stdout.text(),
|
||||
stderr_tail=stderr.text(),
|
||||
duration_s=round(time.monotonic() - started, 3),
|
||||
timed_out=timed_out,
|
||||
forced_kill=forced_kill,
|
||||
ownership=ownership,
|
||||
detail=detail,
|
||||
stdout_capture=captured_stdout,
|
||||
stdout_capture_overflow=capture_overflow,
|
||||
)
|
||||
|
||||
|
||||
def run_managed(
|
||||
command: Sequence[str] | str,
|
||||
*,
|
||||
cwd: Path | str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
shell: bool = False,
|
||||
timeout: float,
|
||||
terminate_grace: float = DEFAULT_TERMINATE_GRACE,
|
||||
tail_bytes: int = MAX_TAIL_BYTES,
|
||||
require_pid_namespace: bool = False,
|
||||
stdin_data: bytes | None = None,
|
||||
capture_stdout_bytes: int | None = None,
|
||||
) -> ManagedProcessResult:
|
||||
"""Run one command with bounded output and owned-tree termination."""
|
||||
|
||||
ownership_slot: list[tuple[subprocess.Popen[bytes], _WindowsJob | None, int | None]] = []
|
||||
try:
|
||||
return _run_managed_inner(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
shell=shell,
|
||||
timeout=timeout,
|
||||
terminate_grace=terminate_grace,
|
||||
tail_bytes=tail_bytes,
|
||||
require_pid_namespace=require_pid_namespace,
|
||||
stdin_data=stdin_data,
|
||||
capture_stdout_bytes=capture_stdout_bytes,
|
||||
_ownership_slot=ownership_slot,
|
||||
)
|
||||
except BaseException:
|
||||
if ownership_slot:
|
||||
_abort_owned_process(*ownership_slot[0])
|
||||
raise
|
||||
|
||||
|
||||
def mark_cleanup_failure(result: ManagedProcessResult, error: BaseException) -> ManagedProcessResult:
|
||||
"""Preserve the primary terminal state when clone cleanup also fails."""
|
||||
|
||||
cleanup = f"{type(error).__name__}: {error}"
|
||||
detail = f"{result.detail}; cleanup: {cleanup}" if result.detail else f"cleanup: {cleanup}"
|
||||
return replace(
|
||||
result,
|
||||
state="cleanup-failure",
|
||||
primary_state=result.primary_state or result.state,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def run_checked(
|
||||
command: Sequence[str] | str,
|
||||
**kwargs: object,
|
||||
) -> ManagedProcessResult:
|
||||
"""Run a managed command and raise with its bounded evidence on failure."""
|
||||
|
||||
result = run_managed(command, **kwargs) # type: ignore[arg-type]
|
||||
if not result.ok:
|
||||
raise ManagedProcessError(command, result)
|
||||
return result
|
||||
931
eval/workflow_bench/promotion_apply.py
Normal file
931
eval/workflow_bench/promotion_apply.py
Normal file
|
|
@ -0,0 +1,931 @@
|
|||
"""Evidence-bound, transactional application of promoted skill overlays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .evolution import MAX_CANDIDATE_OVERLAY_BYTES, candidate_overlay_payload
|
||||
from .process_control import run_managed
|
||||
|
||||
# Shipped byte-identical mirrors of .claude/skills/<name> (see
|
||||
# gitnexus/test/unit/shipped-skills-sync.test.ts — the drift guard).
|
||||
MIRROR_SKILL_ROOTS = ("gitnexus/skills", "gitnexus-claude-plugin/skills")
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class _StagingCleanupError(RuntimeError):
|
||||
"""A staged name still needs transaction-owned cleanup/recovery."""
|
||||
|
||||
def __init__(self, name: str, failure: BaseException, cleanup: BaseException) -> None:
|
||||
self.name = name
|
||||
super().__init__(
|
||||
f"staging failed ({type(failure).__name__}: {failure}) and cleanup failed "
|
||||
f"({type(cleanup).__name__}: {cleanup})"
|
||||
)
|
||||
|
||||
|
||||
def mirror_targets(relative: PurePosixPath) -> list[PurePosixPath]:
|
||||
"""Every repo path one overlay file lands on: canonical + shipped mirrors."""
|
||||
skill = relative.parts[2]
|
||||
rest = PurePosixPath(*relative.parts[3:])
|
||||
targets = [relative]
|
||||
targets += [PurePosixPath(root, skill, rest) for root in MIRROR_SKILL_ROOTS]
|
||||
return targets
|
||||
|
||||
|
||||
def freeze_overlay(overlay: Path, destination: Path) -> str:
|
||||
"""Copy authorized bytes into a private, read-only benchmark snapshot."""
|
||||
digest, payload = candidate_overlay_payload(overlay)
|
||||
destination = destination.expanduser().absolute()
|
||||
if destination.exists() or destination.is_symlink():
|
||||
raise ValueError(f"overlay snapshot destination already exists: {destination}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = Path(tempfile.mkdtemp(prefix=".overlay-snapshot-", dir=destination.parent))
|
||||
try:
|
||||
for relative, content in payload:
|
||||
target = staging / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
for directory in sorted(
|
||||
(path for path in staging.rglob("*") if path.is_dir()),
|
||||
key=lambda path: len(path.parts),
|
||||
reverse=True,
|
||||
):
|
||||
directory.chmod(0o500)
|
||||
staging.chmod(0o500)
|
||||
os.replace(staging, destination)
|
||||
except BaseException:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise
|
||||
frozen_digest, _ = candidate_overlay_payload(destination)
|
||||
if frozen_digest != digest:
|
||||
raise RuntimeError("frozen overlay bytes do not match the authorized input")
|
||||
return digest
|
||||
|
||||
|
||||
def _stage_replacement(path: Path, content: bytes, mode: int) -> Path:
|
||||
descriptor, raw_path = tempfile.mkstemp(prefix=".wfevolve-", dir=path.parent)
|
||||
staged = Path(raw_path)
|
||||
try:
|
||||
os.fchmod(descriptor, stat.S_IMODE(mode))
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except BaseException:
|
||||
# A partially written candidate/backup is never eligible for later
|
||||
# cleanup through the replacements list, so remove it here before the
|
||||
# staging exception escapes.
|
||||
os.close(descriptor)
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
os.close(descriptor)
|
||||
return staged
|
||||
|
||||
|
||||
def _read_destination(path: Path, *, target: PurePosixPath) -> tuple[bytes, int]:
|
||||
"""Read one mirror without following links and reject concurrent mutation."""
|
||||
|
||||
try:
|
||||
before = path.lstat()
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"overlay destination must already be a regular file: {target}") from exc
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise ValueError(f"overlay destination must already be a regular file: {target}")
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode):
|
||||
raise ValueError(f"overlay destination must already be a regular file: {target}")
|
||||
chunks: list[bytes] = []
|
||||
while chunk := os.read(descriptor, 64 * 1024):
|
||||
chunks.append(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
final = path.lstat()
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"overlay destination changed while being read: {target}") from exc
|
||||
|
||||
def identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||
return (
|
||||
value.st_dev,
|
||||
value.st_ino,
|
||||
value.st_size,
|
||||
value.st_mtime_ns,
|
||||
stat.S_IMODE(value.st_mode),
|
||||
)
|
||||
|
||||
if (
|
||||
stat.S_ISLNK(final.st_mode)
|
||||
or not stat.S_ISREG(final.st_mode)
|
||||
or not (identity(before) == identity(opened) == identity(after) == identity(final))
|
||||
):
|
||||
raise ValueError(f"overlay destination changed while being read: {target}")
|
||||
return b"".join(chunks), opened.st_mode
|
||||
|
||||
|
||||
def _open_repository_root(repo_root: Path) -> tuple[Path, int]:
|
||||
root = repo_root.expanduser().absolute()
|
||||
try:
|
||||
metadata = root.lstat()
|
||||
resolved = root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"repository root is unavailable: {root}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"repository root must be a real directory: {root}")
|
||||
if resolved != root:
|
||||
raise ValueError(f"repository root must not traverse symlinks: {root}")
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(root, flags)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"repository root changed while opening: {root}") from exc
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
final = root.lstat()
|
||||
final_resolved = root.resolve(strict=True)
|
||||
|
||||
def identity(value: os.stat_result) -> tuple[int, int, int]:
|
||||
return value.st_dev, value.st_ino, stat.S_IFMT(value.st_mode)
|
||||
|
||||
if (
|
||||
stat.S_ISLNK(final.st_mode)
|
||||
or not stat.S_ISDIR(opened.st_mode)
|
||||
or not stat.S_ISDIR(final.st_mode)
|
||||
or final_resolved != root
|
||||
or not (identity(metadata) == identity(opened) == identity(final))
|
||||
):
|
||||
raise ValueError(f"repository root changed while opening: {root}")
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise ValueError(f"repository root changed while opening: {root}") from exc
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
return root, descriptor
|
||||
|
||||
|
||||
def _open_target_parent(root_descriptor: int, target: PurePosixPath) -> int:
|
||||
if target.is_absolute() or not target.parts or ".." in target.parts:
|
||||
raise ValueError(f"overlay destination escapes repository: {target}")
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
current = os.dup(root_descriptor)
|
||||
try:
|
||||
for part in target.parts[:-1]:
|
||||
try:
|
||||
metadata = os.stat(part, dir_fd=current, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"overlay destination parent is unavailable: {target}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise ValueError(f"overlay destination parent must not be a symlink: {target}")
|
||||
try:
|
||||
child = os.open(part, flags, dir_fd=current)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"overlay destination parent changed while opening: {target}") from exc
|
||||
opened = os.fstat(child)
|
||||
if (
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
stat.S_IFMT(opened.st_mode),
|
||||
) != (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
stat.S_IFMT(metadata.st_mode),
|
||||
):
|
||||
os.close(child)
|
||||
raise ValueError(f"overlay destination parent changed while opening: {target}")
|
||||
os.close(current)
|
||||
current = child
|
||||
return current
|
||||
except BaseException:
|
||||
os.close(current)
|
||||
raise
|
||||
|
||||
|
||||
def _directory_identity(metadata: os.stat_result) -> tuple[int, int, int]:
|
||||
return metadata.st_dev, metadata.st_ino, stat.S_IFMT(metadata.st_mode)
|
||||
|
||||
|
||||
def _validate_repository_root_binding(root: Path, root_descriptor: int, *, phase: str) -> None:
|
||||
"""Prove the held root still names the repository's lexical directory."""
|
||||
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
lexical = root.lstat()
|
||||
resolved = root.resolve(strict=True)
|
||||
reopened = os.open(root, flags)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"repository root changed during overlay {phase}: {root}") from exc
|
||||
try:
|
||||
opened = os.fstat(reopened)
|
||||
held = os.fstat(root_descriptor)
|
||||
if (
|
||||
stat.S_ISLNK(lexical.st_mode)
|
||||
or not stat.S_ISDIR(lexical.st_mode)
|
||||
or resolved != root
|
||||
or not stat.S_ISDIR(opened.st_mode)
|
||||
or not stat.S_ISDIR(held.st_mode)
|
||||
or _directory_identity(lexical) != _directory_identity(opened)
|
||||
or _directory_identity(opened) != _directory_identity(held)
|
||||
):
|
||||
raise ValueError(f"repository root changed during overlay {phase}: {root}")
|
||||
finally:
|
||||
os.close(reopened)
|
||||
|
||||
|
||||
def _validate_prepared_paths(
|
||||
root: Path,
|
||||
root_descriptor: int,
|
||||
prepared: list[dict[str, Any]],
|
||||
*,
|
||||
phase: str,
|
||||
) -> None:
|
||||
"""Rebind every held parent descriptor to its current lexical repo path."""
|
||||
|
||||
_validate_repository_root_binding(root, root_descriptor, phase=phase)
|
||||
for item in prepared:
|
||||
reopened = _open_target_parent(root_descriptor, item["target"])
|
||||
try:
|
||||
if _directory_identity(os.fstat(reopened)) != _directory_identity(os.fstat(item["parent_descriptor"])):
|
||||
raise ValueError(f"overlay destination parent changed during {phase}: {item['target']}")
|
||||
finally:
|
||||
os.close(reopened)
|
||||
# Catch a repository-root replacement that raced the parent walk itself.
|
||||
_validate_repository_root_binding(root, root_descriptor, phase=phase)
|
||||
|
||||
|
||||
def _read_destination_at(
|
||||
parent_descriptor: int,
|
||||
name: str,
|
||||
*,
|
||||
target: PurePosixPath,
|
||||
) -> tuple[bytes, int]:
|
||||
try:
|
||||
before = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"overlay destination must already be a regular file: {target}") from exc
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise ValueError(f"overlay destination must already be a regular file: {target}")
|
||||
descriptor = os.open(
|
||||
name,
|
||||
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0),
|
||||
dir_fd=parent_descriptor,
|
||||
)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
chunks: list[bytes] = []
|
||||
while chunk := os.read(descriptor, 64 * 1024):
|
||||
chunks.append(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
final = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"overlay destination changed while being read: {target}") from exc
|
||||
|
||||
def identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
|
||||
return (
|
||||
value.st_dev,
|
||||
value.st_ino,
|
||||
value.st_size,
|
||||
value.st_mtime_ns,
|
||||
stat.S_IMODE(value.st_mode),
|
||||
)
|
||||
|
||||
if (
|
||||
not stat.S_ISREG(opened.st_mode)
|
||||
or stat.S_ISLNK(final.st_mode)
|
||||
or not stat.S_ISREG(final.st_mode)
|
||||
or not (identity(before) == identity(opened) == identity(after) == identity(final))
|
||||
):
|
||||
raise ValueError(f"overlay destination changed while being read: {target}")
|
||||
return b"".join(chunks), opened.st_mode
|
||||
|
||||
|
||||
def _stage_replacement_at(parent_descriptor: int, content: bytes, mode: int) -> str:
|
||||
for _ in range(100):
|
||||
name = f".wfevolve-{secrets.token_hex(16)}"
|
||||
try:
|
||||
descriptor = os.open(
|
||||
name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
|
||||
stat.S_IMODE(mode),
|
||||
dir_fd=parent_descriptor,
|
||||
)
|
||||
except FileExistsError:
|
||||
continue
|
||||
try:
|
||||
os.fchmod(descriptor, stat.S_IMODE(mode))
|
||||
view = memoryview(content)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError("short write while staging overlay replacement")
|
||||
view = view[written:]
|
||||
os.fsync(descriptor)
|
||||
except BaseException as exc:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
os.unlink(name, dir_fd=parent_descriptor)
|
||||
os.fsync(parent_descriptor)
|
||||
except OSError as cleanup_exc:
|
||||
raise _StagingCleanupError(name, exc, cleanup_exc) from exc
|
||||
raise
|
||||
else:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
os.fsync(parent_descriptor)
|
||||
except OSError as exc:
|
||||
try:
|
||||
os.unlink(name, dir_fd=parent_descriptor)
|
||||
os.fsync(parent_descriptor)
|
||||
except OSError as cleanup_exc:
|
||||
raise _StagingCleanupError(name, exc, cleanup_exc) from exc
|
||||
raise
|
||||
return name
|
||||
raise FileExistsError("could not allocate a unique overlay staging file")
|
||||
|
||||
|
||||
def _temporary_exists(parent_descriptor: int, name: str) -> bool:
|
||||
try:
|
||||
os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _unlink_temporary(parent_descriptor: int, name: str) -> None:
|
||||
try:
|
||||
os.unlink(name, dir_fd=parent_descriptor)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
os.fsync(parent_descriptor)
|
||||
|
||||
|
||||
def _entry_identity_at(parent_descriptor: int, name: str) -> tuple[int, int, int, int, int, int]:
|
||||
metadata = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
stat.S_IFMT(metadata.st_mode),
|
||||
metadata.st_size,
|
||||
metadata.st_mtime_ns,
|
||||
stat.S_IMODE(metadata.st_mode),
|
||||
)
|
||||
|
||||
|
||||
def _same_entry(
|
||||
left: tuple[int, int, int, int, int, int],
|
||||
right: tuple[int, int, int, int, int, int],
|
||||
) -> bool:
|
||||
return left[:2] == right[:2]
|
||||
|
||||
|
||||
_RENAME_EXCHANGE = 2
|
||||
|
||||
|
||||
def _exchange_at(parent_descriptor: int, left: str, right: str) -> None:
|
||||
"""Atomically exchange two existing names in one held directory."""
|
||||
|
||||
try:
|
||||
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
|
||||
except AttributeError as exc:
|
||||
raise RuntimeError("atomic overlay exchange is unavailable on this platform") from exc
|
||||
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
renameat2.restype = ctypes.c_int
|
||||
if (
|
||||
renameat2(
|
||||
parent_descriptor,
|
||||
os.fsencode(left),
|
||||
parent_descriptor,
|
||||
os.fsencode(right),
|
||||
_RENAME_EXCHANGE,
|
||||
)
|
||||
== 0
|
||||
):
|
||||
os.fsync(parent_descriptor)
|
||||
return
|
||||
error = ctypes.get_errno()
|
||||
if error in {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP}:
|
||||
raise RuntimeError("atomic overlay exchange is unavailable on this filesystem")
|
||||
raise OSError(error, os.strerror(error), f"{left} <-> {right}")
|
||||
|
||||
|
||||
def _prepare_targets(
|
||||
payload: list[tuple[PurePosixPath, bytes]],
|
||||
repo_root: Path,
|
||||
) -> tuple[Path, int, list[dict[str, Any]]]:
|
||||
"""Resolve and snapshot every canonical/shipped destination exactly once."""
|
||||
|
||||
root, root_descriptor = _open_repository_root(repo_root)
|
||||
prepared: list[dict[str, Any]] = []
|
||||
seen: set[PurePosixPath] = set()
|
||||
try:
|
||||
for relative, content in payload:
|
||||
for target in mirror_targets(relative):
|
||||
if target in seen:
|
||||
raise ValueError(f"duplicate overlay destination: {target}")
|
||||
seen.add(target)
|
||||
parent_descriptor = _open_target_parent(root_descriptor, target)
|
||||
try:
|
||||
original, mode = _read_destination_at(
|
||||
parent_descriptor,
|
||||
target.name,
|
||||
target=target,
|
||||
)
|
||||
except BaseException:
|
||||
os.close(parent_descriptor)
|
||||
raise
|
||||
prepared.append(
|
||||
{
|
||||
"target": target,
|
||||
"destination": root / target,
|
||||
"parent_path": root / target.parent,
|
||||
"parent_descriptor": parent_descriptor,
|
||||
"name": target.name,
|
||||
"content": content,
|
||||
"original": original,
|
||||
"base_digest": hashlib.sha256(original).hexdigest(),
|
||||
"mode": mode,
|
||||
}
|
||||
)
|
||||
_validate_prepared_paths(
|
||||
root,
|
||||
root_descriptor,
|
||||
prepared,
|
||||
phase="preparation",
|
||||
)
|
||||
return root, root_descriptor, prepared
|
||||
except BaseException:
|
||||
for item in prepared:
|
||||
os.close(item["parent_descriptor"])
|
||||
os.close(root_descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def _close_prepared(root_descriptor: int, prepared: list[dict[str, Any]]) -> None:
|
||||
try:
|
||||
for item in prepared:
|
||||
os.close(item["parent_descriptor"])
|
||||
finally:
|
||||
os.close(root_descriptor)
|
||||
|
||||
|
||||
def destination_base_digests(
|
||||
overlay: Path,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
) -> dict[str, str]:
|
||||
"""Bind promotion evidence to the current bytes of every apply target."""
|
||||
|
||||
_, payload = candidate_overlay_payload(overlay)
|
||||
root, root_descriptor, prepared = _prepare_targets(payload, repo_root)
|
||||
try:
|
||||
_validate_prepared_paths(
|
||||
root,
|
||||
root_descriptor,
|
||||
prepared,
|
||||
phase="base-digest capture",
|
||||
)
|
||||
return {item["target"].as_posix(): item["base_digest"] for item in prepared}
|
||||
finally:
|
||||
_close_prepared(root_descriptor, prepared)
|
||||
|
||||
|
||||
def committed_destination_base_digests(
|
||||
overlay: Path,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
*,
|
||||
ref: str = "HEAD",
|
||||
) -> dict[str, str]:
|
||||
"""Bind targets to one immutable committed incumbent, never live edits."""
|
||||
|
||||
_, payload = candidate_overlay_payload(overlay)
|
||||
root, root_descriptor = _open_repository_root(repo_root)
|
||||
os.close(root_descriptor)
|
||||
rev = run_managed(
|
||||
["git", "-C", str(root), "rev-parse", f"{ref}^{{commit}}"],
|
||||
timeout=60,
|
||||
capture_stdout_bytes=256,
|
||||
)
|
||||
if not rev.ok or rev.stdout_capture_overflow or rev.stdout_capture is None:
|
||||
raise ValueError("could not resolve the committed promotion base")
|
||||
commit = rev.stdout_capture.decode("ascii", errors="strict").strip()
|
||||
if not commit or any(character not in "0123456789abcdefABCDEF" for character in commit):
|
||||
raise ValueError("committed promotion base is not an immutable object id")
|
||||
bindings: dict[str, str] = {}
|
||||
for relative, _content in payload:
|
||||
for target in mirror_targets(relative):
|
||||
key = target.as_posix()
|
||||
if key in bindings:
|
||||
raise ValueError(f"duplicate overlay destination: {target}")
|
||||
result = run_managed(
|
||||
["git", "-C", str(root), "show", f"{commit}:{key}"],
|
||||
timeout=60,
|
||||
capture_stdout_bytes=MAX_CANDIDATE_OVERLAY_BYTES + 1,
|
||||
)
|
||||
if not result.ok or result.stdout_capture_overflow or result.stdout_capture is None:
|
||||
raise ValueError(f"committed overlay destination is unavailable: {target}")
|
||||
bindings[key] = hashlib.sha256(result.stdout_capture).hexdigest()
|
||||
return bindings
|
||||
|
||||
|
||||
def _write_recovery_artifact(
|
||||
root_descriptor: int,
|
||||
repo_root: Path,
|
||||
*,
|
||||
failure: BaseException,
|
||||
rollback_failures: list[str],
|
||||
replacements: list[dict[str, Any]],
|
||||
transaction_state: str,
|
||||
) -> Path:
|
||||
recovery_name = ".wfbench-overlay-recovery-" + datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ") + ".json"
|
||||
|
||||
def descriptor_path(descriptor: int, fallback: Path) -> Path:
|
||||
try:
|
||||
return Path(os.readlink(f"/proc/self/fd/{descriptor}"))
|
||||
except OSError:
|
||||
return fallback
|
||||
|
||||
root_path = descriptor_path(root_descriptor, repo_root)
|
||||
records = []
|
||||
for replacement in replacements:
|
||||
parent = descriptor_path(replacement["parent_descriptor"], replacement["parent_path"])
|
||||
candidate_exists = _temporary_exists(replacement["parent_descriptor"], replacement["candidate"])
|
||||
backup = replacement.get("backup")
|
||||
backup_exists = backup is not None and _temporary_exists(replacement["parent_descriptor"], backup)
|
||||
if candidate_exists or backup_exists:
|
||||
records.append(
|
||||
{
|
||||
"target": replacement["target"].as_posix(),
|
||||
"destination": str(parent / replacement["name"]),
|
||||
"candidate": str(parent / replacement["candidate"]),
|
||||
"candidate_exists": candidate_exists,
|
||||
"backup": str(parent / backup) if backup is not None else None,
|
||||
"backup_exists": backup_exists,
|
||||
}
|
||||
)
|
||||
|
||||
descriptor = os.open(
|
||||
recovery_name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
|
||||
0o600,
|
||||
dir_fd=root_descriptor,
|
||||
)
|
||||
payload = {
|
||||
"failure": f"{type(failure).__name__}: {failure}",
|
||||
"transaction_state": transaction_state,
|
||||
"rollback_failures": rollback_failures,
|
||||
"backups": records,
|
||||
}
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as handle:
|
||||
json.dump(payload, handle, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.fsync(root_descriptor)
|
||||
return root_path / recovery_name
|
||||
|
||||
|
||||
def apply_promoted_overlay(
|
||||
overlay: Path,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
*,
|
||||
expected_digest: str | None = None,
|
||||
expected_target_bases: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Compare-and-swap one evidence-bound overlay across every mirror."""
|
||||
digest, payload = candidate_overlay_payload(overlay)
|
||||
if expected_digest is not None and digest != expected_digest:
|
||||
raise ValueError("candidate overlay digest no longer matches promotion evidence")
|
||||
|
||||
repo_root, root_descriptor, prepared = _prepare_targets(payload, repo_root)
|
||||
current_bases = {item["target"].as_posix(): item["base_digest"] for item in prepared}
|
||||
if expected_target_bases is not None and expected_target_bases != current_bases:
|
||||
expected_paths = set(expected_target_bases)
|
||||
current_paths = set(current_bases)
|
||||
missing = sorted(current_paths - expected_paths)
|
||||
unexpected = sorted(expected_paths - current_paths)
|
||||
drifted = sorted(
|
||||
path for path in current_paths & expected_paths if current_bases[path] != expected_target_bases[path]
|
||||
)
|
||||
details = []
|
||||
if missing:
|
||||
details.append("missing=" + ",".join(missing))
|
||||
if unexpected:
|
||||
details.append("unexpected=" + ",".join(unexpected))
|
||||
if drifted:
|
||||
details.append("drifted=" + ",".join(drifted))
|
||||
_close_prepared(root_descriptor, prepared)
|
||||
raise ValueError("overlay destination base binding mismatch: " + "; ".join(details))
|
||||
|
||||
replacements: list[dict[str, Any]] = []
|
||||
completed: list[dict[str, Any]] = []
|
||||
preserve_backups = False
|
||||
published_all = False
|
||||
rollback_complete = False
|
||||
|
||||
def entry_state(replacement: dict[str, Any], name: str) -> tuple[str, int]:
|
||||
current, mode = _read_destination_at(
|
||||
replacement["parent_descriptor"],
|
||||
name,
|
||||
target=replacement["target"],
|
||||
)
|
||||
return hashlib.sha256(current).hexdigest(), stat.S_IMODE(mode)
|
||||
|
||||
def current_state(replacement: dict[str, Any]) -> tuple[str, int]:
|
||||
return entry_state(replacement, replacement["name"])
|
||||
|
||||
def candidate_is_intact(replacement: dict[str, Any], name: str) -> bool:
|
||||
try:
|
||||
identity = _entry_identity_at(replacement["parent_descriptor"], name)
|
||||
state = entry_state(replacement, name)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return identity == replacement["candidate_identity"] and state == replacement["candidate_state"]
|
||||
|
||||
def rollback_exchange_is_valid(
|
||||
replacement: dict[str, Any],
|
||||
displaced_identity: tuple[int, int, int, int, int, int],
|
||||
displaced_state: tuple[str, int] | None,
|
||||
) -> bool:
|
||||
try:
|
||||
destination_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["name"],
|
||||
)
|
||||
if destination_identity != displaced_identity:
|
||||
return False
|
||||
if displaced_state is not None and current_state(replacement) != displaced_state:
|
||||
return False
|
||||
return candidate_is_intact(replacement, replacement["candidate"])
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
try:
|
||||
for item in prepared:
|
||||
try:
|
||||
candidate = _stage_replacement_at(item["parent_descriptor"], item["content"], item["mode"])
|
||||
except _StagingCleanupError as stage_exc:
|
||||
replacements.append(
|
||||
{
|
||||
**item,
|
||||
"candidate": stage_exc.name,
|
||||
"backup": None,
|
||||
"candidate_identity": None,
|
||||
}
|
||||
)
|
||||
raise
|
||||
replacement = {
|
||||
**item,
|
||||
"candidate": candidate,
|
||||
"backup": None,
|
||||
"candidate_digest": hashlib.sha256(item["content"]).hexdigest(),
|
||||
"base_state": (item["base_digest"], stat.S_IMODE(item["mode"])),
|
||||
"candidate_state": (
|
||||
hashlib.sha256(item["content"]).hexdigest(),
|
||||
stat.S_IMODE(item["mode"]),
|
||||
),
|
||||
"candidate_identity": None,
|
||||
}
|
||||
replacements.append(replacement)
|
||||
replacement["candidate_identity"] = _entry_identity_at(item["parent_descriptor"], candidate)
|
||||
try:
|
||||
replacement["backup"] = _stage_replacement_at(
|
||||
item["parent_descriptor"],
|
||||
item["original"],
|
||||
item["mode"],
|
||||
)
|
||||
except _StagingCleanupError as stage_exc:
|
||||
replacement["backup"] = stage_exc.name
|
||||
raise
|
||||
# Recheck the entire compare set after staging and before the first
|
||||
# replacement, then check each member immediately before its swap.
|
||||
_validate_prepared_paths(
|
||||
repo_root,
|
||||
root_descriptor,
|
||||
replacements,
|
||||
phase="pre-publication",
|
||||
)
|
||||
for replacement in replacements:
|
||||
if current_state(replacement) != replacement["base_state"]:
|
||||
raise ValueError(f"overlay destination drifted before apply: {replacement['target']}")
|
||||
for replacement in replacements:
|
||||
_validate_prepared_paths(
|
||||
repo_root,
|
||||
root_descriptor,
|
||||
[replacement],
|
||||
phase="publication",
|
||||
)
|
||||
if current_state(replacement) != replacement["base_state"]:
|
||||
raise ValueError(f"overlay destination drifted during apply: {replacement['target']}")
|
||||
previous_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["name"],
|
||||
)
|
||||
replacement["publication_previous_identity"] = previous_identity
|
||||
try:
|
||||
_exchange_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["candidate"],
|
||||
replacement["name"],
|
||||
)
|
||||
except BaseException:
|
||||
# A wrapper/interruption can raise after the atomic exchange.
|
||||
# Classify by inode movement so a raced edit in the displaced
|
||||
# slot cannot be mistaken for an exchange that never landed.
|
||||
try:
|
||||
destination_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["name"],
|
||||
)
|
||||
temporary_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["candidate"],
|
||||
)
|
||||
except OSError:
|
||||
completed.append(replacement)
|
||||
else:
|
||||
if _same_entry(destination_identity, replacement["candidate_identity"]) or not _same_entry(
|
||||
temporary_identity,
|
||||
replacement["candidate_identity"],
|
||||
):
|
||||
completed.append(replacement)
|
||||
raise
|
||||
else:
|
||||
completed.append(replacement)
|
||||
observed_destination = current_state(replacement)
|
||||
observed_previous = entry_state(replacement, replacement["candidate"])
|
||||
destination_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["name"],
|
||||
)
|
||||
displaced_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["candidate"],
|
||||
)
|
||||
if (
|
||||
observed_destination == replacement["candidate_state"]
|
||||
and observed_previous == replacement["base_state"]
|
||||
and destination_identity == replacement["candidate_identity"]
|
||||
and displaced_identity == previous_identity
|
||||
):
|
||||
continue
|
||||
raise RuntimeError(f"atomic overlay exchange parity check failed: {replacement['target']}")
|
||||
for replacement in replacements:
|
||||
if (
|
||||
current_state(replacement) != replacement["candidate_state"]
|
||||
or entry_state(replacement, replacement["candidate"]) != replacement["base_state"]
|
||||
or _entry_identity_at(replacement["parent_descriptor"], replacement["name"])
|
||||
!= replacement["candidate_identity"]
|
||||
or _entry_identity_at(replacement["parent_descriptor"], replacement["candidate"])
|
||||
!= replacement["publication_previous_identity"]
|
||||
):
|
||||
raise RuntimeError(f"post-apply parity check failed: {replacement['target']}")
|
||||
_validate_prepared_paths(
|
||||
repo_root,
|
||||
root_descriptor,
|
||||
replacements,
|
||||
phase="post-apply validation",
|
||||
)
|
||||
published_all = True
|
||||
except BaseException as exc:
|
||||
rollback_failures: list[str] = []
|
||||
for replacement in reversed(completed):
|
||||
try:
|
||||
destination_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["name"],
|
||||
)
|
||||
temporary_identity = _entry_identity_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["candidate"],
|
||||
)
|
||||
except BaseException as rollback_exc:
|
||||
rollback_failures.append(
|
||||
f"{replacement['target']}: cannot inspect exchange state: "
|
||||
f"{type(rollback_exc).__name__}: {rollback_exc}"
|
||||
)
|
||||
continue
|
||||
candidate_at_temporary = candidate_is_intact(replacement, replacement["candidate"])
|
||||
candidate_at_destination = candidate_is_intact(replacement, replacement["name"])
|
||||
if candidate_at_temporary and candidate_at_destination:
|
||||
rollback_failures.append(
|
||||
f"{replacement['target']}: candidate inode is linked at both destination and temporary name"
|
||||
)
|
||||
continue
|
||||
if candidate_at_temporary:
|
||||
continue
|
||||
if not candidate_at_destination:
|
||||
rollback_failures.append(f"{replacement['target']}: destination changed after apply")
|
||||
continue
|
||||
try:
|
||||
displaced_state: tuple[str, int] | None = entry_state(replacement, replacement["candidate"])
|
||||
except (OSError, ValueError):
|
||||
displaced_state = None
|
||||
try:
|
||||
_exchange_at(
|
||||
replacement["parent_descriptor"],
|
||||
replacement["candidate"],
|
||||
replacement["name"],
|
||||
)
|
||||
except BaseException as rollback_exc:
|
||||
if not rollback_exchange_is_valid(replacement, temporary_identity, displaced_state):
|
||||
rollback_failures.append(f"{replacement['target']}: {type(rollback_exc).__name__}: {rollback_exc}")
|
||||
else:
|
||||
if not rollback_exchange_is_valid(replacement, temporary_identity, displaced_state):
|
||||
rollback_failures.append(f"{replacement['target']}: rollback parity check failed")
|
||||
if rollback_failures:
|
||||
preserve_backups = True
|
||||
recovery = _write_recovery_artifact(
|
||||
root_descriptor,
|
||||
repo_root,
|
||||
failure=exc,
|
||||
rollback_failures=rollback_failures,
|
||||
replacements=replacements,
|
||||
transaction_state="rollback-incomplete",
|
||||
)
|
||||
raise RuntimeError(f"overlay apply failed and rollback was incomplete; recovery: {recovery}") from exc
|
||||
rollback_complete = True
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
raise
|
||||
raise RuntimeError("overlay apply failed and all replacements were rolled back") from exc
|
||||
finally:
|
||||
active_failure = sys.exc_info()[1]
|
||||
try:
|
||||
if not preserve_backups:
|
||||
cleanup_failures: list[str] = []
|
||||
cleanup_exception: BaseException | None = None
|
||||
for replacement in replacements:
|
||||
for temporary in (replacement["candidate"], replacement["backup"]):
|
||||
if temporary is None:
|
||||
continue
|
||||
try:
|
||||
_unlink_temporary(replacement["parent_descriptor"], temporary)
|
||||
except BaseException as cleanup_exc:
|
||||
cleanup_exception = cleanup_exc
|
||||
cleanup_failures.append(
|
||||
f"{replacement['target']}:{temporary}: {type(cleanup_exc).__name__}: {cleanup_exc}"
|
||||
)
|
||||
break
|
||||
if cleanup_failures:
|
||||
break
|
||||
if cleanup_failures:
|
||||
preserve_backups = True
|
||||
transaction_state = (
|
||||
"published" if published_all else "rolled-back" if rollback_complete else "not-fully-published"
|
||||
)
|
||||
cleanup_failure = RuntimeError(
|
||||
f"overlay transaction is {transaction_state}, but temporary cleanup was incomplete"
|
||||
)
|
||||
try:
|
||||
recovery = _write_recovery_artifact(
|
||||
root_descriptor,
|
||||
repo_root,
|
||||
failure=active_failure or cleanup_failure,
|
||||
rollback_failures=cleanup_failures,
|
||||
replacements=replacements,
|
||||
transaction_state=transaction_state,
|
||||
)
|
||||
except BaseException as recovery_exc:
|
||||
cleanup_failure.add_note(
|
||||
f"recovery artifact creation also failed: {type(recovery_exc).__name__}: {recovery_exc}"
|
||||
)
|
||||
else:
|
||||
cleanup_failure = RuntimeError(f"{cleanup_failure}; recovery: {recovery}")
|
||||
interrupt = active_failure if isinstance(active_failure, (KeyboardInterrupt, SystemExit)) else None
|
||||
if interrupt is None and isinstance(cleanup_exception, (KeyboardInterrupt, SystemExit)):
|
||||
interrupt = cleanup_exception
|
||||
if interrupt is not None:
|
||||
interrupt.add_note(str(cleanup_failure))
|
||||
raise interrupt
|
||||
raise cleanup_failure from active_failure
|
||||
finally:
|
||||
_close_prepared(root_descriptor, prepared)
|
||||
return [replacement["target"].as_posix() for replacement in replacements]
|
||||
677
eval/workflow_bench/proposer_sandbox.py
Normal file
677
eval/workflow_bench/proposer_sandbox.py
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
"""Linux containment and evidence staging for workflow-bench model sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .process_control import ManagedProcessResult, run_managed
|
||||
|
||||
|
||||
MAX_EVIDENCE_FILE_BYTES = 256 * 1024
|
||||
MAX_BUNDLE_BYTES = 2 * 1024 * 1024
|
||||
SANDBOX_WORKSPACE = "/workspace"
|
||||
SANDBOX_HOME = "/home/agent"
|
||||
SANDBOX_TMP = "/tmp"
|
||||
SANDBOX_CLAUDE = "/opt/claude/claude"
|
||||
SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix"
|
||||
SANDBOX_PATH = "/opt/claude:/usr/local/bin:/usr/bin:/bin"
|
||||
SANDBOX_GITNEXUS = "/opt/gitnexus"
|
||||
SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared"
|
||||
SANDBOX_GITNEXUS_REGISTRY = "/opt/gitnexus-registry"
|
||||
SANDBOX_USER_SKILLS = f"{SANDBOX_HOME}/.claude/skills"
|
||||
|
||||
|
||||
class SandboxError(RuntimeError):
|
||||
"""Containment could not be established without weakening the contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadOnlyMount:
|
||||
source: Path
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxSession:
|
||||
private_root: Path
|
||||
clone: Path
|
||||
home: Path
|
||||
temp: Path
|
||||
bwrap_bin: Path
|
||||
claude_host_bin: Path
|
||||
command_prefix: list[str]
|
||||
read_only_mounts: tuple[ReadOnlyMount, ...]
|
||||
|
||||
@property
|
||||
def claude_bin(self) -> str:
|
||||
return SANDBOX_CLAUDE
|
||||
|
||||
@property
|
||||
def transcript_projects(self) -> Path:
|
||||
return self.home / ".claude" / "projects"
|
||||
|
||||
@property
|
||||
def settings_json(self) -> str:
|
||||
return build_claude_settings()
|
||||
|
||||
def environment(
|
||||
self,
|
||||
*,
|
||||
auth_token: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
return build_sandbox_environment(auth_token=auth_token, base_url=base_url)
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[str],
|
||||
*,
|
||||
timeout: float,
|
||||
env: Mapping[str, str] | None = None,
|
||||
stdin_data: bytes | None = None,
|
||||
) -> ManagedProcessResult:
|
||||
return run_managed(
|
||||
[*self.command_prefix, *command],
|
||||
timeout=timeout,
|
||||
env=dict(env) if env is not None else build_sandbox_environment(),
|
||||
require_pid_namespace=True,
|
||||
stdin_data=stdin_data,
|
||||
)
|
||||
|
||||
def command_prefix_for(
|
||||
self,
|
||||
*,
|
||||
read_only_workspace: bool = False,
|
||||
unshare_network: bool = False,
|
||||
read_only_paths: Sequence[Path] = (),
|
||||
extra_read_only_mounts: Sequence[ReadOnlyMount] = (),
|
||||
) -> list[str]:
|
||||
"""Build a stricter command boundary from this session's fixed roots.
|
||||
|
||||
Model sessions use ``read_only_paths`` to freeze the evaluated skill
|
||||
roots. Verifiers use ``read_only_workspace`` so candidate-authored code
|
||||
cannot change the credited implementation. Extra mounts are reserved
|
||||
for harness-owned, post-session evidence such as hidden oracles.
|
||||
"""
|
||||
|
||||
additional: list[ReadOnlyMount] = []
|
||||
clone = _real_directory(self.clone, label="sandbox clone")
|
||||
for raw_path in read_only_paths:
|
||||
lexical = raw_path.expanduser().absolute()
|
||||
try:
|
||||
relative = lexical.relative_to(clone)
|
||||
metadata = lexical.lstat()
|
||||
resolved = lexical.resolve(strict=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SandboxError(f"read-only sandbox path is unavailable: {raw_path}") from exc
|
||||
if (
|
||||
resolved != lexical
|
||||
or stat.S_ISLNK(metadata.st_mode)
|
||||
or not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode))
|
||||
):
|
||||
raise SandboxError(f"read-only sandbox path must be real and non-symlink: {raw_path}")
|
||||
additional.append(
|
||||
ReadOnlyMount(
|
||||
source=lexical,
|
||||
target=f"{SANDBOX_WORKSPACE}/{PurePosixPath(relative.as_posix())}",
|
||||
)
|
||||
)
|
||||
|
||||
for mount in extra_read_only_mounts:
|
||||
source = mount.source.expanduser().absolute()
|
||||
try:
|
||||
metadata = source.lstat()
|
||||
resolved = source.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"extra read-only mount is unavailable: {source}") from exc
|
||||
if (
|
||||
resolved != source
|
||||
or stat.S_ISLNK(metadata.st_mode)
|
||||
or not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode))
|
||||
):
|
||||
raise SandboxError(f"extra read-only mount must be real and non-symlink: {source}")
|
||||
target = PurePosixPath(mount.target)
|
||||
if not target.is_absolute() or ".." in target.parts:
|
||||
raise SandboxError(f"extra read-only mount target must be absolute: {mount.target}")
|
||||
additional.append(ReadOnlyMount(source=source, target=target.as_posix()))
|
||||
|
||||
return _sandbox_command_prefix(
|
||||
bwrap=self.bwrap_bin,
|
||||
clone=clone,
|
||||
home=self.home,
|
||||
temp=self.temp,
|
||||
claude_bin=self.claude_host_bin,
|
||||
mounts=(*self.read_only_mounts, *additional),
|
||||
read_only_workspace=read_only_workspace,
|
||||
unshare_network=unshare_network,
|
||||
)
|
||||
|
||||
|
||||
_TOKEN_PATTERNS = (
|
||||
re.compile(r"sk-ant-[A-Za-z0-9_-]{8,}"),
|
||||
re.compile(r"gh(?:p|o|u|s|r)_[A-Za-z0-9_]{8,}"),
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+"),
|
||||
re.compile(r"(?i)(https?://)[^/@\s:]+:[^/@\s]+@"),
|
||||
)
|
||||
|
||||
|
||||
def redact_text(text: str, secrets: Sequence[str] = ()) -> str:
|
||||
for secret in secrets:
|
||||
if secret:
|
||||
text = text.replace(secret, "[REDACTED]")
|
||||
text = _TOKEN_PATTERNS[0].sub("[REDACTED]", text)
|
||||
text = _TOKEN_PATTERNS[1].sub("[REDACTED]", text)
|
||||
text = _TOKEN_PATTERNS[2].sub(r"\1[REDACTED]", text)
|
||||
return _TOKEN_PATTERNS[3].sub(r"\1[REDACTED]@", text)
|
||||
|
||||
|
||||
def _evidence_bytes(value: Any, secrets: Sequence[str]) -> bytes:
|
||||
if isinstance(value, Path):
|
||||
try:
|
||||
mode = value.lstat().st_mode
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"evidence path is unreadable: {value}: {exc}") from exc
|
||||
if value.is_symlink() or not stat.S_ISREG(mode):
|
||||
raise SandboxError(f"evidence must be a regular non-symlink file: {value}")
|
||||
if value.stat().st_size > MAX_EVIDENCE_FILE_BYTES:
|
||||
raise SandboxError(f"evidence exceeds the per-file limit: {value}")
|
||||
raw = value.read_bytes()
|
||||
return redact_text(raw.decode(errors="replace"), secrets).encode()
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
elif isinstance(value, str):
|
||||
raw = value.encode()
|
||||
else:
|
||||
raw = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
return redact_text(raw.decode(errors="replace"), secrets).encode()
|
||||
|
||||
|
||||
def stage_evidence_bundle(
|
||||
destination: Path,
|
||||
entries: Mapping[str, Any],
|
||||
*,
|
||||
secrets: Sequence[str] = (),
|
||||
) -> Path:
|
||||
"""Write a redacted owner-only evidence bundle with hard byte caps."""
|
||||
|
||||
destination = destination.resolve()
|
||||
if destination.exists():
|
||||
raise SandboxError(f"evidence destination already exists: {destination}")
|
||||
destination.mkdir(parents=True, mode=0o700)
|
||||
destination.chmod(0o700)
|
||||
total = 0
|
||||
try:
|
||||
for name, value in entries.items():
|
||||
relative = PurePosixPath(name)
|
||||
if len(relative.parts) != 1 or relative.name in {"", ".", ".."}:
|
||||
raise SandboxError(f"evidence names must be simple relative files: {name!r}")
|
||||
payload = _evidence_bytes(value, secrets)
|
||||
if len(payload) > MAX_EVIDENCE_FILE_BYTES:
|
||||
raise SandboxError(f"evidence exceeds the per-file limit: {name}")
|
||||
total += len(payload)
|
||||
if total > MAX_BUNDLE_BYTES:
|
||||
raise SandboxError("evidence bundle exceeds the total byte limit")
|
||||
path = destination / relative.name
|
||||
path.write_bytes(payload)
|
||||
path.chmod(0o600)
|
||||
except BaseException:
|
||||
shutil.rmtree(destination, ignore_errors=True)
|
||||
raise
|
||||
return destination
|
||||
|
||||
|
||||
def _validated_base_url(base_url: str) -> str:
|
||||
value = base_url.strip()
|
||||
parsed = urlsplit(value)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise SandboxError("model base URL must be an HTTP(S) endpoint without credentials, query, or fragment")
|
||||
return value
|
||||
|
||||
|
||||
def build_sandbox_environment(
|
||||
*,
|
||||
auth_token: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build the entire parent environment; never copy ``os.environ``."""
|
||||
|
||||
env = {
|
||||
"HOME": SANDBOX_HOME,
|
||||
"USER": "agent",
|
||||
"LOGNAME": "agent",
|
||||
"TMPDIR": SANDBOX_TMP,
|
||||
"XDG_CONFIG_HOME": f"{SANDBOX_HOME}/.config",
|
||||
"XDG_CACHE_HOME": f"{SANDBOX_HOME}/.cache",
|
||||
"XDG_STATE_HOME": f"{SANDBOX_HOME}/.local/state",
|
||||
"PATH": SANDBOX_PATH,
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"TERM": "dumb",
|
||||
"CI": "1",
|
||||
"NO_COLOR": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
|
||||
"NPM_CONFIG_AUDIT": "false",
|
||||
"NPM_CONFIG_FUND": "false",
|
||||
"NPM_CONFIG_CACHE": f"{SANDBOX_TMP}/npm-cache",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
"CLAUDE_CODE_DISABLE_TELEMETRY": "1",
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "1",
|
||||
"CLAUDE_CODE_DONT_INHERIT_ENV": "1",
|
||||
"CLAUDE_CODE_SHELL_PREFIX": SANDBOX_SHELL_PREFIX,
|
||||
"CLAUDE_CONFIG_DIR": f"{SANDBOX_HOME}/.claude",
|
||||
}
|
||||
if auth_token is not None:
|
||||
token = auth_token.strip()
|
||||
if not token:
|
||||
raise SandboxError("model auth token must not be blank")
|
||||
# Every benchmark/proposer invocation uses Claude's --bare mode,
|
||||
# which intentionally ignores OAuth/keychain/AUTH_TOKEN credentials.
|
||||
env["ANTHROPIC_API_KEY"] = token
|
||||
if base_url is not None:
|
||||
env["ANTHROPIC_BASE_URL"] = _validated_base_url(base_url)
|
||||
return env
|
||||
|
||||
|
||||
def build_claude_settings() -> str:
|
||||
"""Inline settings: hooks/plugins are absent and every Bash stays sandboxed."""
|
||||
|
||||
settings = {
|
||||
"sandbox": {
|
||||
"enabled": True,
|
||||
"failIfUnavailable": True,
|
||||
"autoAllowBashIfSandboxed": True,
|
||||
"allowUnsandboxedCommands": False,
|
||||
"enableWeakerNestedSandbox": True,
|
||||
"network": {
|
||||
"allowedDomains": [],
|
||||
"deniedDomains": ["*"],
|
||||
"allowAllUnixSockets": False,
|
||||
"allowLocalBinding": False,
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [SANDBOX_WORKSPACE, SANDBOX_TMP, SANDBOX_HOME],
|
||||
"denyRead": ["/"],
|
||||
"allowRead": [
|
||||
SANDBOX_WORKSPACE,
|
||||
SANDBOX_TMP,
|
||||
SANDBOX_HOME,
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/opt/claude",
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_GITNEXUS_SHARED,
|
||||
SANDBOX_GITNEXUS_REGISTRY,
|
||||
],
|
||||
},
|
||||
},
|
||||
"permissions": {
|
||||
"defaultMode": "dontAsk",
|
||||
"disableBypassPermissionsMode": "disable",
|
||||
},
|
||||
"env": {
|
||||
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "1",
|
||||
"CLAUDE_CODE_DONT_INHERIT_ENV": "1",
|
||||
},
|
||||
}
|
||||
return json.dumps(settings, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _runtime_mount_args() -> list[str]:
|
||||
args: list[str] = []
|
||||
for raw in ("/usr", "/bin", "/lib", "/lib64"):
|
||||
path = Path(raw)
|
||||
if path.exists():
|
||||
args += ["--ro-bind", raw, raw]
|
||||
for raw in (
|
||||
"/etc/ssl",
|
||||
"/etc/hosts",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/passwd",
|
||||
"/etc/group",
|
||||
):
|
||||
path = Path(raw)
|
||||
if path.exists():
|
||||
args += ["--ro-bind", raw, raw]
|
||||
return args
|
||||
|
||||
|
||||
def _create_shell_prefix_wrapper(private_root: Path) -> Path:
|
||||
"""Create Claude's immutable clean-environment command adapter."""
|
||||
|
||||
wrapper = private_root / "shell-prefix"
|
||||
wrapper.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"set -eu\n"
|
||||
'if [ "$#" -ne 1 ]; then exit 64; fi\n'
|
||||
"exec /usr/bin/env -i "
|
||||
f"HOME={SANDBOX_HOME} USER=agent LOGNAME=agent TMPDIR={SANDBOX_TMP} "
|
||||
f"PATH={SANDBOX_PATH} LANG=C.UTF-8 LC_ALL=C.UTF-8 TERM=dumb "
|
||||
'/bin/bash -c "$1"\n'
|
||||
)
|
||||
wrapper.chmod(0o500)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _resolve_executable(executable: Path | str | None, default: str) -> Path:
|
||||
raw = os.fspath(executable) if executable is not None else shutil.which(default)
|
||||
if not raw:
|
||||
raise SandboxError(f"required executable is unavailable: {default}")
|
||||
path = Path(raw).expanduser().resolve()
|
||||
if not path.is_file() or not os.access(path, os.X_OK):
|
||||
raise SandboxError(f"required executable is not an executable regular file: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def preflight_bubblewrap(bwrap_bin: Path | str | None = None) -> Path:
|
||||
"""Prove the required namespaces work; never fall back to host execution."""
|
||||
|
||||
if sys.platform != "linux":
|
||||
raise SandboxError(f"Bubblewrap containment is supported only on Linux/WSL2, not {sys.platform}")
|
||||
bwrap = _resolve_executable(bwrap_bin, "bwrap")
|
||||
command = [
|
||||
str(bwrap),
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
*_runtime_mount_args(),
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--",
|
||||
"/usr/bin/true",
|
||||
]
|
||||
result = run_managed(command, timeout=10, require_pid_namespace=True)
|
||||
if not result.ok:
|
||||
raise SandboxError(f"Bubblewrap namespace preflight failed: {result.detail or result.stderr_tail[-1000:]}")
|
||||
return bwrap
|
||||
|
||||
|
||||
def pid_namespace_command(
|
||||
command: Sequence[str],
|
||||
*,
|
||||
bwrap_bin: Path,
|
||||
) -> list[str]:
|
||||
"""Wrap a trusted host command in an owned PID namespace.
|
||||
|
||||
This boundary deliberately preserves the host filesystem and network; its
|
||||
sole purpose is making every descendant visible to the outer driver even
|
||||
when a nested command creates a new session or process group.
|
||||
"""
|
||||
|
||||
if not command:
|
||||
raise ValueError("PID-namespace command must not be empty")
|
||||
return [
|
||||
str(bwrap_bin),
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--bind",
|
||||
"/",
|
||||
"/",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--",
|
||||
*command,
|
||||
]
|
||||
|
||||
|
||||
def require_claude_sandbox_helpers() -> None:
|
||||
"""Fail before paid work when Claude's mandatory inner sandbox cannot run."""
|
||||
|
||||
_resolve_executable(None, "socat")
|
||||
|
||||
|
||||
def _real_directory(path: Path, *, label: str) -> Path:
|
||||
"""Return an absolute directory path without accepting any symlink hop."""
|
||||
|
||||
lexical = path.expanduser().absolute()
|
||||
try:
|
||||
mode = lexical.lstat().st_mode
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"{label} must be a real directory: {lexical}: {exc}") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise SandboxError(f"{label} must be a real directory: {lexical}")
|
||||
try:
|
||||
resolved = lexical.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"{label} must be a real directory: {lexical}: {exc}") from exc
|
||||
if resolved != lexical:
|
||||
raise SandboxError(f"{label} must not traverse symlinks: {lexical}")
|
||||
return lexical
|
||||
|
||||
|
||||
def _safe_repo_source(repo: Path, relative: str, *, label: str) -> tuple[Path, Path]:
|
||||
candidate = PurePosixPath(relative)
|
||||
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
||||
raise SandboxError(f"{label} must be a repository-relative path: {relative!r}")
|
||||
lexical = repo / Path(*candidate.parts)
|
||||
resolved = lexical.resolve()
|
||||
try:
|
||||
resolved.relative_to(repo)
|
||||
except ValueError as exc:
|
||||
raise SandboxError(f"{label} escapes its allowed repository root: {relative}") from exc
|
||||
if not resolved.exists():
|
||||
raise SandboxError(f"{label} does not exist: {relative}")
|
||||
return lexical, resolved
|
||||
|
||||
|
||||
def _prepare_clone_target(
|
||||
clone: Path,
|
||||
relative: PurePosixPath,
|
||||
*,
|
||||
directory: bool | None,
|
||||
label: str,
|
||||
) -> Path:
|
||||
"""Validate/create a clone-local target without following any symlink.
|
||||
|
||||
This runs before Bubblewrap, so ordinary ``Path.mkdir``/``touch`` calls
|
||||
are not acceptable: an untrusted tracked parent symlink could redirect a
|
||||
mount placeholder write into the host filesystem.
|
||||
"""
|
||||
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0)
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
current_fd = os.open(clone, flags | nofollow)
|
||||
try:
|
||||
for part in relative.parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o700, dir_fd=current_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
next_fd = os.open(part, flags | nofollow, dir_fd=current_fd)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"{label} target has a non-directory or symlink parent: {relative}") from exc
|
||||
os.close(current_fd)
|
||||
current_fd = next_fd
|
||||
|
||||
leaf = relative.parts[-1]
|
||||
try:
|
||||
mode = os.stat(leaf, dir_fd=current_fd, follow_symlinks=False).st_mode
|
||||
except FileNotFoundError:
|
||||
mode = None
|
||||
if mode is not None and stat.S_ISLNK(mode):
|
||||
raise SandboxError(f"{label} target cannot be a symlink: {relative}")
|
||||
if directory is True:
|
||||
if mode is None:
|
||||
os.mkdir(leaf, mode=0o700, dir_fd=current_fd)
|
||||
elif not stat.S_ISDIR(mode):
|
||||
raise SandboxError(f"{label} directory target has the wrong type: {relative}")
|
||||
elif directory is False:
|
||||
if mode is None:
|
||||
file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | nofollow
|
||||
file_fd = os.open(leaf, file_flags, 0o600, dir_fd=current_fd)
|
||||
os.close(file_fd)
|
||||
elif not stat.S_ISREG(mode):
|
||||
raise SandboxError(f"{label} file target has the wrong type: {relative}")
|
||||
elif mode is not None and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
||||
raise SandboxError(f"{label} target has the wrong type: {relative}")
|
||||
finally:
|
||||
os.close(current_fd)
|
||||
return clone / Path(*relative.parts)
|
||||
|
||||
|
||||
def stage_task_assets(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
repo: Path,
|
||||
clone: Path,
|
||||
) -> list[ReadOnlyMount]:
|
||||
"""Compatibility wrapper for immutable task-asset staging."""
|
||||
|
||||
# Kept lazy to avoid a module cycle: task_assets uses the sandbox's
|
||||
# shared error, mount, and no-follow target primitives.
|
||||
from .task_assets import stage_task_assets as stage_immutable_task_assets
|
||||
|
||||
return stage_immutable_task_assets(task, repo=repo, clone=clone)
|
||||
|
||||
|
||||
def _sandbox_command_prefix(
|
||||
*,
|
||||
bwrap: Path,
|
||||
clone: Path,
|
||||
home: Path,
|
||||
temp: Path,
|
||||
claude_bin: Path,
|
||||
mounts: Sequence[ReadOnlyMount],
|
||||
read_only_workspace: bool = False,
|
||||
unshare_network: bool = False,
|
||||
) -> list[str]:
|
||||
args = [
|
||||
str(bwrap),
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
*(["--unshare-net"] if unshare_network else []),
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
*_runtime_mount_args(),
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--tmpfs",
|
||||
"/run",
|
||||
"--ro-bind" if read_only_workspace else "--bind",
|
||||
str(clone),
|
||||
SANDBOX_WORKSPACE,
|
||||
"--bind",
|
||||
str(home),
|
||||
SANDBOX_HOME,
|
||||
"--bind",
|
||||
str(temp),
|
||||
SANDBOX_TMP,
|
||||
"--ro-bind",
|
||||
str(claude_bin),
|
||||
SANDBOX_CLAUDE,
|
||||
]
|
||||
for mount in mounts:
|
||||
args += ["--ro-bind", str(mount.source), mount.target]
|
||||
args += ["--chdir", SANDBOX_WORKSPACE, "--"]
|
||||
return args
|
||||
|
||||
|
||||
@contextmanager
|
||||
def prepare_sandbox(
|
||||
*,
|
||||
clone: Path,
|
||||
claude_bin: Path | str | None = None,
|
||||
bwrap_bin: Path | str | None = None,
|
||||
read_only_mounts: Sequence[ReadOnlyMount] = (),
|
||||
preflight: bool = True,
|
||||
) -> Iterator[SandboxSession]:
|
||||
"""Create private host backing dirs and one immutable Bubblewrap command."""
|
||||
|
||||
# Validate the lexical path before resolving it. Resolving first would
|
||||
# erase the evidence that the caller supplied a symlinked clone root.
|
||||
clone = _real_directory(clone, label="sandbox clone")
|
||||
if preflight:
|
||||
bwrap = preflight_bubblewrap(bwrap_bin)
|
||||
require_claude_sandbox_helpers()
|
||||
else:
|
||||
bwrap = _resolve_executable(bwrap_bin, "bwrap")
|
||||
claude = _resolve_executable(claude_bin, "claude")
|
||||
private_root = Path(tempfile.mkdtemp(prefix="wfbench-sandbox-"))
|
||||
private_root.chmod(0o700)
|
||||
home = private_root / "home"
|
||||
temp = private_root / "tmp"
|
||||
for directory in (home, temp):
|
||||
directory.mkdir(mode=0o700)
|
||||
directory.chmod(0o700)
|
||||
shell_prefix = _create_shell_prefix_wrapper(private_root)
|
||||
# Claude may discover user-level skills below HOME. Keep the rest of HOME
|
||||
# writable for normal CLI state, but overlay an immutable empty skills root
|
||||
# so a model cannot shadow the evaluated repository/plugin skill by name.
|
||||
user_skills = home / ".claude" / "skills"
|
||||
user_skills.mkdir(parents=True, mode=0o500)
|
||||
user_skills.chmod(0o500)
|
||||
protected_mounts = (
|
||||
*read_only_mounts,
|
||||
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
|
||||
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
|
||||
)
|
||||
primary: BaseException | None = None
|
||||
try:
|
||||
command_prefix = _sandbox_command_prefix(
|
||||
bwrap=bwrap,
|
||||
clone=clone,
|
||||
home=home,
|
||||
temp=temp,
|
||||
claude_bin=claude,
|
||||
mounts=protected_mounts,
|
||||
)
|
||||
yield SandboxSession(
|
||||
private_root=private_root,
|
||||
clone=clone,
|
||||
home=home,
|
||||
temp=temp,
|
||||
bwrap_bin=bwrap,
|
||||
claude_host_bin=claude,
|
||||
command_prefix=command_prefix,
|
||||
read_only_mounts=protected_mounts,
|
||||
)
|
||||
except BaseException as exc:
|
||||
primary = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
shutil.rmtree(private_root)
|
||||
except OSError as cleanup:
|
||||
if primary is None:
|
||||
raise
|
||||
primary.add_note(f"sandbox cleanup also failed: {type(cleanup).__name__}: {cleanup}")
|
||||
1331
eval/workflow_bench/runner.py
Normal file
1331
eval/workflow_bench/runner.py
Normal file
File diff suppressed because it is too large
Load diff
492
eval/workflow_bench/runner_artifacts.py
Normal file
492
eval/workflow_bench/runner_artifacts.py
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
"""Workspace, patch, and verifier evidence for workflow benchmark runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterator, Sequence
|
||||
|
||||
from .evolution import skill_fingerprint
|
||||
from .process_control import ManagedProcessError, ManagedProcessResult, run_checked, run_managed
|
||||
from .proposer_sandbox import SANDBOX_WORKSPACE, SandboxSession, build_sandbox_environment
|
||||
|
||||
MAX_PATCH_BYTES = 300_000
|
||||
MAX_WORKSPACE_SNAPSHOT_ENTRIES = 100_000
|
||||
MAX_WORKSPACE_SNAPSHOT_PATH_BYTES = 16 * 1024 * 1024
|
||||
MAX_WORKSPACE_SNAPSHOT_FILE_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
IMPLEMENTATION_ARMS = frozenset(
|
||||
{
|
||||
"workflow",
|
||||
"workflow_direct",
|
||||
"ce_workflow",
|
||||
"ce_workflow_direct",
|
||||
"baseline",
|
||||
"baseline_nomcp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationResult:
|
||||
"""Verifier output that preserves infrastructure terminal state."""
|
||||
|
||||
command: Sequence[str] | str
|
||||
process: ManagedProcessResult
|
||||
output: str
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return self.process.ok
|
||||
|
||||
def __iter__(self) -> Iterator[bool | str]:
|
||||
# Preserve the historical two-value unpacking API for standalone
|
||||
# callers while runner.py inspects ``process.state`` explicitly.
|
||||
yield self.passed
|
||||
yield self.output
|
||||
|
||||
|
||||
def workspace_snapshot(worktree: Path) -> dict[str, str]:
|
||||
"""Hash the workspace without following links, excluding Git internals."""
|
||||
|
||||
root = worktree.expanduser().absolute()
|
||||
mode = root.lstat().st_mode
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode) or root.resolve(strict=True) != root:
|
||||
raise ValueError(f"workspace snapshot root must be a real directory: {root}")
|
||||
|
||||
snapshot: dict[str, str] = {}
|
||||
pending: list[tuple[Path, PurePosixPath]] = [(root, PurePosixPath())]
|
||||
entry_count = 0
|
||||
path_bytes = 0
|
||||
file_bytes = 0
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
while pending:
|
||||
directory, relative_dir = pending.pop()
|
||||
try:
|
||||
children = sorted(os.scandir(directory), key=lambda entry: entry.name, reverse=True)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
|
||||
for entry in children:
|
||||
relative = relative_dir / entry.name
|
||||
if relative.parts[0] == ".git":
|
||||
continue
|
||||
entry_count += 1
|
||||
path_bytes += len(relative.as_posix().encode())
|
||||
if entry_count > MAX_WORKSPACE_SNAPSHOT_ENTRIES or path_bytes > MAX_WORKSPACE_SNAPSHOT_PATH_BYTES:
|
||||
raise ValueError("workspace snapshot exceeds its bounded entry or path limit")
|
||||
metadata = entry.stat(follow_symlinks=False)
|
||||
permissions = stat.S_IMODE(metadata.st_mode)
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
snapshot[relative.as_posix()] = f"d:{permissions:o}"
|
||||
pending.append((Path(entry.path), relative))
|
||||
continue
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
snapshot[relative.as_posix()] = f"l:{permissions:o}:{os.readlink(entry.path)}"
|
||||
continue
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
snapshot[relative.as_posix()] = f"s:{metadata.st_mode}"
|
||||
continue
|
||||
file_bytes += metadata.st_size
|
||||
if file_bytes > MAX_WORKSPACE_SNAPSHOT_FILE_BYTES:
|
||||
raise ValueError("workspace snapshot exceeds its bounded file-byte limit")
|
||||
descriptor = os.open(entry.path, os.O_RDONLY | nofollow)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(opened.st_mode)
|
||||
or opened.st_dev != metadata.st_dev
|
||||
or opened.st_ino != metadata.st_ino
|
||||
):
|
||||
raise ValueError(f"workspace file changed while opening: {entry.path}")
|
||||
digest = hashlib.sha256()
|
||||
while chunk := os.read(descriptor, 64 * 1024):
|
||||
digest.update(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
||||
raise ValueError(f"workspace file changed while hashing: {entry.path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
snapshot[relative.as_posix()] = f"f:{permissions:o}:{metadata.st_size}:{digest.hexdigest()}"
|
||||
return snapshot
|
||||
|
||||
|
||||
def enforce_phase_workspace(
|
||||
worktree: Path,
|
||||
before: dict[str, str],
|
||||
*,
|
||||
allowed_artifact: Path,
|
||||
) -> None:
|
||||
"""Require a phase to change only its one explicit workspace artifact."""
|
||||
|
||||
root = worktree.expanduser().absolute()
|
||||
artifact = allowed_artifact.expanduser().absolute()
|
||||
try:
|
||||
relative = PurePosixPath(artifact.relative_to(root).as_posix())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"phase artifact escapes the workspace: {allowed_artifact}") from exc
|
||||
after = workspace_snapshot(root)
|
||||
changed = {path for path in before.keys() | after.keys() if before.get(path) != after.get(path)}
|
||||
artifact_key = relative.as_posix()
|
||||
artifact_state = after.get(artifact_key)
|
||||
if before.get(artifact_key) == artifact_state:
|
||||
raise ValueError(f"phase did not create or change its required artifact: {relative}")
|
||||
if artifact_state is None or not artifact_state.startswith("f:"):
|
||||
raise ValueError(f"phase artifact must be a regular non-symlink file: {relative}")
|
||||
|
||||
try:
|
||||
metadata = artifact.lstat()
|
||||
descriptor = os.open(artifact, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
except OSError as exc:
|
||||
raise ValueError(f"phase artifact must be a readable regular non-symlink file: {relative}") from exc
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not stat.S_ISREG(opened.st_mode)
|
||||
or metadata.st_dev != opened.st_dev
|
||||
or metadata.st_ino != opened.st_ino
|
||||
):
|
||||
raise ValueError(f"phase artifact must be a regular non-symlink file: {relative}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
allowed = {artifact_key}
|
||||
parent = relative.parent
|
||||
while parent.parts:
|
||||
parent_key = parent.as_posix()
|
||||
if parent_key not in before and after.get(parent_key, "").startswith("d:"):
|
||||
allowed.add(parent_key)
|
||||
parent = parent.parent
|
||||
unauthorized = sorted(changed - allowed)
|
||||
if unauthorized:
|
||||
preview = ", ".join(unauthorized[:8])
|
||||
suffix = " …" if len(unauthorized) > 8 else ""
|
||||
raise ValueError(f"phase changed unauthorized workspace path(s): {preview}{suffix}")
|
||||
|
||||
|
||||
def require_skill_fingerprint(worktree: Path, arm: str, expected: str | None, *, phase: str) -> None:
|
||||
"""Fail closed when a bounded phase changes the evaluated prompt roots."""
|
||||
|
||||
try:
|
||||
observed = skill_fingerprint(worktree, arm)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ValueError(f"{phase} changed the evaluated skill fingerprint") from exc
|
||||
if observed != expected:
|
||||
raise ValueError(f"{phase} changed the evaluated skill fingerprint")
|
||||
|
||||
|
||||
def snapshot_plan_docs(worktree: Path) -> dict[Path, str]:
|
||||
"""Hash direct, regular plan artifacts without following links."""
|
||||
|
||||
plans = worktree / "docs" / "plans"
|
||||
if not plans.exists():
|
||||
return {}
|
||||
if plans.is_symlink() or not plans.is_dir():
|
||||
raise ValueError(f"plan directory must be a real directory: {plans}")
|
||||
|
||||
snapshot: dict[Path, str] = {}
|
||||
for path in sorted(plans.iterdir()):
|
||||
if path.suffix.lower() not in {".md", ".html"}:
|
||||
continue
|
||||
metadata = path.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
raise ValueError(f"plan artifact cannot be a symlink: {path}")
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(f"plan artifact must be a regular file: {path}")
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or opened.st_dev != metadata.st_dev or opened.st_ino != metadata.st_ino:
|
||||
raise ValueError(f"plan artifact changed while opening: {path}")
|
||||
with os.fdopen(descriptor, "rb", closefd=False) as handle:
|
||||
snapshot[path] = hashlib.file_digest(handle, "sha256").hexdigest()
|
||||
after = os.fstat(descriptor)
|
||||
if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
||||
raise ValueError(f"plan artifact changed while hashing: {path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
return snapshot
|
||||
|
||||
|
||||
def new_plan_doc(worktree: Path, before: dict[Path, str]) -> Path:
|
||||
"""Return the sole new or modified plan, rejecting ambiguous evidence."""
|
||||
|
||||
after = snapshot_plan_docs(worktree)
|
||||
deleted = sorted(path for path in before if path not in after)
|
||||
if deleted:
|
||||
raise ValueError("planning deleted existing plan artifact(s): " + ", ".join(str(path) for path in deleted))
|
||||
changed = sorted(path for path, digest in after.items() if before.get(path) != digest)
|
||||
if len(changed) != 1:
|
||||
raise ValueError(f"planning must create or modify exactly one plan artifact; observed {len(changed)}")
|
||||
return changed[0]
|
||||
|
||||
|
||||
def make_worktree(repo: Path, ref: str, parent: Path) -> Path:
|
||||
"""Create a self-contained clone per benchmark arm."""
|
||||
|
||||
target = Path(tempfile.mkdtemp(prefix="wfbench-", dir=parent))
|
||||
target.rmdir()
|
||||
try:
|
||||
run_checked(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--no-local",
|
||||
"--no-hardlinks",
|
||||
"--quiet",
|
||||
str(repo),
|
||||
str(target),
|
||||
],
|
||||
timeout=600,
|
||||
)
|
||||
alternates = target / ".git" / "objects" / "info" / "alternates"
|
||||
if alternates.exists():
|
||||
raise RuntimeError(f"clone unexpectedly has an external object alternate: {alternates}")
|
||||
for obj in (target / ".git" / "objects").rglob("*"):
|
||||
if obj.is_file() and obj.stat().st_nlink > 1:
|
||||
raise RuntimeError(f"clone object is hardlinked to host storage: {obj}")
|
||||
for candidate in (ref, f"origin/{ref}"):
|
||||
proc = run_managed(
|
||||
["git", "-C", str(target), "checkout", "--detach", "--quiet", candidate],
|
||||
timeout=60,
|
||||
)
|
||||
if proc.ok:
|
||||
return target
|
||||
raise RuntimeError(f"ref {ref!r} not found in clone of {repo}")
|
||||
except BaseException as primary:
|
||||
if target.exists():
|
||||
try:
|
||||
shutil.rmtree(target)
|
||||
except OSError as cleanup:
|
||||
primary.add_note(f"clone cleanup also failed: {type(cleanup).__name__}: {cleanup}")
|
||||
raise
|
||||
|
||||
|
||||
def remove_clone(clone: Path) -> None:
|
||||
"""Delete one throwaway arm clone (created by make_worktree)."""
|
||||
|
||||
shutil.rmtree(clone)
|
||||
|
||||
|
||||
def parse_shortstat(text: str) -> dict[str, int]:
|
||||
"""Parse `git diff --shortstat` output into churn counters."""
|
||||
|
||||
keys = {
|
||||
"file": "diff_files",
|
||||
"insertion": "diff_insertions",
|
||||
"deletion": "diff_deletions",
|
||||
}
|
||||
out = dict.fromkeys(keys.values(), 0)
|
||||
for count, word in re.findall(r"(\d+) (file|insertion|deletion)", text):
|
||||
out[keys[word]] = int(count)
|
||||
return out
|
||||
|
||||
|
||||
def _sandbox_git(sandbox: SandboxSession, args: list[str], *, timeout: int = 60) -> str:
|
||||
command = ["/usr/bin/git", "-c", "core.fsmonitor=false", *args]
|
||||
result = sandbox.run(command, timeout=timeout, env=build_sandbox_environment())
|
||||
if not result.ok:
|
||||
raise ManagedProcessError(command, result)
|
||||
return result.stdout_tail
|
||||
|
||||
|
||||
def _prepare_untracked_for_diff(sandbox: SandboxSession) -> None:
|
||||
_sandbox_git(sandbox, ["add", "--intent-to-add", "-A"])
|
||||
|
||||
|
||||
def implementation_diff_digest(
|
||||
sandbox: SandboxSession,
|
||||
orig_sha: str,
|
||||
*,
|
||||
prepare_untracked: bool = True,
|
||||
) -> str:
|
||||
"""Digest non-plan final work entirely inside the containment boundary."""
|
||||
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40,64}", orig_sha):
|
||||
raise ValueError(f"unsafe git object id: {orig_sha!r}")
|
||||
if prepare_untracked:
|
||||
_prepare_untracked_for_diff(sandbox)
|
||||
command = (
|
||||
"/usr/bin/git -c core.fsmonitor=false diff --no-ext-diff --no-textconv --binary "
|
||||
f"{orig_sha} -- . ':(exclude)docs/plans' ':(exclude).claude/skills' "
|
||||
"| /usr/bin/sha256sum"
|
||||
)
|
||||
result = sandbox.run(
|
||||
["/bin/sh", "-c", command],
|
||||
timeout=60,
|
||||
env=build_sandbox_environment(),
|
||||
)
|
||||
if not result.ok:
|
||||
raise ManagedProcessError(command, result)
|
||||
digest = result.stdout_tail.strip().split()[0] if result.stdout_tail.strip() else ""
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise RuntimeError("sandboxed git diff did not produce a SHA-256 digest")
|
||||
return digest
|
||||
|
||||
|
||||
def diff_churn(
|
||||
sandbox: SandboxSession,
|
||||
orig_sha: str,
|
||||
*,
|
||||
prepare_untracked: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Return code churn versus the arm's starting SHA."""
|
||||
|
||||
if prepare_untracked:
|
||||
_prepare_untracked_for_diff(sandbox)
|
||||
output = _sandbox_git(
|
||||
sandbox,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--shortstat",
|
||||
orig_sha,
|
||||
"--",
|
||||
".",
|
||||
":(exclude)docs/plans",
|
||||
":(exclude).claude/skills",
|
||||
],
|
||||
)
|
||||
return parse_shortstat(output)
|
||||
|
||||
|
||||
def _bounded_regular_bytes(path: Path, *, limit: int) -> bytes:
|
||||
"""Read at most ``limit`` bytes without following a generated link."""
|
||||
|
||||
metadata = path.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError(f"generated artifact is not a regular non-symlink file: {path}")
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, os.O_RDONLY | nofollow)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode):
|
||||
raise RuntimeError(f"generated artifact changed type while opening: {path}")
|
||||
chunks: list[bytes] = []
|
||||
remaining = limit
|
||||
while remaining > 0:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def capture_patch(sandbox: SandboxSession, worktree: Path, orig_sha: str) -> bytes:
|
||||
"""Stream a final patch inside the sandbox while retaining a bounded prefix."""
|
||||
|
||||
artifact_dir = Path(tempfile.mkdtemp(prefix=".wfbench-artifact-", dir=worktree))
|
||||
artifact_dir.chmod(0o700)
|
||||
patch = artifact_dir / "final.patch"
|
||||
sandbox_path = f"{SANDBOX_WORKSPACE}/{artifact_dir.relative_to(worktree).as_posix()}/final.patch"
|
||||
sink = """\
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
limit = int(sys.argv[1])
|
||||
output = sys.argv[2]
|
||||
command = sys.argv[3:]
|
||||
with open(output, "xb") as handle:
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
assert process.stdout is not None
|
||||
remaining = limit
|
||||
for chunk in iter(lambda: process.stdout.read(65536), b""):
|
||||
if remaining:
|
||||
retained = chunk[:remaining]
|
||||
handle.write(retained)
|
||||
remaining -= len(retained)
|
||||
process.stdout.close()
|
||||
returncode = process.wait()
|
||||
if returncode:
|
||||
raise SystemExit(returncode)
|
||||
"""
|
||||
command = [
|
||||
"/usr/bin/python3",
|
||||
"-I",
|
||||
"-c",
|
||||
sink,
|
||||
str(MAX_PATCH_BYTES),
|
||||
sandbox_path,
|
||||
"/usr/bin/git",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--binary",
|
||||
orig_sha,
|
||||
"--",
|
||||
".",
|
||||
":(exclude).wfbench-artifact-*",
|
||||
]
|
||||
result = sandbox.run(command, timeout=60, env=build_sandbox_environment())
|
||||
if not result.ok:
|
||||
raise ManagedProcessError(command, result)
|
||||
return _bounded_regular_bytes(patch, limit=MAX_PATCH_BYTES)
|
||||
|
||||
|
||||
def enforce_work_evidence(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
arm: str,
|
||||
before_digest: str,
|
||||
after_digest: str,
|
||||
) -> None:
|
||||
if arm not in IMPLEMENTATION_ARMS or not record.get("resolved"):
|
||||
return
|
||||
if before_digest != after_digest:
|
||||
return
|
||||
record["resolved"] = False
|
||||
record["error_kind"] = "no-work-produced"
|
||||
record["error_detail"] = "verifier passed but the implementation arm produced no non-plan repository change"
|
||||
|
||||
|
||||
def run_verify(
|
||||
command: str,
|
||||
cwd: Path,
|
||||
timeout: int,
|
||||
*,
|
||||
command_prefix: list[str] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
require_pid_namespace: bool = False,
|
||||
) -> VerificationResult:
|
||||
"""Run the task's verify command; keep its output tail for diagnosis."""
|
||||
|
||||
if command_prefix:
|
||||
# HOME is writable during model execution. A non-login shell prevents
|
||||
# candidate-created profile files from running inside trusted evidence
|
||||
# collection or either verifier.
|
||||
managed_command: list[str] | str = [*command_prefix, "/bin/sh", "-c", command]
|
||||
shell = False
|
||||
managed_cwd: Path | None = None
|
||||
else:
|
||||
managed_command = command
|
||||
shell = True
|
||||
managed_cwd = cwd
|
||||
proc = run_managed(
|
||||
managed_command,
|
||||
shell=shell,
|
||||
cwd=managed_cwd,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
require_pid_namespace=require_pid_namespace,
|
||||
)
|
||||
output = proc.stdout_tail + "\n" + proc.stderr_tail
|
||||
if proc.detail:
|
||||
output += f"\n[{proc.state}] {proc.detail}"
|
||||
return VerificationResult(
|
||||
command=managed_command,
|
||||
process=proc,
|
||||
output=output[-4000:],
|
||||
)
|
||||
537
eval/workflow_bench/runner_sessions.py
Normal file
537
eval/workflow_bench/runner_sessions.py
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
"""Headless session execution and transcript evidence for workflow benchmarks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .process_control import run_managed
|
||||
from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_GITNEXUS_REGISTRY,
|
||||
SANDBOX_HOME,
|
||||
SANDBOX_TMP,
|
||||
SANDBOX_WORKSPACE,
|
||||
SandboxError,
|
||||
redact_text,
|
||||
)
|
||||
|
||||
USAGE_FIELDS = (
|
||||
"input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"output_tokens",
|
||||
)
|
||||
MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024
|
||||
# Provenance tag stamped on every parent-captured transcript artifact. The
|
||||
# evidence preflight (evolve._transcript_artifact_metadata) validates against
|
||||
# this exact value, so producer and consumer stay pinned to one schema.
|
||||
PARENT_EVENT_STREAM_SOURCE = "parent-captured-stream-json"
|
||||
|
||||
|
||||
def measured_cost(raw: Any) -> float | None:
|
||||
"""Session cost as a finite non-negative float, or None when unmeasured.
|
||||
|
||||
``cost_usd`` is a promotion metric (lower wins), so an absent/garbage
|
||||
``total_cost_usd`` must NOT collapse to a real measured $0 that a candidate
|
||||
could win on — it stays None and the gate refuses to rank on it. A genuine
|
||||
measured 0.0 is preserved distinctly.
|
||||
"""
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
||||
return None
|
||||
if not math.isfinite(raw) or raw < 0:
|
||||
return None
|
||||
return float(raw)
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT = f"{SANDBOX_GITNEXUS}/dist/cli/index.js"
|
||||
SENSITIVE_EVENT_KEYS = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"anthropic-api-key",
|
||||
"anthropic_api_key",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"client_secret",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"password",
|
||||
}
|
||||
)
|
||||
|
||||
GITNEXUS_READ_ONLY_TOOLS = (
|
||||
"mcp__gitnexus__list_repos",
|
||||
"mcp__gitnexus__query",
|
||||
"mcp__gitnexus__context",
|
||||
"mcp__gitnexus__check",
|
||||
"mcp__gitnexus__impact",
|
||||
"mcp__gitnexus__explain",
|
||||
"mcp__gitnexus__pdg_query",
|
||||
"mcp__gitnexus__route_map",
|
||||
"mcp__gitnexus__tool_map",
|
||||
"mcp__gitnexus__shape_check",
|
||||
"mcp__gitnexus__api_impact",
|
||||
"mcp__gitnexus__trace",
|
||||
"mcp__gitnexus__detect_changes",
|
||||
)
|
||||
GITNEXUS_MUTATING_TOOLS = ("mcp__gitnexus__rename",)
|
||||
BUILTIN_AGENT_TOOLS = ("Read", "Grep", "Glob", "Edit", "Write", "Bash", "Skill")
|
||||
|
||||
|
||||
def sandbox_mcp_config() -> str:
|
||||
"""Credential-free MCP configuration using only the pinned harness runtime."""
|
||||
|
||||
entrypoint = PurePosixPath(SANDBOX_GITNEXUS_ENTRYPOINT)
|
||||
workspace = PurePosixPath(SANDBOX_WORKSPACE)
|
||||
if not entrypoint.is_absolute() or entrypoint == workspace or workspace in entrypoint.parents:
|
||||
raise SandboxError(f"GitNexus MCP executable must stay outside {SANDBOX_WORKSPACE}")
|
||||
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/bin/env",
|
||||
"args": [
|
||||
"-i",
|
||||
f"HOME={SANDBOX_HOME}",
|
||||
f"TMPDIR={SANDBOX_TMP}",
|
||||
f"GITNEXUS_HOME={SANDBOX_GITNEXUS_REGISTRY}",
|
||||
f"GITNEXUS_MCP_ALLOWED_REPOS={SANDBOX_WORKSPACE}",
|
||||
f"GITNEXUS_MCP_DEFAULT_REPO={SANDBOX_WORKSPACE}",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||
"LANG=C.UTF-8",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"/usr/local/bin/node",
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT,
|
||||
"mcp",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.dumps(config, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def allowed_agent_tools(*, implementation: bool, include_mcp: bool = True) -> list[str]:
|
||||
tools = [*BUILTIN_AGENT_TOOLS]
|
||||
if include_mcp:
|
||||
tools.extend(GITNEXUS_READ_ONLY_TOOLS)
|
||||
if include_mcp and implementation:
|
||||
tools.extend(GITNEXUS_MUTATING_TOOLS)
|
||||
return tools
|
||||
|
||||
|
||||
def _persist_parent_event_stream(
|
||||
raw: bytes,
|
||||
*,
|
||||
output_dir: Path,
|
||||
relative_path: str,
|
||||
secrets: tuple[str, ...],
|
||||
) -> dict[str, Any]:
|
||||
"""Persist only the complete event stream captured by the trusted parent."""
|
||||
|
||||
# Parsing before persistence proves the artifact is complete structured
|
||||
# evidence, rather than arbitrary output injected through a tool result.
|
||||
events = _parse_parent_event_stream(raw)
|
||||
relative = PurePosixPath(relative_path)
|
||||
if relative.is_absolute() or len(relative.parts) != 2 or relative.parts[0] != "transcripts":
|
||||
raise ValueError(f"event-stream artifact path must be transcripts/<file>: {relative_path!r}")
|
||||
if any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise ValueError(f"unsafe event-stream artifact path: {relative_path!r}")
|
||||
|
||||
root = output_dir.expanduser().absolute()
|
||||
root_mode = root.lstat().st_mode
|
||||
if stat.S_ISLNK(root_mode) or not stat.S_ISDIR(root_mode) or root.resolve(strict=True) != root:
|
||||
raise ValueError(f"event-stream output root must be a real non-symlink directory: {root}")
|
||||
transcript_dir = root / relative.parts[0]
|
||||
try:
|
||||
transcript_dir.mkdir(mode=0o700)
|
||||
except FileExistsError:
|
||||
mode = transcript_dir.lstat().st_mode
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise ValueError(f"event-stream artifact parent must be a real directory: {transcript_dir}")
|
||||
transcript_dir.chmod(0o700)
|
||||
|
||||
def redact_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return redact_text(value, secrets)
|
||||
if isinstance(value, list):
|
||||
return [redact_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
redacted: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
source_key = str(key)
|
||||
redacted_key = redact_text(source_key, secrets)
|
||||
if redacted_key in redacted:
|
||||
raise ValueError("event-stream keys collide after structural redaction")
|
||||
redacted[redacted_key] = (
|
||||
"[REDACTED]" if source_key.strip().casefold() in SENSITIVE_EVENT_KEYS else redact_value(item)
|
||||
)
|
||||
return redacted
|
||||
return value
|
||||
|
||||
payload = (
|
||||
"".join(
|
||||
json.dumps(
|
||||
redact_value(event),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
for event in events
|
||||
)
|
||||
).encode("utf-8")
|
||||
if len(payload) > MAX_TRANSCRIPT_BYTES:
|
||||
raise ValueError("redacted parent event stream exceeds the bounded artifact limit")
|
||||
_parse_parent_event_stream(payload)
|
||||
destination = transcript_dir / relative.name
|
||||
descriptor = os.open(
|
||||
destination,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError("short write while persisting parent event stream")
|
||||
view = view[written:]
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
return {
|
||||
"path": relative.as_posix(),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"bytes": len(payload),
|
||||
"source": PARENT_EVENT_STREAM_SOURCE,
|
||||
}
|
||||
|
||||
|
||||
def _normalized_skill_identifier(value: Any) -> str | None:
|
||||
"""Return the exact identifier token accepted by the Skill tool."""
|
||||
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
token = stripped.split(maxsplit=1)[0]
|
||||
if token.startswith("/"):
|
||||
token = token[1:]
|
||||
return token or None
|
||||
|
||||
|
||||
def _event_content(event: dict[str, Any]) -> list[Any]:
|
||||
message = event.get("message")
|
||||
content = (message or {}).get("content") if isinstance(message, dict) else None
|
||||
if content is None:
|
||||
content = event.get("content")
|
||||
return content if isinstance(content, list) else []
|
||||
|
||||
|
||||
def _reject_json_constant(value: str) -> None:
|
||||
raise ValueError(f"non-finite JSON constant: {value}")
|
||||
|
||||
|
||||
def _parse_parent_event_stream(raw: bytes) -> list[dict[str, Any]]:
|
||||
"""Strictly parse every CLI-emitted event through EOF."""
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("parent-captured Claude event stream is not UTF-8") from exc
|
||||
events: list[dict[str, Any]] = []
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line, parse_constant=_reject_json_constant)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise ValueError(f"malformed parent-captured event JSON at line {line_number}") from exc
|
||||
if not isinstance(event, dict):
|
||||
raise ValueError(f"parent-captured event {line_number} is not an object")
|
||||
events.append(event)
|
||||
if not events:
|
||||
raise ValueError("parent-captured Claude event stream contains no events")
|
||||
return events
|
||||
|
||||
|
||||
def skill_was_invoked_events(events: Sequence[dict[str, Any]], skill_name: str) -> bool:
|
||||
"""Prove an exact Skill request had a later successful tool result."""
|
||||
|
||||
expected_identifier = _normalized_skill_identifier(skill_name)
|
||||
if expected_identifier is None:
|
||||
raise ValueError("expected skill name must contain an identifier")
|
||||
tool_uses: dict[str, tuple[int, bool]] = {}
|
||||
tool_results: dict[str, tuple[int, bool]] = {}
|
||||
for event_index, event in enumerate(events):
|
||||
for block in _event_content(event):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type == "tool_use":
|
||||
tool_id = block.get("id")
|
||||
if not isinstance(tool_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", tool_id):
|
||||
raise ValueError("tool request has no bounded tool-use id")
|
||||
if tool_id in tool_uses:
|
||||
raise ValueError(f"duplicate tool-use id in parent event stream: {tool_id}")
|
||||
matched = False
|
||||
if str(block.get("name", "")).casefold() == "skill":
|
||||
skill_input = block.get("input")
|
||||
if isinstance(skill_input, dict):
|
||||
matched = any(
|
||||
_normalized_skill_identifier(skill_input.get(field)) == expected_identifier
|
||||
for field in ("skill", "command", "name")
|
||||
)
|
||||
tool_uses[tool_id] = (event_index, matched)
|
||||
elif block_type == "tool_result":
|
||||
tool_id = block.get("tool_use_id")
|
||||
if not isinstance(tool_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,256}", tool_id):
|
||||
raise ValueError("tool result has no bounded tool-use id")
|
||||
if tool_id in tool_results:
|
||||
raise ValueError(f"duplicate tool result in parent event stream: {tool_id}")
|
||||
is_error = block.get("is_error")
|
||||
if is_error not in (None, False, True):
|
||||
raise ValueError(f"tool result has malformed is_error for {tool_id}")
|
||||
tool_results[tool_id] = (event_index, is_error is True)
|
||||
|
||||
matching = [(tool_id, request_index) for tool_id, (request_index, matched) in tool_uses.items() if matched]
|
||||
if not matching:
|
||||
return False
|
||||
successful = False
|
||||
for tool_id, request_index in matching:
|
||||
result = tool_results.get(tool_id)
|
||||
if result is None:
|
||||
raise ValueError(f"matching Skill request has no tool result: {tool_id}")
|
||||
result_index, is_error = result
|
||||
if result_index <= request_index:
|
||||
raise ValueError(f"matching Skill result does not follow its request: {tool_id}")
|
||||
successful = successful or not is_error
|
||||
return successful
|
||||
|
||||
|
||||
def run_claude(
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
*,
|
||||
claude_bin: str,
|
||||
timeout: int,
|
||||
disallowed_tools: list[str] | None = None,
|
||||
model: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
permission_mode: str | None = None,
|
||||
expected_skill: str | None = None,
|
||||
command_prefix: list[str] | None = None,
|
||||
require_pid_namespace: bool = False,
|
||||
bare: bool = False,
|
||||
settings_json: str | None = None,
|
||||
strict_mcp_config: bool = False,
|
||||
allowed_tools: list[str] | None = None,
|
||||
disable_slash_commands: bool = False,
|
||||
mcp_config_json: str | None = None,
|
||||
transcript_projects: Path | None = None,
|
||||
transcript_cwd: Path | None = None,
|
||||
transcript_wait_seconds: float = 0,
|
||||
transcript_output_dir: Path | None = None,
|
||||
transcript_output_prefix: str | None = None,
|
||||
transcript_secrets: tuple[str, ...] = (),
|
||||
plugin_dirs: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Run one headless session and return its usage record."""
|
||||
|
||||
# Kept as compatibility parameters for callers, but deliberately ignored:
|
||||
# every file under the sandbox HOME is writable by agent tools and cannot
|
||||
# serve as trusted evidence.
|
||||
del transcript_projects, transcript_cwd, transcript_wait_seconds
|
||||
|
||||
cmd = [
|
||||
claude_bin,
|
||||
"-p",
|
||||
"--input-format",
|
||||
"text",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
]
|
||||
if bare:
|
||||
cmd.append("--bare")
|
||||
for plugin_dir in plugin_dirs:
|
||||
cmd += ["--plugin-dir", plugin_dir]
|
||||
if settings_json is not None:
|
||||
cmd += ["--settings", settings_json]
|
||||
if strict_mcp_config:
|
||||
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
|
||||
if allowed_tools:
|
||||
cmd += ["--allowedTools", *allowed_tools]
|
||||
if disable_slash_commands:
|
||||
cmd.append("--disable-slash-commands")
|
||||
if permission_mode:
|
||||
cmd += ["--permission-mode", permission_mode]
|
||||
if model:
|
||||
cmd += ["--model", model]
|
||||
for tool in disallowed_tools or []:
|
||||
cmd += ["--disallowedTools", tool]
|
||||
managed_cmd = [*(command_prefix or []), *cmd]
|
||||
started = time.monotonic()
|
||||
proc = run_managed(
|
||||
managed_cmd,
|
||||
cwd=None if command_prefix else cwd,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
require_pid_namespace=require_pid_namespace,
|
||||
stdin_data=prompt.encode(),
|
||||
capture_stdout_bytes=MAX_TRANSCRIPT_BYTES,
|
||||
)
|
||||
wall_s = time.monotonic() - started
|
||||
event_stream_error: str | None = None
|
||||
events: list[dict[str, Any]] = []
|
||||
try:
|
||||
if proc.stdout_capture is None:
|
||||
raise ValueError("parent process did not capture Claude stdout")
|
||||
if proc.stdout_capture_overflow:
|
||||
raise ValueError(f"parent-captured event stream exceeds {MAX_TRANSCRIPT_BYTES} bytes")
|
||||
events = _parse_parent_event_stream(proc.stdout_capture)
|
||||
result_events = [event for event in events if event.get("type") == "result"]
|
||||
if len(result_events) != 1:
|
||||
raise ValueError(f"expected exactly one final result event, observed {len(result_events)}")
|
||||
data = result_events[0]
|
||||
if events[-1] is not data:
|
||||
raise ValueError("final result event is not the last event in the captured stream")
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
event_stream_error = str(exc)
|
||||
data = {}
|
||||
usage = data.get("usage") or {}
|
||||
subtype = data.get("subtype")
|
||||
well_formed = all(field in usage for field in USAGE_FIELDS)
|
||||
session_error = (
|
||||
not proc.ok
|
||||
or event_stream_error is not None
|
||||
or data.get("is_error", False)
|
||||
or str(subtype).startswith("error")
|
||||
or not well_formed
|
||||
)
|
||||
record = {
|
||||
"ok": not session_error,
|
||||
"error_kind": "session-error" if session_error else None,
|
||||
"error_detail": (
|
||||
{
|
||||
"subtype": subtype,
|
||||
"returncode": proc.returncode,
|
||||
"process_state": proc.state,
|
||||
"stderr_tail": proc.stderr_tail[-2000:],
|
||||
"process_detail": proc.detail,
|
||||
"event_stream_error": event_stream_error,
|
||||
}
|
||||
if session_error
|
||||
else None
|
||||
),
|
||||
"session_id": data.get("session_id"),
|
||||
"num_turns": data.get("num_turns", 0),
|
||||
"cost_usd": measured_cost(data.get("total_cost_usd")),
|
||||
"duration_s": round(data.get("duration_ms", wall_s * 1000) / 1000, 1),
|
||||
"transcript_missing": False,
|
||||
**{field: usage.get(field, 0) for field in USAGE_FIELDS},
|
||||
}
|
||||
needs_evidence = expected_skill is not None or transcript_output_dir is not None
|
||||
if needs_evidence:
|
||||
# Only stdout captured by the trusted parent is admissible evidence.
|
||||
# The session's HOME is writable by agent tools and is deliberately
|
||||
# ignored, including when the process reports an error or times out.
|
||||
evidence_diagnostics: list[str] = []
|
||||
if event_stream_error is not None:
|
||||
evidence_diagnostics.append(f"unverifiable parent event stream: {event_stream_error}")
|
||||
elif proc.stdout_capture is None:
|
||||
evidence_diagnostics.append("unverifiable parent event stream: capture is missing")
|
||||
elif proc.stdout_capture_overflow:
|
||||
evidence_diagnostics.append(
|
||||
f"unverifiable parent event stream: capture exceeds {MAX_TRANSCRIPT_BYTES} bytes"
|
||||
)
|
||||
else:
|
||||
if expected_skill is not None:
|
||||
try:
|
||||
record["skill_invoked"] = skill_was_invoked_events(events, expected_skill)
|
||||
except ValueError as exc:
|
||||
record["skill_invoked"] = None
|
||||
evidence_diagnostics.append(f"unverifiable skill evidence: {exc}")
|
||||
|
||||
if transcript_output_dir is not None:
|
||||
try:
|
||||
prefix = transcript_output_prefix or "session"
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]{1,200}", prefix):
|
||||
raise ValueError(f"unsafe transcript artifact prefix: {prefix!r}")
|
||||
session_id = data.get("session_id")
|
||||
if not isinstance(session_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id):
|
||||
raise ValueError(f"unsafe transcript session id: {session_id!r}")
|
||||
record["session_id"] = session_id
|
||||
record["transcript_artifact"] = _persist_parent_event_stream(
|
||||
proc.stdout_capture,
|
||||
output_dir=transcript_output_dir,
|
||||
relative_path=f"transcripts/{prefix}-{session_id}.jsonl",
|
||||
secrets=transcript_secrets,
|
||||
)
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
evidence_diagnostics.append(f"unverifiable event-stream persistence: {exc}")
|
||||
|
||||
if expected_skill is not None and "skill_invoked" not in record:
|
||||
record["skill_invoked"] = None
|
||||
if expected_skill is not None and record.get("skill_invoked") is False:
|
||||
detail = f"parent event stream shows no successful {expected_skill} invocation"
|
||||
if session_error:
|
||||
evidence_diagnostics.append(detail)
|
||||
elif not evidence_diagnostics:
|
||||
record["ok"] = False
|
||||
record["error_kind"] = "skill-not-invoked"
|
||||
record["error_detail"] = detail
|
||||
|
||||
if evidence_diagnostics:
|
||||
record["transcript_missing"] = True
|
||||
record["evidence_diagnostics"] = evidence_diagnostics
|
||||
if not session_error:
|
||||
record["ok"] = False
|
||||
record["error_kind"] = "evidence-unverified"
|
||||
record["error_detail"] = "; ".join(evidence_diagnostics)
|
||||
return record
|
||||
|
||||
|
||||
def sum_sessions(sessions: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
total: dict[str, Any] = {field: sum(session[field] for session in sessions) for field in USAGE_FIELDS}
|
||||
session_costs = [session["cost_usd"] for session in sessions]
|
||||
total["cost_usd"] = None if any(cost is None for cost in session_costs) else round(sum(session_costs), 4)
|
||||
total["duration_s"] = round(sum(session["duration_s"] for session in sessions), 1)
|
||||
total["num_turns"] = sum(session["num_turns"] for session in sessions)
|
||||
total["ok"] = all(session["ok"] for session in sessions)
|
||||
total["session_ids"] = [session["session_id"] for session in sessions]
|
||||
kinds = [session.get("error_kind") for session in sessions if session.get("error_kind")]
|
||||
total["error_kind"] = kinds[0] if kinds else None
|
||||
details = [session.get("error_detail") for session in sessions if session.get("error_detail")]
|
||||
total["error_detail"] = details[0] if details else None
|
||||
invocations = [session["skill_invoked"] for session in sessions if "skill_invoked" in session]
|
||||
if False in invocations:
|
||||
total["skill_invoked"] = False
|
||||
elif None in invocations or not invocations:
|
||||
total["skill_invoked"] = None
|
||||
else:
|
||||
total["skill_invoked"] = True
|
||||
total["transcript_missing"] = any(session.get("transcript_missing", False) for session in sessions)
|
||||
total["transcript_artifacts"] = [
|
||||
session["transcript_artifact"] for session in sessions if "transcript_artifact" in session
|
||||
]
|
||||
total["evidence_diagnostics"] = [
|
||||
diagnostic for session in sessions for diagnostic in session.get("evidence_diagnostics", [])
|
||||
]
|
||||
return total
|
||||
174
eval/workflow_bench/runner_tasks.py
Normal file
174
eval/workflow_bench/runner_tasks.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Model and immutable task-binding validation for workflow benchmarks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .oracle_assets import TaskOracleSnapshot, capture_task_oracles, validate_oracle_declaration
|
||||
from .process_control import run_checked, run_managed
|
||||
from .task_assets import TaskAssetCache, capture_task_dependency_binding
|
||||
|
||||
|
||||
def normalized_model_identifier(value: str | None, *, flag: str = "--model") -> str:
|
||||
model = (value or "").strip()
|
||||
if not model:
|
||||
raise ValueError(f"{flag} must name a nonblank, versioned model")
|
||||
if re.search(r"(?:^|[-/@:])(?:auto|latest)$", model.casefold()):
|
||||
raise ValueError(f"{flag} must not use a mutable auto/latest model alias: {model!r}")
|
||||
return model
|
||||
|
||||
|
||||
def select_tasks(tasks: list[Any], *, include_expensive: bool) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Validate task metadata and filter opt-in expensive scenarios."""
|
||||
|
||||
selected: list[dict[str, Any]] = []
|
||||
skipped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
required_strings = ("id", "class", "repo", "prompt", "verify")
|
||||
optional_strings = ("ref", "setup")
|
||||
for index, raw_task in enumerate(tasks):
|
||||
if not isinstance(raw_task, Mapping):
|
||||
raise ValueError(f"task {index} must be a mapping")
|
||||
task = dict(raw_task)
|
||||
for field in required_strings:
|
||||
if not isinstance(task.get(field), str) or not task[field].strip():
|
||||
raise ValueError(f"task {index} requires a nonblank string {field}")
|
||||
for field in optional_strings:
|
||||
if field in task and not isinstance(task[field], str):
|
||||
raise ValueError(f"task {task['id']} field {field} must be a string")
|
||||
task_id = task["id"]
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", task_id):
|
||||
raise ValueError(f"task id must be a simple artifact-safe slug: {task_id!r}")
|
||||
if task_id in seen:
|
||||
raise ValueError(f"duplicate task id: {task_id}")
|
||||
seen.add(task_id)
|
||||
expensive = task.get("expensive", False)
|
||||
if not isinstance(expensive, bool):
|
||||
raise ValueError(f"task {task_id} expensive metadata must be boolean")
|
||||
copies = task.get("sandbox_copy", [])
|
||||
if not isinstance(copies, list) or not all(isinstance(path, str) and path for path in copies):
|
||||
raise ValueError(f"task {task_id} sandbox_copy must be a string list")
|
||||
dependencies = task.get("sandbox_dependencies", [])
|
||||
if not isinstance(dependencies, list):
|
||||
raise ValueError(f"task {task_id} sandbox_dependencies must be a list")
|
||||
for dependency in dependencies:
|
||||
if (
|
||||
not isinstance(dependency, Mapping)
|
||||
or set(dependency) != {"source", "target"}
|
||||
or not all(isinstance(dependency[field], str) and dependency[field] for field in ("source", "target"))
|
||||
):
|
||||
raise ValueError(
|
||||
f"task {task_id} sandbox_dependencies entries require nonblank source and target strings"
|
||||
)
|
||||
validate_oracle_declaration(task)
|
||||
if expensive and not include_expensive:
|
||||
skipped.append(task_id)
|
||||
else:
|
||||
selected.append(task)
|
||||
if not selected:
|
||||
raise ValueError("no tasks selected after expensive-task filtering")
|
||||
return selected, skipped
|
||||
|
||||
|
||||
def _task_definition_binding(
|
||||
task: dict[str, Any],
|
||||
repo_identity: Path,
|
||||
oracle_snapshot: TaskOracleSnapshot,
|
||||
dependency_binding: Mapping[str, str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": task["id"],
|
||||
"class": task.get("class", ""),
|
||||
"repo_identity": str(repo_identity),
|
||||
"ref": task.get("ref", "HEAD"),
|
||||
"prompt_digest": hashlib.sha256(task["prompt"].encode()).hexdigest(),
|
||||
"setup_digest": hashlib.sha256(str(task.get("setup", "")).encode()).hexdigest(),
|
||||
"verify_digest": hashlib.sha256(str(task["verify"]).encode()).hexdigest(),
|
||||
"expensive": bool(task.get("expensive", False)),
|
||||
"sandbox_copy": list(task.get("sandbox_copy", [])),
|
||||
"sandbox_dependencies": [dict(item) for item in task.get("sandbox_dependencies", [])],
|
||||
**dependency_binding,
|
||||
**oracle_snapshot.binding,
|
||||
}
|
||||
|
||||
|
||||
def resolve_task_bindings(
|
||||
tasks: list[dict[str, Any]],
|
||||
expected: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
oracle_snapshots: list[TaskOracleSnapshot] | None = None,
|
||||
task_asset_cache: TaskAssetCache | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Resolve each repo/ref once and optionally honor an upstream immutable pin."""
|
||||
|
||||
if expected is not None and len(expected) != len(tasks):
|
||||
raise ValueError("task binding count does not match selected tasks")
|
||||
snapshots = oracle_snapshots if oracle_snapshots is not None else capture_task_oracles(tasks)
|
||||
if len(snapshots) != len(tasks):
|
||||
raise ValueError("oracle snapshot count does not match selected tasks")
|
||||
bindings: list[dict[str, Any]] = []
|
||||
for index, (task, oracle_snapshot) in enumerate(zip(tasks, snapshots, strict=True)):
|
||||
requested_repo = Path(task["repo"]).expanduser().resolve()
|
||||
repo_output = run_checked(
|
||||
["git", "-C", str(requested_repo), "rev-parse", "--show-toplevel"],
|
||||
timeout=60,
|
||||
).stdout_tail.strip()
|
||||
repo_identity = Path(repo_output).resolve()
|
||||
if expected is None:
|
||||
resolved_sha = run_checked(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo_identity),
|
||||
"rev-parse",
|
||||
f"{task.get('ref', 'HEAD')}^{{commit}}",
|
||||
],
|
||||
timeout=60,
|
||||
).stdout_tail.strip()
|
||||
else:
|
||||
supplied = expected[index]
|
||||
if not isinstance(supplied, dict):
|
||||
raise ValueError(f"task binding {index} must be an object")
|
||||
resolved_sha = str(supplied.get("resolved_sha", ""))
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40,64}", resolved_sha):
|
||||
raise ValueError(f"task {task['id']} did not resolve to an immutable commit")
|
||||
exists = run_managed(
|
||||
["git", "-C", str(repo_identity), "cat-file", "-e", f"{resolved_sha}^{{commit}}"],
|
||||
timeout=60,
|
||||
)
|
||||
if not exists.ok:
|
||||
raise ValueError(f"pinned task commit is unavailable for {task['id']}: {resolved_sha}")
|
||||
if task_asset_cache is None:
|
||||
dependency_binding = capture_task_dependency_binding(
|
||||
task,
|
||||
repo=repo_identity,
|
||||
resolved_sha=resolved_sha.lower(),
|
||||
)
|
||||
else:
|
||||
dependency_binding = task_asset_cache.prepare(
|
||||
task,
|
||||
repo=repo_identity,
|
||||
resolved_sha=resolved_sha.lower(),
|
||||
).dependency_binding
|
||||
definition = _task_definition_binding(
|
||||
task,
|
||||
repo_identity,
|
||||
oracle_snapshot,
|
||||
dependency_binding,
|
||||
)
|
||||
if expected is not None:
|
||||
supplied_definition = {key: supplied.get(key) for key in definition}
|
||||
if supplied_definition != definition:
|
||||
raise ValueError(f"task binding definition drifted for {task['id']}")
|
||||
bindings.append({**definition, "resolved_sha": resolved_sha.lower()})
|
||||
return bindings
|
||||
|
||||
|
||||
def selected_task_bindings(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Compatibility wrapper for callers that need newly resolved task pins."""
|
||||
|
||||
return resolve_task_bindings(tasks)
|
||||
496
eval/workflow_bench/runtime_mounts.py
Normal file
496
eval/workflow_bench/runtime_mounts.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"""Trusted runtime views for isolated workflow-benchmark sessions.
|
||||
|
||||
The benchmark never mounts an operator checkout wholesale. GitNexus is
|
||||
exposed as a minimal, harness-owned runtime, while the optional Compound
|
||||
Engineering comparator is copied into a bounded immutable snapshot containing
|
||||
only Claude plugin inputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_GITNEXUS_SHARED,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
)
|
||||
|
||||
PINNED_GITNEXUS_VERSION = "1.6.9"
|
||||
HARNESS_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"})
|
||||
SANDBOX_CE_PLUGIN = "/opt/compound-engineering-plugin"
|
||||
CE_PLUGIN_MANIFEST_SCHEMA_VERSION = 1
|
||||
MAX_CE_PLUGIN_FILES = 2_048
|
||||
MAX_CE_PLUGIN_FILE_BYTES = 2 * 1024 * 1024
|
||||
MAX_CE_PLUGIN_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
MAX_CE_PLUGIN_PATH_BYTES = 1_024
|
||||
|
||||
_EXACT_VERSION = re.compile(
|
||||
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
|
||||
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
||||
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
||||
)
|
||||
_ALLOWED_PLUGIN_DIRS = ("skills", "scripts", "assets")
|
||||
_ALLOWED_PLUGIN_MANIFESTS = (
|
||||
PurePosixPath(".claude-plugin/plugin.json"),
|
||||
PurePosixPath(".claude-plugin/marketplace.json"),
|
||||
)
|
||||
_FORBIDDEN_PATH_PARTS = frozenset(
|
||||
{
|
||||
".git",
|
||||
".github",
|
||||
".ssh",
|
||||
".aws",
|
||||
"test",
|
||||
"tests",
|
||||
"doc",
|
||||
"docs",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
}
|
||||
)
|
||||
_SECRET_EXACT_NAMES = frozenset(
|
||||
{
|
||||
".env",
|
||||
".npmrc",
|
||||
".netrc",
|
||||
".pypirc",
|
||||
"credentials",
|
||||
"credentials.json",
|
||||
"secrets",
|
||||
"secrets.json",
|
||||
}
|
||||
)
|
||||
_SECRET_NAME_MARKERS = ("secret", "credential", "private-key", "private_key", "token")
|
||||
_SECRET_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".kdbx")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CePluginConfig:
|
||||
"""Explicit operator input for one pinned CE plugin release."""
|
||||
|
||||
source: Path
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CePluginSnapshot:
|
||||
"""A bounded read-only plugin tree and its content identity."""
|
||||
|
||||
root: Path
|
||||
version: str
|
||||
manifest_digest: str
|
||||
file_count: int
|
||||
total_bytes: int
|
||||
|
||||
@property
|
||||
def mount(self) -> ReadOnlyMount:
|
||||
return ReadOnlyMount(source=self.root, target=SANDBOX_CE_PLUGIN)
|
||||
|
||||
@property
|
||||
def provenance(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "compound-engineering",
|
||||
"version": self.version,
|
||||
"manifest_schema_version": CE_PLUGIN_MANIFEST_SCHEMA_VERSION,
|
||||
"manifest_digest": self.manifest_digest,
|
||||
"file_count": self.file_count,
|
||||
"total_bytes": self.total_bytes,
|
||||
}
|
||||
|
||||
|
||||
def ce_plugin_mounts_for_arm(
|
||||
arm: str,
|
||||
snapshot: CePluginSnapshot | None,
|
||||
) -> tuple[ReadOnlyMount, ...]:
|
||||
"""Expose the staged comparator only to CE arms."""
|
||||
|
||||
if arm not in CE_ARMS:
|
||||
return ()
|
||||
if snapshot is None:
|
||||
raise SandboxError("ce_* arm has no staged Compound Engineering plugin")
|
||||
return (snapshot.mount,)
|
||||
|
||||
|
||||
def ce_plugin_dir_for_arm(arm: str, snapshot: CePluginSnapshot | None) -> str | None:
|
||||
"""Return Claude's fixed in-sandbox plugin path only for CE arms."""
|
||||
|
||||
ce_plugin_mounts_for_arm(arm, snapshot)
|
||||
return SANDBOX_CE_PLUGIN if arm in CE_ARMS else None
|
||||
|
||||
|
||||
def _validated_runtime_root(path: Path, *, label: str) -> Path:
|
||||
"""Return one real directory without accepting any symlink hop."""
|
||||
|
||||
root = path.expanduser().absolute()
|
||||
try:
|
||||
mode = root.lstat().st_mode
|
||||
resolved = root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"{label} is unavailable: {root}: {exc}") from exc
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode) or resolved != root:
|
||||
raise SandboxError(f"{label} must be a real directory: {root}")
|
||||
return root
|
||||
|
||||
|
||||
def _validated_runtime_component(
|
||||
root: Path,
|
||||
relative: str,
|
||||
target: str,
|
||||
*,
|
||||
directory: bool,
|
||||
) -> ReadOnlyMount:
|
||||
"""Validate one direct runtime component before exposing only that path."""
|
||||
|
||||
source = root / relative
|
||||
try:
|
||||
mode = source.lstat().st_mode
|
||||
resolved = source.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"pinned GitNexus runtime component is unavailable: {source}: {exc}") from exc
|
||||
expected_type = stat.S_ISDIR(mode) if directory else stat.S_ISREG(mode)
|
||||
if stat.S_ISLNK(mode) or not expected_type or resolved != source:
|
||||
kind = "directory" if directory else "file"
|
||||
raise SandboxError(f"pinned GitNexus runtime component must be a real {kind}: {source}")
|
||||
return ReadOnlyMount(source=source, target=target)
|
||||
|
||||
|
||||
def trusted_gitnexus_runtime_mounts() -> tuple[ReadOnlyMount, ...]:
|
||||
"""Expose only the files needed by the pinned CLI and linked shared package."""
|
||||
|
||||
runtime = _validated_runtime_root(
|
||||
HARNESS_ROOT / "gitnexus",
|
||||
label="pinned GitNexus runtime",
|
||||
)
|
||||
shared = _validated_runtime_root(
|
||||
HARNESS_ROOT / "gitnexus-shared",
|
||||
label="pinned GitNexus shared runtime",
|
||||
)
|
||||
mounts = (
|
||||
_validated_runtime_component(
|
||||
runtime,
|
||||
"dist",
|
||||
f"{SANDBOX_GITNEXUS}/dist",
|
||||
directory=True,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
runtime,
|
||||
"package.json",
|
||||
f"{SANDBOX_GITNEXUS}/package.json",
|
||||
directory=False,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
runtime,
|
||||
"node_modules",
|
||||
f"{SANDBOX_GITNEXUS}/node_modules",
|
||||
directory=True,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
runtime,
|
||||
"vendor",
|
||||
f"{SANDBOX_GITNEXUS}/vendor",
|
||||
directory=True,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
shared,
|
||||
"dist",
|
||||
f"{SANDBOX_GITNEXUS_SHARED}/dist",
|
||||
directory=True,
|
||||
),
|
||||
_validated_runtime_component(
|
||||
shared,
|
||||
"package.json",
|
||||
f"{SANDBOX_GITNEXUS_SHARED}/package.json",
|
||||
directory=False,
|
||||
),
|
||||
)
|
||||
|
||||
entrypoint = mounts[0].source / "cli" / "index.js"
|
||||
try:
|
||||
entrypoint_mode = entrypoint.lstat().st_mode
|
||||
package = json.loads(mounts[1].source.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SandboxError(f"pinned GitNexus runtime metadata is invalid: {exc}") from exc
|
||||
if stat.S_ISLNK(entrypoint_mode) or not stat.S_ISREG(entrypoint_mode):
|
||||
raise SandboxError(f"pinned GitNexus runtime entrypoint must be regular and non-symlink: {entrypoint}")
|
||||
if package.get("version") != PINNED_GITNEXUS_VERSION:
|
||||
raise SandboxError(
|
||||
"pinned GitNexus runtime version drifted: "
|
||||
f"expected {PINNED_GITNEXUS_VERSION}, got {package.get('version')!r}"
|
||||
)
|
||||
|
||||
linked_shared = mounts[2].source / "gitnexus-shared"
|
||||
if not linked_shared.is_symlink() or linked_shared.resolve(strict=True) != shared:
|
||||
raise SandboxError("pinned GitNexus runtime has an unexpected gitnexus-shared dependency")
|
||||
try:
|
||||
shared_package = json.loads(mounts[5].source.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SandboxError(f"pinned GitNexus shared runtime metadata is invalid: {exc}") from exc
|
||||
if shared_package.get("name") != "gitnexus-shared":
|
||||
raise SandboxError("pinned GitNexus shared runtime has an unexpected package identity")
|
||||
return mounts
|
||||
|
||||
|
||||
def validate_ce_plugin_inputs(
|
||||
arms: Sequence[str],
|
||||
plugin_dir: Path | None,
|
||||
plugin_version: str | None,
|
||||
) -> CePluginConfig | None:
|
||||
"""Require an explicit directory and exact version iff a CE arm is selected."""
|
||||
|
||||
has_ce_arm = any(arm in CE_ARMS for arm in arms)
|
||||
supplied = plugin_dir is not None or plugin_version is not None
|
||||
if not has_ce_arm:
|
||||
if supplied:
|
||||
raise ValueError("--ce-plugin-dir and --ce-plugin-version require at least one ce_* arm")
|
||||
return None
|
||||
if plugin_dir is None or plugin_version is None:
|
||||
raise ValueError("ce_* arms require both --ce-plugin-dir and --ce-plugin-version")
|
||||
if _EXACT_VERSION.fullmatch(plugin_version) is None:
|
||||
raise ValueError("--ce-plugin-version must be an exact semantic version (aliases and ranges are forbidden)")
|
||||
source = _validated_runtime_root(plugin_dir, label="Compound Engineering plugin source")
|
||||
return CePluginConfig(source=source, version=plugin_version)
|
||||
|
||||
|
||||
def _is_forbidden_plugin_path(relative: PurePosixPath) -> bool:
|
||||
for part in relative.parts:
|
||||
lowered = part.lower()
|
||||
if lowered in _FORBIDDEN_PATH_PARTS or lowered in _SECRET_EXACT_NAMES:
|
||||
return True
|
||||
if lowered.startswith(".env.") or lowered.startswith(".npmrc."):
|
||||
return True
|
||||
if lowered.endswith(_SECRET_SUFFIXES) or any(marker in lowered for marker in _SECRET_NAME_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _plugin_files(source: Path) -> Iterator[tuple[PurePosixPath, Path]]:
|
||||
"""Yield only allowlisted plugin files in stable order."""
|
||||
|
||||
manifest_root = source / ".claude-plugin"
|
||||
try:
|
||||
manifest_root_metadata = manifest_root.lstat()
|
||||
except OSError as exc:
|
||||
raise SandboxError(
|
||||
f"Compound Engineering plugin manifest directory is unavailable: {manifest_root}: {exc}"
|
||||
) from exc
|
||||
if stat.S_ISLNK(manifest_root_metadata.st_mode) or not stat.S_ISDIR(manifest_root_metadata.st_mode):
|
||||
raise SandboxError(f"Compound Engineering plugin manifest directory must be real: {manifest_root}")
|
||||
required_manifest = _ALLOWED_PLUGIN_MANIFESTS[0]
|
||||
manifest_path = source / Path(*required_manifest.parts)
|
||||
if not manifest_path.exists():
|
||||
raise SandboxError(f"Compound Engineering plugin manifest is missing: {manifest_path}")
|
||||
for relative in _ALLOWED_PLUGIN_MANIFESTS:
|
||||
candidate = source / Path(*relative.parts)
|
||||
if candidate.exists():
|
||||
yield relative, candidate
|
||||
|
||||
skills = source / "skills"
|
||||
if not skills.exists():
|
||||
raise SandboxError(f"Compound Engineering plugin skills directory is missing: {skills}")
|
||||
|
||||
def walk(directory: Path, relative_dir: PurePosixPath) -> Iterator[tuple[PurePosixPath, Path]]:
|
||||
try:
|
||||
with os.scandir(directory) as scanned:
|
||||
entries = sorted(scanned, key=lambda entry: entry.name)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"Compound Engineering plugin directory is unreadable: {directory}: {exc}") from exc
|
||||
for entry in entries:
|
||||
relative = relative_dir / entry.name
|
||||
if _is_forbidden_plugin_path(relative):
|
||||
continue
|
||||
try:
|
||||
metadata = entry.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"Compound Engineering plugin entry is unreadable: {entry.path}: {exc}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
raise SandboxError(f"Compound Engineering plugin entries must not be symlinks: {entry.path}")
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
yield from walk(Path(entry.path), relative)
|
||||
elif stat.S_ISREG(metadata.st_mode):
|
||||
yield relative, Path(entry.path)
|
||||
else:
|
||||
raise SandboxError(f"Compound Engineering plugin entries must be regular files: {entry.path}")
|
||||
|
||||
for name in _ALLOWED_PLUGIN_DIRS:
|
||||
directory = source / name
|
||||
if not directory.exists():
|
||||
continue
|
||||
try:
|
||||
metadata = directory.lstat()
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"Compound Engineering plugin component is unreadable: {directory}: {exc}") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise SandboxError(f"Compound Engineering plugin component must be a real directory: {directory}")
|
||||
yield from walk(directory, PurePosixPath(name))
|
||||
|
||||
|
||||
def _bounded_plugin_bytes(path: Path) -> tuple[bytes, bool]:
|
||||
"""Read one stable regular file without following a last-component symlink."""
|
||||
|
||||
try:
|
||||
before = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"Compound Engineering plugin file is unreadable: {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise SandboxError(f"Compound Engineering plugin file must be regular and non-symlink: {path}")
|
||||
if before.st_size > MAX_CE_PLUGIN_FILE_BYTES:
|
||||
raise SandboxError(f"Compound Engineering plugin file exceeds the per-file limit: {path}")
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) or not stat.S_ISREG(opened.st_mode):
|
||||
raise SandboxError(f"Compound Engineering plugin file changed during validation: {path}")
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_CE_PLUGIN_FILE_BYTES + 1
|
||||
while remaining > 0:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
payload = b"".join(chunks)
|
||||
after = os.fstat(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(payload) > MAX_CE_PLUGIN_FILE_BYTES:
|
||||
raise SandboxError(f"Compound Engineering plugin file exceeds the per-file limit: {path}")
|
||||
identity_before = (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns)
|
||||
identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
if identity_after != identity_before or len(payload) != after.st_size:
|
||||
raise SandboxError(f"Compound Engineering plugin file changed while being copied: {path}")
|
||||
return payload, bool(before.st_mode & 0o111)
|
||||
|
||||
|
||||
def _write_snapshot_file(path: Path, payload: bytes, *, executable: bool) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o500 if executable else 0o400,
|
||||
)
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
view = view[written:]
|
||||
os.fchmod(descriptor, 0o555 if executable else 0o444)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _freeze_snapshot(root: Path) -> None:
|
||||
for directory, _, _ in os.walk(root, topdown=False):
|
||||
Path(directory).chmod(0o555)
|
||||
root.chmod(0o555)
|
||||
|
||||
|
||||
def _remove_snapshot(root: Path) -> None:
|
||||
if not root.exists():
|
||||
return
|
||||
for directory, _, files in os.walk(root):
|
||||
for name in files:
|
||||
(Path(directory) / name).chmod(0o600)
|
||||
Path(directory).chmod(0o700)
|
||||
shutil.rmtree(root)
|
||||
|
||||
|
||||
def _build_ce_plugin_snapshot(config: CePluginConfig, destination_parent: Path) -> CePluginSnapshot:
|
||||
parent = _validated_runtime_root(destination_parent, label="CE plugin snapshot parent")
|
||||
root = Path(tempfile.mkdtemp(prefix="wfbench-ce-plugin-", dir=parent))
|
||||
root.chmod(0o700)
|
||||
entries: list[dict[str, Any]] = []
|
||||
total_bytes = 0
|
||||
try:
|
||||
for relative, source in _plugin_files(config.source):
|
||||
relative_text = relative.as_posix()
|
||||
if len(relative_text.encode()) > MAX_CE_PLUGIN_PATH_BYTES:
|
||||
raise SandboxError(f"Compound Engineering plugin path exceeds the byte limit: {relative_text}")
|
||||
if len(entries) >= MAX_CE_PLUGIN_FILES:
|
||||
raise SandboxError("Compound Engineering plugin exceeds the file-count limit")
|
||||
payload, executable = _bounded_plugin_bytes(source)
|
||||
total_bytes += len(payload)
|
||||
if total_bytes > MAX_CE_PLUGIN_TOTAL_BYTES:
|
||||
raise SandboxError("Compound Engineering plugin exceeds the total byte limit")
|
||||
_write_snapshot_file(
|
||||
root / Path(*relative.parts),
|
||||
payload,
|
||||
executable=executable,
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"path": relative_text,
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"size": len(payload),
|
||||
"executable": executable,
|
||||
}
|
||||
)
|
||||
|
||||
manifest_path = root / ".claude-plugin" / "plugin.json"
|
||||
try:
|
||||
plugin_manifest = json.loads(manifest_path.read_text())
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise SandboxError(f"Compound Engineering plugin manifest is invalid: {exc}") from exc
|
||||
if not isinstance(plugin_manifest, dict) or plugin_manifest.get("name") != "compound-engineering":
|
||||
raise SandboxError("CE comparator requires the compound-engineering plugin manifest")
|
||||
if plugin_manifest.get("version") != config.version:
|
||||
raise SandboxError(
|
||||
"Compound Engineering plugin version mismatch: "
|
||||
f"expected {config.version}, got {plugin_manifest.get('version')!r}"
|
||||
)
|
||||
for skill in ("ce-plan", "ce-work", "ce-code-review"):
|
||||
if not (root / "skills" / skill / "SKILL.md").is_file():
|
||||
raise SandboxError(f"Compound Engineering plugin is missing required skill: {skill}")
|
||||
|
||||
canonical_manifest = json.dumps(
|
||||
{
|
||||
"schema_version": CE_PLUGIN_MANIFEST_SCHEMA_VERSION,
|
||||
"files": entries,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
snapshot = CePluginSnapshot(
|
||||
root=root,
|
||||
version=config.version,
|
||||
manifest_digest=hashlib.sha256(canonical_manifest).hexdigest(),
|
||||
file_count=len(entries),
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
_freeze_snapshot(root)
|
||||
return snapshot
|
||||
except BaseException:
|
||||
_remove_snapshot(root)
|
||||
raise
|
||||
|
||||
|
||||
@contextmanager
|
||||
def staged_ce_plugin_snapshot(
|
||||
config: CePluginConfig | None,
|
||||
*,
|
||||
destination_parent: Path,
|
||||
) -> Iterator[CePluginSnapshot | None]:
|
||||
"""Yield one bounded immutable CE plugin view and remove it afterward."""
|
||||
|
||||
if config is None:
|
||||
yield None
|
||||
return
|
||||
snapshot = _build_ce_plugin_snapshot(config, destination_parent)
|
||||
try:
|
||||
yield snapshot
|
||||
finally:
|
||||
_remove_snapshot(snapshot.root)
|
||||
396
eval/workflow_bench/sanitized_graph.py
Normal file
396
eval/workflow_bench/sanitized_graph.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
"""Build one reusable GitNexus graph from a history-pruned task snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from .oracle_assets import HIDDEN_HARNESS_PATH, sanitize_clone_for_hidden_oracles
|
||||
from .process_control import ManagedProcessError, run_managed
|
||||
from .proposer_sandbox import (
|
||||
SANDBOX_GITNEXUS,
|
||||
SANDBOX_HOME,
|
||||
SANDBOX_WORKSPACE,
|
||||
ReadOnlyMount,
|
||||
SandboxError,
|
||||
build_sandbox_environment,
|
||||
prepare_sandbox,
|
||||
)
|
||||
from .runner_artifacts import make_worktree, remove_clone
|
||||
from .task_assets import TaskAssetCache, TaskAssetSnapshot
|
||||
|
||||
GRAPH_ASSET_PATHS = (
|
||||
".gitnexus/gitnexus.json",
|
||||
".gitnexus/meta.json",
|
||||
".gitnexus/lbug",
|
||||
)
|
||||
GRAPH_MARKERS = (
|
||||
"eval/workflow_bench",
|
||||
"workflow_bench/oracles",
|
||||
"GITNEXUS_BENCH_ORACLE_ROOT",
|
||||
"tasks.scenarios.yaml",
|
||||
".oracle.test.",
|
||||
"wfbench-oracle",
|
||||
)
|
||||
GRAPH_BUILD_TIMEOUT_SECONDS = 3600
|
||||
GRAPH_QUERY_TIMEOUT_SECONDS = 300
|
||||
MAX_GRAPH_SCRUB_ENTRIES = 250_000
|
||||
MAX_GRAPH_SCRUB_FILE_BYTES = 512 * 1024
|
||||
MAX_GRAPH_SCRUB_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT = f"{SANDBOX_GITNEXUS}/dist/cli/index.js"
|
||||
SANDBOX_INDEX_REGISTRY = f"{SANDBOX_HOME}/.gitnexus-index"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SanitizedGraphSnapshot:
|
||||
"""A graph whose only source was one deterministic parentless commit."""
|
||||
|
||||
assets: TaskAssetSnapshot
|
||||
sanitized_head: str
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return self.assets.digest
|
||||
|
||||
@property
|
||||
def manifest_digest(self) -> str:
|
||||
return self.assets.manifest_digest
|
||||
|
||||
def materialize(self, clone: Path, *, sanitized_head: str) -> None:
|
||||
if sanitized_head != self.sanitized_head:
|
||||
raise SandboxError(
|
||||
"sanitized task identity drifted between graph preparation and arm clone "
|
||||
f"({self.sanitized_head} != {sanitized_head})"
|
||||
)
|
||||
self.assets.materialize(clone)
|
||||
|
||||
|
||||
def _is_restricted_path(value: str) -> bool:
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
|
||||
return False
|
||||
return (
|
||||
relative.parts[0] == ".gitnexus" or relative == HIDDEN_HARNESS_PATH or HIDDEN_HARNESS_PATH in relative.parents
|
||||
)
|
||||
|
||||
|
||||
def validate_no_prebuilt_graph_assets(task: Mapping[str, Any]) -> None:
|
||||
"""Reject declarations that could reintroduce an unsanitized graph/oracle."""
|
||||
|
||||
sandbox_copy = task.get("sandbox_copy", [])
|
||||
if not isinstance(sandbox_copy, list):
|
||||
raise SandboxError("sandbox_copy must be a list")
|
||||
for value in sandbox_copy:
|
||||
if isinstance(value, str) and _is_restricted_path(value):
|
||||
raise SandboxError(f"sandbox_copy cannot import prebuilt graph or harness data: {value}")
|
||||
|
||||
dependencies = task.get("sandbox_dependencies", [])
|
||||
if not isinstance(dependencies, list):
|
||||
raise SandboxError("sandbox_dependencies must be a list")
|
||||
for item in dependencies:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
for field in ("source", "target"):
|
||||
value = item.get(field)
|
||||
if isinstance(value, str) and _is_restricted_path(value):
|
||||
raise SandboxError(f"sandbox dependency cannot expose prebuilt graph or harness data: {value}")
|
||||
|
||||
|
||||
def _replace_control_file(root: Path, name: str, payload: bytes) -> None:
|
||||
path = root / name
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
metadata = None
|
||||
if metadata is not None:
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
raise SandboxError(f"target-controlled {name} must not be a directory")
|
||||
path.unlink()
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
if written <= 0:
|
||||
raise OSError(f"short write while neutralizing {name}")
|
||||
view = view[written:]
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _neutralize_target_index_inputs(root: Path) -> None:
|
||||
index = root / ".gitnexus"
|
||||
try:
|
||||
metadata = index.lstat()
|
||||
except FileNotFoundError:
|
||||
metadata = None
|
||||
if metadata is not None:
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise SandboxError("target .gitnexus path must be a real directory before graph preparation")
|
||||
shutil.rmtree(index)
|
||||
_replace_control_file(root, ".gitnexusrc", b"{}\n")
|
||||
_replace_control_file(root, ".gitnexusignore", b"")
|
||||
|
||||
|
||||
def _scrub_source_references(root: Path) -> tuple[str, ...]:
|
||||
"""Remove graph inputs whose path or stored content references the harness.
|
||||
|
||||
The disposable graph seed may contain docs or shipped skill copies outside
|
||||
the removed harness that name its paths. They are harmless implementation
|
||||
context in an arm checkout, but indexing them would let graph/MCP queries
|
||||
recover benchmark-specific hints. Scan the exact <=512 KiB file universe
|
||||
admitted by the pinned analyzer and remove contaminated inputs before the
|
||||
graph is built. Target-controlled ignore/config files are not consulted.
|
||||
"""
|
||||
|
||||
marker_bytes = tuple(marker.encode() for marker in GRAPH_MARKERS)
|
||||
pending: list[tuple[Path, PurePosixPath]] = [(root, PurePosixPath())]
|
||||
removed: list[str] = []
|
||||
entries = 0
|
||||
scanned_bytes = 0
|
||||
while pending:
|
||||
directory, relative_directory = pending.pop()
|
||||
try:
|
||||
children = sorted(os.scandir(directory), key=lambda item: item.name, reverse=True)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"cannot scan sanitized graph source: {directory}: {exc}") from exc
|
||||
for entry in children:
|
||||
relative = relative_directory / entry.name
|
||||
if relative.parts[0] in {".git", ".gitnexus"}:
|
||||
continue
|
||||
entries += 1
|
||||
if entries > MAX_GRAPH_SCRUB_ENTRIES:
|
||||
raise SandboxError("sanitized graph source exceeds the scrub entry limit")
|
||||
relative_text = relative.as_posix()
|
||||
metadata = entry.stat(follow_symlinks=False)
|
||||
path_matches = any(marker in relative_text for marker in GRAPH_MARKERS)
|
||||
if path_matches:
|
||||
path = Path(entry.path)
|
||||
if stat.S_ISDIR(metadata.st_mode) and not stat.S_ISLNK(metadata.st_mode):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
removed.append(relative_text)
|
||||
continue
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
pending.append((Path(entry.path), relative))
|
||||
continue
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
continue
|
||||
if metadata.st_size > MAX_GRAPH_SCRUB_FILE_BYTES:
|
||||
continue
|
||||
scanned_bytes += metadata.st_size
|
||||
if scanned_bytes > MAX_GRAPH_SCRUB_TOTAL_BYTES:
|
||||
raise SandboxError("sanitized graph source exceeds the scrub byte limit")
|
||||
descriptor = os.open(entry.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or (opened.st_dev, opened.st_ino, opened.st_size) != (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_size,
|
||||
):
|
||||
raise SandboxError(f"sanitized graph source changed while opening: {relative}")
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_GRAPH_SCRUB_FILE_BYTES + 1
|
||||
while remaining > 0:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
payload = b"".join(chunks)
|
||||
after = os.fstat(descriptor)
|
||||
if len(payload) != opened.st_size or (opened.st_size, opened.st_mtime_ns, opened.st_ctime_ns) != (
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
after.st_ctime_ns,
|
||||
):
|
||||
raise SandboxError(f"sanitized graph source changed while scanning: {relative}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if any(marker in payload for marker in marker_bytes):
|
||||
Path(entry.path).unlink()
|
||||
removed.append(relative_text)
|
||||
return tuple(sorted(removed))
|
||||
|
||||
|
||||
def _graph_environment() -> dict[str, str]:
|
||||
env = build_sandbox_environment()
|
||||
env.update(
|
||||
{
|
||||
"GITNEXUS_HOME": SANDBOX_INDEX_REGISTRY,
|
||||
"GITNEXUS_NO_GITIGNORE": "1",
|
||||
"GITNEXUS_WORKER_POOL_SIZE": "1",
|
||||
"GITNEXUS_PARSE_CHUNK_CONCURRENCY": "1",
|
||||
}
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def _run_graph_cli(
|
||||
prefix: Sequence[str],
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
timeout: int,
|
||||
capture_stdout: bool = False,
|
||||
) -> bytes | None:
|
||||
command = [
|
||||
*prefix,
|
||||
"/usr/local/bin/node",
|
||||
SANDBOX_GITNEXUS_ENTRYPOINT,
|
||||
*arguments,
|
||||
]
|
||||
result = run_managed(
|
||||
command,
|
||||
timeout=timeout,
|
||||
env=_graph_environment(),
|
||||
require_pid_namespace=True,
|
||||
capture_stdout_bytes=(2 * 1024 * 1024 if capture_stdout else None),
|
||||
)
|
||||
if not result.ok:
|
||||
raise ManagedProcessError(command, result)
|
||||
if not capture_stdout:
|
||||
return None
|
||||
if result.stdout_capture is None or result.stdout_capture_overflow:
|
||||
raise SandboxError("bounded graph-query output was unavailable")
|
||||
return result.stdout_capture
|
||||
|
||||
|
||||
def _marker_predicate(variable: str) -> str:
|
||||
literals = ("'" + marker.replace("\\", "\\\\").replace("'", "\\'") + "'" for marker in GRAPH_MARKERS)
|
||||
return " OR ".join(f"CAST({variable} AS STRING) CONTAINS {literal}" for literal in literals)
|
||||
|
||||
|
||||
def _parse_empty_query(raw: bytes, *, label: str) -> None:
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="strict"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise SandboxError(f"{label} did not return strict JSON") from exc
|
||||
if payload == []:
|
||||
return
|
||||
if isinstance(payload, dict) and payload.get("row_count") == 0:
|
||||
return
|
||||
raise SandboxError(f"{label} found recoverable benchmark harness references")
|
||||
|
||||
|
||||
def _scrub_and_verify_graph(prefix: Sequence[str]) -> None:
|
||||
node_predicate = _marker_predicate("n")
|
||||
relation_predicate = _marker_predicate("r")
|
||||
node_result = _run_graph_cli(
|
||||
prefix,
|
||||
("cypher", f"MATCH (n) WHERE {node_predicate} RETURN n LIMIT 1", "-r", "benchmark-target", "--limit", "1"),
|
||||
timeout=GRAPH_QUERY_TIMEOUT_SECONDS,
|
||||
capture_stdout=True,
|
||||
)
|
||||
relation_result = _run_graph_cli(
|
||||
prefix,
|
||||
(
|
||||
"cypher",
|
||||
f"MATCH ()-[r]->() WHERE {relation_predicate} RETURN r LIMIT 1",
|
||||
"-r",
|
||||
"benchmark-target",
|
||||
"--limit",
|
||||
"1",
|
||||
),
|
||||
timeout=GRAPH_QUERY_TIMEOUT_SECONDS,
|
||||
capture_stdout=True,
|
||||
)
|
||||
assert node_result is not None and relation_result is not None
|
||||
_parse_empty_query(node_result, label="sanitized graph node proof")
|
||||
_parse_empty_query(relation_result, label="sanitized graph relation proof")
|
||||
|
||||
|
||||
def _validate_graph_metadata(root: Path, sanitized_head: str) -> None:
|
||||
for name in ("gitnexus.json", "meta.json", "lbug"):
|
||||
path = root / ".gitnexus" / name
|
||||
metadata = path.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise SandboxError(f"sanitized graph asset must be regular and non-symlink: {path}")
|
||||
try:
|
||||
metadata_payload = json.loads((root / ".gitnexus" / "gitnexus.json").read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SandboxError("sanitized graph metadata is malformed") from exc
|
||||
if metadata_payload.get("lastCommit") != sanitized_head:
|
||||
raise SandboxError("sanitized graph metadata is not bound to the parentless task commit")
|
||||
if not isinstance(metadata_payload.get("pdg"), dict) or not metadata_payload["pdg"]:
|
||||
raise SandboxError("sanitized graph metadata does not prove a --pdg build")
|
||||
|
||||
|
||||
def prepare_sanitized_graph(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
repo: Path,
|
||||
resolved_sha: str,
|
||||
parent: Path,
|
||||
cache: TaskAssetCache,
|
||||
claude_bin: Path | str,
|
||||
bwrap_bin: Path | str,
|
||||
runtime_mounts: Sequence[ReadOnlyMount],
|
||||
) -> SanitizedGraphSnapshot:
|
||||
"""Sanitize, index offline once, scrub, and freeze graph assets for all arms."""
|
||||
|
||||
validate_no_prebuilt_graph_assets(task)
|
||||
seed = make_worktree(repo, resolved_sha, parent)
|
||||
primary: BaseException | None = None
|
||||
try:
|
||||
sanitized_head = sanitize_clone_for_hidden_oracles(seed)
|
||||
_scrub_source_references(seed)
|
||||
_neutralize_target_index_inputs(seed)
|
||||
with prepare_sandbox(
|
||||
clone=seed,
|
||||
claude_bin=claude_bin,
|
||||
bwrap_bin=bwrap_bin,
|
||||
read_only_mounts=runtime_mounts,
|
||||
preflight=False,
|
||||
) as sandbox:
|
||||
prefix = sandbox.command_prefix_for(unshare_network=True)
|
||||
_run_graph_cli(
|
||||
prefix,
|
||||
(
|
||||
"analyze",
|
||||
SANDBOX_WORKSPACE,
|
||||
"--force",
|
||||
"--pdg",
|
||||
"--index-only",
|
||||
"--no-stats",
|
||||
"--name",
|
||||
"benchmark-target",
|
||||
"--default-branch",
|
||||
"main",
|
||||
"--max-file-size",
|
||||
"512",
|
||||
"--workers",
|
||||
"1",
|
||||
),
|
||||
timeout=GRAPH_BUILD_TIMEOUT_SECONDS,
|
||||
)
|
||||
_scrub_and_verify_graph(prefix)
|
||||
_validate_graph_metadata(seed, sanitized_head)
|
||||
assets = cache.prepare(
|
||||
{"sandbox_copy": list(GRAPH_ASSET_PATHS)},
|
||||
repo=seed,
|
||||
resolved_sha=sanitized_head,
|
||||
)
|
||||
return SanitizedGraphSnapshot(assets=assets, sanitized_head=sanitized_head)
|
||||
except BaseException as exc:
|
||||
primary = exc
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
remove_clone(seed)
|
||||
except OSError as cleanup:
|
||||
if primary is None:
|
||||
raise
|
||||
primary.add_note(f"sanitized graph seed cleanup also failed: {cleanup}")
|
||||
1056
eval/workflow_bench/task_assets.py
Normal file
1056
eval/workflow_bench/task_assets.py
Normal file
File diff suppressed because it is too large
Load diff
138
eval/workflow_bench/tasks.scenarios.yaml
Normal file
138
eval/workflow_bench/tasks.scenarios.yaml
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Scenario suite for the gitnexus workflow benchmark — the ground-base matrix.
|
||||
#
|
||||
# Task fields:
|
||||
# id: short slug used in reports
|
||||
# class: task class for cost/quality routing analysis. The suite spans:
|
||||
# trivial → investigation-bug → investigation-feature → cross-module
|
||||
# repo: path to a git repo that is GitNexus-indexed
|
||||
# ref: git ref benchmarked (fresh detached worktree per arm per run)
|
||||
# setup: optional shell command run in the fresh worktree first
|
||||
# (task-specific preparation; dependencies are immutable mounts)
|
||||
# prompt: the engineering task, phrased once, given verbatim to every arm.
|
||||
# Prescribe the test file path — that keeps `verify` deterministic.
|
||||
# verify: model-visible authored-test command (recorded as a separate signal)
|
||||
# oracle: harness-owned hidden files + command; oracle success is also
|
||||
# required for resolved and files are staged only after the session
|
||||
# expensive: optional boolean; true scenarios require --include-expensive
|
||||
# sandbox_dependencies: repository-local dependencies mounted read-only
|
||||
#
|
||||
# Arms (runner --arms): workflow (plan→work), workflow_direct (work skill,
|
||||
# no plan), baseline (no skills, MCP allowed), baseline_nomcp (no skills, no
|
||||
# graph tools). candidate_workflow and candidate_workflow_direct apply a
|
||||
# skill-only --candidate-overlay and must be paired with their incumbent arm.
|
||||
# Comparing workflow vs workflow_direct vs baseline locates the task-complexity
|
||||
# boundary where each mode pays for itself — that boundary is the routing rule
|
||||
# lfg's gate and work's direct-mode triage encode.
|
||||
#
|
||||
# GitNexus scenarios build one graph from a history-pruned, parentless clone and
|
||||
# cache only that sanitized graph for every arm. The target repository's
|
||||
# prebuilt .gitnexus directory is never copied. Three declared dependency trees
|
||||
# are mounted read-only; missing assets fail before model execution.
|
||||
|
||||
tasks:
|
||||
# Overhead floor — measured 2026-07-11 (see README calibration): the
|
||||
# workflow is EXPECTED to lose here. Kept in the suite so regressions in
|
||||
# the overhead floor stay visible.
|
||||
- id: trivial-version-alias
|
||||
class: trivial
|
||||
repo: ~/GitNexus
|
||||
ref: main
|
||||
sandbox_dependencies: &gitnexus_sandbox_dependencies
|
||||
- source: node_modules
|
||||
target: node_modules
|
||||
- source: gitnexus/node_modules
|
||||
target: gitnexus/node_modules
|
||||
- source: gitnexus-shared/node_modules
|
||||
target: gitnexus-shared/node_modules
|
||||
prompt: >
|
||||
Add -V as a short alias for --version to the gitnexus CLI
|
||||
(gitnexus/src/cli/index.ts), and cover the alias with a unit test in
|
||||
gitnexus/test/unit/cli-commands.test.ts.
|
||||
verify: cd gitnexus && npx tsc --noEmit && npx vitest run test/unit/cli-commands.test.ts
|
||||
oracle:
|
||||
command: >-
|
||||
cd gitnexus && ./node_modules/.bin/vitest run
|
||||
--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"
|
||||
"$GITNEXUS_BENCH_ORACLE_ROOT/trivial-version-alias.oracle.test.ts"
|
||||
files:
|
||||
- source: vitest.config.mts
|
||||
target: vitest.config.mts
|
||||
- source: trivial-version-alias.oracle.test.ts
|
||||
target: trivial-version-alias.oracle.test.ts
|
||||
|
||||
# Investigation-heavy bug: requires locating the degraded-result path in
|
||||
# local-backend, understanding the layer probe, and changing a contract
|
||||
# message without breaking existing consumers.
|
||||
- id: inv-bug-pdg-note
|
||||
class: investigation-bug
|
||||
repo: ~/GitNexus
|
||||
ref: main
|
||||
sandbox_dependencies: *gitnexus_sandbox_dependencies
|
||||
prompt: >
|
||||
When the pdg_query MCP tool returns its "no PDG layer" note, make the
|
||||
note say WHICH sub-layer is missing (CDG vs REACHING_DEF) instead of a
|
||||
generic message, keeping the existing degraded-result contract intact.
|
||||
Cover both modes with unit tests in
|
||||
gitnexus/test/unit/pdg-note-sublayer.test.ts.
|
||||
verify: cd gitnexus && npx tsc --noEmit && npx vitest run test/unit/pdg-note-sublayer.test.ts
|
||||
oracle:
|
||||
command: >-
|
||||
cd gitnexus && ./node_modules/.bin/vitest run
|
||||
--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"
|
||||
"$GITNEXUS_BENCH_ORACLE_ROOT/inv-bug-pdg-note.oracle.test.ts"
|
||||
files:
|
||||
- source: vitest.config.mts
|
||||
target: vitest.config.mts
|
||||
- source: inv-bug-pdg-note.oracle.test.ts
|
||||
target: inv-bug-pdg-note.oracle.test.ts
|
||||
|
||||
# Investigation-heavy feature: touches tool schema, backend filtering, and
|
||||
# pagination totals — three seams that must stay consistent.
|
||||
- id: inv-feature-list-repos-filter
|
||||
class: investigation-feature
|
||||
repo: ~/GitNexus
|
||||
ref: main
|
||||
sandbox_dependencies: *gitnexus_sandbox_dependencies
|
||||
prompt: >
|
||||
Add an optional "name_contains" filter parameter to the list_repos MCP
|
||||
tool: case-insensitive substring match on the repo name, with the
|
||||
pagination object (total/hasMore/nextOffset) reflecting the FILTERED
|
||||
set. Cover with unit tests in
|
||||
gitnexus/test/unit/list-repos-name-filter.test.ts.
|
||||
verify: cd gitnexus && npx tsc --noEmit && npx vitest run test/unit/list-repos-name-filter.test.ts
|
||||
oracle:
|
||||
command: >-
|
||||
cd gitnexus && ./node_modules/.bin/vitest run
|
||||
--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"
|
||||
"$GITNEXUS_BENCH_ORACLE_ROOT/inv-feature-list-repos-filter.oracle.test.ts"
|
||||
files:
|
||||
- source: vitest.config.mts
|
||||
target: vitest.config.mts
|
||||
- source: inv-feature-list-repos-filter.oracle.test.ts
|
||||
target: inv-feature-list-repos-filter.oracle.test.ts
|
||||
|
||||
# Cross-module: worker-pool + pipeline seams, concurrency-sensitive.
|
||||
# The most expensive scenario — run deliberately, not by default.
|
||||
- id: cross-module-parse-retry
|
||||
class: cross-module
|
||||
expensive: true
|
||||
repo: ~/GitNexus
|
||||
ref: main
|
||||
sandbox_dependencies: *gitnexus_sandbox_dependencies
|
||||
prompt: >
|
||||
Add bounded retry with backoff to the ingestion pipeline so a transient
|
||||
parse-worker failure on a file is retried up to 2 times before the file
|
||||
is marked failed, without retrying deterministic parse errors. Cover
|
||||
the retry/no-retry decision with unit tests in
|
||||
gitnexus/test/unit/parse-retry.test.ts.
|
||||
verify: cd gitnexus && npx tsc --noEmit && npx vitest run test/unit/parse-retry.test.ts
|
||||
oracle:
|
||||
command: >-
|
||||
cd gitnexus && ./node_modules/.bin/vitest run
|
||||
--config "$GITNEXUS_BENCH_ORACLE_ROOT/vitest.config.mts"
|
||||
"$GITNEXUS_BENCH_ORACLE_ROOT/cross-module-parse-retry.oracle.test.ts"
|
||||
files:
|
||||
- source: vitest.config.mts
|
||||
target: vitest.config.mts
|
||||
- source: cross-module-parse-retry.oracle.test.ts
|
||||
target: cross-module-parse-retry.oracle.test.ts
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
gitnexus-claude-plugin/skills/gitnexus-lfg/README.md
Normal file
55
gitnexus-claude-plugin/skills/gitnexus-lfg/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin pipeline orchestrator over three existing skills: `gitnexus-plan`
|
||||
produces the plan (asking up front how deep to go), the user chooses at a
|
||||
blocking gate to proceed or stop (an explicit deepen request is still
|
||||
honored), `gitnexus-work` executes it as verified atomic commits, and
|
||||
`gitnexus-review` reviews the result (the open PR if one exists, else the
|
||||
branch diff against the default branch). One bounded fix cycle for review
|
||||
findings, then a final report. It never pushes or opens a PR on its own.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke |
|
||||
|-----|---------------|
|
||||
| **Claude Code** | `/gitnexus-lfg <task description>` or `/gitnexus-lfg docs/plans/<plan>.md` |
|
||||
| **Codex CLI** | Ask: "run the gitnexus pipeline on <task>" (Codex reads `AGENTS.md`), or install the skill user-level (below) |
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-lfg ~/.agents/skills/gitnexus-lfg
|
||||
```
|
||||
|
||||
Optionally, for an explicit slash command, create
|
||||
`~/.codex/prompts/gitnexus-lfg.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: GitNexus pipeline — plan (depth asked up front), user gate, work, PR review
|
||||
argument-hint: <task description or plan path>
|
||||
---
|
||||
Use the gitnexus-lfg skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-lfg/SKILL.md` (prefer the repo copy at
|
||||
`.claude/skills/gitnexus-lfg/SKILL.md` when present) and follow its lanes in
|
||||
order, invoking the real gitnexus-plan / gitnexus-work / gitnexus-review
|
||||
skills for each lane. Stop at the plan gate for the user's choice.
|
||||
```
|
||||
|
||||
## The three lanes
|
||||
|
||||
| Lane | Skill | Gate |
|
||||
|------|-------|------|
|
||||
| Plan | `gitnexus-plan` (`.claude/skills/gitnexus-plan/`) | Depth asked up front; blocking gate: proceed / stop |
|
||||
| Work | `gitnexus-work` (`.claude/skills/gitnexus-work/`) | Structural drift routes back to the plan gate |
|
||||
| Review | `gitnexus-review` (`.claude/skills/gitnexus-review/`) | One fix cycle max, then report |
|
||||
|
||||
## Threshold governance (maintainers)
|
||||
|
||||
The Lane 1 planning boundary (~35 turns) is a promoted benchmark policy from
|
||||
the GitNexus repository's `eval/workflow_bench/` paired candidate loop.
|
||||
Re-evaluate it offline whenever the named model or tool harness changes, and
|
||||
at least every 90 days; update the SKILL.md threshold only after the
|
||||
deterministic promotion gate shows no quality regression. Reading agents
|
||||
never self-edit it from a live task.
|
||||
86
gitnexus-claude-plugin/skills/gitnexus-lfg/SKILL.md
Normal file
86
gitnexus-claude-plugin/skills/gitnexus-lfg/SKILL.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
name: gitnexus-lfg
|
||||
description: "Use when the user wants the GitNexus engineering pipeline run end-to-end on a task: gitnexus-plan (plan depth chosen up front), a blocking gate to execute with gitnexus-work or stop, finishing with a gitnexus-review of the result. Examples: \"/gitnexus-lfg Add retry support to the ingestion pipeline\", \"run the gitnexus pipeline on this\", \"plan, build and review this feature\"."
|
||||
---
|
||||
|
||||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin orchestrator over three existing skills. It adds no engineering logic of
|
||||
its own — it sequences `gitnexus-plan`, `gitnexus-work`, and
|
||||
`gitnexus-review`, with the user deciding at the plan gate. Run every lane
|
||||
by actually invoking the named skill (read its SKILL.md and follow it);
|
||||
never inline a summary of what the skill would have done.
|
||||
|
||||
```
|
||||
/gitnexus-lfg <task description>
|
||||
/gitnexus-lfg docs/plans/<existing-plan>.md # skip lane 1, start at the gate
|
||||
```
|
||||
|
||||
## Lane 1 — Plan
|
||||
|
||||
**Boundary triage first.** If the task is plainly below the planning
|
||||
boundary — trivial or small-bounded work an agent finishes in well under ~35
|
||||
turns (the measured regime where a planning pass costs more than it returns;
|
||||
measured in the GitNexus repository's `eval/workflow_bench/`) — say so and
|
||||
offer `gitnexus-work` direct mode as an alternative to the full pipeline
|
||||
before spending the plan lane. Honor the user's choice.
|
||||
|
||||
The threshold is a promoted benchmark policy measured offline, not a
|
||||
timeless heuristic — never self-edit it from a live task. Its re-evaluation
|
||||
governance lives in this skill's README.
|
||||
|
||||
Otherwise invoke `gitnexus-plan` with the task (knob overrides pass through
|
||||
verbatim; `gitnexus-plan` owns the up-front depth question — never ask it
|
||||
again here). If the input is already a plan file path, skip to Lane 2. The
|
||||
plan lands in `docs/plans/` — record its path; every later lane consumes it.
|
||||
|
||||
## Lane 2 — The plan gate (user choice, blocking)
|
||||
|
||||
Present the plan's chat summary (objective, proposed changes, sequence, top
|
||||
risks, open questions, plan path), then ask the user — as a blocking
|
||||
question (`AskUserQuestion` in Claude Code; a numbered list in chat on CLIs
|
||||
without a blocking tool):
|
||||
|
||||
1. **Proceed to work** — continue to Lane 3.
|
||||
2. **Stop here** — the plan file is the deliverable; end the pipeline.
|
||||
|
||||
Depth was the user's up-front choice in Lane 1, so deepening is not offered
|
||||
by default — but honor an explicit request for it at the gate: run
|
||||
`gitnexus-plan` Deepen mode on the plan file and return here with the
|
||||
strengthened plan, as many times as the user asks. Do not proceed past the
|
||||
gate without an explicit choice — the gate is the pipeline's only checkpoint
|
||||
and exists precisely because execution is expensive to unwind.
|
||||
|
||||
**Headless / non-interactive runs:** no one can answer the gate, so end the
|
||||
pipeline after Lane 1 — the plan file is the deliverable (gate option 2) —
|
||||
and say so in the final report. Never auto-proceed to execution.
|
||||
|
||||
## Lane 3 — Work
|
||||
|
||||
Invoke `gitnexus-work` with the plan path. It re-anchors the plan at HEAD,
|
||||
executes the Implementation Sequence as verified atomic commits, refreshes
|
||||
the knowledge graph when done (its Phase 4), and reports deviations. If it routes back for re-planning (structural drift), run the
|
||||
Deepen pass and return to the Lane 2 gate rather than pushing through.
|
||||
|
||||
## Lane 4 — Review
|
||||
|
||||
Invoke `gitnexus-review` on the completed work. Pass an open PR URL/number
|
||||
when one exists; otherwise pass the current branch. The review skill owns
|
||||
target resolution, exact-SHA checkout/index alignment, and merge-base
|
||||
selection. Do not duplicate that logic here. If work left local changes,
|
||||
pass `local` as a second, separately labeled review surface.
|
||||
|
||||
Surface the review verdict and findings to the user. Findings the user
|
||||
wants fixed: those within `gitnexus-work`'s direct-mode bounds (1–2 files,
|
||||
no architectural decisions) → hand to `gitnexus-work` direct mode; anything
|
||||
larger → offer the plan gate instead (Deepen the plan with the findings, or
|
||||
stop). Then re-run this lane's review once. On that re-run, do not start
|
||||
another fix cycle even if findings remain — report them and point the user
|
||||
at `/gitnexus-work` (or the plan gate) to continue deliberately.
|
||||
|
||||
## Final report
|
||||
|
||||
One message: plan path, deepen cycles run, commits produced, verification
|
||||
status, review verdict with unresolved findings, and what (if anything) was
|
||||
explicitly left undone. The pipeline does not push or open a PR on its own —
|
||||
offer both as next steps.
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
142
gitnexus-claude-plugin/skills/gitnexus-plan/README.md
Normal file
142
gitnexus-claude-plugin/skills/gitnexus-plan/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# gitnexus-plan — implementation-ready engineering plans
|
||||
|
||||
Generates deep, implementation-ready engineering plans by combining GitNexus
|
||||
repository intelligence, statement-level Program Dependence Graph analysis,
|
||||
and the agent's native targeted source verification.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke | Adapter file |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------- |
|
||||
| **Claude Code** | `/gitnexus-plan <task>` | `.claude/skills/gitnexus-plan/SKILL.md` |
|
||||
| **Codex CLI** | Ask: "run gitnexus-plan for <task>" (Codex reads `AGENTS.md`) — or install the user-level prompt below | `AGENTS.md` § Engineering planning & execution |
|
||||
| **Any AGENTS.md-aware agent** | Ask it to "read `.claude/skills/gitnexus-plan/SKILL.md` and follow it for <task>" | `AGENTS.md` § Engineering planning & execution |
|
||||
|
||||
```
|
||||
/gitnexus-plan Add retry support to the ingestion pipeline
|
||||
/gitnexus-plan Fix the stale warm-cache invalidation bug in exportedTypeMap
|
||||
/gitnexus-plan depth:deep impact_depth:3 Migrate the emit phase to streaming COPY
|
||||
```
|
||||
|
||||
Output: `docs/plans/YYYY-MM-DD-gitnexus-plan-<slug>.md` — a 13-section plan whose
|
||||
section 11 is a machine-readable **implementation context pack** that a
|
||||
follow-up agent can consume without re-investigating the repository. Compact
|
||||
and full packs both include versioned evidence provenance: a canonical global
|
||||
dirty digest and a sorted, per-layer cited-path manifest. An npm-dependency-free,
|
||||
versioned Node helper shared byte-for-byte with `gitnexus-work` is the only
|
||||
supported serializer, so planner and executor hash identical bytes. The same
|
||||
helper is the only supported existing-plan reader and plan writer. Its
|
||||
descriptor-anchored `read-plan` receipt binds the canonical path, exact base64
|
||||
bytes, and SHA-256 digest before Deepen or execution. The writer accepts a repo-relative
|
||||
`docs/plans/<date>-gitnexus-plan-<slug>.md` destination, rejects symlink
|
||||
traversal and accidental replacement, and publishes the verified UTF-8
|
||||
document through a descriptor-anchored atomic no-replace move. Deepen first
|
||||
requires the exact canonical path and digest from one read receipt, preserves
|
||||
the prior plan in a verified Git-admin backup, and also publishes without replacement. A safe read/write
|
||||
failure blocks the operation; there is no
|
||||
external-output or read-only-checkout fallback.
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
Codex discovers SKILL.md skills from `~/.agents/skills/` (the same path the
|
||||
other `gitnexus-*` skills install to). To make this skill auto-discoverable in
|
||||
every Codex session:
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-plan ~/.agents/skills/gitnexus-plan
|
||||
```
|
||||
|
||||
Codex prompts are user-level only (not repo-shareable). Optionally, for an
|
||||
explicit `/gitnexus-plan` slash command, also create
|
||||
`~/.codex/prompts/gitnexus-plan.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Implementation-ready engineering plan via GitNexus + PDG + source verification
|
||||
argument-hint: <task description>
|
||||
---
|
||||
|
||||
Use the gitnexus-plan skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-plan/SKILL.md` (if this repo has its own copy at
|
||||
`.claude/skills/gitnexus-plan/SKILL.md`, prefer that one) and follow its phases in
|
||||
order, loading its `references/` files at the phases that call for them. Planning
|
||||
only — never edit code; the only repo file you write is the plan document.
|
||||
```
|
||||
|
||||
## Architecture note: how GitNexus and the agent interact
|
||||
|
||||
Three layers, strictly ordered:
|
||||
|
||||
1. **GitNexus navigates** (`query` → `context` → `impact`/`trace` →
|
||||
`cypher` last-resort). The graph answers _where to look_ and _what is
|
||||
connected_: execution flows, callers/callees, blast radius, related tests.
|
||||
Every call must answer a named planning question.
|
||||
2. **PDG constrains** (`pdg_query` controls/flows, `impact {mode:"pdg",
|
||||
direction, line}` statement slices, `explain` for taint). The
|
||||
statement-level layers
|
||||
answer _what gates and feeds the behavior_ inside the few functions the
|
||||
change centers on. Results are filtered into a bounded slice
|
||||
(`references/pdg-slice.md`), never dumped.
|
||||
3. **The agent verifies** (targeted line-range reads). Current source is
|
||||
authoritative; graph results are navigation hints until verified. On
|
||||
disagreement: trust source, record the discrepancy, recommend re-indexing.
|
||||
|
||||
Token efficiency comes from the **context ledger**
|
||||
(`references/context-ledger.md`): every query and read is recorded with the
|
||||
question it answered, and nothing is re-fetched unless the source changed, a
|
||||
contradiction surfaced, or one of the ledger's defined escalations applies
|
||||
(summary→detail drill-down, ambiguity narrowing, a changed parameter answering
|
||||
a new question). The ledger also enforces symbol budgets (5 primary /
|
||||
20 related by default), pins dirty working-tree evidence as well as HEAD, and
|
||||
uses progressive disclosure to keep the big schemas out of context until the
|
||||
phase that needs them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `SKILL.md` | The skill: phases 0–5, hard rules, config, fallback |
|
||||
| `references/pdg-slice.md` | PDG slice construction: tools, inclusion criteria, schema, security/performance modes |
|
||||
| `references/context-ledger.md` | Ledger schema + anti-reread rules |
|
||||
| `references/plan-template.md` | The 13-section plan document template |
|
||||
| `references/context-pack.md` | Implementation context pack schema + stability contract |
|
||||
| `references/evidence-provenance.md` | Versioned byte contract for dirty-tree evidence |
|
||||
| `scripts/evidence-provenance.mjs` | Snapshot serializer plus descriptor-anchored plan reader/writer |
|
||||
|
||||
## Requirements and graceful degradation
|
||||
|
||||
- Requires a GitNexus index; statement-level sections additionally require the
|
||||
`--pdg` layers.
|
||||
- Freshness is a gate, priced by category: full-plan categories (refactor,
|
||||
security, performance, concurrency, architecture) default to
|
||||
`freshness: strict` — a stale index (or missing PDG layer) is refreshed once with
|
||||
`analyze --index-only [--pdg]` — run via `node .gitnexus/run.cjs` when the
|
||||
project has one, else the installed `gitnexus` CLI
|
||||
(`npm install -g gitnexus`), else `npx gitnexus` — before the graph is relied
|
||||
on, but only when that runner's provenance is known-current.
|
||||
Compact-plan categories default to `accept` (source-weighted, refresh only
|
||||
if a graph claim becomes load-bearing). `--index-only` touches only the
|
||||
`.gitnexus` store, never repo files. Stale analyzer provenance is a
|
||||
disclosed **source-weighted limitation**: planning does not rebuild analyzer
|
||||
output, and it does not use that graph for load-bearing claims.
|
||||
- PDG layer still unavailable after that → the plan says so and skips
|
||||
statement-level claims (never reconstructs fake edges).
|
||||
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
|
||||
labelled **source-derived**, with a recommendation to index.
|
||||
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
|
||||
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
|
||||
writable target repository, and a shared filesystem for the plan and
|
||||
Git-admin vault. The writer fails closed when those guarantees are
|
||||
unavailable; it never redirects the plan elsewhere.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `pdg_query` is intra-procedural; cross-function flow comes from `explain`
|
||||
(taint) or `impact {mode:"pdg"}` inter-procedural reach.
|
||||
- The skill is planning-only by contract: the only repository file it writes
|
||||
is the plan document, and the only other state it may touch is the
|
||||
`.gitnexus` index store for a freshness refresh. It must not build
|
||||
analyzer `dist/` output or mutate source, tests, configuration, benchmark,
|
||||
or evaluation files. Instruction feedback is chat-only.
|
||||
348
gitnexus-claude-plugin/skills/gitnexus-plan/SKILL.md
Normal file
348
gitnexus-claude-plugin/skills/gitnexus-plan/SKILL.md
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
---
|
||||
name: gitnexus-plan
|
||||
description: 'Use when you need a deep, implementation-ready engineering plan for a code change — built from GitNexus graph intelligence, statement-level PDG analysis, and targeted source verification, compact enough that an implementation agent can start without re-investigating. Also strengthens existing plans via Deepen mode. Examples: "/gitnexus-plan Add retry support to the ingestion pipeline", "/gitnexus-plan deepen docs/plans/<plan>.md", "plan this change using the knowledge graph".'
|
||||
---
|
||||
|
||||
# gitnexus-plan — implementation-ready engineering plans
|
||||
|
||||
Produce an implementation-ready plan for an engineering task. GitNexus is the
|
||||
navigation layer (where to look), statement-level PDG is the constraint layer
|
||||
(what gates and feeds the behavior), and your native targeted source reads are
|
||||
the verification layer (what is actually true right now). The output is a plan
|
||||
document plus a compact, machine-readable **implementation context pack**
|
||||
that a follow-up implementation agent (`gitnexus-work`, or any executor) can
|
||||
consume without repeating the investigation.
|
||||
|
||||
```
|
||||
/gitnexus-plan <task description>
|
||||
/gitnexus-plan impact_depth:3 depth:deep <task description> # knob overrides, see Configuration
|
||||
```
|
||||
|
||||
**This skill plans. It never implements.** Do not modify production code,
|
||||
tests, or configuration while running it. The only repository file it writes
|
||||
is the plan document (a working ledger kept outside the repo is fine). The
|
||||
only other permitted state change is an index refresh via
|
||||
`analyze --index-only`, which writes only the `.gitnexus` index store. It
|
||||
must not build analyzer `dist/` output and must not mutate source, tests,
|
||||
configuration, or evaluation data. Stale analyzer provenance is disclosed as
|
||||
a source-weighted limitation, never repaired by a planning run.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Ledger first.** Before every GitNexus call and every repo file read, check
|
||||
the context ledger. Never repeat a query or reread an unchanged range that
|
||||
already answered the same question (allowed repeats are defined in
|
||||
`references/context-ledger.md`; this skill's own reference files are exempt
|
||||
from ledger bookkeeping).
|
||||
- **Every graph query answers a named planning question.** Record the question
|
||||
and the conclusion in the ledger. No exploratory dredging.
|
||||
- **Source beats graph.** The graph navigates; current source is authoritative.
|
||||
Verify before asserting (see Phase 4). Comments are the weakest evidence —
|
||||
never stronger than executable code.
|
||||
- **No fabrication.** Never invent symbols, filenames, test names, tool
|
||||
results, or PDG edges. Unknowns go to _Assumptions and Open Questions_.
|
||||
- **No scope creep.** Adjacent refactors the task didn't ask for go to plan
|
||||
§12 as explicitly-deferred follow-ups, not into Proposed Changes.
|
||||
- **Pin working-tree evidence, not only HEAD.** Every plan form carries the
|
||||
versioned global dirty digest and sorted cited-path manifest defined in
|
||||
`references/context-ledger.md`. Generate it only with the portable helper
|
||||
and byte contract in `scripts/evidence-provenance.mjs` and
|
||||
`references/evidence-provenance.md`; never reimplement the digest.
|
||||
- **Write the plan only through the helper.** The generated-plan path is a
|
||||
normalized repo-relative
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-slug>.md` path. Compose the
|
||||
complete UTF-8 document in memory or in a scratchpad outside the target
|
||||
repo, then pass it on stdin to the helper's `write-plan` command. Never
|
||||
write the destination directly or fall back to an external output path when
|
||||
the safe writer fails.
|
||||
- **Read an existing plan only through the helper.** Deepen must invoke
|
||||
`scripts/evidence-provenance.mjs read-plan`, parse the exact decoded
|
||||
`plan_bytes_base64` from its descriptor-anchored receipt, and retain that
|
||||
receipt's canonical `generated_plan_path` and `plan_digest` as one binding.
|
||||
Never parse a direct lexical-path read or apply one plan's digest to another
|
||||
path.
|
||||
- **Stop when you have enough.** Sufficient evidence ends exploration; plans
|
||||
do not improve monotonically with tokens spent.
|
||||
|
||||
## Phase 0 — Parse and classify
|
||||
|
||||
Read `references/context-ledger.md` and open the ledger with the task:
|
||||
original request, interpreted goal, acceptance criteria. Classify the task:
|
||||
|
||||
| Category | Posture (depth · plan form · tool-call budget · freshness) |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| Bug fix (local) | Narrow, 1–2 primary symbols, `impact_depth` 1 · compact · ~15 · accept |
|
||||
| Feature | Default knobs · compact · ~30 · accept |
|
||||
| Refactor / shared API change | Impact mandatory, `impact_depth` 3 · full · ~45 · strict |
|
||||
| Performance | Default + performance PDG mode (`references/pdg-slice.md`) · full · ~45 · strict |
|
||||
| Security | Default + security PDG mode + `explain` taint findings · full · ~45 · strict |
|
||||
| Dependency upgrade / migration | Impact + compatibility focus; PDG rarely needed · compact · ~20 · accept |
|
||||
| Concurrency / transactional | Control-flow + state-mutation PDG focus · full · ~45 · strict |
|
||||
| Test improvement / docs | Narrowest: usually no impact or PDG pass · compact · ~10 · accept |
|
||||
| Architecture change / spike | Widest: clusters + processes first · full · no cap · strict |
|
||||
|
||||
The category posture overrides the Configuration baseline; explicit `key:value`
|
||||
invocation knobs override both. A task matching several rows combines them:
|
||||
take the widest depth, union the focus areas.
|
||||
|
||||
**Seeded evidence.** When a completed investigation already supplies
|
||||
verified findings — a finished review, a triage document with `path:line`
|
||||
anchors and named failing scenarios — open the ledger FROM it: cite the
|
||||
source document as the opening ledger entries and plan directly against
|
||||
them instead of re-running the graph ladder over ground it already covers.
|
||||
Re-deriving what the evidence proves is budget spent against the
|
||||
turn-economy rule. Phase 4 still source-verifies whatever Proposed Changes
|
||||
will cite, at the pinned commit — seeding replaces exploration, never
|
||||
verification.
|
||||
|
||||
**Depth is the user's decision, asked once, up front.** In an interactive
|
||||
session, when the invocation carries no explicit depth signal (no `depth:`,
|
||||
`form:`, or `freshness:` knob, and not Deepen mode), ask one blocking
|
||||
question before Phase 1 — how deep should this plan go?
|
||||
|
||||
1. **Quick** — `depth:narrow form:compact freshness:accept`. Fastest useful
|
||||
plan: 1–2 primary symbols, minimal graph work, core sections only.
|
||||
2. **Standard** — the category posture above, unchanged. Recommend this
|
||||
unless the classification argues otherwise.
|
||||
3. **Deep** — `depth:deep form:full freshness:strict`. All 13 sections,
|
||||
`impact_depth` 3, clusters/processes read, PDG slices for the central
|
||||
functions.
|
||||
|
||||
The answer sets the knobs exactly as if they had been typed in the
|
||||
invocation; explicit knobs win and skip the question. Headless runs never
|
||||
ask — the category posture applies unchanged. Asking up front replaces
|
||||
offering to deepen a finished plan afterwards: Deepen mode (below) remains
|
||||
the mechanism for strengthening an existing plan document — a later session,
|
||||
review findings, an executor route-back — not a default follow-up question.
|
||||
|
||||
**Turn economy is a deliverable.** The plan is judged on decision quality per
|
||||
token, not thoroughness theater (measured: a 63-turn plan for a two-line
|
||||
change — the GitNexus repo's `eval/workflow_bench/`). Stay within the category's tool-call
|
||||
budget; when the budget runs out with questions still open, record them in
|
||||
§12 instead of digging further — the executor re-verifies cheaply anyway.
|
||||
|
||||
## Phase 1 — Anchor and freshness
|
||||
|
||||
1. Resolve the target repo: `list_repos` if in doubt, else the indexed repo
|
||||
covering the working directory. Pass `repo` explicitly on every call when
|
||||
more than one repo is indexed.
|
||||
2. Record the repo's current HEAD commit in the ledger — every line-number
|
||||
citation in the plan is pinned to it.
|
||||
3. **Resolve and record the analyzer runner** (used by every `analyze`
|
||||
command in this skill): `node .gitnexus/run.cjs analyze …` when the
|
||||
project has a runner (a previous analyze dropped it next to the index),
|
||||
else `gitnexus analyze …` (installed CLI — `npm install -g gitnexus`),
|
||||
else `npx gitnexus analyze …`. Record its path/version and any available
|
||||
source/build identity; do not manufacture provenance from timestamps.
|
||||
4. Read `gitnexus://repo/{name}/context` — codebase overview + staleness check.
|
||||
**Freshness gate.** Plans built on a stale graph make stale blast-radius
|
||||
claims — but a re-index is the largest fixed cost a planning session
|
||||
carries, so the gate is category-priced:
|
||||
- Compact-plan categories default to `freshness: accept`: plan on the
|
||||
current graph with source verification weighted higher — their plans
|
||||
cite little graph evidence. Escalate to a refresh mid-plan only when a
|
||||
graph claim becomes load-bearing (e.g. Proposed Changes rest on a d=1
|
||||
dependent list), and only then.
|
||||
- Full-plan categories default to `freshness: strict`, and under it:
|
||||
- **Analyzer provenance check — before any refresh.** Compare the resolved
|
||||
runner identity with the index metadata and, in an analyzer-source
|
||||
checkout, with current analyzer source. If identity is stale or unknown,
|
||||
do not build output and do not make that graph load-bearing. Record a
|
||||
**stale analyzer provenance — source-weighted limitation** in
|
||||
`index_refresh`, the plan header, and §12; rely on targeted source reads
|
||||
or hand execution to `gitnexus-work`, which owns the build-current gate.
|
||||
- Stale index → run `analyze --index-only` via the resolved runner
|
||||
(append `--pdg` when the task category will reach Phase 3) and re-read
|
||||
the context resource **only when runner provenance is known-current**.
|
||||
Refresh budget, stated once here: at most one `--index-only` refresh in
|
||||
Phase 1 **plus** at most one later `--pdg` upgrade in Phase 3 (only when
|
||||
Phase 1's refresh lacked `--pdg`) per planning session — a Deepen run is
|
||||
its own session. Record each command, runner identity, and outcome in the
|
||||
ledger's `index_refresh`.
|
||||
- Refresh failed or impractical (no write access to the index, prohibitive
|
||||
repo size), or `freshness: accept` was passed → proceed on the stale
|
||||
graph, weight source verification higher, and state the staleness and
|
||||
the skipped refresh in the plan header and Assumptions.
|
||||
- Resources unreadable but tools working → proceed on tools alone, treat
|
||||
freshness as unknown (weight source higher), and note it in the plan.
|
||||
- GitNexus unavailable entirely → switch to **Fallback mode** (below).
|
||||
5. For architecture-scale tasks only, also read
|
||||
`gitnexus://repo/{name}/clusters` and `.../processes`.
|
||||
|
||||
## Phase 2 — Graph navigation ladder
|
||||
|
||||
Use the narrowest operation that answers the current ledger question, in this
|
||||
order. Budgets: at most `max_primary_symbols` (5) primary symbols and
|
||||
`max_related_symbols` (20) related symbols active in the ledger.
|
||||
|
||||
1. `query {search_query, task_context}` — locate concepts, execution flows,
|
||||
modules, and related tests for the task.
|
||||
2. `context {name}` — 360° view of each candidate primary symbol: callers,
|
||||
callees, categorized refs, processes. Promote to primary or discard. An
|
||||
`ambiguous` result (ranked candidates) is answered by one retry narrowed
|
||||
with `kind` / `file_path` / uid — that retry is an allowed repeat.
|
||||
3. `impact {target, direction}` — upstream/downstream blast radius for shared
|
||||
or high-connectivity symbols (`maxDepth` = `impact_depth`; `summaryOnly:
|
||||
true` first for hub symbols, then drill in — an allowed repeat). Record the
|
||||
d=1 items — the **direct (depth-1) dependents** — the plan must account
|
||||
for every one of them.
|
||||
4. `trace {from, to}` — when the task hinges on _how A reaches B_, one call
|
||||
instead of chained context hops.
|
||||
5. Statement-level PDG — Phase 3, for the functions the change centers on.
|
||||
6. `cypher` — last resort, only for a precise graph question the tools above
|
||||
cannot express. Read `gitnexus://repo/{name}/schema` first; anchor and
|
||||
LIMIT every query.
|
||||
7. `detect_changes {scope}` — only when planning against existing uncommitted
|
||||
or branch work.
|
||||
|
||||
Do not run every tool by default. A local test fix may finish the ladder at
|
||||
step 2.
|
||||
|
||||
## Phase 3 — Statement-level PDG slice
|
||||
|
||||
For the 1–3 functions most central to the change, build a bounded **PDG
|
||||
context slice**. Read `references/pdg-slice.md` and follow it — it owns the
|
||||
tool calls, inclusion criteria, depth bounds, slice schema, the security and
|
||||
performance modes, and the no-PDG-layer fallback.
|
||||
|
||||
## Phase 4 — Targeted source verification
|
||||
|
||||
GitNexus said where to look; now confirm what is there. Using ordinary file
|
||||
reads (exact line ranges, not whole files unless genuinely required):
|
||||
|
||||
- Read every source range the plan will cite: signatures, branch conditions,
|
||||
state mutations, error paths, nearby comments that change behavior. Compact
|
||||
plans cite less — verify what they cite, don't expand the citation set to
|
||||
have more to verify.
|
||||
- Read the tests GitNexus associated with the primary symbols; never claim a
|
||||
test exists without having located it.
|
||||
- Verify the build/test commands the plan will name actually exist
|
||||
(package.json scripts / CI workflows), and prefer the script form that
|
||||
carries its prerequisites (pre-hooks) over invoking underlying binaries
|
||||
directly.
|
||||
- Check repo conventions that constrain the change (AGENTS.md, GUARDRAILS.md,
|
||||
lint/build config) — only the parts the change touches.
|
||||
- Mark each ledger symbol `source_verified: true` as you go. **A symbol that
|
||||
is named in Proposed Changes must be source-verified.**
|
||||
- On graph/source disagreement: trust source, record the discrepancy in the
|
||||
ledger and the plan, recommend re-indexing. Never present stale graph data
|
||||
as fact.
|
||||
- Immediately before composition, recompute the versioned
|
||||
`evidence_provenance` snapshot by invoking
|
||||
`scripts/evidence-provenance.mjs` exactly as specified in
|
||||
`references/evidence-provenance.md`: the
|
||||
canonical global dirty digest over all dirty paths and the sorted manifest
|
||||
of every cited path, including object kind and
|
||||
HEAD/index/worktree/untracked layer digests. Re-read any citation that
|
||||
changed during planning. Exclude only the generated plan path.
|
||||
|
||||
Evidence hierarchy, strongest first: current source and config → current tests
|
||||
and executable behavior → compiler/build/lint output → GitNexus graph and PDG
|
||||
→ documentation and comments.
|
||||
|
||||
## Phase 5 — Compose the plan
|
||||
|
||||
1. Read `references/plan-template.md` and fill the category's form — compact
|
||||
(core sections, ≤80 lines excluding the pack) or full (all 13 sections) —
|
||||
from the ledger, tagging claims with the template's four classes —
|
||||
`[verified]`, `[graph]`, `[inferred]`, `[assumed]` — and routing open
|
||||
questions to §12.
|
||||
2. Build the implementation context pack per `references/context-pack.md`
|
||||
(this is section 11 of the plan), including mandatory
|
||||
`evidence_provenance` in compact and full forms.
|
||||
3. Set `generated_plan_path` to
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<slug>.md` under the root of the repo
|
||||
being planned (the Phase 1 target repo, not necessarily the cwd); use a
|
||||
3–5-word kebab-case slug and repo-relative paths inside the document.
|
||||
Compose the complete document without creating that destination, then
|
||||
pipe its exact UTF-8 bytes to `scripts/evidence-provenance.mjs write-plan`
|
||||
as specified in `references/evidence-provenance.md`. The helper safely
|
||||
creates missing parent directories. Initial planning must not pass
|
||||
`--replace`. A safe-write failure blocks plan publication: report it and
|
||||
do not write directly, choose an external destination, or weaken the
|
||||
repo-relative provenance contract. The snapshot and writer commands apply
|
||||
the same strict generated-plan filename/date validator; do not substitute a
|
||||
source, `.git`, or arbitrary `docs/plans/` path in either invocation.
|
||||
4. Present in chat: objective, proposed-changes summary, implementation
|
||||
sequence, top risks, open questions, and the plan file path. Do not paste
|
||||
the whole document into chat.
|
||||
|
||||
## Deepen mode
|
||||
|
||||
`/gitnexus-plan deepen <plan-path>` strengthens an existing plan in place
|
||||
instead of creating a new one:
|
||||
|
||||
1. Resolve the target repository and normalized repo-relative plan candidate,
|
||||
then load it with `scripts/evidence-provenance.mjs read-plan --repo <root>
|
||||
--generated-plan <candidate>` exactly as specified in
|
||||
`references/evidence-provenance.md`. Reject a missing, external, escaping,
|
||||
symlinked, or differently scoped path. Decode and parse only the receipt's
|
||||
exact `plan_bytes_base64`; retain its canonical `generated_plan_path` and
|
||||
`plan_digest` unchanged for the entire Deepen session.
|
||||
2. Re-run Phase 1 in full — analyzer provenance check and freshness gate (a
|
||||
Deepen run is its own session, with its own refresh budget).
|
||||
3. **Re-anchor before re-pinning.** Recompute the plan's global dirty digest
|
||||
and cited-path manifest as well as comparing its old HEAD pin with current
|
||||
HEAD. Changed, renamed, deleted, mixed, or newly absent cited paths get
|
||||
their ranges re-read — or the claim downgraded — _before_ the pin and
|
||||
provenance snapshot move. Moving only the commit pin silently launders
|
||||
dirty or stale claims as verified.
|
||||
4. Escalate to `depth: deep` (impact_depth 3, clusters/processes read)
|
||||
unless the invocation overrides knobs explicitly.
|
||||
5. Seed the ledger from the plan's §11 pack, then re-verify: every
|
||||
`[graph]`/`[inferred]` claim gets a targeted pass toward `[verified]`;
|
||||
every `[assumed]` claim is resolved or kept with its reason; direct
|
||||
(d=1) dependent accounting is re-checked against the refreshed graph;
|
||||
PDG slices are built or expanded for the central functions when the
|
||||
layer is present.
|
||||
6. **Reconcile execution state.** If `gitnexus-work` already landed commits
|
||||
for this plan (a mid-execution route-back), mark the §7 steps present at
|
||||
HEAD as completed and re-sequence the remainder — the rewritten plan must
|
||||
be executable from the top without redoing landed steps.
|
||||
7. Strengthen whatever the deeper pass showed thin — test scenarios, risks,
|
||||
Definition of Done — and carry claim-tag upgrades through the prose.
|
||||
8. Rewrite the **same canonical file** through
|
||||
`scripts/evidence-provenance.mjs write-plan --replace
|
||||
--expected-plan-path <retained-read-plan-path>
|
||||
--expected-plan-digest <retained-read-plan-digest>`: same 13 sections,
|
||||
context pack kept in sync, evidence header updated. `--replace` is reserved
|
||||
for Deepen mode, and both expected values must come from the same read-plan
|
||||
receipt; any digest/path mismatch blocks publication. Retain the successful receipt's
|
||||
`prior_plan_backup_git_path`; it names the verified Git-admin backup of the
|
||||
displaced plan. Summarize the delta in chat: claims upgraded, claims that
|
||||
failed re-verification, sections changed, and that backup path.
|
||||
|
||||
## Configuration
|
||||
|
||||
Baseline defaults — the Phase 0 category posture overrides them, and inline
|
||||
`key:value` tokens before the task text override both (the repo has no
|
||||
skill-config file mechanism; invocation args are the mechanism):
|
||||
|
||||
| Knob | Default | Meaning |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `depth` | by category | `narrow` = `impact_depth` 1, PDG only if one function is clearly central; `default` = this table; `deep` = `impact_depth` 3 + clusters/processes read |
|
||||
| `form` | by category | `compact` (core sections + mini-pack, ≤80 lines excl. pack — see `references/plan-template.md`) or `full` (all 13 sections) |
|
||||
| `impact_depth` | 2 | `maxDepth` for `impact` |
|
||||
| `pdg_data_depth` | 2 | Data-dependence hops in the PDG slice |
|
||||
| `pdg_control_depth` | 2 | Control-dependence hops in the PDG slice |
|
||||
| `max_primary_symbols` | 5 | Ledger budget (active symbols; discards don't count) |
|
||||
| `max_related_symbols` | 20 | Ledger budget (active symbols; discards don't count) |
|
||||
| `max_snippet_lines` | 30 | Longest source excerpt quoted in the plan |
|
||||
| `freshness` | by category | `strict` (full-plan categories) = refresh a stale index (and a missing PDG layer) with `analyze --index-only [--pdg]` before relying on the graph; `accept` (compact categories) = plan on the current graph, source-weighted and labelled, refreshing only if a graph claim becomes load-bearing |
|
||||
|
||||
## Fallback mode (GitNexus or PDG unavailable)
|
||||
|
||||
1. Say so, first thing, in chat and in the plan.
|
||||
2. Use targeted repo exploration (grep/glob/reads) to approximate callers,
|
||||
dependencies, execution flow, state changes, and related tests.
|
||||
3. Label every such finding **source-derived** in the plan — never present it
|
||||
as graph-derived, and never fabricate statement-level edges.
|
||||
4. Recommend `analyze --index-only` (add `--pdg` for the PDG layers) via
|
||||
the resolved runner — `node .gitnexus/run.cjs`, installed `gitnexus`, or
|
||||
`npx gitnexus` — when it would materially raise confidence.
|
||||
|
||||
## Skill feedback
|
||||
|
||||
If this run exposed friction in the instructions, include concise feedback in
|
||||
the final response. Feedback is chat-only: do not append evaluation learnings,
|
||||
edit benchmark data, or modify this skill during a live planning task.
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
# Context ledger
|
||||
|
||||
The ledger is gitnexus-plan's working memory. It exists to make repeated
|
||||
investigation impossible-by-discipline: **before every GitNexus call and
|
||||
every repo file read, check it.** Keep it as structured notes in your working
|
||||
context (or a scratchpad file _outside the repo_ for very long sessions); it
|
||||
is never published verbatim — the plan and context pack are distilled from
|
||||
it. This skill's own reference files are exempt from ledger bookkeeping.
|
||||
|
||||
## Schema
|
||||
|
||||
```yaml
|
||||
context_ledger:
|
||||
task:
|
||||
original_request: ''
|
||||
interpreted_goal: ''
|
||||
category: '' # Phase 0 classification
|
||||
acceptance_criteria: []
|
||||
|
||||
verified_at_commit:
|
||||
'' # target repo HEAD, recorded once in Phase 1;
|
||||
# every line citation in the plan pins to it
|
||||
|
||||
evidence_provenance: {} # required immutable working snapshot; populate
|
||||
# exactly from context-pack.md's normative schema
|
||||
|
||||
index_refresh:
|
||||
'' # analyze --index-only runs: command + outcome
|
||||
# (or "skipped: <reason>"). Budget is
|
||||
# owned by SKILL.md Phase 1: one refresh plus
|
||||
# at most one Phase 3 --pdg upgrade per session
|
||||
|
||||
established_facts: [] # each with its evidence source
|
||||
|
||||
symbols: # budgets count active (primary/related) only;
|
||||
# discards are free — but on budget overflow,
|
||||
# discard something before promoting
|
||||
- name: ''
|
||||
kind: ''
|
||||
file: ''
|
||||
relevance: 'primary | related | discarded'
|
||||
source_verified: false # flipped in Phase 4; required before naming in Proposed Changes
|
||||
|
||||
files_read:
|
||||
- file: ''
|
||||
ranges: [] # e.g. ["120-188"]
|
||||
purpose: ''
|
||||
|
||||
gitnexus_queries:
|
||||
- query: '' # tool + args
|
||||
purpose: '' # the planning question it answers
|
||||
conclusion: '' # one line; details stay in working memory
|
||||
key_output: '' # one-line raw quote when the plan leans on this result
|
||||
|
||||
pdg_slices:
|
||||
- symbol: ''
|
||||
purpose: ''
|
||||
conclusion: ''
|
||||
|
||||
unresolved_questions: []
|
||||
assumptions: [] # explicit, carried into plan §12
|
||||
decisions: [] # with rationale, carried into plan §6/§7
|
||||
```
|
||||
|
||||
## Evidence provenance
|
||||
|
||||
`context-pack.md` is the sole normative emitted field schema, and
|
||||
`evidence-provenance.md` plus `../scripts/evidence-provenance.mjs` are the
|
||||
normative byte contract and implementation. Keep the helper's exact schema-2
|
||||
output in the ledger; do not redefine, abbreviate, or independently reproduce
|
||||
its canonicalization here.
|
||||
|
||||
Build `evidence_provenance` immediately before composing the plan, after all
|
||||
source verification, by invoking the helper exactly as described in
|
||||
`evidence-provenance.md`. It is a versioned, canonical snapshot of both the
|
||||
whole working tree and every path that supports a plan citation:
|
||||
|
||||
- `global_dirty_digest` is SHA-256 over the helper's versioned, NUL-framed
|
||||
records for
|
||||
**every dirty repo-relative path**, not only cited paths. Each record includes
|
||||
path, state, object kind, every available layer digest, and both endpoints of
|
||||
a rename. Overlapping porcelain facts for one path are merged; for example,
|
||||
a staged deletion plus a recreated untracked file is `mixed` and retains
|
||||
both its Git-backed and untracked layers. States are `staged`, `unstaged`,
|
||||
`untracked`, `deleted`, `renamed`, or `mixed`. Exclude only this run's
|
||||
normalized repo-relative generated plan path so writing the plan cannot
|
||||
invalidate its own evidence; do not exclude the rest of `docs/plans/`.
|
||||
- `cited_path_manifest` is sorted by normalized repo-relative path and
|
||||
includes every path cited by a `[verified]` claim or named as evidence in
|
||||
the context pack. Record clean paths too. A path entry has this shape:
|
||||
|
||||
```yaml
|
||||
- path: 'src/example.ts'
|
||||
object_kind: # each layer: regular | symlink | gitlink | directory | absent
|
||||
head: 'regular'
|
||||
index: 'regular'
|
||||
worktree: 'regular'
|
||||
untracked: 'absent'
|
||||
state: 'clean | staged | unstaged | untracked | deleted | renamed | mixed | absent'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:<hex> | absent'
|
||||
index_digest: 'sha256:<hex> | absent'
|
||||
worktree_digest: 'sha256:<hex> | absent'
|
||||
untracked_digest: 'sha256:<hex> | absent'
|
||||
```
|
||||
|
||||
Use Git object contents for HEAD and index digests and filesystem bytes for
|
||||
worktree/untracked digests; never confuse an absent layer with an empty file.
|
||||
Hash symlink targets as link text and gitlinks as object IDs. If a cited path
|
||||
cannot be classified or read, the plan must mark the evidence unavailable
|
||||
instead of emitting a digest it did not prove.
|
||||
|
||||
## Reread rules
|
||||
|
||||
Do **not** repeat a query or reread a source range unless one of:
|
||||
|
||||
- the previous result was incomplete for the question at hand;
|
||||
- the source is known to have changed (an edit happened);
|
||||
- validation exposed a contradiction between graph and source.
|
||||
|
||||
**Allowed repeats** (deliberate escalations, not violations):
|
||||
|
||||
- `summaryOnly: true` → full drill-down on the same `impact` target;
|
||||
- an `ambiguous` result retried once with `kind` / `file_path` / uid narrowing;
|
||||
- the same tool re-run with a changed parameter that answers a _new_ planning
|
||||
question (e.g. `pdg_query` `controls` then `flows` on one function).
|
||||
|
||||
When a repeat is justified, note in the ledger _why_ the earlier entry was
|
||||
insufficient. A ledger full of near-duplicate queries is the failure signal —
|
||||
stop and plan with what is established.
|
||||
|
||||
## Discarding
|
||||
|
||||
Symbols and queries that turned out irrelevant stay in the ledger marked
|
||||
`discarded` with a one-line reason. That is what prevents re-walking dead
|
||||
ends later in the session.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Implementation context pack
|
||||
|
||||
Section 11 of the plan. The stable, machine-readable contract a follow-up
|
||||
implementation agent (`gitnexus-work`, or any executor) consumes to start
|
||||
work **without repeating the investigation**. Distilled from the ledger;
|
||||
every entry traceable to verified evidence.
|
||||
|
||||
**Compact plans emit the mini-pack** — only: `task_summary`,
|
||||
`evidence_provenance`, `files_to_modify`, `tests`,
|
||||
`verification_commands`, `pdg_constraints` (only when a slice actually
|
||||
ran), `assumptions`, `open_questions`, `avoid`. Full plans emit every
|
||||
field. Field semantics are identical in both; `evidence_provenance` is
|
||||
mandatory in both forms. `gitnexus-work` treats absent optional fields as
|
||||
empty, not as errors.
|
||||
|
||||
## Schema
|
||||
|
||||
This is the sole normative emitted `evidence_provenance` field schema. The
|
||||
portable byte contract and executable serializer live in
|
||||
`evidence-provenance.md` and `../scripts/evidence-provenance.mjs`; sibling
|
||||
documents must reference them rather than reimplementing canonical bytes.
|
||||
|
||||
```yaml
|
||||
implementation_context:
|
||||
task_summary: ''
|
||||
acceptance_criteria: []
|
||||
|
||||
evidence_provenance:
|
||||
schema_version: 2
|
||||
head_commit: '' # full commit SHA that source citations pin to
|
||||
# normalized repo-relative docs/plans/<date>-gitnexus-plan-<3-5-word-slug>.md;
|
||||
# safely written; exact path excluded from global_dirty_digest
|
||||
generated_plan_path: ''
|
||||
global_dirty_digest:
|
||||
algorithm: 'sha256'
|
||||
canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records'
|
||||
value: '' # digest only; do not embed the whole dirty-path manifest
|
||||
cited_path_manifest: # sorted by normalized repo-relative path
|
||||
- path: ''
|
||||
object_kind: # per layer: regular | symlink | gitlink | directory | absent
|
||||
head: ''
|
||||
index: ''
|
||||
worktree: ''
|
||||
untracked: ''
|
||||
state: 'clean | staged | unstaged | untracked | deleted | renamed | mixed | absent'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:<hex> | absent'
|
||||
index_digest: 'sha256:<hex> | absent'
|
||||
worktree_digest: 'sha256:<hex> | absent'
|
||||
untracked_digest: 'sha256:<hex> | absent'
|
||||
|
||||
primary_symbols:
|
||||
- symbol: ''
|
||||
file: ''
|
||||
lines: ''
|
||||
role: ''
|
||||
|
||||
related_symbols:
|
||||
- symbol: ''
|
||||
relationship: '' # CALLS / IMPORTS / EXTENDS / test-of / ...
|
||||
relevance: ''
|
||||
|
||||
execution_path: [] # ordered prose steps, from §2/§5
|
||||
|
||||
pdg_constraints: # from the PDG slice; empty + note if no layer
|
||||
- description: ''
|
||||
affected_statements: [] # "<file>:<line>" refs
|
||||
implementation_consequence: ''
|
||||
|
||||
architectural_patterns:
|
||||
- pattern: ''
|
||||
example_location: '' # repo-relative file (+ symbol)
|
||||
usage_guidance: ''
|
||||
|
||||
files_to_modify:
|
||||
- file: ''
|
||||
symbols: []
|
||||
intended_change: ''
|
||||
|
||||
tests:
|
||||
- file: '' # existing file to update, or new path to create
|
||||
scenarios: [] # input → action → expected outcome
|
||||
|
||||
verification_commands: [] # real commands verified to exist AND be runnable —
|
||||
# prefer npm/CI scripts that carry their pre-hooks
|
||||
|
||||
risks: []
|
||||
assumptions: [] # faithful condensation of plan §12 assumptions;
|
||||
# each entry names WHAT to check and HOW —
|
||||
# gitnexus-work re-verifies them before executing
|
||||
open_questions: [] # faithful condensation of plan §12 open questions
|
||||
|
||||
avoid:
|
||||
- 'Do not repeat full repository discovery'
|
||||
- 'Do not replace established patterns without evidence'
|
||||
# + task-specific prohibitions discovered during planning
|
||||
```
|
||||
|
||||
## Must not contain
|
||||
|
||||
- full files;
|
||||
- the repository-wide raw dirty-path manifest (store only its canonical
|
||||
`global_dirty_digest`; detailed entries are bounded to cited paths);
|
||||
- large raw GitNexus responses;
|
||||
- unfiltered PDG dumps;
|
||||
- duplicate code excerpts (cite `file:line`, don't re-quote);
|
||||
- speculative implementation details presented as facts.
|
||||
|
||||
## Stability contract
|
||||
|
||||
Field names above are the interface consumed by `gitnexus-work` (fields it
|
||||
does not act on directly travel as executor context). Add fields
|
||||
freely; do not rename or repurpose existing ones. `assumptions` and `avoid`
|
||||
are load-bearing: an executor treats `assumptions` as things to re-verify
|
||||
cheaply before relying on them, and `avoid` as hard constraints.
|
||||
`evidence_provenance` is also load-bearing: its version, global digest, and
|
||||
sorted cited-path manifest let the executor distinguish commit drift from
|
||||
staged, unstaged, untracked, deleted, renamed, mixed, or absent working-tree
|
||||
evidence. Legacy packs that lack it or use schema 1 require a conservative
|
||||
schema-2 re-anchor; they are not interpreted as a clean tree.
|
||||
`generated_plan_path` is always normalized, relative to the target repo, and
|
||||
scoped to the generated-plan filename shape under `docs/plans/`; schema 2 has
|
||||
no external-output representation. An executor must load the plan with the
|
||||
helper's descriptor-anchored `read-plan` command and require this field to
|
||||
equal the receipt's canonical target-repo-relative path byte-for-byte.
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
# Evidence provenance serializer v2 and safe plan writer
|
||||
|
||||
This file is the normative byte contract for `evidence_provenance` schema 2.
|
||||
The adjacent `scripts/evidence-provenance.mjs` is its executable definition.
|
||||
`gitnexus-plan` and `gitnexus-work` carry byte-identical copies so either skill
|
||||
can produce the same snapshot without relying on the other skill's install.
|
||||
It is also the only supported write boundary for a generated plan. Never
|
||||
recreate the digest with an ad-hoc shell pipeline or write the plan destination
|
||||
directly.
|
||||
|
||||
## Invocation
|
||||
|
||||
From the target repository root, run the helper belonging to the active skill:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs read-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md
|
||||
```
|
||||
|
||||
`read-plan` is the only supported way to load an existing plan for Deepen or
|
||||
execution. It emits a JSON receipt with the canonical `generated_plan_path`,
|
||||
`bytes_read`, exact `plan_bytes_base64`, and `plan_digest` (`sha256:<hex>`).
|
||||
Decode and consume those exact bytes; do not reopen the lexical path. Retain
|
||||
the canonical path and digest together for the complete Deepen session; a
|
||||
receipt for one path never authorizes another, even when their bytes match.
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs snapshot \
|
||||
--repo "$PWD" \
|
||||
--schema-version 2 \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--cited src/one.ts \
|
||||
--cited test/one.test.ts
|
||||
```
|
||||
|
||||
Pass one `--cited` argument for every cited path. The helper emits the complete
|
||||
JSON value for `evidence_provenance`; copy that value without rewriting fields.
|
||||
`gitnexus-work` passes the plan's `schema_version`, `generated_plan_path`, and
|
||||
every path in `cited_path_manifest`. Schema 1 is legacy and deliberately
|
||||
rejected, so the executor must conservatively re-anchor it under schema 2.
|
||||
|
||||
After the snapshot is in the fully composed document, publish its exact UTF-8
|
||||
bytes through the same helper:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
For Deepen only:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--replace \
|
||||
--expected-plan-path docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--expected-plan-digest 'sha256:<digest-from-read-plan>' \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
Initial planning never passes `--replace`; an existing destination is an
|
||||
error. Deepen mode rewrites the same path by adding `--replace`,
|
||||
`--expected-plan-path <generated_plan_path-from-read-plan>`, and
|
||||
`--expected-plan-digest <plan_digest-from-that-same-receipt>`. Standard input must be
|
||||
valid UTF-8 and at most 16 MiB. A successful write prints a JSON receipt with
|
||||
the normalized `generated_plan_path` and `bytes_written`. A successful Deepen
|
||||
write also returns `prior_plan_backup_git_path`, a durable Git-admin path for
|
||||
the displaced plan. The CLI rejects every option that does not apply to its
|
||||
selected command; the direct API likewise requires literal booleans and exact
|
||||
digest strings rather than truthy coercion.
|
||||
|
||||
## Path contract
|
||||
|
||||
Every Git path and CLI path must be valid UTF-8, already normalized to Unicode
|
||||
NFC, and a nonempty POSIX repo-relative path. NUL, backslash, absolute/drive
|
||||
paths, empty components, and `.` or `..` components are rejected. The helper
|
||||
does not silently repair or alias them. Invalid UTF-8 from Git, non-NFC names,
|
||||
unmerged index stages, unsupported Git modes, sockets/devices/FIFOs, unreadable
|
||||
objects, symlink traversal in a parent path component, or a repository mutation
|
||||
observed during the snapshot fail closed.
|
||||
|
||||
The generated-plan path is always repo-relative under schema 2. Snapshot
|
||||
exclusion and writing require exactly
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-kebab-slug>.md`, including a
|
||||
valid calendar date; they cannot target `.git`, source, configuration, or an
|
||||
arbitrary repo file. For compatibility with documented and legacy plans,
|
||||
`read-plan` accepts normalized files matching `docs/plans/*gitnexus-plan*.md`,
|
||||
while retaining the same descriptor-anchored containment checks. That read
|
||||
compatibility does not widen the writer. External output has no schema-2
|
||||
representation. The snapshot exclusion is one exact normalized path
|
||||
comparison. No glob, directory, basename, or `docs/plans/`-wide exclusion is
|
||||
permitted. If the exact path is a rename endpoint, only that endpoint record is
|
||||
excluded.
|
||||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
requires valid UTF-8, hashes the exact bytes, then proves both the parent chain
|
||||
and lexical leaf still name the same held objects before returning its receipt.
|
||||
Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
||||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
parents relative to those descriptors, and proves the descriptor and lexical
|
||||
chains still identify the same directories at the write boundary. A symlink
|
||||
or non-directory parent, an escaping resolved path, a symlink/non-regular final
|
||||
target, or a parent swap is an error.
|
||||
|
||||
The writer creates a random exclusive temporary file relative to the held final
|
||||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
`read-plan` receipt. The expected path must exactly equal the write
|
||||
destination, so identical bytes from one plan cannot authorize another plan.
|
||||
Immediately before
|
||||
preservation, the writer hashes the still-held prior-plan fd and rejects any
|
||||
digest, inode, or path mismatch, including same-inode edits and changes between
|
||||
read and write. It then atomically moves the current destination without
|
||||
replacement to a random `gitnexus-plan-backups/` file under the resolved
|
||||
Git-admin directory and verifies the moved inode and digest against that held
|
||||
fd. Only then does it publish the new plan with the same atomic no-replace
|
||||
primitive. A destination that reappears at either boundary is left untouched.
|
||||
|
||||
Every newly created plan or vault directory is fsynced and then fsynced into
|
||||
its containing directory. Every cross-directory preservation move fsyncs both
|
||||
its source and destination directories before success or a recovery path is
|
||||
reported. After temporary bytes exist, a failed publication or verification preserves
|
||||
every available prior, displaced, unpublished, or intended plan in that
|
||||
Git-admin vault before reporting failure. Each reported recovery is reopened
|
||||
from a freshly resolved Git root and verified before the error names it as
|
||||
`git-path:gitnexus-plan-backups/<random-name>`. Resolve that value with
|
||||
`git rev-parse --git-path gitnexus-plan-backups/<random-name>`; never interpret
|
||||
it as a repo-relative working-tree path. This remains valid if the held plan
|
||||
parent was renamed after publication. The writer never reports recovery
|
||||
through a stale lexical parent and never performs an identity-check-then-unlink
|
||||
rollback that could delete a racer's replacement. Read-only or unsupported
|
||||
checkouts produce a blocking error. Callers must not bypass the helper,
|
||||
redirect to an external path, or weaken these checks.
|
||||
|
||||
## Canonical bytes
|
||||
|
||||
The `global_dirty_digest.value` is lowercase SHA-256 (without a `sha256:`
|
||||
prefix) over this byte stream. All textual values are their exact UTF-8 bytes.
|
||||
`NUL` below is one `0x00` byte.
|
||||
|
||||
1. Prefix fields, each followed by NUL, then one additional NUL:
|
||||
`gitnexus-evidence-provenance`, `schema_version`, `2`.
|
||||
2. Zero or more records sorted by unsigned lexicographic comparison of the
|
||||
normalized path's UTF-8 bytes. Locale and filesystem order are forbidden.
|
||||
3. Each record is `record` + NUL, then the following fixed-order sequence of
|
||||
`field-name` + NUL + `field-value` + NUL pairs, then one additional NUL:
|
||||
`path`, `state`, `head_kind`, `index_kind`, `worktree_kind`,
|
||||
`untracked_kind`, `rename_from`, `rename_to`, `head_digest`,
|
||||
`index_digest`, `worktree_digest`, `untracked_digest`.
|
||||
4. The literal `absent` represents every unavailable rename endpoint, object
|
||||
kind, and layer digest in canonical bytes. It is never an empty string.
|
||||
|
||||
The schema's canonicalization literal is exactly
|
||||
`gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records`. The fixed field
|
||||
count plus the extra NUL after prefix/record makes framing unambiguous; values
|
||||
cannot contain NUL. Duplicate normalized paths are rejected.
|
||||
|
||||
## Records, renames, and states
|
||||
|
||||
The raw dirty set comes from Git porcelain v2 with NUL termination, all
|
||||
untracked files, submodule inspection enabled, a fixed 50% rename threshold,
|
||||
and both `diff.renameLimit=0` and `status.renameLimit=0`, so repository config
|
||||
cannot cap rename candidates. Raw porcelain facts that share a path are merged
|
||||
into one canonical record. A rename contributes two endpoint facts:
|
||||
|
||||
- old endpoint: `path=<old>`, `rename_from=absent`, `rename_to=<new>`;
|
||||
- new endpoint: `path=<new>`, `rename_from=<old>`, `rename_to=absent`.
|
||||
|
||||
Both normally have state `renamed`; record sorting, not old/new role,
|
||||
determines order. A worktree-dirty rename destination or any endpoint that also
|
||||
has another fact is `mixed`, with rename metadata retained. When either endpoint
|
||||
is cited, the cited manifest expands to include both.
|
||||
|
||||
Ordinary `XY` status maps to `mixed` when index and worktree columns are both
|
||||
dirty, otherwise `deleted` for a deletion, `staged` for index-only change, and
|
||||
`unstaged` for worktree-only change. `?` is `untracked`. Multiple distinct
|
||||
facts for the same path become `mixed`; a staged deletion plus a recreated file
|
||||
therefore retains HEAD/index facts while the filesystem object is recorded in
|
||||
the untracked layer. `? child/` is Git's embedded-directory marker: the trailing
|
||||
slash is removed before path normalization and `child` is materialized as one
|
||||
bounded directory object. A cited path outside the dirty set is `clean`,
|
||||
`untracked` when it exists only outside Git layers, or `absent` when no layer
|
||||
exists.
|
||||
|
||||
## Object and digest rules
|
||||
|
||||
Every present layer digest is `sha256:<lowercase-hex>`:
|
||||
|
||||
- HEAD regular/symlink: SHA-256 of the exact Git blob bytes. HEAD directory:
|
||||
SHA-256 of the exact raw Git tree bytes. HEAD gitlink: SHA-256 of the ASCII
|
||||
object ID stored by the tree.
|
||||
- Index regular/symlink: SHA-256 of the stage-0 Git blob bytes. Index gitlink:
|
||||
SHA-256 of its ASCII object ID. The index has no directory layer. Any
|
||||
non-stage-0 entry is rejected.
|
||||
- Tracked worktree regular: raw file bytes, opened without following symlinks.
|
||||
Symlink: raw link-target bytes. Gitlink: ASCII object ID at the checked-out
|
||||
nested HEAD, but only after `rev-parse --show-toplevel` proves that the
|
||||
directory itself is the nested repository root, `HEAD` resolves there, and
|
||||
porcelain v2 reports no staged, unstaged, untracked, or ignored nested changes. The
|
||||
same root, HEAD, and clean-status proof is repeated by the mutation guard. A
|
||||
dirty, empty, uninitialized, or parent-falling-through gitlink fails closed.
|
||||
Directory: the v1 directory stream described below.
|
||||
- A path absent from both HEAD and index places the filesystem object in the
|
||||
`untracked` layer and marks `worktree` absent. A Git-backed path places it in
|
||||
`worktree` and marks `untracked` absent. A missing layer uses literal
|
||||
`absent` for both kind and digest; an empty file is the SHA-256 of zero bytes.
|
||||
|
||||
Filesystem directory bytes use prefix fields
|
||||
`gitnexus-evidence-directory`, `schema_version`, `1`, the same NUL framing,
|
||||
and recursive entries sorted by unsigned UTF-8 relative-path bytes. Each entry
|
||||
has fixed fields `path`, `kind`, `digest`. A single bottom-up filesystem walk
|
||||
visits each node once and returns each child digest plus the flattened subtree
|
||||
needed to preserve those canonical bytes; links are never followed. When the
|
||||
directory is proven to be an exact nested Git top-level, only its administrative
|
||||
`.git` entry is excluded. Every other child, including working files and nested
|
||||
directories, remains evidence.
|
||||
|
||||
Each directory object is bounded to 10,000 visited entries, depth 256, and 256
|
||||
MiB of regular-file content. Exceeding a bound fails closed. These bounds apply
|
||||
independently to each top-level directory object materialized by a record.
|
||||
|
||||
HEAD objects are read only from the full object ID captured at snapshot start;
|
||||
the symbolic `HEAD` name is never re-resolved for layers. Index layers are
|
||||
parsed from one captured stage-0 listing. The helper guards the corresponding
|
||||
HEAD/ref/reflog controls and raw index file, compares the captured listing at
|
||||
the end, and rejects ordinary A-to-B-to-A mutations instead of accepting
|
||||
mixed-era layers.
|
||||
|
||||
Regular files are read through an `O_NOFOLLOW` descriptor with before/after
|
||||
identity checks. Symlinks use lstat/readlink/lstat; directories record identity
|
||||
before and after their inventory. The helper also compares raw porcelain-v2
|
||||
status and HEAD at the start and end, then rechecks filesystem guards. An
|
||||
absent cited path holds a no-follow descriptor for the nearest existing parent
|
||||
and records the first missing component or leaf; that anchored absence is
|
||||
checked both before and after the final Git status pass, so a newly created
|
||||
ignored path cannot evade porcelain. Any observed race rejects the snapshot
|
||||
rather than emitting mixed-era evidence.
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
# Building the PDG context slice
|
||||
|
||||
Statement-level evidence for the 1–3 functions most central to the change.
|
||||
Goal: a compact slice the planning LLM can hold, never a graph dump.
|
||||
|
||||
## Tools (all verified against `gitnexus/src/mcp/tools.ts`)
|
||||
|
||||
| Question | Call |
|
||||
| --- | --- |
|
||||
| Under what condition does X run? Guards? | `pdg_query {mode: "controls", target}` |
|
||||
| Where does variable Y flow inside the function? | `pdg_query {mode: "flows", target, variable}` |
|
||||
| What depends on the statement at line N? | `impact {mode: "pdg", target, direction: "upstream", line: N}` |
|
||||
| Source→sink taint paths (security mode) | `explain {target}` |
|
||||
|
||||
Contract caveats that shape interpretation:
|
||||
|
||||
- `impact` requires `direction` in every mode, `mode: "pdg"` included —
|
||||
`"upstream"` for "what depends on this statement", `"downstream"` for what
|
||||
it depends on. Omitting it fails schema validation.
|
||||
- CDG branch sense is `'T'`/`'F'` in the result's `label` field; a guard's
|
||||
sense depends on its predicate (`if (!ok) return;` rides `'T'`) — never
|
||||
filter guards by a fixed label. Early return/throw edges carry `guard:
|
||||
true`. (The raw edge stores the sense in `reason`, visible only via
|
||||
`cypher`.)
|
||||
- `pdg_query` is intra-procedural and always anchored. Cross-function flow is
|
||||
taint's domain (`explain`) or `impact {mode:"pdg"}`'s inter-procedural reach.
|
||||
- Every `switch` case arm is `'T'` (per-case conditions not distinguished).
|
||||
- No `--pdg` layer → the tools return a "no PDG layer" note, not an error.
|
||||
The note is repo-wide: one probe settles it — do not re-probe per function.
|
||||
Under `freshness: strict` (default), run `analyze --index-only --pdg` via
|
||||
the runner resolved in SKILL.md Phase 1 — this is the one `--pdg` upgrade
|
||||
Phase 1's refresh budget allows (skip it if Phase 1 already refreshed
|
||||
with `--pdg`; apply the runner build check first) — then re-probe. If the refresh failed, is impractical, or `freshness: accept` was
|
||||
passed: record "PDG unavailable" in the ledger, skip the slice, say so in
|
||||
plan §5, and recommend the command. Never reconstruct edges from source by
|
||||
hand.
|
||||
|
||||
## Inclusion criteria
|
||||
|
||||
A statement enters the slice only if it is at least one of:
|
||||
|
||||
- directly matched to the task;
|
||||
- a data-flow predecessor or successor of a relevant statement (within
|
||||
`pdg_data_depth`, default 2);
|
||||
- a control dependency of a relevant statement (within `pdg_control_depth`,
|
||||
default 2);
|
||||
- a state mutation affecting the requested behavior;
|
||||
- an external call on the execution path;
|
||||
- an error-handling or fallback branch;
|
||||
- part of an affected return value;
|
||||
- required to explain a test assertion.
|
||||
|
||||
Everything else is cut. If the slice exceeds ~15 statements per function,
|
||||
tighten relevance rather than raising depth.
|
||||
|
||||
## Slice representation
|
||||
|
||||
Working-memory material: keep the full slice in working context while
|
||||
planning, summarize it into the ledger's one-line `pdg_slices` entries, and
|
||||
distill it into plan §5.
|
||||
|
||||
```yaml
|
||||
pdg_context:
|
||||
entry_symbol: "processFileGroup"
|
||||
source: { file: "gitnexus/src/core/ingestion/worker.ts", start_line: 120, end_line: 188 }
|
||||
relevant_statements:
|
||||
- id: "stmt-12" # stable id or "<file>:<line>"
|
||||
lines: "128-130"
|
||||
type: "condition | call | mutation | return | throw"
|
||||
code: "if (request.retryable) {"
|
||||
relevance: "Controls whether retry scheduling is entered"
|
||||
defines: []
|
||||
uses: ["request.retryable"]
|
||||
control_dependencies: ["stmt-4"]
|
||||
data_dependencies: []
|
||||
execution_flow: # ordered, prose steps
|
||||
- "Validate request"
|
||||
- "Schedule retry"
|
||||
critical_dependencies:
|
||||
- { from: "stmt-7", to: "stmt-18", type: "data", explanation: "Validated request becomes scheduler input" }
|
||||
behavioural_observations:
|
||||
- "Persistence occurs before scheduler invocation"
|
||||
planning_implications:
|
||||
- "Changes to scheduling must account for partial failure"
|
||||
```
|
||||
|
||||
Adapt field names to what the tools actually returned; keep it
|
||||
machine-readable and short. `behavioural_observations` are confirmed facts;
|
||||
`planning_implications` are inferences — keep the distinction.
|
||||
|
||||
## Security mode (task category: security)
|
||||
|
||||
Additionally identify and record: untrusted inputs, validation points,
|
||||
sanitisation points, authn/authz checks, privilege boundaries, sensitive data,
|
||||
persistence operations, network calls, dangerous sinks, and error paths that
|
||||
bypass validation. Run `explain {target}` for persisted source→sink taint
|
||||
paths (intra-procedural TAINTED edges and cross-function TAINT_PATH flows)
|
||||
and include the hop paths for findings relevant to the task. Absence of a
|
||||
taint finding is **not** proof of safety — closure/callback flows,
|
||||
property/field flows, and implicit flows are not modeled, and guard-style
|
||||
sanitizers may be missed — say so when it matters.
|
||||
|
||||
## Performance mode (task category: performance)
|
||||
|
||||
Additionally scan the slice for: loops, repeated calls, blocking operations,
|
||||
network calls, database calls, allocation-heavy paths, caching boundaries,
|
||||
concurrency, fan-out, repeated data transformations. State likely hot-path
|
||||
implications as inferences; never claim measured improvements without
|
||||
benchmark evidence.
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# Plan document template
|
||||
|
||||
Two forms, chosen by the Phase 0 category (`form` knob overrides): **compact**
|
||||
for narrow/default work, **full** for deep work. Repo-relative paths for all
|
||||
repo artifacts in both.
|
||||
|
||||
## Compact form
|
||||
|
||||
Same evidence header, then only the load-bearing sections — keep the §
|
||||
numbers in the headings so `gitnexus-work`'s § references resolve:
|
||||
|
||||
```markdown
|
||||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: <one line>
|
||||
> Evidence verified at commit <sha>; GitNexus index <...>.
|
||||
> Evidence provenance schema 2; global dirty digest <sha256>; cited-path manifest <count> sorted entries; exact generated plan path excluded.
|
||||
|
||||
## Objective (§1)
|
||||
|
||||
## Current Behaviour (§2–3) — ≤10 lines, architecture folded in
|
||||
|
||||
## Findings (§4–5) — only load-bearing, each tagged + tool-named
|
||||
|
||||
## Proposed Changes (§6)
|
||||
|
||||
## Implementation Sequence (§7) — risks inline as step notes
|
||||
|
||||
## Test Strategy (§8)
|
||||
|
||||
## Implementation Context (§11) — the mini-pack (see context-pack.md)
|
||||
|
||||
## Assumptions and Open Questions (§12)
|
||||
|
||||
## Definition of Done (§13)
|
||||
```
|
||||
|
||||
Hard cap: **80 lines excluding the §11 pack**. Anything cut that still
|
||||
matters becomes one line in §12 — never padded prose. A compact plan that
|
||||
outgrows the cap is a signal the task was misclassified: reclassify to full
|
||||
rather than overflowing.
|
||||
|
||||
## Full form
|
||||
|
||||
Fill every section below. If a section is genuinely empty for this task
|
||||
(e.g. no PDG layer indexed), keep the heading and state why in one line —
|
||||
never silently drop it.
|
||||
|
||||
**Claim tagging.** Tag every load-bearing claim with its evidence class:
|
||||
`[verified]` (source-read at the pinned commit), `[graph]` (GitNexus/PDG
|
||||
output, not source-confirmed), `[inferred]` (evidence-backed reasoning),
|
||||
`[assumed]` (unverified — must also appear in §12). Untagged prose is
|
||||
narrative, not evidence.
|
||||
|
||||
```markdown
|
||||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: <one line>
|
||||
> Evidence verified at commit <HEAD sha>; GitNexus index <fresh | refreshed this session (--index-only [--pdg]) | N commits behind, refresh skipped: <reason> | not used>.
|
||||
> Evidence provenance schema 2; global dirty digest <sha256>; cited-path manifest <count> sorted entries; exact generated plan path excluded.
|
||||
|
||||
## 1. Objective
|
||||
|
||||
A concise description of the requested outcome.
|
||||
|
||||
## 2. Current Behaviour
|
||||
|
||||
Describe the current implementation and execution path.
|
||||
|
||||
Include the most relevant symbols, files, and statement-level observations.
|
||||
|
||||
## 3. Relevant Architecture
|
||||
|
||||
Explain the involved modules, boundaries, dependencies, and established patterns.
|
||||
|
||||
## 4. GitNexus Findings
|
||||
|
||||
Summarise:
|
||||
|
||||
- primary symbols;
|
||||
- callers and callees;
|
||||
- impact radius;
|
||||
- related implementations;
|
||||
- related tests;
|
||||
- important cross-module relationships.
|
||||
|
||||
## 5. Statement-Level PDG Findings
|
||||
|
||||
For each critical symbol, explain:
|
||||
|
||||
- relevant statements;
|
||||
- control dependencies;
|
||||
- data dependencies;
|
||||
- state mutations;
|
||||
- error branches;
|
||||
- side effects;
|
||||
- ordering constraints;
|
||||
- planning implications.
|
||||
|
||||
Do not paste an unfiltered graph dump.
|
||||
|
||||
## 6. Proposed Changes
|
||||
|
||||
For every proposed change include:
|
||||
|
||||
- file;
|
||||
- symbol;
|
||||
- exact responsibility;
|
||||
- intended behavioural change;
|
||||
- dependencies;
|
||||
- constraints;
|
||||
- implementation notes.
|
||||
|
||||
## 7. Implementation Sequence
|
||||
|
||||
Provide an ordered sequence of implementation steps.
|
||||
|
||||
Each step must be independently actionable.
|
||||
|
||||
## 8. Test Strategy
|
||||
|
||||
Describe:
|
||||
|
||||
- tests to add;
|
||||
- tests to update;
|
||||
- edge cases;
|
||||
- failure paths;
|
||||
- regression coverage;
|
||||
- integration boundaries;
|
||||
- relevant verification commands.
|
||||
|
||||
## 9. Risk and Impact Analysis
|
||||
|
||||
Include:
|
||||
|
||||
- high-risk symbols;
|
||||
- downstream consumers;
|
||||
- compatibility concerns;
|
||||
- performance concerns;
|
||||
- concurrency or transaction risks;
|
||||
- migration risks;
|
||||
- observability requirements.
|
||||
|
||||
## 10. Files Expected to Change
|
||||
|
||||
| File | Symbols | Reason |
|
||||
| ---- | ------- | ------ |
|
||||
|
||||
## 11. Reusable Implementation Context
|
||||
|
||||
The machine-readable context pack — see `context-pack.md`. Its mandatory
|
||||
`evidence_provenance` field carries the full pinned commit, canonical
|
||||
repository-wide dirty digest, and sorted cited-path manifest.
|
||||
|
||||
## 12. Assumptions and Open Questions
|
||||
|
||||
Clearly separate assumptions from confirmed facts. Explicitly-deferred
|
||||
follow-up suggestions (adjacent work the task didn't ask for) land here too.
|
||||
|
||||
## 13. Definition of Done
|
||||
|
||||
Concrete, testable completion criteria.
|
||||
```
|
||||
|
||||
Composition notes:
|
||||
|
||||
- Immediately before composition, emit `evidence_provenance.schema_version`,
|
||||
the full HEAD commit, the canonical `global_dirty_digest`, and the
|
||||
`cited_path_manifest` sorted by normalized repo-relative path. Include
|
||||
object kinds, rename endpoints, and HEAD/index/worktree/untracked layer
|
||||
digests. Exclude only the generated plan path from the global digest.
|
||||
- Invoke `scripts/evidence-provenance.mjs` per `evidence-provenance.md` and
|
||||
copy its schema-2 JSON; never recreate canonical records in prose or shell.
|
||||
- Publish the fully composed UTF-8 plan only with that helper's `write-plan`
|
||||
command. Initial planning must not replace an existing file; Deepen rewrites
|
||||
the same repo-relative path with `write-plan --replace
|
||||
--expected-plan-path <path-from-read-plan>
|
||||
--expected-plan-digest <digest-from-read-plan>`, which preserves the prior
|
||||
plan in the receipt's `prior_plan_backup_git_path`. Both expected values must
|
||||
come from the same receipt. Deepen must load and bind that canonical path and
|
||||
those original bytes through `read-plan` first. Snapshot, read, and
|
||||
publication must pass the same strict generated-plan filename/date validator.
|
||||
- §2/§5 quote source excerpts at most `max_snippet_lines` (30) lines each, and
|
||||
only when the excerpt carries the argument.
|
||||
- §4 findings each name the tool call they came from (tool + key args), plus a
|
||||
one-line quote of the result when the plan leans on it — that is what makes
|
||||
a tool claim auditable later. Stale-index or fallback-mode findings are
|
||||
labelled as such.
|
||||
- §6 changes may only name symbols the ledger marks `source_verified`.
|
||||
- §7 steps are ordered by dependency and independently actionable — an
|
||||
executor can stop after any step with the tree still coherent. Steps that
|
||||
change output guarded by fingerprints, goldens, or recorded baselines
|
||||
regenerate those artifacts ONCE, in the final step of the sequence — CI
|
||||
judges only the tip, and per-step refreshes churn every intermediate
|
||||
commit and re-drift as later steps land.
|
||||
- §8 names real, located test files for updates; new tests get concrete
|
||||
scenario lists (input → action → expected outcome). Verification commands
|
||||
must exist AND be runnable: prefer the npm/CI script form that carries its
|
||||
prerequisites (pre-hooks, builds) over invoking underlying binaries directly.
|
||||
- §9 must account for every direct (depth-1) dependent the impact pass
|
||||
reported.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,163 +0,0 @@
|
|||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
228
gitnexus-claude-plugin/skills/gitnexus-review/SKILL.md
Normal file
228
gitnexus-claude-plugin/skills/gitnexus-review/SKILL.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
name: gitnexus-review
|
||||
description: 'Review code changes with GitNexus from a GitHub PR URL or number, a branch/ref or commit range, or local staged, unstaged, and untracked changes. Use when the user asks for a code review, merge-risk assessment, regression hunt, missing-test analysis, or a verdict on whether a PR, branch, commit range, or local diff is safe.'
|
||||
---
|
||||
|
||||
# GitNexus review
|
||||
|
||||
Review the requested change surface without editing source, committing, pushing,
|
||||
posting, or resolving threads. A later explicit request may authorize those
|
||||
actions. Use GitNexus for structural evidence and source inspection for proof;
|
||||
neither substitutes for the other.
|
||||
|
||||
## Resolve the target
|
||||
|
||||
Accept these forms:
|
||||
|
||||
| Input | Review surface |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| PR URL, `owner/repo#42`, `#42`, or bare number | GitHub PR |
|
||||
| `base...head` | Merge-base range |
|
||||
| `base..head` | Exact two-dot range |
|
||||
| Branch, tag, or commit | Ref against the repository default branch |
|
||||
| `local`, `staged`, `unstaged`, or working-tree wording | Local changes |
|
||||
| No target | Current branch's open PR; otherwise local changes; otherwise current branch |
|
||||
|
||||
An explicit target always wins. Interpret a bare number as a PR only in a
|
||||
GitHub repository with working `gh` authentication; otherwise ask for a ref or
|
||||
URL. If implicit mode finds both branch commits and local changes, review them
|
||||
as two labeled surfaces rather than silently dropping or blending either one.
|
||||
|
||||
Record the resolved target kind, repository root, default branch, base SHA,
|
||||
head SHA, merge-base when applicable, and included local states. Resolve the
|
||||
default branch from remote metadata (`refs/remotes/<remote>/HEAD` or GitHub
|
||||
repository metadata); use `main` or `master` only as an explicit fallback and
|
||||
say when doing so.
|
||||
|
||||
### PR
|
||||
|
||||
Use `gh pr view`/`gh api` to pin the PR number, repository, title, URL, base
|
||||
ref, base SHA, head ref, and head SHA. Fetch those exact commits without
|
||||
switching the user's branch. Compute `git merge-base <base> <head>` and use
|
||||
that SHA as the review base: GitHub PR diffs are merge-base diffs, while
|
||||
`detect_changes(scope: "compare")` is a two-dot comparison.
|
||||
|
||||
Use the local `git diff <merge-base> <head>` as the complete diff source of
|
||||
truth; use GitHub metadata for PR facts and review state. For fork PRs, fetch
|
||||
the pull ref or the contributor remote instead of assuming the head branch
|
||||
exists on `origin`.
|
||||
|
||||
### Branch, ref, or range
|
||||
|
||||
Resolve every ref to a commit before reviewing. For a branch or `A...B`, use
|
||||
the merge-base as the comparison base. For an explicit `A..B`, honor `A` as
|
||||
the exact base. Do not compare a feature branch directly with a moving default
|
||||
branch tip when merge-base semantics were intended.
|
||||
|
||||
### Local changes
|
||||
|
||||
Inspect `git status --short`, the staged diff, the unstaged diff, and every
|
||||
untracked file. Use `detect_changes` with `staged`, `unstaged`, or `all` as
|
||||
requested. Untracked files are not guaranteed to appear in Git diff or graph
|
||||
mapping, so read them directly and list them in the review provenance.
|
||||
|
||||
## Align the checkout and index
|
||||
|
||||
The graph and diff must describe the same head. Reuse an existing worktree only
|
||||
when it is at the exact target SHA. Otherwise create a temporary detached
|
||||
worktree for the PR/ref head, review there, and remove only that temporary
|
||||
worktree afterward. Never switch or reset the user's current worktree.
|
||||
|
||||
Check GitNexus status in the target worktree. If stale, run
|
||||
`node .gitnexus/run.cjs analyze --index-only` before trusting graph results
|
||||
(temporary worktrees never carry the gitignored `run.cjs` — fall back to the
|
||||
installed `gitnexus` CLI, then `npx gitnexus`), and include `--pdg` in that
|
||||
same refresh when the diff plausibly touches trust or data-flow boundaries,
|
||||
so the taint pass below doesn't pay a second full analyze. Taint and
|
||||
dependence evidence needs that PDG layer: when the workflow's taint pass
|
||||
finds it missing, rebuild with `analyze --pdg --index-only` and record the
|
||||
rebuild in provenance. For local changes, refresh the index so new or
|
||||
modified source is represented.
|
||||
If an exact target checkout/index cannot be established, state the limitation
|
||||
and do not claim a complete graph-backed review.
|
||||
|
||||
## Review workflow
|
||||
|
||||
1. Read the full diff and changed-file list. Separate generated files,
|
||||
dependency churn, tests, and behavior changes.
|
||||
2. Run `detect_changes` against the exact surface:
|
||||
- PR/branch/`...`: `scope: "compare"`, `base_ref: <merge-base SHA>`.
|
||||
- Explicit `A..B`: `scope: "compare"`, `base_ref: <A SHA>` from a worktree
|
||||
at `B`.
|
||||
- Local: `scope: "staged"`, `"unstaged"`, or `"all"`.
|
||||
Pass `worktree` when the MCP server is attached elsewhere.
|
||||
3. Run upstream `impact` with `includeTests: true` for each behaviorally changed
|
||||
symbol. Prioritize public contracts, shared types, control flow, persistence,
|
||||
security boundaries, and error handling; skip mechanical/generated changes.
|
||||
4. Inspect every direct (`d=1`) dependent that is outside the diff. A dependent
|
||||
outside the diff is a lead, not automatically a bug—verify the changed
|
||||
contract and caller behavior in source.
|
||||
5. Use `context` on key or ambiguous symbols and inspect affected execution
|
||||
flows. Read the surrounding implementation and tests at cited locations.
|
||||
6. **Taint and dependence pass.** For changed code on trust or data-flow
|
||||
boundaries — external input, persistence, process execution, network,
|
||||
auth — run `explain` on the changed files or symbols and judge its
|
||||
source→sink taint findings against the diff: a flow the change
|
||||
introduces, or a sanitizer/guard the change removes, is a finding; a
|
||||
pre-existing flow is context, not a defect of this change. When the
|
||||
change claims to guard or sanitize something, verify with `pdg_query`:
|
||||
what controls the changed statement, and where its values flow. This
|
||||
needs a `--pdg` index; if one cannot be built, state that the taint pass
|
||||
was skipped rather than implying coverage.
|
||||
7. Check whether tests exercise the changed behavior, boundary conditions, and
|
||||
affected flows. Run focused read-only validation when practical. When the
|
||||
diff refreshes a committed baseline, fingerprint, or golden, re-run the
|
||||
exact CI check command against the head instead of trusting the committed
|
||||
value — a stale artifact is invisible in the diff and fails only in CI.
|
||||
8. Reconcile graph evidence with the raw diff. New files, dynamic dispatch,
|
||||
configuration, reflection, and untracked content may require direct review
|
||||
even when graph results are empty. Version and invalidation constants are
|
||||
review surface: when the diff changes what gets emitted or persisted,
|
||||
verify every schema/version constant gating caches, incremental
|
||||
writebacks, and fingerprint baselines was bumped or regenerated — in
|
||||
GitNexus itself, for example: `INCREMENTAL_SCHEMA_VERSION` (the
|
||||
incremental write set covers only changed files, so new cross-file edges
|
||||
never reach an existing index without the bump), the parse-store
|
||||
`SCHEMA_BUMP`, and both bench fingerprint sets.
|
||||
|
||||
## Expert lenses
|
||||
|
||||
Depth comes from matching reviewers to what actually changed, not from one
|
||||
generalist pass. After workflow step 2, group the changed files and symbols
|
||||
by the functional areas the graph already knows — the index's cluster
|
||||
listing; `context` names each symbol's cluster — and give each touched area
|
||||
an expert lens: a reviewer charged with that domain's contracts, invariants,
|
||||
and failure modes, grounded in the repo's own material (architecture docs,
|
||||
agent rules, the domain's tests) before judging the diff. A lens verifies,
|
||||
not just reads: when the changed code is a pure function reachable from the
|
||||
repo's own toolchain — parsers, extractors, capture emitters, formatters —
|
||||
execute it on the candidate failing shape (a scratch probe, deleted
|
||||
afterward) and cite the observed output. An empirical probe outranks source
|
||||
reading in the evidence hierarchy; role swaps, dead branches, and
|
||||
error-recovery-dependent behavior repeatedly pass a reading and fail a
|
||||
ten-line probe. The numbered
|
||||
workflow runs exactly once; dispatch the lens passes after step 6, handing
|
||||
each lens the evidence already collected rather than letting lenses repeat
|
||||
the `impact`, `context`, or taint calls. In GitNexus
|
||||
itself, for example: shared ingestion-pipeline changes get an ingestion
|
||||
expert plus one language expert per changed language extractor; embeddings
|
||||
changes an embeddings expert; LadybugDB/storage changes a Ladybug expert.
|
||||
|
||||
Four cross-cutting lenses run regardless of domain:
|
||||
|
||||
- **Architectural fit** — the change lands where the architecture says the
|
||||
concern lives, reuses existing seams, and adds no parallel structure.
|
||||
- **Language conformance** — the repo's own type/lint/test contract as
|
||||
configured (tsconfig strictness, lint rules, test conventions); in a
|
||||
strict TypeScript repo, for example: strictness intact, no `any`/`as any`
|
||||
escapes, module boundaries typed. Judge by the repo's contract, never a
|
||||
universal style bar.
|
||||
- **Definition of Done** — changed behavior has tests, docs the change makes
|
||||
stale are updated, and sync/drift guards (shipped copies, manifests,
|
||||
changelogs) still hold.
|
||||
- **Simplicity** — YAGNI and clear-code check: flag speculative abstraction,
|
||||
unused knobs, and overengineering; the smallest diff that meets the
|
||||
Definition of Done is the standard.
|
||||
|
||||
Scale effort to the surface: a single-domain change of a few files gets one
|
||||
combined pass covering its domain lens plus the four cross-cutting checks;
|
||||
a multi-domain change gets one lens per touched area — run as parallel
|
||||
subagents where the harness supports them, each scoped to its own files
|
||||
plus the shared graph evidence, and as sequential passes otherwise. Never
|
||||
spawn a lens for a domain the diff does not touch. Merge lenses that ground
|
||||
in the same material — two lenses reading the same files pay twice for one
|
||||
read's coverage, so give one reviewer both charges. Where the harness
|
||||
offers model or effort tiers, run mechanical lenses (rename sweeps,
|
||||
doc-consistency checks) on a cheaper tier and reserve the strongest engine
|
||||
for adversarial judgment. Every lens reports
|
||||
through the Finding standard below; merge and dedup before the verdict,
|
||||
dropping anything without a concrete failing scenario.
|
||||
|
||||
## Finding standard
|
||||
|
||||
Report a finding only when the reviewed change introduces a concrete defect,
|
||||
regression, security issue, compatibility break, material coverage gap, or a
|
||||
maintainability cost with a concrete carrying scenario (a dead knob, a
|
||||
duplicated contract, a drift-prone copy).
|
||||
Each finding must include:
|
||||
|
||||
- severity and a precise `path:line` anchor;
|
||||
- the failing scenario or contract;
|
||||
- GitNexus evidence (dependent symbol/process) when applicable;
|
||||
- why existing code or tests do not mitigate it;
|
||||
- a concise remediation or missing test.
|
||||
|
||||
Do not report style preferences, pre-existing issues, raw risk counts, or
|
||||
speculation as defects. Do not infer safety from zero graph hits. Calibrate
|
||||
overall risk from consequence, reachability, reversibility, and test evidence,
|
||||
not from the number of changed symbols alone.
|
||||
|
||||
## Output
|
||||
|
||||
Lead with findings in severity order. If there are none, say so explicitly.
|
||||
Then provide:
|
||||
|
||||
```markdown
|
||||
## Review: <target>
|
||||
|
||||
### Findings
|
||||
|
||||
- [HIGH|MEDIUM|LOW] `path:line` — <problem, evidence, impact, remediation>
|
||||
|
||||
### Change and blast-radius summary
|
||||
|
||||
- Target/base/head/merge-base and local states reviewed
|
||||
- Changed symbols and affected execution flows
|
||||
|
||||
### Coverage and residual risk
|
||||
|
||||
- Tests present, tests missing, graph/diff limitations
|
||||
|
||||
### Verdict
|
||||
|
||||
APPROVE | REQUEST CHANGES | NEEDS DISCUSSION
|
||||
```
|
||||
|
||||
For a branch or local review, use `READY`, `NOT READY`, or `NEEDS DISCUSSION`
|
||||
instead of a PR approval action. Include the exact target SHAs so a later run
|
||||
can tell whether the evidence is stale.
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-review/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-review/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
71
gitnexus-claude-plugin/skills/gitnexus-work/README.md
Normal file
71
gitnexus-claude-plugin/skills/gitnexus-work/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# gitnexus-work — execute a gitnexus-plan
|
||||
|
||||
The executor counterpart to `gitnexus-plan`: consumes a plan's §11
|
||||
implementation context pack and ships it as verified atomic commits, with
|
||||
GitNexus discipline baked in — `impact` before every symbol edit,
|
||||
`detect_changes` before every commit, tests from the plan's scenarios, and a
|
||||
two-layer drift check that re-anchors both commit and dirty working-tree
|
||||
evidence before relying on it.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude Code** | `/gitnexus-work [plan path]` (blank → newest `docs/plans/*gitnexus-plan*.md` in this repo) |
|
||||
| **Codex CLI** | Ask: "run gitnexus-work on <plan path>" (Codex reads `AGENTS.md`), or install the skill user-level (below) |
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-work ~/.agents/skills/gitnexus-work
|
||||
```
|
||||
|
||||
Optionally, for an explicit slash command, create
|
||||
`~/.codex/prompts/gitnexus-work.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Execute a gitnexus-plan as verified atomic commits (impact-checked, detect_changes-gated)
|
||||
argument-hint: <plan path, or blank for the newest plan>
|
||||
---
|
||||
|
||||
Use the gitnexus-work skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-work/SKILL.md` (prefer the repo copy at
|
||||
`.claude/skills/gitnexus-work/SKILL.md` when present) and follow its phases in
|
||||
order. This skill edits code; honor its impact-before-edit and
|
||||
detect_changes-before-commit rules without exception.
|
||||
```
|
||||
|
||||
## Contract with gitnexus-plan
|
||||
|
||||
- Input: the 13-section plan document; §11's `implementation_context` fields
|
||||
are the machine-readable interface (see
|
||||
`../gitnexus-plan/references/context-pack.md` for the stability contract).
|
||||
- `evidence_provenance` is mandatory in compact and full plans. Work always
|
||||
loads the plan only through its byte-identical helper's descriptor-anchored
|
||||
`read-plan` command, consumes the exact base64 bytes from that receipt, and
|
||||
recomputes the global dirty digest and sorted cited-path manifest even at
|
||||
the same HEAD. Schema-2 `generated_plan_path` is a normalized
|
||||
repo-relative `docs/plans/<date>-gitnexus-plan-<slug>.md` path; external,
|
||||
escaping, or differently scoped values are invalid. It must also equal the
|
||||
read receipt's canonical target-repo-relative path byte-for-byte.
|
||||
Missing or schema-1 evidence re-anchors under schema 2.
|
||||
- The plan is never mutated; deviations are recorded in commit messages and
|
||||
the final report.
|
||||
- Changed citations are re-read, new uncited dirty paths are assessed for
|
||||
scope, and unreadable evidence blocks dependent work. Deepen is reserved
|
||||
for drift that invalidates scope, requirements, a key technical decision,
|
||||
or the planned seam.
|
||||
|
||||
## Graph freshness
|
||||
|
||||
One fail-closed **Build-current/index-current procedure** runs before every
|
||||
graph-dependent impact query and again before final graph verification. It
|
||||
compares indexed commit and the schema-4 runner identity (including its
|
||||
`gitnexus-analyzer-dependency-runtime-v4` dependency payload/runtime digest),
|
||||
requires no incomplete-index recovery markers, invalidates on
|
||||
relationship-affecting committed or uncommitted edits, builds and invokes the
|
||||
current local analyzer with PDG indexing when needed, and treats timestamps
|
||||
only as a conservative trigger. Build, refresh, or identity failures block
|
||||
impact and completion; the executor never falls back to a stale runner.
|
||||
269
gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md
Normal file
269
gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
---
|
||||
name: gitnexus-work
|
||||
description: 'Use when executing an engineering plan produced by gitnexus-plan (or a small bounded task directly) — implements step by step with GitNexus impact checks before every symbol edit, tests from the plan''s scenarios, and detect_changes gating every commit. Examples: "/gitnexus-work docs/plans/2026-07-11-gitnexus-plan-ingestion-retry.md", "/gitnexus-work" (latest plan), "execute the plan".'
|
||||
---
|
||||
|
||||
# gitnexus-work — execute a gitnexus-plan
|
||||
|
||||
Execute an implementation plan produced by `gitnexus-plan`, shipping it as a
|
||||
sequence of verified, atomic commits. The plan's section 11
|
||||
(`implementation_context` pack) is the primary machine-readable input; the
|
||||
prose sections are its rationale. This skill **does** edit code — it is the
|
||||
executor counterpart to the planning-only `gitnexus-plan`.
|
||||
|
||||
```
|
||||
/gitnexus-work <plan path> # execute this plan
|
||||
/gitnexus-work # newest docs/plans/*gitnexus-plan*.md here
|
||||
/gitnexus-work <small task text> # direct mode, see Input triage
|
||||
```
|
||||
|
||||
## Input triage
|
||||
|
||||
- **Plan path** (or blank → the newest `docs/plans/*gitnexus-plan*.md` under
|
||||
the current repo root): the normal mode; continue to Phase 1. Schema-2
|
||||
plans have a normalized repo-relative
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-slug>.md`
|
||||
`generated_plan_path`. Resolve only a lexical candidate, then invoke
|
||||
`scripts/evidence-provenance.mjs read-plan --repo <root> --generated-plan
|
||||
<candidate>` and load only the exact bytes in its descriptor-anchored
|
||||
receipt. Require the receipt's canonical repo-relative path to equal the
|
||||
document's `generated_plan_path` byte-for-byte;
|
||||
reject an external, escaping, differently scoped, or mismatched value. A
|
||||
plan in another target repo may still be passed by explicit path. If Phase 1's
|
||||
pre-completed check finds every §7 step of the newest plan already landed,
|
||||
stop and ask instead of re-executing it.
|
||||
- **Bare task text**: trivial and bounded (1–2 files, no architectural
|
||||
decisions) → implement directly with the same discipline: `impact` before
|
||||
every symbol edit, minimal change, tests when behavior changes,
|
||||
verification commands taken from the repo's own scripts (package.json /
|
||||
CI), `detect_changes` before every commit, and the shared
|
||||
Build-current/index-current procedure before graph-dependent impact and
|
||||
final verification. Anything larger → recommend running
|
||||
`/gitnexus-plan` first; honor the user's choice if they decline.
|
||||
|
||||
## Phase 1 — Load and re-anchor the plan
|
||||
|
||||
1. Resolve the target repo and normalized plan candidate, then invoke this
|
||||
skill's descriptor-anchored `scripts/evidence-provenance.mjs read-plan`
|
||||
command exactly as
|
||||
specified in `references/evidence-provenance.md`. Reject a missing,
|
||||
external, escaping, symlinked, or differently scoped path. Decode and read
|
||||
the receipt's exact `plan_bytes_base64` completely; never read or reopen the
|
||||
lexical path directly. It is a decision artifact, not a script: scope
|
||||
boundaries and `avoid` entries bind you; exact code is yours to write.
|
||||
Retain the receipt's canonical `generated_plan_path` and `plan_digest` in
|
||||
session state. Never edit the plan body.
|
||||
2. Parse the §11 `implementation_context` pack: `acceptance_criteria`,
|
||||
`evidence_provenance`, `primary_symbols`, `related_symbols`,
|
||||
`files_to_modify`, `execution_path`, `pdg_constraints`,
|
||||
`architectural_patterns`, `tests`, `verification_commands`, `risks`,
|
||||
`assumptions`, `open_questions`, `avoid`. Compact plans carry the
|
||||
mini-pack subset — absent optional fields are empty, not errors.
|
||||
`evidence_provenance` is mandatory: absence or schema 1 means a legacy
|
||||
plan, not a clean tree. Before relying on it, require exact byte-for-byte
|
||||
equality between the read-plan receipt's canonical `generated_plan_path`
|
||||
and `evidence_provenance.generated_plan_path`.
|
||||
3. **Two-layer drift check — always recompute.** Even when current HEAD is the
|
||||
same HEAD as the plan pin, recompute both the canonical global dirty digest
|
||||
and the sorted cited-path manifest. Read
|
||||
`references/evidence-provenance.md`, then invoke this skill's
|
||||
`scripts/evidence-provenance.mjs` with the plan's exact
|
||||
`generated_plan_path`, every cited manifest path, and schema version 2.
|
||||
Never recreate its bytes in shell or prose. Schema 1 cannot be recomputed
|
||||
unambiguously and requires conservative re-anchoring. Include
|
||||
object kind plus HEAD/index/worktree/untracked layer digests, and classify
|
||||
`staged`, `unstaged`, `untracked`, `deleted`, `renamed`, `mixed`,
|
||||
and `absent` evidence. Honor the generated-plan exclusion exactly; do not
|
||||
exclude all plans.
|
||||
4. **Re-anchor on either mismatch.** Missing or legacy provenance, a HEAD
|
||||
mismatch, or a global dirty digest mismatch requires a conservative
|
||||
re-anchor before work:
|
||||
- Diff every cited-path manifest entry. Changed cited paths — including
|
||||
staged-only, unstaged-only, deleted, both rename endpoints, mixed
|
||||
staged+unstaged, and disappeared untracked paths — get their cited ranges
|
||||
re-read before reliance.
|
||||
- Compare the current whole-tree dirty set with the pinned global digest.
|
||||
New uncited dirty paths get a scope assessment: determine whether they
|
||||
overlap the plan, requirements, tests, or a key technical decision; do not
|
||||
silently ignore them merely because they are uncited.
|
||||
- Unreadable or unclassifiable cited evidence blocks every dependent step
|
||||
until it can be restored, read, or resolved with the user. Never substitute
|
||||
an invented digest or treat absence as an empty file.
|
||||
- Keep the re-anchor result in session state; never mutate the plan body.
|
||||
Use Deepen only if reconciliation invalidates scope, requirements, a key
|
||||
technical decision (KTD), or the planned implementation seam. Ordinary
|
||||
byte drift that leaves those decisions valid is re-verified locally.
|
||||
5. **Re-verify `assumptions` cheaply** (each one names what to check).
|
||||
A failed assumption is a stop-and-replan signal for the steps that
|
||||
depend on it, not something to code around silently.
|
||||
6. Note `open_questions` — if one blocks a step and the answer materially
|
||||
changes the work, ask the user before that step, not after.
|
||||
7. **Pre-completed check.** If commits for this plan already exist on the
|
||||
branch (a prior partial run, or a post-route-back Deepen cycle), verify
|
||||
which §7 steps have landed at HEAD: those are skipped and reported as
|
||||
pre-completed, and execution resumes at the first unlanded step. All
|
||||
steps landed → report that and stop.
|
||||
|
||||
## Phase 2 — Environment
|
||||
|
||||
- On the default branch → create a feature branch named from the plan slug.
|
||||
On a feature branch already → stay only if it is meaningful _for this
|
||||
plan_ (name matches the plan slug, or the user confirms); otherwise
|
||||
branch from here with the slug name.
|
||||
- If the plan document is not yet committed, commit it now
|
||||
(`docs(plans): add <slug> plan`) — the plan travels with the work it
|
||||
drives, and the final review diff then includes it.
|
||||
- Confirm the `verification_commands` from the pack actually run in this
|
||||
checkout (dependencies installed, builds present) before starting, not
|
||||
after the last step.
|
||||
|
||||
### Build-current/index-current procedure
|
||||
|
||||
This is the single graph-freshness procedure owned by `gitnexus-work`; it
|
||||
applies in plan mode and direct mode. Before every graph-dependent `impact`
|
||||
query, run the Build-current/index-current procedure. Before final graph
|
||||
verification, run the same Build-current/index-current procedure again.
|
||||
|
||||
1. Capture current HEAD and working-tree provenance. Read
|
||||
`gitnexus://repo/<name>/context` and use its typed `index.commit` and
|
||||
`index.runner_identity` receipt — never infer analyzer identity from prose,
|
||||
timestamps, or a path alone. Compare `index.commit` with current HEAD. A
|
||||
current receipt has `schemaVersion: 4`, resolved runtime path/version, CLI
|
||||
version, invoked-artifact path/digest, build
|
||||
kind/root/canonicalization/digest, and dependency-runtime
|
||||
manifest/lockfile/canonicalization/package-count/artifact-count/digest. Its
|
||||
dependency canonicalization is
|
||||
`gitnexus-analyzer-dependency-runtime-v4`. The dependency-runtime digest
|
||||
covers resolved package metadata and complete loadable package payloads,
|
||||
including JavaScript, JSON, native, Wasm, and parser artifacts; schema-1,
|
||||
schema-2, and schema-3 receipts are legacy/stale (the MCP context labels
|
||||
them `runner_identity_schema_status: legacy-or-unknown`). Require MCP
|
||||
`index.incomplete_reasons: []`. Run the exact candidate CLI's
|
||||
`status --json` command and require `index.runnerIdentityStatus: current`,
|
||||
`index.incompleteReasons: []`, and top-level `status: up-to-date`. The
|
||||
status comparator checks every semantic field while deliberately excluding
|
||||
only diagnostic `invokedArtifact`; a worker-authored persisted receipt and
|
||||
the CLI's live receipt may therefore differ in that field without becoming
|
||||
stale. Missing, malformed, differently versioned, semantically unequal, or
|
||||
incomplete receipts are unknown/stale, not a match.
|
||||
2. Relationship-affecting committed and uncommitted edits invalidate
|
||||
freshness after the last successful procedure run. This includes staged,
|
||||
unstaged, untracked, deleted, or renamed analyzer/source/config changes
|
||||
that can alter symbols or edges. Any such edit between steps requires an
|
||||
inter-step refresh before the next graph query, even when HEAD did not move.
|
||||
3. If the typed runner receipt is stale or unknown in an analyzer-source
|
||||
checkout, build current local source using the verified package script. In
|
||||
this repo: `cd gitnexus && npm run build`. Resolve the package's `bin`
|
||||
target and run that exact artifact's `status --json` command to capture its
|
||||
current receipt. Source/build timestamps are a conservative rebuild
|
||||
trigger, not proof that an artifact is current.
|
||||
4. Invoke that exact freshly built local CLI from the target repo root with
|
||||
PDG layers enabled. In this repo:
|
||||
`node gitnexus/dist/cli/index.js analyze --index-only --pdg`.
|
||||
Add `--force` when the persisted receipt was absent, malformed,
|
||||
differently versioned, or unequal so an already-up-to-date fast path cannot
|
||||
leave legacy/stale provenance in place. The usual project-runner form,
|
||||
`node .gitnexus/run.cjs analyze`, is acceptable only when its proven runner
|
||||
identity resolves to that same freshly built artifact. Do not fall back to
|
||||
an older project runner, global install, or package download after
|
||||
resolving/building the local artifact.
|
||||
5. Re-read index context, rerun the exact invoked CLI's `status --json`, and
|
||||
prove the post-refresh `index.commit` equals current HEAD, MCP
|
||||
`index.incomplete_reasons` is empty, and its complete
|
||||
`index.runner_identity` equals status `index.runnerIdentity` (the persisted
|
||||
receipt). Require status `index.runnerIdentityStatus: current`, empty
|
||||
`index.incompleteReasons`, and top-level `status: up-to-date`; do not require
|
||||
raw equality with `current.runnerIdentity` because `invokedArtifact` is a
|
||||
diagnostic entrypoint deliberately excluded from semantic freshness.
|
||||
Record the dirty-state digest indexed in this procedure so same-HEAD
|
||||
uncommitted edits can invalidate it later.
|
||||
6. Any build, refresh, metadata-read, or identity-verification failure blocks
|
||||
graph-dependent impact work and final completion. Report the failing
|
||||
command and evidence; do not continue on an older graph.
|
||||
|
||||
## Phase 3 — Execute the Implementation Sequence
|
||||
|
||||
Work through plan §7 step by step, in order. For each step:
|
||||
|
||||
1. **Fresh impact before editing.** Run the Build-current/index-current
|
||||
procedure immediately before every graph-dependent
|
||||
`impact {target, direction: "upstream"}` query. Then account for every
|
||||
direct (d=1) dependent. HIGH or CRITICAL risk → surface it to the user
|
||||
with the blast radius before proceeding (repo mandate — see AGENTS.md
|
||||
GitNexus rules).
|
||||
2. **Honor the constraints.** `pdg_constraints` entries state ordering and
|
||||
dependence facts the change must preserve; `avoid` entries are hard
|
||||
prohibitions; `architectural_patterns` name the shape to mirror (read the
|
||||
example location before inventing one).
|
||||
3. **Implement minimally.** The smallest change that completes the step,
|
||||
following the surrounding code's conventions.
|
||||
4. **Test from the plan's scenarios.** Each `tests[]` scenario (input →
|
||||
action → expected outcome) becomes a real test in the named file. Add
|
||||
coverage the plan missed if the step's behavior demands it; never delete
|
||||
or weaken an assertion to make a step pass. Prove a new regression test
|
||||
discriminates: when the failure mode is subtle, run it once against the
|
||||
pre-fix tree (write the test before the fix, or stash the fix) and watch
|
||||
it fail — a test that passes both ways pins nothing.
|
||||
5. **Verify.** Run the step-relevant `verification_commands` (they carry
|
||||
their build prerequisites; use them as written). If any part of the
|
||||
change executes from build output — worker entrypoints, dist-shipped
|
||||
CLIs, bundled assets — rebuild that output before every verification
|
||||
run: a pass or fail against outdated build output is noise, and "the
|
||||
fix doesn't work" is more often "the fix never loaded".
|
||||
6. **Commit atomically.** `detect_changes {scope: "staged"}` before every
|
||||
commit to confirm only the expected symbols and flows are affected
|
||||
(repo mandate); then one conventional commit per step. Run stage →
|
||||
`detect_changes` → commit as one unbroken sequence from the repository
|
||||
root — interleaving other work between the gate and the commit is how
|
||||
the gate gets skipped. Unexpected
|
||||
affected flows → investigate before committing, not after.
|
||||
|
||||
A relationship-affecting implementation edit or commit invalidates the
|
||||
procedure's prior proof. The next step must perform the required inter-step
|
||||
refresh before its impact query; final verification refreshes again after the
|
||||
last edit.
|
||||
|
||||
Steps are independently actionable: after any commit the tree is coherent.
|
||||
If a step reveals the plan is wrong, stop that step, re-verify the affected
|
||||
claims at HEAD, and either adapt (small, in-scope deviation — record it in
|
||||
the commit message and final summary) or route back to `gitnexus-plan`
|
||||
Deepen mode (structural miss) — with a one-line ask to the user when the
|
||||
choice isn't obvious.
|
||||
|
||||
## Phase 4 — Finish
|
||||
|
||||
1. Run the full `verification_commands` suite once, at the end, even if
|
||||
every step already passed individually.
|
||||
2. Walk plan §13 (Definition of Done) and the pack's `acceptance_criteria`
|
||||
item by item; anything unmet is either finished now or reported as
|
||||
explicitly unmet — never silently dropped.
|
||||
3. **Verify the final knowledge graph.** Before final graph verification, run
|
||||
the same Build-current/index-current procedure after the last edit, even
|
||||
when no commit landed or HEAD still equals the original pin. Then run
|
||||
`detect_changes {scope: "all"}` (or the repo's equivalent final graph
|
||||
check) against that proven-current index and account for every unexpected
|
||||
symbol or flow. A procedure failure blocks completion.
|
||||
4. Report: steps completed, commits made, deviations from the plan (with
|
||||
why), assumptions that failed re-verification, DoD status, final indexed
|
||||
commit and runner identity, and anything deferred. Test failures are
|
||||
reported with their output, not smoothed over.
|
||||
|
||||
## Never
|
||||
|
||||
- Skip the Phase 3 gates: no symbol edit without `impact`, no commit without
|
||||
`detect_changes`.
|
||||
- Expand scope beyond the plan — §12's deferred follow-ups stay deferred.
|
||||
- Mutate the plan body (committing the file verbatim in Phase 2 is not
|
||||
mutation), weaken failing tests, or present unverified work as verified.
|
||||
|
||||
## Skill feedback (GitNexus repo only)
|
||||
|
||||
If this run exposed friction in this skill's own instructions — wrong or
|
||||
missing guidance, a wasted tool budget, a phase that misrouted — and the repo
|
||||
carries `eval/workflow_bench/`, append one JSON line to
|
||||
`eval/workflow_bench/learnings.jsonl` (create the file if absent):
|
||||
`{"skill": "gitnexus-work", "date": "YYYY-MM-DD", "task": "<one line>", "friction": "<one line>", "suggestion": "<one line>"}`.
|
||||
Never edit this skill file itself from a live task: improvements go through
|
||||
the offline candidate loop (`eval/workflow_bench/README.md` § Prompt and
|
||||
skill evolution loop), where a candidate must beat the incumbent on the
|
||||
paired benchmark before a human merges it.
|
||||
8
gitnexus-claude-plugin/skills/gitnexus-work/mcp.json
Normal file
8
gitnexus-claude-plugin/skills/gitnexus-work/mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
# Evidence provenance serializer v2 and safe plan writer
|
||||
|
||||
This file is the normative byte contract for `evidence_provenance` schema 2.
|
||||
The adjacent `scripts/evidence-provenance.mjs` is its executable definition.
|
||||
`gitnexus-plan` and `gitnexus-work` carry byte-identical copies so either skill
|
||||
can produce the same snapshot without relying on the other skill's install.
|
||||
It is also the only supported write boundary for a generated plan. Never
|
||||
recreate the digest with an ad-hoc shell pipeline or write the plan destination
|
||||
directly.
|
||||
|
||||
## Invocation
|
||||
|
||||
From the target repository root, run the helper belonging to the active skill:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs read-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md
|
||||
```
|
||||
|
||||
`read-plan` is the only supported way to load an existing plan for Deepen or
|
||||
execution. It emits a JSON receipt with the canonical `generated_plan_path`,
|
||||
`bytes_read`, exact `plan_bytes_base64`, and `plan_digest` (`sha256:<hex>`).
|
||||
Decode and consume those exact bytes; do not reopen the lexical path. Retain
|
||||
the canonical path and digest together for the complete Deepen session; a
|
||||
receipt for one path never authorizes another, even when their bytes match.
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs snapshot \
|
||||
--repo "$PWD" \
|
||||
--schema-version 2 \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--cited src/one.ts \
|
||||
--cited test/one.test.ts
|
||||
```
|
||||
|
||||
Pass one `--cited` argument for every cited path. The helper emits the complete
|
||||
JSON value for `evidence_provenance`; copy that value without rewriting fields.
|
||||
`gitnexus-work` passes the plan's `schema_version`, `generated_plan_path`, and
|
||||
every path in `cited_path_manifest`. Schema 1 is legacy and deliberately
|
||||
rejected, so the executor must conservatively re-anchor it under schema 2.
|
||||
|
||||
After the snapshot is in the fully composed document, publish its exact UTF-8
|
||||
bytes through the same helper:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
For Deepen only:
|
||||
|
||||
```bash
|
||||
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
|
||||
--repo "$PWD" \
|
||||
--generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--replace \
|
||||
--expected-plan-path docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
|
||||
--expected-plan-digest 'sha256:<digest-from-read-plan>' \
|
||||
< /path/to/outside-repo-scratch-plan.md
|
||||
```
|
||||
|
||||
Initial planning never passes `--replace`; an existing destination is an
|
||||
error. Deepen mode rewrites the same path by adding `--replace`,
|
||||
`--expected-plan-path <generated_plan_path-from-read-plan>`, and
|
||||
`--expected-plan-digest <plan_digest-from-that-same-receipt>`. Standard input must be
|
||||
valid UTF-8 and at most 16 MiB. A successful write prints a JSON receipt with
|
||||
the normalized `generated_plan_path` and `bytes_written`. A successful Deepen
|
||||
write also returns `prior_plan_backup_git_path`, a durable Git-admin path for
|
||||
the displaced plan. The CLI rejects every option that does not apply to its
|
||||
selected command; the direct API likewise requires literal booleans and exact
|
||||
digest strings rather than truthy coercion.
|
||||
|
||||
## Path contract
|
||||
|
||||
Every Git path and CLI path must be valid UTF-8, already normalized to Unicode
|
||||
NFC, and a nonempty POSIX repo-relative path. NUL, backslash, absolute/drive
|
||||
paths, empty components, and `.` or `..` components are rejected. The helper
|
||||
does not silently repair or alias them. Invalid UTF-8 from Git, non-NFC names,
|
||||
unmerged index stages, unsupported Git modes, sockets/devices/FIFOs, unreadable
|
||||
objects, symlink traversal in a parent path component, or a repository mutation
|
||||
observed during the snapshot fail closed.
|
||||
|
||||
The generated-plan path is always repo-relative under schema 2. Snapshot
|
||||
exclusion and writing require exactly
|
||||
`docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-kebab-slug>.md`, including a
|
||||
valid calendar date; they cannot target `.git`, source, configuration, or an
|
||||
arbitrary repo file. For compatibility with documented and legacy plans,
|
||||
`read-plan` accepts normalized files matching `docs/plans/*gitnexus-plan*.md`,
|
||||
while retaining the same descriptor-anchored containment checks. That read
|
||||
compatibility does not widen the writer. External output has no schema-2
|
||||
representation. The snapshot exclusion is one exact normalized path
|
||||
comparison. No glob, directory, basename, or `docs/plans/`-wide exclusion is
|
||||
permitted. If the exact path is a rename endpoint, only that endpoint record is
|
||||
excluded.
|
||||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
requires valid UTF-8, hashes the exact bytes, then proves both the parent chain
|
||||
and lexical leaf still name the same held objects before returning its receipt.
|
||||
Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
||||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
parents relative to those descriptors, and proves the descriptor and lexical
|
||||
chains still identify the same directories at the write boundary. A symlink
|
||||
or non-directory parent, an escaping resolved path, a symlink/non-regular final
|
||||
target, or a parent swap is an error.
|
||||
|
||||
The writer creates a random exclusive temporary file relative to the held final
|
||||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
`read-plan` receipt. The expected path must exactly equal the write
|
||||
destination, so identical bytes from one plan cannot authorize another plan.
|
||||
Immediately before
|
||||
preservation, the writer hashes the still-held prior-plan fd and rejects any
|
||||
digest, inode, or path mismatch, including same-inode edits and changes between
|
||||
read and write. It then atomically moves the current destination without
|
||||
replacement to a random `gitnexus-plan-backups/` file under the resolved
|
||||
Git-admin directory and verifies the moved inode and digest against that held
|
||||
fd. Only then does it publish the new plan with the same atomic no-replace
|
||||
primitive. A destination that reappears at either boundary is left untouched.
|
||||
|
||||
Every newly created plan or vault directory is fsynced and then fsynced into
|
||||
its containing directory. Every cross-directory preservation move fsyncs both
|
||||
its source and destination directories before success or a recovery path is
|
||||
reported. After temporary bytes exist, a failed publication or verification preserves
|
||||
every available prior, displaced, unpublished, or intended plan in that
|
||||
Git-admin vault before reporting failure. Each reported recovery is reopened
|
||||
from a freshly resolved Git root and verified before the error names it as
|
||||
`git-path:gitnexus-plan-backups/<random-name>`. Resolve that value with
|
||||
`git rev-parse --git-path gitnexus-plan-backups/<random-name>`; never interpret
|
||||
it as a repo-relative working-tree path. This remains valid if the held plan
|
||||
parent was renamed after publication. The writer never reports recovery
|
||||
through a stale lexical parent and never performs an identity-check-then-unlink
|
||||
rollback that could delete a racer's replacement. Read-only or unsupported
|
||||
checkouts produce a blocking error. Callers must not bypass the helper,
|
||||
redirect to an external path, or weaken these checks.
|
||||
|
||||
## Canonical bytes
|
||||
|
||||
The `global_dirty_digest.value` is lowercase SHA-256 (without a `sha256:`
|
||||
prefix) over this byte stream. All textual values are their exact UTF-8 bytes.
|
||||
`NUL` below is one `0x00` byte.
|
||||
|
||||
1. Prefix fields, each followed by NUL, then one additional NUL:
|
||||
`gitnexus-evidence-provenance`, `schema_version`, `2`.
|
||||
2. Zero or more records sorted by unsigned lexicographic comparison of the
|
||||
normalized path's UTF-8 bytes. Locale and filesystem order are forbidden.
|
||||
3. Each record is `record` + NUL, then the following fixed-order sequence of
|
||||
`field-name` + NUL + `field-value` + NUL pairs, then one additional NUL:
|
||||
`path`, `state`, `head_kind`, `index_kind`, `worktree_kind`,
|
||||
`untracked_kind`, `rename_from`, `rename_to`, `head_digest`,
|
||||
`index_digest`, `worktree_digest`, `untracked_digest`.
|
||||
4. The literal `absent` represents every unavailable rename endpoint, object
|
||||
kind, and layer digest in canonical bytes. It is never an empty string.
|
||||
|
||||
The schema's canonicalization literal is exactly
|
||||
`gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records`. The fixed field
|
||||
count plus the extra NUL after prefix/record makes framing unambiguous; values
|
||||
cannot contain NUL. Duplicate normalized paths are rejected.
|
||||
|
||||
## Records, renames, and states
|
||||
|
||||
The raw dirty set comes from Git porcelain v2 with NUL termination, all
|
||||
untracked files, submodule inspection enabled, a fixed 50% rename threshold,
|
||||
and both `diff.renameLimit=0` and `status.renameLimit=0`, so repository config
|
||||
cannot cap rename candidates. Raw porcelain facts that share a path are merged
|
||||
into one canonical record. A rename contributes two endpoint facts:
|
||||
|
||||
- old endpoint: `path=<old>`, `rename_from=absent`, `rename_to=<new>`;
|
||||
- new endpoint: `path=<new>`, `rename_from=<old>`, `rename_to=absent`.
|
||||
|
||||
Both normally have state `renamed`; record sorting, not old/new role,
|
||||
determines order. A worktree-dirty rename destination or any endpoint that also
|
||||
has another fact is `mixed`, with rename metadata retained. When either endpoint
|
||||
is cited, the cited manifest expands to include both.
|
||||
|
||||
Ordinary `XY` status maps to `mixed` when index and worktree columns are both
|
||||
dirty, otherwise `deleted` for a deletion, `staged` for index-only change, and
|
||||
`unstaged` for worktree-only change. `?` is `untracked`. Multiple distinct
|
||||
facts for the same path become `mixed`; a staged deletion plus a recreated file
|
||||
therefore retains HEAD/index facts while the filesystem object is recorded in
|
||||
the untracked layer. `? child/` is Git's embedded-directory marker: the trailing
|
||||
slash is removed before path normalization and `child` is materialized as one
|
||||
bounded directory object. A cited path outside the dirty set is `clean`,
|
||||
`untracked` when it exists only outside Git layers, or `absent` when no layer
|
||||
exists.
|
||||
|
||||
## Object and digest rules
|
||||
|
||||
Every present layer digest is `sha256:<lowercase-hex>`:
|
||||
|
||||
- HEAD regular/symlink: SHA-256 of the exact Git blob bytes. HEAD directory:
|
||||
SHA-256 of the exact raw Git tree bytes. HEAD gitlink: SHA-256 of the ASCII
|
||||
object ID stored by the tree.
|
||||
- Index regular/symlink: SHA-256 of the stage-0 Git blob bytes. Index gitlink:
|
||||
SHA-256 of its ASCII object ID. The index has no directory layer. Any
|
||||
non-stage-0 entry is rejected.
|
||||
- Tracked worktree regular: raw file bytes, opened without following symlinks.
|
||||
Symlink: raw link-target bytes. Gitlink: ASCII object ID at the checked-out
|
||||
nested HEAD, but only after `rev-parse --show-toplevel` proves that the
|
||||
directory itself is the nested repository root, `HEAD` resolves there, and
|
||||
porcelain v2 reports no staged, unstaged, untracked, or ignored nested changes. The
|
||||
same root, HEAD, and clean-status proof is repeated by the mutation guard. A
|
||||
dirty, empty, uninitialized, or parent-falling-through gitlink fails closed.
|
||||
Directory: the v1 directory stream described below.
|
||||
- A path absent from both HEAD and index places the filesystem object in the
|
||||
`untracked` layer and marks `worktree` absent. A Git-backed path places it in
|
||||
`worktree` and marks `untracked` absent. A missing layer uses literal
|
||||
`absent` for both kind and digest; an empty file is the SHA-256 of zero bytes.
|
||||
|
||||
Filesystem directory bytes use prefix fields
|
||||
`gitnexus-evidence-directory`, `schema_version`, `1`, the same NUL framing,
|
||||
and recursive entries sorted by unsigned UTF-8 relative-path bytes. Each entry
|
||||
has fixed fields `path`, `kind`, `digest`. A single bottom-up filesystem walk
|
||||
visits each node once and returns each child digest plus the flattened subtree
|
||||
needed to preserve those canonical bytes; links are never followed. When the
|
||||
directory is proven to be an exact nested Git top-level, only its administrative
|
||||
`.git` entry is excluded. Every other child, including working files and nested
|
||||
directories, remains evidence.
|
||||
|
||||
Each directory object is bounded to 10,000 visited entries, depth 256, and 256
|
||||
MiB of regular-file content. Exceeding a bound fails closed. These bounds apply
|
||||
independently to each top-level directory object materialized by a record.
|
||||
|
||||
HEAD objects are read only from the full object ID captured at snapshot start;
|
||||
the symbolic `HEAD` name is never re-resolved for layers. Index layers are
|
||||
parsed from one captured stage-0 listing. The helper guards the corresponding
|
||||
HEAD/ref/reflog controls and raw index file, compares the captured listing at
|
||||
the end, and rejects ordinary A-to-B-to-A mutations instead of accepting
|
||||
mixed-era layers.
|
||||
|
||||
Regular files are read through an `O_NOFOLLOW` descriptor with before/after
|
||||
identity checks. Symlinks use lstat/readlink/lstat; directories record identity
|
||||
before and after their inventory. The helper also compares raw porcelain-v2
|
||||
status and HEAD at the start and end, then rechecks filesystem guards. An
|
||||
absent cited path holds a no-follow descriptor for the nearest existing parent
|
||||
and records the first missing component or leaf; that anchored absence is
|
||||
checked both before and after the final Git status pass, so a newly created
|
||||
ignored path cannot evade porcelain. Any observed race rejects the snapshot
|
||||
rather than emitting mixed-era evidence.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to
|
|||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-pr-review`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
---
|
||||
name: gitnexus-pr-review
|
||||
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
|
||||
---
|
||||
|
||||
# PR Review with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Review this PR"
|
||||
- "What does PR #42 change?"
|
||||
- "Is this safe to merge?"
|
||||
- "What's the blast radius of this PR?"
|
||||
- "Are there missing tests for this PR?"
|
||||
- Reviewing someone else's code changes before merge
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gh pr diff <number> → Get the raw diff
|
||||
2. detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
|
||||
3. For each changed symbol:
|
||||
impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
|
||||
4. context({name: "<key symbol>"}) → Understand callers/callees
|
||||
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
6. Summarize findings with risk assessment
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
|
||||
- [ ] detect_changes to map changes to affected execution flows
|
||||
- [ ] impact on each non-trivial changed symbol
|
||||
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
|
||||
- [ ] context on key changed symbols to understand full picture
|
||||
- [ ] Check if affected processes have test coverage
|
||||
- [ ] Assess overall risk level
|
||||
- [ ] Write review summary with findings
|
||||
```
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
| Dimension | How GitNexus Helps |
|
||||
| --- | --- |
|
||||
| **Correctness** | `context` shows callers — are they all compatible with the change? |
|
||||
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
|
||||
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
|
||||
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
|
||||
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Signal | Risk |
|
||||
| --- | --- |
|
||||
| Changes touch <3 symbols, 0-1 processes | LOW |
|
||||
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
|
||||
| Changes touch >10 symbols or many processes | HIGH |
|
||||
| Changes touch auth, payments, or data integrity code | CRITICAL |
|
||||
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
|
||||
|
||||
## Tools
|
||||
|
||||
**detect_changes** — map PR diff to affected execution flows:
|
||||
|
||||
```
|
||||
detect_changes({scope: "compare", base_ref: "main"})
|
||||
|
||||
→ Changed: 8 symbols in 4 files
|
||||
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**impact** — blast radius per changed symbol:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream"})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
|
||||
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**impact with tests** — check test coverage:
|
||||
|
||||
```
|
||||
impact({target: "validatePayment", direction: "upstream", includeTests: true})
|
||||
|
||||
→ Tests that cover this symbol:
|
||||
- validatePayment.test.ts [direct]
|
||||
- checkout.integration.test.ts [via processCheckout]
|
||||
```
|
||||
|
||||
**context** — understand a changed symbol's role:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates
|
||||
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
|
||||
```
|
||||
|
||||
## Example: "Review PR #42"
|
||||
|
||||
```
|
||||
1. gh pr diff 42 > /tmp/pr42.diff
|
||||
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
|
||||
|
||||
2. detect_changes({scope: "compare", base_ref: "main"})
|
||||
→ Changed symbols: validatePayment, PaymentInput, formatAmount
|
||||
→ Affected processes: CheckoutFlow, RefundFlow
|
||||
→ Risk: MEDIUM
|
||||
|
||||
3. impact({target: "validatePayment", direction: "upstream"})
|
||||
→ d=1: processCheckout, webhookHandler (WILL BREAK)
|
||||
→ webhookHandler is NOT in the PR diff — potential breakage!
|
||||
|
||||
4. impact({target: "PaymentInput", direction: "upstream"})
|
||||
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
|
||||
→ createPayment uses the old PaymentInput shape — breaking change!
|
||||
|
||||
5. context({name: "formatAmount"})
|
||||
→ Called by 12 functions — but change is backwards-compatible (added optional param)
|
||||
|
||||
6. Review summary:
|
||||
- MEDIUM risk — 3 changed symbols affect 2 execution flows
|
||||
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
|
||||
- BUG: createPayment depends on PaymentInput type which changed
|
||||
- OK: formatAmount change is backwards-compatible
|
||||
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
|
||||
```
|
||||
|
||||
## Review Output Format
|
||||
|
||||
Structure your review as:
|
||||
|
||||
```markdown
|
||||
## PR Review: <title>
|
||||
|
||||
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
|
||||
|
||||
### Changes Summary
|
||||
- <N> symbols changed across <M> files
|
||||
- <P> execution flows affected
|
||||
|
||||
### Findings
|
||||
1. **[severity]** Description of finding
|
||||
- Evidence from GitNexus tools
|
||||
- Affected callers/flows
|
||||
|
||||
### Missing Coverage
|
||||
- Callers not updated in PR: ...
|
||||
- Untested flows: ...
|
||||
|
||||
### Recommendation
|
||||
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
|
||||
```
|
||||
228
gitnexus-cursor-integration/skills/gitnexus-review/SKILL.md
Normal file
228
gitnexus-cursor-integration/skills/gitnexus-review/SKILL.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
name: gitnexus-review
|
||||
description: 'Review code changes with GitNexus from a GitHub PR URL or number, a branch/ref or commit range, or local staged, unstaged, and untracked changes. Use when the user asks for a code review, merge-risk assessment, regression hunt, missing-test analysis, or a verdict on whether a PR, branch, commit range, or local diff is safe.'
|
||||
---
|
||||
|
||||
# GitNexus review
|
||||
|
||||
Review the requested change surface without editing source, committing, pushing,
|
||||
posting, or resolving threads. A later explicit request may authorize those
|
||||
actions. Use GitNexus for structural evidence and source inspection for proof;
|
||||
neither substitutes for the other.
|
||||
|
||||
## Resolve the target
|
||||
|
||||
Accept these forms:
|
||||
|
||||
| Input | Review surface |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| PR URL, `owner/repo#42`, `#42`, or bare number | GitHub PR |
|
||||
| `base...head` | Merge-base range |
|
||||
| `base..head` | Exact two-dot range |
|
||||
| Branch, tag, or commit | Ref against the repository default branch |
|
||||
| `local`, `staged`, `unstaged`, or working-tree wording | Local changes |
|
||||
| No target | Current branch's open PR; otherwise local changes; otherwise current branch |
|
||||
|
||||
An explicit target always wins. Interpret a bare number as a PR only in a
|
||||
GitHub repository with working `gh` authentication; otherwise ask for a ref or
|
||||
URL. If implicit mode finds both branch commits and local changes, review them
|
||||
as two labeled surfaces rather than silently dropping or blending either one.
|
||||
|
||||
Record the resolved target kind, repository root, default branch, base SHA,
|
||||
head SHA, merge-base when applicable, and included local states. Resolve the
|
||||
default branch from remote metadata (`refs/remotes/<remote>/HEAD` or GitHub
|
||||
repository metadata); use `main` or `master` only as an explicit fallback and
|
||||
say when doing so.
|
||||
|
||||
### PR
|
||||
|
||||
Use `gh pr view`/`gh api` to pin the PR number, repository, title, URL, base
|
||||
ref, base SHA, head ref, and head SHA. Fetch those exact commits without
|
||||
switching the user's branch. Compute `git merge-base <base> <head>` and use
|
||||
that SHA as the review base: GitHub PR diffs are merge-base diffs, while
|
||||
`detect_changes(scope: "compare")` is a two-dot comparison.
|
||||
|
||||
Use the local `git diff <merge-base> <head>` as the complete diff source of
|
||||
truth; use GitHub metadata for PR facts and review state. For fork PRs, fetch
|
||||
the pull ref or the contributor remote instead of assuming the head branch
|
||||
exists on `origin`.
|
||||
|
||||
### Branch, ref, or range
|
||||
|
||||
Resolve every ref to a commit before reviewing. For a branch or `A...B`, use
|
||||
the merge-base as the comparison base. For an explicit `A..B`, honor `A` as
|
||||
the exact base. Do not compare a feature branch directly with a moving default
|
||||
branch tip when merge-base semantics were intended.
|
||||
|
||||
### Local changes
|
||||
|
||||
Inspect `git status --short`, the staged diff, the unstaged diff, and every
|
||||
untracked file. Use `detect_changes` with `staged`, `unstaged`, or `all` as
|
||||
requested. Untracked files are not guaranteed to appear in Git diff or graph
|
||||
mapping, so read them directly and list them in the review provenance.
|
||||
|
||||
## Align the checkout and index
|
||||
|
||||
The graph and diff must describe the same head. Reuse an existing worktree only
|
||||
when it is at the exact target SHA. Otherwise create a temporary detached
|
||||
worktree for the PR/ref head, review there, and remove only that temporary
|
||||
worktree afterward. Never switch or reset the user's current worktree.
|
||||
|
||||
Check GitNexus status in the target worktree. If stale, run
|
||||
`node .gitnexus/run.cjs analyze --index-only` before trusting graph results
|
||||
(temporary worktrees never carry the gitignored `run.cjs` — fall back to the
|
||||
installed `gitnexus` CLI, then `npx gitnexus`), and include `--pdg` in that
|
||||
same refresh when the diff plausibly touches trust or data-flow boundaries,
|
||||
so the taint pass below doesn't pay a second full analyze. Taint and
|
||||
dependence evidence needs that PDG layer: when the workflow's taint pass
|
||||
finds it missing, rebuild with `analyze --pdg --index-only` and record the
|
||||
rebuild in provenance. For local changes, refresh the index so new or
|
||||
modified source is represented.
|
||||
If an exact target checkout/index cannot be established, state the limitation
|
||||
and do not claim a complete graph-backed review.
|
||||
|
||||
## Review workflow
|
||||
|
||||
1. Read the full diff and changed-file list. Separate generated files,
|
||||
dependency churn, tests, and behavior changes.
|
||||
2. Run `detect_changes` against the exact surface:
|
||||
- PR/branch/`...`: `scope: "compare"`, `base_ref: <merge-base SHA>`.
|
||||
- Explicit `A..B`: `scope: "compare"`, `base_ref: <A SHA>` from a worktree
|
||||
at `B`.
|
||||
- Local: `scope: "staged"`, `"unstaged"`, or `"all"`.
|
||||
Pass `worktree` when the MCP server is attached elsewhere.
|
||||
3. Run upstream `impact` with `includeTests: true` for each behaviorally changed
|
||||
symbol. Prioritize public contracts, shared types, control flow, persistence,
|
||||
security boundaries, and error handling; skip mechanical/generated changes.
|
||||
4. Inspect every direct (`d=1`) dependent that is outside the diff. A dependent
|
||||
outside the diff is a lead, not automatically a bug—verify the changed
|
||||
contract and caller behavior in source.
|
||||
5. Use `context` on key or ambiguous symbols and inspect affected execution
|
||||
flows. Read the surrounding implementation and tests at cited locations.
|
||||
6. **Taint and dependence pass.** For changed code on trust or data-flow
|
||||
boundaries — external input, persistence, process execution, network,
|
||||
auth — run `explain` on the changed files or symbols and judge its
|
||||
source→sink taint findings against the diff: a flow the change
|
||||
introduces, or a sanitizer/guard the change removes, is a finding; a
|
||||
pre-existing flow is context, not a defect of this change. When the
|
||||
change claims to guard or sanitize something, verify with `pdg_query`:
|
||||
what controls the changed statement, and where its values flow. This
|
||||
needs a `--pdg` index; if one cannot be built, state that the taint pass
|
||||
was skipped rather than implying coverage.
|
||||
7. Check whether tests exercise the changed behavior, boundary conditions, and
|
||||
affected flows. Run focused read-only validation when practical. When the
|
||||
diff refreshes a committed baseline, fingerprint, or golden, re-run the
|
||||
exact CI check command against the head instead of trusting the committed
|
||||
value — a stale artifact is invisible in the diff and fails only in CI.
|
||||
8. Reconcile graph evidence with the raw diff. New files, dynamic dispatch,
|
||||
configuration, reflection, and untracked content may require direct review
|
||||
even when graph results are empty. Version and invalidation constants are
|
||||
review surface: when the diff changes what gets emitted or persisted,
|
||||
verify every schema/version constant gating caches, incremental
|
||||
writebacks, and fingerprint baselines was bumped or regenerated — in
|
||||
GitNexus itself, for example: `INCREMENTAL_SCHEMA_VERSION` (the
|
||||
incremental write set covers only changed files, so new cross-file edges
|
||||
never reach an existing index without the bump), the parse-store
|
||||
`SCHEMA_BUMP`, and both bench fingerprint sets.
|
||||
|
||||
## Expert lenses
|
||||
|
||||
Depth comes from matching reviewers to what actually changed, not from one
|
||||
generalist pass. After workflow step 2, group the changed files and symbols
|
||||
by the functional areas the graph already knows — the index's cluster
|
||||
listing; `context` names each symbol's cluster — and give each touched area
|
||||
an expert lens: a reviewer charged with that domain's contracts, invariants,
|
||||
and failure modes, grounded in the repo's own material (architecture docs,
|
||||
agent rules, the domain's tests) before judging the diff. A lens verifies,
|
||||
not just reads: when the changed code is a pure function reachable from the
|
||||
repo's own toolchain — parsers, extractors, capture emitters, formatters —
|
||||
execute it on the candidate failing shape (a scratch probe, deleted
|
||||
afterward) and cite the observed output. An empirical probe outranks source
|
||||
reading in the evidence hierarchy; role swaps, dead branches, and
|
||||
error-recovery-dependent behavior repeatedly pass a reading and fail a
|
||||
ten-line probe. The numbered
|
||||
workflow runs exactly once; dispatch the lens passes after step 6, handing
|
||||
each lens the evidence already collected rather than letting lenses repeat
|
||||
the `impact`, `context`, or taint calls. In GitNexus
|
||||
itself, for example: shared ingestion-pipeline changes get an ingestion
|
||||
expert plus one language expert per changed language extractor; embeddings
|
||||
changes an embeddings expert; LadybugDB/storage changes a Ladybug expert.
|
||||
|
||||
Four cross-cutting lenses run regardless of domain:
|
||||
|
||||
- **Architectural fit** — the change lands where the architecture says the
|
||||
concern lives, reuses existing seams, and adds no parallel structure.
|
||||
- **Language conformance** — the repo's own type/lint/test contract as
|
||||
configured (tsconfig strictness, lint rules, test conventions); in a
|
||||
strict TypeScript repo, for example: strictness intact, no `any`/`as any`
|
||||
escapes, module boundaries typed. Judge by the repo's contract, never a
|
||||
universal style bar.
|
||||
- **Definition of Done** — changed behavior has tests, docs the change makes
|
||||
stale are updated, and sync/drift guards (shipped copies, manifests,
|
||||
changelogs) still hold.
|
||||
- **Simplicity** — YAGNI and clear-code check: flag speculative abstraction,
|
||||
unused knobs, and overengineering; the smallest diff that meets the
|
||||
Definition of Done is the standard.
|
||||
|
||||
Scale effort to the surface: a single-domain change of a few files gets one
|
||||
combined pass covering its domain lens plus the four cross-cutting checks;
|
||||
a multi-domain change gets one lens per touched area — run as parallel
|
||||
subagents where the harness supports them, each scoped to its own files
|
||||
plus the shared graph evidence, and as sequential passes otherwise. Never
|
||||
spawn a lens for a domain the diff does not touch. Merge lenses that ground
|
||||
in the same material — two lenses reading the same files pay twice for one
|
||||
read's coverage, so give one reviewer both charges. Where the harness
|
||||
offers model or effort tiers, run mechanical lenses (rename sweeps,
|
||||
doc-consistency checks) on a cheaper tier and reserve the strongest engine
|
||||
for adversarial judgment. Every lens reports
|
||||
through the Finding standard below; merge and dedup before the verdict,
|
||||
dropping anything without a concrete failing scenario.
|
||||
|
||||
## Finding standard
|
||||
|
||||
Report a finding only when the reviewed change introduces a concrete defect,
|
||||
regression, security issue, compatibility break, material coverage gap, or a
|
||||
maintainability cost with a concrete carrying scenario (a dead knob, a
|
||||
duplicated contract, a drift-prone copy).
|
||||
Each finding must include:
|
||||
|
||||
- severity and a precise `path:line` anchor;
|
||||
- the failing scenario or contract;
|
||||
- GitNexus evidence (dependent symbol/process) when applicable;
|
||||
- why existing code or tests do not mitigate it;
|
||||
- a concise remediation or missing test.
|
||||
|
||||
Do not report style preferences, pre-existing issues, raw risk counts, or
|
||||
speculation as defects. Do not infer safety from zero graph hits. Calibrate
|
||||
overall risk from consequence, reachability, reversibility, and test evidence,
|
||||
not from the number of changed symbols alone.
|
||||
|
||||
## Output
|
||||
|
||||
Lead with findings in severity order. If there are none, say so explicitly.
|
||||
Then provide:
|
||||
|
||||
```markdown
|
||||
## Review: <target>
|
||||
|
||||
### Findings
|
||||
|
||||
- [HIGH|MEDIUM|LOW] `path:line` — <problem, evidence, impact, remediation>
|
||||
|
||||
### Change and blast-radius summary
|
||||
|
||||
- Target/base/head/merge-base and local states reviewed
|
||||
- Changed symbols and affected execution flows
|
||||
|
||||
### Coverage and residual risk
|
||||
|
||||
- Tests present, tests missing, graph/diff limitations
|
||||
|
||||
### Verdict
|
||||
|
||||
APPROVE | REQUEST CHANGES | NEEDS DISCUSSION
|
||||
```
|
||||
|
||||
For a branch or local review, use `READY`, `NOT READY`, or `NEEDS DISCUSSION`
|
||||
instead of a PR approval action. Include the exact target SHAs so a later run
|
||||
can tell whether the evidence is stale.
|
||||
|
|
@ -332,6 +332,9 @@ GitNexus ships with skill files that teach AI agents how to use the tools effect
|
|||
- **Refactoring** — Plan safe refactors using dependency mapping
|
||||
- **Guide** — GitNexus tool/resource/schema reference for the agent
|
||||
- **CLI** — Run analyze/status/clean/wiki commands on request
|
||||
- **PDG Query** — Statement-level control/data dependence queries (`--pdg` index)
|
||||
- **Taint Analysis** — Source→sink data-flow findings (`--pdg` index)
|
||||
- **Plan / Work / Review / LFG** — The engineering family: implementation-ready plans, gated plan execution, graph-backed change review with taint + expert lenses, and the end-to-end pipeline
|
||||
|
||||
Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setup` (global). Run `gitnexus analyze --skills` to additionally generate each detected functional area as a direct project skill under `.claude/skills/gitnexus-area-<name>/`.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* `publish.yml` bumps only `gitnexus/package.json` when it cuts an RC, so
|
||||
* every RC tag through v1.6.10-rc.28 shipped manifests frozen at the last
|
||||
* stable version and failed its own unit suite (the cli-commands version
|
||||
* contract). This script pins all four manifest surfaces to the package
|
||||
* version:
|
||||
* contract). This script pins every version-bearing plugin surface to the
|
||||
* package version:
|
||||
*
|
||||
* - gitnexus-claude-plugin/.claude-plugin/plugin.json (top-level version)
|
||||
* - .claude-plugin/marketplace.json (plugins[gitnexus])
|
||||
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
|
||||
* - .agents/plugins/marketplace.json (plugins[gitnexus])
|
||||
* - gitnexus-claude-plugin/skills/<skill>/mcp.json (gitnexus@<version> launch arg, x10)
|
||||
*
|
||||
* Modes:
|
||||
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
|
||||
|
|
@ -25,11 +26,34 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Plugin skill mcp.json are executable MCP definitions (they launch
|
||||
// `npx -y gitnexus@<version> mcp` when a skill starts), not quickstart docs, so
|
||||
// they must ship pinned to the released version and be auto-stamped here like
|
||||
// the other surfaces — otherwise every skill invocation pulls whatever owns
|
||||
// `gitnexus@latest`, independent of the reviewed plugin version. All ten are
|
||||
// kept byte-identical (the shipped-skills-sync drift guard enforces it).
|
||||
const PLUGIN_SKILL_MCP_DIRS = [
|
||||
'gitnexus-plan',
|
||||
'gitnexus-work',
|
||||
'gitnexus-review',
|
||||
'gitnexus-lfg',
|
||||
'gitnexus-guide',
|
||||
'gitnexus-cli',
|
||||
'gitnexus-debugging',
|
||||
'gitnexus-exploring',
|
||||
'gitnexus-impact-analysis',
|
||||
'gitnexus-refactoring',
|
||||
];
|
||||
|
||||
const MANIFEST_SURFACES = [
|
||||
{ file: 'gitnexus-claude-plugin/.claude-plugin/plugin.json', kind: 'plugin' },
|
||||
{ file: '.claude-plugin/marketplace.json', kind: 'marketplace' },
|
||||
{ file: 'gitnexus-claude-plugin/.codex-plugin/plugin.json', kind: 'plugin' },
|
||||
{ file: '.agents/plugins/marketplace.json', kind: 'marketplace' },
|
||||
...PLUGIN_SKILL_MCP_DIRS.map((name) => ({
|
||||
file: `gitnexus-claude-plugin/skills/${name}/mcp.json`,
|
||||
kind: 'mcp',
|
||||
})),
|
||||
];
|
||||
|
||||
const PLUGIN_NAME = 'gitnexus';
|
||||
|
|
@ -69,6 +93,33 @@ function versionTarget(manifest, kind, filePath) {
|
|||
return entries[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current pinned version and the textual needle for one surface.
|
||||
* `plugin`/`marketplace` pin a JSON `"version"` field; `mcp` pins the version
|
||||
* inside the `gitnexus@<version>` launch arg. Returns `{ from, needle }` where
|
||||
* `needle(v)` renders the exact substring to match/replace for version `v`.
|
||||
* Fail-closed on a missing/ambiguous target.
|
||||
*/
|
||||
function versionInfo(manifest, kind, filePath) {
|
||||
if (kind === 'mcp') {
|
||||
const server = manifest?.mcpServers?.[PLUGIN_NAME];
|
||||
const args = Array.isArray(server?.args) ? server.args : [];
|
||||
const pins = args.filter((arg) => typeof arg === 'string' && arg.startsWith(`${PLUGIN_NAME}@`));
|
||||
if (pins.length !== 1) {
|
||||
throw new Error(
|
||||
`Manifest surface ${filePath} must contain exactly one "${PLUGIN_NAME}@<version>" launch arg, found ${pins.length}`,
|
||||
);
|
||||
}
|
||||
const from = pins[0].slice(`${PLUGIN_NAME}@`.length);
|
||||
if (from.length === 0) {
|
||||
throw new Error(`Manifest surface ${filePath} has an empty ${PLUGIN_NAME}@ version`);
|
||||
}
|
||||
return { from, needle: (value) => `${PLUGIN_NAME}@${value}` };
|
||||
}
|
||||
const target = versionTarget(manifest, kind, filePath);
|
||||
return { from: target.version, needle: (value) => `"version": "${value}"` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync (or with `check: true`, only inspect) every manifest surface under
|
||||
* `rootDir`. Returns `{ version, synced, stale }` where `stale` lists the
|
||||
|
|
@ -86,26 +137,26 @@ export function syncPluginManifests(rootDir, { check = false } = {}) {
|
|||
for (const { file, kind } of MANIFEST_SURFACES) {
|
||||
const manifestPath = path.join(rootDir, file);
|
||||
const { raw, parsed } = readJson(manifestPath);
|
||||
const target = versionTarget(parsed, kind, manifestPath);
|
||||
if (target.version === version) continue;
|
||||
const { from, needle } = versionInfo(parsed, kind, manifestPath);
|
||||
if (from === version) continue;
|
||||
|
||||
stale.push({ file, from: target.version });
|
||||
stale.push({ file, from });
|
||||
if (check) continue;
|
||||
|
||||
// Textual surgery instead of re-serializing: JSON.stringify would refold
|
||||
// arrays and fight prettier, turning a one-line version bump into
|
||||
// formatting churn inside the release commit. The needle is built from
|
||||
// the parsed current version, and anything other than exactly one
|
||||
// the current pinned value, and anything other than exactly one
|
||||
// occurrence aborts rather than guessing.
|
||||
const needle = `"version": "${target.version}"`;
|
||||
const occurrences = raw.split(needle).length - 1;
|
||||
const currentNeedle = needle(from);
|
||||
const occurrences = raw.split(currentNeedle).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
throw new Error(
|
||||
`Manifest surface ${manifestPath} has ${occurrences} occurrences of ${needle}; ` +
|
||||
`Manifest surface ${manifestPath} has ${occurrences} occurrences of ${currentNeedle}; ` +
|
||||
'expected exactly one, refusing to sync',
|
||||
);
|
||||
}
|
||||
writeFileSync(manifestPath, raw.replace(needle, `"version": "${version}"`));
|
||||
writeFileSync(manifestPath, raw.replace(currentNeedle, needle(version)));
|
||||
synced.push(file);
|
||||
}
|
||||
|
||||
|
|
|
|||
55
gitnexus/skills/gitnexus-lfg/README.md
Normal file
55
gitnexus/skills/gitnexus-lfg/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin pipeline orchestrator over three existing skills: `gitnexus-plan`
|
||||
produces the plan (asking up front how deep to go), the user chooses at a
|
||||
blocking gate to proceed or stop (an explicit deepen request is still
|
||||
honored), `gitnexus-work` executes it as verified atomic commits, and
|
||||
`gitnexus-review` reviews the result (the open PR if one exists, else the
|
||||
branch diff against the default branch). One bounded fix cycle for review
|
||||
findings, then a final report. It never pushes or opens a PR on its own.
|
||||
|
||||
## Invocation
|
||||
|
||||
| CLI | How to invoke |
|
||||
|-----|---------------|
|
||||
| **Claude Code** | `/gitnexus-lfg <task description>` or `/gitnexus-lfg docs/plans/<plan>.md` |
|
||||
| **Codex CLI** | Ask: "run the gitnexus pipeline on <task>" (Codex reads `AGENTS.md`), or install the skill user-level (below) |
|
||||
|
||||
### Codex (user-level install)
|
||||
|
||||
```
|
||||
cp -r .claude/skills/gitnexus-lfg ~/.agents/skills/gitnexus-lfg
|
||||
```
|
||||
|
||||
Optionally, for an explicit slash command, create
|
||||
`~/.codex/prompts/gitnexus-lfg.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: GitNexus pipeline — plan (depth asked up front), user gate, work, PR review
|
||||
argument-hint: <task description or plan path>
|
||||
---
|
||||
Use the gitnexus-lfg skill for: $ARGUMENTS
|
||||
|
||||
Read `~/.agents/skills/gitnexus-lfg/SKILL.md` (prefer the repo copy at
|
||||
`.claude/skills/gitnexus-lfg/SKILL.md` when present) and follow its lanes in
|
||||
order, invoking the real gitnexus-plan / gitnexus-work / gitnexus-review
|
||||
skills for each lane. Stop at the plan gate for the user's choice.
|
||||
```
|
||||
|
||||
## The three lanes
|
||||
|
||||
| Lane | Skill | Gate |
|
||||
|------|-------|------|
|
||||
| Plan | `gitnexus-plan` (`.claude/skills/gitnexus-plan/`) | Depth asked up front; blocking gate: proceed / stop |
|
||||
| Work | `gitnexus-work` (`.claude/skills/gitnexus-work/`) | Structural drift routes back to the plan gate |
|
||||
| Review | `gitnexus-review` (`.claude/skills/gitnexus-review/`) | One fix cycle max, then report |
|
||||
|
||||
## Threshold governance (maintainers)
|
||||
|
||||
The Lane 1 planning boundary (~35 turns) is a promoted benchmark policy from
|
||||
the GitNexus repository's `eval/workflow_bench/` paired candidate loop.
|
||||
Re-evaluate it offline whenever the named model or tool harness changes, and
|
||||
at least every 90 days; update the SKILL.md threshold only after the
|
||||
deterministic promotion gate shows no quality regression. Reading agents
|
||||
never self-edit it from a live task.
|
||||
86
gitnexus/skills/gitnexus-lfg/SKILL.md
Normal file
86
gitnexus/skills/gitnexus-lfg/SKILL.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
name: gitnexus-lfg
|
||||
description: "Use when the user wants the GitNexus engineering pipeline run end-to-end on a task: gitnexus-plan (plan depth chosen up front), a blocking gate to execute with gitnexus-work or stop, finishing with a gitnexus-review of the result. Examples: \"/gitnexus-lfg Add retry support to the ingestion pipeline\", \"run the gitnexus pipeline on this\", \"plan, build and review this feature\"."
|
||||
---
|
||||
|
||||
# gitnexus-lfg — plan → gate → work → review
|
||||
|
||||
Thin orchestrator over three existing skills. It adds no engineering logic of
|
||||
its own — it sequences `gitnexus-plan`, `gitnexus-work`, and
|
||||
`gitnexus-review`, with the user deciding at the plan gate. Run every lane
|
||||
by actually invoking the named skill (read its SKILL.md and follow it);
|
||||
never inline a summary of what the skill would have done.
|
||||
|
||||
```
|
||||
/gitnexus-lfg <task description>
|
||||
/gitnexus-lfg docs/plans/<existing-plan>.md # skip lane 1, start at the gate
|
||||
```
|
||||
|
||||
## Lane 1 — Plan
|
||||
|
||||
**Boundary triage first.** If the task is plainly below the planning
|
||||
boundary — trivial or small-bounded work an agent finishes in well under ~35
|
||||
turns (the measured regime where a planning pass costs more than it returns;
|
||||
measured in the GitNexus repository's `eval/workflow_bench/`) — say so and
|
||||
offer `gitnexus-work` direct mode as an alternative to the full pipeline
|
||||
before spending the plan lane. Honor the user's choice.
|
||||
|
||||
The threshold is a promoted benchmark policy measured offline, not a
|
||||
timeless heuristic — never self-edit it from a live task. Its re-evaluation
|
||||
governance lives in this skill's README.
|
||||
|
||||
Otherwise invoke `gitnexus-plan` with the task (knob overrides pass through
|
||||
verbatim; `gitnexus-plan` owns the up-front depth question — never ask it
|
||||
again here). If the input is already a plan file path, skip to Lane 2. The
|
||||
plan lands in `docs/plans/` — record its path; every later lane consumes it.
|
||||
|
||||
## Lane 2 — The plan gate (user choice, blocking)
|
||||
|
||||
Present the plan's chat summary (objective, proposed changes, sequence, top
|
||||
risks, open questions, plan path), then ask the user — as a blocking
|
||||
question (`AskUserQuestion` in Claude Code; a numbered list in chat on CLIs
|
||||
without a blocking tool):
|
||||
|
||||
1. **Proceed to work** — continue to Lane 3.
|
||||
2. **Stop here** — the plan file is the deliverable; end the pipeline.
|
||||
|
||||
Depth was the user's up-front choice in Lane 1, so deepening is not offered
|
||||
by default — but honor an explicit request for it at the gate: run
|
||||
`gitnexus-plan` Deepen mode on the plan file and return here with the
|
||||
strengthened plan, as many times as the user asks. Do not proceed past the
|
||||
gate without an explicit choice — the gate is the pipeline's only checkpoint
|
||||
and exists precisely because execution is expensive to unwind.
|
||||
|
||||
**Headless / non-interactive runs:** no one can answer the gate, so end the
|
||||
pipeline after Lane 1 — the plan file is the deliverable (gate option 2) —
|
||||
and say so in the final report. Never auto-proceed to execution.
|
||||
|
||||
## Lane 3 — Work
|
||||
|
||||
Invoke `gitnexus-work` with the plan path. It re-anchors the plan at HEAD,
|
||||
executes the Implementation Sequence as verified atomic commits, refreshes
|
||||
the knowledge graph when done (its Phase 4), and reports deviations. If it routes back for re-planning (structural drift), run the
|
||||
Deepen pass and return to the Lane 2 gate rather than pushing through.
|
||||
|
||||
## Lane 4 — Review
|
||||
|
||||
Invoke `gitnexus-review` on the completed work. Pass an open PR URL/number
|
||||
when one exists; otherwise pass the current branch. The review skill owns
|
||||
target resolution, exact-SHA checkout/index alignment, and merge-base
|
||||
selection. Do not duplicate that logic here. If work left local changes,
|
||||
pass `local` as a second, separately labeled review surface.
|
||||
|
||||
Surface the review verdict and findings to the user. Findings the user
|
||||
wants fixed: those within `gitnexus-work`'s direct-mode bounds (1–2 files,
|
||||
no architectural decisions) → hand to `gitnexus-work` direct mode; anything
|
||||
larger → offer the plan gate instead (Deepen the plan with the findings, or
|
||||
stop). Then re-run this lane's review once. On that re-run, do not start
|
||||
another fix cycle even if findings remain — report them and point the user
|
||||
at `/gitnexus-work` (or the plan gate) to continue deliberately.
|
||||
|
||||
## Final report
|
||||
|
||||
One message: plan path, deepen cycles run, commits produced, verification
|
||||
status, review verdict with unresolved findings, and what (if anything) was
|
||||
explicitly left undone. The pipeline does not push or open a PR on its own —
|
||||
offer both as next steps.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue