Extending the harness
The exact files to change, the registration points, the validation that catches a mistake, and the tests to add — for the five things contributors add most often: a skill, an engine step, a gate, a hook, and a CLI command.
Paths are relative to the repository root. Engine source lives under src/conductor/src/; see code organization for the layer map.
Add a visualizer plugin
Place a plugin in either ~/.ai-conductor/plugins/<name>/ or <project>/.ai-conductor/plugins/<name>/. The project-local plugin takes precedence when both use the same name. Its plugin.yml needs kind: visualizer, a lowercase-hyphenated name, and an entrypoint; harness_version is optional.
The entrypoint’s default export may be a VisualizerPlugin object or a factory. A factory receives VisualizerFactoryContext and returns a VisualizerPlugin for that run, or null when disabled. The context supplies the resolved harness config, pipeline directory, shared event emitter, and startContext identity (runId, project, branch, feature, engineVersion, and pipelineDir when available). A plugin object must expose its name, start(emitter, context), and async stop() methods. start subscribes to the shared emitter; stop unsubscribes and flushes any pending export.
Select it in the project configuration:
visualizers:
- <name>
Invalid configured names warn and are skipped. A failing factory, malformed factory product, or failing start also leaves the run running while skipping that visualizer. The built-in otel visualizer is configured through the separate otel block, not this list. See configuration for selection and validation behavior.
Add a skill
Skill files to change
skills/<name>/SKILL.md— YAML frontmatter carryingname,description,enforcement, andphase.model:is optional; add it only when the skill must pin a tier.src/conductor/src/engine/model-table-metadata.ts— one ofSKILL_STEP_MAP(:72) when the skill maps to a step,PIN_EXEMPT_SKILLS(:87) when it should never be pin-checked, orEXTRA_MODEL_TABLE_ROWS(:126) for a row with no step behind it.ARCHITECTURE.md— regenerated, not hand-edited. Runbin/generate-model-tableand commit the rewritten region between<!-- BEGIN GENERATED: model-selection-table -->and<!-- END GENERATED: model-selection-table -->.
Skill registration
None beyond the above. bin/install auto-discovers skills: it walks skills/*/ and picks up any directory containing a SKILL.md, then symlinks each into the user-level skill directories for both supported hosts. A hardcoded list used to live there and drifted; do not reintroduce one.
What catches a skill mistake
| Mistake | Caught by |
|---|---|
| Missing frontmatter field | Integrity check 2 |
A /name cross-reference to a nonexistent skill | Integrity check 4 — WARN only, will not fail |
| No model-table row | Integrity check 5 — WARN only, will not fail |
| Table drifted from the generator source | Integrity check 5a |
A model: pin that disagrees with the generator | Integrity check 5b |
A referenced agents/*.md or templates/*.template that does not exist | Integrity checks 3 and 6 |
Duplicate ### N. section identifiers | Integrity check 7 |
| Claude-specific commands, models, tools, or delegations left unscoped | Integrity check 14 |
See validation for what each check does when it goes red.
Skill tests
Integrity checks 2–7 and 14 cover the skill’s structure with no new test file. Add a Vitest test only if the skill introduces engine-side behavior — for example a new entry in SKILL_STEP_MAP that changes generated-table output, which belongs in src/conductor/test/generate-model-table.test.ts or model-table-metadata.test.ts.
Breaking-surface consequence of a skill change
Adding a skill is additive. Deleting or renaming one touches skill symlink targets, a canonical breaking surface, and requires a migration block or a waiver. See releases.
Add an engine step
Steps are keyed by an exhaustive Record<StepName, …> in several modules, so the compiler is your checklist: add the name first, then fix every type error npm run typecheck reports.
Step files to change
| Order | File | What to add |
|---|---|---|
| 1 | src/conductor/src/types/steps.ts:1 | The name, to the StepName union. |
| 2 | src/conductor/src/engine/steps.ts:4 | A StepDefinition appended to ALL_STEPS — or to OUT_OF_BAND_STEPS (:304) if it is not part of the linear gate-loop sequence. |
| 3 | src/conductor/src/engine/artifacts.ts:39 | An entry in STEP_ARTIFACT_GLOBS. |
| 4 | src/conductor/src/engine/provider-model-policy.ts | CLAUDE_STEP_MODELS (:32), CODEX_STEP_MODELS (:61), STEP_EFFORTS (:90). The composed policies at :139 and :155 are deep-frozen. |
| 5 | src/conductor/src/engine/resolved-config.ts | DEFAULT_STEP_RETRIES (:24), DEFAULT_STEP_REVIEW (:58), and the mapping in phaseForStep (:397). |
| 6 | src/conductor/src/engine/model-table-metadata.ts | STEP_RATIONALE (:14) and, if a skill drives the step, SKILL_STEP_MAP (:72). Then regenerate ARCHITECTURE.md. |
| 7 | src/conductor/src/engine/skill-invocation.ts:11 | A SkillInvocationDescriptor in STEP_SKILL_INVOCATIONS — either { kind: 'skill', skillName, arguments } or { kind: 'engine-native' }. Path resolution happens in engine/skill-resolver.ts:65. |
| 8 (optional) | src/conductor/src/engine/artifacts.ts | CUSTOM_COMPLETION_PREDICATES (:1306) when file globs cannot express completion, and GATE_ONLY_PREDICATES (:2394) when the step is a gate-loop-only check. Both are Partial, so neither errors if you skip it. |
| 9 (optional) | src/conductor/src/engine/step-runners.ts:322 | Dispatch behavior in DefaultStepRunner. The StepRunner interface is engine/conductor.ts:527, with StepRunOptions at :477 and StepRunResult at :363. |
Skip helpers live alongside ALL_STEPS: shouldSkipForTier (:420), shouldSkipForTrack (:431), shouldSkipForBootstrapMode (:451), shouldSkipForUpstreamSkip (:465).
The no-code path
A step declared in project config needs none of the above. engine/config.ts:1780 customStepEntries() reads it, and engine/gates.ts:15 checkGate accepts a StepDefinition directly so config-declared steps work without an engine edit. This repo registers one that way:
steps:
maintain-documentation:
after: rebase
skill: .agents/skills/maintain-documentation/SKILL.md
enforcement: gating
completion_artifact: .pipeline/maintain-documentation-pass
Use this for a project-specific step. Use the engine path only for a step every consumer gets.
What catches a step mistake
npm run typecheck catches every missed exhaustive record — that is the point of the design. Integrity check 5a catches a stale ARCHITECTURE.md model table. Nothing catches a missing CUSTOM_COMPLETION_PREDICATES entry, because that map is deliberately partial: the step will simply complete on artifact globs alone.
Step tests
- A unit test under
src/conductor/test/engine/mirroring the module you changed. - If the step gates progression, an acceptance test under
src/conductor/test/acceptance/proving the observable gate behavior across the minimum real internal path. - Bound any Conductor fixture: pre-resolve unrelated steps, use
fromStepfor a targeted transition, and create real evidence for every gate the fixture participates in. See testing.
Update steps and models in the same PR.
Add a gate
There is no single gate registry. “Gate” means three different mechanisms; pick the one that matches what you are enforcing.
Prerequisite gate
Set enforcement: 'gating' and a prerequisites list on the step in engine/steps.ts. Consumed by engine/gates.ts: checkGate (:15), isGatingStep (:38), canSkipStep (:45).
Objective verdict gate
The gate loop. Add the predicate to GATE_ONLY_PREDICATES in engine/artifacts.ts:2394; the loop machinery is engine/gate-verdicts.ts — checkGateCompletion (:17), computeAndWriteVerdict (:62), writeVerdict (:78), readVerdict (:92), readAllVerdicts (:106). Verdicts persist to .pipeline/gates/<step>.json (GATES_DIR, :51).
Routing over verdicts is engine/selector.ts: gateSatisfied (:53), selectNextGate (:111), earliestUnsatisfiedGateIndex (:130). Kickbacks are capped by MAX_KICKBACKS_PER_GATE in engine/conductor.ts:319.
Supporting machinery, if your gate needs it: engine/gate-invalidation.ts, engine/gate-writeback.ts, engine/gate-code-validity.ts, engine/gated-snapshot.ts.
Structural repo gate
A check on repository shape rather than a feature’s runtime state goes into test/test_harness_integrity.sh as a new numbered section, using the existing assert (:27) helper. Use warn_check (:40) only when you genuinely mean advisory — a warn_check can never fail the suite.
Self-host gates compose separately in engine/self-host/release-gate.ts and HALT through engine/self-host/gate-halt.ts.
What catches a gate mistake
A prerequisite gate with a typo in a step name is a type error. A verdict predicate that never returns false is caught by nothing — write the negative test. An integrity section that always passes is caught by nothing either; prove it can fail with a fixture sub-test, as checks 5a and 5c do.
Gate tests
Unit-test the predicate directly rather than through Conductor.run(). Add an acceptance test for the observable block-and-recover behavior. For an integrity-suite gate, add a fixture sub-test in the same section that deliberately breaks the condition and asserts the failing exit code.
Update gates and, for an integrity check, validation.
Add a hook
Hand-written hooks
- Author
hooks/claude/<name>.shin bash. - Wire it into
bin/install’sconfigure_hooks()function, in theharness_hooksdictionary. Each entry names an event (PreToolUse,PostToolUse,SessionStart,Stop,StopFailure), an optionalmatcher, the absolute command path under${hooks_dir}, and atimeout.bin/installmerges these into~/.claude/settings.jsonwithout clobbering entries it did not add.
A hook script that is never added to harness_hooks sits inert on disk — it is syntax-checked and nothing else.
Generated hooks
hooks/claude/docs-guard.sh is generated, not authored. Edit DOCS_GUARD_HOOK in src/conductor/src/engine/session-hook-assets.ts, run bin/generate-docs-guard-hook, and commit the regenerated .sh. Editing the .sh directly is drift and check 5c will fail.
Hook assets must be plain bash plus inline node -e only, with zero references to dist/ or ai-conductor — a generated hook has to keep working while the engine is mid-rebuild.
What catches a hook mistake
| Mistake | Caught by |
|---|---|
| Bash syntax error | Integrity check 1 |
| Hand-edited generated hook | Integrity check 5c |
A hook that writes .pipeline/task-status.json | Integrity check 10 |
Wiring never added to bin/install | Nothing — verify by hand |
Hook tests
Add a bash script under test/ following the existing test_*.sh pattern, and add unit coverage under src/conductor/test/engine/ for any generator change. Note that the bash script will only be syntax-checked unless you also invoke it from the integrity suite, as checks 13 and 14 do for their scripts.
Breaking-surface consequence of a hook change
Anything under hooks/ classifies as hook wiring, a canonical breaking surface. The change needs a runnable migration block, or a waiver when the edit is genuinely internal-only. See releases.
Document the new hook in settings and hooks.
Add a CLI command
A strict three-file pattern.
1. Declare it for --help
Add the subcommand to createProgram() in src/conductor/src/cli.ts:100. Declarations here build the help surface; most of them do no dispatching.
2. Implement detect and dispatch
Create src/conductor/src/engine/<name>-cli.ts exporting a pair:
detectXCommand(argv: string[]): XDispatch | null— a pure argv parser returning a discriminated union keyed onkind, ornullwhen this is not your command.dispatchX(d: XDispatch): Promise<number>— the side-effecting half, returning an exit code.
Twenty-plus modules follow this shape; engine/registry-cli.ts (detectRegistryCommand :184, dispatchRegistry :214) and engine/task-cli.ts (:20, :47) are the cleanest references. Do not copy detectOverlapScanCommand, which is defined inline in index.ts:318 — it is an inconsistency, not the pattern.
3. Wire it into main()
Add the branch to main() in src/conductor/src/index.ts:390, before the detectInline fallthrough at :670. Each branch calls process.exit. Use the lazy await import() form for a heavy runtime, the way the daemon branch does — that is what keeps daemon-cli.ts off the hot path for every other command.
Daemon sub-verbs
A new daemon <verb> must also be added to src/conductor/src/engine/daemon-command.ts. Management verbs go in MANAGEMENT_VERBS (:76); every known sub-verb must appear in DAEMON_SUBVERBS (:120), or detectUnknownDaemonSubcommand (:131) rejects it as a typo — which is deliberate, since the alternative is silently launching a daemon run.
Entry-point coverage
Static wiring reachability review is retired. Cover every independently invoked command root with an appropriate CLI or integration test; do not add a wiring.entry_points configuration block.
What catches a CLI mistake
| Mistake | Caught by |
|---|---|
Declared in cli.ts but never dispatched | Nothing — the command silently falls through to the inline rejection. Add a CLI test. |
| Dispatched but not declared | Nothing — it works but is undiscoverable in --help. |
A daemon sub-verb missing from DAEMON_SUBVERBS | detectUnknownDaemonSubcommand at runtime |
| Independently invoked entry root lacks coverage | Add a CLI or integration test that exercises the entry point. |
CLI tests
- A unit test for
detectXCommandcovering the null case and eachkind— argv parsing is pure and cheap to test exhaustively. - A test for
dispatchXwith its collaborators injected. - A
src/conductor/test/cli/test if the command changes top-level dispatch or help output.
Document the command, its flags, and its exit codes in cli.
Before you open the PR
bash test/test_harness_integrity.shfrom the repo root.cd src/conductor && npm run typecheck && npm test.- Update the canonical affected documentation in the same PR — a PR is not complete while its documentation is stale.
- Add a CHANGELOG entry only for a notable reader-visible implementation change; see releases.