/** * THE STEERING-AUTHOR LANDING (crew#388) — the post-approval write that closes TH-12. * * ## The defect * * The `steering-author` workflow's doctrine says "approved rules land via the rules CRUD with * `provenance.source: "chat"` — the run itself writes nothing", and the propose phase tells the * worker "do not write them yourself". Correct on both counts — evaluator≠creator — but NO * component performed the write: the generic `POST /runs/:id/gate` only confirmed the gate, core * has no steering-author hook, and the studio's `onRunResolved` only reloads rules. Every * chat-authored rule ever approved was silently lost, orphaned as prose in the run's transcript * (campaign 2026-09-01, scenario C5, run f3db4335). * * ## The design * * The landing is CREW-side, on APPROVE of the propose gate — the run keeps writing nothing to the * store. Since #789 the propose phase hands the rules back in its REPLY — one ```json fenced * array — and the landing reads them from the propose unit's stored output: that is the designed * path. A `/proposed-rules.json` is still read FIRST when one exists (runs launched * before #789 were told to write it; a seat's sandbox refused that write outside its workspace * every time, which is why it is no longer asked for), and the cached gate prompt is the last * resort (the output is stored only on approval, so a store hiccup there still has the prompt core * composed FROM that output). * * Invariants: * - AUDITABLE — every landed rule records `governance.rule.upserted` with the chat provenance * and the run id, exactly as the doctrine documents; a landing that could not happen records * `governance.steering.landing_failed` with the reason. * - IDEMPOTENT — a durable marker (`landed.json` beside the proposal) is written after a full * landing; a replayed approve (daemon restart mid-request, double-driven gate) finds it and * re-lands nothing. The rule write itself is an UPSERT keyed by rule id, so even a lost * marker degrades to a same-content overwrite, never a duplicate rule. * - FAIL-LOUD — when no source yields a parseable proposal the approve still succeeds for the * RUN (the gate decision is the operator's and already happened), but the response and the * audit trail carry an explicit `landing.outcome: "failed"` with the reason. A silent no-op * is the bug this module exists to end. * - AMEND-APPROVE — an approve carrying an amend note lands the proposal UNCHANGED: the amend * steers the RUN (core injects it into the run's continuation), not the rule text. An * operator who wants different rules rejects and re-authors; editing rule text inside a gate * note would land words nobody reviewed as JSON nobody saw. */ import type { CoreAdapter } from '../core/adapter.js'; import type { Actor, ConformanceRule, SessionView } from '../core/types.js'; export { STEERING_PROPOSAL_FILENAME } from './governance-steering.js'; import type { AuditLog } from './audit.js'; /** The workflow whose propose gate this landing serves. */ export declare const STEERING_AUTHOR_WORKFLOW = "steering-author"; /** Durable idempotency marker written beside the proposal after a full landing. */ export declare const STEERING_LANDED_MARKER_FILENAME = "landed.json"; /** Where the propose phase is told to write its machine-readable proposal. */ export declare function steeringProposalPath(runId: string): string; /** Where a completed landing records what it landed (replay dedupe). */ export declare function steeringLandedMarkerPath(runId: string): string; /** * The steering fields a rule write may carry (the merged model's additions) — mirrored by * `POST /governance/rules`, which refuses them on a pre-steering engine because the engine * would silently DROP them and persist a rule that enforces differently than written. The * landing applies the same guard: authored-then-downgraded is a loud landing failure, never * a quiet field amputation. */ export declare const STEERING_RULE_FIELDS: readonly ["steering_type", "applies_to", "excludes", "weight", "effect", "trigger", "obligations", "criteria"]; /** Where the landed proposal was read from, most-preferred first. */ export type SteeringProposalSource = 'deliverable' | 'transcript' | 'gate-prompt'; /** * The `landing` field on the `POST /runs/:id/gate` (and gated `/resume`) 200 body — present * ONLY when the approved gate was a steering-author propose gate. Published as * `SteeringLandingResult` in wicked-crew-api-types. */ export interface SteeringLandingResult { /** `landed` = every proposed rule is in the store; `failed` = the RUN still advanced but the * landing (wholly or partly) did not — `error` says why, and the audit trail carries it. */ outcome: 'landed' | 'failed'; /** Rule ids actually upserted (all of them on `landed`; the partial set on a partial `failed`). */ ruleIds: string[]; /** Where the proposal was read from. Absent when no source yielded one. */ source?: SteeringProposalSource; /** `true` when a replayed approve found the durable marker — nothing was re-landed. */ alreadyLanded?: boolean; /** Present iff `outcome: "failed"` — the loud, operator-readable reason. */ error?: string; } /** True when this run IS a steering-author run — its NAME, as the engine recorded it * (`runNameOf`, seam X2: the preset or the registered workflow, never a phase-sequence guess). * A run with no name (a user plan, free text, a partial stub in tests) is "not steering-author", * and the legacy gate path must not 500 on it. */ export declare function isSteeringAuthorRun(run: SessionView): boolean; /** * Recover the default steering type from the author route's own problem-statement preamble * (`Author steering rules for the '' steering type …`) so a proposed rule that omitted * `steering_type` still lands on the sub-page the operator authored it for. `undefined` when * the problem does not carry the preamble (a hand-launched run) — the engine's serde default * (`architecture`) then applies, same as any other write. */ export declare function steeringTypeFromProblem(problem: string): string | undefined; /** A parsed-but-not-yet-normalized proposal entry. */ type RawRule = Record; /** * Extract the proposed rules from free text — the whole text as JSON first (the deliverable * file's shape), then each fenced code block, then every balanced top-level `[...]` in the * text (the transcript / gate-prompt shape, where the JSON array is embedded in prose). * `null` when nothing parses to a non-empty array of `{id, statement}` objects. */ export declare function extractProposedRules(text: string): RawRule[] | null; /** * Stamp the chat provenance and the recovered default steering type onto one proposed rule. * `source: "chat"` is FORCED, not defaulted — the landing is the doctrine's one write path for * chat-authored rules, and a worker that wrote some other source into its proposal must not be * able to disguise where the rule came from. Everything else passes through untouched: the * ENGINE's upsert path is the one spelling of what a valid rule is. */ export declare function normalizeProposedRule(raw: RawRule, defaultType?: string, coercions?: string[]): ConformanceRule; /** The adapter surface the landing needs — narrow, so tests stub exactly what runs. */ export type SteeringLandingAdapter = Pick; export interface SteeringLandingDeps { adapter: SteeringLandingAdapter; audit: AuditLog; actor: Actor; } /** * Perform the landing for an APPROVED steering-author propose gate. Called AFTER * `confirmGate` resolved — the gate decision is already durable; this function's only job is * the write the doctrine promised. Never throws: every failure is folded into a loud * `outcome: "failed"` result (and the audit trail), because a landing error must not turn an * already-successful gate approval into an HTTP 500. * * `gatePrompt` is the cached gate prompt captured BEFORE the confirm (terminal events prune * the cache) — the last-resort proposal source. */ export declare function landSteeringProposal(deps: SteeringLandingDeps, run: SessionView, gatePrompt?: string): Promise; //# sourceMappingURL=steering-landing.d.ts.map