* feat(#1355): detect-and-warn guard for claude-code agent-teams GSD's multi-agent orchestration can stall under claude-code's experimental agent-teams (a subagent's completion fails to route to the orchestrator). Per the maintainer decision, the accepted scope is a read-only detector + one non-fatal warning — NOT the declined run_in_background/TaskOutput conversion. - New Teams Status Module (src/teams-status.cts → gsd-core/bin/lib/teams-status.cjs): pure resolveTeamsStatus({runtime, env}) + thin CLI cmdTeamsStatus reusing resolveRuntime. active = strictly-truthy env flag AND runtime === 'claude'. - Wire `gsd-tools query teams-status [--active]` (read-only; no capability registration needed — conformance gates govern features, not query commands). - One non-fatal warning in plan-phase.md before the first Agent spawn, gated on `query teams-status --active`; zero behavior change on non-claude/teams-off. - Hermeticity: clear CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS in run-tests.cjs + SESSION_ENV_KEYS. Docs reference + CONTEXT.md glossary. Built lib gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1355): add changeset for teams-detect guard Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1355): bump plan-phase.md workflow size baseline (+407B for teams warning) The non-fatal agent-teams warning block added to plan-phase.md grew it 92759 → 93166 bytes, past its committed per-file baseline ratchet. The growth is small, deliberate, and still well under the workflow tier hard cap. Regenerate the baseline via `npm run size:baseline` (only plan-phase.md changed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1355): register teams-status.cjs in the inventory manifest The new teams-status CLI module is a tracked surface; regenerate docs/INVENTORY-MANIFEST.json (cli_modules family) via gen-inventory-manifest.cjs --write so the inventory-manifest-sync gate passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ const SESSION_ENV_KEYS = [
|
||||
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID', 'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
|
||||
'TTY', 'SSH_TTY',
|
||||
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
|
||||
];
|
||||
|
||||
function clearSessionEnv() {
|
||||
|
||||
195
tests/teams-status.test.cjs
Normal file
195
tests/teams-status.test.cjs
Normal file
@@ -0,0 +1,195 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* teams-status.test.cjs — behavioral tests for teams-status.cjs.
|
||||
*
|
||||
* issue #1355: read-only detector for claude-code's experimental agent-teams.
|
||||
* Uses node:test + node:assert/strict.
|
||||
* Pure-function tests (resolveTeamsStatus) pass {runtime, env} directly — no I/O.
|
||||
* End-to-end tests use gsd-tools CLI via spawnSync with explicit hermetic envs.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { resolveTeamsStatus } = require('../gsd-core/bin/lib/teams-status.cjs');
|
||||
|
||||
const gsdToolsPath = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ─── resolveTeamsStatus — pure unit tests ─────────────────────────────────────
|
||||
|
||||
describe('resolveTeamsStatus — pure unit tests', () => {
|
||||
test('claude + "1" → active=true, source="on: env"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
|
||||
});
|
||||
assert.strictEqual(status.active, true);
|
||||
assert.strictEqual(status.env_present, true);
|
||||
assert.strictEqual(status.source, 'on: env');
|
||||
assert.strictEqual(status.runtime, 'claude');
|
||||
});
|
||||
|
||||
test('claude + "true" → active=true, source="on: env"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: 'true' },
|
||||
});
|
||||
assert.strictEqual(status.active, true);
|
||||
assert.strictEqual(status.env_present, true);
|
||||
assert.strictEqual(status.source, 'on: env');
|
||||
});
|
||||
|
||||
test('claude + "TRUE" (case) → active=true, source="on: env"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: 'TRUE' },
|
||||
});
|
||||
assert.strictEqual(status.active, true);
|
||||
assert.strictEqual(status.env_present, true);
|
||||
assert.strictEqual(status.source, 'on: env');
|
||||
});
|
||||
|
||||
test('claude + "0" → active=false, source="off: flag absent"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0' },
|
||||
});
|
||||
assert.strictEqual(status.active, false);
|
||||
assert.strictEqual(status.env_present, false);
|
||||
assert.strictEqual(status.source, 'off: flag absent');
|
||||
});
|
||||
|
||||
test('claude + "false" → active=false, source="off: flag absent"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: 'false' },
|
||||
});
|
||||
assert.strictEqual(status.active, false);
|
||||
assert.strictEqual(status.env_present, false);
|
||||
assert.strictEqual(status.source, 'off: flag absent');
|
||||
});
|
||||
|
||||
test('claude + unset → active=false, source="off: flag absent"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'claude',
|
||||
env: {},
|
||||
});
|
||||
assert.strictEqual(status.active, false);
|
||||
assert.strictEqual(status.env_present, false);
|
||||
assert.strictEqual(status.source, 'off: flag absent');
|
||||
});
|
||||
|
||||
test('codex + "1" → active=false, source="off: non-claude"', () => {
|
||||
const status = resolveTeamsStatus({
|
||||
runtime: 'codex',
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
|
||||
});
|
||||
assert.strictEqual(status.active, false);
|
||||
assert.strictEqual(status.env_present, true);
|
||||
assert.strictEqual(status.source, 'off: non-claude');
|
||||
assert.strictEqual(status.runtime, 'codex');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI/subprocess tests ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal hermetic env for subprocess tests.
|
||||
* NEVER inherit the dev shell env — always set GSD_RUNTIME and
|
||||
* CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS explicitly.
|
||||
*/
|
||||
function makeEnv({ runtime, teamsFlag } = {}) {
|
||||
// Start with a safe minimal env (PATH is required for node to find modules)
|
||||
const env = {
|
||||
PATH: process.env['PATH'] || '',
|
||||
HOME: process.env['HOME'] || '',
|
||||
// Prevent any ambient GSD env from leaking in
|
||||
};
|
||||
if (runtime !== undefined) env['GSD_RUNTIME'] = runtime;
|
||||
if (teamsFlag !== undefined) env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = teamsFlag;
|
||||
return env;
|
||||
}
|
||||
|
||||
describe('gsd-tools query teams-status — CLI subprocess tests', () => {
|
||||
test('default: prints JSON with correct shape (GSD_RUNTIME=claude, flag=1)', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'query', 'teams-status'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: makeEnv({ runtime: 'claude', teamsFlag: '1' }),
|
||||
},
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(status.active, true, 'active should be true');
|
||||
assert.strictEqual(status.env_present, true, 'env_present should be true');
|
||||
assert.strictEqual(status.source, 'on: env', 'source should be "on: env"');
|
||||
assert.strictEqual(status.runtime, 'claude', 'runtime should be "claude"');
|
||||
assert.ok('active' in status, 'output must have active key');
|
||||
assert.ok('env_present' in status, 'output must have env_present key');
|
||||
assert.ok('source' in status, 'output must have source key');
|
||||
assert.ok('runtime' in status, 'output must have runtime key');
|
||||
});
|
||||
|
||||
test('default: prints JSON with active=false when flag unset', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'query', 'teams-status'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: makeEnv({ runtime: 'claude' }),
|
||||
},
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
||||
const status = JSON.parse(result.stdout);
|
||||
assert.strictEqual(status.active, false);
|
||||
assert.strictEqual(status.source, 'off: flag absent');
|
||||
});
|
||||
|
||||
test('--active: exits 0 when GSD_RUNTIME=claude + flag=1', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'query', 'teams-status', '--active'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: makeEnv({ runtime: 'claude', teamsFlag: '1' }),
|
||||
},
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0 when teams active, got ${result.status}: ${result.stderr}`);
|
||||
// --active should print nothing
|
||||
assert.strictEqual(result.stdout, '', '--active must not print to stdout');
|
||||
});
|
||||
|
||||
test('--active: exits 1 when flag unset (GSD_RUNTIME=claude, no flag)', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'query', 'teams-status', '--active'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: makeEnv({ runtime: 'claude' }),
|
||||
},
|
||||
);
|
||||
assert.strictEqual(result.status, 1, `Expected exit 1 when teams not active, got ${result.status}`);
|
||||
});
|
||||
|
||||
test('--active: exits 1 when GSD_RUNTIME=codex + flag=1 (non-claude)', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'query', 'teams-status', '--active'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: makeEnv({ runtime: 'codex', teamsFlag: '1' }),
|
||||
},
|
||||
);
|
||||
assert.strictEqual(result.status, 1, `Expected exit 1 for non-claude runtime, got ${result.status}`);
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@
|
||||
"note.md": 6563,
|
||||
"pause-work.md": 14397,
|
||||
"plan-milestone-gaps.md": 11765,
|
||||
"plan-phase.md": 92759,
|
||||
"plan-phase.md": 93166,
|
||||
"plan-review-convergence.md": 23468,
|
||||
"plant-seed.md": 11741,
|
||||
"pr-branch.md": 9561,
|
||||
|
||||
Reference in New Issue
Block a user