diff --git a/.mcp.json b/.mcp.json
index 05ffffb..3ddcf81 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -3,7 +3,7 @@
"fslangmcp": {
"type": "stdio",
"command": "fslangmcp",
- "args": ["--project", "Partas.Testing.slnx"],
+ "args": ["--project", "Partas.Build.slnx"],
"env": {}
}
}
diff --git a/Build.fsproj b/Build.fsproj
index 583cbcd..6da58bc 100644
--- a/Build.fsproj
+++ b/Build.fsproj
@@ -26,13 +26,7 @@
`Update` modifies the existing implicit reference instead of
adding a duplicate (which would trigger NU1504). -->
-
-
-
-
-
-
diff --git a/Build/Program.fs b/Build/Program.fs
index a5daefa..b75a6c5 100644
--- a/Build/Program.fs
+++ b/Build/Program.fs
@@ -1,4 +1,4 @@
-///
+///
/// The build CLI, written against Partas.Build itself.
///
/// A step is a stage of a pipeline, and a stage that needs a flag binds it in an
@@ -11,16 +11,9 @@ module Build
open System
open System.IO
-open Fake.Core.Context
-open Fake.IO
-open Fake.IO.Globbing.Operators
open Partas.Build
-open Partas.Build.Internal
open Partas.TypeProvider.BuildHelper
-let execContext = FakeExecutionContext.Create false "build.fsx" []
-setExecutionContext (RuntimeContext.Fake execContext)
-
[]
let __REPOSITORY_DIRECTORY__ =
__SOURCE_DIRECTORY__
@@ -35,31 +28,11 @@ type Repo =
let private root = Repo.FileSystem.``.``.ToString()
-let formatFiles =
- !! "**/*.fs"
- -- "**/obj/**/*.*"
- -- "**/AssemblyInfo.fs"
-
module Options =
- let quick =
- Input.option "--quick"
- |> Input.alias "-q"
- |> Input.desc "Skips restores, installations, formatting etc"
- let skipTests =
- Input.option "--skip-tests"
- |> Input.desc "Skips running tests"
let nugetSource =
Input.option "--nuget-source"
|> Input.def "https://api.nuget.org/v3/index.json"
- |> Input.desc "NuGet feed URL for authenticated publishing"
- let watch =
- Input.option "--watch"
- |> Input.desc "Runs the operation in watch mode."
-
- let config =
- Baked.Dotnet.config.option
- |> InputSpec.ofInput
- |> InputSpec.map (Option.defaultValue "Release")
+ |> Input.description "NuGet feed URL for authenticated publishing"
module Project =
let projects = Repo.Project.AllProjects()
@@ -88,7 +61,7 @@ module Project =
Input.option "--project"
|> Input.alias "-p"
|> Input.arity Arity.OneOrMore
- |> Input.desc "The project(s) to target"
+ |> Input.description "The project(s) to target"
|> Input.allowMultipleArgumentsPerToken
|> Input.mapFromManyWith StringComparer.OrdinalIgnoreCase [
yield! allProjects
@@ -97,116 +70,69 @@ module Project =
/// Stages every command opens with. All are skipped by --quick.
module Prelude =
- let restore = input {
- let! quick = Options.quick
- return stage "restore" {
- when' (not quick)
- run "dotnet tool restore --verbosity q"
- run (cmd $"dotnet restore {Repo.Project.SolutionFile}")
- }
- }
- let clean = input {
- let! quick = Options.quick
-
- return stage "clean" {
- when' (not quick)
-
- run (fun (_: StageContext) ->
- Repo.VirtualFileSystem.bin.``.``.EnumerateFiles("*.nupkg", SearchOption.AllDirectories)
- |> Seq.iter (_.ToString() >> Shell.rm)
- !! "**/**/bin"
- ++ Repo.VirtualFileSystem.tmp.ToString()
- -- Repo.VirtualFileSystem.bin.ToString()
- |> Shell.cleanDirs
- )
- }
- }
+ let restore = Baked.Stages.restore Repo.Project.SolutionFile
+ /// Empties every bin directory except the root's, and deletes the packages in the root's.
+ let clean = Baked.Stages.clean [ "**/bin"; "!bin"; "tmp" ] [ "bin/**/*.nupkg" ]
module ProjectManagement =
- let build (project: InputSpec) = input {
- let! config = Options.config
- and! project = project
- return stage $"build {project}" {
- run (cmd $"dotnet build {project} -c {config}")
+ let private packages = Repo.VirtualFileSystem.bin.ToString()
+
+ // These projects share references and output paths; independent dotnet builds must not race.
+ let buildAll =
+ Baked.Stages.build (Project.allProjects |> List.map snd)
+ |> InputSpec.map (StageContext.toggleParallel false)
+ let packAll = Baked.Stages.pack packages (Project.allProjects |> List.map snd)
+ let publishAll = input {
+ let! source = Options.nugetSource
+ and! apiKey = Baked.NuGet.apiKey.option
+ return stage "push" {
+ let packageGlob = Path.Combine(packages, "*.nupkg")
+ match apiKey with
+ | Some key ->
+ stage "push to source" {
+ run (cmd $"dotnet nuget push {packageGlob} --source {source}"
+ |> Cmd.secretOption "--api-key" key
+ |> Cmd.arg "--skip-duplicate")
+ }
+ | None -> stage "push to local feed" { run (cmd $"dotnet nuget push {packageGlob} --source local --skip-duplicate") }
}
}
- let buildAll = stage "build" {
- parallel'
- for _, project in Project.allProjects do
- build (InputSpec.ret project)
- }
-
- let pack (project: InputSpec) = input {
- let! project = project
- return stage $"pack {project}" {
- run (cmd $"dotnet pack {project} --no-restore -o {Repo.VirtualFileSystem.bin.ToString()}")
- }
- }
- let packAll = stage "pack" {
- parallel'
- for _, project in Project.allProjects do
- InputSpec.ret project
- |> pack
- }
- let publish (project: InputSpec) = input {
- let! key = Baked.NuGet.apiKey.option
- and! source = Options.nugetSource
- and! project = project
- return stage $"publish {project}" {
- stage "local publish" {
- when' key.IsNone
- echo "Publishing to local feed"
- run $"dotnet nuget push {project} --source local --skip-duplicate"
- }
- whenSome key (fun key ->
- stage "nuget publish" {
- echo $"Publishing to {source}"
- runSensitive
- $"dotnet nuget push {project} --source {source} --api-key {key} --skip-duplicate"
- })
- }
- }
- let publishAll = stage "publish" {
- Path.Combine(Repo.VirtualFileSystem.bin.ToString(), "*.nupkg")
- |> InputSpec.ret
- |> publish
- }
let bumpArgument =
Baked.SemVer.Stages.bumpArgument (InputSpec.ofInput Project.target)
module Tests =
- let buildAll = input {
- let! skipTests = Options.skipTests
- and! projects =
- Project.testProjects
- |> List.map (_.Path >> InputSpec.ret >> ProjectManagement.build)
- |> InputSpec.sequence
- return stage "build tests" {
- when' (not skipTests)
- projects
- }
- }
+ /// Builds the test projects one at a time; skipped by --skip-tests.
+ let buildAll =
+ Baked.Stages.buildWith Baked.Dotnet.configOrRelease (Project.testProjects |> List.map _.Path)
+ |> InputSpec.map (fun build -> { StageContext.toggleParallel false build with Name = "build tests" })
+ |> InputSpec.map2 (fun skipTests -> StageContext.addPredicateBecause (ValueSome "--skip-tests is set") (fun _ -> not skipTests))
+ (InputSpec.ofInput Baked.Common.skipTests)
+
+ let private expectoArguments = [ "--colours"; "256"; "--sequenced" ]
+
let execute = input {
- let! skipTests = Options.skipTests
- and! config = Options.config
+ let! skipTests = Baked.Common.skipTests
and! ci = Baked.Common.isCI
+ and! suites =
+ [ Repo.Project.``Partas.Build.Cmd.NetStandard.Tests``.Path
+ Repo.Project.``Partas.Build.ExternalAnnotations.Tests``.Path
+ Repo.Project.``Partas.Build.Tests``.Path
+ Repo.Project.``Partas.ExternalAnnotations.Tests``.Path ]
+ |> InputSpec.traverse (fun project ->
+ Baked.Stages.expecto project expectoArguments
+ |> InputSpec.map (StageContext.setOutput (ValueSome StageOutput.Console)))
return stage "test" {
- when' (not skipTests)
- // Expecto reports assertions on stdout; keep them visible in CI and uploaded logs.
+ when' (not skipTests) "--skip-tests is set"
outputTo StageOutput.Console
- // Commands exercised by tests must not append fixture reports to the real job summary.
envVars [ "GITHUB_STEP_SUMMARY", "" ]
- for project in [
- Repo.Project.``Partas.Build.Cmd.NetStandard.Tests``.Path
- Repo.Project.``Partas.Build.ExternalAnnotations.Tests``.Path
- Repo.Project.``Partas.Build.Tests``.Path
- Repo.Project.``Partas.ExternalAnnotations.Tests``.Path
- ] do stage $"test {project}" {
- run (Cmd.ofString $"""dotnet run --project {project} --no-build -c {config} -- {if ci then "--summary" else null} --colours 256 --sequenced""")
- }
- // Runs in both configurations regardless of `--configuration`: Release is what catches FS1118.
+ suites
+ // Built by the run, in both configurations regardless of `--configuration`: Release is what catches FS1118.
for probeConfig in [ "Debug"; "Release" ] do stage $"compiler probe ({probeConfig})" {
- run (Cmd.ofString $"""dotnet run --project {Repo.Project.``Partas.Build.CompilerProbe``.Path} -c {probeConfig} -- {if ci then "--summary" else null} --colours 256 --sequenced""")
+ run (
+ cmd $"dotnet run --project {Repo.Project.``Partas.Build.CompilerProbe``.Path} -c {probeConfig} --"
+ |> Cmd.argIf ci [ "--summary" ]
+ |> Cmd.args expectoArguments
+ )
}
}
}
@@ -214,7 +140,7 @@ module Tests =
module Documentation =
/// Serves under --watch, builds otherwise.
let generate = input {
- let! watch = Options.watch
+ let! watch = Baked.Common.watch
and! noHotReload = Input.option "--no-hot-reload"
return stage "docs" {
run (
@@ -232,9 +158,9 @@ module Documentation =
/// Written for fsdocs, which generated both files at the site root as a link inventory under such a heading.
///
let llms = input {
- let! watch = Options.watch
+ let! watch = Baked.Common.watch
return stage "llms" {
- when' (not watch)
+ when' (not watch) "--watch is set"
run (fun ctx ->
let header = File.ReadAllText(Path.Combine(root, "docs", "static", "llms.txt")).TrimEnd()
for name in [ "llms.txt"; "llms-full.txt" ] do
diff --git a/CLAUDE.md b/CLAUDE.md
index 1819b06..4cb41d5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,4 +1,4 @@
-# CLAUDE.md
+# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`notes/PLAN-Execution.md` and its checklist `notes/PLAN-Execution-Tasks.md` own the current execution model: typed producers and `consumes`, failure handlers, the scope reports, and the shared `StageSettingsBuilder`/`PipelineSettingsBuilder`.
-The one-line version: input collection is **applicative**, not monadic. `InputSpec<'T> = { Inputs: ActionInput list; Read: ParseResult -> 'T }` makes the input set readable without a `ParseResult`, breaking the circularity between registering options and binding their values. Stages are pure (no `ActionContext`); binding happens in an `inputs { let! … and! … return … }` CE, and pipelines/commands harvest `.Inputs` upward.
+The one-line version: input collection is **applicative**, not monadic. `InputSpec<'T> = { Inputs: ActionInput list; Read: ParseResult -> 'T }` makes the input set readable without a `ParseResult`, breaking the circularity between registering options and binding their values. Stages are pure (no `ActionContext`); binding happens in an `input { let! … and! … return … }` CE (`inputs` is its obsolete second name), and pipelines/commands harvest `.Inputs` upward.
## Commands
@@ -38,28 +38,63 @@ Fast inner loop while working on the library only: `dotnet build src/Partas.Buil
## Current state (verify before assuming)
-- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (433 Expecto tests across four suites — 291 in `tests/Partas.Build.Tests`, one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests`, 74 in `tests/Partas.ExternalAnnotations.Tests` and 3 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first.
+- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (506 Expecto tests across four suites — 365 in `tests/Partas.Build.Tests`, one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests`, 73 in `tests/Partas.ExternalAnnotations.Tests` (2 more ignored) and 3 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first.
- The DSL exists end to end: `inputs` (`Builders/Inputs.fs`), `stage` (`Builders/Stage.fs`), `pipeline` (`Builders/Pipeline.fs`), `command`/`rootCommand` (`Builders/Command.fs`). A stage that declares an input turns its pipeline into an `InputSpec`, and the command registers whatever those specs declare. Conditions are in `Builders/Conditions.fs` — `whenAll`/`whenAny`/`whenNot`/`whenEnv`/`whenStage` plus the `when'`/`whenEnvVar`/`whenBranch`/`when{Windows,Linux,OSX}` operations on `StageBuilder`.
- A command carries `PipelineDefaults: BuildPipeline` and takes the pipeline-level operations itself (`workingDir`, `envVars`, the three timeouts, `acceptExitCodes`, the output operations, `noPrefixForStep`/`noStdRedirectForStep`, `runBeforeEachStage`/`runAfterEachStage`, `post`, `verbosity`/`verbose`/`quiet`), each one built through `CommandBuilderBase.MapPipelineDefault`. They are **defaults, not overrides**: `PipelineContext.applyDefaults` copies a setting across only where the pipeline left it at the value `PipelineContext.create` gave it, so a pipeline that sets the same thing wins. See *Command defaults* below.
-- `run`/`runSensitive` start real processes through `CmdRunner` (`Process.fs`). A `Cmd`, defined in `src/Partas.Build.Cmd/Program.fs`, keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. Interpolate through the `cmd` helper — `run (cmd $"dotnet build {project}")`. `run $"..."` binds to the `string` overload and flattens the holes; `runSensitive $"..."` takes the `FormattableString` directly and masks every hole as `***`. There is no `Fake.Core.Process` dependency; `notes/PLAN.md`'s *The command runner* records why.
+- `run`/`runSensitive` start real processes through `CmdRunner` (`Process.fs`). A `Cmd`, defined in `src/Partas.Build.Cmd/Program.fs`, keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. Interpolate through the `cmd` helper — `run (cmd $"dotnet build {project}")`. `run $"..."` binds to the `string` overload and flattens the holes; `runSensitive $"..."` takes the `FormattableString` directly and masks every hole as `***`. A function returning a command line is `runLine (fun ctx -> "...")`; the `run` overloads over `StageContext -> string` (and its `Async`/`Task`/`option` forms) are `[]`, since a lambda returning a message would start a process. `runLine` is a separate name, so the `run` overload set — and the `FS0041` balance below — is unchanged. There is no `Fake.Core.Process` dependency; `notes/PLAN.md`'s *The command runner* records why.
- Fun.Build's `Mode` (`Execution | CommandHelp | Verification`) has **not** been ported. `PipelineContext.Verify` is a placeholder and `buildPipelineVerification` is commented out. `CommandHelp` is redundant: System.CommandLine generates help. Whether `Verification` survives is an open question in `notes/PLAN.md`.
- `PipelineContext.run` is complete enough to execute stages, post stages, timeouts, parallelism and cancellation.
## Architecture notes
-Compile order in `Partas.Build.fsproj` matters (F#): `System.CommandLine/Aliases.fs` → `System.CommandLine/Inputs.fs` → `Exceptions.fs` → `Output.fs` → `Environment.fs` → `Timing.fs` → `Producer.fs` → `Failures.fs` → `Conductors.fs` → `Conductors.Runners.fs` → `Process.fs` → `Operations.fs` → `Dependencies.fs` → `DependencyPlan.fs` → `ExecutionState.fs` → `Builders/StageSettings.fs` → `Builders/Stage.fs` → `Builders/Conditions.fs` → `Builders/PipelineSettings.fs` → `Builders/Pipeline.fs` → `Builders/Inputs.fs` → `Explain.fs` → `Summary.fs` → `Builders/Command.fs`. The batteries-included layer is its own project, `src/Partas.Build.Baked`.
+Compile order in `Partas.Build.fsproj` matters (F#): `System.CommandLine/Aliases.fs` → `System.CommandLine/Inputs.fs` → `Exceptions.fs` → `Output.fs` → `Terminal.fs` → `Annotations.fs` → `Environment.fs` → `Timing.fs` → `Producer.fs` → `Failures.fs` → `Conductors.fs` → `GitHubActions.fs` → `Conductors.Runners.fs` → `Process.fs` → `Operations.fs` → `Dependencies.fs` → `DependencyPlan.fs` → `ExecutionState.fs` → `Builders/StageSettings.fs` → `Builders/Stage.fs` → `Builders/Conditions.fs` → `Builders/PipelineSettings.fs` → `Builders/Pipeline.fs` → `Builders/Inputs.fs` → `Builders/Dependencies.fs` → `MachineOutput.fs` → `Explain.fs` → `Summary.fs` → `RunResult.fs` → `Builders/Command.fs`. The batteries-included layer is its own project, `src/Partas.Build.Baked`.
-`Explain.fs` renders the resolved stage tree `--explain` prints, as text only, independent of the console and
-any stage sink — a stage that silences or captures its output is still described in full. It compiles before
+`Explain.fs` renders the resolved stage tree `--explain` prints, as text or JSON, independent of the console and
+any stage sink — a stage that silences or captures its output is still described in full. `Explain.explain mode`
+builds the model (`ExplainedPipeline`/`ExplainedStage`/`ExplainedStep`/`ExplainedCondition`); `renderWith` and
+`toJson` render it. It compiles before
`Builders/Command.fs`, whose `applyTo` registers the flag on every command and prints what the renderer returns.
-It depends on nothing beyond the core model and the `Input.*` combinators. A command that runs pipelines gets
+It depends on the core model, the `Input.*` combinators and `MachineOutput.fs` (for `--json` and the JSON
+document writer). A command that runs pipelines gets
`Explain.option` and reads it in its own action. A grouping command gets `Explain.groupingOption`, which carries
the rendering — a list of the subcommands it dispatches to — on the option's own `Action`: such a command has no
action of its own, and adding one would displace System.CommandLine's "Required command was not provided.".
-Rendering evaluates every stage's `IsActive`, so a `whenBranch` starts `git` and a `whenStage` runs its condition
-stage. `StageContext.Conditions` lets a skip name the condition that caused it: `addPredicateBecause` writes it
-alongside `IsActive`. The structured conditions in `Builders/Conditions.fs` supply a reason; `when'` supplies
-none, since a `bool` argument carries no reason to report.
+Rendering never calls `IsActive`: it walks `StageContext.Conditions` — `addPredicateBecause` is the single writer
+of both, so the list conjoins to `IsActive` — calling each condition at most once, stopping at the first that fails
+or throws, and rendering a throw as its message (`condition failed: …`) instead of crashing. Under
+`ExplainMode.Evaluated` (text `--explain`) a `whenBranch` starts `git` and a `whenStage` runs its condition stage,
+once. Under `ExplainMode.Static` (`--explain --json`) a condition marked `Conditions.effectful` is reported
+`unevaluated` with its description and left uncalled. The mark is the condition's runtime type,
+`EffectfulCondition` (an `FSharpFunc` subclass), not a closure-keyed table: the optimiser copies a closure it
+inlines, and an eta-expansion at a call site that coerces an argument (`whenBranches ["main"]` against a
+`string seq` parameter) wraps it, so `whenBranches` takes `#seq` to keep the call a plain application.
+`whenBranches`, `whenStageSucceeds` (`when' stage`, `whenStage`) are marked; `whenAll`/`whenAny`/`whenNot` mark
+their result when any child is marked (`Conditions.combined`). A condition wrapped in an unmarked lambda is
+evaluated. `StageContext.Conditions` also lets a skip name the condition that caused it. The structured
+conditions in `Builders/Conditions.fs` supply a reason; `when' value` supplies none, and `when' value "reason"`
+supplies the one written at the call site.
+
+`AiEnvironment.fs` precedes `MachineOutput.fs` and detects agent environment markers through a lazy result.
+`MachineOutput.json` uses a fresh lazy detection in its default factory, so each invocation sees current
+environment values. `PARTAS_BUILD_DISABLE_AI=1` disables automatic JSON, while explicit flags still win.
+`--schema` and `--explain` remain explicit because they stop the command from executing normally.
+
+`MachineOutput.fs` holds the machine-readable flags and the command-tree schema. `applyTo` registers `--json` and
+`--schema` on every command and `--report ` on a command that runs pipelines, each through `reserve`, which
+skips a flag whose name or alias the command already declares (the consumer's option wins; System.CommandLine
+would reject the duplicate). `--schema` carries its action on the option, like `Explain.groupingOption`, and
+prints `MachineOutput.commandSchema` of the parsed command. Under `--json`, `--explain` prints `Explain.toJson` (static)
+and a run prints `RunResult.toJson false` as one line after the run, in place of the timing table; `--report`
+writes the indented form to a file. Both are written for every outcome the command action reaches, a failed
+dependency validation included, but not for a System.CommandLine parse error, nor under `--explain` (a failed
+validation there prints its message and exits `2`). An exception other than a pipeline failure or cancellation
+writes the result as `Failed` and then propagates to System.CommandLine's handler (exit `1`). JSON is written with
+`Utf8JsonWriter` by hand, not reflection serialization: every document carries `formatVersion`
+(`MachineOutput.FormatVersion`). `System.Text.Json` is a package reference on `netstandard2.0` only.
+`Input.sensitive` marks an option or argument as carrying a secret, recorded against the `Symbol` in a
+`ConditionalWeakTable` in `Inputs.fs` (System.CommandLine has no property bag); `--schema` writes a marked
+symbol's present default as `"***"` alongside `"sensitive": true`. `Baked.NuGet.apiKey` is marked, since its
+default is read from `NUGET_API_KEY`. Text `--help` still prints the default.
`Failures.fs` holds what a scope reports about itself, apart from what it prints. A `ScopeReport` carries three
fields: `Outcome` (the scope's own `StageOutcome`), `Propagates` (whether that failure fails the scope
@@ -123,18 +158,78 @@ line already carries. `Summary.render` sizes its three columns to the ambient co
fit — the middle of a stage name, the end of an outcome — so the table is one row per stage at any width, and
the `Depth` indent survives an 80-column CI log.
+`RunResult.fs` holds what a command invocation answers, and the exit codes it ends with: `ExitCode.Success`
+(0 — help, `--version` and `--explain` included), `Failure` (1, a stage failed), `UsageError` (2 — a parse
+error, a missing subcommand, a failed `DependencyPlan.validate`) and `Cancelled` (130). `RunResult` carries the
+code, its `RunOutcome`, and a `PipelineRun` per pipeline started — its `ScopeReports.stages` and ordered
+`StageTimings`, snapshotted in `runReportingTimings`'s `finally` so a later run of the same pipeline value
+leaves an earlier result intact. `Command.root { … }` builds a `RootCommandDefinition` (the `rootCommand`
+operations, via the shared `RootCommandBuilderBase.Define`) without parsing or running; `Command.invoke args`
+and `RootCommandDefinition.Invoke(args, ?output, ?error, ?cancellationToken)` parse and run it, and
+`RootCommandBuilder.Run` is `Define` + `Invoke` + `.ExitCode`, so the exit-code mapping lives in `Invoke` alone:
+a parse result whose `Action` is System.CommandLine's `ParseErrorAction` becomes 2 there, whatever the action
+returned. The command action finds its invocation's state — the cancellation token and the `PipelineRun`
+collector — in a `ConditionalWeakTable` keyed by the `ParseResult`; a parse result invoked directly through
+System.CommandLine has none (the action makes a local, uncancellable one so `--json`/`--report` still see its
+runs), and still gets 2 for a failed dependency validation (the
+action returns it) but 1 for a parse error (System.CommandLine's own). The token reaches the engine through
+`PipelineContext.runWith`, which links it into the pipeline's own timeout source. `output` replaces the
+invocation configuration's `Output`/`Error`, which is where help, parse errors, `--explain`, the timing summary
+and the dependency diagnostic go, and becomes the run writer (`Terminal.withOutput`, below) for everything the
+run sends to the console. `Invoke` runs the parse-and-invoke on a `LongRunning` task and waits on it: a
+`ThreadInterruptedException` on the waiting thread (SageFs's `cancel_eval` is `Thread.Interrupt`) cancels the
+invocation's own linked `CancellationTokenSource` — reaching the registration-based process-tree kill through
+the same token chain as `cancellationToken` — waits up to five seconds for the run to wind down, and rethrows.
+`rootCommandOfScript` is `RootCommandBuilder Args.script`: the builder's primary constructor takes a
+`unit -> string array` read once per `Run`, so argv is read when the command runs, never at module
+initialisation; `rootCommand` is annotated `string array` to keep the array constructor's overload unambiguous.
+
+`Terminal.fs` (`Partas.Build.Internal.Terminal`) is the only route to the console. Spectre's static
+`AnsiConsole` binds to the `Console.Out` current at its first use and keeps writing there after a host swaps
+`Console.Out` (spike in `PLAN-Integration.md` §8 Q5), so the library never calls `AnsiConsole.*` directly:
+`Terminal.ansi ()` answers `AnsiConsole.Console`, rebinding it to the current `Console.Out` when `Console.Out`
+changed since the last call and nothing else replaced `AnsiConsole.Console` meanwhile (an explicit assignment,
+as the tests' `capturingOut` makes, is kept). Each `Console.Out` writer keeps the console it was first seen
+with, so restoring a writer restores its console. Writing through a host's `Console.Out` can deadlock: on Unix
+the runtime locks `Console.Out` for every terminal write, and Expecto's `Console.Out` takes Expecto's lock before
+writing to the terminal, so a pipeline thread and Expecto's logger take the two locks in opposite orders under a
+pseudo-terminal. `tests/Partas.Build.Tests/Main.fs` therefore pins `AnsiConsole.Console` to the real stdout
+before Expecto starts; a host with the same kind of writer passes `output` to `invoke` or pins the console the
+same way. Under `Terminal.withOutput writer` — an `AsyncLocal`, so it flows
+into the thread-pool work the run starts — `ansi ()` is a plain (no ANSI, no colour) console over `writer`,
+`Terminal.out ()` (where a `Console`-sink step line goes) is `writer`, and `CmdRunner.outputPolicy` redirects a
+`Console`-sink child, which would otherwise inherit the real stdout; `withOutput` installs the writer through
+`TextWriter.Synchronized`, since several threads write to it. A console whose writer has no terminal
+reports width `-1` and renders nothing, so `ansi`/`plain` give it `FallbackWidth` (80). `Terminal.ensureUtf8`
+sets the console encodings at most once per process, skips a redirected stream, and swallows a failure.
+
+`PipelineContext.create` starts `EnvVars` empty: it holds the variables the pipeline sets. `runWith` layers
+them over `PipelineContext.ambientEnvironment ()` read as the run starts, so a variable set after the pipeline
+value was built is visible to its steps. `runWith` also refuses a second concurrent run of one pipeline value
+(identity: the `ScopeReports` a value and its record copies share) with an `InvalidOperationException` before
+touching anything; `PipelineContext.withRunState` gives a copy collections of its own that runs independently.
+`Builders/Command.fs`'s `runReportingTimings` holds the guard itself (`PipelineContext.Running.enter`, then
+`runEntered`) outside the `try` whose `finally` records and prints the run, so a refused invocation reports
+nothing of the run holding the value.
+
`src/Partas.Build.Cmd` (`Program.fs`, `Execution.fs`) is the process layer, defining `Cmd` and compiling before `Partas.Build`.
`src/Partas.Build.Baked` is the batteries-included layer over the library: ready-made `Input.*`/`Argument.*` definitions
-for the options every build CLI ends up wanting (`--configuration`, `--nuget-key`, `--project`, `--ci`, a version
-bump), the semver arithmetic in `Version`, and `IO.writeVersion`/`IO.bumpVersion` for editing a project file's
-``. It is the only place in the library that writes to disk.
+for the options every build CLI ends up wanting (`--configuration` and `Dotnet.configOrRelease`, `--nuget-key`,
+`--project`, `--ci`, `--quick`, `--skip-tests`, `--watch`, a version bump), the semver arithmetic in `Version`, and
+`IO.writeVersion`/`IO.bumpVersion` for editing a project file's ``. It is the only place in the library that
+writes to disk, apart from the result file `--report` names. `Stages.fs` holds the prefab stages (`restore`, `clean`, `build`, `pack`, `expecto`, `nugetPush`,
+`fantomas`, `npmInstall`): functions answering `InputSpec` that read Baked's own options, each with a
+`…With` counterpart taking those options as `InputSpec`s. A skip carries a reason for `--explain`. `Clean.fs` is the
+glob matcher behind `clean`, on `System.IO` alone; it never follows a symbolic link, so everything it deletes lies
+under its root.
The core model, once a single `Types.fs`, is split by responsibility and compiles in this order:
1. `Exceptions.fs` (`Partas.Build.ErrorHandling`) — pipeline exceptions, `FailureCause`, `StepOutcome`.
-2. `Output.fs` (`Partas.Build.OutputHandling`) — `OutputCapture`, `StageOutput`, markup.
+2. `Output.fs` (`Partas.Build.OutputHandling`) — `OutputCapture`, `StageOutput`, markup; then `Terminal.fs`
+ (`Partas.Build.Internal.Terminal`) — the console every write goes through, resolved at the moment of writing.
3. `Environment.fs`, `Timing.fs`, `Producer.fs` (`Partas.Build`) — `EnvArg`; `StageTimings`; `ProducerId`/`ProducerRef`/`ProducerValues` and the `ExecutionState` type.
-4. `Conductors.fs` — `Partas.Build.Internal` first: `StageContext`, `PipelineContext`, `CommandSpec`. `Step` is `StepFn | Operation | StepOfStage`: a nested stage *is* one step of its parent, and stages nest arbitrarily. `StageParent` links a stage to a parent stage or the pipeline. The file ends in `Partas.Build` with the `StageContext` lookups that need `FsToolkit`/`HttpClient`.
+4. `Conductors.fs` — `Partas.Build.Internal` first: `StageContext`, `PipelineContext`, `CommandSpec`. `Step` is `StepFn | Operation | StepOfStage`: a nested stage *is* one step of its parent, and stages nest arbitrarily. `StageParent` links a stage to a parent stage or the pipeline. The file ends in `Partas.Build` with the `ConsumerTypes` module — `[]` abbreviations of every model type a consumer names in a signature (`StageContext`, `PipelineContext`, `CommandSpec`, `StageParent`, `RuntimeContext`, `Step`, `StageCondition`, `StageIndex`, `StepIndex`, the `Build*` aliases; the `stepIndex` measure sits in the nested `[] Measures` module, since the Nacara reference routes pages case-insensitively and `stepIndex`/`StepIndex` would collide) — and the `StageContext` lookups that need `FsToolkit`/`HttpClient`, plus the consumer-facing re-exports `writeLine`/`getOutput`/`getVerbosity`/`getNamePath`. A consumer never needs `open Partas.Build.Internal`; `tests/Partas.Build.Tests/ConsumerSurfaceTests.fs` compiles without it and must keep doing so.
5. `Conductors.Runners.fs` (`Partas.Build.Internal.Runners`) — the execution engine (`StageContext.run`, `PipelineContext.run`).
The `Build*` function aliases (`BuildStage`, `BuildStep`, `BuildStageIsActive`, …) no longer take an `ActionContext`: stages are pure, and the aliases match Fun.Build's originals except that Fun.Build declares them as delegates where this port uses plain function types. That difference has one mechanical consequence: **do not mark a CE entry member `inline` when it applies a `Build*` alias**, or Release builds fail with `FS1118` (Debug compiles fine). `Run` members are the usual offenders. `run (build: BuildStage, buildStep: StageContext -> BuildStep)` on `StageBuilder` stays non-generic: generalizing it ties it against the flexible-signature `run (step)` overload under `FS0041` at four existing call sites (`PLAN-Execution.md`'s T8 section). Being non-generic, it composes the alias itself, so that member must also stay non-`inline`. `FS1114` ("marked inline but was not bound in optimization environment") is the failure mode of forwarding one generic member to another sibling overload rather than building its step directly — moving the shared builder settings hit it in both Debug and Release, not only in Release like `FS1118`.
@@ -156,7 +251,7 @@ a `voption` setting transfers only when the pipeline left it `ValueNone`; `PostS
declared none; `AcceptableExitCodes` only while the pipeline is still on `set [0]`; the hooks and `Verify` only
while they are still the `noStageHook`/`alwaysVerify` values `create` installed, so those are named
module-level bindings rather than inline `ignore` — reference equality is the test; and `EnvVars` per key,
-since a pipeline's map starts as the whole ambient environment. `NoPrefixForStep`/`NoStdRedirectForStep` are
+a default key applying wherever the pipeline did not set that key itself. `NoPrefixForStep`/`NoStdRedirectForStep` are
plain bools with no unset state, so a pipeline setting one to the value it already had is indistinguishable
from not setting it at all — the single known gap.
@@ -173,11 +268,18 @@ Overload resolution in these builders fails in ways that are invisible by inspec
`Build/Program.fs` is the whole CLI — repository paths, options, stages and commands in one file. Repository paths come from `Partas.TypeProvider.BuildHelper` (`type Repo = BuildHelperProvider<...>`, with `Repo.FileSystem` and `Repo.VirtualFileSystem` for the real and virtual file systems), so a renamed project breaks compilation instead of failing mid-release. A new packable project goes in `Project.allProjects`, which is both what `bump` can version and what `pack` packs; `Repo.Project..Path` is a compile-time constant, while `PackageId`/`AssemblyName`/`Version` are MSBuild evaluations that shell out to `dotnet msbuild -getProperty`, so keep those off any path that runs before parsing.
-Since Phase 7 the CLI is written against Partas.Build (`Build.fsproj` has a project reference to `src/Partas.Build`), so it doubles as the design's acceptance test. A step is a stage; a stage that needs a flag binds it in `inputs { let! quick = Options.quick ... return stage "..." { when' (not quick); run (cmd $"dotnet ...") } }`, and the command registers it by running the pipeline that contains it. The CLI has no per-command option lists and no process wrappers. Custom operations cannot sit under an `if`/`match`, so a stage that branches builds a `Cmd` first and runs it unconditionally, and a stage that exists only when an option was supplied is yielded through `whenSome` — `ProjectManagement.publish` yields its `nuget publish` stage that way, so the stage closes over the key rather than reaching for `key.Value` under a `when'`.
+Since Phase 7 the CLI is written against Partas.Build (`Build.fsproj` has a project reference to `src/Partas.Build`), so it doubles as the design's acceptance test. A step is a stage; a stage that needs a flag binds it in `input { let! quick = Baked.Common.quick ... return stage "..." { when' (not quick) "--quick is set"; run (cmd $"dotnet ...") } }`, and the command registers it by running the pipeline that contains it. The CLI has no per-command option lists and no process wrappers. Custom operations cannot sit under an `if`/`match`, so a stage that branches builds a `Cmd` first and runs it unconditionally, and a stage that exists only when an option was supplied is yielded through `whenSome` — `Baked.Stages.nugetPush`, which `ProjectManagement.publishAll` uses, matches on the key the same way, so the stage closes over the key rather than reaching for `key.Value` under a `when'`. Restore, clean, build, pack, the Expecto suites and the push are Baked prefabs (`notes/PLAN-Integration.md` §3.1); the CLI keeps only what is its own — the compiler probe, the docs stages, `bump`'s project list.
+
+The four Expecto suites run `--sequenced`. Each drives real pipelines, and a pipeline writes to one process-wide console and holds a thread in `Async.RunSynchronously` for the length of every stage — run in parallel on a two-core runner, that yields a log whose lines belong to no test in particular, with enough blocked workers that the thread pool grows one thread at a time. `Baked.Stages.expecto` captures output under `--ci` by default; this repository overrides its suites to console output so assertions reach the CI log and uploaded artifact. Test child processes receive an empty `GITHUB_STEP_SUMMARY` so fixture commands keep their reports out of the real job summary. Library builds run sequentially because they share referenced assembly outputs. `Tests.execute` also runs `tests/Partas.Build.CompilerProbe` twice, once per configuration, regardless of `--configuration`. Release catches an `inline` member that applies a `Build*` alias (`FS1118`), which a Debug build compiles clean.
-The four Expecto suites run `--sequenced`. Each drives real pipelines, and a pipeline writes to one process-wide console and holds a thread in `Async.RunSynchronously` for the length of every stage — run in parallel on a two-core runner, that yields a log whose lines belong to no test in particular, with enough blocked workers that the thread pool grows one thread at a time. `Tests.execute` captures the suites' output when `--ci` is set, so a green CI run says nothing and a red one lifts the whole failure into the annotation; locally it stays live. `Tests.execute` also runs `tests/Partas.Build.CompilerProbe` twice, once per configuration, regardless of `--configuration`. Release catches an `inline` member that applies a `Build*` alias (`FS1118`), which a Debug build compiles clean.
+The `cmd`, `operations` and `CompilerTests` lists end in `|> testLabel "integration"`, so their tests' full names
+start with an `integration` segment (`integration.cmd.…` in Expecto's output; a `--filter`/`--filter-test-list` on
+them needs it, `--filter-test-case` does not).
+SageFs live testing classifies a test whose full name contains `integration` as Integration and runs it only on
+demand, instead of on every change under a five-second timeout; the rule is `CategoryDetection.categorize` in
+SageFs's `SageFs.Core/Features/LiveTestingTypes.fs`, which for Expecto reads the name alone.
-Fake survives only where it is better than a process call: `!!` and `Shell.cleanDirs` for the clean. Everything else is a `run` step.
+There is no Fake dependency: the clean is `Baked.Stages.clean`, over `System.IO`. Everything else is a `run` step.
### Versioning
@@ -198,6 +300,8 @@ a patch bump move it breaks anything not rebuilt in the same pass with `Could no
- `.editorconfig` sets Stroustrup style, `max_line_length=150`, `fsharp_space_before_uppercase_invocation=true`. No fantomas tool is installed (`.config/dotnet-tools.json` declares no tools at all) and there is no `format`/`lint` command — match surrounding style manually.
- Prefer `voption`/`ValueOption` and `[]` DUs in the library: a departure from the ported Fun.Build code.
-- Public API goes in `[]` modules under `Partas.Build`; the model and engine stay in `Partas.Build.Internal`.
-- Console output is Spectre.Console throughout, with GitHub Actions `::error title=...::` fallbacks when `GITHUB_ENV` is present (see `printError` in both context modules).
+- Public API goes in `[]` modules under `Partas.Build`; the model and engine stay in `Partas.Build.Internal`. A model type that appears in a public signature gets an abbreviation in `ConsumerTypes` (`Conductors.fs`), and a lookup a step needs gets a re-export in the public `StageContext` module.
+- Human-readable console output uses `Terminal.ansi ()`, which follows the invocation writer. Plain host consoles disable CI profile enrichment to preserve plain text. GitHub workflow commands use `Terminal.out ()` directly to preserve one physical line, enabled by case-insensitive `GITHUB_ACTIONS=true` detection (see `WorkflowCommands` and `printError` in both context modules).
- The Nacara site (`docs/Site.fs`, `docs/docs.fsproj`) publishes every page under `docs/content/` and `docs/blog/`, plus the generated API reference, so internal working documents belong in `notes/` (as the `PLAN*.md` files do), not under `docs/`.
+- The `.fsx` pages under `docs/content/` `#load` the library's sources in `` order: a file added to `Partas.Build.fsproj` or `Partas.Build.Baked.fsproj` goes into all four lists, or the pages stop type-checking. Check a page with `dotnet fsi .fsx` from its own directory. `docs/static/` is copied to the site root verbatim: `llms.txt` and `AGENTS-snippet.md` (the block a consumer pastes into its `AGENTS.md`, also packed into the `Partas.Build` package root). Nacara generates no `llms-full.txt`.
+- XML docs carry `` blocks on the entry points an agent hovers (`run`/`runLine`/`runSensitive`, `input`, `whenSome`, `Cmd.argIf`/`argWhenSome`/`cmd`, `stage`/`pipeline`/`command`/`rootCommand`/`rootCommandOfScript`, `Command.root`/`invoke`, `RootCommandDefinition.Invoke`, `RunResult`, `MachineOutput.json`, `Baked.Stages`). They are written to compile: an edit to one is checked by pasting it into a scratch script against the built library. Escape `<` as `<` inside them; `GenerateDocumentationFile` is on and the build stays warning-free.
diff --git a/Partas.Build.ExternalAnnotations/Library.fs b/Partas.Build.ExternalAnnotations/Library.fs
index ca87b48..5c65715 100644
--- a/Partas.Build.ExternalAnnotations/Library.fs
+++ b/Partas.Build.ExternalAnnotations/Library.fs
@@ -58,30 +58,30 @@ module ExternalAnnotations =
module Options =
let strict =
Input.option "--strict"
- |> Input.desc "Fail when any member is skipped, rather than warning and continuing"
+ |> Input.description "Fail when any member is skipped, rather than warning and continuing"
let annotationsTool =
Input.optionMaybe "--annotations-tool"
|> Input.arity Arity.ExactlyOne
- |> Input.desc "Command that generates annotations during pack, e.g. 'dotnet partas-annotations'"
+ |> Input.description "Command that generates annotations during pack, e.g. 'dotnet partas-annotations'"
|> Input.helpName "COMMAND"
let force =
Input.option "--force"
- |> Input.desc "Overwrite an existing Directory.Build.targets"
+ |> Input.description "Overwrite an existing Directory.Build.targets"
let assembly =
Input.option "--assembly"
|> Input.required
|> Input.acceptLegalFilePathsOnly
- |> Input.desc "Assembly to scan for annotations"
+ |> Input.description "Assembly to scan for annotations"
|> Input.helpName "PATH"
let output =
Input.option "--output"
|> Input.required
|> Input.acceptLegalFilePathsOnly
- |> Input.desc "Annotations file to write"
+ |> Input.description "Annotations file to write"
|> Input.helpName "PATH"
let attribute =
@@ -89,28 +89,28 @@ module ExternalAnnotations =
|> Input.def [||]
|> Input.arity Arity.ZeroOrMore
|> Input.allowMultipleArgumentsPerToken
- |> Input.desc
+ |> Input.description
"Collect only these attributes, by simple name; the default is every JetBrains.Annotations attribute"
|> Input.helpName "NAME"
let minMembers =
Input.option "--min-members"
|> Input.def 0
- |> Input.desc "Fail when any assembly's sidecar annotates fewer members than this"
+ |> Input.description "Fail when any assembly's sidecar annotates fewer members than this"
|> Input.helpName "N"
let package =
Input.option "--package"
|> Input.required
|> Input.acceptLegalFilePathsOnly
- |> Input.desc "The .nupkg to check"
+ |> Input.description "The .nupkg to check"
|> Input.helpName "PATH"
let directory =
Input.option "--directory"
|> Input.def "."
|> Input.acceptLegalFilePathsOnly
- |> Input.desc "Directory to write Directory.Build.targets into"
+ |> Input.description "Directory to write Directory.Build.targets into"
|> Input.helpName "PATH"
///
diff --git a/README.md b/README.md
index ff1f8d3..1776065 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,8 @@ pipeline definition instead of by hand. Runs from a `.fsx` script or a build pro
- **Documentation:**
- **Every operation, one line each:** [`docs/content/Build/CAPABILITIES.md`](docs/content/Build/CAPABILITIES.md)
([rendered](https://shayanhabibi.github.io/Partas.Build/build/capabilities/))
-- **Agents:** start at
+- **Agents:** start at ; a consumer repository can paste
+ [the agent snippet](https://shayanhabibi.github.io/Partas.Build/AGENTS-snippet.md) into its own `AGENTS.md`
> The entire [`FSharp.SystemCommandLine`](https://github.com/jordanmarr/FSharp.SystemCommandLine) library is copied directly into this repo. All credit to the original author.
> Much of the pipeline implementation is copied from [`Fun.Build`](https://github.com/slaveOfTime/Fun.Build). All credit to the original author.
@@ -54,7 +55,7 @@ module Options =
let quick =
Input.option "--quick"
|> Input.alias "-q"
- |> Input.desc "Skip restores and cleaning"
+ |> Input.description "Skip restores and cleaning"
let config =
Input.option "--configuration"
@@ -62,14 +63,14 @@ module Options =
|> Input.def "Release"
|> Input.helpName "Debug|Release"
|> Input.acceptOnlyFromAmong [ "Debug"; "Release" ]
- |> Input.desc "Build configuration"
+ |> Input.description "Build configuration"
module Stages =
let restore = input {
let! quick = Options.quick
return stage "restore" {
- when' (not quick)
+ when' (not quick) "--quick is set" // the reason --explain prints when the stage is skipped
run "dotnet restore"
}
}
@@ -141,10 +142,29 @@ Options:
| A stage that exists only when an option has a value | `whenSome`, which yields no stage for `None` rather than an inactive one |
| A block of stages parameterised by an option someone else declares | Take an `InputSpec<'T>` parameter and `let!` it |
| The root command to call itself something other than the script's filename | `name` on `rootCommand` |
+| Restore, clean, build, pack, Expecto and NuGet push stages, ready made | `Partas.Build.Baked`'s `Stages` — each brings its own `--quick`/`--configuration`/`--skip-tests`/`--nuget-key` |
+| What a command would run, without running it | ` --explain`; `--explain --json` for the tree as JSON, `--schema` for the options |
+| A run's outcome for a script or an agent to read | `--json` (the last line of output) or `--report `; exit codes `0` success, `1` failure, `2` usage error, `130` cancelled |
+| The build as a function in a warm F# session (SageFs) | `Command.root { }` and `Command.invoke`, which answer a `RunResult` — see [Hosting](https://shayanhabibi.github.io/Partas.Build/build/hosting/) |
The right-hand column in full is [`docs/content/Build/CAPABILITIES.md`](docs/content/Build/CAPABILITIES.md)
([rendered](https://shayanhabibi.github.io/Partas.Build/build/capabilities/)).
+`--json` defaults to true when the invocation environment identifies an AI coding harness. Detection follows
+the environment rules in [is-ai-agent](https://github.com/sdairs/is-ai-agent#detection-order), including `AGENT`,
+`AI_AGENT` and vendor markers; it does not probe the filesystem or inspect credentials. These inherited markers
+are a convenience signal, not proof that an AI initiated the command.
+
+Use `--json false` for text on one invocation, or set `PARTAS_BUILD_DISABLE_AI=1` to disable automatic agent
+defaults. The override is enabled by any nonblank value except `0`, `false`, `no` or `off` (trimmed and
+case-insensitive). Explicit `--json` still works with the override. `--explain` and `--schema` remain explicit:
+detection changes the output format, never whether the build runs. Child process arguments are unaffected.
+
+`AiEnvironment.detect ()` returns a `Lazy` that reads the environment only when `.Value` is requested.
+Each JSON default creates a fresh detection, so a reusable command in a long-lived host sees environment
+changes between invocations; a saved lazy result retains its first value. `AiEnvironment.detectWith` accepts
+an environment reader for captured environments and tests.
+
## Composition
`command { stage; stage }` is the common form. Consecutive stages yielded into a command become one pipeline
diff --git a/docs/content/Build/CAPABILITIES.md b/docs/content/Build/CAPABILITIES.md
index a86d652..6944626 100644
--- a/docs/content/Build/CAPABILITIES.md
+++ b/docs/content/Build/CAPABILITIES.md
@@ -97,12 +97,13 @@ Available inside `stage`, and inside `whenStage`, which accepts everything `stag
| Operation | What it does |
|---|---|
-| `run` | Adds a step. Takes a literal command line, a `Cmd`, or a function of the `StageContext` returning `unit`, `int`, `Result`, a `Cmd`, an `Async<_>` or a `Task<_>` of any of those, optionally wrapped in `option` |
+| `run` | Adds a step. Takes a literal command line, a `Cmd`, or a function of the `StageContext` returning `unit`, `int`, `Result`, a `Cmd`, an `Async<_>` or a `Task<_>` of any of those, optionally wrapped in `option`. A function returning a `string` is obsolete: use `runLine` |
+| `runLine` | Adds a step that runs the command line a function of the `StageContext` returns (`string`, `Async` or `Task`, optionally wrapped in `option`). The line is split on whitespace, honouring quotes |
| `runSensitive` | Adds a step from an interpolated command line with every hole masked as `***` wherever the library prints it |
| `runOperation` | Adds a step from an `Operation`, with an optional label for `--explain`. Runs under the stage's working directory, environment, acceptable exit codes and output routing |
| `runHttpHealthCheck` | Adds a step that polls a URL until it answers or the stage is cancelled |
| `echo` | Adds a step that prints a message through the stage's output sink |
-| `when'` | Runs the stage only when a `bool` holds, or only when a given `StageContext` succeeds |
+| `when'` | Runs the stage only when a `bool` holds, or only when a given `StageContext` succeeds. `when' (not quick) "--quick is set"` gives `--explain` the reason it prints against the skipped stage |
| `whenEnvVar` | Runs the stage only when an environment variable is set, or set to a given value; also takes an `EnvArg` |
| `whenBranch` / `whenBranches` | Runs the stage only on the named git branch. Reads `git branch --show-current` in the stage's working directory; a missing git evaluates false rather than throwing |
| `whenWindows` / `whenLinux` / `whenOSX` | Runs the stage only on that platform. Pass `false` to invert |
@@ -328,6 +329,60 @@ become one implicit pipeline carrying the command's name and description. `Comma
pipeline written out, for when it needs pipeline-level settings. `pipeline "name" { }` is for several pipelines
under one command, or a pipeline that needs its own name.
+## Flags every command carries
+
+| Flag | What it does |
+|---|---|
+| `--explain` | Prints the resolved stage tree and runs nothing. A grouping command lists its subcommands |
+| `--json` | With `--explain`, prints the tree as JSON, leaving `whenBranch` and `whenStage` conditions unevaluated. On a run, prints the run result — each stage's outcome, failures and timing — as one line of JSON after the run, in place of the timing table |
+| `--report ` | Writes the run result as JSON to a file. Only on commands that run pipelines |
+| `--schema` | Prints the command, its options (name, aliases, type, default, accepted values, description) and its subcommands as JSON, and runs nothing |
+
+`--json` defaults to true in an agent environment, detected lazily from `AGENT`, `AI_AGENT` or vendor markers
+following [is-ai-agent's environment rules](https://github.com/sdairs/is-ai-agent#detection-order).
+Use `--json false` to select text explicitly, or `PARTAS_BUILD_DISABLE_AI=1` to disable automatic defaults.
+The override leaves explicit `--json` available. Detection is fresh per invocation in a long-lived host.
+Neither `--explain` nor `--schema` is enabled automatically, and consumer-declared options keep their defaults.
+
+A command that declares one of these names itself keeps its own option. Every JSON document carries
+`formatVersion`. Text `--explain` evaluates every condition, running a `whenStage` condition stage once; a
+condition that throws is shown with its message. `Conditions.effectful description condition` marks a
+condition of your own so the JSON form leaves it unevaluated too.
+
+## Exit codes
+
+| Code | `ExitCode` / `RunOutcome` | When |
+|---|---|---|
+| `0` | `Success` / `Succeeded` | Every pipeline succeeded, or the invocation printed help, a version, `--explain` or `--schema` |
+| `1` | `Failure` / `Failed` | A stage failed, or the invocation raised an exception |
+| `2` | `UsageError` / `UsageError` | The command line did not parse or validate, or the selected pipelines failed dependency validation, `--explain` included. No stage ran |
+| `130` | `Cancelled` / `Cancelled` | The pipeline's own timeout expired, or the invocation's cancellation token fired |
+
+A stage's own `timeout` is a failure of that stage (`1`), not a cancellation. Ctrl+C terminates the process by
+signal: it writes no run result, `--json` line or `--report` file, and the exit status is the shell's (`130` on
+Unix by convention).
+
+## Invoking a command from code
+
+`rootCommand argv { }` parses and runs as it is constructed, and answers the exit code. `Command.root { }` takes
+the same operations and builds a `RootCommandDefinition` without parsing or running anything, so a host binds it
+once and invokes it any number of times. [Hosting a build in a long-lived session](hosting.md) walks through it.
+
+| Member | What it does |
+|---|---|
+| `Command.root { }` | Builds a `RootCommandDefinition`: every operation `rootCommand` takes, nothing run |
+| `Command.invoke args root` | Parses `args` (without the script name), runs what they select, and answers a `RunResult` |
+| `root.Invoke(args, ?output, ?error, ?cancellationToken)` | The same, with a `TextWriter` for everything the run prints (help, `--explain`, the pipeline's lines and console-sink child processes, as plain text), a writer for parse errors, and a token that cancels the run |
+| `root.Command` | The underlying `System.CommandLine` `RootCommand` |
+
+`Invoke` runs on a thread of its own. `Thread.Interrupt` on the calling thread cancels the run as the token
+would — killing the process tree of any running step — then raises `ThreadInterruptedException` from `Invoke`.
+
+`RunResult` is `{ ExitCode; Outcome: RunOutcome; Pipelines: PipelineRun list }`, where each `PipelineRun` is
+`{ Name; Reports: ScopeReport list; Timings: StageTiming list }`, snapshots taken as the pipeline finished. Its
+members flatten across pipelines: `Reports`, `Timings` (pre-order) and `Failures` (every `StepFailure`, tolerated
+ones included). `RunResult.toJson indented result` is the document `--json` and `--report` write.
+
## Condition builders
`whenAll { }`, `whenAny { }` and `whenNot { }` take these. Each yields a single condition to a stage. An empty
@@ -368,7 +423,7 @@ Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable:
| Function | What it does |
|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| `Input.alias` / `Input.aliases` | Adds alternative names. Options only |
-| `Input.description`, `Input.desc` | The help text |
+| `Input.description` (`Input.desc` is obsolete) | The help text |
| `Input.helpName` | The value placeholder in help — `` |
| `Input.defaultValue`, `Input.def` | The value used when the token is absent |
| `Input.defaultValueFactory` | The same, computed from the `ArgumentResult` |
@@ -376,6 +431,7 @@ Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable:
| `Input.required` | Marks an option required |
| `Input.recursive` | Applies the option to the command and, recursively, its subcommands |
| `Input.hidden` | Keeps it out of help output |
+| `Input.sensitive` | Marks the symbol secret: `--schema` reports it `"sensitive": true` and writes a present default as `"***"` |
| `Input.allowMultipleArgumentsPerToken` | Lets one identifier token carry several values |
| `Input.acceptOnlyFromAmong` | Restricts to a set of legal strings, ordinally |
| `Input.addCompletion` / `Input.addCompletions` | Adds tab-completion suggestions without restricting what is accepted |
@@ -414,7 +470,7 @@ let build (projects: InputSpec) = input {
| `InputSpec.union` | Concatenates input lists, keeping the first occurrence of each |
The `input { let! … and! … return … }` CE is the usual way to build one; `inputs` is the same builder under
-a second name (`src/Partas.Build/Builders/Inputs.fs` binds both). It is applicative: bind every source
+an obsolete second name (`src/Partas.Build/Builders/Inputs.fs` binds both). It is applicative: bind every source
in a single `let!`/`and!` group. A sequential second `let!` is a compile error (`FS0708`): the input set must
be readable before anything is parsed. An `input { }` nested inside another's `return` produces an
`InputSpec>`, which nothing accepts — pass the *source* in as an `InputSpec` instead.
@@ -470,6 +526,25 @@ equivalent. `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combin
| `Baked.Dotnet.config` | `configuration` (alias `-c`) as `string option`, over `release`/`r`/`debug`/`d` case-insensitively |
| `Baked.SemVer.bump` | `bump` as `Bump option`, over `major\|minor\|patch\|alpha\|beta\|rc\|preview\|`, defaulting to `Patch` |
| `Baked.Common.isCI` | `--ci`, defaulting to true when any of the usual CI environment variables is set |
+| `Baked.Common.quick` | `--quick` (alias `-q`): skips restores, installations, cleaning and formatting |
+| `Baked.Common.skipTests` | `--skip-tests` |
+| `Baked.Common.watch` | `--watch` |
+| `Baked.Dotnet.configOrRelease` | `InputSpec`: the `--configuration` value, `Release` when it is omitted |
+
+`Baked.Stages` holds ready-made stages, each an `InputSpec` that `stage`, `pipeline` and `command`
+yield directly and whose options reach the command's `--help`. Each reads Baked's own options; its `…With`
+counterpart takes each of those options as an `InputSpec<_>` instead. A skip reports its reason to `--explain`.
+
+| Function | What it does |
+|---|---|
+| `Stages.restore solution` | `dotnet tool restore`, then `dotnet restore`; skipped by `--quick` |
+| `Stages.clean directories files` | Empties the directories and deletes the files the glob patterns select (`!` excludes), under the stage's working directory, following no links; skipped by `--quick` |
+| `Stages.build projects` | One `dotnet build -c ` sub-stage per project, in parallel |
+| `Stages.pack outDir projects` | One `dotnet pack --no-build --no-restore` sub-stage per project, in parallel |
+| `Stages.expecto project arguments` | Runs a built Expecto suite through `dotnet run --no-build`; skipped by `--skip-tests`; under `--ci`, passes `--summary` and captures the output |
+| `Stages.nugetPush packages` | `dotnet nuget push --skip-duplicate`, to nuget.org with `--nuget-key` (masked everywhere it prints) or to the source named `local` without one |
+| `Stages.fantomas paths` | `dotnet fantomas` over the paths; skipped by `--quick` |
+| `Stages.npmInstall directory` | `npm ci` under `--ci`, `npm install` otherwise; skipped by `--quick` |
| Function | What it does |
|---|---|
@@ -482,7 +557,8 @@ equivalent. `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combin
## Reference
-- [Overview](build-overview.fsx) — the layers, and a first pipeline.
+- [Overview](index.fsx) — the layers, and a first pipeline.
- [Composing reusable blocks](composition.fsx) — blocks, nesting, and composition across files.
+- [Hosting a build in a long-lived session](hosting.md) — `Command.invoke` from SageFs or any F# host.
- [Stage CE run overloads](computation-expression-operations.fsx).
- [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) — full signatures and remarks.
diff --git a/docs/content/Build/composition.fsx b/docs/content/Build/composition.fsx
index d524bf6..7caaccd 100644
--- a/docs/content/Build/composition.fsx
+++ b/docs/content/Build/composition.fsx
@@ -23,6 +23,7 @@ order: 3
#load "../../../src/Partas.Build/System.CommandLine/Inputs.fs"
#load "../../../src/Partas.Build/Exceptions.fs"
#load "../../../src/Partas.Build/Output.fs"
+#load "../../../src/Partas.Build/Terminal.fs"
#load "../../../src/Partas.Build/Annotations.fs"
#load "../../../src/Partas.Build/Environment.fs"
#load "../../../src/Partas.Build/Timing.fs"
@@ -42,18 +43,22 @@ order: 3
#load "../../../src/Partas.Build/Builders/PipelineSettings.fs"
#load "../../../src/Partas.Build/Builders/Pipeline.fs"
#load "../../../src/Partas.Build/Builders/Inputs.fs"
+#load "../../../src/Partas.Build/AiEnvironment.fs"
+#load "../../../src/Partas.Build/MachineOutput.fs"
#load "../../../src/Partas.Build/Explain.fs"
#load "../../../src/Partas.Build/Summary.fs"
+#load "../../../src/Partas.Build/RunResult.fs"
#load "../../../src/Partas.Build/Builders/Command.fs"
#load "../../../src/Partas.Build.Baked/Program.fs"
#load "../../../src/Partas.Build.Baked/Common.fs"
#load "../../../src/Partas.Build.Baked/NuGet.fs"
#load "../../../src/Partas.Build.Baked/Dotnet.fs"
#load "../../../src/Partas.Build.Baked/SemVer.fs"
+#load "../../../src/Partas.Build.Baked/Clean.fs"
+#load "../../../src/Partas.Build.Baked/Stages.fs"
open Partas.Build
-open Partas.Build.Internal
(**
# Composing reusable blocks
@@ -81,7 +86,7 @@ module Options =
let quick =
Input.option "--quick"
|> Input.alias "-q"
- |> Input.desc "Skip restores and cleaning"
+ |> Input.description "Skip restores and cleaning"
let verbose = Input.option "--verbose" |> Input.alias "-v"
@@ -262,7 +267,7 @@ items` does the same over a mapping. These are the two functions to reach for wh
A wrapper can bind flags the wrapped blocks know nothing about, alongside the sequenced blocks:
*)
-let skipTests = Input.option "--skip-tests" |> Input.desc "Build the tests but do not run them"
+let skipTests = Input.option "--skip-tests" |> Input.description "Build the tests but do not run them"
let testGroup (blocks: InputSpec list) = input {
let! stages = InputSpec.sequence blocks
@@ -471,7 +476,7 @@ module Options =
Input.option "--target"
|> Input.mapFromAmong [ "node", "node"; "browser", "browser" ]
|> Input.def "node"
- |> Input.desc "Runtime the wire layer is generated for"
+ |> Input.description "Runtime the wire layer is generated for"
module Stages =
let generate layer = input {
diff --git a/docs/content/Build/computation-expression-operations.fsx b/docs/content/Build/computation-expression-operations.fsx
index 1f70598..373a073 100644
--- a/docs/content/Build/computation-expression-operations.fsx
+++ b/docs/content/Build/computation-expression-operations.fsx
@@ -22,6 +22,7 @@ order: 4
#load "../../../src/Partas.Build/System.CommandLine/Inputs.fs"
#load "../../../src/Partas.Build/Exceptions.fs"
#load "../../../src/Partas.Build/Output.fs"
+#load "../../../src/Partas.Build/Terminal.fs"
#load "../../../src/Partas.Build/Annotations.fs"
#load "../../../src/Partas.Build/Environment.fs"
#load "../../../src/Partas.Build/Timing.fs"
@@ -41,20 +42,24 @@ order: 4
#load "../../../src/Partas.Build/Builders/PipelineSettings.fs"
#load "../../../src/Partas.Build/Builders/Pipeline.fs"
#load "../../../src/Partas.Build/Builders/Inputs.fs"
+#load "../../../src/Partas.Build/AiEnvironment.fs"
+#load "../../../src/Partas.Build/MachineOutput.fs"
#load "../../../src/Partas.Build/Explain.fs"
#load "../../../src/Partas.Build/Summary.fs"
+#load "../../../src/Partas.Build/RunResult.fs"
#load "../../../src/Partas.Build/Builders/Command.fs"
#load "../../../src/Partas.Build.Baked/Program.fs"
#load "../../../src/Partas.Build.Baked/Common.fs"
#load "../../../src/Partas.Build.Baked/NuGet.fs"
#load "../../../src/Partas.Build.Baked/Dotnet.fs"
#load "../../../src/Partas.Build.Baked/SemVer.fs"
+#load "../../../src/Partas.Build.Baked/Clean.fs"
+#load "../../../src/Partas.Build.Baked/Stages.fs"
open System.Threading
open Partas.Build
-open Partas.Build.Internal
(**
# Computation Expression Operations
@@ -124,31 +129,36 @@ let _ = stage "stage" {
run (fun _ -> task { do () })
(**
+#### `runLine`
+
+A function returning a command line is `runLine`, not `run`. `run (fun ctx -> "...")` still compiles, marked
+obsolete: a lambda written to return a message would otherwise start a process. The line is split on whitespace,
+honouring quotes; build a `Cmd` with `cmd $"..."` to keep each interpolation hole as one argument.
+
##### `commandFn: StageContext -> string`
##### `commandFn: StageContext -> Async`
##### `commandFn: StageContext -> Task`
*)
- run (fun _ -> "dotnet build")
- run (fun _ -> async { return "dotnet build" })
- run (fun _ -> task { return "dotnet build" })
+ runLine (fun _ -> "dotnet build")
+ runLine (fun _ -> async { return "dotnet build" })
+ runLine (fun _ -> task { return "dotnet build" })
// With CancellationToken
- run (fun _ -> "dotnet build") CancellationToken.None
- run (fun _ -> async { return "dotnet build" }) CancellationToken.None
- run (fun _ -> task { return "dotnet build" }) CancellationToken.None
+ runLine (fun _ -> "dotnet build") CancellationToken.None
+ runLine (fun _ -> async { return "dotnet build" }) CancellationToken.None
+ runLine (fun _ -> task { return "dotnet build" }) CancellationToken.None
(**
##### `commandMaybeFn: StageContext -> string option`
##### `commandMaybeFn: StageContext -> Async`
##### `commandMaybeFn: StageContext -> Task`
*)
- // todo - overloads without CancellationToken should not require explicit typing
- run (fun _ -> Some "dotnet build")
- run (fun _ -> async { return Option.None })
- run (fun _ -> task { return Some "dotnet build" })
+ runLine (fun _ -> Some "dotnet build")
+ runLine (fun _ -> async { return Option.None })
+ runLine (fun _ -> task { return Some "dotnet build" })
// With CancellationToken
- run (fun _ -> Some "dotnet build") CancellationToken.None
- run (fun _ -> async { return Some "dotnet build" }) CancellationToken.None
- run (fun _ -> task { return Some "dotnet build" }) CancellationToken.None
+ runLine (fun _ -> Some "dotnet build") CancellationToken.None
+ runLine (fun _ -> async { return Some "dotnet build" }) CancellationToken.None
+ runLine (fun _ -> task { return Some "dotnet build" }) CancellationToken.None
(**
diff --git a/docs/content/Build/hosting.md b/docs/content/Build/hosting.md
new file mode 100644
index 0000000..040188c
--- /dev/null
+++ b/docs/content/Build/hosting.md
@@ -0,0 +1,214 @@
+---
+title: Hosting a build in a long-lived session
+category: Build
+order: 5
+---
+
+# Hosting a build in a long-lived session
+
+A build script run as `dotnet fsi build.fsx -- test` compiles the script, resolves its packages and starts a
+runtime on every run, and every tool script it shells out to (`run "dotnet fsi tools/gen.fsx -- …"`) pays the same
+again. A long-lived F# session — [SageFs](https://github.com/WillEhrendreich/SageFs), or any host holding an
+`FsiEvaluationSession` — pays it once. The build then becomes a function the session calls, answering a
+structured `RunResult` instead of an exit code and a screen of text.
+
+This page is written against SageFs, but nothing in it depends on SageFs beyond the location of its startup script.
+
+## The shape
+
+Three files, where a plain script build has one:
+
+| File | Holds | Side effects when loaded |
+|---|---|---|
+| `build.defs.fsx` | Options, stages, commands, and `let root = Command.root { … }` | None |
+| `build.fsx` | `#load "build.defs.fsx"`, then one line that invokes `root` and exits | Runs the build, exits the process |
+| `.SageFs/init.fsx` | `#load "build.defs.fsx"`, then a `build` function over `root` | None |
+
+The split exists because a session must never evaluate the last line of `build.fsx`. `rootCommand argv { … }` and
+`rootCommandOfScript { … }` run the build *as they are constructed*, and `exit` terminates the process — in a
+session, the session's own worker. `Command.root { … }` takes the same operations and builds the command without
+parsing or running anything.
+
+### `build.defs.fsx`
+
+```fsharp
+module BuildDefs
+
+#r "nuget: Partas.Build"
+#r "nuget: Partas.Build.Baked"
+
+open Partas.Build
+open Partas.Build.Baked
+
+let projects = [ "src/MyLib/MyLib.fsproj" ]
+
+let root = Command.root {
+ description "MyLib's build"
+ workingDir __SOURCE_DIRECTORY__
+ command "build" {
+ description "Restores and builds"
+ Command.pipeline {
+ Stages.restore "MyLib.slnx"
+ Stages.build projects
+ }
+ }
+ command "test" {
+ description "Builds and runs the tests"
+ Command.pipeline {
+ Stages.restore "MyLib.slnx"
+ Stages.build projects
+ Stages.expecto "tests/MyLib.Tests/MyLib.Tests.fsproj" [ "--sequenced" ]
+ }
+ }
+}
+```
+
+`workingDir __SOURCE_DIRECTORY__` makes every relative path resolve against the repository, whatever the
+session's current directory is. The explicit `module BuildDefs` gives the file a name that reads well from the
+other two; without it, the module is named after the file.
+
+### `build.fsx`
+
+```fsharp
+#load "build.defs.fsx"
+
+open Partas.Build
+
+exit (Command.invoke (Args.script ()) BuildDefs.root).ExitCode
+```
+
+`dotnet fsi build.fsx -- test --quick` behaves exactly as a single-file build did: same commands, same `--help`,
+same exit codes.
+
+### `.SageFs/init.fsx`
+
+```fsharp
+#load "build.defs.fsx"
+
+open System
+open Partas.Build
+
+let build (args: string list) = BuildDefs.root.Invoke(args, output = Console.Out)
+```
+
+SageFs evaluates the *text* of this file at the end of a session's warm-up, so `#load` paths resolve against the
+session's working directory — the repository — rather than against `.SageFs/`.
+
+`output = Console.Out` is read on each call, which in SageFs is the writer capturing the current evaluation.
+Given a writer, `Invoke` sends everything the run prints to it as plain text: help, `--explain`, the pipeline's
+own lines, and the output of every child process whose stage writes to the console. Without it, a child process
+inherits the worker's real standard output, and its lines never reach the evaluation's result.
+
+## Calling it
+
+From the session, or from an agent through SageFs's `send_fsharp_code`:
+
+```fsharp
+build [ "test"; "--explain" ] // what would run, and what is skipped and why
+let result = build [ "test"; "--quick" ]
+
+result.Outcome // Succeeded | Failed | UsageError | Cancelled
+result.ExitCode // 0 | 1 | 2 | 130
+
+[ for failure in result.Failures -> failure.Label, FailureCause.describe failure.Cause ]
+[ for timing in result.Timings -> timing.Name, timing.Elapsed ]
+```
+
+`build [ "test"; "--json" ]` ends its output with the JSON form of the same result, for a caller that reads
+text rather than F# values.
+
+## Mounting other scripts' commands
+
+A build that runs a tool script as a child process —
+
+```fsharp
+stage "wire" { run "dotnet fsi tools/wire.fsx -- generate" }
+```
+
+— cold-starts `fsi` for it on every run, and loses the tool's result to an exit code. Split the tool the same way
+and mount its command instead:
+
+```fsharp
+// tools/wire.defs.fsx
+module Wire
+
+#r "nuget: Partas.Build"
+open Partas.Build
+
+let generate = command "wire" {
+ description "Regenerates the wire types"
+ stage "generate" { run (cmd $"dotnet run --project tools/WireGen") }
+}
+```
+
+```fsharp
+// build.defs.fsx
+#load "tools/wire.defs.fsx"
+
+let root = Command.root {
+ // …
+ addCommand Wire.generate
+}
+```
+
+`build [ "wire" ]` now runs in the session, ` --help` lists `wire` beside the build's own commands, and
+` wire --help` lists the tool's options.
+The tool keeps a thin `tools/wire.fsx` of its own for standalone use. A tool that exposes its stages rather than
+a whole command composes further: yield them into a pipeline of the build's own.
+
+## Cancelling a run
+
+SageFs's `cancel_eval` interrupts the thread running the evaluation (`Thread.Interrupt`); it signals no
+`CancellationToken`. `Invoke` runs the build on a thread of its own and waits for it, so the interrupt reaches the
+waiting thread, which:
+
+1. cancels the run as a cancellation token would — the pipeline stops, and the whole process tree of every
+ running command is killed, grandchildren included;
+2. waits up to five seconds for the run to wind down;
+3. raises `ThreadInterruptedException`, which ends the evaluation. No `RunResult` is returned.
+
+A caller that owns a token passes it instead: `BuildDefs.root.Invoke(args, output = Console.Out, cancellationToken
+= token)`, and a cancelled run returns a `RunResult` with `Outcome = Cancelled`.
+
+What cancellation does not reach:
+
+- **A step written as a plain F# function** — `run (fun ctx -> …)` — runs on until it returns, unless it reads the
+ token itself (`Async.CancellationToken` inside an `async` step). An `async` step is stopped at its next
+ asynchronous wait. A blocking function keeps its thread after the evaluation has ended.
+- **Work already done.** Cancellation stops what is running; it undoes nothing.
+
+## What stays shared across calls
+
+A session keeps every value it evaluates. For a build, that means:
+
+- **`root` is built once.** A pipeline written as `Command.pipeline { … }` or `pipeline "…" { … }` with no CLI
+ inputs is one value, reused by every call. Such a value runs one run at a time: a second call reaching it while
+ a first is still running — including a straggler step from an interrupted run — fails with exit code `1` and
+ "Pipeline '…' is already running". Stages yielded straight into a `command`, and pipelines built inside an
+ `input { }`, are built afresh for each call.
+- **Option defaults are computed once**, when the module declaring them initialises. `Baked.Common.isCI` reads
+ the CI environment variables and `Baked.NuGet.apiKey` reads `NUGET_API_KEY` at that moment, and keep those
+ values for the life of the worker. Pass the flag explicitly, or restart the session
+ (`hard_reset_fsi_session`), after changing them. The environment a *stage* runs with is read afresh at the
+ start of every run.
+- **Editing `build.defs.fsx`** and re-sending its `#load` defines a new `BuildDefs` module; the `build` function
+ bound earlier still calls the old `root`. Re-send the whole of `.SageFs/init.fsx` instead.
+
+The library keeps no other state between runs. It sets the console encodings at most once per process, and only
+for a stream that is not redirected, and its console follows whichever writer `Console.Out` is when it writes.
+Without a terminal — a SageFs worker has none — tables are laid out 80 columns wide.
+
+## Live testing
+
+SageFs discovers Expecto tests in a loaded project and runs them as code changes, with a five-second default
+timeout per test. It classifies a test whose full name contains `integration` as an integration test, run only
+on demand. A build's own test suites that start processes — as this repository's do — belong there:
+
+```fsharp
+[]
+let tests =
+ testList "cmd" [
+ // …
+ ]
+ |> testLabel "integration"
+```
diff --git a/docs/content/Build/index.fsx b/docs/content/Build/index.fsx
index 9a47f1d..ced5343 100644
--- a/docs/content/Build/index.fsx
+++ b/docs/content/Build/index.fsx
@@ -21,6 +21,7 @@ order: 0
#load "../../../src/Partas.Build/System.CommandLine/Inputs.fs"
#load "../../../src/Partas.Build/Exceptions.fs"
#load "../../../src/Partas.Build/Output.fs"
+#load "../../../src/Partas.Build/Terminal.fs"
#load "../../../src/Partas.Build/Annotations.fs"
#load "../../../src/Partas.Build/Environment.fs"
#load "../../../src/Partas.Build/Timing.fs"
@@ -40,17 +41,21 @@ order: 0
#load "../../../src/Partas.Build/Builders/PipelineSettings.fs"
#load "../../../src/Partas.Build/Builders/Pipeline.fs"
#load "../../../src/Partas.Build/Builders/Inputs.fs"
+#load "../../../src/Partas.Build/AiEnvironment.fs"
+#load "../../../src/Partas.Build/MachineOutput.fs"
#load "../../../src/Partas.Build/Explain.fs"
#load "../../../src/Partas.Build/Summary.fs"
+#load "../../../src/Partas.Build/RunResult.fs"
#load "../../../src/Partas.Build/Builders/Command.fs"
#load "../../../src/Partas.Build.Baked/Program.fs"
#load "../../../src/Partas.Build.Baked/Common.fs"
#load "../../../src/Partas.Build.Baked/NuGet.fs"
#load "../../../src/Partas.Build.Baked/Dotnet.fs"
#load "../../../src/Partas.Build.Baked/SemVer.fs"
+#load "../../../src/Partas.Build.Baked/Clean.fs"
+#load "../../../src/Partas.Build.Baked/Stages.fs"
open Partas.Build
-open Partas.Build.Internal
(**
@@ -68,10 +73,11 @@ automatic validation and help.
|---|---|---|
| Step | `run`, `echo`, … | one action inside a stage |
| Stage | `stage "name" { }` | `StageContext` |
-| Inputs | `inputs { }` | `InputSpec<'T>` |
+| Inputs | `input { }` | `InputSpec<'T>` |
| Pipeline | `pipeline "name" { }` | `PipelineContext` or `InputSpec` |
| Command | `command "name" { }` | `System.CommandLine.Command` |
| Root | `rootCommand argv { }` | `int` exit code — **it runs immediately** |
+| Hosted root | `Command.root { }` | `RootCommandDefinition` — runs on each `Command.invoke args` |
## A first pipeline
*)
@@ -191,6 +197,9 @@ let combined =
`when'` also accepts a whole `StageContext`. It runs for real, side effects and console output included, and
its success is the answer.
+A `bool` carries no reason, so `--explain` prints a stage it turns off as `(skipped)`. Name the reason as a second
+argument, `when' (not quick) "--quick is set"`, and `--explain` prints `(skipped: --quick is set)`.
+
## Inputs
A stage that needs a CLI flag binds it in an `input` CE. It is then lifted into any command that asks for it,
@@ -201,7 +210,7 @@ module Options =
let quick =
Input.option "--quick"
|> Input.alias "-q"
- |> Input.desc "Skip restores and cleaning"
+ |> Input.description "Skip restores and cleaning"
let config =
Input.option "--configuration"
@@ -278,6 +287,35 @@ let main argv =
A command with no pipelines is a grouping node: it gets no action, so System.CommandLine reports the missing
subcommand and prints help instead of succeeding silently.
+The exit code tells a mistaken invocation from a broken build: `0` success (help, `--version` and `--explain`
+included), `1` a stage failed, `2` the command line did not parse or validate — an unknown option, a missing
+subcommand, a producer arrangement dependency validation rejects — and `130` the run was cancelled. The
+`ExitCode` module names the four.
+
+### Invoking from a host
+
+`rootCommand` reads its arguments once and runs at construction. A long-lived host — an F# interactive
+session, a test — builds the root once with `Command.root`, which takes every `rootCommand` operation and runs
+nothing, then invokes it as often as it likes. `Command.invoke` answers a `RunResult`: the exit code, its
+`RunOutcome`, and each pipeline run's `ScopeReport`s and `StageTiming`s.
+*)
+
+let root =
+ Command.root {
+ description "My build"
+ addCommands [ buildCommand ]
+ }
+
+let buildQuick () =
+ let result = root |> Command.invoke [ "build"; "--quick"; "true" ]
+ printfn "%d failure(s)" result.Failures.Length
+ result.ExitCode
+
+(**
+`root.Invoke(args, output = writer, cancellationToken = token)` sends help, `--explain`, the timing summary and
+parse errors to `writer` instead of the console, and cancels the running pipeline — its processes with it —
+when `token` fires.
+
## Composition
### Stages nest
diff --git a/docs/content/external-annotations/external-annotations-api.fsx b/docs/content/external-annotations/external-annotations-api.fsx
index a5557cb..c4e4ba3 100644
--- a/docs/content/external-annotations/external-annotations-api.fsx
+++ b/docs/content/external-annotations/external-annotations-api.fsx
@@ -21,6 +21,7 @@ index: 2
#load "../../../src/Partas.Build/System.CommandLine/Inputs.fs"
#load "../../../src/Partas.Build/Exceptions.fs"
#load "../../../src/Partas.Build/Output.fs"
+#load "../../../src/Partas.Build/Terminal.fs"
#load "../../../src/Partas.Build/Annotations.fs"
#load "../../../src/Partas.Build/Environment.fs"
#load "../../../src/Partas.Build/Timing.fs"
@@ -40,8 +41,11 @@ index: 2
#load "../../../src/Partas.Build/Builders/PipelineSettings.fs"
#load "../../../src/Partas.Build/Builders/Pipeline.fs"
#load "../../../src/Partas.Build/Builders/Inputs.fs"
+#load "../../../src/Partas.Build/AiEnvironment.fs"
+#load "../../../src/Partas.Build/MachineOutput.fs"
#load "../../../src/Partas.Build/Explain.fs"
#load "../../../src/Partas.Build/Summary.fs"
+#load "../../../src/Partas.Build/RunResult.fs"
#load "../../../src/Partas.Build/Builders/Command.fs"
#load "../../../src/Partas.Build.Baked/Program.fs"
#load "../../../src/Partas.Build.Baked/Common.fs"
diff --git a/docs/content/index.fsx b/docs/content/index.fsx
index bcb2fe3..1691c42 100644
--- a/docs/content/index.fsx
+++ b/docs/content/index.fsx
@@ -20,6 +20,7 @@ order: 0
#load "../../src/Partas.Build/System.CommandLine/Inputs.fs"
#load "../../src/Partas.Build/Exceptions.fs"
#load "../../src/Partas.Build/Output.fs"
+#load "../../src/Partas.Build/Terminal.fs"
#load "../../src/Partas.Build/Annotations.fs"
#load "../../src/Partas.Build/Environment.fs"
#load "../../src/Partas.Build/Timing.fs"
@@ -39,17 +40,21 @@ order: 0
#load "../../src/Partas.Build/Builders/PipelineSettings.fs"
#load "../../src/Partas.Build/Builders/Pipeline.fs"
#load "../../src/Partas.Build/Builders/Inputs.fs"
+#load "../../src/Partas.Build/AiEnvironment.fs"
+#load "../../src/Partas.Build/MachineOutput.fs"
#load "../../src/Partas.Build/Explain.fs"
#load "../../src/Partas.Build/Summary.fs"
+#load "../../src/Partas.Build/RunResult.fs"
#load "../../src/Partas.Build/Builders/Command.fs"
#load "../../src/Partas.Build.Baked/Program.fs"
#load "../../src/Partas.Build.Baked/Common.fs"
#load "../../src/Partas.Build.Baked/NuGet.fs"
#load "../../src/Partas.Build.Baked/Dotnet.fs"
#load "../../src/Partas.Build.Baked/SemVer.fs"
+#load "../../src/Partas.Build.Baked/Clean.fs"
+#load "../../src/Partas.Build.Baked/Stages.fs"
open Partas.Build
-open Partas.Build.Internal
(**
@@ -69,10 +74,11 @@ automatic validation and help.
|---|---|---|
| Step | `run`, `echo`, … | one action inside a stage |
| Stage | `stage "name" { }` | `StageContext` |
-| Inputs | `inputs { }` | `InputSpec<'T>` |
+| Inputs | `input { }` | `InputSpec<'T>` |
| Pipeline | `pipeline "name" { }` | `PipelineContext` or `InputSpec` |
| Command | `command "name" { }` | `System.CommandLine.Command` |
| Root | `rootCommand argv { }` | `int` exit code — **it runs immediately** |
+| Hosted root | `Command.root { }` | `RootCommandDefinition` — runs on each `Command.invoke args` |
## A first pipeline
*)
@@ -192,6 +198,9 @@ let combined =
`when'` also accepts a whole `StageContext`. It runs for real, side effects and console output included, and
its success is the answer.
+A `bool` carries no reason, so `--explain` prints a stage it turns off as `(skipped)`. Name the reason as a second
+argument, `when' (not quick) "--quick is set"`, and `--explain` prints `(skipped: --quick is set)`.
+
## Inputs
A stage that needs a CLI flag binds it in an `input` CE. It is then lifted into any command that asks for it,
@@ -202,7 +211,7 @@ module Options =
let quick =
Input.option "--quick"
|> Input.alias "-q"
- |> Input.desc "Skip restores and cleaning"
+ |> Input.description "Skip restores and cleaning"
let config =
Input.option "--configuration"
@@ -279,6 +288,35 @@ let main argv =
A command with no pipelines is a grouping node: it gets no action, so System.CommandLine reports the missing
subcommand and prints help instead of succeeding silently.
+The exit code tells a mistaken invocation from a broken build: `0` success (help, `--version` and `--explain`
+included), `1` a stage failed, `2` the command line did not parse or validate — an unknown option, a missing
+subcommand, a producer arrangement dependency validation rejects — and `130` the run was cancelled. The
+`ExitCode` module names the four.
+
+### Invoking from a host
+
+`rootCommand` reads its arguments once and runs at construction. A long-lived host — an F# interactive
+session, a test — builds the root once with `Command.root`, which takes every `rootCommand` operation and runs
+nothing, then invokes it as often as it likes. `Command.invoke` answers a `RunResult`: the exit code, its
+`RunOutcome`, and each pipeline run's `ScopeReport`s and `StageTiming`s.
+*)
+
+let root =
+ Command.root {
+ description "My build"
+ addCommands [ buildCommand ]
+ }
+
+let buildQuick () =
+ let result = root |> Command.invoke [ "build"; "--quick"; "true" ]
+ printfn "%d failure(s)" result.Failures.Length
+ result.ExitCode
+
+(**
+`root.Invoke(args, output = writer, cancellationToken = token)` sends help, `--explain`, the timing summary and
+parse errors to `writer` instead of the console, and cancels the running pipeline — its processes with it —
+when `token` fires.
+
## Composition
### Stages nest
diff --git a/docs/static/AGENTS-snippet.md b/docs/static/AGENTS-snippet.md
new file mode 100644
index 0000000..ee13515
--- /dev/null
+++ b/docs/static/AGENTS-snippet.md
@@ -0,0 +1,67 @@
+# Partas.Build: instructions for coding agents
+
+Paste this block into a repository's `AGENTS.md`, `CLAUDE.md` or `.claude/rules/` when its build is written with
+[Partas.Build](https://shayanhabibi.github.io/Partas.Build/). Replace `` with how the repository runs its
+build: `dotnet fsi build.fsx --`, `dotnet run --project Build.fsproj --`, or the name of a tool. The same file ships
+at the root of the `Partas.Build` package (`~/.nuget/packages/partas.build//AGENTS-snippet.md`).
+
+---
+
+## Running the build
+
+The build is a command-line program. Ask it what it does instead of reading its source.
+
+1. ` --help` lists the commands. ` --help` lists the options that command accepts.
+ The help is generated from the stages the command runs, so it is complete and current: an option absent from
+ it does not exist for that command.
+2. ` [options] --explain` prints the stages and steps the command would run with those options,
+ and which stages are skipped and why. It runs nothing. Run it before a command you have not run before, and
+ whenever an option's effect is unclear.
+3. ` [options] --explain --json` prints the same tree as JSON. Conditions with side effects
+ (a `git` branch check, a condition stage) are reported as `"status": "unevaluated"` rather than run.
+4. ` --schema` prints the command, its options (name, aliases, type, default, choices) and its
+ subcommands as JSON. A secret default prints as `"***"`.
+
+## Exit codes
+
+| Code | Meaning | What to do |
+|---|---|---|
+| `0` | Success; also `--help`, `--version`, `--explain`, `--schema` | Nothing |
+| `1` | A stage failed, or the build raised an exception | Fix the code or the environment; read the run result for which step |
+| `2` | Usage error: unknown option, missing command, failed validation, or an unsatisfiable stage dependency (also under `--explain`). No stage ran | Fix the command line; check ` --help` |
+| `130` | Cancelled: the pipeline's own timeout expired, or the caller cancelled the run. A stage's timeout is a failure (`1`). Ctrl+C kills the process: the shell reports `130`, but no run result is written | Rerun, or raise the timeout |
+
+## Reading the result of a run
+
+Add `--json` to a run. The stages print as they otherwise would; the timing table is replaced by the run result,
+which is the **last line** of output, one line of JSON:
+
+```json
+{"formatVersion":1,"exitCode":1,"outcome":"failed","pipelines":[{"name":"test",
+ "reports":[{"name":"unit","address":"unit","path":[0],"outcome":"failed","error":"Exit code not acceptable.",
+ "propagates":true,"failures":[{"step":0,"label":"dotnet test","cause":{"kind":"reported",
+ "message":"Exit code not acceptable."}}],"nested":[]}],
+ "timings":[{"name":"unit","depth":0,"elapsedMs":90.7,"outcome":"failed","error":"Exit code not acceptable."}]}]}
+```
+
+- `outcome` is `succeeded`, `failed`, `usageError` or `cancelled`, matching the exit code.
+- `pipelines[].reports` holds one report per stage, nested as the stages nest (`nested`). A report's `outcome` is
+ `succeeded`, `skipped` or `failed`; `propagates: false` marks a failure the build tolerated.
+- `failures[].step` counts from zero and matches `index` in the `--explain --json` tree of the same stage;
+ `label` is the step's command line with secrets masked. `cause.kind` is `command`, `start` (the executable
+ could not be started), `raised` (an exception), `timedOut` or `reported`.
+- `timings` lists every stage that finished, in order, with its `depth` and `elapsedMs`.
+
+`--report ` writes the same document, indented, to a file, with or without `--json`. Take the result from
+there when the run's output is long or interleaved.
+
+Agent environment markers enable `--json` by default. Explicit `--json` makes the format predictable even
+when a harness supplies no marker. Use `--json false` for text, or `PARTAS_BUILD_DISABLE_AI=1` to disable
+automatic defaults. The override does not suppress explicit `--json`; `--explain` and `--schema` remain explicit.
+
+## Rules
+
+- Do not guess option names; read ` --help`.
+- Do not edit the build to skip a failing stage. Read the failing step's `label` and `cause`, and fix the cause.
+- A command listed under "Commands with no description" in `--explain` output has no help text; read its stages
+ with `--explain` before running it.
diff --git a/docs/static/llms.txt b/docs/static/llms.txt
index 414caf9..f0e8113 100644
--- a/docs/static/llms.txt
+++ b/docs/static/llms.txt
@@ -10,14 +10,20 @@
- [Capabilities](https://shayanhabibi.github.io/Partas.Build/build/capabilities/): every operation and Input combinator, one line each
- [Index](https://shayanhabibi.github.io/Partas.Build/build/): the layers, and a first pipeline
- [Composition](https://shayanhabibi.github.io/Partas.Build/build/composition/): nesting stages, and reusing commands across files
+- [Hosting](https://shayanhabibi.github.io/Partas.Build/build/hosting/): running a build from a long-lived F# session (SageFs) through `Command.invoke`
## Ask the script rather than reading it
Every command generates its own `--help` from the stages it runs, and the flags listed there are exactly the
flags those stages bind. `