Merge pull request #3154 from open-gsd/feat/3149-cmdinitdebug
enhance(#3149): dedicated init.debug entry point for /gsd:debug
This commit is contained in:
5
.changeset/gallant-geese-wave.md
Normal file
5
.changeset/gallant-geese-wave.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Changed
|
||||||
|
pr: 3154
|
||||||
|
---
|
||||||
|
**`/gsd:debug` now initializes in one round-trip instead of three** — the workflow previously made three separate `gsd-tools` calls to assemble its context (`state.load`, `resolve-model`, and `config-get workflow.tdd_mode`); it now makes a single `init.debug` call carrying the same resolved values. (#3149)
|
||||||
@@ -159,6 +159,17 @@ own dedicated `cmdInit*` entry points (`cmdInitReview`,
|
|||||||
(`cmdInitDocsUpdate`, `cmdInitUpdate`, `cmdInitTransition`) plus an extension
|
(`cmdInitDocsUpdate`, `cmdInitUpdate`, `cmdInitTransition`) plus an extension
|
||||||
of the pre-existing `cmdInitNewMilestone`.
|
of the pre-existing `cmdInitNewMilestone`.
|
||||||
|
|
||||||
|
An entry point can also land **ahead of** the atom it will unblock. `#3149`
|
||||||
|
gives `debug` a dedicated `cmdInitDebug` (`init.debug`) with no vocabulary
|
||||||
|
change at all: `/gsd-debug` previously made three separate `gsd_run`
|
||||||
|
round-trips and had no `cmdInit*` of its own, so gate (2) could never be
|
||||||
|
satisfied for any debug-scoped fact. Shipping the entry point first satisfies
|
||||||
|
gate (2) on its own schedule and leaves gate (1) — a consuming section of at
|
||||||
|
least 400 bytes — to the change that actually adds the section. `debug` has no
|
||||||
|
`<!-- gsd:section -->` markers yet, so it contributes no key to
|
||||||
|
`section-manifest.json` and `init.debug`'s `section_manifest` field degrades to
|
||||||
|
`null` (read everything) until it does.
|
||||||
|
|
||||||
### Compound conditions are resolved in the fact, never the grammar
|
### Compound conditions are resolved in the fact, never the grammar
|
||||||
|
|
||||||
`state:chunked-mode` looks, at the section-body level, like it should be a
|
`state:chunked-mode` looks, at the section-body level, like it should be a
|
||||||
|
|||||||
@@ -17,24 +17,21 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
|||||||
|
|
||||||
```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}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; 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}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; 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
|
||||||
INIT=$(gsd_run query state.load)
|
INIT=$(gsd_run query init.debug)
|
||||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract `commit_docs` and `config.response_language` from init JSON. Extract `debug_dir` from init JSON — an absolute path anchored on `project_root` (#2376: `debug_file_path` values handed to the spawned `gsd-debug-session-manager` must resolve regardless of that subagent's own cwd, which may differ from the orchestrator's — build them as `{debug_dir}/{slug}.md`, never a bare `.planning/debug/...` literal).
|
One round-trip carries everything this workflow needs (#3149 — this call replaces the former `state.load` + `resolve-model` + `config-get` trio). Extract from init JSON:
|
||||||
|
|
||||||
|
- `commit_docs` — whether planning docs are committed.
|
||||||
|
- `response_language` — TOP-LEVEL field, present ONLY when configured. Absent means English; absence is not a degraded read.
|
||||||
|
- `debug_dir` — an absolute path anchored on `project_root` (#2376: `debug_file_path` values handed to the spawned `gsd-debug-session-manager` must resolve regardless of that subagent's own cwd, which may differ from the orchestrator's — build them as `{debug_dir}/{slug}.md`, never a bare `.planning/debug/...` literal).
|
||||||
|
- `debugger_model` — the resolved model for `gsd-debugger` spawns; used as `{debugger_model}` below and governed by the model-omission rule in step 2.
|
||||||
|
- `tdd_mode` — used as `{TDD_MODE}` in the session parameter blocks below.
|
||||||
|
- `section_manifest` — `null` today, because this workflow declares no applicability-section markers of its own. **When it is `null`, read this workflow in full.** When it is present, read only the files named in its `read` array. `null` and an empty `included` array are NOT the same: `null` means "no manifest for this workflow", an empty `included` means "nothing applies".
|
||||||
|
|
||||||
**If `response_language` is set:** All user-facing questions, prompts, and explanations in this workflow MUST be presented in `{response_language}`. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.
|
**If `response_language` is set:** All user-facing questions, prompts, and explanations in this workflow MUST be presented in `{response_language}`. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.
|
||||||
|
|
||||||
Resolve debugger model:
|
|
||||||
```bash
|
|
||||||
debugger_model=$(gsd_run query resolve-model gsd-debugger --pick model 2>/dev/null || true)
|
|
||||||
```
|
|
||||||
|
|
||||||
Read TDD mode from config:
|
|
||||||
```bash
|
|
||||||
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode --raw 2>/dev/null || echo "false")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1a. LIST subcommand
|
## 1a. LIST subcommand
|
||||||
|
|
||||||
When SUBCMD=list:
|
When SUBCMD=list:
|
||||||
|
|||||||
@@ -22,6 +22,15 @@
|
|||||||
],
|
],
|
||||||
"issue": "622"
|
"issue": "622"
|
||||||
},
|
},
|
||||||
|
"init": {
|
||||||
|
"files": [
|
||||||
|
"init-debug-workflow-contract.test.cjs",
|
||||||
|
"init-debug.test.cjs",
|
||||||
|
"init-manager.test.cjs",
|
||||||
|
"init.test.cjs"
|
||||||
|
],
|
||||||
|
"issue": "3149"
|
||||||
|
},
|
||||||
"milestone": {
|
"milestone": {
|
||||||
"files": [
|
"files": [
|
||||||
"milestone-archive.test.cjs",
|
"milestone-archive.test.cjs",
|
||||||
|
|||||||
@@ -448,6 +448,14 @@ export const INIT_COMMAND_ALIASES: CommandAlias[] = [
|
|||||||
"subcommand": "transition",
|
"subcommand": "transition",
|
||||||
"mutation": false
|
"mutation": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"canonical": "init.debug",
|
||||||
|
"aliases": [
|
||||||
|
"init debug"
|
||||||
|
],
|
||||||
|
"subcommand": "debug",
|
||||||
|
"mutation": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"canonical": "init.new-workspace",
|
"canonical": "init.new-workspace",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface InitModule {
|
|||||||
cmdInitDocsUpdate(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
cmdInitDocsUpdate(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||||
cmdInitUpdate(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
cmdInitUpdate(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||||
cmdInitTransition(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
cmdInitTransition(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||||
|
cmdInitDebug(cwd: string, raw: boolean, options?: Record<string, string | boolean | null | undefined>): void;
|
||||||
cmdInitNewWorkspace(cwd: string, raw: boolean): void;
|
cmdInitNewWorkspace(cwd: string, raw: boolean): void;
|
||||||
cmdInitListWorkspaces(cwd: string, raw: boolean): void;
|
cmdInitListWorkspaces(cwd: string, raw: boolean): void;
|
||||||
cmdInitRemoveWorkspace(cwd: string, name: string | undefined, raw: boolean): void;
|
cmdInitRemoveWorkspace(cwd: string, name: string | undefined, raw: boolean): void;
|
||||||
@@ -171,6 +172,10 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
|
|||||||
init.cmdInitUpdate(cwd, raw, { next: namedArgs['next'], rc: namedArgs['rc'] });
|
init.cmdInitUpdate(cwd, raw, { next: namedArgs['next'], rc: namedArgs['rc'] });
|
||||||
},
|
},
|
||||||
transition: () => init.cmdInitTransition(cwd, raw, {}),
|
transition: () => init.cmdInitTransition(cwd, raw, {}),
|
||||||
|
debug: () => {
|
||||||
|
const namedArgs = parseNamedArgs(args, [], ['diagnose']);
|
||||||
|
init.cmdInitDebug(cwd, raw, { diagnose: namedArgs['diagnose'] });
|
||||||
|
},
|
||||||
'new-workspace': () => init.cmdInitNewWorkspace(cwd, raw),
|
'new-workspace': () => init.cmdInitNewWorkspace(cwd, raw),
|
||||||
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
|
'list-workspaces': () => init.cmdInitListWorkspaces(cwd, raw),
|
||||||
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
|
'remove-workspace': () => init.cmdInitRemoveWorkspace(cwd, args[2], raw),
|
||||||
|
|||||||
63
src/init.cts
63
src/init.cts
@@ -2690,6 +2690,68 @@ function cmdInitTransition(cwd: string, raw: boolean, options: Record<string, un
|
|||||||
output(withProjectRoot(cwd, result), raw);
|
output(withProjectRoot(cwd, result), raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `debug.md`'s dedicated init entry point (#3149; prerequisite for #3128).
|
||||||
|
* `debug.md` previously carried NO `gsd_run query init.*` call at all — it made
|
||||||
|
* THREE separate round-trips instead: `state.load` (for `commit_docs`,
|
||||||
|
* `config.response_language` and `debug_dir`), `resolve-model gsd-debugger
|
||||||
|
* --pick model`, and `config-get workflow.tdd_mode --raw`. Because no
|
||||||
|
* debug-scoped fact was computed at any entry point, a `when=` atom naming one
|
||||||
|
* would have evaluated FALSE forever — ADR-1671's admission gate (2) and the
|
||||||
|
* silent-exclusion bug it exists to prevent (`docs/adr/1671-…:122-131`).
|
||||||
|
*
|
||||||
|
* Every field is resolved through the SAME primitive the call it replaces used,
|
||||||
|
* never a second hand-maintained copy (DEFECT.GENERATIVE-FIX):
|
||||||
|
*
|
||||||
|
* - `commit_docs` — `loadConfig`, the same loader `cmdStateLoad` calls.
|
||||||
|
* - `response_language` — NOT read here: `withProjectRoot` already injects it
|
||||||
|
* when configured (#2402), which is also the shape sibling init bundles use.
|
||||||
|
* It is absent, not null, when unset.
|
||||||
|
* - `debug_dir` — `planningPaths(cwd).debug`, the SAME expression `cmdStateLoad`
|
||||||
|
* now uses; the `debug` field was added to `PlanningPaths` (#3149) so the
|
||||||
|
* location has one source instead of two kept in sync by hand.
|
||||||
|
* - `debugger_model` — `resolveModelInternal`, which IS what `query
|
||||||
|
* resolve-model --pick model` returns (`cmdResolveModel`, src/commands.cts).
|
||||||
|
* - `tdd_mode` — the `Boolean(wf['tdd_mode'])` idiom `cmdInitExecutePhase` and
|
||||||
|
* `cmdInitPlanPhase` already use. `/gsd:debug` has no `--tdd` flag, so the
|
||||||
|
* sibling handlers' `options['tdd'] ||` disjunct is deliberately omitted
|
||||||
|
* rather than carried as a phantom.
|
||||||
|
*
|
||||||
|
* `state.load` is deliberately NOT narrowed — see the note beside its own
|
||||||
|
* `debug_dir` field. This handler is purely additive alongside it.
|
||||||
|
*
|
||||||
|
* `diagnose` is the one flag `/gsd:debug` already documents. Exposing it as a
|
||||||
|
* top-level fact follows `cmdInitUpdate`'s `next_channel` and
|
||||||
|
* `cmdInitAutonomous`'s `plan_strategy_converge` precedent, and is what makes
|
||||||
|
* the router's flag forwarding observable. No `when=` atom consumes it yet:
|
||||||
|
* admission gate (1) — a consuming section of at least 400 bytes — is #3128's
|
||||||
|
* to satisfy, and shipping the atom before its section is the same
|
||||||
|
* silent-exclusion bug from the other direction.
|
||||||
|
*/
|
||||||
|
function cmdInitDebug(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const wf = (config.workflow ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
commit_docs: config.commit_docs,
|
||||||
|
// #2376: absolute — debug.md builds `debug_file_path` as
|
||||||
|
// `{debug_dir}/{slug}.md` for its gsd-debug-session-manager spawns, whose
|
||||||
|
// own cwd may differ from the orchestrator's.
|
||||||
|
debug_dir: toPosixPath(planningPaths(cwd).debug),
|
||||||
|
debugger_model: resolveModelInternal(cwd, 'gsd-debugger'),
|
||||||
|
tdd_mode: Boolean(wf['tdd_mode']),
|
||||||
|
diagnose: options['diagnose'] === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Additive, optional field — degrades to null while `debug` has no key in
|
||||||
|
// `gsd-core/workflows/section-manifest.json` (it has no `gsd:section` markers
|
||||||
|
// until #3128). null means "read everything", which is NOT the same as a
|
||||||
|
// computed empty selection.
|
||||||
|
result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'debug', {});
|
||||||
|
|
||||||
|
output(withProjectRoot(cwd, result), raw);
|
||||||
|
}
|
||||||
|
|
||||||
function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||||
try {
|
try {
|
||||||
(pruneOrphanedWorktrees as (cwd: string) => void)(cwd);
|
(pruneOrphanedWorktrees as (cwd: string) => void)(cwd);
|
||||||
@@ -3708,6 +3770,7 @@ export = {
|
|||||||
cmdInitDocsUpdate,
|
cmdInitDocsUpdate,
|
||||||
cmdInitUpdate,
|
cmdInitUpdate,
|
||||||
cmdInitTransition,
|
cmdInitTransition,
|
||||||
|
cmdInitDebug,
|
||||||
cmdInitNewWorkspace,
|
cmdInitNewWorkspace,
|
||||||
cmdInitListWorkspaces,
|
cmdInitListWorkspaces,
|
||||||
cmdInitRemoveWorkspace,
|
cmdInitRemoveWorkspace,
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ interface PlanningPaths {
|
|||||||
config: string;
|
config: string;
|
||||||
phases: string;
|
phases: string;
|
||||||
requirements: string;
|
requirements: string;
|
||||||
|
debug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
||||||
@@ -178,6 +179,10 @@ function planningPaths(cwd: string, ws?: string | null): PlanningPaths {
|
|||||||
config: path.join(base, 'config.json'),
|
config: path.join(base, 'config.json'),
|
||||||
phases: path.join(base, 'phases'),
|
phases: path.join(base, 'phases'),
|
||||||
requirements: path.join(base, 'REQUIREMENTS.md'),
|
requirements: path.join(base, 'REQUIREMENTS.md'),
|
||||||
|
// #3149: the debug-session directory. Single source for both `state.load`'s
|
||||||
|
// `debug_dir` field and `init.debug`'s — previously each composed its own
|
||||||
|
// `path.join(planning, 'debug')` (DEFECT.GENERATIVE-FIX).
|
||||||
|
debug: path.join(base, 'debug'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,7 +335,8 @@ const STOP_H2_ONLY = (lv: number): boolean => lv === 2;
|
|||||||
|
|
||||||
function cmdStateLoad(cwd: string, raw: boolean): void {
|
function cmdStateLoad(cwd: string, raw: boolean): void {
|
||||||
const config = loadConfig(cwd);
|
const config = loadConfig(cwd);
|
||||||
const planDir = planningPaths(cwd).planning;
|
const paths = planningPaths(cwd);
|
||||||
|
const planDir = paths.planning;
|
||||||
|
|
||||||
const stateRaw = platformReadSync(path.join(planDir, 'STATE.md')) || '';
|
const stateRaw = platformReadSync(path.join(planDir, 'STATE.md')) || '';
|
||||||
|
|
||||||
@@ -351,10 +352,12 @@ function cmdStateLoad(cwd: string, raw: boolean): void {
|
|||||||
config_exists: configExists,
|
config_exists: configExists,
|
||||||
// #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
|
// #2376: absolute (anchored on cwd), not orchestrator-cwd-relative — a
|
||||||
// spawned subagent's own cwd may differ from the orchestrator's.
|
// spawned subagent's own cwd may differ from the orchestrator's.
|
||||||
// debug.md has no init.* call of its own; it reads this field from
|
// #3149: debug.md now has its own `init.debug` entry point and reads this
|
||||||
// `state load` to build debug_file_path for its gsd-debug-session-manager
|
// field from there, not from `state load`. This stays on the state.load
|
||||||
// spawns instead of hardcoding '.planning/debug/{slug}.md'.
|
// bundle regardless: it is a shipped query surface with its own test anchor
|
||||||
debug_dir: toPosixPath(path.join(planDir, 'debug')),
|
// (tests/state.test.cjs), so narrowing it would break unseen consumers for
|
||||||
|
// no gain (Hyrum's Law). Both emit the SAME `planningPaths(cwd).debug`.
|
||||||
|
debug_dir: toPosixPath(paths.debug),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For --raw, output a condensed key=value format
|
// For --raw, output a condensed key=value format
|
||||||
|
|||||||
@@ -76,13 +76,21 @@ describe('debug session management implementation', () => {
|
|||||||
path.join(process.cwd(), 'gsd-core/workflows/debug.md'),
|
path.join(process.cwd(), 'gsd-core/workflows/debug.md'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
// #3149: tdd_mode now arrives on the `init.debug` bundle rather than a
|
||||||
|
// `config-get` call in this workflow, so the old literal-call assertion no
|
||||||
|
// longer describes reality. The invariant it protected is unchanged and is
|
||||||
|
// asserted at its new home: `cmdInitDebug` resolves `config.workflow`'s
|
||||||
|
// `tdd_mode`, never a bare top-level key — proven behaviorally in
|
||||||
|
// tests/init-debug.test.cjs ('honors workflow.tdd_mode, ignores a bare
|
||||||
|
// top-level tdd_mode'). What must remain true HERE is only that this
|
||||||
|
// workflow never reintroduces a bare-key read of its own.
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes('config-get tdd_mode'),
|
!content.includes('config-get tdd_mode'),
|
||||||
'debug.md must not use bare "tdd_mode" key — use "workflow.tdd_mode" to match every other consumer'
|
'debug.md must not use bare "tdd_mode" key — use "workflow.tdd_mode" to match every other consumer'
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes('config-get workflow.tdd_mode'),
|
content.includes('tdd_mode'),
|
||||||
'debug.md must read tdd_mode via the "workflow.tdd_mode" key'
|
'debug.md must still consume tdd_mode (now from the init.debug bundle)'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"paths": {
|
||||||
|
"debug.md": "#3149 (prerequisite for #3128, ADR-1671 admission gate 2): debug.md gains a dedicated `init.debug` entry point (cmdInitDebug) and its Step 0 collapses THREE separate `gsd_run` round-trips into one. Removed: `gsd_run query state.load` (line 20, replaced in place), the `resolve-model gsd-debugger --pick model` block, and the `config-get workflow.tdd_mode --raw` block — 2 prose lead-ins and 2 fenced code blocks in total. Added: a 6-bullet extraction list documenting the bundle's fields (`commit_docs`, the now TOP-LEVEL `response_language`, `debug_dir`, `debugger_model`, `tdd_mode`, `section_manifest`) plus the `section_manifest: null` -> read-everything rule and the null-vs-empty-included distinction. Net SOURCE growth is +618 bytes (20,555 -> 21,173): the bullets that document one bundle cost more bytes than the two shell round-trips they replace, which is the intended trade — the round-trips cost three subprocess spawns at RUN time on every /gsd:debug invocation. No applicability-section marker is added and WHEN_VOCABULARY is unchanged at 29, so the composeWorkflow emission path is byte-identical in shape to before; only this file's own content moved. The `{TDD_MODE}` and `{debugger_model}` placeholders in the session-parameter blocks (lines ~137-145, ~226-234) are deliberately left byte-identical — they now resolve from the init bundle instead of shell variables, and rewording them would ripple into tests/fix-2257-debug-nonterminal-resume.test.cjs and tests/debug-session-manager-commit.test.cjs for no behavioral gain."
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/init-debug-workflow-contract.test.cjs
Normal file
87
tests/init-debug-workflow-contract.test.cjs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// allow-test-rule: source-text-is-the-product (see #3149)
|
||||||
|
// gsd-core/workflows/debug.md is shipped prompt content: the text IS what the
|
||||||
|
// runtime loads, so its Step 0 contract can only be asserted against the text.
|
||||||
|
// The behavioral half of this change lives in tests/init-debug.test.cjs, which
|
||||||
|
// carries no exemption.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `debug.md` Step 0 contract after the `init.debug` consolidation (#3149).
|
||||||
|
*
|
||||||
|
* Matrix: `.gsd/phase/feat-3149-cmdinitdebug/50-test-matrix.md` group F.
|
||||||
|
*
|
||||||
|
* Guards the four ways this consolidation can silently regress:
|
||||||
|
* F1/F2 — a replaced round-trip creeping back, or the new one being lost.
|
||||||
|
* F3 — reading `config.response_language` (the nested state.load shape)
|
||||||
|
* instead of the flat top-level field withProjectRoot injects. This
|
||||||
|
* is the #2402 defect class: the workflow silently stays English.
|
||||||
|
* F4 — losing the `@file:` unwrap, which large init payloads still need.
|
||||||
|
* F5 — dropping the `section_manifest: null` -> read-everything rule,
|
||||||
|
* without which a null manifest reads as "read nothing".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'debug.md');
|
||||||
|
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||||
|
|
||||||
|
/** Every `gsd_run query <name>` invocation in the workflow, in document order. */
|
||||||
|
function queryInvocations(text) {
|
||||||
|
return [...text.matchAll(/gsd_run query ([\w.-]+)/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('debug.md Step 0 init contract (#3149, matrix §F)', () => {
|
||||||
|
test('calls init.debug exactly once (row F1)', () => {
|
||||||
|
const initDebugCalls = queryInvocations(workflow).filter((q) => q === 'init.debug');
|
||||||
|
assert.equal(initDebugCalls.length, 1, 'exactly one init.debug round-trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no longer makes the three replaced calls (row F2)', () => {
|
||||||
|
const queries = queryInvocations(workflow);
|
||||||
|
|
||||||
|
assert.equal(queries.includes('state.load'), false, 'state.load is replaced by init.debug');
|
||||||
|
assert.equal(
|
||||||
|
workflow.includes('resolve-model gsd-debugger'),
|
||||||
|
false,
|
||||||
|
'debugger_model now rides the init bundle'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
workflow.includes('config-get workflow.tdd_mode'),
|
||||||
|
false,
|
||||||
|
'tdd_mode now rides the init bundle'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the flat response_language, not the nested state.load shape (row F3)', () => {
|
||||||
|
assert.equal(
|
||||||
|
workflow.includes('config.response_language'),
|
||||||
|
false,
|
||||||
|
'withProjectRoot injects response_language at the TOP level; reading config.response_language ' +
|
||||||
|
'against an init bundle resolves undefined and silently drops translated output (#2402)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes('`response_language`'),
|
||||||
|
'the field is still documented, just at its new location'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still unwraps an @file: payload (row F4)', () => {
|
||||||
|
assert.ok(
|
||||||
|
workflow.includes('@file:'),
|
||||||
|
'init payloads can spill to a file; dropping the unwrap leaves INIT holding a path, not JSON'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documents the null-manifest read-everything fallback (row F5)', () => {
|
||||||
|
assert.ok(workflow.includes('section_manifest'), 'the field is documented');
|
||||||
|
assert.ok(
|
||||||
|
/`null`[^\r\n]*read this workflow in full/i.test(workflow),
|
||||||
|
'a null section_manifest must be documented as "read everything" — without the rule, ' +
|
||||||
|
'a null manifest reads as an empty selection and the workflow reads nothing'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
470
tests/init-debug.test.cjs
Normal file
470
tests/init-debug.test.cjs
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `init.debug` — the dedicated init entry point for `/gsd:debug` (#3149).
|
||||||
|
*
|
||||||
|
* Prerequisite for #3128 condition 1: ADR-1671 admission gate (2), "a fact the
|
||||||
|
* init seam demonstrably computes at a real entry point"
|
||||||
|
* (`docs/adr/1671-dynamic-context-management-platform.md:122-131`). Before this,
|
||||||
|
* `gsd-core/workflows/debug.md` was one of the last workflows with no `cmdInit*`
|
||||||
|
* of its own, so no debug-scoped fact could ever be computed and any `when=` atom
|
||||||
|
* naming one would have evaluated FALSE forever — the silent-exclusion bug that
|
||||||
|
* rule exists to prevent.
|
||||||
|
*
|
||||||
|
* Matrix: `.gsd/phase/feat-3149-cmdinitdebug/50-test-matrix.md` groups A-E, G3.
|
||||||
|
*
|
||||||
|
* Every test drives the REAL CLI (`runGsdTools` spawns `gsd-tools.cjs`) rather
|
||||||
|
* than requiring `cmdInitDebug` directly — the handler is not exported, and the
|
||||||
|
* flag plumbing under test exists only at the `init-command-router.cjs` seam.
|
||||||
|
* Same rationale recorded in `tests/section-manifest-init-facts.test.cjs:10-14`.
|
||||||
|
*
|
||||||
|
* Group A is the load-bearing half: this change's entire claim is "one round-trip
|
||||||
|
* instead of three, with identical resolved values", so each A-row cross-checks
|
||||||
|
* `init.debug` against the exact command it replaced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { runGsdTools, cleanup, createTempDir, createTempProject } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function writeConfig(tmpDir, config, { ws = null } = {}) {
|
||||||
|
const dir = ws
|
||||||
|
? path.join(tmpDir, '.planning', 'workstreams', ws)
|
||||||
|
: path.join(tmpDir, '.planning');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a gsd-tools query and parses its JSON, asserting a clean exit first. */
|
||||||
|
function runJson(argv, cwd, env = {}) {
|
||||||
|
const result = runGsdTools(argv, cwd, env);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
return JSON.parse(result.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group A: equivalence with the three calls init.debug replaces ──────────
|
||||||
|
|
||||||
|
describe('init.debug resolves identically to the three calls it replaces (matrix §A)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('init-debug-a-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debug_dir matches state.load exactly (row A1)', () => {
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaState = runJson(['query', 'state.load'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
viaInit.debug_dir,
|
||||||
|
viaState.debug_dir,
|
||||||
|
'init.debug must resolve the same debug directory state.load does — debug.md builds ' +
|
||||||
|
'debug_file_path from it (#2376) and a divergence silently writes sessions elsewhere'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debug_dir agrees with state.load under an active workstream (row A2)', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'ws1'), { recursive: true });
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir, { GSD_WORKSTREAM: 'ws1' });
|
||||||
|
const viaState = runJson(['query', 'state.load'], tmpDir, { GSD_WORKSTREAM: 'ws1' });
|
||||||
|
|
||||||
|
assert.equal(viaInit.debug_dir, viaState.debug_dir);
|
||||||
|
assert.match(
|
||||||
|
viaInit.debug_dir,
|
||||||
|
/\/workstreams\/ws1\/debug$/,
|
||||||
|
'an active workstream must scope debug_dir into that workstream, not the project root'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commit_docs matches state.load (row A3)', () => {
|
||||||
|
writeConfig(tmpDir, { planning: { commit_docs: false } });
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaState = runJson(['query', 'state.load'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(viaInit.commit_docs, viaState.config.commit_docs);
|
||||||
|
assert.equal(viaInit.commit_docs, false, 'sanity: the configured value, not the default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('response_language matches state.load config (row A4)', () => {
|
||||||
|
writeConfig(tmpDir, { response_language: 'es' });
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaState = runJson(['query', 'state.load'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(viaInit.response_language, viaState.config.response_language);
|
||||||
|
assert.equal(viaInit.response_language, 'es');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debugger_model matches the resolve-model query (row A5)', () => {
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaResolve = runJson(['query', 'resolve-model', 'gsd-debugger'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
viaInit.debugger_model,
|
||||||
|
viaResolve.model,
|
||||||
|
'debug.md omits the model param when this is empty or "inherit" (#2517) — the value ' +
|
||||||
|
'must be the same one resolve-model produced, not a re-derived default'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tdd_mode matches config-get when set (row A6)', () => {
|
||||||
|
writeConfig(tmpDir, { workflow: { tdd_mode: true } });
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaConfigGet = runGsdTools(['query', 'config-get', 'workflow.tdd_mode', '--raw'], tmpDir);
|
||||||
|
|
||||||
|
assert.ok(viaConfigGet.success);
|
||||||
|
assert.equal(viaInit.tdd_mode, true);
|
||||||
|
assert.equal(String(viaInit.tdd_mode), viaConfigGet.output.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tdd_mode matches config-get when the key is absent (row A7)', () => {
|
||||||
|
writeConfig(tmpDir, {});
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir);
|
||||||
|
const viaConfigGet = runGsdTools(['query', 'config-get', 'workflow.tdd_mode', '--raw'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(viaInit.tdd_mode, false);
|
||||||
|
assert.equal(String(viaInit.tdd_mode), viaConfigGet.output.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tdd_mode matches config-get under workstream inheritance (row A8)', () => {
|
||||||
|
// The one case where the two resolution paths could genuinely disagree:
|
||||||
|
// `config-get` inherits from the ROOT config when an active workstream has
|
||||||
|
// no config.json of its own (#2702, src/config.cts), while the init seam
|
||||||
|
// reads loadConfig's root+workstream merge (src/config-loader.cts). Both
|
||||||
|
// must land on the same boolean or the consolidation changes behavior for
|
||||||
|
// workstream users.
|
||||||
|
writeConfig(tmpDir, { workflow: { tdd_mode: true } });
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'ws1'), { recursive: true });
|
||||||
|
|
||||||
|
const viaInit = runJson(['init', 'debug'], tmpDir, { GSD_WORKSTREAM: 'ws1' });
|
||||||
|
const viaConfigGet = runGsdTools(
|
||||||
|
['query', 'config-get', 'workflow.tdd_mode', '--raw'],
|
||||||
|
tmpDir,
|
||||||
|
{ GSD_WORKSTREAM: 'ws1' }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(viaInit.tdd_mode, true, 'the root value must be inherited, not lost');
|
||||||
|
assert.equal(String(viaInit.tdd_mode), viaConfigGet.output.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors workflow.tdd_mode, ignores a bare top-level tdd_mode (row A9)', () => {
|
||||||
|
// The invariant tests/debug-session-management.test.cjs used to guard by
|
||||||
|
// grepping debug.md for `config-get workflow.tdd_mode`. Asserted here
|
||||||
|
// behaviorally instead, which is strictly stronger: a bare top-level key
|
||||||
|
// must NOT be honored, whatever the read mechanism.
|
||||||
|
writeConfig(tmpDir, { tdd_mode: true });
|
||||||
|
assert.equal(
|
||||||
|
runJson(['init', 'debug'], tmpDir).tdd_mode,
|
||||||
|
false,
|
||||||
|
'a bare top-level tdd_mode key must be ignored — the canonical key is workflow.tdd_mode'
|
||||||
|
);
|
||||||
|
|
||||||
|
writeConfig(tmpDir, { workflow: { tdd_mode: true } });
|
||||||
|
assert.equal(
|
||||||
|
runJson(['init', 'debug'], tmpDir).tdd_mode,
|
||||||
|
true,
|
||||||
|
'the canonical workflow.tdd_mode key must be honored'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group B: bundle shape ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('init.debug bundle shape (matrix §B)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('init-debug-b-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits the documented field set (row B1)', () => {
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
|
||||||
|
for (const key of ['project_root', 'debug_dir', 'commit_docs', 'debugger_model', 'tdd_mode', 'diagnose']) {
|
||||||
|
assert.ok(
|
||||||
|
Object.prototype.hasOwnProperty.call(output, key),
|
||||||
|
`init.debug must emit "${key}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
Object.prototype.hasOwnProperty.call(output, 'section_manifest'),
|
||||||
|
'section_manifest must be present even when it degrades to null'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits response_language entirely when unset (row B2)', () => {
|
||||||
|
writeConfig(tmpDir, {});
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
Object.prototype.hasOwnProperty.call(output, 'response_language'),
|
||||||
|
false,
|
||||||
|
'withProjectRoot injects response_language ONLY when configured — an absent key means ' +
|
||||||
|
'"English", and emitting null/"" instead would make absence look like a degraded read'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debug_dir is an absolute POSIX path (row B3)', () => {
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
|
||||||
|
assert.equal(output.debug_dir.includes('\\'), false, 'no backslash separators (#2376)');
|
||||||
|
assert.ok(output.debug_dir.endsWith('/debug'), 'points at the debug directory');
|
||||||
|
assert.notEqual(output.debug_dir, 'debug');
|
||||||
|
assert.notEqual(output.debug_dir, '.planning/debug', 'must be absolute, never a bare relative literal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('succeeds with no .planning directory (row B4)', () => {
|
||||||
|
const bare = createTempDir('init-debug-bare-');
|
||||||
|
try {
|
||||||
|
const result = runGsdTools(['init', 'debug'], bare);
|
||||||
|
assert.ok(result.success, `must not require an initialized project: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.ok(output.debug_dir.endsWith('/debug'));
|
||||||
|
} finally {
|
||||||
|
cleanup(bare);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('succeeds on an empty config object (row B5)', () => {
|
||||||
|
writeConfig(tmpDir, {});
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
assert.equal(output.tdd_mode, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives valid-JSON-not-an-object config (row B6)', () => {
|
||||||
|
// Valid JSON that is not an object is the input class nobody enumerates:
|
||||||
|
// every one of these parses cleanly and then fails on property access.
|
||||||
|
for (const body of ['0', '"str"', '[]', 'null', 'true']) {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), body);
|
||||||
|
|
||||||
|
const result = runGsdTools(['init', 'debug'], tmpDir);
|
||||||
|
assert.ok(result.success, `config.json = ${body} must degrade, not crash: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.equal(output.tdd_mode, false, `config.json = ${body} must resolve tdd_mode to false`);
|
||||||
|
assert.ok(output.debug_dir.endsWith('/debug'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a present-but-empty config file (row B7)', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), '');
|
||||||
|
|
||||||
|
const result = runGsdTools(['init', 'debug'], tmpDir);
|
||||||
|
assert.ok(result.success, `an empty config file must degrade, not crash: ${result.error}`);
|
||||||
|
assert.equal(JSON.parse(result.output).tdd_mode, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is insensitive to CRLF in config.json (row B8)', () => {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'config.json'),
|
||||||
|
'{\r\n "workflow": {\r\n "tdd_mode": true\r\n }\r\n}\r\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
assert.equal(output.tdd_mode, true, 'CRLF must not change how the config parses');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group C: --diagnose forwarding + CLI negative matrix ──────────────────
|
||||||
|
|
||||||
|
describe('init.debug --diagnose forwarding and hostile argv (matrix §C)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('init-debug-c-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--diagnose surfaces as diagnose:true (row C1)', () => {
|
||||||
|
const output = runJson(['init', 'debug', '--diagnose'], tmpDir);
|
||||||
|
assert.equal(output.diagnose, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absent --diagnose is false, not undefined (row C2)', () => {
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
assert.equal(output.diagnose, false);
|
||||||
|
assert.notEqual(output.diagnose, undefined, 'parseNamedArgs materializes false; never leak undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate --diagnose is idempotent (row C3)', () => {
|
||||||
|
const output = runJson(['init', 'debug', '--diagnose', '--diagnose'], tmpDir);
|
||||||
|
assert.equal(output.diagnose, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores an unrecognized flag (row C4)', () => {
|
||||||
|
const result = runGsdTools(['init', 'debug', '--nope'], tmpDir);
|
||||||
|
assert.ok(result.success, `an unknown flag must not fail the command: ${result.error}`);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.equal(output.diagnose, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a flag-shaped trailing token (row C5)', () => {
|
||||||
|
const result = runGsdTools(['init', 'debug', '--diagnose', '--weird'], tmpDir);
|
||||||
|
assert.ok(result.success, `must not crash on a flag-shaped token: ${result.error}`);
|
||||||
|
assert.equal(JSON.parse(result.output).diagnose, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not interpolate shell metacharacters (row C6)', () => {
|
||||||
|
const canary = path.join(tmpDir, 'PWNED');
|
||||||
|
const hostile = `; touch ${canary}; $(touch ${canary}) \`touch ${canary}\` && touch ${canary}`;
|
||||||
|
|
||||||
|
const result = runGsdTools(['init', 'debug', hostile], tmpDir);
|
||||||
|
|
||||||
|
assert.ok(result.success, `hostile argv must not fail the command: ${result.error}`);
|
||||||
|
assert.equal(fs.existsSync(canary), false, 'no shell interpolation of an attacker-controlled argument');
|
||||||
|
assert.equal(result.output.includes(' at '), false, 'no stack trace in non-debug output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives a very long argument (row C7/C8)', () => {
|
||||||
|
const long = 'x'.repeat(8192);
|
||||||
|
const unicode = 'ünïcødé-🐛-测试';
|
||||||
|
|
||||||
|
for (const arg of [long, unicode]) {
|
||||||
|
const result = runGsdTools(['init', 'debug', arg], tmpDir);
|
||||||
|
assert.ok(result.success, `argument of length ${arg.length} must not crash: ${result.error}`);
|
||||||
|
assert.equal(JSON.parse(result.output).diagnose, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group D: section_manifest, null vs [] ─────────────────────────────────
|
||||||
|
|
||||||
|
describe('init.debug section_manifest degradation (matrix §D)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let manifestDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject('init-debug-d-');
|
||||||
|
manifestDir = createTempDir('init-debug-d-manifest-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
cleanup(manifestDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
function withManifest(body) {
|
||||||
|
const manifestPath = path.join(manifestDir, 'manifest.json');
|
||||||
|
fs.writeFileSync(manifestPath, typeof body === 'string' ? body : JSON.stringify(body));
|
||||||
|
return { GSD_SECTION_MANIFEST: manifestPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('section_manifest is null while debug has no manifest key (row D1)', () => {
|
||||||
|
// Drives the SHIPPED artifact deliberately: `debug` carries no gsd:section
|
||||||
|
// markers until #3128, so the shipped manifest has no `debug` key and the
|
||||||
|
// field must degrade to null — which debug.md reads as "read everything".
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir);
|
||||||
|
assert.equal(output.section_manifest, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit empty debug key computes [], not null (row D2)', () => {
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir, withManifest({ workflows: { debug: [] } }));
|
||||||
|
|
||||||
|
assert.notEqual(output.section_manifest, null, 'a present key must never collapse to the degraded value');
|
||||||
|
assert.deepEqual(output.section_manifest.included, []);
|
||||||
|
assert.deepEqual(output.section_manifest.excluded, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects an always-section for the debug workflow (row D3)', () => {
|
||||||
|
// Proves the workflow key really is 'debug' — a handler passing the wrong
|
||||||
|
// name would silently return null forever and look identical to D1.
|
||||||
|
const output = runJson(['init', 'debug'], tmpDir, withManifest({
|
||||||
|
workflows: {
|
||||||
|
debug: [{ id: 'probe-protocol', when: 'always', read: 'gsd-core/workflows/debug/steps/probe-protocol.md' }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.notEqual(output.section_manifest, null);
|
||||||
|
assert.equal(output.section_manifest.workflow, 'debug');
|
||||||
|
assert.deepEqual(output.section_manifest.included, ['probe-protocol']);
|
||||||
|
assert.deepEqual(output.section_manifest.read, ['gsd-core/workflows/debug/steps/probe-protocol.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing manifest file degrades to null (row D4)', () => {
|
||||||
|
const missing = path.join(manifestDir, 'does-not-exist.json');
|
||||||
|
assert.equal(fs.existsSync(missing), false, 'sanity: file must not exist');
|
||||||
|
|
||||||
|
const result = runGsdTools(['init', 'debug'], tmpDir, { GSD_SECTION_MANIFEST: missing });
|
||||||
|
assert.ok(result.success, `a missing manifest must not crash: ${result.error}`);
|
||||||
|
assert.equal(JSON.parse(result.output).section_manifest, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed manifest degrades to null (row D5)', () => {
|
||||||
|
const result = runGsdTools(['init', 'debug'], tmpDir, withManifest('{ not json'));
|
||||||
|
assert.ok(result.success, `a malformed manifest must not crash: ${result.error}`);
|
||||||
|
assert.equal(JSON.parse(result.output).section_manifest, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pre-6.1 flat manifest shape degrades to null (row D6)', () => {
|
||||||
|
// The pre-#2992 shape had no workflow key at all. Accepting it would
|
||||||
|
// mis-attribute some other workflow's sections to debug.
|
||||||
|
const result = runGsdTools(['init', 'debug'], tmpDir, withManifest({ sections: [{ id: 'x', when: 'always' }] }));
|
||||||
|
assert.ok(result.success);
|
||||||
|
assert.equal(JSON.parse(result.output).section_manifest, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group E: PlanningPaths.debug ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('planningPaths exposes the debug directory (matrix §E)', () => {
|
||||||
|
const { planningPaths } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir('init-debug-e-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planningPaths exposes debug (row E1)', () => {
|
||||||
|
assert.equal(planningPaths(tmpDir).debug, path.join(tmpDir, '.planning', 'debug'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planningPaths.debug is workstream-scoped (row E2)', () => {
|
||||||
|
assert.equal(
|
||||||
|
planningPaths(tmpDir, 'feature-x').debug,
|
||||||
|
path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'debug')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planningPaths.debug does not weaken the traversal guard (row E4)', () => {
|
||||||
|
assert.throws(() => planningPaths(tmpDir, '../../etc'), /invalid path characters/);
|
||||||
|
assert.throws(() => planningPaths(tmpDir, 'foo/bar'), /invalid path characters/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group G: regressions this change must not cause ───────────────────────
|
||||||
|
|
||||||
|
describe('init.debug does not widen the applicability grammar (matrix §G)', () => {
|
||||||
|
test('WHEN_VOCABULARY is unchanged at 29 entries (row G3)', () => {
|
||||||
|
// ADR-1671: the vocabulary is CLOSED and widening it is a coordinated
|
||||||
|
// amendment. This PR delivers admission gate (2) only — the atom that
|
||||||
|
// consumes it belongs to #3128, which owns the amendment.
|
||||||
|
const { WHEN_VOCABULARY } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
|
||||||
|
assert.equal(WHEN_VOCABULARY.length, 29);
|
||||||
|
assert.equal(WHEN_VOCABULARY.includes('flag:--diagnose'), false);
|
||||||
|
assert.equal(WHEN_VOCABULARY.includes('flag:--runtime-probes'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user