Merge branch 'next' into fix/1941-quick-worktree-stale-base
This commit is contained in:
@@ -22,6 +22,7 @@ const {
|
||||
isCapabilityActive,
|
||||
_isSafePropKey,
|
||||
_loadInstalledSkillsManifest,
|
||||
_loadFlatCommandsGsdManifest,
|
||||
_resolveManifest,
|
||||
} = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
@@ -823,6 +824,223 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => {
|
||||
// FAIL before the fix and PASS after, regardless of whether commands/gsd
|
||||
// happens to exist in the current checkout.
|
||||
|
||||
describe('regressions: flat commands/gsd-<stem>.md layout (#1858)', () => {
|
||||
// Flat command layout (Claude local project install shape): skills live at
|
||||
// <repo>/commands/gsd-<stem>.md — the gsd- prefix is baked into the filename
|
||||
// and there is NO commands/gsd/ subdir. _resolveManifest must detect this
|
||||
// layout, strip the gsd- prefix, and produce the same stems the nested
|
||||
// loader (commands/gsd/<stem>.md) would, or every skill-bearing capability
|
||||
// is silently reported surfaced:false / enabled:false / active:false.
|
||||
function makeFlatCommandMd(stem, requires) {
|
||||
const req = requires ? `requires: [${requires.join(', ')}]` : 'requires: [phase]';
|
||||
return [
|
||||
'---',
|
||||
`name: gsd:${stem}`,
|
||||
`description: ${stem} skill`,
|
||||
'argument-hint: "[phase number]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
req,
|
||||
'---',
|
||||
'Execute end-to-end.',
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ── Unit tests for _loadFlatCommandsGsdManifest ─────────────────────────────
|
||||
|
||||
test('_loadFlatCommandsGsdManifest: returns empty map when parent dir absent', () => {
|
||||
const missing = path.join(os.tmpdir(), 'cap-flat-missing-' + Date.now());
|
||||
const manifest = _loadFlatCommandsGsdManifest(missing);
|
||||
assert.ok(manifest instanceof Map, 'should return a Map');
|
||||
assert.strictEqual(manifest.size, 0, 'should be empty when parent dir absent');
|
||||
});
|
||||
|
||||
test('_loadFlatCommandsGsdManifest: scans gsd-<stem>.md and strips the gsd- prefix', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-scan-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8');
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-secure-phase.md'), makeFlatCommandMd('secure-phase'), 'utf8');
|
||||
// Non-gsd file must be ignored
|
||||
fs.writeFileSync(path.join(tmpDir, 'random-doc.md'), '# not a skill\n', 'utf8');
|
||||
// Non-markdown gsd file must be ignored
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-notskill.txt'), 'nope\n', 'utf8');
|
||||
|
||||
const manifest = _loadFlatCommandsGsdManifest(tmpDir);
|
||||
assert.ok(manifest.has('validate-phase'), 'flat gsd-validate-phase.md -> stem validate-phase');
|
||||
assert.ok(manifest.has('secure-phase'), 'flat gsd-secure-phase.md -> stem secure-phase');
|
||||
assert.ok(!manifest.has('gsd-validate-phase'), 'must NOT keep the gsd- prefix on the stem');
|
||||
assert.ok(!manifest.has('random-doc'), 'non-gsd file must be ignored');
|
||||
assert.ok(!manifest.has('notskill'), 'non-.md gsd file must be ignored');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_loadFlatCommandsGsdManifest: parses requires via shared parseRequires (no drift)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-req-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'gsd-my-skill.md'),
|
||||
makeFlatCommandMd('my-skill', ['dep-a', 'dep-b']),
|
||||
'utf8',
|
||||
);
|
||||
const manifest = _loadFlatCommandsGsdManifest(tmpDir);
|
||||
assert.deepStrictEqual(manifest.get('my-skill'), ['dep-a', 'dep-b']);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_loadFlatCommandsGsdManifest: companion _calls_agents_<stem> key present (parity with nested loader)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-agents-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8');
|
||||
const manifest = _loadFlatCommandsGsdManifest(tmpDir);
|
||||
assert.ok(manifest.has('_calls_agents_validate-phase'),
|
||||
'flat loader must emit the companion _calls_agents_ key (same Map shape as loadSkillsManifest)');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Low-1 (review): boundary — empty stem (gsd-.md) skipped, single-char stem kept.
|
||||
test('_loadFlatCommandsGsdManifest: skips gsd-.md (empty stem) and keeps single-char stem (slice boundary)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-edge-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-.md'), makeFlatCommandMd(''), 'utf8');
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-x.md'), makeFlatCommandMd('x'), 'utf8');
|
||||
const manifest = _loadFlatCommandsGsdManifest(tmpDir);
|
||||
assert.ok(!manifest.has(''), 'gsd-.md must NOT register an empty-string stem');
|
||||
assert.ok(!manifest.has('_calls_agents_'), 'no companion key for an empty stem');
|
||||
assert.ok(manifest.has('x'), 'gsd-x.md -> single-char stem "x" (slice(4,-3) boundary)');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// Low-2 (review): unreadable file degrades both keys to [] (parity with nested
|
||||
// loader's catch). POSIX-only AND must not run as root — root bypasses POSIX
|
||||
// read permission bits, so chmod 0o000 would NOT make the file unreadable and
|
||||
// the test would assert [] against the real parsed deps (false failure).
|
||||
// Skip on win32 (DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT) and when getuid()==0.
|
||||
const _skipUnreadable = process.platform === 'win32' || (typeof process.getuid === 'function' && process.getuid() === 0);
|
||||
test('_loadFlatCommandsGsdManifest: unreadable file degrades to empty deps + agents (parity)', { skip: _skipUnreadable }, () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-flat-unread-'));
|
||||
let madeUnreadable = false;
|
||||
try {
|
||||
const skillPath = path.join(tmpDir, 'gsd-secure.md');
|
||||
fs.writeFileSync(skillPath, '---\nname: gsd:secure\nrequires: [phase]\n---\nbody\n', { mode: 0o644 });
|
||||
fs.chmodSync(skillPath, 0o000);
|
||||
madeUnreadable = true;
|
||||
const manifest = _loadFlatCommandsGsdManifest(tmpDir);
|
||||
assert.deepStrictEqual(manifest.get('secure'), [], 'unreadable file -> empty requires (parity with nested catch)');
|
||||
assert.deepStrictEqual(manifest.get('_calls_agents_secure'), [], 'unreadable file -> empty agents (parity with nested catch)');
|
||||
} finally {
|
||||
// Restore writability so cleanup() can rm the tmp tree.
|
||||
if (madeUnreadable) {
|
||||
try { fs.chmodSync(path.join(tmpDir, 'gsd-secure.md'), 0o644); } catch { /* best effort */ }
|
||||
}
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── _resolveManifest picks the flat branch when nested is absent ────────────
|
||||
|
||||
test('_resolveManifest: detects flat commands/gsd-<stem>.md layout when nested commands/gsd/ is absent (#1858)', () => {
|
||||
const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-flat-repo-'));
|
||||
try {
|
||||
// Flat source layout: <repo>/commands/gsd-<stem>.md
|
||||
// NO commands/gsd/ subdir, NO skills/ dir.
|
||||
const commandsDir = path.join(tmpRepo, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-validate-phase.md'), makeFlatCommandMd('validate-phase'), 'utf8');
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-secure-phase.md'), makeFlatCommandMd('secure-phase'), 'utf8');
|
||||
|
||||
// commandsGsdDir = <repo>/commands/gsd (nested — does NOT exist).
|
||||
// dirname(commandsGsdDir) = <repo>/commands (where the flat files live).
|
||||
const commandsGsdDir = path.join(commandsDir, 'gsd');
|
||||
// configDir = a separate empty tmp dir (no skills/ → installed fallback empty).
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-flat-cfg-'));
|
||||
try {
|
||||
const manifest = _resolveManifest(commandsGsdDir, configDir);
|
||||
assert.ok(manifest.has('validate-phase'),
|
||||
'flat layout must populate validate-phase stem (was empty pre-fix → all skill caps unsurfaced)');
|
||||
assert.ok(manifest.has('secure-phase'), 'flat layout must populate secure-phase stem');
|
||||
assert.ok(!manifest.has('gsd-validate-phase'), 'stem must have gsd- prefix stripped');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
} finally {
|
||||
cleanup(tmpRepo);
|
||||
}
|
||||
});
|
||||
|
||||
test('_resolveManifest: flat branch does NOT shadow a populated installed skills dir when no flat files exist', () => {
|
||||
// Precedence: nested > flat-source > installed. If the flat parent dir has
|
||||
// NO gsd-*.md files, the flat loader returns an empty Map and _resolveManifest
|
||||
// must fall through to the installed-skills branch (not return empty).
|
||||
const tmpRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-precedence-'));
|
||||
try {
|
||||
const commandsDir = path.join(tmpRepo, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
// No gsd-*.md files in commands/ — flat loader yields empty.
|
||||
// Installed skills present under configDir:
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-precedence-cfg-'));
|
||||
try {
|
||||
const secureDir = path.join(configDir, 'skills', 'gsd-secure-phase');
|
||||
fs.mkdirSync(secureDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(secureDir, 'SKILL.md'),
|
||||
'---\nname: gsd:secure-phase\nrequires: [phase]\n---\nbody\n', 'utf8');
|
||||
|
||||
const commandsGsdDir = path.join(commandsDir, 'gsd'); // nested absent
|
||||
const manifest = _resolveManifest(commandsGsdDir, configDir);
|
||||
assert.ok(manifest.has('secure-phase'),
|
||||
'when flat dir is empty, installed-skills fallback must still work (precedence flat > installed only when flat non-empty)');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
} finally {
|
||||
cleanup(tmpRepo);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Parity: flat loader produces the same stems as the nested loader ────────
|
||||
// (DEFECT.GENERATIVE-FIX — guards against silent divergence between the two
|
||||
// parallel manifest-builders.)
|
||||
|
||||
test('flat loader and nested loader produce identical stems for the same command set (parity)', () => {
|
||||
const realCommandsGsdDir = path.resolve(__dirname, '..', 'commands', 'gsd');
|
||||
if (!fs.existsSync(realCommandsGsdDir)) return; // skip outside a repo checkout
|
||||
// Build a flat mirror of the real nested commands/gsd/<stem>.md as
|
||||
// commands/gsd-<stem>.md in a temp dir, then compare stem sets.
|
||||
const tmpFlat = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-parity-flat-'));
|
||||
try {
|
||||
const nested = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(realCommandsGsdDir);
|
||||
for (const [stem] of nested) {
|
||||
if (stem.startsWith('_calls_agents_')) continue;
|
||||
const src = path.join(realCommandsGsdDir, stem + '.md');
|
||||
if (!fs.existsSync(src)) continue;
|
||||
fs.writeFileSync(path.join(tmpFlat, 'gsd-' + stem + '.md'), fs.readFileSync(src, 'utf8'), 'utf8');
|
||||
}
|
||||
const flat = _loadFlatCommandsGsdManifest(tmpFlat);
|
||||
const nestedStems = [...nested.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort();
|
||||
const flatStems = [...flat.keys()].filter((k) => !k.startsWith('_calls_agents_')).sort();
|
||||
assert.deepStrictEqual(flatStems, nestedStems,
|
||||
'flat loader stem set must match nested loader stem set for the real command tree');
|
||||
// Nit-2 (review): also compare _calls_agents_<stem> VALUES, not just the
|
||||
// stem set — proves the shared parseCallsAgents output is identical.
|
||||
for (const stem of flatStems) {
|
||||
assert.deepStrictEqual(flat.get(`_calls_agents_${stem}`), nested.get(`_calls_agents_${stem}`),
|
||||
`agent refs for stem "${stem}" must match between flat and nested loaders`);
|
||||
assert.deepStrictEqual(flat.get(stem), nested.get(stem),
|
||||
`requires for stem "${stem}" must match between flat and nested loaders`);
|
||||
}
|
||||
} finally {
|
||||
cleanup(tmpFlat);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('regressions: installed-runtime capability surface (#1160)', () => {
|
||||
// Minimal valid SKILL.md content (frontmatter only — matches what install emits)
|
||||
function makeSkillMd(stem) {
|
||||
|
||||
@@ -493,7 +493,7 @@ describe('resolveLoopHooks plan:post — pure function against real registry', (
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'gap-analysis');
|
||||
});
|
||||
|
||||
test('[happy] real registry byLoopPoint plan:post has 1 step (mempalace), 1 contribution (external-job planner fragment), and 1 gate (gap-analysis)', () => {
|
||||
test('[happy] real registry byLoopPoint plan:post has 1 step (mempalace), 2 contributions (external-job planner + claude-orchestration ultraplan ownership), and 1 gate (gap-analysis)', () => {
|
||||
const entry = realRegistry.byLoopPoint['plan:post'];
|
||||
assert.ok(entry, 'plan:post must exist in byLoopPoint');
|
||||
assert.ok(Array.isArray(entry.steps), 'steps must be an array');
|
||||
@@ -501,8 +501,12 @@ describe('resolveLoopHooks plan:post — pure function against real registry', (
|
||||
assert.ok(Array.isArray(entry.gates), 'gates must be an array');
|
||||
assert.strictEqual(entry.steps.length, 1, 'plan:post must have 1 step (mempalace capture)');
|
||||
assert.strictEqual(entry.steps[0].capId, 'mempalace', 'plan:post step must be from mempalace');
|
||||
assert.strictEqual(entry.contributions.length, 1, 'plan:post must have 1 contribution (external-job planner runtime-budget fragment)');
|
||||
assert.strictEqual(entry.contributions[0].capId, 'external-job', 'plan:post contribution must be from external-job');
|
||||
// #1143: claude-orchestration registers a plan:post contribution declaring
|
||||
// ultraplan plan-offload ownership under its runtime gate (default-off).
|
||||
assert.strictEqual(entry.contributions.length, 2, 'plan:post must have 2 contributions (external-job planner + claude-orchestration ultraplan ownership)');
|
||||
const capIds = entry.contributions.map(c => c.capId).sort();
|
||||
assert.deepStrictEqual(capIds, ['claude-orchestration', 'external-job'],
|
||||
`plan:post contributions must be external-job + claude-orchestration; got ${capIds.join(',')}`);
|
||||
assert.strictEqual(entry.gates.length, 1, 'plan:post must have exactly one gate');
|
||||
assert.strictEqual(entry.gates[0].capId, 'gap-analysis');
|
||||
});
|
||||
|
||||
169
tests/claude-orchestration-command-router.test.cjs
Normal file
169
tests/claude-orchestration-command-router.test.cjs
Normal file
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* claude-orchestration-command-router.test.cjs — end-to-end tests for the
|
||||
* `gsd-tools claude-orchestration` command surface (#1143).
|
||||
*
|
||||
* Exercises the full dispatch path: gsd-tools → dispatchCapabilityCommand →
|
||||
* routeClaudeOrchestrationCommand → the pure detect/emitter functions.
|
||||
*
|
||||
* NOTE: runGsdTools() returns { success, output, exitCode, error } — it does
|
||||
* NOT throw on a non-zero exit (helpers.cjs). Tests assert `.success` and parse
|
||||
* `.output`, and check `.error` on the failure path.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const WAVES_MANIFEST = {
|
||||
waves: [
|
||||
{
|
||||
id: 'w1',
|
||||
plans: [
|
||||
{ id: 'p1', brief: 'Implement the foo module', files_modified: ['src/foo.cts'] },
|
||||
{ id: 'p2', brief: 'Wire the bar seam', files_modified: ['src/bar.cts'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function writeManifest(tmpDir) {
|
||||
const manifestPath = path.join(tmpDir, 'waves.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(WAVES_MANIFEST), 'utf8');
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
/** Run the command and assert it succeeded, returning the parsed JSON output. */
|
||||
function runAndParse(args, cwd) {
|
||||
const res = runGsdTools(args, cwd);
|
||||
assert.strictEqual(res.success, true, 'command should succeed; stderr: ' + (res.error || ''));
|
||||
assert.ok(res.output.length > 0, 'command should emit output');
|
||||
return JSON.parse(res.output);
|
||||
}
|
||||
|
||||
// ─── emit-workflow ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('claude-orchestration emit-workflow (CLI)', () => {
|
||||
test('emits a Workflow script with parallel barriers, gsd-executor + worktree, and resumeFromRunId', () => {
|
||||
const tmp = createTempProject('claw-emit-');
|
||||
try {
|
||||
const manifestPath = writeManifest(tmp);
|
||||
const parsed = runAndParse([
|
||||
'claude-orchestration', 'emit-workflow',
|
||||
'--waves', manifestPath,
|
||||
'--run-id', 'run-cli-1143',
|
||||
'--phase-dir', '.planning/phases/01-foo',
|
||||
], tmp);
|
||||
assert.ok(typeof parsed.script === 'string' && parsed.script.length > 0);
|
||||
assert.ok(parsed.script.includes('parallel('), 'parallel() barrier emitted');
|
||||
assert.ok(parsed.script.includes('gsd-executor'), 'gsd-executor agentType');
|
||||
assert.ok(parsed.script.includes('worktree'), 'worktree isolation');
|
||||
assert.ok(parsed.script.includes('resumeFromRunId'), 'resumeFromRunId wired');
|
||||
assert.ok(parsed.script.includes('run-cli-1143'), 'carries the run id');
|
||||
assert.strictEqual(parsed.summary.resumeRunId, 'run-cli-1143');
|
||||
assert.strictEqual(parsed.summary.waves, 1);
|
||||
assert.strictEqual(parsed.summary.plans, 2);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('budget flag threads a shared token pool into the script', () => {
|
||||
const tmp = createTempProject('claw-budget-');
|
||||
try {
|
||||
const manifestPath = writeManifest(tmp);
|
||||
const parsed = runAndParse([
|
||||
'claude-orchestration', 'emit-workflow',
|
||||
'--waves', manifestPath,
|
||||
'--run-id', 'r',
|
||||
'--budget', '750000',
|
||||
], tmp);
|
||||
assert.ok(parsed.script.includes('budget('), 'budget() pool emitted');
|
||||
assert.ok(parsed.script.includes('750000'));
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing --waves -> non-zero exit with a diagnostic', () => {
|
||||
const tmp = createTempProject('claw-noargs-');
|
||||
try {
|
||||
const res = runGsdTools([
|
||||
'claude-orchestration', 'emit-workflow', '--run-id', 'r',
|
||||
], tmp);
|
||||
assert.strictEqual(res.success, false, 'missing --waves must fail');
|
||||
assert.ok(res.exitCode !== 0, 'non-zero exit');
|
||||
assert.match(res.error || '', /--waves/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detect-backend ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('claude-orchestration detect-backend (CLI)', () => {
|
||||
test('default config (capability off) -> inline, even on Claude', () => {
|
||||
const tmp = createTempProject('claw-detect-');
|
||||
try {
|
||||
const parsed = runAndParse([
|
||||
'claude-orchestration', 'detect-backend',
|
||||
'--runtime', 'claude',
|
||||
'--agent-sdk-version', '1.0.0',
|
||||
], tmp);
|
||||
assert.strictEqual(parsed.backend, 'inline');
|
||||
assert.strictEqual(parsed.available, false);
|
||||
assert.match(parsed.reason, /disabled/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('enabled + claude + capable + new-enough SDK -> workflow', () => {
|
||||
const tmp = createTempProject('claw-detect-on-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, '.planning', 'config.json'),
|
||||
JSON.stringify({ claude_orchestration: { enabled: true, execution_backend: 'auto' } }),
|
||||
'utf8',
|
||||
);
|
||||
const parsed = runAndParse([
|
||||
'claude-orchestration', 'detect-backend',
|
||||
'--runtime', 'claude',
|
||||
'--agent-sdk-version', '1.2.0',
|
||||
], tmp);
|
||||
assert.strictEqual(parsed.backend, 'workflow');
|
||||
assert.strictEqual(parsed.available, true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-Claude runtime -> inline (criterion 6)', () => {
|
||||
const tmp = createTempProject('claw-detect-nonclaude-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, '.planning', 'config.json'),
|
||||
JSON.stringify({ claude_orchestration: { enabled: true } }),
|
||||
'utf8',
|
||||
);
|
||||
const parsed = runAndParse([
|
||||
'claude-orchestration', 'detect-backend',
|
||||
'--runtime', 'codex',
|
||||
'--agent-sdk-version', '1.0.0',
|
||||
], tmp);
|
||||
assert.strictEqual(parsed.backend, 'inline');
|
||||
assert.match(parsed.reason, /claude/i);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
615
tests/claude-orchestration.test.cjs
Normal file
615
tests/claude-orchestration.test.cjs
Normal file
@@ -0,0 +1,615 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* claude-orchestration.test.cjs — Behavioral tests for the Claude orchestration
|
||||
* capability (#1143): Workflow-tool backend detection, Workflow-script emission,
|
||||
* capability-declaration validation, registry integration, and inline-fallback parity.
|
||||
*
|
||||
* The capability is default-off + BETA + claude-only. On any runtime lacking the
|
||||
* Workflow tool it must be a byte-identical no-op. These tests encode that contract.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
const {
|
||||
detectWorkflowBackend,
|
||||
emitWorkflowScript,
|
||||
WORKFLOW_TOOL_FLOOR_VERSION,
|
||||
BACKEND_VALUES,
|
||||
compareSemver,
|
||||
} = require('../gsd-core/bin/lib/claude-orchestration.cjs');
|
||||
|
||||
const {
|
||||
validateCapability,
|
||||
validateAgainstContract,
|
||||
loadAndValidate,
|
||||
buildRegistry,
|
||||
serializeRegistry,
|
||||
normalizeLineEndings,
|
||||
stripGeneratedComment,
|
||||
} = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const CAP_PATH = path.join(ROOT, 'capabilities', 'claude-orchestration', 'capability.json');
|
||||
const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A host-integration descriptor whose dispatch axis signals Workflow-tool capability. */
|
||||
const CAPABLE_HOST = {
|
||||
dispatch: { namedDispatch: true, nested: true, background: true, backgroundDispatch: false },
|
||||
};
|
||||
|
||||
/** Read the real capability declaration (data file — not a source grep). */
|
||||
function loadCap() {
|
||||
return JSON.parse(fs.readFileSync(CAP_PATH, 'utf8'));
|
||||
}
|
||||
|
||||
/** A minimal single-plan wave manifest. */
|
||||
function singleWaveManifest() {
|
||||
return {
|
||||
phaseDir: '.planning/phases/01-foo',
|
||||
runId: 'run-abc-1143',
|
||||
waves: [
|
||||
{
|
||||
id: 'w1',
|
||||
plans: [
|
||||
{ id: 'p1', brief: 'Implement the foo module', files_modified: ['src/foo.cts'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Two plans in one wave that DO NOT overlap (parallel-safe in a single stage). */
|
||||
function nonOverlappingManifest() {
|
||||
return {
|
||||
phaseDir: '.planning/phases/01-foo',
|
||||
runId: 'run-abc-1143',
|
||||
waves: [
|
||||
{
|
||||
id: 'w1',
|
||||
plans: [
|
||||
{ id: 'p1', brief: 'Plan A', files_modified: ['src/a.cts'] },
|
||||
{ id: 'p2', brief: 'Plan B', files_modified: ['src/b.cts'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Two plans in one wave that DO overlap on files_modified (must split into stages). */
|
||||
function overlappingManifest() {
|
||||
return {
|
||||
phaseDir: '.planning/phases/01-foo',
|
||||
runId: 'run-abc-1143',
|
||||
waves: [
|
||||
{
|
||||
id: 'w1',
|
||||
plans: [
|
||||
{ id: 'p1', brief: 'Plan A', files_modified: ['src/shared.cts', 'src/a.cts'] },
|
||||
{ id: 'p2', brief: 'Plan B', files_modified: ['src/shared.cts', 'src/b.cts'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 1. detectWorkflowBackend ─────────────────────────────────────────────────
|
||||
|
||||
describe('detectWorkflowBackend', () => {
|
||||
|
||||
test('capability disabled (default-off) -> inline, even on Claude with the tool', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: { 'claude_orchestration.enabled': false },
|
||||
});
|
||||
assert.strictEqual(r.available, false);
|
||||
assert.strictEqual(r.backend, 'inline');
|
||||
assert.match(r.reason, /disabled/);
|
||||
});
|
||||
|
||||
test('non-Claude runtime -> inline (criterion 6: no change to non-Claude loop)', () => {
|
||||
for (const runtimeId of ['codex', 'cursor', 'opencode', 'copilot', ' Windsurf'.trim()]) {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId,
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'workflow' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'inline', runtimeId + ' should be inline');
|
||||
assert.strictEqual(r.available, false, runtimeId + ' should be unavailable');
|
||||
assert.match(r.reason, /claude/i, runtimeId + ' reason should mention claude');
|
||||
}
|
||||
});
|
||||
|
||||
test('Claude + auto + capable host + new-enough SDK -> workflow', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.2.0',
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'auto' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'workflow');
|
||||
assert.strictEqual(r.available, true);
|
||||
});
|
||||
|
||||
test('Claude + execution_backend:"workflow" forces workflow when tool is capable', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'workflow' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'workflow');
|
||||
assert.strictEqual(r.available, true);
|
||||
});
|
||||
|
||||
test('Claude + execution_backend:"inline" -> inline even when tool is capable', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'inline' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'inline');
|
||||
assert.match(r.reason, /inline/);
|
||||
});
|
||||
|
||||
test('Claude + auto + host lacking nested dispatch -> inline (fail-closed)', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: { dispatch: { nested: false, background: true } },
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'auto' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'inline');
|
||||
assert.strictEqual(r.available, false);
|
||||
});
|
||||
|
||||
test('Claude + unknown agentSdkVersion -> inline fail-closed (criterion 3 fallback)', () => {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId: 'claude',
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: undefined,
|
||||
config: { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'auto' },
|
||||
});
|
||||
assert.strictEqual(r.backend, 'inline');
|
||||
assert.strictEqual(r.available, false);
|
||||
assert.match(r.reason, /version|sdk|unknown/i);
|
||||
});
|
||||
|
||||
test('agent SDK version boundary: floor-1 -> inline, floor -> workflow, floor+patch -> workflow', () => {
|
||||
const floor = WORKFLOW_TOOL_FLOOR_VERSION;
|
||||
const [maj, min, pat] = floor.split('.').map((n) => parseInt(n, 10));
|
||||
// Robust "below" derivation with full borrow chain (works for .0.0 floors too).
|
||||
let below;
|
||||
if (pat > 0) below = `${maj}.${min}.${pat - 1}`;
|
||||
else if (min > 0) below = `${maj}.${min - 1}.999`;
|
||||
else if (maj > 0) below = `${maj - 1}.999.999`;
|
||||
else { assert.ok(false, 'cannot derive below for 0.0.0 floor'); return; }
|
||||
const above = `${maj}.${min}.${pat + 1}`;
|
||||
// Sanity: confirm below really is below per the comparator under test.
|
||||
assert.ok(compareSemver(below, floor) < 0, below + ' must compare below ' + floor);
|
||||
|
||||
const cfg = { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'auto' };
|
||||
|
||||
const rBelow = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: below, config: cfg });
|
||||
assert.strictEqual(rBelow.backend, 'inline', below + ' (floor-1) must be inline');
|
||||
|
||||
const rAt = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: floor, config: cfg });
|
||||
assert.strictEqual(rAt.backend, 'workflow', floor + ' (exact floor) must be workflow');
|
||||
|
||||
const rAbove = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: above, config: cfg });
|
||||
assert.strictEqual(rAbove.backend, 'workflow', above + ' (floor+patch) must be workflow');
|
||||
});
|
||||
|
||||
test('config-level min_agent_sdk_version override raises/lowers the floor', () => {
|
||||
const cfg = {
|
||||
'claude_orchestration.enabled': true,
|
||||
'claude_orchestration.execution_backend': 'auto',
|
||||
'claude_orchestration.min_agent_sdk_version': '2.0.0',
|
||||
};
|
||||
const r1 = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: '1.9.9', config: cfg });
|
||||
assert.strictEqual(r1.backend, 'inline', 'below raised floor -> inline');
|
||||
const r2 = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: '2.0.0', config: cfg });
|
||||
assert.strictEqual(r2.backend, 'workflow', 'at raised floor -> workflow');
|
||||
});
|
||||
|
||||
test('execution_backend:"workflow" + SDK below floor -> inline (M-1: floor applies in both modes)', () => {
|
||||
const cfg = {
|
||||
'claude_orchestration.enabled': true,
|
||||
'claude_orchestration.execution_backend': 'workflow',
|
||||
};
|
||||
const r = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: '0.3.0', config: cfg });
|
||||
assert.strictEqual(r.backend, 'inline', 'workflow mode must still honor the SDK floor (fail-closed)');
|
||||
assert.strictEqual(r.available, false);
|
||||
assert.match(r.reason, /floor|version/);
|
||||
});
|
||||
|
||||
test('pre-release of the floor (0.3.149-rc.1) -> inline (pre-release < GA per SemVer)', () => {
|
||||
const cfg = { 'claude_orchestration.enabled': true, 'claude_orchestration.execution_backend': 'auto' };
|
||||
// Explicitly assert the precedence rule: a pre-release tag is below the GA release.
|
||||
assert.ok(compareSemver('0.3.149-rc.1', '0.3.149') < 0, 'pre-release must compare below GA');
|
||||
const r = detectWorkflowBackend({ runtimeId: 'claude', hostIntegration: CAPABLE_HOST, agentSdkVersion: '0.3.149-rc.1', config: cfg });
|
||||
assert.strictEqual(r.backend, 'inline', 'pre-release of the floor must not activate the BETA backend');
|
||||
assert.strictEqual(r.available, false);
|
||||
});
|
||||
|
||||
test('two pre-releases of the same triple order by their identifiers (SemVer §11)', () => {
|
||||
assert.ok(compareSemver('0.3.149-rc.0', '0.3.149-rc.1') < 0, 'rc.0 < rc.1');
|
||||
assert.ok(compareSemver('1.0.0-alpha.1', '1.0.0-alpha.2') < 0, 'alpha.1 < alpha.2');
|
||||
assert.ok(compareSemver('1.0.0-rc.1', '1.0.0-rc.2') < 0, 'rc.1 < rc.2');
|
||||
// numeric < alphanumeric at the same position
|
||||
assert.ok(compareSemver('1.0.0-1', '1.0.0-alpha') < 0, 'numeric identifier < alphanumeric');
|
||||
});
|
||||
|
||||
test('missing/empty input -> inline, never throws (Postel: liberal-in-input)', () => {
|
||||
assert.strictEqual(detectWorkflowBackend({}).backend, 'inline');
|
||||
assert.strictEqual(detectWorkflowBackend(null).backend, 'inline');
|
||||
assert.strictEqual(detectWorkflowBackend(undefined).backend, 'inline');
|
||||
assert.strictEqual(detectWorkflowBackend({ runtimeId: 'claude' }).backend, 'inline');
|
||||
});
|
||||
|
||||
test('BACKEND_VALUES exposes the closed enum', () => {
|
||||
assert.deepStrictEqual([...BACKEND_VALUES].sort(), ['auto', 'inline', 'workflow']);
|
||||
});
|
||||
|
||||
test('property: pure & deterministic (same input -> same output)', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.record({
|
||||
runtimeId: fc.constantFrom('claude', 'codex', 'cursor', 'opencode'),
|
||||
sdk: fc.option(fc.string({ minLength: 1, maxLength: 8 }).filter((s) => /^\d/.test(s)), { nil: undefined }),
|
||||
backend: fc.constantFrom('auto', 'workflow', 'inline'),
|
||||
enabled: fc.boolean(),
|
||||
}),
|
||||
(input) => {
|
||||
const cfg = {
|
||||
'claude_orchestration.enabled': input.enabled,
|
||||
'claude_orchestration.execution_backend': input.backend,
|
||||
};
|
||||
const a = detectWorkflowBackend({ runtimeId: input.runtimeId, hostIntegration: CAPABLE_HOST, agentSdkVersion: input.sdk, config: cfg });
|
||||
const b = detectWorkflowBackend({ runtimeId: input.runtimeId, hostIntegration: CAPABLE_HOST, agentSdkVersion: input.sdk, config: cfg });
|
||||
assert.deepStrictEqual(a, b);
|
||||
assert.ok(['workflow', 'inline'].includes(a.backend));
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. compareSemver helper ──────────────────────────────────────────────────
|
||||
|
||||
describe('compareSemver', () => {
|
||||
test('ordering', () => {
|
||||
assert.ok(compareSemver('1.0.0', '0.9.9') > 0);
|
||||
assert.ok(compareSemver('1.0.0', '1.0.0') === 0);
|
||||
assert.ok(compareSemver('1.0.0', '1.0.1') < 0);
|
||||
assert.ok(compareSemver('2.0.0', '1.9.9') > 0);
|
||||
});
|
||||
test('garbage versions compare as -1 (fail-closed)', () => {
|
||||
assert.strictEqual(compareSemver('garbage', '1.0.0'), -1);
|
||||
assert.strictEqual(compareSemver('1.0.0', ''), -1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. emitWorkflowScript ────────────────────────────────────────────────────
|
||||
|
||||
describe('emitWorkflowScript', () => {
|
||||
|
||||
test('single-wave single-plan -> one parallel barrier, one agent, executor+worktree', () => {
|
||||
const { ok, script, summary } = emitWorkflowScript(singleWaveManifest());
|
||||
assert.strictEqual(ok, true);
|
||||
assert.ok(typeof script === 'string' && script.length > 0);
|
||||
|
||||
const parallelCount = (script.match(/parallel\s*\(/g) || []).length;
|
||||
assert.ok(parallelCount >= 1, 'at least one parallel() barrier');
|
||||
assert.ok(script.includes('agent('), 'agent() call per plan');
|
||||
assert.ok(script.includes('gsd-executor'), 'uses gsd-executor agentType');
|
||||
assert.ok(script.includes('worktree'), 'uses worktree isolation');
|
||||
assert.ok(script.includes('SUMMARY.md'), 'produces SUMMARY.md (same artifact as inline path)');
|
||||
|
||||
assert.deepStrictEqual(summary.waves, 1);
|
||||
assert.deepStrictEqual(summary.plans, 1);
|
||||
});
|
||||
|
||||
test('multi-wave -> one parallel() barrier per wave (sequential barriers)', () => {
|
||||
const r = emitWorkflowScript({
|
||||
phaseDir: '.planning/phases/01-foo',
|
||||
runId: 'run-multi',
|
||||
waves: [
|
||||
{ id: 'w1', plans: [{ id: 'p1', brief: 'A', files_modified: ['src/a.cts'] }] },
|
||||
{ id: 'w2', plans: [{ id: 'p2', brief: 'B', files_modified: ['src/b.cts'] }] },
|
||||
{ id: 'w3', plans: [{ id: 'p3', brief: 'C', files_modified: ['src/c.cts'] }] },
|
||||
],
|
||||
});
|
||||
assert.strictEqual(r.ok, true);
|
||||
const parallelCount = (r.script.match(/parallel\s*\(/g) || []).length;
|
||||
assert.strictEqual(parallelCount, 3, 'one parallel() per wave');
|
||||
assert.strictEqual(r.summary.waves, 3);
|
||||
assert.strictEqual(r.summary.plans, 3);
|
||||
});
|
||||
|
||||
test('overlapping files_modified -> plans split into separate sequential stages (criterion 2)', () => {
|
||||
const r = emitWorkflowScript(overlappingManifest());
|
||||
assert.strictEqual(r.ok, true);
|
||||
// Two plans sharing src/shared.cts must NOT be in the same stage.
|
||||
const stages = r.summary.stagesByWave[0]; // wave w1
|
||||
assert.ok(Array.isArray(stages), 'stagesByWave present');
|
||||
assert.strictEqual(stages.length, 2, 'overlapping plans split into 2 stages');
|
||||
const stagePlanSets = stages.map((s) => s.slice().sort());
|
||||
const allPlans = stagePlanSets.flat().sort();
|
||||
assert.deepStrictEqual(allPlans, ['p1', 'p2']);
|
||||
// p1 and p2 must be in different stages
|
||||
assert.ok(stages[0].length === 1 && stages[1].length === 1, 'one plan per stage when they overlap');
|
||||
});
|
||||
|
||||
test('non-overlapping plans -> coalesced into a single parallel stage', () => {
|
||||
const r = emitWorkflowScript(nonOverlappingManifest());
|
||||
assert.strictEqual(r.ok, true);
|
||||
const stages = r.summary.stagesByWave[0];
|
||||
assert.strictEqual(stages.length, 1, 'non-overlapping plans share one stage');
|
||||
assert.deepStrictEqual(stages[0].slice().sort(), ['p1', 'p2']);
|
||||
});
|
||||
|
||||
test('resumeFromRunId wired to the provided runId (criterion 4)', () => {
|
||||
const r = emitWorkflowScript(singleWaveManifest());
|
||||
assert.ok(r.script.includes('resumeFromRunId'), 'references resumeFromRunId');
|
||||
assert.ok(r.script.includes('run-abc-1143'), 'carries the run id');
|
||||
assert.strictEqual(r.summary.resumeRunId, 'run-abc-1143');
|
||||
});
|
||||
|
||||
test('shared budget pool emitted when budgetTokens provided', () => {
|
||||
const r = emitWorkflowScript({ ...singleWaveManifest(), budgetTokens: 500000 });
|
||||
assert.ok(r.script.includes('budget('), 'emits budget() pool');
|
||||
assert.ok(r.script.includes('500000'));
|
||||
});
|
||||
|
||||
test('no budget() emitted when budgetTokens omitted', () => {
|
||||
const r = emitWorkflowScript(singleWaveManifest());
|
||||
assert.ok(!r.script.includes('budget('), 'no budget() when unset');
|
||||
});
|
||||
|
||||
test('invalid input -> ok:false with a reason, never throws', () => {
|
||||
const empty = emitWorkflowScript({ phaseDir: '.p', runId: 'r', waves: [] });
|
||||
assert.strictEqual(empty.ok, false);
|
||||
assert.ok(typeof empty.reason === 'string' && empty.reason.length > 0);
|
||||
|
||||
const noRun = emitWorkflowScript({ phaseDir: '.p', runId: '', waves: singleWaveManifest().waves });
|
||||
assert.strictEqual(noRun.ok, false);
|
||||
|
||||
const noPhase = emitWorkflowScript({ phaseDir: '', runId: 'r', waves: singleWaveManifest().waves });
|
||||
assert.strictEqual(noPhase.ok, false);
|
||||
|
||||
const badWave = emitWorkflowScript({ phaseDir: '.p', runId: 'r', waves: [{ id: 'w1', plans: [] }] });
|
||||
assert.strictEqual(badWave.ok, false);
|
||||
});
|
||||
|
||||
test('SECURITY: runId/phaseDir/wave.id/plan.id with injection chars -> ok:false (never reach the script)', () => {
|
||||
// runId is interpolated inside resumeFromRunId("...") — a quote/backslash/newline
|
||||
// could break out of the call. Identifier validation must reject it.
|
||||
const injectRun = emitWorkflowScript({ phaseDir: '.p', runId: 'x");evil("y', waves: singleWaveManifest().waves });
|
||||
assert.strictEqual(injectRun.ok, false);
|
||||
assert.match(injectRun.reason, /runId/i);
|
||||
|
||||
const newlineRun = emitWorkflowScript({ phaseDir: '.p', runId: 'r\nbreakout', waves: singleWaveManifest().waves });
|
||||
assert.strictEqual(newlineRun.ok, false);
|
||||
|
||||
const injectPhase = emitWorkflowScript({ phaseDir: '.p"; drop table', runId: 'r', waves: singleWaveManifest().waves });
|
||||
assert.strictEqual(injectPhase.ok, false);
|
||||
|
||||
const injectWave = emitWorkflowScript({
|
||||
phaseDir: '.p', runId: 'r',
|
||||
waves: [{ id: 'w1\nagent("evil")', plans: [{ id: 'p1', brief: 'b', files_modified: ['a.cts'] }] }],
|
||||
});
|
||||
assert.strictEqual(injectWave.ok, false);
|
||||
|
||||
const injectPlan = emitWorkflowScript({
|
||||
phaseDir: '.p', runId: 'r',
|
||||
waves: [{ id: 'w1', plans: [{ id: 'p1";x("y', brief: 'b', files_modified: ['a.cts'] }] }],
|
||||
});
|
||||
assert.strictEqual(injectPlan.ok, false);
|
||||
});
|
||||
|
||||
test('SECURITY: a brief containing quotes/backslash/newlines is neutralised (never breaks the string literal)', () => {
|
||||
const r = emitWorkflowScript({
|
||||
phaseDir: '.p', runId: 'r',
|
||||
waves: [{ id: 'w1', plans: [{ id: 'p1', brief: 'he said "hi" \\ then \n newline', files_modified: ['a.cts'] }] }],
|
||||
});
|
||||
assert.strictEqual(r.ok, true);
|
||||
// The emitted script must not contain a raw unescaped quote that closes the
|
||||
// agent() string literal, nor a raw newline inside the brief.
|
||||
assert.ok(!r.script.includes('he said "hi" \\\\'), 'no unescaped breakout');
|
||||
// The full brief text never appears verbatim with its dangerous chars intact.
|
||||
assert.ok(!r.script.includes('"hi"'), 'the inner quote must be JSON-escaped, not raw');
|
||||
});
|
||||
|
||||
test('duplicate plan id within a wave -> ok:false (L-5: no silent brief loss)', () => {
|
||||
const r = emitWorkflowScript({
|
||||
phaseDir: '.p', runId: 'r',
|
||||
waves: [{ id: 'w1', plans: [
|
||||
{ id: 'p1', brief: 'first', files_modified: ['a.cts'] },
|
||||
{ id: 'p1', brief: 'second', files_modified: ['b.cts'] },
|
||||
] }],
|
||||
});
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.reason, /duplicate/i);
|
||||
});
|
||||
|
||||
test('non-string files_modified entries -> ok:false (L-7: strict element typing)', () => {
|
||||
const r = emitWorkflowScript({
|
||||
phaseDir: '.p', runId: 'r',
|
||||
waves: [{ id: 'w1', plans: [{ id: 'p1', brief: 'b', files_modified: ['ok.cts', 42, { path: 'x' }] }] }],
|
||||
});
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.reason, /files_modified/);
|
||||
});
|
||||
|
||||
test('property: deterministic (same input -> identical script)', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.record({
|
||||
runId: fc.string({ minLength: 1, maxLength: 12 }).filter((s) => /^[a-zA-Z0-9-]+$/.test(s)),
|
||||
nPlans: fc.integer({ min: 1, max: 5 }),
|
||||
}),
|
||||
({ runId, nPlans }) => {
|
||||
const waves = [{
|
||||
id: 'w1',
|
||||
plans: Array.from({ length: nPlans }, (_, i) => ({
|
||||
id: 'p' + i,
|
||||
brief: 'brief ' + i,
|
||||
files_modified: ['src/file' + i + '.cts'],
|
||||
})),
|
||||
}];
|
||||
const a = emitWorkflowScript({ phaseDir: '.planning/phases/01-x', runId, waves });
|
||||
const b = emitWorkflowScript({ phaseDir: '.planning/phases/01-x', runId, waves });
|
||||
assert.strictEqual(a.script, b.script);
|
||||
assert.deepStrictEqual(a.summary, b.summary);
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Capability declaration validation ─────────────────────────────────────
|
||||
|
||||
describe('capability declaration (capabilities/claude-orchestration/capability.json)', () => {
|
||||
|
||||
test('file exists and parses', () => {
|
||||
const cap = loadCap();
|
||||
assert.strictEqual(cap.id, 'claude-orchestration');
|
||||
});
|
||||
|
||||
test('passes per-file validateCapability', () => {
|
||||
const errors = validateCapability(loadCap(), 'claude-orchestration');
|
||||
assert.deepEqual(errors, [], 'Expected no validation errors: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('passes contract validation (contribution.into roles, when references)', () => {
|
||||
const errors = validateAgainstContract(loadCap(), 'claude-orchestration');
|
||||
assert.deepEqual(errors, [], 'Expected no contract errors: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('default-off: activationKey default is false and points at the enabled key', () => {
|
||||
const cap = loadCap();
|
||||
assert.strictEqual(cap.activationKey, 'claude_orchestration.enabled');
|
||||
assert.strictEqual(cap.config['claude_orchestration.enabled'].default, false);
|
||||
assert.strictEqual(cap.config['claude_orchestration.enabled'].type, 'boolean');
|
||||
});
|
||||
|
||||
test('runtimeCompat is claude-only (criterion 6)', () => {
|
||||
const cap = loadCap();
|
||||
assert.deepStrictEqual(cap.runtimeCompat.supported, ['claude']);
|
||||
assert.deepStrictEqual(cap.runtimeCompat.unsupported, []);
|
||||
});
|
||||
|
||||
test('BETA posture: tier full, role feature', () => {
|
||||
const cap = loadCap();
|
||||
assert.strictEqual(cap.role, 'feature');
|
||||
assert.strictEqual(cap.tier, 'full');
|
||||
});
|
||||
|
||||
test('execution_backend is an enum with auto|workflow|inline defaulting to auto', () => {
|
||||
const slice = loadCap().config['claude_orchestration.execution_backend'];
|
||||
assert.strictEqual(slice.type, 'enum');
|
||||
assert.deepStrictEqual(slice.values, ['auto', 'workflow', 'inline']);
|
||||
assert.strictEqual(slice.default, 'auto');
|
||||
});
|
||||
|
||||
test('registers at WIRED points only (execute:wave:post, plan:post)', () => {
|
||||
const cap = loadCap();
|
||||
const points = cap.contributions.map((c) => c.point);
|
||||
for (const p of points) {
|
||||
assert.ok(
|
||||
['discuss:pre', 'discuss:post', 'plan:pre', 'plan:post', 'execute:post', 'execute:wave:post', 'verify:post', 'ship:pre', 'ship:post'].includes(p),
|
||||
'contribution point ' + p + ' must be a wired point',
|
||||
);
|
||||
}
|
||||
assert.ok(points.includes('execute:wave:post'), 'registers the execute wave hook');
|
||||
assert.ok(points.includes('plan:post'), 'declares plan:* ownership for ultraplan (criterion 5)');
|
||||
});
|
||||
|
||||
test('all contributions gated by the enabled key + onError:skip (default-resilient)', () => {
|
||||
const cap = loadCap();
|
||||
for (const c of cap.contributions) {
|
||||
assert.strictEqual(c.when, 'claude_orchestration.enabled', 'every contribution gated by enabled');
|
||||
assert.strictEqual(c.onError, 'skip', 'every contribution onError:skip');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Registry integration ──────────────────────────────────────────────────
|
||||
|
||||
describe('registry integration', () => {
|
||||
|
||||
test('loadAndValidate includes claude-orchestration with no errors', () => {
|
||||
const { capMap, errors } = loadAndValidate(new Set()); // empty central keys = no collision noise
|
||||
// Filter errors to only those touching our capability.
|
||||
const ours = errors.filter((e) => e.includes('claude-orchestration'));
|
||||
assert.deepEqual(ours, [], 'our capability produced errors: ' + JSON.stringify(ours));
|
||||
assert.ok(capMap.has('claude-orchestration'), 'capMap includes claude-orchestration');
|
||||
});
|
||||
|
||||
test('buildRegistry surfaces the federated config keys in configSchema', () => {
|
||||
const { capMap } = loadAndValidate(new Set());
|
||||
const registry = buildRegistry(capMap);
|
||||
assert.ok(registry.configSchema['claude_orchestration.enabled'], 'enabled key federated');
|
||||
assert.ok(registry.configSchema['claude_orchestration.execution_backend'], 'execution_backend key federated');
|
||||
assert.strictEqual(registry.configSchema['claude_orchestration.enabled'].owner, 'claude-orchestration');
|
||||
assert.strictEqual(registry.configSchema['claude_orchestration.execution_backend'].default, 'auto');
|
||||
});
|
||||
|
||||
test('byLoopPoint[execute:wave:post].contributions includes our capability', () => {
|
||||
const { capMap } = loadAndValidate(new Set());
|
||||
const registry = buildRegistry(capMap);
|
||||
const contribs = registry.byLoopPoint['execute:wave:post'].contributions;
|
||||
const ours = contribs.find((c) => c.capId === 'claude-orchestration');
|
||||
assert.ok(ours, 'our execute:wave:post contribution is registered');
|
||||
assert.strictEqual(ours.into, 'executor');
|
||||
});
|
||||
|
||||
test('committed registry is in sync (gen-capability-registry --check)', () => {
|
||||
const { capMap } = loadAndValidate(new Set());
|
||||
const registry = buildRegistry(capMap);
|
||||
const live = serializeRegistry(registry, capMap);
|
||||
const committed = fs.readFileSync(REGISTRY_PATH, 'utf8');
|
||||
assert.strictEqual(
|
||||
normalizeLineEndings(stripGeneratedComment(committed)),
|
||||
normalizeLineEndings(stripGeneratedComment(live)),
|
||||
'registry is stale — run: node scripts/gen-capability-registry.cjs --write',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Inline-fallback parity (criterion 3 + 6) ──────────────────────────────
|
||||
|
||||
describe('inline-fallback parity', () => {
|
||||
|
||||
test('default config (capability off) -> inline on every runtime, including Claude', () => {
|
||||
// The capability ships default-off; with no user opt-in the backend is always inline.
|
||||
const defaultCfg = {}; // nothing set
|
||||
for (const runtimeId of ['claude', 'codex', 'cursor', 'opencode']) {
|
||||
const r = detectWorkflowBackend({
|
||||
runtimeId,
|
||||
hostIntegration: CAPABLE_HOST,
|
||||
agentSdkVersion: '1.0.0',
|
||||
config: defaultCfg,
|
||||
});
|
||||
assert.strictEqual(r.backend, 'inline', runtimeId + ' default must be inline');
|
||||
assert.strictEqual(r.available, false, runtimeId + ' default must be unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
test('generated Workflow script preserves the inline-path contract (same agent + isolation + artifact)', () => {
|
||||
// Criterion 2: the emitted Workflow composes the SAME gsd-executor agent and worktree
|
||||
// isolation the inline path uses, and produces the same SUMMARY.md artifact.
|
||||
const r = emitWorkflowScript(singleWaveManifest());
|
||||
assert.ok(r.script.includes('gsd-executor'), 'same executor agent as inline dispatch');
|
||||
assert.ok(r.script.includes('worktree'), 'same worktree isolation as inline dispatch');
|
||||
assert.ok(r.script.includes('SUMMARY.md'), 'same SUMMARY.md artifact as inline dispatch');
|
||||
});
|
||||
});
|
||||
@@ -629,15 +629,17 @@ describe('F. Real registry execute:wave:post shape — guard against accidental
|
||||
`ui.safety-gate onError must be 'halt'; got ${uiGate.onError}`);
|
||||
});
|
||||
|
||||
test('[happy] real registry: execute:wave:post has no steps and 2 contributions (mempalace capture-problems + external-job executor fragment)', () => {
|
||||
test('[happy] real registry: execute:wave:post has no steps and 3 contributions (claude-orchestration executor + external-job executor + mempalace capture-problems)', () => {
|
||||
const point = realRegistry.byLoopPoint['execute:wave:post'];
|
||||
assert.strictEqual(point.steps.length, 0,
|
||||
`execute:wave:post steps must be empty; got ${point.steps.length}`);
|
||||
assert.strictEqual(point.contributions.length, 2,
|
||||
`execute:wave:post must have 2 contributions (mempalace + external-job); got ${point.contributions.length}`);
|
||||
// #1143: claude-orchestration registers an execute:wave:post contribution
|
||||
// providing the Workflow-tool backend guidance (default-off, claude-only).
|
||||
assert.strictEqual(point.contributions.length, 3,
|
||||
`execute:wave:post must have 3 contributions (claude-orchestration + external-job + mempalace); got ${point.contributions.length}`);
|
||||
const capIds = point.contributions.map(c => c.capId).sort();
|
||||
assert.deepStrictEqual(capIds, ['external-job', 'mempalace'],
|
||||
`execute:wave:post contributions must be mempalace + external-job; got ${capIds.join(',')}`);
|
||||
assert.deepStrictEqual(capIds, ['claude-orchestration', 'external-job', 'mempalace'],
|
||||
`execute:wave:post contributions must be claude-orchestration + external-job + mempalace; got ${capIds.join(',')}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -423,6 +423,7 @@ function resetRuntimeWarningCaches() {
|
||||
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||
configLoader._resetRuntimeWarningCacheForTests();
|
||||
modelResolver._resetModelPolicyWarningCacheForTests();
|
||||
modelResolver._resetModelOverrideWarningCacheForTests();
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, TOOLS_PATH };
|
||||
|
||||
176
tests/issue-1575-agent-descriptor-parity.test.cjs
Normal file
176
tests/issue-1575-agent-descriptor-parity.test.cjs
Normal file
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
// #1575 — Golden-parity harness (ADR-1235 §0).
|
||||
//
|
||||
// Asserts that the surface path (applySurface) produces byte-for-byte identical
|
||||
// agent output to the install path (installRuntimeArtifacts) for every
|
||||
// descriptor-driven runtime. Both paths run against the SAME configDir so
|
||||
// pathPrefix, attribution, and converter outputs match.
|
||||
//
|
||||
// The harness:
|
||||
// 1. installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution)
|
||||
// 2. Snapshot every gsd-* agent file in configDir/agents/
|
||||
// 3. applySurface(configDir, layout, manifest, ..., opts)
|
||||
// 4. Compare every agent file byte-for-byte: snapshot === current
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// The 7 descriptor-driven agent runtimes (cline deferred per code comment:
|
||||
// rules-only local branch + local/global complication).
|
||||
const DESCRIPTOR_RUNTIMES = [
|
||||
'cursor',
|
||||
'windsurf',
|
||||
'augment',
|
||||
'trae',
|
||||
'codebuddy',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
];
|
||||
|
||||
function snapshotAgents(agentsDir) {
|
||||
const snap = new Map();
|
||||
if (!fs.existsSync(agentsDir)) return snap;
|
||||
for (const name of fs.readdirSync(agentsDir)) {
|
||||
if (!name.startsWith('gsd-')) continue;
|
||||
if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue;
|
||||
snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8'));
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
// Shared manifest + profile so both paths see the same source agents.
|
||||
const manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const profile = resolveProfile({ modes: ['full'], manifest });
|
||||
// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation).
|
||||
const resolveAttribution = () => undefined;
|
||||
|
||||
describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => {
|
||||
|
||||
for (const runtime of DESCRIPTOR_RUNTIMES) {
|
||||
test(`${runtime}: surface agents byte-identical to install agents`, (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: install path writes agents
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
// Step 2: snapshot agent files
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);
|
||||
|
||||
// Step 3: surface path re-materializes into the SAME configDir
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
|
||||
|
||||
// Step 4: compare byte-for-byte
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
|
||||
// File lists must match
|
||||
const installFiles = [...installSnap.keys()].sort();
|
||||
const surfaceFiles = [...surfaceSnap.keys()].sort();
|
||||
assert.deepEqual(
|
||||
surfaceFiles,
|
||||
installFiles,
|
||||
`${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`,
|
||||
);
|
||||
|
||||
// Content must match byte-for-byte
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
const surfaceContent = surfaceSnap.get(fileName);
|
||||
assert.strictEqual(
|
||||
surfaceContent,
|
||||
installContent,
|
||||
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
|
||||
);
|
||||
}
|
||||
|
||||
test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
|
||||
// M2 regression guard: verify parity holds when resolveAttribution returns
|
||||
// a real value. Source agents don't carry Co-Authored-By, so processAttribution
|
||||
// is a no-op (it replaces existing lines, doesn't add new ones). But this test
|
||||
// proves the agentCtx threading is correct for both paths regardless.
|
||||
const attrResolver = () => 'Test Bot <test@example.com>';
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('cursor', configDir, 'global', profile, attrResolver);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, 'install must produce agents');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution: attrResolver });
|
||||
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
assert.strictEqual(surfaceSnap.get(fileName), installContent,
|
||||
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-'));
|
||||
assert.ok(agentFiles.length > 0, 'copilot must have installed agents');
|
||||
assert.ok(
|
||||
agentFiles.every((f) => f.endsWith('.agent.md')),
|
||||
`copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => {
|
||||
test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Seed a pre-existing legacy .agent.md (simulating a prior install)
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n');
|
||||
fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n');
|
||||
|
||||
// Install (should prune stale gsd-*, preserve user agents)
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
const afterInstall = fs.readdirSync(agentsDir);
|
||||
assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned');
|
||||
assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved');
|
||||
|
||||
// Now surface over the install — must converge to the same state
|
||||
const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
|
||||
|
||||
const afterSurface = fs.readdirSync(agentsDir);
|
||||
// Same set of agent files as after install
|
||||
const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort();
|
||||
const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort();
|
||||
assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install');
|
||||
assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface');
|
||||
});
|
||||
});
|
||||
@@ -3770,6 +3770,190 @@ describe('#49 resolveModelPolicy: prototype-pollution guards', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2041: model_overrides Claude full ID → Agent-tool alias on claude runtime ─
|
||||
//
|
||||
// Mirrors the #1133 model_policy alias-mapping tests (above) for the
|
||||
// model_overrides path. Bug: a full Claude model ID in model_overrides
|
||||
// (e.g. "claude-sonnet-5") was returned VERBATIM on the claude runtime and
|
||||
// handed to the Claude Agent tool, whose typed `model` parameter documents only
|
||||
// tier aliases (opus/sonnet/haiku/fable). The model_policy path already maps
|
||||
// full IDs → aliases via CLAUDE_POLICY_ID_TO_ALIAS (#1144); model_overrides
|
||||
// skipped that mapping entirely. The fix mirrors #1144 on the override path.
|
||||
// Non-Claude runtimes and non-Claude values pass through verbatim (parity).
|
||||
|
||||
describe('#2041 model_overrides: Claude full ID → alias on claude runtime', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmp('2041');
|
||||
resetRuntimeWarningCaches();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmr(tmpDir);
|
||||
resetRuntimeWarningCaches();
|
||||
});
|
||||
|
||||
// AC1 + AC2: mappable Claude full IDs resolve to their aliases on claude runtime
|
||||
test('model_overrides claude-sonnet-5 → "sonnet" on runtime:claude (resolveModelInternal)', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-executor': 'claude-sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('model_overrides claude-opus-4-8 → "opus" on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-planner': 'claude-opus-4-8' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
|
||||
});
|
||||
|
||||
test('model_overrides claude-haiku-4-5 → "haiku" on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'haiku');
|
||||
});
|
||||
|
||||
test('model_overrides claude-fable-5 → "fable" on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-planner': 'claude-fable-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'fable');
|
||||
});
|
||||
|
||||
// AC3: bare aliases pass through verbatim
|
||||
test('model_overrides bare "sonnet" alias passes through verbatim on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-executor': 'sonnet' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('model_overrides bare "fable" alias passes through verbatim on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-planner': 'fable' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'fable');
|
||||
});
|
||||
|
||||
// AC1 (implicit claude): mapping fires when runtime key is absent (defaults to claude)
|
||||
test('model_overrides claude-sonnet-5 → "sonnet" with implicit claude runtime (no runtime key)', () => {
|
||||
writeConfig(tmpDir, {
|
||||
model_overrides: { 'gsd-executor': 'claude-sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
// AC4: non-claude runtimes keep full IDs verbatim (parity with model_policy path)
|
||||
test('model_overrides claude-sonnet-5 → verbatim ID on non-claude runtime (opencode)', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'opencode',
|
||||
model_overrides: { 'gsd-executor': 'claude-sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'claude-sonnet-5');
|
||||
});
|
||||
|
||||
// AC5: unmappable Claude full ID warns once + falls through to tier alias
|
||||
test('model_overrides unmappable claude ID (claude-opus-4-5) falls through to tier alias on claude', () => {
|
||||
resetRuntimeWarningCaches();
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_profile: 'balanced',
|
||||
model_overrides: { 'gsd-planner': 'claude-opus-4-5' },
|
||||
});
|
||||
// gsd-planner balanced → opus tier; claude-opus-4-5 has no alias → warn + fall through → 'opus'
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
|
||||
});
|
||||
|
||||
test('model_overrides unmappable claude ID emits a stderr warning exactly once (dedupe)', () => {
|
||||
resetRuntimeWarningCaches();
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_profile: 'balanced',
|
||||
model_overrides: { 'gsd-planner': 'claude-opus-4-5' },
|
||||
});
|
||||
const writes = [];
|
||||
const original = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk) => { writes.push(String(chunk)); return true; };
|
||||
try {
|
||||
resolveModelInternal(tmpDir, 'gsd-planner');
|
||||
resolveModelInternal(tmpDir, 'gsd-planner'); // second call — dedupe must suppress
|
||||
} finally {
|
||||
process.stderr.write = original;
|
||||
}
|
||||
const warnings = writes.filter((w) => w.includes('model_overrides') && w.includes('claude-opus-4-5'));
|
||||
assert.strictEqual(warnings.length, 1,
|
||||
`expected exactly one override warning, got ${warnings.length}: ${JSON.stringify(writes)}`);
|
||||
});
|
||||
|
||||
// AC6: resolveModelForTier (escalation / --attempt path) maps the same way
|
||||
test('resolveModelForTier maps claude-sonnet-5 → "sonnet" on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-executor': 'claude-sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-executor', 0), 'sonnet');
|
||||
});
|
||||
|
||||
test('resolveModelForTier keeps full ID verbatim on non-claude runtime', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'opencode',
|
||||
model_overrides: { 'gsd-executor': 'claude-sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-executor', 0), 'claude-sonnet-5');
|
||||
});
|
||||
|
||||
// MEDIUM-1 (review): exercise the unmappable-override fall-through branch in
|
||||
// resolveModelForTier (closes the mutation-score gap — a future refactor that
|
||||
// accidentally returned the verbatim override instead of falling through
|
||||
// would otherwise survive the suite).
|
||||
test('resolveModelForTier unmappable claude ID falls through to tier alias on claude', () => {
|
||||
resetRuntimeWarningCaches();
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_profile: 'balanced',
|
||||
model_overrides: { 'gsd-planner': 'claude-opus-4-5' },
|
||||
});
|
||||
// unmappable override → fall through → no dynamic_routing → resolveModelInternal → 'opus'
|
||||
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-planner', 0), 'opus');
|
||||
});
|
||||
|
||||
// LOW-2 (review): pin the case-sensitive contract — a case-variant like
|
||||
// "Claude-Sonnet-5" is NOT mapped (alias keys are case-sensitive, matching
|
||||
// the model_policy path and the Claude API).
|
||||
test('model_overrides case-variant "Claude-Sonnet-5" passes through verbatim (case-sensitive contract)', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-executor': 'Claude-Sonnet-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'Claude-Sonnet-5');
|
||||
});
|
||||
|
||||
// Regression guard: non-Claude custom / vendor values still pass through verbatim
|
||||
// on the claude runtime (the fix must NOT touch values that aren't Claude IDs).
|
||||
test('model_overrides non-Claude custom model passes through verbatim on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-planner': 'my-custom-model' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'my-custom-model');
|
||||
});
|
||||
|
||||
test('model_overrides non-Claude vendor ID (openai/gpt-5) passes through verbatim on runtime:claude', () => {
|
||||
writeConfig(tmpDir, {
|
||||
runtime: 'claude',
|
||||
model_overrides: { 'gsd-executor': 'openai/gpt-5' },
|
||||
});
|
||||
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'openai/gpt-5');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveModelForTier: model_policy beats dynamic_routing ─────────────────
|
||||
|
||||
describe('#49 resolveModelForTier: model_policy beats dynamic_routing', () => {
|
||||
|
||||
@@ -20,22 +20,36 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
test('repo-layout: root AGENTS.md is absent — no ad-hoc AI instruction file committed alongside CONTEXT.md', () => {
|
||||
const agentsMdPath = path.join(ROOT, 'AGENTS.md');
|
||||
test('repo-layout: root AGENTS.md is not git-tracked — no ad-hoc AI instruction file committed alongside CONTEXT.md', () => {
|
||||
// The installer legitimately writes AGENTS.md to process.cwd() when
|
||||
// `gsd install copilot` runs inside a repo checkout (issue #786). The file
|
||||
// may exist on disk — that's expected after a local install. What must NOT
|
||||
// happen is committing it to git, where editors and AI tools would silently
|
||||
// pick up the installer-generated stub instead of CONTEXT.md.
|
||||
let tracked;
|
||||
try {
|
||||
execFileSync('git', ['ls-files', '--error-unmatch', 'AGENTS.md'], {
|
||||
cwd: ROOT, encoding: 'utf8', stdio: 'pipe',
|
||||
});
|
||||
tracked = true;
|
||||
} catch {
|
||||
tracked = false;
|
||||
}
|
||||
assert.equal(
|
||||
fs.existsSync(agentsMdPath),
|
||||
tracked,
|
||||
false,
|
||||
[
|
||||
'root AGENTS.md must not be committed.',
|
||||
'root AGENTS.md must not be git-tracked.',
|
||||
'This file is written by `gsd install copilot` (bin/install.js, local Copilot path, issue #786)',
|
||||
'when the installer runs inside a repo checkout.',
|
||||
'when the installer runs inside a repo checkout — its presence on disk is fine,',
|
||||
'but it must never be committed.',
|
||||
'The repository source of truth for architecture and contributor guidance is',
|
||||
'CONTEXT.md and docs/adr/ — not an installer-generated instruction stub.',
|
||||
'Run `gsd uninstall copilot` to remove the artefact, then verify it is gitignored',
|
||||
'before re-running the install in this checkout.',
|
||||
'Run `git rm --cached AGENTS.md` to untrack it if accidentally staged.',
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,20 +83,26 @@ const GOLDEN = {
|
||||
|
||||
// ── copilot ──────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// #1575: agents kind added (copilot cutover — .agent.md rename handled by _copyStaged).
|
||||
'copilot/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'copilot/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── antigravity ──────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// #1575: agents kind added (antigravity cutover — scope-aware converter).
|
||||
'antigravity/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'antigravity/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── windsurf ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -108,11 +108,16 @@ describe('resolveRuntimeArtifactLayout — copilot', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'copilot');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
// #1575: agents kind added (copilot cutover)
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,11 +126,16 @@ describe('resolveRuntimeArtifactLayout — antigravity', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'antigravity');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
// #1575: agents kind added (antigravity cutover)
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user