import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { checkGitCompletion } from "./git-completion-checker.ts"; import { checkLocalImportClosure } from "./local-import-closure-checker.ts"; import { evaluateSpine } from "./state-machine.ts"; import { SPINE_STATE_ROOT, type CurrentBinding, type EvidenceRecord, type HandoffRecord, type IssueBinding, type PrBinding, type SpineContextSnapshot, type SpineTaskState, } from "./types.ts"; export interface BindInput { issueIdentifier: string; issueUrl?: string; issueTitle?: string; localIssuePath?: string; } export interface LinkPrInput { prUrl: string; prNumber?: number; prHeadSha?: string; prBranch?: string; prTitle?: string; prBody?: string; metadata?: Record; writebackRecorded?: boolean; } export interface EvidenceInput { kind: EvidenceRecord["kind"]; command?: string; exitCode?: number; summary: string; outputExcerpt?: string; } /** * Two evidence records are duplicates when they share the same `kind`, * `command`, and `exitCode`. These three fields identify a verification step; * re-running the same step refreshes the existing record instead of adding a * duplicate. `summary`, `outputExcerpt`, and `timestamp` are not part of the * key, so a fresh run updates the most recent outcome in place. */ function isDuplicateEvidence(existing: EvidenceRecord, incoming: EvidenceInput): boolean { return existing.kind === incoming.kind && existing.command === incoming.command && existing.exitCode === incoming.exitCode; } export interface HandoffInput { done: string[]; changed: string[]; verification: string[]; blockers?: string[]; next?: string[]; risks?: string[]; } export function safeIssueIdentifier(issueIdentifier: string): string { const trimmed = issueIdentifier.trim(); const ascii = trimmed .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^A-Za-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); const digest = createHash("sha256").update(trimmed).digest("hex").slice(0, 8); return `${ascii || "issue"}-${digest}`; } async function readJson(path: string): Promise { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } async function writeJson(path: string, value: unknown): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function toPortablePath(path: string): string { return path.split(String.fromCharCode(92)).join("/"); } export class SpineStateStore { readonly cwd: string; readonly root: string; constructor(cwd: string) { this.cwd = cwd; this.root = join(cwd, SPINE_STATE_ROOT); } currentPath(): string { return join(this.root, "current.json"); } taskPath(issueIdentifier: string): string { return join(this.root, "tasks", `${safeIssueIdentifier(issueIdentifier)}.json`); } async loadCurrent(): Promise { return readJson(this.currentPath()); } async loadActiveTask(): Promise { const current = await this.loadCurrent(); if (!current) return undefined; return readJson(join(this.root, current.taskFile)); } async context(): Promise { const current = await this.loadCurrent(); const task = current ? await this.loadActiveTask() : undefined; const localImportClosure = task ? await checkLocalImportClosure(this.cwd, task.issue.identifier, task.issue.localIssuePath) : undefined; return { root: relative(this.cwd, this.root) || SPINE_STATE_ROOT, current, task, evaluation: evaluateSpine(task, undefined, localImportClosure), }; } async bind(input: BindInput): Promise { const issueIdentifier = input.issueIdentifier.trim(); if (!issueIdentifier) throw new Error("issueIdentifier is required"); const now = new Date().toISOString(); const path = this.taskPath(issueIdentifier); const existing = await readJson(path); const issue: IssueBinding = { identifier: issueIdentifier, url: input.issueUrl, title: input.issueTitle, localIssuePath: input.localIssuePath?.trim() || existing?.issue.localIssuePath, boundAt: existing?.issue.boundAt ?? now, }; const task: SpineTaskState = { issue, pr: existing?.pr, evidence: existing?.evidence ?? [], handoff: existing?.handoff, verifiedAt: existing?.verifiedAt, updatedAt: now, }; await writeJson(path, task); const current: CurrentBinding = { issueIdentifier, taskFile: toPortablePath(relative(this.root, path)), updatedAt: now, }; await writeJson(this.currentPath(), current); return this.context(); } async linkPr(input: LinkPrInput): Promise { if (!input.prUrl.trim()) throw new Error("prUrl is required"); const task = await this.requireActiveTask(); const now = new Date().toISOString(); task.pr = { prUrl: input.prUrl.trim(), prNumber: input.prNumber, prHeadSha: input.prHeadSha, prBranch: input.prBranch, prTitle: input.prTitle, prBody: input.prBody, metadata: input.metadata, writebackRecorded: input.writebackRecorded, linkedAt: now, } satisfies PrBinding; task.updatedAt = now; task.verifiedAt = undefined; await this.writeActiveTask(task); return this.context(); } async addEvidence(input: EvidenceInput): Promise { if (!input.summary.trim()) throw new Error("summary is required"); const task = await this.requireActiveTask(); const now = new Date().toISOString(); const incoming: EvidenceRecord = { ...input, summary: input.summary.trim(), timestamp: now }; const duplicateIndex = task.evidence.findIndex((record) => isDuplicateEvidence(record, input)); if (duplicateIndex >= 0) { // Idempotent: refresh the matching record in place so repeated calls for // the same verification step (kind + command + exitCode) keep at most one // evidence entry instead of appending duplicates. task.evidence[duplicateIndex] = { ...task.evidence[duplicateIndex], ...incoming }; } else { task.evidence.push(incoming); } task.updatedAt = now; task.verifiedAt = undefined; await this.writeActiveTask(task); return this.context(); } async handoff(input: HandoffInput): Promise { const task = await this.requireActiveTask(); const now = new Date().toISOString(); task.handoff = { ...input, timestamp: now } satisfies HandoffRecord; task.updatedAt = now; task.verifiedAt = undefined; await this.writeActiveTask(task); return this.context(); } async verify(): Promise { const task = await this.loadActiveTask(); const gitCompletion = checkGitCompletion(this.cwd, task); const localImportClosure = task ? await checkLocalImportClosure(this.cwd, task.issue.identifier, task.issue.localIssuePath) : undefined; const evaluation = evaluateSpine(task, gitCompletion, localImportClosure); if (task && evaluation.missing.length === 0) { task.verifiedAt = new Date().toISOString(); task.updatedAt = task.verifiedAt; await this.writeActiveTask(task); } const verifiedTask = await this.loadActiveTask(); const verifiedEvaluation = evaluateSpine(verifiedTask, gitCompletion, localImportClosure); const current = await this.loadCurrent(); return { root: relative(this.cwd, this.root) || SPINE_STATE_ROOT, current, task: verifiedTask, evaluation: verifiedEvaluation, }; } private async requireActiveTask(): Promise { const task = await this.loadActiveTask(); if (!task) throw new Error("No active issue. Call multica_spine_bind first."); return task; } private async writeActiveTask(task: SpineTaskState): Promise { await writeJson(this.taskPath(task.issue.identifier), task); } }