enhance(#4570): allow disabling planner stall detection (#4585)

* enhance(#4570): allow disabling planner stall detection

* docs(#4570): add changelog fragment

Emitted-Drift-Ack-Growth: plan-phase.md — the explicit opt-out gate covers all five planner and checker spawn classes
Emitted-Drift-Ack-Growth: settings-advanced.md — the toggle prompt and bounded-recovery warning expose the new setting

* chore(#4570): refresh compact-content baseline

* fix(#4570): preserve default-on watchdog fallback

* docs(#4570): qualify the chunked-mode orchestrator rules with the toggle

The two chunked-planning-mode stall-watch imperatives read as
unconditional, with the opt-out stated only in a following bullet. State
the PLANNER_STALL_DETECTION_ENABLED condition inline, matching the three
sites already qualified in plan-phase.md.

* chore(#4570): refresh compact-content baselines against rebased next

* fix(#4570): sync planner stall launcher

Keep the stall-detection helper aligned with the canonical runtime launcher.

* chore(#4570): refresh compact baseline after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-23 19:46:23 -03:00
committed by GitHub
parent 17bf25a3d2
commit 1e3e1f7cd8
21 changed files with 417 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 4585
---
**Planner stall detection can now be disabled explicitly** — set `planner.stall_detection_enabled` to `false` to use the runtime-native completion wait without watchdog polling; the default remains enabled, and disabling it gives up bounded automatic recovery.

View File

@@ -280,6 +280,8 @@ Module owning agent-presence resolution and verification, extracted from the Cor
### Config Loader Module ### Config Loader Module
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record<string,unknown>` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Loading is **not side-effect-free by default**: normalizing a legacy key marks the config dirty and writes the migrated shape back to disk, which is how a legacy project is migrated by ordinary use. `options.persist: false` (#3648) suppresses exactly that write while leaving resolution identical — the option a caller passes when it is only ASKING the config something (the Git Query Module's protected-branch predicate runs on every `execute-phase` and `ship`, and a question must not rewrite the file it asks about). It is opt-OUT, so every pre-existing caller keeps the historical write-back. Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record<string,unknown>` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Loading is **not side-effect-free by default**: normalizing a legacy key marks the config dirty and writes the migrated shape back to disk, which is how a legacy project is migrated by ordinary use. `options.persist: false` (#3648) suppresses exactly that write while leaving resolution identical — the option a caller passes when it is only ASKING the config something (the Git Query Module's protected-branch predicate runs on every `execute-phase` and `ship`, and a question must not rewrite the file it asks about). It is opt-OUT, so every pre-existing caller keeps the historical write-back. Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
`planner.stall_detection_enabled` (#4570) is a central, default-on boolean projected as `planner_stall_detection_enabled` by this module. Root/workstream overlay happens before its type resolution; only a real boolean is honored, while an absent or hand-edited non-boolean value fails safe to the manifest default `true`. The plan-phase workflow reads it through `query config-get`, never by parsing config JSON locally, so root/project/workstream routing remains owned by the canonical configuration seams.
### Planning Publication Gate (`planning.pr_strict`) ### Planning Publication Gate (`planning.pr_strict`)
The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`. The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`.

View File

@@ -26,6 +26,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"search_gitignored": false, "search_gitignored": false,
"sub_repos": [] "sub_repos": []
}, },
"planner": {
"stall_detection_enabled": true
},
"context": null, "context": null,
"workflow": { "workflow": {
"research": true, "research": true,
@@ -545,6 +548,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) | | `workflow.subagent_timeout` | number | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes) |
| `executor.stall_detect_interval_minutes` | number | `5` | Minutes between executor stall checks while an executor agent is active. The execute-phase orchestrator uses this cadence to inspect recent commits and avoid waiting forever on a silent agent. | | `executor.stall_detect_interval_minutes` | number | `5` | Minutes between executor stall checks while an executor agent is active. The execute-phase orchestrator uses this cadence to inspect recent commits and avoid waiting forever on a silent agent. |
| `executor.stall_threshold_minutes` | number | `10` | Minutes without executor completion or expected-branch commit activity before execute-phase offers recovery choices for a possible stalled executor. | | `executor.stall_threshold_minutes` | number | `10` | Minutes without executor completion or expected-branch commit activity before execute-phase offers recovery choices for a possible stalled executor. |
| `planner.stall_detection_enabled` | boolean | `true` | Controls bounded stall detection for the standard planner, chunked outline/per-plan planners, plan-checker, and revision planner. Set it with `gsd config-set planner.stall_detection_enabled false` to skip watchdog polling and await each agent through the runtime-native completion mechanism instead. **Warning:** `false` gives up bounded recovery if the runtime loses the completion handoff; you may need to interrupt and use the existing filesystem fallback. Planner execution and result handling are never skipped. |
| `planner.stall_detect_interval_minutes` | number | `5` | Minutes between planner/plan-checker stall checks while a planner or plan-checker agent is active. The plan-phase orchestrator uses this cadence to inspect on-disk `*-PLAN.md` activity and avoid waiting forever on a silent agent (#2650). | | `planner.stall_detect_interval_minutes` | number | `5` | Minutes between planner/plan-checker stall checks while a planner or plan-checker agent is active. The plan-phase orchestrator uses this cadence to inspect on-disk `*-PLAN.md` activity and avoid waiting forever on a silent agent (#2650). |
| `planner.stall_threshold_minutes` | number | `10` | Minutes without a completion marker or fresh on-disk plan activity before plan-phase automatically surfaces the accept-plans/retry/stop recovery choice for a possible stalled planner or plan-checker (#2650). | | `planner.stall_threshold_minutes` | number | `10` | Minutes without a completion marker or fresh on-disk plan activity before plan-phase automatically surfaces the accept-plans/retry/stop recovery choice for a possible stalled planner or plan-checker (#2650). |
| `workflow.inline_plan_threshold` | number | `3` | Maximum number of tasks in a phase before the planner generates a separate PLAN.md file instead of inlining tasks in the prompt | | `workflow.inline_plan_threshold` | number | `3` | Maximum number of tasks in a phase before the planner generates a separate PLAN.md file instead of inlining tasks in the prompt |

View File

@@ -105,6 +105,7 @@ GSD はプロジェクト設定を `.planning/config.json` に保存します。
| `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase` のコンテキスト収集方法を制御。`'discuss'`(デフォルト)は質問を1つずつ行います。`'assumptions'` はまずコードベースを読み取り、信頼度レベル付きの構造化された仮説を生成し、誤っている点のみ修正を求めます。v1.28 で追加 | | `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase` のコンテキスト収集方法を制御。`'discuss'`(デフォルト)は質問を1つずつ行います。`'assumptions'` はまずコードベースを読み取り、信頼度レベル付きの構造化された仮説を生成し、誤っている点のみ修正を求めます。v1.28 で追加 |
| `workflow.skip_discuss` | boolean | `false` | `true` の場合、`/gsd-autonomous` は discuss-phase を完全にスキップし、ROADMAP のフェーズ目標から最小限の CONTEXT.md を作成します。開発者の要望が PROJECT.md/REQUIREMENTS.md に十分に記載されているプロジェクトに適しています。v1.28 で追加 | | `workflow.skip_discuss` | boolean | `false` | `true` の場合、`/gsd-autonomous` は discuss-phase を完全にスキップし、ROADMAP のフェーズ目標から最小限の CONTEXT.md を作成します。開発者の要望が PROJECT.md/REQUIREMENTS.md に十分に記載されているプロジェクトに適しています。v1.28 で追加 |
| `workflow.text_mode` | boolean | `false` | AskUserQuestion の TUI メニューをプレーンテキストの番号付きリストに置き換えます。TUI メニューが表示されない Claude Code リモートセッション(`/rc` モード)で必要です。discuss-phase で `--text` フラグを使用してセッションごとに設定することもできます。v1.28 で追加 | | `workflow.text_mode` | boolean | `false` | AskUserQuestion の TUI メニューをプレーンテキストの番号付きリストに置き換えます。TUI メニューが表示されない Claude Code リモートセッション(`/rc` モード)で必要です。discuss-phase で `--text` フラグを使用してセッションごとに設定することもできます。v1.28 で追加 |
| `planner.stall_detection_enabled` | boolean | `true` | 標準プランナー、チャンク化されたアウトライン/プラン別プランナー、プランチェッカー、改訂プランナーの有界な停止検出を制御します。`gsd config-set planner.stall_detection_enabled false` を設定すると watchdog ポーリングを省略し、各エージェントをランタイムネイティブの完了機構で待機します。**警告:** `false` はランタイムが完了通知を失った場合の有界な復旧を放棄します。既存のファイルシステムフォールバックを使うために中断が必要になることがあります。 |
### 推奨プリセット ### 推奨プリセット

View File

@@ -105,6 +105,7 @@ GSD는 프로젝트 설정을 `.planning/config.json`에 저장합니다. `/gsd-
| `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase`의 컨텍스트 수집 방식을 제어합니다. `'discuss'` (기본값)는 질문을 하나씩 합니다. `'assumptions'`는 코드베이스를 먼저 읽고 신뢰도 수준이 있는 구조화된 가정을 생성하여 틀린 부분만 수정하도록 요청합니다. v1.28에서 추가 | | `workflow.discuss_mode` | string | `'discuss'` | `/gsd-discuss-phase`의 컨텍스트 수집 방식을 제어합니다. `'discuss'` (기본값)는 질문을 하나씩 합니다. `'assumptions'`는 코드베이스를 먼저 읽고 신뢰도 수준이 있는 구조화된 가정을 생성하여 틀린 부분만 수정하도록 요청합니다. v1.28에서 추가 |
| `workflow.skip_discuss` | boolean | `false` | `true`로 설정하면 `/gsd-autonomous`가 discuss 단계를 완전히 건너뛰고 ROADMAP 단계 목표로부터 최소한의 CONTEXT.md를 작성합니다. 개발자 선호사항이 PROJECT.md/REQUIREMENTS.md에 모두 캡처된 프로젝트에 유용합니다. v1.28에서 추가 | | `workflow.skip_discuss` | boolean | `false` | `true`로 설정하면 `/gsd-autonomous`가 discuss 단계를 완전히 건너뛰고 ROADMAP 단계 목표로부터 최소한의 CONTEXT.md를 작성합니다. 개발자 선호사항이 PROJECT.md/REQUIREMENTS.md에 모두 캡처된 프로젝트에 유용합니다. v1.28에서 추가 |
| `workflow.text_mode` | boolean | `false` | AskUserQuestion TUI 메뉴를 일반 텍스트 번호 목록으로 대체합니다. TUI 메뉴가 렌더링되지 않는 Claude Code 원격 세션 (`/rc` 모드)에 필요합니다. discuss 단계에서 `--text` 플래그로 세션별 설정도 가능합니다. v1.28에서 추가 | | `workflow.text_mode` | boolean | `false` | AskUserQuestion TUI 메뉴를 일반 텍스트 번호 목록으로 대체합니다. TUI 메뉴가 렌더링되지 않는 Claude Code 원격 세션 (`/rc` 모드)에 필요합니다. discuss 단계에서 `--text` 플래그로 세션별 설정도 가능합니다. v1.28에서 추가 |
| `planner.stall_detection_enabled` | boolean | `true` | 표준 플래너, 청크형 개요/플랜별 플래너, 플랜 검사기, 수정 플래너의 제한된 정지 감지를 제어합니다. `gsd config-set planner.stall_detection_enabled false`로 설정하면 watchdog 폴링을 건너뛰고 각 에이전트를 런타임 네이티브 완료 메커니즘으로 기다립니다. **경고:** `false`는 런타임이 완료 전달을 잃을 때의 제한된 복구를 포기합니다. 기존 파일 시스템 폴백을 사용하려면 중단해야 할 수 있습니다. |
### 권장 프리셋 ### 권장 프리셋

View File

@@ -25,6 +25,9 @@ O GSD armazena as configurações do projeto em `.planning/config.json`. Criado
"search_gitignored": false, "search_gitignored": false,
"sub_repos": [] "sub_repos": []
}, },
"planner": {
"stall_detection_enabled": true
},
"context": null, "context": null,
"workflow": { "workflow": {
"research": true, "research": true,
@@ -267,6 +270,9 @@ Todos os controles de fluxo de trabalho seguem o padrão **ausente = habilitado*
| `workflow.subagent_timeout` | number | `600` | Timeout em segundos para invocações individuais de subagente. Aumente para fases de pesquisa ou execução de longa duração | | `workflow.subagent_timeout` | number | `600` | Timeout em segundos para invocações individuais de subagente. Aumente para fases de pesquisa ou execução de longa duração |
| `executor.stall_detect_interval_minutes` | number | `5` | Minutos entre verificações de travamento do executor enquanto um agente executor está ativo. O orquestrador de fase de execução usa essa cadência para inspecionar commits recentes e evitar espera eterna por um agente silencioso. | | `executor.stall_detect_interval_minutes` | number | `5` | Minutos entre verificações de travamento do executor enquanto um agente executor está ativo. O orquestrador de fase de execução usa essa cadência para inspecionar commits recentes e evitar espera eterna por um agente silencioso. |
| `executor.stall_threshold_minutes` | number | `10` | Minutos sem conclusão do executor ou atividade de commit no branch esperado antes que a fase de execução ofereça opções de recuperação para um possível executor travado. | | `executor.stall_threshold_minutes` | number | `10` | Minutos sem conclusão do executor ou atividade de commit no branch esperado antes que a fase de execução ofereça opções de recuperação para um possível executor travado. |
| `planner.stall_detection_enabled` | boolean | `true` | Controla a detecção limitada de travamento do planejador padrão, dos planejadores de esboço/por plano em chunks, do verificador de planos e do planejador de revisão. Use `gsd config-set planner.stall_detection_enabled false` para ignorar o polling do watchdog e aguardar cada agente pelo mecanismo de conclusão nativa do runtime. **Aviso:** `false` abre mão da recuperação limitada se o runtime perder a entrega da conclusão; pode ser necessário interromper e usar o fallback existente do sistema de arquivos. A execução e o tratamento do resultado nunca são ignorados. |
| `planner.stall_detect_interval_minutes` | number | `5` | Minutos entre verificações de travamento enquanto um planejador ou verificador de planos está ativo. O orquestrador inspeciona a atividade em disco de `*-PLAN.md` nessa cadência (#2650). |
| `planner.stall_threshold_minutes` | number | `10` | Minutos sem marcador de conclusão ou atividade recente em planos antes que a fase de planejamento ofereça automaticamente as opções aceitar/tentar novamente/parar (#2650). |
| `workflow.inline_plan_threshold` | number | `3` | Número máximo de tasks em uma fase antes que o planejador gere um arquivo PLAN.md separado em vez de incorporar tasks no prompt | | `workflow.inline_plan_threshold` | number | `3` | Número máximo de tasks em uma fase antes que o planejador gere um arquivo PLAN.md separado em vez de incorporar tasks no prompt |
| `workflow.drift_threshold` | number | `3` | Número mínimo de novos elementos estruturais (novos diretórios, exportações barrel, migrações, módulos de rota) introduzidos durante uma fase antes que o gate de deriva pós-execução da base de código tome ação. Consulte [#2003](https://github.com/open-gsd/gsd-core/issues/2003). Adicionado na v1.39 | | `workflow.drift_threshold` | number | `3` | Número mínimo de novos elementos estruturais (novos diretórios, exportações barrel, migrações, módulos de rota) introduzidos durante uma fase antes que o gate de deriva pós-execução da base de código tome ação. Consulte [#2003](https://github.com/open-gsd/gsd-core/issues/2003). Adicionado na v1.39 |
| `workflow.drift_action` | string | `warn` | O que fazer quando `workflow.drift_threshold` é excedido após `/gsd-execute-phase`. `warn` imprime uma mensagem sugerindo `/gsd-map-codebase --paths …`; `auto-remap` gera `gsd-codebase-mapper` com escopo para os caminhos afetados. Adicionado na v1.39 | | `workflow.drift_action` | string | `warn` | O que fazer quando `workflow.drift_threshold` é excedido após `/gsd-execute-phase`. `warn` imprime uma mensagem sugerindo `/gsd-map-codebase --paths …`; `auto-remap` gera `gsd-codebase-mapper` com escopo para os caminhos afetados. Adicionado na v1.39 |

View File

@@ -25,6 +25,9 @@ GSD 将项目设置存储在 `.planning/config.json` 中。该文件在 `/gsd-ne
"search_gitignored": false, "search_gitignored": false,
"sub_repos": [] "sub_repos": []
}, },
"planner": {
"stall_detection_enabled": true
},
"context": null, "context": null,
"workflow": { "workflow": {
"research": true, "research": true,
@@ -267,6 +270,9 @@ API 密钥字段接受字符串值(密钥本身)。也可以设置为哨兵
| `workflow.subagent_timeout` | number | `600` | 单个 subagent 调用的超时秒数。对于长时间运行的研究或执行阶段可适当增加 | | `workflow.subagent_timeout` | number | `600` | 单个 subagent 调用的超时秒数。对于长时间运行的研究或执行阶段可适当增加 |
| `executor.stall_detect_interval_minutes` | number | `5` | 执行器 agent 活跃时,执行器停滞检测的间隔分钟数。执行阶段编排器以此频率检查最近的提交,避免无限等待静默的 agent。 | | `executor.stall_detect_interval_minutes` | number | `5` | 执行器 agent 活跃时,执行器停滞检测的间隔分钟数。执行阶段编排器以此频率检查最近的提交,避免无限等待静默的 agent。 |
| `executor.stall_threshold_minutes` | number | `10` | 执行器完成或预期分支提交活动缺失超过此分钟数后,执行阶段为可能停滞的执行器提供恢复选项。 | | `executor.stall_threshold_minutes` | number | `10` | 执行器完成或预期分支提交活动缺失超过此分钟数后,执行阶段为可能停滞的执行器提供恢复选项。 |
| `planner.stall_detection_enabled` | boolean | `true` | 控制标准规划器、分块大纲/逐计划规划器、计划检查器和修订规划器的有界停滞检测。运行 `gsd config-set planner.stall_detection_enabled false` 可跳过 watchdog 轮询,改用运行时原生完成机制等待每个 agent。**警告:** `false` 会放弃运行时丢失完成回传时的有界恢复;届时可能需要中断并使用现有文件系统回退。规划器执行和结果处理不会被跳过。 |
| `planner.stall_detect_interval_minutes` | number | `5` | 规划器或计划检查器活跃时的停滞检查间隔分钟数。规划阶段编排器按此频率检查磁盘上的 `*-PLAN.md` 活动(#2650)。 |
| `planner.stall_threshold_minutes` | number | `10` | 没有完成标记或新的磁盘计划活动超过此分钟数后,规划阶段自动提供接受计划/重试/停止恢复选项(#2650)。 |
| `workflow.inline_plan_threshold` | number | `3` | 阶段中任务数量的最大值,超过此值后规划器生成单独的 PLAN.md 文件而非在提示词中内联任务 | | `workflow.inline_plan_threshold` | number | `3` | 阶段中任务数量的最大值,超过此值后规划器生成单独的 PLAN.md 文件而非在提示词中内联任务 |
| `workflow.drift_threshold` | number | `3` | 阶段期间引入的新结构元素(新目录、桶形导出、迁移、路由模块)的最小数量,超过此值后执行后代码库漂移门禁采取行动。参见 [#2003](https://github.com/open-gsd/gsd-core/issues/2003)。v1.39 新增 | | `workflow.drift_threshold` | number | `3` | 阶段期间引入的新结构元素(新目录、桶形导出、迁移、路由模块)的最小数量,超过此值后执行后代码库漂移门禁采取行动。参见 [#2003](https://github.com/open-gsd/gsd-core/issues/2003)。v1.39 新增 |
| `workflow.drift_action` | string | `warn` | `/gsd-execute-phase` 后超过 `workflow.drift_threshold` 时的处理方式。`warn` 打印建议运行 `/gsd-map-codebase --paths …` 的消息;`auto-remap` 派生 `gsd-codebase-mapper` 限定于受影响路径。v1.39 新增 | | `workflow.drift_action` | string | `warn` | `/gsd-execute-phase` 后超过 `workflow.drift_threshold` 时的处理方式。`warn` 打印建议运行 `/gsd-map-codebase --paths …` 的消息;`auto-remap` 派生 `gsd-codebase-mapper` 限定于受影响路径。v1.39 新增 |

View File

@@ -24,6 +24,12 @@
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | 里程碑策略的分支模板 | | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | 里程碑策略的分支模板 |
</config_schema> </config_schema>
<planner_stall_detection>
`planner.stall_detection_enabled` 默认为 `true`,控制标准规划器、分块规划器、计划检查器和修订规划器的有界停滞检测。运行 `gsd config-set planner.stall_detection_enabled false` 可跳过 watchdog 轮询,并通过运行时原生完成机制等待每个 agent。**警告:** `false` 会放弃运行时丢失完成回传时的有界恢复;可能需要中断并使用现有文件系统回退。
</planner_stall_detection>
<commit_docs_behavior> <commit_docs_behavior>
**当 `commit_docs: true`(默认):** **当 `commit_docs: true`(默认):**
@@ -197,4 +203,4 @@ fi
</branching_strategy_behavior> </branching_strategy_behavior>
</planning_config> </planning_config>

View File

@@ -68,6 +68,9 @@
"sub_repos": [], "sub_repos": [],
"granularity": "standard" "granularity": "standard"
}, },
"planner": {
"stall_detection_enabled": true
},
"hooks": { "hooks": {
"context_warnings": true, "context_warnings": true,
"workflow_guard": false "workflow_guard": false

View File

@@ -72,6 +72,7 @@
"workflow.context_guard_mode", "workflow.context_guard_mode",
"executor.stall_detect_interval_minutes", "executor.stall_detect_interval_minutes",
"executor.stall_threshold_minutes", "executor.stall_threshold_minutes",
"planner.stall_detection_enabled",
"planner.stall_detect_interval_minutes", "planner.stall_detect_interval_minutes",
"planner.stall_threshold_minutes", "planner.stall_threshold_minutes",
"workflow.inline_plan_threshold", "workflow.inline_plan_threshold",

View File

@@ -40,6 +40,7 @@ Configuration options for `.planning/` directory behavior.
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. | | `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. |
| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). | | `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
| `planner.stall_detection_enabled` | `true` | Default-on bounded stall detection for planner and plan-checker agents. Set to `false` to await runtime-native completion without watchdog polling; this gives up bounded automatic recovery if the completion handoff is lost. _Loader projection:_ `planner_stall_detection_enabled`. |
| `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. | | `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. |
| `workflow.test_command` | `null` | Custom shell command run as the regression/test gate by execute-phase, audit-fix, and post-merge-gate. When unset, GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). Example: `npm test`. | | `workflow.test_command` | `null` | Custom shell command run as the regression/test gate by execute-phase, audit-fix, and post-merge-gate. When unset, GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). Example: `npm test`. |
| `workflow.build_command` | `null` | Custom shell command run as the build gate by the post-merge gate. When unset, the build step is skipped/auto-detected. Example: `npm run build`. | | `workflow.build_command` | `null` | Custom shell command run as the build gate by the post-merge gate. When unset, the build step is skipped/auto-detected. Example: `npm run build`. |
@@ -313,6 +314,14 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
| `workflow.security_block_on` | string | `"high"` | `"critical"`, `"high"`, `"medium"`, `"low"`, `"none"` | Minimum threat severity that blocks phase advancement. The auditor counts only open threats at or above this severity toward the blocking gate (SECURITY.md `threats_open`); `none` disables severity blocking. | | `workflow.security_block_on` | string | `"high"` | `"critical"`, `"high"`, `"medium"`, `"low"`, `"none"` | Minimum threat severity that blocks phase advancement. The auditor counts only open threats at or above this severity toward the blocking gate (SECURITY.md `threats_open`); `none` disables severity blocking. |
| `workflow.post_planning_gaps` | boolean | `true` | `true`, `false` | Post-planning gap report (#2493). After plans are generated, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against all PLAN.md files and emits a unified `Source \| Item \| Status` table. Non-blocking. Set to `false` to skip Step 13e of plan-phase. _Alias:_ `post_planning_gaps` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.post_planning_gaps` is the canonical namespaced form. | | `workflow.post_planning_gaps` | boolean | `true` | `true`, `false` | Post-planning gap report (#2493). After plans are generated, scans REQUIREMENTS.md and CONTEXT.md `<decisions>` against all PLAN.md files and emits a unified `Source \| Item \| Status` table. Non-blocking. Set to `false` to skip Step 13e of plan-phase. _Alias:_ `post_planning_gaps` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.post_planning_gaps` is the canonical namespaced form. |
### Planner Fields
Set via the `planner.*` namespace. These settings affect only planner/plan-checker waits; executor stall controls are independent.
| Key | Type | Default | Allowed Values | Description |
|-----|------|---------|----------------|-------------|
| `planner.stall_detection_enabled` | boolean | `true` | `true`, `false` | Default-on bounded stall detection for the standard planner, chunked outline/per-plan planners, plan-checker, and revision planner. `false` skips `gsd_stall_watch` polling but still awaits and consumes the real runtime-native agent result. It gives up bounded automatic recovery if the runtime loses the completion handoff. _Loader projection:_ `planner_stall_detection_enabled`. |
### Ship Fields ### Ship Fields
Set via `ship.*` namespace in config.json. These fields affect `/gsd:ship` PRD-style pull request body composition only. Set via `ship.*` namespace in config.json. These fields affect `/gsd:ship` PRD-style pull request body composition only.

View File

@@ -941,6 +941,9 @@ Every task MUST include these fields — they are NOT optional:
**If `CHUNKED_MODE` is `false` (default):** Spawn the planner as a single long-lived Agent: **If `CHUNKED_MODE` is `false` (default):** Spawn the planner as a single long-lived Agent:
**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:**
- **`true` (default):** use `run_in_background=true` in the Agent() call shown below, then use `gsd_stall_watch` as specified after it.
```text ```text
Agent( Agent(
prompt=filled_prompt, prompt=filled_prompt,
@@ -951,7 +954,9 @@ Agent(
) )
``` ```
**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## PLANNING COMPLETE" "## PHASE SPLIT RECOMMENDED" "## ⚠ Source Audit" "## CHECKPOINT REACHED" "## PLANNING INCONCLUSIVE")` while waiting/active — `marker_received` -> step 9; `stalled` -> 9a. **ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## PLANNING COMPLETE" "## PHASE SPLIT RECOMMENDED" "## ⚠ Source Audit" "## CHECKPOINT REACHED" "## PLANNING INCONCLUSIVE")` while waiting/active — `marker_received` -> step 9; `stalled` -> 9a.
- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result to step 9. Skip `gsd_stall_watch` entirely. This is not fire-and-forget; empty, truncated, or unrecognized returns still use step 9a.
**If `CHUNKED_MODE` is `true`:** Skip the Agent() call above — proceed to step 8.5 instead. **If `CHUNKED_MODE` is `true`:** Skip the Agent() call above — proceed to step 8.5 instead.
@@ -1080,7 +1085,12 @@ Agent(
) )
``` ```
**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `CHECKER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## VERIFICATION PASSED" "## ISSUES FOUND")` while waiting/active. **Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:**
- **`true` (default):** use `run_in_background=true` in the Agent() call above, then use `gsd_stall_watch` below.
**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `CHECKER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md' "## VERIFICATION PASSED" "## ISSUES FOUND")` while waiting/active.
- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result to step 11. Skip `gsd_stall_watch` entirely. Treat a recognized returned marker exactly like `marker_received`; empty, truncated, or unrecognized returns still use step 11a.
## 11. Handle Checker Return ## 11. Handle Checker Return
@@ -1172,7 +1182,12 @@ Agent(
) )
``` ```
**ORCHESTRATOR RULE — ALL RUNTIMES:** (7.99; no marker, mtimes only) `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md')` while waiting/active — `stalled` -> 1) Accept as revised, to step 13, 2) Retry, 3) Stop. **Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:**
- **`true` (default):** use `run_in_background=true` in the Agent() call above, then use `gsd_stall_watch` below.
**ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** (7.99; no marker, mtimes only) `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "${PHASE_DIR}"'/*-PLAN.md')` while waiting/active — `stalled` -> 1) Accept as revised, to step 13, 2) Retry, 3) Stop.
- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and pass the real returned result into the existing revision-return handling. Skip `gsd_stall_watch` entirely; an empty, truncated, or unrecognized result keeps the existing filesystem fallback.
**If the planner returns `## REVISION_CONFLICT`:** follow the shared Conflict Return protocol in **If the planner returns `## REVISION_CONFLICT`:** follow the shared Conflict Return protocol in
`gsd-core/references/revision-loop.md`, with this workflow's bindings: `gsd-core/references/revision-loop.md`, with this workflow's bindings:

View File

@@ -50,6 +50,9 @@ Display:
Spawn the planner in **outline-only** mode — it must write only the outline manifest, not any Spawn the planner in **outline-only** mode — it must write only the outline manifest, not any
PLAN.md files: PLAN.md files:
**Dispatch/wait gate — `PLANNER_STALL_DETECTION_ENABLED`:**
- **`true` (default):** use `run_in_background=true` in the Agent() call below, then use `gsd_stall_watch` after it.
```javascript ```javascript
Agent( Agent(
prompt="{same planning_context as step 8, plus:} prompt="{same planning_context as step 8, plus:}
@@ -71,7 +74,9 @@ Agent(
) )
``` ```
**ORCHESTRATOR RULE — ALL RUNTIMES:** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$OUTLINE_FILE" "## OUTLINE COMPLETE")` while waiting/active. **ORCHESTRATOR RULE — ALL RUNTIMES (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$OUTLINE_FILE" "## OUTLINE COMPLETE")` while waiting/active.
- **`false`:** issue the same Agent() call but omit `run_in_background`; await its ordinary runtime-native completion and consume the real returned result. Skip `gsd_stall_watch` entirely and treat a recognized `## OUTLINE COMPLETE` return exactly like `marker_received`; empty or unrecognized returns keep the existing Retry/Stop path.
Handle return: Handle return:
- **`marker_received`:** Read `PLAN-OUTLINE.md`, extract plan list. Continue to 8.5.2. - **`marker_received`:** Read `PLAN-OUTLINE.md`, extract plan list. Continue to 8.5.2.
@@ -137,7 +142,8 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch.
4. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file. The prompt 4. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file. The prompt
is unchanged per plan; what changes is whether the runnable set's Agent() calls are issued one is unchanged per plan; what changes is whether the runnable set's Agent() calls are issued one
at a time (serial) or together in one message (concurrent, every call still carrying at a time (serial) or together in one message (concurrent, every call still carrying
`run_in_background=true` exactly as today): `run_in_background=true` exactly as today when `PLANNER_STALL_DETECTION_ENABLED` is `true`
(default)):
```javascript ```javascript
Agent( Agent(
prompt="{same planning_context as step 8, plus:} prompt="{same planning_context as step 8, plus:}
@@ -161,7 +167,7 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch.
**Concurrent dispatch:** issue every runnable entry's Agent() call together, in this one **Concurrent dispatch:** issue every runnable entry's Agent() call together, in this one
message, before waiting on any of them. message, before waiting on any of them.
5. **ORCHESTRATOR RULE — ALL RUNTIMES, per batch:** for every entry dispatched in this round, 5. **ORCHESTRATOR RULE — ALL RUNTIMES, per batch (when `PLANNER_STALL_DETECTION_ENABLED` is `true`):** for every entry dispatched in this round,
`TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$PLAN_FILE" "## PLAN COMPLETE")` `TS=$(date +%s)`; repeat `PLANNER_STALL_RESULT=$(gsd_stall_watch "$TS" "{outputFile}" "$PLAN_FILE" "## PLAN COMPLETE")`
while waiting/active for THAT entry. Serial dispatch waits on one entry at a time (unchanged). while waiting/active for THAT entry. Serial dispatch waits on one entry at a time (unchanged).
Concurrent dispatch waits on every entry issued in step 4 before proceeding — this is the Concurrent dispatch waits on every entry issued in step 4 before proceeding — this is the
@@ -170,6 +176,13 @@ path regardless of `CHUNKED_PARALLEL` — there is nothing to batch.
Retry/Stop recovery for that one plan; it does not block verifying/committing sibling entries Retry/Stop recovery for that one plan; it does not block verifying/committing sibling entries
in the same batch that already reached `marker_received`. in the same batch that already reached `marker_received`.
- **`false`:** when `PLANNER_STALL_DETECTION_ENABLED` is `false`, omit `run_in_background`
from every call, skip `gsd_stall_watch`, and await each real returned result through the
ordinary runtime-native completion mechanism. A concurrent batch still issues the runnable
calls together and awaits all of their ordinary results before step 6. Treat each recognized
`## PLAN COMPLETE` return exactly like `marker_received`; empty or unrecognized returns keep
step 8's existing Retry/Stop path. This is never fire-and-forget.
6. **Verify disk, per entry:** check `${PHASE_DIR}/{plan_id}-PLAN.md` exists for each entry that 6. **Verify disk, per entry:** check `${PHASE_DIR}/{plan_id}-PLAN.md` exists for each entry that
reached `marker_received`. Unchanged per-plan check. reached `marker_received`. Unchanged per-plan check.
@@ -189,4 +202,3 @@ gsd_run query commit "docs(${PADDED_PHASE}): plan ${plan_id} (chunked)" --files
Move to the next Wave only once every entry in the current Wave is committed or the run was Move to the next Wave only once every entry in the current Wave is committed or the run was
stopped. After every Wave's plans are written and committed, treat this as `## PLANNING COMPLETE` stopped. After every Wave's plans are written and committed, treat this as `## PLANNING COMPLETE`
and continue to step 9. and continue to step 9.

View File

@@ -1,8 +1,9 @@
# Bounded Stall-Detection Helpers (#2650) # Bounded Stall-Detection Helpers (#2650)
Every planner/plan-checker spawn in `plan-phase.md` dispatches with `planner.stall_detection_enabled` controls the wait policy for the five scoped
`run_in_background=true`, records `TS=$(date +%s)`, and then repeatedly planner/plan-checker spawns. It defaults to `true`. In that default-on mode each
calls `gsd_stall_watch` until it returns something other than spawn dispatches with `run_in_background=true`, records `TS=$(date +%s)`, and
then repeatedly calls `gsd_stall_watch` until it returns something other than
`waiting`/`active`. This mirrors the already-shipped `executor.stall_*` `waiting`/`active`. This mirrors the already-shipped `executor.stall_*`
pattern (`execute-phase.md`, bug #3212, commit `e7942c21b`) but — unlike pattern (`execute-phase.md`, bug #3212, commit `e7942c21b`) but — unlike
that prose-only surveillance, which cannot run during a *blocking* `Agent()` that prose-only surveillance, which cannot run during a *blocking* `Agent()`
@@ -11,6 +12,15 @@ issued as its own tool call, so it returns control to the orchestrator on
its own schedule regardless of whether the backgrounded agent's own its own schedule regardless of whether the backgrounded agent's own
completion notification ever arrives. completion notification ever arrives.
When the key is explicitly the JSON boolean `false`, each scoped call instead
omits `run_in_background`, waits through the runtime-native ordinary Agent()
completion mechanism, and consumes that real returned result. It never calls
`gsd_stall_watch`, so there are no periodic shell sleeps. This is still a wait,
not fire-and-forget. It deliberately gives up #2650's bounded automatic
recovery: if the runtime loses the completion handoff, the user may need to
interrupt and use the existing filesystem fallback. The completion markers and
empty/truncated/unrecognized-return fallback remain unchanged.
**Binding `{outputFile}` (load-bearing, not optional):** every `gsd_stall_watch` **Binding `{outputFile}` (load-bearing, not optional):** every `gsd_stall_watch`
call below takes `{outputFile}` as its second argument — a literal token the call below takes `{outputFile}` as its second argument — a literal token the
orchestrator must substitute with the REAL path from the immediately preceding orchestrator must substitute with the REAL path from the immediately preceding
@@ -71,10 +81,18 @@ config-get calls.
This block is independent of, and never gated behind, the `query This block is independent of, and never gated behind, the `query
teams-status` / `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` guard used for the teams-status` / `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` guard used for the
researcher spawn — the stall path applies on every runtime, teams-active or researcher spawn — the stall path applies on every runtime, teams-active or
not (AC2). not (AC2). The toggle is resolved through the canonical `config-get` seam, so
root/project/workstream selection stays with the Config Loader rather than a
workflow-local JSON parser. Only the exact boolean result `false` disables;
missing, malformed, string, numeric, or otherwise unrecognized values fail safe
to `true`.
```bash ```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; _gsd_id_ok() { case "$("$1" runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') return 0;; *) return 1;; esac; }; _gsd_homes() { _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif _gsd_homes; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; [ -n "$_G" ] && _gsd_id_ok "$_G"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and no identity-proving gsd_run is on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; _gsd_id_ok gsd_run && GSD_IDENTITY_STATUS=ok; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; _gsd_id_ok() { case "$("$1" runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') return 0;; *) return 1;; esac; }; _gsd_homes() { _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif _gsd_homes; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; [ -n "$_G" ] && _gsd_id_ok "$_G"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and no identity-proving gsd_run is on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; _gsd_id_ok gsd_run && GSD_IDENTITY_STATUS=ok; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
PLANNER_STALL_DETECTION_ENABLED=$(gsd_run query config-get planner.stall_detection_enabled --raw 2>/dev/null || echo "true")
# Defense in depth for hand-edited config and old runtimes: only the exact
# canonical false token may disable the default-on recovery policy.
[ "$PLANNER_STALL_DETECTION_ENABLED" = "false" ] || PLANNER_STALL_DETECTION_ENABLED=true
PLANNER_STALL_INTERVAL_MINUTES=$(gsd_run query config-get planner.stall_detect_interval_minutes --raw 2>/dev/null || echo "5") PLANNER_STALL_INTERVAL_MINUTES=$(gsd_run query config-get planner.stall_detect_interval_minutes --raw 2>/dev/null || echo "5")
PLANNER_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get planner.stall_threshold_minutes --raw 2>/dev/null || echo "10") PLANNER_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get planner.stall_threshold_minutes --raw 2>/dev/null || echo "10")
# Both values are config-controlled (.planning/config.json, editable by any repo # Both values are config-controlled (.planning/config.json, editable by any repo

View File

@@ -1,8 +1,8 @@
Apply response_language to all user-facing prose — narration between tool calls, status updates, progress notes, and findings included; preserve code, paths, and identifiers. Apply response_language to all user-facing prose — narration between tool calls, status updates, progress notes, and findings included; preserve code, paths, and identifiers.
<purpose> <purpose>
Interactive configuration of GSD power-user knobs — plan bounce, node repair, subagent timeouts, Interactive configuration of GSD power-user knobs — plan bounce, planner stall detection, node
inline plan threshold, cross-AI execution, base branch, branch templates, response language, repair, subagent timeouts, inline plan threshold, cross-AI execution, base branch, branch templates, response language,
context window, gitignored search, graphify build timeout, runtime model tier overrides, and context window, gitignored search, graphify build timeout, runtime model tier overrides, and
model policy configuration (provider + budget → canonical tier mapping, or manual model ID model policy configuration (provider + budget → canonical tier mapping, or manual model ID
assignment per cost tier). assignment per cost tier).
@@ -49,6 +49,7 @@ Parse the following current values. If a key is absent, fall back to the documen
shown in parentheses: shown in parentheses:
Planning Tuning: Planning Tuning:
- `planner.stall_detection_enabled` (default: `true`)
- `workflow.plan_bounce` (default: `false`) - `workflow.plan_bounce` (default: `false`)
- `workflow.plan_bounce_passes` (default: `2`) - `workflow.plan_bounce_passes` (default: `2`)
- `workflow.plan_bounce_script` (default: `null`) - `workflow.plan_bounce_script` (default: `null`)
@@ -126,8 +127,21 @@ stored verbatim as a string.
### Section 1 — Planning Tuning ### Section 1 — Planning Tuning
Before offering the disabled choice, display this warning verbatim:
`Warning: disabling planner stall detection gives up bounded automatic recovery if the runtime loses an agent completion handoff. Planning still waits through the runtime-native completion mechanism, but you may need to interrupt and use the filesystem fallback.`
```text ```text
AskUserQuestion([ AskUserQuestion([
{
question: "Enable bounded planner/plan-checker stall detection? (current: <value or true>)",
header: "Planner Watchdog",
multiSelect: false,
options: [
{ label: "Yes (default: true)", description: "Poll for completion markers and plan-file activity, with bounded recovery." },
{ label: "No (false)", description: "Await runtime-native completion without polling. Gives up bounded automatic recovery if completion handoff is lost." }
]
},
{ {
question: "Run external plan-bounce validator against generated PLAN.md? (current: <value or false>)", question: "Run external plan-bounce validator against generated PLAN.md? (current: <value or false>)",
header: "Plan Bounce", header: "Plan Bounce",
@@ -500,6 +514,7 @@ keys and sibling sub-objects.
```bash ```bash
# Example — only write keys the user changed. "Keep current" selections are skipped. # Example — only write keys the user changed. "Keep current" selections are skipped.
gsd_run query config-set planner.stall_detection_enabled false
gsd_run query config-set workflow.plan_bounce_passes 5 gsd_run query config-set workflow.plan_bounce_passes 5
gsd_run query config-set workflow.subagent_timeout 300000 gsd_run query config-set workflow.subagent_timeout 300000
gsd_run query config-set git.base_branch main gsd_run query config-set git.base_branch main
@@ -518,6 +533,10 @@ anything not listed in Sections 1–8 MUST survive the update):
```json ```json
{ {
...existing_config, ...existing_config,
"planner": {
...existing_planner,
"stall_detection_enabled": <new|existing>
},
"workflow": { "workflow": {
...existing_workflow, ...existing_workflow,
"plan_bounce": <new|existing>, "plan_bounce": <new|existing>,
@@ -752,6 +771,7 @@ Display:
| Setting | Value | | Setting | Value |
|--------------------------------------------|-------| |--------------------------------------------|-------|
| planner.stall_detection_enabled | {true/false} |
| workflow.plan_bounce | {on/off} | | workflow.plan_bounce | {on/off} |
| workflow.plan_bounce_passes | {n} | | workflow.plan_bounce_passes | {n} |
| workflow.plan_bounce_script | {path/null} | | workflow.plan_bounce_script | {path/null} |
@@ -805,6 +825,7 @@ UI/AI phase gates), use /gsd:settings.
- [ ] Current config read from resolved `$GSD_CONFIG_PATH` - [ ] Current config read from resolved `$GSD_CONFIG_PATH`
- [ ] Eight sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime/Output, Runtime Model Tiers, Model Policy) - [ ] Eight sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime/Output, Runtime Model Tiers, Model Policy)
- [ ] Every field pre-selected to its current value (or documented default if absent) - [ ] Every field pre-selected to its current value (or documented default if absent)
- [ ] Disabling planner stall detection shows the bounded-recovery warning before writing `false`
- [ ] Numeric inputs validated — non-numeric rejected and re-prompted - [ ] Numeric inputs validated — non-numeric rejected and re-prompted
- [ ] Branch-template inputs validated — non-default must contain a placeholder - [ ] Branch-template inputs validated — non-default must contain a placeholder
- [ ] Null-allowed fields accept an empty input as a clear - [ ] Null-allowed fields accept an empty input as a clear

View File

@@ -162,9 +162,22 @@ const CONFIG_DEFAULTS = {
research_before_questions: _getNestedConfigDefault('workflow', 'research_before_questions'), // #3894 research_before_questions: _getNestedConfigDefault('workflow', 'research_before_questions'), // #3894
smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'), smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'),
inline_plan_threshold: _getNestedConfigDefault('workflow', 'inline_plan_threshold'), // #3801 inline_plan_threshold: _getNestedConfigDefault('workflow', 'inline_plan_threshold'), // #3801
planner_stall_detection_enabled: _getNestedConfigDefault('planner', 'stall_detection_enabled'),
max_prompt_tokens: _getNestedConfigDefault('review', 'max_prompt_tokens'), max_prompt_tokens: _getNestedConfigDefault('review', 'max_prompt_tokens'),
}; };
/**
* Resolve the planner watchdog policy from hand-edited configuration.
* Only a real JSON boolean may override the default; strings/numbers/null
* fail safe to the manifest-owned default-on behavior (#4570).
*/
function resolvePlannerStallDetectionEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value;
// A missing or skewed manifest must never convert an absent override into
// permission to disable the watchdog. The documented contract is default-on.
return true;
}
/** /**
* Deep-merge two plain config objects. `overlay` wins on key conflict. * Deep-merge two plain config objects. `overlay` wins on key conflict.
* Explicit `null` in overlay overrides base (null means "unset this key"). * Explicit `null` in overlay overrides base (null means "unset this key").
@@ -932,6 +945,9 @@ function loadConfigResolvedInternal(cwd: string, options: Record<string, unknown
phase_naming: get('phase_naming') ?? defaults.phase_naming, phase_naming: get('phase_naming') ?? defaults.phase_naming,
project_code: get('project_code') ?? defaults.project_code, project_code: get('project_code') ?? defaults.project_code,
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout, subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
planner_stall_detection_enabled: resolvePlannerStallDetectionEnabled(
getNested('planner', 'stall_detection_enabled'),
),
model_overrides: (parsed['model_overrides']) || null, model_overrides: (parsed['model_overrides']) || null,
agent_tools: (parsed['agent_tools']) || null, agent_tools: (parsed['agent_tools']) || null,
models: (parsed['models']) || null, models: (parsed['models']) || null,
@@ -1091,6 +1107,9 @@ function loadConfigResolvedInternal(cwd: string, options: Record<string, unknown
resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids, resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids,
context_window: (globalDefaults['context_window']) ?? defaults.context_window, context_window: (globalDefaults['context_window']) ?? defaults.context_window,
subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout, subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout,
planner_stall_detection_enabled: resolvePlannerStallDetectionEnabled(
(globalDefaults['planner'] as Record<string, unknown> | undefined)?.['stall_detection_enabled'],
),
model_overrides: (globalDefaults['model_overrides']) || null, model_overrides: (globalDefaults['model_overrides']) || null,
models: (globalDefaults['models']) || null, models: (globalDefaults['models']) || null,
granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null, granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null,
@@ -1191,6 +1210,7 @@ export = {
_getNestedConfigDefault, _getNestedConfigDefault,
_getConfigValue, _getConfigValue,
_getConfigNested, _getConfigNested,
resolvePlannerStallDetectionEnabled,
_deepMergeConfig, _deepMergeConfig,
_warnedUnknownConfigKeys, _warnedUnknownConfigKeys,
_warnedShadowedGlobalKeys, _warnedShadowedGlobalKeys,

View File

@@ -17,7 +17,7 @@ import cliExitMod = require('./cli-exit.cjs');
const { ExitError } = cliExitMod; const { ExitError } = cliExitMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoader = require('./config-loader.cjs'); import configLoader = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoader; const { CONFIG_DEFAULTS, resolvePlannerStallDetectionEnabled } = configLoader;
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs'; import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs'); import planningWorkspace = require('./planning-workspace.cjs');
@@ -107,6 +107,7 @@ const SCHEMA_DEFAULTS: Record<string, unknown> = {
'context_window': 200000, 'context_window': 200000,
'executor.stall_detect_interval_minutes': 5, 'executor.stall_detect_interval_minutes': 5,
'executor.stall_threshold_minutes': 10, 'executor.stall_threshold_minutes': 10,
'planner.stall_detection_enabled': CONFIG_DEFAULTS.planner_stall_detection_enabled,
'planner.stall_detect_interval_minutes': 5, 'planner.stall_detect_interval_minutes': 5,
'planner.stall_threshold_minutes': 10, 'planner.stall_threshold_minutes': 10,
'git.create_tag': true, 'git.create_tag': true,
@@ -185,12 +186,15 @@ function resolveSchemaDefault(cwd: string, kp: string): { found: boolean; value:
* Centralizing emission here means masking can't be missed at a call site. * Centralizing emission here means masking can't be missed at a call site.
*/ */
function emitResolvedDefault(kp: string, value: unknown, raw: boolean): void { function emitResolvedDefault(kp: string, value: unknown, raw: boolean): void {
const resolvedValue = kp === 'planner.stall_detection_enabled'
? resolvePlannerStallDetectionEnabled(value)
: value;
if (isSecretKey(kp)) { if (isSecretKey(kp)) {
const masked = maskSecret(value as Parameters<typeof maskSecret>[0]); const masked = maskSecret(resolvedValue as Parameters<typeof maskSecret>[0]);
output(masked, raw, masked); output(masked, raw, masked);
return; return;
} }
output(value, raw, String(value)); output(resolvedValue, raw, String(resolvedValue));
} }
// ─── Validation helpers ─────────────────────────────────────────────────────── // ─── Validation helpers ───────────────────────────────────────────────────────
@@ -955,6 +959,14 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
} }
} }
// Planner watchdog opt-out (#4570) — only a real boolean may change the
// default-on policy. In particular, string "false" must not disable it.
if (kp === 'planner.stall_detection_enabled') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid planner.stall_detection_enabled '${val}'. Must be a boolean (true or false).`);
}
}
// #3086 — git.create_tag: boolean only // #3086 — git.create_tag: boolean only
if (kp === 'git.create_tag') { if (kp === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') { if (typeof parsedValue !== 'boolean') {
@@ -1251,6 +1263,10 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND); error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
} }
if (kp === 'planner.stall_detection_enabled') {
current = resolvePlannerStallDetectionEnabled(current);
}
// Never echo plaintext for sensitive keys via config-get. Plaintext lives // Never echo plaintext for sensitive keys via config-get. Plaintext lives
// in config.json on disk; the CLI surface always shows the masked form. // in config.json on disk; the CLI surface always shows the masked form.
if (isSecretKey(kp)) { if (isSecretKey(kp)) {

View File

@@ -28,9 +28,9 @@
"reductionPct": 13.53 "reductionPct": 13.53
}, },
"plan-phase": { "plan-phase": {
"offTokens": 28000, "offTokens": 28406,
"onTokens": 24649, "onTokens": 25055,
"reductionPct": 11.97 "reductionPct": 11.8
}, },
"verify-work": { "verify-work": {
"offTokens": 13445, "offTokens": 13445,
@@ -39,8 +39,8 @@
} }
}, },
"aggregate": { "aggregate": {
"offTokens": 108858, "offTokens": 109264,
"onTokens": 91988, "onTokens": 92394,
"reductionPct": 15.5 "reductionPct": 15.44
} }
} }

View File

@@ -23,9 +23,9 @@
"reductionPct": 20.79 "reductionPct": 20.79
}, },
"agents/gsd-code-fixer.md": { "agents/gsd-code-fixer.md": {
"offTokens": 10740, "offTokens": 10835,
"onTokens": 6690, "onTokens": 6781,
"reductionPct": 37.71 "reductionPct": 37.42
}, },
"agents/gsd-code-reviewer.md": { "agents/gsd-code-reviewer.md": {
"offTokens": 4408, "offTokens": 4408,
@@ -38,9 +38,9 @@
"reductionPct": 9.76 "reductionPct": 9.76
}, },
"agents/gsd-debug-session-manager.md": { "agents/gsd-debug-session-manager.md": {
"offTokens": 5027, "offTokens": 5088,
"onTokens": 4738, "onTokens": 4799,
"reductionPct": 5.75 "reductionPct": 5.68
}, },
"agents/gsd-doc-classifier.md": { "agents/gsd-doc-classifier.md": {
"offTokens": 2901, "offTokens": 2901,
@@ -73,9 +73,9 @@
"reductionPct": 17.83 "reductionPct": 17.83
}, },
"agents/gsd-eval-auditor.md": { "agents/gsd-eval-auditor.md": {
"offTokens": 2941, "offTokens": 3002,
"onTokens": 2808, "onTokens": 2869,
"reductionPct": 4.52 "reductionPct": 4.43
}, },
"agents/gsd-eval-planner.md": { "agents/gsd-eval-planner.md": {
"offTokens": 1674, "offTokens": 1674,
@@ -93,9 +93,9 @@
"reductionPct": 42.1 "reductionPct": 42.1
}, },
"agents/gsd-intel-updater.md": { "agents/gsd-intel-updater.md": {
"offTokens": 4353, "offTokens": 4414,
"onTokens": 4115, "onTokens": 4176,
"reductionPct": 5.47 "reductionPct": 5.39
}, },
"agents/gsd-mempalace-curator.md": { "agents/gsd-mempalace-curator.md": {
"offTokens": 1160, "offTokens": 1160,
@@ -113,14 +113,14 @@
"reductionPct": 15.37 "reductionPct": 15.37
}, },
"agents/gsd-project-researcher.md": { "agents/gsd-project-researcher.md": {
"offTokens": 5464, "offTokens": 5525,
"onTokens": 5135, "onTokens": 5196,
"reductionPct": 6.02 "reductionPct": 5.95
}, },
"agents/gsd-research-synthesizer.md": { "agents/gsd-research-synthesizer.md": {
"offTokens": 3122, "offTokens": 3183,
"onTokens": 2917, "onTokens": 2978,
"reductionPct": 6.57 "reductionPct": 6.44
}, },
"agents/gsd-roadmapper.md": { "agents/gsd-roadmapper.md": {
"offTokens": 6008, "offTokens": 6008,
@@ -133,9 +133,9 @@
"reductionPct": 10.54 "reductionPct": 10.54
}, },
"agents/gsd-ui-auditor.md": { "agents/gsd-ui-auditor.md": {
"offTokens": 4145, "offTokens": 6729,
"onTokens": 3926, "onTokens": 3926,
"reductionPct": 5.28 "reductionPct": 41.66
}, },
"agents/gsd-ui-checker.md": { "agents/gsd-ui-checker.md": {
"offTokens": 4499, "offTokens": 4499,
@@ -143,9 +143,9 @@
"reductionPct": 32.25 "reductionPct": 32.25
}, },
"agents/gsd-ui-researcher.md": { "agents/gsd-ui-researcher.md": {
"offTokens": 5432, "offTokens": 5493,
"onTokens": 4815, "onTokens": 4876,
"reductionPct": 11.36 "reductionPct": 11.23
}, },
"agents/gsd-user-profiler.md": { "agents/gsd-user-profiler.md": {
"offTokens": 1859, "offTokens": 1859,
@@ -163,14 +163,14 @@
"reductionPct": 33.87 "reductionPct": 33.87
}, },
"gsd-core/workflows/help/modes/full.md": { "gsd-core/workflows/help/modes/full.md": {
"offTokens": 9950, "offTokens": 9933,
"onTokens": 6579, "onTokens": 6573,
"reductionPct": 33.88 "reductionPct": 33.83
} }
}, },
"aggregate": { "aggregate": {
"offTokens": 118958, "offTokens": 121986,
"onTokens": 94602, "onTokens": 95053,
"reductionPct": 20.47 "reductionPct": 22.08
} }
} }

View File

@@ -47,14 +47,21 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const fc = require('fast-check'); const fc = require('fast-check');
const { cleanup, readFileNormalized, readWorkflowCombined } = require('./helpers.cjs'); const { cleanup, readFileNormalized, readWorkflowCombined, runGsdTools } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..'); const REPO_ROOT = path.join(__dirname, '..');
const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'); const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
const STALL_HELPERS_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md'); const STALL_HELPERS_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md');
const CHUNKED_PLANNING_MODE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); const CHUNKED_PLANNING_MODE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
const CONFIG_SCHEMA_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); const CONFIG_SCHEMA_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
const CONFIG_DEFAULTS_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-defaults.manifest.json');
const CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md'); const CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md');
const PT_BR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'pt-BR', 'CONFIGURATION.md');
const ZH_CN_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'CONFIGURATION.md');
const JA_JP_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ja-JP', 'CONFIGURATION.md');
const KO_KR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ko-KR', 'CONFIGURATION.md');
const ZH_CN_PLANNING_CONFIG_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'references', 'planning-config.md');
const SETTINGS_ADVANCED_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'settings-advanced.md');
function readPlanPhase() { function readPlanPhase() {
return readFileNormalized(PLAN_PHASE_PATH); return readFileNormalized(PLAN_PHASE_PATH);
@@ -461,6 +468,148 @@ describe('bug #2650 config schema — planner.stall_* keys mirror executor.stall
}); });
}); });
describe('enhancement #4570 config contract — planner stall detection has a typed default-on opt-out', () => {
function makeProject(t, config = {}) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-config-'));
t.after(() => cleanup(tmp));
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
return tmp;
}
test('central schema and canonical defaults manifest register planner.stall_detection_enabled=true', () => {
const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_MANIFEST_PATH, 'utf8'));
const defaults = JSON.parse(fs.readFileSync(CONFIG_DEFAULTS_MANIFEST_PATH, 'utf8'));
assert.ok(schema.validKeys.includes('planner.stall_detection_enabled'));
assert.equal(defaults.planner?.stall_detection_enabled, true);
assert.equal(schema.validKeys.includes('executor.stall_detection_enabled'), false,
'the planner opt-out must not introduce an executor sibling outside approved scope');
});
test('config-get defaults absent values to true; config-set false round-trips as boolean false', (t) => {
const tmp = makeProject(t);
const env = { HOME: tmp, USERPROFILE: tmp };
const absent = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
assert.equal(absent.success, true, absent.error);
assert.equal(absent.output, 'true');
const noConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-no-config-'));
t.after(() => cleanup(noConfig));
const absentFile = runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'],
noConfig,
{ HOME: noConfig, USERPROFILE: noConfig },
);
assert.equal(absentFile.success, true, absentFile.error);
assert.equal(absentFile.output, 'true');
const set = runGsdTools(['config-set', 'planner.stall_detection_enabled', 'false'], tmp, env);
assert.equal(set.success, true, set.error);
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf8'));
assert.equal(onDisk.planner.stall_detection_enabled, false);
assert.equal(typeof onDisk.planner.stall_detection_enabled, 'boolean');
const roundTrip = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
assert.equal(roundTrip.success, true, roundTrip.error);
assert.equal(roundTrip.output, 'false');
});
test('property: every non-boolean CLI value is rejected without modifying config', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: true }, sentinel: 'preserve' });
const configPath = path.join(tmp, '.planning', 'config.json');
const before = fs.readFileSync(configPath, 'utf8');
const printable = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]{}._- ';
const invalidValue = fc.array(fc.constantFrom(...printable), { minLength: 1, maxLength: 16 })
.map((chars) => chars.join(''))
.filter((value) => !['true', 'false', 'null'].includes(value));
fc.assert(
fc.property(invalidValue, (value) => {
const result = runGsdTools(
['config-set', 'planner.stall_detection_enabled', value],
tmp,
{ HOME: tmp, USERPROFILE: tmp },
);
return result.success === false && fs.readFileSync(configPath, 'utf8') === before;
}),
{ numRuns: 20 },
);
});
test('hand-edited non-booleans fail safely to true on both config-get and Config Loader reads', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: 'false' } });
const result = runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'],
tmp,
{ HOME: tmp, USERPROFILE: tmp },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output, 'true', 'a string "false" must not disable the watchdog');
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, true);
});
test('manifest skew cannot make a non-boolean planner setting disable detection', () => {
const { resolvePlannerStallDetectionEnabled } = require('../gsd-core/bin/lib/config-loader.cjs');
for (const value of [undefined, null, 'false', 0, {}]) {
assert.equal(resolvePlannerStallDetectionEnabled(value), true);
}
});
test('root, GSD_PROJECT, and workstream reads retain canonical scope precedence', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: false } });
const projectDir = path.join(tmp, '.planning', 'product-a');
const workstreamDir = path.join(tmp, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(workstreamDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
const home = { HOME: tmp, USERPROFILE: tmp };
assert.equal(runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, home).output, 'false');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_PROJECT: 'product-a' },
).output, 'true');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_WORKSTREAM: 'alpha' },
).output, 'true');
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, false);
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, true);
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{}}\n');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_WORKSTREAM: 'alpha' },
).output, 'false', 'an omitted workstream value must inherit the root value');
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, false);
});
test('English and enumerating localized docs state default, CLI opt-out, effect, and recovery loss', () => {
for (const docsPath of [CONFIGURATION_DOCS_PATH, PT_BR_CONFIGURATION_DOCS_PATH, ZH_CN_CONFIGURATION_DOCS_PATH, JA_JP_CONFIGURATION_DOCS_PATH, KO_KR_CONFIGURATION_DOCS_PATH]) {
const docs = fs.readFileSync(docsPath, 'utf8');
assert.match(docs, /`planner\.stall_detection_enabled`\s*\|\s*boolean\s*\|\s*`true`/);
assert.match(docs, /config-set planner\.stall_detection_enabled false/);
assert.match(docs, /runtime-native|nativa do runtime|运行时原生|ランタイムネイティブ|런타임 네이티브/i);
assert.match(docs, /bounded recovery|recupera[cç][aã]o limitada|有界恢复|有界な復旧|제한된 복구/i);
}
assert.match(fs.readFileSync(ZH_CN_PLANNING_CONFIG_PATH, 'utf8'), /planner\.stall_detection_enabled/);
});
test('advanced settings warns about recovery loss before offering to persist false', () => {
const settings = fs.readFileSync(SETTINGS_ADVANCED_PATH, 'utf8');
assert.match(settings, /planner\.stall_detection_enabled/);
assert.match(settings, /default:\s*`true`/);
assert.match(settings, /bounded automatic recovery[\s\S]{0,500}false/i);
assert.match(settings, /config-set planner\.stall_detection_enabled false/);
});
});
describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch in the background with bounded stall surveillance', () => { describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch in the background with bounded stall surveillance', () => {
let workflow; let workflow;
@@ -475,10 +624,32 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch
test('stall-detection-helpers.md resolves PLANNER_STALL_INTERVAL_MINUTES / PLANNER_STALL_THRESHOLD_MINUTES from config', () => { test('stall-detection-helpers.md resolves PLANNER_STALL_INTERVAL_MINUTES / PLANNER_STALL_THRESHOLD_MINUTES from config', () => {
const helpersDoc = readStallHelpersDoc(); const helpersDoc = readStallHelpersDoc();
assert.match(helpersDoc, /PLANNER_STALL_DETECTION_ENABLED=.*planner\.stall_detection_enabled/);
assert.match(helpersDoc, /PLANNER_STALL_INTERVAL_MINUTES=.*planner\.stall_detect_interval_minutes/); assert.match(helpersDoc, /PLANNER_STALL_INTERVAL_MINUTES=.*planner\.stall_detect_interval_minutes/);
assert.match(helpersDoc, /PLANNER_STALL_THRESHOLD_MINUTES=.*planner\.stall_threshold_minutes/); assert.match(helpersDoc, /PLANNER_STALL_THRESHOLD_MINUTES=.*planner\.stall_threshold_minutes/);
}); });
test('invalid or absent toggle values normalize to default-on; only boolean false disables', (t) => {
const helpersBash = extractStallHelpersBash();
for (const [stored, expected] of [[undefined, 'true'], [true, 'true'], [false, 'false'], ['false', 'true'], [0, 'true'], [null, 'true']]) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-resolve-'));
t.after(() => cleanup(tmp));
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
const config = stored === undefined ? {} : { planner: { stall_detection_enabled: stored } };
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config)}\n`);
const result = runBashScript(
`${helpersBash}\nprintf '%s\\n' "$PLANNER_STALL_DETECTION_ENABLED"\n`,
[],
{
cwd: tmp,
env: { ...process.env, HOME: tmp, USERPROFILE: tmp, RUNTIME_DIR: REPO_ROOT, GSD_PROJECT: '', GSD_WORKSTREAM: '' },
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), expected, `stored ${JSON.stringify(stored)} resolved incorrectly`);
}
});
test('standard planner spawn (step 8) dispatches with run_in_background=true and calls gsd_stall_watch', () => { test('standard planner spawn (step 8) dispatches with run_in_background=true and calls gsd_stall_watch', () => {
const idx = workflow.indexOf('## 8. Spawn gsd-planner Agent'); const idx = workflow.indexOf('## 8. Spawn gsd-planner Agent');
assert.notEqual(idx, -1); assert.notEqual(idx, -1);
@@ -593,6 +764,34 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch
`expected exactly 5 gsd_stall_watch "$TS" "{outputFile}" spawn-site invocations across plan-phase.md + steps/*.md, found ${callCount}`); `expected exactly 5 gsd_stall_watch "$TS" "{outputFile}" spawn-site invocations across plan-phase.md + steps/*.md, found ${callCount}`);
}); });
test('all five spawn classes gate background surveillance and retain a runtime-native blocking result path', () => {
const mainSections = [
['standard planner', '## 8. Spawn gsd-planner Agent', '## 9. Handle Planner Return'],
['plan-checker', '## 10. Spawn gsd-plan-checker Agent', '## 11. Handle Checker Return'],
['revision planner', '## 12. Revision Loop', '## 12.5. Plan Bounce'],
];
const chunkedDoc = readChunkedPlanningMode();
const sections = mainSections.map(([label, start, end]) => {
const startAt = workflow.indexOf(start);
return [label, workflow.slice(startAt, workflow.indexOf(end, startAt))];
});
sections.push(
['chunked outline', chunkedDoc.slice(
chunkedDoc.indexOf('### 8.5.1 Outline Phase'),
chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'),
)],
['chunked per-plan', chunkedDoc.slice(chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'))],
);
for (const [label, section] of sections) {
assert.match(section, /PLANNER_STALL_DETECTION_ENABLED/, `${label}: missing toggle gate`);
assert.match(section, /`true`[\s\S]*run_in_background=true[\s\S]*gsd_stall_watch/,
`${label}: default-on branch must retain background watcher behavior`);
assert.match(section, /`false`[\s\S]{0,700}(?:omit|without) `?run_in_background`?[\s\S]{0,700}(?:ordinary|runtime-native)[\s\S]{0,500}(?:return|result)/i,
`${label}: explicit-off branch must omit backgrounding and await the real runtime result`);
}
});
test('step 7.99 documents that {outputFile} must be bound from the real Agent() return (not passed literally)', () => { test('step 7.99 documents that {outputFile} must be bound from the real Agent() return (not passed literally)', () => {
const idx = workflow.indexOf('## 7.99. Bounded Stall-Detection Helpers'); const idx = workflow.indexOf('## 7.99. Bounded Stall-Detection Helpers');
assert.notEqual(idx, -1); assert.notEqual(idx, -1);

View File

@@ -62,6 +62,9 @@ const REPO_ROOT = path.join(__dirname, '..');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md'); const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md');
const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL); const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL);
const PLAN_PHASE_REL = path.join('gsd-core', 'workflows', 'plan-phase.md');
const CHUNKED_PLAN_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
const STALL_HELPERS_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md');
// plan-phase.md was the original #2930 pilot but was reverted to unmarked // plan-phase.md was the original #2930 pilot but was reverted to unmarked
// (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6 // (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6
// gate and cannot absorb marker overhead) — it was a genuinely unmarked // gate and cannot absorb marker overhead) — it was a genuinely unmarked
@@ -284,6 +287,22 @@ test('noSectionMarkerLeaksIntoEmittedArtifacts', (t) => {
false, false,
`${runtime}: emitted execute-phase.md still contains a gsd:section marker token`, `${runtime}: emitted execute-phase.md still contains a gsd:section marker token`,
); );
// #4570: assert against the real installed workflow bytes for every runtime,
// after composition and runtime conversion. The background parameter itself
// is runtime vocabulary (`run_in_background` becomes `background` on Hermes),
// so parity is pinned on the shared gate and its two semantic branches.
for (const relativePath of [PLAN_PHASE_REL, CHUNKED_PLAN_REL, STALL_HELPERS_REL]) {
const installedPath = path.join(configDir, relativePath);
assert.ok(fs.existsSync(installedPath), `${runtime}: emitted ${relativePath} is missing`);
const installed = fs.readFileSync(installedPath, 'utf8');
assert.match(installed, /PLANNER_STALL_DETECTION_ENABLED/,
`${runtime}: ${relativePath} lost the planner stall-detection gate during conversion`);
assert.match(installed, /gsd_stall_watch/,
`${runtime}: ${relativePath} lost the default-on watcher branch during conversion`);
assert.match(installed, /runtime-native/,
`${runtime}: ${relativePath} lost the explicit-off ordinary completion branch during conversion`);
}
cleanup(root); cleanup(root);
} }
}); });