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
2 changes: 1 addition & 1 deletion .changeset/post-tool-use-awareness-push.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
'@colony/hooks': minor
'colony': patch
'colonyq': patch
---

Push awareness: when an edit touches a file another live session holds, the PostToolUse hook now injects a one-line `[Colony] session X recently claimed <file> …` note into the agent's context immediately (Claude Code `additionalContext`), instead of waiting for the next turn's preface. Debounced to once per 2 minutes per session via an `awareness-push` observation marker (hook processes are one-shot, so the marker doubles as audit trail). `autoClaimFromToolUse` now reports live-owner blocked takeovers in its `conflicts` result.
5 changes: 5 additions & 0 deletions .changeset/windows-plan-paths-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@colony/spec': patch
---

Windows CI green: `publishPlan` returns forward-slash `spec_change_path` / `plan_workspace_path` (separator-stable for MCP callers on every OS), and `archiveChange` fails deterministically when the archive target already exists (POSIX rename threw; Windows MoveFileEx silently clobbered).
4 changes: 3 additions & 1 deletion apps/cli/src/lib/gitguardex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,9 @@ function execGx(execFileSync: GitGuardexExecFileSync, args: string[], cwd: strin
}

function shellQuote(value: string): string {
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) return value;
// Same safe charset as health.ts/sweep.ts shellQuote (and the test
// helper apps/cli/test/shell-quote-helper.ts) — keep all copies aligned.
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
return `'${value.replace(/'/g, `'\\''`)}'`;
}

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/test/agents-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../src/executors/gitguardex.js';
import { createProgram } from '../src/index.js';
import { withStore } from '../src/util/store.js';
import { shellQuoteForTest } from './shell-quote-helper.js';

const MINIMAL_SPEC = `# SPEC

Expand Down Expand Up @@ -148,7 +149,7 @@ describe('colony agents spawn', () => {
expect(output).toContain('Stop and hand off if quota or conflict');
expect(output).toContain('Sibling files:\n- apps/cli/test/agents-spawn.test.ts');
expect(output).toContain('Do-not-touch list:\n- apps/cli/test/agents-spawn.test.ts');
expect(output).toContain(`--target ${repoRoot}`);
expect(output).toContain(`--target ${shellQuoteForTest(repoRoot)}`);
expect(output).toContain('--claim apps/cli/src/executors/gitguardex.ts');
expect(existsSync(gxArgsFile)).toBe(false);
});
Expand Down
17 changes: 12 additions & 5 deletions apps/cli/test/cockpit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import kleur from 'kleur';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProgram } from '../src/index.js';
Expand All @@ -9,6 +9,7 @@ import {
defaultCockpitSessionName,
formatCommand,
} from '../src/lib/gitguardex.js';
import { shellQuoteForTest } from './shell-quote-helper.js';

let dataDir: string;
let output: string;
Expand Down Expand Up @@ -42,7 +43,11 @@ describe('colony cockpit', () => {
);

expect(output).toContain('gitguardex cockpit dry-run');
expect(output).toContain('command: gx cockpit --target /work/colony --session colony-colony');
// --repo-root '/work/colony' resolves OS-natively (D:\work\colony on
// Windows) and quotes when the path leaves the shell-safe charset.
expect(output).toContain(
`command: gx cockpit --target ${shellQuoteForTest(resolve('/work/colony'))} --session colony-colony`,
);
expect(output).toContain('next ready spawn commands:');
expect(output).toContain('no ready Colony plan subtasks');
});
Expand All @@ -69,8 +74,8 @@ describe('colony cockpit', () => {

expect(JSON.parse(output)).toMatchObject({
dry_run: true,
command: 'gx cockpit --target /work/colony --session ops',
repo_root: '/work/colony',
command: `gx cockpit --target ${shellQuoteForTest(resolve('/work/colony'))} --session ops`,
repo_root: resolve('/work/colony'),
session_name: 'ops',
next_spawn_commands: [],
});
Expand All @@ -79,7 +84,9 @@ describe('colony cockpit', () => {
it('quotes rendered paths and sessions for shell output', () => {
expect(
formatCommand('gx', buildGitGuardexCockpitCommand('/work/repo with space', 'ops cockpit')),
).toBe("gx cockpit --target '/work/repo with space' --session 'ops cockpit'");
).toBe(
`gx cockpit --target ${shellQuoteForTest(resolve('/work/repo with space'))} --session 'ops cockpit'`,
);
});

it('normalizes the default session name from the repo root', () => {
Expand Down
14 changes: 9 additions & 5 deletions apps/cli/test/gitguardex.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';

import { defaultSettings } from '@colony/config';
import { MemoryStore, listPlans } from '@colony/core';
import { type QueenOrderedPlanInput, publishOrderedPlan } from '@colony/queen';
Expand All @@ -12,7 +13,10 @@ import {
} from '../src/executors/gitguardex.js';
import { createProgram } from '../src/index.js';
import { type GitGuardexExecFileSync, spawnGitGuardexAgent } from '../src/lib/gitguardex.js';

// gx (gitguardex) is a POSIX tool; Windows cannot exec the PATH-stub shim
// (execFileSync without shell rejects .cmd), so PATH-spawning integration
// tests run on POSIX only.
const POSIX = process.platform !== 'win32';
const MINIMAL_SPEC = `# SPEC

## §G goal
Expand Down Expand Up @@ -62,7 +66,7 @@ beforeEach(() => {
originalGxStatus = process.env.GX_FAKE_AGENTS_STATUS;
originalGxFailVersion = process.env.GX_FAKE_FAIL_VERSION;
process.env.COLONY_HOME = dataDir;
process.env.PATH = `${binDir}:${process.env.PATH ?? ''}`;
process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ''}`;
process.env.GX_FAKE_LOG = logPath;
process.env.GX_FAKE_AGENTS_STATUS = JSON.stringify({
schemaVersion: 1,
Expand Down Expand Up @@ -181,7 +185,7 @@ describe('GitGuardex executor CLI', () => {
expect(output).toContain('--claim apps/cli/src/lib/gitguardex.ts');
});

it('calls gx cockpit with repo target and colony session name', async () => {
it.runIf(POSIX)('calls gx cockpit with repo target and colony session name', async () => {
await createProgram().parseAsync(['node', 'test', 'cockpit', '--repo-root', repoRoot], {
from: 'node',
});
Expand Down Expand Up @@ -249,7 +253,7 @@ describe('GitGuardex executor CLI', () => {
});
});

it('shows GitGuardex lanes in colony status', async () => {
it.runIf(POSIX)('shows GitGuardex lanes in colony status', async () => {
process.env.GX_FAKE_AGENTS_STATUS = JSON.stringify({
schemaVersion: 1,
repoRoot,
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/test/queen-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import kleur from 'kleur';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildColonyHealthPayload, formatColonyHealthOutput } from '../src/commands/health.js';
import { shellQuoteForTest } from './shell-quote-helper.js';

const NOW = 1_800_000_000_000;
const SINCE = NOW - 24 * 3_600_000;
Expand Down Expand Up @@ -441,7 +442,7 @@ describe('queen wave health', () => {
state: 'archived',
recommendation: {
action: 'publish-new-plan',
command: `colony queen plan --repo-root ${repoRoot} "<goal>"`,
command: `colony queen plan --repo-root ${shellQuoteForTest(repoRoot)} "<goal>"`,
},
});
});
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/test/shell-quote-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Mirror of the production shellQuote rule (packages/queen/src/sweep.ts,
* apps/cli/src/commands/health.ts, apps/cli/src/lib/gitguardex.ts) so path
* expectations stay correct on every OS: Windows temp paths contain `\` and
* `~`, which fall outside the safe charset and get single-quoted.
*/
export function shellQuoteForTest(value: string): string {
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
return `'${value.replaceAll("'", "'\\''")}'`;
}
47 changes: 30 additions & 17 deletions apps/cli/test/skills-wire.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
COLONY_CUE_SKILL_ID,
Expand All @@ -9,6 +9,29 @@ import {
wireColonySkill,
} from '../src/commands/skills.js';

// Cross-platform fake `cue`: a Node script plus a .cmd shim so Windows
// spawn (which cannot exec shebang scripts) resolves it too.
function writeFakeCue(dir: string): void {
const cuePath = join(dir, 'cue');
writeFileSync(
cuePath,
[
'#!/usr/bin/env node',
'const args = process.argv.slice(2);',
"if (args[0] === '--version') { process.stdout.write('0.0.0\\n'); process.exit(0); }",
"process.stdout.write('called: ' + args.join(' ') + '\\n');",
'process.exit(0);',
'',
].join('\n'),
{ mode: 0o755 },
);
}

// The real `cue` CLI ships as a POSIX binary and the wirer spawns it via
// execFileSync without a shell, which Windows cannot satisfy for extension-
// less PATH stubs — run the cue-present integration tests on POSIX only.
const POSIX = process.platform !== 'win32';

describe('colony skills wire (cue auto-wiring)', () => {
const originalPath = process.env.PATH;
afterEach(() => {
Expand Down Expand Up @@ -41,18 +64,13 @@ describe('colony skills wire (cue auto-wiring)', () => {
expect(result.ok).toBe(false);
});

it('invokes `cue skills add-to-profile <id>` when cue is present', () => {
it.runIf(POSIX)('invokes `cue skills add-to-profile <id>` when cue is present', () => {
const dir = mkdtempSync(join(tmpdir(), 'fake-cue-'));
const cuePath = join(dir, 'cue');
// Stub cue: answer --version for detection, echo every other invocation so
// the test can assert the exact subcommand the wirer shells out to.
writeFileSync(
cuePath,
'#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo 0.0.0; exit 0; fi\necho "called: $*"\nexit 0\n',
{ mode: 0o755 },
);
writeFakeCue(dir);
try {
process.env.PATH = `${dir}:${originalPath ?? ''}`;
process.env.PATH = `${dir}${delimiter}${originalPath ?? ''}`;
const result = wireColonySkill();
expect(result.cueDetected).toBe(true);
expect(result.ok).toBe(true);
Expand All @@ -62,16 +80,11 @@ describe('colony skills wire (cue auto-wiring)', () => {
}
});

it('passes --preview through in dry-run mode', () => {
it.runIf(POSIX)('passes --preview through in dry-run mode', () => {
const dir = mkdtempSync(join(tmpdir(), 'fake-cue-'));
const cuePath = join(dir, 'cue');
writeFileSync(
cuePath,
'#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo 0.0.0; exit 0; fi\necho "called: $*"\nexit 0\n',
{ mode: 0o755 },
);
writeFakeCue(dir);
try {
process.env.PATH = `${dir}:${originalPath ?? ''}`;
process.env.PATH = `${dir}${delimiter}${originalPath ?? ''}`;
const result = wireColonySkill({ dryRun: true });
expect(result.message).toContain('add-to-profile colony/colony --preview');
} finally {
Expand Down
1 change: 1 addition & 0 deletions apps/mcp-server/src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export function mcpErrorResponse(
| 'QUEEN_INVALID_GOAL'
| 'RESCUE_CONFIRM_REQUIRED'
| 'SESSION_NOT_FOUND'
| 'SPEC_ARCHIVE_ALREADY_EXISTS'
| 'SPEC_ARCHIVE_CONFLICT'
| 'TASK_LINK_SELF'
| 'SCOUT_NO_CLAIM',
Expand Down
19 changes: 17 additions & 2 deletions apps/mcp-server/src/tools/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,29 @@ export function register(server: McpServer, ctx: ToolContext): void {
reason: `Archive ${args.slug}: ${merge.applied} deltas applied, ${merge.conflicts.length} conflicts`,
});

const archivePath = repo.archiveChange(args.slug);
let archivePath: string;
try {
archivePath = repo.archiveChange(args.slug);
} catch (err) {
if (err instanceof Error && err.message.startsWith('archive target already exists')) {
// Same-day re-archive of the same slug: deterministic on every OS
// since archiveChange checks the target explicitly. Surface it as a
// structured "already done" rather than an opaque failure so retry
// loops can stop.
return mcpErrorResponse('SPEC_ARCHIVE_ALREADY_EXISTS', err.message, {
status: 'already_archived',
slug: args.slug,
});
}
throw err;
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
status: 'archived',
archived_path: archivePath,
archived_path: archivePath.replace(/\\/g, '/'),
merged_root_hash: merge.spec.rootHash,
conflicts: merge.conflicts,
applied: merge.applied,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-12
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# agent-claude-fix-windows-plan-paths-archive-and-relea-2026-06-12-16-11 (minimal / T1)

Branch: `agent/<your-name>/<branch-slug>`

Describe the change in a sentence or two. Commit message is the spec of record.

## Handoff

- Handoff: change=`agent-claude-fix-windows-plan-paths-archive-and-relea-2026-06-12-16-11`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-fix-windows-plan-paths-archive-and-relea-2026-06-12-16-11` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-claude-fix-windows-plan-paths-archive-and-relea-2026-06-12-16-11/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state in the completion handoff.
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).
1 change: 1 addition & 0 deletions packages/compress/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/compress",
"version": "0.5.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/config",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/contracts",
"version": "0.6.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/core",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/embedding/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/embedding",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/foraging/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/foraging",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/hooks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/hooks",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/installers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/installers",
"version": "0.7.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/process/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@colony/process",
"version": "0.6.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/src/plan-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ export function publishPlan(args: PublishPlanInput): PublishPlanResult {
return {
plan_slug: args.slug,
spec_task_id: opened.task_id,
spec_change_path: opened.path,
plan_workspace_path: workspace.dir,
// Forward-slash the returned paths so MCP callers get separator-stable
// result data across OSes (these are reference paths, not used for fs here).
spec_change_path: opened.path.replace(/\\/g, '/'),
plan_workspace_path: workspace.dir.replace(/\\/g, '/'),
subtasks: subtaskThreads,
};
}
Expand Down
Loading
Loading