import { existsSync, statSync } from "node:fs"; import type { EffortLevel, HarnessEvent } from "../contracts/index.ts"; import { type CommentFinding, coreFacade, type HarnessLesson, type ObservabilityConfig, type PendingLessonCredit, type Policy, } from "../core/index.ts"; import { filterCodeTargets, filterTestTargets, gitCommonDirOf, gitRootOf, listChangedRepoFiles, } from "../platform/git.ts"; import { runProcess } from "../platform/process.ts"; import { type ProviderPort, providers } from "../providers/index.ts"; // invariant: one definition, taken from core rather than restated. export const OBS_CONFIG = coreFacade.observability.DEFAULT_OBS; // why: tool.end, shell.end, mcp.end and file.edit are debug-level kinds, so the passive audit trail only // persists when debug writing is on. The difference from OBS_CONFIG is stated here once instead of being // re-declared per entrypoint. export const OBS_CONFIG_AUDIT = { ...OBS_CONFIG, debugEnabled: true }; // why: the base configs are module constants, so the one operator-controlled field has to be layered on // per call rather than baked in at import time. export function obsConfigFor( policy: { obs: Policy["obs"] }, base: ObservabilityConfig = OBS_CONFIG, ): ObservabilityConfig { return { ...base, globalSpool: policy.obs.globalSpool, // why: debugEnabled is deliberately absent from Policy.obs. The only events that resolve to debug level // are emitted with OBS_CONFIG_AUDIT, which forces it on for the audit trail (AD-016 item 7), so there is // nothing a project could switch. Exposing it would repeat the dead-section mistake this replaces. includePayloads: policy.obs.includePayloads, maxAttrChars: policy.obs.maxAttrChars, sessionCostAlertUsd: policy.obs.sessionCostAlertUsd, retentionDays: policy.obs.retentionDays, }; } /** Characters on disk, or zero when the file went away between the write and the read. */ export function sizeOf(path: string): number { try { return statSync(path).size; } catch { return 0; } } export function sessionIdFromKey(event: HarnessEvent): string { const prefix = `${event.provider}-`; return event.sessionKey.startsWith(prefix) ? event.sessionKey.slice(prefix.length) : event.sessionKey; } export async function currentGitBranch(root: string): Promise { const gitRoot = await gitRootOf(root); if (gitRoot === null) { return null; } const result = await runProcess({ command: ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd: gitRoot }); if (result.exitCode !== 0) { return null; } const branch = result.stdout.trim(); return branch.length > 0 ? branch : null; } // why: `event.projectDir` prefers `CLAUDE_PROJECT_DIR`, kept pointed at the session's original root on // purpose, including inside a worktree. `event.cwd` is the field a host actually moves — present on every // Claude Code hook, but on Cursor only `beforeShellExecution` carries it ([/decisions/ad-114.md](/decisions/ad-114.md)). // Absent, `recallSessionCwd` is the next best answer for an event, like `stop`, that never gets one. export async function shaScopeRoot(event: HarnessEvent): Promise { if (event.cwd) { return event.cwd; } const recalled = coreFacade.handoff.readHandoff( event.projectDir, event.provider, event.sessionKey, ).last_shell_cwd; return recallSessionCwd(recalled, event.projectDir); } // why: trusted only once confirmed against `projectDir`'s own git object database — a deleted worktree, or a // stale value a different repository entirely once wrote to this same session key, must not redirect a gate // into the wrong directory ([/decisions/ad-145.md](/decisions/ad-145.md)). async function recallSessionCwd(recalled: string | undefined, projectDir: string): Promise { if (!recalled || recalled === projectDir || !existsSync(recalled)) { return projectDir; } const [recalledCommon, projectCommon] = await Promise.all([ gitCommonDirOf(recalled), gitCommonDirOf(projectDir), ]); return recalledCommon !== null && recalledCommon === projectCommon ? recalled : projectDir; } export type TurnScope = { turnBase: string; changedFiles: string[]; codeTargets: string[]; testTargets: string[]; commentTargets: string[]; pendingCredit: PendingLessonCredit | undefined; }; /** * "What did this turn add" — the one answer every gate that scopes by turn shares, whether it runs * at `stop` or, since AD-116, before `commit`/`push`/`pr-open` ships it. A second, differently-scoped * answer to the same question is the drift AD-071 already named once ([/decisions/ad-116.md](/decisions/ad-116.md)). * * invariant: `turn_base_sha` falls back to `HEAD` when the handoff is absent, its seal diverged, or * `resolveTurnBase` finds a different git root — identical to `stop.ts`'s own fallback * ([/decisions/ad-144.md](/decisions/ad-144.md)). * why: `root` is state (worktree-stable, AD-114); `gitRoot` is where the turn's files actually are * (`shaScopeRoot(event)`) — diffing a worktree-valid sha from the wrong directory read a whole unrelated * branch as added ([/decisions/ad-129.md](/decisions/ad-129.md)). */ export async function computeTurnScope( root: string, gitRoot: string, provider: string, sessionKey: string, policy: Pick, ): Promise { const seal = coreFacade.handoff.handoffInjectable(root, sessionKey); const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider, sessionKey) : undefined; const turnBase = await resolveTurnBase(handoff, gitRoot); const rawChangedFiles = await listChangedRepoFiles(gitRoot, turnBase); const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions( root, gitRoot, provider, sessionKey, ); const changedFiles = rawChangedFiles.filter((file) => !otherSessionFiles.has(file)); const codeTargets = filterCodeTargets(changedFiles, policy.codePaths); const testTargets = filterTestTargets(changedFiles); const commentScope = changedFiles.filter((file) => coreFacade.policy.isUnderCodePaths(file, policy.codePaths), ); const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope); return { turnBase, changedFiles, codeTargets, testTargets, commentTargets, pendingCredit: handoff?.pending_lesson_credit, }; } /** * The same unresolved-comment check `stop.ts` already runs at the end of the turn, exposed so an * action-time rail can ask the identical question before a `commit`/`push`/`pr-open` ships it * ([/decisions/ad-115.md](/decisions/ad-115.md)). */ export async function pendingCommentViolations( root: string, gitRoot: string, provider: string, sessionKey: string, policy: Pick, ): Promise { if (!policy.comments.enabled || policy.comments.onViolation !== "followup") { return []; } const scope = await computeTurnScope(root, gitRoot, provider, sessionKey, policy); if (scope.commentTargets.length === 0) { return []; } return coreFacade.commentPolicy.scanAddedComments( gitRoot, scope.commentTargets, policy.comments.mode, scope.turnBase, ); } /** * why: `turn_base_sha` alone cannot tell "this session's own HEAD" from "the HEAD of a worktree the shell * was sitting in three turns ago" — both are just a sha. Comparing the git root it was captured from * against the root being diffed against is what tells the two apart, the same way an absent * `turn_base_sha` already falls back to `HEAD` rather than diffing against nothing, instead of diffing one * repository's turn against an unrelated one's entire history ([/decisions/ad-144.md](/decisions/ad-144.md)). * * invariant: a slice with a sha but no recorded root (state written before this existed) resolves to * `HEAD`, the same as no slice at all — safe by default, and self-healing on the next `prompt.submit`. */ export async function resolveTurnBase( slice: { turn_base_sha?: string; turn_base_root?: string } | undefined, gitRoot: string, ): Promise { if (!slice?.turn_base_sha || !slice.turn_base_root) { return "HEAD"; } const currentRoot = await gitRootOf(gitRoot); return currentRoot !== null && currentRoot === slice.turn_base_root ? slice.turn_base_sha : "HEAD"; } export async function currentGitSha(root: string): Promise { const gitRoot = await gitRootOf(root); if (gitRoot === null) { return null; } const result = await runProcess({ command: ["git", "rev-parse", "--short", "HEAD"], cwd: gitRoot }); if (result.exitCode !== 0) { return null; } const sha = result.stdout.trim(); return sha.length > 0 ? sha : null; } /** * hazard: this fell back to a shipped list whenever the project's was empty, so a spawn could be refused by an * allowlist that exists nowhere in the project — and the refusal named no source, so an operator reading `[]` in * their own config could only conclude that empty meant none. There is no shipped list now: the effective one is * exactly what the project configured ([/decisions/ad-053.md](/decisions/ad-053.md)). */ export function effectiveAllowedModels( configured: string[] | Record | undefined, provider: ProviderPort, ): string[] { return coreFacade.policy.forProvider(configured, provider.name) ?? []; } export function effectiveBlockedPatterns( configured: string[] | Record | undefined, provider: ProviderPort, ): string[] { const fromConfig = coreFacade.policy.forProvider(configured, provider.name) ?? []; return [...fromConfig, ...provider.policyDefaults().blockedPatterns]; } export function effectiveMinEffort( configured: EffortLevel | null, provider: ProviderPort, ): EffortLevel | null { return configured ?? provider.policyDefaults().minEffort; } /** * Everything `evaluateSubagentSpawn` needs about a spawn, assembled once. * * hazard: `subagent-start` and `tool-before` each built this object, seven identical lines apart from the * indentation, so a new field in `policy.subagents` had to be remembered in two places — the shape where a * consumer stops growing with its producer ([/decisions/ad-065.md](/decisions/ad-065.md)). The duplication rail * found it on its first honest run ([/decisions/ad-071.md](/decisions/ad-071.md)). */ export function subagentSpawnInput( event: HarnessEvent, policy: Policy, provider: ProviderPort, model: string, ): Parameters[0] { return { provider: provider.name, sessionKey: event.sessionKey, projectDir: event.projectDir, model, effort: event.effort, allowedModels: effectiveAllowedModels(policy.subagents.allowedModels, provider), blockedPatterns: effectiveBlockedPatterns(policy.subagents.blockedPatterns, provider), minEffort: effectiveMinEffort(policy.subagents.minEffort, provider), requireModel: policy.subagents.requireModel, enforceAllowlist: policy.subagents.enforceAllowlist, blockParentFast: policy.subagents.blockParentFast, blockMode: policy.subagents.blockMode, }; } export function readModelFromToolInput(toolInput: Record | undefined): string { if (!toolInput) { return ""; } const model = toolInput.model ?? toolInput.Model; return typeof model === "string" ? model : ""; } /** * hazard: this used to be a copy of the core renderer, on the reasoning that presentation is not core's business. * The copy is what the model actually receives, so the tier added to the core block rendered in `lessons list` and * in nothing an agent ever saw. Two renderers for one string is the same defect as a consumer without a producer, * pointed sideways ([/decisions/ad-040.md](/decisions/ad-040.md)). */ export function renderLessonLine(lesson: HarnessLesson): string { return coreFacade.lesson.renderLessonBlock(lesson); } // invariant: a single dispatcher, called from both session entrypoints — the durable view is written at // session start and again at session end, so a second copy of this logic in either one would repeat the // AD-042 defect. // why: a registry lookup, not a name-checking chain — adding a third adapter to `providers` gets it // dispatched here for free, exactly as it already gets `detect`/`capabilities`/`render` for free — // `ProviderPort` itself, not this function, is what requires `lessonsView` to exist ([/decisions/ad-139.md](/decisions/ad-139.md)). export function renderProviderLessonsView( providerName: string, root: string, registry: readonly ProviderPort[] = providers, ): string | null { const provider = registry.find((candidate) => candidate.name === providerName); return provider?.lessonsView(root) ?? null; } /** * why: `omitted` is rendered because the char budget silently cuts below `maxInjectSession` — the count promises * five and a 900-char budget fits about two. A reader who cannot tell that eligible lessons were dropped has no * way to know the budget is the binding constraint ([/decisions/ad-043.md](/decisions/ad-043.md)). * * invariant: silent when nothing was dropped. A note on every healthy turn is one more line to skim past. */ export function formatLessonsBlock(lessons: HarnessLesson[], title: string, omitted = 0): string { if (lessons.length === 0) { return ""; } const lines = [title, ...lessons.map(renderLessonLine)]; if (omitted > 0) { const noun = omitted === 1 ? "lesson" : "lessons"; lines.push( ` (${omitted} more eligible ${noun} omitted under the char budget — raise maxCharsSession to see them)`, ); } return lines.join("\n"); } /** * The producer half of the feature: what the harness witnessed, written where only the harness can write it. * * hazard: this did not exist in the first cut. `observe` had no caller, so the store was never written, no proof * could ever be satisfied, and every rule that parsed denied for ever — `require:` is mandatory, so that was * every rule ([/decisions/ad-100.md](/decisions/ad-100.md)). * * why `wants` first: this runs on every tool call and the sha is a process spawn. Nothing is asked of git unless * a declared rule requires this kind of proof, so an operator whose only rule wants a subagent pays no git on any * command. * * invariant: after the event, never able to change it. A rail that records what happened must not become a rail * that decides whether it may. */ export async function observeForRules( event: HarnessEvent, // why the shape and not `HandlerContext`: `run.ts` already imports this module, so naming its type here would // close an import cycle. Only the one field is needed. ctx: { policy: { rules: Policy["rules"] } }, ): Promise { const config = ctx.policy.rules; if (!coreFacade.rules.wants(event.projectDir, config, event)) { return; } const sha = await currentGitSha(await shaScopeRoot(event)); coreFacade.rules.observe(event.projectDir, config, event, { sha, sessionKey: event.sessionKey, at: new Date().toISOString(), }); }