diff --git a/.changeset/1355-teams-detect-guard.md b/.changeset/1355-teams-detect-guard.md new file mode 100644 index 000000000..454f24ba2 --- /dev/null +++ b/.changeset/1355-teams-detect-guard.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 1371 +--- + +**`gsd-tools query teams-status` + a plan-phase warning detect claude-code agent-teams** — GSD's multi-agent orchestration can stall under claude-code's experimental agent-teams (a subagent's completion can fail to route back to the orchestrator). A new read-only `query teams-status` command reports `{ active, runtime, env_present, source }` (and `--active` for a clean shell guard), and `/gsd:plan-phase` now emits a single non-fatal warning when agent-teams is detected, recommending you disable it for GSD workflows. The detector only activates on the `claude` runtime with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` strictly truthy — every other runtime and the teams-off path are completely unaffected. (#1355) diff --git a/.gitignore b/.gitignore index 9a5f28709..479c85bad 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ build/ /gsd-core/bin/lib/phase-lifecycle.cjs /gsd-core/bin/lib/workstream-name-policy.cjs /gsd-core/bin/lib/decisions.cjs +/gsd-core/bin/lib/teams-status.cjs /gsd-core/bin/lib/validate.cjs /gsd-core/bin/lib/schema-detect.cjs /gsd-core/bin/lib/runtime-name-policy.cjs diff --git a/CONTEXT.md b/CONTEXT.md index fbb349075..3507c87c4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -192,6 +192,9 @@ A Capability whose integration shape brings its own external process, service, o `RULESET.CAPABILITY.precedence-engine-single-owner=the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.` +### Teams Status Module +Pure read-only detector for claude-code's experimental agent-teams feature (issue #1355). Stops gsd-core hanging silently when run under `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`. Source of truth: `src/teams-status.cts` → `gsd-core/bin/lib/teams-status.cjs`. Exports: `resolveTeamsStatus({ runtime, env }) → TeamsStatus` (pure, env injected, no process.env/disk inside — hermetic for tests) and `cmdTeamsStatus(cwd, { active? })` (I/O entry point; reuses `resolveRuntime` from `runtime-slash.cjs` for canonical `GSD_RUNTIME` → `config.runtime` → `'claude'` precedence). `TeamsStatus` shape: `{ active: boolean, runtime: string, env_present: boolean, source: 'on: env' | 'off: flag absent' | 'off: non-claude' }`. `active` is true only when the flag is strictly truthy (`"1"` or `"true"`, case-insensitive) AND the runtime is `"claude"`. CLI surface: `gsd-tools query teams-status [--active]` (default: JSON; `--active`: exit 0/1 boolean). Used only by a non-fatal `--active` check warning in `plan-phase.md` init block — does NOT block execution, does NOT activate capabilities, does NOT change behavior on any non-claude runtime. + ### Wing A MemPalace organizational unit corresponding to one project or repository. GSD derives the wing name from `project_code` or the project directory when `mempalace.wing` is unset. A wing contains Rooms. Cross-project Tunnels connect rooms across wings. MemPalace vocabulary — see Connected Capability, MemPalace memory capability (issue #956). diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 05bf93c37..5112666a9 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -206,6 +206,48 @@ node gsd-tools.cjs capability set code-review --gate workflow.code_review=false --- +## Teams Status + +### `query teams-status` + +```bash +node gsd-tools.cjs query teams-status [--active] +``` + +Read-only detector for claude-code's experimental agent-teams feature (issue #1355). Resolves the runtime via the canonical `GSD_RUNTIME` → `config.runtime` → `'claude'` precedence, then checks `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`. + +**Default (no flags):** prints a JSON object and exits 0: + +```json +{ + "active": false, + "runtime": "claude", + "env_present": false, + "source": "off: flag absent" +} +``` + +Fields: + +| Field | Type | Description | +|---|---|---| +| `active` | boolean | `true` only when `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is strictly truthy (`"1"` or `"true"`, case-insensitive) **and** the resolved runtime is `"claude"` | +| `runtime` | string | The resolved runtime name (e.g. `"claude"`, `"codex"`) | +| `env_present` | boolean | `true` when the env flag is set to a strictly-truthy value | +| `source` | string | One of: `"on: env"`, `"off: flag absent"`, `"off: non-claude"` | + +**`--active` flag:** exits 0 if `active` is true, exits 1 otherwise. Prints nothing. Useful in bash conditionals: + +```bash +if gsd_run query teams-status --active >/dev/null 2>&1; then + echo "agent-teams is on" +fi +``` + +This command is strictly read-only — no config writes, no disk mutation. + +--- + ## Model Resolution ```bash diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index e1e14665c..20a230ea3 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -371,6 +371,7 @@ "state.cjs", "surface.cjs", "task-command-router.cjs", + "teams-status.cjs", "template.cjs", "uat-predicate.cjs", "uat.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f3df9be1..f3d772670 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -158,6 +158,8 @@ export default tseslint.config( 'gsd-core/bin/lib/git-base-branch.cjs', // ADR-1213: tsc-generated runtime artifact — lint the src/capability-writer.cts source. 'gsd-core/bin/lib/capability-writer.cjs', + // issue #1355: tsc-generated runtime artifact — lint the src/teams-status.cts source. + 'gsd-core/bin/lib/teams-status.cjs', ], }, diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 82bef0877..a4d6842e6 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1519,6 +1519,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + // ─── teams-status ────────────────────────────────────────────────────── + // Read-only detector for claude-code's experimental agent-teams feature. + // issue #1355: stop gsd-core hanging silently under claude-code agent-teams. + // No capability registration needed — this is a diagnostic query command, + // not a feature capability. + case 'teams-status': { + const teamsStatus = require('./lib/teams-status.cjs'); + teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') }); + break; + } + // ─── detect-custom-files ─────────────────────────────────────────────── // CJS-native: no SDK counterpart exists in the command registry. // detect-custom-files reads a gsd-file-manifest.json against the diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 3880cd258..5574aa38d 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -499,6 +499,12 @@ Display banner: ### Spawn gsd-phase-researcher +```bash +if gsd_run query teams-status --active >/dev/null 2>&1; then + echo "⚠️ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS detected. GSD's multi-agent orchestration is not validated under claude-code agent-teams and may stall (a subagent's completion can fail to route to the orchestrator). Recommend disabling agent-teams for GSD workflows. See https://github.com/open-gsd/gsd-core/issues/1355" >&2 +fi +``` + ```bash PHASE_DESC=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section) if [ -z "${PLAN_PRE_HOOKS_JSON:-}" ]; then diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index c447779eb..d6b5d4958 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -461,6 +461,7 @@ function main() { // them so the local runner matches CI; tests that need them set them explicitly. delete process.env.GSD_PROJECT; delete process.env.GSD_WORKSTREAM; + delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS; // Log selected files to stderr for CI / harness-test visibility. // node:test default reporter doesn't echo filenames, so this gives diff --git a/src/teams-status.cts b/src/teams-status.cts new file mode 100644 index 000000000..fca4452f2 --- /dev/null +++ b/src/teams-status.cts @@ -0,0 +1,93 @@ +/** + * Teams Status Module — issue #1355 + * + * Read-only detector for claude-code's experimental agent-teams feature. + * Exposes a PURE core function (env injected, no process.env/disk inside) and + * a thin CLI wrapper that reuses resolveRuntime from runtime-slash.cjs. + * + * Exports: + * resolveTeamsStatus({ runtime, env }) → TeamsStatus + * cmdTeamsStatus(cwd, opts) — I/O entry point + * + * resolveTeamsStatus is PURE: env and runtime are injected, no process.env or + * disk access inside the function. Pass process.env explicitly at call sites. + * + * cmdTeamsStatus is the I/O handler. It reads process.env, resolves the + * runtime via resolveRuntime(cwd) from runtime-slash.cjs (GSD_RUNTIME → + * config.runtime → 'claude' precedence), then: + * - default: prints JSON.stringify(status) to stdout via io.output, exits 0. + * - --active: prints nothing, exits 0 if status.active, exit 1 otherwise. + * + * Strictly read-only — no config writes, no disk mutation. + * + * Dependencies: + * - ./io.cjs (output) + * - ./runtime-slash.cjs (resolveRuntime) + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import ioMod = require('./io.cjs'); +const { output: coreOutput } = ioMod; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface TeamsStatus { + /** true only when env flag is strictly-truthy AND runtime === 'claude' */ + active: boolean; + /** resolved runtime name */ + runtime: string; + /** true when CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS is set to a truthy value */ + env_present: boolean; + /** human-readable source description */ + source: 'on: env' | 'off: flag absent' | 'off: non-claude'; +} + +// ─── Pure core ──────────────────────────────────────────────────────────────── + +/** + * Resolve the agent-teams status from injected runtime and env. + * + * Strict truthiness: only '1' and 'true' (case-insensitive, trimmed) are on. + * '0', 'false', '', and unset are all off. + * + * @param opts.runtime The resolved runtime name (e.g. 'claude', 'codex') + * @param opts.env The environment map to read from (typically process.env) + */ +export function resolveTeamsStatus(opts: { runtime: string; env: NodeJS.ProcessEnv }): TeamsStatus { + const raw = (opts.env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] ?? '').trim().toLowerCase(); + const envOn = raw === '1' || raw === 'true'; // strict: never '0'/'false'/'' as on + const isClaude = opts.runtime === 'claude'; + const source = !isClaude ? 'off: non-claude' : (envOn ? 'on: env' : 'off: flag absent'); + return { active: envOn && isClaude, runtime: opts.runtime, env_present: envOn, source }; +} + +// ─── CLI command handler ────────────────────────────────────────────────────── + +/** + * Command entry point: resolve runtime via resolveRuntime(cwd), read process.env, + * call resolveTeamsStatus, and emit the result. + * + * @param cwd Project root directory (used by resolveRuntime for config.json) + * @param opts Command options + * @param opts.active When true: print nothing, exit 0 if active, exit 1 otherwise + */ +export function cmdTeamsStatus(cwd: string, opts: { active?: boolean }): void { + // Resolve runtime via the canonical precedence: + // GSD_RUNTIME → config.runtime → 'claude' + // Reuses resolveRuntime from runtime-slash.cjs — no reimplementation. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const runtimeSlash = require('./runtime-slash.cjs') as { + resolveRuntime: (projectDir: string | null | undefined) => string; + }; + const runtime = runtimeSlash.resolveRuntime(cwd); + + const status = resolveTeamsStatus({ runtime, env: process.env }); + + if (opts.active) { + // --active mode: no output, exit code encodes the boolean + process.exit(status.active ? 0 : 1); + } + + // Default: emit JSON to stdout via io.output, exit 0 + coreOutput(status, false); +} diff --git a/tests/active-workstream-store.unit.test.cjs b/tests/active-workstream-store.unit.test.cjs index 6d1006682..5d97a85c5 100644 --- a/tests/active-workstream-store.unit.test.cjs +++ b/tests/active-workstream-store.unit.test.cjs @@ -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() { diff --git a/tests/teams-status.test.cjs b/tests/teams-status.test.cjs new file mode 100644 index 000000000..68f13038e --- /dev/null +++ b/tests/teams-status.test.cjs @@ -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}`); + }); +}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index f5ddd2b06..5f50cf975 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -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,