Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/legacy-assignment-provenance.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/legacy-casting-upgrade.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/portable-mcp-preserve-newlines.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions docs/src/content/docs/features/standalone-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 104 additions & 7 deletions docs/src/content/docs/scenarios/upgrading.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand All @@ -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)
```
Expand All @@ -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.

---

Expand All @@ -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-<content-hash>/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+)
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`);
Expand All @@ -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`);
Expand Down
41 changes: 41 additions & 0 deletions packages/squad-cli/src/cli/core/managed-file.ts
Original file line number Diff line number Diff line change
@@ -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'))) {
Comment thread
bradygaster marked this conversation as resolved.
Resolved
copyFileSync(source, destination);
return;
}
writeManagedText(destination, text);
}
32 changes: 18 additions & 14 deletions packages/squad-cli/src/cli/core/mcp-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 &&
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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 };
}

Expand Down
Loading
Loading