diff --git a/.changeset/squad-2-0-multi-host-clients.md b/.changeset/squad-2-0-multi-host-clients.md index 6bb73e01e..80e500a6d 100644 --- a/.changeset/squad-2-0-multi-host-clients.md +++ b/.changeset/squad-2-0-multi-host-clients.md @@ -3,6 +3,6 @@ "@bradygaster/squad-cli": major --- -Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), and a host-agnostic coordinator prompt with per-host dispatch. +Squad 2.0: decoupled backend and multi-host client adapters. Add `squad init --client` and `squad upgrade --client` (copilot, claude, kiro, opencode, cursor, codex, antigravity) with additive, non-clobbering config merges, persisted in `.squad/config.json` `clients[]`; add the `HostProjection` / spec `ClientAdapter` SDK surface with canonical capability tokens under `@bradygaster/squad-sdk/client` (and the deprecated `/clients` alias), a host-agnostic coordinator prompt with per-host dispatch, and locked/transactional repair and `team.md` backfill for handwritten `.squad/casting/registry.json` files during `squad upgrade` (including `--dry-run`). The repair is per-record (valid records and their extra fields are preserved verbatim, the registry revision is bumped), archives the pre-repair originals under `.squad/casting/handwritten-archive-/` by default, fails closed with a per-entry `dropped` list instead of silently discarding entries, derives backfilled agent ids from the `team.md` Charter column / `.squad/agents/*/charter.md` folders rather than from display names, reconciles a single Init Mode `history.json` snapshot, and never rolls a durable commit back. Projection details: the Codex coordinator is emitted as a TOML custom agent (`.codex/agents/squad.toml` with `name`, `description`, `developer_instructions`); `.gitignore` entries are plain lines under a `# squad:` comment (uninstall removes only those); merged files keep their existing line endings, JSON indentation and trailing-newline convention and are written atomically; OpenCode targets `opencode.jsonc` when it is the only config and refuses to clobber a JSONC file that contains comments; symlinked/junctioned ancestors of the repo root are accepted while symlinks escaping the root are still refused; a newly requested client whose host config could not be merged is reported but not persisted; `squad upgrade --dry-run --client ` previews the clients it would project. diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 9d999c88b..1bead7ad1 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -92,6 +92,21 @@ export function detectParentGitRepo(dest: string): string | null { return null; } +/** + * True only when a human can actually answer a prompt: stdin AND stdout are TTYs and the run + * is not CI / explicitly non-interactive. squad init must never block on stdin otherwise + * (headless model runs, pipes, redirected output). + */ +export function isInteractiveSession( + env: NodeJS.ProcessEnv = process.env, + stdin: { isTTY?: boolean } = process.stdin, + stdout: { isTTY?: boolean } = process.stdout, +): boolean { + if (!stdin.isTTY || !stdout.isTTY) return false; + const off = (v: string | undefined): boolean => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'; + return !off(env['CI']) && !off(env['SQUAD_NONINTERACTIVE']); +} + /** True when animations should be suppressed (NO_COLOR, dumb term, non-TTY). */ export function isInitNoColor(): boolean { return ( @@ -226,7 +241,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi console.log(); let useShared = true; - if (process.stdin.isTTY) { + if (isInteractiveSession()) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -539,7 +554,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // to the team roster, allowing squad-labeled issues to be auto-assigned. const teamContent = readTeamMd(squadDir); if (!hasCopilot(teamContent)) { - if (process.stdin.isTTY) { + if (isInteractiveSession()) { const { createInterface } = await import('node:readline/promises'); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index c0c907207..47ee8950c 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -8,7 +8,13 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } from '@bradygaster/squad-sdk'; -import { ensureCastingRegistryPair, preflightCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; +import { + backfillCastingRegistryFromTeam, + ensureCastingRegistryPair, + preflightCastingRegistryPair, + repairHandwrittenCastingRegistry, + type RepairResult, +} from '@bradygaster/squad-sdk/casting'; import { success, warn, info, dim, bold } from './output.js'; import { fatal } from './errors.js'; import { detectSquadDir } from './detect-squad-dir.js'; @@ -652,6 +658,15 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri const created: string[] = []; try { storage.mkdirSync(castingDir, { recursive: true }); + // LLM-handwritten registries (role keys, agents arrays, no schema) are normalized instead of crashing upgrade; + // the repair itself decides whether existing history.json content blocks it + const repair = repairHandwrittenCastingRegistry(castingDir); + if (repair.repaired) { + warn('Normalized a handwritten .squad/casting/registry.json'); + created.push(...repair.files.map((f) => `.squad/casting/${f}`)); + } else { + warnCastingRepairSkipped('Could not normalize handwritten .squad/casting/registry.json', repair); + } const pair = ensureCastingRegistryPair( castingDir, templateContent( @@ -667,6 +682,14 @@ export function ensureCastingDefaults(dest: string, templatesDir?: string): stri if (pair.created) { created.push('.squad/casting/registry.json', '.squad/casting/history.json'); } + // team.md + charters written directly by a model on an older CLI leave the registry empty: backfill it from the roster + const backfill = backfillCastingRegistryFromTeam(castingDir, path.join(dest, '.squad', 'team.md')); + if (backfill.repaired) { + warn('Backfilled .squad/casting/registry.json from .squad/team.md'); + created.push(...backfill.files.map((f) => `.squad/casting/${f}`).filter((f) => !created.includes(f))); + } else { + warnCastingRepairSkipped('Could not backfill .squad/casting/registry.json from .squad/team.md', backfill); + } const policyPath = path.join(castingDir, 'policy.json'); if (!storage.existsSync(policyPath)) { @@ -1213,6 +1236,15 @@ async function refreshClients(dest: string, squadDir: string, options: UpgradeOp } } +/** Surface why a handwritten-registry repair or team.md backfill declined to act, including per-entry drops. */ +function warnCastingRepairSkipped(headline: string, result: RepairResult): void { + if (result.repaired || !result.reason) return; + warn(`${headline}: ${result.reason}`); + for (const { name, reason } of result.dropped ?? []) { + dim(` - ${name}: ${reason}`); + } +} + /** * Run the upgrade command */ @@ -1245,12 +1277,27 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); - const castingMigrated = preflightCastingRegistryPair( - path.join(squadDirInfo.path, 'casting'), - options.dryRun ?? false, - ); + // A handwritten registry (model wrote registry.json itself over the empty init pair) is repaired BEFORE the + // strict preflight, which would otherwise reject it and make the repair in ensureCastingDefaults unreachable. + // The repair reconciles a single Init Mode history snapshot; registries with real history keep failing closed. + const castingDir = path.join(squadDirInfo.path, 'casting'); + const dryRun = options.dryRun ?? false; + const repair = repairHandwrittenCastingRegistry(castingDir, { dryRun }); + const handwrittenRepaired = repair.repaired; + if (handwrittenRepaired) { + if (dryRun) { + info('Would normalize handwritten .squad/casting/registry.json to a managed registry/history pair.'); + } else { + warn('Normalized a handwritten .squad/casting/registry.json'); + } + } else { + warnCastingRepairSkipped('Could not normalize handwritten .squad/casting/registry.json', repair); + } + const castingMigrated = handwrittenRepaired && dryRun + ? false + : preflightCastingRegistryPair(castingDir, dryRun); if (castingMigrated) { - info(options.dryRun + info(dryRun ? 'Would migrate legacy casting state to a managed registry/history pair.' : 'Migrated legacy casting state to a managed registry/history pair.'); } @@ -1258,6 +1305,12 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr // --dry-run: preview what upgrade would do without writing (#1052) if (options.dryRun) { info(`\n${bold('Dry run')} — previewing upgrade from ${oldVersion || 'unknown'} → ${cliVersion}\n`); + const backfill = backfillCastingRegistryFromTeam(castingDir, path.join(squadDirInfo.path, 'team.md'), { dryRun: true }); + if (backfill.repaired) { + info('Would backfill .squad/casting/registry.json from .squad/team.md'); + } else { + warnCastingRepairSkipped('Would not backfill .squad/casting/registry.json from .squad/team.md', backfill); + } const templatesDir = getTemplatesDir(); const agentSrc = path.join(templatesDir, 'squad.agent.md.template'); if (storage.existsSync(agentSrc)) { diff --git a/packages/squad-sdk/src/casting/handwritten-registry.ts b/packages/squad-sdk/src/casting/handwritten-registry.ts new file mode 100644 index 000000000..98dcaf319 --- /dev/null +++ b/packages/squad-sdk/src/casting/handwritten-registry.ts @@ -0,0 +1,932 @@ +/** + * Tolerant repair of LLM-handwritten `.squad/casting/registry.json`. + * + * Models doing Init Mode often write the registry in an ad-hoc shape (role keys, + * an `agents` array, `{ name, role, status }` entries, no schema fields). The + * durable registry reader rejects those, which used to crash `squad upgrade`. + * This module normalizes such shapes into the canonical + * `squad-agent-provenance/v1` registry, record by record: records that already + * parse as valid provenance records are preserved verbatim, incomplete records + * only get their missing fields synthesized, and any record that would have to + * be dropped fails the whole repair closed. Canonical and valid legacy + * registries are never touched. + * + * @module casting/handwritten-registry + */ + +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + CastingCommitInDoubtError, + acquireCastingRegistryLock, + commitCastingRegistryPair, + readCastingRegistryPair, + recoverCastingRegistryTransaction, + validateCastingRegistryPairForCommit, + validateCastingRegistryPairRaw, +} from './durable-registry.js'; +import { + AGENT_PROVENANCE_SCHEMA, + AGENT_PROVENANCE_VERSION, + parseAgentProvenanceRegistry, + type AgentProvenanceRecord, + type AgentProvenanceRegistry, + type AgentProvenanceStatus, +} from './agent-provenance.js'; + +const META_KEYS = new Set([ + 'schema', 'schema_version', 'version', 'revision', 'generated_at', 'universe', 'created_at', 'updated_at', 'description', '$schema', + 'transaction_id', 'registry_revision', +]); +const CANONICAL_ROOT_KEYS = new Set([ + 'schema', 'schema_version', 'revision', 'generated_at', 'agents', 'transaction_id', 'registry_revision', +]); +const IDENTITY_FIELDS = ['name', 'persistent_name', 'display_name', 'role']; +const STATUSES: readonly AgentProvenanceStatus[] = ['active', 'inactive', 'retired']; +const COMMIT_MANIFEST = 'registry-history.commit.json'; +const JOURNAL_FILE = 'registry-history.transaction.json'; +const ARCHIVE_PREFIX = 'handwritten-archive-'; +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined); +function normalizeStatus(v: unknown): AgentProvenanceStatus { + const s = typeof v === 'string' ? v.trim().toLowerCase() : ''; + return (STATUSES as readonly string[]).includes(s) ? (s as AgentProvenanceStatus) : 'active'; +} +const isTs = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && !Number.isNaN(Date.parse(v)); +const stripBom = (raw: string): string => (raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw); +const sha256 = (...parts: Array): string => + createHash('sha256').update(parts.map((p) => p ?? '').join('\u0000'), 'utf8').digest('hex'); + +export function toAgentId(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export interface DroppedRecord { name: string; reason: string } + +/** Agent record with any producer-specific extra fields preserved. */ +export type PreservedProvenanceRecord = AgentProvenanceRecord & Record; + +export interface NormalizeHandwrittenResult { + /** Present only when every entry could be normalized. */ + registry?: AgentProvenanceRegistry & Record; + /** Entries that could not be normalized; non-empty means the repair fails closed. */ + dropped: DroppedRecord[]; + reason?: string; +} + +interface RawEntry { key?: string; value: unknown } + +function collectEntries(root: Record): { entries: RawEntry[]; dropped: DroppedRecord[] } { + const agents = root['agents']; + if (Array.isArray(agents)) return { entries: agents.map((value) => ({ value })), dropped: [] }; + if (isRecord(agents)) { + return { entries: Object.entries(agents).map(([key, value]) => ({ key, value })), dropped: [] }; + } + // no wrapper: only non-meta keys whose value is a record carrying identity fields are agents + const entries: RawEntry[] = []; + const dropped: DroppedRecord[] = []; + for (const [key, value] of Object.entries(root)) { + if (META_KEYS.has(key)) continue; + if (!isRecord(value)) { + dropped.push({ name: key, reason: typeof value === 'string' + ? 'bare string value is ambiguous (role → name or name → role); write an object with name and role' + : 'value is not an agent record' }); + continue; + } + if (!IDENTITY_FIELDS.some((f) => str(value[f]))) { + dropped.push({ name: key, reason: 'record has none of name, persistent_name, display_name, or role' }); + continue; + } + entries.push({ key, value }); + } + return { entries, dropped }; +} + +function recordDiagnostics(id: string, record: Record): string[] { + try { + const parsed = parseAgentProvenanceRegistry({ + schema: AGENT_PROVENANCE_SCHEMA, + schema_version: AGENT_PROVENANCE_VERSION, + revision: 1, + generated_at: '1970-01-01T00:00:00.000Z', + agents: { [id]: record }, + }); + return parsed.diagnostics.map((d) => `${d.path.replace(/^agents\.[^.]+\.?/, '') || 'record'}: ${d.message}`); + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } +} + +/** + * Normalize an arbitrary handwritten registry into the canonical shape. Valid + * records are preserved verbatim; incomplete ones are completed in place. Any + * entry that cannot be normalized is reported in `dropped` and no registry is + * returned. + */ +export function normalizeHandwrittenCastingRegistry(value: unknown, now = new Date().toISOString()): NormalizeHandwrittenResult { + const rootRaw = Array.isArray(value) ? { agents: value } : value; + if (!isRecord(rootRaw)) return { dropped: [], reason: 'registry.json root is not an object or array' }; + if (rootRaw['schema'] !== undefined && rootRaw['schema'] !== AGENT_PROVENANCE_SCHEMA) { + return { dropped: [], reason: 'registry.json has an unsupported schema' }; + } + const { entries, dropped } = collectEntries(rootRaw); + const firstEntryUniverse = entries.map((e) => (isRecord(e.value) ? str(e.value['universe']) : undefined)).find(Boolean); + const rootUniverse = str(rootRaw['universe']) ?? firstEntryUniverse ?? 'unspecified'; + // a repair is a new generation: the registry is stamped `now`, but records missing created_at + // inherit the author's generated_at when it is valid + const authoredAt = isTs(rootRaw['generated_at']) ? rootRaw['generated_at'] : now; + const previousRevision = Number.isSafeInteger(rootRaw['revision']) && (rootRaw['revision'] as number) >= 1 + ? (rootRaw['revision'] as number) + : undefined; + + const agents: Record = {}; + const owners = new Map(); + for (const { key, value: raw } of entries) { + if (!isRecord(raw)) { + dropped.push({ name: key ?? String(raw), reason: 'entry is not an agent record' }); + continue; + } + const explicitId = str(raw['id']); + const validUnderKey = !explicitId && key !== undefined && ID_PATTERN.test(key) && recordDiagnostics(key, raw).length === 0; + let name = str(raw['persistent_name']) ?? str(raw['display_name']) ?? str(raw['name']) ?? str(raw['agent']) ?? str(raw['id']); + let role = str(raw['role']) ?? str(raw['title']); + const keyMatchesNameSlug = Boolean( + key && name && ( + toAgentId(key) === toAgentId(name) + || toAgentId(key).replace(/-/g, '') === toAgentId(name).replace(/-/g, '') + ), + ); + const keyMatchesAvatarPath = Boolean( + key && isRecord(raw['avatar']) && str(raw['avatar']['path'])?.startsWith(`.squad/agents/${key}/`), + ); + // role-keyed entry without an explicit role: the key is the role + if (!validUnderKey && !role && key && name && !keyMatchesNameSlug && !keyMatchesAvatarPath) role = key; + name ??= key; + if (!name) { + dropped.push({ name: key ?? '(unnamed)', reason: 'entry has no name' }); + continue; + } + const id = validUnderKey + ? key! + : explicitId + ? toAgentId(explicitId) + : key && ID_PATTERN.test(key) && (keyMatchesNameSlug || keyMatchesAvatarPath) + ? key + : key && toAgentId(key) === toAgentId(name) + ? key + : toAgentId(name); + if (!id || !ID_PATTERN.test(id)) { + dropped.push({ name, reason: `cannot derive a lowercase kebab-case agent id from "${explicitId ?? key ?? name}"` }); + continue; + } + if (agents[id]) { + dropped.push({ name, reason: `agent id "${id}" collides with "${agents[id].display_name}"` }); + continue; + } + const owner = owners.get(name.toLowerCase()); + if (owner) { + dropped.push({ name, reason: `display name duplicates agent "${owner}"` }); + continue; + } + + let record: Record = raw; + if (recordDiagnostics(id, raw).length > 0) { + const status = normalizeStatus(raw['status']); + // the id lives in the map key; an `id` field is only an input hint for incomplete records + const { id: _id, ...fields } = raw; + void _id; + record = { + ...fields, + display_name: name, + persistent_name: name, + role: role ?? id, + universe: str(raw['universe']) ?? rootUniverse, + // preserve a recorded lifecycle status: a retired agent must not be silently resurrected + status, + created_at: isTs(raw['created_at']) ? raw['created_at'] : authoredAt, + updated_at: isTs(raw['updated_at']) ? raw['updated_at'] : now, + ...(status === 'retired' ? { retired_at: isTs(raw['retired_at']) ? raw['retired_at'] : now } : {}), + }; + const remaining = recordDiagnostics(id, record); + if (remaining.length > 0) { + dropped.push({ name, reason: remaining.join('; ') }); + continue; + } + } + owners.set(name.toLowerCase(), id); + agents[id] = record as PreservedProvenanceRecord; + } + if (dropped.length > 0) { + return { dropped, reason: `${dropped.length} registry entr${dropped.length === 1 ? 'y' : 'ies'} could not be normalized` }; + } + if (Object.keys(agents).length === 0) return { dropped, reason: 'registry.json has no recoverable agents' }; + // keep harmless root metadata the author wrote (universe, description, ...) but never agent-like keys + const extras = Object.fromEntries(Object.entries(rootRaw).filter(([k]) => META_KEYS.has(k) && !CANONICAL_ROOT_KEYS.has(k))); + return { + dropped, + registry: { + ...extras, + schema: AGENT_PROVENANCE_SCHEMA, + schema_version: AGENT_PROVENANCE_VERSION, + revision: previousRevision === undefined ? 1 : previousRevision + 1, + generated_at: now, + agents, + }, + }; +} + +function isAcceptable(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value['schema'] !== undefined) { + try { return parseAgentProvenanceRegistry(value).completeness === 'complete'; } catch { return false; } + } + // legacy registries are read by the durable reader; only repair what it cannot read + if (!isRecord(value['agents'])) return false; + return Object.keys(value['agents']).length > 0 && Object.entries(value['agents']).every(([id, a]) => ID_PATTERN.test(id) + && isRecord(a) + && !!str(a['persistent_name']) && isTs(a['created_at']) && !!str(a['universe']) + && (STATUSES as readonly string[]).includes(String(a['status'])) + && (a['status'] !== 'retired' || isTs(a['retired_at']))); +} + +interface CastingDiskSnapshot { + registryBytes: Buffer | undefined; + registryRaw: string | undefined; + historyBytes: Buffer | undefined; + historyRaw: string | undefined; + manifestBytes: Buffer | undefined; + manifestRaw: string | undefined; + journalBytes: Buffer | undefined; + journalRaw: string | undefined; + existingEntries: Set; +} + +function readOptionalBytes(filePath: string): Buffer | undefined { + try { + return fs.readFileSync(filePath); + } catch (error) { + if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) { + return undefined; + } + throw error; + } +} + +function assertNotSymlink(targetPath: string): fs.Stats | undefined { + try { + const st = fs.lstatSync(targetPath); + if (st.isSymbolicLink()) { + throw new Error(`Refusing to operate on symlinked casting path: ${targetPath}`); + } + return st; + } catch (error) { + if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) { + return undefined; + } + throw error; + } +} + +function verifyCastingDirectoryAndFiles(castingDir: string, extraPaths: string[] = []): boolean { + const parentSt = assertNotSymlink(path.dirname(castingDir)); + if (!parentSt || !parentSt.isDirectory()) return false; + const dirSt = assertNotSymlink(castingDir); + if (!dirSt || !dirSt.isDirectory()) return false; + for (const name of ['registry.json', 'history.json', COMMIT_MANIFEST, JOURNAL_FILE]) { + assertNotSymlink(path.join(castingDir, name)); + } + for (const entry of fs.readdirSync(castingDir)) { + if (entry.startsWith(ARCHIVE_PREFIX)) { + assertNotSymlink(path.join(castingDir, entry)); + } + } + for (const extra of extraPaths) { + assertNotSymlink(extra); + } + return true; +} + +function captureDiskSnapshot(castingDir: string): CastingDiskSnapshot { + const registryBytes = readOptionalBytes(path.join(castingDir, 'registry.json')); + const historyBytes = readOptionalBytes(path.join(castingDir, 'history.json')); + const manifestBytes = readOptionalBytes(path.join(castingDir, COMMIT_MANIFEST)); + const journalBytes = readOptionalBytes(path.join(castingDir, JOURNAL_FILE)); + return { + registryBytes, + registryRaw: registryBytes?.toString('utf8'), + historyBytes, + historyRaw: historyBytes?.toString('utf8'), + manifestBytes, + manifestRaw: manifestBytes?.toString('utf8'), + journalBytes, + journalRaw: journalBytes?.toString('utf8'), + existingEntries: new Set(fs.readdirSync(castingDir)), + }; +} + +/** Write via temp file + fsync + rename so a crash never leaves a torn file in place. */ +function writeFileDurably(filePath: string, bytes: Buffer): void { + const tempPath = `${filePath}.tmp-${randomUUID()}`; + try { + const descriptor = fs.openSync(tempPath, 'wx', 0o600); + try { + fs.writeSync(descriptor, bytes); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(tempPath, filePath); + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function restoreDiskSnapshot(castingDir: string, snapshot: CastingDiskSnapshot): void { + const tracked: Array<[string, Buffer | undefined]> = [ + [path.join(castingDir, 'registry.json'), snapshot.registryBytes], + [path.join(castingDir, 'history.json'), snapshot.historyBytes], + [path.join(castingDir, COMMIT_MANIFEST), snapshot.manifestBytes], + [path.join(castingDir, JOURNAL_FILE), snapshot.journalBytes], + ]; + for (const [filePath, bytes] of tracked) { + if (bytes !== undefined) { + if (!readOptionalBytes(filePath)?.equals(bytes)) writeFileDurably(filePath, bytes); + } else { + fs.rmSync(filePath, { force: true }); + } + } + for (const entry of fs.readdirSync(castingDir)) { + if (entry.startsWith('registry.lock')) continue; + if (!snapshot.existingEntries.has(entry)) { + fs.rmSync(path.join(castingDir, entry), { recursive: true, force: true }); + } + } +} + +function attachRestoreFailure(error: unknown, restoreError: unknown): void { + if (!(error instanceof Error)) return; + const previous = (error as { cause?: unknown }).cause; + (error as { cause?: unknown }).cause = previous === undefined + ? restoreError + : new AggregateError([previous, restoreError], 'Restoring the pre-repair casting snapshot also failed'); +} + +function commitManifestMismatch(snapshot: CastingDiskSnapshot): boolean { + if (snapshot.manifestRaw === undefined) return false; + try { + validateCastingRegistryPairRaw( + snapshot.registryRaw, + snapshot.historyRaw, + snapshot.journalRaw, + snapshot.manifestRaw, + ); + return false; + } catch (error) { + return /commit manifest|registry revision does not match/i.test( + error instanceof Error ? error.message : String(error), + ); + } +} + +/** + * True when `history.json` records any cast evidence (assignment snapshots, + * universe usage, or legacy assignments). Empty arrays and objects are not + * history; unreadable history counts as history so callers fail closed. + */ +export function hasExistingCastHistory(historyRaw: string | undefined): boolean { + if (historyRaw === undefined) return false; + try { + const history = JSON.parse(stripBom(historyRaw)) as unknown; + if (!isRecord(history)) return true; + return Object.entries(history).some(([key, value]) => { + if (['transaction_id', 'registry_revision'].includes(key)) return false; + if (Array.isArray(value)) return value.length > 0; + if (isRecord(value)) return Object.keys(value).length > 0; + return value !== undefined && value !== null; + }); + } catch { + return true; + } +} + +const HISTORY_BLOCKER = 'history.json already contains cast history'; + +/** + * Build the genesis history for a repaired registry. When `history.json` holds a + * single model-written assignment snapshot that references only agents of the + * repaired registry, that snapshot is normalized (id/name references resolved, + * usage entry derived) instead of being treated as a blocker. + */ +function normalizeHandwrittenHistory( + historyRaw: string | undefined, + registry: AgentProvenanceRegistry, +): { history?: Record; reason?: string } { + const agents = registry.agents; + const ids = Object.keys(agents); + const universe = agents[ids[0]!]!.universe; + const genesis = (): Record => ({ + universe_usage_history: [{ universe, used_at: registry.generated_at }], + assignment_cast_snapshots: { + 'handwritten-cast-import': { universe, created_at: registry.generated_at, agents: ids }, + }, + }); + if (!hasExistingCastHistory(historyRaw)) return { history: genesis() }; + let parsed: unknown; + try { + parsed = JSON.parse(stripBom(historyRaw!)); + } catch { + return { reason: `${HISTORY_BLOCKER} that is not valid JSON` }; + } + if (!isRecord(parsed)) return { reason: HISTORY_BLOCKER }; + const unknownKeys = Object.keys(parsed).filter((k) => !['universe_usage_history', 'assignment_cast_snapshots', 'transaction_id', 'registry_revision'].includes(k)); + if (unknownKeys.length > 0) { + return { reason: `${HISTORY_BLOCKER} in an unsupported shape (${unknownKeys.join(', ')})` }; + } + const snapshots = parsed['assignment_cast_snapshots']; + const usage = parsed['universe_usage_history'] ?? []; + if (!isRecord(snapshots) || !Array.isArray(usage)) return { reason: HISTORY_BLOCKER }; + const snapshotEntries = Object.entries(snapshots); + if (snapshotEntries.length !== 1 || usage.length > 1) { + return { reason: `${HISTORY_BLOCKER} (${snapshotEntries.length} snapshots, ${usage.length} usage entries); only a single Init Mode snapshot can be normalized` }; + } + const [rawKey, rawSnapshot] = snapshotEntries[0]!; + if (!isRecord(rawSnapshot)) return { reason: `${HISTORY_BLOCKER} with a malformed snapshot` }; + const byName = new Map(Object.entries(agents).map(([id, rec]) => [rec.display_name.toLowerCase(), id])); + const resolveValue = (ref: unknown): string | undefined => { + if (typeof ref !== 'string') return undefined; + const trimmed = ref.trim(); + if (!trimmed) return undefined; + if (Object.hasOwn(agents, trimmed)) return trimmed; + return byName.get(trimmed.toLowerCase()) ?? (Object.hasOwn(agents, toAgentId(trimmed)) ? toAgentId(trimmed) : undefined); + }; + const rawAgents = rawSnapshot['agents']; + const refs: Array<[unknown, string | undefined]> = Array.isArray(rawAgents) + ? rawAgents.map((r) => [r, undefined]) + : isRecord(rawAgents) ? Object.entries(rawAgents).map(([k, v]) => [v, k]) : []; + if (refs.length === 0) return { reason: `${HISTORY_BLOCKER} whose snapshot lists no agents` }; + const members: string[] = []; + for (const [ref, hint] of refs) { + const id = resolveValue(ref); + if (!id) return { reason: `${HISTORY_BLOCKER} referencing unknown agent "${String(ref ?? hint)}"` }; + if (hint !== undefined) { + const trimmedHint = hint.trim(); + const hintAgentId = Object.hasOwn(agents, trimmedHint) + ? trimmedHint + : byName.get(trimmedHint.toLowerCase()); + if (hintAgentId !== undefined && hintAgentId !== id) { + return { reason: `${HISTORY_BLOCKER} where snapshot key "${hint}" disagrees with agent "${String(ref)}"` }; + } + } + if (members.includes(id)) return { reason: `${HISTORY_BLOCKER} referencing agent "${id}" twice` }; + members.push(id); + } + const key = str(rawSnapshot['assignment_id']) ?? str(rawKey); + if (!key) return { reason: `${HISTORY_BLOCKER} with an unnamed snapshot` }; + const snapshotUniverse = str(rawSnapshot['universe']) ?? universe; + const createdAt = isTs(rawSnapshot['created_at']) ? rawSnapshot['created_at'] : registry.generated_at; + const rawUsage = usage[0]; + if (rawUsage !== undefined && !isRecord(rawUsage)) return { reason: `${HISTORY_BLOCKER} with malformed usage history` }; + const usageUniverse = rawUsage ? str(rawUsage['universe']) ?? snapshotUniverse : snapshotUniverse; + const usedAt = rawUsage && isTs(rawUsage['used_at']) ? rawUsage['used_at'] : createdAt; + return { + history: { + universe_usage_history: [{ universe: usageUniverse, used_at: usedAt }], + assignment_cast_snapshots: { [key]: { universe: snapshotUniverse, created_at: createdAt, agents: members } }, + }, + }; +} + +export interface RepairHandwrittenOptions { + /** Override the `generated_at` timestamp when normalizing a handwritten registry. */ + now?: string; + /** + * Archive the pre-repair `registry.json`, `history.json`, and commit manifest under + * `handwritten-archive-/` inside the casting directory. Defaults to true. + */ + keepBackup?: boolean; + /** When true, validate that repair would succeed in memory without mutating disk. */ + dryRun?: boolean; +} + +function resolveRepairOptions( + nowOrOptions?: string | RepairHandwrittenOptions, + maybeOptions?: RepairHandwrittenOptions, +): Required { + const opts = typeof nowOrOptions === 'object' && nowOrOptions !== null ? nowOrOptions : (maybeOptions ?? {}); + const now = typeof nowOrOptions === 'string' ? nowOrOptions : (opts.now ?? new Date().toISOString()); + return { + now, + keepBackup: opts.keepBackup ?? true, + dryRun: Boolean(opts.dryRun), + }; +} + +export interface RepairResult { + repaired: boolean; + /** Relative file names written or created inside the casting directory. */ + files: string[]; + /** Relative directory inside the casting directory holding the pre-repair originals. */ + backupPath?: string; + reason?: string; + /** Entries that blocked the repair; present only when the repair failed closed because of them. */ + dropped?: DroppedRecord[]; +} + +const NOOP: RepairResult = { repaired: false, files: [] }; + +function failed(reason: string, dropped?: DroppedRecord[]): RepairResult { + return { repaired: false, files: [], reason, ...(dropped && dropped.length > 0 ? { dropped } : {}) }; +} + +/** + * Repair `registry.json` in place when it is handwritten and unreadable. + * Acquires the casting writer lock BEFORE reading `registry.json`, `history.json`, + * or `registry-history.commit.json`, and rolls a pending durable transaction + * forward before taking its snapshot. Pre-repair originals are archived under + * `handwritten-archive-/` unless `options.keepBackup === false`. If + * `commitCastingRegistryPair` fails before its journal is durable, the original + * snapshot is restored and the error rethrown; once the journal is durable the + * commit is only ever rolled forward (never falling back to a fresh cast). + */ +export function repairHandwrittenCastingRegistry( + castingDir: string, + nowOrOptions?: string | RepairHandwrittenOptions, + maybeOptions?: RepairHandwrittenOptions, +): RepairResult { + const options = resolveRepairOptions(nowOrOptions, maybeOptions); + if (!verifyCastingDirectoryAndFiles(castingDir)) { + return NOOP; + } + const release = options.dryRun + ? () => {} + : acquireCastingRegistryLock(castingDir, 'handwritten registry repair'); + try { + verifyCastingDirectoryAndFiles(castingDir); + if (!options.dryRun) recoverCastingRegistryTransaction(castingDir); + const snapshot = captureDiskSnapshot(castingDir); + if (snapshot.registryRaw === undefined) { + return NOOP; + } + if (snapshot.journalRaw !== undefined) { + return failed('a durable casting transaction is awaiting roll-forward'); + } + let parsed: unknown; + try { + parsed = JSON.parse(stripBom(snapshot.registryRaw)); + } catch { + return failed('registry.json is not valid JSON'); + } + if (isRecord(parsed) && parsed['schema'] !== undefined && parsed['schema'] !== AGENT_PROVENANCE_SCHEMA) { + return failed('registry.json has an unsupported schema'); + } + const hasManifest = snapshot.manifestRaw !== undefined; + const hasAgents = isRecord(parsed) && isRecord(parsed['agents']) && Object.keys(parsed['agents']).length > 0; + const manifestMismatch = hasManifest && isAcceptable(parsed) && hasAgents && commitManifestMismatch(snapshot); + if (isAcceptable(parsed) && !manifestMismatch) { + return NOOP; + } + if (manifestMismatch) { + const unmintedInitOverwrite = isRecord(parsed) + && parsed['transaction_id'] === undefined + && !hasExistingCastHistory(snapshot.historyRaw); + if (!unmintedInitOverwrite) { + return recommitExistingPair(castingDir, snapshot, options); + } + } + const normalized = normalizeHandwrittenCastingRegistry(parsed, options.now); + if (!normalized.registry) { + return failed(normalized.reason ?? 'registry.json has no recoverable agents', normalized.dropped); + } + const history = normalizeHandwrittenHistory(snapshot.historyRaw, normalized.registry); + if (!history.history) { + return failed(history.reason ?? HISTORY_BLOCKER); + } + return commitGenesisPair(castingDir, snapshot, normalized.registry, history.history, options); + } finally { + release(); + } +} + +function recommitExistingPair( + castingDir: string, + snapshot: CastingDiskSnapshot, + options: Required, +): RepairResult { + if (snapshot.registryRaw === undefined || snapshot.historyRaw === undefined || snapshot.manifestRaw === undefined) { + return failed('history.json and commit manifest are required to recommit pair'); + } + let registry: unknown; + let history: unknown; + let manifest: unknown; + try { + registry = JSON.parse(stripBom(snapshot.registryRaw)); + history = JSON.parse(stripBom(snapshot.historyRaw)); + manifest = JSON.parse(stripBom(snapshot.manifestRaw)); + } catch { + return failed('registry.json, history.json, or commit manifest is not valid JSON'); + } + if (!isRecord(history)) { + return failed(`${HISTORY_BLOCKER} that is not an object`); + } + if (!isRecord(registry) || !Number.isSafeInteger(registry['revision']) || (registry['revision'] as number) < 1) { + return failed('registry/history pair is missing revision metadata'); + } + if ( + !isRecord(manifest) + || manifest['version'] !== 1 + || typeof manifest['transaction_id'] !== 'string' + || manifest['transaction_id'].length === 0 + || !Number.isSafeInteger(manifest['registry_revision']) + || (manifest['registry_revision'] as number) < 1 + ) { + return failed('commit manifest is malformed'); + } + const expectedTxId = manifest['transaction_id']; + const expectedRevision = manifest['registry_revision'] as number; + if ( + registry['transaction_id'] !== expectedTxId + || history['transaction_id'] !== expectedTxId + || registry['revision'] !== expectedRevision + || history['registry_revision'] !== expectedRevision + ) { + return failed('registry, history, and commit manifest generation metadata do not agree'); + } + const nextRevision = expectedRevision + 1; + const { transaction_id: _rt, ...reg } = registry; + const { transaction_id: _ht, registry_revision: _hr, ...hist } = history; + void _rt; + void _ht; + void _hr; + return commitPairWithRollback(castingDir, snapshot, { ...reg, revision: nextRevision }, hist, nextRevision, options); +} + +function commitGenesisPair( + castingDir: string, + snapshot: CastingDiskSnapshot, + normalized: AgentProvenanceRegistry, + history: Record, + options: Required, +): RepairResult { + const { transaction_id: _t, ...registry } = normalized as unknown as Record; + void _t; + return commitPairWithRollback(castingDir, snapshot, registry, history, normalized.revision, options); +} + +function archiveOriginals(castingDir: string, snapshot: CastingDiskSnapshot): string { + const archiveName = `${ARCHIVE_PREFIX}${sha256(snapshot.registryRaw, snapshot.historyRaw, snapshot.manifestRaw)}`; + const archiveDir = path.join(castingDir, archiveName); + const existingArchive = assertNotSymlink(archiveDir); + if (existingArchive && !existingArchive.isDirectory()) { + throw new Error(`Refusing to operate on non-directory casting archive path: ${archiveDir}`); + } + fs.mkdirSync(archiveDir, { recursive: true }); + assertNotSymlink(archiveDir); + const originals: Array<[string, Buffer | undefined]> = [ + ['registry.json', snapshot.registryBytes], + ['history.json', snapshot.historyBytes], + [COMMIT_MANIFEST, snapshot.manifestBytes], + ]; + for (const [name, bytes] of originals) { + if (bytes === undefined) continue; + const target = path.join(archiveDir, name); + assertNotSymlink(target); + const existing = readOptionalBytes(target); + if (existing !== undefined && !existing.equals(bytes)) { + throw new Error(`Cannot archive handwritten casting originals: archive differs at ${target}`); + } + if (existing === undefined) writeFileDurably(target, bytes); + } + return archiveName; +} + +function commitPairWithRollback( + castingDir: string, + snapshot: CastingDiskSnapshot, + registry: Record, + history: Record, + targetRevision: number, + options: Required, +): RepairResult { + validateCastingRegistryPairForCommit(registry, history, targetRevision); + const files = ['registry.json', 'history.json', COMMIT_MANIFEST]; + if (options.dryRun) { + return { repaired: true, files }; + } + let committed = false; + try { + const backupPath = options.keepBackup ? archiveOriginals(castingDir, snapshot) : undefined; + commitCastingRegistryPair( + castingDir, + snapshot.registryRaw, + registry, + snapshot.historyRaw, + history, + targetRevision, + ); + committed = true; + readCastingRegistryPair(castingDir, 1); + return { repaired: true, files, ...(backupPath ? { backupPath } : {}) }; + } catch (error) { + // Once the journal is durable the commit must only roll forward; never restore previous bytes. + if (committed || error instanceof CastingCommitInDoubtError) throw error; + try { + restoreDiskSnapshot(castingDir, snapshot); + } catch (restoreError) { + attachRestoreFailure(error, restoreError); + } + throw error; + } +} + +// ── backfill from team.md ─────────────────────────────────────────── + +const BUILTIN_NAMES = new Set(['scribe', 'ralph', 'rai', 'fact checker', 'fact-checker', 'squad', 'coordinator', 'copilot', '@copilot']); + +export interface TeamRosterMember { + name: string; + role: string; + /** Agent folder id taken from the `Charter` column (`.squad/agents//charter.md`), when present. */ + charterId?: string; +} + +const UNIVERSE_LINE = /^\s*(?:[-*]\s+)?\*{0,2}universe\*{0,2}\s*:\s*\*{0,2}\s*([^\n|*]+?)\s*$/im; +const UNIVERSE_CELL = /^\s*\|\s*\*{0,2}universe\*{0,2}\s*\|\s*([^|\n]+?)\s*\|/im; + +/** Parse specialist rows from `team.md` (`## Members` table, else the first Name/Role table). */ +export function parseTeamRoster(teamMd: string): { members: TeamRosterMember[]; universe?: string } { + const universe = (UNIVERSE_LINE.exec(teamMd)?.[1] ?? UNIVERSE_CELL.exec(teamMd)?.[1])?.replace(/[*_`]/g, '').trim(); + const lines = teamMd.split(/\r?\n/); + const membersHeading = lines.findIndex((l) => /^##\s+members\b/i.test(l.trim())); + const scope = membersHeading === -1 + ? lines + : lines.slice(membersHeading + 1, (() => { const n = lines.findIndex((l, i) => i > membersHeading && /^##\s/.test(l)); return n === -1 ? lines.length : n; })()); + const members: TeamRosterMember[] = []; + let nameCol = -1; + let roleCol = -1; + let statusCol = -1; + let charterCol = -1; + for (const line of scope) { + if (!line.trim().startsWith('|')) { if (members.length > 0) break; nameCol = roleCol = statusCol = charterCol = -1; continue; } + const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((x) => x.trim()); + if (nameCol === -1) { + nameCol = cells.findIndex((x) => /^name$/i.test(x)); + roleCol = cells.findIndex((x) => /^role$/i.test(x)); + statusCol = cells.findIndex((x) => /^status$/i.test(x)); + charterCol = cells.findIndex((x) => /^charter$/i.test(x)); + continue; + } + if (roleCol === -1 || cells.every((x) => /^:?-+:?$/.test(x))) continue; + const name = cells[nameCol]?.replace(/[*_`]/g, '').trim(); + const role = cells[roleCol]?.replace(/[*_`]/g, '').trim(); + const status = statusCol === -1 ? '' : (cells[statusCol] ?? ''); + if (!name || !role || BUILTIN_NAMES.has(name.toLowerCase()) || name.startsWith('@') || /human|retired|alumni/i.test(status) || /human/i.test(role)) continue; + const charterId = charterCol === -1 ? undefined : /agents[\\/]([^\\/|()\s]+)[\\/]charter\.md/i.exec(cells[charterCol] ?? '')?.[1]; + members.push({ name, role, ...(charterId ? { charterId } : {}) }); + } + return { members, ...(universe ? { universe } : {}) }; +} + +/** Map charter `## Identity` names to the agent folders that declare them (duplicates retained to detect ambiguity). */ +function charterIdentities(agentsDir: string): Map { + const identities = new Map(); + if (!fs.existsSync(agentsDir)) return identities; + for (const folder of fs.readdirSync(agentsDir, { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const charter = readOptionalBytes(path.join(agentsDir, folder.name, 'charter.md'))?.toString('utf8'); + if (charter === undefined) continue; + for (const section of charter.matchAll(/^## Identity[ \t]*\r?\n([\s\S]*?)(?=^##[ \t]|(?![\s\S]))/gm)) { + for (const nameField of section[1]!.matchAll(/^- \*\*Name:\*\*[ \t]+([^\r\n]+)\r?$/gm)) { + const name = nameField[1]!.trim().toLowerCase(); + identities.set(name, [...(identities.get(name) ?? []), folder.name]); + } + } + } + return identities; +} + +/** + * Resolve each roster row to its agent folder id. The registry id IS the agent + * folder name, so ids are never derived from display names alone: the `Charter` + * column path wins when its folder exists, then a charter whose `## Identity` + * name matches, then a folder literally named `toAgentId(name)`. + */ +export function resolveRosterAgentIds( + squadDir: string, + members: readonly TeamRosterMember[], +): { resolved: Array; dropped: DroppedRecord[] } { + const agentsDir = path.join(squadDir, 'agents'); + const identities = charterIdentities(agentsDir); + const folderExists = (id: string): boolean => ID_PATTERN.test(id) && (assertNotSymlink(path.join(agentsDir, id))?.isDirectory() ?? false); + const resolved: Array = []; + const dropped: DroppedRecord[] = []; + for (const member of members) { + let id: string | undefined; + if (member.charterId && folderExists(member.charterId)) { + id = member.charterId; + } else { + const matches = identities.get(member.name.toLowerCase()) ?? []; + if (matches.length > 1) { + dropped.push({ name: member.name, reason: `charters of ${matches.join(', ')} all declare this name` }); + continue; + } + id = matches[0] && ID_PATTERN.test(matches[0]) ? matches[0] : undefined; + if (!id && folderExists(toAgentId(member.name))) id = toAgentId(member.name); + } + if (!id) { + dropped.push({ + name: member.name, + reason: member.charterId + ? `charter folder .squad/agents/${member.charterId}/ does not exist` + : 'no .squad/agents//charter.md declares this name; the agent id cannot be proven', + }); + continue; + } + if (resolved.some((r) => r.id === id)) { + dropped.push({ name: member.name, reason: `agent folder "${id}" is already claimed by another roster row` }); + continue; + } + resolved.push({ ...member, id }); + } + return { resolved, dropped }; +} + +/** + * Backfill an EMPTY casting registry from the `team.md` roster. Models on older CLIs (no + * `squad cast --name`) write team.md and charters directly and leave `registry.json` at + * `agents: {}`. Registries that already have agents, absent registries and empty rosters + * are left untouched. Every roster row must resolve to an existing agent folder and + * team.md must declare a universe; otherwise the backfill fails closed with a reason. + */ +export function backfillCastingRegistryFromTeam( + castingDir: string, + teamMdPath: string, + nowOrOptions?: string | RepairHandwrittenOptions, + maybeOptions?: RepairHandwrittenOptions, +): RepairResult { + const options = resolveRepairOptions(nowOrOptions, maybeOptions); + if (!verifyCastingDirectoryAndFiles(castingDir, [teamMdPath])) { + return NOOP; + } + const release = options.dryRun + ? () => {} + : acquireCastingRegistryLock(castingDir, 'team.md registry backfill'); + try { + verifyCastingDirectoryAndFiles(castingDir, [teamMdPath]); + const teamBytes = readOptionalBytes(teamMdPath); + if (teamBytes === undefined) { + return NOOP; + } + if (!options.dryRun) recoverCastingRegistryTransaction(castingDir); + const snapshot = captureDiskSnapshot(castingDir); + if (snapshot.registryRaw === undefined) { + return NOOP; + } + if (snapshot.journalRaw !== undefined) { + return failed('a durable casting transaction is awaiting roll-forward'); + } + let current: unknown; + try { + current = JSON.parse(stripBom(snapshot.registryRaw)); + } catch { + return failed('registry.json is not valid JSON'); + } + if (!isRecord(current) || !isRecord(current['agents']) || Object.keys(current['agents']).length > 0) { + return NOOP; + } + if (hasExistingCastHistory(snapshot.historyRaw)) { + return NOOP; + } + const { members, universe } = parseTeamRoster(stripBom(teamBytes.toString('utf8'))); + if (members.length === 0) { + return NOOP; + } + if (!universe) { + return failed('team.md does not declare a universe (expected a `**Universe:** ` line); no universe will be invented'); + } + const { resolved, dropped } = resolveRosterAgentIds(path.dirname(castingDir), members); + if (dropped.length > 0) { + return failed(`${dropped.length} team.md roster row${dropped.length === 1 ? '' : 's'} could not be mapped to an agent folder`, dropped); + } + const agents = Object.fromEntries(resolved.map(({ id, name, role }) => [id, { id, display_name: name, role }])); + // the empty init pair's generated_at is a template placeholder; backfilled records are created `now` + const normalized = normalizeHandwrittenCastingRegistry({ ...current, generated_at: options.now, universe, agents }, options.now); + if (!normalized.registry) { + return failed(normalized.reason ?? 'team.md has no specialist members', normalized.dropped); + } + const history = normalizeHandwrittenHistory(snapshot.historyRaw, normalized.registry); + if (!history.history) { + return failed(history.reason ?? HISTORY_BLOCKER); + } + return commitGenesisPair(castingDir, snapshot, normalized.registry, history.history, options); + } finally { + release(); + } +} diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index 93b70b254..12ad849a4 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -75,6 +75,22 @@ export { type CastingRecoveryPathState, } from './durable-registry.js'; +export { + backfillCastingRegistryFromTeam, + hasExistingCastHistory, + normalizeHandwrittenCastingRegistry, + parseTeamRoster, + repairHandwrittenCastingRegistry, + resolveRosterAgentIds, + toAgentId, + type DroppedRecord, + type NormalizeHandwrittenResult, + type PreservedProvenanceRecord, + type RepairHandwrittenOptions, + type RepairResult, + type TeamRosterMember, +} from './handwritten-registry.js'; + // --- Legacy Types (kept for backward compat) --- export interface CastingUniverse { diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 92a65835c..bfea27cef 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -186,3 +186,20 @@ export type { export * from './client/adapter-contract.js'; export * from './client/adapters/index.js'; +// Handwritten casting registry repair & team.md backfill +export { + backfillCastingRegistryFromTeam, + hasExistingCastHistory, + normalizeHandwrittenCastingRegistry, + parseTeamRoster, + repairHandwrittenCastingRegistry, + resolveRosterAgentIds, + toAgentId, + type DroppedRecord, + type NormalizeHandwrittenResult, + type PreservedProvenanceRecord, + type RepairHandwrittenOptions, + type RepairResult, + type TeamRosterMember, +} from './casting/handwritten-registry.js'; + diff --git a/test/casting-handwritten-repair.test.ts b/test/casting-handwritten-repair.test.ts new file mode 100644 index 000000000..cf4c637c0 --- /dev/null +++ b/test/casting-handwritten-repair.test.ts @@ -0,0 +1,987 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _setCastingDurabilityHooksForTesting, + CastingCommitInDoubtError, + backfillCastingRegistryFromTeam, + ensureCastingRegistryPair, + hasExistingCastHistory, + normalizeHandwrittenCastingRegistry, + parseAgentProvenanceRegistry, + parseTeamRoster, + readCastingRegistryPair, + repairHandwrittenCastingRegistry, +} from '@bradygaster/squad-sdk/casting'; +import { isInteractiveSession, runInit } from '@bradygaster/squad-cli/core/init'; +import { ensureCastingDefaults, runUpgrade } from '@bradygaster/squad-cli/core/upgrade'; + +const MANIFEST = 'registry-history.commit.json'; +const JOURNAL = 'registry-history.transaction.json'; +const ARCHIVE_RE = /^handwritten-archive-[0-9a-f]{64}$/; + +function snapshotTree(dir: string): Record { + const result: Record = {}; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + result[`${entry.name}/`] = ''; + for (const [k, v] of Object.entries(snapshotTree(full))) { + result[`${entry.name}/${k}`] = v; + } + } else { + result[entry.name] = fs.readFileSync(full).toString('base64'); + } + } + return result; +} + +function writeCharter(squadDir: string, id: string, name: string, role = 'Specialist'): void { + const folder = path.join(squadDir, 'agents', id); + fs.mkdirSync(folder, { recursive: true }); + fs.writeFileSync(path.join(folder, 'charter.md'), `# ${name}\n\n## Identity\n\n- **Name:** ${name}\n- **Role:** ${role}\n\n## Scope\n\nStuff.\n`); +} + +describe('Layer 2: Handwritten casting registry repair & backfill', () => { + const shapes: Record = { + 'role-keyed entries without a wrapper': { + lead: { name: 'Avery', status: 'active' }, + backend: { name: 'Nova', role: 'Backend Developer' }, + tester: { name: 'Quinn' }, + }, + 'agents as an array': { + universe: 'Star Trek', + agents: [ + { name: 'Data', role: 'Expert' }, + { name: "B'Elanna", role: 'Engineer', status: 'ACTIVE' }, + ], + }, + 'simplified object with version and name/role/status': { + version: 1, + universe: 'Squad', + agents: { + avery: { name: 'Avery', role: 'Lead', status: 'active' }, + nova: { name: 'Nova', role: 'Dev', status: 'weird' }, + }, + }, + 'bare array': [ + { name: 'Ada', role: 'Core' }, + { name: 'Linus', role: 'CLI' }, + ], + }; + + let castingDir: string; + + beforeEach(() => { + castingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-casting-repair-')); + }); + + afterEach(() => { + _setCastingDurabilityHooksForTesting(null); + vi.restoreAllMocks(); + fs.rmSync(castingDir, { recursive: true, force: true }); + }); + + const write = (name: string, body: unknown): void => { + fs.writeFileSync( + path.join(castingDir, name), + typeof body === 'string' ? body : JSON.stringify(body, null, 2), + ); + }; + + const initEmptyCanonicalPair = (dir = castingDir): void => { + const reg = JSON.stringify({ + schema: 'squad-agent-provenance/v1', + schema_version: 1, + revision: 1, + generated_at: '1970-01-01T00:00:00.000Z', + agents: {}, + }); + const hist = JSON.stringify({ + universe_usage_history: [], + assignment_cast_snapshots: {}, + }); + ensureCastingRegistryPair(dir, reg, hist, 'test init'); + }; + + it.each(Object.keys(shapes))('normalizes handwritten shape: %s', (name) => { + const { registry: reg, dropped } = normalizeHandwrittenCastingRegistry(shapes[name], '2026-10-18T00:00:00.000Z'); + expect(dropped).toEqual([]); + expect(reg).toBeDefined(); + const parsed = parseAgentProvenanceRegistry(reg); + expect(parsed.completeness).toBe('complete'); + for (const [id, rec] of Object.entries(reg!.agents)) { + expect(id).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + expect(rec.persistent_name).toBe(rec.display_name); + expect(['active', 'inactive', 'retired']).toContain(rec.status); + } + }); + + it('maps role keys and names to canonical ids, roles, and statuses', () => { + const reg = normalizeHandwrittenCastingRegistry(shapes['role-keyed entries without a wrapper']).registry!; + expect(Object.keys(reg.agents).sort()).toEqual(['avery', 'nova', 'quinn']); + expect(reg.agents['avery']).toMatchObject({ display_name: 'Avery', role: 'lead', status: 'active' }); + expect(reg.agents['nova']).toMatchObject({ role: 'Backend Developer' }); + expect(reg.agents['quinn']).toMatchObject({ display_name: 'Quinn', role: 'tester' }); + const belanna = normalizeHandwrittenCastingRegistry(shapes['agents as an array']).registry!; + expect(Object.keys(belanna.agents)).toEqual(['data', 'b-elanna']); + expect(belanna.agents['b-elanna']!.status).toBe('active'); + expect( + normalizeHandwrittenCastingRegistry(shapes['simplified object with version and name/role/status']).registry!.agents['nova']!.status, + ).toBe('active'); + }); + + describe('per-record normalization (H3)', () => { + const valid = { + display_name: 'Data', + persistent_name: 'Data', + role: 'Android', + universe: 'Star Trek', + status: 'active', + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-02-01T00:00:00.000Z', + github_handle: 'data-bot', + legacy_named: true, + avatar: { kind: 'repository-path', path: '.squad/agents/data/avatar.png' }, + }; + + it('preserves already-valid records verbatim and only completes incomplete ones', () => { + const result = normalizeHandwrittenCastingRegistry({ + schema: 'squad-agent-provenance/v1', + schema_version: 1, + revision: 4, + generated_at: '2025-03-01T00:00:00.000Z', + agents: { + data: valid, + tuvok: { name: 'Tuvok', role: 'Security', universe: 'Voyager', created_at: '2025-01-05T00:00:00.000Z', status: 'retired' }, + }, + }, '2026-10-18T00:00:00.000Z'); + expect(result.dropped).toEqual([]); + const reg = result.registry!; + expect(reg.agents['data']).toEqual(valid); + expect(reg.agents['tuvok']).toMatchObject({ + name: 'Tuvok', + display_name: 'Tuvok', + persistent_name: 'Tuvok', + role: 'Security', + universe: 'Voyager', + status: 'retired', + created_at: '2025-01-05T00:00:00.000Z', + updated_at: '2026-10-18T00:00:00.000Z', + retired_at: '2026-10-18T00:00:00.000Z', + }); + expect(reg.revision).toBe(5); + expect(reg.generated_at).toBe('2026-10-18T00:00:00.000Z'); + expect(parseAgentProvenanceRegistry(reg).completeness).toBe('complete'); + }); + + it('starts at revision 1 when the handwritten registry carries no revision', () => { + expect(normalizeHandwrittenCastingRegistry(shapes['bare array']).registry!.revision).toBe(1); + }); + }); + + describe('fail-closed heuristics (M8)', () => { + it('does not treat bare-string or non-agent top-level values as agents', () => { + const meta = normalizeHandwrittenCastingRegistry({ team_name: 'Alpha', notes: 'wip', owner: 'me' }); + expect(meta.registry).toBeUndefined(); + expect(meta.dropped.map((d) => d.name).sort()).toEqual(['notes', 'owner', 'team_name']); + expect(meta.reason).toMatch(/could not be normalized/); + + const bare = normalizeHandwrittenCastingRegistry({ avery: 'Lead / Architect' }); + expect(bare.registry).toBeUndefined(); + expect(bare.dropped).toEqual([{ name: 'avery', reason: expect.stringMatching(/ambiguous/) }]); + + const noIdentity = normalizeHandwrittenCastingRegistry({ settings: { verbose: true } }); + expect(noIdentity.registry).toBeUndefined(); + expect(noIdentity.dropped).toEqual([{ name: 'settings', reason: expect.stringMatching(/none of name/) }]); + }); + + it('fails closed instead of silently dropping unmappable, duplicate, or colliding records', () => { + const nonLatin = normalizeHandwrittenCastingRegistry({ agents: [{ name: 'Data', role: 'x' }, { name: '数据', role: 'y' }] }); + expect(nonLatin.registry).toBeUndefined(); + expect(nonLatin.dropped).toEqual([{ name: '数据', reason: expect.stringMatching(/kebab-case agent id/) }]); + + const duplicate = normalizeHandwrittenCastingRegistry({ agents: [{ name: 'Data', role: 'x' }, { name: 'data', role: 'y' }] }); + expect(duplicate.registry).toBeUndefined(); + expect(duplicate.dropped).toHaveLength(1); + + const collision = normalizeHandwrittenCastingRegistry({ agents: { nova: { name: 'Nova', role: 'a' }, other: { id: 'nova', name: 'Other', role: 'b' } } }); + expect(collision.registry).toBeUndefined(); + expect(collision.dropped).toEqual([{ name: 'Other', reason: expect.stringMatching(/collides/) }]); + }); + + it('repair reports the dropped entries and leaves disk untouched', () => { + write('registry.json', { lead: { name: 'Avery' }, tester: 'Quinn' }); + const beforeTree = snapshotTree(castingDir); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/could not be normalized/), + dropped: [{ name: 'tester', reason: expect.stringMatching(/ambiguous/) }], + }); + expect(snapshotTree(castingDir)).toEqual(beforeTree); + }); + }); + + it.each(Object.keys(shapes))( + 'repairs on disk and archives the originals under handwritten-archive- by default: %s', + (name) => { + write('registry.json', shapes[name]); + const original = fs.readFileSync(path.join(castingDir, 'registry.json')); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ + repaired: true, + files: ['registry.json', 'history.json', MANIFEST], + backupPath: expect.stringMatching(ARCHIVE_RE), + }); + expect(fs.readdirSync(castingDir).filter((f) => f.endsWith('.bak'))).toEqual([]); + expect(fs.readFileSync(path.join(castingDir, result.backupPath!, 'registry.json')).equals(original)).toBe(true); + expect(fs.existsSync(path.join(castingDir, result.backupPath!, 'history.json'))).toBe(false); + + const pair = readCastingRegistryPair(castingDir); + expect(Object.keys(pair.registry!.agents as object).length).toBeGreaterThan(0); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + }, + ); + + it('skips the archive only when options.keepBackup === false is explicitly passed', () => { + initEmptyCanonicalPair(); + write('registry.json', shapes['role-keyed entries without a wrapper']); + const originalRegistry = fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8'); + const originalManifest = fs.readFileSync(path.join(castingDir, MANIFEST), 'utf8'); + + const archived = repairHandwrittenCastingRegistry(castingDir, { keepBackup: true }); + expect(archived.backupPath).toMatch(ARCHIVE_RE); + expect(fs.readFileSync(path.join(castingDir, archived.backupPath!, 'registry.json'), 'utf8')).toBe(originalRegistry); + expect(fs.readFileSync(path.join(castingDir, archived.backupPath!, MANIFEST), 'utf8')).toBe(originalManifest); + expect(fs.existsSync(path.join(castingDir, archived.backupPath!, 'history.json'))).toBe(true); + + fs.rmSync(path.join(castingDir, archived.backupPath!), { recursive: true, force: true }); + fs.rmSync(path.join(castingDir, 'history.json')); + fs.rmSync(path.join(castingDir, MANIFEST)); + write('registry.json', shapes['agents as an array']); + const bare = repairHandwrittenCastingRegistry(castingDir, { keepBackup: false }); + expect(bare.repaired).toBe(true); + expect(bare.backupPath).toBeUndefined(); + expect(fs.readdirSync(castingDir).filter((f) => ARCHIVE_RE.test(f))).toEqual([]); + }); + + it('strips a UTF-8 BOM before parsing registry.json', () => { + fs.writeFileSync(path.join(castingDir, 'registry.json'), '\uFEFF' + JSON.stringify(shapes['bare array'])); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(true); + expect(Object.keys(readCastingRegistryPair(castingDir).registry!.agents as object).sort()).toEqual(['ada', 'linus']); + }); + + it('preserves retired and inactive agents and commits a canonical manifest', () => { + write('registry.json', { + universe: 'Star Trek', + agents: [ + { name: 'Data', role: 'Expert', status: 'active' }, + { name: 'Tuvok', role: 'Security', status: 'retired' }, + { name: 'Neelix', role: 'Morale', status: 'inactive' }, + ], + }); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ + repaired: true, + files: ['registry.json', 'history.json', MANIFEST], + }); + const pair = readCastingRegistryPair(castingDir); + const agents = pair.registry!.agents as Record>; + expect(agents['tuvok']!['status']).toBe('retired'); + expect(typeof agents['tuvok']!['retired_at']).toBe('string'); + expect(agents['neelix']!['status']).toBe('inactive'); + expect(agents['data']!['status']).toBe('active'); + }); + + describe('Lock acquisition before reading registry or staging repair', () => { + it('acquires registry.lock BEFORE reading registry.json, history.json, or commit manifest', () => { + initEmptyCanonicalPair(); + write('registry.json', { + universe: 'Star Trek', + agents: [{ name: 'Data', role: 'Expert' }], + }); + + let lockHeldAtBeforePublish = false; + let lockHeldDuringRead = false; + // If a concurrent writer updates registry.json right before our lock publishes, + // repairHandwrittenCastingRegistry must read the post-lock bytes rather than stale pre-lock bytes. + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === 'lock:before-publish') { + lockHeldAtBeforePublish = true; + write('registry.json', { + universe: 'Star Trek', + agents: [ + { name: 'Data', role: 'Expert' }, + { name: 'Seven', role: 'Astrometrics' }, + ], + }); + } + if (boundary === 'lock:after-publish') { + lockHeldDuringRead = fs.existsSync(path.join(castingDir, 'registry.lock')); + } + }, + }); + + const result = repairHandwrittenCastingRegistry(castingDir); + expect(lockHeldAtBeforePublish).toBe(true); + expect(lockHeldDuringRead).toBe(true); + expect(result.repaired).toBe(true); + const pair = readCastingRegistryPair(castingDir); + expect(Object.keys(pair.registry!.agents as object).sort()).toEqual(['data', 'seven']); + }); + + it('backfillCastingRegistryFromTeam acquires registry.lock BEFORE reading registry.json', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-backfill-lock-')); + try { + const squadDir = path.join(dest, '.squad'); + const dir = path.join(squadDir, 'casting'); + fs.mkdirSync(dir, { recursive: true }); + initEmptyCanonicalPair(dir); + writeCharter(squadDir, 'neo', 'Neo', 'Lead'); + const teamMdPath = path.join(squadDir, 'team.md'); + fs.writeFileSync( + teamMdPath, + ['# Team', '', '**Universe:** Matrix', '', '## Members', '', '| Name | Role |', '|---|---|', '| Neo | Lead |'].join('\n'), + ); + + const origReadFileSync = fs.readFileSync.bind(fs); + const lockStatesOnRegistryRead: boolean[] = []; + vi.spyOn(fs, 'readFileSync').mockImplementation(((p: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (typeof p === 'string' && path.basename(p) === 'registry.json') { + lockStatesOnRegistryRead.push(fs.existsSync(path.join(dir, 'registry.lock'))); + } + return (origReadFileSync as (...a: unknown[]) => unknown)(p, ...args); + }) as typeof fs.readFileSync); + + const res = backfillCastingRegistryFromTeam(dir, teamMdPath); + expect(res.repaired).toBe(true); + expect(lockStatesOnRegistryRead.length).toBeGreaterThan(0); + expect(lockStatesOnRegistryRead[0]).toBe(true); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + }); + + describe('Rollback on commitCastingRegistryPair failure (no fresh-cast fallback)', () => { + it.each(['payload:registry-write', 'journal:write'] as const)( + 'restores original bytes and rethrows when commitCastingRegistryPair fails before the journal is durable at %s', + (failingBoundary) => { + initEmptyCanonicalPair(); + write('registry.json', shapes['role-keyed entries without a wrapper']); + const beforeTree = snapshotTree(castingDir); + + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === failingBoundary) { + throw new Error(`simulated ${failingBoundary} failure`); + } + }, + }); + + expect(() => repairHandwrittenCastingRegistry(castingDir)).toThrow( + new RegExp(`simulated ${failingBoundary} failure`), + ); + expect(snapshotTree(castingDir)).toEqual(beforeTree); + expect(fs.existsSync(path.join(castingDir, MANIFEST))).toBe(true); + expect(fs.readdirSync(castingDir).filter((f) => f.endsWith('.bak') || ARCHIVE_RE.test(f))).toEqual([]); + }, + ); + + it('does NOT restore previous bytes once the journal is durable (in-doubt commit), and rolls forward on the next run', () => { + initEmptyCanonicalPair(); + write('registry.json', shapes['role-keyed entries without a wrapper']); + const handwritten = fs.readFileSync(path.join(castingDir, 'registry.json')); + const historyBefore = fs.readFileSync(path.join(castingDir, 'history.json')); + + // history is replaced after the journal is durable; failing the later registry write (and its recovery) + // leaves the transaction in doubt + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === 'registry:write') throw new Error('simulated registry:write failure'); + }, + }); + let thrown: unknown; + try { + repairHandwrittenCastingRegistry(castingDir); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(CastingCommitInDoubtError); + expect(fs.existsSync(path.join(castingDir, JOURNAL))).toBe(true); + expect(fs.readFileSync(path.join(castingDir, 'registry.json')).equals(handwritten)).toBe(true); + expect(fs.readFileSync(path.join(castingDir, 'history.json')).equals(historyBefore)).toBe(false); + expect(fs.readdirSync(castingDir).filter((f) => ARCHIVE_RE.test(f))).toHaveLength(1); + + _setCastingDurabilityHooksForTesting(null); + const next = repairHandwrittenCastingRegistry(castingDir); + expect(next).toEqual({ repaired: false, files: [] }); + expect(fs.existsSync(path.join(castingDir, JOURNAL))).toBe(false); + const pair = readCastingRegistryPair(castingDir); + expect(Object.keys(pair.registry!.agents as object).sort()).toEqual(['avery', 'nova', 'quinn']); + }); + + it('rolls a pending journal forward before taking the repair snapshot', () => { + initEmptyCanonicalPair(); + write('registry.json', shapes['agents as an array']); + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === 'manifest:write') throw new Error('simulated manifest:write failure'); + }, + }); + expect(() => repairHandwrittenCastingRegistry(castingDir)).toThrow(CastingCommitInDoubtError); + expect(fs.existsSync(path.join(castingDir, JOURNAL))).toBe(true); + expect(() => readCastingRegistryPair(castingDir, 1)).toThrow(/roll-forward/); + + _setCastingDurabilityHooksForTesting(null); + const recoveredAt: string[] = []; + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { if (boundary === 'cleanup:journal-unlink') recoveredAt.push(boundary); }, + }); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + expect(recoveredAt).toEqual(['cleanup:journal-unlink']); + expect(Object.keys(readCastingRegistryPair(castingDir).registry!.agents as object)).toEqual(['data', 'b-elanna']); + }); + + it('never falls back to writing a fresh cast when recommitting a manifest-mismatched canonical pair fails', () => { + write('registry.json', shapes['agents as an array']); + repairHandwrittenCastingRegistry(castingDir); + const committed = readCastingRegistryPair(castingDir); + + // Reformat registry.json so it is canonical but disagrees with the commit manifest bytes + fs.writeFileSync(path.join(castingDir, 'registry.json'), JSON.stringify(committed.registry)); + const beforeTree = snapshotTree(castingDir); + + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === 'payload:history-write') { + throw new Error('simulated recommit failure'); + } + }, + }); + + expect(() => repairHandwrittenCastingRegistry(castingDir)).toThrow(/simulated recommit failure/); + expect(snapshotTree(castingDir)).toEqual(beforeTree); + }); + + it('recommits a manifest-mismatched canonical pair at the next revision', () => { + write('registry.json', shapes['agents as an array']); + repairHandwrittenCastingRegistry(castingDir); + const committed = readCastingRegistryPair(castingDir); + fs.writeFileSync(path.join(castingDir, 'registry.json'), JSON.stringify(committed.registry)); + + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result).toMatchObject({ repaired: true, backupPath: expect.stringMatching(ARCHIVE_RE) }); + const recommitted = readCastingRegistryPair(castingDir); + expect(recommitted.registry!['revision']).toBe((committed.registry!['revision'] as number) + 1); + expect(recommitted.history!['assignment_cast_snapshots']).toEqual(committed.history!['assignment_cast_snapshots']); + }); + + it('restores original bytes and rethrows when backfillCastingRegistryFromTeam fails during commit', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-backfill-rollback-')); + try { + const squadDir = path.join(dest, '.squad'); + const dir = path.join(squadDir, 'casting'); + fs.mkdirSync(dir, { recursive: true }); + initEmptyCanonicalPair(dir); + writeCharter(squadDir, 'neo', 'Neo', 'Lead'); + const teamMdPath = path.join(squadDir, 'team.md'); + fs.writeFileSync( + teamMdPath, + ['# Team', '', '**Universe:** Matrix', '', '## Members', '', '| Name | Role |', '|---|---|', '| Neo | Lead |'].join('\n'), + ); + const beforeTree = snapshotTree(dir); + + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { + if (boundary === 'payload:registry-write') { + throw new Error('backfill commit failed'); + } + }, + }); + + expect(() => backfillCastingRegistryFromTeam(dir, teamMdPath)).toThrow(/backfill commit failed/); + expect(snapshotTree(dir)).toEqual(beforeTree); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + }); + + describe('Canonical and historical registries are never overwritten', () => { + it('leaves canonical registries, absent registries, and unrecoverable content untouched', () => { + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + + initEmptyCanonicalPair(); + const emptyCanonicalTree = snapshotTree(castingDir); + expect(repairHandwrittenCastingRegistry(castingDir)).toEqual({ repaired: false, files: [] }); + expect(snapshotTree(castingDir)).toEqual(emptyCanonicalTree); + + const canonical = normalizeHandwrittenCastingRegistry(shapes['bare array']).registry!; + fs.rmSync(path.join(castingDir, MANIFEST), { force: true }); + write('registry.json', canonical); + write('history.json', { universe_usage_history: [], assignment_cast_snapshots: {} }); + const before = fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8'); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(false); + expect(fs.readFileSync(path.join(castingDir, 'registry.json'), 'utf8')).toBe(before); + + write('registry.json', '{ not json'); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/not valid JSON/), + }); + write('registry.json', { agents: {}, note: 'empty' }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/no recoverable agents/), + }); + expect(fs.readdirSync(castingDir).filter((f) => ARCHIVE_RE.test(f))).toEqual([]); + }); + + it('refuses to overwrite a non-canonical registry when history.json has cast history for other agents', () => { + write('registry.json', shapes['role-keyed entries without a wrapper']); + write('history.json', { + universe_usage_history: [{ universe: 'Original', used_at: '2026-01-01T00:00:00.000Z' }], + assignment_cast_snapshots: { + 'init-cast': { + universe: 'Original', + created_at: '2026-01-01T00:00:00.000Z', + agents: ['alpha'], + }, + }, + }); + const beforeTree = snapshotTree(castingDir); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/cast history.*unknown agent "alpha"/), + }); + expect(snapshotTree(castingDir)).toEqual(beforeTree); + + write('history.json', { + assignment_cast_snapshots: { a: { agents: ['avery'] }, b: { agents: ['nova'] } }, + universe_usage_history: [], + }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/2 snapshots/), + }); + }); + + it('normalizes a single Init Mode snapshot that is consistent with the handwritten registry (M9)', () => { + write('registry.json', { + universe: 'Star Trek', + agents: { lead: { name: 'Data', role: 'Lead' }, security: { name: 'Tuvok', role: 'Security' } }, + }); + write('history.json', { + universe_usage_history: [{ universe: 'Star Trek', assignment_id: 'init-2026', used_at: '2026-01-01T00:00:00.000Z' }], + assignment_cast_snapshots: { + 'init-2026': { + universe: 'Star Trek', + agents: { lead: 'Data', security: 'Tuvok' }, + created_at: '2026-01-01T00:00:00.000Z', + }, + }, + }); + expect(hasExistingCastHistory(fs.readFileSync(path.join(castingDir, 'history.json'), 'utf8'))).toBe(true); + const result = repairHandwrittenCastingRegistry(castingDir); + expect(result.repaired).toBe(true); + const pair = readCastingRegistryPair(castingDir); + expect(Object.keys(pair.registry!.agents as object).sort()).toEqual(['data', 'tuvok']); + expect(pair.history!['assignment_cast_snapshots']).toEqual({ + 'init-2026': { universe: 'Star Trek', created_at: '2026-01-01T00:00:00.000Z', agents: ['data', 'tuvok'] }, + }); + expect(pair.history!['universe_usage_history']).toEqual([{ universe: 'Star Trek', used_at: '2026-01-01T00:00:00.000Z' }]); + }); + + it('hasExistingCastHistory treats empty arrays and objects as no history', () => { + expect(hasExistingCastHistory(undefined)).toBe(false); + expect(hasExistingCastHistory(JSON.stringify({ assignments: [], universe_usage_history: [], assignment_cast_snapshots: {} }))).toBe(false); + expect(hasExistingCastHistory('\uFEFF' + JSON.stringify({ assignment_cast_snapshots: {} }))).toBe(false); + expect(hasExistingCastHistory(JSON.stringify({ assignments: [{ assignment_id: 'x' }] }))).toBe(true); + expect(hasExistingCastHistory(JSON.stringify({ universe_usage_history: [{ universe: 'u' }] }))).toBe(true); + expect(hasExistingCastHistory('{ nope')).toBe(true); + expect(hasExistingCastHistory('[]')).toBe(true); + + write('registry.json', shapes['bare array']); + write('history.json', { assignments: [], universe_usage_history: [], assignment_cast_snapshots: {} }); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(true); + }); + }); + + describe('squad upgrade and --dry-run integration', () => { + it('runUpgrade(..., { dryRun: true }) succeeds on a handwritten registry without mutating any file on disk, then normal runUpgrade repairs it', async () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-hw-')); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-home-')); + const quiet = vi.spyOn(console, 'log').mockImplementation(() => {}); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const savedStdin = process.stdin.isTTY; + process.env.SQUAD_HOME_DIR_OVERRIDE = home; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + await runInit(dest, { includeWorkflows: false } as never); + const dir = path.join(dest, '.squad', 'casting'); + fs.writeFileSync( + path.join(dir, 'registry.json'), + JSON.stringify({ + universe: 'Star Trek', + agents: [ + { name: 'Data', role: 'Expert' }, + { name: 'Tuvok', role: 'Security', status: 'retired' }, + ], + }), + ); + + const beforeDryRun = snapshotTree(dest); + await expect(runUpgrade(dest, { dryRun: true })).resolves.toBeDefined(); + expect(snapshotTree(dest)).toEqual(beforeDryRun); + + await runUpgrade(dest, { force: true }); + const pair = readCastingRegistryPair(dir); + const agents = pair.registry!.agents as Record>; + expect(Object.keys(agents).sort()).toEqual(['data', 'tuvok']); + expect(agents['tuvok']!['status']).toBe('retired'); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.bak'))).toEqual([]); + } finally { + delete process.env.SQUAD_HOME_DIR_OVERRIDE; + (process.stdin as { isTTY?: boolean }).isTTY = savedStdin; + quiet.mockRestore(); + writeSpy.mockRestore(); + fs.rmSync(dest, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } + }, 120_000); + + it('runUpgrade(..., { dryRun: true }) still fails closed on unrepairable/corrupt registries', async () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-corrupt-')); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-home2-')); + const quiet = vi.spyOn(console, 'log').mockImplementation(() => {}); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const savedStdin = process.stdin.isTTY; + process.env.SQUAD_HOME_DIR_OVERRIDE = home; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + await runInit(dest, { includeWorkflows: false } as never); + const dir = path.join(dest, '.squad', 'casting'); + fs.writeFileSync(path.join(dir, 'registry.json'), '{ malformed json'); + const beforeTree = snapshotTree(dest); + + await expect(runUpgrade(dest, { dryRun: true })).rejects.toThrow(); + expect(snapshotTree(dest)).toEqual(beforeTree); + } finally { + delete process.env.SQUAD_HOME_DIR_OVERRIDE; + (process.stdin as { isTTY?: boolean }).isTTY = savedStdin; + quiet.mockRestore(); + writeSpy.mockRestore(); + fs.rmSync(dest, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } + }, 120_000); + + it('ensureCastingDefaults backfills an empty registry from team.md using the charter folder as the agent id', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-backfill-defaults-')); + try { + const squadDir = path.join(dest, '.squad'); + fs.mkdirSync(squadDir, { recursive: true }); + writeCharter(squadDir, 'data', 'Data', 'Framework Expert'); + writeCharter(squadDir, 'belanna', "B'Elanna", 'Durable Systems'); + const team = [ + '# Squad Team', + '', + '**Universe:** Star Trek', + '', + 'Notes: the universe: see casting. line above is the only declaration.', + '', + '## Coordinator', + '| Name | Role |', + '|---|---|', + '| Squad | Coordinator |', + '', + '## Members', + '', + '| Name | Role | Charter | Status |', + '|------|------|---------|--------|', + '| Data | Framework Expert | .squad/agents/data/charter.md | ✅ Active |', + "| B'Elanna | Durable Systems | .squad/agents/belanna/charter.md | ✅ Active |", + '| Pat | Human reviewer | - | human |', + ].join('\n'); + fs.writeFileSync(path.join(squadDir, 'team.md'), team); + expect(parseTeamRoster(team)).toEqual({ + universe: 'Star Trek', + members: [ + { name: 'Data', role: 'Framework Expert', charterId: 'data' }, + { name: "B'Elanna", role: 'Durable Systems', charterId: 'belanna' }, + ], + }); + + const created = ensureCastingDefaults(dest); + expect(created).toEqual(expect.arrayContaining(['.squad/casting/registry.json', '.squad/casting/history.json'])); + const dir = path.join(squadDir, 'casting'); + const pair = readCastingRegistryPair(dir); + expect(Object.keys(pair.registry!.agents as object).sort()).toEqual(['belanna', 'data']); + expect((pair.registry!.agents as Record>)['belanna']).toMatchObject({ + display_name: "B'Elanna", + role: 'Durable Systems', + universe: 'Star Trek', + }); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.bak'))).toEqual([]); + expect(ensureCastingDefaults(dest)).toEqual([]); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + + it('backfill resolves ids via charter Identity when the Charter column is absent, and fails closed when no folder can be proven', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-backfill-identity-')); + const warnings: string[] = []; + const quiet = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }); + try { + const squadDir = path.join(dest, '.squad'); + const dir = path.join(squadDir, 'casting'); + fs.mkdirSync(dir, { recursive: true }); + initEmptyCanonicalPair(dir); + writeCharter(squadDir, 'belanna', "B'Elanna", 'Engineer'); + const teamMdPath = path.join(squadDir, 'team.md'); + const header = ['# Team', '', '**Universe:** Star Trek', '', '## Members', '', '| Name | Role |', '|---|---|']; + fs.writeFileSync(teamMdPath, [...header, "| B'Elanna | Engineer |", '| Seven | Astrometrics |'].join('\n')); + + const beforeTree = snapshotTree(dir); + const blocked = backfillCastingRegistryFromTeam(dir, teamMdPath); + expect(blocked).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/could not be mapped to an agent folder/), + dropped: [{ name: 'Seven', reason: expect.stringMatching(/cannot be proven/) }], + }); + expect(snapshotTree(dir)).toEqual(beforeTree); + expect(fs.readdirSync(dir).filter((f) => ARCHIVE_RE.test(f))).toEqual([]); + + expect(ensureCastingDefaults(dest)).toEqual(expect.not.arrayContaining(['.squad/casting/registry.json'])); + expect(warnings.join('\n')).toMatch(/Could not backfill[\s\S]*Seven: .*cannot be proven/); + + writeCharter(squadDir, 'seven', 'Seven', 'Astrometrics'); + const ok = backfillCastingRegistryFromTeam(dir, teamMdPath); + expect(ok.repaired).toBe(true); + expect(Object.keys(readCastingRegistryPair(dir).registry!.agents as object).sort()).toEqual(['belanna', 'seven']); + } finally { + quiet.mockRestore(); + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + + it('backfill does not invent a universe when team.md does not declare one', () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-backfill-universe-')); + try { + const squadDir = path.join(dest, '.squad'); + const dir = path.join(squadDir, 'casting'); + fs.mkdirSync(dir, { recursive: true }); + initEmptyCanonicalPair(dir); + writeCharter(squadDir, 'neo', 'Neo', 'Lead'); + const teamMdPath = path.join(squadDir, 'team.md'); + fs.writeFileSync(teamMdPath, ['# Team', '', 'Our universe: see casting.', '', '## Members', '', '| Name | Role |', '|---|---|', '| Neo | Lead |'].join('\n')); + expect(parseTeamRoster(fs.readFileSync(teamMdPath, 'utf8')).universe).toBeUndefined(); + expect(backfillCastingRegistryFromTeam(dir, teamMdPath)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/does not declare a universe/), + }); + } finally { + fs.rmSync(dest, { recursive: true, force: true }); + } + }); + + it('runUpgrade(..., { dryRun: true }) previews the team.md backfill without writing', async () => { + const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-backfill-')); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-upgrade-dryrun-home3-')); + const logged: string[] = []; + const quiet = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(' ')); }); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const savedStdin = process.stdin.isTTY; + process.env.SQUAD_HOME_DIR_OVERRIDE = home; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + await runInit(dest, { includeWorkflows: false } as never); + const squadDir = path.join(dest, '.squad'); + writeCharter(squadDir, 'neo', 'Neo', 'Lead'); + fs.writeFileSync( + path.join(squadDir, 'team.md'), + ['# Team', '', '**Universe:** Matrix', '', '## Members', '', '| Name | Role | Charter |', '|---|---|---|', '| Neo | Lead | .squad/agents/neo/charter.md |'].join('\n'), + ); + const beforeDryRun = snapshotTree(dest); + await runUpgrade(dest, { dryRun: true }); + expect(snapshotTree(dest)).toEqual(beforeDryRun); + expect(logged.join('\n')).toMatch(/Would backfill \.squad\/casting\/registry\.json from \.squad\/team\.md/); + + await runUpgrade(dest, { force: true }); + expect(Object.keys(readCastingRegistryPair(path.join(squadDir, 'casting')).registry!.agents as object)).toEqual(['neo']); + } finally { + delete process.env.SQUAD_HOME_DIR_OVERRIDE; + (process.stdin as { isTTY?: boolean }).isTTY = savedStdin; + quiet.mockRestore(); + writeSpy.mockRestore(); + fs.rmSync(dest, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } + }, 120_000); + + it('isInteractiveSession requires TTYs on stdin/stdout and no CI or SQUAD_NONINTERACTIVE opt-out', () => { + expect(isInteractiveSession({}, { isTTY: true }, { isTTY: true })).toBe(true); + expect(isInteractiveSession({}, { isTTY: undefined }, { isTTY: true })).toBe(false); + expect(isInteractiveSession({}, { isTTY: true }, { isTTY: undefined })).toBe(false); + expect(isInteractiveSession({ CI: 'true' }, { isTTY: true }, { isTTY: true })).toBe(false); + expect(isInteractiveSession({ CI: '0' }, { isTTY: true }, { isTTY: true })).toBe(true); + expect(isInteractiveSession({ SQUAD_NONINTERACTIVE: '1' }, { isTTY: true }, { isTTY: true })).toBe(false); + }); + }); + + describe('PR #2252 review thread regressions', () => { + it('Thread 1: preserves existing valid map keys (e.g. agents.belanna named B\'Elanna with avatar) when repairing sibling entries', () => { + const belannaValid = { + display_name: "B'Elanna", + persistent_name: "B'Elanna", + role: 'Engineer', + universe: 'Star Trek', + status: 'active', + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-02-01T00:00:00.000Z', + avatar: { kind: 'repository-path', path: '.squad/agents/belanna/avatar.png' }, + }; + const result = normalizeHandwrittenCastingRegistry({ + universe: 'Star Trek', + agents: { + belanna: belannaValid, + data: { name: 'Data', role: 'Expert' }, + }, + }, '2026-10-20T00:00:00.000Z'); + expect(result.dropped).toEqual([]); + expect(result.registry).toBeDefined(); + expect(Object.keys(result.registry!.agents).sort()).toEqual(['belanna', 'data']); + expect(result.registry!.agents['belanna']).toEqual(belannaValid); + + // Also preserves `belanna` when the record itself is incomplete and carries an avatar under `.squad/agents/belanna/` + const incompleteBelanna = normalizeHandwrittenCastingRegistry({ + universe: 'Star Trek', + agents: { + belanna: { + name: "B'Elanna", + role: 'Engineer', + avatar: { kind: 'repository-path', path: '.squad/agents/belanna/avatar.png' }, + }, + }, + }, '2026-10-20T00:00:00.000Z'); + expect(incompleteBelanna.dropped).toEqual([]); + expect(Object.keys(incompleteBelanna.registry!.agents)).toEqual(['belanna']); + }); + + it('Thread 2: normalizes legacy-shaped retired records missing or having invalid retired_at instead of early-exiting', () => { + write('registry.json', { + agents: { + tuvok: { + persistent_name: 'Tuvok', + role: 'Security', + universe: 'Star Trek', + created_at: '2025-01-01T00:00:00.000Z', + status: 'retired', + }, + }, + }); + const result = repairHandwrittenCastingRegistry(castingDir, '2026-10-20T00:00:00.000Z'); + expect(result.repaired).toBe(true); + const pair = readCastingRegistryPair(castingDir); + const tuvok = (pair.registry!.agents as Record>)['tuvok']!; + expect(tuvok['status']).toBe('retired'); + expect(tuvok['retired_at']).toBe('2026-10-20T00:00:00.000Z'); + }); + + it('Thread 3: rejects snapshot agent maps where the value is unknown or disagrees with a recognized ID key', () => { + write('registry.json', { + universe: 'Star Trek', + agents: { + data: { name: 'Data', role: 'Lead' }, + tuvok: { name: 'Tuvok', role: 'Security' }, + }, + }); + write('history.json', { + universe_usage_history: [], + assignment_cast_snapshots: { + 'init-2026': { + universe: 'Star Trek', + agents: { data: 'Unknown Agent' }, + created_at: '2026-01-01T00:00:00.000Z', + }, + }, + }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/unknown agent "Unknown Agent"/), + }); + + write('history.json', { + universe_usage_history: [], + assignment_cast_snapshots: { + 'init-2026': { + universe: 'Star Trek', + agents: { data: 'Tuvok' }, + created_at: '2026-01-01T00:00:00.000Z', + }, + }, + }); + expect(repairHandwrittenCastingRegistry(castingDir)).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/snapshot key "data" disagrees with agent "Tuvok"/), + }); + }); + + it('Thread 4: refuses to recommit a mixed-generation pair when registry, history, and manifest metadata disagree', () => { + write('registry.json', shapes['agents as an array']); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(true); + const gen1 = readCastingRegistryPair(castingDir); + + // Reformat to trigger a legitimate recommit to revision 2 + fs.writeFileSync(path.join(castingDir, 'registry.json'), JSON.stringify(gen1.registry)); + expect(repairHandwrittenCastingRegistry(castingDir).repaired).toBe(true); + const gen2 = readCastingRegistryPair(castingDir); + expect(gen2.registry!['revision']).toBe(2); + + // Overwrite history.json with generation 1 while registry.json and manifest are at generation 2 + fs.writeFileSync(path.join(castingDir, 'history.json'), gen1.historyRaw!); + const mixedTree = snapshotTree(castingDir); + + const attempt = repairHandwrittenCastingRegistry(castingDir); + expect(attempt).toMatchObject({ + repaired: false, + reason: expect.stringMatching(/generation metadata do not agree/), + }); + expect(snapshotTree(castingDir)).toEqual(mixedTree); + }); + + it('Thread 5: rejects a symlinked handwritten-archive- directory before creating or writing archive files', () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-outside-archive-')); + try { + write('registry.json', shapes['bare array']); + // Discover the deterministic archive directory name via a dry inspection or temp run + const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-probe-archive-')); + let archiveName: string; + try { + fs.copyFileSync(path.join(castingDir, 'registry.json'), path.join(probeDir, 'registry.json')); + archiveName = repairHandwrittenCastingRegistry(probeDir).backupPath!; + } finally { + fs.rmSync(probeDir, { recursive: true, force: true }); + } + expect(archiveName).toMatch(ARCHIVE_RE); + + const archivePath = path.join(castingDir, archiveName); + try { + fs.symlinkSync(outsideDir, archivePath, process.platform === 'win32' ? 'junction' : 'dir'); + } catch { + return; + } + + expect(() => repairHandwrittenCastingRegistry(castingDir)).toThrow(/symlinked casting path/); + expect(fs.readdirSync(outsideDir)).toEqual([]); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + }); +});