@martinloop/mcp
Advanced tools
| /** | ||
| * Context Handoff — A-CTX-2 contracts. | ||
| * | ||
| * Chain verification circuit breaker for upstream-to-downstream context handoff. | ||
| * | ||
| * Rule: a downstream agent MUST NOT execute before verifyContextHandoff returns ok=true. | ||
| * Healthy handoffs are silent. Broken handoffs stop execution and return one reason code | ||
| * plus one recovery instruction. Never silently discard excluded or unverifiable context. | ||
| */ | ||
| /** | ||
| * Overall integrity state of the upstream chain. | ||
| * "verified" — all upstream receipts and artifacts check out | ||
| * "evidence_boundary" — upstream integrity could not be established (no receipt) | ||
| * "tamper_detected" — receipt hash or artifact hash does not match | ||
| * "incomplete" — required fields or artifacts are absent | ||
| * "unsupported_schema"— handoff schemaVersion is not recognised | ||
| */ | ||
| export type ChainIntegrityState = "verified" | "evidence_boundary" | "tamper_detected" | "incomplete" | "unsupported_schema"; | ||
| export type HandoffClaimState = "verified" | "unverified" | "rejected" | "unknown"; | ||
| /** A single verifiable statement carried across the handoff boundary. */ | ||
| export interface ContextHandoffClaim { | ||
| claimId: string; | ||
| statement: string; | ||
| evidenceRefs: string[]; | ||
| verificationState: HandoffClaimState; | ||
| } | ||
| /** A content-addressed artifact that must be present and hash-stable across the boundary. */ | ||
| export interface ContextHandoffArtifact { | ||
| path?: string; | ||
| sha256: string; | ||
| required: boolean; | ||
| label?: string; | ||
| } | ||
| export declare const HANDOFF_SCHEMA_VERSION: "martin.handoff.v1"; | ||
| /** | ||
| * Immutable record produced by the upstream agent and verified before the | ||
| * downstream agent executes. | ||
| * | ||
| * upstreamIntegrity propagates from the parent handoff — it can never be | ||
| * upgraded by downstream success. A "tamper_detected" upstream remains | ||
| * "tamper_detected" regardless of what the current agent does. | ||
| */ | ||
| export interface ContextHandoffReceipt { | ||
| schemaVersion: typeof HANDOFF_SCHEMA_VERSION; | ||
| handoffId: string; | ||
| chainId: string; | ||
| missionId?: string; | ||
| producerRunId: string; | ||
| /** SHA-256 of the upstream run's canonical receipt file. */ | ||
| producerReceiptHash: string; | ||
| /** Parent handoff IDs for multi-hop chain lineage. */ | ||
| parentHandoffIds?: string[]; | ||
| claims: ContextHandoffClaim[]; | ||
| artifacts: ContextHandoffArtifact[]; | ||
| /** Free-text assumptions that have not yet been verified at handoff time. */ | ||
| unresolvedAssumptions: string[]; | ||
| upstreamIntegrity: "verified" | "evidence_boundary" | "tamper_detected" | "incomplete"; | ||
| createdAt: string; | ||
| } | ||
| /** | ||
| * Safe ledger record for a context object that was denied before reaching | ||
| * executable context. | ||
| * | ||
| * MUST NOT contain the secret value or raw denied content — only safe | ||
| * identity, hashes, and reason codes. | ||
| */ | ||
| export interface ContextExclusionDecision { | ||
| decision: "excluded"; | ||
| objectId: string; | ||
| reasonCode: "secret_detected" | "policy_denied" | "authority_boundary" | "integrity_unverified"; | ||
| reason: string; | ||
| /** SHA-256 of the denied content — never the content itself. */ | ||
| sourceHash?: string; | ||
| excludedAt: string; | ||
| } | ||
| export interface ContextHandoffVerification { | ||
| ok: boolean; | ||
| integrity: ChainIntegrityState; | ||
| reasons: Array<{ | ||
| code: string; | ||
| message: string; | ||
| claimId?: string; | ||
| artifactSha256?: string; | ||
| }>; | ||
| } | ||
| /** | ||
| * Decision produced by decideContextCircuitBreak. | ||
| * | ||
| * Healthy: { shouldStop: false, silent: true } | ||
| * Broken: { shouldStop: true, silent: false, reasonCode, message, nextAction } | ||
| */ | ||
| export interface ContextCircuitBreakResult { | ||
| shouldStop: boolean; | ||
| /** true = healthy, no human-visible output; false = must surface to caller */ | ||
| silent: boolean; | ||
| verification: ContextHandoffVerification; | ||
| reasonCode?: string; | ||
| message?: string; | ||
| nextAction?: string; | ||
| } |
| /** | ||
| * Context Handoff — A-CTX-2 contracts. | ||
| * | ||
| * Chain verification circuit breaker for upstream-to-downstream context handoff. | ||
| * | ||
| * Rule: a downstream agent MUST NOT execute before verifyContextHandoff returns ok=true. | ||
| * Healthy handoffs are silent. Broken handoffs stop execution and return one reason code | ||
| * plus one recovery instruction. Never silently discard excluded or unverifiable context. | ||
| */ | ||
| // ─── Receipt ────────────────────────────────────────────────────────────────── | ||
| export const HANDOFF_SCHEMA_VERSION = "martin.handoff.v1"; |
| /** | ||
| * Context Runtime — A-CTX-1 contracts. | ||
| * | ||
| * Defines the governed context layer: objects, budgets, manifests, ledger | ||
| * entries, fault protocol, and continuation checkpoints. | ||
| * | ||
| * Non-negotiable invariants (enforced in implementation, not type system): | ||
| * - No `confidence` float anywhere — use UsageEvidence enums only | ||
| * - No `outcome_delta` field anywhere — permanently banned | ||
| * - `nowMs` is always a captured input; never Date.now() inside a compiler | ||
| * - Secret scan hit → deny persistence entirely; reason-coded ledger entry only | ||
| * - Fault budget accumulates by taskId, NOT by runId | ||
| */ | ||
| /** | ||
| * Evidence quality for a metric or measurement. | ||
| * | ||
| * "observed" = distinctive substring from the object appears verbatim in model output | ||
| * "inferred" = weak kind correlation, narrow and documented per kind | ||
| * "unknown" = default; metrics MUST be withheld, never reported as zero | ||
| */ | ||
| export type UsageEvidence = "observed" | "inferred" | "unknown"; | ||
| export type ContextKind = "mission" | "policy" | "security" | "task" | "continuation" | "working_diff" | "diagnostic" | "source" | "test" | "decision" | "todo" | "receipt_summary" | "tool_result"; | ||
| export type ContextPriority = "required" | "high" | "normal" | "low"; | ||
| export type ContextTrust = "authoritative" | "workspace" | "external" | "untrusted"; | ||
| export type ContextSensitivity = "public" | "workspace" | "restricted" | "secret"; | ||
| /** A typed, hashed, provenance-tracked object eligible for working-set inclusion. */ | ||
| export interface ContextObject { | ||
| /** Stable identifier across revisions. */ | ||
| id: string; | ||
| kind: ContextKind; | ||
| priority: ContextPriority; | ||
| trust: ContextTrust; | ||
| sensitivity: ContextSensitivity; | ||
| /** File path, receipt hash, or MCP URI — where the object originates. */ | ||
| sourceRef: string; | ||
| /** SHA-256 of raw content. */ | ||
| contentHash: string; | ||
| /** Git SHA or equivalent revision of the source file, if known. */ | ||
| sourceRevisionHash?: string; | ||
| /** Pre-render token estimate used during selection. */ | ||
| estimatedTokens: number; | ||
| /** Set iff the object was manually pinned; counts against pinnedTokensMax. */ | ||
| pinnedBy?: string; | ||
| tags?: string[]; | ||
| metadata?: Record<string, unknown>; | ||
| } | ||
| /** | ||
| * Token budget for one context compilation pass. | ||
| * | ||
| * pinnedTokensMax REQUIRED: caps manual-pin tokens to prevent deterministic | ||
| * selector starvation. Any pin that would exceed this cap must be rejected. | ||
| */ | ||
| export interface ContextBudget { | ||
| modelWindowTokens: number; | ||
| systemReserveTokens: number; | ||
| outputReserveTokens: number; | ||
| toolReserveTokens: number; | ||
| overflowReserveTokens: number; | ||
| maxWorkingSetTokens: number; | ||
| /** Hard cap on manually-pinned token allocation. */ | ||
| pinnedTokensMax: number; | ||
| } | ||
| /** | ||
| * The compiler's per-object inclusion/exclusion record. | ||
| * | ||
| * "included_truncated" is DISTINCT from "included" — truncation must be | ||
| * explicit in every receipt. Never use "included" for a truncated object. | ||
| */ | ||
| export interface ContextCandidateDecision { | ||
| objectId: string; | ||
| decision: "included" | "included_truncated" | "excluded" | "deferred" | "fault_loaded"; | ||
| /** | ||
| * Only present when decision === "included_truncated". | ||
| * Records the pre-truncation token count for receipts. | ||
| */ | ||
| truncatedFromTokens?: number; | ||
| /** Machine-readable reason code for this decision. */ | ||
| reason: string; | ||
| /** Actual post-render token count after adapter recount. Omitted pre-render. */ | ||
| renderedTokens?: number; | ||
| } | ||
| export declare const CONTEXT_MANIFEST_VERSION: "context-manifest/1"; | ||
| /** | ||
| * Immutable record of one context compilation pass. | ||
| * | ||
| * Determinism invariant: | ||
| * same inputs + policyHash + adapterVersion + nowMs → same manifestHash | ||
| * | ||
| * nowMs MUST be an explicit captured input — never Date.now() inside the | ||
| * compiler or rankContextObject. | ||
| */ | ||
| export interface ContextManifest { | ||
| schemaVersion: typeof CONTEXT_MANIFEST_VERSION; | ||
| /** SHA-256 of (decisions + budget + policyHash + adapterVersion + nowMs). */ | ||
| manifestHash: string; | ||
| taskId: string; | ||
| runId: string; | ||
| /** Explicit input — never Date.now() inside the compiler. */ | ||
| nowMs: number; | ||
| adapterVersion: string; | ||
| policyHash: string; | ||
| budget: ContextBudget; | ||
| decisions: ContextCandidateDecision[]; | ||
| totalRenderedTokens: number; | ||
| /** Render pass count; must be ≤ MAX_RENDER_PASSES (5). */ | ||
| renderPasses: number; | ||
| compilationDurationMs: number; | ||
| } | ||
| export declare const CONTEXT_LEDGER_VERSION: "context-ledger/1"; | ||
| /** | ||
| * Fourth ledger entry type — on the SAME receipt chain as cost and verification. | ||
| * | ||
| * FORBIDDEN: confidence field, outcome_delta field — both permanently banned. | ||
| * overflowCount and faultBudgetUsed accumulate by taskId, NOT by runId. | ||
| */ | ||
| export interface ContextLedgerEntry { | ||
| schemaVersion: typeof CONTEXT_LEDGER_VERSION; | ||
| entryType: "context"; | ||
| taskId: string; | ||
| runId: string; | ||
| manifestHash: string; | ||
| totalIncluded: number; | ||
| totalExcluded: number; | ||
| totalFaultLoaded: number; | ||
| /** Cumulative overflow count — resets per task, never per run. */ | ||
| overflowCount: number; | ||
| /** Cumulative fault budget consumed — resets per task, never per run. */ | ||
| faultBudgetUsed: number; | ||
| adapterVersion: string; | ||
| } | ||
| /** Request to demand-load a missing context object on fault. */ | ||
| export interface ContextFaultRequest { | ||
| objectId: string; | ||
| taskId: string; | ||
| runId: string; | ||
| reason: string; | ||
| } | ||
| /** Result of a context fault resolution attempt. */ | ||
| export interface ContextFaultResult { | ||
| objectId: string; | ||
| status: "loaded" | "unavailable" | "secret_blocked" | "budget_exceeded"; | ||
| /** Post-render token count when status === "loaded". */ | ||
| renderedTokens?: number; | ||
| ledgerEventEmitted: boolean; | ||
| } | ||
| /** Minimal task item tracked within a ContinuationCheckpoint. */ | ||
| export interface TaskItem { | ||
| id: string; | ||
| description: string; | ||
| status: "pending" | "in_progress" | "completed" | "failed"; | ||
| createdAtMs: number; | ||
| completedAtMs?: number; | ||
| } | ||
| /** | ||
| * Durable task state preserved outside the provider transcript. | ||
| * | ||
| * Write discipline (all enforced in CheckpointStore): | ||
| * - All writes MUST go through CheckpointStore.withTaskLock (CAS-locked). | ||
| * - Reject if store.read(taskId).sequenceNumber !== checkpoint.sequenceNumber - 1. | ||
| * - All array fields MUST be bounded before persistence. | ||
| * - Old completed entries MUST be compacted into completedSummaryHash. | ||
| * | ||
| * Testing requirement: two real concurrent Node processes, not jest mocks. | ||
| */ | ||
| export interface ContinuationCheckpoint { | ||
| schemaVersion: "checkpoint/1"; | ||
| taskId: string; | ||
| /** CAS lock key: reject write if stored sequenceNumber !== this - 1. */ | ||
| sequenceNumber: number; | ||
| /** SHA-256 of the previous checkpoint — integrity chain. */ | ||
| parentHash: string; | ||
| /** Old completed[] compacted into a hash-referenced summary to bound size. */ | ||
| completedSummaryHash?: string; | ||
| pending: TaskItem[]; | ||
| inProgress: TaskItem[]; | ||
| decisions: ContextCandidateDecision[]; | ||
| createdAtMs: number; | ||
| } | ||
| /** | ||
| * Governs what may enter the working set, how much, and why. | ||
| * policyHash must be included in every manifestHash computation. | ||
| */ | ||
| export interface ContextPolicy { | ||
| policyHash: string; | ||
| /** Sensitivity levels that are unconditionally excluded. */ | ||
| deniedSensitivities: ContextSensitivity[]; | ||
| /** Trust levels that are unconditionally excluded. */ | ||
| deniedTrustLevels: ContextTrust[]; | ||
| /** Behaviour when required objects alone exceed budget. */ | ||
| requiredOverBudgetAction: "explicit_escalation" | "fail_closed"; | ||
| /** Maximum allowed compiler wall-clock duration in ms. */ | ||
| maxCompilerDurationMs: number; | ||
| /** Maximum overhead ratio: compilationDurationMs / totalRenderedTokens. */ | ||
| maxOverheadRatio: number; | ||
| } |
| /** | ||
| * Context Runtime — A-CTX-1 contracts. | ||
| * | ||
| * Defines the governed context layer: objects, budgets, manifests, ledger | ||
| * entries, fault protocol, and continuation checkpoints. | ||
| * | ||
| * Non-negotiable invariants (enforced in implementation, not type system): | ||
| * - No `confidence` float anywhere — use UsageEvidence enums only | ||
| * - No `outcome_delta` field anywhere — permanently banned | ||
| * - `nowMs` is always a captured input; never Date.now() inside a compiler | ||
| * - Secret scan hit → deny persistence entirely; reason-coded ledger entry only | ||
| * - Fault budget accumulates by taskId, NOT by runId | ||
| */ | ||
| // ─── ContextManifest ───────────────────────────────────────────────────────── | ||
| export const CONTEXT_MANIFEST_VERSION = "context-manifest/1"; | ||
| // ─── ContextLedgerEntry ─────────────────────────────────────────────────────── | ||
| export const CONTEXT_LEDGER_VERSION = "context-ledger/1"; |
| /** | ||
| * Context Shadow — A-CTX-0 schema contracts. | ||
| * | ||
| * Shadow mode observes the existing prompt without altering it. | ||
| * No source text is retained in manifests or receipts — only hashes and counts. | ||
| */ | ||
| export declare const CONTEXT_SHADOW_MANIFEST_VERSION: "context-shadow-manifest/1"; | ||
| export declare const CONTEXT_C5_VERSION: "context-c5/1"; | ||
| export type ContextEvidence = "observed" | "configured" | "inferred" | "unknown"; | ||
| export type ContextShadowSegmentKind = "system" | "mission" | "task" | "workspace" | "diagnostic" | "tool" | "other"; | ||
| export interface ContextShadowSegmentInput { | ||
| segmentId: string; | ||
| kind: ContextShadowSegmentKind; | ||
| required: boolean; | ||
| text: string; | ||
| } | ||
| export interface ContextShadowDecisionV1 { | ||
| segmentId: string; | ||
| kind: string; | ||
| /** SHA-256 of segment.text — never the source text itself. */ | ||
| contentHash: string; | ||
| estimatedTokens: number; | ||
| /** Always true in shadow mode — actual prompt is never altered. */ | ||
| actuallyIncluded: true; | ||
| /** What the shadow compiler would have decided if it controlled selection. */ | ||
| proposedDecision: "included" | "excluded"; | ||
| reason: "required" | "within_shadow_budget" | "shadow_budget_exhausted" | "required_over_budget"; | ||
| } | ||
| export interface ContextShadowManifestV1 { | ||
| schemaVersion: typeof CONTEXT_SHADOW_MANIFEST_VERSION; | ||
| manifestId: string; | ||
| /** SHA-256 of the canonical JSON of the manifest (excluding manifestId and manifestHash). */ | ||
| manifestHash: string; | ||
| /** SHA-256 of the canonical compiler input, for cross-run determinism checks. */ | ||
| compilerInputHash: string; | ||
| runId: string; | ||
| taskId?: string; | ||
| adapter: string; | ||
| model?: string; | ||
| capturedAt: string; | ||
| mode: "shadow"; | ||
| modelWindowTokens: number | null; | ||
| modelWindowEvidence: ContextEvidence; | ||
| shadowBudgetTokens: number; | ||
| /** SHA-256 of framed source segments — confirms prompt identity without storing content. */ | ||
| actualPromptHash: string; | ||
| /** Estimated token count of all segments (actual prompt). */ | ||
| actualEstimatedTokens: number; | ||
| /** Estimated token count of shadow-selected segments only. */ | ||
| proposedEstimatedTokens: number; | ||
| /** True if required segments alone exceed shadowBudgetTokens. */ | ||
| requiredOverBudget: boolean; | ||
| decisions: ContextShadowDecisionV1[]; | ||
| } | ||
| export interface ContextC5EnvelopeV1 { | ||
| schemaVersion: typeof CONTEXT_C5_VERSION; | ||
| resource: "context"; | ||
| event: "shadow_compiled"; | ||
| runId: string; | ||
| taskId?: string; | ||
| manifestId: string; | ||
| manifestHash: string; | ||
| adapter: string; | ||
| model?: string; | ||
| mode: "shadow"; | ||
| actualEstimatedTokens: number; | ||
| proposedEstimatedTokens: number; | ||
| candidateCount: number; | ||
| includedCount: number; | ||
| excludedCount: number; | ||
| requiredOverBudget: boolean; | ||
| modelWindowTokens: number | null; | ||
| modelWindowEvidence: ContextEvidence; | ||
| createdAt: string; | ||
| } |
| /** | ||
| * Context Shadow — A-CTX-0 schema contracts. | ||
| * | ||
| * Shadow mode observes the existing prompt without altering it. | ||
| * No source text is retained in manifests or receipts — only hashes and counts. | ||
| */ | ||
| export const CONTEXT_SHADOW_MANIFEST_VERSION = "context-shadow-manifest/1"; | ||
| export const CONTEXT_C5_VERSION = "context-c5/1"; |
| /** | ||
| * R4 Delivery — M1 Contract | ||
| * | ||
| * Shared with Lane B (Control Plane). Do not modify unilaterally. | ||
| * The server selects one message; the client never downloads the catalog. | ||
| */ | ||
| export declare const DELIVERY_MESSAGE_SCHEMA_VERSION: "delivery-message/1"; | ||
| export declare const DELIVERY_RECORD_SCHEMA_VERSION: "delivery-record/1"; | ||
| export declare const MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION: "martin-message-selection/1"; | ||
| export declare const ALLOWED_ACTION_TYPES: readonly ["upgrade_cli", "upgrade_mcp", "submit_feedback", "open_release_notes", "dismiss", "view_spend_report", "view_run_explain"]; | ||
| export type ActionType = (typeof ALLOWED_ACTION_TYPES)[number]; | ||
| export type MessageKind = "update" | "feedback_request" | "milestone"; | ||
| export interface DeliveryMessage { | ||
| schemaVersion: typeof DELIVERY_MESSAGE_SCHEMA_VERSION; | ||
| id: string; | ||
| revision: number; | ||
| kind: MessageKind; | ||
| title: string; | ||
| body: string; | ||
| action: { | ||
| type: ActionType; | ||
| url?: string; | ||
| targetVersion?: string; | ||
| }; | ||
| expiresAt: string; | ||
| cooldownHours: number; | ||
| } | ||
| export interface MessageSelectionResponse { | ||
| schemaVersion: typeof MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION; | ||
| message?: DeliveryMessage; | ||
| } | ||
| /** Local cooldown/dismissal state — persisted in .martin/delivery-record.json */ | ||
| export interface DeliveryRecord { | ||
| schemaVersion: typeof DELIVERY_RECORD_SCHEMA_VERSION; | ||
| lastMessageId?: string; | ||
| lastShownAtEpochMs?: number; | ||
| dismissedIds: string[]; | ||
| cooldownUntilEpochMs?: number; | ||
| cachedMessage?: DeliveryMessage; | ||
| cachedAtEpochMs?: number; | ||
| } | ||
| /** Structured update field for --json and MCP responses */ | ||
| export interface UpdateAvailableField { | ||
| targetVersion: string; | ||
| kind: "cli" | "mcp"; | ||
| message?: string; | ||
| } |
| /** | ||
| * R4 Delivery — M1 Contract | ||
| * | ||
| * Shared with Lane B (Control Plane). Do not modify unilaterally. | ||
| * The server selects one message; the client never downloads the catalog. | ||
| */ | ||
| export const DELIVERY_MESSAGE_SCHEMA_VERSION = "delivery-message/1"; | ||
| export const DELIVERY_RECORD_SCHEMA_VERSION = "delivery-record/1"; | ||
| export const MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION = "martin-message-selection/1"; | ||
| export const ALLOWED_ACTION_TYPES = [ | ||
| "upgrade_cli", | ||
| "upgrade_mcp", | ||
| "submit_feedback", | ||
| "open_release_notes", | ||
| "dismiss", | ||
| "view_spend_report", | ||
| "view_run_explain", | ||
| ]; |
| /** | ||
| * Exit contracts for the MartinLoop Eight-Exit Runtime. | ||
| * | ||
| * Precedence rationale (product decision — changing EXIT_PRECEDENCE in core | ||
| * requires updating this paragraph in the same commit): | ||
| * 1. human_interrupt and external_event outrank all others — they represent | ||
| * authority external to the run's own evidence and cannot be argued with. | ||
| * 2. wall_clock, budget_cap, and turn_cap all outrank goal_met — governance | ||
| * limits (time, cost, iterations) cannot be overridden by a simultaneously | ||
| * verified goal; resource limits rank wall_clock > budget_cap > turn_cap. | ||
| * 3. turn_cap is suppressed at signal-generation time when the goal is already | ||
| * met, so goal_met and turn_cap cannot co-occur in a well-formed evaluation. | ||
| * Completing on the final permitted iteration is legitimate success, not an | ||
| * overrun; turn_cap fires only when iterations are exhausted without | ||
| * verified completion. | ||
| * 4. goal_met outranks error_threshold and no_progress — a deterministically | ||
| * verified result takes priority over soft progress signals. | ||
| */ | ||
| export declare const EXIT_POLICY_VERSION: "exit-policy/1"; | ||
| export declare const EXIT_EVALUATION_VERSION: "exit-evaluation/1"; | ||
| export declare const EXIT_SIGNAL_VERSION: "exit-signal/1"; | ||
| export declare const TERMINATION_ENVELOPE_VERSION: "termination/1"; | ||
| export declare const EXIT_KINDS: readonly ["goal_met", "turn_cap", "budget_cap", "wall_clock", "no_progress", "human_interrupt", "error_threshold", "external_event"]; | ||
| export type ExitKind = (typeof EXIT_KINDS)[number]; | ||
| export type ExitEvaluationPhase = "pre_run" | "pre_attempt" | "post_attempt" | "before_retry" | "during_attempt"; | ||
| export type ExternalEventDisposition = "satisfied" | "superseded" | "cancelled"; | ||
| export interface ExternalExitEvent { | ||
| source: string; | ||
| event: string; | ||
| disposition: ExternalEventDisposition; | ||
| observedAt: string; | ||
| subject?: string; | ||
| reason?: string; | ||
| evidenceUri?: string; | ||
| } | ||
| export interface ExitPolicyV1 { | ||
| schemaVersion: typeof EXIT_POLICY_VERSION; | ||
| goal: { | ||
| verifierRequired: boolean; | ||
| minimumScore: number; | ||
| }; | ||
| turns: { | ||
| max: number; | ||
| }; | ||
| budget: { | ||
| maxUsd: number; | ||
| maxTokens: number; | ||
| }; | ||
| wallClock: { | ||
| maxElapsedMs: number; | ||
| deadlineAt?: string; | ||
| }; | ||
| progress: { | ||
| windowSize: number; | ||
| unchangedStateLimit: number; | ||
| }; | ||
| errors: { | ||
| maxConsecutive: number; | ||
| }; | ||
| humanInterrupt: { | ||
| enabled: boolean; | ||
| }; | ||
| externalEvent: { | ||
| enabled: boolean; | ||
| }; | ||
| } | ||
| export interface ExitSignalV1 { | ||
| schemaVersion: typeof EXIT_SIGNAL_VERSION; | ||
| runId: string; | ||
| kind: "human_interrupt" | "external_event"; | ||
| requestedAt: string; | ||
| requestedBy: string; | ||
| reason?: string; | ||
| externalEvent?: ExternalExitEvent; | ||
| } | ||
| export interface ExitSnapshotV1 { | ||
| phase: ExitEvaluationPhase; | ||
| evaluatedAt: string; | ||
| runStartedAtMs: number; | ||
| nowMs: number; | ||
| turnsUsed: number; | ||
| actualUsd: number; | ||
| tokensUsed: number; | ||
| result?: { | ||
| status: "completed" | "failed"; | ||
| verificationPassed: boolean; | ||
| verifierScore: number; | ||
| }; | ||
| recentStateHashes: string[]; | ||
| consecutiveErrors: number; | ||
| humanInterrupt?: ExitSignalV1; | ||
| externalEvent?: ExternalExitEvent; | ||
| trajectoryStop?: { | ||
| shouldStop: boolean; | ||
| reason: string; | ||
| }; | ||
| } | ||
| export interface ExitMatchV1 { | ||
| kind: ExitKind; | ||
| reason: string; | ||
| evidence: Record<string, unknown>; | ||
| } | ||
| export interface ExitEvaluationV1 { | ||
| schemaVersion: typeof EXIT_EVALUATION_VERSION; | ||
| policyVersion: typeof EXIT_POLICY_VERSION; | ||
| shouldExit: boolean; | ||
| primary?: ExitKind; | ||
| matched: ExitKind[]; | ||
| phase: ExitEvaluationPhase; | ||
| evaluatedAt: string; | ||
| matches: ExitMatchV1[]; | ||
| } | ||
| export type TerminationEnvelopeV1 = { | ||
| schemaVersion: typeof TERMINATION_ENVELOPE_VERSION; | ||
| class: "operational_exit"; | ||
| exit: ExitEvaluationV1; | ||
| } | { | ||
| schemaVersion: typeof TERMINATION_ENVELOPE_VERSION; | ||
| class: "guard_stop"; | ||
| guard: { | ||
| reasonCode: string; | ||
| reason: string; | ||
| failureClass?: string; | ||
| safetySurface?: string; | ||
| }; | ||
| }; |
| /** | ||
| * Exit contracts for the MartinLoop Eight-Exit Runtime. | ||
| * | ||
| * Precedence rationale (product decision — changing EXIT_PRECEDENCE in core | ||
| * requires updating this paragraph in the same commit): | ||
| * 1. human_interrupt and external_event outrank all others — they represent | ||
| * authority external to the run's own evidence and cannot be argued with. | ||
| * 2. wall_clock, budget_cap, and turn_cap all outrank goal_met — governance | ||
| * limits (time, cost, iterations) cannot be overridden by a simultaneously | ||
| * verified goal; resource limits rank wall_clock > budget_cap > turn_cap. | ||
| * 3. turn_cap is suppressed at signal-generation time when the goal is already | ||
| * met, so goal_met and turn_cap cannot co-occur in a well-formed evaluation. | ||
| * Completing on the final permitted iteration is legitimate success, not an | ||
| * overrun; turn_cap fires only when iterations are exhausted without | ||
| * verified completion. | ||
| * 4. goal_met outranks error_threshold and no_progress — a deterministically | ||
| * verified result takes priority over soft progress signals. | ||
| */ | ||
| export const EXIT_POLICY_VERSION = "exit-policy/1"; | ||
| export const EXIT_EVALUATION_VERSION = "exit-evaluation/1"; | ||
| export const EXIT_SIGNAL_VERSION = "exit-signal/1"; | ||
| export const TERMINATION_ENVELOPE_VERSION = "termination/1"; | ||
| export const EXIT_KINDS = [ | ||
| "goal_met", | ||
| "turn_cap", | ||
| "budget_cap", | ||
| "wall_clock", | ||
| "no_progress", | ||
| "human_interrupt", | ||
| "error_threshold", | ||
| "external_event" | ||
| ]; |
| /** | ||
| * Mission Governance contracts — C2 | ||
| * | ||
| * A MissionRecord governs one software mission: intent → budget → runs → | ||
| * verification → decision → receipt. It never replaces LoopRecord; it | ||
| * aggregates above it. | ||
| * | ||
| * Invariants: | ||
| * - One accountable human owner per mission. | ||
| * - State transitions are append-only in the ledger. | ||
| * - Ledger is the authority; mission.json is a rebuildable cache. | ||
| * - A mission cannot become "verified" without verified run evidence. | ||
| * - A mission cannot become "shipped" without an explicit ship decision. | ||
| * - Unknown schema versions fail closed. | ||
| * - Budgets cannot be raised silently. | ||
| */ | ||
| export declare const MISSION_SCHEMA_VERSION: "martin.mission.v1"; | ||
| export declare const MISSION_STATUSES: readonly ["planned", "running", "blocked", "verified", "shipped", "rolled_back", "killed"]; | ||
| export type MissionStatus = (typeof MISSION_STATUSES)[number]; | ||
| export declare const ALLOWED_MISSION_TRANSITIONS: Record<MissionStatus, readonly MissionStatus[]>; | ||
| export type MissionDecision = "ship" | "retry" | "rollback" | "kill"; | ||
| export interface MissionBudget { | ||
| maxUsd: number; | ||
| maxTokens: number; | ||
| maxRuns: number; | ||
| maxConcurrentRuns: number; | ||
| } | ||
| export interface MissionCost { | ||
| /** Sum of actualUsd across all linked runs. */ | ||
| totalActualUsd: number; | ||
| /** Number of linked runs with status "completed" (verified outcome). */ | ||
| verifiedOutcomeCount: number; | ||
| /** Number of linked runs regardless of outcome. */ | ||
| totalRunCount: number; | ||
| } | ||
| export type MissionRunRole = "primary" | "experiment" | "validation"; | ||
| export interface MissionRunLink { | ||
| loopId: string; | ||
| role: MissionRunRole; | ||
| attachedAt: string; | ||
| /** Set to true when the linked run completed with verification passed. */ | ||
| verifiedOutcome?: boolean; | ||
| /** Actual cost of this run in USD. */ | ||
| actualUsd?: number; | ||
| } | ||
| export interface MissionApproval { | ||
| approvalId: string; | ||
| kind: "ship" | "budget_increase" | "scope_change"; | ||
| decision: "approved" | "denied"; | ||
| approvedBy: string; | ||
| approvedAt: string; | ||
| note?: string; | ||
| } | ||
| export interface MissionOutcome { | ||
| decision: MissionDecision; | ||
| decidedAt: string; | ||
| decidedBy: string; | ||
| note?: string; | ||
| } | ||
| export type MissionEventKind = "mission.created" | "mission.status_changed" | "mission.run_attached" | "mission.run_verified" | "mission.approved" | "mission.closed" | "mission.collision_blocked"; | ||
| export interface MissionEvent { | ||
| eventId: string; | ||
| kind: MissionEventKind; | ||
| missionId: string; | ||
| timestamp: string; | ||
| payload: Record<string, unknown>; | ||
| } | ||
| export interface MissionRecord { | ||
| schemaVersion: typeof MISSION_SCHEMA_VERSION; | ||
| missionId: string; | ||
| /** Monotonically increasing write counter. Used for CAS enforcement. */ | ||
| revision: number; | ||
| title: string; | ||
| objective: string; | ||
| ownerId: string; | ||
| workspaceId: string; | ||
| projectId: string; | ||
| status: MissionStatus; | ||
| budget: MissionBudget; | ||
| cost: MissionCost; | ||
| runLinks: MissionRunLink[]; | ||
| approvals: MissionApproval[]; | ||
| outcome?: MissionOutcome; | ||
| acceptanceCriteria: string[]; | ||
| createdAt: string; | ||
| updatedAt: string; | ||
| } | ||
| export interface MissionDraft { | ||
| missionId?: string; | ||
| title: string; | ||
| objective: string; | ||
| ownerId: string; | ||
| workspaceId: string; | ||
| projectId: string; | ||
| budget: MissionBudget; | ||
| acceptanceCriteria?: string[]; | ||
| } | ||
| export declare function createMissionRecord(draft: MissionDraft, options?: { | ||
| now?: () => string; | ||
| idFactory?: (prefix: string) => string; | ||
| }): MissionRecord; | ||
| export declare function isMissionTransitionAllowed(from: MissionStatus, to: MissionStatus): boolean; |
| /** | ||
| * Mission Governance contracts — C2 | ||
| * | ||
| * A MissionRecord governs one software mission: intent → budget → runs → | ||
| * verification → decision → receipt. It never replaces LoopRecord; it | ||
| * aggregates above it. | ||
| * | ||
| * Invariants: | ||
| * - One accountable human owner per mission. | ||
| * - State transitions are append-only in the ledger. | ||
| * - Ledger is the authority; mission.json is a rebuildable cache. | ||
| * - A mission cannot become "verified" without verified run evidence. | ||
| * - A mission cannot become "shipped" without an explicit ship decision. | ||
| * - Unknown schema versions fail closed. | ||
| * - Budgets cannot be raised silently. | ||
| */ | ||
| export const MISSION_SCHEMA_VERSION = "martin.mission.v1"; | ||
| // ─── Status and transitions ─────────────────────────────────────────────────── | ||
| export const MISSION_STATUSES = [ | ||
| "planned", | ||
| "running", | ||
| "blocked", | ||
| "verified", | ||
| "shipped", | ||
| "rolled_back", | ||
| "killed" | ||
| ]; | ||
| export const ALLOWED_MISSION_TRANSITIONS = { | ||
| planned: ["running", "killed"], | ||
| running: ["blocked", "verified", "rolled_back", "killed"], | ||
| blocked: ["running", "rolled_back", "killed"], | ||
| verified: ["shipped", "running", "rolled_back", "killed"], | ||
| shipped: [], | ||
| rolled_back: ["running", "killed"], | ||
| killed: [] | ||
| }; | ||
| // ─── Factory ────────────────────────────────────────────────────────────────── | ||
| export function createMissionRecord(draft, options = {}) { | ||
| const now = options.now ?? (() => new Date().toISOString()); | ||
| const makeId = options.idFactory ?? ((prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`); | ||
| const ts = now(); | ||
| return { | ||
| schemaVersion: MISSION_SCHEMA_VERSION, | ||
| missionId: draft.missionId ?? makeId("mission"), | ||
| revision: 0, | ||
| title: draft.title, | ||
| objective: draft.objective, | ||
| ownerId: draft.ownerId, | ||
| workspaceId: draft.workspaceId, | ||
| projectId: draft.projectId, | ||
| status: "planned", | ||
| budget: { ...draft.budget }, | ||
| cost: { totalActualUsd: 0, verifiedOutcomeCount: 0, totalRunCount: 0 }, | ||
| runLinks: [], | ||
| approvals: [], | ||
| acceptanceCriteria: draft.acceptanceCriteria ?? [], | ||
| createdAt: ts, | ||
| updatedAt: ts | ||
| }; | ||
| } | ||
| // ─── Transition guard ───────────────────────────────────────────────────────── | ||
| export function isMissionTransitionAllowed(from, to) { | ||
| return ALLOWED_MISSION_TRANSITIONS[from].includes(to); | ||
| } |
| import type { CostProvenance, LoopLifecycleState, LoopStatus, ReceiptIntegritySummary } from "./index.js"; | ||
| export declare const VERIFIED_HANDOFF_OUTCOMES: readonly ["VERIFIED", "STOPPED", "NEEDS_REVIEW"]; | ||
| export type VerifiedHandoffOutcome = (typeof VERIFIED_HANDOFF_OUTCOMES)[number]; | ||
| export declare const EVIDENCE_STATUSES: readonly ["PASSED", "FAILED", "CONTRADICTED", "NOT_RUN", "NOT_EVALUATED"]; | ||
| export type EvidenceStatus = (typeof EVIDENCE_STATUSES)[number]; | ||
| /** | ||
| * Granular internal/runtime state. Retained for diagnosis, policy, | ||
| * receipts and engineering review. | ||
| */ | ||
| export declare const TEST_INTEGRITY_STATUSES: readonly ["UNCHANGED", "AUTHORIZED_CHANGE", "PREVENTED", "DETECTED_AND_ROLLED_BACK", "DETECTED_NEEDS_REVIEW", "NOT_EVALUATED"]; | ||
| export type TestIntegrityStatus = (typeof TEST_INTEGRITY_STATUSES)[number]; | ||
| /** | ||
| * Stable public-facing verdict used by CLI, MCP, JSON/Markdown handoffs | ||
| * and the website. Do not make each renderer collapse states independently. | ||
| */ | ||
| export declare const TEST_INTEGRITY_VERDICTS: readonly ["VERIFIED", "TAMPERING_DETECTED", "NOT_EVALUATED"]; | ||
| export type TestIntegrityVerdict = (typeof TEST_INTEGRITY_VERDICTS)[number]; | ||
| export interface VerifiedHandoffCheckV1 { | ||
| command: string; | ||
| status: EvidenceStatus; | ||
| exitCode?: number; | ||
| timedOut?: boolean; | ||
| detail?: string; | ||
| } | ||
| export interface VerifiedHandoffRequirementV1 { | ||
| requirement: string; | ||
| status: "PROVEN" | "FAILED" | "UNRESOLVED" | "NOT_EVALUATED"; | ||
| evidence?: string[]; | ||
| } | ||
| export interface VerifiedHandoffScopeV1 { | ||
| status: "WITHIN_SCOPE" | "VIOLATION_REJECTED" | "NEEDS_REVIEW" | "NOT_EVALUATED"; | ||
| allowedPaths: string[]; | ||
| deniedPaths: string[]; | ||
| changedFiles: string[]; | ||
| violations: string[]; | ||
| } | ||
| export interface VerifiedHandoffTestIntegrityV1 { | ||
| /** Public rendering contract. */ | ||
| verdict: TestIntegrityVerdict; | ||
| /** Granular runtime state for diagnosis and evidence. */ | ||
| status: TestIntegrityStatus; | ||
| protectedPaths: string[]; | ||
| changedProtectedPaths: string[]; | ||
| findings: Array<{ | ||
| filePath: string; | ||
| issue: string; | ||
| severity: "high" | "medium" | "low"; | ||
| detail: string; | ||
| }>; | ||
| summary: string; | ||
| } | ||
| export interface VerifiedHandoffRecoveryV1 { | ||
| rollbackBoundaryAvailable: boolean; | ||
| rollbackAttempted: boolean; | ||
| rollbackSucceeded?: boolean; | ||
| isolatedRef?: string; | ||
| nextCommand?: string; | ||
| summary: string; | ||
| } | ||
| export interface VerifiedHandoffV1 { | ||
| schemaVersion: "1.0.0"; | ||
| handoffId: string; | ||
| loopId: string; | ||
| generatedAt: string; | ||
| task: { | ||
| title: string; | ||
| objective: string; | ||
| }; | ||
| definitionOfDone: { | ||
| acceptanceCriteria: string[]; | ||
| verificationPlan: string[]; | ||
| }; | ||
| outcome: VerifiedHandoffOutcome; | ||
| sourceStatus: { | ||
| status: LoopStatus; | ||
| lifecycleState: LoopLifecycleState; | ||
| }; | ||
| verification: { | ||
| status: EvidenceStatus; | ||
| summary: string; | ||
| checks: VerifiedHandoffCheckV1[]; | ||
| warnings: string[]; | ||
| }; | ||
| requirements: VerifiedHandoffRequirementV1[]; | ||
| scope: VerifiedHandoffScopeV1; | ||
| testIntegrity: VerifiedHandoffTestIntegrityV1; | ||
| unresolvedWork: string[]; | ||
| stopReason?: string; | ||
| recovery: VerifiedHandoffRecoveryV1; | ||
| usage: { | ||
| attempts: number; | ||
| actualUsd: number; | ||
| estimatedUsd?: number; | ||
| tokensIn: number; | ||
| tokensOut: number; | ||
| costProvenance: CostProvenance; | ||
| }; | ||
| receiptIntegrity: ReceiptIntegritySummary; | ||
| nextAction: string; | ||
| } |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| export const VERIFIED_HANDOFF_OUTCOMES = [ | ||
| "VERIFIED", | ||
| "STOPPED", | ||
| "NEEDS_REVIEW", | ||
| ]; | ||
| export const EVIDENCE_STATUSES = [ | ||
| "PASSED", | ||
| "FAILED", | ||
| "CONTRADICTED", | ||
| "NOT_RUN", | ||
| "NOT_EVALUATED", | ||
| ]; | ||
| /** | ||
| * Granular internal/runtime state. Retained for diagnosis, policy, | ||
| * receipts and engineering review. | ||
| */ | ||
| export const TEST_INTEGRITY_STATUSES = [ | ||
| "UNCHANGED", | ||
| "AUTHORIZED_CHANGE", | ||
| "PREVENTED", | ||
| "DETECTED_AND_ROLLED_BACK", | ||
| "DETECTED_NEEDS_REVIEW", | ||
| "NOT_EVALUATED", | ||
| ]; | ||
| /** | ||
| * Stable public-facing verdict used by CLI, MCP, JSON/Markdown handoffs | ||
| * and the website. Do not make each renderer collapse states independently. | ||
| */ | ||
| export const TEST_INTEGRITY_VERDICTS = [ | ||
| "VERIFIED", | ||
| "TAMPERING_DETECTED", | ||
| "NOT_EVALUATED", | ||
| ]; |
| /** | ||
| * Context Chain Gate — C1 | ||
| * | ||
| * Converts chain verification evidence into a merge-gate decision and | ||
| * a PR comment (Infracost-style). Two things hard-fail. Everything else | ||
| * is configurable or comment-only. | ||
| * | ||
| * Hard failures (always block merge): | ||
| * 1. no_governance_receipt — no receipt at all | ||
| * 2. tamper_detected — receipt hash does not match producer run | ||
| * | ||
| * Configurable gate (team decides; default: warn only): | ||
| * 3. verifier_failed — unverified claims, incomplete integrity, | ||
| * missing artifacts, unresolved assumptions | ||
| * | ||
| * Comment-only (never block, always visible): | ||
| * - Cost: actual vs budget — money is already spent, blocking adds friction | ||
| * - Chain lineage: chainId, producerRunId, consumerRunId | ||
| * - Integrity state | ||
| * | ||
| * Standing rules: | ||
| * - Pure functions, no side effects. | ||
| * - evaluateChainGate never reads environment variables or config files. | ||
| * - renderGatePrComment produces valid GitHub-flavored markdown. | ||
| * - No fake data, no placeholder behaviour. | ||
| */ | ||
| import type { ContextHandoffReceipt } from "../contracts/index.js"; | ||
| import type { ChainIntegrityState, ContextHandoffVerification } from "../contracts/index.js"; | ||
| export interface ChainGateConfig { | ||
| /** | ||
| * When true, a failed verifier (unverified claims, incomplete integrity, | ||
| * missing artifacts, unresolved assumptions) blocks the merge. | ||
| * Default: false — warn in PR comment, do not block. | ||
| */ | ||
| blockOnVerifierFailure: boolean; | ||
| } | ||
| export interface ChainGateCost { | ||
| actualUsd: number; | ||
| budgetUsd: number; | ||
| } | ||
| export interface ChainGateInput { | ||
| /** null means no receipt was provided — triggers the hard no_governance_receipt failure. */ | ||
| receipt: ContextHandoffReceipt | null; | ||
| /** null when receipt is null. Must be provided when receipt is non-null. */ | ||
| verification: ContextHandoffVerification | null; | ||
| cost?: ChainGateCost; | ||
| config?: Partial<ChainGateConfig>; | ||
| } | ||
| export type ChainGateConclusion = "failure" | "neutral" | "success"; | ||
| export interface ChainGateResult { | ||
| noGovernance: boolean; | ||
| tamperDetected: boolean; | ||
| verifierFailed: boolean; | ||
| shouldBlock: boolean; | ||
| conclusion: ChainGateConclusion; | ||
| failureReasonCode?: string; | ||
| failureMessage?: string; | ||
| cost?: ChainGateCost & { | ||
| exceeded: boolean; | ||
| }; | ||
| integrity: ChainIntegrityState | "absent"; | ||
| } | ||
| /** | ||
| * Evaluates whether the chain gate should block merge, and assembles | ||
| * the data needed for the PR comment. | ||
| * | ||
| * Call order: | ||
| * 1. Check for missing receipt (hard fail). | ||
| * 2. Check for tamper_detected integrity (hard fail). | ||
| * 3. Check verifier result against config (configurable gate). | ||
| * 4. Return success / neutral with full comment data. | ||
| */ | ||
| export declare function evaluateChainGate(input: ChainGateInput): ChainGateResult; | ||
| export interface GatePrCommentOptions { | ||
| runId?: string; | ||
| chainId?: string; | ||
| handoffId?: string; | ||
| producerRunId?: string; | ||
| prNumber?: number; | ||
| headSha?: string; | ||
| } | ||
| /** | ||
| * Renders an Infracost-style GitHub PR comment summarising the gate result. | ||
| * | ||
| * Hard failures and cost overruns are shown prominently. | ||
| * Cost is always present when provided — never omitted, never a blocking signal. | ||
| * Lineage fields (chainId, producerRunId, runId) provide audit trail links. | ||
| */ | ||
| export declare function renderGatePrComment(result: ChainGateResult, options?: GatePrCommentOptions): string; |
| /** | ||
| * Context Chain Gate — C1 | ||
| * | ||
| * Converts chain verification evidence into a merge-gate decision and | ||
| * a PR comment (Infracost-style). Two things hard-fail. Everything else | ||
| * is configurable or comment-only. | ||
| * | ||
| * Hard failures (always block merge): | ||
| * 1. no_governance_receipt — no receipt at all | ||
| * 2. tamper_detected — receipt hash does not match producer run | ||
| * | ||
| * Configurable gate (team decides; default: warn only): | ||
| * 3. verifier_failed — unverified claims, incomplete integrity, | ||
| * missing artifacts, unresolved assumptions | ||
| * | ||
| * Comment-only (never block, always visible): | ||
| * - Cost: actual vs budget — money is already spent, blocking adds friction | ||
| * - Chain lineage: chainId, producerRunId, consumerRunId | ||
| * - Integrity state | ||
| * | ||
| * Standing rules: | ||
| * - Pure functions, no side effects. | ||
| * - evaluateChainGate never reads environment variables or config files. | ||
| * - renderGatePrComment produces valid GitHub-flavored markdown. | ||
| * - No fake data, no placeholder behaviour. | ||
| */ | ||
| const DEFAULT_CONFIG = { | ||
| blockOnVerifierFailure: false | ||
| }; | ||
| // ─── Gate evaluation ───────────────────────────────────────────────────────── | ||
| /** | ||
| * Evaluates whether the chain gate should block merge, and assembles | ||
| * the data needed for the PR comment. | ||
| * | ||
| * Call order: | ||
| * 1. Check for missing receipt (hard fail). | ||
| * 2. Check for tamper_detected integrity (hard fail). | ||
| * 3. Check verifier result against config (configurable gate). | ||
| * 4. Return success / neutral with full comment data. | ||
| */ | ||
| export function evaluateChainGate(input) { | ||
| const config = { ...DEFAULT_CONFIG, ...input.config }; | ||
| const costResult = input.cost !== undefined | ||
| ? { ...input.cost, exceeded: input.cost.actualUsd > input.cost.budgetUsd } | ||
| : undefined; | ||
| // ── Hard fail 1: no governance receipt ─────────────────────────────────── | ||
| if (input.receipt === null || input.verification === null) { | ||
| return { | ||
| noGovernance: true, | ||
| tamperDetected: false, | ||
| verifierFailed: false, | ||
| shouldBlock: true, | ||
| conclusion: "failure", | ||
| failureReasonCode: "no_governance_receipt", | ||
| failureMessage: "No MartinLoop governance receipt was found for this run. " + | ||
| "Every AI-authored PR must carry a verified governance receipt.", | ||
| integrity: "absent", | ||
| ...(costResult !== undefined ? { cost: costResult } : {}) | ||
| }; | ||
| } | ||
| const integrity = input.verification.integrity; | ||
| // ── Hard fail 2: tampered receipt ───────────────────────────────────────── | ||
| if (integrity === "tamper_detected") { | ||
| return { | ||
| noGovernance: false, | ||
| tamperDetected: true, | ||
| verifierFailed: false, | ||
| shouldBlock: true, | ||
| conclusion: "failure", | ||
| failureReasonCode: "tamper_detected", | ||
| failureMessage: "Governance receipt integrity check failed: tamper detected. " + | ||
| "The receipt hash does not match the producer run record.", | ||
| integrity, | ||
| ...(costResult !== undefined ? { cost: costResult } : {}) | ||
| }; | ||
| } | ||
| // ── Configurable gate: verifier failed ─────────────────────────────────── | ||
| const verifierFailed = !input.verification.ok; | ||
| const blockOnVerifier = verifierFailed && config.blockOnVerifierFailure; | ||
| if (blockOnVerifier) { | ||
| const first = input.verification.reasons[0]; | ||
| return { | ||
| noGovernance: false, | ||
| tamperDetected: false, | ||
| verifierFailed: true, | ||
| shouldBlock: true, | ||
| conclusion: "failure", | ||
| failureReasonCode: first?.code ?? "verifier_failed", | ||
| failureMessage: first?.message ?? "Context chain verification failed.", | ||
| integrity, | ||
| ...(costResult !== undefined ? { cost: costResult } : {}) | ||
| }; | ||
| } | ||
| // ── Pass (or warn-only verifier failure) ────────────────────────────────── | ||
| return { | ||
| noGovernance: false, | ||
| tamperDetected: false, | ||
| verifierFailed, | ||
| shouldBlock: false, | ||
| conclusion: verifierFailed ? "neutral" : "success", | ||
| integrity, | ||
| ...(costResult !== undefined ? { cost: costResult } : {}) | ||
| }; | ||
| } | ||
| /** | ||
| * Renders an Infracost-style GitHub PR comment summarising the gate result. | ||
| * | ||
| * Hard failures and cost overruns are shown prominently. | ||
| * Cost is always present when provided — never omitted, never a blocking signal. | ||
| * Lineage fields (chainId, producerRunId, runId) provide audit trail links. | ||
| */ | ||
| export function renderGatePrComment(result, options = {}) { | ||
| const lines = []; | ||
| lines.push("## MartinLoop Governance"); | ||
| lines.push(""); | ||
| // Status headline | ||
| if (result.noGovernance) { | ||
| lines.push("🚫 **No governance receipt** — merge is blocked."); | ||
| } | ||
| else if (result.tamperDetected) { | ||
| lines.push("🚫 **Tampered receipt** — merge is blocked."); | ||
| } | ||
| else if (result.verifierFailed && result.shouldBlock) { | ||
| lines.push("⚠️ **Verifier failed** — merge is blocked (configured as required)."); | ||
| } | ||
| else if (result.verifierFailed) { | ||
| lines.push("⚠️ **Verifier warnings** — merge is permitted, review recommended."); | ||
| } | ||
| else { | ||
| lines.push("✅ **Governance verified** — chain integrity confirmed."); | ||
| } | ||
| lines.push(""); | ||
| lines.push("| Signal | Value | Status |"); | ||
| lines.push("|--------|-------|--------|"); | ||
| // Chain integrity | ||
| const integrityIcon = result.integrity === "verified" | ||
| ? "✅" | ||
| : result.integrity === "absent" || result.integrity === "tamper_detected" | ||
| ? "🚫" | ||
| : "⚠️"; | ||
| lines.push(`| Chain integrity | \`${result.integrity}\` | ${integrityIcon} |`); | ||
| // Cost — always comment-only, never a block signal | ||
| if (result.cost !== undefined) { | ||
| const { actualUsd, budgetUsd, exceeded } = result.cost; | ||
| const costIcon = exceeded ? "⚠️" : "✅"; | ||
| const delta = actualUsd - budgetUsd; | ||
| const costNote = exceeded | ||
| ? `over budget (+$${delta.toFixed(4)})` | ||
| : "within budget"; | ||
| lines.push(`| Cost | \`$${actualUsd.toFixed(4)}\` / \`$${budgetUsd.toFixed(4)}\` | ${costIcon} ${costNote} |`); | ||
| } | ||
| // Lineage (audit trail, never a block signal) | ||
| if (options.chainId) { | ||
| lines.push(`| Chain ID | \`${options.chainId}\` | ℹ️ |`); | ||
| } | ||
| if (options.handoffId) { | ||
| lines.push(`| Handoff ID | \`${options.handoffId}\` | ℹ️ |`); | ||
| } | ||
| if (options.producerRunId) { | ||
| lines.push(`| Producer run | \`${options.producerRunId}\` | ℹ️ |`); | ||
| } | ||
| if (options.runId) { | ||
| lines.push(`| Consumer run | \`${options.runId}\` | ℹ️ |`); | ||
| } | ||
| // Failure detail | ||
| if (result.failureMessage) { | ||
| lines.push(""); | ||
| lines.push(`> **Failure:** ${result.failureMessage}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("_Powered by [MartinLoop](https://github.com/Keesan12/martin-loop) governance gate_"); | ||
| return lines.join("\n"); | ||
| } |
| /** | ||
| * Context Compiler — A-CTX-1 | ||
| * | ||
| * Deterministic working-set selector. Given a set of ContextObject candidates, | ||
| * a budget, a policy, and an explicit nowMs, produces a ContextManifest whose | ||
| * hash is identical for identical inputs on any platform. | ||
| * | ||
| * Shadow mode: the manifest is computed and emitted but the original provider | ||
| * packet is returned byte-for-byte unchanged. | ||
| * | ||
| * Standing invariants: | ||
| * - nowMs is ALWAYS a captured input — never Date.now() internally | ||
| * - No raw source text in manifest or ledger receipt — hashes and counts only | ||
| * - No confidence float, no outcome_delta — both permanently banned | ||
| * - Fault budget accumulates by taskId, not runId | ||
| * - required-over-budget is an explicit failure, never a silent drop | ||
| * - MAX_RENDER_PASSES = 5 — hard constant | ||
| */ | ||
| import type { ContextBudget, ContextLedgerEntry, ContextManifest, ContextObject, ContextPolicy } from "../contracts/index.js"; | ||
| export declare const MAX_RENDER_PASSES = 5; | ||
| /** | ||
| * Minimal adapter interface for token recounting. | ||
| * Implementations may call the provider SDK's token counter. | ||
| * Must not make network calls to the provider inference endpoint. | ||
| */ | ||
| export interface ContextAdapter { | ||
| readonly version: string; | ||
| /** Recount the actual rendered tokens for a serialized text segment. */ | ||
| recountTokens(text: string): number; | ||
| } | ||
| /** Fallback adapter: char÷4 heuristic, no external calls. */ | ||
| export declare const HEURISTIC_ADAPTER: ContextAdapter; | ||
| export interface CompileContextInput { | ||
| taskId: string; | ||
| runId: string; | ||
| /** Must be a captured value — never call Date.now() inside the compiler. */ | ||
| nowMs: number; | ||
| candidates: readonly ContextObject[]; | ||
| budget: ContextBudget; | ||
| policy: ContextPolicy; | ||
| adapter: ContextAdapter; | ||
| } | ||
| export type CompileContextOutput = { | ||
| ok: true; | ||
| manifest: ContextManifest; | ||
| ledgerEntry: ContextLedgerEntry; | ||
| } | { | ||
| ok: false; | ||
| reason: "required_over_budget" | "recount_exceeded_passes"; | ||
| manifest: null; | ||
| ledgerEntry: null; | ||
| }; | ||
| export declare function compileContext(input: CompileContextInput): CompileContextOutput; |
| /** | ||
| * Context Compiler — A-CTX-1 | ||
| * | ||
| * Deterministic working-set selector. Given a set of ContextObject candidates, | ||
| * a budget, a policy, and an explicit nowMs, produces a ContextManifest whose | ||
| * hash is identical for identical inputs on any platform. | ||
| * | ||
| * Shadow mode: the manifest is computed and emitted but the original provider | ||
| * packet is returned byte-for-byte unchanged. | ||
| * | ||
| * Standing invariants: | ||
| * - nowMs is ALWAYS a captured input — never Date.now() internally | ||
| * - No raw source text in manifest or ledger receipt — hashes and counts only | ||
| * - No confidence float, no outcome_delta — both permanently banned | ||
| * - Fault budget accumulates by taskId, not runId | ||
| * - required-over-budget is an explicit failure, never a silent drop | ||
| * - MAX_RENDER_PASSES = 5 — hard constant | ||
| */ | ||
| import { createHash } from "node:crypto"; | ||
| import { CONTEXT_LEDGER_VERSION, CONTEXT_MANIFEST_VERSION } from "../contracts/index.js"; | ||
| // ─── Constants ──────────────────────────────────────────────────────────────── | ||
| export const MAX_RENDER_PASSES = 5; | ||
| /** Fallback adapter: char÷4 heuristic, no external calls. */ | ||
| export const HEURISTIC_ADAPTER = { | ||
| version: "heuristic@1", | ||
| recountTokens: (text) => text.length === 0 ? 0 : Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4)) | ||
| }; | ||
| // ─── Helpers ────────────────────────────────────────────────────────────────── | ||
| function sha256(value) { | ||
| return createHash("sha256").update(value, "utf8").digest("hex"); | ||
| } | ||
| function canonicalize(value) { | ||
| if (Array.isArray(value)) | ||
| return value.map(canonicalize); | ||
| if (value !== null && typeof value === "object") { | ||
| return Object.fromEntries(Object.entries(value) | ||
| .sort(([a], [b]) => a.localeCompare(b)) | ||
| .map(([k, v]) => [k, canonicalize(v)])); | ||
| } | ||
| return value; | ||
| } | ||
| function canonicalJson(value) { | ||
| return JSON.stringify(canonicalize(value)); | ||
| } | ||
| /** Priority order for selection: required → pinned → high → normal → low */ | ||
| const PRIORITY_RANK = { | ||
| required: 0, | ||
| high: 2, | ||
| normal: 3, | ||
| low: 4 | ||
| }; | ||
| /** Pinned objects slot between required and high. */ | ||
| function selectionRank(obj) { | ||
| if (obj.priority === "required") | ||
| return 0; | ||
| if (obj.pinnedBy !== undefined) | ||
| return 1; | ||
| return PRIORITY_RANK[obj.priority]; | ||
| } | ||
| // ─── Compiler ──────────────────────────────────────────────────────────────── | ||
| export function compileContext(input) { | ||
| const { taskId, runId, nowMs, candidates, budget, policy, adapter } = input; | ||
| // ── Eligibility filter — policy-denied objects become governed exclusion records ── | ||
| // | ||
| // A-CTX-2 fix: denied objects must NOT silently disappear. They enter decisions[] | ||
| // as "excluded" with a reason code so the exclusion is visible as governed evidence. | ||
| // executableContext = admittedObjects only; ledgerDecisions = admitted + excluded. | ||
| const policyExcluded = []; | ||
| const eligibleList = []; | ||
| for (const obj of candidates) { | ||
| if (policy.deniedSensitivities.includes(obj.sensitivity)) { | ||
| policyExcluded.push({ | ||
| objectId: obj.id, | ||
| decision: "excluded", | ||
| reason: `sensitivity_denied:${obj.sensitivity}` | ||
| }); | ||
| } | ||
| else if (policy.deniedTrustLevels.includes(obj.trust)) { | ||
| policyExcluded.push({ | ||
| objectId: obj.id, | ||
| decision: "excluded", | ||
| reason: `trust_denied:${obj.trust}` | ||
| }); | ||
| } | ||
| else { | ||
| eligibleList.push(obj); | ||
| } | ||
| } | ||
| const eligible = eligibleList; | ||
| // ── Compute compilerInputHash ──────────────────────────────────────────── | ||
| const inputFingerprint = { | ||
| taskId, | ||
| runId, | ||
| nowMs, | ||
| adapterVersion: adapter.version, | ||
| policyHash: policy.policyHash, | ||
| budget: { | ||
| modelWindowTokens: budget.modelWindowTokens, | ||
| maxWorkingSetTokens: budget.maxWorkingSetTokens, | ||
| pinnedTokensMax: budget.pinnedTokensMax | ||
| }, | ||
| candidateIds: eligible.map((o) => o.id).sort() | ||
| }; | ||
| // ── Pinned token cap ───────────────────────────────────────────────────── | ||
| const pinnedTokensTotal = eligible | ||
| .filter((o) => o.pinnedBy !== undefined) | ||
| .reduce((s, o) => s + o.estimatedTokens, 0); | ||
| if (pinnedTokensTotal > budget.pinnedTokensMax) { | ||
| // Fail-closed: reject the entire batch, not individual pins | ||
| return { ok: false, reason: "required_over_budget", manifest: null, ledgerEntry: null }; | ||
| } | ||
| // ── Sort by selection rank, then objectId as stable tie-breaker ────────── | ||
| const sorted = [...eligible].sort((a, b) => { | ||
| const rankDiff = selectionRank(a) - selectionRank(b); | ||
| if (rankDiff !== 0) | ||
| return rankDiff; | ||
| // Stable tie-breaker: objectId lexicographic | ||
| return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; | ||
| }); | ||
| // ── Selection pass ─────────────────────────────────────────────────────── | ||
| let remaining = budget.maxWorkingSetTokens; | ||
| const decisions = []; | ||
| let requiredOverBudget = false; | ||
| // Required objects total token check first | ||
| const requiredTokens = sorted | ||
| .filter((o) => o.priority === "required") | ||
| .reduce((s, o) => s + o.estimatedTokens, 0); | ||
| if (requiredTokens > remaining) { | ||
| requiredOverBudget = true; | ||
| if (policy.requiredOverBudgetAction === "fail_closed") { | ||
| return { ok: false, reason: "required_over_budget", manifest: null, ledgerEntry: null }; | ||
| } | ||
| // explicit_escalation: record and continue — all required marked excluded | ||
| } | ||
| for (const obj of sorted) { | ||
| const isRequired = obj.priority === "required"; | ||
| if (requiredOverBudget && isRequired) { | ||
| decisions.push({ | ||
| objectId: obj.id, | ||
| decision: "excluded", | ||
| reason: "required_over_budget" | ||
| }); | ||
| continue; | ||
| } | ||
| if (obj.estimatedTokens <= remaining) { | ||
| decisions.push({ | ||
| objectId: obj.id, | ||
| decision: "included", | ||
| reason: isRequired ? "required" : obj.pinnedBy !== undefined ? "pinned" : "within_budget" | ||
| }); | ||
| remaining -= obj.estimatedTokens; | ||
| } | ||
| else { | ||
| // Attempt truncation for required/pinned, exclude otherwise | ||
| if (isRequired || obj.pinnedBy !== undefined) { | ||
| decisions.push({ | ||
| objectId: obj.id, | ||
| decision: "included_truncated", | ||
| truncatedFromTokens: obj.estimatedTokens, | ||
| reason: isRequired ? "required_truncated" : "pinned_truncated" | ||
| }); | ||
| // Truncated objects consume whatever is left | ||
| remaining = 0; | ||
| } | ||
| else { | ||
| decisions.push({ | ||
| objectId: obj.id, | ||
| decision: "excluded", | ||
| reason: "budget_exhausted" | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| // ── Adapter recount (up to MAX_RENDER_PASSES) ──────────────────────────── | ||
| const includedDecisions = decisions.filter((d) => d.decision === "included" || d.decision === "included_truncated"); | ||
| let totalRenderedTokens = 0; | ||
| let renderPasses = 0; | ||
| let recountConverged = false; | ||
| const includedObjects = new Map(eligible.map((o) => [o.id, o])); | ||
| for (let pass = 0; pass < MAX_RENDER_PASSES; pass++) { | ||
| renderPasses = pass + 1; | ||
| let passTotal = 0; | ||
| for (const d of includedDecisions) { | ||
| const obj = includedObjects.get(d.objectId); | ||
| if (!obj) | ||
| continue; | ||
| // In shadow mode we use the contentHash as a proxy text for recounting | ||
| const recounted = adapter.recountTokens(obj.contentHash); | ||
| d.renderedTokens = recounted; | ||
| passTotal += recounted; | ||
| } | ||
| if (passTotal === totalRenderedTokens && pass > 0) { | ||
| recountConverged = true; | ||
| break; | ||
| } | ||
| totalRenderedTokens = passTotal; | ||
| if (pass === 0) | ||
| recountConverged = false; | ||
| } | ||
| if (!recountConverged && renderPasses >= MAX_RENDER_PASSES) { | ||
| return { ok: false, reason: "recount_exceeded_passes", manifest: null, ledgerEntry: null }; | ||
| } | ||
| // ── Merge policy exclusions — excluded objects precede selection decisions ── | ||
| // | ||
| // A-CTX-2: executableContext = admittedObjects (decisions[]); | ||
| // ledgerDecisions = [...policyExcluded, ...decisions] | ||
| // Policy-excluded objects never enter the admitted working set, but their | ||
| // exclusion is visible as governed evidence in the manifest and ledger. | ||
| const allDecisions = [...policyExcluded, ...decisions]; | ||
| // ── Manifest hash ──────────────────────────────────────────────────────── | ||
| const manifestHashInput = canonicalJson({ | ||
| adapterVersion: adapter.version, | ||
| budget: inputFingerprint.budget, | ||
| decisions: allDecisions.map((d) => ({ | ||
| decision: d.decision, | ||
| objectId: d.objectId, | ||
| reason: d.reason, | ||
| ...(d.truncatedFromTokens !== undefined | ||
| ? { truncatedFromTokens: d.truncatedFromTokens } | ||
| : {}) | ||
| })), | ||
| nowMs, | ||
| policyHash: policy.policyHash, | ||
| runId, | ||
| taskId, | ||
| totalRenderedTokens | ||
| }); | ||
| const manifestHash = sha256(manifestHashInput); | ||
| const compilationDurationMs = 0; // In shadow mode: nowMs is frozen; duration is metadata-only | ||
| const manifest = { | ||
| schemaVersion: CONTEXT_MANIFEST_VERSION, | ||
| manifestHash, | ||
| taskId, | ||
| runId, | ||
| nowMs, | ||
| adapterVersion: adapter.version, | ||
| policyHash: policy.policyHash, | ||
| budget, | ||
| decisions: allDecisions, | ||
| totalRenderedTokens, | ||
| renderPasses, | ||
| compilationDurationMs | ||
| }; | ||
| const totalIncluded = allDecisions.filter((d) => d.decision === "included" || d.decision === "included_truncated").length; | ||
| const totalExcluded = allDecisions.filter((d) => d.decision === "excluded").length; | ||
| const totalFaultLoaded = allDecisions.filter((d) => d.decision === "fault_loaded").length; | ||
| const ledgerEntry = { | ||
| schemaVersion: CONTEXT_LEDGER_VERSION, | ||
| entryType: "context", | ||
| taskId, | ||
| runId, | ||
| manifestHash, | ||
| totalIncluded, | ||
| totalExcluded, | ||
| totalFaultLoaded, | ||
| overflowCount: requiredOverBudget ? 1 : 0, | ||
| faultBudgetUsed: 0, | ||
| adapterVersion: adapter.version | ||
| }; | ||
| return { ok: true, manifest, ledgerEntry }; | ||
| } |
| /** | ||
| * Context Handoff Verifier — A-CTX-2 | ||
| * | ||
| * Pure functions for verifying upstream-to-downstream context handoffs and | ||
| * deciding whether to circuit-break before the downstream agent executes. | ||
| * | ||
| * Standing rules: | ||
| * - These functions are pure and side-effect-free. | ||
| * - verifyContextHandoff fails closed — any ambiguity → not ok. | ||
| * - decideContextCircuitBreak is the sole gate before adapter execution. | ||
| * - The downstream adapter MUST NOT be called when shouldStop is true. | ||
| */ | ||
| import type { ContextCircuitBreakResult, ContextHandoffReceipt, ContextHandoffVerification } from "../contracts/index.js"; | ||
| export interface VerifyContextHandoffInput { | ||
| handoff: ContextHandoffReceipt; | ||
| /** true when the producer receipt file hash has been independently confirmed */ | ||
| producerReceiptVerified: boolean; | ||
| /** Map of sha256 → true for every artifact hash available to the verifier */ | ||
| availableArtifacts: ReadonlyMap<string, true>; | ||
| } | ||
| /** | ||
| * Deterministically verifies a context handoff. | ||
| * | ||
| * Returns ok=false when ANY of the following is true: | ||
| * - schemaVersion is not in SUPPORTED_SCHEMAS | ||
| * - producerReceiptVerified is false | ||
| * - upstreamIntegrity is not "verified" | ||
| * - a required artifact is absent from availableArtifacts | ||
| * - an artifact sha256 does not appear in availableArtifacts (hash changed) | ||
| * - any required claim has state "unverified" or "rejected" | ||
| * - any claim has state "unknown" | ||
| * - unresolvedAssumptions is non-empty | ||
| * - handoffId, chainId, producerRunId, or producerReceiptHash is blank | ||
| */ | ||
| export declare function verifyContextHandoff(input: VerifyContextHandoffInput): ContextHandoffVerification; | ||
| /** | ||
| * Converts a ContextHandoffVerification into a gate decision. | ||
| * | ||
| * Healthy: { shouldStop: false, silent: true } | ||
| * Broken: { shouldStop: true, silent: false, reasonCode, message, nextAction } | ||
| */ | ||
| export declare function decideContextCircuitBreak(verification: ContextHandoffVerification): ContextCircuitBreakResult; |
| /** | ||
| * Context Handoff Verifier — A-CTX-2 | ||
| * | ||
| * Pure functions for verifying upstream-to-downstream context handoffs and | ||
| * deciding whether to circuit-break before the downstream agent executes. | ||
| * | ||
| * Standing rules: | ||
| * - These functions are pure and side-effect-free. | ||
| * - verifyContextHandoff fails closed — any ambiguity → not ok. | ||
| * - decideContextCircuitBreak is the sole gate before adapter execution. | ||
| * - The downstream adapter MUST NOT be called when shouldStop is true. | ||
| */ | ||
| import { HANDOFF_SCHEMA_VERSION } from "../contracts/index.js"; | ||
| // ─── Supported schema versions ──────────────────────────────────────────────── | ||
| const SUPPORTED_SCHEMAS = new Set([HANDOFF_SCHEMA_VERSION]); | ||
| /** | ||
| * Deterministically verifies a context handoff. | ||
| * | ||
| * Returns ok=false when ANY of the following is true: | ||
| * - schemaVersion is not in SUPPORTED_SCHEMAS | ||
| * - producerReceiptVerified is false | ||
| * - upstreamIntegrity is not "verified" | ||
| * - a required artifact is absent from availableArtifacts | ||
| * - an artifact sha256 does not appear in availableArtifacts (hash changed) | ||
| * - any required claim has state "unverified" or "rejected" | ||
| * - any claim has state "unknown" | ||
| * - unresolvedAssumptions is non-empty | ||
| * - handoffId, chainId, producerRunId, or producerReceiptHash is blank | ||
| */ | ||
| export function verifyContextHandoff(input) { | ||
| const { handoff, producerReceiptVerified, availableArtifacts } = input; | ||
| const reasons = []; | ||
| // ── Schema ────────────────────────────────────────────────────────────────── | ||
| if (!SUPPORTED_SCHEMAS.has(handoff.schemaVersion)) { | ||
| return { | ||
| ok: false, | ||
| integrity: "unsupported_schema", | ||
| reasons: [ | ||
| { | ||
| code: "unsupported_schema", | ||
| message: `Handoff schemaVersion "${handoff.schemaVersion}" is not supported. Supported: ${[...SUPPORTED_SCHEMAS].join(", ")}.` | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| // ── Required identity fields ───────────────────────────────────────────────── | ||
| if (!isPresent(handoff.handoffId)) { | ||
| reasons.push({ code: "missing_handoff_id", message: "handoffId is required." }); | ||
| } | ||
| if (!isPresent(handoff.chainId)) { | ||
| reasons.push({ code: "missing_chain_id", message: "chainId is required." }); | ||
| } | ||
| if (!isPresent(handoff.producerRunId)) { | ||
| reasons.push({ code: "missing_producer_run_id", message: "producerRunId is required." }); | ||
| } | ||
| if (!isPresent(handoff.producerReceiptHash)) { | ||
| reasons.push({ code: "missing_producer_receipt_hash", message: "producerReceiptHash is required." }); | ||
| } | ||
| // ── Producer receipt verification ──────────────────────────────────────────── | ||
| if (!producerReceiptVerified) { | ||
| reasons.push({ | ||
| code: "producer_receipt_unverified", | ||
| message: "The producer receipt hash could not be independently verified." | ||
| }); | ||
| } | ||
| // ── Upstream integrity propagation ─────────────────────────────────────────── | ||
| if (handoff.upstreamIntegrity !== "verified") { | ||
| reasons.push({ | ||
| code: `upstream_integrity_${handoff.upstreamIntegrity}`, | ||
| message: `Upstream integrity is "${handoff.upstreamIntegrity}" — cannot proceed.` | ||
| }); | ||
| } | ||
| // ── Artifact verification ──────────────────────────────────────────────────── | ||
| for (const artifact of handoff.artifacts) { | ||
| const present = availableArtifacts.has(artifact.sha256); | ||
| if (artifact.required && !present) { | ||
| reasons.push({ | ||
| code: "missing_required_artifact", | ||
| message: `Required artifact is absent (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`, | ||
| artifactSha256: artifact.sha256 | ||
| }); | ||
| } | ||
| else if (!present) { | ||
| reasons.push({ | ||
| code: "artifact_hash_mismatch", | ||
| message: `Artifact hash not found in available set (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`, | ||
| artifactSha256: artifact.sha256 | ||
| }); | ||
| } | ||
| } | ||
| // ── Claim verification ──────────────────────────────────────────────────────── | ||
| for (const claim of handoff.claims) { | ||
| if (claim.verificationState === "rejected") { | ||
| reasons.push({ | ||
| code: "claim_rejected", | ||
| message: `Claim "${claim.claimId}" was rejected: ${claim.statement}`, | ||
| claimId: claim.claimId | ||
| }); | ||
| } | ||
| else if (claim.verificationState === "unverified") { | ||
| reasons.push({ | ||
| code: "claim_unverified", | ||
| message: `Claim "${claim.claimId}" is unverified: ${claim.statement}`, | ||
| claimId: claim.claimId | ||
| }); | ||
| } | ||
| else if (claim.verificationState === "unknown") { | ||
| reasons.push({ | ||
| code: "claim_unknown", | ||
| message: `Claim "${claim.claimId}" has unknown verification state: ${claim.statement}`, | ||
| claimId: claim.claimId | ||
| }); | ||
| } | ||
| } | ||
| // ── Unresolved assumptions ──────────────────────────────────────────────────── | ||
| if (handoff.unresolvedAssumptions.length > 0) { | ||
| reasons.push({ | ||
| code: "unresolved_assumptions", | ||
| message: `${handoff.unresolvedAssumptions.length} assumption(s) remain unresolved: ${handoff.unresolvedAssumptions.slice(0, 3).join("; ")}${handoff.unresolvedAssumptions.length > 3 ? " …" : ""}` | ||
| }); | ||
| } | ||
| const ok = reasons.length === 0; | ||
| const integrity = ok ? "verified" : deriveIntegrity(handoff, reasons); | ||
| return { ok, integrity, reasons }; | ||
| } | ||
| // ─── Circuit breaker ────────────────────────────────────────────────────────── | ||
| /** | ||
| * Converts a ContextHandoffVerification into a gate decision. | ||
| * | ||
| * Healthy: { shouldStop: false, silent: true } | ||
| * Broken: { shouldStop: true, silent: false, reasonCode, message, nextAction } | ||
| */ | ||
| export function decideContextCircuitBreak(verification) { | ||
| if (verification.ok) { | ||
| return { | ||
| shouldStop: false, | ||
| silent: true, | ||
| verification | ||
| }; | ||
| } | ||
| const first = verification.reasons[0]; | ||
| return { | ||
| shouldStop: true, | ||
| silent: false, | ||
| verification, | ||
| reasonCode: first?.code ?? "handoff_verification_failed", | ||
| message: first?.message ?? "Context handoff verification failed.", | ||
| nextAction: "Repair or replace the upstream handoff receipt, then retry." | ||
| }; | ||
| } | ||
| // ─── Helpers ────────────────────────────────────────────────────────────────── | ||
| function isPresent(value) { | ||
| return typeof value === "string" && value.trim().length > 0; | ||
| } | ||
| function deriveIntegrity(handoff, reasons) { | ||
| const codes = reasons.map((r) => r.code); | ||
| if (codes.includes("unsupported_schema")) | ||
| return "unsupported_schema"; | ||
| if (codes.some((c) => c === "upstream_integrity_tamper_detected" || | ||
| c === "artifact_hash_mismatch")) { | ||
| return "tamper_detected"; | ||
| } | ||
| if (codes.includes("producer_receipt_unverified") || codes.includes("upstream_integrity_evidence_boundary")) { | ||
| return "evidence_boundary"; | ||
| } | ||
| return "incomplete"; | ||
| } |
| /** | ||
| * Context Shadow Compiler — A-CTX-0 | ||
| * | ||
| * Deterministic shadow manifest emitter. Observes the compiled prompt and | ||
| * records what a context-aware selector would have chosen, without altering | ||
| * the actual provider input in any way. | ||
| * | ||
| * Invariants: | ||
| * - nowMs is always a captured input; Date.now() is never called internally | ||
| * - No source text appears in any output type | ||
| * - Required segments are always preferred first | ||
| * - required-over-budget is recorded, never thrown | ||
| * - Manifest hash covers metadata only (no content) | ||
| */ | ||
| import type { ContextC5EnvelopeV1, ContextEvidence, ContextShadowManifestV1 } from "../contracts/index.js"; | ||
| export interface ContextShadowSegment { | ||
| segmentId: string; | ||
| kind: string; | ||
| required: boolean; | ||
| text: string; | ||
| } | ||
| export interface CompileContextShadowInput { | ||
| runId: string; | ||
| taskId?: string; | ||
| adapter: string; | ||
| model?: string; | ||
| /** Milliseconds since epoch — must be explicit. Never call Date.now() here. */ | ||
| nowMs: number; | ||
| shadowBudgetTokens: number; | ||
| modelWindowTokens?: number; | ||
| modelWindowEvidence?: ContextEvidence; | ||
| segments: readonly ContextShadowSegment[]; | ||
| } | ||
| export interface CompileContextShadowResult { | ||
| manifest: ContextShadowManifestV1; | ||
| receipt: ContextC5EnvelopeV1; | ||
| } | ||
| export declare function estimateContextTokens(text: string): number; | ||
| export declare function compileContextShadow(input: CompileContextShadowInput): CompileContextShadowResult; |
| /** | ||
| * Context Shadow Compiler — A-CTX-0 | ||
| * | ||
| * Deterministic shadow manifest emitter. Observes the compiled prompt and | ||
| * records what a context-aware selector would have chosen, without altering | ||
| * the actual provider input in any way. | ||
| * | ||
| * Invariants: | ||
| * - nowMs is always a captured input; Date.now() is never called internally | ||
| * - No source text appears in any output type | ||
| * - Required segments are always preferred first | ||
| * - required-over-budget is recorded, never thrown | ||
| * - Manifest hash covers metadata only (no content) | ||
| */ | ||
| import { createHash } from "node:crypto"; | ||
| import { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "../contracts/index.js"; | ||
| // ─── Helpers ───────────────────────────────────────────────────────────────── | ||
| function sha256(value) { | ||
| return createHash("sha256").update(value, "utf8").digest("hex"); | ||
| } | ||
| function canonicalize(value) { | ||
| if (Array.isArray(value)) { | ||
| return value.map(canonicalize); | ||
| } | ||
| if (value !== null && typeof value === "object") { | ||
| return Object.fromEntries(Object.entries(value) | ||
| .sort(([a], [b]) => a.localeCompare(b)) | ||
| .map(([k, v]) => [k, canonicalize(v)])); | ||
| } | ||
| return value; | ||
| } | ||
| function canonicalJson(value) { | ||
| return JSON.stringify(canonicalize(value)); | ||
| } | ||
| export function estimateContextTokens(text) { | ||
| if (text.length === 0) | ||
| return 0; | ||
| return Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4)); | ||
| } | ||
| // ─── Compiler ──────────────────────────────────────────────────────────────── | ||
| export function compileContextShadow(input) { | ||
| const { runId, adapter, nowMs, shadowBudgetTokens, segments } = input; | ||
| // Validate: all segmentIds must be non-empty and unique | ||
| const seen = new Set(); | ||
| for (const seg of segments) { | ||
| if (!seg.segmentId || seg.segmentId.trim() === "") { | ||
| throw new Error("compileContextShadow: segmentId must be non-empty"); | ||
| } | ||
| if (seen.has(seg.segmentId)) { | ||
| throw new Error(`compileContextShadow: duplicate segmentId "${seg.segmentId}"`); | ||
| } | ||
| seen.add(seg.segmentId); | ||
| } | ||
| // Hash each segment's content — never expose the text itself | ||
| const hashed = segments.map((seg) => ({ | ||
| segmentId: seg.segmentId, | ||
| kind: seg.kind, | ||
| required: seg.required, | ||
| contentHash: sha256(seg.text), | ||
| estimatedTokens: estimateContextTokens(seg.text) | ||
| })); | ||
| // Compute actualPromptHash from all segments in original order | ||
| const actualPromptHash = sha256(canonicalJson(hashed.map((h) => ({ segmentId: h.segmentId, contentHash: h.contentHash })))); | ||
| const actualEstimatedTokens = hashed.reduce((sum, h) => sum + h.estimatedTokens, 0); | ||
| // Compute compilerInputHash — includes nowMs for cross-run determinism | ||
| const compilerInputHash = sha256(canonicalJson({ | ||
| adapter, | ||
| nowMs, | ||
| segments: hashed.map((h) => ({ | ||
| segmentId: h.segmentId, | ||
| kind: h.kind, | ||
| required: h.required, | ||
| contentHash: h.contentHash, | ||
| estimatedTokens: h.estimatedTokens | ||
| })), | ||
| shadowBudgetTokens, | ||
| ...(input.taskId === undefined ? {} : { taskId: input.taskId }), | ||
| ...(input.model === undefined ? {} : { model: input.model }) | ||
| })); | ||
| // Shadow selection: required first (in original order), then optional | ||
| const required = hashed.filter((h) => h.required); | ||
| const optional = hashed.filter((h) => !h.required); | ||
| let remaining = shadowBudgetTokens; | ||
| const decisions = []; | ||
| // Required segments — always included even if over budget | ||
| let requiredOverBudget = false; | ||
| const requiredTokens = required.reduce((s, h) => s + h.estimatedTokens, 0); | ||
| if (requiredTokens > shadowBudgetTokens) { | ||
| requiredOverBudget = true; | ||
| } | ||
| for (const h of required) { | ||
| const included = !requiredOverBudget; | ||
| decisions.push({ | ||
| segmentId: h.segmentId, | ||
| kind: h.kind, | ||
| contentHash: h.contentHash, | ||
| estimatedTokens: h.estimatedTokens, | ||
| actuallyIncluded: true, | ||
| proposedDecision: included ? "included" : "excluded", | ||
| reason: requiredOverBudget ? "required_over_budget" : "required" | ||
| }); | ||
| if (!requiredOverBudget) { | ||
| remaining -= h.estimatedTokens; | ||
| } | ||
| } | ||
| // Optional segments — include while budget remains | ||
| for (const h of optional) { | ||
| if (h.estimatedTokens <= remaining) { | ||
| decisions.push({ | ||
| segmentId: h.segmentId, | ||
| kind: h.kind, | ||
| contentHash: h.contentHash, | ||
| estimatedTokens: h.estimatedTokens, | ||
| actuallyIncluded: true, | ||
| proposedDecision: "included", | ||
| reason: "within_shadow_budget" | ||
| }); | ||
| remaining -= h.estimatedTokens; | ||
| } | ||
| else { | ||
| decisions.push({ | ||
| segmentId: h.segmentId, | ||
| kind: h.kind, | ||
| contentHash: h.contentHash, | ||
| estimatedTokens: h.estimatedTokens, | ||
| actuallyIncluded: true, | ||
| proposedDecision: "excluded", | ||
| reason: "shadow_budget_exhausted" | ||
| }); | ||
| } | ||
| } | ||
| // Restore original segment order in decisions list | ||
| const orderMap = new Map(segments.map((s, i) => [s.segmentId, i])); | ||
| decisions.sort((a, b) => (orderMap.get(a.segmentId) ?? 0) - (orderMap.get(b.segmentId) ?? 0)); | ||
| const proposedEstimatedTokens = decisions | ||
| .filter((d) => d.proposedDecision === "included") | ||
| .reduce((s, d) => s + d.estimatedTokens, 0); | ||
| const capturedAt = new Date(nowMs).toISOString(); | ||
| const manifestId = sha256(`${runId}:${compilerInputHash}:${capturedAt}`); | ||
| // Manifest hash covers metadata only — no content | ||
| const manifestHashInput = canonicalJson({ | ||
| adapter, | ||
| capturedAt, | ||
| compilerInputHash, | ||
| manifestId, | ||
| mode: "shadow", | ||
| modelWindowEvidence: input.modelWindowEvidence ?? "unknown", | ||
| modelWindowTokens: input.modelWindowTokens ?? null, | ||
| requiredOverBudget, | ||
| runId, | ||
| shadowBudgetTokens, | ||
| ...(input.taskId === undefined ? {} : { taskId: input.taskId }), | ||
| ...(input.model === undefined ? {} : { model: input.model }) | ||
| }); | ||
| const manifestHash = sha256(manifestHashInput); | ||
| const includedCount = decisions.filter((d) => d.proposedDecision === "included").length; | ||
| const excludedCount = decisions.filter((d) => d.proposedDecision === "excluded").length; | ||
| const manifest = { | ||
| schemaVersion: CONTEXT_SHADOW_MANIFEST_VERSION, | ||
| manifestId, | ||
| manifestHash, | ||
| compilerInputHash, | ||
| runId, | ||
| adapter, | ||
| capturedAt, | ||
| mode: "shadow", | ||
| modelWindowTokens: input.modelWindowTokens ?? null, | ||
| modelWindowEvidence: input.modelWindowEvidence ?? "unknown", | ||
| shadowBudgetTokens, | ||
| actualPromptHash, | ||
| actualEstimatedTokens, | ||
| proposedEstimatedTokens, | ||
| requiredOverBudget, | ||
| decisions, | ||
| ...(input.taskId === undefined ? {} : { taskId: input.taskId }), | ||
| ...(input.model === undefined ? {} : { model: input.model }) | ||
| }; | ||
| const receipt = { | ||
| schemaVersion: CONTEXT_C5_VERSION, | ||
| resource: "context", | ||
| event: "shadow_compiled", | ||
| runId, | ||
| manifestId, | ||
| manifestHash, | ||
| mode: "shadow", | ||
| adapter, | ||
| actualEstimatedTokens, | ||
| proposedEstimatedTokens, | ||
| candidateCount: decisions.length, | ||
| includedCount, | ||
| excludedCount, | ||
| requiredOverBudget, | ||
| modelWindowTokens: input.modelWindowTokens ?? null, | ||
| modelWindowEvidence: input.modelWindowEvidence ?? "unknown", | ||
| createdAt: capturedAt, | ||
| ...(input.taskId === undefined ? {} : { taskId: input.taskId }), | ||
| ...(input.model === undefined ? {} : { model: input.model }) | ||
| }; | ||
| return { manifest, receipt }; | ||
| } |
| export { fetchSelectedMessage } from "./message-client.js"; | ||
| export type { MessageClientOptions, MessageSelectRequest } from "./message-client.js"; | ||
| export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js"; | ||
| export { parseMessageSelectionResponse } from "./message-schema.js"; | ||
| export type { ParseFailure, ParseResult, ValidationError } from "./message-schema.js"; | ||
| export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js"; |
| export { fetchSelectedMessage } from "./message-client.js"; | ||
| export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js"; | ||
| export { parseMessageSelectionResponse } from "./message-schema.js"; | ||
| export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js"; |
| import { type DeliveryMessage } from "../../contracts/index.js"; | ||
| export interface MessageSelectRequest { | ||
| installId?: string; | ||
| clientVersion: string; | ||
| clientKind: "cli" | "mcp"; | ||
| /** Enum signal — never a transcript or raw content */ | ||
| trigger?: "first_verified_run" | "milestone_reached" | "version_check"; | ||
| } | ||
| export interface MessageClientOptions { | ||
| endpoint?: string; | ||
| timeoutMs?: number; | ||
| } | ||
| /** | ||
| * Fetch a server-selected message from the Control Plane. | ||
| * Returns null on any network failure, timeout, or validation error. | ||
| * Never throws — the primary command must never be affected. | ||
| */ | ||
| export declare function fetchSelectedMessage(request: MessageSelectRequest, options?: MessageClientOptions): Promise<DeliveryMessage | null>; |
| import { parseMessageSelectionResponse } from "./message-schema.js"; | ||
| const DEFAULT_ENDPOINT = "https://api.martinloop.com/v1/messages/select"; | ||
| const REQUEST_TIMEOUT_MS = 4_000; | ||
| /** | ||
| * Fetch a server-selected message from the Control Plane. | ||
| * Returns null on any network failure, timeout, or validation error. | ||
| * Never throws — the primary command must never be affected. | ||
| */ | ||
| export async function fetchSelectedMessage(request, options = {}) { | ||
| const endpoint = options.endpoint ?? process.env["MARTIN_MESSAGE_ENDPOINT"] ?? DEFAULT_ENDPOINT; | ||
| const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; | ||
| let controller; | ||
| let timeoutId; | ||
| try { | ||
| controller = new AbortController(); | ||
| timeoutId = setTimeout(() => controller.abort(), timeoutMs); | ||
| const response = await fetch(endpoint, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json", "Accept": "application/json" }, | ||
| body: JSON.stringify(request), | ||
| signal: controller.signal, | ||
| }); | ||
| if (!response.ok) | ||
| return null; | ||
| const raw = await response.text(); | ||
| const result = parseMessageSelectionResponse(raw); | ||
| if (!result.ok) | ||
| return null; | ||
| return result.message ?? null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| finally { | ||
| if (timeoutId !== undefined) | ||
| clearTimeout(timeoutId); | ||
| } | ||
| } |
| import { type DeliveryMessage, type DeliveryRecord } from "../../contracts/index.js"; | ||
| /** | ||
| * Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws. | ||
| */ | ||
| export declare function loadDeliveryRecord(ledgerPath: string): DeliveryRecord; | ||
| /** | ||
| * Persist DeliveryRecord atomically via temp-file + rename. | ||
| */ | ||
| export declare function saveDeliveryRecord(ledgerPath: string, record: DeliveryRecord): void; | ||
| /** | ||
| * Returns true when the cooldown period has elapsed and the message should be shown. | ||
| * Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired. | ||
| */ | ||
| export declare function isCooldownExpired(record: DeliveryRecord, nowMs: number): boolean; | ||
| /** | ||
| * Returns true if this message id was permanently dismissed. | ||
| */ | ||
| export declare function isDismissed(record: DeliveryRecord, messageId: string): boolean; | ||
| /** | ||
| * Record that a message was shown. Updates cooldown window. | ||
| */ | ||
| export declare function recordShown(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord; | ||
| /** | ||
| * Record that a message was permanently dismissed. | ||
| */ | ||
| export declare function recordDismissed(record: DeliveryRecord, messageId: string): DeliveryRecord; | ||
| /** | ||
| * Cache a fetched message for offline use. | ||
| */ | ||
| export declare function cacheMessage(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord; | ||
| /** | ||
| * Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json | ||
| * or ~/.martin/delivery-record.json. | ||
| */ | ||
| export declare function resolveDefaultLedgerPath(): string; |
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { DELIVERY_RECORD_SCHEMA_VERSION } from "../../contracts/index.js"; | ||
| const EMPTY_RECORD = { | ||
| schemaVersion: DELIVERY_RECORD_SCHEMA_VERSION, | ||
| dismissedIds: [], | ||
| }; | ||
| /** | ||
| * Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws. | ||
| */ | ||
| export function loadDeliveryRecord(ledgerPath) { | ||
| try { | ||
| const raw = fs.readFileSync(ledgerPath, "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (typeof parsed === "object" && | ||
| parsed !== null && | ||
| !Array.isArray(parsed) && | ||
| parsed["schemaVersion"] === DELIVERY_RECORD_SCHEMA_VERSION) { | ||
| return parsed; | ||
| } | ||
| } | ||
| catch { | ||
| // file missing or corrupt — start fresh | ||
| } | ||
| return { ...EMPTY_RECORD, dismissedIds: [] }; | ||
| } | ||
| /** | ||
| * Persist DeliveryRecord atomically via temp-file + rename. | ||
| */ | ||
| export function saveDeliveryRecord(ledgerPath, record) { | ||
| const dir = path.dirname(ledgerPath); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const tmp = path.join(os.tmpdir(), `martin-dlv-${process.pid}-${Date.now()}.json`); | ||
| fs.writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8"); | ||
| fs.renameSync(tmp, ledgerPath); | ||
| } | ||
| /** | ||
| * Returns true when the cooldown period has elapsed and the message should be shown. | ||
| * Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired. | ||
| */ | ||
| export function isCooldownExpired(record, nowMs) { | ||
| const until = record.cooldownUntilEpochMs; | ||
| if (until === undefined) | ||
| return true; | ||
| const last = record.lastShownAtEpochMs ?? 0; | ||
| // If clock appears to have gone backwards past last-shown, treat as expired | ||
| if (nowMs < last) | ||
| return true; | ||
| return nowMs >= until; | ||
| } | ||
| /** | ||
| * Returns true if this message id was permanently dismissed. | ||
| */ | ||
| export function isDismissed(record, messageId) { | ||
| return record.dismissedIds.includes(messageId); | ||
| } | ||
| /** | ||
| * Record that a message was shown. Updates cooldown window. | ||
| */ | ||
| export function recordShown(record, message, nowMs) { | ||
| return { | ||
| ...record, | ||
| lastMessageId: message.id, | ||
| lastShownAtEpochMs: nowMs, | ||
| cooldownUntilEpochMs: nowMs + message.cooldownHours * 60 * 60 * 1000, | ||
| }; | ||
| } | ||
| /** | ||
| * Record that a message was permanently dismissed. | ||
| */ | ||
| export function recordDismissed(record, messageId) { | ||
| if (record.dismissedIds.includes(messageId)) | ||
| return record; | ||
| return { | ||
| ...record, | ||
| dismissedIds: [...record.dismissedIds, messageId], | ||
| }; | ||
| } | ||
| /** | ||
| * Cache a fetched message for offline use. | ||
| */ | ||
| export function cacheMessage(record, message, nowMs) { | ||
| return { | ||
| ...record, | ||
| cachedMessage: message, | ||
| cachedAtEpochMs: nowMs, | ||
| }; | ||
| } | ||
| /** | ||
| * Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json | ||
| * or ~/.martin/delivery-record.json. | ||
| */ | ||
| export function resolveDefaultLedgerPath() { | ||
| const stateDir = process.env["MARTIN_STATE_DIR"] ?? path.join(os.homedir(), ".martin"); | ||
| return path.join(stateDir, "delivery-record.json"); | ||
| } |
| import { type DeliveryMessage } from "../../contracts/index.js"; | ||
| export type ValidationError = "schema_version_unknown" | "schema_unknown_kind" | "schema_unknown_action_type" | "schema_invalid_id" | "schema_invalid_date" | "schema_invalid_cooldown" | "schema_url_not_https" | "schema_text_too_long" | "schema_response_too_large" | "schema_malformed"; | ||
| export interface ParseResult { | ||
| ok: true; | ||
| message: DeliveryMessage | undefined; | ||
| } | ||
| export interface ParseFailure { | ||
| ok: false; | ||
| error: ValidationError; | ||
| detail?: string; | ||
| } | ||
| /** | ||
| * Parse and validate a raw response body from the message selection endpoint. | ||
| * Returns ok:false on any violation — caller must render nothing and log the error. | ||
| */ | ||
| export declare function parseMessageSelectionResponse(raw: string): ParseResult | ParseFailure; |
| import { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION, } from "../../contracts/index.js"; | ||
| const MAX_RESPONSE_BYTES = 8_192; | ||
| const MAX_TITLE_LENGTH = 120; | ||
| const MAX_BODY_LENGTH = 500; | ||
| const MAX_ID_LENGTH = 128; | ||
| const ALLOWED_ACTION_SET = new Set(ALLOWED_ACTION_TYPES); | ||
| /** | ||
| * Parse and validate a raw response body from the message selection endpoint. | ||
| * Returns ok:false on any violation — caller must render nothing and log the error. | ||
| */ | ||
| export function parseMessageSelectionResponse(raw) { | ||
| if (Buffer.byteLength(raw, "utf8") > MAX_RESPONSE_BYTES) { | ||
| return { ok: false, error: "schema_response_too_large" }; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } | ||
| catch { | ||
| return { ok: false, error: "schema_malformed" }; | ||
| } | ||
| if (!isObject(parsed)) { | ||
| return { ok: false, error: "schema_malformed" }; | ||
| } | ||
| if (parsed["schemaVersion"] !== MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION) { | ||
| return { ok: false, error: "schema_version_unknown", detail: String(parsed["schemaVersion"]) }; | ||
| } | ||
| if (!("message" in parsed) || parsed["message"] === undefined || parsed["message"] === null) { | ||
| return { ok: true, message: undefined }; | ||
| } | ||
| const result = validateMessage(parsed["message"]); | ||
| if (!result.ok) | ||
| return result; | ||
| return { ok: true, message: result.message }; | ||
| } | ||
| function validateMessage(raw) { | ||
| if (!isObject(raw)) { | ||
| return { ok: false, error: "schema_malformed" }; | ||
| } | ||
| if (raw["schemaVersion"] !== DELIVERY_MESSAGE_SCHEMA_VERSION) { | ||
| return { ok: false, error: "schema_version_unknown", detail: String(raw["schemaVersion"]) }; | ||
| } | ||
| const id = raw["id"]; | ||
| if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID_LENGTH || !/^[\w\-.:]+$/.test(id)) { | ||
| return { ok: false, error: "schema_invalid_id" }; | ||
| } | ||
| const kind = raw["kind"]; | ||
| if (kind !== "update" && kind !== "feedback_request" && kind !== "milestone") { | ||
| return { ok: false, error: "schema_unknown_kind", detail: String(kind) }; | ||
| } | ||
| const revision = raw["revision"]; | ||
| if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) { | ||
| return { ok: false, error: "schema_malformed", detail: "revision" }; | ||
| } | ||
| const title = raw["title"]; | ||
| if (typeof title !== "string" || title.length === 0 || title.length > MAX_TITLE_LENGTH) { | ||
| return { ok: false, error: "schema_text_too_long", detail: "title" }; | ||
| } | ||
| const body = raw["body"]; | ||
| if (typeof body !== "string" || body.length === 0 || body.length > MAX_BODY_LENGTH) { | ||
| return { ok: false, error: "schema_text_too_long", detail: "body" }; | ||
| } | ||
| const action = raw["action"]; | ||
| if (!isObject(action)) { | ||
| return { ok: false, error: "schema_malformed", detail: "action" }; | ||
| } | ||
| const actionType = action["type"]; | ||
| if (typeof actionType !== "string" || !ALLOWED_ACTION_SET.has(actionType)) { | ||
| return { ok: false, error: "schema_unknown_action_type", detail: String(actionType) }; | ||
| } | ||
| if ("url" in action && action["url"] !== undefined) { | ||
| if (typeof action["url"] !== "string" || !action["url"].startsWith("https://")) { | ||
| return { ok: false, error: "schema_url_not_https" }; | ||
| } | ||
| } | ||
| if ("targetVersion" in action && action["targetVersion"] !== undefined) { | ||
| if (typeof action["targetVersion"] !== "string") { | ||
| return { ok: false, error: "schema_malformed", detail: "targetVersion" }; | ||
| } | ||
| } | ||
| const expiresAt = raw["expiresAt"]; | ||
| if (typeof expiresAt !== "string" || isNaN(Date.parse(expiresAt))) { | ||
| return { ok: false, error: "schema_invalid_date", detail: "expiresAt" }; | ||
| } | ||
| const cooldownHours = raw["cooldownHours"]; | ||
| if (typeof cooldownHours !== "number" || | ||
| !Number.isFinite(cooldownHours) || | ||
| cooldownHours < 0 || | ||
| cooldownHours > 8760) { | ||
| return { ok: false, error: "schema_invalid_cooldown" }; | ||
| } | ||
| const message = { | ||
| schemaVersion: DELIVERY_MESSAGE_SCHEMA_VERSION, | ||
| id, | ||
| revision, | ||
| kind, | ||
| title, | ||
| body, | ||
| action: { | ||
| type: actionType, | ||
| ...(action["url"] !== undefined ? { url: action["url"] } : {}), | ||
| ...(action["targetVersion"] !== undefined ? { targetVersion: action["targetVersion"] } : {}), | ||
| }, | ||
| expiresAt, | ||
| cooldownHours, | ||
| }; | ||
| return { ok: true, message }; | ||
| } | ||
| function isObject(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } |
| /** | ||
| * Returns the installed version of the CLI package (`martin-loop`). | ||
| * Call site: CLI only. Never call from MCP. | ||
| */ | ||
| export declare function getCliInstalledVersion(): string | null; | ||
| /** | ||
| * Returns the installed version of the MCP package (`@martinloop/mcp`). | ||
| * Call site: MCP only. Never call from CLI. | ||
| */ | ||
| export declare function getMcpInstalledVersion(): string | null; | ||
| /** | ||
| * Compare two semver strings. Returns true when `available` is strictly | ||
| * newer than `current`. | ||
| * | ||
| * Prereleases are never surfaced as updates unless the user's current | ||
| * version is itself a prerelease (semver prerelease protection). | ||
| * Build metadata (+build) does not affect precedence. | ||
| */ | ||
| export declare function isNewerVersion(current: string, available: string): boolean; |
| import { createRequire } from "node:module"; | ||
| // CLI and MCP version checks are intentionally independent — they use different | ||
| // package names and must never share a constant or resolve function. | ||
| /** | ||
| * Returns the installed version of the CLI package (`martin-loop`). | ||
| * Call site: CLI only. Never call from MCP. | ||
| */ | ||
| export function getCliInstalledVersion() { | ||
| return resolvePackageVersion("martin-loop"); | ||
| } | ||
| /** | ||
| * Returns the installed version of the MCP package (`@martinloop/mcp`). | ||
| * Call site: MCP only. Never call from CLI. | ||
| */ | ||
| export function getMcpInstalledVersion() { | ||
| return resolvePackageVersion("@martinloop/mcp"); | ||
| } | ||
| /** | ||
| * Compare two semver strings. Returns true when `available` is strictly | ||
| * newer than `current`. | ||
| * | ||
| * Prereleases are never surfaced as updates unless the user's current | ||
| * version is itself a prerelease (semver prerelease protection). | ||
| * Build metadata (+build) does not affect precedence. | ||
| */ | ||
| export function isNewerVersion(current, available) { | ||
| const currentVersion = parseSemver(current); | ||
| const availableVersion = parseSemver(available); | ||
| if (!currentVersion || !availableVersion) | ||
| return false; | ||
| // Stable users must never be offered a prerelease. | ||
| if (currentVersion.prerelease.length === 0 && | ||
| availableVersion.prerelease.length > 0) { | ||
| return false; | ||
| } | ||
| return compareSemver(availableVersion, currentVersion) > 0; | ||
| } | ||
| function resolvePackageVersion(name) { | ||
| try { | ||
| const require = createRequire(import.meta.url); | ||
| const pkg = require(`${name}/package.json`); | ||
| return typeof pkg.version === "string" ? pkg.version : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| function parseSemver(version) { | ||
| const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version); | ||
| if (!match) | ||
| return null; | ||
| const prerelease = []; | ||
| for (const identifier of match[4]?.split(".") ?? []) { | ||
| if (/^\d+$/.test(identifier)) { | ||
| // Leading zeros on numeric identifiers are invalid per SemVer spec. | ||
| if (identifier.length > 1 && identifier.startsWith("0")) | ||
| return null; | ||
| prerelease.push(Number(identifier)); | ||
| } | ||
| else { | ||
| prerelease.push(identifier); | ||
| } | ||
| } | ||
| return { | ||
| major: Number(match[1]), | ||
| minor: Number(match[2]), | ||
| patch: Number(match[3]), | ||
| prerelease, | ||
| }; | ||
| } | ||
| /** | ||
| * Returns a positive number if left > right, negative if left < right, 0 if equal. | ||
| * Implements SemVer 2.0.0 precedence rules exactly. | ||
| */ | ||
| function compareSemver(left, right) { | ||
| for (const key of ["major", "minor", "patch"]) { | ||
| if (left[key] !== right[key]) | ||
| return left[key] > right[key] ? 1 : -1; | ||
| } | ||
| // When base versions are equal: stable (no prerelease) > prerelease. | ||
| if (left.prerelease.length === 0 && right.prerelease.length === 0) | ||
| return 0; | ||
| if (left.prerelease.length === 0) | ||
| return 1; | ||
| if (right.prerelease.length === 0) | ||
| return -1; | ||
| // Both have prereleases — compare identifier by identifier. | ||
| const length = Math.max(left.prerelease.length, right.prerelease.length); | ||
| for (let index = 0; index < length; index++) { | ||
| const a = left.prerelease[index]; | ||
| const b = right.prerelease[index]; | ||
| if (a === undefined) | ||
| return -1; // fewer identifiers = lower precedence | ||
| if (b === undefined) | ||
| return 1; | ||
| if (a === b) | ||
| continue; | ||
| if (typeof a === "number" && typeof b === "number") | ||
| return a > b ? 1 : -1; | ||
| if (typeof a === "number") | ||
| return -1; // numeric < alphanumeric | ||
| if (typeof b === "number") | ||
| return 1; | ||
| return a > b ? 1 : -1; // both alphanumeric: lexicographic | ||
| } | ||
| return 0; | ||
| } |
| /** | ||
| * Durable exit signals — per-kind, exclusive-create, precedence-safe. | ||
| * | ||
| * Each signal kind occupies its own immutable slot: | ||
| * <runDir>/signals/human_interrupt.json | ||
| * <runDir>/signals/external_event.json | ||
| * | ||
| * Atomic publication (crash-safe): | ||
| * 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir | ||
| * 2. Sync and close the tmp file | ||
| * 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists) | ||
| * 4. Unlink tmp in finally; best-effort dir sync on POSIX | ||
| * | ||
| * The final slot is only visible after a fully written tmp — a crash cannot | ||
| * leave a permanently malformed final slot. | ||
| * | ||
| * Per-kind files let different kinds coexist — an external event filed first | ||
| * does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured. | ||
| * | ||
| * Real-path containment guards against symlink/junction traversal out of the | ||
| * run store. Diagnostics from malformed signals are propagated — never silently | ||
| * discarded as "no signal". | ||
| */ | ||
| import type { ExitSignalV1 } from "../contracts/index.js"; | ||
| export interface ExitSignalSource { | ||
| poll(runId: string): Promise<SignalReadResult>; | ||
| } | ||
| export interface SignalReadResult { | ||
| signals: readonly ExitSignalV1[]; | ||
| diagnostics: readonly SignalDiagnostic[]; | ||
| } | ||
| export interface SignalDiagnostic { | ||
| kind: "human_interrupt" | "external_event"; | ||
| error: string; | ||
| } | ||
| /** | ||
| * Typed error surfaced by the monitor when diagnostics are detected and no | ||
| * onDiagnostic handler is registered. Contains safe diagnostic codes and | ||
| * signal kinds — no absolute paths or payload secrets. | ||
| */ | ||
| export declare class SignalDiagnosticError extends Error { | ||
| readonly diagnostics: readonly SignalDiagnostic[]; | ||
| constructor(diagnostics: readonly SignalDiagnostic[]); | ||
| } | ||
| /** Path to a per-kind signal file — safe against lexical traversal. */ | ||
| export declare function exitSignalPath(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): string; | ||
| /** | ||
| * Write one signal of its kind atomically. Returns "created" on success or | ||
| * "already_exists" when that kind was already filed (first writer wins). | ||
| * Throws on IO errors other than EEXIST on the final slot. | ||
| * | ||
| * Publication sequence (crash-safe): | ||
| * 1. Validate signal and size-check the payload | ||
| * 2. mkdir the signals directory | ||
| * 3. realpath-check: the real signals dir must stay inside the real root | ||
| * 4. Write complete JSON to a unique tmp file (wx, 0o600) | ||
| * 5. Sync and close the tmp file | ||
| * 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists) | ||
| * 7. Unlink tmp in finally; best-effort dir sync on POSIX | ||
| */ | ||
| export declare function writeExitSignal(runsRoot: string, signal: ExitSignalV1): Promise<"created" | "already_exists">; | ||
| /** | ||
| * Read all present signals for a run (both kinds). Returns a structured | ||
| * result with any parse diagnostics rather than throwing on malformed files. | ||
| */ | ||
| export declare function readAllExitSignals(runsRoot: string, runId: string): Promise<SignalReadResult>; | ||
| /** Read a single signal kind; returns undefined when absent. */ | ||
| export declare function readExitSignal(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): Promise<ExitSignalV1 | undefined>; | ||
| export declare function createFileExitSignalSource(runsRoot: string): ExitSignalSource; | ||
| /** | ||
| * Polls for any exit signal and calls onSignal with the full set when any | ||
| * new signal appears. Returns a dispose function — MUST be called on every | ||
| * return/throw path in the run harness (invariant: one interval per run). | ||
| * | ||
| * Diagnostics (malformed signals, containment failures) are routed to | ||
| * onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError | ||
| * through onError so callers always learn about corruption rather than | ||
| * silently treating it as "no signal". | ||
| */ | ||
| export declare function startExitSignalMonitor(input: { | ||
| source?: ExitSignalSource; | ||
| runId: string; | ||
| controller: AbortController; | ||
| pollIntervalMs?: number; | ||
| onSignal: (signals: readonly ExitSignalV1[]) => void; | ||
| onDiagnostic?: (diagnostics: readonly SignalDiagnostic[]) => void; | ||
| onError: (error: Error) => void; | ||
| }): () => void; |
| /** | ||
| * Durable exit signals — per-kind, exclusive-create, precedence-safe. | ||
| * | ||
| * Each signal kind occupies its own immutable slot: | ||
| * <runDir>/signals/human_interrupt.json | ||
| * <runDir>/signals/external_event.json | ||
| * | ||
| * Atomic publication (crash-safe): | ||
| * 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir | ||
| * 2. Sync and close the tmp file | ||
| * 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists) | ||
| * 4. Unlink tmp in finally; best-effort dir sync on POSIX | ||
| * | ||
| * The final slot is only visible after a fully written tmp — a crash cannot | ||
| * leave a permanently malformed final slot. | ||
| * | ||
| * Per-kind files let different kinds coexist — an external event filed first | ||
| * does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured. | ||
| * | ||
| * Real-path containment guards against symlink/junction traversal out of the | ||
| * run store. Diagnostics from malformed signals are propagated — never silently | ||
| * discarded as "no signal". | ||
| */ | ||
| import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises"; | ||
| import { basename, dirname, join, resolve } from "node:path"; | ||
| import { randomUUID } from "node:crypto"; | ||
| const MAX_SIGNAL_BYTES = 64 * 1024; | ||
| const RUN_ID_RE = /^[A-Za-z0-9._-]{1,128}$/u; | ||
| /** | ||
| * Typed error surfaced by the monitor when diagnostics are detected and no | ||
| * onDiagnostic handler is registered. Contains safe diagnostic codes and | ||
| * signal kinds — no absolute paths or payload secrets. | ||
| */ | ||
| export class SignalDiagnosticError extends Error { | ||
| diagnostics; | ||
| constructor(diagnostics) { | ||
| const summary = diagnostics | ||
| .map((d) => `${d.kind}: ${d.error}`) | ||
| .join("; "); | ||
| super(`Signal diagnostics: ${summary}`); | ||
| this.name = "SignalDiagnosticError"; | ||
| this.diagnostics = diagnostics; | ||
| } | ||
| } | ||
| /** Path to a per-kind signal file — safe against lexical traversal. */ | ||
| export function exitSignalPath(runsRoot, runId, kind) { | ||
| assertRunId(runId); | ||
| const root = resolve(runsRoot); | ||
| const runDir = resolve(root, runId); | ||
| const sep = process.platform === "win32" ? "\\" : "/"; | ||
| if (runDir !== root && !runDir.startsWith(`${root}${sep}`)) { | ||
| throw new Error("Resolved run path escapes runs root"); | ||
| } | ||
| const filename = kind === "human_interrupt" | ||
| ? "human_interrupt.json" | ||
| : "external_event.json"; | ||
| return join(runDir, "signals", filename); | ||
| } | ||
| /** | ||
| * Write one signal of its kind atomically. Returns "created" on success or | ||
| * "already_exists" when that kind was already filed (first writer wins). | ||
| * Throws on IO errors other than EEXIST on the final slot. | ||
| * | ||
| * Publication sequence (crash-safe): | ||
| * 1. Validate signal and size-check the payload | ||
| * 2. mkdir the signals directory | ||
| * 3. realpath-check: the real signals dir must stay inside the real root | ||
| * 4. Write complete JSON to a unique tmp file (wx, 0o600) | ||
| * 5. Sync and close the tmp file | ||
| * 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists) | ||
| * 7. Unlink tmp in finally; best-effort dir sync on POSIX | ||
| */ | ||
| export async function writeExitSignal(runsRoot, signal) { | ||
| validateExitSignal(signal); | ||
| const payload = `${JSON.stringify(signal)}\n`; | ||
| if (Buffer.byteLength(payload, "utf8") > MAX_SIGNAL_BYTES) { | ||
| throw new Error("Exit signal payload exceeds 64 KiB"); | ||
| } | ||
| const finalPath = exitSignalPath(runsRoot, signal.runId, signal.kind); | ||
| const sigDir = dirname(finalPath); | ||
| await mkdir(sigDir, { recursive: true }); | ||
| // Real-path containment: the real signals directory must stay inside the | ||
| // real run store root. This catches symlink/junction escapes that lexical | ||
| // resolve() cannot detect. | ||
| const realRoot = await realpath(resolve(runsRoot)); | ||
| const realSigDir = await realpath(sigDir); | ||
| if (!isContained(realRoot, realSigDir)) { | ||
| throw new Error("Signal directory escapes the run store"); | ||
| } | ||
| // Atomic publication via unique tmp file + hard-link. | ||
| // Both tmp and final are in the same directory, so they are on the same | ||
| // filesystem — hard-link is always available. | ||
| const tmpPath = join(sigDir, `.tmp-${randomUUID()}`); | ||
| let handle; | ||
| try { | ||
| handle = await open(tmpPath, "wx", 0o600); | ||
| await handle.writeFile(payload, "utf8"); | ||
| await handle.sync(); | ||
| await handle.close(); | ||
| handle = undefined; | ||
| } | ||
| catch (err) { | ||
| if (handle !== undefined) { | ||
| await handle.close().catch(() => undefined); | ||
| handle = undefined; | ||
| } | ||
| await unlink(tmpPath).catch(() => undefined); | ||
| throw err; | ||
| } | ||
| // Claim the final slot atomically — link() fails with EEXIST if slot is | ||
| // already occupied, preserving first-signal-wins without replacing the file. | ||
| let outcome; | ||
| try { | ||
| await link(tmpPath, finalPath); | ||
| outcome = "created"; | ||
| } | ||
| catch (err) { | ||
| if (err.code === "EEXIST") { | ||
| outcome = "already_exists"; | ||
| } | ||
| else { | ||
| throw err; | ||
| } | ||
| } | ||
| finally { | ||
| await unlink(tmpPath).catch(() => undefined); | ||
| // Best-effort directory sync so the new entry is durable on POSIX. | ||
| // Windows directories cannot be opened with O_RDONLY — skip. | ||
| if (process.platform !== "win32") { | ||
| const dh = await open(sigDir, "r").catch(() => undefined); | ||
| if (dh !== undefined) { | ||
| await dh.sync().catch(() => undefined); | ||
| await dh.close().catch(() => undefined); | ||
| } | ||
| } | ||
| } | ||
| return outcome; | ||
| } | ||
| /** | ||
| * Read all present signals for a run (both kinds). Returns a structured | ||
| * result with any parse diagnostics rather than throwing on malformed files. | ||
| */ | ||
| export async function readAllExitSignals(runsRoot, runId) { | ||
| const kinds = [ | ||
| "human_interrupt", | ||
| "external_event" | ||
| ]; | ||
| const signals = []; | ||
| const diagnostics = []; | ||
| for (const kind of kinds) { | ||
| const result = await readOneSignal(runsRoot, runId, kind); | ||
| if (result.signal !== undefined) | ||
| signals.push(result.signal); | ||
| if (result.diagnostic !== undefined) | ||
| diagnostics.push(result.diagnostic); | ||
| } | ||
| return { signals, diagnostics }; | ||
| } | ||
| /** Read a single signal kind; returns undefined when absent. */ | ||
| export async function readExitSignal(runsRoot, runId, kind) { | ||
| const { signal } = await readOneSignal(runsRoot, runId, kind); | ||
| return signal; | ||
| } | ||
| export function createFileExitSignalSource(runsRoot) { | ||
| return { | ||
| poll: async (runId) => readAllExitSignals(runsRoot, runId) | ||
| }; | ||
| } | ||
| /** | ||
| * Polls for any exit signal and calls onSignal with the full set when any | ||
| * new signal appears. Returns a dispose function — MUST be called on every | ||
| * return/throw path in the run harness (invariant: one interval per run). | ||
| * | ||
| * Diagnostics (malformed signals, containment failures) are routed to | ||
| * onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError | ||
| * through onError so callers always learn about corruption rather than | ||
| * silently treating it as "no signal". | ||
| */ | ||
| export function startExitSignalMonitor(input) { | ||
| if (input.source === undefined) | ||
| return () => undefined; | ||
| const intervalMs = input.pollIntervalMs ?? 250; | ||
| let polling = false; | ||
| let lastCount = 0; | ||
| const timer = setInterval(() => { | ||
| if (polling || input.controller.signal.aborted) | ||
| return; | ||
| polling = true; | ||
| void input.source | ||
| .poll(input.runId) | ||
| .then(({ signals, diagnostics }) => { | ||
| if (input.controller.signal.aborted) | ||
| return; | ||
| // Surface diagnostics without aborting the run | ||
| if (diagnostics.length > 0) { | ||
| if (input.onDiagnostic !== undefined) { | ||
| input.onDiagnostic(diagnostics); | ||
| } | ||
| else { | ||
| input.onError(new SignalDiagnosticError(diagnostics)); | ||
| } | ||
| } | ||
| // Only fire onSignal when new signals appear | ||
| if (signals.length > lastCount) { | ||
| lastCount = signals.length; | ||
| input.onSignal(signals); | ||
| input.controller.abort(signals); | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| const e = err instanceof Error ? err : new Error(String(err)); | ||
| input.onError(e); | ||
| input.controller.abort(e); | ||
| }) | ||
| .finally(() => { polling = false; }); | ||
| }, intervalMs); | ||
| timer.unref?.(); | ||
| return () => clearInterval(timer); | ||
| } | ||
| // ─── Internal helpers ──────────────────────────────────────────────────────── | ||
| async function readOneSignal(runsRoot, runId, kind) { | ||
| assertRunId(runId); | ||
| const finalPath = exitSignalPath(runsRoot, runId, kind); | ||
| const sigDir = dirname(finalPath); | ||
| // Real-path containment for reads: signals dir must stay inside the run store. | ||
| // ENOENT on the signals dir means no signals have been written — return absent. | ||
| let realSigDir; | ||
| try { | ||
| realSigDir = await realpath(sigDir); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return {}; | ||
| return { | ||
| diagnostic: { | ||
| kind, | ||
| error: `containment check failed [${err.code ?? "ERR"}]` | ||
| } | ||
| }; | ||
| } | ||
| const realRoot = await realpath(resolve(runsRoot)); | ||
| if (!isContained(realRoot, realSigDir)) { | ||
| return { diagnostic: { kind, error: "signal directory escapes the run store" } }; | ||
| } | ||
| // lstat: reject symlinks and surface size violations before reading. | ||
| // With atomic publication, the final file is always a complete hardlinked | ||
| // inode — a size=0 or a symlink indicates tampering. | ||
| let size; | ||
| try { | ||
| const lstats = await lstat(finalPath); | ||
| if (lstats.isSymbolicLink()) { | ||
| return { diagnostic: { kind, error: "signal file is a symbolic link" } }; | ||
| } | ||
| size = lstats.size; | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return {}; | ||
| return { | ||
| diagnostic: { | ||
| kind, | ||
| error: `stat error [${err.code ?? "ERR"}]` | ||
| } | ||
| }; | ||
| } | ||
| if (size > MAX_SIGNAL_BYTES) { | ||
| return { diagnostic: { kind, error: "signal file exceeds 64 KiB" } }; | ||
| } | ||
| let text; | ||
| try { | ||
| text = await readFile(finalPath, "utf8"); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return {}; | ||
| return { | ||
| diagnostic: { | ||
| kind, | ||
| error: `read error [${err.code ?? "ERR"}]` | ||
| } | ||
| }; | ||
| } | ||
| // With atomic publication the final file should always contain valid JSON. | ||
| // A parse failure indicates file corruption — surface as diagnostic. | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(text); | ||
| } | ||
| catch { | ||
| return { diagnostic: { kind, error: "signal file contains invalid JSON" } }; | ||
| } | ||
| try { | ||
| validateExitSignal(parsed); | ||
| return { signal: parsed }; | ||
| } | ||
| catch (err) { | ||
| return { diagnostic: { kind, error: String(err) } }; | ||
| } | ||
| } | ||
| function validateExitSignal(signal) { | ||
| if (signal.schemaVersion !== "exit-signal/1") { | ||
| throw new Error(`Unsupported exit signal schema: ${String(signal.schemaVersion)}`); | ||
| } | ||
| assertRunId(signal.runId); | ||
| if (signal.kind !== "human_interrupt" && signal.kind !== "external_event") { | ||
| throw new Error(`Invalid exit signal kind: ${String(signal.kind)}`); | ||
| } | ||
| if (!signal.requestedBy?.trim() || !Number.isFinite(Date.parse(signal.requestedAt))) { | ||
| throw new Error("Exit signal requester and timestamp are required"); | ||
| } | ||
| if (signal.kind === "external_event" && signal.externalEvent === undefined) { | ||
| throw new Error("external_event signal requires externalEvent evidence"); | ||
| } | ||
| if (signal.externalEvent !== undefined) { | ||
| const ev = signal.externalEvent; | ||
| if (!ev.source?.trim() || !ev.event?.trim()) { | ||
| throw new Error("External event source and event are required"); | ||
| } | ||
| if (!["satisfied", "superseded", "cancelled"].includes(ev.disposition)) { | ||
| throw new Error(`Invalid external event disposition: ${String(ev.disposition)}`); | ||
| } | ||
| if (!Number.isFinite(Date.parse(ev.observedAt))) { | ||
| throw new Error("External event observedAt must be an ISO timestamp"); | ||
| } | ||
| } | ||
| } | ||
| function assertRunId(runId) { | ||
| if (runId === "." || | ||
| runId === ".." || | ||
| !RUN_ID_RE.test(runId) || | ||
| basename(runId) !== runId) { | ||
| throw new Error(`Invalid run id: ${JSON.stringify(runId)}`); | ||
| } | ||
| } | ||
| /** True when candidate equals root or is a strict descendant under root. */ | ||
| function isContained(root, candidate) { | ||
| const sep = process.platform === "win32" ? "\\" : "/"; | ||
| return candidate === root || candidate.startsWith(`${root}${sep}`); | ||
| } |
| /** | ||
| * Eight-Exit Runtime — pure deterministic evaluator. | ||
| * | ||
| * EXIT_PRECEDENCE rationale (product decision — see contracts/exits.ts for rationale; | ||
| * any change to this array requires updating that paragraph in the same commit): | ||
| * human_interrupt > external_event > wall_clock > budget_cap > turn_cap > | ||
| * goal_met > error_threshold > no_progress | ||
| */ | ||
| import type { ExitEvaluationV1, ExitPolicyV1, ExitSnapshotV1, LoopBudget, LoopLifecycleState, LoopStatus } from "../contracts/index.js"; | ||
| export interface LegacyExitDecision { | ||
| shouldExit: boolean; | ||
| lifecycleState: LoopLifecycleState; | ||
| status: LoopStatus; | ||
| reason: string; | ||
| reasonCode?: string; | ||
| failureClass?: string; | ||
| safetySurface?: string; | ||
| exitEvaluation: ExitEvaluationV1; | ||
| } | ||
| export interface ExitPolicyOverrides { | ||
| goal?: Partial<ExitPolicyV1["goal"]>; | ||
| turns?: Partial<ExitPolicyV1["turns"]>; | ||
| budget?: Partial<ExitPolicyV1["budget"]>; | ||
| wallClock?: Partial<ExitPolicyV1["wallClock"]>; | ||
| progress?: Partial<ExitPolicyV1["progress"]>; | ||
| errors?: Partial<ExitPolicyV1["errors"]>; | ||
| humanInterrupt?: Partial<ExitPolicyV1["humanInterrupt"]>; | ||
| externalEvent?: Partial<ExitPolicyV1["externalEvent"]>; | ||
| } | ||
| export declare function createDefaultExitPolicy(budget: LoopBudget, overrides?: ExitPolicyOverrides): ExitPolicyV1; | ||
| export declare function validateExitPolicy(policy: ExitPolicyV1): void; | ||
| export declare function evaluateExitPolicy(policy: ExitPolicyV1, snapshot: ExitSnapshotV1): ExitEvaluationV1; | ||
| export declare function toLegacyExitDecision(evaluation: ExitEvaluationV1, externalDisposition?: "satisfied" | "superseded" | "cancelled"): LegacyExitDecision; | ||
| /** | ||
| * Hash the meaningful workspace/verification state for no-progress detection. | ||
| * Excludes timestamps, cost counters, prose summaries — only structural changes count. | ||
| */ | ||
| export declare function hashProgressState(value: unknown): string; | ||
| /** @deprecated Use evaluateExitPolicy instead. Retained for downstream compatibility. */ | ||
| export { inferExitCompat as inferExitCompat }; | ||
| declare function inferExitCompat(_unused: unknown): never; |
| /** | ||
| * Eight-Exit Runtime — pure deterministic evaluator. | ||
| * | ||
| * EXIT_PRECEDENCE rationale (product decision — see contracts/exits.ts for rationale; | ||
| * any change to this array requires updating that paragraph in the same commit): | ||
| * human_interrupt > external_event > wall_clock > budget_cap > turn_cap > | ||
| * goal_met > error_threshold > no_progress | ||
| */ | ||
| import { createHash } from "node:crypto"; | ||
| // Precedence: human authority first, resource limits before goal success, | ||
| // resource exits ranked by urgency (wall_clock > budget_cap > turn_cap). | ||
| // Note: turn_cap is suppressed at signal-generation time when goalMet is true | ||
| // so goal_met and turn_cap cannot co-occur in practice. | ||
| const EXIT_PRECEDENCE = [ | ||
| "human_interrupt", | ||
| "external_event", | ||
| "wall_clock", | ||
| "budget_cap", | ||
| "turn_cap", | ||
| "goal_met", | ||
| "error_threshold", | ||
| "no_progress" | ||
| ]; | ||
| export function createDefaultExitPolicy(budget, overrides = {}) { | ||
| const base = { | ||
| schemaVersion: "exit-policy/1", | ||
| goal: { verifierRequired: true, minimumScore: 1 }, | ||
| turns: { max: budget.maxIterations }, | ||
| budget: { maxUsd: budget.maxUsd, maxTokens: budget.maxTokens }, | ||
| wallClock: { maxElapsedMs: 30 * 60 * 1000 }, | ||
| progress: { windowSize: 3, unchangedStateLimit: 3 }, | ||
| errors: { maxConsecutive: 3 }, | ||
| humanInterrupt: { enabled: true }, | ||
| externalEvent: { enabled: true } | ||
| }; | ||
| return { | ||
| ...base, | ||
| ...overrides, | ||
| goal: { ...base.goal, ...overrides.goal }, | ||
| turns: { ...base.turns, ...overrides.turns }, | ||
| budget: { ...base.budget, ...overrides.budget }, | ||
| wallClock: { ...base.wallClock, ...overrides.wallClock }, | ||
| progress: { ...base.progress, ...overrides.progress }, | ||
| errors: { ...base.errors, ...overrides.errors }, | ||
| humanInterrupt: { ...base.humanInterrupt, ...overrides.humanInterrupt }, | ||
| externalEvent: { ...base.externalEvent, ...overrides.externalEvent } | ||
| }; | ||
| } | ||
| export function validateExitPolicy(policy) { | ||
| assertFinitePositive("turns.max", policy.turns.max); | ||
| assertFinitePositive("budget.maxUsd", policy.budget.maxUsd); | ||
| assertFinitePositive("budget.maxTokens", policy.budget.maxTokens); | ||
| assertFinitePositive("wallClock.maxElapsedMs", policy.wallClock.maxElapsedMs); | ||
| assertFinitePositive("progress.windowSize", policy.progress.windowSize); | ||
| assertFinitePositive("progress.unchangedStateLimit", policy.progress.unchangedStateLimit); | ||
| assertFinitePositive("errors.maxConsecutive", policy.errors.maxConsecutive); | ||
| if (policy.goal.minimumScore < 0 || policy.goal.minimumScore > 1) { | ||
| throw new RangeError("goal.minimumScore must be between 0 and 1"); | ||
| } | ||
| if (policy.progress.unchangedStateLimit > policy.progress.windowSize) { | ||
| throw new RangeError("progress.unchangedStateLimit cannot exceed progress.windowSize"); | ||
| } | ||
| if (policy.wallClock.deadlineAt !== undefined) { | ||
| const parsed = Date.parse(policy.wallClock.deadlineAt); | ||
| if (!Number.isFinite(parsed)) { | ||
| throw new RangeError("wallClock.deadlineAt must be an ISO timestamp"); | ||
| } | ||
| } | ||
| } | ||
| export function evaluateExitPolicy(policy, snapshot) { | ||
| validateExitPolicy(policy); | ||
| const matches = []; | ||
| const push = (kind, reason, evidence) => { | ||
| matches.push({ kind, reason, evidence }); | ||
| }; | ||
| // 1. Human interrupt (highest authority — cannot be argued with) | ||
| if (policy.humanInterrupt.enabled && snapshot.humanInterrupt !== undefined) { | ||
| push("human_interrupt", snapshot.humanInterrupt.reason ?? "Human interrupt requested.", { | ||
| requestedAt: snapshot.humanInterrupt.requestedAt, | ||
| requestedBy: snapshot.humanInterrupt.requestedBy | ||
| }); | ||
| } | ||
| // 2. External event | ||
| if (policy.externalEvent.enabled && snapshot.externalEvent !== undefined) { | ||
| push("external_event", snapshot.externalEvent.reason ?? "External terminal event observed.", { | ||
| source: snapshot.externalEvent.source, | ||
| event: snapshot.externalEvent.event, | ||
| disposition: snapshot.externalEvent.disposition, | ||
| subject: snapshot.externalEvent.subject, | ||
| evidenceUri: snapshot.externalEvent.evidenceUri | ||
| }); | ||
| } | ||
| // 3. Goal met (verified success outranks resource exhaustion) | ||
| const result = snapshot.result; | ||
| const goalMet = result !== undefined && | ||
| result.status === "completed" && | ||
| (!policy.goal.verifierRequired || result.verificationPassed) && | ||
| result.verifierScore >= policy.goal.minimumScore; | ||
| if (goalMet) { | ||
| push("goal_met", "Configured verification goal passed.", { | ||
| verifierScore: result.verifierScore, | ||
| minimumScore: policy.goal.minimumScore | ||
| }); | ||
| } | ||
| // 4. Wall clock | ||
| const elapsedMs = Math.max(0, snapshot.nowMs - snapshot.runStartedAtMs); | ||
| const deadlineMs = policy.wallClock.deadlineAt === undefined | ||
| ? undefined | ||
| : Date.parse(policy.wallClock.deadlineAt); | ||
| if (elapsedMs >= policy.wallClock.maxElapsedMs || | ||
| (deadlineMs !== undefined && snapshot.nowMs >= deadlineMs)) { | ||
| push("wall_clock", "Run wall-clock limit reached.", { | ||
| elapsedMs, | ||
| maxElapsedMs: policy.wallClock.maxElapsedMs, | ||
| deadlineAt: policy.wallClock.deadlineAt | ||
| }); | ||
| } | ||
| // 5. Budget cap | ||
| const usdExceeded = snapshot.actualUsd >= policy.budget.maxUsd; | ||
| const tokensExceeded = snapshot.tokensUsed >= policy.budget.maxTokens; | ||
| if (usdExceeded || tokensExceeded) { | ||
| push("budget_cap", "Run token or dollar budget reached.", { | ||
| actualUsd: snapshot.actualUsd, | ||
| maxUsd: policy.budget.maxUsd, | ||
| tokensUsed: snapshot.tokensUsed, | ||
| maxTokens: policy.budget.maxTokens, | ||
| usdExceeded, | ||
| tokensExceeded | ||
| }); | ||
| } | ||
| // 6. Turn cap — suppressed when goalMet: completing on the final allowed | ||
| // iteration is legitimate success, not an overrun. turn_cap fires only when | ||
| // iterations are exhausted without verified completion. | ||
| if (!goalMet && snapshot.turnsUsed >= policy.turns.max) { | ||
| push("turn_cap", "Run iteration limit reached.", { | ||
| turnsUsed: snapshot.turnsUsed, | ||
| maxTurns: policy.turns.max | ||
| }); | ||
| } | ||
| // 7. Error threshold | ||
| if (snapshot.consecutiveErrors >= policy.errors.maxConsecutive) { | ||
| push("error_threshold", "Consecutive error threshold reached.", { | ||
| consecutiveErrors: snapshot.consecutiveErrors, | ||
| maxConsecutive: policy.errors.maxConsecutive | ||
| }); | ||
| } | ||
| // 8. No progress — only fires when the window is FULL (A3 fix: sub-window must not fire) | ||
| const requiredHashes = policy.progress.unchangedStateLimit; | ||
| const recentHashes = snapshot.recentStateHashes.slice(-requiredHashes); | ||
| const hashStall = recentHashes.length === requiredHashes && new Set(recentHashes).size === 1; | ||
| if (hashStall || snapshot.trajectoryStop?.shouldStop === true) { | ||
| push("no_progress", snapshot.trajectoryStop?.reason ?? "Canonical run state stopped changing.", { | ||
| unchangedStateCount: hashStall ? recentHashes.length : 0, | ||
| stateHash: hashStall ? recentHashes[0] : undefined, | ||
| trajectoryStop: snapshot.trajectoryStop?.shouldStop ?? false | ||
| }); | ||
| } | ||
| const matched = EXIT_PRECEDENCE.filter((kind) => matches.some((m) => m.kind === kind)); | ||
| return { | ||
| schemaVersion: "exit-evaluation/1", | ||
| policyVersion: policy.schemaVersion, | ||
| shouldExit: matched.length > 0, | ||
| ...(matched[0] === undefined ? {} : { primary: matched[0] }), | ||
| matched, | ||
| phase: snapshot.phase, | ||
| evaluatedAt: snapshot.evaluatedAt, | ||
| matches | ||
| }; | ||
| } | ||
| export function toLegacyExitDecision(evaluation, externalDisposition) { | ||
| const primary = evaluation.primary; | ||
| if (primary === undefined) { | ||
| return { | ||
| shouldExit: false, | ||
| lifecycleState: "running", | ||
| status: "running", | ||
| reason: "No exit condition matched.", | ||
| exitEvaluation: evaluation | ||
| }; | ||
| } | ||
| const reason = evaluation.matches.find((m) => m.kind === primary)?.reason ?? | ||
| `MartinLoop exited because ${primary} fired.`; | ||
| if (primary === "goal_met") { | ||
| return completed(reason, primary, evaluation); | ||
| } | ||
| if (primary === "external_event" && externalDisposition === "satisfied") { | ||
| return completed(reason, primary, evaluation); | ||
| } | ||
| if (primary === "human_interrupt") { | ||
| return exited("human_escalation", reason, primary, evaluation); | ||
| } | ||
| if (primary === "budget_cap" || primary === "turn_cap") { | ||
| return exited("budget_exit", reason, primary, evaluation); | ||
| } | ||
| if (primary === "no_progress") { | ||
| return exited("diminishing_returns", reason, primary, evaluation); | ||
| } | ||
| if (primary === "error_threshold") { | ||
| return exited("error_threshold", reason, primary, evaluation); | ||
| } | ||
| if (primary === "wall_clock") { | ||
| return exited("wall_clock", reason, primary, evaluation); | ||
| } | ||
| if (primary === "external_event") { | ||
| return exited("external_event", reason, primary, evaluation); | ||
| } | ||
| return exited("stuck_exit", reason, primary, evaluation); | ||
| } | ||
| /** | ||
| * Hash the meaningful workspace/verification state for no-progress detection. | ||
| * Excludes timestamps, cost counters, prose summaries — only structural changes count. | ||
| */ | ||
| export function hashProgressState(value) { | ||
| return createHash("sha256").update(stableJson(value)).digest("hex"); | ||
| } | ||
| function stableJson(value) { | ||
| if (value === null || typeof value !== "object") { | ||
| return JSON.stringify(value) ?? "null"; | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return `[${value.map(stableJson).join(",")}]`; | ||
| } | ||
| const obj = value; | ||
| return `{${Object.keys(obj) | ||
| .sort() | ||
| .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`) | ||
| .join(",")}}`; | ||
| } | ||
| function completed(reason, primary, evaluation) { | ||
| return { | ||
| shouldExit: true, | ||
| lifecycleState: "completed", | ||
| status: "completed", | ||
| reason, | ||
| reasonCode: primary, | ||
| exitEvaluation: evaluation | ||
| }; | ||
| } | ||
| function exited(lifecycleState, reason, primary, evaluation) { | ||
| return { | ||
| shouldExit: true, | ||
| lifecycleState, | ||
| status: "exited", | ||
| reason, | ||
| reasonCode: primary, | ||
| exitEvaluation: evaluation | ||
| }; | ||
| } | ||
| function assertFinitePositive(name, value) { | ||
| if (!Number.isFinite(value) || value <= 0) { | ||
| throw new RangeError(`${name} must be a finite number greater than zero`); | ||
| } | ||
| } | ||
| /** @deprecated Use evaluateExitPolicy instead. Retained for downstream compatibility. */ | ||
| export { inferExitCompat as inferExitCompat }; | ||
| function inferExitCompat(_unused) { | ||
| throw new Error("inferExitCompat is a compatibility stub — call evaluateExitPolicy directly"); | ||
| } |
| /** | ||
| * Mission aggregation — C2 | ||
| * | ||
| * Computes verified-outcome and cost metrics from a mission's linked runs. | ||
| * Does not hit the filesystem — operates on already-loaded MissionRecord. | ||
| */ | ||
| import type { MissionCost, MissionRecord, MissionRunLink } from "../../contracts/index.js"; | ||
| export interface MissionMetrics { | ||
| totalActualUsd: number; | ||
| verifiedOutcomeCount: number; | ||
| totalRunCount: number; | ||
| /** USD per verified outcome. Infinity when verifiedOutcomeCount === 0. */ | ||
| costPerVerifiedOutcome: number; | ||
| /** Fraction of runs with verified outcomes (0–1). */ | ||
| verifiedRate: number; | ||
| } | ||
| /** | ||
| * Re-derive mission cost metrics from the run links. | ||
| * Use this to rebuild the cost object from ledger-authoritative data | ||
| * rather than trusting the cached mission.json cost field. | ||
| */ | ||
| export declare function aggregateMissionMetrics(runLinks: MissionRunLink[]): MissionMetrics; | ||
| /** | ||
| * Rebuild the MissionCost record from the authoritative run links. | ||
| * Call after loading from ledger to ensure the cached cost field is consistent. | ||
| */ | ||
| export declare function rebuildMissionCost(mission: MissionRecord): MissionCost; |
| /** | ||
| * Mission aggregation — C2 | ||
| * | ||
| * Computes verified-outcome and cost metrics from a mission's linked runs. | ||
| * Does not hit the filesystem — operates on already-loaded MissionRecord. | ||
| */ | ||
| /** | ||
| * Re-derive mission cost metrics from the run links. | ||
| * Use this to rebuild the cost object from ledger-authoritative data | ||
| * rather than trusting the cached mission.json cost field. | ||
| */ | ||
| export function aggregateMissionMetrics(runLinks) { | ||
| let totalActualUsd = 0; | ||
| let verifiedOutcomeCount = 0; | ||
| for (const link of runLinks) { | ||
| totalActualUsd += link.actualUsd ?? 0; | ||
| if (link.verifiedOutcome === true) | ||
| verifiedOutcomeCount += 1; | ||
| } | ||
| const totalRunCount = runLinks.length; | ||
| const costPerVerifiedOutcome = verifiedOutcomeCount === 0 ? Infinity : totalActualUsd / verifiedOutcomeCount; | ||
| const verifiedRate = totalRunCount === 0 ? 0 : verifiedOutcomeCount / totalRunCount; | ||
| return { | ||
| totalActualUsd, | ||
| verifiedOutcomeCount, | ||
| totalRunCount, | ||
| costPerVerifiedOutcome, | ||
| verifiedRate | ||
| }; | ||
| } | ||
| /** | ||
| * Rebuild the MissionCost record from the authoritative run links. | ||
| * Call after loading from ledger to ensure the cached cost field is consistent. | ||
| */ | ||
| export function rebuildMissionCost(mission) { | ||
| const metrics = aggregateMissionMetrics(mission.runLinks); | ||
| return { | ||
| totalActualUsd: metrics.totalActualUsd, | ||
| verifiedOutcomeCount: metrics.verifiedOutcomeCount, | ||
| totalRunCount: metrics.totalRunCount | ||
| }; | ||
| } |
| export { aggregateMissionMetrics, rebuildMissionCost } from "./aggregate.js"; | ||
| export type { MissionMetrics } from "./aggregate.js"; |
| export { aggregateMissionMetrics, rebuildMissionCost } from "./aggregate.js"; |
| /** | ||
| * Context Handoff Store — A-CTX-2 persistence layer. | ||
| * | ||
| * Produces, writes, and reads ContextHandoffReceipt files so that | ||
| * downstream processes can verify them without rerunning the upstream agent. | ||
| * | ||
| * Storage layout (under runsRoot/<runId>/): | ||
| * context-handoff.json — the receipt produced by this run for the next hop | ||
| * | ||
| * The producerReceiptHash is computed as SHA-256 of the receipt-integrity.json | ||
| * file written by writeLoopRecord. If that file is absent, upstreamIntegrity is | ||
| * set to "evidence_boundary" — the receipt is still written but will not pass | ||
| * verifyContextHandoff. | ||
| */ | ||
| import type { ContextHandoffArtifact, ContextHandoffClaim, ContextHandoffReceipt } from "../../contracts/index.js"; | ||
| /** Compute the SHA-256 hex digest of a file's raw bytes. */ | ||
| export declare function computeFileHash(filePath: string): Promise<string>; | ||
| export declare function contextHandoffPath(runsRoot: string, runId: string): string; | ||
| /** | ||
| * Persist a ContextHandoffReceipt for this run so the next hop can load it. | ||
| * Overwrites any existing file — callers must not call this more than once per run. | ||
| */ | ||
| export declare function writeContextHandoff(runsRoot: string, runId: string, receipt: ContextHandoffReceipt): Promise<void>; | ||
| /** | ||
| * Load a ContextHandoffReceipt from a previous run. | ||
| * Returns null when the file does not exist (not an error — the consumer | ||
| * must treat absence as a hard gate failure). | ||
| */ | ||
| export declare function readContextHandoff(runsRoot: string, runId: string): Promise<ContextHandoffReceipt | null>; | ||
| export interface BuildHandoffReceiptInput { | ||
| runsRoot: string; | ||
| /** Run that is producing this handoff (the upstream/producer run). */ | ||
| producerRunId: string; | ||
| /** Stable identifier for the chain. Shared across all hops. */ | ||
| chainId: string; | ||
| /** Optional mission identifier shared across hops. */ | ||
| missionId?: string; | ||
| /** Unique ID for this specific handoff crossing. Generated if absent. */ | ||
| handoffId?: string; | ||
| /** Verified claims the producer is asserting. All must be state "verified". */ | ||
| claims?: ContextHandoffClaim[]; | ||
| /** | ||
| * File paths whose SHA-256 hashes should be captured as artifacts. | ||
| * Each file is hashed at call time — callers must call after writing artifacts. | ||
| */ | ||
| artifactFiles?: Array<{ | ||
| filePath: string; | ||
| label?: string; | ||
| required: boolean; | ||
| }>; | ||
| /** Optional pre-computed artifacts (bypasses file hashing). */ | ||
| artifacts?: ContextHandoffArtifact[]; | ||
| /** Assumptions that are not yet resolved at handoff time. */ | ||
| unresolvedAssumptions?: string[]; | ||
| /** Parent handoff IDs for multi-hop lineage. */ | ||
| parentHandoffIds?: string[]; | ||
| /** Clock override (defaults to new Date().toISOString()). */ | ||
| now?: () => string; | ||
| } | ||
| /** | ||
| * Build a ContextHandoffReceipt from a completed run. | ||
| * | ||
| * - producerReceiptHash is SHA-256 of the receipt-integrity.json file | ||
| * written by writeLoopRecord. If that file is absent, upstreamIntegrity | ||
| * is "evidence_boundary". | ||
| * - Artifact hashes are computed from real files at call time. | ||
| */ | ||
| export declare function buildContextHandoffReceipt(input: BuildHandoffReceiptInput): Promise<ContextHandoffReceipt>; |
| /** | ||
| * Context Handoff Store — A-CTX-2 persistence layer. | ||
| * | ||
| * Produces, writes, and reads ContextHandoffReceipt files so that | ||
| * downstream processes can verify them without rerunning the upstream agent. | ||
| * | ||
| * Storage layout (under runsRoot/<runId>/): | ||
| * context-handoff.json — the receipt produced by this run for the next hop | ||
| * | ||
| * The producerReceiptHash is computed as SHA-256 of the receipt-integrity.json | ||
| * file written by writeLoopRecord. If that file is absent, upstreamIntegrity is | ||
| * set to "evidence_boundary" — the receipt is still written but will not pass | ||
| * verifyContextHandoff. | ||
| */ | ||
| import { createHash } from "node:crypto"; | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { HANDOFF_SCHEMA_VERSION } from "../../contracts/index.js"; | ||
| import { resolveReceiptIntegrityPath } from "./integrity.js"; | ||
| import { runDir } from "./store.js"; | ||
| // ─── Hash helpers ────────────────────────────────────────────────────────────── | ||
| /** Compute the SHA-256 hex digest of a file's raw bytes. */ | ||
| export async function computeFileHash(filePath) { | ||
| const buf = await readFile(filePath); | ||
| return createHash("sha256").update(buf).digest("hex"); | ||
| } | ||
| /** Compute the SHA-256 hex digest of a UTF-8 string. */ | ||
| function sha256String(value) { | ||
| return createHash("sha256").update(value, "utf8").digest("hex"); | ||
| } | ||
| // ─── Paths ───────────────────────────────────────────────────────────────────── | ||
| export function contextHandoffPath(runsRoot, runId) { | ||
| return join(runDir(runsRoot, runId), "context-handoff.json"); | ||
| } | ||
| // ─── Write ───────────────────────────────────────────────────────────────────── | ||
| /** | ||
| * Persist a ContextHandoffReceipt for this run so the next hop can load it. | ||
| * Overwrites any existing file — callers must not call this more than once per run. | ||
| */ | ||
| export async function writeContextHandoff(runsRoot, runId, receipt) { | ||
| const dir = runDir(runsRoot, runId); | ||
| await mkdir(dir, { recursive: true }); | ||
| await writeFile(contextHandoffPath(runsRoot, runId), JSON.stringify(receipt, null, 2), "utf8"); | ||
| } | ||
| // ─── Read ────────────────────────────────────────────────────────────────────── | ||
| /** | ||
| * Load a ContextHandoffReceipt from a previous run. | ||
| * Returns null when the file does not exist (not an error — the consumer | ||
| * must treat absence as a hard gate failure). | ||
| */ | ||
| export async function readContextHandoff(runsRoot, runId) { | ||
| const path = contextHandoffPath(runsRoot, runId); | ||
| const raw = await readFile(path, "utf8").catch(() => null); | ||
| if (raw === null) | ||
| return null; | ||
| return JSON.parse(raw); | ||
| } | ||
| /** | ||
| * Build a ContextHandoffReceipt from a completed run. | ||
| * | ||
| * - producerReceiptHash is SHA-256 of the receipt-integrity.json file | ||
| * written by writeLoopRecord. If that file is absent, upstreamIntegrity | ||
| * is "evidence_boundary". | ||
| * - Artifact hashes are computed from real files at call time. | ||
| */ | ||
| export async function buildContextHandoffReceipt(input) { | ||
| const { runsRoot, producerRunId, chainId, missionId, claims = [], unresolvedAssumptions = [], parentHandoffIds, now = () => new Date().toISOString() } = input; | ||
| // Determine handoffId | ||
| const handoffId = input.handoffId ?? | ||
| `hoff_${sha256String(`${chainId}:${producerRunId}:${now()}`).slice(0, 16)}`; | ||
| // Hash the receipt-integrity.json file to obtain producerReceiptHash | ||
| const integrityPath = resolveReceiptIntegrityPath(runsRoot, producerRunId); | ||
| let producerReceiptHash; | ||
| let upstreamIntegrity; | ||
| const integrityRaw = await readFile(integrityPath, "utf8").catch(() => null); | ||
| if (integrityRaw === null) { | ||
| // No integrity file — cannot establish upstream integrity | ||
| producerReceiptHash = ""; | ||
| upstreamIntegrity = "evidence_boundary"; | ||
| } | ||
| else { | ||
| producerReceiptHash = sha256String(integrityRaw); | ||
| upstreamIntegrity = "verified"; | ||
| } | ||
| // Hash artifact files | ||
| const artifactFiles = input.artifactFiles ?? []; | ||
| const fileArtifacts = await Promise.all(artifactFiles.map(async (af) => ({ | ||
| path: af.filePath, | ||
| sha256: await computeFileHash(af.filePath), | ||
| required: af.required, | ||
| label: af.label | ||
| }))); | ||
| const artifacts = [ | ||
| ...(input.artifacts ?? []), | ||
| ...fileArtifacts | ||
| ]; | ||
| const receipt = { | ||
| schemaVersion: HANDOFF_SCHEMA_VERSION, | ||
| handoffId, | ||
| chainId, | ||
| ...(missionId !== undefined ? { missionId } : {}), | ||
| producerRunId, | ||
| producerReceiptHash, | ||
| ...(parentHandoffIds !== undefined ? { parentHandoffIds } : {}), | ||
| claims, | ||
| artifacts, | ||
| unresolvedAssumptions, | ||
| upstreamIntegrity, | ||
| createdAt: now() | ||
| }; | ||
| return receipt; | ||
| } |
| /** | ||
| * Mission Store — C2 durable persistence. | ||
| * | ||
| * Layout under <runsRoot>/missions/<missionId>/: | ||
| * mission.json — rebuildable snapshot/cache (NOT the authority) | ||
| * ledger.jsonl — append-only, SHA-256 hash-chained event log (authority) | ||
| * ledger-chain.json — chain head hash for tamper detection | ||
| * .lock — cross-process exclusive lock (created with O_EXCL) | ||
| * | ||
| * Rules: | ||
| * - Ledger is always written before mission.json is updated. | ||
| * - mission.json is written atomically (temp file → rename). | ||
| * - Cross-process lock is held during every write; stale locks (>8s) are removed. | ||
| * - CAS revision must match before any write is accepted. | ||
| * - Corrupt or unverifiable ledger data fails closed. | ||
| * - Workspace isolation: all paths are under caller-supplied runsRoot. | ||
| */ | ||
| import type { MissionDecision, MissionEvent, MissionRecord, MissionRunRole, MissionStatus } from "../../contracts/index.js"; | ||
| export declare function missionDir(runsRoot: string, missionId: string): string; | ||
| /** | ||
| * Read the current mission snapshot. | ||
| * Returns null when no mission exists at this path. | ||
| * Fails closed when the snapshot schema version is not recognised. | ||
| */ | ||
| export declare function readMission(runsRoot: string, missionId: string): Promise<MissionRecord | null>; | ||
| export declare function readMissionLedger(runsRoot: string, missionId: string): Promise<MissionEvent[]>; | ||
| export interface LedgerIntegrityResult { | ||
| ok: boolean; | ||
| reason?: string; | ||
| entryCount: number; | ||
| } | ||
| /** | ||
| * Verify the stored chain head matches a full replay of the ledger. | ||
| * Returns ok=false when the ledger has been tampered with or entries are missing. | ||
| */ | ||
| export declare function verifyMissionLedger(runsRoot: string, missionId: string): Promise<LedgerIntegrityResult>; | ||
| export declare function createMission(runsRoot: string, mission: MissionRecord): Promise<void>; | ||
| export interface AttachRunOptions { | ||
| loopId: string; | ||
| role: MissionRunRole; | ||
| verifiedOutcome?: boolean; | ||
| actualUsd?: number; | ||
| now?: () => string; | ||
| /** Expected revision for CAS enforcement. */ | ||
| expectedRevision: number; | ||
| } | ||
| export declare function attachRun(runsRoot: string, missionId: string, options: AttachRunOptions): Promise<MissionRecord>; | ||
| export interface ChangeMissionStatusOptions { | ||
| toStatus: MissionStatus; | ||
| expectedRevision: number; | ||
| decidedBy?: string; | ||
| decision?: MissionDecision; | ||
| note?: string; | ||
| now?: () => string; | ||
| } | ||
| export declare function changeMissionStatus(runsRoot: string, missionId: string, options: ChangeMissionStatusOptions): Promise<MissionRecord>; |
| /** | ||
| * Mission Store — C2 durable persistence. | ||
| * | ||
| * Layout under <runsRoot>/missions/<missionId>/: | ||
| * mission.json — rebuildable snapshot/cache (NOT the authority) | ||
| * ledger.jsonl — append-only, SHA-256 hash-chained event log (authority) | ||
| * ledger-chain.json — chain head hash for tamper detection | ||
| * .lock — cross-process exclusive lock (created with O_EXCL) | ||
| * | ||
| * Rules: | ||
| * - Ledger is always written before mission.json is updated. | ||
| * - mission.json is written atomically (temp file → rename). | ||
| * - Cross-process lock is held during every write; stale locks (>8s) are removed. | ||
| * - CAS revision must match before any write is accepted. | ||
| * - Corrupt or unverifiable ledger data fails closed. | ||
| * - Workspace isolation: all paths are under caller-supplied runsRoot. | ||
| */ | ||
| import { createHash } from "node:crypto"; | ||
| import { constants, open } from "node:fs/promises"; | ||
| import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { MISSION_SCHEMA_VERSION, isMissionTransitionAllowed } from "../../contracts/index.js"; | ||
| // ─── Paths ──────────────────────────────────────────────────────────────────── | ||
| export function missionDir(runsRoot, missionId) { | ||
| return join(runsRoot, "missions", missionId); | ||
| } | ||
| function missionJsonPath(runsRoot, missionId) { | ||
| return join(missionDir(runsRoot, missionId), "mission.json"); | ||
| } | ||
| function ledgerPath(runsRoot, missionId) { | ||
| return join(missionDir(runsRoot, missionId), "ledger.jsonl"); | ||
| } | ||
| function chainPath(runsRoot, missionId) { | ||
| return join(missionDir(runsRoot, missionId), "ledger-chain.json"); | ||
| } | ||
| function lockPath(runsRoot, missionId) { | ||
| return join(missionDir(runsRoot, missionId), ".lock"); | ||
| } | ||
| // ─── Hash chain ─────────────────────────────────────────────────────────────── | ||
| function sha256(value) { | ||
| return createHash("sha256").update(value, "utf8").digest("hex"); | ||
| } | ||
| async function readChainHead(runsRoot, missionId) { | ||
| const raw = await readFile(chainPath(runsRoot, missionId), "utf8").catch(() => null); | ||
| if (raw === null) | ||
| return { headHash: "root", entryCount: 0 }; | ||
| return JSON.parse(raw); | ||
| } | ||
| async function appendLedgerEntry(runsRoot, missionId, event) { | ||
| const line = JSON.stringify(event); | ||
| const head = await readChainHead(runsRoot, missionId); | ||
| const newHash = sha256(`${head.headHash}\n${line}`); | ||
| const newHead = { headHash: newHash, entryCount: head.entryCount + 1 }; | ||
| // Append event first, then update chain head | ||
| await appendFile(ledgerPath(runsRoot, missionId), `${line}\n`, "utf8"); | ||
| await atomicWrite(chainPath(runsRoot, missionId), JSON.stringify(newHead, null, 2)); | ||
| } | ||
| // ─── Atomic write ───────────────────────────────────────────────────────────── | ||
| async function atomicWrite(filePath, content) { | ||
| const tmp = `${filePath}.tmp`; | ||
| await writeFile(tmp, content, "utf8"); | ||
| await rename(tmp, filePath); | ||
| } | ||
| // ─── Cross-process lock ─────────────────────────────────────────────────────── | ||
| const LOCK_STALE_MS = 8_000; | ||
| const LOCK_RETRY_INTERVAL_MS = 50; | ||
| const LOCK_MAX_RETRIES = 60; // 3s total | ||
| async function acquireLock(runsRoot, missionId) { | ||
| const lp = lockPath(runsRoot, missionId); | ||
| for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) { | ||
| try { | ||
| // O_EXCL — fails if file exists (atomic on all POSIX and Windows NTFS) | ||
| const fh = await open(lp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); | ||
| await fh.write(String(Date.now())); | ||
| await fh.close(); | ||
| return; | ||
| } | ||
| catch { | ||
| // Lock exists — check if stale | ||
| try { | ||
| const st = await stat(lp); | ||
| const ageMs = Date.now() - st.mtimeMs; | ||
| if (ageMs > LOCK_STALE_MS) { | ||
| await rm(lp, { force: true }); | ||
| continue; // retry immediately after removing stale lock | ||
| } | ||
| } | ||
| catch { | ||
| // Lock disappeared between check and stat — retry | ||
| continue; | ||
| } | ||
| await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS)); | ||
| } | ||
| } | ||
| throw new Error(`mission-store: could not acquire lock for ${missionId} after ${LOCK_MAX_RETRIES} retries`); | ||
| } | ||
| async function releaseLock(runsRoot, missionId) { | ||
| await rm(lockPath(runsRoot, missionId), { force: true }); | ||
| } | ||
| async function withLock(runsRoot, missionId, fn) { | ||
| await acquireLock(runsRoot, missionId); | ||
| try { | ||
| return await fn(); | ||
| } | ||
| finally { | ||
| await releaseLock(runsRoot, missionId); | ||
| } | ||
| } | ||
| // ─── Event ID factory ───────────────────────────────────────────────────────── | ||
| let _seq = 0; | ||
| function makeEventId(missionId) { | ||
| return `evt_${missionId.slice(0, 8)}_${Date.now()}_${(_seq++).toString().padStart(4, "0")}`; | ||
| } | ||
| // ─── Read mission ───────────────────────────────────────────────────────────── | ||
| /** | ||
| * Read the current mission snapshot. | ||
| * Returns null when no mission exists at this path. | ||
| * Fails closed when the snapshot schema version is not recognised. | ||
| */ | ||
| export async function readMission(runsRoot, missionId) { | ||
| const raw = await readFile(missionJsonPath(runsRoot, missionId), "utf8").catch(() => null); | ||
| if (raw === null) | ||
| return null; | ||
| const record = JSON.parse(raw); | ||
| if (record.schemaVersion !== MISSION_SCHEMA_VERSION) { | ||
| throw new Error(`mission-store: unsupported schema "${record.schemaVersion}" for mission ${missionId}`); | ||
| } | ||
| return record; | ||
| } | ||
| // ─── Read ledger ────────────────────────────────────────────────────────────── | ||
| export async function readMissionLedger(runsRoot, missionId) { | ||
| const raw = await readFile(ledgerPath(runsRoot, missionId), "utf8").catch(() => ""); | ||
| return raw | ||
| .split(/\r?\n/u) | ||
| .map((l) => l.trim()) | ||
| .filter(Boolean) | ||
| .map((l) => JSON.parse(l)); | ||
| } | ||
| /** | ||
| * Verify the stored chain head matches a full replay of the ledger. | ||
| * Returns ok=false when the ledger has been tampered with or entries are missing. | ||
| */ | ||
| export async function verifyMissionLedger(runsRoot, missionId) { | ||
| const events = await readMissionLedger(runsRoot, missionId); | ||
| const stored = await readChainHead(runsRoot, missionId); | ||
| let hash = "root"; | ||
| for (const event of events) { | ||
| hash = sha256(`${hash}\n${JSON.stringify(event)}`); | ||
| } | ||
| if (hash !== stored.headHash) { | ||
| return { | ||
| ok: false, | ||
| reason: `chain_mismatch: expected ${stored.headHash}, replayed ${hash}`, | ||
| entryCount: events.length | ||
| }; | ||
| } | ||
| if (events.length !== stored.entryCount) { | ||
| return { | ||
| ok: false, | ||
| reason: `count_mismatch: stored ${stored.entryCount}, actual ${events.length}`, | ||
| entryCount: events.length | ||
| }; | ||
| } | ||
| return { ok: true, entryCount: events.length }; | ||
| } | ||
| // ─── Create mission ─────────────────────────────────────────────────────────── | ||
| export async function createMission(runsRoot, mission) { | ||
| const dir = missionDir(runsRoot, mission.missionId); | ||
| await mkdir(dir, { recursive: true }); | ||
| await withLock(runsRoot, mission.missionId, async () => { | ||
| // Fail if already exists | ||
| const existing = await readMission(runsRoot, mission.missionId); | ||
| if (existing !== null) { | ||
| throw new Error(`mission-store: mission ${mission.missionId} already exists`); | ||
| } | ||
| const event = { | ||
| eventId: makeEventId(mission.missionId), | ||
| kind: "mission.created", | ||
| missionId: mission.missionId, | ||
| timestamp: mission.createdAt, | ||
| payload: { status: mission.status, ownerId: mission.ownerId } | ||
| }; | ||
| await appendLedgerEntry(runsRoot, mission.missionId, event); | ||
| await atomicWrite(missionJsonPath(runsRoot, mission.missionId), JSON.stringify(mission, null, 2)); | ||
| }); | ||
| } | ||
| export async function attachRun(runsRoot, missionId, options) { | ||
| return withLock(runsRoot, missionId, async () => { | ||
| const mission = await readMission(runsRoot, missionId); | ||
| if (mission === null) | ||
| throw new Error(`mission-store: mission ${missionId} not found`); | ||
| if (mission.revision !== options.expectedRevision) { | ||
| throw new Error(`mission-store: CAS revision mismatch for ${missionId}: ` + | ||
| `expected ${options.expectedRevision}, found ${mission.revision}`); | ||
| } | ||
| const ts = options.now ? options.now() : new Date().toISOString(); | ||
| const link = { | ||
| loopId: options.loopId, | ||
| role: options.role, | ||
| attachedAt: ts, | ||
| ...(options.verifiedOutcome !== undefined ? { verifiedOutcome: options.verifiedOutcome } : {}), | ||
| ...(options.actualUsd !== undefined ? { actualUsd: options.actualUsd } : {}) | ||
| }; | ||
| const newCost = { | ||
| totalActualUsd: mission.cost.totalActualUsd + (options.actualUsd ?? 0), | ||
| verifiedOutcomeCount: mission.cost.verifiedOutcomeCount + (options.verifiedOutcome === true ? 1 : 0), | ||
| totalRunCount: mission.cost.totalRunCount + 1 | ||
| }; | ||
| const updated = { | ||
| ...mission, | ||
| revision: mission.revision + 1, | ||
| runLinks: [...mission.runLinks, link], | ||
| cost: newCost, | ||
| updatedAt: ts | ||
| }; | ||
| const event = { | ||
| eventId: makeEventId(missionId), | ||
| kind: "mission.run_attached", | ||
| missionId, | ||
| timestamp: ts, | ||
| payload: { | ||
| loopId: options.loopId, | ||
| role: options.role, | ||
| verifiedOutcome: options.verifiedOutcome, | ||
| actualUsd: options.actualUsd | ||
| } | ||
| }; | ||
| await appendLedgerEntry(runsRoot, missionId, event); | ||
| await atomicWrite(missionJsonPath(runsRoot, missionId), JSON.stringify(updated, null, 2)); | ||
| return updated; | ||
| }); | ||
| } | ||
| export async function changeMissionStatus(runsRoot, missionId, options) { | ||
| return withLock(runsRoot, missionId, async () => { | ||
| const mission = await readMission(runsRoot, missionId); | ||
| if (mission === null) | ||
| throw new Error(`mission-store: mission ${missionId} not found`); | ||
| if (mission.revision !== options.expectedRevision) { | ||
| throw new Error(`mission-store: CAS revision mismatch for ${missionId}: ` + | ||
| `expected ${options.expectedRevision}, found ${mission.revision}`); | ||
| } | ||
| if (!isMissionTransitionAllowed(mission.status, options.toStatus)) { | ||
| throw new Error(`mission-store: transition ${mission.status} → ${options.toStatus} is not allowed`); | ||
| } | ||
| const ts = options.now ? options.now() : new Date().toISOString(); | ||
| const updated = { | ||
| ...mission, | ||
| revision: mission.revision + 1, | ||
| status: options.toStatus, | ||
| updatedAt: ts, | ||
| ...(options.decision && options.decidedBy | ||
| ? { | ||
| outcome: { | ||
| decision: options.decision, | ||
| decidedAt: ts, | ||
| decidedBy: options.decidedBy, | ||
| ...(options.note ? { note: options.note } : {}) | ||
| } | ||
| } | ||
| : {}) | ||
| }; | ||
| const eventKind = options.toStatus === "shipped" || options.toStatus === "killed" || options.toStatus === "rolled_back" | ||
| ? "mission.closed" | ||
| : "mission.status_changed"; | ||
| const event = { | ||
| eventId: makeEventId(missionId), | ||
| kind: eventKind, | ||
| missionId, | ||
| timestamp: ts, | ||
| payload: { | ||
| from: mission.status, | ||
| to: options.toStatus, | ||
| decision: options.decision, | ||
| decidedBy: options.decidedBy | ||
| } | ||
| }; | ||
| await appendLedgerEntry(runsRoot, missionId, event); | ||
| await atomicWrite(missionJsonPath(runsRoot, missionId), JSON.stringify(updated, null, 2)); | ||
| return updated; | ||
| }); | ||
| } |
| import type { LoopRecord } from "../contracts/index.js"; | ||
| import type { ExitDecision } from "./policy.js"; | ||
| export interface AvoidedUsdInput { | ||
| lifecycleState: LoopRecord["lifecycleState"]; | ||
| actualUsd: number; | ||
| uncontrolledBaselineUsd?: number; | ||
| } | ||
| export declare function calculateAvoidedUsd(input: AvoidedUsdInput): number; | ||
| export declare function calculateLoopAvoidedUsd(input: { | ||
| loop: LoopRecord; | ||
| decision: ExitDecision; | ||
| uncontrolledBaselineUsd?: number; | ||
| }): number; |
| export function calculateAvoidedUsd(input) { | ||
| if (input.lifecycleState !== "completed") | ||
| return 0; | ||
| if (!Number.isFinite(input.actualUsd) || input.actualUsd < 0) | ||
| return 0; | ||
| if (input.uncontrolledBaselineUsd === undefined || | ||
| !Number.isFinite(input.uncontrolledBaselineUsd) || | ||
| input.uncontrolledBaselineUsd <= input.actualUsd) { | ||
| return 0; | ||
| } | ||
| return roundUsd(input.uncontrolledBaselineUsd - input.actualUsd); | ||
| } | ||
| export function calculateLoopAvoidedUsd(input) { | ||
| return calculateAvoidedUsd({ | ||
| lifecycleState: input.decision.lifecycleState, | ||
| actualUsd: input.loop.cost.actualUsd, | ||
| uncontrolledBaselineUsd: input.uncontrolledBaselineUsd | ||
| }); | ||
| } | ||
| function roundUsd(value) { | ||
| return Math.round(value * 100) / 100; | ||
| } |
| import type { TerminationEnvelopeV1 } from "../contracts/index.js"; | ||
| /** | ||
| * Atomically persist the termination envelope for a governed run. | ||
| * | ||
| * Crash-safe write sequence (mirrors exit-signal.ts atomic publication): | ||
| * 1. Write complete JSON to a unique tmp file (wx, 0o600) in runDirectory | ||
| * 2. Sync and close the tmp file | ||
| * 3. Hard-link tmp → termination.json (EEXIST → slot already occupied) | ||
| * 4. On EEXIST, read and return the existing envelope | ||
| * 5. Unlink the tmp file in finally (best-effort) | ||
| * | ||
| * First terminal exit path wins — competing callers that hit EEXIST get back | ||
| * the envelope that won the race. This is the A1 idempotency guarantee. | ||
| */ | ||
| export declare function persistTerminationEnvelope(runDirectory: string, envelope: TerminationEnvelopeV1): Promise<TerminationEnvelopeV1>; |
| import { link, open, readFile, unlink } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { randomUUID } from "node:crypto"; | ||
| /** | ||
| * Atomically persist the termination envelope for a governed run. | ||
| * | ||
| * Crash-safe write sequence (mirrors exit-signal.ts atomic publication): | ||
| * 1. Write complete JSON to a unique tmp file (wx, 0o600) in runDirectory | ||
| * 2. Sync and close the tmp file | ||
| * 3. Hard-link tmp → termination.json (EEXIST → slot already occupied) | ||
| * 4. On EEXIST, read and return the existing envelope | ||
| * 5. Unlink the tmp file in finally (best-effort) | ||
| * | ||
| * First terminal exit path wins — competing callers that hit EEXIST get back | ||
| * the envelope that won the race. This is the A1 idempotency guarantee. | ||
| */ | ||
| export async function persistTerminationEnvelope(runDirectory, envelope) { | ||
| const finalPath = join(runDirectory, "termination.json"); | ||
| const tmpPath = join(runDirectory, `.termination-tmp-${randomUUID()}.json`); | ||
| const json = `${JSON.stringify(envelope, null, 2)}\n`; | ||
| let handle; | ||
| try { | ||
| handle = await open(tmpPath, "wx", 0o600); | ||
| await handle.writeFile(json, "utf8"); | ||
| await handle.sync(); | ||
| await handle.close(); | ||
| handle = undefined; | ||
| try { | ||
| await link(tmpPath, finalPath); | ||
| return envelope; | ||
| } | ||
| catch (linkErr) { | ||
| if (linkErr.code !== "EEXIST") | ||
| throw linkErr; | ||
| // Another path won the race — read what's on disk and return it | ||
| const existing = await readFile(finalPath, "utf8"); | ||
| return JSON.parse(existing); | ||
| } | ||
| } | ||
| finally { | ||
| await handle?.close().catch(() => undefined); | ||
| await unlink(tmpPath).catch(() => undefined); | ||
| } | ||
| } |
| import type { LoopRecord, ReceiptIntegritySummary, TestIntegrityStatus, TestIntegrityVerdict, VerifiedHandoffOutcome, VerifiedHandoffRecoveryV1, VerifiedHandoffRequirementV1, VerifiedHandoffScopeV1, VerifiedHandoffTestIntegrityV1, VerifiedHandoffV1 } from "../contracts/index.js"; | ||
| export declare function toTestIntegrityVerdict(status: TestIntegrityStatus): TestIntegrityVerdict; | ||
| export interface VerifierExecutionBinding { | ||
| runId: string; | ||
| workspaceId: string; | ||
| cwd: string; | ||
| commands: string[]; | ||
| } | ||
| export interface BoundVerifierEvidence { | ||
| passed: boolean; | ||
| binding?: VerifierExecutionBinding; | ||
| steps?: Array<{ | ||
| command: string; | ||
| launched: boolean; | ||
| completed?: boolean; | ||
| crashed?: boolean; | ||
| exitCode?: number; | ||
| timedOut?: boolean; | ||
| }>; | ||
| } | ||
| export declare function verifierActuallyPassed(evidence: BoundVerifierEvidence | null | undefined, expected: VerifierExecutionBinding): boolean; | ||
| export declare function resolveVerifiedHandoffOutcome(input: { | ||
| lifecycleState: LoopRecord["lifecycleState"]; | ||
| executionStatus?: "completed" | "write_blocked" | "sandbox_blocked" | "policy_rejected" | "failed"; | ||
| verificationStatus: BuildVerifiedHandoffInput["verification"]["status"]; | ||
| receiptIntegrity: ReceiptIntegritySummary["state"]; | ||
| scopeStatus?: VerifiedHandoffScopeV1["status"]; | ||
| testIntegrityStatus?: TestIntegrityStatus; | ||
| mutationRequired?: boolean; | ||
| changedFileCount?: number; | ||
| definitionOfDonePreSatisfied?: boolean; | ||
| evidenceContradicted?: boolean; | ||
| unresolvedWorkCount: number; | ||
| }): VerifiedHandoffOutcome; | ||
| export interface BuildVerifiedHandoffInput { | ||
| loop: LoopRecord; | ||
| generatedAt?: string; | ||
| receiptIntegrity: ReceiptIntegritySummary; | ||
| verification: { | ||
| status: "passed" | "failed" | "contradicted" | "not_run"; | ||
| summary: string; | ||
| steps: Array<{ | ||
| command: string; | ||
| launched: boolean; | ||
| completed?: boolean; | ||
| crashed?: boolean; | ||
| exitCode?: number; | ||
| timedOut?: boolean; | ||
| detail?: string; | ||
| }>; | ||
| warnings: string[]; | ||
| binding?: VerifierExecutionBinding; | ||
| }; | ||
| executionStatus?: "completed" | "write_blocked" | "sandbox_blocked" | "policy_rejected" | "failed"; | ||
| mutationRequired?: boolean; | ||
| definitionOfDonePreSatisfied?: boolean; | ||
| evidenceContradicted?: boolean; | ||
| changedFiles?: string[]; | ||
| scope?: Partial<VerifiedHandoffScopeV1>; | ||
| testIntegrity?: Partial<VerifiedHandoffTestIntegrityV1>; | ||
| requirements?: VerifiedHandoffRequirementV1[]; | ||
| unresolvedWork?: string[]; | ||
| stopReason?: string; | ||
| recovery?: Partial<VerifiedHandoffRecoveryV1>; | ||
| nextAction: string; | ||
| } | ||
| export declare function buildVerifiedHandoff(input: BuildVerifiedHandoffInput): VerifiedHandoffV1; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { createHash } from "node:crypto"; | ||
| // --------------------------------------------------------------------------- | ||
| // Mapping: granular TestIntegrityStatus → public TestIntegrityVerdict | ||
| // | ||
| // A new enum value MUST appear here or TypeScript compilation will fail, | ||
| // ensuring the mapping is always exhaustive. | ||
| // --------------------------------------------------------------------------- | ||
| export function toTestIntegrityVerdict(status) { | ||
| switch (status) { | ||
| case "UNCHANGED": | ||
| case "AUTHORIZED_CHANGE": | ||
| return "VERIFIED"; | ||
| case "PREVENTED": | ||
| case "DETECTED_AND_ROLLED_BACK": | ||
| case "DETECTED_NEEDS_REVIEW": | ||
| return "TAMPERING_DETECTED"; | ||
| case "NOT_EVALUATED": | ||
| return "NOT_EVALUATED"; | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Outcome resolution — deterministic, no hidden defaults | ||
| // --------------------------------------------------------------------------- | ||
| const STOPPED_LIFECYCLE_STATES = new Set([ | ||
| "budget_exit", | ||
| "diminishing_returns", | ||
| "stuck_exit", | ||
| "human_escalation", | ||
| ]); | ||
| export function verifierActuallyPassed(evidence, expected) { | ||
| if (!evidence?.passed || !evidence.binding || !evidence.steps) { | ||
| return false; | ||
| } | ||
| if (evidence.binding.runId !== expected.runId || | ||
| evidence.binding.workspaceId !== expected.workspaceId || | ||
| evidence.binding.cwd !== expected.cwd || | ||
| JSON.stringify(evidence.binding.commands) !== JSON.stringify(expected.commands)) { | ||
| return false; | ||
| } | ||
| if (evidence.steps.length !== expected.commands.length) { | ||
| return false; | ||
| } | ||
| return evidence.steps.every((step, index) => step.command === expected.commands[index] && | ||
| step.launched === true && | ||
| step.completed === true && | ||
| step.crashed === false && | ||
| step.timedOut !== true && | ||
| step.exitCode === 0); | ||
| } | ||
| export function resolveVerifiedHandoffOutcome(input) { | ||
| if (STOPPED_LIFECYCLE_STATES.has(input.lifecycleState) || | ||
| input.executionStatus === "write_blocked" || | ||
| input.executionStatus === "sandbox_blocked" || | ||
| input.executionStatus === "policy_rejected") { | ||
| return "STOPPED"; | ||
| } | ||
| if (input.executionStatus === "failed" || | ||
| input.evidenceContradicted === true || | ||
| (input.mutationRequired === true && | ||
| (input.changedFileCount ?? 0) === 0 && | ||
| input.definitionOfDonePreSatisfied !== true)) { | ||
| return "NEEDS_REVIEW"; | ||
| } | ||
| const evidenceTrustworthy = input.receiptIntegrity === "verified"; | ||
| const scopeAcceptable = input.scopeStatus === undefined || | ||
| input.scopeStatus === "WITHIN_SCOPE" || | ||
| input.scopeStatus === "NOT_EVALUATED"; | ||
| const testIntegrityAcceptable = input.testIntegrityStatus === undefined || | ||
| input.testIntegrityStatus === "UNCHANGED" || | ||
| input.testIntegrityStatus === "AUTHORIZED_CHANGE" || | ||
| input.testIntegrityStatus === "NOT_EVALUATED"; | ||
| if (input.verificationStatus === "passed" && | ||
| evidenceTrustworthy && | ||
| scopeAcceptable && | ||
| testIntegrityAcceptable && | ||
| input.unresolvedWorkCount === 0) { | ||
| return "VERIFIED"; | ||
| } | ||
| return "NEEDS_REVIEW"; | ||
| } | ||
| function toEvidenceStatus(status) { | ||
| switch (status) { | ||
| case "passed": | ||
| return "PASSED"; | ||
| case "failed": | ||
| return "FAILED"; | ||
| case "contradicted": | ||
| return "CONTRADICTED"; | ||
| case "not_run": | ||
| return "NOT_RUN"; | ||
| } | ||
| } | ||
| function toCheck(step) { | ||
| const status = !step.launched | ||
| ? "NOT_RUN" | ||
| : step.timedOut | ||
| ? "FAILED" | ||
| : step.exitCode === 0 | ||
| ? "PASSED" | ||
| : "FAILED"; | ||
| return { | ||
| command: step.command, | ||
| status, | ||
| ...(step.exitCode !== undefined ? { exitCode: step.exitCode } : {}), | ||
| ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}), | ||
| ...(step.detail ? { detail: step.detail } : {}), | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Main builder | ||
| // --------------------------------------------------------------------------- | ||
| export function buildVerifiedHandoff(input) { | ||
| const generatedAt = input.generatedAt ?? new Date().toISOString(); | ||
| const changedFiles = input.changedFiles ?? []; | ||
| const unresolvedWork = input.unresolvedWork ?? []; | ||
| const scope = { | ||
| status: input.scope?.status ?? "NOT_EVALUATED", | ||
| allowedPaths: input.scope?.allowedPaths ?? input.loop.task.allowedPaths ?? [], | ||
| deniedPaths: input.scope?.deniedPaths ?? input.loop.task.deniedPaths ?? [], | ||
| changedFiles: input.scope?.changedFiles ?? changedFiles, | ||
| violations: input.scope?.violations ?? [], | ||
| }; | ||
| const testIntegrityStatus = input.testIntegrity?.status ?? "NOT_EVALUATED"; | ||
| const testIntegrity = { | ||
| verdict: toTestIntegrityVerdict(testIntegrityStatus), | ||
| status: testIntegrityStatus, | ||
| protectedPaths: input.testIntegrity?.protectedPaths ?? [], | ||
| changedProtectedPaths: input.testIntegrity?.changedProtectedPaths ?? [], | ||
| findings: input.testIntegrity?.findings ?? [], | ||
| summary: input.testIntegrity?.summary ?? | ||
| "Test integrity was not evaluated for this run.", | ||
| }; | ||
| const outcome = resolveVerifiedHandoffOutcome({ | ||
| lifecycleState: input.loop.lifecycleState, | ||
| executionStatus: input.executionStatus, | ||
| verificationStatus: input.verification.status, | ||
| receiptIntegrity: input.receiptIntegrity.state, | ||
| scopeStatus: scope.status, | ||
| testIntegrityStatus: testIntegrity.status, | ||
| mutationRequired: input.mutationRequired ?? input.loop.task.mutationMode === "edit", | ||
| changedFileCount: scope.changedFiles.length, | ||
| definitionOfDonePreSatisfied: input.definitionOfDonePreSatisfied, | ||
| evidenceContradicted: input.evidenceContradicted ?? input.verification.status === "contradicted", | ||
| unresolvedWorkCount: unresolvedWork.length, | ||
| }); | ||
| const handoffId = `vh_${createHash("sha256") | ||
| .update(`${input.loop.loopId}:${generatedAt}`) | ||
| .digest("hex") | ||
| .slice(0, 16)}`; | ||
| return { | ||
| schemaVersion: "1.0.0", | ||
| handoffId, | ||
| loopId: input.loop.loopId, | ||
| generatedAt, | ||
| task: { | ||
| title: input.loop.task.title, | ||
| objective: input.loop.task.objective, | ||
| }, | ||
| definitionOfDone: { | ||
| acceptanceCriteria: input.loop.task.acceptanceCriteria ?? [], | ||
| verificationPlan: input.loop.task.verificationPlan, | ||
| }, | ||
| outcome, | ||
| sourceStatus: { | ||
| status: input.loop.status, | ||
| lifecycleState: input.loop.lifecycleState, | ||
| }, | ||
| verification: { | ||
| status: toEvidenceStatus(input.verification.status), | ||
| summary: input.verification.summary, | ||
| checks: input.verification.steps.map(toCheck), | ||
| warnings: input.verification.warnings, | ||
| }, | ||
| requirements: input.requirements ?? [], | ||
| scope, | ||
| testIntegrity, | ||
| unresolvedWork, | ||
| ...(input.stopReason ? { stopReason: input.stopReason } : {}), | ||
| recovery: { | ||
| rollbackBoundaryAvailable: input.recovery?.rollbackBoundaryAvailable ?? false, | ||
| rollbackAttempted: input.recovery?.rollbackAttempted ?? false, | ||
| ...(input.recovery?.rollbackSucceeded !== undefined | ||
| ? { rollbackSucceeded: input.recovery.rollbackSucceeded } | ||
| : {}), | ||
| ...(input.recovery?.isolatedRef | ||
| ? { isolatedRef: input.recovery.isolatedRef } | ||
| : {}), | ||
| ...(input.recovery?.nextCommand | ||
| ? { nextCommand: input.recovery.nextCommand } | ||
| : {}), | ||
| summary: input.recovery?.summary ?? | ||
| "No explicit recovery evidence was supplied to the handoff builder.", | ||
| }, | ||
| usage: { | ||
| attempts: input.loop.attempts.length, | ||
| actualUsd: input.loop.cost.actualUsd, | ||
| ...(input.loop.cost.estimatedUsd !== undefined | ||
| ? { estimatedUsd: input.loop.cost.estimatedUsd } | ||
| : {}), | ||
| tokensIn: input.loop.cost.tokensIn, | ||
| tokensOut: input.loop.cost.tokensOut, | ||
| costProvenance: input.loop.cost.provenance ?? | ||
| "unavailable", | ||
| }, | ||
| receiptIntegrity: input.receiptIntegrity, | ||
| nextAction: input.nextAction, | ||
| }; | ||
| } |
@@ -7,3 +7,3 @@ export declare const MARTIN_TOOL_NAMES: readonly ["martin_run", "martin_inspect", "martin_status", "martin_doctor", "martin_plan", "martin_preflight", "martin_estimate", "martin_logs", "martin_pause", "martin_cancel", "martin_continue", "martin_list_runs", "martin_triage_runs", "martin_get_run", "martin_get_attempt", "martin_get_verification_results", "martin_run_dossier", "martin_dossier", "martin_eval", "martin_pr_summary", "martin_create_pr", "martin_review_pr"]; | ||
| export declare const MARTIN_PAID_REMOTE_TOOL_NAMES: readonly ["martin_doctor", "martin_plan", "martin_preflight", "martin_estimate", "martin_run", "martin_list_runs", "martin_triage_runs", "martin_get_run", "martin_get_verification_results", "martin_dossier", "martin_eval"]; | ||
| export declare const MARTIN_RESOURCE_URIS: readonly ["martin://server/health", "martin://runs/recent", "martin://runs/triage", "martin://runs/latest", "martin://runs/latest/summary", "martin://runs/latest/receipt", "martin://runs/latest/proof-card", "martin://runs/latest/budget-status", "martin://runs/latest/verifier-evidence", "martin://runs/latest/rollback-evidence", "martin://policies/current", "martin://repo/risk-map", "martin://verifiers/results", "martin://agent/next-step", "martin://agent/governance-status", "martin://agent/memory-summary", "martin://guides/mcp-usage", "martin://guides/agent-start", "martin://guides/command-map", "martin://guides/ide-onboarding", "martin://guides/operating-rules", "martin://guides/publish-readiness"]; | ||
| export declare const MARTIN_RESOURCE_URIS: readonly ["martin://server/health", "martin://runs/recent", "martin://runs/triage", "martin://runs/latest", "martin://runs/latest/summary", "martin://runs/latest/proof-card", "martin://runs/latest/budget-status", "martin://runs/latest/verifier-evidence", "martin://runs/latest/rollback-evidence", "martin://policies/current", "martin://repo/risk-map", "martin://verifiers/results", "martin://agent/next-step", "martin://agent/governance-status", "martin://agent/mode-status", "martin://agent/memory-summary", "martin://guides/mcp-usage", "martin://guides/agent-start", "martin://guides/command-map", "martin://guides/ide-onboarding", "martin://guides/operating-rules", "martin://guides/publish-readiness"]; | ||
| export declare const MARTIN_RESOURCE_TEMPLATE_URIS: readonly ["martin://runs/{loopId}", "martin://runs/{loopId}/dossier", "martin://runs/{loopId}/attempts/{attemptIndex}", "martin://runs/{loopId}/verification"]; | ||
@@ -10,0 +10,0 @@ export declare const MARTIN_PROMPT_NAMES: readonly ["martin_start", "martin_preflight", "martin_triage", "martin_resume", "martin_prove", "martin_release_check", "martin_governed_coding_kickoff", "martin_debug_failed_run", "martin_publish_readiness_review", "martin_triage_run_store", "safe_bug_fix", "write_tests_first", "small_refactor", "security_review", "pr_review", "release_check"]; |
@@ -87,3 +87,2 @@ import { createHash } from "node:crypto"; | ||
| "martin://runs/latest/summary", | ||
| "martin://runs/latest/receipt", | ||
| "martin://runs/latest/proof-card", | ||
@@ -98,2 +97,3 @@ "martin://runs/latest/budget-status", | ||
| "martin://agent/governance-status", | ||
| "martin://agent/mode-status", | ||
| "martin://agent/memory-summary", | ||
@@ -100,0 +100,0 @@ "martin://guides/mcp-usage", |
@@ -9,3 +9,2 @@ import type { ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; | ||
| readonly latestSummary: "martin://runs/latest/summary"; | ||
| readonly latestReceipt: "martin://runs/latest/receipt"; | ||
| readonly latestProofCard: "martin://runs/latest/proof-card"; | ||
@@ -26,4 +25,4 @@ readonly latestBudgetStatus: "martin://runs/latest/budget-status"; | ||
| readonly governanceStatus: "martin://agent/governance-status"; | ||
| readonly modeStatus: "martin://agent/mode-status"; | ||
| readonly memorySummary: "martin://agent/memory-summary"; | ||
| readonly modeStatus: "martin://agent/mode-status"; | ||
| }; | ||
@@ -30,0 +29,0 @@ export declare const MARTIN_RESOURCE_TEMPLATES: ResourceTemplate[]; |
+47
-95
| import { readFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { homedir } from "node:os"; | ||
| import { buildMartinDiscoveryMetadata } from "./discovery-metadata.js"; | ||
@@ -22,3 +22,2 @@ import { MARTIN_MCP_PACKAGE_VERSION } from "./package-version.js"; | ||
| latestSummary: "martin://runs/latest/summary", | ||
| latestReceipt: "martin://runs/latest/receipt", | ||
| latestProofCard: "martin://runs/latest/proof-card", | ||
@@ -39,4 +38,4 @@ latestBudgetStatus: "martin://runs/latest/budget-status", | ||
| governanceStatus: "martin://agent/governance-status", | ||
| memorySummary: "martin://agent/memory-summary", | ||
| modeStatus: "martin://agent/mode-status" | ||
| modeStatus: "martin://agent/mode-status", | ||
| memorySummary: "martin://agent/memory-summary" | ||
| }; | ||
@@ -106,13 +105,6 @@ export const MARTIN_RESOURCE_TEMPLATES = [ | ||
| { | ||
| uri: MARTIN_STATIC_RESOURCE_URIS.latestReceipt, | ||
| name: "martin_latest_receipt", | ||
| title: "Martin Latest Receipt", | ||
| description: "Canonical structured receipt for the latest run; use this before optional proof-card views.", | ||
| mimeType: "application/json" | ||
| }, | ||
| { | ||
| uri: MARTIN_STATIC_RESOURCE_URIS.latestProofCard, | ||
| name: "martin_latest_proof_card", | ||
| title: "Martin Latest Proof Card", | ||
| description: "Optional Markdown proof view derived from the latest receipt.", | ||
| description: "Small Markdown receipt showing what happened, what Martin prevented, and the next safe action.", | ||
| mimeType: "text/markdown" | ||
@@ -219,2 +211,9 @@ }, | ||
| { | ||
| uri: MARTIN_STATIC_RESOURCE_URIS.modeStatus, | ||
| name: "martin_mode_status", | ||
| title: "Martin Mode Status", | ||
| description: "Current MartinLoop working mode, whether it is inherited or explicitly configured, and how to switch it.", | ||
| mimeType: "application/json" | ||
| }, | ||
| { | ||
| uri: MARTIN_STATIC_RESOURCE_URIS.memorySummary, | ||
@@ -225,9 +224,2 @@ name: "martin_memory_summary", | ||
| mimeType: "application/json" | ||
| }, | ||
| { | ||
| uri: MARTIN_STATIC_RESOURCE_URIS.modeStatus, | ||
| name: "martin_mode_status", | ||
| title: "Martin Mode Status", | ||
| description: "Current Martin working mode (auto, plan, or edits) and consent state. Read before starting agent work to confirm whether the user expects autonomous execution, plan approval, or per-edit review.", | ||
| mimeType: "application/json" | ||
| } | ||
@@ -268,4 +260,2 @@ ]; | ||
| return jsonResource(input.uri, withDiscoveryMetadata(await buildLatestSummaryResource(context.runsRoot), context.runsRoot)); | ||
| case MARTIN_STATIC_RESOURCE_URIS.latestReceipt: | ||
| return jsonResource(input.uri, withDiscoveryMetadata(await buildLatestReceiptResource(context.runsRoot), context.runsRoot)); | ||
| case MARTIN_STATIC_RESOURCE_URIS.latestProofCard: | ||
@@ -293,3 +283,3 @@ return textResource(input.uri, "text/markdown", await buildLatestProofCardResource(context.runsRoot)); | ||
| case MARTIN_STATIC_RESOURCE_URIS.modeStatus: | ||
| return jsonResource(input.uri, withDiscoveryMetadata(await buildModeStatusResource(), context.runsRoot)); | ||
| return jsonResource(input.uri, withDiscoveryMetadata(await buildModeStatusResource(context.workingDirectory), context.runsRoot)); | ||
| case MARTIN_STATIC_RESOURCE_URIS.agentNextStep: | ||
@@ -443,40 +433,2 @@ return jsonResource(input.uri, withDiscoveryMetadata(await buildAgentNextStepResource(context.runsRoot), context.runsRoot)); | ||
| } | ||
| async function buildLatestReceiptResource(runsRoot) { | ||
| const latest = await loadLatestRunForCompactResource(runsRoot); | ||
| if (latest.empty || !latest.detail) { | ||
| return compactEmptyState("latest-receipt", runsRoot, latest.warnings); | ||
| } | ||
| const ledgerEvents = await readLedgerEvents(latest.detail); | ||
| const loop = latest.detail.loop; | ||
| const verification = buildVerificationHistorySnapshot(loop, ledgerEvents); | ||
| const preview = buildPersistedLoopPreview(loop); | ||
| return { | ||
| kind: "latest-receipt", | ||
| loop: preview, | ||
| task: { | ||
| title: loop.task?.title, | ||
| objective: loop.task?.objective, | ||
| verificationPlan: loop.task?.verificationPlan ?? [] | ||
| }, | ||
| receiptIntegrity: latest.detail.loop.receiptIntegrity, | ||
| verification: { | ||
| status: verification.latestVerification?.passed === true | ||
| ? "passed" | ||
| : verification.latestVerification?.passed === false | ||
| ? "failed" | ||
| : "unavailable", | ||
| summary: verification.latestVerification?.summary, | ||
| latest: verification.latestVerification, | ||
| count: verification.verificationCount | ||
| }, | ||
| whatHappened: loop.attempts.at(-1)?.summary ?? verification.latestVerification?.summary ?? loop.task?.objective, | ||
| whatMartinPrevented: describePrevention(loop), | ||
| nextSafeAction: inferAgentNextStep(loop, verification), | ||
| proofCard: { | ||
| artifactGuaranteed: false, | ||
| resource: MARTIN_STATIC_RESOURCE_URIS.latestProofCard | ||
| }, | ||
| warnings: [...latest.detail.warnings, ...verification.warnings] | ||
| }; | ||
| } | ||
| async function buildLatestBudgetStatusResource(runsRoot) { | ||
@@ -612,3 +564,3 @@ const latest = await loadLatestRunForCompactResource(runsRoot); | ||
| "What Martin prevented: unknown until a governed run executes.", | ||
| "Next safe action: call `martin_doctor`, run `npx martin-loop demo`, then inspect `martin://runs/latest/receipt` or `martin://runs/latest/summary`.", | ||
| "Next safe action: call `martin_doctor`, run `npx martin-loop demo`, then inspect `martin://runs/latest/summary`.", | ||
| "", | ||
@@ -720,2 +672,29 @@ "Estimate note: no cost or token savings are claimed without run evidence.", | ||
| } | ||
| async function buildModeStatusResource(workingDirectory) { | ||
| const configPath = join(homedir(), ".martin", "config.json"); | ||
| let config = {}; | ||
| try { | ||
| config = JSON.parse(await readFile(configPath, "utf8")); | ||
| } | ||
| catch { | ||
| // Fresh config means the implicit auto default is in effect. | ||
| } | ||
| const projectMode = config.projectOverrides?.[workingDirectory]; | ||
| const defaultMode = config.defaultMode ?? "auto"; | ||
| const effectiveMode = projectMode ?? defaultMode; | ||
| return { | ||
| kind: "mode-status", | ||
| effectiveMode, | ||
| defaultMode, | ||
| scope: projectMode ? "project" : config.defaultMode ? "global" : "implicit_default", | ||
| configured: Boolean(projectMode || config.defaultMode), | ||
| projectOverride: projectMode ?? null, | ||
| workingDirectory, | ||
| switchCommands: { | ||
| auto: "martin mode auto", | ||
| plan: "martin mode plan", | ||
| edits: "martin mode edits" | ||
| } | ||
| }; | ||
| } | ||
| function buildCurrentPoliciesResource(workingDirectory) { | ||
@@ -764,3 +743,3 @@ const signals = inspectRepoSignals(workingDirectory); | ||
| toolOrResource: "martin_triage_runs", | ||
| instruction: "Call `martin_triage_runs` and inspect the latest receipt before spending another attempt." | ||
| instruction: "Call `martin_triage_runs` and inspect the proof card before spending another attempt." | ||
| }; | ||
@@ -771,4 +750,4 @@ } | ||
| action: "prove_and_share", | ||
| toolOrResource: MARTIN_STATIC_RESOURCE_URIS.latestReceipt, | ||
| instruction: "Read the latest receipt and full dossier before sharing or promoting the result; generate a proof card only when a visual artifact is explicitly needed." | ||
| toolOrResource: MARTIN_STATIC_RESOURCE_URIS.latestProofCard, | ||
| instruction: "Read the proof card and full dossier before sharing or promoting the result." | ||
| }; | ||
@@ -798,3 +777,3 @@ } | ||
| 4. After execution, inspect \`${MARTIN_STATIC_RESOURCE_URIS.recentRuns}\`, \`martin://runs/{loopId}\`, and \`martin://runs/{loopId}/verification\`. | ||
| 5. For low-context agents, read \`${MARTIN_STATIC_RESOURCE_URIS.agentNextStep}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestSummary}\`, or \`${MARTIN_STATIC_RESOURCE_URIS.latestReceipt}\` before asking for full JSON. Treat \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\` as an optional derived view. | ||
| 5. For low-context agents, read \`${MARTIN_STATIC_RESOURCE_URIS.agentNextStep}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestSummary}\`, or \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\` before asking for full JSON. | ||
| 6. Read \`${MARTIN_STATIC_RESOURCE_URIS.triage}\` or call \`martin_triage_runs\` to prioritize which run needs attention first. | ||
@@ -806,3 +785,3 @@ 7. Use \`martin_debug_failed_run\` when a loop exits failed, budget-bound, or escalated. | ||
| - Tools: \`martin_run\`, \`martin_inspect\`, \`martin_status\`, \`martin_doctor\`, \`martin_preflight\`, \`martin_list_runs\`, \`martin_triage_runs\`, \`martin_get_run\`, \`martin_get_attempt\`, \`martin_get_verification_results\`, \`martin_run_dossier\` | ||
| - Static resources: \`${MARTIN_STATIC_RESOURCE_URIS.serverHealth}\`, \`${MARTIN_STATIC_RESOURCE_URIS.recentRuns}\`, \`${MARTIN_STATIC_RESOURCE_URIS.triage}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestSummary}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestReceipt}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestBudgetStatus}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestVerifierEvidence}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestRollbackEvidence}\`, \`${MARTIN_STATIC_RESOURCE_URIS.agentNextStep}\`, \`${MARTIN_STATIC_RESOURCE_URIS.mcpUsageGuide}\`, \`${MARTIN_STATIC_RESOURCE_URIS.agentStartGuide}\`, \`${MARTIN_STATIC_RESOURCE_URIS.publishReadinessGuide}\` | ||
| - Static resources: \`${MARTIN_STATIC_RESOURCE_URIS.serverHealth}\`, \`${MARTIN_STATIC_RESOURCE_URIS.recentRuns}\`, \`${MARTIN_STATIC_RESOURCE_URIS.triage}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestSummary}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestBudgetStatus}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestVerifierEvidence}\`, \`${MARTIN_STATIC_RESOURCE_URIS.latestRollbackEvidence}\`, \`${MARTIN_STATIC_RESOURCE_URIS.agentNextStep}\`, \`${MARTIN_STATIC_RESOURCE_URIS.governanceStatus}\`, \`${MARTIN_STATIC_RESOURCE_URIS.modeStatus}\`, \`${MARTIN_STATIC_RESOURCE_URIS.memorySummary}\`, \`${MARTIN_STATIC_RESOURCE_URIS.mcpUsageGuide}\`, \`${MARTIN_STATIC_RESOURCE_URIS.agentStartGuide}\`, \`${MARTIN_STATIC_RESOURCE_URIS.publishReadinessGuide}\` | ||
| - Resource templates: \`martin://runs/{loopId}\`, \`martin://runs/{loopId}/attempts/{attemptIndex}\`, \`martin://runs/{loopId}/verification\` | ||
@@ -834,3 +813,3 @@ - Prompts: \`martin_start\`, \`martin_preflight\`, \`martin_triage\`, \`martin_resume\`, \`martin_prove\`, \`martin_release_check\`, \`martin_governed_coding_kickoff\`, \`martin_debug_failed_run\`, \`martin_publish_readiness_review\`, \`martin_triage_run_store\` | ||
| 4. If the verifier failed, use prompt \`martin_debug_failed_run\` or \`martin_triage\`. | ||
| 5. If you need a shareable receipt, read \`${MARTIN_STATIC_RESOURCE_URIS.latestReceipt}\` first. Generate or inspect \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\` only when a visual artifact is explicitly required. | ||
| 5. If you need a shareable receipt, read \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\`. | ||
@@ -873,4 +852,3 @@ ## Install Profiles | ||
| - Need a quick receipt: \`${MARTIN_STATIC_RESOURCE_URIS.latestSummary}\` | ||
| - Need the structured receipt source of truth: \`${MARTIN_STATIC_RESOURCE_URIS.latestReceipt}\` | ||
| - Need an optional visual proof object: \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\` | ||
| - Need a shareable proof object: \`${MARTIN_STATIC_RESOURCE_URIS.latestProofCard}\` | ||
| - Need a full run review: \`martin_dossier\` | ||
@@ -958,28 +936,2 @@ - Need to decide whether to retry: \`martin_triage_runs\` | ||
| } | ||
| async function buildModeStatusResource() { | ||
| let currentMode = "auto"; | ||
| let modeConfigured = false; | ||
| try { | ||
| const config = JSON.parse(await readFile(join(homedir(), ".martin", "config.json"), "utf8")); | ||
| if (config.defaultMode) { | ||
| currentMode = config.defaultMode; | ||
| modeConfigured = true; | ||
| } | ||
| } | ||
| catch { /* fresh install — default to auto */ } | ||
| return { | ||
| kind: "mode-status", | ||
| currentMode, | ||
| modeConfigured, | ||
| modes: { | ||
| auto: "MartinLoop governs autonomously — estimate, preflight, run, receipt without per-step approval.", | ||
| plan: "MartinLoop shows the proposed plan before executing. Agent waits for user approval.", | ||
| edits: "MartinLoop shows each file change before writing. Maximum human control." | ||
| }, | ||
| agentGuidance: modeConfigured | ||
| ? `User has explicitly set mode to "${currentMode}". Respect this preference during the session.` | ||
| : "Mode not explicitly configured. Defaulting to auto. Suggest running `martin mode auto|plan|edits` to set a preference.", | ||
| changeCommand: "martin mode auto | plan | edits" | ||
| }; | ||
| } | ||
| function withDiscoveryMetadata(value, runsRoot) { | ||
@@ -986,0 +938,0 @@ return { |
@@ -1,2 +0,2 @@ | ||
| type ToolName = "martin_run" | "martin_inspect" | "martin_status" | "martin_doctor" | "martin_estimate" | "martin_plan" | "martin_preflight" | "martin_logs" | "martin_cancel" | "martin_pause" | "martin_continue" | "martin_list_runs" | "martin_triage_runs" | "martin_get_run" | "martin_get_attempt" | "martin_get_verification_results" | "martin_run_dossier" | "martin_dossier" | "martin_eval" | "martin_pr_summary" | "martin_create_pr" | "martin_review_pr"; | ||
| type ToolName = "martin_run" | "martin_inspect" | "martin_status" | "martin_doctor" | "martin_plan" | "martin_preflight" | "martin_estimate" | "martin_logs" | "martin_cancel" | "martin_pause" | "martin_continue" | "martin_list_runs" | "martin_triage_runs" | "martin_get_run" | "martin_get_attempt" | "martin_get_verification_results" | "martin_run_dossier" | "martin_dossier" | "martin_eval" | "martin_pr_summary" | "martin_create_pr" | "martin_review_pr"; | ||
| export { sanitizeToolErrorMessage } from "./tools/tool-errors.js"; | ||
@@ -3,0 +3,0 @@ export declare function validateToolInput(name: ToolName, args: unknown): unknown; |
@@ -17,4 +17,2 @@ import { existsSync, lstatSync, realpathSync } from "node:fs"; | ||
| return validateDoctorInput(args); | ||
| case "martin_estimate": | ||
| return validateEstimateInput(args); | ||
| case "martin_plan": | ||
@@ -24,2 +22,4 @@ return validatePlanInput(args); | ||
| return validatePreflightInput(args); | ||
| case "martin_estimate": | ||
| return validateEstimateInput(args); | ||
| case "martin_logs": | ||
@@ -259,18 +259,23 @@ return validateLogsInput(args); | ||
| } | ||
| function validatePreflightInput(args) { | ||
| return validateRunInput(args); | ||
| } | ||
| function validatePlanInput(args) { | ||
| return validateRunInput(args); | ||
| } | ||
| function validateEstimateInput(args) { | ||
| const record = requireObject(args); | ||
| assertAllowedKeys(record, ["objective", "engine", "budgetUsd", "fileScope"]); | ||
| assertAllowedKeys(record, ["objective", "engine", "budgetUsd", "fileScope", "workingDirectory"]); | ||
| const engine = optionalEnum(record.engine, "engine", MARTIN_ENGINE_VALUES); | ||
| const fileScope = normalizeSafePathPatterns(record.fileScope, "fileScope"); | ||
| return { | ||
| objective: requireString(record.objective, "objective"), | ||
| ...optionalEnumAsObject(record.engine, "engine", MARTIN_ENGINE_VALUES), | ||
| ...(engine ? { engine } : {}), | ||
| ...optionalPositiveNumber(record.budgetUsd, "budgetUsd"), | ||
| ...optionalStringArrayAsObject(record.fileScope, "fileScope") | ||
| ...(fileScope ? { fileScope } : {}), | ||
| ...(record.workingDirectory !== undefined | ||
| ? { workingDirectory: resolveSafeRepoRoot(requireString(record.workingDirectory, "workingDirectory")) } | ||
| : {}) | ||
| }; | ||
| } | ||
| function validatePreflightInput(args) { | ||
| return validateRunInput(args); | ||
| } | ||
| function validatePlanInput(args) { | ||
| return validateRunInput(args); | ||
| } | ||
| function validateLogsInput(args) { | ||
@@ -277,0 +282,0 @@ const record = requireObject(args); |
+23
-0
@@ -19,2 +19,3 @@ #!/usr/bin/env node | ||
| */ | ||
| import { type Server as NodeHttpServer } from "node:http"; | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
@@ -92,2 +93,24 @@ export declare function createMartinMcpServer(serverInfo?: { | ||
| }>>; | ||
| export interface MartinMcpHttpServerOptions { | ||
| host?: string; | ||
| port?: number; | ||
| path?: string; | ||
| } | ||
| export interface MartinMcpHttpServerHandle { | ||
| server: NodeHttpServer; | ||
| host: string; | ||
| port: number; | ||
| path: string; | ||
| endpoint: string; | ||
| close: () => Promise<void>; | ||
| } | ||
| export declare function parseMartinMcpServerArgs(argv: string[]): { | ||
| transport: "stdio"; | ||
| } | { | ||
| transport: "http"; | ||
| host: string; | ||
| port: number; | ||
| path: string; | ||
| }; | ||
| export declare function connectMartinMcpHttpServer(options?: MartinMcpHttpServerOptions): Promise<MartinMcpHttpServerHandle>; | ||
| export declare function isDirectExecutionEntry(entryPath: string | undefined, moduleUrl?: string): boolean; |
| import { type CodexHostPlatform } from "../vendor/adapters/index.js"; | ||
| import type { UpdateAvailableField } from "../vendor/contracts/index.js"; | ||
| import { type MartinEngine } from "./tool-support.js"; | ||
@@ -111,3 +112,4 @@ import { buildPolicyPackDefinition, type MartinPlanProposal, type MartinPolicyPack, type MartinRiskAssessment, type MartinRunContract } from "./workflow-governance.js"; | ||
| plan: MartinPlanProposal; | ||
| updateAvailable?: UpdateAvailableField; | ||
| } | ||
| export declare function martinPreflightTool(input: MartinPreflightInput): Promise<MartinPreflightOutput>; |
| import { probeCodexLaunch, resolveCliCommandAvailability } from "../vendor/adapters/index.js"; | ||
| import { resolveRunsRoot } from "../vendor/core/index.js"; | ||
| import { fetchSelectedMessage, getMcpInstalledVersion, isCooldownExpired, isDismissed, isNewerVersion, loadDeliveryRecord, recordShown, resolveDefaultLedgerPath, resolveRunsRoot, saveDeliveryRecord, } from "../vendor/core/index.js"; | ||
| import { MARTIN_MCP_PACKAGE_VERSION } from "../package-version.js"; | ||
| import { resolveSafeRepoRoot } from "../server-validation.js"; | ||
@@ -7,2 +8,5 @@ import { createSkippedCliAvailability, formatUsd, getEngineAvailability, resolveExecutionMode } from "./tool-support.js"; | ||
| export async function martinPreflightTool(input) { | ||
| // Fire delivery fetch early so it races with the rest of preflight work. | ||
| const mcpVersion = getMcpInstalledVersion() ?? MARTIN_MCP_PACKAGE_VERSION; | ||
| const deliveryFetchPromise = fetchSelectedMessage({ clientVersion: mcpVersion, clientKind: "mcp", trigger: "version_check" }, { timeoutMs: 3_000 }).catch(() => null); | ||
| const executionMode = resolveExecutionMode(); | ||
@@ -144,4 +148,40 @@ const workspaceRoot = resolveSafeRepoRoot(); | ||
| runContract, | ||
| plan | ||
| plan, | ||
| ...await resolvePreflightUpdateAvailable(deliveryFetchPromise, mcpVersion) | ||
| }; | ||
| } | ||
| async function resolvePreflightUpdateAvailable(fetchPromise, currentVersion) { | ||
| try { | ||
| const message = await fetchPromise; | ||
| if (!message) | ||
| return {}; | ||
| if (message.action.type !== "upgrade_mcp") | ||
| return {}; | ||
| const targetVersion = message.action.targetVersion; | ||
| if (!targetVersion) | ||
| return {}; | ||
| if (!isNewerVersion(currentVersion, targetVersion)) | ||
| return {}; | ||
| const ledgerPath = resolveDefaultLedgerPath(); | ||
| const record = loadDeliveryRecord(ledgerPath); | ||
| const nowMs = Date.now(); | ||
| if (!isCooldownExpired(record, nowMs)) | ||
| return {}; | ||
| if (isDismissed(record, message.id)) | ||
| return {}; | ||
| try { | ||
| saveDeliveryRecord(ledgerPath, recordShown(record, message, nowMs)); | ||
| } | ||
| catch { /* ledger write failure must not surface */ } | ||
| return { | ||
| updateAvailable: { | ||
| targetVersion, | ||
| kind: "mcp", | ||
| message: message.body | ||
| } | ||
| }; | ||
| } | ||
| catch { | ||
| return {}; | ||
| } | ||
| } |
@@ -5,3 +5,3 @@ import { buildArtifactSummary, buildBudgetSnapshot, buildCostSnapshot, buildEventSummaries, buildLoopPreview, buildVerificationSummary } from "./tool-support.js"; | ||
| import { assessRunRisk } from "./workflow-governance.js"; | ||
| import type { ReceiptIntegritySummary, ReceiptScope } from "../vendor/contracts/index.js"; | ||
| import type { ReceiptIntegritySummary, ReceiptScope, TerminationEnvelopeV1, VerifiedHandoffV1 } from "../vendor/contracts/index.js"; | ||
| export interface MartinRunDossierInput { | ||
@@ -49,2 +49,4 @@ file?: string; | ||
| control: Awaited<ReturnType<typeof readRunControlState>>; | ||
| terminationEnvelope?: TerminationEnvelopeV1; | ||
| verifiedHandoff: VerifiedHandoffV1; | ||
| format: "json" | "md" | "github-pr"; | ||
@@ -51,0 +53,0 @@ rendered?: string; |
@@ -7,2 +7,3 @@ import { buildArtifactSummary, buildBudgetSnapshot, buildCostSnapshot, buildEventSummaries, buildLoopPreview, resolveReceiptIntegrity, buildSuggestedPromptNames, buildSuggestedResourceUris, buildVerificationSummary } from "./tool-support.js"; | ||
| import { assessRunRisk, inspectRepoSignals } from "./workflow-governance.js"; | ||
| import { buildVerifiedHandoff } from "../vendor/core/index.js"; | ||
| export async function martinRunDossierTool(input) { | ||
@@ -58,2 +59,3 @@ const detail = await loadDetailedLoopRecord(input); | ||
| ...(detail.loop.receiptScope ? { receiptScope: detail.loop.receiptScope } : {}), | ||
| ...(detail.loop.terminationEnvelope ? { terminationEnvelope: detail.loop.terminationEnvelope } : {}), | ||
| attempts, | ||
@@ -77,3 +79,33 @@ verification, | ||
| }, | ||
| warnings: [...detail.warnings, ...verification.warnings] | ||
| warnings: [...detail.warnings, ...verification.warnings], | ||
| verifiedHandoff: buildVerifiedHandoff({ | ||
| loop: detail.loop, | ||
| receiptIntegrity: resolveReceiptIntegrity(detail.loop), | ||
| verification: { | ||
| status: verification.status, | ||
| summary: verification.summary ?? "No verification summary recorded.", | ||
| steps: [], | ||
| warnings: verification.warnings, | ||
| }, | ||
| scope: { | ||
| status: detail.loop.task?.allowedPaths?.length || | ||
| detail.loop.task?.deniedPaths?.length | ||
| ? "WITHIN_SCOPE" | ||
| : "NOT_EVALUATED", | ||
| allowedPaths: detail.loop.task?.allowedPaths ?? [], | ||
| deniedPaths: detail.loop.task?.deniedPaths ?? [], | ||
| changedFiles: [], | ||
| violations: [], | ||
| }, | ||
| testIntegrity: { | ||
| status: "NOT_EVALUATED", | ||
| verdict: "NOT_EVALUATED", | ||
| protectedPaths: [], | ||
| changedProtectedPaths: [], | ||
| findings: [], | ||
| summary: "Automatic test-integrity evidence was not recorded for this run.", | ||
| }, | ||
| unresolvedWork: verification.status === "passed" ? [] : [verification.summary ?? "Verification did not pass."], | ||
| nextAction: review.nextAction, | ||
| }), | ||
| }; | ||
@@ -87,4 +119,10 @@ if (output.format !== "json") { | ||
| const lines = [ | ||
| output.format === "github-pr" ? "## MartinLoop Run Dossier" : "# MartinLoop Run Dossier", | ||
| output.format === "github-pr" ? "## MartinLoop Verified Handoff" : "# MartinLoop Verified Handoff", | ||
| "", | ||
| `Outcome: ${output.verifiedHandoff.outcome}`, | ||
| `Definition of Done: ${output.verifiedHandoff.definitionOfDone.acceptanceCriteria.length} acceptance criterion/criteria`, | ||
| `Verification: ${output.verifiedHandoff.verification.status}`, | ||
| `Scope: ${output.verifiedHandoff.scope.status}`, | ||
| `Test Integrity: ${output.verifiedHandoff.testIntegrity.verdict}`, | ||
| "", | ||
| `Objective: ${output.loop.objective}`, | ||
@@ -91,0 +129,0 @@ `Run: ${output.loop.loopId}`, |
@@ -1,3 +0,2 @@ | ||
| import { type SpawnLike } from "../vendor/adapters/index.js"; | ||
| import { type RunStore, type RunMartinInput, type RunMartinResult } from "../vendor/core/index.js"; | ||
| import { type RunStore } from "../vendor/core/index.js"; | ||
| import type { LoopBudget, ReceiptScope } from "../vendor/contracts/index.js"; | ||
@@ -47,5 +46,3 @@ import { buildArtifactSummary, buildVerificationSummary, buildLoopPreview, type MartinEngine } from "./tool-support.js"; | ||
| } | ||
| export declare function __setProofModeVerifierSpawnImplForTests(spawnImpl?: SpawnLike): void; | ||
| export declare function __setRunStoreOverrideForTests(store?: RunStore): void; | ||
| export declare function __setRunMartinImplForTests(impl?: (input: RunMartinInput) => Promise<RunMartinResult>): void; | ||
| export declare function runLoopTool(input: RunLoopInput): Promise<RunLoopOutput>; |
@@ -1,2 +0,2 @@ | ||
| import { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, probeCodexLaunch, resolveCliCommandAvailability, createVerifierOnlyAdapter } from "../vendor/adapters/index.js"; | ||
| import { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, createStubDirectProviderAdapter, probeCodexLaunch, resolveCliCommandAvailability } from "../vendor/adapters/index.js"; | ||
| import { createFileRunStore, evaluateCostGovernor, resolveRunsRoot, runMartin } from "../vendor/core/index.js"; | ||
@@ -7,14 +7,6 @@ import { normalizeSafePathPatterns, resolveSafeRepoRoot } from "../server-validation.js"; | ||
| import { normalizeLoopBudget } from "./workflow-governance.js"; | ||
| let proofModeVerifierSpawnImpl; | ||
| let runStoreOverrideForTests; | ||
| let runMartinImpl = runMartin; | ||
| export function __setProofModeVerifierSpawnImplForTests(spawnImpl) { | ||
| proofModeVerifierSpawnImpl = spawnImpl; | ||
| } | ||
| export function __setRunStoreOverrideForTests(store) { | ||
| runStoreOverrideForTests = store; | ||
| } | ||
| export function __setRunMartinImplForTests(impl) { | ||
| runMartinImpl = impl ?? runMartin; | ||
| } | ||
| export async function runLoopTool(input) { | ||
@@ -71,7 +63,6 @@ const workingDirectory = resolveSafeRepoRoot(input.workingDirectory); | ||
| const adapter = !executionMode.liveMode | ||
| ? createVerifierOnlyAdapter({ | ||
| workingDirectory, | ||
| label: "Proof mode adapter (MARTIN_LIVE=false)", | ||
| ...(input.verifyTimeoutMs !== undefined ? { verifyTimeoutMs: input.verifyTimeoutMs } : {}), | ||
| ...(proofModeVerifierSpawnImpl ? { spawnImpl: proofModeVerifierSpawnImpl } : {}) | ||
| ? createStubDirectProviderAdapter({ | ||
| label: "Proof mode stub adapter (MARTIN_LIVE=false)", | ||
| providerId: "stub", | ||
| model: "stub" | ||
| }) | ||
@@ -107,3 +98,3 @@ : engine === "codex" | ||
| const budget = normalizeLoopBudget(partialBudget); | ||
| const result = await runMartinImpl({ | ||
| const result = await runMartin({ | ||
| workspaceId: input.workspaceId ?? "ws_mcp", | ||
@@ -110,0 +101,0 @@ projectId: input.projectId ?? "proj_mcp", |
@@ -1,2 +0,2 @@ | ||
| import type { LoopArtifact, LoopBudget, LoopCost, LoopEvent, LoopTask, ReceiptIntegritySummary, ReceiptScope } from "../vendor/contracts/index.js"; | ||
| import type { LoopArtifact, LoopBudget, LoopCost, LoopEvent, LoopTask, ReceiptIntegritySummary, ReceiptScope, TerminationEnvelopeV1 } from "../vendor/contracts/index.js"; | ||
| import { type LedgerEvent, type LoopAttemptRecord, type LoopRunRecord } from "../vendor/core/index.js"; | ||
@@ -17,2 +17,3 @@ export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini", "openai"]; | ||
| receiptScope?: ReceiptScope; | ||
| terminationEnvelope?: TerminationEnvelopeV1; | ||
| routingEconomics?: import("../vendor/contracts/index.js").RoutingEconomics; | ||
@@ -19,0 +20,0 @@ } |
@@ -108,7 +108,2 @@ import { accessSync, constants } from "node:fs"; | ||
| } | ||
| // Claude Code native installer places binary at %USERPROFILE%\.local\bin | ||
| const userProfile = process.env.USERPROFILE ?? process.env.HOMEPATH; | ||
| if (userProfile) { | ||
| dirs.push(join(userProfile, ".local", "bin")); | ||
| } | ||
| // Scoop | ||
@@ -170,9 +165,4 @@ if (home) { | ||
| function suggestInstallCommand(command) { | ||
| if (command === "claude") { | ||
| const installCmd = process.platform === "win32" | ||
| ? "irm https://claude.ai/install.ps1 | iex" | ||
| : "curl -fsSL https://claude.ai/install.sh | bash"; | ||
| return `Install with: ${installCmd}`; | ||
| } | ||
| const npmInstalls = { | ||
| claude: "npm install -g @anthropic-ai/claude-code", | ||
| codex: "npm install -g @openai/codex", | ||
@@ -444,3 +434,2 @@ gemini: "npm install -g @google/gemini-cli" | ||
| "martin://runs/latest/summary", | ||
| "martin://runs/latest/receipt", | ||
| "martin://runs/latest/proof-card", | ||
@@ -447,0 +436,0 @@ "martin://runs/latest/budget-status", |
@@ -14,4 +14,4 @@ /** | ||
| */ | ||
| import { readGitExecutionArtifacts, resolveGitRepositoryRoot, runSubprocess, runVerification } from "./cli-bridge.js"; | ||
| import { buildCodexExecArgs, DEFAULT_CODEX_CHATGPT_MODEL } from "./codex-launcher.js"; | ||
| import { readGitChangedFiles, readGitExecutionArtifacts, resolveGitRepositoryRoot, runSubprocess, runVerification } from "./cli-bridge.js"; | ||
| import { buildCodexExecArgs } from "./codex-launcher.js"; | ||
| import { createAdapterCapabilities, normalizeStructuredErrors, normalizeUsage } from "./runtime-support.js"; | ||
@@ -385,10 +385,6 @@ // --------------------------------------------------------------------------- | ||
| } | ||
| // result events contain aggregate usage that duplicates previously streamed | ||
| // assistant-message usage events — skip to avoid double-counting. | ||
| extractUsageFromEvent(event, terminate); | ||
| if (event.type === "result") { | ||
| finalResult = event; | ||
| } | ||
| else { | ||
| extractUsageFromEvent(event, terminate); | ||
| } | ||
| }; | ||
@@ -519,2 +515,10 @@ return { | ||
| const estimatedUsage = estimateUsage(prompt, options.model ?? options.command, options.command); | ||
| const repoRoot = request.context.repoRoot; | ||
| const gitRepoRoot = repoRoot ? resolveGitRepositoryRoot(repoRoot) : undefined; | ||
| // A governed run may begin in a deliberately dirty workspace. Capture that | ||
| // baseline so existing operator work is neither reported as this run's | ||
| // execution nor treated as scope creep. | ||
| const baselineChangedFiles = gitRepoRoot | ||
| ? new Set(await readGitChangedFiles(gitRepoRoot, 5_000, options.spawnImpl)) | ||
| : new Set(); | ||
| // Preflight: bail if projected cost exceeds remaining budget | ||
@@ -555,3 +559,4 @@ if (request.context.remainingBudgetUsd > 0) { | ||
| ...(stdinData === undefined ? {} : { stdinData }), | ||
| ...(streamingUsage ? { onStdoutChunk: streamingUsage.onChunk } : {}) | ||
| ...(streamingUsage ? { onStdoutChunk: streamingUsage.onChunk } : {}), | ||
| ...(request.signal !== undefined ? { signal: request.signal } : {}) | ||
| }); | ||
@@ -702,15 +707,26 @@ if (agentResult.terminationReason) { | ||
| const verificationStack = request.context.verificationStack; | ||
| const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, verificationStack, options.spawnImpl); | ||
| const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, verificationStack, options.spawnImpl, { | ||
| runId: request.loopId, | ||
| workspaceId: request.workspaceId, | ||
| cwd: workingDirectory, | ||
| }); | ||
| // Check for zero-diff (agent ran but made no file changes) | ||
| const repoRoot = request.context.repoRoot; | ||
| const gitRepoRoot = repoRoot ? resolveGitRepositoryRoot(repoRoot) : undefined; | ||
| let noDiff = false; | ||
| if (gitRepoRoot) { | ||
| noDiff = await checkNoDiff(gitRepoRoot, options.spawnImpl); | ||
| } | ||
| const postRunChangedFiles = gitRepoRoot | ||
| ? await readGitChangedFiles(gitRepoRoot, 5_000, options.spawnImpl) | ||
| : []; | ||
| const agentChangedFiles = postRunChangedFiles.filter((file) => !baselineChangedFiles.has(file)); | ||
| const noDiff = gitRepoRoot !== undefined && agentChangedFiles.length === 0; | ||
| // Extract structured errors from stderr/stdout for better failure context | ||
| const structuredErrors = normalizeStructuredErrors(extractStructuredErrors(agentResult.stderr, agentResult.stdout)); | ||
| const executionArtifacts = gitRepoRoot | ||
| const rawExecutionArtifacts = gitRepoRoot | ||
| ? await readGitExecutionArtifacts(gitRepoRoot, 5000, options.spawnImpl) | ||
| : undefined; | ||
| const executionArtifacts = rawExecutionArtifacts | ||
| ? { | ||
| ...(agentChangedFiles.length > 0 ? { changedFiles: agentChangedFiles } : {}), | ||
| ...(baselineChangedFiles.size === 0 && rawExecutionArtifacts.diffStats | ||
| ? { diffStats: rawExecutionArtifacts.diffStats } | ||
| : {}) | ||
| } | ||
| : undefined; | ||
| // Scope contract enforcement: check touched files against allowedPaths/deniedPaths | ||
@@ -720,9 +736,4 @@ let scopeViolations = []; | ||
| if (gitRepoRoot && (scopeCtx.allowedPaths?.length || scopeCtx.deniedPaths?.length)) { | ||
| const diffResult = await runSubprocess("git", ["diff", "--name-only", "HEAD"], { | ||
| cwd: gitRepoRoot, | ||
| timeoutMs: 5000, | ||
| spawnImpl: options.spawnImpl | ||
| }); | ||
| if (diffResult.exitCode === 0 && diffResult.stdout.trim()) { | ||
| const touchedFiles = diffResult.stdout.trim().split("\n").filter(Boolean); | ||
| if (agentChangedFiles.length > 0) { | ||
| const touchedFiles = agentChangedFiles; | ||
| const allowed = scopeCtx.allowedPaths ?? []; | ||
@@ -750,3 +761,3 @@ const denied = scopeCtx.deniedPaths ?? []; | ||
| usage, | ||
| verification: { passed: true, summary: verification.summary }, | ||
| verification, | ||
| ...(executionArtifacts | ||
@@ -796,3 +807,3 @@ ? { | ||
| try { | ||
| if (gitRepoRoot) { | ||
| if (gitRepoRoot && baselineChangedFiles.size === 0) { | ||
| await runSubprocess("git", ["restore", "--staged", "--worktree", "."], { | ||
@@ -814,3 +825,3 @@ cwd: gitRepoRoot, | ||
| usage, | ||
| verification: { passed: false, summary: verification.summary }, | ||
| verification, | ||
| ...(executionArtifacts | ||
@@ -918,3 +929,3 @@ ? { | ||
| const command = options.command ?? "codex"; | ||
| const launchModel = options.model ?? DEFAULT_CODEX_CHATGPT_MODEL; | ||
| const launchModel = options.model; | ||
| return createAgentCliAdapter({ | ||
@@ -1165,9 +1176,1 @@ command, | ||
| } | ||
| async function checkNoDiff(repoRoot, spawnImpl) { | ||
| const result = await runSubprocess("git", ["diff", "--name-only", "HEAD"], { | ||
| cwd: repoRoot, | ||
| timeoutMs: 5000, | ||
| spawnImpl | ||
| }); | ||
| return result.exitCode === 0 && result.stdout.trim().length === 0; | ||
| } |
| import { type ChildProcess, type SpawnOptions } from "node:child_process"; | ||
| import { diffStatsFromNumstat } from "./runtime-support.js"; | ||
| import type { VerifierExecutionBinding } from "../core/index.js"; | ||
| export type SpawnLike = (command: string, args?: readonly string[], options?: SpawnOptions) => ChildProcess; | ||
@@ -9,2 +10,4 @@ export interface SubprocessResult { | ||
| timedOut: boolean; | ||
| completed: boolean; | ||
| crashed: boolean; | ||
| /** | ||
@@ -34,2 +37,3 @@ * True when the subprocess was terminated early because its combined | ||
| warnings?: string[]; | ||
| binding: VerifierExecutionBinding; | ||
| } | ||
@@ -39,2 +43,4 @@ export interface VerificationStepOutcome { | ||
| launched: boolean; | ||
| completed: boolean; | ||
| crashed: boolean; | ||
| exitCode?: number; | ||
@@ -66,2 +72,4 @@ timedOut: boolean; | ||
| onStdoutChunk?: (chunk: Buffer, terminate: (reason: string) => void) => void; | ||
| /** Optional abort signal — kills the subprocess when aborted. */ | ||
| signal?: AbortSignal; | ||
| }): Promise<SubprocessResult>; | ||
@@ -72,3 +80,3 @@ export declare function runVerification(commands: string[], cwd: string, timeoutMs: number, verificationStack?: Array<{ | ||
| fastFail?: boolean; | ||
| }>, spawnImpl?: SpawnLike): Promise<VerificationOutcome>; | ||
| }>, spawnImpl?: SpawnLike, binding?: Omit<VerifierExecutionBinding, "commands">): Promise<VerificationOutcome>; | ||
| export declare function readGitExecutionArtifacts(repoRoot: string, timeoutMs: number, spawnImpl?: SpawnLike): Promise<{ | ||
@@ -75,0 +83,0 @@ changedFiles?: string[]; |
| import { spawn } from "node:child_process"; | ||
| import { delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path"; | ||
| import { basename, delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
@@ -35,3 +35,3 @@ import { diffStatsFromNumstat } from "./runtime-support.js"; | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| resolveOnce({ exitCode: 1, stdout: "", stderr: message, launched: false }); | ||
| resolveOnce({ exitCode: 1, stdout: "", stderr: message, launched: false, completed: false, crashed: true }); | ||
| return; | ||
@@ -57,2 +57,14 @@ } | ||
| }; | ||
| // Honour the harness abort signal — kill the subprocess immediately | ||
| if (options.signal !== undefined) { | ||
| const sig = options.signal; | ||
| if (sig.aborted) { | ||
| proc.kill("SIGTERM"); | ||
| } | ||
| else { | ||
| const onAbort = () => { proc.kill("SIGTERM"); }; | ||
| sig.addEventListener("abort", onAbort, { once: true }); | ||
| proc.on("close", () => { sig.removeEventListener("abort", onAbort); }); | ||
| } | ||
| } | ||
| proc.stdout?.on("data", (chunk) => { | ||
@@ -98,6 +110,7 @@ if (outputCapped || timedOut || terminationReason) { | ||
| clearTimeout(timer); | ||
| resolveOnce({ exitCode: 1, stdout: "", stderr: error.message, launched: false }); | ||
| resolveOnce({ exitCode: 1, stdout: "", stderr: error.message, launched: false, completed: false, crashed: true }); | ||
| }); | ||
| proc.on("close", (code) => { | ||
| clearTimeout(timer); | ||
| const completed = code !== null && !timedOut && !outputCapped && !terminationReason; | ||
| resolveOnce({ | ||
@@ -107,3 +120,5 @@ exitCode: code ?? 1, | ||
| stderr: Buffer.concat(stderrChunks).toString("utf8"), | ||
| launched: true | ||
| launched: true, | ||
| completed, | ||
| crashed: !completed && !timedOut && !outputCapped && !terminationReason, | ||
| }); | ||
@@ -123,3 +138,5 @@ }); | ||
| stderr: stdinError.message, | ||
| launched: false | ||
| launched: false, | ||
| completed: false, | ||
| crashed: true, | ||
| }); | ||
@@ -131,3 +148,3 @@ } | ||
| } | ||
| export async function runVerification(commands, cwd, timeoutMs, verificationStack, spawnImpl) { | ||
| export async function runVerification(commands, cwd, timeoutMs, verificationStack, spawnImpl, binding) { | ||
| const steps = verificationStack && verificationStack.length > 0 | ||
@@ -139,4 +156,10 @@ ? verificationStack.map((step) => ({ | ||
| : commands.map((command) => ({ command, fastFail: true })); | ||
| const executionBinding = { | ||
| runId: binding?.runId ?? "unbound", | ||
| workspaceId: binding?.workspaceId ?? "unbound", | ||
| cwd: binding?.cwd ?? cwd, | ||
| commands: steps.map((step) => step.command), | ||
| }; | ||
| if (steps.length === 0) { | ||
| return { passed: true, summary: "No verification commands specified.", steps: [] }; | ||
| return { passed: true, summary: "No verification commands specified.", steps: [], binding: executionBinding }; | ||
| } | ||
@@ -179,2 +202,4 @@ const failedSteps = []; | ||
| launched: result.launched, | ||
| completed: result.completed, | ||
| crashed: result.crashed, | ||
| exitCode: result.exitCode, | ||
@@ -190,2 +215,3 @@ timedOut: result.timedOut, | ||
| steps: stepOutcomes, | ||
| binding: executionBinding, | ||
| ...(warnings.length ? { warnings } : {}) | ||
@@ -200,3 +226,3 @@ }; | ||
| if (step.fastFail) { | ||
| return { passed: false, summary, steps: stepOutcomes, ...(warnings.length ? { warnings } : {}) }; | ||
| return { passed: false, summary, steps: stepOutcomes, binding: executionBinding, ...(warnings.length ? { warnings } : {}) }; | ||
| } | ||
@@ -211,2 +237,3 @@ failedSteps.push(step.command); | ||
| steps: stepOutcomes, | ||
| binding: executionBinding, | ||
| ...(warnings.length ? { warnings } : {}) | ||
@@ -219,2 +246,3 @@ }; | ||
| steps: stepOutcomes, | ||
| binding: executionBinding, | ||
| ...(warnings.length ? { warnings } : {}) | ||
@@ -380,15 +408,28 @@ }; | ||
| const scriptPathPattern = /["']?(?:%~?dp0%?|\$basedir)[\\/]([^"'\s]+\.[cm]?js)["']?/gi; | ||
| const matches = contents.matchAll(scriptPathPattern); | ||
| const matches = [...contents.matchAll(scriptPathPattern)]; | ||
| // Collect all candidate scripts that exist on disk. | ||
| const candidates = []; | ||
| for (const match of matches) { | ||
| const relativeScript = match[1]; | ||
| if (!relativeScript) { | ||
| if (!relativeScript) | ||
| continue; | ||
| } | ||
| const segments = relativeScript.split(/[\\/]+/u).filter(Boolean); | ||
| const resolvedScript = resolve(shimDir, ...segments); | ||
| if (existsSync(resolvedScript)) { | ||
| return resolvedScript; | ||
| candidates.push(resolvedScript); | ||
| } | ||
| } | ||
| return undefined; | ||
| if (candidates.length === 0) | ||
| return undefined; | ||
| // npm.cmd / npm.ps1 / npm.bat shims reference both npm-prefix.js and npm-cli.js. | ||
| // npm-cli.js is the semantic npm CLI entry point — never pick npm-prefix.js for | ||
| // these. Fail closed: if npm-cli.js is not among the resolved candidates, return | ||
| // undefined so the caller falls back to wrapper-shell behavior. | ||
| const shimBasename = basename(shimPath).toLowerCase(); | ||
| if (/^npm(\.(cmd|ps1|bat))?$/.test(shimBasename)) { | ||
| return candidates.find((p) => basename(p).toLowerCase() === "npm-cli.js"); | ||
| } | ||
| // For all other npm-installed executables (codex.cmd, claude.ps1, etc.), the | ||
| // shim wraps exactly one package bin target — use the first resolving candidate. | ||
| return candidates[0]; | ||
| } | ||
@@ -395,0 +436,0 @@ /** |
@@ -46,3 +46,63 @@ import { spawnSync } from "node:child_process"; | ||
| type SpawnSyncLike = typeof spawnSync; | ||
| export declare const DEFAULT_CODEX_CHATGPT_MODEL = "gpt-5.4"; | ||
| /** | ||
| * Outcome when the preflight probe confirms the working directory is writable. | ||
| * capabilitySource is always "probe" — result is measured, not assumed. | ||
| */ | ||
| export interface CodexSandboxPreflightOk { | ||
| ok: true; | ||
| effectiveSandbox: "read-only" | "workspace-write"; | ||
| capabilitySource: "probe"; | ||
| writableRoot: string; | ||
| } | ||
| /** | ||
| * Outcome when the working directory cannot be written but workspace-write | ||
| * was requested. This is a first-class typed failure — distinct from a | ||
| * provider-unavailable or environment-mismatch error. No model call has | ||
| * been attempted when this is returned. | ||
| */ | ||
| export interface CodexSandboxPreflightReadOnly { | ||
| ok: false; | ||
| code: "provider_sandbox_read_only"; | ||
| requestedCapability: "workspace-write"; | ||
| detectedCapability: "read-only"; | ||
| effectiveSandbox: "read-only"; | ||
| affectedPath: string; | ||
| writableRoot: string; | ||
| capabilitySource: "probe"; | ||
| remediation: string; | ||
| } | ||
| export type CodexSandboxPreflightOutcome = CodexSandboxPreflightOk | CodexSandboxPreflightReadOnly; | ||
| /** | ||
| * Probes whether the given directory is writable by the current process. | ||
| * | ||
| * Strategy: create a uniquely named temp file inside the directory, write a | ||
| * sentinel byte, then remove it. This is a real filesystem action — not an | ||
| * inference from binary metadata or launch-probe output. | ||
| * | ||
| * The probe leaves no file behind on either success or failure. | ||
| * | ||
| * Exported for unit testing with a real tmp directory. | ||
| */ | ||
| export declare function probeFilesystemWriteCapability(directory: string): { | ||
| writable: true; | ||
| } | { | ||
| writable: false; | ||
| reason: string; | ||
| }; | ||
| /** | ||
| * Checks whether the requested sandbox mode is achievable for the given | ||
| * working directory. The adapter receives `requestedSandbox` from CLI/core — | ||
| * it does not decide the mode itself. | ||
| * | ||
| * When `requestedSandbox` is "workspace-write" and the working directory is not | ||
| * writable, this function returns `provider_sandbox_read_only` before any model | ||
| * execution is attempted. | ||
| * | ||
| * When `requestedSandbox` is "read-only" no write probe is performed; the | ||
| * outcome is `ok: true, effectiveSandbox: "read-only"` immediately. | ||
| */ | ||
| export declare function checkCodexSandboxPreflight(input: { | ||
| requestedSandbox: "read-only" | "workspace-write"; | ||
| workingDirectory: string; | ||
| }): CodexSandboxPreflightOutcome; | ||
| export interface CodexProbeCandidateResult { | ||
@@ -49,0 +109,0 @@ path: string; |
| import { spawnSync } from "node:child_process"; | ||
| import { existsSync, readdirSync, statSync } from "node:fs"; | ||
| import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { dirname, extname, join, resolve } from "node:path"; | ||
| import { resolveNpmShimScript } from "./cli-bridge.js"; | ||
| const codexLaunchProbeCache = new Map(); | ||
| export const DEFAULT_CODEX_CHATGPT_MODEL = "gpt-5.4"; | ||
| /** | ||
| * Probes whether the given directory is writable by the current process. | ||
| * | ||
| * Strategy: create a uniquely named temp file inside the directory, write a | ||
| * sentinel byte, then remove it. This is a real filesystem action — not an | ||
| * inference from binary metadata or launch-probe output. | ||
| * | ||
| * The probe leaves no file behind on either success or failure. | ||
| * | ||
| * Exported for unit testing with a real tmp directory. | ||
| */ | ||
| export function probeFilesystemWriteCapability(directory) { | ||
| // Ensure the directory exists before probing. | ||
| try { | ||
| mkdirSync(directory, { recursive: true }); | ||
| } | ||
| catch (err) { | ||
| return { | ||
| writable: false, | ||
| reason: `Could not create directory ${directory}: ${err instanceof Error ? err.message : String(err)}` | ||
| }; | ||
| } | ||
| // Use mkdtempSync so the filename is guaranteed unique even under concurrent runs. | ||
| let tempDir; | ||
| try { | ||
| tempDir = mkdtempSync(join(directory, ".ml-write-probe-")); | ||
| const tempFile = join(tempDir, "capability.tmp"); | ||
| writeFileSync(tempFile, "\x01", { encoding: "binary", flag: "wx" }); | ||
| unlinkSync(tempFile); | ||
| return { writable: true }; | ||
| } | ||
| catch (err) { | ||
| return { | ||
| writable: false, | ||
| reason: err instanceof Error ? err.message : String(err) | ||
| }; | ||
| } | ||
| finally { | ||
| if (tempDir) { | ||
| try { | ||
| rmSync(tempDir, { recursive: true, force: true }); | ||
| } | ||
| catch { /* ignore cleanup errors */ } | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Checks whether the requested sandbox mode is achievable for the given | ||
| * working directory. The adapter receives `requestedSandbox` from CLI/core — | ||
| * it does not decide the mode itself. | ||
| * | ||
| * When `requestedSandbox` is "workspace-write" and the working directory is not | ||
| * writable, this function returns `provider_sandbox_read_only` before any model | ||
| * execution is attempted. | ||
| * | ||
| * When `requestedSandbox` is "read-only" no write probe is performed; the | ||
| * outcome is `ok: true, effectiveSandbox: "read-only"` immediately. | ||
| */ | ||
| export function checkCodexSandboxPreflight(input) { | ||
| const dir = resolve(input.workingDirectory); | ||
| if (input.requestedSandbox === "read-only") { | ||
| return { | ||
| ok: true, | ||
| effectiveSandbox: "read-only", | ||
| capabilitySource: "probe", | ||
| writableRoot: dir | ||
| }; | ||
| } | ||
| // workspace-write: run the actual filesystem probe. | ||
| const probeResult = probeFilesystemWriteCapability(dir); | ||
| if (probeResult.writable) { | ||
| return { | ||
| ok: true, | ||
| effectiveSandbox: "workspace-write", | ||
| capabilitySource: "probe", | ||
| writableRoot: dir | ||
| }; | ||
| } | ||
| return { | ||
| ok: false, | ||
| code: "provider_sandbox_read_only", | ||
| requestedCapability: "workspace-write", | ||
| detectedCapability: "read-only", | ||
| effectiveSandbox: "read-only", | ||
| affectedPath: dir, | ||
| writableRoot: dir, | ||
| capabilitySource: "probe", | ||
| remediation: `The working directory ${dir} is not writable by the current process. ` + | ||
| "Launch MartinLoop in a session with write access to that directory, or use " + | ||
| "`--sandbox read-only` for inspection-only work." | ||
| }; | ||
| } | ||
| const CODEX_LAUNCH_PROBE_PROMPT = [ | ||
@@ -20,3 +111,3 @@ "You are validating MartinLoop Codex host readiness.", | ||
| candidatePaths: input.candidatePaths, | ||
| model: input.model ?? DEFAULT_CODEX_CHATGPT_MODEL | ||
| model: input.model | ||
| }); | ||
@@ -93,2 +184,13 @@ } | ||
| } | ||
| function codexProbeCandidatePreference(path, diagnosis, platform) { | ||
| const hostPreference = codexProbePreference(diagnosis) * 10; | ||
| if (platform !== "win32" || diagnosis.installKind !== "windows_shim") { | ||
| return hostPreference; | ||
| } | ||
| // `where codex` commonly returns npm's extensionless POSIX shim before | ||
| // `codex.cmd`. Node cannot spawn that text shim directly on Windows, while | ||
| // the .cmd/.ps1 shim has a supported invocation path (or can be unwrapped). | ||
| const extension = extname(path).toLowerCase(); | ||
| return hostPreference + (extension === ".cmd" || extension === ".bat" || extension === ".ps1" ? 0 : 1); | ||
| } | ||
| function buildProbeCandidates(input) { | ||
@@ -111,3 +213,5 @@ const pathCandidates = normalizeCandidates(input.availability.candidatePaths ?? [input.availability.resolvedPath ?? input.availability.command]); | ||
| diagnosis, | ||
| preference: input.platform === "win32" ? codexProbePreference(diagnosis) : 0, | ||
| preference: input.platform === "win32" | ||
| ? codexProbeCandidatePreference(path, diagnosis, input.platform) | ||
| : 0, | ||
| discoveryIndex | ||
@@ -211,7 +315,7 @@ }; | ||
| return { | ||
| summary: `Codex launched with a model that is not supported for ChatGPT-account authentication. Use an explicit supported model such as \`${DEFAULT_CODEX_CHATGPT_MODEL}\` for governed Codex work.`, | ||
| summary: "Codex launched with a model that is not supported for ChatGPT-account authentication. Pass an explicit model that your ChatGPT account supports for governed Codex work.", | ||
| diagnosis: { | ||
| ...diagnosis, | ||
| warnings, | ||
| remediation: `Override the Codex launch model to a ChatGPT-account-supported option such as \`${DEFAULT_CODEX_CHATGPT_MODEL}\` before running governed Codex work.` | ||
| remediation: "Override the Codex launch model to a ChatGPT-account-supported option before running governed Codex work." | ||
| } | ||
@@ -282,2 +386,7 @@ }; | ||
| const extraArgs = options.extraArgs ?? []; | ||
| const sandboxArgs = sandbox === "workspace-write" | ||
| // In Codex CLI, --approve-for-me is the write-enabled automatic-review | ||
| // mode and is mutually exclusive with --sandbox workspace-write. | ||
| ? ["--approve-for-me"] | ||
| : ["--sandbox", sandbox]; | ||
| return [ | ||
@@ -289,4 +398,3 @@ "exec", | ||
| options.workingDirectory, | ||
| "--sandbox", | ||
| sandbox, | ||
| ...sandboxArgs, | ||
| "--json", | ||
@@ -366,6 +474,2 @@ "--color", | ||
| dirs.push(join(localAppData, "OpenAI", "Codex", "bin")); | ||
| // Claude Code native installer places binary at %USERPROFILE%\.local\bin | ||
| const userProfile = env.USERPROFILE ?? env.HOMEPATH; | ||
| if (userProfile) | ||
| dirs.push(join(userProfile, ".local", "bin")); | ||
| if (home) | ||
@@ -397,9 +501,4 @@ dirs.push(join(home, "scoop", "shims")); | ||
| function suggestInstall(command) { | ||
| if (command === "claude") { | ||
| const installCmd = process.platform === "win32" | ||
| ? "irm https://claude.ai/install.ps1 | iex" | ||
| : "curl -fsSL https://claude.ai/install.sh | bash"; | ||
| return `Install with: ${installCmd}`; | ||
| } | ||
| const installs = { | ||
| claude: "Install with: npm install -g @anthropic-ai/claude-code", | ||
| codex: "Install with: npm install -g @openai/codex", | ||
@@ -482,3 +581,3 @@ gemini: "Install with: npm install -g @google/gemini-cli" | ||
| workingDirectory: input.workingDirectory, | ||
| model: input.model ?? DEFAULT_CODEX_CHATGPT_MODEL, | ||
| model: input.model, | ||
| mode: "probe" | ||
@@ -485,0 +584,0 @@ }); |
| export { createDirectProviderAdapter, type DirectProviderAdapterOptions } from "./direct-provider.js"; | ||
| export { createStubDirectProviderAdapter, type StubDirectProviderAdapterOptions } from "./stub-direct-provider.js"; | ||
| export { createStubAgentCliAdapter, type StubAgentCliAdapterOptions } from "./stub-agent-cli.js"; | ||
| export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, type AgentCliAdapterOptions, type ClaudeCliAdapterOptions, type CodexCliAdapterOptions, type GeminiCliAdapterOptions, type CliArgsBuilder } from "./claude-cli.js"; | ||
| export { createVerifierOnlyAdapter, type VerifierOnlyAdapterOptions } from "./verifier-only.js"; | ||
| export { createOpenAiCompatibleAdapter, resolveOpenAiCompatibleRuntimeConfig, type OpenAiCompatibleAdapterOptions } from "./openai-compatible.js"; | ||
| export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability, type CliCommandAvailability, type CodexHostDiagnosis, type CodexHostPlatform, type CodexLaunchProbeResult } from "./codex-launcher.js"; | ||
| export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability, type CliCommandAvailability, type CodexHostDiagnosis, type CodexHostPlatform, type CodexLaunchProbeResult, checkCodexSandboxPreflight, probeFilesystemWriteCapability, type CodexSandboxPreflightOk, type CodexSandboxPreflightOutcome, type CodexSandboxPreflightReadOnly } from "./codex-launcher.js"; | ||
| export { createSpawnPlan, type SpawnLike, type SpawnPlan, type SubprocessResult, type VerificationOutcome } from "./cli-bridge.js"; |
| export { createDirectProviderAdapter } from "./direct-provider.js"; | ||
| export { createStubDirectProviderAdapter } from "./stub-direct-provider.js"; | ||
| export { createStubAgentCliAdapter } from "./stub-agent-cli.js"; | ||
| export { createAgentCliAdapter, createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter } from "./claude-cli.js"; | ||
| export { createVerifierOnlyAdapter } from "./verifier-only.js"; | ||
| export { createOpenAiCompatibleAdapter, resolveOpenAiCompatibleRuntimeConfig } from "./openai-compatible.js"; | ||
| export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability } from "./codex-launcher.js"; | ||
| export { detectCodexHostPlatform, diagnoseCodexHost, probeCodexLaunch, resolveCliCommandAvailability, checkCodexSandboxPreflight, probeFilesystemWriteCapability } from "./codex-launcher.js"; | ||
| export { createSpawnPlan } from "./cli-bridge.js"; |
@@ -72,2 +72,19 @@ /** | ||
| } | ||
| function normalizeOpenAiCompatibleUsage(input) { | ||
| const hasKnownPricing = KNOWN_MODEL_PRICING[input.model] !== undefined; | ||
| const pricing = KNOWN_MODEL_PRICING[input.model] ?? { | ||
| inputPer1K: FALLBACK_INPUT_PER_1K, | ||
| outputPer1K: FALLBACK_OUTPUT_PER_1K | ||
| }; | ||
| const actualUsd = (input.tokensIn / 1000) * pricing.inputPer1K + | ||
| (input.tokensOut / 1000) * pricing.outputPer1K; | ||
| const provenance = input.usageWasFullyProviderReported && hasKnownPricing ? "actual" : "estimated"; | ||
| return normalizeUsage({ | ||
| actualUsd, | ||
| ...(provenance === "estimated" ? { estimatedUsd: actualUsd } : {}), | ||
| tokensIn: input.tokensIn, | ||
| tokensOut: input.tokensOut, | ||
| provenance | ||
| }); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
@@ -176,2 +193,3 @@ // Prompt builder | ||
| const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); | ||
| const NON_RETRYABLE_STATUS = new Set([400, 401, 403]); | ||
| const endpoint = `${baseUrl}/v1/chat/completions`; | ||
@@ -181,2 +199,3 @@ let responseText = ""; | ||
| let tokensOut = 0; | ||
| let usageWasFullyProviderReported = false; | ||
| const headers = { "Content-Type": "application/json" }; | ||
@@ -213,2 +232,13 @@ if (apiKey) | ||
| const errMsg = body.error?.message ?? `HTTP ${res.status}`; | ||
| // Fail immediately on non-retryable errors (auth, bad request) | ||
| if (NON_RETRYABLE_STATUS.has(res.status)) { | ||
| return { | ||
| status: "failed", | ||
| summary: `${model} API error: ${errMsg}`, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }), | ||
| verification: { passed: false, summary: "API call failed before verifier." }, | ||
| failure: { message: errMsg, classHint: "infrastructure_error" } | ||
| }; | ||
| } | ||
| // Retry on transient errors | ||
| if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES - 1) { | ||
@@ -230,4 +260,9 @@ lastError = errMsg; | ||
| if (body.usage) { | ||
| tokensIn = body.usage.prompt_tokens ?? tokensIn; | ||
| tokensOut = body.usage.completion_tokens ?? 0; | ||
| const providerPromptTokens = body.usage.prompt_tokens; | ||
| const providerCompletionTokens = body.usage.completion_tokens; | ||
| usageWasFullyProviderReported = | ||
| typeof providerPromptTokens === "number" && | ||
| typeof providerCompletionTokens === "number"; | ||
| tokensIn = providerPromptTokens ?? tokensIn; | ||
| tokensOut = providerCompletionTokens ?? Math.ceil(responseText.length / CHARS_PER_TOKEN); | ||
| } | ||
@@ -275,3 +310,8 @@ else { | ||
| summary: `${model} returned an empty response.`, | ||
| usage: normalizeUsage({ actualUsd: 0, tokensIn, tokensOut: 0, provenance: "actual" }), | ||
| usage: normalizeOpenAiCompatibleUsage({ | ||
| model, | ||
| tokensIn, | ||
| tokensOut: 0, | ||
| usageWasFullyProviderReported | ||
| }), | ||
| verification: { passed: false, summary: "Empty response — nothing to verify." }, | ||
@@ -282,3 +322,7 @@ failure: { message: "empty_response" } | ||
| // Run verification | ||
| const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack); | ||
| const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack, undefined, { | ||
| runId: request.loopId, | ||
| workspaceId: request.workspaceId, | ||
| cwd: workingDirectory, | ||
| }); | ||
| const execution = { | ||
@@ -289,7 +333,2 @@ changedFiles: hasVerificationSteps | ||
| }; | ||
| const pricing = KNOWN_MODEL_PRICING[model] ?? { | ||
| inputPer1K: FALLBACK_INPUT_PER_1K, | ||
| outputPer1K: FALLBACK_OUTPUT_PER_1K | ||
| }; | ||
| const actualUsd = (tokensIn / 1000) * pricing.inputPer1K + (tokensOut / 1000) * pricing.outputPer1K; | ||
| return { | ||
@@ -300,7 +339,7 @@ status: verification.passed ? "completed" : "failed", | ||
| : `${model} completed but verifier failed: ${verification.summary}`, | ||
| usage: normalizeUsage({ | ||
| actualUsd, | ||
| usage: normalizeOpenAiCompatibleUsage({ | ||
| model, | ||
| tokensIn, | ||
| tokensOut, | ||
| provenance: "actual" | ||
| usageWasFullyProviderReported | ||
| }), | ||
@@ -307,0 +346,0 @@ verification, |
| export type DiffVisibilityLevel = "none" | "git" | "adapter_reported"; | ||
| export type VerifierCompatibility = "full" | "verify_only" | "unsupported"; | ||
| export type VerifierCompatibility = "full" | "proof" | "unsupported"; | ||
| export type SandboxExpectation = "host_process" | "workspace_write" | "provider_managed" | "not_applicable"; | ||
@@ -4,0 +4,0 @@ export type LaunchReadiness = "path_lookup" | "configured_endpoint" | "built_in"; |
@@ -0,7 +1,17 @@ | ||
| /** | ||
| * Contracts for the Martin Loop agentic system. | ||
| * | ||
| * This module defines the core type contracts and data structures for autonomous agent loop management, | ||
| * including loop lifecycle states, task definitions, budget tracking, cost accounting, verification, | ||
| * patch decisions, telemetry, and governance policies. It provides the public API surface for | ||
| * creating and managing loop records, handling events, validating batches, and tracking | ||
| * portfolio snapshots and routing economics. | ||
| */ | ||
| import type { TerminationEnvelopeV1 } from "./exits.js"; | ||
| export type LoopStatus = "queued" | "running" | "verifying" | "completed" | "failed" | "exited"; | ||
| export type LoopLifecycleState = "created" | "running" | "verifying" | "completed" | "budget_exit" | "diminishing_returns" | "stuck_exit" | "human_escalation"; | ||
| export type LoopLifecycleState = "created" | "running" | "verifying" | "completed" | "budget_exit" | "diminishing_returns" | "stuck_exit" | "human_escalation" | "wall_clock" | "error_threshold" | "external_event"; | ||
| export declare const FAILURE_CLASSES: readonly ["logic_error", "hallucination", "syntax_error", "type_error", "test_regression", "scope_creep", "no_progress", "repo_grounding_failure", "verification_failure", "environment_mismatch", "budget_pressure", "safety_leash_blocked", "sandbox_write_blocked"]; | ||
| export type FailureClass = (typeof FAILURE_CLASSES)[number]; | ||
| export type InterventionType = "compress_context" | "change_model" | "tighten_task" | "switch_adapter" | "run_verifier" | "escalate_human" | "stop_loop"; | ||
| export type LoopEventType = "run.started" | "attempt.started" | "attempt.completed" | "failure.classified" | "intervention.selected" | "verification.completed" | "budget.updated" | "run.completed"; | ||
| export type LoopEventType = "run.started" | "attempt.started" | "attempt.completed" | "failure.classified" | "intervention.selected" | "verification.completed" | "budget.updated" | "run.completed" | "run.terminated"; | ||
| export interface LoopTask { | ||
@@ -15,2 +25,4 @@ title: string; | ||
| mutationMode?: MutationMode; | ||
| /** Explicit task-authority evidence that all definition-of-done criteria were satisfied before execution. */ | ||
| definitionOfDonePreSatisfied?: boolean; | ||
| executionProfile?: ExecutionProfile; | ||
@@ -77,2 +89,12 @@ allowedNetworkDomains?: string[]; | ||
| runsRoot?: string; | ||
| /** Sandbox mode the mission requested before execution. */ | ||
| requestedSandbox?: "read-only" | "workspace-write" | "danger-full-access"; | ||
| /** Effective sandbox capability detected by pre-run filesystem probe. */ | ||
| effectiveSandbox?: "read-only" | "workspace-write" | "unknown"; | ||
| /** Absolute path that the filesystem write probe tested. */ | ||
| writableRoot?: string; | ||
| /** How the effective sandbox was determined. */ | ||
| capabilitySource?: "probe" | "configured" | "unknown"; | ||
| /** Enforced demo changes (DEMO.md-only enforcement output). */ | ||
| demoChangedFiles?: string[]; | ||
| } | ||
@@ -161,2 +183,4 @@ export type ReceiptIntegrityState = "verified" | "unsigned" | "tamper_detected" | "relocated" | "material_missing" | "selector_noncanonical"; | ||
| routingEconomics?: RoutingEconomics; | ||
| /** Canonical termination identity written by finishFromEvaluation. Present when run ended via exit policy. */ | ||
| terminationEnvelope?: TerminationEnvelopeV1; | ||
| } | ||
@@ -181,2 +205,3 @@ export interface LoopRecordDraft { | ||
| receiptIntegrity?: ReceiptIntegritySummary; | ||
| terminationEnvelope?: TerminationEnvelopeV1; | ||
| } | ||
@@ -417,1 +442,15 @@ export type { MartinErrorCategory, MartinOutputMode, MartinRunListFilters, MartinRunSelector } from "./operator.js"; | ||
| export type { CircuitBreakDecision, TrajectoryAssessment, TrajectorySignal } from "./trajectory.js"; | ||
| export { EXIT_KINDS, EXIT_POLICY_VERSION, EXIT_EVALUATION_VERSION, EXIT_SIGNAL_VERSION, TERMINATION_ENVELOPE_VERSION } from "./exits.js"; | ||
| export type { ExitKind, ExitEvaluationPhase, ExternalEventDisposition, ExternalExitEvent, ExitPolicyV1, ExitSignalV1, ExitSnapshotV1, ExitMatchV1, ExitEvaluationV1, TerminationEnvelopeV1 } from "./exits.js"; | ||
| export { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, DELIVERY_RECORD_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION } from "./delivery.js"; | ||
| export type { ActionType, DeliveryMessage, DeliveryRecord, MessageKind, MessageSelectionResponse, UpdateAvailableField } from "./delivery.js"; | ||
| export { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "./context-shadow.js"; | ||
| export type { ContextC5EnvelopeV1, ContextEvidence, ContextShadowDecisionV1, ContextShadowManifestV1, ContextShadowSegmentInput, ContextShadowSegmentKind } from "./context-shadow.js"; | ||
| export { CONTEXT_MANIFEST_VERSION, CONTEXT_LEDGER_VERSION } from "./context-manifest.js"; | ||
| export type { ContinuationCheckpoint, ContextBudget, ContextCandidateDecision, ContextFaultRequest, ContextFaultResult, ContextKind, ContextLedgerEntry, ContextManifest, ContextObject, ContextPolicy, ContextPriority, ContextSensitivity, ContextTrust, TaskItem, UsageEvidence } from "./context-manifest.js"; | ||
| export { EVIDENCE_STATUSES, TEST_INTEGRITY_STATUSES, TEST_INTEGRITY_VERDICTS, VERIFIED_HANDOFF_OUTCOMES, } from "./verified-handoff.js"; | ||
| export type { EvidenceStatus, TestIntegrityStatus, TestIntegrityVerdict, VerifiedHandoffCheckV1, VerifiedHandoffOutcome, VerifiedHandoffRecoveryV1, VerifiedHandoffRequirementV1, VerifiedHandoffScopeV1, VerifiedHandoffTestIntegrityV1, VerifiedHandoffV1, } from "./verified-handoff.js"; | ||
| export { HANDOFF_SCHEMA_VERSION } from "./context-handoff.js"; | ||
| export type { ChainIntegrityState, ContextCircuitBreakResult, ContextExclusionDecision, ContextHandoffArtifact, ContextHandoffClaim, ContextHandoffReceipt, ContextHandoffVerification, HandoffClaimState } from "./context-handoff.js"; | ||
| export { MISSION_SCHEMA_VERSION, MISSION_STATUSES, ALLOWED_MISSION_TRANSITIONS, createMissionRecord, isMissionTransitionAllowed } from './mission.js'; | ||
| export type { MissionStatus, MissionDecision, MissionBudget, MissionCost, MissionRunLink, MissionRunRole, MissionApproval, MissionOutcome, MissionEvent, MissionEventKind, MissionRecord, MissionDraft } from './mission.js'; |
@@ -0,1 +1,10 @@ | ||
| /** | ||
| * Contracts for the Martin Loop agentic system. | ||
| * | ||
| * This module defines the core type contracts and data structures for autonomous agent loop management, | ||
| * including loop lifecycle states, task definitions, budget tracking, cost accounting, verification, | ||
| * patch decisions, telemetry, and governance policies. It provides the public API surface for | ||
| * creating and managing loop records, handling events, validating batches, and tracking | ||
| * portfolio snapshots and routing economics. | ||
| */ | ||
| export const FAILURE_CLASSES = [ | ||
@@ -56,2 +65,3 @@ "logic_error", | ||
| ...(draft.receiptIntegrity ? { receiptIntegrity: draft.receiptIntegrity } : {}), | ||
| ...(draft.terminationEnvelope ? { terminationEnvelope: draft.terminationEnvelope } : {}), | ||
| ...(draft.teamId ? { teamId: draft.teamId } : {}) | ||
@@ -214,2 +224,4 @@ }; | ||
| return current === "failed" ? "failed" : "completed"; | ||
| case "run.terminated": | ||
| return "exited"; | ||
| default: | ||
@@ -228,1 +240,13 @@ return current; | ||
| export { cloneCircuitBreakDecision, cloneTrajectoryAssessment } from "./trajectory.js"; | ||
| export { EXIT_KINDS, EXIT_POLICY_VERSION, EXIT_EVALUATION_VERSION, EXIT_SIGNAL_VERSION, TERMINATION_ENVELOPE_VERSION } from "./exits.js"; | ||
| // ─── R4 Delivery — M1 Contract ────────────────────────────────────────────── | ||
| export { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, DELIVERY_RECORD_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION } from "./delivery.js"; | ||
| // ─── Context Shadow — A-CTX-0 ──────────────────────────────────────────────── | ||
| export { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "./context-shadow.js"; | ||
| // ─── Context Runtime — A-CTX-1 ─────────────────────────────────────────────── | ||
| export { CONTEXT_MANIFEST_VERSION, CONTEXT_LEDGER_VERSION } from "./context-manifest.js"; | ||
| // ─── Track A — Verified Handoff ─────────────────────────────────────────────── | ||
| export { EVIDENCE_STATUSES, TEST_INTEGRITY_STATUSES, TEST_INTEGRITY_VERDICTS, VERIFIED_HANDOFF_OUTCOMES, } from "./verified-handoff.js"; | ||
| // ─── Context Handoff — A-CTX-2 ─────────────────────────────────────────────── | ||
| export { HANDOFF_SCHEMA_VERSION } from "./context-handoff.js"; | ||
| export { MISSION_SCHEMA_VERSION, MISSION_STATUSES, ALLOWED_MISSION_TRANSITIONS, createMissionRecord, isMissionTransitionAllowed } from './mission.js'; |
@@ -1,2 +0,2 @@ | ||
| export declare const MARTIN_ERROR_CATEGORIES: readonly ["invalid_input", "environment", "auth", "not_found", "store_unreadable", "verification_failed", "policy_blocked", "budget_exit", "transient"]; | ||
| export declare const MARTIN_ERROR_CATEGORIES: readonly ["invalid_input", "environment", "auth", "not_found", "store_unreadable", "verification_failed", "policy_blocked", "budget_exit", "transient", "install_failed"]; | ||
| export type MartinErrorCategory = (typeof MARTIN_ERROR_CATEGORIES)[number]; | ||
@@ -6,2 +6,3 @@ export type MartinOutputMode = "human" | "json" | "quiet"; | ||
| runsDir?: string; | ||
| workspaceId?: string; | ||
| file?: string; | ||
@@ -14,2 +15,3 @@ loopId?: string; | ||
| runsDir?: string; | ||
| workspaceId?: string; | ||
| limit?: number; | ||
@@ -16,0 +18,0 @@ status?: string; |
@@ -10,3 +10,4 @@ export const MARTIN_ERROR_CATEGORIES = [ | ||
| "budget_exit", | ||
| "transient" | ||
| "transient", | ||
| "install_failed" | ||
| ]; |
@@ -1,2 +0,2 @@ | ||
| import { type ApprovalPolicy, type CostProvenance, type ExecutionProfile, type FailureClass, type InterventionType, type LoopArtifact, type LoopAttempt, type LoopBudget, type ProviderUsageSettlement, type MutationMode, type LoopRecord, type LoopTask, type ReceiptScope } from "../contracts/index.js"; | ||
| import { type ApprovalPolicy, type ContextHandoffReceipt, type CostProvenance, type ExecutionProfile, type FailureClass, type InterventionType, type LoopArtifact, type LoopAttempt, type LoopBudget, type ProviderUsageSettlement, type MutationMode, type LoopRecord, type LoopTask, type ReceiptScope } from "../contracts/index.js"; | ||
| import { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, type ExitDecision } from "./policy.js"; | ||
@@ -7,2 +7,5 @@ import { evaluateChangeApprovalLeash, evaluateFilesystemLeash, evaluateSecretLeash, redactSecretsFromText, resolveExecutionProfile, evaluateVerificationLeash } from "./leash.js"; | ||
| import { type RunStore } from "./persistence/index.js"; | ||
| import { type ExitPolicyOverrides } from "./exits.js"; | ||
| import { type ExitSignalSource } from "./exit-signal.js"; | ||
| import { type VerifierExecutionBinding } from "./verified-handoff.js"; | ||
| export type { ApprovalPolicy, BudgetPreflightEstimate, BudgetSettlement, CostProvenance, EvidenceVector, ExecutionProfile, FailureClass, InterventionType, PatchDecision, PatchDecisionArtifact, PatchDecisionReasonCode, PatchScore, MutationMode, RollbackBoundaryArtifact, RollbackBoundaryStrategy, RollbackFileSnapshot, RollbackOutcomeArtifact, RollbackOutcomeStatus, PolicyPhase, CallStage, AgentRole, FirstDelta, RoutingEconomics } from "../contracts/index.js"; | ||
@@ -18,2 +21,4 @@ export { classifyFailure, computeEvidenceVector, evaluatePatchDecision, evaluateCostGovernor, evaluateBudgetPreflight, inferExit, nextPolicyPhase, policyPhaseToLifecycleState, scorePatchDecision, selectRecoveryRecipe, evaluateVerificationLeash, evaluateFilesystemLeash, evaluateChangeApprovalLeash, evaluateSecretLeash, resolveExecutionProfile, redactSecretsFromText, buildRepoGroundingIndex, loadOrBuildRepoGroundingIndex, queryRepoGroundingIndex, scanPatchForGroundingViolations, captureRollbackBoundary, listAttemptChangedFilesSinceBoundary, restoreRollbackBoundary }; | ||
| export { assessTrajectory, decideCircuitBreak } from "./trajectory.js"; | ||
| export { calculateAvoidedUsd, calculateLoopAvoidedUsd } from "./savings.js"; | ||
| export type { AvoidedUsdInput } from "./savings.js"; | ||
| export { classifyRoute, evaluatePreworkBurnPolicy, resolveModelForTier, selectBestEngine } from "./routing.js"; | ||
@@ -29,2 +34,4 @@ export type { RouteDecision, RouteClassificationInput, AvailableEngine } from "./routing.js"; | ||
| export type { PromptPacket, CompilerAdapterRequest } from "./compiler.js"; | ||
| export { buildVerifiedHandoff, resolveVerifiedHandoffOutcome, toTestIntegrityVerdict, verifierActuallyPassed, } from "./verified-handoff.js"; | ||
| export type { BoundVerifierEvidence, BuildVerifiedHandoffInput, VerifierExecutionBinding, } from "./verified-handoff.js"; | ||
| export { createFileRunStore, makeLedgerEvent, readAllLoopRecords, readLatestLoopRecord, readLatestLoopRecordFromFile, readLoopRecordsFromFile, resolveRunsRoot, resolveReceiptIntegrityPath, verifyReceiptIntegrityFromFiles, writeReceiptIntegrityMaterial } from "./persistence/index.js"; | ||
@@ -34,2 +41,10 @@ export type { AttemptArtifacts, LedgerEvent, LedgerEventKind, LoopAttemptRecord, LoopRunRecord, ReceiptIntegrityChainEntry, RunContract, RunStore, StoredReceiptIntegrityMaterial } from "./persistence/index.js"; | ||
| export type { CompileResult } from "./persistence/index.js"; | ||
| export { compileContextShadow, estimateContextTokens } from "./context-shadow.js"; | ||
| export type { CompileContextShadowInput, CompileContextShadowResult, ContextShadowSegment } from "./context-shadow.js"; | ||
| export { compileContext, HEURISTIC_ADAPTER, MAX_RENDER_PASSES } from "./context-compiler.js"; | ||
| export type { CompileContextInput, CompileContextOutput, ContextAdapter } from "./context-compiler.js"; | ||
| export { verifyContextHandoff, decideContextCircuitBreak } from "./context-handoff.js"; | ||
| export type { VerifyContextHandoffInput } from "./context-handoff.js"; | ||
| export { evaluateChainGate, renderGatePrComment } from "./context-chain-gate.js"; | ||
| export type { ChainGateConfig, ChainGateCost, ChainGateConclusion, ChainGateInput, ChainGateResult, GatePrCommentOptions } from "./context-chain-gate.js"; | ||
| export { appendMemory, readMemoryEntries, getPreference, buildMemorySummary, recordPreference, recordConsent } from "./persistence/memory-store.js"; | ||
@@ -41,2 +56,3 @@ export type { MemoryEntry, MemoryKind, MemorySummary } from "./persistence/memory-store.js"; | ||
| loopId: string; | ||
| workspaceId: string; | ||
| attemptId: string; | ||
@@ -49,2 +65,3 @@ context: { | ||
| mutationMode?: MutationMode; | ||
| definitionOfDonePreSatisfied?: boolean; | ||
| /** Absolute path to the repository root. */ | ||
@@ -67,2 +84,4 @@ repoRoot?: string; | ||
| previousAttempts: LoopAttempt[]; | ||
| /** Abort signal propagated from the harness — adapters should honour it. */ | ||
| signal?: AbortSignal; | ||
| } | ||
@@ -72,2 +91,4 @@ export interface MartinVerificationStep { | ||
| launched: boolean; | ||
| completed?: boolean; | ||
| crashed?: boolean; | ||
| exitCode?: number; | ||
@@ -83,2 +104,3 @@ timedOut: boolean; | ||
| warnings?: string[]; | ||
| binding?: VerifierExecutionBinding; | ||
| } | ||
@@ -103,2 +125,3 @@ export interface MartinAdapterResult { | ||
| warnings?: string[]; | ||
| binding?: VerifierExecutionBinding; | ||
| }; | ||
@@ -188,2 +211,16 @@ execution?: { | ||
| store?: RunStore; | ||
| /** Overrides for the default eight-exit policy derived from budget. */ | ||
| exitPolicy?: ExitPolicyOverrides; | ||
| /** Source for durable cancellation/external-event signals. */ | ||
| exitSignalSource?: ExitSignalSource; | ||
| /** Poll interval for the signal monitor in ms (default 250). */ | ||
| exitSignalPollIntervalMs?: number; | ||
| /** Clock override for testing (default Date.now). */ | ||
| nowMs?: () => number; | ||
| /** When provided, runMartin verifies the handoff before invoking the adapter. */ | ||
| contextHandoff?: ContextHandoffReceipt; | ||
| /** true when the producer receipt file hash has been independently confirmed. */ | ||
| producerReceiptVerified?: boolean; | ||
| /** Map of sha256 → true for every artifact available to the verifier. */ | ||
| availableArtifacts?: ReadonlyMap<string, true>; | ||
| } | ||
@@ -198,1 +235,7 @@ export interface RunMartinResult { | ||
| export declare function runMartin(input: RunMartinInput): Promise<RunMartinResult>; | ||
| export { createDefaultExitPolicy, evaluateExitPolicy, hashProgressState, toLegacyExitDecision, validateExitPolicy } from "./exits.js"; | ||
| export type { ExitPolicyOverrides, LegacyExitDecision } from "./exits.js"; | ||
| export { SignalDiagnosticError, createFileExitSignalSource, exitSignalPath, readAllExitSignals, readExitSignal, startExitSignalMonitor, writeExitSignal } from "./exit-signal.js"; | ||
| export type { ExitSignalSource, SignalDiagnostic, SignalReadResult } from "./exit-signal.js"; | ||
| export { cacheMessage, fetchSelectedMessage, getCliInstalledVersion, getMcpInstalledVersion, isCooldownExpired, isDismissed, isNewerVersion, loadDeliveryRecord, parseMessageSelectionResponse, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./delivery/index.js"; | ||
| export type { MessageClientOptions, MessageSelectRequest, ParseFailure, ParseResult, ValidationError, } from "./delivery/index.js"; |
| import { type CompilerAdapterRequest, type PromptPacket } from "../compiler.js"; | ||
| import { type ContextAdapter } from "../context-compiler.js"; | ||
| import type { RunStore } from "./store.js"; | ||
@@ -13,2 +14,6 @@ export interface CompileResult { | ||
| * R3.8: Any attempt prompt can be reconstructed from disk artifacts alone. | ||
| * | ||
| * A-CTX-0: After compiling, emit a shadow manifest via compileContextShadow | ||
| * and append a context.shadow_compiled ledger event. Shadow failure must not | ||
| * break the governed run — original packet is returned unchanged in all cases. | ||
| */ | ||
@@ -19,2 +24,5 @@ export declare function compileAndPersistContext(request: CompilerAdapterRequest, options: { | ||
| now?: string; | ||
| nowMs?: number; | ||
| contextShadowBudgetTokens?: number; | ||
| contextAdapter?: ContextAdapter; | ||
| }): Promise<CompileResult>; |
@@ -0,2 +1,5 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { compilePromptPacket } from "../compiler.js"; | ||
| import { compileContext, HEURISTIC_ADAPTER } from "../context-compiler.js"; | ||
| import { compileContextShadow } from "../context-shadow.js"; | ||
| import { makeLedgerEvent } from "./ledger.js"; | ||
@@ -10,2 +13,6 @@ /** | ||
| * R3.8: Any attempt prompt can be reconstructed from disk artifacts alone. | ||
| * | ||
| * A-CTX-0: After compiling, emit a shadow manifest via compileContextShadow | ||
| * and append a context.shadow_compiled ledger event. Shadow failure must not | ||
| * break the governed run — original packet is returned unchanged in all cases. | ||
| */ | ||
@@ -33,4 +40,153 @@ export async function compileAndPersistContext(request, options) { | ||
| })); | ||
| // ── A-CTX-0: Shadow manifest emission ──────────────────────────────── | ||
| // Use the serialized PromptPacket as the single shadow segment. | ||
| // This is a A-CTX-0 fallback — A-CTX-1 will map structured segments. | ||
| // Shadow failure MUST NOT break the governed run. | ||
| try { | ||
| const nowMs = options.nowMs ?? Date.now(); | ||
| const shadow = compileContextShadow({ | ||
| runId: request.loopId, | ||
| adapter: "martin-core", | ||
| nowMs, | ||
| shadowBudgetTokens: options.contextShadowBudgetTokens ?? 8_000, | ||
| modelWindowEvidence: "unknown", | ||
| segments: [ | ||
| { | ||
| segmentId: "compiled-prompt", | ||
| kind: "compiled_prompt", | ||
| required: true, | ||
| text: JSON.stringify(packet) | ||
| } | ||
| ] | ||
| }); | ||
| await options.store.appendLedger(request.loopId, makeLedgerEvent({ | ||
| kind: "context.shadow_compiled", | ||
| runId: request.loopId, | ||
| attemptIndex: options.attemptIndex, | ||
| payload: shadow.receipt, | ||
| timestamp: ts | ||
| })); | ||
| } | ||
| catch (shadowErr) { | ||
| // Shadow telemetry failure is non-fatal. Emit a diagnostic ledger entry | ||
| // so the failure is visible without breaking the governed run. | ||
| const errorKind = shadowErr instanceof Error ? shadowErr.name : "UnknownError"; | ||
| try { | ||
| await options.store.appendLedger(request.loopId, makeLedgerEvent({ | ||
| kind: "context.shadow_compiled", | ||
| runId: request.loopId, | ||
| attemptIndex: options.attemptIndex, | ||
| payload: { | ||
| mode: "shadow", | ||
| error: "shadow_emit_failed", | ||
| errorKind | ||
| }, | ||
| timestamp: ts | ||
| })); | ||
| } | ||
| catch { | ||
| // Last-resort: if the diagnostic ledger write also fails, swallow it | ||
| // so the governed run completes. No prompt content is stored here. | ||
| } | ||
| } | ||
| // ── End A-CTX-0 shadow emission ─────────────────────────────────────── | ||
| // ── A-CTX-1: Deterministic governed context compilation ─────────────── | ||
| // Shadow mode: compute manifest but return original packet unchanged. | ||
| // nowMs captured once here — never passed as Date.now() inside compileContext. | ||
| // Failure must not break the governed run. | ||
| try { | ||
| const nowMs = options.nowMs ?? Date.now(); | ||
| const adapter = options.contextAdapter ?? HEURISTIC_ADAPTER; | ||
| // Build one candidate from the serialized packet. | ||
| // Only the hash goes into the manifest — never the raw text. | ||
| const packetText = JSON.stringify(packet); | ||
| const packetHash = createHash("sha256").update(packetText, "utf8").digest("hex"); | ||
| const estimatedTokens = Math.max(1, Math.ceil(Buffer.byteLength(packetText, "utf8") / 4)); | ||
| const result = compileContext({ | ||
| taskId: request.attemptId, | ||
| runId: request.loopId, | ||
| nowMs, | ||
| candidates: [ | ||
| { | ||
| id: "compiled-prompt", | ||
| kind: "task", | ||
| priority: "required", | ||
| trust: "authoritative", | ||
| sensitivity: "workspace", | ||
| sourceRef: `run://${request.loopId}/compiled-context`, | ||
| contentHash: packetHash, | ||
| estimatedTokens | ||
| } | ||
| ], | ||
| budget: { | ||
| modelWindowTokens: 200_000, | ||
| systemReserveTokens: 2_000, | ||
| outputReserveTokens: 4_000, | ||
| toolReserveTokens: 3_000, | ||
| overflowReserveTokens: 2_000, | ||
| maxWorkingSetTokens: options.contextShadowBudgetTokens ?? 8_000, | ||
| pinnedTokensMax: 1_500 | ||
| }, | ||
| policy: { | ||
| policyHash: "shadow-passthrough-v1", | ||
| deniedSensitivities: ["secret"], | ||
| deniedTrustLevels: [], | ||
| requiredOverBudgetAction: "explicit_escalation", | ||
| maxCompilerDurationMs: 250, | ||
| maxOverheadRatio: 0.15 | ||
| }, | ||
| adapter | ||
| }); | ||
| if (result.ok) { | ||
| await options.store.appendLedger(request.loopId, makeLedgerEvent({ | ||
| kind: "context.manifest_compiled", | ||
| runId: request.loopId, | ||
| attemptIndex: options.attemptIndex, | ||
| payload: result.ledgerEntry, | ||
| timestamp: ts | ||
| })); | ||
| } | ||
| else { | ||
| // Explicit compiler failure (required_over_budget or recount_exceeded_passes). | ||
| // Record as a diagnostic — not a crash, not silent. | ||
| await options.store.appendLedger(request.loopId, makeLedgerEvent({ | ||
| kind: "context.manifest_compiled", | ||
| runId: request.loopId, | ||
| attemptIndex: options.attemptIndex, | ||
| payload: { | ||
| mode: "shadow", | ||
| error: "compiler_failed", | ||
| reason: result.reason | ||
| }, | ||
| timestamp: ts | ||
| })); | ||
| } | ||
| } | ||
| catch (compilerErr) { | ||
| // Unexpected compiler failure is non-fatal. Emit a named diagnostic so | ||
| // the failure is visible in the ledger without breaking the governed run. | ||
| // Only error.name (e.g. "TypeError") is recorded — never error.message, | ||
| // which could contain prompt or segment text. | ||
| const errorKind = compilerErr instanceof Error ? compilerErr.name : "UnknownError"; | ||
| try { | ||
| await options.store.appendLedger(request.loopId, makeLedgerEvent({ | ||
| kind: "context.manifest_compiled", | ||
| runId: request.loopId, | ||
| attemptIndex: options.attemptIndex, | ||
| payload: { | ||
| mode: "shadow", | ||
| error: "compiler_threw", | ||
| errorKind | ||
| }, | ||
| timestamp: ts | ||
| })); | ||
| } | ||
| catch { | ||
| // Last-resort: diagnostic write failed too. The run must not be affected. | ||
| // No prompt content is stored anywhere in this path. | ||
| } | ||
| } | ||
| // ── End A-CTX-1 compiler ────────────────────────────────────────────── | ||
| } | ||
| return { packet }; | ||
| } |
@@ -11,1 +11,5 @@ export { makeLedgerEvent } from "./ledger.js"; | ||
| export type { CompileResult } from "./compiler.js"; | ||
| export { buildContextHandoffReceipt, computeFileHash, contextHandoffPath, readContextHandoff, writeContextHandoff } from "./context-handoff-store.js"; | ||
| export type { BuildHandoffReceiptInput } from "./context-handoff-store.js"; | ||
| export { attachRun, changeMissionStatus, createMission, missionDir, readMission, readMissionLedger, verifyMissionLedger } from "./mission-store.js"; | ||
| export type { AttachRunOptions, ChangeMissionStatusOptions, LedgerIntegrityResult } from "./mission-store.js"; |
@@ -6,1 +6,3 @@ export { makeLedgerEvent } from "./ledger.js"; | ||
| export { compileAndPersistContext } from "./compiler.js"; | ||
| export { buildContextHandoffReceipt, computeFileHash, contextHandoffPath, readContextHandoff, writeContextHandoff } from "./context-handoff-store.js"; | ||
| export { attachRun, changeMissionStatus, createMission, missionDir, readMission, readMissionLedger, verifyMissionLedger } from "./mission-store.js"; |
@@ -6,3 +6,3 @@ /** | ||
| */ | ||
| export type LedgerEventKind = "contract.created" | "attempt.admitted" | "attempt.rejected" | "prompt.compiled" | "patch.generated" | "verification.completed" | "grounding.violations_found" | "safety.violations_found" | "budget.settled" | "attempt.kept" | "attempt.discarded" | "run.exited"; | ||
| export type LedgerEventKind = "contract.created" | "attempt.admitted" | "attempt.rejected" | "prompt.compiled" | "patch.generated" | "verification.completed" | "grounding.violations_found" | "safety.violations_found" | "budget.settled" | "attempt.kept" | "attempt.discarded" | "run.exited" | "run.terminated" | "run.diagnostic" | "context.shadow_compiled" | "context.manifest_compiled" | "context.handoff.received" | "context.handoff.verified" | "context.handoff.blocked" | "context.object.excluded"; | ||
| export interface LedgerEvent { | ||
@@ -9,0 +9,0 @@ kind: LedgerEventKind; |
@@ -141,2 +141,4 @@ import type { BudgetPreflightEstimate, CostProvenance, EvidenceVector, FailureClass, InterventionType, LoopAttempt, LoopBudget, LoopCost, LoopLifecycleState, LoopStatus, PatchDecisionArtifact, PatchScore, PolicyPhase } from "../contracts/index.js"; | ||
| changedFileCount?: number; | ||
| mutationRequired?: boolean; | ||
| definitionOfDonePreSatisfied?: boolean; | ||
| diffNovelty?: number; | ||
@@ -143,0 +145,0 @@ diffStats?: { |
@@ -517,2 +517,5 @@ /** | ||
| const changedFileCount = input.changedFileCount ?? 0; | ||
| const auditablePreSatisfiedNoChange = input.verificationPassed && | ||
| input.mutationRequired === true && | ||
| input.definitionOfDonePreSatisfied === true; | ||
| const noveltyScore = input.diffNovelty ?? (changedFileCount > 0 ? 1 : 0); | ||
@@ -531,3 +534,3 @@ const diffRiskScore = computeDiffRiskScore(input.diffStats); | ||
| } | ||
| if (changedFileEvidenceAvailable && changedFileCount === 0) { | ||
| if (changedFileEvidenceAvailable && changedFileCount === 0 && !auditablePreSatisfiedNoChange) { | ||
| reasonCodes.push("no_code_change"); | ||
@@ -565,3 +568,3 @@ } | ||
| } | ||
| if (changedFileEvidenceAvailable && changedFileCount === 0) { | ||
| if (changedFileEvidenceAvailable && changedFileCount === 0 && !auditablePreSatisfiedNoChange) { | ||
| score -= 0.35; | ||
@@ -568,0 +571,0 @@ } |
| import { spawnSync } from "node:child_process"; | ||
| import { readFileSync } from "node:fs"; | ||
| import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; | ||
@@ -14,7 +15,20 @@ import { dirname, relative, resolve } from "node:path"; | ||
| const baselineUntracked = new Set(input.boundary.untrackedFiles); | ||
| const baselineContentChanges = input.boundary.snapshots | ||
| .filter((snapshot) => snapshot.existed && repoFileDiffersFromSnapshot(input.repoRoot, snapshot)) | ||
| .map((snapshot) => snapshot.path); | ||
| return uniqueSorted([ | ||
| ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)), | ||
| ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)) | ||
| ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)), | ||
| ...baselineContentChanges | ||
| ]); | ||
| } | ||
| function repoFileDiffersFromSnapshot(repoRoot, snapshot) { | ||
| try { | ||
| const current = readFileSync(resolveRepoPath(repoRoot, snapshot.path)); | ||
| return current.toString("base64") !== snapshot.contentBase64; | ||
| } | ||
| catch { | ||
| return true; | ||
| } | ||
| } | ||
| export async function captureRollbackBoundary(input) { | ||
@@ -21,0 +35,0 @@ if (!input.repoRoot) { |
+3
-13
| { | ||
| "name": "@martinloop/mcp", | ||
| "version": "0.3.9", | ||
| "version": "0.5.0", | ||
| "mcpName": "io.github.Keesan12/martin-loop", | ||
| "private": false, | ||
| "type": "module", | ||
| "description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.", | ||
| "description": "MCP server for AI coding agents — verifier gates, stop limits, and Verified Handoffs.", | ||
| "license": "Apache-2.0", | ||
@@ -21,14 +21,7 @@ "author": "MartinLoop contributors", | ||
| "mcp", | ||
| "mcp-server", | ||
| "model-context-protocol", | ||
| "martin-loop", | ||
| "ai-agent", | ||
| "ai-governance", | ||
| "agent-governance", | ||
| "claude", | ||
| "claude-code", | ||
| "codex", | ||
| "cursor", | ||
| "budget-guardrails", | ||
| "verifier-gates", | ||
| "martin_doctor", | ||
@@ -70,5 +63,3 @@ "martin_triage_runs", | ||
| "start": "node dist/server.js", | ||
| "inspect:live": "node ./scripts/inspect-live.mjs", | ||
| "mcpb:build": "node ./mcpb/build-mcpb.mjs", | ||
| "mcpb:validate": "mcpb validate ./dist-mcpb/martinloop" | ||
| "inspect:live": "node ./scripts/inspect-live.mjs" | ||
| }, | ||
@@ -85,5 +76,4 @@ "dependencies": { | ||
| "devDependencies": { | ||
| "@anthropic-ai/mcpb": "2.1.2", | ||
| "@martin/contracts": "workspace:*" | ||
| } | ||
| } |
+82
-141
@@ -1,192 +0,133 @@ | ||
| # MartinLoop MCP | ||
| # @martinloop/mcp | ||
| <div align="center"> | ||
| <img src="https://raw.githubusercontent.com/Keesan12/martin-loop/main/docs/assets/martinloop-logo.png" alt="MartinLoop" width="240"> | ||
| You give an AI agent a coding task. It runs. You get a bill. | ||
| **Stop runaway loops, bad code, and token waste.** | ||
| But did it actually work? Did it pass your tests? How much did it spend? What files did it touch? Did it loop 47 times trying the same broken approach? | ||
| The governed MCP runtime for Claude Code, Codex, Gemini CLI, Cursor, and autonomous coding agents. | ||
| You don't know. And that's the problem. | ||
| [](https://www.npmjs.com/package/@martinloop/mcp) | ||
| [](https://www.npmjs.com/package/@martinloop/mcp) | ||
| [](https://github.com/Keesan12/martin-loop/blob/main/LICENSE) | ||
| [](#requirements) | ||
| [](https://glama.ai/mcp/servers/Keesan12/martin-loop) | ||
| </div> | ||
| ## What This Does | ||
| AI coding agents can write code. MartinLoop decides whether they are allowed to keep spending, whether the result is actually verified, and what evidence must remain for review. | ||
| MartinLoop is a governed loop for AI coding agents. You tell it what to do, set a budget, and point it at your test suite. It runs the agent, checks your tests after every attempt, and stops when either the tests pass or the money runs out. | ||
| It wraps agent execution with: | ||
| When it's done, you get a receipt — not a vague summary, but a structured record: dollars spent, attempts made, verification results, files changed, and whether you got what you asked for. | ||
| - hard USD, token, iteration, time, command, and file-change limits | ||
| - verifier gates that must pass before completion | ||
| - allowed and denied path contracts | ||
| - preflight checks before any execution or spend | ||
| - durable pause, continue, and cancel receipts | ||
| - failure triage, run dossiers, evaluations, and PR-review evidence | ||
| **One line to connect it:** | ||
| ## Connect in one command | ||
| | Host | Command | | ||
| | --- | --- | | ||
| | Claude Code | `claude mcp add martin-loop -- npx -y @martinloop/mcp` | | ||
| | Codex | `codex mcp add martin-loop -- npx -y @martinloop/mcp` | | ||
| | Gemini CLI | `gemini mcp add martin-loop -- npx -y @martinloop/mcp` | | ||
| | Any stdio MCP host | `npx -y @martinloop/mcp` | | ||
| Windows Claude Code: | ||
| ```sh | ||
| claude mcp add --transport stdio --scope user martin-loop -- cmd /c npx -y @martinloop/mcp | ||
| claude mcp add martin-loop -- npx -y @martinloop/mcp | ||
| ``` | ||
| No API key is required to start and inspect the server. Live execution requires a supported coding-agent CLI and its normal authentication. | ||
| That's it. Your agent now runs governed. | ||
| ## Why agents use MartinLoop | ||
| ## Real Numbers From Real Runs | ||
| | Uncontrolled agent run | Governed MartinLoop run | | ||
| | --- | --- | | ||
| | The agent decides when it is done | Your verifier decides whether completion is valid | | ||
| | Retries continue until the user notices | Hard budgets and stop conditions prevent open-ended spend | | ||
| | Scope drifts across the repository | Allowed and denied paths constrain writes | | ||
| | The final answer hides failed attempts | The dossier preserves attempts, costs, verification, and artifacts | | ||
| | Process control is ephemeral | Pause, continue, and cancel actions leave durable receipts | | ||
| | “Probably fixed” becomes accepted | Missing, failed, contradicted, or unknown verification stays incomplete | | ||
| We tested this against live repos with real API spend: | ||
| MartinLoop is not another coding agent. It is the enforcement and evidence layer around the agents you already use. | ||
| | What happened | Without MartinLoop | With MartinLoop | | ||
| |---|---|---| | ||
| | Budget: $1.50 task | Agent spent **$28.42** | Agent stopped at **$1.20** | | ||
| | Failing verifier | Retried indefinitely | Stopped after 3 attempts with diagnosis | | ||
| | CLI not on PATH | "not available on PATH" (dead stop) | Auto-discovered in AppData, kept running | | ||
| | `bun run lint && bun run test` | Passed `&&` as a literal arg (always fails) | Routed through shell, worked | | ||
| ## Agent operating contract | ||
| These aren't hypotheticals. The $28 overshoot happened in production testing. We fixed the circuit breaker in `0.3.8`. | ||
| Use this sequence for governed coding work: | ||
| ## Install | ||
| ```text | ||
| martin_doctor | ||
| → martin_estimate | ||
| → martin_plan | ||
| → martin_preflight | ||
| → martin_run | ||
| → martin_dossier | ||
| → martin_eval | ||
| ### Claude Code | ||
| ```sh | ||
| claude mcp add martin-loop -- npx -y @martinloop/mcp | ||
| ``` | ||
| ### Rules for agents | ||
| Windows: | ||
| ```sh | ||
| claude mcp add --transport stdio --scope user martin-loop -- cmd /c npx -y @martinloop/mcp | ||
| ``` | ||
| 1. Start with `martin_doctor` to inspect the environment and run store. | ||
| 2. Call `martin_estimate` before spending. | ||
| 3. Keep the same objective and compatible scope through estimate, plan, preflight, and run. | ||
| 4. Prefer read-only tools until execution is explicitly authorized. | ||
| 5. Treat failed, contradicted, missing, or unknown verification as incomplete. | ||
| 6. Use `martin://agent/next-step` or `martin://guides/agent-start` when the next action is unclear. | ||
| 7. Do not use `martin_create_pr` with `execute: true` unless the user has explicitly authorized a GitHub write. | ||
| ### Codex | ||
| ```sh | ||
| codex mcp add martin-loop -- npx -y @martinloop/mcp | ||
| ``` | ||
| `martin_run` intentionally hard-blocks until the required doctor, estimate, plan, and preflight receipts exist for the same task. | ||
| ### Gemini CLI | ||
| ```sh | ||
| gemini mcp add martin-loop -- npx -y @martinloop/mcp | ||
| ``` | ||
| ## What a governed run looks like | ||
| ### Any MCP Host | ||
| ```sh | ||
| npx -y @martinloop/mcp | ||
| ``` | ||
| ```text | ||
| User objective: | ||
| "Fix the auth regression. Budget: $3. Verify: npm test." | ||
| ## How a Governed Run Works | ||
| doctor checks environment, CLI availability, and run storage | ||
| estimate predicts route, spend, and pre-work burn without spending | ||
| plan defines scope, verifier proposal, policy pack, and risk | ||
| preflight validates the contract before execution | ||
| run executes inside budget, scope, policy, and verifier gates | ||
| dossier returns attempts, cost, verification, artifacts, and evidence paths | ||
| eval grades completion, verifier health, risk, and reviewability | ||
| ``` | ||
| You: "Fix the auth bug. Budget $3. Verify: npm test" | ||
| Every attempt is bounded by the run contract. A confident model answer does not override a failed verifier. | ||
| martin_doctor → checks CLI, auth, environment | ||
| martin_plan → scopes the task, sets constraints | ||
| martin_preflight → validates before any spend | ||
| martin_run → agent works inside budget + verifier gates | ||
| martin_dossier → receipt: $1.40 spent, 2 attempts, tests pass | ||
| ``` | ||
| ## Proof, not promises | ||
| Every attempt runs your verifier. Every dollar is tracked. If the agent drifts off-task, the scope contract catches it. If it blows the budget, the circuit breaker kills the subprocess mid-stream — not after the bill arrives. | ||
| This real governed run spent `$0.51` against a `$3.00` budget. The verifier passed and the receipt integrity was signed. MartinLoop still kept the result at `EVIDENCE_BOUNDARY` because rollback evidence had not been recorded. | ||
| ## What Your Agent Gets | ||
| <div align="center"> | ||
| <img src="https://raw.githubusercontent.com/Keesan12/martin-loop/main/docs/assets/proof-receipt-live-governed.png" alt="MartinLoop governed run receipt showing spend, budget, verifier result, integrity, and evidence boundary" width="720"> | ||
| </div> | ||
| ### 21 Tools | ||
| Inspect the example receipt: | ||
| **Run the loop:** | ||
| `martin_doctor` `martin_plan` `martin_preflight` `martin_run` `martin_pause` `martin_continue` `martin_cancel` | ||
| - [Markdown receipt](https://github.com/Keesan12/martin-loop/blob/main/docs/examples/proof-receipts/live-governed-run-receipt.md) | ||
| - [JSON receipt](https://github.com/Keesan12/martin-loop/blob/main/docs/examples/proof-receipts/live-governed-run-receipt.json) | ||
| **Inspect results:** | ||
| `martin_status` `martin_logs` `martin_dossier` `martin_eval` `martin_inspect` `martin_list_runs` `martin_get_run` `martin_get_attempt` `martin_get_verification_results` `martin_triage_runs` | ||
| ## Tool surface | ||
| **Ship the work:** | ||
| `martin_pr_summary` `martin_create_pr` `martin_review_pr` | ||
| ### Governed workflow | ||
| ### 11 Read-Only Resources | ||
| - `martin_doctor` — inspect environment readiness; expected first call | ||
| - `martin_estimate` — estimate cost and route without spending | ||
| - `martin_plan` — produce scoped plan, verifier proposal, policy pack, and risk | ||
| - `martin_preflight` — validate readiness before execution | ||
| - `martin_run` — execute a governed coding task | ||
| - `martin_pause` — record a durable pause request | ||
| - `martin_continue` — record a durable resume request | ||
| - `martin_cancel` — record a durable cancellation request | ||
| Your agent can pull context without side effects: | ||
| ### Inspection and review | ||
| `martin://runs/latest` · `martin://runs/latest/proof-card` · `martin://runs/latest/budget-status` · `martin://runs/latest/verifier-evidence` · `martin://runs/recent` · `martin://server/health` · `martin://policies/current` · `martin://agent/next-step` · `martin://guides/mcp-usage` · `martin://guides/agent-start` · `martin://repo/risk-map` | ||
| - `martin_status`, `martin_logs`, `martin_inspect` | ||
| - `martin_list_runs`, `martin_triage_runs` | ||
| - `martin_get_run`, `martin_get_attempt`, `martin_get_verification_results` | ||
| - `martin_run_dossier`, `martin_dossier`, `martin_eval` | ||
| - `martin_pr_summary`, `martin_create_pr`, `martin_review_pr` | ||
| ### Configuration Profiles | ||
| ### Read-only resources | ||
| Generate host config tuned to your workflow: | ||
| The server exposes run summaries, receipts, budget status, verifier evidence, policies, health, agent guidance, and repository-risk context through `martin://` resources, including: | ||
| ```text | ||
| martin://runs/latest | ||
| martin://runs/latest/summary | ||
| martin://runs/latest/receipt | ||
| martin://runs/latest/budget-status | ||
| martin://runs/latest/verifier-evidence | ||
| martin://runs/recent | ||
| martin://server/health | ||
| martin://policies/current | ||
| martin://agent/next-step | ||
| martin://guides/agent-start | ||
| martin://repo/risk-map | ||
| ```sh | ||
| npx martin-loop mcp print-config --host claude --profile minimal # run + inspect | ||
| npx martin-loop mcp print-config --host claude --profile diagnostic # + doctor + triage | ||
| npx martin-loop mcp print-config --host claude --profile full-local # all local tools | ||
| npx martin-loop mcp print-config --host claude --profile github-review # + PR workflow | ||
| ``` | ||
| Use resources when the agent needs context without side effects. | ||
| ## What Happens When Things Go Wrong | ||
| ## Configuration profiles | ||
| MartinLoop doesn't just report failures — it tries to fix them: | ||
| Generate host config tuned to the minimum surface needed: | ||
| | Failure | Old behavior | Now | | ||
| |---|---|---| | ||
| | CLI not on PATH | Error message, dead stop | Searches npm global, homebrew, nvm, scoop — uses what it finds | | ||
| | Verifier says "command not found" | Generic "verification failed" | Tells the next attempt: "bun is missing, install with `npm i -g bun`" | | ||
| | `git restore` fails mid-rollback | Throws, leaves dirty state | Retries once, falls back to `git checkout`, cleans up | | ||
| | Invalid `--profile` flag | Crashes | Warns, falls back to `minimal`, keeps running | | ||
| ```sh | ||
| npx martin-loop mcp print-config --host claude --profile minimal | ||
| npx martin-loop mcp print-config --host claude --profile diagnostic | ||
| npx martin-loop mcp print-config --host claude --profile full-local | ||
| npx martin-loop mcp print-config --host claude --profile github-review | ||
| ``` | ||
| ## Who Uses This | ||
| - `minimal` — core run and inspection path | ||
| - `diagnostic` — adds doctor and triage surfaces | ||
| - `full-local` — all local tools | ||
| - `github-review` — adds PR evidence and review workflow | ||
| - **Engineers running overnight agent loops** — you need a kill switch that actually works, and a receipt in the morning | ||
| - **Teams with shared API budgets** — you need per-task spend caps, not org-wide prayer | ||
| - **Anyone reviewing agent-generated PRs** — the dossier shows what the agent tried, what passed, and what didn't | ||
| ## Trust boundaries | ||
| - Cost and token values include provenance: actual, estimated, or unavailable. | ||
| - A verifier proves only the commands you configured, not every product requirement. | ||
| - Receipt integrity must be verified before evidence is trusted externally. | ||
| - Unknown or missing evidence never passes silently. | ||
| - MartinLoop does not guarantee that an agent will produce correct code; it makes execution bounded, verification explicit, and failure reviewable. | ||
| ## Requirements | ||
| - Node.js 20+ | ||
| - One supported coding-agent CLI for live execution: Claude Code, Codex, Gemini CLI, or an OpenAI-compatible route | ||
| - Git repository for rollback-aware and changed-file workflows | ||
| - One AI coding CLI: [Claude Code](https://docs.anthropic.com/claude-code), [Codex](https://github.com/openai/codex), or [Gemini CLI](https://github.com/google/gemini-cli) | ||
| ## Links | ||
| - [Glama server page](https://glama.ai/mcp/servers/Keesan12/martin-loop) | ||
| - [npm: @martinloop/mcp](https://www.npmjs.com/package/@martinloop/mcp) | ||
| - [GitHub source and issues](https://github.com/Keesan12/martin-loop) | ||
| - [MCP setup guide](https://github.com/Keesan12/martin-loop/blob/main/docs/getting-started/mcp.md) | ||
| - [MCP tool reference](https://github.com/Keesan12/martin-loop/blob/main/docs/reference/mcp-tools.md) | ||
| - [martin-loop](https://www.npmjs.com/package/martin-loop) — the standalone CLI | ||
| - [GitHub](https://github.com/Keesan12/martin-loop) — source and issues | ||
| - [martinloop.com](https://martinloop.com) | ||
@@ -196,2 +137,2 @@ | ||
| Apache-2.0. | ||
| Apache-2.0 |
+3
-3
@@ -5,3 +5,3 @@ { | ||
| "title": "Martin Loop", | ||
| "description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.", | ||
| "description": "MCP server for AI coding agents — verifier gates, stop limits, and Verified Handoffs.", | ||
| "repository": { | ||
@@ -11,3 +11,3 @@ "url": "https://github.com/Keesan12/martin-loop", | ||
| }, | ||
| "version": "0.3.9", | ||
| "version": "0.5.0", | ||
| "packages": [ | ||
@@ -17,3 +17,3 @@ { | ||
| "identifier": "@martinloop/mcp", | ||
| "version": "0.3.9", | ||
| "version": "0.5.0", | ||
| "transport": { | ||
@@ -20,0 +20,0 @@ "type": "stdio" |
| import type { MartinAdapter, MartinAdapterRequest, MartinAdapterResult } from "../core/index.js"; | ||
| export interface StubAgentCliAdapterOptions { | ||
| command: string[]; | ||
| profile?: string; | ||
| label?: string; | ||
| responder?: (request: MartinAdapterRequest) => Promise<MartinAdapterResult> | MartinAdapterResult; | ||
| } | ||
| export declare function createStubAgentCliAdapter(options: StubAgentCliAdapterOptions): MartinAdapter; |
| import { createAdapterCapabilities } from "./runtime-support.js"; | ||
| export function createStubAgentCliAdapter(options) { | ||
| const metadata = { | ||
| command: options.command.join(" "), | ||
| providerId: "agent-cli", | ||
| model: options.profile ?? options.command[0] ?? "default", | ||
| transport: "cli", | ||
| capabilities: createAdapterCapabilities(), | ||
| ...(options.profile ? { profile: options.profile } : {}) | ||
| }; | ||
| return { | ||
| adapterId: `agent-cli:${options.command.join(":")}`, | ||
| kind: "agent-cli", | ||
| label: options.label ?? `Stub agent CLI (${options.command.join(" ")})`, | ||
| metadata, | ||
| async execute(request) { | ||
| if (options.responder) { | ||
| return await options.responder(request); | ||
| } | ||
| return { | ||
| status: "failed", | ||
| summary: `Stub agent CLI ${options.command.join(" ")} did not execute a live session.`, | ||
| usage: { | ||
| actualUsd: 0, | ||
| tokensIn: 0, | ||
| tokensOut: 0, | ||
| provenance: "unavailable" | ||
| }, | ||
| verification: { | ||
| passed: false, | ||
| summary: "No CLI execution was attempted." | ||
| }, | ||
| failure: { | ||
| message: `Agent CLI ${options.command.join(" ")} is not configured for live execution.`, | ||
| classHint: "environment_mismatch" | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } |
| import type { MartinAdapter } from "../core/index.js"; | ||
| import { type SpawnLike } from "./cli-bridge.js"; | ||
| export interface VerifierOnlyAdapterOptions { | ||
| workingDirectory?: string; | ||
| verifyTimeoutMs?: number; | ||
| label?: string; | ||
| spawnImpl?: SpawnLike; | ||
| } | ||
| export declare function createVerifierOnlyAdapter(options?: VerifierOnlyAdapterOptions): MartinAdapter; |
| import { readGitChangedFiles, runVerification } from "./cli-bridge.js"; | ||
| import { createAdapterCapabilities, normalizeUsage } from "./runtime-support.js"; | ||
| export function createVerifierOnlyAdapter(options = {}) { | ||
| const workingDirectory = options.workingDirectory ?? process.cwd(); | ||
| const verifyTimeoutMs = options.verifyTimeoutMs ?? 120_000; | ||
| return { | ||
| adapterId: "direct:verifier:verify-only", | ||
| kind: "direct-provider", | ||
| label: options.label ?? "Verifier-only adapter", | ||
| metadata: { | ||
| providerId: "verifier", | ||
| model: "verify-only", | ||
| transport: "cli", | ||
| capabilities: createAdapterCapabilities({ | ||
| usageSettlement: true, | ||
| diffArtifacts: true | ||
| }) | ||
| }, | ||
| async execute(request) { | ||
| const shouldTrackVerifierWrites = request.context.verificationPlan.length > 0 || | ||
| (request.context.verificationStack?.length ?? 0) > 0; | ||
| const baselineChangedFiles = shouldTrackVerifierWrites | ||
| ? new Set(await readGitChangedFiles(workingDirectory, 5_000, options.spawnImpl)) | ||
| : new Set(); | ||
| const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack, options.spawnImpl); | ||
| const changedFiles = shouldTrackVerifierWrites | ||
| ? (await readGitChangedFiles(workingDirectory, 5_000, options.spawnImpl)).filter((file) => !baselineChangedFiles.has(file)) | ||
| : []; | ||
| const execution = { changedFiles }; | ||
| if (verification.passed) { | ||
| return { | ||
| status: "completed", | ||
| summary: changedFiles.length > 0 | ||
| ? `Verifier-only run completed but modified files: ${changedFiles.join(", ")}` | ||
| : "Verifier-only run completed without file edits.", | ||
| usage: normalizeUsage({ | ||
| actualUsd: 0, | ||
| tokensIn: 0, | ||
| tokensOut: 0, | ||
| provenance: "actual" | ||
| }), | ||
| verification, | ||
| execution | ||
| }; | ||
| } | ||
| return { | ||
| status: "failed", | ||
| summary: "Verifier-only run failed.", | ||
| usage: normalizeUsage({ | ||
| actualUsd: 0, | ||
| tokensIn: 0, | ||
| tokensOut: 0, | ||
| provenance: "actual" | ||
| }), | ||
| verification, | ||
| execution, | ||
| failure: { | ||
| message: verification.summary | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1102592
25.64%1
-50%197
30.46%26014
24.58%138
-29.95%91
10.98%13
44.44%