Skip to content

Add hosted build APIs and agent-aware JSON output - #4

Merged
shayanhabibi merged 25 commits into
masterfrom
feature/build-hosting-discoverability
Oct 7, 2026
Merged

shayanhabibi merged 25 commits into
masterfrom
feature/build-hosting-discoverability

Conversation

@shayanhabibi

Copy link
Copy Markdown
Owner

Partas.Build currently requires agents to parse human console output and offers no reusable root-command definition for long-lived hosts. This branch adds hosted invocation and structured discovery/results, with JSON output enabled automatically when the invocation environment identifies an AI coding harness.

  • Add Command.root, Command.invoke, RunResult, cancellation and exit-code categories; make console routing, environment refresh and repeated invocation safe in a long-lived host.
  • Add --json, --report, --schema and static JSON --explain. JSON run results remain the last output line; step output is unchanged, sensitive values remain masked, and consumer-declared options retain precedence.
  • Add AiEnvironment.detect / detectWith, returning lazy boolean detections using the is-ai-agent README's environment predicates. JSON defaults evaluate a fresh detection per invocation. Cursor's legacy trace/pager signals must occur together; credentials and configuration are excluded.
  • PARTAS_BUILD_DISABLE_AI=1 forces detection false. Nonblank override values enable it except trimmed, case-insensitive 0, false, no and off. Explicit --json or --json false wins; --schema and --explain are never automatically enabled. Detection uses environment signals only, without the upstream filesystem probe.
  • Add Baked prefab stages, consumer API examples and hosting/agent documentation. Preserve master's GitHub summaries, annotations, CI output isolation, package versions and configurable NuGet publishing after rebasing.

Validation:

  • dotnet run --project Build.fsproj -- test: exit 0; 540 suite tests passed, 9 skipped, plus 11 Debug and 11 Release compiler probes passed. Library builds cover net10.0, net8.0 and netstandard2.0 where declared.
  • Observed the AI JSON default test fail before implementation and pass afterward; observed the Cursor false-positive regression fail before its correction and pass afterward. Detection tests cover false-like generic values, exact predicates, compound markers, excluded credentials, override short-circuiting and lazy caching; invocation coverage checks explicit flags and changes between invocations.
  • dotnet fsi index.fsx from docs/content/Build: exit 0, no warnings or errors. Updated every documentation source load list that includes MachineOutput.
  • Independent review found no remaining important issues; git diff --check passed.

The clean test command retains an existing docs restore warning (NU1608: FSharp.Compiler.Service 43.12.400 requests FSharp.Core 10.1.400, while 10.1.401 is resolved). Existing platform skips include the symlink test on this Windows machine, which lacks Developer Mode/symlink privileges, and annotation tests unavailable under the current SDK. Environment markers identify inherited harness context, not proof that an AI initiated the command; they affect output defaults only.

claude and others added 25 commits October 7, 2026 10:27
Survey of Xantham's scripts and Build/Program.fs: shared boilerplate that
Baked prefab stages could absorb, API traps, machine-readable output and
exit codes for agents, and what must change for pipelines to run inside a
long-lived SageFs FSI session (argv-free invoke, console and cancellation
hygiene). Records consumer-side findings separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…nResult

Implements PLAN-Integration §4.2 and §5.3.1.

- Exit codes: 0 success (help, --version, --explain), 1 stage failure,
  2 usage error (parse error, missing subcommand, failed dependency
  validation), 130 cancelled; named by the ExitCode module.
- Command.root { ... } builds a RootCommandDefinition without parsing or
  running; Command.invoke args / RootCommandDefinition.Invoke(args,
  ?output, ?error, ?cancellationToken) parse, run and return a RunResult
  (ExitCode, RunOutcome, a PipelineRun per pipeline with its ScopeReport
  and StageTiming snapshots). rootCommand's Run goes through Invoke, so
  the exit-code mapping lives in one place.
- PipelineContext.runWith links an external cancellation token into the
  pipeline's timeout source; run is runWith CancellationToken.None.
- --explain, the timing summary, the grouping --explain and the
  dependency diagnostic write to the invocation configuration's
  Output/Error instead of Console.
- Records the §8 Q4 decision and section statuses in PLAN-Integration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…rted

The remarks read as a serialisation guarantee that Invoke does not enforce.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
An [<AutoOpen>] ConsumerTypes module abbreviates StageContext, PipelineContext,
CommandSpec, StageParent, RuntimeContext, StepIndex and the Build* aliases, and
the public StageContext module re-exports writeLine, getOutput, getVerbosity and
getNamePath. The docs pages drop `open Partas.Build.Internal`, and
ConsumerSurfaceTests compiles consumer-style code without it.

Baked's bump stage reports through StageContext.writeLine instead of printfn,
so quiet and captured stages route it; BakedTests covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
- Build/Program.fs: drop `open Partas.Build.Internal`, build the Expecto
  arguments with Cmd.argIf/Cmd.args instead of a null-holed Cmd.ofString,
  remove the unused formatFiles glob.
- .mcp.json: point fslangmcp at Partas.Build.slnx.
- PLAN-Execution.md: status header reflects the implementation.
- PLAN-Integration.md: record what is implemented and how it was verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
Public condition functions return BuildStageIsActive, so a consumer writing
a reusable condition in the same style can name it without opening Internal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…plain

PLAN-Integration §4.1 and §4.3.

- MachineOutput.fs: `--json` and `--schema` on every command, `--report <path>`
  on every command that runs pipelines, each skipped when the command already
  declares the name or an alias. `--schema` prints the command tree as JSON:
  options with aliases, type, default, accepted values, description, arity.
- Under `--json`, `--explain` prints the stage tree as JSON (statically), and a
  run prints its RunResult as one line of JSON in place of the timing table;
  `--report` writes it indented to a file. Every document carries
  formatVersion 1. System.Text.Json is referenced on netstandard2.0 only.
- Explain builds a model (ExplainedPipeline/Stage/Step/Condition) under an
  ExplainMode. It walks StageContext.Conditions instead of calling IsActive,
  so each condition runs at most once (a failing `when' stage` ran twice) and
  a throwing condition renders as its message. ExplainMode.Static leaves
  conditions marked Conditions.effectful unevaluated: whenBranch(es),
  whenStage / `when' stage`, and whenAll/whenAny/whenNot over any of them.
- Conditions.whenBranches takes #seq<string>, keeping the call a plain
  application the mark survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
A stage that reads MachineOutput.json or MachineOutput.report registers the
option through its pipeline's inputs; reserve now treats that same instance
as taken, so --help and --schema list it once.

The explain document's step `index` now counts from zero, matching the run
result's `step` and `path`. The text form still prints an unlabelled step as
`step 1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
Input.sensitive marks an option or argument as carrying a secret; --schema
writes its present default as "***" and reports "sensitive": true.
Baked.NuGet.apiKey, whose default is read from NUGET_API_KEY, is marked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…t, reentrancy, interrupts

Implements PLAN-Integration §5.3.2-§5.3.6 and answers §8 Q5.

- Spike (§8 Q5): Spectre's static AnsiConsole stays bound to the Console.Out
  current at its first use; without a terminal every console reports width
  -1 and renders nothing. Recorded in the plan.
- Terminal.fs is the only route to the console: Terminal.ansi () rebinds
  AnsiConsole.Console when Console.Out changes (an explicit assignment is
  kept), falls back to 80 columns, and under Terminal.withOutput (an
  AsyncLocal run writer) writes plain text to that writer. Invoke's output
  now receives the run's console output, child processes included.
- Console encodings are set at most once per process, only for a stream
  that is not redirected, and never throw.
- rootCommandOfScript reads argv when the command runs: RootCommandBuilder
  takes unit -> string array, with an array constructor for rootCommand.
- PipelineContext.create starts EnvVars empty; runWith layers the
  pipeline's own variables over the environment as the run starts.
  applyDefaults' per-key rule follows.
- runWith fails fast with InvalidOperationException on a concurrent second
  run of one pipeline value; PipelineContext.withRunState gives a copy that
  runs independently.
- RootCommandDefinition.Invoke runs on a worker; a ThreadInterruptedException
  on the waiting thread cancels the invocation's token source, killing the
  process tree through the existing registration, then rethrows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f

Fix host-safety review findings: refused runs, run writer threading, cts disposal

- A command invocation refused a running pipeline value no longer records or
  prints the other run's reports and timings: runReportingTimings enters the
  reentrancy guard before the try whose finally reads the collections, and
  runs through the new internal PipelineContext.runEntered.
- Terminal.withOutput installs the caller's writer through
  TextWriter.Synchronized, since parallel' stages and a redirected child's
  reader callbacks write to it concurrently.
- runInterruptibly disposes the linked CancellationTokenSource in a finally,
  so failed or interrupted invocations leave no registration on the caller's
  token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…o stdout

Terminal.ansi() rebinding to the current Console.Out routed library output
through Expecto's FuncTextWriter. On Unix the runtime locks Console.Out for
every terminal write, while Expecto's writer takes Expecto's lock first, so a
pipeline thread and Expecto's logger took the two locks in opposite orders
and the Release suite could hang under a pseudo-terminal.

- Terminal.ansi() remembers the console each Console.Out writer was first seen
  with, so a writer restored after a swap gets its console back instead of a
  new console over itself.
- The test host pins AnsiConsole.Console to the real stdout before Expecto
  installs its writer.
- Document the hazard and the host-side remedies in Terminal.fs, CLAUDE.md
  and PLAN-Integration.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
- `runLine` runs a command line derived from the stage context
  (string, Async/Task, and their option forms). The same six `run`
  overloads are now [<Obsolete>]: a lambda returning a message would
  start a process. `run`'s overload set is otherwise unchanged, so the
  FS0041 balance around `run (build, buildStep)` holds (spiked over every
  call-site shape; CompilerProbe Debug and Release).
- `Input.desc` is [<Obsolete>] in favour of `Input.description`; `inputs`
  already was, in favour of `input`. Usages in src/, Build/,
  Partas.Build.ExternalAnnotations/, README and docs/content migrated.
- `when' value "reason"` on `stage` (and its InputSpec mirror) records
  the reason through addPredicateBecause, so --explain prints
  `(skipped: --quick is set)`. Build/Program.fs gives each `when'` one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…tegration §3.1)

Partas.Build.Baked gains Common.quick/skipTests/watch, Dotnet.configOrRelease,
and Stages: restore, clean, build, pack, expecto, nugetPush, fantomas and
npmInstall, each a function answering InputSpec<StageContext> with a ...With
counterpart taking its options as InputSpecs (PLAN §8 Q3: functions). Skips
carry an --explain reason. clean is a System.IO glob clean (Clean.fs), so
Build/Program.fs and Build.fsproj no longer need Fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
… remark

Clean.directories now drops any selected directory nested inside another
selected one, literal or matched, so Clean.run no longer empties a parent
and then re-creates a literal child under it. The doc remark states that
contract; a test covers literal-under-match, literal-under-literal,
match-under-literal and a shared name prefix.

expectoWith's remark states what runs the suite instead of arguing for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
A linked directory is neither walked nor selected, a literal directory reached
through a link is dropped, and emptying a directory removes the links in it
without touching their targets. Previously **/bin could empty a directory
outside the root, and a cyclic link made the walk recurse without end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…t output line

- Baked/Common.fs and MachineOutput.fs used the now-obsolete `Input.desc` (the json and
  baked-stages branches predate api-traps' obsoletion); both use `Input.description`, and
  the build is warning-free again. Common.fs's `--ci` line had lost api-traps' migration
  in the baked-stages merge.
- The machine-output tests assumed the given `output` writer receives only the JSON. Since
  host routes the run's own console lines to that writer too, they now read the result
  from the last line, as the documented `--json` contract states, and check `--report`
  keeps the JSON (not every line) out of the output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
§7 rows 1-10 are done on this branch; the status header and a note under §7 say what the
merges decided. CLAUDE.md's test count reflects the merged suite (362 in Partas.Build.Tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…ation

The four .fsx pages under docs/content #load the library in compile order and
stopped type-checking when Terminal.fs joined it. They load it now, and Baked's
Clean.fs and Stages.fs too.

The cmd, operations and CompilerTests lists end in testLabel "integration", which
SageFs live testing reads from the test name to run them on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
<example> blocks on the run overloads, runLine, runSensitive, input, whenSome, cmd,
Cmd.argIf/argWhenSome, stage, pipeline, command, rootCommand, rootCommandOfScript,
Command.root/invoke, RootCommandDefinition.Invoke, RunResult and its JSON form,
the --json flag and Baked.Stages. The run (string) remark claimed an interpolated
string keeps each hole one argument; it binds to the string overload and is
re-split, and the remark now says so. AGENTS-snippet.md is packed into the
Partas.Build package root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
…nippet

docs/content/Build/hosting.md covers the build.defs.fsx/build.fsx split, the
SageFs init script binding Invoke with the eval's writer, mounting tool scripts
with addCommand, cancellation and what a session shares between calls.
docs/static/AGENTS-snippet.md tells agents to use --help, --explain (and --json),
the exit codes and the JSON run result; llms.txt and README link it.
CAPABILITIES.md gains exit codes, Command.root/invoke/RunResult, Input.sensitive
and the Baked prefabs. PLAN-Integration records 4.4, 4.5 and 5.4 as done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
- ConsumerTypes: move the stepIndex measure into a nested AutoOpen
  Measures module so the docs site no longer hits nacara/duplicate-route,
  and abbreviate Step, StageCondition, StageIndex and BuildEnvInfo so the
  public StageContext.addStep/addSteps/setSteps and Steps need no Internal.
- invoke: under --explain a failed dependency validation exits 2 without a
  run result or --report file; an exception other than a pipeline failure
  or cancellation writes the run result as failed, then propagates.
- MachineOutput: the --schema input is `schema`, the renderer `commandSchema`.
- Docs: Ctrl+C is not an ExitCode.Cancelled path; test totals, Explain.fs
  dependencies and Terminal.fs in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LK3n2hgMAMxKAmuKyZL8f
@shayanhabibi
shayanhabibi merged commit efd8e4f into master Oct 7, 2026
@shayanhabibi
shayanhabibi deleted the feature/build-hosting-discoverability branch October 7, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants