martin-loop
Advanced tools
| import type { LoopRecord } from "../contracts/index.js"; | ||
| export interface MilestoneState { | ||
| version: 5; | ||
| firstRunAt: string | null; | ||
| runCount: number; | ||
| successfulRunCount: number; | ||
| failedRunCount: number; | ||
| totalActualSpendUsd: number; | ||
| totalEstimatedUncontrolledSpendUsd: number; | ||
| totalSavedUsd: number; | ||
| savingsConfidence: "confirmed" | "estimated" | "unavailable"; | ||
| bestRunSavedUsd: number | null; | ||
| bestRunAt: string | null; | ||
| reposUsed: string[]; | ||
| lastRunAt: string | null; | ||
| dailyStreakDays: number; | ||
| receiptsGenerated: number; | ||
| rollbacksTriggered: number; | ||
| verifierBlocks: number; | ||
| currentRank: RankName; | ||
| loopMilestones: { | ||
| reached: number[]; | ||
| }; | ||
| streakMilestones: { | ||
| reached: number[]; | ||
| }; | ||
| savingsMilestones: { | ||
| reachedUsd: number[]; | ||
| }; | ||
| star: { | ||
| shownCount: number; | ||
| confirmed: boolean; | ||
| lastShownAtSavedUsd?: number; | ||
| }; | ||
| feedback: { | ||
| shownCount: number; | ||
| lastShownAtSavedUsd?: number; | ||
| lastShownAtRunCount?: number; | ||
| scores: Array<{ | ||
| score: number; | ||
| runCount: number; | ||
| }>; | ||
| featureVotes: string[]; | ||
| email: string | null; | ||
| optedOut: boolean; | ||
| lastDelivery?: IntakeDelivery; | ||
| }; | ||
| waitlist: { | ||
| status: "not_asked" | "declined" | "joined"; | ||
| declinedCount: number; | ||
| email: string | null; | ||
| shownAt: string | null; | ||
| lastDelivery?: IntakeDelivery; | ||
| }; | ||
| suppressUntilRun: number; | ||
| governedBadge: { | ||
| ctaShownAt: string | null; | ||
| }; | ||
| } | ||
| export type RankName = "Observer" | "Operator" | "Engineer" | "Architect" | "Control Plane" | "Infrastructure" | "Legend"; | ||
| export type InlineMilestone = { | ||
| kind: "streak_milestone"; | ||
| days: number; | ||
| } | { | ||
| kind: "savings_milestone"; | ||
| usd: number; | ||
| }; | ||
| export type InteractivePrompt = { | ||
| kind: "loop_milestone"; | ||
| count: number; | ||
| } | { | ||
| kind: "star"; | ||
| hard: boolean; | ||
| } | { | ||
| kind: "feedback"; | ||
| } | { | ||
| kind: "waitlist"; | ||
| } | null; | ||
| export interface RunPromptResult { | ||
| inlineMilestones: InlineMilestone[]; | ||
| interactivePrompt: InteractivePrompt; | ||
| } | ||
| export declare function computeRank(successfulRunCount: number, totalSavedUsd: number): RankName; | ||
| export declare function nextRank(rank: RankName): { | ||
| name: RankName; | ||
| loopsNeeded: number; | ||
| savedNeeded: number; | ||
| } | null; | ||
| export declare function deriveSavingsConfidence(loop: LoopRecord): "confirmed" | "estimated" | "unavailable"; | ||
| export declare function estimatedUncontrolledUsd(loop: LoopRecord): number; | ||
| export declare function wasRollbackTaken(loop: LoopRecord): boolean; | ||
| export declare function wasVerifierBlocked(loop: LoopRecord): boolean; | ||
| export declare function recordRunAndGetPrompt(input: { | ||
| success: boolean; | ||
| repoRoot: string; | ||
| actualSpendUsd: number; | ||
| estimatedUncontrolledUsd: number; | ||
| savingsConfidence: "confirmed" | "estimated" | "unavailable"; | ||
| rollbackTaken: boolean; | ||
| verifierBlock: boolean; | ||
| }): Promise<RunPromptResult>; | ||
| export declare function recordStarConfirmed(): Promise<void>; | ||
| export type IntakePayload = { | ||
| source: "martin-cli"; | ||
| event: "feedback" | "pilot_interest"; | ||
| cliVersion: string; | ||
| email?: string; | ||
| score?: number; | ||
| featureVote?: string; | ||
| consentToContact: boolean; | ||
| platform: NodeJS.Platform; | ||
| createdAt: string; | ||
| }; | ||
| export type IntakeSubmissionResult = { | ||
| ok: true; | ||
| status: "accepted" | "duplicate"; | ||
| submissionId?: string; | ||
| } | { | ||
| ok: false; | ||
| status: "timeout" | "network_error" | "rejected" | "server_error"; | ||
| retryable: boolean; | ||
| message: string; | ||
| }; | ||
| /** Privacy-safe local record of the most recent intake hand-off. */ | ||
| export interface IntakeDelivery { | ||
| status: "accepted" | "duplicate" | "queued" | "rejected"; | ||
| recordedAt: string; | ||
| submissionId?: string; | ||
| } | ||
| export declare function intakeDeliveryFromResult(result: IntakeSubmissionResult): IntakeDelivery; | ||
| export declare function submitToIntake(payload: IntakePayload, options?: { | ||
| queueOnFailure?: boolean; | ||
| }): Promise<IntakeSubmissionResult>; | ||
| export declare function retryQueuedIntake(): Promise<void>; | ||
| export declare function recordWaitlistJoined(email: string): Promise<void>; | ||
| export declare function recordWaitlistDeclined(): Promise<void>; | ||
| export declare function recordFeedback(score: number, featureVote?: string, email?: string): Promise<void>; | ||
| export declare function readMilestoneState(): Promise<MilestoneState | null>; | ||
| export declare function isBadgeCtaEligible(state: MilestoneState): boolean; | ||
| export declare function recordBadgeCtaShown(): Promise<void>; |
| import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
| // Set by npm/pnpm when running as a package script; "unknown" in test/direct-node contexts. | ||
| const CLI_VERSION = process.env["npm_package_version"] ?? "unknown"; | ||
| // --------------------------------------------------------------------------- | ||
| // State file | ||
| // --------------------------------------------------------------------------- | ||
| const STATE_PATH = join(homedir(), ".martin", "milestone-state.json"); | ||
| // --------------------------------------------------------------------------- | ||
| // Rank logic | ||
| // --------------------------------------------------------------------------- | ||
| const LOOP_MILESTONES = [10, 25, 50, 100, 250, 500, 1000]; | ||
| const STREAK_MILESTONES = [3, 7, 14, 30, 100]; | ||
| const SAVINGS_MILESTONES = [10, 50, 100, 500, 1000]; | ||
| export function computeRank(successfulRunCount, totalSavedUsd) { | ||
| if (successfulRunCount >= 1000 && totalSavedUsd >= 1000) | ||
| return "Legend"; | ||
| if (successfulRunCount >= 500 && totalSavedUsd >= 500) | ||
| return "Infrastructure"; | ||
| if (successfulRunCount >= 100 && totalSavedUsd >= 100) | ||
| return "Control Plane"; | ||
| if (successfulRunCount >= 50 && totalSavedUsd >= 25) | ||
| return "Architect"; | ||
| if (successfulRunCount >= 25 && totalSavedUsd >= 10) | ||
| return "Engineer"; | ||
| if (successfulRunCount >= 10) | ||
| return "Operator"; | ||
| return "Observer"; | ||
| } | ||
| export function nextRank(rank) { | ||
| switch (rank) { | ||
| case "Observer": return { name: "Operator", loopsNeeded: 10, savedNeeded: 0 }; | ||
| case "Operator": return { name: "Engineer", loopsNeeded: 25, savedNeeded: 10 }; | ||
| case "Engineer": return { name: "Architect", loopsNeeded: 50, savedNeeded: 25 }; | ||
| case "Architect": return { name: "Control Plane", loopsNeeded: 100, savedNeeded: 100 }; | ||
| case "Control Plane": return { name: "Infrastructure", loopsNeeded: 500, savedNeeded: 500 }; | ||
| case "Infrastructure": return { name: "Legend", loopsNeeded: 1000, savedNeeded: 1000 }; | ||
| case "Legend": return null; | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Helper functions (exported — imported by index.ts before it is touched) | ||
| // --------------------------------------------------------------------------- | ||
| export function deriveSavingsConfidence(loop) { | ||
| const p = loop.cost.provenance; | ||
| if (p === "actual") | ||
| return "confirmed"; | ||
| if (p === "estimated") | ||
| return "estimated"; | ||
| return "unavailable"; | ||
| } | ||
| export function estimatedUncontrolledUsd(loop) { | ||
| return loop.cost.actualUsd + (loop.cost.avoidedUsd ?? 0); | ||
| } | ||
| export function wasRollbackTaken(loop) { | ||
| return loop.lifecycleState === "budget_exit"; | ||
| } | ||
| export function wasVerifierBlocked(loop) { | ||
| return loop.status === "failed"; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // State I/O | ||
| // --------------------------------------------------------------------------- | ||
| // Fill any fields that may be absent in a v5 state written by an older iteration | ||
| // of this module. Spreads defaults first so new fields always have a safe value. | ||
| function fillDefaults(parsed) { | ||
| const defaults = freshState(); | ||
| return { | ||
| ...defaults, | ||
| ...parsed, | ||
| version: 5, | ||
| // Nested objects need explicit merge so a partial sub-object doesn't | ||
| // silently drop sibling keys added in later iterations. | ||
| star: { ...defaults.star, ...(parsed["star"] ?? {}) }, | ||
| feedback: { ...defaults.feedback, ...(parsed["feedback"] ?? {}) }, | ||
| waitlist: { ...defaults.waitlist, ...(parsed["waitlist"] ?? {}) }, | ||
| loopMilestones: { ...defaults.loopMilestones, ...(parsed["loopMilestones"] ?? {}) }, | ||
| streakMilestones: { ...defaults.streakMilestones, ...(parsed["streakMilestones"] ?? {}) }, | ||
| savingsMilestones: { ...defaults.savingsMilestones, ...(parsed["savingsMilestones"] ?? {}) }, | ||
| governedBadge: { ...defaults.governedBadge, ...(parsed["governedBadge"] ?? {}) }, | ||
| }; | ||
| } | ||
| async function readState() { | ||
| try { | ||
| const raw = await readFile(STATE_PATH, "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed["version"] === 5) | ||
| return fillDefaults(parsed); | ||
| } | ||
| catch { /* first run or corrupt — start fresh */ } | ||
| return freshState(); | ||
| } | ||
| async function writeState(state) { | ||
| await mkdir(join(homedir(), ".martin"), { recursive: true }); | ||
| await writeFile(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); | ||
| } | ||
| function freshState() { | ||
| return { | ||
| version: 5, | ||
| firstRunAt: null, | ||
| runCount: 0, | ||
| successfulRunCount: 0, | ||
| failedRunCount: 0, | ||
| totalActualSpendUsd: 0, | ||
| totalEstimatedUncontrolledSpendUsd: 0, | ||
| totalSavedUsd: 0, | ||
| savingsConfidence: "unavailable", | ||
| bestRunSavedUsd: null, | ||
| bestRunAt: null, | ||
| reposUsed: [], | ||
| lastRunAt: null, | ||
| dailyStreakDays: 0, | ||
| receiptsGenerated: 0, | ||
| rollbacksTriggered: 0, | ||
| verifierBlocks: 0, | ||
| currentRank: "Observer", | ||
| loopMilestones: { reached: [] }, | ||
| streakMilestones: { reached: [] }, | ||
| savingsMilestones: { reachedUsd: [] }, | ||
| star: { shownCount: 0, confirmed: false }, | ||
| feedback: { | ||
| shownCount: 0, | ||
| scores: [], | ||
| featureVotes: [], | ||
| email: null, | ||
| optedOut: false | ||
| }, | ||
| waitlist: { | ||
| status: "not_asked", | ||
| declinedCount: 0, | ||
| email: null, | ||
| shownAt: null | ||
| }, | ||
| suppressUntilRun: 0, | ||
| governedBadge: { ctaShownAt: null } | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Streak computation | ||
| // --------------------------------------------------------------------------- | ||
| function updateStreak(prevLastRunAt, currentStreak, now) { | ||
| if (!prevLastRunAt) | ||
| return 1; | ||
| const last = new Date(prevLastRunAt); | ||
| const daysDiff = Math.floor((now.getTime() - last.getTime()) / (24 * 60 * 60 * 1000)); | ||
| if (daysDiff === 0) | ||
| return currentStreak; // same day | ||
| if (daysDiff === 1) | ||
| return currentStreak + 1; // consecutive | ||
| return 1; // streak broken | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Core export: recordRunAndGetPrompt | ||
| // --------------------------------------------------------------------------- | ||
| export async function recordRunAndGetPrompt(input) { | ||
| // CI guard — suppress all interactive prompts in non-interactive or CI environments | ||
| if (!process.stdout.isTTY || process.env["CI"]) { | ||
| return { inlineMilestones: [], interactivePrompt: null }; | ||
| } | ||
| const state = await readState(); | ||
| const now = new Date(); | ||
| const savedThisRun = input.savingsConfidence !== "unavailable" | ||
| ? Math.max(0, input.estimatedUncontrolledUsd - input.actualSpendUsd) | ||
| : 0; | ||
| // Update counters | ||
| state.runCount += 1; | ||
| if (input.success) | ||
| state.successfulRunCount += 1; | ||
| else | ||
| state.failedRunCount += 1; | ||
| if (!state.firstRunAt) | ||
| state.firstRunAt = now.toISOString(); | ||
| const prevLastRunAt = state.lastRunAt; | ||
| state.lastRunAt = now.toISOString(); | ||
| state.totalActualSpendUsd += input.actualSpendUsd; | ||
| state.totalEstimatedUncontrolledSpendUsd += input.estimatedUncontrolledUsd; | ||
| if (input.savingsConfidence !== "unavailable") { | ||
| state.totalSavedUsd += savedThisRun; | ||
| } | ||
| // Update savings confidence (upgrade only: unavailable → estimated → confirmed) | ||
| if (input.savingsConfidence === "confirmed" || | ||
| (input.savingsConfidence === "estimated" && state.savingsConfidence !== "confirmed")) { | ||
| state.savingsConfidence = input.savingsConfidence; | ||
| } | ||
| // Best run | ||
| if (savedThisRun > 0 && (state.bestRunSavedUsd === null || savedThisRun > state.bestRunSavedUsd)) { | ||
| state.bestRunSavedUsd = savedThisRun; | ||
| state.bestRunAt = now.toISOString(); | ||
| } | ||
| // Repos | ||
| const repoKey = input.repoRoot.toLowerCase(); | ||
| if (!state.reposUsed.includes(repoKey)) { | ||
| state.reposUsed = [...state.reposUsed, repoKey]; | ||
| } | ||
| // Streak — use prevLastRunAt captured before state.lastRunAt was overwritten | ||
| if (input.success) { | ||
| state.dailyStreakDays = updateStreak(prevLastRunAt, state.dailyStreakDays, now); | ||
| } | ||
| // Counters | ||
| if (input.rollbackTaken) | ||
| state.rollbacksTriggered += 1; | ||
| if (input.verifierBlock) | ||
| state.verifierBlocks += 1; | ||
| if (input.success) | ||
| state.receiptsGenerated += 1; | ||
| // Rank | ||
| const prevRank = state.currentRank; | ||
| state.currentRank = computeRank(state.successfulRunCount, state.totalSavedUsd); | ||
| const rankChanged = state.currentRank !== prevRank; | ||
| // --------------------------------------------------------------------------- | ||
| // Collect inline milestones (streak + savings — always render, no suppression) | ||
| // --------------------------------------------------------------------------- | ||
| const inlineMilestones = []; | ||
| if (input.success) { | ||
| for (const days of STREAK_MILESTONES) { | ||
| if (state.dailyStreakDays === days && !state.streakMilestones.reached.includes(days)) { | ||
| state.streakMilestones.reached = [...state.streakMilestones.reached, days]; | ||
| inlineMilestones.push({ kind: "streak_milestone", days }); | ||
| } | ||
| } | ||
| } | ||
| for (const usd of SAVINGS_MILESTONES) { | ||
| if (state.totalSavedUsd >= usd && !state.savingsMilestones.reachedUsd.includes(usd)) { | ||
| state.savingsMilestones.reachedUsd = [...state.savingsMilestones.reachedUsd, usd]; | ||
| inlineMilestones.push({ kind: "savings_milestone", usd }); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Check for loop milestone (fires once, suppresses GTM that run) | ||
| // --------------------------------------------------------------------------- | ||
| let loopMilestoneHit = null; | ||
| if (input.success) { | ||
| for (const count of LOOP_MILESTONES) { | ||
| if (state.successfulRunCount === count && !state.loopMilestones.reached.includes(count)) { | ||
| state.loopMilestones.reached = [...state.loopMilestones.reached, count]; | ||
| loopMilestoneHit = count; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| await writeState(state); | ||
| // Loop milestone takes exclusive interactive slot — no GTM alongside it | ||
| if (loopMilestoneHit !== null) { | ||
| return { | ||
| inlineMilestones, | ||
| interactivePrompt: { kind: "loop_milestone", count: loopMilestoneHit } | ||
| }; | ||
| } | ||
| // GTM: max 1 interactive prompt per run. Priority: waitlist > feedback > star | ||
| // Suppressed if suppressUntilRun is set and not yet reached | ||
| if (state.suppressUntilRun > state.runCount) { | ||
| return { inlineMilestones, interactivePrompt: null }; | ||
| } | ||
| const interactivePrompt = selectGtmPrompt(state, input, savedThisRun, rankChanged); | ||
| return { inlineMilestones, interactivePrompt }; | ||
| } | ||
| function selectGtmPrompt(state, input, savedThisRun, _rankChanged) { | ||
| // Waitlist: $50+ saved OR 2+ repos | ||
| if (state.waitlist.status === "not_asked" && | ||
| (state.totalSavedUsd >= 50 || state.reposUsed.length >= 2)) { | ||
| return { kind: "waitlist" }; | ||
| } | ||
| // Feedback: $10+ saved OR 5+ successful runs OR rollback/verifier-block | ||
| // Also fire every $50 saved after first feedback | ||
| const feedbackBySpend = state.totalSavedUsd >= 10 && | ||
| (state.feedback.lastShownAtSavedUsd === undefined || | ||
| state.totalSavedUsd - state.feedback.lastShownAtSavedUsd >= 50); | ||
| // Recurrence intervals — product decisions, not magic numbers. | ||
| // feedbackByRuns: re-prompt every 15 successful runs after first show. | ||
| // feedbackByEvent: re-prompt every 10 successful runs after a qualifying event show. | ||
| const FEEDBACK_RECURRENCE_RUN_INTERVAL = 15; | ||
| const FEEDBACK_RECURRENCE_EVENT_INTERVAL = 10; | ||
| const feedbackByRuns = state.successfulRunCount >= 5 && | ||
| (state.feedback.lastShownAtRunCount === undefined || | ||
| state.successfulRunCount - state.feedback.lastShownAtRunCount >= FEEDBACK_RECURRENCE_RUN_INTERVAL); | ||
| const feedbackByEvent = (input.rollbackTaken || input.verifierBlock) && | ||
| (state.feedback.shownCount === 0 || | ||
| state.successfulRunCount - (state.feedback.lastShownAtRunCount ?? 0) >= FEEDBACK_RECURRENCE_EVENT_INTERVAL); | ||
| if (feedbackBySpend || feedbackByRuns || feedbackByEvent) { | ||
| return { kind: "feedback" }; | ||
| } | ||
| // Star: soft from run 2, hard from run 10. Max 2 shows total. Suppress after confirmed. | ||
| if (!state.star.confirmed && state.star.shownCount < 2 && state.successfulRunCount >= 2) { | ||
| return { kind: "star", hard: state.successfulRunCount >= 10 }; | ||
| } | ||
| return null; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Post-interaction recording | ||
| // --------------------------------------------------------------------------- | ||
| export async function recordStarConfirmed() { | ||
| const state = await readState(); | ||
| state.star.confirmed = true; | ||
| await writeState(state); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // CLI intake — best-effort, never blocks user flow. | ||
| // Submits to MartinLoop-owned Supabase Edge Function. Auth key is server-side. | ||
| // | ||
| // Intake URL model: production default endpoint. | ||
| // Read at call time so MARTIN_INTAKE_URL overrides work in tests and at runtime. | ||
| // "not_configured" cannot occur — a hardcoded fallback always exists. | ||
| // --------------------------------------------------------------------------- | ||
| const INTAKE_DEFAULT_URL = "https://tupopqvqnyyjuxseyxkr.supabase.co/functions/v1/cli-intake"; | ||
| function getIntakeUrl() { | ||
| return process.env["MARTIN_INTAKE_URL"] ?? INTAKE_DEFAULT_URL; | ||
| } | ||
| export function intakeDeliveryFromResult(result) { | ||
| if (result.ok) { | ||
| return { | ||
| status: result.status, | ||
| recordedAt: new Date().toISOString(), | ||
| ...(result.submissionId ? { submissionId: result.submissionId } : {}) | ||
| }; | ||
| } | ||
| return { | ||
| status: result.retryable ? "queued" : "rejected", | ||
| recordedAt: new Date().toISOString() | ||
| }; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Retry queue (~/.martin/intake-draft.jsonl) | ||
| // | ||
| // Scope: GLOBAL — one file shared across all workspaces/repos on | ||
| // this machine, same as the milestone state file. | ||
| // Submissions are user-level, not repo-level. | ||
| // | ||
| // Concurrent-write: ALL queue writes (queueIntakeRetry and retryQueuedIntake) | ||
| // acquire an exclusive lock file (.jsonl.lock) before the | ||
| // read-modify-write cycle. Lock uses O_CREAT|O_EXCL (atomic | ||
| // on POSIX and NTFS), stores the writer's PID for stale-lock | ||
| // detection, and has a 500 ms bounded wait with 50 ms retry | ||
| // interval. Failure to acquire the lock silently skips the | ||
| // queue operation — the main CLI command is never affected. | ||
| // Two concurrent writers are serialized: the second reads the | ||
| // first's entry and writes both, so no entry is lost. | ||
| // | ||
| // What is queued: retryable failures only (timeout, network_error, server_error, | ||
| // 429 rate-limit) | ||
| // What is excluded: email (PII stripped at queue-write time) | ||
| // 422/4xx permanent rejections (retrying is futile) | ||
| // PII migration: sanitizeRetryQueue() strips email from any legacy entries | ||
| // written by older CLI versions before processing the queue | ||
| // Opt-out: if state.feedback.optedOut, nothing is queued and existing | ||
| // queue is cleared on next retryQueuedIntake() call | ||
| // Max size: 50 entries — enforced at every write; oldest dropped | ||
| // Retention: entries older than 30 days are dropped on read | ||
| // Deduplication: entries with the same event:cliVersion:createdAt key are | ||
| // replaced rather than appended (enforced at every write) | ||
| // File mode: 0o600 (user read/write only), written via atomic rename | ||
| // Retry trigger: retryQueuedIntake() — called only on verified-success | ||
| // interactive TTY runs; no immediate backoff (next-run retry) | ||
| // --------------------------------------------------------------------------- | ||
| const MAX_RETRY_ENTRIES = 50; | ||
| const RETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; | ||
| const LOCK_RETRY_INTERVAL_MS = 50; | ||
| const LOCK_TIMEOUT_MS = 500; | ||
| function intakeDraftPath() { | ||
| return join(homedir(), ".martin", "intake-draft.jsonl"); | ||
| } | ||
| function queueLockPath() { | ||
| return `${intakeDraftPath()}.lock`; | ||
| } | ||
| async function ensureIntakeDirectory() { | ||
| await mkdir(dirname(intakeDraftPath()), { recursive: true, mode: 0o700 }); | ||
| } | ||
| // Acquire an exclusive lock file. Uses O_CREAT|O_EXCL (atomic on POSIX and | ||
| // NTFS). Stores the writer's PID so stale locks from dead processes are | ||
| // detected and removed. Bounded wait: LOCK_TIMEOUT_MS with LOCK_RETRY_INTERVAL_MS | ||
| // between attempts. Returns false on timeout — never throws. | ||
| async function acquireQueueLock() { | ||
| const lock = queueLockPath(); | ||
| await ensureIntakeDirectory(); | ||
| const deadline = Date.now() + LOCK_TIMEOUT_MS; | ||
| while (Date.now() < deadline) { | ||
| try { | ||
| // wx = O_WRONLY|O_CREAT|O_EXCL — atomic exclusive creation | ||
| await writeFile(lock, String(process.pid), { flag: "wx", encoding: "utf8" }); | ||
| return true; | ||
| } | ||
| catch (err) { | ||
| if (err.code !== "EEXIST") | ||
| return false; | ||
| // Lock exists — check whether the holding process is still alive | ||
| try { | ||
| const held = parseInt(await readFile(lock, "utf8"), 10); | ||
| if (!isNaN(held) && held !== process.pid) { | ||
| try { | ||
| process.kill(held, 0); // throws ESRCH if process is gone | ||
| } | ||
| catch { | ||
| // Stale lock — remove and retry immediately | ||
| await rm(lock, { force: true }).catch(() => undefined); | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| catch { /* ignore read errors — retry after interval */ } | ||
| await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS)); | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| async function releaseQueueLock() { | ||
| await rm(queueLockPath(), { force: true }).catch(() => undefined); | ||
| } | ||
| // Read the queue, applying retention, deduplication, and the entry cap. | ||
| async function readRetryQueue() { | ||
| const raw = await readFile(intakeDraftPath(), "utf8").catch(() => ""); | ||
| const cutoff = Date.now() - RETRY_RETENTION_MS; | ||
| const all = raw | ||
| .split("\n") | ||
| .filter(Boolean) | ||
| .flatMap((line) => { | ||
| try { | ||
| const entry = JSON.parse(line); | ||
| const failedAt = Date.parse(entry._failedAt); | ||
| if (!Number.isFinite(failedAt) || failedAt < cutoff) | ||
| return []; | ||
| return [entry]; | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| }); | ||
| // Dedup: last occurrence of each key wins | ||
| const seen = new Map(); | ||
| for (const entry of all) { | ||
| seen.set(`${entry.event}:${entry.cliVersion}:${entry.createdAt}`, entry); | ||
| } | ||
| return [...seen.values()].slice(-MAX_RETRY_ENTRIES); | ||
| } | ||
| // Full rewrite — always called while holding the queue lock. | ||
| async function writeRetryQueue(entries) { | ||
| await ensureIntakeDirectory(); | ||
| const path = intakeDraftPath(); | ||
| const tempPath = `${path}.${process.pid}.tmp`; | ||
| const body = entries.map((e) => JSON.stringify(e)).join("\n"); | ||
| await writeFile(tempPath, body ? `${body}\n` : "", { encoding: "utf8", mode: 0o600 }); | ||
| await rename(tempPath, path); | ||
| } | ||
| // Add one entry to the retry queue under an exclusive lock. | ||
| // Concurrent processes are serialized: the second writer reads the first's | ||
| // entry before writing, so both entries are preserved. | ||
| // Silently skips if the lock cannot be acquired — the main CLI command is | ||
| // never affected; the event may be re-submitted on the next eligible run. | ||
| async function queueIntakeRetry(payload, reason) { | ||
| const state = await readState(); | ||
| if (state.feedback.optedOut) | ||
| return; | ||
| const { email: _email, ...safePayload } = payload; | ||
| const entry = { ...safePayload, _failedAt: new Date().toISOString(), _reason: reason }; | ||
| const locked = await acquireQueueLock().catch(() => false); | ||
| if (!locked) | ||
| return; // skip silently — lock held by another process | ||
| try { | ||
| const existing = await readRetryQueue(); | ||
| const dedupeKey = `${entry.event}:${entry.cliVersion}:${entry.createdAt}`; | ||
| const deduped = existing.filter((c) => `${c.event}:${c.cliVersion}:${c.createdAt}` !== dedupeKey); | ||
| await writeRetryQueue([...deduped, entry].slice(-MAX_RETRY_ENTRIES)); | ||
| } | ||
| finally { | ||
| await releaseQueueLock(); | ||
| } | ||
| } | ||
| export async function submitToIntake(payload, options = {}) { | ||
| const { queueOnFailure = true } = options; | ||
| const controller = new AbortController(); | ||
| // 3 s timeout — intake is optional and non-blocking; next-run retry handles failures. | ||
| const timer = setTimeout(() => controller.abort(), 3_000); | ||
| try { | ||
| const response = await fetch(getIntakeUrl(), { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Idempotency-Key": `${payload.event}:${payload.cliVersion}:${payload.createdAt}`, | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| signal: controller.signal, | ||
| }); | ||
| // 409 = idempotent duplicate | ||
| if (response.status === 409) | ||
| return { ok: true, status: "duplicate" }; | ||
| // 422 = validation rejection — payload will never be valid; do not retry | ||
| if (response.status === 422) { | ||
| return { ok: false, status: "rejected", retryable: false, message: "Submission rejected by intake validation." }; | ||
| } | ||
| // 429 = rate-limited — retry on the next eligible run (next-run retry, not immediate backoff) | ||
| if (response.status === 429) { | ||
| const result = { | ||
| ok: false, | ||
| status: "server_error", | ||
| retryable: true, | ||
| message: "Intake request rate-limited (429). Will retry on next run.", | ||
| }; | ||
| if (queueOnFailure) { | ||
| await queueIntakeRetry(payload, "server_error").catch(() => undefined); | ||
| } | ||
| return result; | ||
| } | ||
| if (!response.ok) { | ||
| // 400, 401, 403 and other 4xx: permanent client-side failures — do not retry. | ||
| // 5xx: transient server failures — retry on next eligible run. | ||
| const result = { | ||
| ok: false, | ||
| status: "server_error", | ||
| retryable: response.status >= 500, | ||
| message: `Intake request failed with HTTP ${response.status}.`, | ||
| }; | ||
| if (result.retryable && queueOnFailure) { | ||
| await queueIntakeRetry(payload, "server_error").catch(() => undefined); | ||
| } | ||
| return result; | ||
| } | ||
| const body = (await response.json().catch(() => ({}))); | ||
| return { | ||
| ok: true, | ||
| status: "accepted", | ||
| submissionId: typeof body.id === "string" ? body.id : undefined, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const timedOut = error instanceof Error && error.name === "AbortError"; | ||
| const reason = timedOut ? "timeout" : "network_error"; | ||
| const result = timedOut | ||
| ? { ok: false, status: "timeout", retryable: true, message: "Intake request timed out after 3 seconds. Will retry on next run." } | ||
| : { ok: false, status: "network_error", retryable: true, message: error instanceof Error ? error.message : String(error) }; | ||
| if (queueOnFailure) { | ||
| await queueIntakeRetry(payload, reason).catch(() => undefined); | ||
| } | ||
| return result; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Retry queued intake submissions. | ||
| // Called only on verified-success interactive TTY runs — never in CI, MCP, | ||
| // quiet, JSON, or piped output modes. Never recurses: uses queueOnFailure:false. | ||
| // --------------------------------------------------------------------------- | ||
| // Strips email from any legacy queue entries written by older CLI versions. | ||
| // readRetryQueue() already drops malformed lines, so only structurally valid | ||
| // entries with an unexpected email field need to be sanitized here. | ||
| async function sanitizeRetryQueue() { | ||
| const raw = await readFile(intakeDraftPath(), "utf8").catch(() => ""); | ||
| if (!raw.trim()) | ||
| return; | ||
| const sanitized = raw | ||
| .split("\n") | ||
| .filter(Boolean) | ||
| .map((line) => { | ||
| try { | ||
| const entry = JSON.parse(line); | ||
| if ("email" in entry) { | ||
| const { email: _e, ...clean } = entry; | ||
| return JSON.stringify(clean); | ||
| } | ||
| return line; | ||
| } | ||
| catch { | ||
| return null; // drop malformed lines | ||
| } | ||
| }) | ||
| .filter((l) => l !== null) | ||
| .join("\n"); | ||
| if (sanitized !== raw.trim()) { | ||
| await writeRetryQueue(sanitized | ||
| .split("\n") | ||
| .filter(Boolean) | ||
| .flatMap((l) => { try { | ||
| return [JSON.parse(l)]; | ||
| } | ||
| catch { | ||
| return []; | ||
| } })).catch(() => undefined); | ||
| } | ||
| } | ||
| export async function retryQueuedIntake() { | ||
| // Acquire exclusive lock for the full read-compact-rewrite cycle. | ||
| // If another process holds the lock, skip this attempt silently — | ||
| // the main CLI result is never affected. | ||
| const locked = await acquireQueueLock().catch(() => false); | ||
| if (!locked) | ||
| return; | ||
| try { | ||
| // Sanitize legacy entries (strips PII from old CLI versions) while holding lock. | ||
| await sanitizeRetryQueue().catch(() => undefined); | ||
| const state = await readState(); | ||
| if (state.feedback.optedOut) { | ||
| await writeRetryQueue([]).catch(() => undefined); | ||
| return; | ||
| } | ||
| const queued = await readRetryQueue(); | ||
| if (queued.length === 0) | ||
| return; | ||
| const remaining = []; | ||
| for (const entry of queued) { | ||
| const { _failedAt: _f, _reason: _r, ...payload } = entry; | ||
| // queueOnFailure:false prevents recursive re-queueing from within a retry run. | ||
| const result = await submitToIntake(payload, { queueOnFailure: false }); | ||
| if (!result.ok && result.retryable) { | ||
| remaining.push(entry); | ||
| } | ||
| } | ||
| await writeRetryQueue(remaining).catch(() => undefined); | ||
| } | ||
| finally { | ||
| await releaseQueueLock(); | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Post-interaction recording | ||
| // --------------------------------------------------------------------------- | ||
| export async function recordWaitlistJoined(email) { | ||
| const state = await readState(); | ||
| state.waitlist.status = "joined"; | ||
| state.waitlist.email = email; | ||
| state.waitlist.shownAt = new Date().toISOString(); | ||
| await writeState(state); | ||
| const submission = await submitToIntake({ | ||
| source: "martin-cli", | ||
| event: "pilot_interest", | ||
| cliVersion: CLI_VERSION, | ||
| email, | ||
| consentToContact: true, | ||
| platform: process.platform, | ||
| createdAt: new Date().toISOString(), | ||
| }); | ||
| const updated = await readState(); | ||
| updated.waitlist.lastDelivery = intakeDeliveryFromResult(submission); | ||
| await writeState(updated); | ||
| } | ||
| export async function recordWaitlistDeclined() { | ||
| const state = await readState(); | ||
| state.waitlist.declinedCount += 1; | ||
| if (state.waitlist.declinedCount >= 2) { | ||
| state.waitlist.status = "declined"; | ||
| } | ||
| await writeState(state); | ||
| } | ||
| export async function recordFeedback(score, featureVote, email) { | ||
| const state = await readState(); | ||
| state.feedback.shownCount += 1; | ||
| state.feedback.lastShownAtSavedUsd = state.totalSavedUsd; | ||
| state.feedback.lastShownAtRunCount = state.successfulRunCount; | ||
| state.feedback.scores = [...state.feedback.scores, { score, runCount: state.successfulRunCount }]; | ||
| if (featureVote) | ||
| state.feedback.featureVotes = [...state.feedback.featureVotes, featureVote]; | ||
| if (email) | ||
| state.feedback.email = email; | ||
| await writeState(state); | ||
| const submission = await submitToIntake({ | ||
| source: "martin-cli", | ||
| event: "feedback", | ||
| cliVersion: CLI_VERSION, | ||
| score, | ||
| ...(featureVote ? { featureVote } : {}), | ||
| ...(email ? { email } : {}), | ||
| consentToContact: !!email, | ||
| platform: process.platform, | ||
| createdAt: new Date().toISOString(), | ||
| }); | ||
| const updated = await readState(); | ||
| updated.feedback.lastDelivery = intakeDeliveryFromResult(submission); | ||
| await writeState(updated); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // For martin stats command | ||
| // --------------------------------------------------------------------------- | ||
| export async function readMilestoneState() { | ||
| try { | ||
| const raw = await readFile(STATE_PATH, "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| return parsed.version === 5 ? parsed : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Governed badge CTA — one-time post-run prompt | ||
| // --------------------------------------------------------------------------- | ||
| export function isBadgeCtaEligible(state) { | ||
| return state.governedBadge.ctaShownAt === null; | ||
| } | ||
| export async function recordBadgeCtaShown() { | ||
| const state = await readState(); | ||
| state.governedBadge.ctaShownAt = new Date().toISOString(); | ||
| await writeState(state); | ||
| } | ||
| //# sourceMappingURL=cli-milestone-state.js.map |
| import type { MartinOutputMode } from "../../contracts/index.js"; | ||
| export declare class InstallError extends Error { | ||
| constructor(message: string); | ||
| } | ||
| export interface NativeInstallRuntime { | ||
| platform: NodeJS.Platform; | ||
| arch: string; | ||
| fetchBytes(url: string): Promise<Uint8Array>; | ||
| verifyExecutable(path: string): string; | ||
| now(): number; | ||
| } | ||
| export interface InstallOptions { | ||
| version?: string; | ||
| dir?: string; | ||
| outputMode: MartinOutputMode; | ||
| runtime?: NativeInstallRuntime; | ||
| } | ||
| export interface InstallResult { | ||
| version: string; | ||
| installPath: string; | ||
| aliasPath: string; | ||
| backupPath?: string; | ||
| target: string; | ||
| assetName: string; | ||
| } | ||
| export declare function nativeTarget(platformName?: NodeJS.Platform, architecture?: string): string; | ||
| export declare function nativeAssetName(target: string): string; | ||
| export declare function parseSha256File(contents: string, expectedAsset: string): string; | ||
| export declare function runInstall(options: InstallOptions): Promise<InstallResult>; | ||
| export declare function verifyInstalledNativeBinary(path: string): { | ||
| path: string; | ||
| size: number; | ||
| sha256: string; | ||
| }; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { createHash } from "node:crypto"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; | ||
| import { arch, homedir, platform } from "node:os"; | ||
| import { join } from "node:path"; | ||
| const RELEASE_REPOSITORY = "Keesan12/martin-loop"; | ||
| const PRODUCT_NAME = "martin-loop"; | ||
| export class InstallError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = "InstallError"; | ||
| } | ||
| } | ||
| export function nativeTarget(platformName = platform(), architecture = arch()) { | ||
| const os = platformName === "darwin" | ||
| ? "macos" | ||
| : platformName === "win32" | ||
| ? "win" | ||
| : platformName === "linux" | ||
| ? "linux" | ||
| : undefined; | ||
| const normalizedArch = architecture === "x64" || architecture === "arm64" ? architecture : undefined; | ||
| if (!os) { | ||
| throw new InstallError(`Unsupported operating system: ${platformName}`); | ||
| } | ||
| if (!normalizedArch) { | ||
| throw new InstallError(`Unsupported architecture: ${architecture}`); | ||
| } | ||
| if (os === "win" && normalizedArch !== "x64") { | ||
| throw new InstallError("Windows native releases currently support x64 only"); | ||
| } | ||
| return `${os}-${normalizedArch}`; | ||
| } | ||
| export function nativeAssetName(target) { | ||
| return `${PRODUCT_NAME}-${target}${target.startsWith("win-") ? ".exe" : ""}`; | ||
| } | ||
| export function parseSha256File(contents, expectedAsset) { | ||
| const match = contents.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); | ||
| if (!match) { | ||
| throw new InstallError("Checksum file is missing or malformed"); | ||
| } | ||
| const digest = match[1]; | ||
| const fileName = match[2]; | ||
| if (!digest || !fileName) { | ||
| throw new InstallError("Checksum file is missing or malformed"); | ||
| } | ||
| if (fileName !== expectedAsset) { | ||
| throw new InstallError(`Checksum file names ${fileName} instead of expected asset ${expectedAsset}`); | ||
| } | ||
| return digest.toLowerCase(); | ||
| } | ||
| function sha256(bytes) { | ||
| return createHash("sha256").update(bytes).digest("hex"); | ||
| } | ||
| function validateBinary(bytes, target) { | ||
| if (bytes.byteLength < 1024) { | ||
| throw new InstallError(`Downloaded asset is too small (${bytes.byteLength} bytes); the release asset may be missing`); | ||
| } | ||
| const isPe = bytes[0] === 0x4d && bytes[1] === 0x5a; | ||
| const isElf = bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46; | ||
| const magic = Buffer.from(bytes.subarray(0, 4)).readUInt32BE(0); | ||
| const isMachO = magic === 0xfeedface || | ||
| magic === 0xfeedfacf || | ||
| magic === 0xcefaedfe || | ||
| magic === 0xcffaedfe; | ||
| const valid = target.startsWith("win-") | ||
| ? isPe | ||
| : target.startsWith("linux-") | ||
| ? isElf | ||
| : isMachO; | ||
| if (!valid) { | ||
| throw new InstallError(`Downloaded asset is not a valid ${target} executable`); | ||
| } | ||
| } | ||
| async function fetchBytes(url) { | ||
| let response; | ||
| try { | ||
| response = await fetch(url, { | ||
| headers: { | ||
| Accept: "application/octet-stream", | ||
| "User-Agent": "martin-loop-installer" | ||
| }, | ||
| redirect: "follow" | ||
| }); | ||
| } | ||
| catch (error) { | ||
| throw new InstallError(`Network request failed for ${url}: ${error.message}`); | ||
| } | ||
| if (!response.ok) { | ||
| throw new InstallError(`HTTP ${response.status} downloading ${url}`); | ||
| } | ||
| return new Uint8Array(await response.arrayBuffer()); | ||
| } | ||
| const defaultRuntime = { | ||
| platform: platform(), | ||
| arch: arch(), | ||
| fetchBytes, | ||
| verifyExecutable(path) { | ||
| return execFileSync(path, ["--version"], { | ||
| encoding: "utf8", | ||
| timeout: 10_000, | ||
| windowsHide: true | ||
| }).trim(); | ||
| }, | ||
| now: () => Date.now() | ||
| }; | ||
| function defaultInstallDirectory(platformName) { | ||
| if (platformName === "win32") { | ||
| return join(process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local"), "martin-loop", "bin"); | ||
| } | ||
| return join(homedir(), ".local", "bin"); | ||
| } | ||
| function readVersionFromLatestRelease(bytes) { | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(Buffer.from(bytes).toString("utf8")); | ||
| } | ||
| catch { | ||
| throw new InstallError("Latest release response was not valid JSON"); | ||
| } | ||
| if (typeof parsed.tag_name !== "string" || !/^v\d+\.\d+\.\d+/.test(parsed.tag_name)) { | ||
| throw new InstallError("Latest release response did not contain a valid version tag"); | ||
| } | ||
| return parsed.tag_name.slice(1); | ||
| } | ||
| function removeIfPresent(path) { | ||
| if (existsSync(path)) { | ||
| rmSync(path, { force: true }); | ||
| } | ||
| } | ||
| export async function runInstall(options) { | ||
| const runtime = options.runtime ?? defaultRuntime; | ||
| const target = nativeTarget(runtime.platform, runtime.arch); | ||
| const assetName = nativeAssetName(target); | ||
| const installDirectory = options.dir ?? defaultInstallDirectory(runtime.platform); | ||
| const extension = target.startsWith("win-") ? ".exe" : ""; | ||
| const installPath = join(installDirectory, `${PRODUCT_NAME}${extension}`); | ||
| const aliasPath = join(installDirectory, `martin${extension}`); | ||
| const nonce = `${process.pid}-${runtime.now()}`; | ||
| const stagedPath = join(installDirectory, `.${PRODUCT_NAME}.${nonce}.stage${extension}`); | ||
| const stagedAliasPath = join(installDirectory, `.martin.${nonce}.stage${extension}`); | ||
| const backupPath = join(installDirectory, `.${PRODUCT_NAME}.${nonce}.backup${extension}`); | ||
| const aliasBackupPath = join(installDirectory, `.martin.${nonce}.backup${extension}`); | ||
| const version = options.version ?? | ||
| readVersionFromLatestRelease(await runtime.fetchBytes(`https://api.github.com/repos/${RELEASE_REPOSITORY}/releases/latest`)); | ||
| if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { | ||
| throw new InstallError(`Invalid release version: ${version}`); | ||
| } | ||
| const releaseBase = `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${version}`; | ||
| const assetUrl = `${releaseBase}/${assetName}`; | ||
| const checksumUrl = `${assetUrl}.sha256`; | ||
| const assetBytes = await runtime.fetchBytes(assetUrl); | ||
| const checksumBytes = await runtime.fetchBytes(checksumUrl); | ||
| const expected = parseSha256File(Buffer.from(checksumBytes).toString("utf8"), assetName); | ||
| const actual = sha256(assetBytes); | ||
| if (actual !== expected) { | ||
| throw new InstallError(`Checksum mismatch for ${assetName}`); | ||
| } | ||
| validateBinary(assetBytes, target); | ||
| mkdirSync(installDirectory, { recursive: true }); | ||
| writeFileSync(stagedPath, assetBytes, { mode: 0o755, flag: "wx" }); | ||
| if (runtime.platform !== "win32") { | ||
| chmodSync(stagedPath, 0o755); | ||
| } | ||
| try { | ||
| runtime.verifyExecutable(stagedPath); | ||
| } | ||
| catch (error) { | ||
| removeIfPresent(stagedPath); | ||
| throw new InstallError(`Downloaded executable failed verification: ${error.message}`); | ||
| } | ||
| const hadInstall = existsSync(installPath); | ||
| const hadAlias = existsSync(aliasPath); | ||
| try { | ||
| if (hadInstall) | ||
| renameSync(installPath, backupPath); | ||
| if (hadAlias) | ||
| renameSync(aliasPath, aliasBackupPath); | ||
| renameSync(stagedPath, installPath); | ||
| if (runtime.platform === "win32") { | ||
| copyFileSync(installPath, stagedAliasPath); | ||
| } | ||
| else { | ||
| symlinkSync(installPath, stagedAliasPath); | ||
| } | ||
| renameSync(stagedAliasPath, aliasPath); | ||
| runtime.verifyExecutable(installPath); | ||
| } | ||
| catch (error) { | ||
| removeIfPresent(stagedPath); | ||
| removeIfPresent(stagedAliasPath); | ||
| removeIfPresent(aliasPath); | ||
| removeIfPresent(installPath); | ||
| if (hadInstall && existsSync(backupPath)) | ||
| renameSync(backupPath, installPath); | ||
| if (hadAlias && existsSync(aliasBackupPath)) | ||
| renameSync(aliasBackupPath, aliasPath); | ||
| throw new InstallError(`Native install failed and was rolled back: ${error.message}`); | ||
| } | ||
| removeIfPresent(aliasBackupPath); | ||
| return { | ||
| version, | ||
| installPath, | ||
| aliasPath, | ||
| ...(hadInstall && existsSync(backupPath) ? { backupPath } : {}), | ||
| target, | ||
| assetName | ||
| }; | ||
| } | ||
| export function verifyInstalledNativeBinary(path) { | ||
| if (!existsSync(path)) { | ||
| throw new InstallError(`Installed binary not found: ${path}`); | ||
| } | ||
| const bytes = readFileSync(path); | ||
| return { | ||
| path, | ||
| size: statSync(path).size, | ||
| sha256: sha256(bytes) | ||
| }; | ||
| } | ||
| //# sourceMappingURL=install.js.map |
| export declare const MARTINLOOP_BADGE_MARKDOWN = "[](https://martinloop.com)"; | ||
| export declare const MARTINLOOP_BADGE_CTA: readonly ["", "Your repo is now governed by MartinLoop.", "Add the badge to your README:", "", "[](https://martinloop.com)"]; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| export const MARTINLOOP_BADGE_MARKDOWN = "[](https://martinloop.com)"; | ||
| export const MARTINLOOP_BADGE_CTA = [ | ||
| "", | ||
| "Your repo is now governed by MartinLoop.", | ||
| "Add the badge to your README:", | ||
| "", | ||
| MARTINLOOP_BADGE_MARKDOWN, | ||
| ]; | ||
| //# sourceMappingURL=governed-badge.js.map |
| export interface MartinMcpInstallRecord { | ||
| id: string; | ||
| host: string; | ||
| scope: string; | ||
| targetPath: string; | ||
| installedSha256: string; | ||
| backupPath: string | null; | ||
| installedAt: string; | ||
| } | ||
| export interface MartinMcpInstallLedger { | ||
| schemaVersion: 1; | ||
| installs: MartinMcpInstallRecord[]; | ||
| } | ||
| export interface RecordMartinMcpInstallInput { | ||
| host: string; | ||
| scope: string; | ||
| targetPath: string; | ||
| content: string; | ||
| previousContent?: string; | ||
| stateRoot?: string; | ||
| } | ||
| export interface MartinMcpInstallSelector { | ||
| host: string; | ||
| scope: string; | ||
| targetPath: string; | ||
| stateRoot?: string; | ||
| } | ||
| export interface MartinMcpInstallVerification { | ||
| status: "ok" | "missing_record" | "missing" | "modified"; | ||
| targetPath: string; | ||
| record: MartinMcpInstallRecord | null; | ||
| } | ||
| export declare function recordMartinMcpInstall(input: RecordMartinMcpInstallInput): Promise<MartinMcpInstallRecord>; | ||
| export declare function readMartinMcpInstallLedger(stateRoot?: string): Promise<MartinMcpInstallLedger>; | ||
| export declare function verifyMartinMcpInstall(selector: MartinMcpInstallSelector): Promise<MartinMcpInstallVerification>; | ||
| export declare function rollbackMartinMcpInstall(selector: MartinMcpInstallSelector): Promise<MartinMcpInstallRecord>; | ||
| export declare function uninstallMartinMcp(selector: MartinMcpInstallSelector): Promise<MartinMcpInstallRecord[]>; | ||
| export declare function writeFileAtomically(targetPath: string, content: string): Promise<void>; | ||
| export declare function resolveMartinMcpInstallStateRoot(override?: string): string; | ||
| export declare function sha256(content: string): string; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { basename, dirname, join } from "node:path"; | ||
| export async function recordMartinMcpInstall(input) { | ||
| const stateRoot = resolveMartinMcpInstallStateRoot(input.stateRoot); | ||
| const recordId = randomUUID(); | ||
| const backupPath = input.previousContent === undefined | ||
| ? null | ||
| : join(stateRoot, "backups", `${recordId}.bak`); | ||
| if (backupPath && input.previousContent !== undefined) { | ||
| await writeFileAtomically(backupPath, input.previousContent); | ||
| } | ||
| await writeFileAtomically(input.targetPath, input.content); | ||
| const ledger = await readMartinMcpInstallLedger(stateRoot); | ||
| const record = { | ||
| id: recordId, | ||
| host: input.host, | ||
| scope: input.scope, | ||
| targetPath: input.targetPath, | ||
| installedSha256: sha256(input.content), | ||
| backupPath, | ||
| installedAt: new Date().toISOString() | ||
| }; | ||
| ledger.installs.push(record); | ||
| await writeFileAtomically(join(stateRoot, "install-state.json"), `${JSON.stringify(ledger, null, 2)}\n`); | ||
| return record; | ||
| } | ||
| export async function readMartinMcpInstallLedger(stateRoot = resolveMartinMcpInstallStateRoot()) { | ||
| try { | ||
| const parsed = JSON.parse(await readFile(join(stateRoot, "install-state.json"), "utf8")); | ||
| if (parsed.schemaVersion === 1 && Array.isArray(parsed.installs)) { | ||
| return parsed; | ||
| } | ||
| } | ||
| catch { | ||
| // Missing or invalid state is treated as an empty local ledger. | ||
| } | ||
| return { schemaVersion: 1, installs: [] }; | ||
| } | ||
| export async function verifyMartinMcpInstall(selector) { | ||
| const stateRoot = resolveMartinMcpInstallStateRoot(selector.stateRoot); | ||
| const ledger = await readMartinMcpInstallLedger(stateRoot); | ||
| const record = matchingRecords(ledger, selector).at(-1) ?? null; | ||
| if (!record) { | ||
| return { status: "missing_record", targetPath: selector.targetPath, record }; | ||
| } | ||
| try { | ||
| const current = await readFile(selector.targetPath, "utf8"); | ||
| return { | ||
| status: sha256(current) === record.installedSha256 ? "ok" : "modified", | ||
| targetPath: selector.targetPath, | ||
| record | ||
| }; | ||
| } | ||
| catch { | ||
| return { status: "missing", targetPath: selector.targetPath, record }; | ||
| } | ||
| } | ||
| export async function rollbackMartinMcpInstall(selector) { | ||
| const stateRoot = resolveMartinMcpInstallStateRoot(selector.stateRoot); | ||
| const ledger = await readMartinMcpInstallLedger(stateRoot); | ||
| const record = matchingRecords(ledger, selector).at(-1); | ||
| await requireUnmodifiedInstall(selector, record); | ||
| await restoreRecordTarget(record); | ||
| ledger.installs = ledger.installs.filter((entry) => entry.id !== record.id); | ||
| await writeLedger(stateRoot, ledger); | ||
| return record; | ||
| } | ||
| export async function uninstallMartinMcp(selector) { | ||
| const stateRoot = resolveMartinMcpInstallStateRoot(selector.stateRoot); | ||
| const ledger = await readMartinMcpInstallLedger(stateRoot); | ||
| const records = matchingRecords(ledger, selector); | ||
| const latest = records.at(-1); | ||
| await requireUnmodifiedInstall(selector, latest); | ||
| const original = records[0]; | ||
| await restoreRecordTarget(original); | ||
| const recordIds = new Set(records.map((record) => record.id)); | ||
| ledger.installs = ledger.installs.filter((entry) => !recordIds.has(entry.id)); | ||
| await writeLedger(stateRoot, ledger); | ||
| return records; | ||
| } | ||
| export async function writeFileAtomically(targetPath, content) { | ||
| await mkdir(dirname(targetPath), { recursive: true }); | ||
| const temporaryPath = join(dirname(targetPath), `.${basename(targetPath)}.${randomUUID()}.tmp`); | ||
| try { | ||
| await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" }); | ||
| await rename(temporaryPath, targetPath); | ||
| } | ||
| catch (error) { | ||
| await rm(temporaryPath, { force: true }).catch(() => { }); | ||
| throw error; | ||
| } | ||
| } | ||
| export function resolveMartinMcpInstallStateRoot(override) { | ||
| return override ?? join(homedir(), ".martin-loop", "mcp-installs"); | ||
| } | ||
| export function sha256(content) { | ||
| return createHash("sha256").update(content, "utf8").digest("hex"); | ||
| } | ||
| function matchingRecords(ledger, selector) { | ||
| return ledger.installs.filter((record) => record.host === selector.host && | ||
| record.scope === selector.scope && | ||
| record.targetPath === selector.targetPath); | ||
| } | ||
| async function requireUnmodifiedInstall(selector, record) { | ||
| if (!record) { | ||
| throw new Error(`No recorded MartinLoop MCP install for ${selector.targetPath}.`); | ||
| } | ||
| const verification = await verifyMartinMcpInstall(selector); | ||
| if (verification.status !== "ok") { | ||
| throw new Error(`Refusing to modify ${selector.targetPath}: install verification is ${verification.status}.`); | ||
| } | ||
| } | ||
| async function restoreRecordTarget(record) { | ||
| if (record.backupPath) { | ||
| await writeFileAtomically(record.targetPath, await readFile(record.backupPath, "utf8")); | ||
| return; | ||
| } | ||
| await rm(record.targetPath, { force: true }); | ||
| } | ||
| async function writeLedger(stateRoot, ledger) { | ||
| await writeFileAtomically(join(stateRoot, "install-state.json"), `${JSON.stringify(ledger, null, 2)}\n`); | ||
| } | ||
| //# sourceMappingURL=mcp-install-state.js.map |
| import type { PostRunExperience, PostRunExperienceInput } from "./types.js"; | ||
| export declare function selectPostRunExperience(input: Readonly<PostRunExperienceInput>): PostRunExperience; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| export function selectPostRunExperience(input) { | ||
| const eligible = input.run.completed && | ||
| input.run.verified && | ||
| input.run.receiptFinalized && | ||
| input.run.persistenceFinalized && | ||
| input.run.exitCode === 0 && | ||
| input.environment.interactiveTty && | ||
| !input.environment.ci && | ||
| input.environment.outputMode === "human"; | ||
| if (!eligible) | ||
| return { kind: "none" }; | ||
| if (input.remote.required) { | ||
| return { kind: "required-notice", message: input.remote.required }; | ||
| } | ||
| // If the startup update prompt was shown this session, suppress all optional post-run experiences. | ||
| if (input.environment.startupPromptShown === true) { | ||
| return { kind: "none" }; | ||
| } | ||
| if (input.telemetry.noticeEligible) { | ||
| return { kind: "telemetry-notice" }; | ||
| } | ||
| if (input.localEngagement.runFiveFeedbackEligible) { | ||
| return { kind: "feedback", milestone: 5 }; | ||
| } | ||
| if (input.localEngagement.starEligible) { | ||
| return { kind: "star" }; | ||
| } | ||
| if (input.localEngagement.badgeEligible) { | ||
| return { kind: "badge" }; | ||
| } | ||
| if (input.remote.engagement) { | ||
| return { kind: "remote-experience", message: input.remote.engagement }; | ||
| } | ||
| return { kind: "none" }; | ||
| } | ||
| //# sourceMappingURL=coordinator.js.map |
| import type { PostRunExperience } from "./types.js"; | ||
| import type { RemoteExperienceV1 } from "../remote-experience.js"; | ||
| export interface PostRunExperienceRenderDependencies { | ||
| renderRequiredNotice(message: Extract<PostRunExperience, { | ||
| kind: "required-notice"; | ||
| }>["message"]): Promise<void>; | ||
| renderTelemetryNotice(): Promise<void>; | ||
| renderRunFiveFeedback(): Promise<void>; | ||
| renderStarPrompt(): Promise<void>; | ||
| renderBadge(): Promise<void>; | ||
| renderRemoteExperience(message: Extract<PostRunExperience, { | ||
| kind: "remote-experience"; | ||
| }>["message"]): Promise<void>; | ||
| } | ||
| export declare function renderPostRunExperience(experience: PostRunExperience, dependencies: PostRunExperienceRenderDependencies): Promise<void>; | ||
| export declare function renderRemoteExperienceMessage(message: RemoteExperienceV1): Promise<void>; | ||
| export interface DashboardInviteDeps { | ||
| /** Emit remote_experience_clicked event. No-op when telemetry is inactive. */ | ||
| emitClicked(experienceId: string, experienceType: string): Promise<void>; | ||
| recordDelivered(cooldownKey: string): Promise<void>; | ||
| recordDismissed(dismissKey: string): Promise<void>; | ||
| } | ||
| /** | ||
| * Renders an interactive [Y] Open / [L] Later / [N] Don't ask again prompt for | ||
| * dashboard_invite experiences. Does not collect email in the terminal. | ||
| * The secure website handles all account creation and email collection. | ||
| */ | ||
| export declare function renderDashboardInviteInteractive(message: RemoteExperienceV1, deps: DashboardInviteDeps): Promise<void>; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { spawn } from "node:child_process"; | ||
| function assertNever(value) { | ||
| throw new Error(`Unhandled post-run experience: ${JSON.stringify(value)}`); | ||
| } | ||
| export async function renderPostRunExperience(experience, dependencies) { | ||
| switch (experience.kind) { | ||
| case "required-notice": | ||
| await dependencies.renderRequiredNotice(experience.message); | ||
| return; | ||
| case "telemetry-notice": | ||
| await dependencies.renderTelemetryNotice(); | ||
| return; | ||
| case "feedback": | ||
| await dependencies.renderRunFiveFeedback(); | ||
| return; | ||
| case "star": | ||
| await dependencies.renderStarPrompt(); | ||
| return; | ||
| case "badge": | ||
| await dependencies.renderBadge(); | ||
| return; | ||
| case "remote-experience": | ||
| await dependencies.renderRemoteExperience(experience.message); | ||
| return; | ||
| case "none": | ||
| return; | ||
| default: | ||
| assertNever(experience); | ||
| } | ||
| } | ||
| export async function renderRemoteExperienceMessage(message) { | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`); | ||
| process.stdout.write(` ${message.title}\n\n`); | ||
| for (const line of message.body.split("\n")) { | ||
| process.stdout.write(` ${line}\n`); | ||
| } | ||
| if (message.action) { | ||
| process.stdout.write(`\n ${message.action.label}: ${message.action.url}\n`); | ||
| } | ||
| process.stdout.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`); | ||
| } | ||
| async function readDashboardInviteKey() { | ||
| return new Promise((resolve) => { | ||
| const stdin = process.stdin; | ||
| if (!stdin.isTTY) { | ||
| resolve("l"); | ||
| return; | ||
| } | ||
| const prev = stdin.isRaw; | ||
| stdin.setRawMode(true); | ||
| stdin.resume(); | ||
| stdin.setEncoding("utf-8"); | ||
| const timeout = setTimeout(() => { cleanup(); resolve("l"); }, 30_000); | ||
| const onData = (key) => { | ||
| // Ctrl+C, Enter, and newline all mean Later — never terminate the process. | ||
| if (key === "\u0003" || key === "\r" || key === "\n") { | ||
| cleanup(); | ||
| resolve("l"); | ||
| return; | ||
| } | ||
| const normalized = key.toLowerCase(); | ||
| cleanup(); | ||
| if (normalized === "y") | ||
| resolve("y"); | ||
| else if (normalized === "n") | ||
| resolve("n"); | ||
| else | ||
| resolve("l"); | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| stdin.removeListener("data", onData); | ||
| try { | ||
| stdin.setRawMode(prev ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| stdin.pause(); | ||
| }; | ||
| stdin.on("data", onData); | ||
| }); | ||
| } | ||
| async function openUrl(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| if (parsed.protocol !== "https:") | ||
| return false; | ||
| const safe = parsed.toString(); | ||
| const [command, args] = process.platform === "win32" | ||
| ? ["rundll32.exe", ["url.dll,FileProtocolHandler", safe]] | ||
| : process.platform === "darwin" | ||
| ? ["open", [safe]] | ||
| : ["xdg-open", [safe]]; | ||
| return await new Promise((resolve) => { | ||
| const child = spawn(command, args, { detached: true, stdio: "ignore", shell: false }); | ||
| child.once("spawn", () => { child.unref(); resolve(true); }); | ||
| child.once("error", () => resolve(false)); | ||
| }); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** | ||
| * Renders an interactive [Y] Open / [L] Later / [N] Don't ask again prompt for | ||
| * dashboard_invite experiences. Does not collect email in the terminal. | ||
| * The secure website handles all account creation and email collection. | ||
| */ | ||
| export async function renderDashboardInviteInteractive(message, deps) { | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`); | ||
| process.stdout.write(` ${message.title}\n\n`); | ||
| for (const line of message.body.split("\n")) { | ||
| process.stdout.write(` ${line}\n`); | ||
| } | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(" [Y] Open secure signup [L] Later [N] Don't ask again\n\n"); | ||
| process.stdout.write(" > "); | ||
| // Record delivery before awaiting keypress so an interrupted process doesn't repeat. | ||
| await deps.recordDelivered(message.cooldownKey); | ||
| const key = await readDashboardInviteKey(); | ||
| process.stdout.write(`${key.toUpperCase()}\n\n`); | ||
| if (key === "y" && message.action?.url) { | ||
| const opened = await openUrl(message.action.url); | ||
| if (opened) | ||
| await deps.emitClicked(message.id, message.type); | ||
| } | ||
| else if (key === "n") { | ||
| // Permanently suppress dashboard invites; does not affect required notices. | ||
| await deps.recordDismissed("dashboard_invite"); | ||
| } | ||
| // L/timeout: delivery already recorded above. | ||
| process.stdout.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`); | ||
| } | ||
| //# sourceMappingURL=renderer.js.map |
| import type { RemoteExperienceV1 } from "../remote-experience.js"; | ||
| export type PostRunExperience = { | ||
| kind: "required-notice"; | ||
| message: RemoteExperienceV1; | ||
| } | { | ||
| kind: "telemetry-notice"; | ||
| } | { | ||
| kind: "feedback"; | ||
| milestone: 5; | ||
| } | { | ||
| kind: "star"; | ||
| } | { | ||
| kind: "badge"; | ||
| } | { | ||
| kind: "remote-experience"; | ||
| message: RemoteExperienceV1; | ||
| } | { | ||
| kind: "none"; | ||
| }; | ||
| export interface PostRunExperienceInput { | ||
| run: { | ||
| completed: boolean; | ||
| verified: boolean; | ||
| receiptFinalized: boolean; | ||
| persistenceFinalized: boolean; | ||
| exitCode: number; | ||
| }; | ||
| environment: { | ||
| interactiveTty: boolean; | ||
| ci: boolean; | ||
| outputMode: "human" | "json" | "quiet" | "mcp"; | ||
| startupPromptShown?: boolean; | ||
| }; | ||
| telemetry: { | ||
| noticeEligible: boolean; | ||
| }; | ||
| localEngagement: { | ||
| runFiveFeedbackEligible: boolean; | ||
| starEligible: boolean; | ||
| badgeEligible: boolean; | ||
| }; | ||
| remote: { | ||
| required: RemoteExperienceV1 | null; | ||
| engagement: RemoteExperienceV1 | null; | ||
| }; | ||
| } |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| export {}; | ||
| //# sourceMappingURL=types.js.map |
| export interface RemoteExperienceV1 { | ||
| schemaVersion: 1; | ||
| id: string; | ||
| class: "required" | "engagement"; | ||
| type: "security_notice" | "migration_notice" | "update_notice" | "announcement" | "beta_invite" | "dashboard_invite" | "design_partner_invite"; | ||
| title: string; | ||
| body: string; | ||
| action?: { | ||
| label: string; | ||
| url: string; | ||
| }; | ||
| expiresAt?: string; | ||
| cooldownKey: string; | ||
| } | ||
| /** | ||
| * Generic request fields always sent. | ||
| * When telemetry is disabled or installId is null, only these fields are included. | ||
| * Never includes sessionId, run counts, repo data, scores, or workspace identifiers. | ||
| */ | ||
| export interface RemoteExperienceRequest { | ||
| schemaVersion: 1; | ||
| cliVersion: string; | ||
| nodeVersion: string; | ||
| platform: string; | ||
| arch: string; | ||
| /** Only included when telemetry is active and installId is available. */ | ||
| installId?: string; | ||
| } | ||
| export declare function parseRemoteExperience(value: unknown, nowMs?: number): RemoteExperienceV1 | null; | ||
| /** | ||
| * Returns the remote-experience endpoint (separate from product-events telemetry). | ||
| * Configured via MARTIN_REMOTE_EXPERIENCE_ENDPOINT. Returns "" when not set. | ||
| * fetchRemoteExperience returns null when the endpoint is empty. | ||
| */ | ||
| export declare function resolveRemoteExperienceEndpoint(): string; | ||
| export declare function fetchRemoteExperience(request: Readonly<RemoteExperienceRequest>, options: { | ||
| endpoint: string; | ||
| timeoutMs?: number; | ||
| fetchImpl?: typeof fetch; | ||
| }): Promise<RemoteExperienceV1 | null>; | ||
| /** | ||
| * Returns true when the cooldownKey was delivered within the cooldown window. | ||
| * Safe to call even if the ledger file does not exist. | ||
| */ | ||
| export declare function isRemoteExperienceOnCooldown(cooldownKey: string, nowMs?: number): Promise<boolean>; | ||
| /** | ||
| * Records that an experience was delivered. Call AFTER the experience has been | ||
| * displayed to the user — never at fetch or selection time. | ||
| */ | ||
| export declare function recordRemoteExperienceDelivered(cooldownKey: string, nowMs?: number): Promise<void>; | ||
| /** | ||
| * Returns true when the dismissKey has been permanently dismissed (user pressed N). | ||
| */ | ||
| export declare function isRemoteExperienceDismissed(dismissKey: string): Promise<boolean>; | ||
| /** | ||
| * Permanently suppresses an experience by dismissKey. Used when the user presses N. | ||
| * Does not affect required notices. | ||
| */ | ||
| export declare function recordRemoteExperienceDismissed(dismissKey: string): Promise<void>; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { martinFilePath } from "./home-dir.js"; | ||
| const REMOTE_TYPES = new Set([ | ||
| "security_notice", | ||
| "migration_notice", | ||
| "update_notice", | ||
| "announcement", | ||
| "beta_invite", | ||
| "dashboard_invite", | ||
| "design_partner_invite", | ||
| ]); | ||
| const REMOTE_ACTION_HOSTS = new Set([ | ||
| "martinloop.com", | ||
| "www.martinloop.com", | ||
| "app.martinloop.com", | ||
| ]); | ||
| function hasControlCharacters(value) { | ||
| return /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value); | ||
| } | ||
| export function parseRemoteExperience(value, nowMs = Date.now()) { | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) | ||
| return null; | ||
| const input = value; | ||
| if (input.schemaVersion !== 1) | ||
| return null; | ||
| if (typeof input.id !== "string" || input.id.length > 128) | ||
| return null; | ||
| if (input.class !== "required" && input.class !== "engagement") | ||
| return null; | ||
| if (typeof input.type !== "string" || !REMOTE_TYPES.has(input.type)) | ||
| return null; | ||
| if (typeof input.title !== "string" || input.title.length > 120) | ||
| return null; | ||
| if (typeof input.body !== "string" || input.body.length > 1200) | ||
| return null; | ||
| if (hasControlCharacters(input.title) || hasControlCharacters(input.body)) | ||
| return null; | ||
| if (typeof input.cooldownKey !== "string" || input.cooldownKey.length > 128) | ||
| return null; | ||
| let expiresAt; | ||
| if (input.expiresAt !== undefined) { | ||
| if (typeof input.expiresAt !== "string") | ||
| return null; | ||
| const parsed = Date.parse(input.expiresAt); | ||
| if (!Number.isFinite(parsed) || parsed <= nowMs) | ||
| return null; | ||
| expiresAt = input.expiresAt; | ||
| } | ||
| let action; | ||
| if (input.action !== undefined) { | ||
| if (!input.action || typeof input.action !== "object" || Array.isArray(input.action)) | ||
| return null; | ||
| const raw = input.action; | ||
| if (typeof raw.label !== "string" || raw.label.length > 80 || typeof raw.url !== "string") | ||
| return null; | ||
| let parsedUrl; | ||
| try { | ||
| parsedUrl = new URL(raw.url); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| if (parsedUrl.protocol !== "https:" || !REMOTE_ACTION_HOSTS.has(parsedUrl.hostname)) | ||
| return null; | ||
| action = { label: raw.label, url: raw.url }; | ||
| } | ||
| return { | ||
| schemaVersion: 1, | ||
| id: input.id, | ||
| class: input.class, | ||
| type: input.type, | ||
| title: input.title, | ||
| body: input.body, | ||
| cooldownKey: input.cooldownKey, | ||
| ...(expiresAt ? { expiresAt } : {}), | ||
| ...(action ? { action } : {}), | ||
| }; | ||
| } | ||
| // ─── Endpoint ───────────────────────────────────────────────────────────────── | ||
| /** | ||
| * Returns the remote-experience endpoint (separate from product-events telemetry). | ||
| * Configured via MARTIN_REMOTE_EXPERIENCE_ENDPOINT. Returns "" when not set. | ||
| * fetchRemoteExperience returns null when the endpoint is empty. | ||
| */ | ||
| export function resolveRemoteExperienceEndpoint() { | ||
| return process.env["MARTIN_REMOTE_EXPERIENCE_ENDPOINT"]?.trim() ?? ""; | ||
| } | ||
| // ─── Fetch ──────────────────────────────────────────────────────────────────── | ||
| export async function fetchRemoteExperience(request, options) { | ||
| if (!options.endpoint) | ||
| return null; | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 1500); | ||
| try { | ||
| const response = await (options.fetchImpl ?? fetch)(options.endpoint, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/json", | ||
| "user-agent": `MartinLoop-CLI/${request.cliVersion}`, | ||
| }, | ||
| body: JSON.stringify(request), | ||
| signal: controller.signal, | ||
| }); | ||
| if (!response.ok) | ||
| return null; | ||
| const body = await response.json(); | ||
| return parseRemoteExperience(body); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
| // ─── Delivery ledger ────────────────────────────────────────────────────────── | ||
| const DELIVERY_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days | ||
| function deliveryLedgerPath() { | ||
| return martinFilePath("remote-experience-delivered.json"); | ||
| } | ||
| async function readDeliveryLedger() { | ||
| try { | ||
| const raw = await fs.readFile(deliveryLedgerPath(), "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.schemaVersion !== 1 || !parsed.deliveries || typeof parsed.deliveries !== "object") { | ||
| return { schemaVersion: 1, deliveries: {}, dismissed: {} }; | ||
| } | ||
| return { | ||
| schemaVersion: 1, | ||
| deliveries: parsed.deliveries, | ||
| dismissed: (parsed.dismissed && typeof parsed.dismissed === "object" && !Array.isArray(parsed.dismissed)) | ||
| ? parsed.dismissed | ||
| : {}, | ||
| }; | ||
| } | ||
| catch { | ||
| return { schemaVersion: 1, deliveries: {}, dismissed: {} }; | ||
| } | ||
| } | ||
| async function writeDeliveryLedger(ledger) { | ||
| const target = deliveryLedgerPath(); | ||
| const directory = path.dirname(target); | ||
| const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; | ||
| await fs.mkdir(directory, { recursive: true }); | ||
| await fs.writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); | ||
| await fs.rename(temporary, target); | ||
| } | ||
| /** | ||
| * Returns true when the cooldownKey was delivered within the cooldown window. | ||
| * Safe to call even if the ledger file does not exist. | ||
| */ | ||
| export async function isRemoteExperienceOnCooldown(cooldownKey, nowMs = Date.now()) { | ||
| const ledger = await readDeliveryLedger(); | ||
| const deliveredAt = ledger.deliveries[cooldownKey]; | ||
| return typeof deliveredAt === "number" && nowMs - deliveredAt < DELIVERY_COOLDOWN_MS; | ||
| } | ||
| /** | ||
| * Records that an experience was delivered. Call AFTER the experience has been | ||
| * displayed to the user — never at fetch or selection time. | ||
| */ | ||
| export async function recordRemoteExperienceDelivered(cooldownKey, nowMs = Date.now()) { | ||
| try { | ||
| const ledger = await readDeliveryLedger(); | ||
| ledger.deliveries[cooldownKey] = nowMs; | ||
| await writeDeliveryLedger(ledger); | ||
| } | ||
| catch { /* delivery recording failures must never affect the run result */ } | ||
| } | ||
| /** | ||
| * Returns true when the dismissKey has been permanently dismissed (user pressed N). | ||
| */ | ||
| export async function isRemoteExperienceDismissed(dismissKey) { | ||
| const ledger = await readDeliveryLedger(); | ||
| return ledger.dismissed[dismissKey] === true; | ||
| } | ||
| /** | ||
| * Permanently suppresses an experience by dismissKey. Used when the user presses N. | ||
| * Does not affect required notices. | ||
| */ | ||
| export async function recordRemoteExperienceDismissed(dismissKey) { | ||
| try { | ||
| const ledger = await readDeliveryLedger(); | ||
| ledger.dismissed[dismissKey] = true; | ||
| await writeDeliveryLedger(ledger); | ||
| } | ||
| catch { /* dismissal recording failures must never affect the run result */ } | ||
| } | ||
| //# sourceMappingURL=remote-experience.js.map |
| export interface RunFiveSummary { | ||
| /** Total successful governed runs. Do not pass if unavailable — omit rather than guess. */ | ||
| completedRuns: number; | ||
| } | ||
| export declare function renderRunFiveFeedback(summary: RunFiveSummary): Promise<void>; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| async function readSingleKeypress() { | ||
| return new Promise((resolve) => { | ||
| const stdin = process.stdin; | ||
| if (!stdin.isTTY) { | ||
| resolve(""); | ||
| return; | ||
| } | ||
| const prev = stdin.isRaw; | ||
| stdin.setRawMode(true); | ||
| stdin.resume(); | ||
| stdin.setEncoding("utf-8"); | ||
| const timeout = setTimeout(() => { cleanup(); resolve(""); }, 15_000); | ||
| const onData = (key) => { | ||
| if (key === "\u0003") { | ||
| cleanup(); | ||
| process.exit(0); | ||
| } | ||
| cleanup(); | ||
| resolve(key); | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| stdin.removeListener("data", onData); | ||
| try { | ||
| stdin.setRawMode(prev ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| stdin.pause(); | ||
| }; | ||
| stdin.on("data", onData); | ||
| }); | ||
| } | ||
| export async function renderRunFiveFeedback(summary) { | ||
| if (!process.stdout.isTTY || !process.stdin.isTTY) | ||
| return; | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(`MartinLoop has completed ${summary.completedRuns} successful governed runs.\n\n`); | ||
| process.stdout.write(`Has MartinLoop been useful?\n`); | ||
| process.stdout.write(` 1 2 3 4 5\n\n`); | ||
| process.stdout.write(`What should we improve?\n`); | ||
| process.stdout.write(` [s] Speed [r] Reliability [u] UX [i] Integrations [x] Skip\n`); | ||
| process.stdout.write(` > `); | ||
| const key = await readSingleKeypress(); | ||
| process.stdout.write(`${key}\n\n`); | ||
| } | ||
| //# sourceMappingURL=run-five-feedback.js.map |
| export interface TelemetryConfigV1 { | ||
| schemaVersion: 1; | ||
| enabled: boolean; | ||
| noticeShown: boolean; | ||
| installId: string | null; | ||
| initializedEventSent: boolean; | ||
| } | ||
| export declare const DEFAULT_TELEMETRY_CONFIG: TelemetryConfigV1; | ||
| export declare function readTelemetryConfig(): Promise<TelemetryConfigV1>; | ||
| export declare function writeTelemetryConfig(config: TelemetryConfigV1): Promise<void>; | ||
| export declare function telemetryEnvironmentDisabled(env?: NodeJS.ProcessEnv): boolean; | ||
| export declare function isTelemetrySendingEnabled(config: TelemetryConfigV1, env?: NodeJS.ProcessEnv): boolean; | ||
| export declare function shouldShowTelemetryNotice(input: { | ||
| config: TelemetryConfigV1; | ||
| interactiveTty: boolean; | ||
| humanOutput: boolean; | ||
| env?: NodeJS.ProcessEnv; | ||
| }): boolean; | ||
| export declare const TELEMETRY_NOTICE: string; | ||
| export declare function renderTelemetryNotice(config: TelemetryConfigV1, output?: NodeJS.WriteStream, inputStream?: NodeJS.ReadStream): Promise<TelemetryConfigV1>; | ||
| export declare function currentTelemetrySessionId(): string; | ||
| export declare function ensureTelemetryInstallId(config: TelemetryConfigV1): Promise<TelemetryConfigV1>; | ||
| export type ProductEventName = "install_initialized" | "run_started" | "run_completed" | "run_failed" | "telemetry_changed" | "control_plane_connected" | "remote_experience_clicked"; | ||
| export interface ProductEventEnvelopeV1 { | ||
| eventId: string; | ||
| schemaVersion: 1; | ||
| installId: string; | ||
| sessionId: string; | ||
| event: ProductEventName; | ||
| cliVersion: string; | ||
| nodeVersion: string; | ||
| platform: NodeJS.Platform; | ||
| arch: string; | ||
| emittedAt: string; | ||
| payload: Readonly<Record<string, unknown>>; | ||
| } | ||
| export declare function assertAllowedTelemetryPayload(event: ProductEventName, payload: Readonly<Record<string, unknown>>): void; | ||
| export declare function resolveProductEventsEndpoint(env?: NodeJS.ProcessEnv): string; | ||
| export declare function sendProductEvent(input: { | ||
| endpoint: string; | ||
| config: TelemetryConfigV1; | ||
| event: ProductEventName; | ||
| payload: Readonly<Record<string, unknown>>; | ||
| cliVersion: string; | ||
| fetchImpl?: typeof fetch; | ||
| env?: NodeJS.ProcessEnv; | ||
| timeoutMs?: number; | ||
| }): Promise<boolean>; | ||
| export declare function initializeTelemetryIfNeeded(input: { | ||
| config: TelemetryConfigV1; | ||
| endpoint: string; | ||
| cliVersion: string; | ||
| }): Promise<TelemetryConfigV1>; | ||
| export type TelemetryFailureReason = "provider_unavailable" | "verification_failed" | "budget_exit" | "policy_blocked" | "persistence_failed" | "unknown"; | ||
| export declare function toTelemetryFailureReason(reasonCode: string | undefined): TelemetryFailureReason; | ||
| export declare function executeTelemetryCommand(action: "status" | "explain" | "on" | "off"): Promise<number>; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { randomUUID } from "node:crypto"; | ||
| import fs from "node:fs/promises"; | ||
| import path from "node:path"; | ||
| import { martinFilePath } from "./home-dir.js"; | ||
| // Telemetry is OFF by default. The user must explicitly opt in via the | ||
| // interactive notice (Y) or `martin telemetry on`. No event is sent before | ||
| // explicit acceptance. | ||
| export const DEFAULT_TELEMETRY_CONFIG = { | ||
| schemaVersion: 1, | ||
| enabled: false, | ||
| noticeShown: false, | ||
| installId: null, | ||
| initializedEventSent: false, | ||
| }; | ||
| function telemetryConfigPath() { | ||
| return martinFilePath("telemetry.json"); | ||
| } | ||
| export async function readTelemetryConfig() { | ||
| try { | ||
| const raw = await fs.readFile(telemetryConfigPath(), "utf8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.schemaVersion !== 1) | ||
| return { ...DEFAULT_TELEMETRY_CONFIG }; | ||
| return { | ||
| schemaVersion: 1, | ||
| // Honour persisted preference. If the field is absent (upgrading from | ||
| // an old config written before the field existed), default to false so | ||
| // existing installs remain in a known-off state until they re-consent. | ||
| enabled: parsed.enabled === true, | ||
| noticeShown: parsed.noticeShown === true, | ||
| installId: typeof parsed.installId === "string" ? parsed.installId : null, | ||
| initializedEventSent: parsed.initializedEventSent === true, | ||
| }; | ||
| } | ||
| catch { | ||
| return { ...DEFAULT_TELEMETRY_CONFIG }; | ||
| } | ||
| } | ||
| export async function writeTelemetryConfig(config) { | ||
| const target = telemetryConfigPath(); | ||
| const directory = path.dirname(target); | ||
| const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; | ||
| await fs.mkdir(directory, { recursive: true }); | ||
| await fs.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8"); | ||
| await fs.rename(temporary, target); | ||
| } | ||
| // ─── Environment controls ───────────────────────────────────────────────────── | ||
| function envTruthy(value) { | ||
| if (!value) | ||
| return false; | ||
| return !["0", "false", "no", "off"].includes(value.toLowerCase()); | ||
| } | ||
| export function telemetryEnvironmentDisabled(env = process.env) { | ||
| return (envTruthy(env["MARTIN_TELEMETRY_DISABLED"]) || | ||
| envTruthy(env["DO_NOT_TRACK"]) || | ||
| envTruthy(env["CI"])); | ||
| } | ||
| export function isTelemetrySendingEnabled(config, env = process.env) { | ||
| return (config.enabled && | ||
| config.noticeShown && | ||
| !telemetryEnvironmentDisabled(env) && | ||
| !envTruthy(env["MARTIN_TELEMETRY_DEBUG"])); | ||
| } | ||
| // ─── Notice ─────────────────────────────────────────────────────────────────── | ||
| // The notice is shown whenever the user has not yet been asked — regardless | ||
| // of whether telemetry is currently enabled. This allows the notice to act as | ||
| // the explicit opt-in invitation even on a fresh install (where enabled=false). | ||
| export function shouldShowTelemetryNotice(input) { | ||
| return (!input.config.noticeShown && | ||
| input.interactiveTty && | ||
| input.humanOutput && | ||
| !telemetryEnvironmentDisabled(input.env ?? {})); | ||
| } | ||
| export const TELEMETRY_NOTICE = [ | ||
| "MartinLoop anonymous usage analytics", | ||
| "", | ||
| "Help improve MartinLoop by sharing minimal anonymous usage data?", | ||
| "It never sends code, prompts, repository contents, file paths,", | ||
| "environment variables, secrets, or receipt contents.", | ||
| "", | ||
| "Inspect exactly what would be sent:", | ||
| " martin telemetry explain", | ||
| ].join("\n"); | ||
| // Reads a single Y/N keypress to obtain explicit consent. | ||
| // Returns true if the user pressed Y (opt in), false for N or timeout. | ||
| async function readTelemetryConsentKey(input = process.stdin) { | ||
| return new Promise((resolve) => { | ||
| if (!input.isTTY) { | ||
| resolve(false); | ||
| return; | ||
| } | ||
| const prev = input.isRaw; | ||
| input.setRawMode(true); | ||
| input.resume(); | ||
| input.setEncoding("utf-8"); | ||
| const timeout = setTimeout(() => { cleanup(); resolve(false); }, 30_000); | ||
| const onData = (key) => { | ||
| if (key === "\u0003") { | ||
| cleanup(); | ||
| process.exit(0); | ||
| } | ||
| cleanup(); | ||
| resolve(key.toLowerCase() === "y"); | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| input.removeListener("data", onData); | ||
| try { | ||
| input.setRawMode(prev ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| input.pause(); | ||
| }; | ||
| input.on("data", onData); | ||
| }); | ||
| } | ||
| // Displays the opt-in notice and prompts Y/N. Marks noticeShown regardless | ||
| // of the user's choice; only sets enabled=true on Y. An informational notice | ||
| // alone is not consent — the user must press Y. | ||
| export async function renderTelemetryNotice(config, output = process.stdout, inputStream = process.stdin) { | ||
| output.write(`\n${TELEMETRY_NOTICE}\n\n`); | ||
| output.write(` Enable analytics? [Y/n] > `); | ||
| const accepted = await readTelemetryConsentKey(inputStream); | ||
| output.write(`${accepted ? "Y" : "N"}\n\n`); | ||
| const next = { ...config, noticeShown: true, enabled: accepted }; | ||
| await writeTelemetryConfig(next); | ||
| return next; | ||
| } | ||
| // ─── Session / install IDs ──────────────────────────────────────────────────── | ||
| const SESSION_ID = randomUUID(); | ||
| export function currentTelemetrySessionId() { | ||
| return SESSION_ID; | ||
| } | ||
| export async function ensureTelemetryInstallId(config) { | ||
| if (config.installId) | ||
| return config; | ||
| const next = { ...config, installId: randomUUID() }; | ||
| await writeTelemetryConfig(next); | ||
| return next; | ||
| } | ||
| const EVENT_PAYLOAD_KEYS = { | ||
| install_initialized: new Set(), | ||
| run_started: new Set(["command"]), | ||
| run_completed: new Set(["durationMs", "command", "receiptGenerated", "recoveryOccurred"]), | ||
| run_failed: new Set(["durationMs", "command", "reason"]), | ||
| telemetry_changed: new Set(["enabled", "source"]), | ||
| control_plane_connected: new Set(["connected"]), | ||
| remote_experience_clicked: new Set(["experienceId", "experienceType"]), | ||
| }; | ||
| export function assertAllowedTelemetryPayload(event, payload) { | ||
| const allowed = EVENT_PAYLOAD_KEYS[event]; | ||
| for (const key of Object.keys(payload)) { | ||
| if (!allowed.has(key)) | ||
| throw new Error(`Unsupported telemetry payload key: ${key}`); | ||
| } | ||
| } | ||
| // ─── Endpoint ───────────────────────────────────────────────────────────────── | ||
| const PRODUCT_EVENTS_ENDPOINT = "https://tupopqvqnyyjuxseyxkr.supabase.co/functions/v1/product-events"; | ||
| export function resolveProductEventsEndpoint(env = process.env) { | ||
| return env["MARTIN_PRODUCT_EVENTS_ENDPOINT"]?.trim() || PRODUCT_EVENTS_ENDPOINT; | ||
| } | ||
| // ─── Sender ─────────────────────────────────────────────────────────────────── | ||
| export async function sendProductEvent(input) { | ||
| const env = input.env ?? {}; | ||
| if (!isTelemetrySendingEnabled(input.config, env)) | ||
| return false; | ||
| if (!input.config.installId) | ||
| return false; | ||
| try { | ||
| assertAllowedTelemetryPayload(input.event, input.payload); | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| const envelope = { | ||
| eventId: randomUUID(), | ||
| schemaVersion: 1, | ||
| installId: input.config.installId, | ||
| sessionId: currentTelemetrySessionId(), | ||
| event: input.event, | ||
| cliVersion: input.cliVersion, | ||
| nodeVersion: process.version, | ||
| platform: process.platform, | ||
| arch: process.arch, | ||
| emittedAt: new Date().toISOString(), | ||
| payload: input.payload, | ||
| }; | ||
| if (envTruthy(env["MARTIN_TELEMETRY_DEBUG"])) { | ||
| process.stderr.write(`${JSON.stringify(envelope)}\n`); | ||
| return false; | ||
| } | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 1500); | ||
| try { | ||
| const response = await (input.fetchImpl ?? fetch)(input.endpoint, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/json", | ||
| "user-agent": `MartinLoop-CLI/${input.cliVersion}`, | ||
| }, | ||
| body: JSON.stringify(envelope), | ||
| signal: controller.signal, | ||
| }); | ||
| return response.status === 204; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
| // ─── Initialization ─────────────────────────────────────────────────────────── | ||
| export async function initializeTelemetryIfNeeded(input) { | ||
| let config = input.config; | ||
| if (!isTelemetrySendingEnabled(config)) | ||
| return config; | ||
| config = await ensureTelemetryInstallId(config); | ||
| if (config.initializedEventSent) | ||
| return config; | ||
| const sent = await sendProductEvent({ | ||
| endpoint: input.endpoint, | ||
| config, | ||
| event: "install_initialized", | ||
| payload: {}, | ||
| cliVersion: input.cliVersion, | ||
| }); | ||
| if (!sent) | ||
| return config; | ||
| const next = { ...config, initializedEventSent: true }; | ||
| await writeTelemetryConfig(next); | ||
| return next; | ||
| } | ||
| export function toTelemetryFailureReason(reasonCode) { | ||
| switch (reasonCode) { | ||
| case "provider_unavailable": | ||
| case "verification_failed": | ||
| case "budget_exit": | ||
| case "policy_blocked": | ||
| case "persistence_failed": | ||
| return reasonCode; | ||
| default: | ||
| return "unknown"; | ||
| } | ||
| } | ||
| // ─── CLI commands ───────────────────────────────────────────────────────────── | ||
| const TELEMETRY_MANIFEST = `Sent: | ||
| - random installation ID | ||
| - per-process session ID | ||
| - CLI version | ||
| - Node version | ||
| - operating system and architecture | ||
| - event name | ||
| - event timestamp | ||
| - command category | ||
| - run duration | ||
| - success/failure category | ||
| - whether a receipt was generated | ||
| - whether recovery occurred | ||
| - opaque remote-experience ID/type after a click | ||
| Never sent: | ||
| - source code | ||
| - prompts | ||
| - task text | ||
| - repository contents | ||
| - repository name | ||
| - file names | ||
| - file paths | ||
| - environment variables | ||
| - secrets | ||
| - provider/model output | ||
| - receipt contents | ||
| - event-ledger contents | ||
| - approval details | ||
| - verifier evidence | ||
| - email addresses | ||
| - workspace, project, or organization identifiers | ||
| - raw exception messages or stacks`; | ||
| export async function executeTelemetryCommand(action) { | ||
| const config = await readTelemetryConfig(); | ||
| switch (action) { | ||
| case "status": { | ||
| const effective = isTelemetrySendingEnabled(config); | ||
| const envDisabled = telemetryEnvironmentDisabled(); | ||
| process.stdout.write(`Telemetry\n`); | ||
| process.stdout.write(` Stored enabled: ${config.enabled}\n`); | ||
| process.stdout.write(` Notice shown: ${config.noticeShown}\n`); | ||
| process.stdout.write(` Env disabled: ${envDisabled}\n`); | ||
| process.stdout.write(` Effective: ${effective ? "sending" : "not sending"}\n`); | ||
| return 0; | ||
| } | ||
| case "explain": | ||
| process.stdout.write(`${TELEMETRY_MANIFEST}\n`); | ||
| return 0; | ||
| case "on": | ||
| await writeTelemetryConfig({ ...config, enabled: true }); | ||
| process.stdout.write(`Telemetry enabled. A first-run notice will appear if not already shown.\n`); | ||
| return 0; | ||
| case "off": | ||
| await writeTelemetryConfig({ ...config, enabled: false }); | ||
| process.stdout.write(`Telemetry disabled.\n`); | ||
| return 0; | ||
| } | ||
| } | ||
| //# sourceMappingURL=telemetry.js.map |
| export declare function fetchLatestNpmVersion(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>; | ||
| export type InstallChannel = "global-npm" | "npx" | "native" | "source" | "unknown"; | ||
| export declare function detectInstallChannel(env?: NodeJS.ProcessEnv): InstallChannel; | ||
| export interface UpdatePromptInput { | ||
| currentVersion: string; | ||
| interactiveTty: boolean; | ||
| outputMode: string; | ||
| ci: boolean; | ||
| command: string; | ||
| channel: InstallChannel; | ||
| } | ||
| export declare function shouldShowUpdatePrompt(input: UpdatePromptInput): boolean; | ||
| export declare function runNpmUpdate(): { | ||
| success: boolean; | ||
| error?: string; | ||
| }; | ||
| /** | ||
| * Shows the startup update prompt. | ||
| * Returns: | ||
| * false — prompt not shown (not a newer version, wrong channel, suppressed) | ||
| * "deferred" — prompt shown, user pressed L/Enter/timeout (original command continues) | ||
| * "updated" — prompt shown, user pressed Y (update was attempted; do not continue original command) | ||
| */ | ||
| export declare function maybeShowUpdatePrompt(currentVersion: string, fetchImpl?: typeof fetch): Promise<false | "deferred" | "updated">; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { spawnSync } from "node:child_process"; | ||
| // ─── Version check ──────────────────────────────────────────────────────────── | ||
| function semverGt(a, b) { | ||
| const parse = (v) => v.replace(/^v/, "").split(".").map(Number); | ||
| const [aMaj, aMin, aPat] = parse(a); | ||
| const [bMaj, bMin, bPat] = parse(b); | ||
| if (aMaj !== bMaj) | ||
| return (aMaj ?? 0) > (bMaj ?? 0); | ||
| if (aMin !== bMin) | ||
| return (aMin ?? 0) > (bMin ?? 0); | ||
| return (aPat ?? 0) > (bPat ?? 0); | ||
| } | ||
| export async function fetchLatestNpmVersion(fetchImpl = fetch, timeoutMs = 3000) { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| const response = await fetchImpl("https://registry.npmjs.org/martin-loop/latest", { | ||
| headers: { accept: "application/json" }, | ||
| signal: controller.signal, | ||
| }); | ||
| if (!response.ok) | ||
| return null; | ||
| const data = (await response.json()); | ||
| return typeof data.version === "string" ? data.version : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| export function detectInstallChannel(env = process.env) { | ||
| // Native installer sets a build-time constant | ||
| if (typeof globalThis["__MARTIN_NATIVE_PACKAGE_VERSION__"] === "string") { | ||
| return "native"; | ||
| } | ||
| // Running via npx: npm_lifecycle_script contains npx or _ | ||
| if (env["npm_execpath"]?.includes("npx") || env["npm_command"] === "exec") { | ||
| return "npx"; | ||
| } | ||
| // Global npm install: npm_config_global is "true" | ||
| if (env["npm_config_global"] === "true") { | ||
| return "global-npm"; | ||
| } | ||
| // npm_lifecycle_event present without npm_config_global suggests local/source | ||
| if (env["npm_lifecycle_event"]) { | ||
| return "source"; | ||
| } | ||
| return "unknown"; | ||
| } | ||
| export function shouldShowUpdatePrompt(input) { | ||
| if (input.channel !== "global-npm") | ||
| return false; | ||
| if (!input.interactiveTty) | ||
| return false; | ||
| if (input.ci) | ||
| return false; | ||
| if (input.outputMode !== "human") | ||
| return false; | ||
| // Suppress for meta-commands that don't run governed code | ||
| const suppress = new Set(["help", "version", "telemetry", "update"]); | ||
| if (suppress.has(input.command)) | ||
| return false; | ||
| return true; | ||
| } | ||
| // ─── Raw keypress ───────────────────────────────────────────────────────────── | ||
| async function readUpdateKey() { | ||
| return new Promise((resolve) => { | ||
| const stdin = process.stdin; | ||
| if (!stdin.isTTY) { | ||
| resolve("l"); | ||
| return; | ||
| } | ||
| const prev = stdin.isRaw; | ||
| stdin.setRawMode(true); | ||
| stdin.resume(); | ||
| stdin.setEncoding("utf-8"); | ||
| const timeout = setTimeout(() => { cleanup(); resolve("l"); }, 30_000); | ||
| const onData = (key) => { | ||
| if (key === "\u0003") { | ||
| cleanup(); | ||
| process.exit(0); | ||
| } | ||
| cleanup(); | ||
| resolve(key.toLowerCase() === "y" ? "y" : "l"); | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| stdin.removeListener("data", onData); | ||
| try { | ||
| stdin.setRawMode(prev ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| stdin.pause(); | ||
| }; | ||
| stdin.on("data", onData); | ||
| }); | ||
| } | ||
| // ─── Update execution ───────────────────────────────────────────────────────── | ||
| export function runNpmUpdate() { | ||
| const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; | ||
| const result = spawnSync(npmCmd, ["install", "--global", "martin-loop@latest"], { | ||
| stdio: "inherit", | ||
| shell: false, | ||
| }); | ||
| if (result.status === 0) | ||
| return { success: true }; | ||
| return { success: false, error: result.error?.message ?? `exit ${result.status ?? "unknown"}` }; | ||
| } | ||
| // ─── Prompt display ─────────────────────────────────────────────────────────── | ||
| /** | ||
| * Shows the startup update prompt. | ||
| * Returns: | ||
| * false — prompt not shown (not a newer version, wrong channel, suppressed) | ||
| * "deferred" — prompt shown, user pressed L/Enter/timeout (original command continues) | ||
| * "updated" — prompt shown, user pressed Y (update was attempted; do not continue original command) | ||
| */ | ||
| export async function maybeShowUpdatePrompt(currentVersion, fetchImpl) { | ||
| const channel = detectInstallChannel(); | ||
| if (channel !== "global-npm") | ||
| return false; | ||
| const latest = await fetchLatestNpmVersion(fetchImpl); | ||
| if (!latest || !semverGt(latest, currentVersion)) | ||
| return false; | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(`MartinLoop ${latest} is available. You are running ${currentVersion}.\n\n`); | ||
| process.stdout.write(" [Y] Update now [L] Later\n\n"); | ||
| process.stdout.write(" > "); | ||
| const key = await readUpdateKey(); | ||
| process.stdout.write(`${key.toUpperCase()}\n\n`); | ||
| if (key === "y") { | ||
| process.stdout.write("Running: npm install --global martin-loop@latest\n\n"); | ||
| const result = runNpmUpdate(); | ||
| if (result.success) { | ||
| process.stdout.write(`Updated to ${latest}. Please rerun your command in a new terminal.\n\n`); | ||
| } | ||
| else { | ||
| process.stdout.write(`Update failed: ${result.error ?? "unknown error"}. Run manually:\n` + | ||
| ` npm install --global martin-loop@latest\n\n`); | ||
| } | ||
| return "updated"; | ||
| } | ||
| // L/Enter/timeout: prompt shown but user deferred. | ||
| return "deferred"; | ||
| } | ||
| //# sourceMappingURL=update-prompt.js.map |
| /** | ||
| * Detect if the current platform is macOS or Ubuntu (Linux). | ||
| * This helper is used to adjust safeguard requirements for non‑Windows environments. | ||
| */ | ||
| export declare function isMacOrUbuntu(): boolean; |
| // src/utils/platform.ts | ||
| /** | ||
| * Detect if the current platform is macOS or Ubuntu (Linux). | ||
| * This helper is used to adjust safeguard requirements for non‑Windows environments. | ||
| */ | ||
| export function isMacOrUbuntu() { | ||
| const platform = process.platform; | ||
| // "darwin" = macOS, "linux" = Ubuntu (or other Linux distributions). | ||
| return platform === "darwin" || platform === "linux"; | ||
| } | ||
| //# sourceMappingURL=platform.js.map |
| import type { VerifiedHandoffV1 } from "../contracts/index.js"; | ||
| export declare function renderVerifiedHandoffHuman(handoff: VerifiedHandoffV1): string; |
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| const WIDTH = 72; | ||
| export function renderVerifiedHandoffHuman(handoff) { | ||
| const divider = "=".repeat(WIDTH); | ||
| const checks = handoff.verification.checks.length | ||
| ? handoff.verification.checks.map((c) => ` ${symbol(c.status)} ${c.command} — ${c.status}`) | ||
| : [" · No individual verification steps were recorded."]; | ||
| const unresolved = handoff.unresolvedWork.length | ||
| ? handoff.unresolvedWork.map((item) => ` - ${item}`) | ||
| : [" - None recorded."]; | ||
| return [ | ||
| divider, | ||
| ` MARTINLOOP VERIFIED HANDOFF — ${handoff.outcome}`, | ||
| divider, | ||
| `Task: ${handoff.task.objective}`, | ||
| `Run: ${handoff.loopId}`, | ||
| `Verification: ${handoff.verification.status}`, | ||
| ...checks, | ||
| `Scope: ${handoff.scope.status}`, | ||
| `Test Integrity: ${handoff.testIntegrity.verdict}`, | ||
| `Attempts: ${handoff.usage.attempts}`, | ||
| `Cost: $${handoff.usage.actualUsd.toFixed(2)} (${handoff.usage.costProvenance})`, | ||
| "Unresolved:", | ||
| ...unresolved, | ||
| `Recovery: ${handoff.recovery.summary}`, | ||
| `Next: ${handoff.nextAction}`, | ||
| divider, | ||
| ].join("\n"); | ||
| } | ||
| function symbol(status) { | ||
| if (status === "PASSED") | ||
| return "✓"; | ||
| if (status === "FAILED" || status === "CONTRADICTED") | ||
| return "✕"; | ||
| return "·"; | ||
| } | ||
| //# sourceMappingURL=verified-handoff-renderer.js.map |
| /** | ||
| * 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"; | ||
| //# sourceMappingURL=context-handoff.js.map |
| /** | ||
| * 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"; | ||
| //# sourceMappingURL=context-manifest.js.map |
| /** | ||
| * 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"; | ||
| //# sourceMappingURL=context-shadow.js.map |
| /** | ||
| * 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", | ||
| ]; | ||
| //# sourceMappingURL=delivery.js.map |
| /** | ||
| * 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" | ||
| ]; | ||
| //# sourceMappingURL=exits.js.map |
| /** | ||
| * 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); | ||
| } | ||
| //# sourceMappingURL=mission.js.map |
| 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", | ||
| ]; | ||
| //# sourceMappingURL=verified-handoff.js.map |
| /** | ||
| * 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"); | ||
| } | ||
| //# sourceMappingURL=context-chain-gate.js.map |
| /** | ||
| * 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 }; | ||
| } | ||
| //# sourceMappingURL=context-compiler.js.map |
| /** | ||
| * 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"; | ||
| } | ||
| //# sourceMappingURL=context-handoff.js.map |
| /** | ||
| * 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 }; | ||
| } | ||
| //# sourceMappingURL=context-shadow.js.map |
| 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"; | ||
| //# sourceMappingURL=index.js.map |
| 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); | ||
| } | ||
| } | ||
| //# sourceMappingURL=message-client.js.map |
| 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"); | ||
| } | ||
| //# sourceMappingURL=message-ledger.js.map |
| 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); | ||
| } | ||
| //# sourceMappingURL=message-schema.js.map |
| /** | ||
| * 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; | ||
| } | ||
| //# sourceMappingURL=update-check.js.map |
| /** | ||
| * 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}`); | ||
| } | ||
| //# sourceMappingURL=exit-signal.js.map |
| /** | ||
| * 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"); | ||
| } | ||
| //# sourceMappingURL=exits.js.map |
| /** | ||
| * 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 | ||
| }; | ||
| } | ||
| //# sourceMappingURL=aggregate.js.map |
| export { aggregateMissionMetrics, rebuildMissionCost } from "./aggregate.js"; | ||
| export type { MissionMetrics } from "./aggregate.js"; |
| export { aggregateMissionMetrics, rebuildMissionCost } from "./aggregate.js"; | ||
| //# sourceMappingURL=index.js.map |
| /** | ||
| * 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; | ||
| } | ||
| //# sourceMappingURL=context-handoff-store.js.map |
| /** | ||
| * 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; | ||
| }); | ||
| } | ||
| //# sourceMappingURL=mission-store.js.map |
| 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; | ||
| } | ||
| //# sourceMappingURL=savings.js.map |
| 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); | ||
| } | ||
| } | ||
| //# sourceMappingURL=termination-store.js.map |
| 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, | ||
| }; | ||
| } | ||
| //# sourceMappingURL=verified-handoff.js.map |
+2
-2
@@ -5,4 +5,4 @@ export { runMartin, compilePromptPacket, createFileRunStore, makeLedgerEvent, resolveRunsRoot } from "./vendor/core/index.js"; | ||
| export type { ParsedCliArguments, RunCommandRequest } from "./vendor/cli/index.js"; | ||
| export { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, createDirectProviderAdapter, createOpenAiCompatibleAdapter, createVerifierOnlyAdapter } from "./vendor/adapters/index.js"; | ||
| export type { AgentCliAdapterOptions, ClaudeCliAdapterOptions, CliArgsBuilder, CodexCliAdapterOptions, GeminiCliAdapterOptions, DirectProviderAdapterOptions, OpenAiCompatibleAdapterOptions, SpawnLike, SubprocessResult, VerificationOutcome, VerifierOnlyAdapterOptions } from "./vendor/adapters/index.js"; | ||
| export { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, createDirectProviderAdapter, createOpenAiCompatibleAdapter } from "./vendor/adapters/index.js"; | ||
| export type { AgentCliAdapterOptions, ClaudeCliAdapterOptions, CliArgsBuilder, CodexCliAdapterOptions, GeminiCliAdapterOptions, DirectProviderAdapterOptions, OpenAiCompatibleAdapterOptions, SpawnLike, SubprocessResult, VerificationOutcome } from "./vendor/adapters/index.js"; | ||
| export { appendLoopEvent, buildPortfolioSnapshot, createGovernanceSnapshot, createLoopRecord, createTelemetryEnvelope, DEFAULT_BUDGET, EMPTY_COST, validateTelemetryBatch, validateTelemetryEnvelope } from "./vendor/contracts/index.js"; | ||
@@ -9,0 +9,0 @@ export type { ApprovalPolicy, ExecutionProfile, LoopBudget, LoopRecord, LoopTask } from "./vendor/contracts/index.js"; |
+1
-1
@@ -5,3 +5,3 @@ import { runMartin } from "./vendor/core/index.js"; | ||
| export { executeCli, parseCliArguments, renderCliHelp } from "./vendor/cli/index.js"; | ||
| export { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, createDirectProviderAdapter, createOpenAiCompatibleAdapter, createVerifierOnlyAdapter } from "./vendor/adapters/index.js"; | ||
| export { createClaudeCliAdapter, createCodexCliAdapter, createGeminiCliAdapter, createDirectProviderAdapter, createOpenAiCompatibleAdapter } from "./vendor/adapters/index.js"; | ||
| export { appendLoopEvent, buildPortfolioSnapshot, createGovernanceSnapshot, createLoopRecord, createTelemetryEnvelope, DEFAULT_BUDGET, EMPTY_COST, validateTelemetryBatch, validateTelemetryEnvelope } from "./vendor/contracts/index.js"; | ||
@@ -8,0 +8,0 @@ |
@@ -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,10 +1176,2 @@ 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; | ||
| } | ||
| //# sourceMappingURL=claude-cli.js.map |
| 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 { 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 { 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"; | ||
| //# sourceMappingURL=index.js.map |
@@ -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, |
@@ -21,9 +21,8 @@ export { playWhileWaiting } from "./space-invaders.js"; | ||
| } | ||
| // Race the task against the prompt delay. | ||
| let timerId; | ||
| const delayPromise = new Promise(resolve => { | ||
| const delayPromise = new Promise((resolve) => { | ||
| timerId = setTimeout(resolve, promptAfterMs); | ||
| }); | ||
| const outcome = await Promise.race([ | ||
| task.then((v) => ({ done: true, value: v })), | ||
| task.then((value) => ({ done: true, value })), | ||
| delayPromise.then(() => ({ done: false })) | ||
@@ -35,5 +34,3 @@ ]); | ||
| } | ||
| // Task is still running — prompt once. | ||
| const accepted = await promptOnce(); | ||
| if (accepted) { | ||
| if (await promptOnce()) { | ||
| const { playWhileWaiting } = await import("./space-invaders.js"); | ||
@@ -44,8 +41,4 @@ return playWhileWaiting(task); | ||
| } | ||
| /** | ||
| * Prompt the user once for the arcade. Returns true if they pressed y/Y. | ||
| * Times out after 15 s and returns false. Restores terminal state on exit. | ||
| */ | ||
| async function promptOnce() { | ||
| return new Promise(resolve => { | ||
| return new Promise((resolve) => { | ||
| const stdin = process.stdin; | ||
@@ -56,3 +49,3 @@ if (!stdin.isTTY) { | ||
| } | ||
| const prevRaw = stdin.isRaw; | ||
| const previousRaw = stdin.isRaw; | ||
| const cleanup = (answer) => { | ||
@@ -62,9 +55,10 @@ clearTimeout(autoNo); | ||
| try { | ||
| stdin.setRawMode(prevRaw ?? false); | ||
| stdin.setRawMode(previousRaw ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| catch { | ||
| // Terminal state restoration is best effort. | ||
| } | ||
| stdin.pause(); | ||
| resolve(answer); | ||
| }; | ||
| // Auto-decline after 15 s so unattended runs are never blocked. | ||
| const autoNo = setTimeout(() => { | ||
@@ -78,3 +72,2 @@ process.stdout.write("n\n"); | ||
| catch { | ||
| // setRawMode can fail in certain environments — fall back to no prompt. | ||
| clearTimeout(autoNo); | ||
@@ -90,3 +83,2 @@ resolve(false); | ||
| const onKey = (key) => { | ||
| // Ctrl+C — honour it even mid-prompt. | ||
| if (key === "\u0003") { | ||
@@ -93,0 +85,0 @@ process.stdout.write("\n"); |
@@ -0,1 +1,4 @@ | ||
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { appendFileSync } from "node:fs"; | ||
@@ -77,6 +80,7 @@ import * as readline from "node:readline"; | ||
| console.log(""); | ||
| process.stdout.write(" [1–5 or Enter to skip]\n > "); | ||
| const ratingInput = await readSingleLine(); | ||
| const rating = parseInt(ratingInput.trim(), 10); | ||
| if (!ratingInput.trim() || isNaN(rating) || rating < 1 || rating > 5) { | ||
| process.stdout.write(" [1–5 — key registers instantly, Enter to skip]\n > "); | ||
| const ratingKey = await readSingleKeypress(); | ||
| process.stdout.write(`${ratingKey}\n`); | ||
| const rating = parseInt(ratingKey, 10); | ||
| if (!ratingKey || ratingKey === "\r" || ratingKey === "\n" || isNaN(rating) || rating < 1 || rating > 5) { | ||
| console.log(""); | ||
@@ -292,2 +296,31 @@ return 0; | ||
| } | ||
| function readSingleKeypress() { | ||
| return new Promise((resolve) => { | ||
| const stdin = process.stdin; | ||
| if (!stdin.isTTY) { | ||
| resolve(""); | ||
| return; | ||
| } | ||
| const prev = stdin.isRaw; | ||
| stdin.setRawMode(true); | ||
| stdin.resume(); | ||
| stdin.setEncoding("utf-8"); | ||
| const timeout = setTimeout(() => { cleanup(); resolve(""); }, 30_000); | ||
| const onData = (key) => { | ||
| if (key === "\u0003") { | ||
| cleanup(); | ||
| process.exit(0); | ||
| } | ||
| cleanup(); | ||
| resolve(key); | ||
| }; | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| stdin.removeListener("data", onData); | ||
| stdin.setRawMode(prev ?? false); | ||
| stdin.pause(); | ||
| }; | ||
| stdin.on("data", onData); | ||
| }); | ||
| } | ||
| //# sourceMappingURL=feedback.js.map |
@@ -1,2 +0,9 @@ | ||
| export declare function martinFilePath(...segments: string[]): string; | ||
| /** | ||
| * Returns an absolute path under ~/.martin/<filename>. | ||
| * Creates no directories — callers are responsible for mkdir. | ||
| */ | ||
| export declare function martinFilePath(filename: string): string; | ||
| /** | ||
| * Ensures ~/.martin exists. Safe to call multiple times (mkdir recursive). | ||
| */ | ||
| export declare function ensureMartinDir(): void; |
@@ -0,11 +1,20 @@ | ||
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { mkdirSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| const MARTIN_HOME = join(homedir(), ".martin"); | ||
| export function martinFilePath(...segments) { | ||
| return join(MARTIN_HOME, ...segments); | ||
| /** | ||
| * Returns an absolute path under ~/.martin/<filename>. | ||
| * Creates no directories — callers are responsible for mkdir. | ||
| */ | ||
| export function martinFilePath(filename) { | ||
| return join(homedir(), ".martin", filename); | ||
| } | ||
| /** | ||
| * Ensures ~/.martin exists. Safe to call multiple times (mkdir recursive). | ||
| */ | ||
| export function ensureMartinDir() { | ||
| mkdirSync(MARTIN_HOME, { recursive: true }); | ||
| mkdirSync(join(homedir(), ".martin"), { recursive: true }); | ||
| } | ||
| //# sourceMappingURL=home-dir.js.map |
@@ -6,4 +6,2 @@ import { probeCodexLaunch, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| import { type MartinMcpHost, type MartinMcpPlatform, type MartinMcpProfile, type MartinMcpScope, type MartinMcpTransport } from "./mcp-config.js"; | ||
| type CodexAvailabilityForTests = ReturnType<typeof resolveCliCommandAvailability>; | ||
| type CodexProbeForTests = ReturnType<typeof probeCodexLaunch>; | ||
| export type RunCommandRequest = { | ||
@@ -30,6 +28,3 @@ workspaceId: string; | ||
| acceptanceCriteria?: string[]; | ||
| /** Offer or start the Arcade immediately (skips the 30 s wait). */ | ||
| arcade?: boolean; | ||
| /** Disable the automatic Arcade prompt for this run. */ | ||
| noArcade?: boolean; | ||
| approvalPolicy?: import("../contracts/index.js").ApprovalPolicy; | ||
| }; | ||
@@ -178,4 +173,2 @@ type InspectCommand = { | ||
| outputDir?: string; | ||
| proofCard: boolean; | ||
| proofCardFormat: "svg" | "png" | "both"; | ||
| }; | ||
@@ -186,19 +179,16 @@ type BadgeCommand = { | ||
| runsDir?: string; | ||
| governed?: boolean; | ||
| }; | ||
| type PlanCommand = { | ||
| command: "plan"; | ||
| objective: string; | ||
| verify?: string; | ||
| budgetUsd?: number; | ||
| cwd?: string; | ||
| type CancelCommand = { | ||
| command: "cancel"; | ||
| runId: string; | ||
| reason?: string; | ||
| runsDir?: string; | ||
| }; | ||
| type ExecuteCommand = { | ||
| command: "execute"; | ||
| objective: string; | ||
| verify?: string; | ||
| budgetUsd?: number; | ||
| maxIterations?: number; | ||
| engine?: "claude" | "codex" | "gemini" | "openai"; | ||
| cwd?: string; | ||
| type SignalCommand = { | ||
| command: "signal"; | ||
| runId: string; | ||
| event: string; | ||
| disposition: "stop" | "continue"; | ||
| reason?: string; | ||
| runsDir?: string; | ||
@@ -220,3 +210,10 @@ }; | ||
| force: boolean; | ||
| } | InspectCommand | ResumeCommand | DoctorCommand | StartCommand | EnableCommand | EnvCommand | ReviewCommand | ReceiptsExplainCommand | NativePhaseCommand | PreflightCommand | TriageCommand | DossierCommand | RunsCommand | McpCommand | EstimateCommand | GateCommand | ModeCommand | CleanCommand | ChallengeCommand | ShareCommand | BadgeCommand | PlanCommand | ExecuteCommand; | ||
| } | InspectCommand | ResumeCommand | DoctorCommand | StartCommand | EnableCommand | EnvCommand | ReviewCommand | ReceiptsExplainCommand | NativePhaseCommand | PreflightCommand | TriageCommand | DossierCommand | RunsCommand | McpCommand | EstimateCommand | GateCommand | ModeCommand | CleanCommand | ChallengeCommand | ShareCommand | BadgeCommand | CancelCommand | SignalCommand | { | ||
| command: "telemetry"; | ||
| action: "status" | "explain" | "on" | "off"; | ||
| } | { | ||
| command: "install"; | ||
| version?: string; | ||
| directory?: string; | ||
| }; | ||
| export declare function executeCli(args: string[]): Promise<{ | ||
@@ -228,2 +225,4 @@ exitCode: number; | ||
| export declare function __setRunAdapterOverrideForTests(adapter?: MartinAdapter): void; | ||
| type CodexAvailabilityForTests = ReturnType<typeof resolveCliCommandAvailability>; | ||
| type CodexProbeForTests = ReturnType<typeof probeCodexLaunch>; | ||
| export declare function __setCodexHostOverridesForTests(overrides?: { | ||
@@ -230,0 +229,0 @@ availability?: CodexAvailabilityForTests; |
| { | ||
| "name": "@martin/cli", | ||
| "version": "0.4.5", | ||
| "version": "0.5.0", | ||
| "type": "module", | ||
| "description": "Martin Loop CLI — budget-aware coding loops with failure classification and verified exits.", | ||
| "description": "Open-source execution control for coding agents with verifier-gated completion, stop limits, rollback evidence, and Verified Handoffs.", | ||
| "main": "./index.js", | ||
@@ -7,0 +7,0 @@ "types": "./index.d.ts", |
@@ -38,5 +38,5 @@ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| writeJsonFile(join(loopRoot, "loop.json"), loop), | ||
| writeEvents(join(loopRoot, "events.jsonl"), loop.events), | ||
| ...loop.attempts.map((attempt) => writeJsonFile(join(attemptsRoot, `${String(attempt.index).padStart(3, "0")}-${attempt.attemptId}.json`), attempt)) | ||
| ]); | ||
| const persistedEvents = await writeEvents(join(loopRoot, "events.jsonl"), loop.events); | ||
| // Append summary to workspace-level index | ||
@@ -56,3 +56,3 @@ await appendFile(join(runsRoot, `${loop.workspaceId}.jsonl`), `${JSON.stringify({ loopId: loop.loopId, status: loop.status, cost: loop.cost, updatedAt: loop.updatedAt })}\n`, "utf8"); | ||
| loopRecord: loop, | ||
| ledgerEntries: loop.events, | ||
| ledgerEntries: persistedEvents, | ||
| scope: loop.receiptScope ?? | ||
@@ -97,7 +97,7 @@ { | ||
| if (events.length === 0) { | ||
| return; | ||
| return []; | ||
| } | ||
| const appended = events.map((event) => `${JSON.stringify(event)}\n`).join(""); | ||
| await appendFile(path, appended, "utf8"); | ||
| return; | ||
| return events; | ||
| } | ||
@@ -107,2 +107,3 @@ const merged = mergeEvents(existing.events, events); | ||
| await writeFile(path, body.length > 0 ? `${body}\n` : "", "utf8"); | ||
| return merged; | ||
| } | ||
@@ -109,0 +110,0 @@ async function loadExistingEvents(path) { |
| import { readFile, readdir, stat } from "node:fs/promises"; | ||
| import { join, resolve } from "node:path"; | ||
| import { probeCodexLaunch, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| import { diagnoseCodexHost, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| import { resolveRunsRoot } from "../core/index.js"; | ||
@@ -400,8 +400,6 @@ const DEFAULT_BLOCKED_PATHS = [ | ||
| const availability = resolveCliCommandAvailability("codex"); | ||
| const probe = mode === "live" | ||
| ? probeCodexLaunch({ | ||
| workingDirectory: receiptScope.workingDirectory, | ||
| availability | ||
| }) | ||
| : undefined; | ||
| const diagnosis = diagnoseCodexHost(availability); | ||
| const summary = availability.available | ||
| ? "Codex CLI detected. Run martin preflight for a live launch check before governed execution." | ||
| : availability.detail; | ||
| return { | ||
@@ -415,19 +413,10 @@ mode, | ||
| ...(availability.candidatePaths?.length ? { candidatePaths: availability.candidatePaths } : {}), | ||
| ...(probe | ||
| ? { | ||
| selectedPath: probe.command, | ||
| hostPlatform: probe.diagnosis.hostPlatform, | ||
| installKind: probe.diagnosis.installKind, | ||
| nativeInstallValid: probe.diagnosis.nativeInstallValid, | ||
| invocationMode: probe.diagnosis.invocationMode, | ||
| sandboxMode: probe.diagnosis.sandboxMode, | ||
| sandboxCompatible: probe.diagnosis.sandboxCompatible, | ||
| launchReady: probe.ok, | ||
| summary: probe.summary, | ||
| ...(probe.diagnosis.remediation ? { remediation: probe.diagnosis.remediation } : {}), | ||
| ...(probe.candidateProbeResults?.length | ||
| ? { candidateProbeResults: probe.candidateProbeResults } | ||
| : {}) | ||
| } | ||
| : {}) | ||
| hostPlatform: diagnosis.hostPlatform, | ||
| installKind: diagnosis.installKind, | ||
| nativeInstallValid: diagnosis.nativeInstallValid, | ||
| invocationMode: diagnosis.invocationMode, | ||
| sandboxMode: diagnosis.sandboxMode, | ||
| sandboxCompatible: diagnosis.sandboxCompatible, | ||
| summary, | ||
| ...(diagnosis.remediation ? { remediation: diagnosis.remediation } : {}) | ||
| } | ||
@@ -434,0 +423,0 @@ }; |
| const COMPLETE_EVIDENCE_LINE = "Martin stopped Ralph here."; | ||
| const INCOMPLETE_EVIDENCE_LINE = "Incomplete Martin proof: missing budget, rollback, or verifier evidence."; | ||
| const NON_MUTATING_EVIDENCE_LINE = "Proof or verifier-only runs are evidence boundaries, not real Martin mutation receipts."; | ||
| const NON_MUTATING_EVIDENCE_LINE = "No-spend proof runs are evidence boundaries, not real Martin mutation receipts."; | ||
| const UNSIGNED_EVIDENCE_LINE = "Receipt integrity unavailable: Martin proof is not yet trustworthy."; | ||
@@ -5,0 +5,0 @@ const TAMPERED_EVIDENCE_LINE = "Receipt integrity failed: Martin proof is not trustworthy."; |
@@ -0,1 +1,4 @@ | ||
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { readFileSync, writeFileSync } from "node:fs"; | ||
@@ -2,0 +5,0 @@ import { martinFilePath, ensureMartinDir } from "./home-dir.js"; |
@@ -1,2 +0,3 @@ | ||
| import type { CostProvenance, LoopArtifact, LoopRecord, MartinRunListFilters, MartinRunSelector, ReceiptIntegritySummary, ReceiptScope } from "../contracts/index.js"; | ||
| import { type VerifierExecutionBinding } from "../core/index.js"; | ||
| import type { CostProvenance, LoopArtifact, LoopRecord, MartinRunListFilters, MartinRunSelector, ReceiptIntegritySummary, ReceiptScope, VerifiedHandoffV1 } from "../contracts/index.js"; | ||
| export interface LocalCorpusHotspot { | ||
@@ -62,2 +63,4 @@ scopeFingerprint: string; | ||
| warnings: string[]; | ||
| binding?: VerifierExecutionBinding; | ||
| changedFiles: string[]; | ||
| } | ||
@@ -67,2 +70,4 @@ export interface VerificationStepSummary { | ||
| launched: boolean; | ||
| completed?: boolean; | ||
| crashed?: boolean; | ||
| exitCode?: number; | ||
@@ -118,2 +123,3 @@ timedOut?: boolean; | ||
| export declare function buildRunReceipt(loop: LoopRecord, verification?: VerificationSummary, receiptScope?: ReceiptScope | undefined): Record<string, unknown>; | ||
| export declare function buildVerifiedHandoffFromPersistedLoop(detail: PersistedLoopDetail): VerifiedHandoffV1; | ||
| export declare function buildRunDossier(detail: PersistedLoopDetail): Record<string, unknown>; | ||
@@ -129,2 +135,3 @@ export declare function triagePersistedLoops(filters: MartinRunListFilters, options?: { | ||
| invocationRoot?: string; | ||
| workspaceId?: string; | ||
| }): Promise<{ | ||
@@ -131,0 +138,0 @@ runsRoot: string; |
+168
-35
@@ -5,3 +5,3 @@ import { createHash } from "node:crypto"; | ||
| import path from "node:path"; | ||
| import { decideCircuitBreak, resolveRunsRoot, verifyReceiptIntegrityFromFiles } from "../core/index.js"; | ||
| import { buildVerifiedHandoff, decideCircuitBreak, resolveRunsRoot, verifierActuallyPassed, verifyReceiptIntegrityFromFiles, } from "../core/index.js"; | ||
| import { CliCommandError } from "./ux.js"; | ||
@@ -177,2 +177,5 @@ const RUN_INDEX_FILENAME = "run-index.ndjson"; | ||
| .filter((loop) => { | ||
| if (filters.workspaceId && loop.workspaceId !== filters.workspaceId) { | ||
| return false; | ||
| } | ||
| if (filters.status && loop.status !== filters.status) { | ||
@@ -269,3 +272,3 @@ return false; | ||
| const inspected = await collectPersistedLoops(runsRoot); | ||
| const loop = inspected.loops[0]; | ||
| const loop = inspected.loops.find((candidate) => !selector.workspaceId || candidate.workspaceId === selector.workspaceId); | ||
| if (!loop) { | ||
@@ -342,7 +345,8 @@ throw new CliCommandError("not_found", "No persisted Martin loops were found."); | ||
| steps: [], | ||
| warnings: [...integrityWarnings, "Verification evidence was not recorded for this persisted loop."] | ||
| warnings: [...integrityWarnings, "Verification evidence was not recorded for this persisted loop."], | ||
| changedFiles: [], | ||
| }; | ||
| } | ||
| const payload = isRecord(latestEvent.payload) ? latestEvent.payload : undefined; | ||
| const passed = payload?.["passed"] === true; | ||
| const claimedPassed = payload?.["passed"] === true; | ||
| const latestAttemptIndex = typeof payload?.["attemptIndex"] === "number" | ||
@@ -353,5 +357,18 @@ ? payload["attemptIndex"] | ||
| const steps = readVerificationSteps(payload); | ||
| const contradicted = passed && hasVerificationContradiction(warnings, steps); | ||
| const binding = readVerifierBinding(payload); | ||
| const changedFiles = readStringArray(payload?.["changedFiles"]); | ||
| const expectedBinding = binding | ||
| ? { | ||
| runId: loop.loopId, | ||
| workspaceId: loop.workspaceId, | ||
| cwd: loop.receiptScope?.workingDirectory ?? loop.task.repoRoot ?? binding.cwd, | ||
| commands: loop.task.verificationPlan, | ||
| } | ||
| : undefined; | ||
| const executionPassed = expectedBinding | ||
| ? verifierActuallyPassed({ passed: claimedPassed, binding, steps }, expectedBinding) | ||
| : false; | ||
| const contradicted = claimedPassed && (!executionPassed || hasVerificationContradiction(warnings, steps)); | ||
| return { | ||
| status: contradicted ? "contradicted" : passed ? "passed" : "failed", | ||
| status: contradicted ? "contradicted" : executionPassed ? "passed" : "failed", | ||
| summary: typeof payload?.["summary"] === "string" | ||
@@ -361,3 +378,3 @@ ? payload["summary"] | ||
| ? "Verifier evidence is contradicted by launch/runtime diagnostics." | ||
| : passed | ||
| : executionPassed | ||
| ? "Verification passed." | ||
@@ -369,3 +386,5 @@ : "Verification failed.", | ||
| steps, | ||
| warnings | ||
| warnings, | ||
| ...(binding ? { binding } : {}), | ||
| changedFiles, | ||
| }; | ||
@@ -434,2 +453,79 @@ } | ||
| } | ||
| export function buildVerifiedHandoffFromPersistedLoop(detail) { | ||
| const verification = buildVerificationSummary(detail.loop); | ||
| const rollbackArtifacts = detail.loop.artifacts.filter((artifact) => /rollback|restore|diff|patch/iu.test(`${artifact.kind} ${artifact.label} ${artifact.uri}`)); | ||
| const stopReason = detail.loop.events | ||
| .filter((event) => event.type === "run.completed" || | ||
| event.type === "run.exited") | ||
| .at(-1) | ||
| ?.payload?.["reason"]; | ||
| return buildVerifiedHandoff({ | ||
| loop: detail.loop, | ||
| mutationRequired: detail.loop.task.mutationMode === "edit", | ||
| definitionOfDonePreSatisfied: detail.loop.task.definitionOfDonePreSatisfied, | ||
| receiptIntegrity: detail.integrity, | ||
| verification: { | ||
| status: verification.status, | ||
| summary: verification.summary, | ||
| steps: verification.steps.map((step) => ({ | ||
| command: step.command, | ||
| launched: step.launched, | ||
| exitCode: step.exitCode, | ||
| timedOut: step.timedOut, | ||
| detail: step.detail, | ||
| })), | ||
| warnings: verification.warnings, | ||
| ...(verification.binding ? { binding: verification.binding } : {}), | ||
| }, | ||
| 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: verification.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], | ||
| ...(typeof stopReason === "string" ? { stopReason } : {}), | ||
| recovery: { | ||
| rollbackBoundaryAvailable: rollbackArtifacts.length > 0, | ||
| rollbackAttempted: rollbackArtifacts.some((artifact) => /restore|rollback outcome/iu.test(artifact.label)), | ||
| summary: rollbackArtifacts.length > 0 | ||
| ? `${rollbackArtifacts.length} rollback/recovery artifact(s) recorded.` | ||
| : "No rollback artifact was found in the persisted run.", | ||
| }, | ||
| nextAction: verification.status === "passed" | ||
| ? "Review the Verified Handoff and decide whether to merge or promote." | ||
| : "Resolve the failed or missing evidence before claiming completion.", | ||
| }); | ||
| } | ||
| function readVerifierBinding(payload) { | ||
| const value = payload?.["binding"]; | ||
| if (!isRecord(value)) | ||
| return undefined; | ||
| const commands = readStringArray(value["commands"]); | ||
| return typeof value["runId"] === "string" && | ||
| typeof value["workspaceId"] === "string" && | ||
| typeof value["cwd"] === "string" | ||
| ? { | ||
| runId: value["runId"], | ||
| workspaceId: value["workspaceId"], | ||
| cwd: value["cwd"], | ||
| commands, | ||
| } | ||
| : undefined; | ||
| } | ||
| function readStringArray(value) { | ||
| return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : []; | ||
| } | ||
| export function buildRunDossier(detail) { | ||
@@ -440,2 +536,3 @@ const verification = buildVerificationSummary(detail.loop); | ||
| const receipt = buildRunReceipt(detail.loop, verification, receiptScope); | ||
| const verifiedHandoff = buildVerifiedHandoffFromPersistedLoop(detail); | ||
| return { | ||
@@ -451,4 +548,8 @@ source: detail.source, | ||
| ...(receiptScope ? { receiptScope } : {}), | ||
| ...(detail.loop.terminationEnvelope | ||
| ? { terminationEnvelope: detail.loop.terminationEnvelope } | ||
| : {}), | ||
| verification, | ||
| receipt, | ||
| verifiedHandoff, | ||
| artifacts: artifactSummary, | ||
@@ -481,18 +582,25 @@ recentEvents: detail.loop.events.slice(-10) | ||
| const warnings = []; | ||
| const latestIndexed = await loadLatestLoopFromRunIndex(runsRoot, warnings); | ||
| if (latestIndexed) { | ||
| const indexedLoop = await loadLatestLoopFromWorkspaceIndexes(runsRoot, entries, warnings, options.workspaceId); | ||
| if (indexedLoop) { | ||
| return { | ||
| runsRoot, | ||
| loop: latestIndexed, | ||
| loop: indexedLoop, | ||
| warnings | ||
| }; | ||
| } | ||
| const indexedLoop = await loadLatestLoopFromWorkspaceIndexes(runsRoot, entries, warnings); | ||
| if (indexedLoop) { | ||
| if (options.workspaceId && runsDir === undefined) { | ||
| return { | ||
| runsRoot, | ||
| loop: indexedLoop, | ||
| loop: undefined, | ||
| warnings | ||
| }; | ||
| } | ||
| const latestIndexed = await loadLatestLoopFromRunIndex(runsRoot, warnings, options.workspaceId); | ||
| if (latestIndexed) { | ||
| return { | ||
| runsRoot, | ||
| loop: latestIndexed, | ||
| warnings | ||
| }; | ||
| } | ||
| let latestLoop; | ||
@@ -508,2 +616,5 @@ for (const entry of entries) { | ||
| const loop = await readLoopRecordFile(canonical); | ||
| if (options.workspaceId && loop.workspaceId !== options.workspaceId) { | ||
| continue; | ||
| } | ||
| const candidateTimestamp = loopTimestamp(loop); | ||
@@ -519,3 +630,3 @@ const latestTimestamp = latestLoop ? loopTimestamp(latestLoop) : Number.NEGATIVE_INFINITY; | ||
| } | ||
| const loop = (await readLoopsFromFile(path.join(runsRoot, entryName), runsRoot)).sort((left, right) => loopTimestamp(right) - loopTimestamp(left))[0]; | ||
| const loop = (await readLoopsFromFile(path.join(runsRoot, entryName), runsRoot)).sort((left, right) => loopTimestamp(right) - loopTimestamp(left)).find((candidate) => !options.workspaceId || candidate.workspaceId === options.workspaceId); | ||
| const candidateTimestamp = loop ? loopTimestamp(loop) : Number.NEGATIVE_INFINITY; | ||
@@ -574,2 +685,5 @@ const latestTimestamp = latestLoop ? loopTimestamp(latestLoop) : Number.NEGATIVE_INFINITY; | ||
| } | ||
| if (filters.workspaceId && resolved.loop.workspaceId !== filters.workspaceId) { | ||
| continue; | ||
| } | ||
| if (filters.adapterId && !resolved.loop.attempts.some((attempt) => attempt.adapterId === filters.adapterId)) { | ||
@@ -590,3 +704,3 @@ continue; | ||
| } | ||
| async function loadLatestLoopFromRunIndex(runsRoot, warnings) { | ||
| async function loadLatestLoopFromRunIndex(runsRoot, warnings, workspaceId) { | ||
| const indexed = await readRunIndexEntries(runsRoot); | ||
@@ -599,3 +713,6 @@ if (indexed.entries.length === 0) { | ||
| try { | ||
| return (await loadLoopById(entry.loopId, runsRoot)).loop; | ||
| const loop = (await loadLoopById(entry.loopId, runsRoot)).loop; | ||
| if (!workspaceId || loop.workspaceId === workspaceId) { | ||
| return loop; | ||
| } | ||
| } | ||
@@ -797,4 +914,21 @@ catch (error) { | ||
| } | ||
| async function loadLatestLoopFromWorkspaceIndexes(runsRoot, entries, warnings) { | ||
| let latestSummary; | ||
| async function loadLatestLoopFromWorkspaceIndexes(runsRoot, entries, warnings, workspaceId) { | ||
| if (workspaceId) { | ||
| const workspaceIndex = path.join(runsRoot, `${workspaceId}.jsonl`); | ||
| try { | ||
| const summary = await readLatestWorkspaceIndexSummary(workspaceIndex); | ||
| if (!summary) { | ||
| return undefined; | ||
| } | ||
| const loop = (await loadLoopById(summary.loopId, runsRoot)).loop; | ||
| return loop.workspaceId === workspaceId ? loop : undefined; | ||
| } | ||
| catch (error) { | ||
| if (!isMissing(error)) { | ||
| warnings.push(`Skipped unreadable workspace index '${path.basename(workspaceIndex)}': ${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
| return undefined; | ||
| } | ||
| } | ||
| const summaries = []; | ||
| for (const entry of entries) { | ||
@@ -810,9 +944,3 @@ const entryName = String(entry.name); | ||
| } | ||
| const candidateTimestamp = parseTimestamp(candidate.updatedAt); | ||
| const latestTimestamp = latestSummary | ||
| ? parseTimestamp(latestSummary.updatedAt) | ||
| : Number.NEGATIVE_INFINITY; | ||
| if (candidateTimestamp > latestTimestamp) { | ||
| latestSummary = candidate; | ||
| } | ||
| summaries.push(candidate); | ||
| } | ||
@@ -823,12 +951,15 @@ catch (error) { | ||
| } | ||
| if (!latestSummary) { | ||
| return undefined; | ||
| summaries.sort((left, right) => parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt)); | ||
| for (const summary of summaries) { | ||
| try { | ||
| const loop = (await loadLoopById(summary.loopId, runsRoot)).loop; | ||
| if (!workspaceId || loop.workspaceId === workspaceId) { | ||
| return loop; | ||
| } | ||
| } | ||
| catch (error) { | ||
| warnings.push(`Workspace index pointed at unreadable loop '${summary.loopId}': ${error instanceof Error ? error.message : String(error)}`); | ||
| } | ||
| } | ||
| try { | ||
| return (await loadLoopById(latestSummary.loopId, runsRoot)).loop; | ||
| } | ||
| catch (error) { | ||
| warnings.push(`Workspace index pointed at unreadable loop '${latestSummary.loopId}': ${error instanceof Error ? error.message : String(error)}`); | ||
| return undefined; | ||
| } | ||
| return undefined; | ||
| } | ||
@@ -1032,2 +1163,4 @@ async function collectPersistedLoops(runsRoot) { | ||
| launched: candidate["launched"], | ||
| ...(typeof candidate["completed"] === "boolean" ? { completed: candidate["completed"] } : {}), | ||
| ...(typeof candidate["crashed"] === "boolean" ? { crashed: candidate["crashed"] } : {}), | ||
| ...(typeof candidate["exitCode"] === "number" ? { exitCode: candidate["exitCode"] } : {}), | ||
@@ -1034,0 +1167,0 @@ ...(typeof candidate["timedOut"] === "boolean" ? { timedOut: candidate["timedOut"] } : {}), |
| export declare function shouldShowStarPrompt(runCount: number, lastShownAt: number): boolean; | ||
| export declare function maybeShowStarPrompt(runCount: number): Promise<void>; | ||
| export declare function showInlineStarCta(): Promise<void>; |
@@ -0,1 +1,4 @@ | ||
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { readRunStats, writeRunStats } from "./run-stats.js"; | ||
@@ -54,2 +57,29 @@ const STAR_URL = "https://github.com/Keesan12/martin-loop"; | ||
| } | ||
| export async function showInlineStarCta() { | ||
| if (!process.stdout.isTTY || !process.stdin.isTTY) | ||
| return; | ||
| console.log(""); | ||
| console.log("─────────────────────────────────────────────"); | ||
| console.log("⭐ MartinLoop saved you from a runaway bill."); | ||
| console.log(` ${STAR_URL}`); | ||
| console.log(""); | ||
| console.log(" [Enter] open in browser [s] skip"); | ||
| process.stdout.write(" > "); | ||
| const key = await readSingleKeypress(); | ||
| console.log(""); | ||
| if (key === "\r" || key === "\n") { | ||
| try { | ||
| const { exec } = await import("node:child_process"); | ||
| const cmd = process.platform === "win32" ? `start "" "${STAR_URL}"` | ||
| : process.platform === "darwin" ? `open "${STAR_URL}"` | ||
| : `xdg-open "${STAR_URL}"`; | ||
| exec(cmd); | ||
| console.log(" Opening GitHub... ⭐"); | ||
| } | ||
| catch { | ||
| console.log(` Open this in your browser: ${STAR_URL}`); | ||
| } | ||
| } | ||
| console.log("─────────────────────────────────────────────"); | ||
| } | ||
| async function readSingleKeypress() { | ||
@@ -56,0 +86,0 @@ return new Promise((resolve) => { |
@@ -1,2 +0,3 @@ | ||
| import type { MartinErrorCategory, MartinOutputMode } from "../contracts/index.js"; | ||
| import type { MartinErrorCategory, MartinOutputMode, VerifiedHandoffOutcome } from "../contracts/index.js"; | ||
| export declare function exitCodeForGovernedOutcome(outcome: VerifiedHandoffOutcome): number; | ||
| export interface CliFailurePayload { | ||
@@ -19,2 +20,3 @@ ok: false; | ||
| warnings?: string[]; | ||
| exitCode?: number; | ||
| } | ||
@@ -41,1 +43,22 @@ export declare class CliCommandError extends Error { | ||
| }; | ||
| import type { MilestoneState, InlineMilestone, InteractivePrompt, RankName } from "./cli-milestone-state.js"; | ||
| export declare function buildRankHeader(rank: RankName, termWidth: number): string; | ||
| export type RunOutcome = "success" | "awaiting_signoff" | "approval_blocked" | "failure"; | ||
| export declare function renderRunHeader(rank: RankName, outcome: RunOutcome, attempts: number, actualUsd: number, savedThisRun: number, lifetimeSaved: number, savingsConfidence: "confirmed" | "estimated" | "unavailable", receiptPersisted: boolean): string; | ||
| export declare function renderInlineMilestone(milestone: InlineMilestone): string; | ||
| export declare function renderLoopMilestoneBox(count: number, rank: RankName, prevRank: RankName | null): string; | ||
| export interface MilestonePromptCtx { | ||
| rank: RankName; | ||
| prevRank: RankName | null; | ||
| totalSavedUsd: number; | ||
| successfulRunCount: number; | ||
| starShownCount: number; | ||
| } | ||
| export interface MilestonePromptCallbacks { | ||
| onStarConfirmed: () => Promise<void>; | ||
| onWaitlistJoined: (email: string) => Promise<void>; | ||
| onWaitlistDeclined: () => Promise<void>; | ||
| onFeedback: (score: number, featureVote?: string, email?: string) => Promise<void>; | ||
| } | ||
| export declare function renderMilestonePrompt(prompt: InteractivePrompt, ctx: MilestonePromptCtx, callbacks: MilestonePromptCallbacks): Promise<void>; | ||
| export declare function renderLoopCard(state: MilestoneState | null): void; |
+327
-4
@@ -10,4 +10,15 @@ const EXIT_CODES = { | ||
| budget_exit: 9, | ||
| transient: 10 | ||
| transient: 10, | ||
| install_failed: 11 | ||
| }; | ||
| export function exitCodeForGovernedOutcome(outcome) { | ||
| switch (outcome) { | ||
| case "VERIFIED": | ||
| return 0; | ||
| case "STOPPED": | ||
| return EXIT_CODES.budget_exit; | ||
| case "NEEDS_REVIEW": | ||
| return EXIT_CODES.verification_failed; | ||
| } | ||
| } | ||
| export class CliCommandError extends Error { | ||
@@ -40,3 +51,3 @@ category; | ||
| return { | ||
| exitCode: 0, | ||
| exitCode: input.exitCode ?? 0, | ||
| stdout: formatJson(payload), | ||
@@ -48,3 +59,3 @@ stderr: "" | ||
| return { | ||
| exitCode: 0, | ||
| exitCode: input.exitCode ?? 0, | ||
| stdout: input.quiet ?? "", | ||
@@ -57,3 +68,3 @@ stderr: "" | ||
| return { | ||
| exitCode: 0, | ||
| exitCode: input.exitCode ?? 0, | ||
| stdout: [...lines, ...(warnings.length > 0 ? ["", ...warnings] : [])].join("\n"), | ||
@@ -98,2 +109,314 @@ stderr: "" | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Loop Experience Engine v5 — rendering | ||
| // --------------------------------------------------------------------------- | ||
| import * as readline from "node:readline"; | ||
| import { exec } from "node:child_process"; | ||
| import { nextRank } from "./cli-milestone-state.js"; | ||
| const KEY_TIMEOUT_MS = 30_000; | ||
| function readSingleKey() { | ||
| return new Promise((resolve) => { | ||
| if (!process.stdin.isTTY) { | ||
| resolve(""); | ||
| return; | ||
| } | ||
| let settled = false; | ||
| const settle = (key) => { | ||
| if (settled) | ||
| return; | ||
| settled = true; | ||
| clearTimeout(timer); | ||
| process.stdin.setRawMode(false); | ||
| process.stdin.pause(); | ||
| process.stdout.write("\n"); | ||
| resolve(key); | ||
| }; | ||
| const timer = setTimeout(() => settle(""), KEY_TIMEOUT_MS); | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| process.stdin.setEncoding("utf8"); | ||
| process.stdin.once("data", (key) => settle(key)); | ||
| process.stdin.once("error", () => settle("")); | ||
| }); | ||
| } | ||
| // Creates one readline interface for an entire prompt interaction (which may | ||
| // involve several sequential questions), instead of a fresh interface per | ||
| // question. Recreating the interface for every question raced its own | ||
| // "line" and "close" events against each other and could resolve empty even | ||
| // when the user had typed a real answer. | ||
| function createLineReader() { | ||
| const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); | ||
| let closed = false; | ||
| let pendingResolve = null; | ||
| rl.on("close", () => { | ||
| closed = true; | ||
| if (pendingResolve) { | ||
| const resolve = pendingResolve; | ||
| pendingResolve = null; | ||
| resolve(""); | ||
| } | ||
| }); | ||
| const read = () => { | ||
| if (closed) | ||
| return Promise.resolve(""); | ||
| return new Promise((resolve) => { | ||
| pendingResolve = resolve; | ||
| rl.once("line", (line) => { | ||
| pendingResolve = null; | ||
| resolve(line); | ||
| }); | ||
| }); | ||
| }; | ||
| const close = () => { | ||
| if (!closed) | ||
| rl.close(); | ||
| }; | ||
| return { read, close }; | ||
| } | ||
| function openUrl(url) { | ||
| const cmd = process.platform === "win32" ? `start "" "${url}"` : | ||
| process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`; | ||
| exec(cmd); // fire-and-forget — best effort | ||
| } | ||
| const SEPARATOR = "━".repeat(47); | ||
| const THIN = "─".repeat(47); | ||
| const STREAK_MESSAGES = { | ||
| 3: "🔥 3-day streak. this is becoming a pattern.", | ||
| 7: "🔥 7-day streak. that's a workflow, not a trial.", | ||
| 14: "🔥 14 days. this is infrastructure now.", | ||
| 30: "🔥 30-day streak. control plane energy.", | ||
| 100: "🔥 100-day streak. legend territory." | ||
| }; | ||
| const SAVINGS_MESSAGES = { | ||
| 10: " 💰 $10 saved lifetime. receipts are real.", | ||
| 50: " 💰 $50 saved lifetime. martin is earning its keep.", | ||
| 100: " 💰 $100 saved. three digits. this is infrastructure now.", | ||
| 500: " 💰 $500 saved. operator-tier governance.", | ||
| 1000: " 💰 $1,000 saved. the receipts speak for themselves." | ||
| }; | ||
| const LOOP_MESSAGES = { | ||
| 10: "10 governed loops.", | ||
| 25: "25 loops. pattern established.", | ||
| 50: "50 loops. this is how the good teams work.", | ||
| 100: "100 loops. three digits of governed execution.", | ||
| 250: "250 loops. serious infrastructure.", | ||
| 500: "500 loops. this is a different category of operator.", | ||
| 1000: "1,000 loops. legend." | ||
| }; | ||
| export function buildRankHeader(rank, termWidth) { | ||
| const left = "∞ martinloop"; | ||
| const right = `[${rank}]`; | ||
| const pad = termWidth - left.length - right.length; | ||
| if (pad < 2) | ||
| return left; | ||
| return left + " ".repeat(pad) + right; | ||
| } | ||
| export function renderRunHeader(rank, outcome, attempts, actualUsd, savedThisRun, lifetimeSaved, savingsConfidence, receiptPersisted) { | ||
| const termWidth = process.stdout.columns ?? 80; | ||
| const lines = [ | ||
| SEPARATOR, | ||
| buildRankHeader(rank, termWidth), | ||
| SEPARATOR | ||
| ]; | ||
| const checkMark = outcome === "success" ? "✓ verified" : | ||
| outcome === "awaiting_signoff" ? "✓ verified — awaiting sign-off" : | ||
| outcome === "approval_blocked" ? "✗ approval required" : | ||
| "✗ run failed"; | ||
| const attemptStr = `${attempts} attempt${attempts === 1 ? "" : "s"}`; | ||
| if (outcome === "success" || outcome === "awaiting_signoff") { | ||
| lines.push(` ${checkMark} · ${attemptStr} · $${actualUsd.toFixed(2)} spent`); | ||
| if (outcome === "awaiting_signoff") { | ||
| lines.push(" verification passed; a receipt was recorded. review and accept when ready."); | ||
| } | ||
| if (savingsConfidence === "confirmed" && savedThisRun > 0) { | ||
| lines.push(` 💰 confirmed saved $${savedThisRun.toFixed(2)} this run · $${lifetimeSaved.toFixed(2)} lifetime`); | ||
| if (savedThisRun >= 20) { | ||
| lines.push(" that agent had plans."); | ||
| } | ||
| } | ||
| else if (savingsConfidence === "estimated" && savedThisRun > 0) { | ||
| lines.push(` 💰 estimated saved ~$${savedThisRun.toFixed(2)} this run · ~$${lifetimeSaved.toFixed(2)} lifetime`); | ||
| if (savedThisRun >= 20) { | ||
| lines.push(" martin does not apologize for this."); | ||
| } | ||
| } | ||
| // Tier 3: no dollar figure, loop count shown at end via run output | ||
| } | ||
| else { | ||
| lines.push(` ${checkMark} · ${attemptStr}`); | ||
| lines.push(receiptPersisted | ||
| ? " failure evidence and a signed receipt were saved for inspection." | ||
| : " run evidence could not be persisted; no receipt is available."); | ||
| } | ||
| lines.push(SEPARATOR); | ||
| return lines.join("\n"); | ||
| } | ||
| export function renderInlineMilestone(milestone) { | ||
| if (milestone.kind === "streak_milestone") { | ||
| return STREAK_MESSAGES[milestone.days] ?? `🔥 ${milestone.days}-day streak.`; | ||
| } | ||
| return SAVINGS_MESSAGES[milestone.usd] ?? ` 💰 $${milestone.usd} saved lifetime.`; | ||
| } | ||
| export function renderLoopMilestoneBox(count, rank, prevRank) { | ||
| const rankLine = rank !== prevRank ? `\n ⬡ rank unlocked: ${rank}` : ""; | ||
| const body = LOOP_MESSAGES[count] ?? `${count} loops.`; | ||
| return ["", THIN, ` ∞ ${body}${rankLine}`, THIN, ""].join("\n"); | ||
| } | ||
| export async function renderMilestonePrompt(prompt, ctx, callbacks) { | ||
| const { rank, prevRank, totalSavedUsd, successfulRunCount, starShownCount } = ctx; | ||
| const { onStarConfirmed, onWaitlistJoined, onWaitlistDeclined, onFeedback } = callbacks; | ||
| if (!prompt || !process.stdout.isTTY) | ||
| return; | ||
| if (prompt.kind === "loop_milestone") { | ||
| process.stdout.write(renderLoopMilestoneBox(prompt.count, rank, prevRank)); | ||
| return; | ||
| } | ||
| if (prompt.kind === "star") { | ||
| if (prompt.hard) { | ||
| // Hard ask — run 10+. Direct, no fluff. martin is free and staying that way. | ||
| process.stdout.write(`\n${successfulRunCount} governed loops. martin is free and always will be.\n` + | ||
| "a star is the only ask we make — it takes 3 seconds and keeps this moving:\n" + | ||
| " github.com/Keesan12/martin-loop\n" + | ||
| "[s] open · [enter] skip\n"); | ||
| } | ||
| else if (starShownCount === 0) { | ||
| // Soft first ask — run 2+ | ||
| process.stdout.write("\nmartin is free. it stays that way because people star it.\n" + | ||
| "if it saved you time today, 3 seconds here helps more people find it:\n" + | ||
| " github.com/Keesan12/martin-loop\n" + | ||
| "[s] open · [enter] skip\n"); | ||
| } | ||
| else { | ||
| // Soft second ask — still not hard threshold, but shown once already | ||
| process.stdout.write(`\n$${totalSavedUsd.toFixed(2)} saved. ${successfulRunCount} loops. still free.\n` + | ||
| "the star's there if you want to make it official — last time we'll ask:\n" + | ||
| " github.com/Keesan12/martin-loop\n" + | ||
| "[s] open · [enter] skip\n"); | ||
| } | ||
| const key = await readSingleKey(); | ||
| if (key === "s" || key === "S") { | ||
| await openUrl("https://github.com/Keesan12/martin-loop"); | ||
| await onStarConfirmed(); | ||
| process.stdout.write("appreciated. keeps the open-core moving.\n"); | ||
| } | ||
| return; | ||
| } | ||
| if (prompt.kind === "feedback") { | ||
| process.stdout.write("\nquick one — [enter] to skip entirely.\n" + | ||
| "0–5: is martin actually earning its keep? "); | ||
| const reader = createLineReader(); | ||
| try { | ||
| const line = await reader.read(); | ||
| const trimmed = line.trim(); | ||
| if (!trimmed) | ||
| return; | ||
| const score = parseInt(trimmed, 10); | ||
| if (isNaN(score) || score < 0 || score > 5) | ||
| return; | ||
| let featureVote; | ||
| let email; | ||
| if (score >= 4) { | ||
| process.stdout.write("what should we build next? (one feature, or [enter] to skip) "); | ||
| const vote = await reader.read(); | ||
| if (vote.trim()) | ||
| featureVote = vote.trim(); | ||
| process.stdout.write("want pilot access? early features, direct line to the founding team,\n" + | ||
| "releases before public — email to join: _ ([enter] to skip) "); | ||
| const em = await reader.read(); | ||
| if (em.trim()) | ||
| email = em.trim(); | ||
| } | ||
| else if (score <= 2) { | ||
| process.stdout.write("what's not working? one sentence is plenty. "); | ||
| const note = await reader.read(); | ||
| if (note.trim()) | ||
| featureVote = note.trim(); | ||
| } | ||
| await onFeedback(score, featureVote, email); | ||
| } | ||
| finally { | ||
| reader.close(); | ||
| } | ||
| return; | ||
| } | ||
| if (prompt.kind === "waitlist") { | ||
| const savingsLine = totalSavedUsd >= 50 ? ` and saved $${Math.floor(totalSavedUsd)}+` : ""; | ||
| process.stdout.write(`\nyou've governed ${successfulRunCount} loops${savingsLine}. that's a workflow, not a trial.\n\n` + | ||
| "we're onboarding pilot partners now — what you get:\n" + | ||
| " · new features before public release\n" + | ||
| " · direct access to the founding team\n" + | ||
| " · input on what gets built next\n" + | ||
| " · early pricing before it changes\n\n" + | ||
| "what we ask: real feedback and one conversation.\n\n" + | ||
| "email to join the pilot: _ ([enter] to skip)\n"); | ||
| const waitlistReader = createLineReader(); | ||
| const line = await waitlistReader.read(); | ||
| waitlistReader.close(); | ||
| if (line.trim()) { | ||
| await onWaitlistJoined(line.trim()); | ||
| } | ||
| else { | ||
| await onWaitlistDeclined(); | ||
| } | ||
| } | ||
| } | ||
| export function renderLoopCard(state) { | ||
| if (!state) { | ||
| process.stdout.write("no runs recorded yet. run martin-loop to get started.\n"); | ||
| return; | ||
| } | ||
| const W = 63; | ||
| const border = "─".repeat(W - 2); | ||
| const pad = (s) => `│ ${s.padEnd(W - 3)}│`; | ||
| const blank = pad(""); | ||
| const titleLeft = "∞ M A R T I N L O O P"; | ||
| const titleRight = state.currentRank; | ||
| const titlePad = W - 3 - titleLeft.length - titleRight.length; | ||
| const titleLine = titlePad >= 0 | ||
| ? `│ ${titleLeft}${" ".repeat(titlePad)}${titleRight} │` | ||
| : pad(titleLeft); | ||
| const lines = [`┌${border}┐`, blank, titleLine, pad(` ${border.slice(0, W - 6)}`), blank]; | ||
| // Stats row 1 | ||
| const streakStr = state.dailyStreakDays >= 3 ? `🔥 ${state.dailyStreakDays}-day streak` : ""; | ||
| const repoStr = `${state.reposUsed.length} repo${state.reposUsed.length === 1 ? "" : "s"}`; | ||
| const row1 = `${String(state.successfulRunCount)} loops`.padEnd(22) + | ||
| streakStr.padEnd(22) + repoStr; | ||
| if (streakStr) | ||
| lines.push(pad(` ${row1}`)); | ||
| else | ||
| lines.push(pad(` ${String(state.successfulRunCount)} loops`.padEnd(22) + repoStr)); | ||
| // Stats row 2 | ||
| if (state.savingsConfidence !== "unavailable") { | ||
| const savedStr = `$${state.totalSavedUsd.toFixed(2)} saved`; | ||
| const rollStr = state.rollbacksTriggered > 0 ? `${state.rollbacksTriggered} rollbacks` : ""; | ||
| const blkStr = state.verifierBlocks > 0 ? `${state.verifierBlocks} blk` : ""; | ||
| const row2 = savedStr.padEnd(22) + rollStr.padEnd(22) + blkStr; | ||
| lines.push(pad(` ${row2}`)); | ||
| } | ||
| else { | ||
| lines.push(pad(` ${state.successfulRunCount} governed runs completed.`)); | ||
| } | ||
| lines.push(blank, pad(` ${border.slice(0, W - 6)}`), blank); | ||
| // Started | ||
| if (state.firstRunAt) { | ||
| const d = new Date(state.firstRunAt); | ||
| const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); | ||
| lines.push(pad(` started ${dateStr}`)); | ||
| } | ||
| // Best run | ||
| if (state.bestRunSavedUsd !== null && state.savingsConfidence !== "unavailable" && state.bestRunAt) { | ||
| const conf = state.savingsConfidence === "confirmed" ? "confirmed saved" : "estimated saved"; | ||
| const d = new Date(state.bestRunAt); | ||
| const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); | ||
| lines.push(pad(` best run ${conf} $${state.bestRunSavedUsd.toFixed(2)} · ${dateStr}`)); | ||
| } | ||
| lines.push(pad(` current rank ${state.currentRank}`)); | ||
| // Next rank | ||
| const next = nextRank(state.currentRank); | ||
| if (next) { | ||
| lines.push(pad(` next rank ${next.name} at ${next.loopsNeeded} loops`)); | ||
| } | ||
| lines.push(blank, `└${border}┘`); | ||
| process.stdout.write(lines.join("\n") + "\n"); | ||
| } | ||
| //# sourceMappingURL=ux.js.map |
@@ -42,2 +42,4 @@ import type { LoopBudget, MutationMode, ReceiptScope } from "../contracts/index.js"; | ||
| export declare function evaluateCliRunGate(input: CliRunGateInput): Promise<CliRunGateResult>; | ||
| export declare function deriveWorkspaceKey(workingDirectory: string): string; | ||
| export declare function deriveWorkspaceId(workingDirectory: string): string; | ||
| export {}; |
@@ -1,6 +0,10 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| // SPDX-FileCopyrightText: MartinLoop contributors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { createHash, randomBytes } from "node:crypto"; | ||
| import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; | ||
| import { join, resolve } from "node:path"; | ||
| const WORKFLOW_STATE_DIRECTORY = "_martin"; | ||
| const WORKFLOW_STATE_FILENAME = "workflow-state.json"; | ||
| const WORKSPACES_DIRECTORY = "workspaces"; | ||
| const DOCTOR_TTL_MS = 24 * 60 * 60 * 1000; | ||
@@ -25,8 +29,9 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; | ||
| } | ||
| // Write the "plan" step into the mcp section of the shared workflow-state.json. | ||
| // The CLI and MCP packages share the same _martin/workflow-state.json file; | ||
| // the CLI writes to `cli.*` steps and can also stamp `mcp.plan` so that | ||
| // martin://agent/governance-status and martin gate reflect plan completion. | ||
| // Write the "plan" step into the mcp section of the per-workspace workflow-state.json. | ||
| // CLI and MCP share the same per-workspace file so that martin://agent/governance-status | ||
| // and martin gate reflect plan completion. | ||
| export async function recordMcpPlanStep(input) { | ||
| const statePath = join(resolve(input.runsRoot), WORKFLOW_STATE_DIRECTORY, WORKFLOW_STATE_FILENAME); | ||
| const workspaceKey = deriveWorkspaceKey(input.workingDirectory); | ||
| const mcpDir = join(resolve(input.runsRoot), WORKFLOW_STATE_DIRECTORY, WORKSPACES_DIRECTORY, workspaceKey); | ||
| const statePath = join(mcpDir, WORKFLOW_STATE_FILENAME); | ||
| let state = { version: 1 }; | ||
@@ -59,7 +64,9 @@ try { | ||
| }; | ||
| await mkdir(join(resolve(input.runsRoot), WORKFLOW_STATE_DIRECTORY), { recursive: true }); | ||
| await writeFile(statePath, JSON.stringify(state, null, 2), "utf8"); | ||
| await mkdir(mcpDir, { recursive: true }); | ||
| const mcpTmp = `${statePath}.${randomBytes(4).toString("hex")}.tmp`; | ||
| await writeFile(mcpTmp, JSON.stringify(state, null, 2), "utf8"); | ||
| await rename(mcpTmp, statePath); | ||
| } | ||
| export async function recordCliWorkflowStep(input) { | ||
| const state = await readWorkflowState(input.runsRoot); | ||
| const state = await readWorkflowState(input.runsRoot, input.workingDirectory); | ||
| const receipt = { | ||
@@ -79,11 +86,9 @@ step: input.step, | ||
| state.cli[input.step] = receipt; | ||
| await writeWorkflowState(input.runsRoot, state); | ||
| await writeWorkflowState(input.runsRoot, state, input.workingDirectory); | ||
| } | ||
| export async function evaluateCliRunGate(input) { | ||
| const state = await readWorkflowState(input.runsRoot); | ||
| const state = await readWorkflowState(input.runsRoot, input.workingDirectory); | ||
| const cliState = state.cli ?? {}; | ||
| const workingDirectory = normalizeWorkingDirectory(input.workingDirectory); | ||
| const objectiveKey = normalizeObjective(input.objective); | ||
| const verificationPlanKey = hashVerificationPlan(input.verificationPlan); | ||
| const engine = input.engine ?? "claude"; | ||
| const scopeKey = hashReceiptScope(input.receiptScope ?? { | ||
@@ -102,3 +107,2 @@ invocationRoot: input.workingDirectory, | ||
| const pathScopeKey = hashPathScope(input.allowedPaths ?? [], input.deniedPaths ?? []); | ||
| const budgetKey = input.budget ? hashBudget(input.budget) : undefined; | ||
| const missingSteps = []; | ||
@@ -133,12 +137,9 @@ // Doctor/session-start are repo-scoped readiness checks. They should remain | ||
| } | ||
| // Preflight check: match on workingDirectory + engine + execution bounds. | ||
| // The full hash match was too strict — minor objective wording differences would break | ||
| // the receipt chain. The key governance signal is that preflight ran for this directory | ||
| // and engine recently; the exact objective text can drift between preflight and run. | ||
| // Path policy and budget are execution bounds, so changing them requires a fresh preflight. | ||
| // Preflight scope: workspace identity + verifier + path policy only. | ||
| // Engine, budget, max-iterations, and token limits are runtime execution controls — | ||
| // not safety identity. Changing them does NOT require a fresh preflight. | ||
| // Only a changed verifier command or path scope invalidates the receipt. | ||
| const preflightReady = isFresh(cliState["preflight"], PREFLIGHT_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory && | ||
| receipt.engine === engine && | ||
| receipt.verificationPlanKey === verificationPlanKey && | ||
| receipt.pathScopeKey === pathScopeKey && | ||
| (!budgetKey || receipt.budgetKey === budgetKey)); | ||
| receipt.pathScopeKey === pathScopeKey); | ||
| if (!preflightReady) { | ||
@@ -179,8 +180,10 @@ missingSteps.push("preflight"); | ||
| const preflightReason = missingSteps.includes("preflight") | ||
| ? " Preflight must be rerun when engine, verifier, path scope or budget changed." | ||
| ? " Preflight must be rerun when verifier or path scope changed." | ||
| : ""; | ||
| return `Governed run blocked until MartinLoop receipts exist for ${labels.join(", ")}.${preflightReason} Next command: ${nextCommand}`; | ||
| } | ||
| async function readWorkflowState(runsRoot) { | ||
| const statePath = resolveWorkflowStatePath(runsRoot); | ||
| // Reads per-workspace state when workingDirectory is supplied; reads global state otherwise. | ||
| // Global state is used only for flags like firstRunBannerShown that are not workspace-scoped. | ||
| async function readWorkflowState(runsRoot, workingDirectory) { | ||
| const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory); | ||
| try { | ||
@@ -195,9 +198,25 @@ const raw = await readFile(statePath, "utf8"); | ||
| } | ||
| async function writeWorkflowState(runsRoot, state) { | ||
| const statePath = resolveWorkflowStatePath(runsRoot); | ||
| await mkdir(join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY), { recursive: true }); | ||
| await writeFile(statePath, JSON.stringify(state, null, 2), "utf8"); | ||
| // Writes per-workspace state atomically (tmp → rename) when workingDirectory is supplied. | ||
| // Atomic rename prevents partial reads from concurrent processes in the same workspace. | ||
| async function writeWorkflowState(runsRoot, state, workingDirectory) { | ||
| const statePath = resolveWorkflowStatePath(runsRoot, workingDirectory); | ||
| const dir = workingDirectory | ||
| ? join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory)) | ||
| : join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY); | ||
| await mkdir(dir, { recursive: true }); | ||
| const tmpPath = `${statePath}.${randomBytes(4).toString("hex")}.tmp`; | ||
| await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf8"); | ||
| await rename(tmpPath, statePath); | ||
| } | ||
| function resolveWorkflowStatePath(runsRoot) { | ||
| return join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKFLOW_STATE_FILENAME); | ||
| // Per-workspace path: <runsRoot>/_martin/workspaces/<workspaceKey>/workflow-state.json | ||
| // Global path: <runsRoot>/_martin/workflow-state.json (firstRunBanner only) | ||
| // | ||
| // COMPATIBILITY: pre-fix versions wrote all CLI receipts to the global path. | ||
| // Legacy state is NOT migrated. Users must re-run the governance sequence once after upgrading. | ||
| function resolveWorkflowStatePath(runsRoot, workingDirectory) { | ||
| const base = join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY); | ||
| if (workingDirectory) { | ||
| return join(base, WORKSPACES_DIRECTORY, deriveWorkspaceKey(workingDirectory), WORKFLOW_STATE_FILENAME); | ||
| } | ||
| return join(base, WORKFLOW_STATE_FILENAME); | ||
| } | ||
@@ -221,2 +240,14 @@ function isFresh(receipt, ttlMs, predicate) { | ||
| } | ||
| // Derives a stable per-workspace key from the normalized working directory path. | ||
| // Uses SHA-256 so the key is portable (same repo on same machine = same key), | ||
| // but does not encode machine-specific absolute paths into the stored receipts. | ||
| export function deriveWorkspaceKey(workingDirectory) { | ||
| return createHash("sha256") | ||
| .update(normalizeWorkingDirectory(workingDirectory)) | ||
| .digest("hex") | ||
| .slice(0, 16); | ||
| } | ||
| export function deriveWorkspaceId(workingDirectory) { | ||
| return `ws_${deriveWorkspaceKey(workingDirectory)}`; | ||
| } | ||
| function hashVerificationPlan(verificationPlan) { | ||
@@ -223,0 +254,0 @@ const normalized = verificationPlan.map((step) => step.trim()).filter(Boolean); |
| 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,2 +240,14 @@ 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'; | ||
| //# sourceMappingURL=index.js.map |
@@ -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,4 +10,5 @@ export const MARTIN_ERROR_CATEGORIES = [ | ||
| "budget_exit", | ||
| "transient" | ||
| "transient", | ||
| "install_failed" | ||
| ]; | ||
| //# sourceMappingURL=operator.js.map |
@@ -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,2 +40,151 @@ 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 ────────────────────────────────────────────── | ||
| } | ||
@@ -35,0 +191,0 @@ 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,2 +6,4 @@ 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"; | ||
| //# sourceMappingURL=index.js.map |
@@ -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) { |
+26
-6
| { | ||
| "name": "martin-loop", | ||
| "private": false, | ||
| "version": "0.4.5", | ||
| "version": "0.5.0", | ||
| "type": "module", | ||
@@ -29,3 +29,3 @@ "description": "Open-source command center for governed AI coding agents with built-in onboarding, hard gates, MCP, and shareable run receipts.", | ||
| "type": "git", | ||
| "url": "https://github.com/Keesan12/martin-loop.git" | ||
| "url": "git+https://github.com/Keesan12/martin-loop.git" | ||
| }, | ||
@@ -38,4 +38,4 @@ "bugs": { | ||
| "bin": { | ||
| "martin": "./dist/bin/martin-loop.js", | ||
| "martin-loop": "./dist/bin/martin-loop.js" | ||
| "martin": "dist/bin/martin-loop.js", | ||
| "martin-loop": "dist/bin/martin-loop.js" | ||
| }, | ||
@@ -81,2 +81,5 @@ "exports": { | ||
| "lint": "pnpm -r lint", | ||
| "release:truth:generate": "node scripts/public-release-truth.mjs", | ||
| "release:truth:check": "node scripts/public-release-truth.mjs --check", | ||
| "product-truth:validate": "node scripts/validate-product-truth.mjs", | ||
| "public:copy-scan": "node ./scripts/public-copy-scan.mjs", | ||
@@ -97,3 +100,4 @@ "public:portability-guard": "node ./scripts/public-portability-guard.mjs", | ||
| "release:truth-check": "node ./scripts/release-truth-check.mjs --allow-divergence-marker=[sync-parity]", | ||
| "run:cli": "pnpm --filter @martin/cli dev" | ||
| "run:cli": "pnpm --filter @martin/cli dev", | ||
| "public:promotion-guard": "node scripts/public-portability-guard.mjs" | ||
| }, | ||
@@ -119,3 +123,19 @@ "devDependencies": { | ||
| "benchmarks" | ||
| ] | ||
| ], | ||
| "pnpm": { | ||
| "overrides": { | ||
| "@hono/node-server": "^1.19.13", | ||
| "ajv": "^8.20.0", | ||
| "fast-uri": "^3.1.2", | ||
| "hono": "^4.12.21", | ||
| "ip-address": "^10.1.1", | ||
| "postcss": "^8.5.15", | ||
| "qs": "^6.15.2", | ||
| "vite": "^7.3.2" | ||
| } | ||
| }, | ||
| "directories": { | ||
| "doc": "docs", | ||
| "example": "examples" | ||
| } | ||
| } |
+81
-41
| # MartinLoop | ||
| Your coding agent says it's done. MartinLoop makes it prove it. | ||
| One system to control, verify and understand coding-agent work. | ||
| <div align="center"> | ||
@@ -26,2 +31,20 @@ <img src="./docs/assets/martinloop-logo.png" alt="MartinLoop" width="260"> | ||
| ## Start Here | ||
| **Install** — run `npx -y martin-loop@0.5.0 start`, or install it globally with `npm install -g martin-loop@0.5.0`. | ||
| **Governed run** — define an objective, verifier, budget, and iteration cap with `martin run`. | ||
| **Verifier** — completion requires fresh verifier evidence bound to the active run and workspace. A configured verifier proves only the checks it runs; `VERIFIED` is not a claim that the code is bug-free or automatically safe to merge. | ||
| **Budget** — set a hard spend ceiling with `--budget-usd` and an attempt ceiling with `--max-iterations`. | ||
| **Receipts** — inspect the latest result with `martin dossier --latest` and validate stored integrity with `martin runs verify --latest`. | ||
| **MCP** — install `@martinloop/mcp@0.5.0` in a supported host or generate host configuration with `martin mcp print-config`. | ||
| **Documentation** — continue with the [quickstart](./docs/getting-started/quickstart.md), [CLI reference](./docs/reference/cli.md), or [MCP setup](./docs/getting-started/mcp.md). | ||
| When `--model` is provided, MartinLoop passes it through unchanged. Without `--model`, the authenticated host runtime chooses its own default. MartinLoop does not inject a hidden fallback model. | ||
| ## Why MartinLoop | ||
@@ -78,3 +101,3 @@ | ||
| `start` prints the first-run guided path. `run` auto-checks `doctor`, `session-start`, and `preflight`, then executes when the environment is ready. | ||
| `start` prints the first-run guided path. `run` auto-checks `doctor`, `session-start`, and `preflight`, then executes when the environment is ready. Use `--proof` only when you intentionally want an explicit no-spend lane. | ||
@@ -89,26 +112,6 @@ Inspect-first flow: | ||
| `share --latest` writes `run-receipt.json` and `run-receipt.md` into the selected run directory under `share/`. Proof-card images are opt-in with `--with-proof-card` or `--proof-card-format`. | ||
| `share --latest` writes three files into the selected run directory under `share/`: `run-receipt.json`, `run-receipt.md`, and `proof-card.svg`. | ||
| Release notes for the current root package: [MartinLoop 0.4.5](./docs/release/OSS-0.4.5-RELEASE-NOTES.md). | ||
| Release notes for the current root package: [MartinLoop 0.5.0](./docs/release/OSS-0.5.0-RELEASE-NOTES.md). | ||
| ## MartinLoop Arcade | ||
| Long governed runs can take a few minutes. MartinLoop Arcade keeps the terminal useful while you wait. | ||
| After 30 seconds, if the run is still going and you are in an interactive terminal, MartinLoop asks once: | ||
| ``` | ||
| Still working. Play MartinLoop Arcade while you wait? [y/N] | ||
| ``` | ||
| Pressing `y` launches a terminal Space Invaders game. The governed run continues in the background — receipts, budget tracking, and the final result are untouched. The game closes automatically when the run finishes and the terminal is fully restored. | ||
| The prompt never appears in CI, piped output, JSON mode, or non-interactive environments. | ||
| ```sh | ||
| martin run "your task" --verify "npm test" # prompts after 30 s if still running | ||
| martin run "your task" --verify "npm test" --arcade # offer the game immediately | ||
| martin run "your task" --verify "npm test" --no-arcade # disable the prompt | ||
| ``` | ||
| ## Visual Proof | ||
@@ -141,3 +144,3 @@ | ||
| ```sh | ||
| npx -y martin-loop@latest run "Summarize the demo workspace and prove tests still pass" --verify "npm test" | ||
| npx -y martin-loop@latest run "Summarize the demo workspace and prove tests still pass" --proof --verify "npm test" | ||
| npx -y martin-loop@latest runs verify --latest | ||
@@ -154,24 +157,20 @@ npx -y martin-loop@latest share --latest | ||
| ```sh | ||
| npx -y martin-loop@0.4.3 --version | ||
| npx -y martin-loop@0.4.3 start | ||
| npx -y martin-loop@0.4.3 demo | ||
| npx -y martin-loop@0.5.0 --version | ||
| npx -y martin-loop@0.5.0 start | ||
| npx -y martin-loop@0.5.0 demo | ||
| cd martin-loop-demo | ||
| npm install | ||
| npx -y martin-loop@0.4.3 run "Summarize the demo workspace and prove tests still pass" --verify "npm test" --budget-usd 2 --max-iterations 1 --json | ||
| npx -y martin-loop@0.4.3 dossier --latest --json | ||
| npx -y martin-loop@0.4.3 share --latest --json | ||
| npx -y martin-loop@0.5.0 run "Summarize the demo workspace and prove tests still pass" --verify "npm test" --budget-usd 2 --max-iterations 1 --json | ||
| npx -y martin-loop@0.5.0 dossier --latest --json | ||
| npx -y martin-loop@0.5.0 share --latest --json | ||
| ``` | ||
| For deterministic installs, pin the package line (`martin-loop@0.4.3`) or use `martin-loop@latest`. Plain `npx martin-loop` can resolve a stale local cache on some machines. | ||
| For deterministic installs, pin the package line (`martin-loop@0.5.0`) or use `martin-loop@latest`. Plain `npx martin-loop` can resolve a stale local cache on some machines. | ||
| Default share bundle outputs: | ||
| Expected share bundle outputs: | ||
| - `share/run-receipt.json` | ||
| - `share/run-receipt.md` | ||
| - `share/proof-card.svg` | ||
| Optional proof-card outputs: | ||
| - `share/proof-card-r<revision>-<hash>.svg` | ||
| - `share/proof-card-r<revision>-<hash>.png` | ||
| ## See It In Action | ||
@@ -211,3 +210,3 @@ | ||
| - Run receipts capture stop reason, verifier evidence, budget posture, integrity state, and the next safe action. | ||
| - `martin share --latest` turns the latest governed run into a local share bundle with a redacted JSON receipt and Markdown recap. Proof-card images are generated only when explicitly requested. | ||
| - `martin share --latest` turns the latest governed run into a local share bundle with a redacted JSON receipt, Markdown recap, and proof-card SVG. | ||
| - MCP integration gives hosts one write-capable execution entrypoint plus richer planning, inspection, and review helpers. | ||
@@ -221,3 +220,3 @@ | ||
| | Policy and budget | Defaults come from `martin.config.yaml`; CLI flags can override them. Budget preflight blocks attempts that would exceed policy. | | ||
| | Agent adapters | Claude CLI, Codex CLI, Gemini CLI, direct-provider, and verifier-only adapters normalize execution results. | | ||
| | Agent adapters | Claude CLI, Codex CLI, Gemini CLI, and direct-provider adapters normalize execution results. | | ||
| | Safety and verification | Scope checks, verifier command checks, prompt integrity, and grounding decide whether work can continue. | | ||
@@ -335,3 +334,3 @@ | Persistence | JSONL run records, evidence summaries, and repo-backed artifacts make every run inspectable later. Each loop record is locally signed (HMAC, per-runs-root key) and `dossier`/`runs get`/`runs verify`/`challenge`/`badge` report an `integrity` verdict (`verified` / `tamper_detected` / `unsigned`) so post-hoc edits to a record are detectable, not just inspectable. | | ||
| The root `martin-loop` package and the standalone `@martinloop/mcp` package move on separate version lines. The current root package line here is `0.4.3`; the current standalone MCP source line is `0.3.7`, and the live npm baseline is `0.3.7`. | ||
| The root `martin-loop` package and the standalone `@martinloop/mcp` package are both advancing to `0.5.0` in this release. Their version lines may move independently in future releases. | ||
@@ -386,3 +385,3 @@ The public MCP release train labels are: | ||
| The root SDK also exports `createCodexCliAdapter`, `createGeminiCliAdapter`, `createDirectProviderAdapter`, `createOpenAiCompatibleAdapter`, and `createVerifierOnlyAdapter`. | ||
| The root SDK also exports `createCodexCliAdapter`, `createGeminiCliAdapter`, `createDirectProviderAdapter`, and `createOpenAiCompatibleAdapter`. | ||
@@ -395,2 +394,3 @@ More detail: [SDK reference](./docs/reference/sdk.md) and [package map](./docs/reference/packages.md). | ||
| - [Examples](./docs/getting-started/examples.md) | ||
| - [Agent Failure Atlas](./docs/agent-failure-atlas.md) | ||
| - [Failure Taxonomy (13 Runtime Classes)](./docs/oss/FAILURE-TAXONOMY-13.md) | ||
@@ -467,4 +467,44 @@ - [PRE-028-PUBLIC-SURFACE-DIFF.md](./docs/oss/PRE-028-PUBLIC-SURFACE-DIFF.md) | ||
| ## Telemetry & Privacy | ||
| MartinLoop sends minimal anonymous usage data to help improve reliability and prioritize development. A first-run notice appears before any data is transmitted. No data is sent on that first run. | ||
| **What is sent:** | ||
| - Random installation ID (generated locally, never linked to your identity) | ||
| - Per-process session ID | ||
| - CLI version, Node version, OS and architecture | ||
| - Event name and timestamp | ||
| - Command category, run duration, success/failure category | ||
| - Whether a receipt was generated; whether recovery occurred | ||
| - Opaque remote-experience ID/type after a click | ||
| **What is never sent:** | ||
| - Source code, prompts, task text, repository contents, file names, file paths | ||
| - Environment variables, secrets, provider/model output | ||
| - Receipt contents, ledger contents, approval details, verifier evidence | ||
| - Email addresses, workspace, project, or organization identifiers | ||
| - Raw exception messages or stack traces | ||
| **Endpoint:** `https://tupopqvqnyyjuxseyxkr.supabase.co/functions/v1/product-events` | ||
| **Headers sent:** `Content-Type: application/json`, `User-Agent: MartinLoop-CLI/<version>` | ||
| No authorization header, API key, or direct table access. | ||
| **Opt out anytime:** | ||
| ``` | ||
| martin telemetry off | ||
| ``` | ||
| **Inspect what is sent:** | ||
| ``` | ||
| martin telemetry explain | ||
| ``` | ||
| **Environment variables that disable telemetry:** `MARTIN_TELEMETRY_DISABLED=1`, `DO_NOT_TRACK=1`, `CI=1` | ||
| MartinLoop continues to work normally with telemetry disabled. No features are gated on telemetry consent. | ||
| ## License | ||
| Apache-2.0. See [LICENSE](./LICENSE). |
| 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 | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
| //# sourceMappingURL=verifier-only.js.map |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
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 5 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.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
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.
1314323
37.65%206
56.06%29948
36.69%500
8.7%97
49.23%25
150%