Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 4 additions & 5 deletions .github/agents/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,15 @@ The `name` parameter generates the human-readable agent ID shown in the tasks pa

**When you detect a directive:**

1. Capture the directive:
- **worktree/orphan backend:** Write it immediately to `.squad/decisions/inbox/copilot-directive-{timestamp}.md` using this format:
1. Capture the directive with the runtime state tools when available:
- Prefer `state.write` to write `decisions/inbox/copilot-directive-{timestamp}.md` using this format:
```
### {timestamp}: User directive
**By:** {user name} (via Copilot)
**What:** {the directive, verbatim or lightly paraphrased}
**Why:** User request — captured for team memory
```
- **git-notes backend:** Persist via:
`powershell .squad/scripts/notes/write-note.ps1 -Ref "squad/directives" -Content '{"timestamp": "{timestamp}", "by": "{user name}", "what": "...", "why": "User request"}'`
- Do **not** run `git notes`, checkout `squad-state`, or manually commit mutable `.squad/` state. The runtime owns state persistence.
2. Acknowledge briefly: `"📌 Captured. {one-line summary of the directive}."`
3. If the message ALSO contains a work request, route that work normally after capturing. If it's directive-only, you're done — no agent spawn needed.

Expand All @@ -264,7 +263,7 @@ When memory tools are available, use them before writing durable memory by hand:
- Search governed memory with `memory.search` before relying only on raw file search.
- Promote, delete, and audit governed entries with `memory.promote`, `memory.delete`, and `memory.audit`.

If memory tools are not available, keep the prompt-only fallback: write local `.squad/` files directly using the decision inbox, agent histories, and skills. Do not claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.
If memory tools are not available, use `state.write`/`state.append` for durable Squad state when those tools are present. Only fall back to local `.squad/` file writes when no runtime state tool exists, and never claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.

### Routing

Expand Down
9 changes: 4 additions & 5 deletions .squad-templates/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,15 @@ The `name` parameter generates the human-readable agent ID shown in the tasks pa

**When you detect a directive:**

1. Capture the directive:
- **worktree/orphan backend:** Write it immediately to `.squad/decisions/inbox/copilot-directive-{timestamp}.md` using this format:
1. Capture the directive with the runtime state tools when available:
- Prefer `state.write` to write `decisions/inbox/copilot-directive-{timestamp}.md` using this format:
```
### {timestamp}: User directive
**By:** {user name} (via Copilot)
**What:** {the directive, verbatim or lightly paraphrased}
**Why:** User request — captured for team memory
```
- **git-notes backend:** Persist via:
`powershell .squad/scripts/notes/write-note.ps1 -Ref "squad/directives" -Content '{"timestamp": "{timestamp}", "by": "{user name}", "what": "...", "why": "User request"}'`
- Do **not** run `git notes`, checkout `squad-state`, or manually commit mutable `.squad/` state. The runtime owns state persistence.
2. Acknowledge briefly: `"📌 Captured. {one-line summary of the directive}."`
3. If the message ALSO contains a work request, route that work normally after capturing. If it's directive-only, you're done — no agent spawn needed.

Expand All @@ -264,7 +263,7 @@ When memory tools are available, use them before writing durable memory by hand:
- Search governed memory with `memory.search` before relying only on raw file search.
- Promote, delete, and audit governed entries with `memory.promote`, `memory.delete`, and `memory.audit`.

If memory tools are not available, keep the prompt-only fallback: write local `.squad/` files directly using the decision inbox, agent histories, and skills. Do not claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.
If memory tools are not available, use `state.write`/`state.append` for durable Squad state when those tools are present. Only fall back to local `.squad/` file writes when no runtime state tool exists, and never claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.

### Routing

Expand Down
9 changes: 4 additions & 5 deletions packages/squad-cli/templates/squad.agent.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,15 @@ The `name` parameter generates the human-readable agent ID shown in the tasks pa

**When you detect a directive:**

1. Capture the directive:
- **worktree/orphan backend:** Write it immediately to `.squad/decisions/inbox/copilot-directive-{timestamp}.md` using this format:
1. Capture the directive with the runtime state tools when available:
- Prefer `state.write` to write `decisions/inbox/copilot-directive-{timestamp}.md` using this format:
```
### {timestamp}: User directive
**By:** {user name} (via Copilot)
**What:** {the directive, verbatim or lightly paraphrased}
**Why:** User request — captured for team memory
```
- **git-notes backend:** Persist via:
`powershell .squad/scripts/notes/write-note.ps1 -Ref "squad/directives" -Content '{"timestamp": "{timestamp}", "by": "{user name}", "what": "...", "why": "User request"}'`
- Do **not** run `git notes`, checkout `squad-state`, or manually commit mutable `.squad/` state. The runtime owns state persistence.
2. Acknowledge briefly: `"📌 Captured. {one-line summary of the directive}."`
3. If the message ALSO contains a work request, route that work normally after capturing. If it's directive-only, you're done — no agent spawn needed.

Expand All @@ -264,7 +263,7 @@ When memory tools are available, use them before writing durable memory by hand:
- Search governed memory with `memory.search` before relying only on raw file search.
- Promote, delete, and audit governed entries with `memory.promote`, `memory.delete`, and `memory.audit`.

If memory tools are not available, keep the prompt-only fallback: write local `.squad/` files directly using the decision inbox, agent histories, and skills. Do not claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.
If memory tools are not available, use `state.write`/`state.append` for durable Squad state when those tools are present. Only fall back to local `.squad/` file writes when no runtime state tool exists, and never claim provider-backed Copilot Memory, semantic indexing, or remote deletion unless a configured tool or CLI bridge performed the operation. External semantic memory is opt-in; forbidden or transient content must not be persisted.

### Routing

Expand Down
16 changes: 14 additions & 2 deletions packages/squad-sdk/src/state-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,11 +532,15 @@ export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverr
}
}
} catch { /* fall through */ }
const explicitBackend = cliOverride !== undefined || configBackend !== undefined;
const chosen = normalizeBackendType(cliOverride ?? configBackend ?? 'local');
try {
return createBackend(chosen, squadDir, repoRoot);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (explicitBackend && chosen !== 'local') {
throw new Error(`State backend '${chosen}' failed: ${msg}`);
}
console.warn(`Warning: State backend '${chosen}' failed: ${msg}. Falling back to 'local'.`);
return new WorktreeBackend(squadDir);
}
Expand All @@ -557,9 +561,17 @@ function normalizeBackendType(type: string): StateBackendType {
function createBackend(type: StateBackendType, squadDir: string, repoRoot: string): StateBackend {
switch (type) {
case 'local': return new WorktreeBackend(squadDir);
case 'orphan': return new OrphanBranchBackend(repoRoot);
case 'two-layer': return new TwoLayerBackend(repoRoot);
case 'orphan':
requireGitRepository(repoRoot);
return new OrphanBranchBackend(repoRoot);
case 'two-layer':
requireGitRepository(repoRoot);
return new TwoLayerBackend(repoRoot);
case 'external': return new WorktreeBackend(squadDir); // Stub — PR #797
default: throw new Error(`Unknown state backend type: ${type}`);
}
}

function requireGitRepository(repoRoot: string): void {
gitExecOrThrow(['rev-parse', '--git-dir'], repoRoot);
}
215 changes: 215 additions & 0 deletions packages/squad-sdk/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { trace, SpanStatusCode } from '../runtime/otel-api.js';
import type { StorageProvider } from '../storage/storage-provider.js';
import { FSStorageProvider } from '../storage/fs-storage-provider.js';
import type { SquadState } from '../state/squad-state.js';
import { validateStateKey } from '../state-backend.js';
import { spawnParallel, type FanOutDependencies } from '../coordinator/fan-out.js';
import { LocalMemoryStore, type CopilotMemoryProviderClient, type MemoryClass } from '../memory/index.js';

Expand Down Expand Up @@ -89,6 +90,28 @@ export interface MemoryEntry {
content: string;
}

export interface StateReadRequest {
key: string;
}

export interface StateWriteRequest {
key: string;
content: string;
}

export interface StateAppendRequest {
key: string;
content: string;
}

export interface StateDeleteRequest {
key: string;
}

export interface StateListRequest {
dir?: string;
}

export interface StatusQuery {
/** Filter by agent name */
agentName?: string;
Expand Down Expand Up @@ -210,6 +233,28 @@ function sanitizeErrorForLlm(error: unknown, squadRoot: string): string {
return firstLine.slice(0, 512);
}

function normalizeStateToolKey(key: string): string {
const normalized = key
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/^\.squad(?:\/|$)/, '')
.replace(/\/+$/, '');
validateStateKey(normalized);
return normalized;
}
Comment on lines +236 to +244

function normalizeStateToolDir(dir?: string): string {
const normalized = (dir ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/^\.squad(?:\/|$)/, '')
.replace(/\/+$/, '');
if (normalized.length > 0) {
validateStateKey(normalized);
}
return normalized;
}

// --- Tool Registry ---

export class ToolRegistry {
Expand Down Expand Up @@ -565,6 +610,170 @@ export class ToolRegistry {
},
});

const stateRead = defineTool<StateReadRequest>({
name: 'state.read',
description: 'Read mutable Squad state by key through the configured state backend. Keys are relative to .squad/; do not use shell git or direct file reads for mutable state.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'State key relative to .squad/, for example decisions.md or agents/data/history.md' },
},
required: ['key'],
},
handler: async (args) => {
try {
const key = normalizeStateToolKey(args.key);
const content = this.storage.readSync(path.join(this.squadRoot, key));
if (content === undefined) {
return {
textResultForLlm: `State key not found: ${key}`,
resultType: 'failure',
error: 'State key does not exist',
};
}
return {
textResultForLlm: content,
resultType: 'success',
toolTelemetry: { key },
};
} catch (error) {
return {
textResultForLlm: `Failed to read state: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: String(error),
};
}
},
});

const stateWrite = defineTool<StateWriteRequest>({
name: 'state.write',
description: 'Write mutable Squad state through the configured state backend. Keys are relative to .squad/; do not use shell git, checkout squad-state, or direct file writes for mutable state.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'State key relative to .squad/' },
content: { type: 'string', description: 'Complete content to store at the key' },
},
required: ['key', 'content'],
},
handler: async (args) => {
try {
const key = normalizeStateToolKey(args.key);
this.storage.writeSync(path.join(this.squadRoot, key), args.content);
return {
textResultForLlm: `State written: ${key}`,
resultType: 'success',
toolTelemetry: { key },
};
} catch (error) {
return {
textResultForLlm: `Failed to write state: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: String(error),
};
}
},
});

const stateAppend = defineTool<StateAppendRequest>({
name: 'state.append',
description: 'Append to mutable Squad state through the configured state backend. Keys are relative to .squad/.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'State key relative to .squad/' },
content: { type: 'string', description: 'Content to append' },
},
required: ['key', 'content'],
},
handler: async (args) => {
try {
const key = normalizeStateToolKey(args.key);
this.storage.appendSync(path.join(this.squadRoot, key), args.content);
return {
textResultForLlm: `State appended: ${key}`,
resultType: 'success',
toolTelemetry: { key },
};
} catch (error) {
return {
textResultForLlm: `Failed to append state: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: String(error),
};
}
},
});

const stateDelete = defineTool<StateDeleteRequest>({
name: 'state.delete',
description: 'Delete mutable Squad state through the configured state backend. Keys are relative to .squad/.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', description: 'State key relative to .squad/' },
},
required: ['key'],
},
handler: async (args) => {
try {
const key = normalizeStateToolKey(args.key);
this.storage.deleteSync(path.join(this.squadRoot, key));
return {
textResultForLlm: `State deleted: ${key}`,
resultType: 'success',
toolTelemetry: { key },
};
} catch (error) {
return {
textResultForLlm: `Failed to delete state: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: String(error),
};
}
},
});

const stateList = defineTool<StateListRequest>({
name: 'state.list',
description: 'List mutable Squad state entries through the configured state backend. Directories are relative to .squad/.',
parameters: {
type: 'object',
properties: {
dir: { type: 'string', description: 'Directory relative to .squad/; omit for root' },
},
},
handler: async (args) => {
try {
const dir = normalizeStateToolDir(args.dir);
const entries = this.storage.listSync(path.join(this.squadRoot, dir));
return {
textResultForLlm: entries.length > 0 ? entries.join('\n') : '(empty)',
resultType: 'success',
toolTelemetry: { dir },
};
} catch (error) {
return {
textResultForLlm: `Failed to list state: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: String(error),
};
}
},
});

const stateHealth = defineTool<Record<string, never>>({
name: 'state.health',
description: 'Report the active Squad state storage layer so agents can verify they are using runtime-owned state instead of manual git/file choreography.',
parameters: { type: 'object', properties: {} },
handler: async () => ({
textResultForLlm: `State backend storage: ${this.storage.constructor.name}`,
resultType: 'success',
toolTelemetry: { storage: this.storage.constructor.name },
}),
});

Comment on lines +697 to +796
const memoryClassify = defineTool<GovernedMemoryRequest>({
name: 'memory.classify',
description: 'Classify proposed memory with Squad governance policy without persisting content.',
Expand Down Expand Up @@ -935,6 +1144,12 @@ export class ToolRegistry {
this.tools.set('squad_route', squadRoute);
this.tools.set('squad_decide', squadDecide);
this.tools.set('squad_memory', squadMemory);
this.tools.set('state.read', stateRead);
this.tools.set('state.write', stateWrite);
this.tools.set('state.append', stateAppend);
this.tools.set('state.delete', stateDelete);
this.tools.set('state.list', stateList);
this.tools.set('state.health', stateHealth);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should all squad tools be namespaced with a squad prefix?

this.tools.set('memory.classify', memoryClassify);
this.tools.set('memory.write', memoryWrite);
this.tools.set('memory.search', memorySearch);
Expand Down
Loading
Loading