Skip to content
Closed
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
17 changes: 17 additions & 0 deletions .changeset/git-state-backends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@bradygaster/squad-sdk": minor
"@bradygaster/squad-cli": minor
---

feat(sdk): git-notes + orphan-branch state backends for .squad/

Adds two git-native state storage backends as alternatives to the worktree
and external directory approaches:

- **git-notes** (`refs/notes/squad`): State stored in git notes ref. Survives
branch switches, invisible in diffs and PRs.
- **orphan-branch** (`squad-state`): Dedicated orphan branch with no common
ancestor. State files never appear in main.

Configure via `.squad/config.json`: `{ "stateBackend": "git-notes" }` or
the `--state-backend` CLI flag.
35 changes: 34 additions & 1 deletion packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ async function main(): Promise<void> {
console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`);
console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`);
console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`);
console.log(` ${BOLD}--state-backend${RESET} State storage: worktree | external | git-notes | orphan`);
console.log(`\nInstallation:`);
console.log(` npm install --save-dev @bradygaster/squad-cli`);
console.log(`\nInsider channel:`);
Expand Down Expand Up @@ -295,8 +296,33 @@ async function main(): Promise<void> {
const noWorkflows = args.includes('--no-workflows');
const sdk = args.includes('--sdk');
const roles = args.includes('--roles');

// --state-backend: write stateBackend into .squad/config.json on init
const stateBackendIdx = args.indexOf('--state-backend');
const stateBackendVal = (stateBackendIdx !== -1 && args[stateBackendIdx + 1])
? args[stateBackendIdx + 1]
: undefined;

Comment on lines +301 to +305
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--state-backend parsing accepts whatever token follows, including another flag when the value is omitted (e.g. --state-backend --sdk). Validate that the next arg exists, does not start with -, and is one of the supported backend values before using it.

Suggested change
const stateBackendIdx = args.indexOf('--state-backend');
const stateBackendVal = (stateBackendIdx !== -1 && args[stateBackendIdx + 1])
? args[stateBackendIdx + 1]
: undefined;
const supportedStateBackends = new Set(['fs', 'sqlite']);
const stateBackendIdx = args.indexOf('--state-backend');
let stateBackendVal: string | undefined;
if (stateBackendIdx !== -1) {
const candidate = args[stateBackendIdx + 1];
if (!candidate || candidate.startsWith('-') || !supportedStateBackends.has(candidate)) {
fatal(`Invalid value for --state-backend. Supported values: ${Array.from(supportedStateBackends).join(', ')}`);
}
stateBackendVal = candidate;
}

Copilot uses AI. Check for mistakes.
// Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).catch(err => {
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).then(async () => {
if (stateBackendVal) {
const { join } = await import('node:path');
const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
const squadDir = join(dest, '.squad');
if (!existsSync(squadDir)) mkdirSync(squadDir, { recursive: true });
const configPath = join(squadDir, 'config.json');
// Read existing config first, then merge (avoids overwriting unrelated keys)
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch { /* fresh config */ }
config['stateBackend'] = stateBackendVal;
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`✓ State backend set to '${stateBackendVal}' in .squad/config.json`);
}
}).catch(err => {
fatal(err.message);
});
Comment on lines +307 to 327
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When .squad/config.json is missing or malformed, this code overwrites it with { stateBackend: ... }, which can discard existing settings and also creates a config file that fails loadDirConfig()'s schema check (missing version/teamRoot). Prefer: (1) refuse to overwrite malformed JSON, and (2) when creating a new config file, write a schema-valid object (or store stateBackend under an existing schema such as watch/another dedicated section).

Suggested change
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).then(async () => {
if (stateBackendVal) {
const { join } = await import('node:path');
const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
const squadDir = join(dest, '.squad');
if (!existsSync(squadDir)) mkdirSync(squadDir, { recursive: true });
const configPath = join(squadDir, 'config.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch { /* fresh config */ }
config['stateBackend'] = stateBackendVal;
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`✓ State backend set to '${stateBackendVal}' in .squad/config.json`);
}
}).catch(err => {
fatal(err.message);
});
try {
await runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal });
if (stateBackendVal) {
const { join } = await import('node:path');
const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs');
const squadDir = join(dest, '.squad');
if (!existsSync(squadDir)) mkdirSync(squadDir, { recursive: true });
const configPath = join(squadDir, 'config.json');
if (!existsSync(configPath)) {
fatal('Failed to set state backend: .squad/config.json was not created by init.');
}
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
fatal('Failed to set state backend: existing .squad/config.json contains malformed JSON. Please fix it manually and retry.');
}
if (!parsedConfig || typeof parsedConfig !== 'object' || Array.isArray(parsedConfig)) {
fatal('Failed to set state backend: .squad/config.json must contain a JSON object.');
}
const config = parsedConfig as Record<string, unknown>;
if (!('version' in config) || !('teamRoot' in config)) {
fatal('Failed to set state backend: .squad/config.json is missing required fields (version, teamRoot).');
}
config['stateBackend'] = stateBackendVal;
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.log(`✓ State backend set to '${stateBackendVal}' in .squad/config.json`);
}
} catch (err) {
fatal(err instanceof Error ? err.message : String(err));
}

Copilot uses AI. Check for mistakes.
return;
Expand Down Expand Up @@ -377,6 +403,12 @@ async function main(): Promise<void> {
if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false;
}

// --state-backend flag for watch command
const watchStateBackendIdx = args.indexOf('--state-backend');
const rawWatchStateBackend = (watchStateBackendIdx !== -1 && args[watchStateBackendIdx + 1])
? args[watchStateBackendIdx + 1] as string
: undefined;

// Legacy flag compat: --board-project sets board sub-option
const boardProjectIdx = args.indexOf('--board-project');
if (boardProjectIdx !== -1 && args[boardProjectIdx + 1]) {
Expand All @@ -394,6 +426,7 @@ async function main(): Promise<void> {
timeout,
copilotFlags,
agentCmd,
stateBackend: watchStateBackend as any,
capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
});

Expand Down
6 changes: 5 additions & 1 deletion packages/squad-cli/src/cli/commands/watch/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface WatchConfig {
copilotFlags?: string;
/** Hidden — fully override the agent command. */
agentCmd?: string;
/** State storage backend: worktree | external | git-notes | orphan */
stateBackend?: string;
/** Per-capability config: `true` / `false` / object with sub-options. */
capabilities: Record<string, boolean | Record<string, unknown>>;
}
Expand Down Expand Up @@ -63,6 +65,7 @@ export function loadWatchConfig(
timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout,
copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags,
agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd,
stateBackend: cliOverrides.stateBackend ?? fileConfig.stateBackend ?? DEFAULTS.stateBackend,
capabilities: {
...DEFAULTS.capabilities,
...(fileConfig.capabilities ?? {}),
Expand All @@ -83,10 +86,11 @@ function normalizeFileConfig(raw: Record<string, unknown>): Partial<WatchConfig>
if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout'];
if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags'];
if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd'];
if (typeof raw['stateBackend'] === 'string') result.stateBackend = raw['stateBackend'];

// Everything else is a capability key
const caps: Record<string, boolean | Record<string, unknown>> = {};
const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd']);
const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'stateBackend']);
for (const [key, value] of Object.entries(raw)) {
if (reserved.has(key)) continue;
if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) {
Expand Down
4 changes: 4 additions & 0 deletions packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export * from './roles/index.js';
export * from './platform/index.js';
export * from './storage/index.js';

// Git-native state backends (Issue #807)
export type { StateBackend, StateBackendType, StateBackendConfig } from './state-backend.js';
export { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend } from './state-backend.js';

// State facade (Phase 2) — namespaced to avoid conflicts with existing config/sharing exports
export {
// Error classes
Expand Down
3 changes: 3 additions & 0 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface SquadDirConfig {
consult?: boolean;
/** True when extraction is disabled for consult sessions (read-only consultation) */
extractionDisabled?: boolean;
/** State storage backend: worktree | external | git-notes | orphan */
stateBackend?: string;
}
Comment on lines 34 to 39
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SquadDirConfig.stateBackend is typed as string, which loses type-safety and allows invalid values to propagate. Consider importing StateBackendType and typing this as StateBackendType, or at least narrowing/validating to the supported set when loading config.json.

Copilot uses AI. Check for mistakes.

/**
Expand Down Expand Up @@ -222,6 +224,7 @@ export function loadDirConfig(squadDir: string): SquadDirConfig | null {
projectKey: typeof parsed.projectKey === 'string' ? parsed.projectKey : null,
consult: parsed.consult === true ? true : undefined,
extractionDisabled: parsed.extractionDisabled === true ? true : undefined,
stateBackend: typeof parsed.stateBackend === 'string' ? parsed.stateBackend : undefined,
};
}
return null;
Expand Down
Loading
Loading