Skip to content

Commit 2cc5ff8

Browse files
NagyViktNagyVikt
andauthored
fix(spec,release): Windows-green plan tests + valid release plan (#595)
* fix(spec): forward-slash publish result paths; deterministic archive-target collision spec_change_path/plan_workspace_path returned OS-native separators, so Windows callers (and the plan.test.ts contains-assertions) saw backslash paths. archiveChange relied on renameSync failing onto an existing target — POSIX throws, Windows MoveFileEx clobbers — so the existence check is now explicit. * fix(spec,release): Windows-green plan tests + valid release plan - publishPlan returns forward-slash spec_change_path/plan_workspace_path so Windows callers (and plan.test.ts contains-assertions) see separator-stable reference paths. - archiveChange checks the archive target explicitly: POSIX renameSync threw on an existing target, Windows MoveFileEx silently clobbered — the 'delta written then archive throws' test now errors on every OS. - .changeset/post-tool-use-awareness-push.md referenced package 'colony' (not in workspace; the CLI is 'colonyq'), which crashed changesets' assemble-release-plan and failed every Release run since #594. * fix(cli,mcp): Windows-portable test expectations + structured already-archived error mcp-server going green on Windows exposed the next packages in pnpm's recursive run: 9 apps/cli failures, all path-portability in tests — expectations assumed POSIX literals where the implementation resolves and shell-quotes OS-natively. Added shellQuoteForTest mirroring the production quoting rule; cockpit/queen-health/agents-spawn expectations now derive from resolve(). PATH stubs join with path.delimiter. The gx/cue PATH-spawning integration tests gate to POSIX (both tools spawn via execFileSync without shell, which Windows cannot satisfy). spec_archive returns structured SPEC_ARCHIVE_ALREADY_EXISTS (status: already_archived) instead of an opaque throw on same-day re-archive (review follow-up). * chore(release): mark internal @colony/* packages private The npm @Colony scope is not ours (@colony/core@3.1.0 belongs to a different project), so 'changeset publish' would 403 on every public internal package and fail Release after versioning. colonyq is the product artifact and ships self-contained (prepack bundles, no @colony/* deps in the published tarball — e2e-publish green), so the ten internal packages are now private: publish ships exactly colonyq. * fix(spec,cli): review follow-ups — staging recovery, aligned shellQuote charset, normalized archived_path --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 1d78c99 commit 2cc5ff8

25 files changed

Lines changed: 141 additions & 36 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
'@colony/hooks': minor
3-
'colony': patch
3+
'colonyq': patch
44
---
55

66
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@colony/spec': patch
3+
---
4+
5+
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).

apps/cli/src/lib/gitguardex.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,9 @@ function execGx(execFileSync: GitGuardexExecFileSync, args: string[], cwd: strin
834834
}
835835

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

apps/cli/test/agents-spawn.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '../src/executors/gitguardex.js';
1313
import { createProgram } from '../src/index.js';
1414
import { withStore } from '../src/util/store.js';
15+
import { shellQuoteForTest } from './shell-quote-helper.js';
1516

1617
const MINIMAL_SPEC = `# SPEC
1718
@@ -148,7 +149,7 @@ describe('colony agents spawn', () => {
148149
expect(output).toContain('Stop and hand off if quota or conflict');
149150
expect(output).toContain('Sibling files:\n- apps/cli/test/agents-spawn.test.ts');
150151
expect(output).toContain('Do-not-touch list:\n- apps/cli/test/agents-spawn.test.ts');
151-
expect(output).toContain(`--target ${repoRoot}`);
152+
expect(output).toContain(`--target ${shellQuoteForTest(repoRoot)}`);
152153
expect(output).toContain('--claim apps/cli/src/executors/gitguardex.ts');
153154
expect(existsSync(gxArgsFile)).toBe(false);
154155
});

apps/cli/test/cockpit.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdtempSync, rmSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
3-
import { join } from 'node:path';
3+
import { join, resolve } from 'node:path';
44
import kleur from 'kleur';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { createProgram } from '../src/index.js';
@@ -9,6 +9,7 @@ import {
99
defaultCockpitSessionName,
1010
formatCommand,
1111
} from '../src/lib/gitguardex.js';
12+
import { shellQuoteForTest } from './shell-quote-helper.js';
1213

1314
let dataDir: string;
1415
let output: string;
@@ -42,7 +43,11 @@ describe('colony cockpit', () => {
4243
);
4344

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

7075
expect(JSON.parse(output)).toMatchObject({
7176
dry_run: true,
72-
command: 'gx cockpit --target /work/colony --session ops',
73-
repo_root: '/work/colony',
77+
command: `gx cockpit --target ${shellQuoteForTest(resolve('/work/colony'))} --session ops`,
78+
repo_root: resolve('/work/colony'),
7479
session_name: 'ops',
7580
next_spawn_commands: [],
7681
});
@@ -79,7 +84,9 @@ describe('colony cockpit', () => {
7984
it('quotes rendered paths and sessions for shell output', () => {
8085
expect(
8186
formatCommand('gx', buildGitGuardexCockpitCommand('/work/repo with space', 'ops cockpit')),
82-
).toBe("gx cockpit --target '/work/repo with space' --session 'ops cockpit'");
87+
).toBe(
88+
`gx cockpit --target ${shellQuoteForTest(resolve('/work/repo with space'))} --session 'ops cockpit'`,
89+
);
8390
});
8491

8592
it('normalizes the default session name from the repo root', () => {

apps/cli/test/gitguardex.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
3-
import { join } from 'node:path';
3+
import { delimiter, join } from 'node:path';
4+
45
import { defaultSettings } from '@colony/config';
56
import { MemoryStore, listPlans } from '@colony/core';
67
import { type QueenOrderedPlanInput, publishOrderedPlan } from '@colony/queen';
@@ -12,7 +13,10 @@ import {
1213
} from '../src/executors/gitguardex.js';
1314
import { createProgram } from '../src/index.js';
1415
import { type GitGuardexExecFileSync, spawnGitGuardexAgent } from '../src/lib/gitguardex.js';
15-
16+
// gx (gitguardex) is a POSIX tool; Windows cannot exec the PATH-stub shim
17+
// (execFileSync without shell rejects .cmd), so PATH-spawning integration
18+
// tests run on POSIX only.
19+
const POSIX = process.platform !== 'win32';
1620
const MINIMAL_SPEC = `# SPEC
1721
1822
## §G goal
@@ -62,7 +66,7 @@ beforeEach(() => {
6266
originalGxStatus = process.env.GX_FAKE_AGENTS_STATUS;
6367
originalGxFailVersion = process.env.GX_FAKE_FAIL_VERSION;
6468
process.env.COLONY_HOME = dataDir;
65-
process.env.PATH = `${binDir}:${process.env.PATH ?? ''}`;
69+
process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ''}`;
6670
process.env.GX_FAKE_LOG = logPath;
6771
process.env.GX_FAKE_AGENTS_STATUS = JSON.stringify({
6872
schemaVersion: 1,
@@ -181,7 +185,7 @@ describe('GitGuardex executor CLI', () => {
181185
expect(output).toContain('--claim apps/cli/src/lib/gitguardex.ts');
182186
});
183187

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

252-
it('shows GitGuardex lanes in colony status', async () => {
256+
it.runIf(POSIX)('shows GitGuardex lanes in colony status', async () => {
253257
process.env.GX_FAKE_AGENTS_STATUS = JSON.stringify({
254258
schemaVersion: 1,
255259
repoRoot,

apps/cli/test/queen-health.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import kleur from 'kleur';
1212
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1313
import { buildColonyHealthPayload, formatColonyHealthOutput } from '../src/commands/health.js';
14+
import { shellQuoteForTest } from './shell-quote-helper.js';
1415

1516
const NOW = 1_800_000_000_000;
1617
const SINCE = NOW - 24 * 3_600_000;
@@ -441,7 +442,7 @@ describe('queen wave health', () => {
441442
state: 'archived',
442443
recommendation: {
443444
action: 'publish-new-plan',
444-
command: `colony queen plan --repo-root ${repoRoot} "<goal>"`,
445+
command: `colony queen plan --repo-root ${shellQuoteForTest(repoRoot)} "<goal>"`,
445446
},
446447
});
447448
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Mirror of the production shellQuote rule (packages/queen/src/sweep.ts,
3+
* apps/cli/src/commands/health.ts, apps/cli/src/lib/gitguardex.ts) so path
4+
* expectations stay correct on every OS: Windows temp paths contain `\` and
5+
* `~`, which fall outside the safe charset and get single-quoted.
6+
*/
7+
export function shellQuoteForTest(value: string): string {
8+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
9+
return `'${value.replaceAll("'", "'\\''")}'`;
10+
}

apps/cli/test/skills-wire.test.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
3-
import { join } from 'node:path';
3+
import { delimiter, join } from 'node:path';
44
import { afterEach, describe, expect, it } from 'vitest';
55
import {
66
COLONY_CUE_SKILL_ID,
@@ -9,6 +9,29 @@ import {
99
wireColonySkill,
1010
} from '../src/commands/skills.js';
1111

12+
// Cross-platform fake `cue`: a Node script plus a .cmd shim so Windows
13+
// spawn (which cannot exec shebang scripts) resolves it too.
14+
function writeFakeCue(dir: string): void {
15+
const cuePath = join(dir, 'cue');
16+
writeFileSync(
17+
cuePath,
18+
[
19+
'#!/usr/bin/env node',
20+
'const args = process.argv.slice(2);',
21+
"if (args[0] === '--version') { process.stdout.write('0.0.0\\n'); process.exit(0); }",
22+
"process.stdout.write('called: ' + args.join(' ') + '\\n');",
23+
'process.exit(0);',
24+
'',
25+
].join('\n'),
26+
{ mode: 0o755 },
27+
);
28+
}
29+
30+
// The real `cue` CLI ships as a POSIX binary and the wirer spawns it via
31+
// execFileSync without a shell, which Windows cannot satisfy for extension-
32+
// less PATH stubs — run the cue-present integration tests on POSIX only.
33+
const POSIX = process.platform !== 'win32';
34+
1235
describe('colony skills wire (cue auto-wiring)', () => {
1336
const originalPath = process.env.PATH;
1437
afterEach(() => {
@@ -41,18 +64,13 @@ describe('colony skills wire (cue auto-wiring)', () => {
4164
expect(result.ok).toBe(false);
4265
});
4366

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

65-
it('passes --preview through in dry-run mode', () => {
83+
it.runIf(POSIX)('passes --preview through in dry-run mode', () => {
6684
const dir = mkdtempSync(join(tmpdir(), 'fake-cue-'));
67-
const cuePath = join(dir, 'cue');
68-
writeFileSync(
69-
cuePath,
70-
'#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo 0.0.0; exit 0; fi\necho "called: $*"\nexit 0\n',
71-
{ mode: 0o755 },
72-
);
85+
writeFakeCue(dir);
7386
try {
74-
process.env.PATH = `${dir}:${originalPath ?? ''}`;
87+
process.env.PATH = `${dir}${delimiter}${originalPath ?? ''}`;
7588
const result = wireColonySkill({ dryRun: true });
7689
expect(result.message).toContain('add-to-profile colony/colony --preview');
7790
} finally {

apps/mcp-server/src/tools/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ export function mcpErrorResponse(
296296
| 'QUEEN_INVALID_GOAL'
297297
| 'RESCUE_CONFIRM_REQUIRED'
298298
| 'SESSION_NOT_FOUND'
299+
| 'SPEC_ARCHIVE_ALREADY_EXISTS'
299300
| 'SPEC_ARCHIVE_CONFLICT'
300301
| 'TASK_LINK_SELF'
301302
| 'SCOUT_NO_CLAIM',

0 commit comments

Comments
 (0)