feat(#1355): detect-and-warn guard for claude-code agent-teams (#1371)

* 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:
Tom Boucher
2026-06-17 08:52:23 -04:00
committed by GitHub
parent 08e42b0ef1
commit 120f85164b
13 changed files with 363 additions and 2 deletions

View File

@@ -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)

1
.gitignore vendored
View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -371,6 +371,7 @@
"state.cjs",
"surface.cjs",
"task-command-router.cjs",
"teams-status.cjs",
"template.cjs",
"uat-predicate.cjs",
"uat.cjs",

View File

@@ -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',
],
},

View File

@@ -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

View File

@@ -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

View File

@@ -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

93
src/teams-status.cts Normal file
View File

@@ -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);
}

View File

@@ -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
View 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}`);
});
});

View File

@@ -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,