diff --git a/.changeset/legacy-assignment-provenance.md b/.changeset/legacy-assignment-provenance.md new file mode 100644 index 000000000..804edf0eb --- /dev/null +++ b/.changeset/legacy-assignment-provenance.md @@ -0,0 +1,10 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +Support historical assigned_at casting timestamps with conflict detection and +preserved original dates. Provide actionable fail-closed recovery diagnostics +for missing universe evidence without inventing historical state. Convert +historical entries/assignments using unambiguous authoritative folder/charter +identities and explicit assignment membership, preserving timestamps and +byte-exact originals while rejecting missing or conflicting identity evidence. diff --git a/.changeset/legacy-casting-upgrade.md b/.changeset/legacy-casting-upgrade.md new file mode 100644 index 000000000..8cb474991 --- /dev/null +++ b/.changeset/legacy-casting-upgrade.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +Automatically migrate supported unversioned casting state before upgrading managed files, including already-current upgrades. Preserve identities and timestamps, archive original registry/history bytes without overwriting, and fail closed on unsupported or inconsistent state. Dry runs validate without mutating casting state. diff --git a/.changeset/portable-mcp-preserve-newlines.md b/.changeset/portable-mcp-preserve-newlines.md new file mode 100644 index 000000000..a409489e3 --- /dev/null +++ b/.changeset/portable-mcp-preserve-newlines.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Keep standalone squad_state MCP commands portable in shared project configuration while retaining machine-local launch resolution. Preserve existing destination newline style when upgrading managed text files and refreshing coordinator capabilities, avoiding newline-only rewrites. diff --git a/docs/src/content/docs/features/standalone-install.md b/docs/src/content/docs/features/standalone-install.md index a2178854e..6edea7a5f 100644 --- a/docs/src/content/docs/features/standalone-install.md +++ b/docs/src/content/docs/features/standalone-install.md @@ -173,16 +173,28 @@ inside the container write a registry-independent MCP spec. ## The squad_state MCP server `squad init` writes a `squad_state` MCP entry into `.mcp.json` so Copilot can -reach Squad's state tools. A bundle install writes the local executable directly: +reach Squad's state tools. Shared project configuration uses the installed +executable on each host's `PATH`, not the initiating user's installation path. +On macOS and Linux: ```json -{ "command": "/opt/squad/squad", "args": ["state-mcp"] } +{ "command": "squad", "args": ["state-mcp"] } ``` The launcher exports `SQUAD_STANDALONE_HOME`, and the resolver checks it *before* probing the npm registry, so a firewalled machine makes no registry -call at all. The path is absolute because Copilot spawns the MCP server in its -own environment, where neither `PATH` nor that variable is guaranteed. +call at all. On Windows, the shared entry uses `"command": "squad.exe"` so MCP +subprocess spawning selects the real executable rather than a PowerShell or CMD +shim. Each teammate must install Squad and expose that executable in the +**MCP host's PATH**; restart the host after changing PATH. Moving a project +between Windows and Unix requires rerunning `squad upgrade` to select the host's +portable executable name. Missing PATH setup fails visibly when the host starts +the server; no teammate's absolute installation path is written into the repo. + +Machine-local launch resolution still uses the detected absolute bundle path. +Npm installations keep their portable, version-pinned `npx` specification. +Upgrade preserves other MCP servers, user environment settings, tool +restrictions, and custom fields on the managed entry. ## Building a bundle yourself diff --git a/docs/src/content/docs/scenarios/upgrading.md b/docs/src/content/docs/scenarios/upgrading.md index 567b92978..56c44f9dd 100644 --- a/docs/src/content/docs/scenarios/upgrading.md +++ b/docs/src/content/docs/scenarios/upgrading.md @@ -1,6 +1,6 @@ # Upgrading Squad -Update Squad-owned files to the latest version without touching your team state. +Update Squad-owned files to the latest version without losing your team state. --- @@ -18,7 +18,7 @@ Squad detects your installed version, updates Squad-owned files, and runs any ne ✅ upgraded coordinator from 0.1.0 to 0.2.0 ✅ upgraded .squad/templates/ -.squad/ untouched — your team state is safe +Casting state validated — your team's identities and history are preserved Squad is upgraded. (v0.2.0) ``` @@ -35,11 +35,23 @@ That's it. | `.ai-team-templates/` | ✅ Yes | Overwritten with latest templates | | `.github/workflows/squad-*.yml` | ✅ Yes | Overwritten with latest squad workflows | | `.github/copilot-instructions.md` | ⚡ Conditional | Updated only if @copilot is enabled on the team | -| `.ai-team/` | ❌ Never | Your team's knowledge, decisions, casting state, skills | +| `.squad/` (or legacy `.ai-team/`) | ⚡ Conditional | Casting schema migrations and built-in file refreshes; team knowledge is preserved | Squad-owned files (`squad.agent.md` and `.ai-team-templates/`) are replaced entirely. Don't put custom changes in them — they'll be lost on upgrade. -Your team state in `.ai-team/` is never touched. Agent charters, histories, decisions, casting state, skills, and session logs are all safe. +Updates retain each existing destination's newline style, including CRLF on +Windows, across the coordinator, generated capability block, templates, +built-in skills, and workflows. A newline-only difference does not rewrite a +managed template; real content updates still apply. New files use shipped +template newlines. Upgrade does not change your repository's line-ending policy. + +Shared `.mcp.json` entries never contain a standalone installation's absolute +path. Packaged installs use `squad.exe` on Windows or `squad` on Unix from the +MCP host's PATH; npm installs retain their version-pinned `npx` entry. + +Agent identities, charters, histories, decisions, and session logs are preserved. +Supported legacy casting state is migrated as described below; built-in skills +may be refreshed as Squad-owned files. --- @@ -48,11 +60,94 @@ Your team state in `.ai-team/` is never touched. Agent charters, histories, deci Some upgrades require additive changes to your team state directory — like creating a new subdirectory that didn't exist in older versions. Migrations are: -- **Additive** — they only create new files or directories, never modify existing ones +- **Preserving** — directory migrations are additive; casting schema migrations + preserve original files in a non-overwriting archive - **Idempotent** — safe to re-run; if the change already exists, it's skipped Example: upgrading to v0.2.0 creates `.ai-team/skills/` if it doesn't already exist. +### Legacy casting state (upgrades to 1.x) + +`squad upgrade` validates casting state **before** replacing the coordinator, +skills, workflows, or other upgrade-managed files, even when the installed +coordinator already matches the CLI version. + +Supported unversioned registries have an `agents` object with persistent names, +universes, statuses, and creation dates. Roles default to the agent ID only when +absent; display names and update dates default to the persistent name and creation +date only when absent. Supported history contains assignment snapshots using +`members[].folder`, agent ID arrays, or role-ID-to-persistent-name maps, with +matching universe usage records using `created_at` or `used_at`. +Snapshots and usage records also accept the historical `assigned_at` timestamp. +If multiple recognized timestamp fields are present, each must be valid and +denote the same instant. The original timestamp string, including its timezone +offset, is retained; migration never substitutes an agent creation date for an +assignment date. + +The historical `entries[]` / `assignments[]` variant is also supported when +each registry entry's persistent name matches exactly one canonical folder's +`charter.md` under the team's `agents/` directory. The charter must contain an +`## Identity` section with `- **Name:**`; its optional `- **Role:**` supplies the +role, otherwise the canonical folder ID is used. Missing, duplicated, malformed, +or conflicting identities stop migration; names are never slugified into IDs. +Keep the original producer's charters with the state when testing this variant. +Each assignment's explicit persistent-name list supplies **only that historical +membership**, not today's roster. Its recorded universe and timestamp supply +the corresponding usage event. Missing universe or date evidence is never filled +from the current naming policy or agent creation dates. + +Migration preserves agent IDs, names, original dates (including timezone offsets), +assignment keys, and snapshot dates. Revision 1 and `generated_at` establish a +**new managed migration baseline**, not a reconstructed historical generation. +An agent created after an older assignment keeps its later creation date. + +The byte-exact originals, including repository paths and metadata not represented +by the current runtime schema, are retained at +`casting/legacy-archive-/registry.json` and `history.json`. +Archives are never overwritten. Treat them as private state: inspect them before +sharing or committing. Publication uses the shared writer lock and recoverable +registry/history commit transaction. Repeating migration preserves managed bytes. + +`squad upgrade --dry-run` validates and previews migration without creating locks, +archives, or transaction files. Unknown schemas, damaged files, missing pair +members, mismatched assignment evidence, or inconsistent managed transactions +stop the upgrade before managed files change. Back up the state and seek recovery +help; do not manually add `revision`, delete casting files, or rewrite dates. + +#### Incomplete legacy provenance + +Automatic migration refuses agents without a nonempty universe, snapshots with +a missing or `null` universe, and snapshots without matching historical usage. +Descriptive names alone do not prove a universe. A current coordinator's naming +policy does not prove the policy of an older assignment. Migration will not +invent a universe, usage entry, or historical membership. + +The historical `entries[]` / `assignments[]` pair requires recovery when original +folder/charter identities are missing or ambiguous. Persistent names are not +canonical folder IDs. Today's team roster is not evidence of which agents +belonged to an older assignment. Restore the original authoritative `charter.md` +files alongside the original casting pair; do not create charters from guessed +name-to-folder mappings. + +If either policy stops your upgrade: + +1. Keep the installed coordinator and both original casting files intact. Make + a private backup of the entire team state directory and coordinator before + attempting recovery. +2. Retrieve the original assignment records and producing coordinator from + version control or backups. For identity recovery, include original folder + IDs and each historical assignment's membership, not just today's roster. + For universe recovery, include recorded universe and usage evidence for each + assignment. Do not fill fields from guesses or create synthetic usage. +3. Request migration/recovery support with the producer version, schema shape, + and anonymized evidence. Do not publish private paths, state archives, or + team knowledge. If evidence is unavailable, keep using the installed version; + this refusal is **not** a completed migration. +4. After an evidence-backed recovery is available, test it on a disposable + backup using `squad upgrade --dry-run`, then upgrade and verify identities, + historical membership, dates, and byte-exact original archives before + applying it to the live team. + --- ## Migrating .ai-team/ → .squad/ (v0.5.0+) @@ -144,13 +239,15 @@ git add .github/agents/squad.agent.md .ai-team-templates/ git commit -m "Upgrade Squad to v0.2.0" ``` -No changes to `.ai-team/` — the diff is limited to Squad-owned files. +Review the upgrade diff, including any casting migration and original-file +archives, before committing. Do not share archives containing private metadata. --- ## Tips -- **Upgrade is safe.** It only overwrites files that Squad owns. Your team state is never modified. +- **Upgrade preserves your team.** It refreshes Squad-owned files and safely + migrates supported casting state with original-file archives. - **Don't customize `squad.agent.md`.** Any changes you make will be overwritten on the next upgrade. If you need custom behavior, use directives in `decisions.md` instead. - **Re-running upgrade is harmless.** If you're not sure whether an upgrade completed, run it again. It's idempotent. diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index 48d296804..d73ca9039 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -14,7 +14,7 @@ import { getPackageVersion, stampVersion } from './version.js'; import { initSquad as sdkInitSquad, cleanupOrphanInitPrompt, ensurePersonalSquadDir, resolveGlobalSquadPath, resolvePersonalSquadDir, clearResolveSquadCache, type InitOptions } from '@bradygaster/squad-sdk'; import { installGitHooks } from '../commands/install-hooks.js'; import { liftInitMutableStateOntoOrphan } from '../commands/migrate-backend.js'; -import { resolveSquadStateMcpSpec } from './mcp-spec.js'; +import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.js'; import { describeMcpSpec, ensureUserOwnedTemplates } from './upgrade.js'; import { getTemplatesDir } from './templates.js'; import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js'; @@ -451,7 +451,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // stale project-level entry left by the SDK init writer in // `.copilot/mcp-config.json`. No HOME modifications. try { - const mcpSpec = await resolveSquadStateMcpSpec(getPackageVersion()); + const mcpSpec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(getPackageVersion())); const rootResult = ensureSquadStateMcpInRoot(dest, getPackageVersion(), mcpSpec); if (rootResult.written) { success(`installed squad_state MCP server to .mcp.json (${describeMcpSpec(mcpSpec)}) — Copilot CLI will auto-load on next invocation`); @@ -474,7 +474,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // for vanilla `squad init` (no --state-backend flag) so the squad_state // MCP entry is reachable regardless of init path. No HOME modifications. try { - const mcpSpec = await resolveSquadStateMcpSpec(version); + const mcpSpec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(version)); const rootResult = ensureSquadStateMcpInRoot(dest, version, mcpSpec); if (rootResult.written) { success(`installed squad_state MCP server to .mcp.json (${describeMcpSpec(mcpSpec)}) — Copilot CLI will auto-load on next invocation`); diff --git a/packages/squad-cli/src/cli/core/managed-file.ts b/packages/squad-cli/src/cli/core/managed-file.ts new file mode 100644 index 000000000..1d2b19e48 --- /dev/null +++ b/packages/squad-cli/src/cli/core/managed-file.ts @@ -0,0 +1,41 @@ +import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { extname } from 'node:path'; + +const MANAGED_TEXT_EXTENSIONS = new Set([ + '.md', '.json', '.yml', '.yaml', '.js', '.mjs', '.cjs', '.ts', + '.ps1', '.sh', '.txt', '.template', +]); + +function readExisting(filePath: string): string | undefined { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +/** Keep destination newlines, and avoid rewriting a newline-only difference. */ +export function writeManagedText(filePath: string, content: string): boolean { + const existing = readExisting(filePath); + const normalize = (text: string) => text.replace(/\r\n?|\n/g, '\n'); + if (existing !== undefined && normalize(existing) === normalize(content)) return false; + const eol = existing?.match(/\r\n|\n|\r/)?.[0]; + const next = eol ? normalize(content).replace(/\n/g, eol) : content; + writeFileSync(filePath, next, 'utf8'); + return true; +} + +export function copyManagedFile(source: string, destination: string): void { + if (!MANAGED_TEXT_EXTENSIONS.has(extname(source).toLowerCase()) || !existsSync(destination)) { + copyFileSync(source, destination); + return; + } + const bytes = readFileSync(source); + const text = bytes.toString('utf8'); + if (bytes.includes(0) || !bytes.equals(Buffer.from(text, 'utf8'))) { + copyFileSync(source, destination); + return; + } + writeManagedText(destination, text); +} diff --git a/packages/squad-cli/src/cli/core/mcp-root.ts b/packages/squad-cli/src/cli/core/mcp-root.ts index e41f58030..554d711c6 100644 --- a/packages/squad-cli/src/cli/core/mcp-root.ts +++ b/packages/squad-cli/src/cli/core/mcp-root.ts @@ -22,14 +22,17 @@ * Safety: we refuse to overwrite a malformed `.mcp.json` rather than * silently clobber a user-edited file; other `mcpServers.*` entries are * preserved semantically through the JSON round-trip, and user-supplied - * `env` values on the `squad_state` entry survive re-runs. + * `env`, tool restrictions, and custom fields on the `squad_state` entry survive + * re-runs. Standalone launchers are published as PATH commands, never absolute + * machine-local installation paths. * * @module cli/core/mcp-root */ import path from 'node:path'; import { FSStorageProvider } from '@bradygaster/squad-sdk'; -import type { SquadStateMcpSpec } from './mcp-spec.js'; +import { projectSquadStateMcpSpec, type SquadStateMcpSpec } from './mcp-spec.js'; +import { writeManagedText } from './managed-file.js'; const storage = new FSStorageProvider(); @@ -63,7 +66,7 @@ export interface EnsureRootResult { * @param dest Squad project root (absolute or relative). * @param _cliVersion Reserved for forensic metadata (unused — Copilot * CLI does not currently surface extra fields). - * @param spec Pinned/insider command + args from + * @param spec Local standalone or pinned/insider command + args from * `resolveSquadStateMcpSpec`. */ export function ensureSquadStateMcpInRoot( @@ -97,23 +100,24 @@ export function ensureSquadStateMcpInRoot( } const existing = parsed.mcpServers[key]; + const projectSpec = projectSquadStateMcpSpec(spec); - // `command`/`args` are Squad-managed (they carry the version pin) and are + // `command`/`args` are Squad-managed (version pin or portable launcher) and are // always refreshed. `env` is user territory — Squad writes no keys into it, // so anything already there (corporate npm proxy settings, for example) is // carried forward rather than reset. See bradygaster/squad#1893. const hasUsableEnv = !!existing?.env && typeof existing.env === 'object' && !Array.isArray(existing.env); const desired: McpServerEntry = { - command: spec.command, - args: [...spec.args], + ...existing, + command: projectSpec.command, + args: [...projectSpec.args], env: hasUsableEnv ? { ...existing!.env } : {}, - tools: ['*'], + tools: Array.isArray(existing?.tools) && existing.tools.every(tool => typeof tool === 'string') + ? [...existing.tools] : ['*'], }; - // `desired.env` is a verbatim copy of `existing.env` whenever the latter is a - // usable object, so `hasUsableEnv` is the whole env comparison. A malformed - // `env` (array/string/null) is normalized to `{}` above and must be rewritten. + // User-owned fields are carried forward; only launch command/args are managed. if ( existing && existing.command === desired.command && @@ -122,15 +126,15 @@ export function ensureSquadStateMcpInRoot( existing.args.every((a, i) => a === desired.args![i]) && hasUsableEnv && Array.isArray(existing.tools) && - existing.tools.length === 1 && - existing.tools[0] === '*' + existing.tools.length === desired.tools!.length && + existing.tools.every((tool, index) => tool === desired.tools![index]) ) { return { written: false, key, path: cfgPath }; } parsed.mcpServers[key] = desired; - storage.writeSync(cfgPath, JSON.stringify(parsed, null, 2) + '\n'); + writeManagedText(cfgPath, JSON.stringify(parsed, null, 2) + '\n'); return { written: true, key, path: cfgPath }; } @@ -168,7 +172,7 @@ export function tombstoneStaleSquadStateInProjectMcp(dest: string): TombstoneRes } delete config.mcpServers.squad_state; - storage.writeSync(cfgPath, JSON.stringify(config, null, 2) + '\n'); + writeManagedText(cfgPath, JSON.stringify(config, null, 2) + '\n'); return { removed: true, path: cfgPath }; } diff --git a/packages/squad-cli/src/cli/core/mcp-spec.ts b/packages/squad-cli/src/cli/core/mcp-spec.ts index e95a07476..d1a755e3c 100644 --- a/packages/squad-cli/src/cli/core/mcp-spec.ts +++ b/packages/squad-cli/src/cli/core/mcp-spec.ts @@ -7,7 +7,9 @@ * Resolution order: * 0. If Squad is running from a standalone bundle (`SQUAD_STANDALONE_HOME`, * exported by the bundle launcher) → spawn that bundle's launcher - * directly with an absolute path. This tier short-circuits *before* any + * directly with an absolute path for machine-local use. Project config + * writers convert this to the host's portable PATH command through + * `projectSquadStateMcpSpec`. This tier short-circuits *before* any * registry probe, so a machine that cannot reach registry.npmjs.org * never makes a network call here and never writes an `npx` spec it * cannot execute later (#1593). @@ -30,7 +32,7 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; export interface SquadStateMcpSpec { - /** Executable to spawn — `npx`, or an absolute launcher path when bundled. */ + /** `npx`, a local absolute launcher, or a portable project PATH executable. */ command: string; /** Argv for the executable. */ args: string[]; @@ -40,6 +42,16 @@ export interface SquadStateMcpSpec { const PACKAGE_NAME = '@bradygaster/squad-cli'; +/** Shared configs must resolve the installed CLI on each host, not this host. */ +export function projectSquadStateMcpSpec( + spec: SquadStateMcpSpec, + platform: NodeJS.Platform = process.platform, +): SquadStateMcpSpec { + return spec.source === 'standalone' + ? { command: platform === 'win32' ? 'squad.exe' : 'squad', args: ['state-mcp'], source: 'standalone' } + : spec; +} + /** * Environment variable exported by the standalone bundle launchers * (`squad`, `squad.cmd`, `squad.ps1`) pointing at the bundle root. @@ -70,9 +82,9 @@ export function _resetMcpSpecCache(): void { * Locate the launcher of the standalone bundle this CLI is running from. * * Returns an absolute path, or null when not running from a bundle. The - * absolute path matters: Copilot spawns the MCP server itself, in an - * environment that will not necessarily have the bundle on PATH or - * `SQUAD_STANDALONE_HOME` set, so the written spec has to stand alone. + * absolute path supports machine-local launch contexts without PATH or + * `SQUAD_STANDALONE_HOME`. Shared project config uses the portable mapper + * instead; each MCP host must expose its own installed Squad on PATH. */ export function detectStandaloneLauncher(): string | null { const home = process.env[STANDALONE_HOME_ENV]; diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index 288b4e81d..b3e38b2bb 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -8,7 +8,7 @@ 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 } from '@bradygaster/squad-sdk/casting'; +import { ensureCastingRegistryPair, preflightCastingRegistryPair } 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'; @@ -16,7 +16,8 @@ import { TEMPLATE_MANIFEST, getTemplatesDir } from './templates.js'; import { runMigrations } from './migrations.js'; import { scrubEmails } from './email-scrub.js'; import { getPackageVersion, stampVersion, readInstalledVersion } from './version.js'; -import { resolveSquadStateMcpSpec, type SquadStateMcpSpec } from './mcp-spec.js'; +import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec, type SquadStateMcpSpec } from './mcp-spec.js'; +import { copyManagedFile, writeManagedText } from './managed-file.js'; export { resolveSquadStateMcpSpec } from './mcp-spec.js'; import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js'; @@ -141,7 +142,7 @@ function copyDirRecursive(src: string, dest: string, force = true): void { if (storage.isDirectorySync(srcEntry)) { copyDirRecursive(srcEntry, destEntry, force); } else if (force || !storage.existsSync(destEntry)) { - storage.copySync(srcEntry, destEntry); + copyManagedFile(srcEntry, destEntry); } } } @@ -247,13 +248,13 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str // Back up locally-customized squad.agent.md before overwriting (#1052) if (storage.existsSync(agentDest)) { - const existing = (storage.readSync(agentDest) ?? '').replace(/\r\n/g, '\n'); + const existing = storage.readSync(agentDest) ?? ''; // Strip the version stamp before comparing (version line changes every upgrade) const stripVersion = (s: string) => s.replace(/\n?/g, ''); // Strip the generated Team Capabilities block too (#1608) — it is machine // written on every cast change and must not read as a user customization. const normalize = (s: string) => stripTeamCapabilitiesBlock(stripVersion(s)); - const normalizedExisting = normalize(existing); + const normalizedExisting = normalize(existing.replace(/\r\n/g, '\n')); const normalizedTemplate = normalize(agentContent.replace(/\r\n/g, '\n')); if (normalizedExisting !== normalizedTemplate && normalizedExisting.trim().length > 0) { @@ -274,7 +275,7 @@ function writeAgentTemplate(agentSrc: string, agentDest: string, cliVersion: str return; } - storage.writeSync(agentDest, agentContent); + writeManagedText(agentDest, agentContent); stampVersion(agentDest, cliVersion); // Re-advertise the current cast to outer coordinators (#1608). An upgrade @@ -497,12 +498,12 @@ function writeWorkflowFile(file: string, srcPath: string, destPath: string, proj const stub = generateProjectWorkflowStub(file, projectType); if (stub) { backupIfCustomized(stub); - storage.writeSync(destPath, stub); + writeManagedText(destPath, stub); return; } } backupIfCustomized(storage.readSync(srcPath) ?? ''); - storage.copySync(srcPath, destPath); + copyManagedFile(srcPath, destPath); } /* ── Infrastructure ensure functions ────────────────────────────── */ @@ -552,7 +553,7 @@ export function ensureGitattributes(dest: string): string[] { if (added.length > 0) { const suffix = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; try { - storage.writeSync(filePath, content + suffix + added.join('\n') + '\n'); + writeManagedText(filePath, content + suffix + added.join('\n') + '\n'); } catch (err: unknown) { if (err instanceof Error && 'code' in err && ['EPERM', 'EACCES'].includes((err as NodeJS.ErrnoException).code ?? '')) { warn('Could not update .gitattributes (read-only). Add merge=union entries manually.'); @@ -599,7 +600,7 @@ export function ensureGitignore(dest: string): string[] { } if (added.length > 0) { const suffix = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; - storage.writeSync(filePath, content + suffix + added.join('\n') + '\n'); + writeManagedText(filePath, content + suffix + added.join('\n') + '\n'); } return added; } @@ -789,7 +790,7 @@ function syncAllSkills(dest: string, templatesDir: string): number { warnIfSkillCustomized(srcPath, destPath, entry.source); storage.mkdirSync(path.dirname(destPath), { recursive: true }); - storage.copySync(srcPath, destPath); + copyManagedFile(srcPath, destPath); synced++; } return synced; @@ -810,7 +811,7 @@ function refreshSquadTemplatesDir(dest: string, templatesDir: string): void { if (storage.isDirectorySync(srcPath)) { copyDirRecursive(srcPath, destPath); } else { - storage.copySync(srcPath, destPath); + copyManagedFile(srcPath, destPath); } } } @@ -938,7 +939,7 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated: // git root looking for .mcp.json) and tombstone any stale project-level // entry left by older Squad versions in `.copilot/mcp-config.json`. // No HOME modifications. - const pinnedSpec = await resolveSquadStateMcpSpec(getPackageVersion()); + const pinnedSpec = projectSquadStateMcpSpec(await resolveSquadStateMcpSpec(getPackageVersion())); try { const rootResult = ensureSquadStateMcpInRoot(dest, getPackageVersion(), pinnedSpec); if (rootResult.written) { @@ -1188,7 +1189,7 @@ export function ensureUserOwnedTemplates(dest: string, templatesDir: string): st if (storage.existsSync(destPath)) continue; storage.mkdirSync(path.dirname(destPath), { recursive: true }); - storage.copySync(srcPath, destPath); + copyManagedFile(srcPath, destPath); created.push(entry.destination); } return created; @@ -1226,6 +1227,16 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr const projectType = detectProjectType(dest); + const castingMigrated = preflightCastingRegistryPair( + path.join(squadDirInfo.path, 'casting'), + options.dryRun ?? false, + ); + if (castingMigrated) { + info(options.dryRun + ? 'Would migrate legacy casting state to a managed registry/history pair.' + : 'Migrated legacy casting state to a managed registry/history pair.'); + } + // --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`); @@ -1320,7 +1331,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr warnIfSkillCustomized(srcPath, destPath, file.source); } storage.mkdirSync(path.dirname(destPath), { recursive: true }); - storage.copySync(srcPath, destPath); + copyManagedFile(srcPath, destPath); filesUpdated.push(file.destination); } @@ -1359,7 +1370,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr if (copilotEnabled && storage.existsSync(copilotInstructionsSrc)) { storage.mkdirSync(path.dirname(copilotInstructionsDest), { recursive: true }); - storage.copySync(copilotInstructionsSrc, copilotInstructionsDest); + copyManagedFile(copilotInstructionsSrc, copilotInstructionsDest); success('upgraded .github/copilot-instructions.md'); filesUpdated.push('copilot-instructions.md'); } diff --git a/packages/squad-sdk/src/casting/durable-registry.ts b/packages/squad-sdk/src/casting/durable-registry.ts index 339db9f6e..bfaebe97f 100644 --- a/packages/squad-sdk/src/casting/durable-registry.ts +++ b/packages/squad-sdk/src/casting/durable-registry.ts @@ -41,6 +41,9 @@ export type CastingDurabilityBoundary = | 'payload:history-fsync' | 'payload:dir-fsync' | 'payload:parent-fsync' + | 'archive:write' + | 'archive:file-fsync' + | 'archive:parent-fsync' | 'journal:write' | 'journal:file-fsync' | 'journal:rename' @@ -1363,6 +1366,365 @@ export function commitCastingRegistryPair( } } +function legacyObject(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Cannot migrate legacy casting: ${label} must be an object`); + } + return value as Record; +} + +function legacyTimestamp(value: unknown, label: string): string { + if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { + throw new Error(`Cannot migrate legacy casting: ${label} is not a timestamp`); + } + return value; +} + +function legacyEventTimestamp( + record: Record, + label: string, + primary: 'created_at' | 'used_at', +): string { + const timestamps = [primary, ...['created_at', 'used_at', 'assigned_at'].filter(field => field !== primary)] + .filter(field => Object.hasOwn(record, field)) + .map(field => legacyTimestamp(record[field], `${label} ${field}`)); + if (timestamps.length === 0) { + throw new Error(`Cannot migrate legacy casting: ${label} lacks a recognized timestamp`); + } + const timestamp = timestamps[0]!; + if (timestamps.some(value => Date.parse(value) !== Date.parse(timestamp))) { + throw new Error(`Cannot migrate legacy casting: ${label} timestamps disagree`); + } + return timestamp; +} + +function requireLegacyUniverse(record: Record, label: string): void { + if (typeof record['universe'] !== 'string' || record['universe'].length === 0) { + throw new Error( + `Cannot migrate legacy casting: ${label} lacks authoritative universe evidence. ` + + 'No universe or historical usage will be invented. Preserve both casting files and recover ' + + 'the original assignment evidence from backups or the producing coordinator; see the ' + + '"Incomplete legacy provenance" section of the upgrading guide.', + ); + } +} + +function rejectLegacyGenerationMetadata(record: Record): void { + if (['schema', 'schema_version', 'revision', 'registry_revision', 'transaction_id', 'generated_at'] + .some(key => Object.hasOwn(record, key))) { + throw new Error('Cannot migrate legacy casting: unsupported generation metadata'); + } +} + +function legacyIdentityError(): Error { + return new Error( + 'Cannot migrate legacy casting: entries/assignments requires unambiguous authoritative historical ' + + 'identity mapping. Each persistent name must match exactly one canonical agent folder with ' + + 'an Identity section containing Name in charter.md. Persistent names are not canonical ' + + 'agent IDs; the current team roster cannot establish historical membership. Preserve both files ' + + 'and recover original assignment/folder/charter evidence from the producing coordinator or backups; ' + + 'see the "Incomplete legacy provenance" section of the upgrading guide.', + ); +} + +function convertLegacyEntries( + castingDir: string, + entries: unknown[], + assignments: unknown[], +): { registry: Record; history: Record } { + const agentsDir = path.join(path.dirname(castingDir), 'agents'); + const identities = new Map(); + if (!existsSync(agentsDir)) throw legacyIdentityError(); + for (const folder of readdirSync(agentsDir, { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const charter = readText(path.join(agentsDir, folder.name, 'charter.md')); + if (charter === undefined) continue; + const sections = [...charter.matchAll(/^## Identity[ \t]*\r?\n([\s\S]*?)(?=^##[ \t]|(?![\s\S]))/gm)]; + for (const section of sections) { + const names = [...section[1]!.matchAll(/^- \*\*Name:\*\*[ \t]+([^\r\n]+)\r?$/gm)]; + const roles = [...section[1]!.matchAll(/^- \*\*Role:\*\*[ \t]+([^\r\n]+)\r?$/gm)]; + for (const nameField of names) { + const name = nameField[1]!.trim(); + const matches = identities.get(name) ?? []; + // Retain invalid or duplicate evidence so it cannot silently select a different folder. + matches.push({ + id: folder.name, + role: sections.length !== 1 || names.length !== 1 || roles.length > 1 + ? '' : roles.length === 0 ? folder.name : roles[0]![1]!.trim(), + }); + identities.set(name, matches); + } + } + } + const agents: Record> = Object.create(null); + const ids = new Map(); + for (const value of entries) { + const entry = legacyObject(value, 'entry'); + rejectLegacyGenerationMetadata(entry); + requireLegacyUniverse(entry, 'entry'); + const name = entry['persistent_name']; + if (typeof name !== 'string' || ids.has(name)) throw legacyIdentityError(); + const matches = identities.get(name); + if (matches?.length !== 1) throw legacyIdentityError(); + const { id, role } = matches[0]!; + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || !role || Object.hasOwn(agents, id)) { + throw legacyIdentityError(); + } + if (['id', 'folder'].some(field => entry[field] !== undefined && entry[field] !== id) + || (entry['role'] !== undefined && entry['role'] !== role)) { + throw legacyIdentityError(); + } + ids.set(name, id); + agents[id] = { ...entry, role }; + } + const snapshots: Record> = Object.create(null); + const usage: Record[] = []; + for (const value of assignments) { + const assignment = legacyObject(value, 'assignment'); + rejectLegacyGenerationMetadata(assignment); + requireLegacyUniverse(assignment, 'assignment'); + const key = assignment['assignment_id']; + if (typeof key !== 'string' || !key || Object.hasOwn(snapshots, key) + || !Array.isArray(assignment['agents'])) { + throw new Error('Cannot migrate legacy casting: assignment identity or membership is malformed'); + } + const members = assignment['agents'].map((name: unknown) => { + if (typeof name !== 'string' || !ids.has(name)) throw legacyIdentityError(); + return ids.get(name)!; + }); + if (new Set(members).size !== members.length) throw legacyIdentityError(); + const timestamp = legacyEventTimestamp(assignment, 'assignment', 'created_at'); + // These assignments explicitly record both the selected universe and its use date. + snapshots[key] = { agents: members, universe: assignment['universe'], created_at: timestamp }; + usage.push({ universe: assignment['universe'], used_at: timestamp, assignment_id: key }); + } + return { registry: { agents }, history: { + assignment_cast_snapshots: snapshots, universe_usage_history: usage, + } }; +} + +function convertHistoricalCastingPair( + registry: Record, + history: Record, + castingDir: string, +): { registry: Record; history: Record } { + if (Object.hasOwn(registry, 'entries')) { + if ( + Object.keys(registry).length !== 1 + || !Array.isArray(registry['entries']) + || Object.keys(history).length !== 1 + || !Array.isArray(history['assignments']) + ) { + throw new Error('Cannot migrate legacy casting: unsupported entries/assignments schema'); + } + const converted = convertLegacyEntries(castingDir, registry['entries'], history['assignments']); + return convertHistoricalCastingPair(converted.registry, converted.history, castingDir); + } + if ( + Object.keys(registry).some(key => key !== 'agents') + || Object.keys(history).some(key => ![ + 'assignment_cast_snapshots', 'universe_usage_history', + ].includes(key)) + ) { + throw new Error('Cannot migrate legacy casting: unsupported schema or generation metadata'); + } + const agents: Record> = Object.create(null); + for (const [id, value] of Object.entries(legacyObject(registry['agents'], 'agents'))) { + const agent = legacyObject(value, `agent ${id}`); + rejectLegacyGenerationMetadata(agent); + requireLegacyUniverse(agent, `agent ${id}`); + if (agent['legacy_named'] !== undefined && typeof agent['legacy_named'] !== 'boolean') { + throw new Error(`Cannot migrate legacy casting: agent ${id} has invalid legacy_named`); + } + agents[id] = { + ...agent, + display_name: agent['display_name'] === undefined ? agent['persistent_name'] : agent['display_name'], + role: agent['role'] === undefined ? id : agent['role'], + updated_at: agent['updated_at'] === undefined ? agent['created_at'] : agent['updated_at'], + }; + } + const nextRegistry = { + schema: 'squad-agent-provenance/v1', + schema_version: 1, + // A new managed baseline, not a claim about historical casting generations. + revision: 1, + generated_at: new Date(now()).toISOString(), + agents, + }; + validateRegistryShape(nextRegistry); + + const snapshots: Record> = Object.create(null); + for (const [key, value] of Object.entries( + legacyObject(history['assignment_cast_snapshots'], 'snapshots'), + )) { + const snapshot = legacyObject(value, `snapshot ${key}`); + rejectLegacyGenerationMetadata(snapshot); + requireLegacyUniverse(snapshot, `snapshot ${key}`); + const ids: string[] = []; + if (snapshot['members'] !== undefined && snapshot['agents'] === undefined) { + if (!Array.isArray(snapshot['members'])) { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} members are malformed`); + } + for (const value of snapshot['members']) { + const member = legacyObject(value, `snapshot ${key} member`); + const id = member['folder']; + if ( + typeof id !== 'string' || !Object.hasOwn(agents, id) + || member['persistent_name'] !== agents[id]!['persistent_name'] + || member['role'] !== agents[id]!['role'] + ) { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} member identity is inconsistent`); + } + ids.push(id); + } + } else if (snapshot['agents'] !== undefined && snapshot['members'] === undefined) { + if (Array.isArray(snapshot['agents'])) { + for (const id of snapshot['agents']) { + if (typeof id !== 'string' || !Object.hasOwn(agents, id)) { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} references unknown agents`); + } + ids.push(id); + } + } else { + for (const [id, name] of Object.entries(legacyObject(snapshot['agents'], `snapshot ${key} agents`))) { + if (!Object.hasOwn(agents, id) || name !== agents[id]!['persistent_name']) { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} agent identity is inconsistent`); + } + ids.push(id); + } + } + } else { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} has ambiguous agent references`); + } + if (!key || new Set(ids).size !== ids.length) { + throw new Error(`Cannot migrate legacy casting: snapshot ${key} has duplicate or empty identity`); + } + snapshots[key] = { + agents: ids, + universe: snapshot['universe'], + created_at: legacyEventTimestamp(snapshot, `snapshot ${key}`, 'created_at'), + }; + } + if (!Array.isArray(history['universe_usage_history'])) { + throw new Error('Cannot migrate legacy casting: usage history must be an array'); + } + const unmatched = new Set(Object.keys(snapshots)); + const usage = history['universe_usage_history'].map((value: unknown) => { + const record = legacyObject(value, 'usage record'); + rejectLegacyGenerationMetadata(record); + requireLegacyUniverse(record, 'usage record'); + const usedAt = legacyEventTimestamp(record, 'usage', 'used_at'); + const candidates = [...unmatched].filter(key => { + const snapshot = snapshots[key]!; + const original = legacyObject( + (history['assignment_cast_snapshots'] as Record)[key], + `snapshot ${key}`, + ); + return ( + (record['assignment_id'] === undefined || record['assignment_id'] === key) + && snapshot['universe'] === record['universe'] + && Date.parse(String(snapshot['created_at'])) === Date.parse(usedAt) + && ['repository', 'team_root'].every(field => ( + record[field] === undefined && original[field] === undefined + ) || record[field] === original[field]) + ); + }); + if (candidates.length !== 1) { + throw new Error('Cannot migrate legacy casting: usage and snapshot evidence is inconsistent or ambiguous'); + } + unmatched.delete(candidates[0]!); + return { universe: record['universe'], used_at: usedAt }; + }); + if (unmatched.size !== 0) { + throw new Error('Cannot migrate legacy casting: snapshots lack matching usage evidence'); + } + const nextHistory = { assignment_cast_snapshots: snapshots, universe_usage_history: usage }; + validateCastingRegistryPairForCommit(nextRegistry, nextHistory, 1); + return { registry: nextRegistry, history: nextHistory }; +} + +function migrateHistoricalCastingPairLocked(castingDir: string, dryRun: boolean): boolean { + const { registryPath, historyPath, journalPath, manifestPath } = transactionPaths(castingDir); + const registryRaw = readText(registryPath); + const historyRaw = readText(historyPath); + if (registryRaw === undefined && historyRaw === undefined) { + if (readText(journalPath) !== undefined || readText(manifestPath) !== undefined) { + throw new Error('Cannot migrate casting: transaction metadata exists without a pair'); + } + return false; + } + if ((registryRaw === undefined) !== (historyRaw === undefined)) { + throw new Error('Cannot initialize casting registry/history: exactly one authoritative file exists'); + } + const registry = parseObject(registryRaw, 'casting/registry.json')!; + const history = parseObject(historyRaw, 'casting/history.json')!; + if ( + Object.keys(registry).length !== 1 + || (!Object.hasOwn(registry, 'agents') && !Object.hasOwn(registry, 'entries')) + || readText(journalPath) !== undefined || readText(manifestPath) !== undefined + ) { + const snapshot = validateCastingRegistryPairRaw( + registryRaw, historyRaw, readText(journalPath), readText(manifestPath), + ); + if (snapshot.transactionId) return false; + if (!dryRun) { + commitCastingRegistryPair( + castingDir, registryRaw, registry, historyRaw, history, registry['revision'] as number, + ); + } + return true; + } + if ( + !readFileSync(registryPath).equals(Buffer.from(registryRaw!, 'utf8')) + || !readFileSync(historyPath).equals(Buffer.from(historyRaw!, 'utf8')) + ) { + throw new Error('Cannot migrate legacy casting: originals are not valid UTF-8'); + } + const converted = convertHistoricalCastingPair(registry, history, castingDir); + if (dryRun) return true; + + const archive = path.join(castingDir, `legacy-archive-${sha256(registryRaw + '\u0000' + historyRaw)}`); + mkdirSync(archive, { recursive: true }); + const originals: [string, string][] = [['registry.json', registryRaw!], ['history.json', historyRaw!]]; + for (const [name, raw] of originals) { + const archivePath = path.join(archive, name); + const existing = readText(archivePath); + if (existing !== undefined && existing !== raw) { + throw new Error(`Cannot migrate casting: original archive differs at ${archivePath}`); + } + if (existing === undefined) { + writeDurableFile(archivePath, raw, 'archive:write', 'archive:file-fsync'); + } else { + // A previous attempt may have stopped between archive write and fsync. + const descriptor = openSync(archivePath, 'r+'); + try { + boundary('archive:file-fsync', archivePath); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + } + } + boundary('archive:parent-fsync', archive); + flushDirectory(archive); + flushDirectory(castingDir); + commitCastingRegistryPair(castingDir, registryRaw, converted.registry, historyRaw, converted.history, 1); + return true; +} + +/** Validate before upgrade-owned writes; dry runs neither lock nor recover nor archive. */ +export function preflightCastingRegistryPair(castingDir: string, dryRun = false): boolean { + if (dryRun) return migrateHistoricalCastingPairLocked(castingDir, true); + if (!existsSync(castingDir)) return false; + const release = acquireCastingRegistryLock(castingDir, 'CLI upgrade casting preflight'); + try { + recoverCastingRegistryTransaction(castingDir); + return migrateHistoricalCastingPairLocked(castingDir, false); + } finally { + release(); + } +} + /** * Create a missing pair or migrate a valid generationless legacy pair while * the caller holds the shared casting writer lock. @@ -1402,23 +1764,11 @@ export function ensureCastingRegistryPairLocked( }; } - const snapshot = readCastingRegistryPair(castingDir); - if (snapshot.transactionId) { - return { snapshot, created: false, migrated: false }; - } - const revision = validatePairRoots(snapshot.registry, snapshot.history, false); - commitCastingRegistryPair( - castingDir, - snapshot.registryRaw, - snapshot.registry!, - snapshot.historyRaw, - snapshot.history!, - revision, - ); + const migrated = migrateHistoricalCastingPairLocked(castingDir, false); return { snapshot: readCastingRegistryPair(castingDir), created: false, - migrated: true, + migrated, }; } diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index 031eb0e18..93b70b254 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -61,6 +61,7 @@ export { ensureCastingRegistryPair, ensureCastingRegistryPairLocked, prepareCastingRegistryPairLocked, + preflightCastingRegistryPair, readCastingRegistryPair, recoverCastingRegistryTransaction, validateCastingRegistryPairForCommit, diff --git a/packages/squad-sdk/src/config/team-capabilities.ts b/packages/squad-sdk/src/config/team-capabilities.ts index 1f650dd4d..46e96af17 100644 --- a/packages/squad-sdk/src/config/team-capabilities.ts +++ b/packages/squad-sdk/src/config/team-capabilities.ts @@ -755,6 +755,7 @@ export function syncTeamCapabilities( return { updated: false, profile }; } - storage.writeSync(options.agentFile, next); + const eol = existing.match(/\r\n|\n|\r/)?.[0] ?? '\n'; + storage.writeSync(options.agentFile, next.replace(/\n/g, eol)); return { updated: true, profile }; } diff --git a/test/casting-legacy-migration.test.ts b/test/casting-legacy-migration.test.ts new file mode 100644 index 000000000..4cb4321ed --- /dev/null +++ b/test/casting-legacy-migration.test.ts @@ -0,0 +1,489 @@ +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + _setCastingDurabilityHooksForTesting, + CastingCommitInDoubtError, + ensureCastingRegistryPair, + preflightCastingRegistryPair, + readCastingRegistryPair, + validateCastingRegistryPairRaw, +} from '../packages/squad-sdk/src/casting/durable-registry.js'; + +const roots: string[] = []; +const registryRaw = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-legacy', 'registry.json'), 'utf8'); +const historyRaw = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-legacy', 'history.json'), 'utf8'); + +function fixture() { + const registry: { agents: Record>; [key: string]: unknown } = JSON.parse(registryRaw); + const history: { + assignment_cast_snapshots: Record>; + universe_usage_history: Record[]; + [key: string]: unknown; + } = JSON.parse(historyRaw); + return { registry, history }; +} + +function castingDir(): string { + const root = mkdtempSync(join(process.cwd(), '.test-legacy-migration-')); + roots.push(root); + const dir = join(root, 'casting'); + mkdirSync(dir); + writeFileSync(join(dir, 'registry.json'), registryRaw); + writeFileSync(join(dir, 'history.json'), historyRaw); + return dir; +} + +function entriesFixture(dir: string, crlf = false) { + const fixtureDir = join(process.cwd(), 'test', 'fixtures', 'casting-entries'); + const registry = JSON.parse(readFileSync(join(fixtureDir, 'registry.json'), 'utf8')); + const history = JSON.parse(readFileSync(join(fixtureDir, 'history.json'), 'utf8')); + const identities: Record = JSON.parse(readFileSync(join(fixtureDir, 'identities.json'), 'utf8')); + for (const file of ['registry.json', 'history.json']) { + writeFileSync(join(dir, file), readFileSync(join(fixtureDir, file))); + } + for (const [id, charter] of Object.entries(identities)) { + const folder = join(dir, '..', 'agents', id); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, 'charter.md'), crlf ? charter.replace(/\n/g, '\r\n') : charter); + } + return { registry, history, identities }; +} + +afterEach(() => { + _setCastingDurabilityHooksForTesting(null); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('historical casting migration', () => { + it.each([false, true])('maps anonymized native entries using authoritative charters (CRLF=%s), not name slugs or today’s roster', crlf => { + const dir = castingDir(); + const source = entriesFixture(dir, crlf); + writeFileSync(join(dir, '..', 'team.md'), 'A misleading current roster with no historical membership.\n'); + const originals = ['registry.json', 'history.json'].map(file => readFileSync(join(dir, file))); + expect(preflightCastingRegistryPair(dir, true)).toBe(true); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + expect(readFileSync(join(dir, 'registry.json'))).toEqual(originals[0]); + expect(readFileSync(join(dir, 'history.json'))).toEqual(originals[1]); + expect(preflightCastingRegistryPair(dir)).toBe(true); + const pair = readCastingRegistryPair(dir); + const agents = pair.registry!.agents as Record>; + expect(Object.keys(agents)).toEqual(Object.keys(source.identities)); + for (const [index, id] of Object.keys(source.identities).entries()) { + expect(agents[id]).toMatchObject({ + ...source.registry.entries[index], + role: index < 12 ? `Example Role ${String(index + 1).padStart(2, '0')}` : id, + updated_at: source.registry.entries[index].created_at, + }); + } + expect(pair.history).toMatchObject({ + assignment_cast_snapshots: { + 'example-onboarding-001': { + agents: Object.keys(source.identities).slice(0, 12), + universe: 'descriptive', created_at: '2024-02-03', + }, + }, + universe_usage_history: [{ universe: 'descriptive', used_at: '2024-02-03' }], + }); + const archive = readdirSync(dir).find(name => name.startsWith('legacy-archive-'))!; + for (const [index, file] of ['registry.json', 'history.json'].entries()) { + expect(readFileSync(join(dir, archive, file))).toEqual(originals[index]); + } + expect(preflightCastingRegistryPair(dir)).toBe(false); + expect(readCastingRegistryPair(dir)).toEqual(pair); + for (const [id, charter] of Object.entries(source.identities)) { + expect(readFileSync(join(dir, '..', 'agents', id, 'charter.md'), 'utf8')) + .toBe(crlf ? charter.replace(/\n/g, '\r\n') : charter); + } + }); + + it.each([ + 'missing-charter', 'duplicate-folder', 'multiple-names', 'multiple-sections', 'multiple-roles', + 'conflicting-role', 'conflicting-folder', 'invalid-folder', 'unknown-member', + 'duplicate-member', 'duplicate-entry', 'duplicate-assignment', 'entry-generation', 'assignment-generation', + ])('refuses %s entries evidence without changing either original or creating archives', kind => { + const dir = castingDir(); + const pair = entriesFixture(dir); + const agentsDir = join(dir, '..', 'agents'); + const charter = join(agentsDir, 'agent-key-01', 'charter.md'); + if (kind === 'missing-charter') rmSync(charter); + if (kind === 'duplicate-folder' || kind === 'invalid-folder') { + const folder = join(agentsDir, kind === 'invalid-folder' ? 'Invalid_Folder' : 'different-folder'); + mkdirSync(folder); + writeFileSync(join(folder, 'charter.md'), pair.identities['agent-key-01']!); + if (kind === 'invalid-folder') rmSync(charter); + } + if (kind === 'multiple-names') writeFileSync(charter, pair.identities['agent-key-01']!.replace( + '## Identity\n', '## Identity\n- **Name:** Example Member 01\n', + )); + if (kind === 'multiple-sections') writeFileSync(charter, pair.identities['agent-key-01']! + '\n## Identity\n- **Name:** Example Member 01\n'); + if (kind === 'multiple-roles') writeFileSync(charter, pair.identities['agent-key-01']!.replace( + '## Identity\n', '## Identity\n- **Role:** Another role\n', + )); + if (kind === 'conflicting-role') pair.registry.entries[0].role = 'Conflicting role'; + if (kind === 'conflicting-folder') pair.registry.entries[0].folder = 'different-folder'; + if (kind === 'unknown-member') pair.history.assignments[0].agents.push('No recorded identity'); + if (kind === 'duplicate-member') pair.history.assignments[0].agents.push(pair.history.assignments[0].agents[0]); + if (kind === 'duplicate-entry') pair.registry.entries.push(pair.registry.entries[0]); + if (kind === 'duplicate-assignment') pair.history.assignments.push(pair.history.assignments[0]); + if (kind === 'entry-generation') pair.registry.entries[0].revision = 9; + if (kind === 'assignment-generation') pair.history.assignments[0].revision = 9; + const originals = [JSON.stringify(pair.registry), JSON.stringify(pair.history)]; + writeFileSync(join(dir, 'registry.json'), originals[0]!); + writeFileSync(join(dir, 'history.json'), originals[1]!); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/Cannot migrate legacy casting/); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/Cannot migrate legacy casting/); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(originals[0]); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(originals[1]); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it.each([['created_at', 'used_at'], ['created_at', 'assigned_at'], ['used_at', 'assigned_at']])( + 'rejects conflicting entries assignment timestamps %s/%s without writes', (first, second) => { + const dir = castingDir(); + const pair = entriesFixture(dir); + const assignment = pair.history.assignments[0]; + delete assignment.created_at; + assignment[first!] = '2024-02-03'; + assignment[second!] = '2024-02-04'; + const raw = JSON.stringify(pair.history); + writeFileSync(join(dir, 'history.json'), raw); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/timestamps disagree/); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/timestamps disagree/); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(raw); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }, + ); + + it('preserves explicit historical subsets and simultaneous distinct entries assignments', () => { + const dir = castingDir(); + const pair = entriesFixture(dir); + pair.history.assignments.push({ + ...pair.history.assignments[0], assignment_id: 'another-assignment', agents: ['Example Member 14'], + }); + writeFileSync(join(dir, 'history.json'), JSON.stringify(pair.history)); + preflightCastingRegistryPair(dir); + expect(readCastingRegistryPair(dir).history).toMatchObject({ + assignment_cast_snapshots: { + 'example-onboarding-001': { agents: Object.keys(pair.identities).slice(0, 12) }, + 'another-assignment': { agents: ['agent-key-14'] }, + }, + universe_usage_history: [ + { universe: 'descriptive', used_at: '2024-02-03' }, + { universe: 'descriptive', used_at: '2024-02-03' }, + ], + }); + }); + + it('refuses the anonymized native thirteen-agent pair with its missing universes, null snapshot and empty usage intact', () => { + const dir = castingDir(); + const fixtureDir = join(process.cwd(), 'test', 'fixtures', 'casting-incomplete-universe'); + const originals = ['registry.json', 'history.json'].map(file => readFileSync(join(fixtureDir, file))); + const registry = JSON.parse(originals[0]!.toString()); + const history = JSON.parse(originals[1]!.toString()); + expect(Object.keys(registry.agents)).toHaveLength(13); + expect(Object.values(registry.agents).every(agent => !Object.hasOwn(agent as object, 'universe'))).toBe(true); + expect(history.assignment_cast_snapshots['example-writing-2024-02-03'].universe).toBeNull(); + expect(history.universe_usage_history).toEqual([]); + for (const [index, file] of ['registry.json', 'history.json'].entries()) writeFileSync(join(dir, file), originals[index]!); + for (const dryRun of [true, false, false]) { + expect(() => preflightCastingRegistryPair(dir, dryRun)).toThrow(/authoritative universe evidence.*Incomplete legacy provenance/); + for (const [index, file] of ['registry.json', 'history.json'].entries()) { + expect(readFileSync(join(dir, file))).toEqual(originals[index]); + } + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + } + }); + + it('migrates the anonymized pinned assigned_at producer pair without altering dates or membership', () => { + const dir = castingDir(); + const originalRegistry = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-assigned-at', 'registry.json')); + const originalHistory = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-assigned-at', 'history.json')); + writeFileSync(join(dir, 'registry.json'), originalRegistry); + writeFileSync(join(dir, 'history.json'), originalHistory); + expect(preflightCastingRegistryPair(dir, true)).toBe(true); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + expect(readFileSync(join(dir, 'registry.json'))).toEqual(originalRegistry); + expect(readFileSync(join(dir, 'history.json'))).toEqual(originalHistory); + expect(preflightCastingRegistryPair(dir)).toBe(true); + const pair = readCastingRegistryPair(dir); + const source = JSON.parse(originalHistory.toString()); + const key = 'descriptive-2026-08-08T01:16:24.000Z'; + expect(pair.history).toMatchObject({ + assignment_cast_snapshots: { + [key]: { + agents: source.assignment_cast_snapshots[key].agents, + universe: 'none (descriptive naming)', + created_at: source.assignment_cast_snapshots[key].assigned_at, + }, + }, + universe_usage_history: [{ universe: 'none (descriptive naming)', used_at: '2026-08-08T01:16:24.000Z' }], + }); + const archive = readdirSync(dir).find(name => name.startsWith('legacy-archive-'))!; + expect(readFileSync(join(dir, archive, 'registry.json'))).toEqual(originalRegistry); + expect(readFileSync(join(dir, archive, 'history.json'))).toEqual(originalHistory); + expect(preflightCastingRegistryPair(dir)).toBe(false); + expect(readCastingRegistryPair(dir)).toEqual(pair); + }); + + it.each(['snapshot', 'usage'] as const)('accepts agreeing timestamp aliases on %s and retains the original offset', kind => { + const dir = castingDir(); + const pair = fixture(); + const record = kind === 'snapshot' + ? pair.history.assignment_cast_snapshots['initial-assignment']! + : pair.history.universe_usage_history[0]!; + record.created_at = kind === 'snapshot' ? '2026-03-10T16:34:25.614+02:00' : '2026-03-10T14:34:25.614Z'; + record.used_at = kind === 'usage' ? '2026-03-10T16:34:25.614+02:00' : '2026-03-10T14:34:25.614Z'; + record.assigned_at = '2026-03-10T14:34:25.614Z'; + writeFileSync(join(dir, 'history.json'), JSON.stringify(pair.history)); + preflightCastingRegistryPair(dir); + const result = readCastingRegistryPair(dir).history!; + expect(kind === 'snapshot' + ? (result.assignment_cast_snapshots as Record>)['initial-assignment']!.created_at + : (result.universe_usage_history as Record[])[0]!.used_at) + .toBe('2026-03-10T16:34:25.614+02:00'); + }); + + it.each(['snapshot', 'usage'] as const)('rejects every conflicting recognized timestamp pair on %s without writes', kind => { + for (const fields of [['created_at', 'used_at'], ['created_at', 'assigned_at'], ['used_at', 'assigned_at']]) { + const dir = castingDir(); + const pair = fixture(); + const record = kind === 'snapshot' + ? pair.history.assignment_cast_snapshots['initial-assignment']! + : pair.history.universe_usage_history[0]!; + delete record.created_at; + record[fields[0]!] = '2026-03-10T14:34:25.614Z'; + record[fields[1]!] = '2026-03-11T14:34:25.614Z'; + const raw = JSON.stringify(pair.history); + writeFileSync(join(dir, 'history.json'), raw); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/timestamps disagree/); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/timestamps disagree/); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(raw); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + } + }); + + it.each(['agents', 'snapshot', 'usage'] as const)('refuses missing universe evidence at %s without manufacturing provenance', kind => { + const dir = castingDir(); + const pair = fixture(); + if (kind === 'agents') { + for (const agent of Object.values(pair.registry.agents)) delete agent.universe; + pair.history.assignment_cast_snapshots['initial-assignment']!.universe = null; + pair.history.universe_usage_history = []; + } else if (kind === 'snapshot') { + pair.history.assignment_cast_snapshots['initial-assignment']!.universe = null; + } else { + delete pair.history.universe_usage_history[0]!.universe; + } + const rawRegistry = JSON.stringify(pair.registry); + const rawHistory = JSON.stringify(pair.history); + writeFileSync(join(dir, 'registry.json'), rawRegistry); + writeFileSync(join(dir, 'history.json'), rawHistory); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/authoritative universe evidence.*No universe or historical usage will be invented/); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/Incomplete legacy provenance/); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(rawRegistry); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(rawHistory); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it('recognizes entries/assignments but refuses to derive historical identities from names', () => { + const dir = castingDir(); + const pair = fixture(); + const rawRegistry = JSON.stringify({ entries: Object.values(pair.registry.agents) }); + const rawHistory = JSON.stringify({ assignments: [] }); + writeFileSync(join(dir, 'registry.json'), rawRegistry); + writeFileSync(join(dir, 'history.json'), rawHistory); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/authoritative historical identity mapping/); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/current team roster cannot establish historical membership/); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(rawRegistry); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(rawHistory); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it('establishes a new baseline without rewriting identities, dates, or original metadata', () => { + const dir = castingDir(); + expect(() => readCastingRegistryPair(dir, 1)).toThrow(/revision is invalid/); + _setCastingDurabilityHooksForTesting({ now: () => Date.parse('2026-10-08T12:00:00.000Z') }); + expect(preflightCastingRegistryPair(dir)).toBe(true); + const snapshot = readCastingRegistryPair(dir); + expect(snapshot.registry).toMatchObject({ + revision: 1, generated_at: '2026-10-08T12:00:00.000Z', + agents: { + alpha: { ...fixture().registry.agents.alpha, display_name: 'Alpha', updated_at: '2026-03-10T14:34:25.614Z' }, + beta: { ...fixture().registry.agents.beta, display_name: 'Beta', updated_at: '2026-05-18T17:43:49.438+02:00' }, + }, + }); + expect(snapshot.history).toMatchObject({ + assignment_cast_snapshots: { + 'initial-assignment': { universe: 'Example', created_at: '2026-03-10T14:34:25.614Z', agents: ['alpha', 'beta'] }, + }, + universe_usage_history: [{ universe: 'Example', used_at: '2026-03-10T14:34:25.614Z' }], + }); + const archives = readdirSync(dir).filter(name => name.startsWith('legacy-archive-')); + expect(archives).toHaveLength(1); + expect(readFileSync(join(dir, archives[0]!, 'registry.json'), 'utf8')).toBe(registryRaw); + expect(readFileSync(join(dir, archives[0]!, 'history.json'), 'utf8')).toBe(historyRaw); + expect(preflightCastingRegistryPair(dir)).toBe(false); + expect(ensureCastingRegistryPair(dir, '{}', '{}').migrated).toBe(false); + expect(readCastingRegistryPair(dir)).toEqual(snapshot); + expect(readdirSync(dir).filter(name => name.startsWith('legacy-archive-'))).toEqual(archives); + }); + + it('also migrates through initialization using the shared protocol', () => { + const dir = castingDir(); + expect(ensureCastingRegistryPair(dir, '{}', '{}').migrated).toBe(true); + expect(readCastingRegistryPair(dir).transactionId).toBeDefined(); + }); + + it.each(['map', 'array'])('supports the documented %s snapshot variant and used_at', variant => { + const dir = castingDir(); + const pair = fixture(); + delete pair.registry.agents.alpha!.role; + delete pair.registry.agents.beta!.role; + pair.registry.agents.beta!.updated_at = '2026-06-01T12:00:00Z'; + const snapshot = pair.history.assignment_cast_snapshots['initial-assignment']!; + delete snapshot.members; + snapshot.agents = variant === 'map' ? { alpha: 'Alpha', beta: 'Beta' } : ['alpha', 'beta']; + const usage = pair.history.universe_usage_history[0]!; + usage.used_at = usage.created_at; + delete usage.created_at; + writeFileSync(join(dir, 'registry.json'), JSON.stringify(pair.registry)); + writeFileSync(join(dir, 'history.json'), JSON.stringify(pair.history)); + expect(preflightCastingRegistryPair(dir)).toBe(true); + expect(readCastingRegistryPair(dir).registry).toMatchObject({ + agents: { alpha: { role: 'alpha' }, beta: { role: 'beta', updated_at: '2026-06-01T12:00:00Z' } }, + }); + }); + + it('validates dry runs without locks, archives, creation, or recovery', () => { + const dir = castingDir(); + expect(preflightCastingRegistryPair(dir, true)).toBe(true); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(registryRaw); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(historyRaw); + expect(preflightCastingRegistryPair(join(dir, 'missing'), true)).toBe(false); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it.each([ + ['unknown schema', (pair: ReturnType) => { pair.registry.schema = 'future/v2'; }], + ['partial revision', (pair: ReturnType) => { pair.registry.revision = 1; }], + ['history generation', (pair: ReturnType) => { pair.history.registry_revision = 1; }], + ['snapshot generation', (pair: ReturnType) => { pair.history.assignment_cast_snapshots['initial-assignment']!.revision = 2; }], + ['invalid usage date', (pair: ReturnType) => { pair.history.universe_usage_history[0]!.used_at = null; }], + ['missing creation date', (pair: ReturnType) => { delete pair.registry.agents.beta!.created_at; }], + ['invalid update date', (pair: ReturnType) => { pair.registry.agents.beta!.updated_at = null; }], + ['duplicate name', (pair: ReturnType) => { pair.registry.agents.beta!.persistent_name = 'Alpha'; }], + ['unknown agent', (pair: ReturnType) => { + pair.history.assignment_cast_snapshots['initial-assignment']!.agents = ['missing']; + delete pair.history.assignment_cast_snapshots['initial-assignment']!.members; + }], + ['name mismatch', (pair: ReturnType) => { + pair.history.assignment_cast_snapshots['initial-assignment']!.members = [ + { folder: 'alpha', persistent_name: 'Wrong', role: 'Lead' }, + ]; + }], + ['assignment mismatch', (pair: ReturnType) => { pair.history.universe_usage_history[0]!.assignment_id = 'other'; }], + ['repository mismatch', (pair: ReturnType) => { pair.history.universe_usage_history[0]!.repository = 'other'; }], + ['timestamp mismatch', (pair: ReturnType) => { pair.history.universe_usage_history[0]!.created_at = '2026-03-11T00:00:00Z'; }], + ['missing usage', (pair: ReturnType) => { pair.history.universe_usage_history = []; }], + ] as const)('refuses %s without changing the pair or archiving', (_name, mutate) => { + const dir = castingDir(); + const pair = fixture(); + mutate(pair); + const beforeRegistry = JSON.stringify(pair.registry); + const beforeHistory = JSON.stringify(pair.history); + writeFileSync(join(dir, 'registry.json'), beforeRegistry); + writeFileSync(join(dir, 'history.json'), beforeHistory); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(); + expect(() => preflightCastingRegistryPair(dir)).toThrow(); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(beforeRegistry); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(beforeHistory); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it.each(['archive:write', 'archive:file-fsync', 'archive:parent-fsync', 'payload:registry-write'] as const)('preserves originals when %s fails', failure => { + const dir = castingDir(); + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { if (boundary === failure) throw new Error('injected failure'); }, + }); + expect(() => preflightCastingRegistryPair(dir)).toThrow('injected failure'); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(registryRaw); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(historyRaw); + _setCastingDurabilityHooksForTesting(null); + expect(preflightCastingRegistryPair(dir)).toBe(true); + }); + + it('never overwrites a differing archive on retry', () => { + const dir = castingDir(); + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { if (boundary === 'payload:registry-write') throw new Error('stop before journal'); }, + }); + expect(() => preflightCastingRegistryPair(dir)).toThrow('stop before journal'); + _setCastingDurabilityHooksForTesting(null); + const archive = readdirSync(dir).find(name => name.startsWith('legacy-archive-'))!; + writeFileSync(join(dir, archive, 'registry.json'), 'keep this archive'); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/archive differs/); + expect(readFileSync(join(dir, archive, 'registry.json'), 'utf8')).toBe('keep this archive'); + expect(readFileSync(join(dir, 'registry.json'), 'utf8')).toBe(registryRaw); + }); + + it('rolls forward an interrupted migration before retry and refuses recovery during dry run', () => { + const dir = castingDir(); + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { if (boundary === 'history:rename') throw new Error('interrupted publication'); }, + }); + expect(() => preflightCastingRegistryPair(dir)).toThrow(CastingCommitInDoubtError); + const before = readdirSync(dir); + expect(() => preflightCastingRegistryPair(dir, true)).toThrow(/awaiting roll-forward/); + expect(readdirSync(dir)).toEqual(before); + _setCastingDurabilityHooksForTesting(null); + expect(preflightCastingRegistryPair(dir)).toBe(false); + expect(readCastingRegistryPair(dir).transactionId).toBeDefined(); + expect(readdirSync(dir).filter(name => name.startsWith('legacy-archive-'))).toHaveLength(1); + }); + + it('does not relax strict managed pair validation', () => { + const dir = castingDir(); + preflightCastingRegistryPair(dir); + const snapshot = readCastingRegistryPair(dir); + const modified = JSON.parse(snapshot.historyRaw!); + modified.assignment_cast_snapshots['initial-assignment'].repository = 'unexpected'; + const raw = JSON.stringify(modified); + writeFileSync(join(dir, 'history.json'), raw); + expect(() => preflightCastingRegistryPair(dir)).toThrow(); + expect(() => validateCastingRegistryPairRaw(snapshot.registryRaw, raw, undefined, + readFileSync(join(dir, 'registry-history.commit.json'), 'utf8'))).toThrow(); + expect(readFileSync(join(dir, 'history.json'), 'utf8')).toBe(raw); + }); + + it('refuses damaged UTF-8 rather than archiving replacement characters', () => { + const dir = castingDir(); + const prefix = Buffer.from(registryRaw.slice(0, registryRaw.indexOf('Alpha'))); + const suffix = Buffer.from(registryRaw.slice(registryRaw.indexOf('Alpha') + 5)); + const raw = Buffer.concat([prefix, Buffer.from([0xff]), suffix]); + writeFileSync(join(dir, 'registry.json'), raw); + expect(() => preflightCastingRegistryPair(dir)).toThrow(/valid UTF-8/); + expect(readFileSync(join(dir, 'registry.json'))).toEqual(raw); + expect(readdirSync(dir).sort()).toEqual(['history.json', 'registry.json']); + }); + + it('holds the shared writer lock during archival and publication', () => { + const dir = castingDir(); + let observed = false; + let clock = Date.now(); + _setCastingDurabilityHooksForTesting({ + now: () => clock, + wait: milliseconds => { clock += milliseconds; }, + lockTimeoutMs: 50, + boundary: ({ boundary }) => { + if (boundary !== 'archive:write' || observed) return; + observed = true; + expect(() => preflightCastingRegistryPair(dir)).toThrow(/Timed out waiting/); + }, + }); + expect(preflightCastingRegistryPair(dir)).toBe(true); + expect(observed).toBe(true); + expect(readCastingRegistryPair(dir).transactionId).toBeDefined(); + }); +}); diff --git a/test/cli/legacy-casting-upgrade.test.ts b/test/cli/legacy-casting-upgrade.test.ts new file mode 100644 index 000000000..bc20a4444 --- /dev/null +++ b/test/cli/legacy-casting-upgrade.test.ts @@ -0,0 +1,208 @@ +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runInit } from '@bradygaster/squad-cli/core/init'; +import { runUpgrade } from '@bradygaster/squad-cli/core/upgrade'; +import { getPackageVersion } from '@bradygaster/squad-cli/core/version'; +import { _setCastingDurabilityHooksForTesting, readCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; + +const registryRaw = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-legacy', 'registry.json'), 'utf8'); +const historyRaw = readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-legacy', 'history.json'), 'utf8'); +let root: string; +let home: string; +let casting: string; + +function tree(dir: string): Record { + const result: Record = {}; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + result[entry.name + '/'] = ''; + for (const [key, value] of Object.entries(tree(join(dir, entry.name)))) { + result[entry.name + '/' + key] = value; + } + } else { + result[entry.name] = readFileSync(join(dir, entry.name)).toString('base64'); + } + } + return result; +} + +function version(value: string): void { + const file = join(root, '.github', 'agents', 'squad.agent.md'); + writeFileSync(file, readFileSync(file, 'utf8').replace(//, ``)); +} + +describe('upgrade preflights legacy casting before managed writes', () => { + beforeEach(async () => { + root = mkdtempSync(join(process.cwd(), '.test-legacy-upgrade-')); + home = mkdtempSync(join(process.cwd(), '.test-legacy-home-')); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + process.env.SQUAD_HOME_DIR_OVERRIDE = home; + await runInit(root); + casting = join(root, '.squad', 'casting'); + rmSync(casting, { recursive: true }); + mkdirSync(casting); + writeFileSync(join(casting, 'registry.json'), registryRaw); + writeFileSync(join(casting, 'history.json'), historyRaw); + mkdirSync(join(root, '.squad', 'agents', 'beta'), { recursive: true }); + writeFileSync(join(root, '.squad', 'agents', 'beta', 'history.md'), 'Preserve this agent history.\n'); + }); + + afterEach(() => { + _setCastingDurabilityHooksForTesting(null); + delete process.env.SQUAD_HOME_DIR_OVERRIDE; + rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + it.each(['0.13.0', 'current'])('migrates %s state before refreshing templates, then stays idempotent', async installed => { + version(installed === 'current' ? getPackageVersion() : installed); + await runUpgrade(root); + const first = readCastingRegistryPair(casting); + expect(first.registry).toMatchObject({ + revision: 1, + agents: { beta: { created_at: '2026-05-18T17:43:49.438+02:00' } }, + }); + const archive = readdirSync(casting).find(name => name.startsWith('legacy-archive-'))!; + expect(readFileSync(join(casting, archive, 'registry.json'), 'utf8')).toBe(registryRaw); + expect(readFileSync(join(casting, archive, 'history.json'), 'utf8')).toBe(historyRaw); + expect(readFileSync(join(root, '.squad', 'agents', 'beta', 'history.md'), 'utf8')).toBe('Preserve this agent history.\n'); + await runUpgrade(root); + expect(readCastingRegistryPair(casting)).toEqual(first); + }); + + it.each(['0.13.0', 'current'])('dry run on %s previews without changing any files', async installed => { + version(installed === 'current' ? getPackageVersion() : installed); + const before = tree(root); + const homeBefore = tree(home); + await runUpgrade(root, { dryRun: true }); + expect(tree(root)).toEqual(before); + expect(tree(home)).toEqual(homeBefore); + }); + + it('upgrades assigned_at producer state, archives exact bytes, and leaves casting unchanged on repeat', async () => { + version('0.11.0'); + const originals = Object.fromEntries(['registry.json', 'history.json'].map(file => [ + file, readFileSync(join(process.cwd(), 'test', 'fixtures', 'casting-assigned-at', file)), + ])); + for (const [file, bytes] of Object.entries(originals)) writeFileSync(join(casting, file), bytes); + const before = tree(root); + const homeBefore = tree(home); + await runUpgrade(root, { dryRun: true }); + expect(tree(root)).toEqual(before); + expect(tree(home)).toEqual(homeBefore); + await runUpgrade(root); + const first = readCastingRegistryPair(casting); + expect(first.history).toMatchObject({ + assignment_cast_snapshots: { + 'descriptive-2026-08-08T01:16:24.000Z': { + created_at: '2026-08-08T01:16:24.000Z', + agents: ['analyst', 'designer', 'planner', 'tester', 'reviewer', 'critic', 'recorder'], + }, + }, + universe_usage_history: [{ used_at: '2026-08-08T01:16:24.000Z' }], + }); + const archive = readdirSync(casting).find(name => name.startsWith('legacy-archive-'))!; + for (const [file, bytes] of Object.entries(originals)) expect(readFileSync(join(casting, archive, file))).toEqual(bytes); + await runUpgrade(root); + expect(readCastingRegistryPair(casting)).toEqual(first); + }); + + it('upgrades native-shaped entries with explicit folder identities and historical membership, then preserves bytes on repeat', async () => { + version('0.9.6-insider.2'); + const fixtureDir = join(process.cwd(), 'test', 'fixtures', 'casting-entries'); + const originals = Object.fromEntries(['registry.json', 'history.json'].map(file => [ + file, readFileSync(join(fixtureDir, file)), + ])); + const identities: Record = JSON.parse(readFileSync(join(fixtureDir, 'identities.json'), 'utf8')); + for (const [file, bytes] of Object.entries(originals)) writeFileSync(join(casting, file), bytes); + for (const [id, charter] of Object.entries(identities)) { + const folder = join(root, '.squad', 'agents', id); + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, 'charter.md'), charter); + } + const before = tree(root), homeBefore = tree(home); + await runUpgrade(root, { dryRun: true }); + expect(tree(root)).toEqual(before); + expect(tree(home)).toEqual(homeBefore); + await runUpgrade(root); + const first = readCastingRegistryPair(casting); + expect(Object.keys(first.registry!.agents as object)).toEqual(Object.keys(identities)); + expect(first.history).toMatchObject({ + assignment_cast_snapshots: { + 'example-onboarding-001': { + agents: Object.keys(identities).slice(0, 12), + created_at: '2024-02-03', universe: 'descriptive', + }, + }, + universe_usage_history: [{ used_at: '2024-02-03', universe: 'descriptive' }], + }); + const archive = readdirSync(casting).find(name => name.startsWith('legacy-archive-'))!; + for (const [file, bytes] of Object.entries(originals)) expect(readFileSync(join(casting, archive, file))).toEqual(bytes); + const castingBeforeRepeat = tree(casting); + await runUpgrade(root); + expect(tree(casting)).toEqual(castingBeforeRepeat); + expect(readCastingRegistryPair(casting)).toEqual(first); + for (const [id, charter] of Object.entries(identities)) { + expect(readFileSync(join(root, '.squad', 'agents', id, 'charter.md'), 'utf8')).toBe(charter); + } + }); + + it('refuses the unchanged native-shaped incomplete-universe fixture before all managed writes, including custom MCP', async () => { + version('0.10.0'); + const fixtureDir = join(process.cwd(), 'test', 'fixtures', 'casting-incomplete-universe'); + for (const file of ['registry.json', 'history.json']) writeFileSync(join(casting, file), readFileSync(join(fixtureDir, file))); + writeFileSync(join(root, '.mcp.json'), '{"mcpServers":{"custom":{"command":"node","args":["custom.mjs"]}}}\n'); + const before = tree(root), homeBefore = tree(home); + for (const options of [{ dryRun: true }, {}, {}]) { + await expect(runUpgrade(root, options)).rejects.toThrow(/authoritative universe evidence.*No universe or historical usage will be invented/); + expect(tree(root)).toEqual(before); + expect(tree(home)).toEqual(homeBefore); + } + }); + + it.each(['missing-universe', 'entries'] as const)('refuses %s provenance before managed upgrade writes', async kind => { + version('0.10.0'); + const registry = JSON.parse(registryRaw); + const history = JSON.parse(historyRaw); + if (kind === 'missing-universe') { + for (const agent of Object.values(registry.agents) as Record[]) delete agent.universe; + history.assignment_cast_snapshots['initial-assignment'].universe = null; + history.universe_usage_history = []; + } + writeFileSync(join(casting, 'registry.json'), JSON.stringify(kind === 'entries' ? { entries: Object.values(registry.agents) } : registry)); + writeFileSync(join(casting, 'history.json'), JSON.stringify(kind === 'entries' ? { assignments: [] } : history)); + const before = tree(root); + const homeBefore = tree(home); + for (const options of [{ dryRun: true }, {}, {}]) { + await expect(runUpgrade(root, options)).rejects.toThrow(/Incomplete legacy provenance/); + expect(tree(root)).toEqual(before); + expect(tree(home)).toEqual(homeBefore); + } + }); + + it.each(['0.13.0', 'current'])('invalid casting on %s fails before any upgrade writes', async installed => { + version(installed === 'current' ? getPackageVersion() : installed); + const malformed = JSON.parse(registryRaw); + malformed.schema = 'unsupported/v99'; + writeFileSync(join(casting, 'registry.json'), JSON.stringify(malformed)); + const before = tree(root); + await expect(runUpgrade(root)).rejects.toThrow(); + expect(tree(root)).toEqual(before); + }); + + it.each(['archive:write', 'payload:registry-write'] as const)('%s failure leaves managed files unchanged', async failure => { + version('0.13.0'); + const managedTree = () => Object.fromEntries(Object.entries(tree(root)) + .filter(([file]) => !file.startsWith('.squad/casting/'))); + const before = managedTree(); + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary }) => { if (boundary === failure) throw new Error('preflight failed'); }, + }); + await expect(runUpgrade(root)).rejects.toThrow('preflight failed'); + expect(managedTree()).toEqual(before); + expect(readFileSync(join(casting, 'registry.json'), 'utf8')).toBe(registryRaw); + expect(readFileSync(join(casting, 'history.json'), 'utf8')).toBe(historyRaw); + }); +}); diff --git a/test/cli/upgrade-portability.test.ts b/test/cli/upgrade-portability.test.ts new file mode 100644 index 000000000..dcfe482d1 --- /dev/null +++ b/test/cli/upgrade-portability.test.ts @@ -0,0 +1,181 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runInit } from '../../packages/squad-cli/src/cli/core/init.js'; +import { runUpgrade } from '../../packages/squad-cli/src/cli/core/upgrade.js'; +import { getPackageVersion } from '../../packages/squad-cli/src/cli/core/version.js'; +import { STANDALONE_HOME_ENV } from '../../packages/squad-cli/src/cli/core/mcp-spec.js'; +import { getTemplatesDir } from '../../packages/squad-cli/src/cli/core/templates.js'; +import { copyManagedFile, writeManagedText } from '../../packages/squad-cli/src/cli/core/managed-file.js'; +import { TEAM_CAPABILITIES_BEGIN } from '@bradygaster/squad-sdk/config'; + +vi.mock('../../packages/squad-cli/src/cli/core/npm-registry.js', () => ({ + isSquadCliVersionPublished: vi.fn(async () => true), +})); + +let root: string; +const originalStandalone = process.env[STANDALONE_HOME_ENV]; +const originalHome = process.env.SQUAD_HOME_DIR_OVERRIDE; +const portableCommand = process.platform === 'win32' ? 'squad.exe' : 'squad'; + +function write(relative: string, body: string): void { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body); +} + +function bundled(): void { + const bundle = path.join(root, 'example-bundle'); + mkdirSync(bundle); + writeFileSync(path.join(bundle, portableCommand), 'standalone detection fixture'); + process.env[STANDALONE_HOME_ENV] = bundle; +} + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'squad-upgrade-portability-')); + delete process.env[STANDALONE_HOME_ENV]; + process.env.SQUAD_HOME_DIR_OVERRIDE = path.join(root, 'home'); +}); + +afterEach(() => { + if (originalStandalone === undefined) delete process.env[STANDALONE_HOME_ENV]; + else process.env[STANDALONE_HOME_ENV] = originalStandalone; + if (originalHome === undefined) delete process.env.SQUAD_HOME_DIR_OVERRIDE; + else process.env.SQUAD_HOME_DIR_OVERRIDE = originalHome; + rmSync(root, { recursive: true, force: true }); +}); + +describe('upgrade portability and destination newlines', () => { + it.each(['packaged', 'npm'])('%s init and upgrade publish portable MCP specs and preserve other servers', async mode => { + if (mode === 'packaged') bundled(); + const custom = { command: 'node', args: ['./custom-server.js'], env: { MODE: 'user' } }; + write('.mcp.json', JSON.stringify({ + mcpServers: { custom, squad_state: { command: 'npx', args: ['old'], env: { EXTRA: 'keep' }, tools: ['*'] } }, + }, null, 2).replace(/\n/g, '\r\n') + '\r\n'); + await runInit(root); + for (const operation of [async () => {}, async () => { await runUpgrade(root); }]) { + await operation(); + const raw = readFileSync(path.join(root, '.mcp.json'), 'utf8'); + const config = JSON.parse(raw); + expect(config.mcpServers.custom).toEqual(custom); + expect(config.mcpServers.squad_state.env).toEqual({ EXTRA: 'keep' }); + expect(config.mcpServers.squad_state.command).toBe(mode === 'packaged' ? portableCommand : 'npx'); + expect(config.mcpServers.squad_state.args).toEqual(mode === 'packaged' + ? ['state-mcp'] : ['-y', `@bradygaster/squad-cli@${getPackageVersion()}`, 'state-mcp']); + expect(raw).not.toContain('example-bundle'); + expect(raw).not.toMatch(/(? { + bundled(); + if (project === 'npm') write('package.json', '{"name":"example-project","private":true}'); + else write('pyproject.toml', '[project]\nname = "example-project"\n'); + await runInit(root); + const agent = path.join('.github', 'agents', 'squad.agent.md'); + const existingAgent = readFileSync(path.join(root, agent), 'utf8') + .replace(//, ``); + write(agent, existingAgent.replace(/\r?\n/g, '\r\n')); + const agentBefore = readFileSync(path.join(root, agent), 'utf8'); + const managed = [ + path.join('.github', 'workflows', 'squad-ci.yml'), + path.join('.github', 'skills', 'secret-handling', 'SKILL.md'), + path.join('.squad', 'templates', 'charter.md'), + path.join('.squad', 'templates', 'scripts', 'notes', 'fetch.ps1'), + path.join('.github', 'copilot-instructions.md'), + ]; + for (const file of managed) write(file, 'OLD-MANAGED-BODY\r\nreplace this\r\n'); + write(path.join('.squad', 'ralph-instructions.md'), 'Keep user-owned Ralph instructions.\r\n'); + const team = path.join(root, '.squad', 'team.md'); + writeFileSync(team, readFileSync(team, 'utf8') + '\n🤖 Coding Agent\n'); + await runUpgrade(root); + for (const file of [agent, ...managed]) { + const body = readFileSync(path.join(root, file), 'utf8'); + expect(body, file).toContain('\r\n'); + expect(body, file).not.toMatch(/(?`); + expect(readFileSync(path.join(root, agent), 'utf8')).toContain(TEAM_CAPABILITIES_BEGIN); + expect(readFileSync(path.join(root, agent + '.local-backup'), 'utf8')).toBe(agentBefore); + expect(readFileSync(path.join(root, '.squad', 'ralph-instructions.md'), 'utf8')).toBe('Keep user-owned Ralph instructions.\r\n'); + const template = path.join(root, '.squad', 'templates', 'charter.md'); + expect(readFileSync(template, 'utf8').replace(/\r\n/g, '\n')) + .toBe(readFileSync(path.join(getTemplatesDir(), 'charter.md'), 'utf8').replace(/\r\n/g, '\n')); + const filesBefore = Object.fromEntries([agent, ...managed].map(file => [file, readFileSync(path.join(root, file), 'utf8')])); + utimesSync(template, new Date(0), new Date(0)); + const mtime = statSync(template).mtimeMs; + await runUpgrade(root); + for (const file of [agent, ...managed]) expect(readFileSync(path.join(root, file), 'utf8')).toBe(filesBefore[file]); + expect(statSync(template).mtimeMs).toBe(mtime); + }); +}); + +describe('managed text updates', () => { + it.each(['\n', '\r\n'])('keeps existing %j newlines while applying real content changes', eol => { + const file = path.join(root, 'managed.md'); + writeFileSync(file, `old${eol}body${eol}`); + expect(writeManagedText(file, 'new\nbody\n')).toBe(true); + expect(readFileSync(file, 'utf8')).toBe(`new${eol}body${eol}`); + }); + + it('does not rewrite or normalize a newline-only difference', () => { + const source = path.join(root, 'source.md'); + const destination = path.join(root, 'destination.md'); + writeFileSync(source, 'same\nbody\n'); + writeFileSync(destination, 'same\r\nbody\r\n'); + utimesSync(destination, new Date(0), new Date(0)); + const before = statSync(destination).mtimeMs; + copyManagedFile(source, destination); + expect(statSync(destination).mtimeMs).toBe(before); + expect(readFileSync(destination, 'utf8')).toBe('same\r\nbody\r\n'); + }); + + it('keeps source newlines when creating a new file without a destination style', () => { + const file = path.join(root, 'new.md'); + writeManagedText(file, 'new\r\nfile\r\n'); + expect(readFileSync(file, 'utf8')).toBe('new\r\nfile\r\n'); + }); + + it('copies binary bytes unchanged rather than applying text transformations', () => { + const source = path.join(root, 'asset.bin'); + const destination = path.join(root, 'copied.bin'); + const bytes = Buffer.from([0, 10, 13, 255, 128, 1]); + writeFileSync(source, bytes); + copyManagedFile(source, destination); + expect(readFileSync(destination)).toEqual(bytes); + }); + + it.each(['.bin', '.asset', '.example', ''])('copies unknown %j assets exactly over an existing CRLF destination', extension => { + const source = path.join(root, `payload${extension}`); + const destination = path.join(root, `copied${extension}`); + const bytes = Buffer.from([1, 2, 10, 3, 4, 10, 127]); + expect(bytes.includes(0)).toBe(false); + expect(Buffer.from(bytes.toString('utf8'), 'utf8')).toEqual(bytes); + writeFileSync(source, bytes); + writeFileSync(destination, 'existing\r\nasset\r\n'); + copyManagedFile(source, destination); + expect(readFileSync(destination)).toEqual(bytes); + }); + + it.each(['.md', '.json', '.yml', '.ps1', '.template'])('preserves CRLF for known %s text updates and skips repeat writes', extension => { + const source = path.join(root, `source${extension}`); + const destination = path.join(root, `destination${extension}`); + writeFileSync(source, 'new\nbody\n'); + writeFileSync(destination, 'old\r\nbody\r\n'); + copyManagedFile(source, destination); + expect(readFileSync(destination, 'utf8')).toBe('new\r\nbody\r\n'); + utimesSync(destination, new Date(0), new Date(0)); + const before = statSync(destination).mtimeMs; + copyManagedFile(source, destination); + expect(statSync(destination).mtimeMs).toBe(before); + expect(readFileSync(destination, 'utf8')).toBe('new\r\nbody\r\n'); + }); +}); diff --git a/test/fixtures/casting-assigned-at/history.json b/test/fixtures/casting-assigned-at/history.json new file mode 100644 index 000000000..92621e2ba --- /dev/null +++ b/test/fixtures/casting-assigned-at/history.json @@ -0,0 +1,13 @@ +{ + "assignment_cast_snapshots": { + "descriptive-2026-08-08T01:16:24.000Z": { + "assigned_at": "2026-08-08T01:16:24.000Z", + "agents": ["analyst", "designer", "planner", "tester", "reviewer", "critic", "recorder"], + "agent_count": 7, + "universe": "none (descriptive naming)" + } + }, + "universe_usage_history": [ + {"universe":"none (descriptive naming)","assigned_at":"2026-08-08T01:16:24.000Z","agent_count":7} + ] +} diff --git a/test/fixtures/casting-assigned-at/registry.json b/test/fixtures/casting-assigned-at/registry.json new file mode 100644 index 000000000..e5a6747e7 --- /dev/null +++ b/test/fixtures/casting-assigned-at/registry.json @@ -0,0 +1,11 @@ +{ + "agents": { + "analyst": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"analyst","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "designer": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"designer","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "planner": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"planner","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "tester": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"tester","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "reviewer": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"reviewer","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "critic": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"critic","universe":"none (descriptive naming)","legacy_named":false,"status":"active"}, + "recorder": {"created_at":"2026-08-08T01:16:24.000Z","persistent_name":"recorder","universe":"none (descriptive naming)","legacy_named":false,"status":"active"} + } +} diff --git a/test/fixtures/casting-entries/history.json b/test/fixtures/casting-entries/history.json new file mode 100644 index 000000000..a67598b36 --- /dev/null +++ b/test/fixtures/casting-entries/history.json @@ -0,0 +1,10 @@ +{ + "assignments": [ + { + "assignment_id": "example-onboarding-001", + "universe": "descriptive", + "created_at": "2024-02-03", + "agents": ["Example Member 01", "Example Member 02", "Example Member 03", "Example Member 04", "Example Member 05", "Example Member 06", "Example Member 07", "Example Member 08", "Example Member 09", "Example Member 10", "Example Member 11", "Example Member 12"] + } + ] +} diff --git a/test/fixtures/casting-entries/identities.json b/test/fixtures/casting-entries/identities.json new file mode 100644 index 000000000..25e7d401a --- /dev/null +++ b/test/fixtures/casting-entries/identities.json @@ -0,0 +1,16 @@ +{ + "agent-key-01": "# Example Member 01\n\n## Identity\n- **Name:** Example Member 01\n- **Role:** Example Role 01\n\n## Scope\nUnrelated prose.\n", + "agent-key-02": "# Example Member 02\n\n## Identity\n- **Name:** Example Member 02\n- **Role:** Example Role 02\n", + "agent-key-03": "# Example Member 03\n\n## Identity\n- **Name:** Example Member 03\n- **Role:** Example Role 03\n", + "agent-key-04": "# Example Member 04\n\n## Identity\n- **Name:** Example Member 04\n- **Role:** Example Role 04\n", + "agent-key-05": "# Example Member 05\n\n## Identity\n- **Name:** Example Member 05\n- **Role:** Example Role 05\n", + "agent-key-06": "# Example Member 06\n\n## Identity\n- **Name:** Example Member 06\n- **Role:** Example Role 06\n", + "agent-key-07": "# Example Member 07\n\n## Identity\n- **Name:** Example Member 07\n- **Role:** Example Role 07\n", + "agent-key-08": "# Example Member 08\n\n## Identity\n- **Name:** Example Member 08\n- **Role:** Example Role 08\n", + "agent-key-09": "# Example Member 09\n\n## Identity\n- **Name:** Example Member 09\n- **Role:** Example Role 09\n", + "agent-key-10": "# Example Member 10\n\n## Identity\n- **Name:** Example Member 10\n- **Role:** Example Role 10\n", + "agent-key-11": "# Example Member 11\n\n## Identity\n- **Name:** Example Member 11\n- **Role:** Example Role 11\n", + "agent-key-12": "# Example Member 12\n\n## Identity\n- **Name:** Example Member 12\n- **Role:** Example Role 12\n", + "agent-key-13": "# Example Member 13\n\n## Identity\n- **Name:** Example Member 13\n- **Scope:** Example scope 13\n", + "agent-key-14": "# Example Member 14\n\n## Identity\n- **Name:** Example Member 14\n- **Scope:** Example scope 14\n" +} diff --git a/test/fixtures/casting-entries/registry.json b/test/fixtures/casting-entries/registry.json new file mode 100644 index 000000000..ae9ca9625 --- /dev/null +++ b/test/fixtures/casting-entries/registry.json @@ -0,0 +1,18 @@ +{ + "entries": [ + { "persistent_name": "Example Member 01", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 02", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 03", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 04", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 05", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 06", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 07", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 08", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 09", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 10", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 11", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 12", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 13", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" }, + { "persistent_name": "Example Member 14", "universe": "descriptive", "created_at": "2024-02-03", "legacy_named": true, "status": "active" } + ] +} diff --git a/test/fixtures/casting-incomplete-universe/history.json b/test/fixtures/casting-incomplete-universe/history.json new file mode 100644 index 000000000..455aadfd9 --- /dev/null +++ b/test/fixtures/casting-incomplete-universe/history.json @@ -0,0 +1,13 @@ +{ + "universe_usage_history": [], + "assignment_cast_snapshots": { + "example-writing-2024-02-03": { + "assignment_id": "example-writing-2024-02-03", + "universe": null, + "naming_style": "descriptive-creative", + "created_at": "2024-02-03T09:00:00Z", + "agents": ["writer-01", "writer-02", "writer-03", "writer-04", "writer-05", "writer-06"], + "note": "Example descriptive naming request without a recorded universe." + } + } +} diff --git a/test/fixtures/casting-incomplete-universe/registry.json b/test/fixtures/casting-incomplete-universe/registry.json new file mode 100644 index 000000000..b6c359d5f --- /dev/null +++ b/test/fixtures/casting-incomplete-universe/registry.json @@ -0,0 +1,17 @@ +{ + "agents": { + "writer-01": { "persistent_name": "Example Writer 01", "role": "Example Role 01", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-02": { "persistent_name": "Example Writer 02", "role": "Example Role 02", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-03": { "persistent_name": "Example Writer 03", "role": "Example Role 03", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-04": { "persistent_name": "Example Writer 04", "role": "Example Role 04", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-05": { "persistent_name": "Example Writer 05", "role": "Example Role 05", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-06": { "persistent_name": "Example Writer 06", "role": "Example Role 06", "created_at": "2024-02-03T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-07": { "persistent_name": "Example Writer 07", "role": "Example Role 07", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-08": { "persistent_name": "Example Writer 08", "role": "Example Role 08", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-09": { "persistent_name": "Example Writer 09", "role": "Example Role 09", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-10": { "persistent_name": "Example Writer 10", "role": "Example Role 10", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-11": { "persistent_name": "Example Writer 11", "role": "Example Role 11", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-12": { "persistent_name": "Example Writer 12", "role": "Example Role 12", "created_at": "2024-02-04T09:00:00Z", "legacy_named": false, "status": "active" }, + "writer-13": { "persistent_name": "Example Writer 13", "role": "Example Role 13", "created_at": "2024-02-05T09:00:00Z", "legacy_named": false, "status": "active" } + } +} diff --git a/test/fixtures/casting-legacy/history.json b/test/fixtures/casting-legacy/history.json new file mode 100644 index 000000000..998dee8db --- /dev/null +++ b/test/fixtures/casting-legacy/history.json @@ -0,0 +1,23 @@ +{ + "universe_usage_history": [ + { + "assignment_id": "initial-assignment", + "repository": "example-repo", + "team_root": "D:\\example\\repo", + "universe": "Example", + "created_at": "2026-03-10T14:34:25.614Z" + } + ], + "assignment_cast_snapshots": { + "initial-assignment": { + "repository": "example-repo", + "team_root": "D:\\example\\repo", + "universe": "Example", + "created_at": "2026-03-10T14:34:25.614Z", + "members": [ + { "persistent_name": "Alpha", "folder": "alpha", "role": "Lead" }, + { "persistent_name": "Beta", "folder": "beta", "role": "Engineer" } + ] + } + } +} diff --git a/test/fixtures/casting-legacy/registry.json b/test/fixtures/casting-legacy/registry.json new file mode 100644 index 000000000..22ad3e4c9 --- /dev/null +++ b/test/fixtures/casting-legacy/registry.json @@ -0,0 +1,20 @@ +{ + "agents": { + "alpha": { + "persistent_name": "Alpha", + "role": "Lead", + "universe": "Example", + "created_at": "2026-03-10T14:34:25.614Z", + "legacy_named": false, + "status": "active" + }, + "beta": { + "persistent_name": "Beta", + "role": "Engineer", + "universe": "Example", + "created_at": "2026-05-18T17:43:49.438+02:00", + "legacy_named": false, + "status": "active" + } + } +} diff --git a/test/mcp-root-write.test.ts b/test/mcp-root-write.test.ts index d4db06500..77be2bdf2 100644 --- a/test/mcp-root-write.test.ts +++ b/test/mcp-root-write.test.ts @@ -169,6 +169,40 @@ describe('iter-8 mcp-root: repo-root .mcp.json writer + project tombstone', () = expect(JSON.parse(fs.readFileSync(cfgPath, 'utf8')).mcpServers.squad_state.env).toEqual({}); }); + it.each(['npx', 'absolute'])('replaces an existing %s spec with a portable packaged command', previous => { + const cfgPath = getProjectMcpJsonPath(tmpProject); + const launcher = 'C:\\Users\\example\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Example.Squad\\squad.exe'; + const custom = { command: 'node', args: ['./my-server.js'], env: { MODE: 'custom' } }; + fs.writeFileSync(cfgPath, JSON.stringify({ + mcpServers: { + custom, + squad_state: { + command: previous === 'npx' ? 'npx' : launcher, + args: previous === 'npx' ? PINNED_SPEC.args : ['state-mcp'], + env: { npm_config_registry: 'https://packagefeedproxy.microsoft.io/npm/' }, + tools: ['read_state'], + timeout: 60000, + }, + }, + }, null, 2).replace(/\n/g, '\r\n') + '\r\n'); + const spec: SquadStateMcpSpec = { command: launcher, args: ['state-mcp'], source: 'standalone' }; + expect(ensureSquadStateMcpInRoot(tmpProject, '1.0.1', spec).written).toBe(true); + const raw = fs.readFileSync(cfgPath, 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.mcpServers.squad_state).toEqual({ + command: process.platform === 'win32' ? 'squad.exe' : 'squad', + args: ['state-mcp'], + env: { npm_config_registry: 'https://packagefeedproxy.microsoft.io/npm/' }, + tools: ['read_state'], + timeout: 60000, + }); + expect(parsed.mcpServers.custom).toEqual(custom); + expect(raw).not.toContain('WinGet'); + expect(raw).not.toMatch(/(? { const copilotDir = path.join(tmpProject, '.copilot'); fs.mkdirSync(copilotDir, { recursive: true }); diff --git a/test/mcp-spec-init.test.ts b/test/mcp-spec-init.test.ts index 4aa4091d0..4d3d1497b 100644 --- a/test/mcp-spec-init.test.ts +++ b/test/mcp-spec-init.test.ts @@ -11,7 +11,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdtempSync, copyFileSync, mkdirSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -24,6 +25,7 @@ vi.mock( import { resolveSquadStateMcpSpec, + projectSquadStateMcpSpec, detectStandaloneLauncher, STANDALONE_HOME_ENV, _resetMcpSpecCache, @@ -212,11 +214,57 @@ describe('describeMcpSpec — standalone specs (#1593)', () => { args: ['state-mcp'], source: 'standalone', }); + expect(described).toContain('/opt/squad/squad'); expect(described).not.toContain(''); }); }); +describe('project MCP launch portability', () => { + it.each([ + ['win32', 'squad.exe'], + ['linux', 'squad'], + ['darwin', 'squad'], + ] as const)('uses the supported PATH executable on %s without altering the local spec', (platform, command) => { + const local = { command: 'C:\\example\\bundle\\squad.exe', args: ['state-mcp'], source: 'standalone' as const }; + expect(projectSquadStateMcpSpec(local, platform)).toEqual({ + command, args: ['state-mcp'], source: 'standalone', + }); + expect(local.command).toBe('C:\\example\\bundle\\squad.exe'); + expect(projectSquadStateMcpSpec({ + command: 'npx', args: ['-y', '@bradygaster/squad-cli@1.0.1', 'state-mcp'], source: 'pinned', + }, platform)).toEqual({ + command: 'npx', args: ['-y', '@bradygaster/squad-cli@1.0.1', 'state-mcp'], source: 'pinned', + }); + }); + + it.runIf(process.platform === 'win32')('resolves squad.exe through subprocess PATH without a shell or shim', () => { + const root = mkdtempSync(path.join(tmpdir(), 'squad-mcp-path-')); + try { + const bin = path.join(root, 'bin'); + const cwd = path.join(root, 'project'); + mkdirSync(bin); + mkdirSync(cwd); + // A real PE executable, not a PowerShell alias or CMD wrapper. + copyFileSync(process.execPath, path.join(bin, 'squad.exe')); + writeFileSync(path.join(bin, 'squad.cmd'), '@echo off\r\nexit /b 99\r\n'); + writeFileSync(path.join(bin, 'squad.ps1'), 'exit 99\r\n'); + const spec = projectSquadStateMcpSpec({ + command: path.join(bin, 'squad.exe'), args: ['state-mcp'], source: 'standalone', + }); + const env = { ...process.env }; + for (const key of Object.keys(env)) if (key.toLowerCase() === 'path') delete env[key]; + env.PATH = bin; + const result = spawnSync(spec.command, ['--version'], { cwd, env, shell: false, encoding: 'utf8' }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(process.version); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('init.ts uses resolveSquadStateMcpSpec (asymmetry fix)', () => { // Source-level architectural check: init.ts must reference the shared // resolver to keep the npm-registry fallback consistent with upgrade.ts. diff --git a/test/team-capabilities-lifecycle.test.ts b/test/team-capabilities-lifecycle.test.ts index b1c5276ea..9491d5efb 100644 --- a/test/team-capabilities-lifecycle.test.ts +++ b/test/team-capabilities-lifecycle.test.ts @@ -134,6 +134,21 @@ describe('#1608 — cast lifecycle regenerates the agent file', () => { expect(doc).toContain('| Pipelines | Nori |'); }); + it('preserves CRLF around and inside refreshed capability blocks and leaves repeat sync unchanged', () => { + seed(teamMd('| Nori | Data Engineer |'), routingMd('| Pipelines | Nori |')); + storage.writeSync(AGENT_FILE, AGENT_DOC.replace(/\n/g, '\r\n')); + expect(syncTeamCapabilities({ squadDir: SQUAD_DIR, agentFile: AGENT_FILE, storage }).updated).toBe(true); + const first = storage.readSync(AGENT_FILE)!; + expect(first).toContain('\r\n'); + expect(first).not.toMatch(/(? { seed(teamMd('| Nori | Data Engineer |'), routingMd('| Pipelines | Nori |')); syncTeamCapabilities({ squadDir: SQUAD_DIR, agentFile: AGENT_FILE, storage });