agentleFS
Servant-Software-LLCServant-Software-LLC/Guardrails/.claude/skills/guardrails-dev-knowledge/SKILL.md

guardrails-dev-knowledge

Skill2 starsChanged 2 months agoMITView on GitHub

Guardrails repo development knowledge: solution layout, build/test/run commands, dotnet-tool packaging, testing conventions and gotchas, dogfooding safety rules. Use when implementing, testing, running, or packaging the harness, or onboarding an agent to the codebase.

  • Reads credentials
  • Deletes or force-pushes
  • Commits and pushes

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use SafeDelete.DeleteDirectory (link-safe + #109 read-only), GitWorktreeProvider.RemoveWorktreeLinkSafe, ResetSegment / ResetHardLinkSafe …

    line 419
  2. Very specific:

    When the harness executes a plan that builds THIS repo: guardrail scripts must build from source (dotnet build/dotnet run --project …

    line 440

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Guardrails Dev Knowledge

Solution layout

Guardrails.sln                  # classic .sln (NOT .slnx — kept for tooling compatibility)
global.json                     # floor SDK 10.0.100, rollForward latestMajor — local == CI
src/Guardrails.Core/            # Model, Loading, Graph, Execution, State, Journal, Prompts — NO UI deps
src/Guardrails.Cli/             # the dotnet tool: PackAsTool, ToolCommandName=guardrails
tests/Guardrails.Core.Tests/    # unit — fake runners/probes, TestData fixture folders
tests/Guardrails.Integration.Tests/  # real processes; plan builders in temp dirs
examples/hello-guardrails/      # golden example: runnable demo + acceptance fixture
.github/workflows/ci.yml        # 3-OS matrix: windows/ubuntu/macos-latest

TFM net10.0 everywhere (retargeted from net8.0 in #417 — .NET 8 LTS ends Nov 2026); CLI has <RollForward>LatestMajor</RollForward>, which future-proofs against .NET 11+. TreatWarningsAsErrors everywhere, with the SDK analyzer wave pinned at AnalysisLevel 10.0 in Directory.Build.props. NuGet: System.CommandLine 2.0.9 (GA API: SetAction/GetRequiredValue), Spectre.Console (CLI only, behind IRunObserver), YamlDotNet (Core, frontmatter), xunit.v3.

Packaging (dotnet tool)

  • PackageId ServantSoftware.Guardrails (org convention — NOT bare guardrails); ToolCommandName stays guardrails (that is the invoked command). Package metadata (Authors/Company Servant Software LLC, Description, PackageLicenseExpression MIT with a root LICENSE, RepositoryUrl, PackageTags, README packed) lives in src/Guardrails.Cli/Guardrails.Cli.csproj. Version: 1.1.0 — the default a locally built tool reports (and stamps into installed skills); the RELEASED version is tag-derived (-p:Version=${GITHUB_REF_NAME#v}) and overrides it.
  • Release versioning: stable vX.Y.0, no prerelease suffix (issue #421). The tag is the scheme; cut-release is the procedure. Consumers install with a plain dotnet tool install -g ServantSoftware.Guardrails — --prerelease is no longer needed anywhere. The line starts at 1.1.0 because the published history ends at 1.0.0-preview.49 and a 0.x restart would sort BELOW it (major 0 < major 1) — i.e. a downgrade dotnet tool update would refuse. Full rationale: .claude/skills/cut-release/SKILL.md → Pick the version.
  • Local pack + acceptance (leaves the machine clean):
    dotnet pack src/Guardrails.Cli -c Release -o nupkg -p:Version=1.1.0-local   # nupkg/ is gitignored
    dotnet tool install --global --add-source ./nupkg ServantSoftware.Guardrails --version 1.1.0-local
    guardrails validate examples/hello-guardrails/hello-guardrails   # via the INSTALLED tool
    guardrails plan     examples/hello-guardrails/hello-guardrails
    dotnet tool uninstall -g ServantSoftware.Guardrails
    Stamp the local pack with a -local suffix (-p:Version=<csproj version>-local): with --add-source ./nupkg the restore sees the local folder AND nuget.org, so a bare --version 1.1.0 can silently install the PUBLISHED package instead of the bits you just built — which makes the acceptance run prove nothing. A suffix that was never published cannot collide. (Use dotnet tool update if a version is already installed.) The -local suffix is a dev-pack convention only — release tags never carry a suffix. Do NOT run the example's prompt tasks via the installed tool — no token spend.
  • Publish pipeline: .github/workflows/release.yml — on a pushed tag v*, the 3-OS test matrix gates, then the publish job packs with the version derived FROM the tag (-p:Version=${GITHUB_REF_NAME#v}) and pushes via Trusted Publishing (OIDC): the job has id-token: write, NuGet/login@v1 (input user: ${{ secrets.NUGET_USER }}) exchanges the OIDC token for a short-lived key, then dotnet nuget push. No NUGET_API_KEY secret — only a non-sensitive NUGET_USER (nuget.org profile name) plus a Trusted Publishing policy on nuget.org (Repository Owner Servant-Software-LLC, Repository Guardrails, Workflow File release.yml).
  • Bundled skills: the CLI csproj globs three skill folders (plan-breakdown incl. references/, guardrails-review, guardrails-domain-knowledge) as Content with CopyToOutputDirectory=PreserveNewest and a <Link>skills\<name>\%(RecursiveDir)…</Link>. They land in the build output under skills/ AND — because the dotnet-tool packer sweeps copy-to-output content — inside the nupkg at tools/net10.0/any/skills/..., i.e. next to the entry assembly (AppContext.BaseDirectory) for the installed global tool. Do NOT add explicit Pack=true/PackagePath on those items — the packer already includes them, so doing so duplicates the path and trips NU5118 (warning-as-error). guardrails skills install reads from AppContext.BaseDirectory/skills. Repo bootstrap: install.ps1 (root, tested) and install.sh (root, untested twin) verify dotnet, install/update the tool, then run guardrails skills install.
  • Skill-version stamping + --version drift (#152/#156/#169, docs/DEPLOYMENT.md §Skill versioning and drift detection): each skill's version now lives inside its own SKILL.md frontmatter, under metadata.guardrails-version (e.g. 1.1.0) — NOT in the separate .guardrails-skill-version sidecar of #152, which #156 replaced. The key is stamped at INSTALL time by guardrails skills install (via SkillFrontmatterStamper, using the tool's own version GuardrailsVersion.Current — a release fact, not author-typed): after copying each skill into the target, the command writes metadata.guardrails-version into the installed SKILL.md. The BUNDLED/published copies are intentionally unstamped and the repo-source .claude/skills/**/SKILL.md files stay clean — only the installed copy carries the version. (#169: stamping must happen at install, not build — PackAsTool packs a fresh dotnet publish of the unstamped source, so any build-time stamp on $(OutDir) never reached the .nupkg; the old StampSkillVersions.targets/Task was REMOVED.) The CLI tool version and an installed skill's version are independent — updating the tool does NOT refresh installed skills, because skills install SKIPS an already-present folder unless --force (the silent-skip trap: a stale /plan-breakdown keeps emitting legacy output for an older harness with no error — the incident a preview.25 harness produced a captureHashes/no-writeScope folder); a SKIPPED skill keeps its old/absent stamp (the drift signal), a --force reinstall re-stamps. guardrails --version surfaces drift: it keeps stdout as the bare version line (scripts parse it, unchanged) and writes a drift-warning block to stderr — exit code stays 0 — for any known skill found under ~/.claude/skills or ./.claude/skills whose metadata.guardrails-version is missing (unversioned) or ≠ the harness. (It reads INSTALLED skills; from the bundled dir it derives only folder names, never their version.) A git-TRACKED root is SOURCE, not an install, and is dropped from the report entirely (#461). That is this repo's own ./.claude/skills: authored there, packed into the tool, and unstamped BY DESIGN — so it is permanently "drifted" and any note about it would be noise on every --version. The discriminator is tracked, asked of git, not "inside a repo": an untracked install directory inside a working copy is still reported. The remedy names the root it applies to. One Remedy for <root> line per drifted root, carrying the command whose destination IS that root — bare / --project / --target <root> — resolved through the same ResolveTargetDir the install command uses, so the printed command cannot drift from where the command writes. (Before #461 a single fixed guardrails skills install --force was printed for every case; it writes to ~/.claude/skills, so for any other root it could not clear the warning it accompanied.) skills install --force overwrites the folder with a frontmatter-stamped copy and removes any orphaned preview.26 sidecar — but it REFUSES a destination that git tracks AND that is dirty, because --force deletes the whole folder first and a never-committed file is the one thing git cannot restore. --overwrite-tracked is the explicit override. The guard keys on the DESTINATION, not the flag spelling, so --target <repo>/.claude/skills --force is guarded too.

Commands

dotnet build "Guardrails.sln" -c Release          # 0 warnings tolerated
dotnet test  "Guardrails.sln" -c Release          # full suite; integration spawns real processes
dotnet run --project src/Guardrails.Cli -- validate <plan-folder>
dotnet run --project src/Guardrails.Cli -- run <plan-folder> --no-ui [--fresh]
dotnet run --project src/Guardrails.Cli -- plan|status|reset <plan-folder>
dotnet run --project src/Guardrails.Cli -- graph <plan-folder> [--check] [--stdout] [--format mermaid]
#   renders the task/guardrail DAG to <folder>/diagram.md (Mermaid); --check reports staleness
#   via a source-sha256 in the file's provenance comment (SSOT §10); --stdout writes nothing
dotnet run --project src/Guardrails.Cli -- skills install [--project] [--target <dir>] [--force]
#   `--project` → ./.claude/skills (else ~/.claude/skills); `install skills` is a hidden alias
dotnet pack src/Guardrails.Cli -c Release -o nupkg    # local tool package (bundles skills/)

Smoke test of record: run examples/hello-guardrails/hello-guardrails --fresh --no-ui (spends ~$1 of Claude tokens — prompt tasks; needs claude on PATH).

Conventions & gotchas (hard-won)

  • JSON manifests: System.Text.Json with ReadCommentHandling.Skip + AllowTrailingCommas — committed examples use // comments; don't break this.
  • Diagnostic codes: GR10xx loading, GR20xx validation (DiagnosticCodes.cs); tests assert codes; never renumber. GR2009 = prompt-runner command not on PATH (WARNING, not error). GR2022 = a guardrail/script-action reading a non-ancestor task's state key (#121); GR2023 = a prompt-runner maxOutputTokens ≤ 0 (#114). GR2025 (WARNING) = plan missing/stale a /guardrails-review marker (#79, SSOT §13) — surfaced at the CLI command layer (PlanValidator.ReviewMarkerDiagnostic), NOT inside PlanValidator.Validate. GR2024 (StagingOutputsInvalid) and GR2026 (StaleCoverageToken, the #157 stale-covers-key-behaviors warning, SSOT §4.4) are both TAKEN; GR2027–GR2030 are now allocated too — the four-folder/terminal-gate/model block (GR2027 malformed catches: in any of the four folders; GR2028 terminal <plan>/guardrails/ folder missing a real integration-set re-run; GR2029 retired integrationGate: true key; GR2030 invalid model value — SSOT §3.3/§2). GR2031 (InvalidAutonomyPolicy — an unrecognized autonomyPolicy value, SSOT §2.1/§7.2; generalized from the #274 Part C driftPolicy check in the M1 fold, #254/#269/#274) is TAKEN too. GR2032–GR2034 are the multi-wave block (GR2032 mixed flat/waved layout; GR2033 wave numbering; GR2034 cross-wave dependsOn — SSOT §14.1). GR2035 (DuplicateCheckName — two checks in ONE folder sharing a Name, e.g. 01-build.ps1+01-build.sh both → Name "01-build"; ERROR, per-folder across all four folders, #332/SSOT §4.5) and GR2036 (ExpectedDurationNonPositive — a guardrail sidecar's optional expectedDurationSeconds progress hint ≤ 0, SSOT §4.1.1, issue #331) are TAKEN too. GR2037 (BannedGuardrailPattern — a generated guardrail SCRIPT matches a known-bad regex from the data-driven banned-pattern registry references/banned-guardrail-patterns.json, embedded into Core + scanned over the four-folder script guardrails' comment-stripped bodies; ERROR, one per match; a CURATED set of three — #73 + #187a + #462 — grown only by reviewed addition, #346/SSOT §4.6) is TAKEN too. GR2071 (PromptInstructsUngrantedCommand — a task's action.prompt.md INSTRUCTS a shell command the task's own effective allowedTools refuse, so the agent hits a wall on its first turn; WARNING, static, #587 check A / SSOT §4.9) is TAKEN too. GR2072 (CheckSetPredatesSourceTree — the running binary predates checks the working tree declares; WARNING, validate only, self-hosting case only, #564/SSOT §16) is TAKEN too, and is the first code on this ladder that reports the TOOL rather than the plan. GR2080 (CursorRunnerUngoverned — a kind: "cursor" block runs with no tool allowlist or containment hook; WARNING, one per cursor block, #764/SSOT §9.9) is TAKEN too, and so are GR2081 (CursorApprovalModeInvalid — unknown cursor approvalMode, reported by the LOADER, or the key on a non-cursor block, reported by the VALIDATOR; ERROR, #767) and GR2082 (CursorApprovalFlagInExtraArgs — --force/--yolo/--auto-review in a cursor block's extraArgs; ERROR, #767). GR2084 (ClaudeGatewayBlockInvalid — a malformed claude gateway block, an owned env var in its env, or --settings in its extraArgs; ERROR), GR2085 (ClaudeModelNameToGateway — a Claude model name would reach a gateway block; WARNING) and GR2086 (ClaudeGatewayModelsShareEndpoint — two models share one gateway under maxParallelism > 1; WARNING) are TAKEN by #782/SSOT §9.10. GR2083 is RESERVED BY NAME for #544 (native local-inference actions) and is NOT free: #782 took the codes around it. Next free: GR1011 / GR2091 (GR2090 = WriteScopeExtensionlessFile, #838; GR2089 = StallTimeoutInGuardrailOverrides, GR2088 = StallTimeoutNegative, #811; GR2087 = GuardrailOverridesKeyIgnored, #782; GR2083 reserved for #544) — and DiagnosticCodes.cs WINS, so re-verify there before allocating. GR1010 is taken (WaveFolderIsNotALoadablePlan, #472); GR2038–GR2059 and GR2062–GR2071 are taken. RESERVED BY NAME and not to be re-used: GR2051–GR2054 (model tiering, doc 17 §13.2), GR2060 + GR2062 (doc 19), GR2061 (doc 18), GR2063 (doc 20). The two ladders advance INDEPENDENTLY — a note stating only one of them is half a fact, which is how a sibling skill came to claim "GR1010 / GR2055" long after both were taken.
  • Sorts are ordinal everywhere (guardrail order, task folders) — locale bugs.
  • Atomic writes (AtomicFile) for anything resume reads (state.json, run.json).
  • Process spawning: ArgumentList only; Kill(entireProcessTree: true); interpreter resolution via InterpreterMap with injectable IExecutableProbe (tests use FakeExecutableProbe, never the real PATH). Child streams are pinned UTF-8 (no BOM) in ProcessRunner (stdout/stderr decode + stdin encode) — never rely on Console.OutputEncoding, which is the Windows OEM code page and corrupts non-ASCII in the logs (#55, SSOT §5.1).
  • Process identity across processes (pid + start identity, #704): Process.StartTime is identical for every reader on Windows and macOS (the kernel stores the creation time), but on Linux .NET RECONSTRUCTS it per reading process as CLOCK_REALTIME_COARSE − CLOCK_BOOTTIME plus ticks-since-boot, so two processes reading the same pid disagree — by milliseconds, and by hours in a WSL2/VM/devcontainer guest whose wall clock steps on wake. No tolerance fixes that, so on Linux compare what the kernel stores: /proc/<pid>/stat field 22 (count fields from the LAST ) — the process name may contain spaces and parentheses) plus /proc/sys/kernel/random/boot_id. Ask liveness through IProcessProbe (SystemProcessProbe: exact on every OS, and three-valued — CannotTell when the start identity is unreadable, never a guess), keep the comparisons pure (CompareLinux / CompareStartTime take the kernel's text or value, so every OS tests every branch), inject a fake in tests, and prove cross-process agreement by running the shipped CLI as a child process (CrossProcessLivenessTests). Do not copy the exact-ticks comparison in WorktreeReclaim.IsLockedByLiveProcess: it predates this and, on Linux, cannot match a lock written by a different process (#724).
  • Merge-sequence protocol: journal.ReserveMergeSequence() BEFORE stateManager.MergeFragment(...); pass the reserved value to RecordAttempt.
  • Recorded action outcome → guardrails (TaskExecutor): a guardrail gets the action's captured result via GUARDRAILS_ACTION_RESULT (action-result.json = {kind, exitCode, summary}), GUARDRAILS_ACTION_STDOUT, GUARDRAILS_ACTION_STDERR (SSOT §5.1). Doctrine is verify-don't-replay (#62): a guardrail may verify a postcondition from this recorded output instead of re-running the action's command — but it's a speed/flake trade-off, sound only against output the action couldn't fabricate (a produced artifact, a runner-written TRX). The recorded exitCode is ALWAYS 0 at guardrail time (a non-zero action fails the attempt first), so never expose a GUARDRAILS_ACTION_EXIT_CODE env var — it would be tautological.
  • Agent-CLI runners share one session loop (#764). StreamJsonCliSession owns everything after the spawn — stdin write, live tee to the stream log + transcript.md, the #504 StallWatch, the #452 denial fail-fast, the Win32Exception launch catch, ClaudeStreamParser, and failure classification. ClaudePromptRunner and CursorPromptRunner own only argv, env and prompt delivery, plus a StreamJsonCliDialect (summary label, an optional ConfigurationRefusal recogniser — #767), a per-run IToolDenialScanner (Claude: ClaudePermissionScanner.Scanner; Cursor: CursorToolCallScanner, #773 — it reads each completed tool_call's result.rejected, and pairs started/completed by exact call_id (fallback toolCallId) through CursorCallPairing (#778: open at the terminal result = abandoned refusal, named for both roles but merged into the wall lists and counted toward AllShellRefused for judges only (CursorPromptRunner.Finish sets the lists by role; the scanner keeps abandoned targets in AbandonedBlockedWritePaths/AbandonedCommands); open with no terminal result = PromptResult.InFlightToolCalls, never a refusal; the transcript renderer shares that pairing, so Render and StreamingWriter keep one small piece of cross-line state), feeding the wall tracker and the #452 counter, which is inert for cursor actions because no task-action caller sets AbortAfterConsecutiveToolDenials) and an optional per-run line observer (Cursor's prompt-echo check). A change to that loop changes BOTH runners; Claude's summaries stay byte-identical because its label is claude. Cursor is not installed on CI: CursorPromptRunnerTests spawns a fake OS-picked agent through the real ProcessRunner that implements Cursor's MEASURED prompt rule (stdin is read only when there is no positional argument, and the prompt taken is echoed as a user event; it also writes stderr.txt to stderr, which is how the admin Run-Everything refusal is reproduced), replays two real captured streams from TestData/cursor-live/, and replays the measured refusal SHAPES from TestData/cursor-refusals/ (every-shell-rejected, mixed, consecutive). The bash twin of that fake escapes quotes only (keep backslashes out of its test prompts). A real agent run is the manual scripts/smoke/cursor-live-smoke.ps1, never a test.
  • A claude GATEWAY block is the same runner, not a new kind (#782, SSOT §9.10). A kind: "claude" block with baseUrl gets its ClaudePromptRunner built with a ClaudeGatewayConfig (+ the run's ClaudeGatewayRunContext) by PromptRunnerRegistry, so EVERY dispatch to that instance (action, tier route, judge, ai-merge, breakdown, ai-triage, overwatch) takes the gateway path; a claude block without baseUrl must stay byte-identical, and tests pin that. Where things live: owned/scrubbed/blanked NAME lists in Prompts/ClaudeGateway.cs (ClaudeGatewayEnvironment, read by the runner, the validator and the preflight — one list); the launch pieces (scrub predicate on ResolvedCommand.ScrubInheritedEnvironment, the ONE composed --settings file with the containment hook merged in, owned-key drops, stream-log preamble) in Prompts/ClaudeGatewayLaunch.cs; the pure identity/match rules in Prompts/ClaudeGatewayBackendIdentity.cs; GR2084–GR2086 in Loading/ClaudeGatewayValidation.cs; the pre-DAG preflight in Cli/ClaudeGatewayPreflight.cs (called from PlanPreflightPhase, injectable ManagedSettingsPaths and ReadEnvironment for tests); the cost is nulled in StreamJsonCliSession via StreamJsonCliDialect.CostIsFiction, and spend renders through Journal/SpendFormat.cs (tokens instead of $0.00). Two rules to keep: a REAL token is never written into the composed settings file (the log viewer serves that directory; only the placeholder is written), and the child's CLAUDE_CONFIG_DIR is never ~/.claude. Tests use a loopback fake gateway (tests/Guardrails.Integration.Tests/ClaudeGateway/ FakeGatewayServer.cs); a real gateway is the manual scripts/smoke/claude-gateway-live-smoke.ps1, which never writes into the operator's ~/.claude and is re-run on every Claude Code upgrade.
  • Windows launch by bare name (#764). Process.Start with UseShellExecute = false does NOT apply PATHEXT, so a .cmd-only install (Cursor's agent.cmd) cannot be launched as agent. PathExecutableProbe.ResolveFullPath(command, pathVariable) returns the file the GR2009 probe found (PATHEXT matches preferred over an extensionless sibling within a directory); CursorPromptRunner launches that. Claude's launch deliberately still uses the bare command. Both the probe and the runner take the PATH as a VALUE in tests (new PathExecutableProbe(pathDir), resolveCommand:), never a process-wide mutation.
  • Claude specifics live ONLY in Prompts/ — ClaudePromptRunner (flags, invocation), ClaudeStreamParser (terminal result), and ClaudeTranscriptRenderer (the deterministic transcript.md projection of the raw stream, #27). Verdicts come from files, never exit codes. PromptComposer injects dependency-context + prior-attempt pointers that reference transcript.md (#26); it stays PURE (no IO) — TaskExecutor resolves paths/existence, and the renderer must stay deterministic (golden-file tested).
  • Worktree containment hook (WorktreeContainmentHook, issues #199/#192, SSOT §9.4): the OUTER runtime boundary, on top of the write-scope CHECK's post-hoc INNER diff. WriteHookFiles(logDir, worktreeRoot) generates an OS-picked Claude Code PreToolUse hook script (.ps1/.sh, the ACCEPTED worktree-root spellings baked in as a LIST of single-quoted literals — the as-given form plus RealPath.Resolve's, so a macOS /var vs /private/var aliasing cannot block a legitimate in-worktree write, #464; adding a spelling is a data change, not a control-flow one) + a containment-settings.json into the attempt's log dir (never inside the segment — must not pollute git status); ActionRunner/GuardrailRunner append --settings <path> to PromptRunnerSettings.ExtraArgs ONLY when a real segment worktree is present (worktreeRoot param non-null; null in serial mode — where, since #816, a prompt ACTION still gets --settings for the write-scope hook alone). The hook's path-escape decision REUSES WorkspaceContainment.Escapes's rule (re-expressed in shell/PowerShell since the hook runs as a Claude-spawned OS process, not a .NET callback) — any future change to the escape rule must be made in WorkspaceContainment.Escapes (unit-tested) AND both script templates (WorktreeContainmentHookTests runs the REAL generated script standalone with synthetic PreToolUse stdin JSON via InterpreterMap+ProcessRunner — no claude binary needed — as the drift-catching regression gate). Same hook additively blocks the git stash family unconditionally in worktree mode (#192); PromptComposer.ComposeAction/ComposeGuardrail's isWorktreeMode param also injects a ## Worktree safety advisory section with the stash-free alternative. WorktreeContainmentHookWiringTests (Integration.Tests) proves the end-to-end plumbing (settings file generated + --settings actually reaches a fake-CLI's argv) through a real worktree-mode run.
  • Write-scope hook + end-of-attempt check (WriteScopeHook, ScopeDiffBase, issue #816, SSOT §3.4/§9.4): the scope test in the hook scripts is NEVER a second glob implementation — WriteScope.ToAnchoredPatterns compiles IsInScope's rule to anchored ERE/.NET-common patterns and the scripts only apply them (WriteScopePatternTests pins agreement, including a seeded generative corpus); a change to the matcher must keep that test green. Worktree mode composes the hook into containment-settings.json (one --settings); serial mode writes write-scope-settings.json alone. The retrospective check takes a ScopeDiffBase: the segment's taskBase in worktree mode, or in serial mode a per-attempt snapshot tree staged through a PRIVATE GIT_INDEX_FILE (never the operator's index) — the plan's logs//state/ are dropped by PREFIX in WriteScopeCheck.Check, NOT by an exclude pathspec (git refuses an exclude naming a .gitignored dir, which faulted the whole run when tried). WriteScopeHookTests runs the real generated script like the containment tests. Every scope/staging git call goes through ScopeGit (drains stdout+stderr concurrently, timeout + cancellation, core.safecrlf=false, core.quotePath=false; NORMAL conversion mode always — a raw-bytes snapshot mixed normalised seeded blobs with raw re-hashed ones and de-CRLF'd tracked files on revert; line-ending tests must use NON-RACY fixtures, mtime set back before git add, or they cannot see that). Never read a child's stdout to its end before draining stderr: under Git for Windows' system core.autocrlf=true, git add emits a warning per LF file and ~35 of them filled the stderr pipe and deadlocked the snapshot (#816 review).
  • CLI output seam (IConsoleIo): the CLI writes ALL user-facing output through an injected IConsoleIo (Out/Error TextWriters), never the process-global Console.*. Production wires SystemConsoleIo.Instance (the ONLY place that touches Console.Out/Console.Error); Program.cs builds the tree via CommandFactory.BuildRootCommand(io). Every command factory takes io (ValidateCommand.Create(io), … SkillsCommand.Create(io)) and the helpers take the writer (ConsoleRunObserver(TextWriter), PlanProbe.PrintDiagnostics(diags, TextWriter), DryRun.Execute(folder, io), FolderArgument.ResolveAndAnnounce(value, TextWriter)). LEFT on Console by design: the Spectre LiveRunObserver, the UI-capability probes (Console.IsOutputRedirected, AnsiConsole...Interactive), and ResetCommand's confirmation INPUT (Console.IsInputRedirected/ReadLine). Tests inject a StringWriter-backed StringConsoleIo and capture from its OutText/ErrorText — no Console.SetOut, no global console state, so the CLI-driving test classes are parallel-safe (there is no ConsoleCaptureCollection; do not reintroduce one).
  • Live status diagram (#219, SSOT §10.1): OnTheFlyDiagramObserver (in src/Guardrails.Cli/Ui/, sibling to OnTheFlyLogSiteObserver) is a decorator IRunObserver wired in RunCommand in BOTH the live and --no-ui paths (stacked AROUND the log-site observer), writing logs/<runId>/diagram.html — one lock guards map-mutation + atomic write, best-effort. It translates events to SVG node ids via MermaidRenderer.StatusNodes(plan) (the status-node surface, sibling to TaskFolderTargets — derived from the SAME AllocateNodeIdBases
    • OrderBy(Name) ordinal math the emitter uses; a bijection golden test guards drift). The plan-level bracket badges are driven by concrete PlanGuardrailsStarting/Finished methods called from RunCommand (NOT IRunObserver methods — keep the interface small). duringRun toggles the meta refresh + spinner only. One-time fixture gotcha: adding the badge scaffolding changed the plan-root diagram.html BYTES (a node-status script + badge JS) — regenerate every committed diagram.html via the real guardrails graph command — but NOT its source-sha256 (status is hash-neutral chrome), so graph --check stays 0 (the golden GraphSourceHash tests are untouched). One committed docs/plans/preflights-impl fixture is an invalid plan (retired integrationGate, GR2029) and cannot be regenerated — its diagram.html stays stale.
  • Testing doctrine: TCS-gated fakes for concurrency (no sleeps); .ps1 + .sh fixture flavors OS-picked; plan builders pin defaultRetries: 0; prompt-pipeline tests use FakeClaudePlanBuilder (tokenless); real-claude tests gated behind GUARDRAILS_REAL_CLAUDE=1; xunit.v3 wants TestContext.Current.CancellationToken (xUnit1051 is an error).
  • PROCESS-WIDE state in tests — prefer a SEAM, and a serialized collection only as the fallback (#520, #594). xUnit runs classes in parallel, so a test that mutates process-wide state can break a class that did nothing wrong — and the failure surfaces at the VICTIM, on whichever platform's scheduler exposed it, which is why every instance so far was found by a red build rather than by review. The known resources: environment variables, the console output code page, Spectre's DefaultExclusivityMode, the current directory, and thread-default culture.
    • First ask whether the production code can take the value as a PARAMETER. A try/finally around Environment.SetEnvironmentVariable restores the value but cannot close the window; the state being shared is the process's, not the lock's. Three seams exist as precedent: GitLsFilesProbe's workingDirectory (#593), CommandFactory.BuildRootCommand's TelemetryOverrides (#594), and its environment reader, which supply's caller-scope check consults (#373 — two Supply test classes that set the task namespace process-wide raced each other, found by a red local run before its PR). All are nullable, all default to the previous ambient behaviour, and all let a test scope itself by PASSING A VALUE.
    • A serialized collection is the fallback, and it has a real cost. It buys wall clock on every CI run forever, and it removes the concurrency coverage that catches the NEXT defect of this shape. Measure the exposure before reaching for one: #594 found 4 classes mutating a telemetry variable against 20+ spawning a real run, so serializing that set would have been permanent and would still not protect a test nobody has written yet.
    • When the seam lands, RETIRE the collection — but retire it on its OWN change. TelemetryEnvironmentCollection recorded its reason as "there is no seam into that path short of threading a switch through the whole run command"; #594 threaded exactly that switch, so no mutator remained and the guard was retired in #699. Retiring it was first ATTEMPTED inside the #520 convention's own change and backed out, which is the part worth learning from: removing a serializer raises the assembly's parallelism, and that promptly surfaced two wall-clock failures on two platforms in a repo that had had none in its previous 40 CI runs. Both were real. #697 was a ratio asserting an unsound load-immunity argument; #698 was a PRODUCT BUG — the log server's final /events flush only ran when shutdown was observed in the parked branch, so a subscriber could silently lose the run's last row. A retirement is a concurrency change, not a cleanup — land it alone, where a red build can only mean one thing, and expect it to find things.
    • GitEnvironmentCollection is the counter-example worth keeping: its own doc records that the mechanism it guarded is GONE, and states the condition for deleting it (a full three-OS run without it, repeated enough to mean something). That is how a fallback should be written down — with its own exit criterion.
    • A grep for these APIs is not a check. #520's audit matched a doc comment in MarkReviewedEvidencePathFormsTests describing why it never calls Directory.SetCurrentDirectory — a false positive on the best-documented file in the suite. Any automated version must tell a USE from a MENTION (the #500 doctrine, from the opposite direction), which is why #520 recommends the convention now and a meta-test only if a third unguarded instance appears.
  • Reproducing an IN-PROCESS flake needs in-process load, not a busy machine (#727 review). A test that goes red in the full suite and green alone is often starved of a thread-pool thread by the ~2,900 other tests sharing its testhost — xunit's default parallelism runs collections concurrently, and that is what the full suite exercises. Re-running it under --filter proves nothing: a two-test run leaves the pool idle, and no amount of external CPU load can saturate a pool nothing is queuing onto. Reproduce by running the suspect test INSIDE a large parallel batch (several hundred tests in one testhost); and prefer a dedicated Thread over Task.Run in any test that BLOCKS its worker, so the outcome cannot depend on the scheduler.
  • Three shapes for ORDERING a fake against a bound without a sleep (#828). Each replaced a race that went red only under full-suite load:
    • A fake that must stay stuck parks on a test-owned TaskCompletionSource, released only after the teardown under test returns (WebhookEventSinkTests.ParkedTransport), with the teardown awaited under a minutes-long hung-test guard. A fixed Task.Delay(10s) "park" is a race: a busy runner outlasts it.
    • A line that must be written before a timeout starts: print it BEFORE reading stdin and send a prompt far larger than any pipe buffer. ProcessRunner arms the timeout only after its stdin write completes, which cannot happen until the fake has printed (AutocompactThrashSessionTests). A competing session bound is removed through a dialect seam (StreamJsonCliDialect.ThrashGrace), not out-waited.
    • A drain bound that must not beat the reader: inject a DrainPolicy.Clock that holds still until the stdoutLineSink has seen the line (ProcessRunnerDrainBoundTests.ClockHeldUntilLine). A 1 s idle grace on a free clock lost already-written output whenever the reader went unscheduled for a second; thread-pool starvation (queue ProcessorCount * 8 blocking work items) reproduces it every time.
  • Windows .sh hazard: bare bash can resolve to WSL's System32\bash.exe and fail on Windows paths (GitHub issue #1). Tests/examples use OS-appropriate scripts; guardrails.json interpreters is the user escape hatch.
  • A subagent reporting "done" is NOT evidence its work landed. A completion notification looks identical whether the agent committed or is still holding every file in its own worktree, so VERIFY before you believe it: git -C <agent worktree> log origin/<branch>..HEAD (is there a commit at all?) and git -C <agent worktree> status --porcelain (is the work still uncommitted?), asked of the AGENT'S OWN worktree, not the one you are sitting in. In one session four of five agents held substantial work with nothing committed — including 139 untracked files, which no git stash saves — and one of those worktrees became permanently unverifiable while still holding it. The check costs two commands; the failure costs the work.
    • The mirror-image mistake: a branch vanishing right after its own merge is EXPECTED, not loss. GitHub auto-deletes a merged PR's branch, so git ls-remote origin <branch> returning nothing is what success looks like. An agent primed about durability re-pushed an already-merged branch because "absent from the remote" is byte-identical to "work lost". Ask git log origin/master --oneline --grep '#<issue>' or git branch --merged master first — the question is whether the COMMITS are on master, never whether the branch still exists.
  • Never write a recursive delete, git worktree remove, or an unguarded git reset --hard / git clean (#826). Use SafeDelete.DeleteDirectory (link-safe + #109 read-only), GitWorktreeProvider.RemoveWorktreeLinkSafe, ResetSegment / ResetHardLinkSafe (harness trees) or ResetHardInOperatorTree (a tree the operator may own); LinkSafeDeleteTests.Source_EveryRecursiveDeleteAndGitRewrite_GoesThroughTheLinkSafePrimitive fails otherwise (it knows positional Delete(p, true), DirectoryInfo.Delete(true), ArgumentList.Add("--hard") and one-string "git … forms of reset --hard/--merge/--keep / clean / checkout / restore / stash / rm / switch / read-tree, and requires a guard CALL earlier in the SAME method body — or a substantive // #826 link-guard: <why> line, >= 3 words and >= 20 chars, directly above the call). It is a TRIPWIRE for literal shapes, not a proof: #826's final review found a leaf-link hole (WriteScopeCheck.Safe() checked ancestors only) behind a justification it accepted — a justification must state an invariant that is actually true. Measured: git follows a Windows junction on worktree remove, and Directory.EnumerateFiles(…, AllDirectories) walks into one — any tree walk that may meet a link uses LinkSafeTree (no-follow). Link fixtures for tests: tests/Guardrails.Core.Tests/TestSupport/TestLinks.cs (junction on Windows, symlink on Unix; linked into the integration project).
  • A mutation proof must REBUILD — --no-build INVERTS its result. dotnet test --no-build on Guardrails.Integration.Tests runs against the Guardrails.Core.dll already sitting in that project's output, so a mutation just made to Core is not in it: the test exercises UNMUTATED code and the proof reports a false PASS (measured on #708). The shortcut is safe only when the source is unchanged; in a mutation proof it reports the opposite of the truth.

Dogfooding safety

When the harness executes a plan that builds THIS repo: guardrail scripts must build from source (dotnet build/dotnet run --project src/Guardrails.Cli) — never invoke the globally installed guardrails tool that is executing the plan (file locks on its own binaries, and you'd be testing the old build).

Design-of-record → draft-PR review workflow (#106)

A design-of-record — a substantial docs/plans/NN-*.md architecture doc — goes into a draft GitHub PR for inline human review before its implementation milestones begin. The loop: author the doc on a branch → gh pr create --draft → human comments inline → architect revises and pushes until addressed → only then does coding (breakdown / harness work) start. This is the product's own "everything is a reviewable draft a human approves before it runs" gate applied to the design (docs/plans/01-overview.md pitches the artifacts as "reviewable in a PR"; plan-breakdown already presents its task folder as a draft).

  • Applies to: substantial designs-of-record (new-capability architecture, contract changes, multi-milestone plans — e.g. the parallel-execution / disjoint-scope plans this loop was forged on).
  • Does NOT apply to: trivial/mechanical changes (typos, one-line clarifications, renumbering) — those go straight in.
  • Distinct from the v2 "CI mode / PR-per-task" roadmap bet #2 (the harness emitting a check-run/PR per task during a run): that is automation, this is human design review.

The guardrails-architect operating contract owns the full statement (.claude/agents/guardrails-architect.md).

Status pointers

Milestone status lives in guardrails-domain-knowledge → Status section. Roadmap + Reality Gate: docs/plans/03-roadmap.md. Schema truth: docs/plans/02-schemas-and-contracts.md.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.