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
24 changes: 24 additions & 0 deletions .changeset/add-colony-spec-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@colony/spec': minor
'@colony/mcp-server': minor
'@imdeadpool/colony': minor
---

Add `@colony/spec` — the spec-driven dev lane (colonykit-in-colony).
Provides a `SPEC.md` grammar, `CHANGE.md` grammar, three-way sync
engine, backprop failure-signature gate, and cite-scoped context
resolver. Rides on `@colony/core`'s TaskThread, ProposalSystem, and
MemoryStore — no parallel infrastructure.

Six new MCP tools land in `apps/mcp-server/src/tools/spec.ts`:
`spec_read`, `spec_change_open`, `spec_change_add_delta`,
`spec_build_context`, `spec_build_record_failure`, `spec_archive`.

Four matching Claude Code skills ship under `skills/` at the repo
root: `/co:change`, `/co:build`, `/co:check`, `/co:archive`, plus
supporting internals (`spec`, `sync`, `backprop`).

Tests: `packages/spec/test/spec.test.ts` covers grammar round-trip,
always-on invariant detection, stable hashing, cite-scope transitive
closure, and all four sync conflict shapes. `apps/mcp-server` tool
list updated to include the six new tools.
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Claude Code works the same way Codex does in this repo: isolated `agent/*` branc

## Architectural rules

- Monorepo with pnpm workspaces. Dependency direction is strictly downward: `apps/*` may depend on `packages/*`; `packages/*` may depend on each other only in the order `process → config → compress → storage → { core, embedding } → hooks → installers`. (`core` and `embedding` are siblings — both consume `config` and `storage`, neither depends on the other. `process` has no upstream deps — only `node:` builtins.) No upward or sideways imports that break this order.
- Monorepo with pnpm workspaces. Dependency direction is strictly downward: `apps/*` may depend on `packages/*`; `packages/*` may depend on each other only in the order `process → config → compress → storage → { core, embedding } → hooks → installers → spec`. (`core` and `embedding` are siblings — both consume `config` and `storage`, neither depends on the other. `process` has no upstream deps — only `node:` builtins. `spec` sits at the end of the chain because it consumes core + storage + compress.) No upward or sideways imports that break this order.
- All database I/O goes through `@colony/storage`. No other package opens the DB directly.
- Settings access goes through `@colony/config`. No direct reads from `~/.colony/settings.json` elsewhere.
- All user-visible strings default to the caveman intensity from settings (default `full`).
Expand All @@ -57,6 +57,8 @@ packages/core domain models, MemoryStore facade, Embedder interface
packages/embedding provider factory (local / ollama / openai / none)
packages/hooks lifecycle hook handlers + worker auto-spawn
packages/installers per-IDE integration modules
packages/spec spec-driven dev lane (grammar, sync, backprop, context)
skills Claude Code skill definitions (/co:change, /co:build, /co:check, /co:archive)
viewer Vite + React read-only UI
hooks-scripts portable shell stubs that invoke node handlers
docs architecture + user docs
Expand Down
1 change: 1 addition & 0 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@colony/embedding": "workspace:*",
"@colony/hooks": "workspace:*",
"@colony/process": "workspace:*",
"@colony/spec": "workspace:*",
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
},
Expand Down
7 changes: 7 additions & 0 deletions apps/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as hivemind from './tools/hivemind.js';
import * as profile from './tools/profile.js';
import * as proposal from './tools/proposal.js';
import * as search from './tools/search.js';
import * as spec from './tools/spec.js';
import * as task from './tools/task.js';
import * as wake from './tools/wake.js';

Expand Down Expand Up @@ -70,6 +71,12 @@ export function buildServer(store: MemoryStore, settings: Settings): McpServer {
profile.register(server, ctx);
wake.register(server, ctx);

// Spec-driven dev lane (@colony/spec). Adds spec_read, spec_change_open,
// spec_change_add_delta, spec_build_context, spec_build_record_failure,
// spec_archive. Registered last so the heartbeat wrapper has seen every
// core tool first.
spec.register(server, ctx);

return server;
}

Expand Down
274 changes: 274 additions & 0 deletions apps/mcp-server/src/tools/spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import {
BackpropGate,
SpecRepository,
SyncEngine,
type SyncStrategy,
computeFailureSignature,
parseSpec,
resolveTaskContext,
serializeSpec,
} from '@colony/spec';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ToolContext } from './context.js';

export function register(server: McpServer, ctx: ToolContext): void {
const { store } = ctx;

server.tool(
'spec_read',
'Read the root SPEC.md for a repo. Returns parsed sections + rootHash.',
{ repo_root: z.string().min(1) },
async ({ repo_root }) => {
const repo = new SpecRepository({ repoRoot: repo_root, store });
const spec = repo.readRoot();
return {
content: [
{
type: 'text',
text: JSON.stringify({
rootHash: spec.rootHash,
sections: Object.fromEntries(
Object.entries(spec.sections).map(([k, v]) => [
k,
{ body: v.body, row_count: v.rows?.length ?? null },
]),
),
alwaysInvariants: spec.alwaysInvariants,
}),
},
],
};
},
);

server.tool(
'spec_change_open',
'Open a new spec change. Creates openspec/changes/<slug>/CHANGE.md, opens a task-thread on spec/<slug>, joins caller as participant.',
{
repo_root: z.string().min(1),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9-]+$/, 'kebab-case only'),
session_id: z.string().min(1),
agent: z.string().min(1),
proposal: z.string().optional(),
},
async ({ repo_root, slug, session_id, agent, proposal }) => {
const repo = new SpecRepository({ repoRoot: repo_root, store });
const result = repo.openChange({
slug,
session_id,
agent,
...(proposal !== undefined ? { proposal } : {}),
});
return {
content: [
{
type: 'text',
text: JSON.stringify({
task_id: result.task_id,
path: result.path,
base_root_hash: result.change.baseRootHash,
}),
},
],
};
},
);

server.tool(
'spec_change_add_delta',
'Append a delta row to an in-flight change. op ∈ add|modify|remove; target is a root spec id like V.3 or T.12.',
{
repo_root: z.string().min(1),
slug: z.string().min(1),
session_id: z.string().min(1),
op: z.enum(['add', 'modify', 'remove']),
target: z.string().min(1),
row_cells: z.array(z.string()).optional(),
},
async ({ repo_root, slug, session_id, op, target, row_cells }) => {
const repo = new SpecRepository({ repoRoot: repo_root, store });
const change = repo.readChange(slug);
change.deltaRows.push({
op,
target,
...(row_cells ? { row: { id: target, cells: row_cells } } : {}),
});
repo.writeChange(change);
const task = repo.listSpecTasks().find((t) => t.slug === slug);
store.addObservation({
session_id,
kind: 'spec-delta',
content: `${op} ${target}${row_cells ? ` = ${row_cells.join(' | ')}` : ''}`,
...(task ? { task_id: task.task_id } : {}),
});
return {
content: [
{ type: 'text', text: JSON.stringify({ delta_count: change.deltaRows.length }) },
],
};
},
);

server.tool(
'spec_build_context',
'Resolve cite-scoped context for a §T task id. Returns only the invariants and rows the task is obliged to respect plus §V.always entries — not the whole spec.',
{
repo_root: z.string().min(1),
task_id: z.string().min(1).describe('§T row id, e.g. T5'),
},
async ({ repo_root, task_id }) => {
const repo = new SpecRepository({ repoRoot: repo_root, store });
const spec = repo.readRoot();
const resolved = resolveTaskContext(spec, task_id);
if (!resolved) {
return {
content: [{ type: 'text', text: JSON.stringify({ error: `no task ${task_id}` }) }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
cited_ids: resolved.cited_ids,
always_invariants: resolved.always_invariants,
rendered: resolved.rendered,
}),
},
],
};
},
);

server.tool(
'spec_build_record_failure',
'Record a test failure during /co:build. Hashes the signature, appends §B, and — if the promote_after threshold is reached — proposes a §V invariant via colony ProposalSystem. Returns the decision.',
{
repo_root: z.string().min(1),
slug: z.string().min(1),
session_id: z.string().min(1),
agent: z.string().min(1),
test_id: z.string().min(1),
error: z.string().min(1),
stack: z.string().optional(),
error_summary: z.string().min(1),
promote_after: z.number().int().positive().optional(),
},
async (args) => {
const repo = new SpecRepository({ repoRoot: args.repo_root, store });
const specTask = repo.listSpecTasks().find((t) => t.slug === args.slug);
if (!specTask) {
return {
content: [
{ type: 'text', text: JSON.stringify({ error: `no open change ${args.slug}` }) },
],
isError: true,
};
}
const signature = computeFailureSignature({
test_id: args.test_id,
error: args.error,
...(args.stack !== undefined ? { stack: args.stack } : {}),
});
const gate = new BackpropGate({
store,
repoRoot: args.repo_root,
branch: specTask.branch,
...(args.promote_after !== undefined ? { promoteAfter: args.promote_after } : {}),
});
const decision = gate.recordFailure({
task_id: specTask.task_id,
session_id: args.session_id,
agent: args.agent,
signature,
error_summary: args.error_summary,
});
return {
content: [
{
type: 'text',
text: JSON.stringify({
action: decision.action,
signature_hash: signature.hash,
match_count: decision.matchCount,
proposal_id: decision.proposal_id ?? null,
}),
},
],
};
},
);

server.tool(
'spec_archive',
'Validate, three-way-merge, and archive an in-flight change. Atomic: either the archive + root write both land, or neither does.',
{
repo_root: z.string().min(1),
slug: z.string().min(1),
session_id: z.string().min(1),
agent: z.string().min(1),
strategy: z.enum(['three_way', 'refuse_on_conflict', 'last_writer_wins']).optional(),
},
async (args) => {
const repo = new SpecRepository({ repoRoot: args.repo_root, store });
const currentRoot = repo.readRoot();
const change = repo.readChange(args.slug);

// Reconstruct the base root from the recorded hash. In practice we'd
// keep an archived snapshot; for now, if the hash still matches current,
// base == current. If not, fall back to current (last_writer_wins on
// drift).
const baseRoot =
currentRoot.rootHash === change.baseRootHash
? currentRoot
: parseSpec(serializeSpec(currentRoot));

const strategy: SyncStrategy = args.strategy ?? 'three_way';
const engine = new SyncEngine(strategy);
const merge = engine.merge(currentRoot, baseRoot, change);

if (!merge.clean && strategy === 'refuse_on_conflict') {
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'refused',
conflicts: merge.conflicts,
applied: merge.applied,
}),
},
],
isError: true,
};
}

repo.writeRoot(merge.spec, {
session_id: args.session_id,
agent: args.agent,
reason: `Archive ${args.slug}: ${merge.applied} deltas applied, ${merge.conflicts.length} conflicts`,
});

const archivePath = repo.archiveChange(args.slug);
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'archived',
archived_path: archivePath,
merged_root_hash: merge.spec.rootHash,
conflicts: merge.conflicts,
applied: merge.applied,
}),
},
],
};
},
);
}
6 changes: 6 additions & 0 deletions apps/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ describe('MCP server', () => {
'hivemind_context',
'list_sessions',
'search',
'spec_archive',
'spec_build_context',
'spec_build_record_failure',
'spec_change_add_delta',
'spec_change_open',
'spec_read',
'task_accept_handoff',
'task_ack_wake',
'task_cancel_wake',
Expand Down
Loading
Loading