diff --git a/.changeset/casting-crlf-manifest-tolerance.md b/.changeset/casting-crlf-manifest-tolerance.md new file mode 100644 index 000000000..e855507d8 --- /dev/null +++ b/.changeset/casting-crlf-manifest-tolerance.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": patch +"@bradygaster/squad-cli": patch +--- + +Keep manifest-backed casting registries valid across Windows CRLF checkouts and normalize casting JSON to LF during upgrade. diff --git a/.gitattributes b/.gitattributes index 2746a7086..ca9ba7620 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,9 @@ # universe/roles, persistent name registry, universe assignment history). # They are committed to 'main' like team.md and routing.md, not to squad-state. # They do NOT need union merge and are intentionally NOT listed above. +# Their commit manifest hashes canonical LF content, so Windows checkouts must +# not rewrite the authoritative JSON files to CRLF. +.squad/casting/*.json text eol=lf # Squad: union merge for append-only team state files .squad/decisions/decisions.md merge=union diff --git a/packages/squad-cli/src/cli/core/upgrade.ts b/packages/squad-cli/src/cli/core/upgrade.ts index dff9258e0..0595d0d2e 100644 --- a/packages/squad-cli/src/cli/core/upgrade.ts +++ b/packages/squad-cli/src/cli/core/upgrade.ts @@ -12,6 +12,7 @@ import { clearResolveSquadCache, isRetiredProfileTeamRoot } from '@bradygaster/s import { backfillCastingRegistryFromTeam, ensureCastingRegistryPair, + normalizeCastingJsonLineEndings as normalizeCastingJsonFiles, preflightCastingRegistryPair, repairHandwrittenCastingRegistry, type RepairResult, @@ -678,6 +679,7 @@ const GITATTRIBUTES_RULES = [ '.squad/agents/*/history.md merge=union', '.squad/log/** merge=union', '.squad/orchestration-log/** merge=union', + '.squad/casting/*.json text eol=lf', ]; const GITIGNORE_ENTRIES = [ @@ -740,7 +742,7 @@ export function ensureGitattributes(dest: string, options: { dryRun?: boolean } appendLinesPreservingEol(filePath, added); } 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.'); + warn('Could not update .gitattributes (read-only). Add the required Squad entries manually.'); return []; } throw err; @@ -749,6 +751,12 @@ export function ensureGitattributes(dest: string, options: { dryRun?: boolean } return added; } +export function normalizeCastingJsonLineEndings(dest: string): string[] { + const castingDir = path.join(dest, '.squad', 'casting'); + return normalizeCastingJsonFiles(castingDir) + .map(name => `.squad/casting/${name}`); +} + /** * Check whether an existing gitignore line already covers a candidate entry * via parent-directory matching (e.g. `.squad/` covers `.squad/log/`). @@ -1173,6 +1181,11 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated: success(`scaffolded ${castingFiles.length} default casting files`); filesUpdated.push(...castingFiles); } + const normalizedCasting = normalizeCastingJsonLineEndings(dest); + if (normalizedCasting.length > 0) { + success(`normalized ${normalizedCasting.length} casting JSON file(s) to LF`); + filesUpdated.push(...normalizedCasting); + } const memoryFiles = ensureMemoryGovernanceUpgradeDefaults(dest); if (memoryFiles.length > 0) { diff --git a/packages/squad-sdk/src/casting/durable-registry.ts b/packages/squad-sdk/src/casting/durable-registry.ts index bfaebe97f..938197bda 100644 --- a/packages/squad-sdk/src/casting/durable-registry.ts +++ b/packages/squad-sdk/src/casting/durable-registry.ts @@ -60,6 +60,10 @@ export type CastingDurabilityBoundary = | 'registry:file-fsync' | 'registry:rename' | 'registry:parent-fsync' + | 'normalization:write' + | 'normalization:file-fsync' + | 'normalization:rename' + | 'normalization:parent-fsync' | 'cleanup:journal-unlink' | 'cleanup:journal-parent-fsync' | 'cleanup:payload-remove' @@ -226,7 +230,7 @@ function writeDurableFile( function durableReplace( filePath: string, content: string, - stage: 'journal' | 'history' | 'manifest' | 'registry', + stage: 'journal' | 'history' | 'manifest' | 'registry' | 'normalization', transactionId: string, ): void { const tempPath = `${filePath}.tmp-${transactionId}-${randomUUID()}`; @@ -253,6 +257,12 @@ function sha256(content: string | undefined): string | null { : createHash('sha256').update(content, 'utf8').digest('hex'); } +/** Treat Git's CRLF worktree conversion as byte-equivalent to canonical LF JSON. */ +function matchesSha256(content: string | undefined, expected: string | null): boolean { + if (sha256(content) === expected) return true; + return content !== undefined && sha256(content.replace(/\r\n/g, '\n')) === expected; +} + function parseObject(raw: string | undefined, label: string): Record | undefined { if (raw === undefined) return undefined; try { @@ -1127,9 +1137,8 @@ function expectedState( nextHash: string, label: string, ): 'previous' | 'next' { - const actual = sha256(raw); - if (actual === previousHash) return 'previous'; - if (actual === nextHash) return 'next'; + if (matchesSha256(raw, previousHash)) return 'previous'; + if (matchesSha256(raw, nextHash)) return 'next'; throw new Error(`Cannot recover casting transaction: ${label} changed outside the journal`); } @@ -1184,8 +1193,8 @@ export function recoverCastingRegistryTransaction(castingDir: string): void { if ( nextRegistryRaw === undefined || nextHistoryRaw === undefined - || sha256(nextRegistryRaw) !== journal.registry.next_sha256 - || sha256(nextHistoryRaw) !== journal.history.next_sha256 + || !matchesSha256(nextRegistryRaw, journal.registry.next_sha256) + || !matchesSha256(nextHistoryRaw, journal.history.next_sha256) ) { throw new Error('Cannot recover casting transaction: durable payload is missing or corrupt'); } @@ -1197,7 +1206,7 @@ export function recoverCastingRegistryTransaction(castingDir: string): void { history_sha256: journal.history.next_sha256, }; const nextManifestRaw = JSON.stringify(nextManifest, null, 2) + '\n'; - if (sha256(nextManifestRaw) !== journal.manifest.next_sha256) { + if (!matchesSha256(nextManifestRaw, journal.manifest.next_sha256)) { throw new Error('Cannot recover casting transaction: manifest hash is corrupt'); } @@ -1725,6 +1734,40 @@ export function preflightCastingRegistryPair(castingDir: string, dryRun = false) } } +/** + * Normalize casting JSON to LF while holding the shared writer lock. + * Each live file is replaced atomically after recovery and pair validation. + */ +export function normalizeCastingJsonLineEndings(castingDir: string): string[] { + if (!existsSync(castingDir)) return []; + const release = acquireCastingRegistryLock(castingDir, 'casting JSON line-ending normalization'); + try { + recoverCastingRegistryTransaction(castingDir); + readCastingRegistryPair(castingDir); + + const normalized: string[] = []; + const transactionId = randomUUID(); + const entries = readdirSync(castingDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.json')) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const filePath = path.join(castingDir, entry.name); + const content = readText(filePath); + if (content === undefined || !content.includes('\r\n')) continue; + durableReplace( + filePath, + content.replace(/\r\n/g, '\n'), + 'normalization', + transactionId, + ); + normalized.push(entry.name); + } + return normalized; + } finally { + release(); + } +} + /** * Create a missing pair or migrate a valid generationless legacy pair while * the caller holds the shared casting writer lock. @@ -1857,8 +1900,8 @@ export function validateCastingRegistryPairRaw( || historyTransaction !== manifest.transaction_id || historyRevision !== manifest.registry_revision || revision !== manifest.registry_revision - || sha256(registryRaw) !== manifest.registry_sha256 - || sha256(historyRaw) !== manifest.history_sha256 + || !matchesSha256(registryRaw, manifest.registry_sha256) + || !matchesSha256(historyRaw, manifest.history_sha256) ) { throw new Error( 'Cannot read a consistent casting registry/history pair: registry/history bytes do not match the stable commit manifest', @@ -1927,8 +1970,8 @@ export function readCastingRegistryPair( && historyTransaction === manifest.transaction_id && historyRevision === manifest.registry_revision && revision === manifest.registry_revision - && sha256(registryRaw) === manifest.registry_sha256 - && sha256(historyRaw) === manifest.history_sha256 + && matchesSha256(registryRaw, manifest.registry_sha256) + && matchesSha256(historyRaw, manifest.history_sha256) ) { return { registryRaw, diff --git a/packages/squad-sdk/src/casting/index.ts b/packages/squad-sdk/src/casting/index.ts index 12ad849a4..fc5ca3df4 100644 --- a/packages/squad-sdk/src/casting/index.ts +++ b/packages/squad-sdk/src/casting/index.ts @@ -60,6 +60,7 @@ export { commitCastingRegistryPair, ensureCastingRegistryPair, ensureCastingRegistryPairLocked, + normalizeCastingJsonLineEndings, prepareCastingRegistryPairLocked, preflightCastingRegistryPair, readCastingRegistryPair, diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index a6c60b6b4..56de16ee1 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -1368,20 +1368,20 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } // ------------------------------------------------------------------------- - // Create .gitattributes for merge drivers + // Create .gitattributes for merge drivers and stable casting hashes // Append-only mutable state files use union merge to handle concurrent appends. - // Note: .squad/casting/* files are identity (not mutable) so they don't need - // union merge and are NOT listed here. + // Casting JSON is authoritative identity whose commit manifest hashes LF bytes. // ------------------------------------------------------------------------- const gitattributesPath = join(teamRoot, '.gitattributes'); - const unionRules = [ + const gitattributesRules = [ '.squad/decisions.md merge=union', '.squad/agents/*/history.md merge=union', '.squad/log/** merge=union', '.squad/orchestration-log/** merge=union', '.squad/rai/audit-trail.md merge=union', '.squad/fact-checker/audit-trail.md merge=union', + '.squad/casting/*.json text eol=lf', ]; let existingAttrs = ''; @@ -1389,10 +1389,10 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre existingAttrs = storage.readSync(gitattributesPath) ?? ''; } - const missingRules = unionRules.filter(rule => !existingAttrs.includes(rule)); + const missingRules = gitattributesRules.filter(rule => !existingAttrs.includes(rule)); if (missingRules.length > 0) { const block = (existingAttrs && !existingAttrs.endsWith('\n') ? '\n' : '') - + '# Squad: union merge for append-only team state files\n' + + '# Squad: merge and line-ending rules for team state files\n' + missingRules.join('\n') + '\n'; await storage.append(gitattributesPath, block); createdFiles.push(toRelativePath(gitattributesPath)); diff --git a/test/casting-durable-registry.test.ts b/test/casting-durable-registry.test.ts index 74824a172..757ec661f 100644 --- a/test/casting-durable-registry.test.ts +++ b/test/casting-durable-registry.test.ts @@ -19,8 +19,11 @@ import { CastingCommitInDoubtError, commitCastingRegistryPair, ensureCastingRegistryPair, + normalizeCastingJsonLineEndings, + preflightCastingRegistryPair, readCastingRegistryPair, recoverCastingRegistryTransaction, + validateCastingRegistryPairRaw, type CastingDurabilityBoundary, } from '../packages/squad-sdk/src/casting/durable-registry.js'; @@ -809,6 +812,76 @@ describe('casting registry/history roll-forward transaction', () => { ); }); + it('accepts a stable manifest when Git converts all casting JSON files to CRLF (#2266)', () => { + const castingDir = temporaryCastingDir('crlf-manifest'); + const old = initialPair(); + writeFileSync(join(castingDir, 'registry.json'), old.registryRaw); + writeFileSync(join(castingDir, 'history.json'), old.historyRaw); + commitCastingRegistryPair( + castingDir, + old.registryRaw, + { ...old.registry, revision: 2 }, + old.historyRaw, + { ...old.history, assignment_cast_snapshots: { next: historySnapshot() } }, + 2, + ); + + for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) { + const filePath = join(castingDir, name); + writeFileSync(filePath, readFileSync(filePath, 'utf8').replace(/\r?\n/g, '\r\n')); + } + + const registryRaw = readFileSync(join(castingDir, 'registry.json'), 'utf8'); + const historyRaw = readFileSync(join(castingDir, 'history.json'), 'utf8'); + const manifestRaw = readFileSync(join(castingDir, 'registry-history.commit.json'), 'utf8'); + expect(() => validateCastingRegistryPairRaw( + registryRaw, + historyRaw, + undefined, + manifestRaw, + )).not.toThrow(); + expect(preflightCastingRegistryPair(castingDir)).toBe(false); + expect(readCastingRegistryPair(castingDir, 1)).toMatchObject({ + registry: { revision: 2 }, + history: { registry_revision: 2 }, + }); + }); + + it('holds the writer lock and leaves the live file intact when normalization fails before rename', () => { + const castingDir = temporaryCastingDir('normalization-atomic'); + const old = initialPair(); + writeFileSync(join(castingDir, 'registry.json'), old.registryRaw); + writeFileSync(join(castingDir, 'history.json'), old.historyRaw); + commitCastingRegistryPair( + castingDir, + old.registryRaw, + { ...old.registry, revision: 2 }, + old.historyRaw, + { ...old.history, assignment_cast_snapshots: { next: historySnapshot() } }, + 2, + ); + const policyPath = join(castingDir, 'policy.json'); + const crlfPolicy = '{\r\n "version": 1\r\n}\r\n'; + writeFileSync(policyPath, crlfPolicy); + + let observedLock = false; + _setCastingDurabilityHooksForTesting({ + boundary: ({ boundary, path }) => { + if (boundary === 'normalization:rename' && path === policyPath) { + observedLock = existsSync(join(castingDir, 'registry.lock')); + throw new Error('injected normalization failure'); + } + }, + }); + + expect(() => normalizeCastingJsonLineEndings(castingDir)) + .toThrow(/injected normalization failure/); + expect(observedLock).toBe(true); + expect(readFileSync(policyPath, 'utf8')).toBe(crlfPolicy); + expect(readdirSync(castingDir).some(name => name.includes('.tmp-'))).toBe(false); + expect(existsSync(join(castingDir, 'registry.lock'))).toBe(false); + }); + it('fails before journal publication when an external writer mutates the pair', () => { const castingDir = temporaryCastingDir('external-mutation'); const old = initialPair(); diff --git a/test/cli/doctor.test.ts b/test/cli/doctor.test.ts index 046de87a4..77e5ba569 100644 --- a/test/cli/doctor.test.ts +++ b/test/cli/doctor.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdir, rm, writeFile } from 'fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { join, resolve } from 'path'; import { existsSync, mkdirSync } from 'fs'; import { execFileSync } from 'child_process'; @@ -23,6 +23,7 @@ import { import type { DoctorCheck } from '@bradygaster/squad-cli/commands/doctor'; import { FSStorageProvider, OrphanBranchBackend } from '@bradygaster/squad-sdk'; import { getPackageVersion } from '../../packages/squad-cli/src/cli/core/version.js'; +import { ensureCastingRegistryPair } from '@bradygaster/squad-sdk/casting'; const TEST_ROOT = join(process.cwd(), `.test-doctor-${randomBytes(4).toString('hex')}`); @@ -269,6 +270,22 @@ describe('squad doctor', () => { .toMatchObject({ status: 'fail' }); }); + it('accepts manifest-backed casting files converted to CRLF by Git (#2266)', async () => { + await scaffold(TEST_ROOT); + const castingDir = join(TEST_ROOT, '.squad', 'casting'); + const registryRaw = await readFile(join(castingDir, 'registry.json'), 'utf8'); + const historyRaw = await readFile(join(castingDir, 'history.json'), 'utf8'); + ensureCastingRegistryPair(castingDir, registryRaw, historyRaw, 'doctor CRLF test'); + for (const name of ['registry.json', 'history.json', 'registry-history.commit.json']) { + const filePath = join(castingDir, name); + await writeFile(filePath, (await readFile(filePath, 'utf8')).replace(/\r?\n/g, '\r\n')); + } + + const checks = await runDoctor(TEST_ROOT); + expect(checks.find(check => check.name === 'casting/registry.json exists')) + .toMatchObject({ status: 'pass' }); + }, 15_000); + it('detects remote mode from config.json with teamRoot', async () => { await scaffold(TEST_ROOT); const configPath = join(TEST_ROOT, '.squad', 'config.json'); diff --git a/test/cli/init-upgrade-parity.test.ts b/test/cli/init-upgrade-parity.test.ts index 3935a5249..a7c3f3039 100644 --- a/test/cli/init-upgrade-parity.test.ts +++ b/test/cli/init-upgrade-parity.test.ts @@ -53,6 +53,7 @@ const EXPECTED_GITATTRIBUTES_RULES = [ '.squad/agents/*/history.md merge=union', '.squad/log/** merge=union', '.squad/orchestration-log/** merge=union', + '.squad/casting/*.json text eol=lf', ]; /** diff --git a/test/cli/init.test.ts b/test/cli/init.test.ts index cdd98425e..c1aadf472 100644 --- a/test/cli/init.test.ts +++ b/test/cli/init.test.ts @@ -272,6 +272,7 @@ describe('CLI: init command', () => { const content = await readFile(gitattributesPath, 'utf-8'); expect(content).toContain('.squad/decisions.md merge=union'); expect(content).toContain('.squad/orchestration-log/** merge=union'); + expect(content).toContain('.squad/casting/*.json text eol=lf'); }); it('should append to .gitignore with runtime state exclusions', async () => { diff --git a/test/cli/upgrade.test.ts b/test/cli/upgrade.test.ts index 2f9087c39..bf3dbf7cd 100644 --- a/test/cli/upgrade.test.ts +++ b/test/cli/upgrade.test.ts @@ -379,6 +379,7 @@ describe('CLI: upgrade command', () => { const content = readFileSync(join(dir, '.gitattributes'), 'utf8'); expect(content).toContain('.squad/decisions.md merge=union'); expect(content).toContain('.squad/log/** merge=union'); + expect(content).toContain('.squad/casting/*.json text eol=lf'); rmSync(dir, { recursive: true, force: true }); }); @@ -395,6 +396,26 @@ describe('CLI: upgrade command', () => { rmSync(dir, { recursive: true, force: true }); }); + it('upgrade accepts CRLF manifest-backed casting files and normalizes every casting JSON file to LF (#2266)', async () => { + const castingDir = join(TEST_ROOT, '.squad', 'casting'); + const names = readdirSync(castingDir).filter(name => name.endsWith('.json')); + expect(names).toContain('registry-history.commit.json'); + for (const name of names) { + const filePath = join(castingDir, name); + writeFileSync(filePath, readFileSync(filePath, 'utf8').replace(/\r?\n/g, '\r\n')); + expect(readFileSync(filePath, 'utf8')).toContain('\r\n'); + } + + const result = await runUpgrade(TEST_ROOT); + + expect(readFileSync(join(TEST_ROOT, '.gitattributes'), 'utf8')) + .toContain('.squad/casting/*.json text eol=lf'); + for (const name of names) { + expect(readFileSync(join(castingDir, name), 'utf8')).not.toContain('\r\n'); + expect(result.filesUpdated).toContain(`.squad/casting/${name}`); + } + }, 20_000); + it('ensureGitattributes is idempotent', () => { const dir = join(TEST_ROOT, 'gitattr-test-idempotent'); mkdirSync(dir, { recursive: true }); diff --git a/test/init.test.ts b/test/init.test.ts index 52a04ca30..cac06e0ab 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -310,6 +310,7 @@ describe('Squad Initialization', () => { const content = await readFile(gitattributesPath, 'utf-8'); expect(content).toContain('history.md merge=union'); expect(content).toContain('.squad/decisions.md merge=union'); + expect(content).toContain('.squad/casting/*.json text eol=lf'); }); it('should install the squad-help disambiguation skill (regression: #1297 / supersedes squad name collision)', async () => {