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
In this repository5 other files it gives its agents.
- cut-releaseSkill
- guardrails-domain-knowledgeSkill
- guardrails-reviewSkill
- plan-breakdownSkill
- uber-reportSkill
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
- Very specific: line 419
Use SafeDelete.DeleteDirectory (link-safe + #109 read-only), GitWorktreeProvider.RemoveWorktreeLinkSafe, ResetSegment / ResetHardLinkSafe …
- Very specific: line 440
When the harness executes a plan that builds THIS repo: guardrail scripts must build from source (dotnet build/dotnet run --project …
≈ “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.
Files
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-latestTFM 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 bareguardrails);ToolCommandNamestaysguardrails(that is the invoked command). Package metadata (Authors/CompanyServant Software LLC, Description,PackageLicenseExpressionMIT with a rootLICENSE, RepositoryUrl, PackageTags, README packed) lives insrc/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-releaseis the procedure. Consumers install with a plaindotnet tool install -g ServantSoftware.Guardrails—--prereleaseis no longer needed anywhere. The line starts at1.1.0because the published history ends at1.0.0-preview.49and a0.xrestart would sort BELOW it (major 0 < major 1) — i.e. a downgradedotnet tool updatewould refuse. Full rationale:.claude/skills/cut-release/SKILL.md→ Pick the version. - Local pack + acceptance (leaves the machine clean):
Stamp the local pack with adotnet 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-localsuffix (-p:Version=<csproj version>-local): with--add-source ./nupkgthe restore sees the local folder AND nuget.org, so a bare--version 1.1.0can 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. (Usedotnet tool updateif a version is already installed.) The-localsuffix 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 tagv*, 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 hasid-token: write,NuGet/login@v1(inputuser: ${{ secrets.NUGET_USER }}) exchanges the OIDC token for a short-lived key, thendotnet nuget push. NoNUGET_API_KEYsecret — only a non-sensitiveNUGET_USER(nuget.org profile name) plus a Trusted Publishing policy on nuget.org (Repository OwnerServant-Software-LLC, RepositoryGuardrails, Workflow Filerelease.yml). - Bundled skills: the CLI csproj globs three skill folders
(
plan-breakdownincl.references/,guardrails-review,guardrails-domain-knowledge) asContentwithCopyToOutputDirectory=PreserveNewestand a<Link>skills\<name>\%(RecursiveDir)…</Link>. They land in the build output underskills/AND — because the dotnet-tool packer sweeps copy-to-output content — inside the nupkg attools/net10.0/any/skills/..., i.e. next to the entry assembly (AppContext.BaseDirectory) for the installed global tool. Do NOT add explicitPack=true/PackagePathon those items — the packer already includes them, so doing so duplicates the path and trips NU5118 (warning-as-error).guardrails skills installreads fromAppContext.BaseDirectory/skills. Repo bootstrap:install.ps1(root, tested) andinstall.sh(root, untested twin) verify dotnet, install/update the tool, then runguardrails skills install. - Skill-version stamping +
--versiondrift (#152/#156/#169,docs/DEPLOYMENT.md§Skill versioning and drift detection): each skill's version now lives inside its ownSKILL.mdfrontmatter, undermetadata.guardrails-version(e.g.1.1.0) — NOT in the separate.guardrails-skill-versionsidecar of #152, which #156 replaced. The key is stamped at INSTALL time byguardrails skills install(viaSkillFrontmatterStamper, using the tool's own versionGuardrailsVersion.Current— a release fact, not author-typed): after copying each skill into the target, the command writesmetadata.guardrails-versioninto the installedSKILL.md. The BUNDLED/published copies are intentionally unstamped and the repo-source.claude/skills/**/SKILL.mdfiles stay clean — only the installed copy carries the version. (#169: stamping must happen at install, not build —PackAsToolpacks a freshdotnet publishof the unstamped source, so any build-time stamp on$(OutDir)never reached the.nupkg; the oldStampSkillVersions.targets/Task was REMOVED.) The CLI tool version and an installed skill's version are independent — updating the tool does NOT refresh installed skills, becauseskills installSKIPS an already-present folder unless--force(the silent-skip trap: a stale/plan-breakdownkeeps emitting legacy output for an older harness with no error — the incident a preview.25 harness produced acaptureHashes/no-writeScopefolder); a SKIPPED skill keeps its old/absent stamp (the drift signal), a--forcereinstall re-stamps.guardrails --versionsurfaces 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/skillsor./.claude/skillswhosemetadata.guardrails-versionis 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. OneRemedy for <root>line per drifted root, carrying the command whose destination IS that root — bare /--project/--target <root>— resolved through the sameResolveTargetDirthe install command uses, so the printed command cannot drift from where the command writes. (Before #461 a single fixedguardrails skills install --forcewas printed for every case; it writes to~/.claude/skills, so for any other root it could not clear the warning it accompanied.)skills install --forceoverwrites 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--forcedeletes the whole folder first and a never-committed file is the one thing git cannot restore.--overwrite-trackedis the explicit override. The guard keys on the DESTINATION, not the flag spelling, so--target <repo>/.claude/skills --forceis 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-runnercommandnot on PATH (WARNING, not error). GR2022 = a guardrail/script-action reading a non-ancestor task's state key (#121); GR2023 = a prompt-runnermaxOutputTokens≤ 0 (#114). GR2025 (WARNING) = plan missing/stale a/guardrails-reviewmarker (#79, SSOT §13) — surfaced at the CLI command layer (PlanValidator.ReviewMarkerDiagnostic), NOT insidePlanValidator.Validate. GR2024 (StagingOutputsInvalid) and GR2026 (StaleCoverageToken, the #157 stale-covers-key-behaviorswarning, SSOT §4.4) are both TAKEN; GR2027–GR2030 are now allocated too — the four-folder/terminal-gate/model block (GR2027 malformedcatches:in any of the four folders; GR2028 terminal<plan>/guardrails/folder missing a real integration-set re-run; GR2029 retiredintegrationGate: truekey; GR2030 invalidmodelvalue — SSOT §3.3/§2). GR2031 (InvalidAutonomyPolicy — an unrecognizedautonomyPolicyvalue, SSOT §2.1/§7.2; generalized from the #274 Part CdriftPolicycheck 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-wavedependsOn— SSOT §14.1). GR2035 (DuplicateCheckName — two checks in ONE folder sharing aName, e.g.01-build.ps1+01-build.shboth → Name "01-build"; ERROR, per-folder across all four folders, #332/SSOT §4.5) and GR2036 (ExpectedDurationNonPositive — a guardrail sidecar's optionalexpectedDurationSecondsprogress 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 registryreferences/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'saction.prompt.mdINSTRUCTS a shell command the task's own effectiveallowedToolsrefuse, 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,validateonly, 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 — akind: "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 cursorapprovalMode, reported by the LOADER, or the key on a non-cursor block, reported by the VALIDATOR; ERROR, #767) and GR2082 (CursorApprovalFlagInExtraArgs —--force/--yolo/--auto-reviewin a cursor block'sextraArgs; ERROR, #767). GR2084 (ClaudeGatewayBlockInvalid — a malformed claude gateway block, an owned env var in itsenv, or--settingsin itsextraArgs; ERROR), GR2085 (ClaudeModelNameToGateway — a Claude model name would reach a gateway block; WARNING) and GR2086 (ClaudeGatewayModelsShareEndpoint — two models share one gateway undermaxParallelism > 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) — andDiagnosticCodes.csWINS, 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:
ArgumentListonly;Kill(entireProcessTree: true); interpreter resolution viaInterpreterMapwith injectableIExecutableProbe(tests useFakeExecutableProbe, never the real PATH). Child streams are pinned UTF-8 (no BOM) inProcessRunner(stdout/stderr decode + stdin encode) — never rely onConsole.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.StartTimeis identical for every reader on Windows and macOS (the kernel stores the creation time), but on Linux .NET RECONSTRUCTS it per reading process asCLOCK_REALTIME_COARSE − CLOCK_BOOTTIMEplus 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>/statfield 22 (count fields from the LAST)— the process name may contain spaces and parentheses) plus/proc/sys/kernel/random/boot_id. Ask liveness throughIProcessProbe(SystemProcessProbe: exact on every OS, and three-valued —CannotTellwhen the start identity is unreadable, never a guess), keep the comparisons pure (CompareLinux/CompareStartTimetake 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 inWorktreeReclaim.IsLockedByLiveProcess: it predates this and, on Linux, cannot match a lock written by a different process (#724). - Merge-sequence protocol:
journal.ReserveMergeSequence()BEFOREstateManager.MergeFragment(...); pass the reserved value toRecordAttempt. - Recorded action outcome → guardrails (
TaskExecutor): a guardrail gets the action's captured result viaGUARDRAILS_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 recordedexitCodeis ALWAYS 0 at guardrail time (a non-zero action fails the attempt first), so never expose aGUARDRAILS_ACTION_EXIT_CODEenv var — it would be tautological. - Agent-CLI runners share one session loop (#764).
StreamJsonCliSessionowns everything after the spawn — stdin write, live tee to the stream log +transcript.md, the #504StallWatch, the #452 denial fail-fast, the Win32Exception launch catch,ClaudeStreamParser, and failure classification.ClaudePromptRunnerandCursorPromptRunnerown only argv, env and prompt delivery, plus aStreamJsonCliDialect(summary label, an optionalConfigurationRefusalrecogniser — #767), a per-runIToolDenialScanner(Claude:ClaudePermissionScanner.Scanner; Cursor:CursorToolCallScanner, #773 — it reads each completedtool_call'sresult.rejected, and pairsstarted/completedby exactcall_id(fallbacktoolCallId) throughCursorCallPairing(#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.Finishsets the lists by role; the scanner keeps abandoned targets inAbandonedBlockedWritePaths/AbandonedCommands); open with no terminal result =PromptResult.InFlightToolCalls, never a refusal; the transcript renderer shares that pairing, soRenderandStreamingWriterkeep 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 setsAbortAfterConsecutiveToolDenials) 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 isclaude. Cursor is not installed on CI:CursorPromptRunnerTestsspawns a fake OS-pickedagentthrough the realProcessRunnerthat implements Cursor's MEASURED prompt rule (stdin is read only when there is no positional argument, and the prompt taken is echoed as auserevent; it also writesstderr.txtto stderr, which is how the admin Run-Everything refusal is reproduced), replays two real captured streams fromTestData/cursor-live/, and replays the measured refusal SHAPES fromTestData/cursor-refusals/(every-shell-rejected, mixed, consecutive). The bash twin of that fake escapes quotes only (keep backslashes out of its test prompts). A realagentrun is the manualscripts/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 withbaseUrlgets itsClaudePromptRunnerbuilt with aClaudeGatewayConfig(+ the run'sClaudeGatewayRunContext) byPromptRunnerRegistry, so EVERY dispatch to that instance (action, tier route, judge, ai-merge, breakdown, ai-triage, overwatch) takes the gateway path; a claude block withoutbaseUrlmust stay byte-identical, and tests pin that. Where things live: owned/scrubbed/blanked NAME lists inPrompts/ClaudeGateway.cs(ClaudeGatewayEnvironment, read by the runner, the validator and the preflight — one list); the launch pieces (scrub predicate onResolvedCommand.ScrubInheritedEnvironment, the ONE composed--settingsfile with the containment hook merged in, owned-key drops, stream-log preamble) inPrompts/ClaudeGatewayLaunch.cs; the pure identity/match rules inPrompts/ClaudeGatewayBackendIdentity.cs; GR2084–GR2086 inLoading/ClaudeGatewayValidation.cs; the pre-DAG preflight inCli/ClaudeGatewayPreflight.cs(called fromPlanPreflightPhase, injectableManagedSettingsPathsandReadEnvironmentfor tests); the cost is nulled inStreamJsonCliSessionviaStreamJsonCliDialect.CostIsFiction, and spend renders throughJournal/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'sCLAUDE_CONFIG_DIRis never~/.claude. Tests use a loopback fake gateway (tests/Guardrails.Integration.Tests/ClaudeGateway/FakeGatewayServer.cs); a real gateway is the manualscripts/smoke/claude-gateway-live-smoke.ps1, which never writes into the operator's~/.claudeand is re-run on every Claude Code upgrade. - Windows launch by bare name (#764).
Process.StartwithUseShellExecute = falsedoes NOT apply PATHEXT, so a.cmd-only install (Cursor'sagent.cmd) cannot be launched asagent.PathExecutableProbe.ResolveFullPath(command, pathVariable)returns the file the GR2009 probe found (PATHEXT matches preferred over an extensionless sibling within a directory);CursorPromptRunnerlaunches 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), andClaudeTranscriptRenderer(the deterministictranscript.mdprojection of the raw stream, #27). Verdicts come from files, never exit codes.PromptComposerinjects dependency-context + prior-attempt pointers that referencetranscript.md(#26); it stays PURE (no IO) —TaskExecutorresolves 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 plusRealPath.Resolve's, so a macOS/varvs/private/varaliasing cannot block a legitimate in-worktree write, #464; adding a spelling is a data change, not a control-flow one) + acontainment-settings.jsoninto the attempt's log dir (never inside the segment — must not pollutegit status);ActionRunner/GuardrailRunnerappend--settings <path>toPromptRunnerSettings.ExtraArgsONLY when a real segment worktree is present (worktreeRootparam non-null; null in serial mode — where, since #816, a prompt ACTION still gets--settingsfor the write-scope hook alone). The hook's path-escape decision REUSESWorkspaceContainment.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 inWorkspaceContainment.Escapes(unit-tested) AND both script templates (WorktreeContainmentHookTestsruns the REAL generated script standalone with synthetic PreToolUse stdin JSON viaInterpreterMap+ProcessRunner— noclaudebinary needed — as the drift-catching regression gate). Same hook additively blocks thegit stashfamily unconditionally in worktree mode (#192);PromptComposer.ComposeAction/ComposeGuardrail'sisWorktreeModeparam also injects a## Worktree safetyadvisory section with the stash-free alternative.WorktreeContainmentHookWiringTests(Integration.Tests) proves the end-to-end plumbing (settings file generated +--settingsactually 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.ToAnchoredPatternscompilesIsInScope's rule to anchored ERE/.NET-common patterns and the scripts only apply them (WriteScopePatternTestspins agreement, including a seeded generative corpus); a change to the matcher must keep that test green. Worktree mode composes the hook intocontainment-settings.json(one--settings); serial mode writeswrite-scope-settings.jsonalone. The retrospective check takes aScopeDiffBase: the segment's taskBase in worktree mode, or in serial mode a per-attempt snapshot tree staged through a PRIVATEGIT_INDEX_FILE(never the operator's index) — the plan'slogs//state/are dropped by PREFIX inWriteScopeCheck.Check, NOT by an exclude pathspec (git refuses an exclude naming a.gitignored dir, which faulted the whole run when tried).WriteScopeHookTestsruns the real generated script like the containment tests. Every scope/staging git call goes throughScopeGit(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 beforegit add, or they cannot see that). Never read a child's stdout to its end before draining stderr: under Git for Windows' systemcore.autocrlf=true,git addemits 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 injectedIConsoleIo(Out/ErrorTextWriters), never the process-globalConsole.*. Production wiresSystemConsoleIo.Instance(the ONLY place that touchesConsole.Out/Console.Error);Program.csbuilds the tree viaCommandFactory.BuildRootCommand(io). Every command factory takesio(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 onConsoleby design: the SpectreLiveRunObserver, the UI-capability probes (Console.IsOutputRedirected,AnsiConsole...Interactive), andResetCommand's confirmation INPUT (Console.IsInputRedirected/ReadLine). Tests inject aStringWriter-backedStringConsoleIoand capture from itsOutText/ErrorText— noConsole.SetOut, no global console state, so the CLI-driving test classes are parallel-safe (there is noConsoleCaptureCollection; do not reintroduce one). - Live status diagram (#219, SSOT §10.1):
OnTheFlyDiagramObserver(insrc/Guardrails.Cli/Ui/, sibling toOnTheFlyLogSiteObserver) is a decoratorIRunObserverwired inRunCommandin BOTH the live and--no-uipaths (stacked AROUND the log-site observer), writinglogs/<runId>/diagram.html— one lock guards map-mutation + atomic write, best-effort. It translates events to SVG node ids viaMermaidRenderer.StatusNodes(plan)(the status-node surface, sibling toTaskFolderTargets— derived from the SAMEAllocateNodeIdBasesOrderBy(Name)ordinal math the emitter uses; a bijection golden test guards drift). The plan-level bracket badges are driven by concretePlanGuardrailsStarting/Finishedmethods called fromRunCommand(NOTIRunObservermethods — keep the interface small).duringRuntoggles the meta refresh + spinner only. One-time fixture gotcha: adding the badge scaffolding changed the plan-rootdiagram.htmlBYTES (anode-statusscript + badge JS) — regenerate every committeddiagram.htmlvia the realguardrails graphcommand — but NOT itssource-sha256(status is hash-neutral chrome), sograph --checkstays 0 (the goldenGraphSourceHashtests are untouched). One committeddocs/plans/preflights-implfixture is an invalid plan (retiredintegrationGate, GR2029) and cannot be regenerated — itsdiagram.htmlstays stale.
- Testing doctrine: TCS-gated fakes for concurrency (no sleeps);
.ps1+.shfixture flavors OS-picked; plan builders pindefaultRetries: 0; prompt-pipeline tests useFakeClaudePlanBuilder(tokenless); real-claude tests gated behindGUARDRAILS_REAL_CLAUDE=1; xunit.v3 wantsTestContext.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/finallyaroundEnvironment.SetEnvironmentVariablerestores 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'sworkingDirectory(#593),CommandFactory.BuildRootCommand'sTelemetryOverrides(#594), and itsenvironmentreader, whichsupply'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.
TelemetryEnvironmentCollectionrecorded its reason as "there is no seam into that path short of threading a switch through the wholeruncommand"; #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/eventsflush 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. GitEnvironmentCollectionis 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
MarkReviewedEvidencePathFormsTestsdescribing why it never callsDirectory.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.
- First ask whether the production code can take the value as a PARAMETER. A
- 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
--filterproves 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 dedicatedThreadoverTask.Runin 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 fixedTask.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.
ProcessRunnerarms 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.Clockthat holds still until thestdoutLineSinkhas 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 (queueProcessorCount * 8blocking work items) reproduces it every time.
- A fake that must stay stuck parks on a test-owned
- Windows .sh hazard: bare
bashcan resolve to WSL'sSystem32\bash.exeand fail on Windows paths (GitHub issue #1). Tests/examples use OS-appropriate scripts;guardrails.json interpretersis 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?) andgit -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 nogit stashsaves — 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". Askgit log origin/master --oneline --grep '#<issue>'orgit branch --merged masterfirst — the question is whether the COMMITS are on master, never whether the branch still exists.
- 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
- Never write a recursive delete,
git worktree remove, or an unguardedgit reset --hard/git clean(#826). UseSafeDelete.DeleteDirectory(link-safe + #109 read-only),GitWorktreeProvider.RemoveWorktreeLinkSafe,ResetSegment/ResetHardLinkSafe(harness trees) orResetHardInOperatorTree(a tree the operator may own);LinkSafeDeleteTests.Source_EveryRecursiveDeleteAndGitRewrite_GoesThroughTheLinkSafePrimitivefails otherwise (it knows positionalDelete(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 onworktree remove, andDirectory.EnumerateFiles(…, AllDirectories)walks into one — any tree walk that may meet a link usesLinkSafeTree(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-buildINVERTS its result.dotnet test --no-buildonGuardrails.Integration.Testsruns against theGuardrails.Core.dllalready 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.
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
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.

