Skip to content

Commit c713a82

Browse files
fix(cli): recover legacy casting without blocking upgrade (#2365)
Support 0.9.x agents and entries array casting pairs, preserve exact originals, and isolate casting migration failures so bridge and template upgrades still complete. Co-authored-by: B'Elanna (Squad) <tamirdresher@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 995eea17-c356-4912-90ea-5e5b144e7ba9
1 parent 0aeb071 commit c713a82

16 files changed

Lines changed: 552 additions & 91 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-cli": patch
3+
"@bradygaster/squad-sdk": patch
4+
---
5+
6+
Migrate Squad 0.9.x casting registries that store agents and assignments as arrays, preserve byte-exact originals, keep the rest of `squad upgrade` running when casting evidence still needs repair, and normalize an absolute self `teamRoot` to `"."`.

‎docs/src/content/docs/scenarios/upgrading.md‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,10 @@ Example: upgrading to v0.2.0 creates `.ai-team/skills/` if it doesn't already ex
422422

423423
### Legacy casting state (upgrades to 1.x)
424424

425-
`squad upgrade` validates casting state **before** replacing the coordinator,
425+
`squad upgrade` attempts casting migration **before** replacing the coordinator,
426426
skills, workflows, or other upgrade-managed files, even when the installed
427-
coordinator already matches the CLI version.
427+
coordinator already matches the CLI version. A casting-only failure leaves both
428+
live casting files unchanged, but no longer blocks unrelated upgrade work.
428429

429430
Supported unversioned registries have an `agents` object with persistent names,
430431
universes, statuses, and creation dates. Roles default to the agent ID only when
@@ -438,16 +439,19 @@ denote the same instant. The original timestamp string, including its timezone
438439
offset, is retained; migration never substitutes an agent creation date for an
439440
assignment date.
440441

441-
The historical `entries[]` / `assignments[]` variant is also supported when
442+
The historical `entries[]` / `assignments[]` and `agents[]` / `assignments[]`
443+
variants emitted by 0.9.x coordinators are also supported when
442444
each registry entry's persistent name matches exactly one canonical folder's
443445
`charter.md` under the team's `agents/` directory. The charter must contain an
444446
`## Identity` section with `- **Name:**`; its optional `- **Role:**` supplies the
445447
role, otherwise the canonical folder ID is used. Missing, duplicated, malformed,
446448
or conflicting identities stop migration; names are never slugified into IDs.
447449
Keep the original producer's charters with the state when testing this variant.
448-
Each assignment's explicit persistent-name list supplies **only that historical
449-
membership**, not today's roster. Its recorded universe and timestamp supply
450-
the corresponding usage event. Missing universe or date evidence is never filled
450+
Each assignment's explicit persistent-name list or `{ name, role }` member list
451+
supplies **only that historical membership**, not today's roster. Member roles
452+
must agree with the authoritative charter. Its recorded universe and
453+
`timestamp`, `created_at`, `used_at`, or `assigned_at` value supplies the
454+
corresponding usage event. Missing universe or date evidence is never filled
451455
from the current naming policy or agent creation dates.
452456

453457
Migration preserves agent IDs, names, original dates (including timezone offsets),
@@ -465,8 +469,11 @@ registry/history commit transaction. Repeating migration preserves managed bytes
465469
`squad upgrade --dry-run` validates and previews migration without creating locks,
466470
archives, or transaction files. Unknown schemas, damaged files, missing pair
467471
members, mismatched assignment evidence, or inconsistent managed transactions
468-
stop the upgrade before managed files change. Back up the state and seek recovery
469-
help; do not manually add `revision`, delete casting files, or rewrite dates.
472+
leave the casting pair byte-exact and produce an actionable warning. The rest of
473+
the upgrade continues, including client templates and the `squad_state` bridge,
474+
then repeats the casting warning in the completion summary. Back up the state and
475+
seek recovery help; do not manually add `revision`, delete casting files, or
476+
rewrite dates.
470477

471478
#### Incomplete legacy provenance
472479

@@ -476,12 +483,13 @@ Descriptive names alone do not prove a universe. A current coordinator's naming
476483
policy does not prove the policy of an older assignment. Migration will not
477484
invent a universe, usage entry, or historical membership.
478485

479-
The historical `entries[]` / `assignments[]` pair requires recovery when original
480-
folder/charter identities are missing or ambiguous. Persistent names are not
481-
canonical folder IDs. Today's team roster is not evidence of which agents
482-
belonged to an older assignment. Restore the original authoritative `charter.md`
483-
files alongside the original casting pair; do not create charters from guessed
484-
name-to-folder mappings.
486+
The historical `entries[]` / `assignments[]` and `agents[]` / `assignments[]`
487+
pairs require recovery when original folder/charter identities are missing or
488+
ambiguous. The diagnostic names the unmapped persistent name and the number of
489+
matching folders. Persistent names are not canonical folder IDs. Today's team
490+
roster is not evidence of which agents belonged to an older assignment. Restore
491+
the original authoritative `charter.md` files alongside the original casting
492+
pair; do not create charters from guessed name-to-folder mappings.
485493

486494
If either policy stops your upgrade:
487495

‎packages/squad-cli/src/cli/commands/doctor.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import path from 'node:path';
1414
import { lstatSync, readFileSync, readdirSync, type Dirent } from 'node:fs';
1515
import { exec, execFileSync } from 'node:child_process';
1616
import { FSStorageProvider, resolveStateBackend, type StateBackendType } from '@bradygaster/squad-sdk';
17-
import { readCastingRegistryPair } from '@bradygaster/squad-sdk/casting';
17+
import { detectHistoricalCastingSchema, readCastingRegistryPair } from '@bradygaster/squad-sdk/casting';
1818
import { resolveSquad, resolveSquadPaths, clearResolveSquadCache } from '@bradygaster/squad-sdk/resolution';
1919
import { fatalNoSquad } from '../core/errors.js';
2020
import { isMutableStateKey, MUTABLE_STATE_PATHS } from '@bradygaster/squad-sdk/tools';
@@ -292,12 +292,30 @@ function checkAgentsDir(squadDir: string): DoctorCheck {
292292
}
293293

294294
function checkCastingRegistry(squadDir: string): DoctorCheck {
295-
const registryPath = path.join(squadDir, 'casting', 'registry.json');
295+
const castingDir = path.join(squadDir, 'casting');
296+
const registryPath = path.join(castingDir, 'registry.json');
296297
if (!fileExists(registryPath)) {
297298
return { name: 'casting/registry.json exists', status: 'fail', message: 'file not found' };
298299
}
299300
try {
300-
readCastingRegistryPair(path.join(squadDir, 'casting'));
301+
const registry = JSON.parse(storage.readSync(registryPath) ?? '{}') as unknown;
302+
const history = JSON.parse(storage.readSync(path.join(castingDir, 'history.json')) ?? '{}') as unknown;
303+
const legacySchema = detectHistoricalCastingSchema(registry, history);
304+
if (legacySchema) {
305+
return {
306+
name: 'casting/registry.json exists',
307+
status: 'fail',
308+
message:
309+
`detected legacy casting schema ${legacySchema} from a Squad 0.9.x coordinator. `
310+
+ 'Run `squad upgrade --force` to map persistent names through the existing agent folders, '
311+
+ 'preserve byte-exact originals, and create the managed durable pair.',
312+
};
313+
}
314+
} catch {
315+
// The strict pair reader below reports malformed JSON and missing history precisely.
316+
}
317+
try {
318+
readCastingRegistryPair(castingDir);
301319
} catch (error) {
302320
return {
303321
name: 'casting/registry.json exists',

‎packages/squad-cli/src/cli/core/upgrade.ts‎

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FSStorageProvider, stripTeamCapabilitiesBlock, syncTeamCapabilities } f
1111
import { clearResolveSquadCache, isRetiredProfileTeamRoot } from '@bradygaster/squad-sdk/resolution';
1212
import {
1313
backfillCastingRegistryFromTeam,
14+
detectHistoricalCastingSchema,
1415
ensureCastingRegistryPair,
1516
normalizeCastingJsonLineEndings as normalizeCastingJsonFiles,
1617
preflightCastingRegistryPair,
@@ -298,6 +299,18 @@ function stripRetiredProfileTeamRoot(squadDir: string, dest: string, dryRun: boo
298299
const teamRoot = record['teamRoot'];
299300
if (typeof teamRoot !== 'string' || teamRoot.length === 0 || teamRoot === '.') return;
300301
const target = path.resolve(dest, teamRoot);
302+
const comparable = (value: string) => process.platform === 'win32' ? value.toLowerCase() : value;
303+
if (comparable(target) === comparable(path.resolve(dest))) {
304+
if (dryRun) {
305+
info('Would normalize the absolute "teamRoot" setting to "." in .squad/config.json.');
306+
return;
307+
}
308+
record['teamRoot'] = '.';
309+
storage.writeSync(configPath, JSON.stringify(record, null, 2) + '\n');
310+
clearResolveSquadCache();
311+
info('Normalized the absolute "teamRoot" setting to "." in .squad/config.json.');
312+
return;
313+
}
301314
if (!isRetiredProfileTeamRoot(target)) return;
302315
if (dryRun) {
303316
info('Would remove obsolete "teamRoot" setting from .squad/config.json.');
@@ -1157,7 +1170,12 @@ function runLegacySkillMigration(dest: string, filesUpdated: string[]): void {
11571170
}
11581171
}
11591172

1160-
async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated: string[]): Promise<void> {
1173+
async function runEnsureChecks(
1174+
dest: string,
1175+
templatesDir: string,
1176+
filesUpdated: string[],
1177+
options: { skipCasting?: boolean } = {},
1178+
): Promise<void> {
11611179
const attrAdded = ensureGitattributes(dest);
11621180
if (attrAdded.length > 0) {
11631181
success(`ensured .gitattributes (${attrAdded.length} rules added)`);
@@ -1176,15 +1194,17 @@ async function runEnsureChecks(dest: string, templatesDir: string, filesUpdated:
11761194
filesUpdated.push(...dirsCreated);
11771195
}
11781196

1179-
const castingFiles = ensureCastingDefaults(dest, templatesDir);
1180-
if (castingFiles.length > 0) {
1181-
success(`scaffolded ${castingFiles.length} default casting files`);
1182-
filesUpdated.push(...castingFiles);
1183-
}
1184-
const normalizedCasting = normalizeCastingJsonLineEndings(dest);
1185-
if (normalizedCasting.length > 0) {
1186-
success(`normalized ${normalizedCasting.length} casting JSON file(s) to LF`);
1187-
filesUpdated.push(...normalizedCasting);
1197+
if (!options.skipCasting) {
1198+
const castingFiles = ensureCastingDefaults(dest, templatesDir);
1199+
if (castingFiles.length > 0) {
1200+
success(`scaffolded ${castingFiles.length} default casting files`);
1201+
filesUpdated.push(...castingFiles);
1202+
}
1203+
const normalizedCasting = normalizeCastingJsonLineEndings(dest);
1204+
if (normalizedCasting.length > 0) {
1205+
success(`normalized ${normalizedCasting.length} casting JSON file(s) to LF`);
1206+
filesUpdated.push(...normalizedCasting);
1207+
}
11881208
}
11891209

11901210
const memoryFiles = ensureMemoryGovernanceUpgradeDefaults(dest);
@@ -1535,6 +1555,22 @@ function warnCastingRepairSkipped(headline: string, result: RepairResult): void
15351555
}
15361556
}
15371557

1558+
function detectLegacyCastingFiles(castingDir: string): ReturnType<typeof detectHistoricalCastingSchema> {
1559+
try {
1560+
const registry = JSON.parse(storage.readSync(path.join(castingDir, 'registry.json')) ?? '{}') as unknown;
1561+
const history = JSON.parse(storage.readSync(path.join(castingDir, 'history.json')) ?? '{}') as unknown;
1562+
return detectHistoricalCastingSchema(registry, history);
1563+
} catch {
1564+
return undefined;
1565+
}
1566+
}
1567+
1568+
function reportCastingMigrationFailure(message: string | undefined): void {
1569+
if (!message) return;
1570+
warn(`Upgrade completed, but legacy casting still needs repair: ${message}`);
1571+
info(' The original registry.json and history.json were left unchanged. See "Incomplete legacy provenance" in the upgrading guide, then run `squad upgrade --force` again.');
1572+
}
1573+
15381574
/**
15391575
* Run the upgrade command
15401576
*/
@@ -1573,7 +1609,10 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
15731609
// The repair reconciles a single Init Mode history snapshot; registries with real history keep failing closed.
15741610
const castingDir = path.join(squadDirInfo.path, 'casting');
15751611
const dryRun = options.dryRun ?? false;
1576-
const repair = repairHandwrittenCastingRegistry(castingDir, { dryRun });
1612+
const historicalSchema = detectLegacyCastingFiles(castingDir);
1613+
const repair: RepairResult = historicalSchema
1614+
? { repaired: false, files: [] }
1615+
: repairHandwrittenCastingRegistry(castingDir, { dryRun });
15771616
const handwrittenRepaired = repair.repaired;
15781617
if (handwrittenRepaired) {
15791618
if (dryRun) {
@@ -1586,13 +1625,22 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
15861625
}
15871626
// Legacy registries still go through the strict preflight (and its specific errors); only the
15881627
// preflight's generic failure is enriched with the handwritten-repair reason and fix.
1589-
const castingMigrated = handwrittenRepaired && dryRun
1590-
? false
1591-
: withCastingRepairContext(repair, () => preflightCastingRegistryPair(castingDir, dryRun));
1628+
let castingMigrationFailure: string | undefined;
1629+
let castingMigrated = false;
1630+
try {
1631+
castingMigrated = handwrittenRepaired && dryRun
1632+
? false
1633+
: withCastingRepairContext(repair, () => preflightCastingRegistryPair(castingDir, dryRun));
1634+
} catch (error) {
1635+
castingMigrationFailure = error instanceof Error ? error.message : String(error);
1636+
warn(`Could not migrate legacy casting; continuing with the rest of the upgrade: ${castingMigrationFailure}`);
1637+
}
15921638
if (castingMigrated) {
1593-
info(dryRun
1594-
? 'Would migrate legacy casting state to a managed registry/history pair.'
1595-
: 'Migrated legacy casting state to a managed registry/history pair.');
1639+
if (dryRun) {
1640+
info('Would migrate legacy casting state to a managed registry/history pair.');
1641+
} else {
1642+
warn('Migrated legacy casting state to a managed registry/history pair; byte-exact originals were preserved in its legacy archive.');
1643+
}
15961644
}
15971645

15981646
// --dry-run: preview what upgrade would do without writing (#1052, #2297)
@@ -1639,6 +1687,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
16391687
warn(`Could not plan client files: ${err instanceof Error ? err.message : String(err)}`);
16401688
}
16411689
console.log(`\nRun without --dry-run to apply.\n`);
1690+
reportCastingMigrationFailure(castingMigrationFailure);
16421691
return { fromVersion: oldVersion, toVersion: cliVersion, filesUpdated: [], migrationsRun: [] };
16431692
}
16441693

@@ -1661,7 +1710,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
16611710
// to render the <!-- squad:capabilities --> block, so a 0.9.x project (empty casting/) would
16621711
// otherwise get the block only on the SECOND upgrade (F4, upgrade matrix).
16631712
prepareBuiltinAgents(dest, templatesDir, filesUpdated);
1664-
preflightCastingPair(dest, templatesDir, filesUpdated);
1713+
if (!castingMigrationFailure) preflightCastingPair(dest, templatesDir, filesUpdated);
16651714

16661715
// Refresh squad.agent.md
16671716
const agentSrc = path.join(templatesDir, 'squad.agent.md.template');
@@ -1675,8 +1724,9 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
16751724
}
16761725

16771726
// Run infrastructure ensure checks even when already current
1678-
await runEnsureChecks(dest, templatesDir, filesUpdated);
1727+
await runEnsureChecks(dest, templatesDir, filesUpdated, { skipCasting: !!castingMigrationFailure });
16791728
await refreshClients(dest, squadDirInfo.path, options, filesUpdated);
1729+
reportCastingMigrationFailure(castingMigrationFailure);
16801730

16811731
return {
16821732
fromVersion: oldVersion,
@@ -1696,7 +1746,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
16961746

16971747
// See preflightCastingPair: the capabilities block needs the casting pair first (F4).
16981748
prepareBuiltinAgents(dest, templatesDir, filesUpdated);
1699-
preflightCastingPair(dest, templatesDir, filesUpdated);
1749+
if (!castingMigrationFailure) preflightCastingPair(dest, templatesDir, filesUpdated);
17001750

17011751
storage.mkdirSync(path.dirname(agentDest), { recursive: true });
17021752
writeAgentTemplate(agentSrc, agentDest, cliVersion, mcpConfigMode, isGitHubForMcp, { squadDir: squadDirInfo.path });
@@ -1734,7 +1784,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
17341784
}
17351785

17361786
// Run infrastructure ensure checks
1737-
await runEnsureChecks(dest, templatesDir, filesUpdated);
1787+
await runEnsureChecks(dest, templatesDir, filesUpdated, { skipCasting: !!castingMigrationFailure });
17381788
await refreshClients(dest, squadDirInfo.path, options, filesUpdated);
17391789

17401790
console.log();
@@ -1745,6 +1795,7 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
17451795
dim('Preserves user state: team.md, decisions/, agents/*/history.md');
17461796
}
17471797
console.log();
1798+
reportCastingMigrationFailure(castingMigrationFailure);
17481799

17491800
return {
17501801
fromVersion: fromLabel,

0 commit comments

Comments
 (0)