From 14437ab04f94dc2ac8984054838e7b3bb58ed8ea Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 24 Apr 2026 11:53:21 +0200 Subject: [PATCH] feat(spec,mcp-server): add @colony/spec and six spec_* MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit colonykit-in-colony: a spec-driven dev lane that rides on @colony/core's TaskThread, ProposalSystem, and MemoryStore instead of introducing parallel infrastructure. **packages/spec/** (new workspace package): - grammar.ts — SPEC.md parser / serializer, §G §C §I §V §T §B sections, always-on invariants (`.always` id suffix), FNV-1a stable hash - change.ts — CHANGE.md with §P §S §T §B plus base_root_hash front-matter for three-way merge - sync.ts — three-way merge with three strategies: three_way (default), refuse_on_conflict, last_writer_wins - backprop.ts — failure-signature gate (test-id + error-class + top-3 frames). Promotes a §V draft via ProposalSystem only after promote_after matches (default 2) — no flake-driven invariant churn - context.ts — cite-scoped context loader; resolves a §T id to its transitive closure of cites plus §V.always - repository.ts — filesystem ↔ MemoryStore bridge, atomic archive via tempdir-then-rename so a crash mid-move leaves either the archived or the pre-archive state - hash.ts — base_root_hash computation + verification - constants.ts — SPEC_OBSERVATION_KINDS, SPEC_BRANCH_PREFIX **apps/mcp-server/src/tools/spec.ts** (new): Six MCP tools — spec_read, spec_change_open, spec_change_add_delta, spec_build_context, spec_build_record_failure, spec_archive — wired in via the same `register(server, ctx)` pattern as the other tool groups. Uses MemoryStore.addObservation directly for spec-kind observations so we don't need to widen TaskThread.post's kind enum. **skills/**: /co:change, /co:build, /co:check, /co:archive definitions for Claude Code users who want to drive the spec flow via slash commands. Not loaded by any runtime — consumed by hand when a user opts in. Tests: packages/spec covers grammar round-trip, always-on detection, stable hashing, cite-scope transitive closure, all four sync conflict shapes (10/10 green). apps/mcp-server listTools assertion extended with the six new tools (17/17 green). This change intentionally does NOT add: colonykit init CLI, token- budget CI gate, design.md scaffolding, or cross-change concurrent-edit detection. Prove the core loop on one change before adding surface. --- .changeset/add-colony-spec-package.md | 24 +++ CLAUDE.md | 4 +- apps/mcp-server/package.json | 1 + apps/mcp-server/src/server.ts | 7 + apps/mcp-server/src/tools/spec.ts | 274 ++++++++++++++++++++++++++ apps/mcp-server/test/server.test.ts | 6 + packages/spec/README.md | 79 ++++++++ packages/spec/package.json | 25 +++ packages/spec/src/backprop.ts | 263 ++++++++++++++++++++++++ packages/spec/src/change.ts | 112 +++++++++++ packages/spec/src/constants.ts | 41 ++++ packages/spec/src/context.ts | 124 ++++++++++++ packages/spec/src/grammar.ts | 159 +++++++++++++++ packages/spec/src/hash.ts | 26 +++ packages/spec/src/index.ts | 26 +++ packages/spec/src/repository.ts | 185 +++++++++++++++++ packages/spec/src/sync.ts | 191 ++++++++++++++++++ packages/spec/test/spec.test.ts | 186 +++++++++++++++++ packages/spec/tsconfig.json | 16 ++ pnpm-lock.yaml | 25 +++ skills/archive/SKILL.md | 42 ++++ skills/build/SKILL.md | 67 +++++++ skills/change/SKILL.md | 43 ++++ skills/check/SKILL.md | 49 +++++ 24 files changed, 1974 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-colony-spec-package.md create mode 100644 apps/mcp-server/src/tools/spec.ts create mode 100644 packages/spec/README.md create mode 100644 packages/spec/package.json create mode 100644 packages/spec/src/backprop.ts create mode 100644 packages/spec/src/change.ts create mode 100644 packages/spec/src/constants.ts create mode 100644 packages/spec/src/context.ts create mode 100644 packages/spec/src/grammar.ts create mode 100644 packages/spec/src/hash.ts create mode 100644 packages/spec/src/index.ts create mode 100644 packages/spec/src/repository.ts create mode 100644 packages/spec/src/sync.ts create mode 100644 packages/spec/test/spec.test.ts create mode 100644 packages/spec/tsconfig.json create mode 100644 skills/archive/SKILL.md create mode 100644 skills/build/SKILL.md create mode 100644 skills/change/SKILL.md create mode 100644 skills/check/SKILL.md diff --git a/.changeset/add-colony-spec-package.md b/.changeset/add-colony-spec-package.md new file mode 100644 index 0000000..4af3f70 --- /dev/null +++ b/.changeset/add-colony-spec-package.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e1ebfa0..0ffc636 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). @@ -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 diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index eae2bd8..5097fab 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -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" }, diff --git a/apps/mcp-server/src/server.ts b/apps/mcp-server/src/server.ts index b978ac6..879c602 100644 --- a/apps/mcp-server/src/server.ts +++ b/apps/mcp-server/src/server.ts @@ -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'; @@ -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; } diff --git a/apps/mcp-server/src/tools/spec.ts b/apps/mcp-server/src/tools/spec.ts new file mode 100644 index 0000000..8c362ac --- /dev/null +++ b/apps/mcp-server/src/tools/spec.ts @@ -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//CHANGE.md, opens a task-thread on spec/, 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, + }), + }, + ], + }; + }, + ); +} diff --git a/apps/mcp-server/test/server.test.ts b/apps/mcp-server/test/server.test.ts index 4e55a48..0e81e65 100644 --- a/apps/mcp-server/test/server.test.ts +++ b/apps/mcp-server/test/server.test.ts @@ -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', diff --git a/packages/spec/README.md b/packages/spec/README.md new file mode 100644 index 0000000..20805fa --- /dev/null +++ b/packages/spec/README.md @@ -0,0 +1,79 @@ +# @colony/spec · colonykit-in-colony + +Spec-driven dev as a colony lane. Adds six MCP tools (`spec_read`, `spec_change_open`, `spec_change_add_delta`, `spec_build_context`, `spec_build_record_failure`, `spec_archive`) and a parallel set of Claude Code skills (`/co:change`, `/co:build`, `/co:check`, `/co:archive`). Rides on colony's `TaskThread`, `ProposalSystem`, `MemoryStore`, and `@colony/compress` — no parallel infra. + +## what this package is + +The colonykit merge plan, condensed to the parts colony doesn't already have: + +- **Spec grammar** (`grammar.ts`). Parse/serialize `SPEC.md` with the six fixed sections §G/§C/§I/§V/§T/§B. Round-trip stable. Always-on invariants identified by `.always` id suffix. +- **Change grammar** (`change.ts`). In-flight `CHANGE.md` with §P/§S/§T/§B plus `base_root_hash` front-matter for the three-way merge ancestor. +- **Sync engine** (`sync.ts`). Three-way merge with three strategies from the v2 plan: `three_way` (default, refuse-on-conflict), `refuse_on_conflict` (explicit), `last_writer_wins` (opt-in, warns). +- **Backprop gate** (`backprop.ts`). Failure signature hashing (test-id + error-class + top-3 frames). Promotes a §V draft via `colony.ProposalSystem` only after `promote_after` matching failures (default 2). No flake-driven invariant churn. +- **Cite-scoped context** (`context.ts`). Given a §T id, returns the transitive closure of its cites plus §V.always. The loader the `/co:build` skill uses — every task loads only what it's obliged to respect. + +## what this package is NOT + +- Not a standalone CLI. Drops into the colony monorepo as a workspace package; surfaces via the existing colony MCP server and hooks. +- Not a replacement for `task_thread`, `task_propose`, `task_reinforce`, `attention_inbox`. Those still do the work — this package just calls them with spec-shaped arguments. +- Not a new storage schema. Spec observations live on task-threads where `branch` starts with `spec/`. Filtering is a branch-prefix check. + +## integration (three files to change) + +``` +apps/mcp-server/package.json add @colony/spec: workspace:* +apps/mcp-server/src/server.ts add registerSpecTools(server, store, settings) +pnpm-workspace.yaml (already includes packages/*) +``` + +See `patches/0001-add-spec-tools.patch` for the exact diff. + +## file layout in-repo + +Install adds these files to any colony-shaped repo once `colonykit init` runs: + +``` +SPEC.md durable root spec (§G §C §I §V §T §B) +openspec/ + config.yaml caveman level, validator rules, sync strategy + changes/ + / + CHANGE.md §P §S §T §B + base_root_hash + design.md optional, only with /co:change --design + archive/ + 2026-04-24-/ immutable after /co:archive +``` + +## mapping to colony primitives + +| colonykit concept | colony primitive | +|---------------------------|-----------------------------------------------------------| +| active change | `TaskThread` with `branch: spec/` | +| §S delta rows | `Observation` with `kind: spec-delta` | +| §B bug entries | `Observation` with `kind: spec-bug`, signature in meta | +| §V draft invariant | `ProposalSystem` proposal + `kind: spec-invariant-draft` | +| backprop reinforcement | `ProposalSystem.reinforce({ kind: 'rediscovered' })` | +| backprop lookahead | `MemoryStore.search()` over prior §B signatures | +| cross-change conflicts | `TaskThread.claimFile('SPEC.md#V.3')` on the root thread | +| handoff between agents | `TaskThread.handOff()` on the change's thread | +| archive move | filesystem rename + `spec-sync` observation on root | + +Every one of these is already in `@colony/core`. What's new is the spec-grammar dialect and the discipline of using these primitives for spec mutation. + +## tests + +```bash +cd packages/spec +pnpm test +``` + +Covers: grammar round-trip, always-on identification, stable hashing, cite-scoped context with transitive closure, all four sync conflict shapes (clean, drift, cited-removal, last-writer-wins override). + +## what's intentionally missing (v0.0.1) + +- `colonykit init` — not included; the installer can be a thin wrapper around `SpecRepository.writeRoot()` with a default SPEC.md template. Add when the core loop is proven. +- Token-budget CI gate — defer until we have a fixture repo and a baseline to measure against. Writing it before the baseline is measuring theater. +- `design.md` scaffolding — the `/co:change --design` path is specified in the skill but not yet implemented by `spec_change_open`. One-liner to add when someone asks for it. +- Cross-change concurrent-edit detection — plumbed through `TaskThread.claimFile()` on `SPEC.md#` but not yet surfaced in `/co:check`. Add after the single-change flow is proven end-to-end. + +These are deferred intentionally. Prove the core loop on one change before adding surface. diff --git a/packages/spec/package.json b/packages/spec/package.json new file mode 100644 index 0000000..a71b4a3 --- /dev/null +++ b/packages/spec/package.json @@ -0,0 +1,25 @@ +{ + "name": "@colony/spec", + "version": "0.0.1", + "license": "MIT", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "dev": "tsup src/index.ts --format esm --dts --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@colony/compress": "workspace:*", + "@colony/core": "workspace:*", + "@colony/storage": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.5" + } +} diff --git a/packages/spec/src/backprop.ts b/packages/spec/src/backprop.ts new file mode 100644 index 0000000..6518808 --- /dev/null +++ b/packages/spec/src/backprop.ts @@ -0,0 +1,263 @@ +import type { MemoryStore } from '@colony/core'; +import { ProposalSystem } from '@colony/core'; +import { SPEC_BRANCH_PREFIX, SPEC_OBSERVATION_KINDS } from './constants.js'; + +// A failure signature is the invariant key: (test-id, top-3 stack-frames, +// error-class). Two failures with the same signature are "the same bug +// class", which is exactly what we need for the promote_after threshold. +export interface FailureSignature { + test_id: string; + error_class: string; + frames: string[]; + // The hash suffix used in §B rows, stored separately for compact + // display: `auth-timeout:a3f9`. + hash: string; +} + +export interface PromotionDecision { + action: 'append_only' | 'propose_invariant' | 'promote_existing'; + signature: FailureSignature; + matchCount: number; + proposal_id?: number; +} + +// Build a stable signature from the raw failure fields. Normalization +// rules are deliberately strict so that whitespace, line numbers, and +// absolute path differences don't split a "same bug" into multiple +// signatures. +export function computeFailureSignature(input: { + test_id: string; + error: string; + stack?: string; +}): FailureSignature { + const errorClass = extractErrorClass(input.error); + const frames = extractTopFrames(input.stack ?? '', 3); + const keyString = [input.test_id, errorClass, ...frames].join('||'); + return { + test_id: input.test_id, + error_class: errorClass, + frames, + hash: shortHash(keyString), + }; +} + +export interface BackpropGateOptions { + store: MemoryStore; + repoRoot: string; + // From config.yaml: backprop.promote_after. Default 2. + promoteAfter?: number; + // Branch the backprop gate records promotions against. Conventionally + // one per slug: `spec/` so each change gets its own bug-log. + branch: string; +} + +// The gate's job: given a failure signature, decide whether to +// a) just append to §B (append_only — first occurrence) +// b) propose a new §V draft via colony's ProposalSystem (propose_invariant) +// c) reinforce an existing proposal (promote_existing) +// +// Crucially, the proposal mechanism is colony's existing one. We don't +// reinvent reinforcement, decay, or promotion — we just tell colony "here's +// another rediscovery of proposal N". Colony's 1-hour half-life and 2.5 +// threshold do the rest. +export class BackpropGate { + private readonly proposals: ProposalSystem; + private readonly promoteAfter: number; + + constructor(private readonly opts: BackpropGateOptions) { + this.proposals = new ProposalSystem(opts.store); + this.promoteAfter = opts.promoteAfter ?? 2; + } + + // Called by /co:build when a test fails. Returns the decision so the + // build skill can render the right user-facing message. + recordFailure(input: { + task_id: number; + session_id: string; + agent: string; + signature: FailureSignature; + error_summary: string; + }): PromotionDecision { + // Every failure always appends a §B row; that's non-negotiable. + this.opts.store.addObservation({ + session_id: input.session_id, + kind: SPEC_OBSERVATION_KINDS.SPEC_BUG, + content: formatBugRow(input.signature, input.error_summary), + task_id: input.task_id, + metadata: { + signature_hash: input.signature.hash, + test_id: input.signature.test_id, + error_class: input.signature.error_class, + }, + }); + + // How many prior failures share this signature on this task? + const matchCount = this.countPriorMatches(input.task_id, input.signature.hash); + + if (matchCount + 1 < this.promoteAfter) { + return { action: 'append_only', signature: input.signature, matchCount: matchCount + 1 }; + } + + // Threshold crossed. Look for an existing proposal keyed on this + // signature. If one exists, reinforce (rediscovered). If not, propose. + const existing = this.findExistingProposal(input.signature.hash); + if (existing) { + const { strength, promoted } = this.proposals.reinforce({ + proposal_id: existing, + session_id: input.session_id, + kind: 'rediscovered', + }); + return { + action: 'promote_existing', + signature: input.signature, + matchCount: matchCount + 1, + proposal_id: existing, + }; + } + + const proposal_id = this.proposals.propose({ + repo_root: this.opts.repoRoot, + branch: this.opts.branch, + summary: invariantSummary(input.signature), + rationale: invariantRationale(input.signature, input.error_summary, matchCount + 1), + touches_files: [], + session_id: input.session_id, + }); + + // Record the signature -> proposal_id mapping so future same-sig + // failures find it via findExistingProposal. We put this in the + // proposal's own observation metadata by appending a SPEC_INVARIANT_DRAFT + // observation tagged with the signature hash. + this.opts.store.addObservation({ + session_id: input.session_id, + kind: SPEC_OBSERVATION_KINDS.SPEC_INVARIANT_DRAFT, + content: `Draft invariant for signature ${input.signature.hash}; proposal_id=${proposal_id}`, + task_id: input.task_id, + metadata: { + signature_hash: input.signature.hash, + proposal_id, + }, + }); + + return { + action: 'propose_invariant', + signature: input.signature, + matchCount: matchCount + 1, + proposal_id, + }; + } + + // Lookahead: given a task-signature (the task's cites + verbs), return + // archived bug entries whose signatures are similar. The exact simi- + // larity is BM25 + optional semantic re-rank via colony's search — + // we delegate to MemoryStore.search() rather than reimplementing it. + // + // Called by /co:build before executing a task, so the agent sees + // "this pattern previously failed when..." as preamble. + async lookahead(taskSignature: string, limit = 3): Promise> { + const hits = await this.opts.store.search(taskSignature, limit); + return hits + .filter((h) => h.snippet.includes('BUG:') || /signature_hash/.test(h.snippet)) + .map((h) => ({ id: h.id, snippet: h.snippet })); + } + + // ---- private -------------------------------------------------------- + + private countPriorMatches(task_id: number, signatureHash: string): number { + // Pull all SPEC_BUG observations on this task and count by signature. + // For a typical change this is tens to hundreds of rows, well below + // the threshold where we'd need a separate index. + const all = this.opts.store.storage.taskTimeline(task_id, 500); + let n = 0; + for (const obs of all) { + if (obs.kind !== SPEC_OBSERVATION_KINDS.SPEC_BUG) continue; + const meta = safeJson(obs.metadata) as { signature_hash?: string }; + if (meta.signature_hash === signatureHash) n++; + } + return n; + } + + private findExistingProposal(signatureHash: string): number | undefined { + // We indexed the proposal_id on the SPEC_INVARIANT_DRAFT observation + // when it was created. Scan the whole spec lane's observations for + // that signature hash. + const tasks = this.opts.store.storage.listTasks(200); + for (const t of tasks) { + if (!t.branch.startsWith(SPEC_BRANCH_PREFIX)) continue; + const rows = this.opts.store.storage.taskTimeline(t.id, 500); + for (const obs of rows) { + if (obs.kind !== SPEC_OBSERVATION_KINDS.SPEC_INVARIANT_DRAFT) continue; + const meta = safeJson(obs.metadata) as { signature_hash?: string; proposal_id?: number }; + if (meta.signature_hash === signatureHash && typeof meta.proposal_id === 'number') { + return meta.proposal_id; + } + } + } + return undefined; + } +} + +function extractErrorClass(error: string): string { + // "TypeError: foo is not a function" -> "TypeError" + // "AssertionError [ERR_ASSERTION]: ..." -> "AssertionError" + const m = /^([A-Z][A-Za-z0-9_]*(?:Error|Exception))/.exec(error.trim()); + return m ? m[1] : 'Error'; +} + +function extractTopFrames(stack: string, n: number): string[] { + const frames: string[] = []; + for (const line of stack.split('\n')) { + const m = /\bat\s+([^\s(]+)\s*\(?([^:)]+):\d+:\d+\)?/.exec(line); + if (m) { + // function-name @ file (stripping line numbers so same code in + // different commits hashes the same). + frames.push(`${m[1]}@${basename(m[2])}`); + if (frames.length >= n) break; + } + } + return frames; +} + +function basename(path: string): string { + const parts = path.split(/[/\\]/); + return parts[parts.length - 1] ?? path; +} + +function shortHash(key: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = (h * 0x01000193) >>> 0; + } + return h.toString(16).slice(0, 4); +} + +function formatBugRow(sig: FailureSignature, summary: string): string { + // The compact §B row format: `|||` + return `BUG:${sig.hash}|${sig.test_id}|${sig.error_class}|${summary}`; +} + +function invariantSummary(sig: FailureSignature): string { + return `Prevent recurrence of ${sig.error_class} in ${sig.test_id}`; +} + +function invariantRationale(sig: FailureSignature, error_summary: string, count: number): string { + return [ + `Signature ${sig.hash} has fired ${count} time(s).`, + `Test: ${sig.test_id}`, + `Class: ${sig.error_class}`, + `Summary: ${error_summary}`, + `Top frames: ${sig.frames.join(' / ')}`, + '', + 'This proposal will be promoted to a real §V invariant once collective reinforcement crosses the threshold.', + ].join('\n'); +} + +function safeJson(s: string | null | undefined): unknown { + if (!s) return {}; + try { + return JSON.parse(s); + } catch { + return {}; + } +} diff --git a/packages/spec/src/change.ts b/packages/spec/src/change.ts new file mode 100644 index 0000000..599e6d9 --- /dev/null +++ b/packages/spec/src/change.ts @@ -0,0 +1,112 @@ +import type { SpecRow } from './grammar.js'; + +// A DeltaRow is the unit of change in §S. Exactly one of add/modify/remove +// is populated. The `target` string is the root spec id being touched, +// e.g. 'V.3' or 'T.12' or 'I.config'. +export interface DeltaRow { + op: 'add' | 'modify' | 'remove'; + target: string; + row?: SpecRow; +} + +export interface Change { + slug: string; + // Hash of the root SPEC.md captured at /co:change time. Used as the + // common ancestor in the three-way merge at archive time. + baseRootHash: string; + proposal: string; + deltaRows: DeltaRow[]; + tasks: SpecRow[]; + bugs: SpecRow[]; +} + +const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n/; + +export function parseChange(text: string, slug: string): Change { + const fm = FRONTMATTER_RE.exec(text); + const frontmatter: Record = {}; + if (fm) { + for (const line of fm[1].split('\n')) { + const m = /^([a-z_]+):\s*(.+)$/.exec(line.trim()); + if (m) frontmatter[m[1]] = m[2]; + } + } + const body = fm ? text.slice(fm[0].length) : text; + + return { + slug, + baseRootHash: frontmatter['base_root_hash'] ?? '', + proposal: extractSection(body, 'P'), + deltaRows: parseDeltaRows(extractSection(body, 'S')), + tasks: parseTableRows(extractSection(body, 'T')), + bugs: parseTableRows(extractSection(body, 'B')), + }; +} + +export function serializeChange(change: Change): string { + const parts: string[] = []; + parts.push('---'); + parts.push(`base_root_hash: ${change.baseRootHash}`); + parts.push(`slug: ${change.slug}`); + parts.push('---'); + parts.push(''); + parts.push(`# CHANGE · ${change.slug}`); + parts.push(''); + parts.push('## §P proposal'); + parts.push(change.proposal || '-'); + parts.push(''); + parts.push('## §S delta'); + parts.push('op|target|row'); + parts.push('-|-|-'); + for (const d of change.deltaRows) { + parts.push(`${d.op}|${d.target}|${d.row ? d.row.cells.join(' ') : '-'}`); + } + parts.push(''); + parts.push('## §T tasks'); + parts.push(renderTable(change.tasks)); + parts.push(''); + parts.push('## §B bugs'); + parts.push(renderTable(change.bugs)); + parts.push(''); + return parts.join('\n'); +} + +function extractSection(text: string, name: string): string { + const re = new RegExp(`^##\\s+§${name}\\b[^\\n]*\\n([\\s\\S]*?)(?=^##\\s+§|\\z)`, 'm'); + const match = re.exec(text); + return match ? match[1].trim() : ''; +} + +function parseDeltaRows(body: string): DeltaRow[] { + const rows = parseTableRows(body); + const deltas: DeltaRow[] = []; + for (const row of rows) { + const op = row.cells[0] as DeltaRow['op']; + const target = row.cells[1] ?? ''; + if (!op || !target) continue; + if (op !== 'add' && op !== 'modify' && op !== 'remove') continue; + deltas.push({ op, target, row }); + } + return deltas; +} + +function parseTableRows(body: string): SpecRow[] { + const rows: SpecRow[] = []; + for (const raw of body.split('\n')) { + const t = raw.trim(); + if (!t) continue; + if (/^id\s*\|/.test(t) || /^op\s*\|/.test(t) || /^-+\s*\|/.test(t)) continue; + const cells = t.split('|').map((c) => c.trim()); + const id = cells[0]; + if (!id || id === '-') continue; + rows.push({ id, cells }); + } + return rows; +} + +function renderTable(rows: SpecRow[]): string { + if (rows.length === 0) return 'id|status|task|cites\n-|-|-|-'; + const lines = ['id|status|task|cites', '-|-|-|-']; + for (const r of rows) lines.push(r.cells.join('|')); + return lines.join('\n'); +} diff --git a/packages/spec/src/constants.ts b/packages/spec/src/constants.ts new file mode 100644 index 0000000..c1e03cd --- /dev/null +++ b/packages/spec/src/constants.ts @@ -0,0 +1,41 @@ +// Observation kinds used on spec task-threads. These extend colony's +// existing kind enum ('note' | 'question' | 'answer' | 'decision' | +// 'blocker' | 'claim' | 'handoff' | 'accept' | 'decline' | 'wake' | ...) +// with spec-specific kinds. colony's storage layer stores kind as a free +// string so extension is additive — no schema migration needed. +export const SPEC_OBSERVATION_KINDS = { + // Writes to the root SPEC.md go through here. One entry per /co:spec + // invocation; content is a diff patch against the previous root state. + SPEC_WRITE: 'spec-write', + + // Delta rows authored on an in-flight change. Each §S row (add / modify + // / remove against a specific §V / §I / §T id) is one observation. + SPEC_DELTA: 'spec-delta', + + // §B bug entries. Content is a compact failure record; metadata carries + // the signature hash so /co:build can query by signature. + SPEC_BUG: 'spec-bug', + + // §V invariant proposals emitted by the backprop gate. These start in + // a 'draft' state and only promote to the root spec after a /co:spec + // writer confirms them. + SPEC_INVARIANT_DRAFT: 'spec-invariant-draft', + + // Final sync event — the archive move completed and the merged root + // has been written. Used as the sentinel for "everything below this + // ts is in the archive". + SPEC_SYNC: 'spec-sync', +} as const; + +export type SpecObservationKind = + (typeof SPEC_OBSERVATION_KINDS)[keyof typeof SPEC_OBSERVATION_KINDS]; + +// Reserved key in TaskThread.metadata that marks the thread as a spec +// lane. Non-spec tasks never set this, so filters like "list only spec +// changes" are a single WHERE clause against the metadata JSON. +export const SPEC_TASK_METADATA_KEY = 'colonykit_spec_lane' as const; + +// Reserved branch prefix for spec changes. Task threads are keyed on +// (repo_root, branch); by convention colonykit owns `spec/*` so it +// cannot collide with a developer's real git branches. +export const SPEC_BRANCH_PREFIX = 'spec/' as const; diff --git a/packages/spec/src/context.ts b/packages/spec/src/context.ts new file mode 100644 index 0000000..347aa32 --- /dev/null +++ b/packages/spec/src/context.ts @@ -0,0 +1,124 @@ +import type { Spec, SpecRow } from './grammar.js'; + +export interface ResolvedContext { + // §G is always included. + goal: string; + // The root §T row being executed. + task: SpecRow; + // §V/§I/§T ids reachable from the task's cites column. + cited_ids: string[]; + // Always-on invariants (§V entries with id ending `.always`). + always_invariants: string[]; + // The rendered, caveman-encoded context string the agent sees. + rendered: string; + // Ids referenced during execution that were NOT in cited_ids + always. + // Populated lazily at runtime by the build skill — not by this function. + // The field lives here because the consumer (ResolvedContext -> log + // entry) wants it in one struct. + manifest_misses?: string[]; +} + +// Pure: no IO. Takes a parsed spec and a task id, returns the slice. +// Cite syntax on §T rows: comma-separated list of ids in the last cell, +// e.g. `T5|~|rewrite skills/spec|V1,V2,§sync`. Non-id tokens (prefixed +// with `§`, like `§sync`, `§migration`) are section pointers and are +// rendered as section references. +export function resolveTaskContext(spec: Spec, taskId: string): ResolvedContext | null { + const tasks = spec.sections['T']?.rows ?? []; + const task = tasks.find((r) => r.id === taskId); + if (!task) return null; + + const citeCell = task.cells[task.cells.length - 1] ?? ''; + const tokens = citeCell + .split(/[,\s]+/) + .map((t) => t.trim()) + .filter((t) => t && t !== '-'); + + // Expand the closure: when a cite points to a §V row, follow that row's + // cites column too, up to a small depth. Depth-2 is enough in practice + // — deeper closures usually indicate the spec is leaking everything + // everywhere and the author needs to tighten the cites. + const closure = new Set(tokens); + const depth = 2; + let frontier = [...tokens]; + for (let d = 0; d < depth; d++) { + const next: string[] = []; + for (const id of frontier) { + const row = findRow(spec, id); + if (!row) continue; + const childCell = row.cells[row.cells.length - 1] ?? ''; + const children = childCell + .split(/[,\s]+/) + .map((t) => t.trim()) + .filter((t) => t && t !== '-'); + for (const c of children) { + if (!closure.has(c)) { + closure.add(c); + next.push(c); + } + } + } + frontier = next; + if (frontier.length === 0) break; + } + + const always = spec.alwaysInvariants; + const rendered = renderContext(spec, task, [...closure], always); + return { + goal: spec.sections['G'].body, + task, + cited_ids: [...closure], + always_invariants: always, + rendered, + }; +} + +function findRow(spec: Spec, id: string): SpecRow | undefined { + for (const name of ['V', 'I', 'T', 'B'] as const) { + const row = spec.sections[name].rows?.find((r) => r.id === id); + if (row) return row; + } + return undefined; +} + +function renderContext( + spec: Spec, + task: SpecRow, + cited: string[], + always: string[], +): string { + const parts: string[] = []; + parts.push(`# task · ${task.id}`); + parts.push(''); + parts.push('## §G goal'); + parts.push(spec.sections['G'].body.trim()); + parts.push(''); + parts.push('## this task'); + parts.push(task.cells.join(' | ')); + parts.push(''); + + const citedRows = cited + .map((id) => ({ id, row: findRow(spec, id) })) + .filter((e): e is { id: string; row: SpecRow } => !!e.row); + + if (citedRows.length > 0) { + parts.push('## cited'); + for (const { row } of citedRows) { + parts.push(`- ${row.cells.join(' | ')}`); + } + parts.push(''); + } + + if (always.length > 0) { + const alwaysRows = always + .map((id) => findRow(spec, id)) + .filter((r): r is SpecRow => !!r); + parts.push('## §V always-on'); + for (const row of alwaysRows) { + parts.push(`- ${row.cells.join(' | ')}`); + } + parts.push(''); + } + + return parts.join('\n'); +} diff --git a/packages/spec/src/grammar.ts b/packages/spec/src/grammar.ts new file mode 100644 index 0000000..b37f531 --- /dev/null +++ b/packages/spec/src/grammar.ts @@ -0,0 +1,159 @@ +import { compress, expand } from '@colony/compress'; + +// The six fixed sections of SPEC.md, in order. Section order is part of +// the grammar — parsers rely on it when a section is empty and its +// trailing whitespace is ambiguous. +export const SPEC_SECTIONS = ['G', 'C', 'I', 'V', 'T', 'B'] as const; +export type SpecSectionName = (typeof SPEC_SECTIONS)[number]; + +export interface SpecSection { + name: SpecSectionName; + // The section body in its on-disk form (caveman-compressed). + // Consumers call expandSection() when they need human-readable text. + body: string; + // Parsed rows for table-valued sections (V/T/B). Undefined for prose + // sections (G/C/I). + rows?: SpecRow[]; +} + +export interface SpecRow { + id: string; + // For §V: "status | invariant text"; we just store the full rendered + // row and let callers split on '|' when they need structure. Keeping + // the shape loose here lets new column conventions ship without a + // grammar version bump. + cells: string[]; +} + +export interface Spec { + sections: Record; + // Front-matter hash. Recomputed on every serialize; stored here so + // consumers can pass it to openChange() without re-hashing. + rootHash: string; + // Always-on invariants: §V entries whose id ends with `.always`. + // Precomputed for the cite-scoped loader. + alwaysInvariants: string[]; +} + +const SECTION_HEADER = /^##\s+§([GCIVTB])\b.*$/m; + +export function parseSpec(text: string): Spec { + const sections: Record = {}; + const headerMatches: Array<{ name: SpecSectionName; start: number; end: number }> = []; + + const re = /^##\s+§([GCIVTB])\b.*$/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + const name = match[1] as SpecSectionName; + headerMatches.push({ name, start: match.index, end: match.index + match[0].length }); + } + + for (let i = 0; i < headerMatches.length; i++) { + const cur = headerMatches[i]; + if (!cur) continue; + const next = headerMatches[i + 1]; + const bodyStart = cur.end; + const bodyEnd = next ? next.start : text.length; + const body = text.slice(bodyStart, bodyEnd).trim(); + sections[cur.name] = { + name: cur.name, + body, + ...(isTableSection(cur.name) ? { rows: parseRows(body) } : {}), + }; + } + + // Fill in missing sections as empty. The grammar requires all six to + // exist; this keeps downstream consumers from null-checking. + for (const name of SPEC_SECTIONS) { + if (!sections[name]) { + sections[name] = { name, body: '', ...(isTableSection(name) ? { rows: [] } : {}) }; + } + } + + const alwaysInvariants = (sections['V'].rows ?? []) + .filter((row) => row.id.endsWith('.always')) + .map((row) => row.id); + + return { + sections: sections as Record, + rootHash: hashOf(text), + alwaysInvariants, + }; +} + +export function serializeSpec(spec: Spec): string { + const parts: string[] = ['# SPEC\n']; + const titles: Record = { + G: '## §G goal', + C: '## §C constraints', + I: '## §I interfaces', + V: '## §V invariants', + T: '## §T tasks', + B: '## §B bugs', + }; + for (const name of SPEC_SECTIONS) { + parts.push(titles[name]); + const section = spec.sections[name]; + if (section.body) { + parts.push(section.body); + } else { + parts.push('-'); + } + parts.push(''); + } + return parts.join('\n'); +} + +// Runs the caveman compressor on prose sections; table sections are +// passed through unchanged because literal preservation is critical for +// pipe-table rows (they contain paths, commands, env names). +export function compressSpec(spec: Spec, intensity: 'lite' | 'full' | 'ultra' = 'full'): Spec { + const next: Record = { ...spec.sections }; + for (const name of SPEC_SECTIONS) { + if (!isTableSection(name)) { + next[name] = { ...spec.sections[name], body: compress(spec.sections[name].body, { intensity }) }; + } + } + return { ...spec, sections: next }; +} + +export function expandSpec(spec: Spec): Spec { + const next: Record = { ...spec.sections }; + for (const name of SPEC_SECTIONS) { + if (!isTableSection(name)) { + next[name] = { ...spec.sections[name], body: expand(spec.sections[name].body) }; + } + } + return { ...spec, sections: next }; +} + +function isTableSection(name: SpecSectionName): boolean { + return name === 'V' || name === 'T' || name === 'B'; +} + +function parseRows(body: string): SpecRow[] { + const lines = body.split('\n').filter((l) => l.trim().startsWith('|') || /^\S+\|/.test(l.trim())); + const rows: SpecRow[] = []; + for (const raw of lines) { + // Skip header + separator rows (id|status|... and -|-|-|-). + const trimmed = raw.trim(); + if (!trimmed || /^id\s*\|/.test(trimmed) || /^-+\s*\|/.test(trimmed)) continue; + const cells = trimmed.split('|').map((c) => c.trim()); + const id = cells[0]; + if (!id || id === '-') continue; + rows.push({ id, cells }); + } + return rows; +} + +// Hash is used by the sync contract as a three-way-merge ancestor +// marker. We use a simple FNV-1a because the dependency surface should +// stay zero — no crypto imports for what's effectively a change-detector. +function hashOf(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} diff --git a/packages/spec/src/hash.ts b/packages/spec/src/hash.ts new file mode 100644 index 0000000..07436d1 --- /dev/null +++ b/packages/spec/src/hash.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { parseSpec } from './grammar.js'; + +// Deterministic, dependency-free hash of the current root SPEC.md. +// Used as the three-way-merge ancestor for every in-flight change. +// +// Stability requirements: +// - Same file contents -> same hash across machines and Node versions. +// - Whitespace normalized so that formatting-only edits don't invalidate +// every in-flight change. +// - Invariant: computeBaseRootHash(f) === parseSpec(readFile(f)).rootHash. +export function computeBaseRootHash(specPath: string): string { + const text = readFileSync(specPath, 'utf8'); + return parseSpec(text).rootHash; +} + +// Verify that a change's captured baseRootHash still matches the current +// root. Returns false iff the root has drifted since /co:change was run. +export function verifyBaseRootHash(specPath: string, recorded: string): boolean { + try { + const current = computeBaseRootHash(specPath); + return current === recorded; + } catch { + return false; + } +} diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts new file mode 100644 index 0000000..bbbc437 --- /dev/null +++ b/packages/spec/src/index.ts @@ -0,0 +1,26 @@ +// Public surface for @colony/spec. +// +// colonykit lives here as the spec-driven lane of the colony runtime. The +// core insight: a project "spec" is just a task-thread with a reserved +// shape and a single writer. Everything novel — backprop reflex, root↔delta +// sync, cite-scoped context loading — reuses the storage, task-thread, +// proposal, and embedding infrastructure that colony already has. + +export { parseSpec, serializeSpec, type Spec, type SpecSection } from './grammar.js'; +export { parseChange, serializeChange, type Change, type DeltaRow } from './change.js'; +export { computeBaseRootHash, verifyBaseRootHash } from './hash.js'; +export { + SpecRepository, + type SpecRepositoryOptions, + type OpenChangeInput, + type ArchiveResult, +} from './repository.js'; +export { SyncEngine, type SyncStrategy, type MergeResult, type MergeConflict } from './sync.js'; +export { + BackpropGate, + type FailureSignature, + type PromotionDecision, + computeFailureSignature, +} from './backprop.js'; +export { resolveTaskContext, type ResolvedContext } from './context.js'; +export { SPEC_OBSERVATION_KINDS, SPEC_TASK_METADATA_KEY } from './constants.js'; diff --git a/packages/spec/src/repository.ts b/packages/spec/src/repository.ts new file mode 100644 index 0000000..8ecd74a --- /dev/null +++ b/packages/spec/src/repository.ts @@ -0,0 +1,185 @@ +import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { MemoryStore, TaskThread } from '@colony/core'; +import { SPEC_BRANCH_PREFIX, SPEC_OBSERVATION_KINDS } from './constants.js'; +import { type Change, parseChange, serializeChange } from './change.js'; +import { type Spec, parseSpec, serializeSpec } from './grammar.js'; +import { computeBaseRootHash } from './hash.js'; + +export interface SpecRepositoryOptions { + // Absolute path to the repo root. SPEC.md lives at `${repoRoot}/SPEC.md`. + repoRoot: string; + // The colony MemoryStore the spec task-threads live on. + store: MemoryStore; +} + +export interface OpenChangeInput { + slug: string; + // Session id of the author. Matches colony's session-id convention + // (e.g. 'claude@abc', 'codex@def'). + session_id: string; + // Agent name ('claude', 'codex'). Used for handoff routing. + agent: string; + // Optional initial proposal text. Agents usually fill this in + // afterwards, but /co:change accepts --message for one-shot use. + proposal?: string; +} + +export interface ArchiveResult { + archivedPath: string; + mergedRootHash: string; + conflicts: number; +} + +// The file-system layout colonykit mandates. Kept here — not scattered +// through skills — so a single repo can have exactly one source of truth +// for where things live. +export const LAYOUT = { + specFile: 'SPEC.md', + changesDir: 'openspec/changes', + archiveDir: 'openspec/changes/archive', + configFile: 'openspec/config.yaml', +} as const; + +export class SpecRepository { + readonly repoRoot: string; + readonly store: MemoryStore; + + constructor(opts: SpecRepositoryOptions) { + this.repoRoot = opts.repoRoot; + this.store = opts.store; + } + + // ---- root spec access ------------------------------------------------ + + specPath(): string { + return join(this.repoRoot, LAYOUT.specFile); + } + + readRoot(): Spec { + const path = this.specPath(); + if (!existsSync(path)) { + throw new Error(`SPEC.md not found at ${path}. Run \`colony spec init\` first.`); + } + return parseSpec(readFileSync(path, 'utf8')); + } + + // Only called from the spec skill or from SyncEngine — never directly + // from build/check/archive. Records an observation on a dedicated + // 'spec/root' task-thread so every root mutation is auditable. + writeRoot(spec: Spec, opts: { session_id: string; agent: string; reason: string }): void { + const path = this.specPath(); + const serialized = serializeSpec(spec); + writeFileSync(path, serialized, 'utf8'); + + // Persist a record of the write on the reserved root task-thread. + // Using an ambient "branch: spec/root" means it's queryable alongside + // every change that ever modified the root. + const thread = TaskThread.open(this.store, { + repo_root: this.repoRoot, + branch: `${SPEC_BRANCH_PREFIX}root`, + session_id: opts.session_id, + }); + thread.join(opts.session_id, opts.agent); + // Post directly via MemoryStore — spec-kind strings aren't in TaskThread.post's + // enum and don't need its claim/handoff bookkeeping. + this.store.addObservation({ + session_id: opts.session_id, + kind: SPEC_OBSERVATION_KINDS.SPEC_WRITE, + content: opts.reason, + task_id: thread.task_id, + }); + } + + // ---- change lifecycle ------------------------------------------------ + + openChange(input: OpenChangeInput): { change: Change; task_id: number; path: string } { + const root = this.readRoot(); + const change: Change = { + slug: input.slug, + baseRootHash: root.rootHash, + proposal: input.proposal ?? '', + deltaRows: [], + tasks: [], + bugs: [], + }; + + const changePath = this.changePath(input.slug); + mkdirSync(dirname(changePath), { recursive: true }); + writeFileSync(changePath, serializeChange(change), 'utf8'); + + // Open the backing task-thread. The branch convention `spec/` + // is how /co:check recognizes a thread as a spec lane (together + // with the metadata marker). + const thread = TaskThread.open(this.store, { + repo_root: this.repoRoot, + branch: `${SPEC_BRANCH_PREFIX}${input.slug}`, + session_id: input.session_id, + }); + thread.join(input.session_id, input.agent); + this.store.addObservation({ + session_id: input.session_id, + kind: SPEC_OBSERVATION_KINDS.SPEC_DELTA, + content: `Opened change ${input.slug}; base_root_hash=${change.baseRootHash}`, + task_id: thread.task_id, + }); + + return { change, task_id: thread.task_id, path: changePath }; + } + + readChange(slug: string): Change { + const path = this.changePath(slug); + if (!existsSync(path)) { + throw new Error(`CHANGE.md not found for slug '${slug}' at ${path}`); + } + return parseChange(readFileSync(path, 'utf8'), slug); + } + + writeChange(change: Change): void { + writeFileSync(this.changePath(change.slug), serializeChange(change), 'utf8'); + } + + // Atomic archive move. The critical invariant from the v2 plan: + // archive writes happen via tempdir-then-rename so a crash mid-move + // leaves either the fully-archived state or the pre-archive state, + // never a half-moved directory. + archiveChange(slug: string, date: string = todayIso()): string { + const changeDir = dirname(this.changePath(slug)); + const archiveTarget = join(this.repoRoot, LAYOUT.archiveDir, `${date}-${slug}`); + mkdirSync(dirname(archiveTarget), { recursive: true }); + + // Stage: rename into a sibling `.archive-staging-` directory, + // then move to final. Two renames keep the window where neither + // path exists at zero. + const stagingPath = join(this.repoRoot, LAYOUT.archiveDir, `.staging-${slug}`); + renameSync(changeDir, stagingPath); + renameSync(stagingPath, archiveTarget); + return archiveTarget; + } + + // ---- introspection -------------------------------------------------- + + // Lists all task-threads that are spec lanes (by metadata marker). + // Used by /co:check to surface cross-change conflicts. + listSpecTasks(): Array<{ task_id: number; branch: string; slug: string }> { + const tasks = this.store.storage.listTasks(500); + return tasks + .filter((t) => t.repo_root === this.repoRoot && t.branch.startsWith(SPEC_BRANCH_PREFIX)) + .map((t) => ({ + task_id: t.id, + branch: t.branch, + slug: t.branch.slice(SPEC_BRANCH_PREFIX.length), + })); + } + + // ---- private helpers ------------------------------------------------ + + private changePath(slug: string): string { + return join(this.repoRoot, LAYOUT.changesDir, slug, 'CHANGE.md'); + } + +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/packages/spec/src/sync.ts b/packages/spec/src/sync.ts new file mode 100644 index 0000000..e29e006 --- /dev/null +++ b/packages/spec/src/sync.ts @@ -0,0 +1,191 @@ +import type { Change, DeltaRow } from './change.js'; +import { type Spec, type SpecSection, parseSpec, serializeSpec } from './grammar.js'; + +export type SyncStrategy = 'three_way' | 'refuse_on_conflict' | 'last_writer_wins'; + +export interface MergeConflict { + target: string; + reason: 'root_modified_since_base' | 'delta_removes_cited_row' | 'unknown_target'; + delta: DeltaRow; +} + +export interface MergeResult { + spec: Spec; + conflicts: MergeConflict[]; + // True only when zero conflicts and every delta row applied cleanly. + clean: boolean; + applied: number; +} + +// SyncEngine is pure: no IO, no task-thread writes. Callers (the sync +// skill, or SpecRepository) decide whether to persist the result and +// which strategy to honor. +// +// This is deliberately separate from repository.ts — sync is the most +// subtle correctness surface in colonykit, and testing it should not +// require the MemoryStore or the filesystem. +export class SyncEngine { + constructor(private readonly strategy: SyncStrategy = 'three_way') {} + + // Merge a change's delta rows into the current root spec, with + // `baseRoot` as the three-way-merge ancestor. + merge(currentRoot: Spec, baseRoot: Spec, change: Change): MergeResult { + const conflicts: MergeConflict[] = []; + const next: Spec = cloneSpec(currentRoot); + let applied = 0; + + for (const delta of change.deltaRows) { + const conflict = this.detectConflict(currentRoot, baseRoot, delta); + if (conflict) { + conflicts.push(conflict); + if (this.strategy === 'refuse_on_conflict') continue; + if (this.strategy === 'three_way' && !isAutoMergeable(conflict)) continue; + // last_writer_wins falls through to apply. + } + if (this.applyDelta(next, delta)) applied++; + } + + return { + spec: next, + conflicts, + clean: conflicts.length === 0, + applied, + }; + } + + // Renders a conflict report that can be embedded into SPEC.md as a + // comment block. Useful when strategy = 'refuse_on_conflict' and the + // human needs to resolve manually. + static renderConflictMarkers(conflicts: MergeConflict[]): string { + if (conflicts.length === 0) return ''; + const lines = [''); + return lines.join('\n'); + } + + private detectConflict( + currentRoot: Spec, + baseRoot: Spec, + delta: DeltaRow, + ): MergeConflict | null { + const sectionLetter = sectionOf(delta.target); + if (!sectionLetter || !['V', 'I', 'T', 'B'].includes(sectionLetter)) { + // G/C sections are prose-only; delta rows against them are nonsensical. + return { target: delta.target, reason: 'unknown_target', delta }; + } + + const current = findRow(currentRoot, delta.target); + const base = findRow(baseRoot, delta.target); + + // Row drifted since the change was opened. + if (current && base && serializeRow(current) !== serializeRow(base)) { + return { target: delta.target, reason: 'root_modified_since_base', delta }; + } + + // Remove that targets a row something else now cites. We only flag + // citation conflicts for §V removes because those are load-bearing; + // removing a §T row that something cites is usually fine (task just + // gets reassigned). + if (delta.op === 'remove' && sectionLetter === 'V') { + const cited = citesReferencing(currentRoot, delta.target); + if (cited.length > 0) { + return { target: delta.target, reason: 'delta_removes_cited_row', delta }; + } + } + + return null; + } + + private applyDelta(spec: Spec, delta: DeltaRow): boolean { + const sectionLetter = sectionOf(delta.target); + if (!sectionLetter || !['V', 'I', 'T', 'B'].includes(sectionLetter)) return false; + const section = spec.sections[sectionLetter as 'V' | 'I' | 'T' | 'B']; + if (!section.rows) return false; + + if (delta.op === 'remove') { + const before = section.rows.length; + section.rows = section.rows.filter((r) => r.id !== delta.target); + return section.rows.length < before; + } + + if (!delta.row) return false; + if (delta.op === 'add') { + section.rows.push(delta.row); + return true; + } + if (delta.op === 'modify') { + const idx = section.rows.findIndex((r) => r.id === delta.target); + if (idx < 0) { + section.rows.push(delta.row); + return true; + } + section.rows[idx] = delta.row; + return true; + } + return false; + } +} + +function isAutoMergeable(conflict: MergeConflict): boolean { + // The only conflict shape we can auto-merge is: root unchanged, delta + // removes cited row, and the citations are also being removed in the + // same change. We don't handle that case here yet — conservative default + // is to NOT auto-merge any conflict under three_way; caller must rerun + // with last_writer_wins or resolve manually. This keeps the default + // path safe. + void conflict; + return false; +} + +function findRow(spec: Spec, target: string) { + const sectionLetter = sectionOf(target); + if (!sectionLetter) return undefined; + const section = spec.sections[sectionLetter as 'V' | 'I' | 'T' | 'B' | 'G' | 'C']; + if (!section?.rows) return undefined; + return section.rows.find((r) => r.id === target); +} + +// Extract the section letter from an id. Supports both styles: +// 'V1', 'T5', 'B2' → first character is the letter +// 'V8.always', 'I.config' → split on '.', first part +// If the id starts with a known section letter followed by anything, +// that letter wins. +function sectionOf(id: string): string | undefined { + const first = id[0]; + if (first && ['G', 'C', 'I', 'V', 'T', 'B'].includes(first)) return first; + return undefined; +} + +function serializeRow(row: { cells: string[] }): string { + return row.cells.join('|'); +} + +// Scan §T for rows that cite the given §V id. Used to detect the +// "delta removes cited row" conflict. +function citesReferencing(spec: Spec, target: string): string[] { + const tasks = spec.sections['T']?.rows ?? []; + const result: string[] = []; + for (const row of tasks) { + // Conventionally, the cites column is the last one. It's comma- + // separated ids like "V1,V3,§sync". Match on the literal target. + const last = row.cells[row.cells.length - 1] ?? ''; + if (last.split(/[,\s]+/).some((c) => c === target)) { + result.push(row.id); + } + } + return result; +} + +function cloneSpec(spec: Spec): Spec { + const sections = {} as Spec['sections']; + for (const [k, v] of Object.entries(spec.sections)) { + (sections as Record)[k] = { + ...v, + rows: v.rows ? v.rows.map((r) => ({ ...r, cells: [...r.cells] })) : undefined, + }; + } + return { ...spec, sections, alwaysInvariants: [...spec.alwaysInvariants] }; +} diff --git a/packages/spec/test/spec.test.ts b/packages/spec/test/spec.test.ts new file mode 100644 index 0000000..876d317 --- /dev/null +++ b/packages/spec/test/spec.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { parseSpec, serializeSpec } from '../src/grammar.js'; +import { resolveTaskContext } from '../src/context.js'; +import { SyncEngine } from '../src/sync.js'; +import type { Change } from '../src/change.js'; + +const SAMPLE_SPEC = `# SPEC + +## §G goal +One spec file at root. One lifecycle for changes. + +## §C constraints +- markdown + pipe tables only. +- single-thread exec. + +## §I interfaces +- cmds: /co:spec, /co:change, /co:build +- config: openspec/config.yaml + +## §V invariants +id|rule|cites +-|-|- +V1|SPEC.md is sole source of truth|- +V2|spec is only writer of root|V1 +V8.always|slash commands emit ≤ 1 status line per phase|- +V9.always|/co:check is read-only|- + +## §T tasks +id|status|task|cites +-|-|-|- +T1|x|scaffold repo root|V1 +T5|.|rewrite spec skill|V1,V2 +T8|.|port check skill|V9.always + +## §B bugs +id|date|cause|fix +-|-|-|- +`; + +describe('grammar', () => { + it('round-trips a sample SPEC.md', () => { + const spec = parseSpec(SAMPLE_SPEC); + const out = serializeSpec(spec); + const reparsed = parseSpec(out); + expect(reparsed.sections.V.rows?.length).toBe(4); + expect(reparsed.sections.T.rows?.length).toBe(3); + }); + + it('identifies always-on invariants by id suffix', () => { + const spec = parseSpec(SAMPLE_SPEC); + expect(spec.alwaysInvariants.sort()).toEqual(['V8.always', 'V9.always']); + }); + + it('computes a stable rootHash for identical content', () => { + expect(parseSpec(SAMPLE_SPEC).rootHash).toBe(parseSpec(SAMPLE_SPEC).rootHash); + }); +}); + +describe('cite-scoped context loading', () => { + it('returns only the cited subset plus always-on invariants', () => { + const spec = parseSpec(SAMPLE_SPEC); + const ctx = resolveTaskContext(spec, 'T5'); + expect(ctx).not.toBeNull(); + expect(ctx!.cited_ids.sort()).toEqual(['V1', 'V2']); + expect(ctx!.always_invariants.sort()).toEqual(['V8.always', 'V9.always']); + // The rendered string must NOT contain invariants that weren't cited. + // In our sample that's the "bugs" section — V has no uncited rows, + // but T has T1 and T8 which should not appear in T5's context. + expect(ctx!.rendered).not.toContain('T1|'); + expect(ctx!.rendered).not.toContain('T8|'); + }); + + it('follows cites transitively up to depth 2', () => { + const spec = parseSpec(SAMPLE_SPEC); + // V2 cites V1; asking for T5 (cites V1, V2) should still show both. + // More importantly, asking for a task that cites only V2 should + // pull in V1 via transitive closure. + const specWithChain = parseSpec( + SAMPLE_SPEC.replace('T5|.|rewrite spec skill|V1,V2', 'T5|.|rewrite spec skill|V2'), + ); + const ctx = resolveTaskContext(specWithChain, 'T5'); + expect(ctx!.cited_ids.sort()).toEqual(['V1', 'V2']); + }); + + it('returns null for unknown task ids', () => { + const spec = parseSpec(SAMPLE_SPEC); + expect(resolveTaskContext(spec, 'T999')).toBeNull(); + }); +}); + +describe('sync engine conflict detection', () => { + it('applies a clean modify when root unchanged since base', () => { + const base = parseSpec(SAMPLE_SPEC); + const current = parseSpec(SAMPLE_SPEC); // identical to base + const change: Change = { + slug: 'test', + baseRootHash: base.rootHash, + proposal: '', + deltaRows: [ + { + op: 'modify', + target: 'V2', + row: { id: 'V2', cells: ['V2', 'spec is only writer (revised)', 'V1'] }, + }, + ], + tasks: [], + bugs: [], + }; + const engine = new SyncEngine('three_way'); + const result = engine.merge(current, base, change); + expect(result.clean).toBe(true); + expect(result.applied).toBe(1); + }); + + it('flags root_modified_since_base when current row != base row', () => { + const base = parseSpec(SAMPLE_SPEC); + const currentText = SAMPLE_SPEC.replace( + 'V2|spec is only writer of root|V1', + 'V2|spec is only writer of root AND sync|V1', + ); + const current = parseSpec(currentText); + const change: Change = { + slug: 'test', + baseRootHash: base.rootHash, + proposal: '', + deltaRows: [ + { + op: 'modify', + target: 'V2', + row: { id: 'V2', cells: ['V2', 'a different edit', 'V1'] }, + }, + ], + tasks: [], + bugs: [], + }; + const engine = new SyncEngine('three_way'); + const result = engine.merge(current, base, change); + expect(result.clean).toBe(false); + expect(result.conflicts[0]?.reason).toBe('root_modified_since_base'); + // three_way does NOT auto-merge; nothing applied. + expect(result.applied).toBe(0); + }); + + it('last_writer_wins overwrites despite the conflict', () => { + const base = parseSpec(SAMPLE_SPEC); + const currentText = SAMPLE_SPEC.replace( + 'V2|spec is only writer of root|V1', + 'V2|spec is only writer of root AND sync|V1', + ); + const current = parseSpec(currentText); + const change: Change = { + slug: 'test', + baseRootHash: base.rootHash, + proposal: '', + deltaRows: [ + { + op: 'modify', + target: 'V2', + row: { id: 'V2', cells: ['V2', 'forced edit', 'V1'] }, + }, + ], + tasks: [], + bugs: [], + }; + const engine = new SyncEngine('last_writer_wins'); + const result = engine.merge(current, base, change); + expect(result.conflicts.length).toBe(1); + expect(result.applied).toBe(1); + }); + + it('flags delta_removes_cited_row when §V is removed but §T still cites it', () => { + const base = parseSpec(SAMPLE_SPEC); + const current = parseSpec(SAMPLE_SPEC); + const change: Change = { + slug: 'test', + baseRootHash: base.rootHash, + proposal: '', + deltaRows: [{ op: 'remove', target: 'V1' }], + tasks: [], + bugs: [], + }; + const engine = new SyncEngine('three_way'); + const result = engine.merge(current, base, change); + expect(result.conflicts.some((c) => c.reason === 'delta_removes_cited_row')).toBe(true); + }); +}); diff --git a/packages/spec/tsconfig.json b/packages/spec/tsconfig.json new file mode 100644 index 0000000..dfb71c3 --- /dev/null +++ b/packages/spec/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "allowJs": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8716f08..fc8c4ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@colony/process': specifier: workspace:* version: link:../../packages/process + '@colony/spec': + specifier: workspace:* + version: link:../../packages/spec '@modelcontextprotocol/sdk': specifier: ^1.0.0 version: 1.29.0(zod@3.25.76) @@ -314,6 +317,28 @@ importers: specifier: ^2.1.5 version: 2.1.9(@types/node@22.19.17) + packages/spec: + dependencies: + '@colony/compress': + specifier: workspace:* + version: link:../compress + '@colony/core': + specifier: workspace:* + version: link:../core + '@colony/storage': + specifier: workspace:* + version: link:../storage + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.5 + version: 2.1.9(@types/node@22.19.17) + packages/storage: dependencies: '@colony/config': diff --git a/skills/archive/SKILL.md b/skills/archive/SKILL.md new file mode 100644 index 0000000..e17da2e --- /dev/null +++ b/skills/archive/SKILL.md @@ -0,0 +1,42 @@ +--- +name: co-archive +description: Validate, three-way-merge, and archive a colonykit change. Use when the user runs `/co:archive ` or says the change is done. Atomic — either the archive and root-write both land, or neither does. +--- + +# /co:archive + +Archive a completed change and merge its §S delta into the root SPEC.md. + +## Preconditions + +- All §T rows in the change's CHANGE.md must be status `x` (done). If not, refuse and list the remaining tasks. +- The colony MCP server must be running. + +## Procedure + +1. Verify completion: read `openspec/changes//CHANGE.md` §T, confirm all rows are `x`. +2. Call `spec_archive` with: + - `repo_root`, `slug`, `session_id`, `agent` + - `strategy`: default `three_way`. Pass `last_writer_wins` only if the user explicitly accepted conflicts. +3. Handle the response: + - `status: archived` — print `archived_path`, `applied` delta count, and any conflicts that were resolved. + - `status: refused` — print the conflict list and tell the user to either resolve manually or re-run with `--strategy last_writer_wins`. + +## Conflict handling + +If the response includes non-empty `conflicts[]`: + +- `root_modified_since_base` — root changed since `/co:change` opened. The safe move is to tell the user: someone else edited SPEC.md; re-open the change against the current root, or use `last_writer_wins` at your own risk. +- `delta_removes_cited_row` — the change removes a §V row that something else still cites. This is usually a mistake in the delta; advise editing the change rather than forcing the archive. +- `unknown_target` — the delta targets a section that doesn't accept deltas (e.g. `G.` or `C.`). Edit the change. + +## Output + +One line on success: +``` +✓ archived add-dark-mode · 7 deltas applied · openspec/changes/archive/2026-04-24-add-dark-mode/ +``` + +## Why atomic + +`spec_archive` stages the archive move in a temp path, writes the new root, then atomically renames. A crash mid-call leaves either the pre-archive state or the fully-archived state. Don't build a "retry after partial archive" path — there is no partial archive. diff --git a/skills/build/SKILL.md b/skills/build/SKILL.md new file mode 100644 index 0000000..b832369 --- /dev/null +++ b/skills/build/SKILL.md @@ -0,0 +1,67 @@ +--- +name: co-build +description: Execute the next §T task on the current colonykit change. Use when the user runs `/co:build` or asks you to proceed with the next task. Pulls cite-scoped context, executes, on test failure records via backprop gating. +--- + +# /co:build + +Execute the next pending task in the current change. + +## Preconditions + +- There must be an active change. If the user hasn't run `/co:change` yet, refuse and redirect them. +- Determine the active change: read `openspec/changes/*/CHANGE.md`; the one whose backing task-thread is newest + not archived is active. If multiple, ask which. + +## Procedure + +### 1. Find the next task + +Read `CHANGE.md` §T. The next task is the first row with status `.` (todo). +If all are `x` (done) or `~` (wip-by-someone-else), stop — nothing to build. + +### 2. Claim it + +Call `task_claim_file` with `file_path = "CHANGE.md#T"` so other agents on this spec thread see the claim. Overlapping claims surface in `attention_inbox` on their next turn. + +### 3. Load cite-scoped context — DO NOT read the whole SPEC.md + +Call `spec_build_context` with `repo_root` and `task_id = T`. + +The returned `rendered` field is your entire context for this task. §G + the task row + its cited §V/§I/§T rows + any §V.always invariants. Nothing else. + +This is the point of cite-scoped loading. Reading the full spec defeats the token economy the §T cites column exists to enforce. + +### 4. Lookahead — check for prior failures + +Call `search` with the task row's text + cites as query. If hits include `BUG:` or `signature_hash` patterns, prepend a brief "prior failures on this pattern" note to your plan. Don't block — just surface. + +### 5. Execute + +Do the work. Edit files. Run tests. + +### 6. On test failure — record via backprop + +Call `spec_build_record_failure` with: +- `test_id`: the specific failing test (e.g. `packages/spec/test/sync.test.ts > three-way conflict`) +- `error`: the error class + message (first line of the failure) +- `stack`: the stack trace (for signature frame extraction) +- `error_summary`: one-line human summary + +The tool returns an `action`: +- `append_only` — first occurrence; §B row appended, no invariant proposal. Retry the test. +- `propose_invariant` — threshold crossed; a draft §V was proposed via colony's ProposalSystem. Tell the user: "draft invariant proposed after N failures — will promote once confirmed." +- `promote_existing` — same signature as a prior failure; colony reinforced the existing proposal. Tell the user: "reinforced existing invariant proposal #N." + +### 7. On success — mark `x` + +Edit `CHANGE.md` §T, flip `.` → `x` for this task. Post a `task_post` with kind `decision`. + +## Token budget + +Per §V8 — ≤ 1 status line per phase unless `--verbose`. + +Phases: claim, context-load, execute, verify, mark. Verbose mode shows each; default mode shows only the final result. + +## Why this shape + +The whole skill is designed so the agent never pulls more spec content than the current task cites. If the task row is `T5|.|rewrite skills/spec|V1,V2,§sync`, the agent sees §G + T5 + V1 + V2 + the §sync section + any §V.always — nothing else. This caps per-task context at a small fraction of the full SPEC.md. diff --git a/skills/change/SKILL.md b/skills/change/SKILL.md new file mode 100644 index 0000000..ad2af32 --- /dev/null +++ b/skills/change/SKILL.md @@ -0,0 +1,43 @@ +--- +name: co-change +description: Open a new colonykit spec change. Use when the user runs `/co:change ` or asks to start a new proposal, delta, or change-in-flight. Creates openspec/changes//CHANGE.md and opens a task-thread on spec/. +--- + +# /co:change + +Open a new spec change for the current repo. + +## Preconditions + +- SPEC.md must exist at the repo root. If missing, tell the user to run `colony spec init` first. +- The colony MCP server must be running. Verify via the presence of the `spec_change_open` tool. + +## Procedure + +1. Infer the repo root from the current working directory (walk up until you find `.git/`). +2. Derive the slug: + - If the user passed one, use it verbatim. + - Else generate a kebab-case slug from the user's description (≤ 40 chars, `[a-z0-9-]+`). +3. Call `spec_change_open` with: + - `repo_root`: absolute path to repo root + - `slug`: the derived slug + - `session_id`: your current session id + - `agent`: your agent name (`claude` or `codex`) + - `proposal`: a one-paragraph proposal if the user gave context, else empty +4. If the user passed `--design`, also call the filesystem to create `openspec/changes//design.md` with a template (motivation / trade-offs / alternatives). +5. Report back only: + - `task_id` (for later `task_*` tool calls) + - `path` (so the user can edit CHANGE.md directly) + +## Output discipline + +Per §V8 of SPEC.md — one status line. The user doesn't want narration; they want the path. + +``` +✓ change add-dark-mode opened · openspec/changes/add-dark-mode/CHANGE.md · task #42 +``` + +## Failure modes + +- `spec_change_open` returns isError when SPEC.md is missing. Surface the error verbatim and stop. +- If the task-thread already exists for this slug, the call is idempotent; report the existing task_id rather than failing. diff --git a/skills/check/SKILL.md b/skills/check/SKILL.md new file mode 100644 index 0000000..d02f8d1 --- /dev/null +++ b/skills/check/SKILL.md @@ -0,0 +1,49 @@ +--- +name: co-check +description: Read-only drift check across root SPEC.md, the active change's delta, and the code. Use when the user runs `/co:check` or asks whether the spec and code are in sync. Writes nothing. +--- + +# /co:check + +Read-only drift detection. + +## Preconditions + +- SPEC.md exists. Colony MCP server running. + +## Procedure + +1. Call `spec_read` — get rootHash and section shapes. +2. If there's an active change for this repo, call `attention_inbox` with the user's session id — surface pending handoffs, stalled lanes, and any recent claims by other sessions on `SPEC.md#V.*` or `CHANGE.md#*`. +3. Scan §T rows: + - For any row cited as `x` (done), check that the cited files exist and the cited commands still succeed (run them with `--dry-run` where available). + - For rows with status `~` (wip), list the session_id that claimed them via `task_list` + `task_timeline`. +4. Cross-change conflict surface (opt-in with `--cross-changes`): + - List all spec lanes via `task_list` filtered on branch prefix `spec/`. + - For each pair of in-flight changes, check whether both touch the same §V/§I/§T id. Report pairs that do. + +## Output + +A single report, no writes. Format: + +``` +SPEC.md rootHash=abc12345 + §V 12 invariants (3 always-on) + §T 18 tasks (11 done, 4 wip, 3 todo) + §B 2 bugs + +Active change: add-dark-mode (task #42) + base_root_hash: abc12345 (in sync with root) + deltas: 5 rows (3 add, 2 modify) + +Attention: + - codex@x7 claimed packages/spec/src/sync.ts 4m ago + - 1 pending handoff to you from claude@a1 + +Cross-change (--cross-changes): + none +``` + +## Why read-only is invariant + +§V9 of the root spec makes this a hard rule. Never modify SPEC.md or any CHANGE.md from inside /co:check — even to fix a typo. If the user asks for fixes during a check, tell them to run `/co:spec` or edit the change.