tooling/ai_harness/doctor/SPECIFICATION/02_contracts.md
Invocation: scripts/ai_harness/doctor [OPTIONS]
Options:
--help — Print help text, exit 0, do not run checks--fix — Auto-repair fixable problemsExit codes:
0 — All checks passed (or all fixable issues were fixed)1 — One or more checks failedOutput format: One line per check:
Check: <name> ............ <STATUS>
<detail line>
<detail line>
Where STATUS is one of: OK, FAIL, FIXED
In --fix mode, a single run may produce a mix of all three statuses:
FIXED for checks that were repaired, FAIL for unfixable problems, and
OK for checks that were already passing. The exit code is 1 if any
check has status FAIL; FIXED checks do not cause a non-zero exit.
The context hash flows through the ROP chain, accumulating data at each step. It is designed for destructuring via Ruby rightward assignment pattern matching.
Initial context — built by Main.main:
Main.main builds a minimal context containing only an empty results
accumulator. It does NOT parse ARGV or resolve the repo root — those are
the responsibility of chain steps.
context = {
results: [] # Accumulated check results (Array<Hash>)
}
Added by Steps::ParseArgv:
ParseArgv reads ARGV directly and validates command-line arguments.
On success, it adds:
context[:fix] = true | false # Whether --fix was requested
context[:print_help] = true | false # Whether --help was requested
If arguments are invalid, ParseArgv returns
Result.err(Messages::InvalidArguments) — the chain short-circuits and
no further steps run. --help is NOT an error; it sets
print_help: true on the ok path.
Added by Steps::HandleAction (or its sub-chain):
HandleAction reads context[:print_help]. If true, it sets
stdout_text and exit_code: 0 directly (using HelpText.help).
If false, it delegates to Steps::PerformDoctorChecks::Main.main,
a sub-chain that runs the doctor checks. Either way, after
HandleAction:
context[:stdout_text] = String # Text to print to stdout
context[:exit_code] = Integer # 0 or 1
Within the PerformDoctorChecks sub-chain:
The sub-chain runs when print_help is false. It adds to the context:
context[:repo_root] = String # Added by ResolveRepoRoot
context[:stdout_text] = String # Added by FormatOutput
context[:exit_code] = Integer # Added by DetermineExitCode
ResolveRepoRoot raises on failure (git not found, not in a repo) —
these are infrastructure errors, not domain errors. Check steps append
to context[:results].
Important: The context hash does NOT contain an IO object. Check and
transform steps are pure — no IO side effects. Stdout is handled by
PrintStdout via inspect_ok, stderr by PrintStderr via
inspect_err (see §2.2).
Rightward assignment example in a check step:
def self.check(context)
context => { repo_root: String => repo_root, fix: (TrueClass | FalseClass) => fix, results: Array => results }
# ...
end
Note: check steps destructure fix: — a key that was added by ParseArgv
earlier in the chain. The context accumulates entries from each step as
the chain progresses.
Each entry in :results is a Hash:
{ name: String, status: "OK" | "FAIL" | "FIXED", details: Array<String> }
Main.main is a dumb router (see 03_constraints.md §1 for the
rationale and hard constraint). It takes no arguments, builds the initial
context (an empty results accumulator), runs the ROP chain (which handles
all stdout output via inspect_ok/inspect_err), pattern-matches the
result to extract the exit code, and returns it.
# @return [Integer] exit code (0 = success, 1 = failure)
def self.main
The entrypoint simply calls exit AiHarness::Doctor::Main.main.
The ROP chain ends with inspect_ok and inspect_err for IO side
effects. PrintStdout prints stdout_text from the context to stdout.
PrintStderr prints stderr_text from the message content to stderr.
Both return nil (the inspect_* contract). The original Result passes
through unchanged, so Main.main can then pattern-match it to extract
only the exit code:
case result
in { ok: { exit_code: Integer => code } }
# Normal completion — output already printed by PrintStdout
code
in { err: Messages::InvalidArguments => message }
# Error output already printed by PrintStderr
message.content => { exit_code: Integer => code }
code
else
raise Gitlab::Fp::UnmatchedResultError.new(result: result)
end
Steps::PrintStdout and Steps::PrintStderr are step classes — not
methods on Main. PrintStdout destructures stdout_text from the
context and prints to $stdout. PrintStderr destructures
stderr_text from the message's content hash and prints to $stderr.
Both return nil.
Steps::ParseArgv — parses and validates command-line arguments:
def self.parse(context) → Gitlab::Fp::Result
ARGV directly (not from context)--help: adds print_help: true, fix: false to context, returns
Result.ok(context)--fix: adds print_help: false, fix: true, returns
Result.ok(context)print_help: false, fix: false, returns
Result.ok(context)Result.err(Messages::InvalidArguments.new(content)) where content
is { stderr_text: String, exit_code: 1 }. The stderr_text
includes both the error message and the help text (from HelpText).ParseArgv is the only step chained via .and_then.Steps::HandleAction — dispatches based on print_help:
def self.handle(context) → Hash
context[:print_help] is true: sets context[:stdout_text] from
HelpText.help and context[:exit_code] = 0. Returns context.context[:print_help] is false: delegates to
Steps::PerformDoctorChecks::Main.main(context) sub-chain, which
sets stdout_text and exit_code. Returns context.context (the hash, not a Result) — chained via .mapSteps::PrintStdout — stdout IO side effect:
def self.print(context) → nil
stdout_text from the context and prints to $stdout..inspect_ok.nil (the inspect_* contract).Steps::PrintStderr — stderr IO side effect:
def self.print(message) → nil
stderr_text from the message's content hash and
prints to $stderr..inspect_err.nil (the inspect_* contract).HelpText — shared help text (not a step, no context):
def self.help → String
ParseArgv (for invalid
args error text) and HandleAction (for --help output).Steps::PerformDoctorChecks)Steps::PerformDoctorChecks::Main — sub-chain for doctor checks:
def self.main(context) → Hash
fix:, results:, etc.)ResolveRepoRoot → check steps →
FormatOutput → DetermineExitCodecontext[:stdout_text] and context[:exit_code]context — follows the sub-chain pattern from
ee/lib/remote_development/workspace_operations/create/creator.rbSteps::PerformDoctorChecks::ResolveRepoRoot:
def self.resolve(context) → Hash
git rev-parse --show-toplevel to determine repo rootrepo_root: String to contextRuntimeError if git fails or returns empty (infrastructure error)context — chained via .mapCheck steps — each has a single public class method:
def self.check(context) → Hash
context[:results]context — .map wraps it in Result.ok automaticallyCheck steps are infallible. They record pass/fail status in
context[:results] and always pass the context forward. This ensures all
checks always run. They are chained via .map, which encodes in the chain
structure itself that they cannot fail.
Steps::PerformDoctorChecks::FormatOutput:
def self.format(context) → Hash
context[:results] and produces a formatted output stringcontext[:stdout_text] to the contextcontext — chained via .mapSteps::PerformDoctorChecks::DetermineExitCode:
def self.determine(context) → Hash
context[:results] and checks if any result has status: "FAIL"context[:exit_code] (0 if no FAILs, 1 if any FAIL) to the contextcontext — chained via .mapAll message types are defined in tooling/ai_harness/doctor/messages.rb
as subclasses of Gitlab::Fp::Message, simulating a union type.
| Message Class | Type | Meaning |
|---|---|---|
InvalidArguments | err | Unknown or invalid CLI arguments; exit 1 |
--help is NOT an error — it is handled on the ok path via
context[:print_help] (see §2.1). Only InvalidArguments uses the err
rail. Its content hash contains { stderr_text: String, exit_code: Integer }.
The normal completion path (both --help and doctor checks) flows
through as Result.ok(context), with stdout_text and exit_code in
the context hash. The per-check granularity lives in
context[:results].
Every case ... in match on Result types must include an else clause that
raises Gitlab::Fp::UnmatchedResultError, ensuring exhaustive matching.
These files are expected to exist in the repo:
AGENTS.md (top-level, and optionally at subdirectory levels)CLAUDE.md (identical to AGENTS.md at the same level).ai/*.md (instruction modules referenced via .ai/...).ai/README.mdThese patterns must be in root .gitignore (non-rooted):
AGENTS.local.mdCLAUDE.local.md.ai/*AGENTS.local.md**/AGENTS.local.mdCLAUDE.local.md**/CLAUDE.local.md.claude/rules/**.claude/skills/**.claude/agents/**.claude/commands/**.claude/settings.json.claude/settings.local.json.claude/settings.local.jsonc.opencode/**.gitlab/duo/chat-rules.md.gitlab/duo/mcp.jsonAGENTS.local.md and CLAUDE.local.md (at any directory level) are
personal local override files that must never be committed. They are
gitignored by the patterns enforced in §3.2, but the forbidden check
catches the case where someone force-adds them with git add --force.
This closes the gap where both the gitignore check and the forbidden check
would otherwise pass silently if the gitignore entry exists but the file
is tracked.
These files in a gitignored or .git/info/exclude'd state are acceptable.