diff --git a/.changeset/sharp-tunas-leap.md b/.changeset/sharp-tunas-leap.md new file mode 100644 index 000000000..465fd1047 --- /dev/null +++ b/.changeset/sharp-tunas-leap.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3019 +--- +**Flag-gated workflow guidance is now actually loaded on demand** — `/gsd-plan-phase` reads its PRD-express, ADR-ingest, reviews-prerequisite, research-only and chunked-planning guidance only when the matching flag or config is active, instead of always inlining all six branches. This also repairs `/gsd-execute-phase --wave`, whose section gating never took effect because the workflow never forwarded the flag to the init bundle, so wave-filtering guidance was silently skipped on every run. (#2993) diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index d338f9178..6df508aab 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -1,7 +1,7 @@ --- name: gsd:plan-phase description: Create detailed phase plan (PLAN.md) with verification loop -argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp] [--no-tracer] [--no-reversibility-gates]" +argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--skip-ui] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--bounce] [--skip-bounce] [--chunked] [--tdd] [--mvp] [--granularity ] [--no-tracer] [--no-reversibility-gates]" effort: max allowed-tools: - Read diff --git a/docs/adr/1671-dynamic-context-management-platform.md b/docs/adr/1671-dynamic-context-management-platform.md index 3030c7988..733c99fa9 100644 --- a/docs/adr/1671-dynamic-context-management-platform.md +++ b/docs/adr/1671-dynamic-context-management-platform.md @@ -115,6 +115,33 @@ Pure Agent Skills (A alone) and pure MCP (D alone) were rejected as the foundati Playwright-MCP session), live git repository state, Capability-Registry/hook-resolved conditions, interactive answers given mid-run, and UAT/verification runtime results. None is knowable from parsed CLI arguments or `.planning/` state at init time. + + **Amended by #2993 (Phase 6.2) — the vocabulary widens 14 → 19, second + coordinated amendment.** Rolling the fragment model onto `plan-phase.md` — + the largest workflow in the repo — surfaced 5 more atoms, gated by the same + two admission tests #2992 established: a named consuming section of at + least 400 bytes, and a fact the init seam demonstrably computes. **Shipped + (5).** `flag:--ingest`, `flag:--prd`, `flag:--research-phase`, + `flag:--reviews` (each a direct `parseNamedArgs` addition to the + `plan-phase` router handler; the generic flags-Set builder in `init.cts` + picks them up automatically), and `state:chunked-mode`. + + `state:chunked-mode` is the one atom in this batch that is not a bare flag + check: `plan-phase.md`'s `CHUNKED_MODE` is true when EITHER `--chunked` is + passed OR `.planning/config.json`'s `workflow.plan_chunked` is set — a + disjunction of a flag and a config read. That disjunction is resolved to a + single boolean **in the fact**, computed once by the init seam + (`buildSectionManifestField` in `src/init.cts`) before `selectSections` is + ever called; `WHEN_PREDICATES['state:chunked-mode']` reads only + `facts.chunkedMode` and contains no `||`. The `when=` grammar therefore + still sees exactly one atom with no operator — the same invariant #2992 + restated is unchanged by this amendment. This generalizes to a rule for + every future atom: **any condition that cannot be reduced to a single + boolean fact is not an atom** — it is either resolved upstream in fact + computation (as here) or it is not eligible for the grammar at all, per + the "Rejected" cases (`--auto`/`--chain`/persisted-config interleaving; + negated `--skip-bounce` OR `--gaps` OR NOT(...)) recorded in + `.gsd/phase/chore-2993-fragmentize-plan-phase/40-design.md`. - **Budget unit:** bytes for emission caps (matches `lfByteCount`, deterministic, offline-safe); a token estimate for run-time selection. **Corrected by #2931 (Phase 4) — the Windsurf cap was never load-bearing.** The Context diff --git a/docs/reference/workflow-fragments.md b/docs/reference/workflow-fragments.md index bd0b6e539..a5de3e222 100644 --- a/docs/reference/workflow-fragments.md +++ b/docs/reference/workflow-fragments.md @@ -46,8 +46,9 @@ gap fragment and composes back byte-identical to its source. ## The frozen `when=` vocabulary -`when=` takes exactly one of 14 atoms (widened from 4 via the ADR-1671 -amendment for #2992, epic #1671 Phase 6.1): +`when=` takes exactly one of 19 atoms (widened from 4 to 14 via the ADR-1671 +amendment for #2992, epic #1671 Phase 6.1, then from 14 to 19 via the +ADR-1671 amendment for #2993, epic #1671 Phase 6.2): | Value | Meaning | |---|---| @@ -59,9 +60,14 @@ amendment for #2992, epic #1671 Phase 6.1): | `flag:--discuss` | Applicable when the workflow runs with `--discuss`. | | `flag:--forensic` | Applicable when the workflow runs with `--forensic`. | | `flag:--full` | Applicable when the workflow runs with `--full`. | +| `flag:--ingest` | Applicable when the workflow runs with `--ingest `. | +| `flag:--prd` | Applicable when the workflow runs with `--prd `. | | `flag:--research` | Applicable when the workflow runs with `--research`. | +| `flag:--research-phase` | Applicable when the workflow runs with `--research-phase `. A distinct atom from `flag:--research` above — neither aliases the other. | | `flag:--reset-phase-numbers` | Applicable when the workflow runs with `--reset-phase-numbers`. | +| `flag:--reviews` | Applicable when the workflow runs with `--reviews`. | | `flag:--validate` | Applicable when the workflow runs with `--validate`. | +| `state:chunked-mode` | Applicable when chunked planning mode is active — see [Compound conditions are resolved in the fact, never the grammar](#compound-conditions-are-resolved-in-the-fact-never-the-grammar) below. | | `state:needs-codebase-map` | Applicable when a codebase map is needed (init-computed). | | `state:phase-mvp-mode` | Applicable when the current phase's `ROADMAP.md` entry declares `**Mode:** mvp`. | | `state:worktrees-enabled` | Applicable when `.planning/config.json`'s `workflow.use_worktrees` is enabled. | @@ -99,6 +105,31 @@ points invoked by 20+ other workflows, so a dedicated `cmdInit*` seam does not yet exist to compute their facts. They are withheld pending that seam, not rejected. +### Compound conditions are resolved in the fact, never the grammar + +`state:chunked-mode` looks, at the section-body level, like it should be a +compound condition: plan-phase's chunked planning mode activates on +`--chunked` **OR** `.planning/config.json`'s `workflow.plan_chunked` being +`true`. The vocabulary stays operator-free anyway, because the disjunction is +resolved **before** it ever reaches `when=` — the init seam +(`buildSectionManifestField` in `src/init.cts`) computes ONE boolean, +`InvocationFacts.chunkedMode = flags.has('--chunked') || +readConfigJsonBoolean(cwd, ['workflow', 'plan_chunked'])`, and +`WHEN_PREDICATES['state:chunked-mode']` reads only that single field. The +marker grammar never sees `--chunked`, never sees the config key, and never +sees an `OR` — it sees exactly one atom with no operator, same as every other +entry in the frozen list. + +This is the general rule for any future atom whose real-world trigger is +itself a compound expression: **compounding belongs in fact computation +(`src/init.cts`), never in the `when=` grammar (`src/workflow-fragments.cts` / +`src/section-manifest.cts`).** A condition that cannot be reduced to one +boolean fact computed ahead of evaluation is not eligible to become an atom — +widening the grammar itself to express `OR`/`AND`/negation is exactly the +Greenspun's Tenth Rule drift [The frozen `when=` +vocabulary](#the-frozen-when-vocabulary) above exists to prevent, regardless +of how reasonable a single compound condition looks in isolation. + ## Fails closed An authoring mistake throws at parse time, naming the source file and 1-based @@ -192,7 +223,7 @@ At init time, a separate pure evaluator, `src/section-manifest.cts` (`selectSections`), partitions a workflow's manifest sections into `included`/`excluded` id lists against one invocation's `InvocationFacts` — `{flags, phaseNumber, hasPriorPhases, needsCodebaseMap?, -phaseMvpMode?, worktreesEnabled?}`. Only a workflow with a **dedicated +phaseMvpMode?, worktreesEnabled?, chunkedMode?}`. Only a workflow with a **dedicated `cmdInit*` entry point** in `src/init.cts` can have this evaluation run for it, because only that entry point can assemble `InvocationFacts` from its own parsed CLI options and `.planning/` state reads — this is admission gate 2 @@ -214,42 +245,51 @@ own `false` into `undefined` (`namedArgs['wave'] || undefined`) before handing options to the facts builder, so `flags` only ever contains tokens that were actually seen. -## Piloted on one workflow so far +## Piloted on execute-phase.md, then rolled out to plan-phase.md -Only `gsd-core/workflows/execute-phase.md` carries markers today. The marker -grammar and composer seam are general-purpose across any workflow file, but -rollout to other LARGE/XL workflows is intentionally sequenced as later work, -not part of this phase. +Two workflows carry markers today: `gsd-core/workflows/execute-phase.md` (the +#2930/Phase-3 pilot) and `gsd-core/workflows/plan-phase.md` (#2993, epic +#1671 Phase 6.2). The marker grammar and composer seam are general-purpose +across any workflow file; rollout to further LARGE/XL workflows remains +sequenced as later work. -The pilot marks three `` blocks: `partial-wave` (`flag:--wave`), -`gap-closure-artifacts` (`state:gap-closure-phase`), and `regression-gate` -(`state:has-prior-phases`). +`execute-phase.md` marks three `` blocks: `partial-wave` +(`flag:--wave`), `gap-closure-artifacts` (`state:gap-closure-phase`), and +`regression-gate` (`state:has-prior-phases`). -**The pilot was retargeted from `plan-phase.md` mid-phase.** Issue #2930's -own motivating mutually-exclusive branches (`--prd`, `--ingest`, `--mvp`, -`--reviews`) all live in `plan-phase.md`, not `execute-phase.md`. But -`plan-phase.md` sits only 36 B under an independent, pre-existing size gate -(`tests/phase6-capstone-conformance.test.cjs`'s `PRE_PHASE6`, an ADR-857 -Phase-6 completion property) and cannot absorb any marker overhead at all — -so it could not be fragmentized under this phase's grammar regardless of -branch shape. This is direct evidence for the epic's premise that -fragmentization pays off, and it also means Phase 4 (moving size caps from -source bytes to emitted bytes) may need to land before `plan-phase.md` -itself can be fragmentized. Separately, and independent of the size-gate -finding, `--mvp` would remain unmarkable by this grammar even if the size -gate allowed it: its content in `plan-phase.md` is INTERLEAVED with other -flags rather than living in its own contiguous section (`MVP_MODE` +`plan-phase.md` marks six sections: `reviews-prerequisite` (`flag:--reviews`), +`prd-express-gate` (`flag:--prd`), `adr-ingest-express-path` (`flag:--ingest`), +`research-only-modifiers` and `research-only-early-exit` (both +`flag:--research-phase` — two consumers sharing one atom, gated by the same +`RESEARCH_ONLY` condition, so they include/exclude together), and +`chunked-planning-mode` (`state:chunked-mode`). + +**`plan-phase.md` was originally retargeted away from the #2930 pilot, +then fragmentized here once the blocker cleared.** Issue #2930's own +motivating mutually-exclusive branches (`--prd`, `--ingest`, `--mvp`, +`--reviews`) all live in `plan-phase.md`, not `execute-phase.md`, but at the +time `plan-phase.md` sat only 36 B under an independent, pre-existing size +gate (`tests/phase6-capstone-conformance.test.cjs`'s `PRE_PHASE6`, an +ADR-857 Phase-6 completion property) and could not absorb any marker +overhead at all. #2993 resolves this **because fragmentizing is net-negative +on host source, not net-positive**: each gated body moves from always-inline +prose to a `gsd-core/workflows/plan-phase/steps/.md` step file, leaving +only a ~200 B conditional-read stub behind — the six extractions trim +`plan-phase.md` from 94,483 B to 87,575 B, moving the file from 36 B of +`PRE_PHASE6` headroom to roughly 7,000 B, well clear of the cap. + +`--mvp` remains unmarkable by this grammar, unchanged by #2993 and by +deliberate ADR-1671 decision: its content in `plan-phase.md` is INTERLEAVED +with other flags rather than living in its own contiguous section (`MVP_MODE` resolution shares a single bash block with `--tdd`, `--no-tracer`, and -`--no-reversibility-gates` handling at `plan-phase.md:125-158`, and -elsewhere it is inline `${MVP_MODE === 'true' ? ... }` template -interpolation embedded inside the planner prompt at `plan-phase.md:794-803`) -— the marker grammar is closed, non-nesting, and whole-line (see -[Marker syntax](#marker-syntax) above), with no way to wrap part of a line -or split a shared conditional block without either corrupting the +`--no-reversibility-gates` handling, and elsewhere it is inline +`${MVP_MODE === 'true' ? ... }` template interpolation embedded inside the +planner prompt) — the marker grammar is closed, non-nesting, and whole-line +(see [Marker syntax](#marker-syntax) above), with no way to wrap part of a +line or split a shared conditional block without either corrupting the conditional or bundling unrelated flags into one section. See [ADR-1671](../adr/1671-dynamic-context-management-platform.md) open -question 1's resolution for the full record, and Phase 6 (LARGE/XL rollout) -for how both limits get addressed. +question 1's resolution for the full record. ## Related diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index efa74b31d..dec6d3c64 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -81,7 +81,8 @@ Load all context in one call: ```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 -INIT=$(gsd_run query init.execute-phase "${PHASE_ARG}") +WAVE_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--wave[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then WAVE_PARAM="--wave ${BASH_REMATCH[2]}"; fi +INIT=$(gsd_run query init.execute-phase "${PHASE_ARG}" $WAVE_PARAM) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS=$(gsd_run query agent-skills gsd-executor) ``` diff --git a/gsd-core/workflows/help/modes/full.md b/gsd-core/workflows/help/modes/full.md index 8759cc3b8..70ca6a85b 100644 --- a/gsd-core/workflows/help/modes/full.md +++ b/gsd-core/workflows/help/modes/full.md @@ -105,7 +105,7 @@ Usage: `/gsd:discuss-phase 2` Usage: `/gsd:discuss-phase 2 --batch` Usage: `/gsd:discuss-phase 2 --batch=3` -**`/gsd:plan-phase [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp] [--no-tracer] [--no-reversibility-gates]`** +**`/gsd:plan-phase [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--skip-ui] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--bounce] [--skip-bounce] [--chunked] [--tdd] [--mvp] [--granularity ] [--no-tracer] [--no-reversibility-gates]`** Create detailed execution plan for a specific phase. - `--skip-research` — bypass the research subagent @@ -113,10 +113,15 @@ Create detailed execution plan for a specific phase. - Modifiers: `--research` forces refresh (re-spawn researcher). `--view` prints existing `RESEARCH.md` to stdout without spawning. With neither, auto-uses an existing `RESEARCH.md` (one-line notice, then clean exit). - `--gaps` — focus only on closing gaps from a prior plan-check - `--skip-verify` — skip the post-plan verifier loop +- `--skip-ui` — skip the UI-SPEC gate for a detected frontend phase (not recommended for frontend phases) - `--ingest ` — pre-ingest external ADRs/PRDs/SPECs before planning (see *PRD Express Path* below) - `--ingest-format ` — hint the ADR ingester's parser when `--ingest` is set; defaults to `auto` +- `--bounce` — run the optional external plan-refinement pass (or set `workflow.plan_bounce: true` to activate by default); requires `workflow.plan_bounce_script` +- `--skip-bounce` — disable the plan-refinement pass even when `workflow.plan_bounce` config enables it +- `--chunked` — split the planner run into a short outline pass plus one short per-plan pass each (~3–5 min), committing each plan individually for crash resilience; re-running `--chunked` resumes from the last committed plan (or set `workflow.plan_chunked: true` to activate by default) - `--tdd` — plan in test-driven order (tests before code) - `--mvp` — MVP enrichment (user story + Walking Skeleton) on top of the default tracer-first ordering (see also `/gsd:mvp-phase`) +- `--granularity ` — override the resolved plan granularity for this run (wins over per-phase/top-level config and project defaults) - `--no-tracer` — opt out of the default tracer-first slice and plan horizontal layers (legacy default) - `--no-reversibility-gates` — suppress the `checkpoint:decision` a `one-way`-door decision normally earns, for intentionally-unattended runs (ratings are still recorded) diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 4b2e2cedc..f6ae9b75e 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -68,7 +68,12 @@ Load all context in one call (paths only to minimize orchestrator context): ```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 GRAN_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--granularity[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then GRAN_PARAM="--granularity ${BASH_REMATCH[2]}"; fi -INIT=$(gsd_run query init.plan-phase "$PHASE" $GRAN_PARAM) +PRD_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--prd[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then PRD_PARAM="--prd ${BASH_REMATCH[2]}"; fi +INGEST_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--ingest[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then INGEST_PARAM="--ingest ${BASH_REMATCH[2]}"; fi +RESEARCH_PHASE_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--research-phase[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then RESEARCH_PHASE_PARAM="--research-phase ${BASH_REMATCH[2]}"; fi +REVIEWS_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--reviews([[:space:]]|$) ]]; then REVIEWS_PARAM="--reviews"; fi +CHUNKED_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--chunked([[:space:]]|$) ]]; then CHUNKED_PARAM="--chunked"; fi +INIT=$(gsd_run query init.plan-phase "$PHASE" $GRAN_PARAM $PRD_PARAM $INGEST_PARAM $RESEARCH_PHASE_PARAM $REVIEWS_PARAM $CHUNKED_PARAM) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher) AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner) @@ -182,23 +187,9 @@ if [[ "$ARGUMENTS" =~ --chunked ]] || [[ "$CHUNKED_CFG" == "true" ]]; then fi ``` -## 2.5. Validate `--reviews` Prerequisite - -**Skip if:** No `--reviews` flag. - -**If `--reviews` AND `--gaps`:** Error — cannot combine `--reviews` with `--gaps`. These are conflicting modes. - -**If `--reviews` AND `has_reviews` is false (no REVIEWS.md in phase dir):** - -Error: -``` -No REVIEWS.md found for Phase {N}. Run reviews first: - -/gsd:review --phase {N} - -Then re-run /gsd:plan-phase {N} --reviews -``` -Exit workflow. + +If `section_manifest` is `null` or `"reviews-prerequisite"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md`. Otherwise skip — do not read the file. + ## 3. Validate Phase @@ -213,28 +204,13 @@ Now that `PHASE` is finalized, resolve MVP mode: MVP_MODE=$(gsd_run query phase.mvp-mode "${PHASE}" $MVP_FLAG_ARG --pick active) ``` -## 3.5. Handle PRD Express Path + +If `section_manifest` is `null` or `"prd-express-gate"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/prd-express-gate.md`. Otherwise skip — do not read the file. + -**Skip if:** No `--prd` flag in arguments. - -**If `--prd ` provided:** - -Read and execute `gsd-core/workflows/plan-phase/steps/prd-express-path.md` — it reads the PRD (`$PRD_FILE`), generates `CONTEXT.md` (every PRD requirement/story/criterion → locked decision, uncovered areas → "Claude's Discretion", canonical refs extracted from ROADMAP.md + PRD-referenced specs), commits it, sets `context_content`, and bypasses step 4 (Load CONTEXT.md). The rest of the workflow proceeds normally with the PRD-derived context. - -## 3.6. Handle ADR Ingest Express Path - -**Skip if:** No `--ingest` flag in arguments. - -**If `--ingest ` provided:** - -1. Display banner: `GSD ► ADR Ingest Express Path` with `{INGEST_PATH}` and `{INGEST_FORMAT}`. -2. Parse each resolved ADR through `gsd-core/bin/lib/adr-parser.cjs` (`--input`, `--format`) and collect normalized records. -3. Status gate: reject `superseded`/`rejected`/`deprecated`; warn on `proposed`; missing status defaults to `accepted`. -4. Empty-decisions fallback: if all parsed ADRs have zero `decisions[]`, emit `ADR ingest produced no locked decisions; fall back to discuss-phase for this phase.` and exit with `/gsd:discuss-phase {N}` guidance. -5. Generate CONTEXT.md using ``, ``, ``, ``, ``, ``, map `consequences_positive[]` to Success Criteria and `consequences_negative[]` to Risk Summary, and include `**Source:** ADR Ingest Express Path ({INGEST_PATH})`. -6. Commit with `gsd-tools.cjs query commit "docs(${padded_phase}): generate context from ADR ingest" --files "${phase_dir}/${padded_phase}-CONTEXT.md"` and set `context_content`; continue to step 5. - -**Effect:** This bypasses step 4 (Load CONTEXT.md) since CONTEXT.md was synthesized from ADR input. + +If `section_manifest` is `null` or `"adr-ingest-express-path"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md`. Otherwise skip — do not read the file. + ## 4. Load CONTEXT.md @@ -305,22 +281,9 @@ If `AI_SPEC_FILE` is non-empty, pass `AI_SPEC_PATH` and `FRAMEWORK_LINE` to the **Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag. -### 5.0. Research-Only Modifiers (`--view`, `--research`) - -**Skip if:** `RESEARCH_ONLY` is `false`. - -Three branches in research-only mode (`--research-phase `): - -1. **`--view`**: print `RESEARCH.md` to stdout, no spawn, exit. If `RESEARCH.md` is missing, error with: `--view requires an existing RESEARCH.md; drop --view to spawn the researcher.` -2. **`--research`** (force-refresh): re-spawn researcher unconditionally — fall through to "Spawn gsd-phase-researcher" below. -3. **Neither flag AND `has_research=true`:** auto-use the existing research and exit cleanly — do not prompt, do not re-spawn. Emit `RESEARCH.md already exists for Phase ${PHASE}, using it. To force-refresh, re-invoke with --research; to print, re-invoke with --view. Path: ${research_path}` then exit. The explicit-flag escape hatches cover any deviation; this matches §5.1's promptless auto-use of existing research, removing the §5.0/§5.1 inconsistency (#159). - -```bash -if [[ "$VIEW_ONLY" == "true" ]]; then - [[ -f "$research_path" ]] || { echo "Error: --view requires an existing RESEARCH.md (Phase ${PHASE}). Drop --view to spawn the researcher."; exit 1; } - cat "$research_path"; exit 0 -fi -``` + +If `section_manifest` is `null` or `"research-only-modifiers"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/research-only-modifiers.md`. Otherwise skip — do not read the file. + ### 5.1. Standard Research Decision @@ -408,23 +371,9 @@ Agent( - **`## RESEARCH COMPLETE`:** Display confirmation, continue to step 6 - **`## RESEARCH BLOCKED`:** Display blocker, offer: 1) Provide context, 2) Skip research, 3) Abort -### Research-Only Early Exit (`--research-phase`) - -**Skip if:** `RESEARCH_ONLY` is `false` (the default). - -**If `RESEARCH_ONLY=true`:** the user invoked `/gsd:plan-phase --research-phase ` for research-only mode. Do **not** continue to Section 5.5+ (validation strategy, planner, plan-checker, verification, gaps, bounce, post-planning-gaps). Print the research-complete summary and exit cleanly: - -```text -✓ Research-only mode complete (#3042) - - Phase: ${PHASE} - RESEARCH.md: ${research_path} - -Re-run /gsd:plan-phase ${PHASE} to plan the phase using this research, -or /gsd:plan-phase ${PHASE} --research to refresh research and plan. -``` - -This exits the workflow. The planner / plan-checker / verifier blocks below are skipped. + +If `section_manifest` is `null` or `"research-only-early-exit"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/research-only-early-exit.md`. Otherwise skip — do not read the file. + ## 5.5. Create Validation Strategy @@ -889,112 +838,9 @@ Agent( **If `CHUNKED_MODE` is `true`:** Skip the Agent() call above — proceed to step 8.5 instead. -## 8.5. Chunked Planning Mode - -**Skip if `CHUNKED_MODE` is `false`.** - -Chunked mode splits the single planner run into a short outline run + N short per-plan -runs (~3–5 min each), committing each plan individually for crash resilience. Rerunning -`/gsd:plan-phase {N} --chunked` resumes from the last committed plan. - -For recovering plans from a prior *non-chunked* run, use step 6's "Add more plans" or -proceed to `/gsd:execute-phase` — don't start a fresh chunked run over them. - -### 8.5.1 Outline Phase (outline-only mode, ~2 min) - -**Resume detection:** If `${PHASE_DIR}/${PADDED_PHASE}-PLAN-OUTLINE.md` exists and contains -the `## OUTLINE COMPLETE` marker (written by the outline agent — #2762), skip to 8.5.2. - -```bash -OUTLINE_FILE="${PHASE_DIR}/${PADDED_PHASE}-PLAN-OUTLINE.md" -if [[ -f "$OUTLINE_FILE" ]] && grep -q "^## OUTLINE COMPLETE" "$OUTLINE_FILE"; then - # reuse existing outline — skip to 8.5.2 -fi -``` - -Display: -```text -◆ Chunked mode: spawning outline planner... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) -``` - -Spawn the planner in **outline-only** mode — it must write only the outline manifest, not any -PLAN.md files: - -```javascript -Agent( - prompt="{same planning_context as step 8, plus:} - - **Chunked mode: outline-only.** - Do NOT write any PLAN.md files in this Task. - Write only: {PHASE_DIR}/{PADDED_PHASE}-PLAN-OUTLINE.md - - The outline must be a markdown table with columns: - Plan ID | Objective | Wave | Depends On | Requirements - - End the file with a final line `## OUTLINE COMPLETE` — §8.5.1's resume-check greps - the file for it, so it MUST be written here, not just returned. - Return: ## OUTLINE COMPLETE with plan count.", - subagent_type="gsd-planner", - model="{planner_model}", - description="Outline Phase {phase} (chunked)" -) -``` - -> **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available. - -Handle return: -- **`## OUTLINE COMPLETE`:** Read `PLAN-OUTLINE.md`, extract plan list. Continue to 8.5.2. -- **Any other return or empty:** Display error. Offer: 1) Retry outline, 2) Stop. - -### 8.5.2 Per-Plan Tasks (single-plan mode, ~3-5 min each) - -For each plan entry extracted from `PLAN-OUTLINE.md`: - -1. **Resume check:** Skip if `${PHASE_DIR}/{plan_id}-PLAN.md` exists with valid frontmatter - (resume safety) — UNLESS `--reviews` is set, whose purpose is to REPLAN with review - feedback (§6), so existing plans are overwritten, not skipped (#2762). - - ```bash - PLAN_FILE="${PHASE_DIR}/${plan_id}-PLAN.md" - if [[ -f "$PLAN_FILE" ]] && head -1 "$PLAN_FILE" | grep -q '^---' && [[ "$ARGUMENTS" != *"--reviews"* ]]; then - continue # resume safety — NOT under --reviews (replan) - fi - ``` - -2. Display: - ```text - ◆ Chunked mode: planning {plan_id} ({k}/{N})... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) - ``` - -3. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file: - ```javascript - Agent( - prompt="{same planning_context as step 8, plus:} - - **Chunked mode: single-plan.** - Write exactly ONE plan file: {PHASE_DIR}/{plan_id}-PLAN.md - Plan to write: {plan_id} — {objective} - Wave: {wave} | Depends on: {depends_on} - Phase requirement IDs to cover in this plan: {plan_requirements} - - Return: ## PLAN COMPLETE with the plan ID.", - subagent_type="gsd-planner", - model="{planner_model}", - description="Plan {plan_id} (chunked {k}/{N})" - ) - ``` - - > **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available. - -4. **Verify disk:** Check `${PHASE_DIR}/{plan_id}-PLAN.md` exists. If missing: offer 1) Retry, 2) Stop. - -5. **Commit per-plan:** - ```bash - gsd_run query commit "docs(${PADDED_PHASE}): plan ${plan_id} (chunked)" --files "${PHASE_DIR}/${plan_id}-PLAN.md" - ``` - -After all N plans are written and committed, treat this as `## PLANNING COMPLETE` and continue -to step 9. + +If `section_manifest` is `null` or `"chunked-planning-mode"` is in its `included` list: read and execute `gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md`. Otherwise skip — do not read the file. + ## 9. Handle Planner Return diff --git a/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md b/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md new file mode 100644 index 000000000..1a46967c1 --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md @@ -0,0 +1,15 @@ +## 3.6. Handle ADR Ingest Express Path + +**Skip if:** No `--ingest` flag in arguments. + +**If `--ingest ` provided:** + +1. Display banner: `GSD ► ADR Ingest Express Path` with `{INGEST_PATH}` and `{INGEST_FORMAT}`. +2. Parse each resolved ADR through `gsd-core/bin/lib/adr-parser.cjs` (`--input`, `--format`) and collect normalized records. +3. Status gate: reject `superseded`/`rejected`/`deprecated`; warn on `proposed`; missing status defaults to `accepted`. +4. Empty-decisions fallback: if all parsed ADRs have zero `decisions[]`, emit `ADR ingest produced no locked decisions; fall back to discuss-phase for this phase.` and exit with `/gsd:discuss-phase {N}` guidance. +5. Generate CONTEXT.md using ``, ``, ``, ``, ``, ``, map `consequences_positive[]` to Success Criteria and `consequences_negative[]` to Risk Summary, and include `**Source:** ADR Ingest Express Path ({INGEST_PATH})`. +6. Commit with `gsd-tools.cjs query commit "docs(${padded_phase}): generate context from ADR ingest" --files "${phase_dir}/${padded_phase}-CONTEXT.md"` and set `context_content`; continue to step 5. + +**Effect:** This bypasses step 4 (Load CONTEXT.md) since CONTEXT.md was synthesized from ADR input. + diff --git a/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md b/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md new file mode 100644 index 000000000..c44177743 --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md @@ -0,0 +1,108 @@ +## 8.5. Chunked Planning Mode + +**Skip if `CHUNKED_MODE` is `false`.** + +Chunked mode splits the single planner run into a short outline run + N short per-plan +runs (~3–5 min each), committing each plan individually for crash resilience. Rerunning +`/gsd:plan-phase {N} --chunked` resumes from the last committed plan. + +For recovering plans from a prior *non-chunked* run, use step 6's "Add more plans" or +proceed to `/gsd:execute-phase` — don't start a fresh chunked run over them. + +### 8.5.1 Outline Phase (outline-only mode, ~2 min) + +**Resume detection:** If `${PHASE_DIR}/${PADDED_PHASE}-PLAN-OUTLINE.md` exists and contains +the `## OUTLINE COMPLETE` marker (written by the outline agent — #2762), skip to 8.5.2. + +```bash +OUTLINE_FILE="${PHASE_DIR}/${PADDED_PHASE}-PLAN-OUTLINE.md" +if [[ -f "$OUTLINE_FILE" ]] && grep -q "^## OUTLINE COMPLETE" "$OUTLINE_FILE"; then + # reuse existing outline — skip to 8.5.2 +fi +``` + +Display: +```text +◆ Chunked mode: spawning outline planner... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) +``` + +Spawn the planner in **outline-only** mode — it must write only the outline manifest, not any +PLAN.md files: + +```javascript +Agent( + prompt="{same planning_context as step 8, plus:} + + **Chunked mode: outline-only.** + Do NOT write any PLAN.md files in this Task. + Write only: {PHASE_DIR}/{PADDED_PHASE}-PLAN-OUTLINE.md + + The outline must be a markdown table with columns: + Plan ID | Objective | Wave | Depends On | Requirements + + End the file with a final line `## OUTLINE COMPLETE` — §8.5.1's resume-check greps + the file for it, so it MUST be written here, not just returned. + Return: ## OUTLINE COMPLETE with plan count.", + subagent_type="gsd-planner", + model="{planner_model}", + description="Outline Phase {phase} (chunked)" +) +``` + +> **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available. + +Handle return: +- **`## OUTLINE COMPLETE`:** Read `PLAN-OUTLINE.md`, extract plan list. Continue to 8.5.2. +- **Any other return or empty:** Display error. Offer: 1) Retry outline, 2) Stop. + +### 8.5.2 Per-Plan Tasks (single-plan mode, ~3-5 min each) + +For each plan entry extracted from `PLAN-OUTLINE.md`: + +1. **Resume check:** Skip if `${PHASE_DIR}/{plan_id}-PLAN.md` exists with valid frontmatter + (resume safety) — UNLESS `--reviews` is set, whose purpose is to REPLAN with review + feedback (§6), so existing plans are overwritten, not skipped (#2762). + + ```bash + PLAN_FILE="${PHASE_DIR}/${plan_id}-PLAN.md" + if [[ -f "$PLAN_FILE" ]] && head -1 "$PLAN_FILE" | grep -q '^---' && [[ "$ARGUMENTS" != *"--reviews"* ]]; then + continue # resume safety — NOT under --reviews (replan) + fi + ``` + +2. Display: + ```text + ◆ Chunked mode: planning {plan_id} ({k}/{N})... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze) + ``` + +3. Spawn the planner in **single-plan** mode — it must write exactly one PLAN.md file: + ```javascript + Agent( + prompt="{same planning_context as step 8, plus:} + + **Chunked mode: single-plan.** + Write exactly ONE plan file: {PHASE_DIR}/{plan_id}-PLAN.md + Plan to write: {plan_id} — {objective} + Wave: {wave} | Depends on: {depends_on} + Phase requirement IDs to cover in this plan: {plan_requirements} + + Return: ## PLAN COMPLETE with the plan ID.", + subagent_type="gsd-planner", + model="{planner_model}", + description="Plan {plan_id} (chunked {k}/{N})" + ) + ``` + + > **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available. + +4. **Verify disk:** Check `${PHASE_DIR}/{plan_id}-PLAN.md` exists. If missing: offer 1) Retry, 2) Stop. + +5. **Commit per-plan:** +```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_run query commit "docs(${PADDED_PHASE}): plan ${plan_id} (chunked)" --files "${PHASE_DIR}/${plan_id}-PLAN.md" +``` + +After all N plans are written and committed, treat this as `## PLANNING COMPLETE` and continue +to step 9. + diff --git a/gsd-core/workflows/plan-phase/steps/prd-express-gate.md b/gsd-core/workflows/plan-phase/steps/prd-express-gate.md new file mode 100644 index 000000000..2a6c37e63 --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/prd-express-gate.md @@ -0,0 +1,8 @@ +## 3.5. Handle PRD Express Path + +**Skip if:** No `--prd` flag in arguments. + +**If `--prd ` provided:** + +Read and execute `gsd-core/workflows/plan-phase/steps/prd-express-path.md` — it reads the PRD (`$PRD_FILE`), generates `CONTEXT.md` (every PRD requirement/story/criterion → locked decision, uncovered areas → "Claude's Discretion", canonical refs extracted from ROADMAP.md + PRD-referenced specs), commits it, sets `context_content`, and bypasses step 4 (Load CONTEXT.md). The rest of the workflow proceeds normally with the PRD-derived context. + diff --git a/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md b/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md new file mode 100644 index 000000000..4a48e99f8 --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md @@ -0,0 +1,17 @@ +### Research-Only Early Exit (`--research-phase`) + +**Skip if:** `RESEARCH_ONLY` is `false` (the default). + +**If `RESEARCH_ONLY=true`:** the user invoked `/gsd:plan-phase --research-phase ` for research-only mode. Do **not** continue to Section 5.5+ (validation strategy, planner, plan-checker, verification, gaps, bounce, post-planning-gaps). Print the research-complete summary and exit cleanly: + +```text +✓ Research-only mode complete (#3042) + + Phase: ${PHASE} + RESEARCH.md: ${research_path} + +Re-run /gsd:plan-phase ${PHASE} to plan the phase using this research, +or /gsd:plan-phase ${PHASE} --research to refresh research and plan. +``` + +This exits the workflow. The planner / plan-checker / verifier blocks below are skipped. diff --git a/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md b/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md new file mode 100644 index 000000000..036ae025f --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md @@ -0,0 +1,16 @@ +### 5.0. Research-Only Modifiers (`--view`, `--research`) + +**Skip if:** `RESEARCH_ONLY` is `false`. + +Three branches in research-only mode (`--research-phase `): + +1. **`--view`**: print `RESEARCH.md` to stdout, no spawn, exit. If `RESEARCH.md` is missing, error with: `--view requires an existing RESEARCH.md; drop --view to spawn the researcher.` +2. **`--research`** (force-refresh): re-spawn researcher unconditionally — fall through to "Spawn gsd-phase-researcher" below. +3. **Neither flag AND `has_research=true`:** auto-use the existing research and exit cleanly — do not prompt, do not re-spawn. Emit `RESEARCH.md already exists for Phase ${PHASE}, using it. To force-refresh, re-invoke with --research; to print, re-invoke with --view. Path: ${research_path}` then exit. The explicit-flag escape hatches cover any deviation; this matches §5.1's promptless auto-use of existing research, removing the §5.0/§5.1 inconsistency (#159). + +```bash +if [[ "$VIEW_ONLY" == "true" ]]; then + [[ -f "$research_path" ]] || { echo "Error: --view requires an existing RESEARCH.md (Phase ${PHASE}). Drop --view to spawn the researcher."; exit 1; } + cat "$research_path"; exit 0 +fi +``` diff --git a/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md b/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md new file mode 100644 index 000000000..e0b19523d --- /dev/null +++ b/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md @@ -0,0 +1,17 @@ +## 2.5. Validate `--reviews` Prerequisite + +**Skip if:** No `--reviews` flag. + +**If `--reviews` AND `--gaps`:** Error — cannot combine `--reviews` with `--gaps`. These are conflicting modes. + +**If `--reviews` AND `has_reviews` is false (no REVIEWS.md in phase dir):** + +Error: +``` +No REVIEWS.md found for Phase {N}. Run reviews first: + +/gsd:review --phase {N} + +Then re-run /gsd:plan-phase {N} --reviews +``` +Exit workflow. diff --git a/gsd-core/workflows/section-manifest.json b/gsd-core/workflows/section-manifest.json index 754677162..8a9654a53 100644 --- a/gsd-core/workflows/section-manifest.json +++ b/gsd-core/workflows/section-manifest.json @@ -16,6 +16,38 @@ "when": "state:has-prior-phases", "read": "gsd-core/workflows/execute-phase/steps/regression-gate.md" } + ], + "plan-phase": [ + { + "id": "reviews-prerequisite", + "when": "flag:--reviews", + "read": "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md" + }, + { + "id": "prd-express-gate", + "when": "flag:--prd", + "read": "gsd-core/workflows/plan-phase/steps/prd-express-gate.md" + }, + { + "id": "adr-ingest-express-path", + "when": "flag:--ingest", + "read": "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md" + }, + { + "id": "research-only-modifiers", + "when": "flag:--research-phase", + "read": "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md" + }, + { + "id": "research-only-early-exit", + "when": "flag:--research-phase", + "read": "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md" + }, + { + "id": "chunked-planning-mode", + "when": "state:chunked-mode", + "read": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md" + } ] } } diff --git a/skills/gsd-plan-phase/SKILL.md b/skills/gsd-plan-phase/SKILL.md index 33039a665..73bf6fa65 100644 --- a/skills/gsd-plan-phase/SKILL.md +++ b/skills/gsd-plan-phase/SKILL.md @@ -1,7 +1,7 @@ --- name: gsd-plan-phase description: "Create detailed phase plan (PLAN.md) with verification loop" -argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp] [--no-tracer] [--no-reversibility-gates]" +argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--skip-ui] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--bounce] [--skip-bounce] [--chunked] [--tdd] [--mvp] [--granularity ] [--no-tracer] [--no-reversibility-gates]" effort: max allowed-tools: - Read diff --git a/src/init-command-router.cts b/src/init-command-router.cts index 0f5ee1208..25b49f317 100644 --- a/src/init-command-router.cts +++ b/src/init-command-router.cts @@ -76,11 +76,20 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio }); }, 'plan-phase': () => { - const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']); + const namedArgs = parseNamedArgs( + args, + ['granularity', 'prd', 'ingest', 'research-phase'], + ['validate', 'tdd', 'reviews', 'chunked'], + ); init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'], + prd: namedArgs['prd'], + ingest: namedArgs['ingest'], + 'research-phase': namedArgs['research-phase'], + reviews: namedArgs['reviews'], + chunked: namedArgs['chunked'], }); }, 'new-project': () => { diff --git a/src/init.cts b/src/init.cts index 03ca3da4f..b16236c25 100644 --- a/src/init.cts +++ b/src/init.cts @@ -557,6 +557,14 @@ function buildSectionManifestField( flags.add(`--${key}`); } + // `state:chunked-mode` (#2993) is a disjunction — `--chunked` flag OR + // `.planning/config.json` `workflow.plan_chunked` — resolved to ONE + // boolean HERE, in fact computation, never in the `when=` grammar itself + // (WHEN_PREDICATES['state:chunked-mode'] reads only `facts.chunkedMode`). + // That separation is what keeps ADR-1671:69's Greenspun guard intact: the + // grammar still sees exactly one atom with no operator. + const chunkedMode = flags.has('--chunked') || readConfigJsonBoolean(cwd, ['workflow', 'plan_chunked']); + const facts: sectionManifest.InvocationFacts = { flags, phaseNumber, @@ -564,6 +572,7 @@ function buildSectionManifestField( worktreesEnabled: readConfigJsonBoolean(cwd, ['workflow', 'use_worktrees']), phaseMvpMode: detectPhaseMvpMode(cwd, phaseNumber), needsCodebaseMap: overrides.needsCodebaseMap, + chunkedMode, }; try { diff --git a/src/section-manifest.cts b/src/section-manifest.cts index 170616725..d065225d8 100644 --- a/src/section-manifest.cts +++ b/src/section-manifest.cts @@ -18,6 +18,8 @@ * binding on this implementation: `when=` is a closed vocabulary, widened * from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic #1671 * Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/ + * 40-design.md`), then from 14 to 19 via the ADR-1671 amendment for #2993 + * (epic #1671 Phase 6.2; see `.gsd/phase/chore-2993-fragmentize-plan-phase/ * 40-design.md`). {@link WHEN_PREDICATES} is a total map from each frozen * vocabulary entry to exactly one predicate over {@link InvocationFacts}. * It MUST NOT tokenize, split on operators, or interpret structure in the @@ -80,6 +82,16 @@ export interface InvocationFacts { readonly phaseMvpMode?: boolean; /** Whether worktrees are enabled (`.planning/config.json` `workflow.use_worktrees`). Absent/undefined is falsy, never throws. */ readonly worktreesEnabled?: boolean; + /** + * Whether plan-phase chunked mode is active: `--chunked` flag OR + * `.planning/config.json` `workflow.plan_chunked` (#2993). The disjunction + * is resolved by the caller (the init seam) into this single boolean + * BEFORE it reaches this module — `state:chunked-mode`'s predicate below + * reads only this field, never `--chunked` and the config value + * separately, which is what keeps the `when=` grammar operator-free. + * Absent/undefined is falsy, never throws. + */ + readonly chunkedMode?: boolean; } /** A single input to {@link selectSections}: structurally compatible with {@link workflowFragments.WorkflowSection}. */ @@ -167,9 +179,14 @@ export const WHEN_PREDICATES: Readonly hasFlag(facts, '--discuss'), 'flag:--forensic': (facts: InvocationFacts) => hasFlag(facts, '--forensic'), 'flag:--full': (facts: InvocationFacts) => hasFlag(facts, '--full'), + 'flag:--ingest': (facts: InvocationFacts) => hasFlag(facts, '--ingest'), + 'flag:--prd': (facts: InvocationFacts) => hasFlag(facts, '--prd'), 'flag:--research': (facts: InvocationFacts) => hasFlag(facts, '--research'), + 'flag:--research-phase': (facts: InvocationFacts) => hasFlag(facts, '--research-phase'), 'flag:--reset-phase-numbers': (facts: InvocationFacts) => hasFlag(facts, '--reset-phase-numbers'), + 'flag:--reviews': (facts: InvocationFacts) => hasFlag(facts, '--reviews'), 'flag:--validate': (facts: InvocationFacts) => hasFlag(facts, '--validate'), + 'state:chunked-mode': (facts: InvocationFacts) => facts.chunkedMode === true, 'state:needs-codebase-map': (facts: InvocationFacts) => facts.needsCodebaseMap === true, 'state:phase-mvp-mode': (facts: InvocationFacts) => facts.phaseMvpMode === true, 'state:worktrees-enabled': (facts: InvocationFacts) => facts.worktreesEnabled === true, diff --git a/src/workflow-fragments.cts b/src/workflow-fragments.cts index 6ab61c287..fd630834a 100644 --- a/src/workflow-fragments.cts +++ b/src/workflow-fragments.cts @@ -93,8 +93,10 @@ import contextComposer = require('./context-composer.cjs'); * * Widened from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic * #1671 Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/ + * 40-design.md`), then from 14 to 19 via the ADR-1671 amendment for #2993 + * (epic #1671 Phase 6.2; see `.gsd/phase/chore-2993-fragmentize-plan-phase/ * 40-design.md`). The vocabulary remains CLOSED: no operators, no negation, - * no nesting. Cardinality is not expressiveness — a 14-entry flat list with + * no nesting. Cardinality is not expressiveness — a 19-entry flat list with * no composition is still not a language. * * Held at 14, not wider: an atom whose fact is never computed always @@ -106,6 +108,13 @@ import contextComposer = require('./context-composer.cjs'); * (docs-update, autonomous, code-review, complete-milestone) have no * dedicated `cmdInit*` entry point yet to compute the backing fact, so they * are withheld until that entry point exists (#2992 / ADR-1671 Phase 6.1). + * + * The #2993 widening adds 5 entries fragmentizing `plan-phase.md`: + * `flag:--ingest`, `flag:--prd`, `flag:--research-phase`, `flag:--reviews`, + * `state:chunked-mode`. `state:chunked-mode` is a disjunction (`--chunked` + * flag OR `.planning/config.json` `workflow.plan_chunked`) resolved to a + * single boolean FACT by the init seam (`src/init.cts`) — the grammar still + * sees exactly one atom with no operator, preserving the same guard. */ export const WHEN_VOCABULARY: readonly string[] = Object.freeze([ 'always', @@ -116,9 +125,14 @@ export const WHEN_VOCABULARY: readonly string[] = Object.freeze([ 'flag:--discuss', 'flag:--forensic', 'flag:--full', + 'flag:--ingest', + 'flag:--prd', 'flag:--research', + 'flag:--research-phase', 'flag:--reset-phase-numbers', + 'flag:--reviews', 'flag:--validate', + 'state:chunked-mode', 'state:needs-codebase-map', 'state:phase-mvp-mode', 'state:worktrees-enabled', diff --git a/tests/emitted-drift-acks/2930-fragmentize-execute-phase-markers.json b/tests/emitted-drift-acks/2930-fragmentize-execute-phase-markers.json index 4bfaedce3..968e9111d 100644 --- a/tests/emitted-drift-acks/2930-fragmentize-execute-phase-markers.json +++ b/tests/emitted-drift-acks/2930-fragmentize-execute-phase-markers.json @@ -1,6 +1,6 @@ { "version": 1, "paths": { - "execute-phase.md": "#2930 (epic #1671 Phase 3): pilots the in-file `` marker grammar by wrapping the --wave/gap-closure/regression-gate branch sections (partial-wave, gap-closure-artifacts, regression-gate) in marker pairs, proving the composeWorkflow seam runs at install time before per-runtime rewrites. Retargeted from plan-phase.md (chore/2930 review): plan-phase.md sits only 36 B under the ADR-857 Phase-6 PRE_PHASE6 gate (tests/phase6-capstone-conformance.test.cjs) and cannot absorb marker overhead, so the maintainer retargeted the pilot to execute-phase.md, which has 728 B of headroom under its own PRE_PHASE6 cap. SOURCE grows by exactly 275 marker bytes (6 marker lines); the EMITTED artifact composeWorkflow produces at install is byte-identical to the pre-#2930 file (markers are stripped, never shipped). See .gsd/phase/chore-2930-fragmentize-xl-workflow/40-design.md 'Known limits' item 5. #2639: handle_branching now warns when local is ahead of origin (+376 B condensed one-line WARNING + rev-list --count check)." + "execute-phase.md": "#2930 (epic #1671 Phase 3): pilots the in-file `` marker grammar by wrapping the --wave/gap-closure/regression-gate branch sections (partial-wave, gap-closure-artifacts, regression-gate) in marker pairs, proving the composeWorkflow seam runs at install time before per-runtime rewrites. Retargeted from plan-phase.md (chore/2930 review): plan-phase.md sits only 36 B under the ADR-857 Phase-6 PRE_PHASE6 gate (tests/phase6-capstone-conformance.test.cjs) and cannot absorb marker overhead, so the maintainer retargeted the pilot to execute-phase.md, which has 728 B of headroom under its own PRE_PHASE6 cap. SOURCE grows by exactly 275 marker bytes (6 marker lines); the EMITTED artifact composeWorkflow produces at install is byte-identical to the pre-#2930 file (markers are stripped, never shipped). See .gsd/phase/chore-2930-fragmentize-xl-workflow/40-design.md 'Known limits' item 5. #2639: handle_branching now warns when local is ahead of origin (+376 B condensed one-line WARNING + rev-list --count check). #2993 (epic #1671 Phase 6.2): fixes the sibling gap #2932 shipped — `flag:--wave` section gating was added to the WHEN_VOCABULARY and to init.execute-phase's section manifest, but execute-phase.md never actually parsed `--wave` out of `$ARGUMENTS` or forwarded it on the `gsd_run query init.execute-phase` line, so the flag could never fire. This diff adds a WAVE_PARAM extraction (`--wave ` via BASH_REMATCH) and appends it to the init call, growing the file 163 bytes (89,507 -> 89,670)." } } diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index fdf01a49a..f3bb136be 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -291,8 +291,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 5fc49d410..6eb0ea232 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 85a955ae5..f23a357f9 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -361,8 +361,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 926ab036c..a309eabda 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -290,8 +290,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index 6827f900f..b01f8a4b9 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -294,8 +294,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index e8d944346..537688d8c 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index 3504f9d96..3f65c430b 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -397,8 +397,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index 9761d9ecf..599b6fdda 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -292,8 +292,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index c87c2d68f..f0584d6bf 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 37283c500..54afcc76a 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -291,8 +291,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index 07c09f03f..c308f3029 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index 7a65931f3..c081497b0 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -321,8 +321,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index fc4ba607a..65928cfe3 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -357,8 +357,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index 52cbe6527..f534e91f9 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 379435b62..6d98d0ec4 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -259,8 +259,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index 0450058f6..617fdbdf8 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -291,8 +291,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index 206944827..853d43860 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -291,8 +291,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index 3111001d1..1286fa845 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -291,8 +291,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index ccea22d6d..087b40fd9 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -362,8 +362,14 @@ "gsd-core/workflows/pause-work.md", "gsd-core/workflows/plan-milestone-gaps.md", "gsd-core/workflows/plan-phase.md", + "gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md", + "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", "gsd-core/workflows/plan-phase/steps/closed-phase-gate.md", + "gsd-core/workflows/plan-phase/steps/prd-express-gate.md", "gsd-core/workflows/plan-phase/steps/prd-express-path.md", + "gsd-core/workflows/plan-phase/steps/research-only-early-exit.md", + "gsd-core/workflows/plan-phase/steps/research-only-modifiers.md", + "gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md", "gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md", "gsd-core/workflows/plan-review-convergence.md", "gsd-core/workflows/plant-seed.md", diff --git a/tests/gen-section-manifest.test.cjs b/tests/gen-section-manifest.test.cjs index 03efadfc5..06cc6f13d 100644 --- a/tests/gen-section-manifest.test.cjs +++ b/tests/gen-section-manifest.test.cjs @@ -576,6 +576,174 @@ describe('gen-section-manifest.cjs --check / --write (matrix D)', () => { }); }); +// ─── #2993 (epic #1671 Phase 6.2) rows C1/C2: the shipped, committed +// gsd-core/workflows/section-manifest.json artifact itself — never a +// regenerated fixture — gains a `plan-phase` key with all 6 sections in +// document order (C1), while `execute-phase`'s own entry stays +// BYTE-IDENTICAL (C2, a Hyrum gate: #2932/#2992's 3 pre-existing sections +// must not shift shape just because a sibling workflow key was added). + +describe('shipped gsd-core/workflows/section-manifest.json (#2993 rows C1/C2)', () => { + const SHIPPED_MANIFEST_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'section-manifest.json'); + + function readShippedManifest() { + return JSON.parse(fs.readFileSync(SHIPPED_MANIFEST_PATH, 'utf8')); + } + + test('planPhaseKeyHasAllSixSectionsInDocumentOrder (row C1)', () => { + const manifest = readShippedManifest(); + assert.deepEqual(manifest.workflows['plan-phase'], [ + { + id: 'reviews-prerequisite', + when: 'flag:--reviews', + read: 'gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md', + }, + { + id: 'prd-express-gate', + when: 'flag:--prd', + read: 'gsd-core/workflows/plan-phase/steps/prd-express-gate.md', + }, + { + id: 'adr-ingest-express-path', + when: 'flag:--ingest', + read: 'gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md', + }, + { + id: 'research-only-modifiers', + when: 'flag:--research-phase', + read: 'gsd-core/workflows/plan-phase/steps/research-only-modifiers.md', + }, + { + id: 'research-only-early-exit', + when: 'flag:--research-phase', + read: 'gsd-core/workflows/plan-phase/steps/research-only-early-exit.md', + }, + { + id: 'chunked-planning-mode', + when: 'state:chunked-mode', + read: 'gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md', + }, + ]); + }); + + test('executePhaseKeyIsByteIdenticalToBeforeThisChange (row C2 — Hyrum gate)', () => { + const manifest = readShippedManifest(); + assert.deepEqual(manifest.workflows['execute-phase'], [ + { + id: 'partial-wave', + when: 'flag:--wave', + read: 'gsd-core/workflows/execute-phase/steps/partial-wave.md', + }, + { + id: 'gap-closure-artifacts', + when: 'state:gap-closure-phase', + read: 'gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md', + }, + { + id: 'regression-gate', + when: 'state:has-prior-phases', + read: 'gsd-core/workflows/execute-phase/steps/regression-gate.md', + }, + ]); + }); + + test('shippedManifestPassesTheRealCheckAgainstTheRealRepo (idempotency, mirrors row 29)', () => { + const r = runGenSectionManifest([ + '--check', '--workflows-dir', path.join(ROOT, 'gsd-core', 'workflows'), '--manifest-path', SHIPPED_MANIFEST_PATH, '--repo-root', ROOT, + ]); + assert.equal(r.code, 0, `the shipped manifest must already be up to date; stderr: ${r.stderr}`); + }); +}); + +// ─── Flag-forwarding regression guard (BLOCKER fix, epic #1671 Phase 6.2) ── +// +// The workflows never forwarded their flags to the init CLI, so every +// `flag:--X` atom was permanently FALSE in production and its gated section +// permanently EXCLUDED (`prd-express-gate` unreachable under `--prd`; +// `partial-wave` unreachable under `--wave`, pre-existing since #2932 Phase +// 5). This guard is DERIVED from the shipped manifest — never a hardcoded +// {workflow, flag} list — so it also catches the NEXT `flag:--X` atom added +// to any workflow without its own dedicated test. + +describe('workflow gsd_run query init. invocations forward every flag:--X atom (regression guard)', () => { + const SHIPPED_MANIFEST_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'section-manifest.json'); + + function readShippedManifest() { + return JSON.parse(fs.readFileSync(SHIPPED_MANIFEST_PATH, 'utf8')); + } + + /** + * All shell variable names assigned (anywhere in `scope`, via a + * double-quoted `VAR="..."` assignment) a value whose whitespace-split + * tokens include the exact literal `atomFlag` token (e.g. `--prd`). + * Whole-token comparison (not substring) so `--research-phase` never + * satisfies a check for `--research`, and vice versa. + */ + function varsCarryingFlagToken(scope, atomFlag) { + const carriers = new Set(); + const assignRe = /([A-Za-z_][A-Za-z0-9_]*)="([^"]*)"/g; + let m; + while ((m = assignRe.exec(scope)) !== null) { + const [, varName, rhs] = m; + if (rhs.split(/\s+/).includes(atomFlag)) carriers.add(varName); + } + return carriers; + } + + /** `$VAR` / `${VAR}` variable references appearing anywhere in `line`. */ + function varsReferencedIn(line) { + const referenced = new Set(); + const refRe = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g; + let m; + while ((m = refRe.exec(line)) !== null) referenced.add(m[1]); + return referenced; + } + + const manifest = readShippedManifest(); + for (const [workflow, sections] of Object.entries(manifest.workflows)) { + const flagAtoms = [ + ...new Set( + sections + .map((s) => s.when) + .filter((when) => when.startsWith('flag:--')) + .map((when) => when.slice('flag:'.length)), + ), + ]; + if (flagAtoms.length === 0) continue; + + test(`${workflow}.md forwards [${flagAtoms.join(', ')}] to its gsd_run query init.${workflow} invocation`, () => { + const workflowMdPath = path.join(ROOT, 'gsd-core', 'workflows', `${workflow}.md`); + const content = fs.readFileSync(workflowMdPath, 'utf8'); + + const initLineRe = new RegExp(`^.*gsd_run query init\\.${workflow}\\b.*$`, 'm'); + const initLineMatch = content.match(initLineRe); + assert.ok(initLineMatch, `${workflow}.md must contain a "gsd_run query init.${workflow}" invocation line`); + const initLine = initLineMatch[0]; + + // Everything up to and including the invocation line: real workflows + // parse $ARGUMENTS into a param variable earlier in the same bash + // block, then reference that variable on the INIT= line itself. + const scope = content.slice(0, content.indexOf(initLine) + initLine.length); + const referencedVars = varsReferencedIn(initLine); + + for (const atomFlag of flagAtoms) { + const directlyPresent = initLine + .split(/\s+/) + .includes(atomFlag); + const carriers = varsCarryingFlagToken(scope, atomFlag); + const forwardedViaVar = [...carriers].some((v) => referencedVars.has(v)); + + assert.ok( + directlyPresent || forwardedViaVar, + `${workflow}.md's "gsd_run query init.${workflow}" invocation line must forward a parameter for ` + + `${atomFlag} (its gated section is otherwise permanently excluded — see BLOCKER, epic #1671 Phase 6.2). ` + + `Invocation line: ${initLine}`, + ); + } + }); + } +}); + // ─── REASON enum shape lock (mirrors gen-context-index.cjs precedent) ────── describe('gen-section-manifest.cjs REASON enum', () => { diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 912e06568..88e0b2ee6 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -3613,22 +3613,35 @@ describe('init section manifest', () => { // execute-phase-only to per-workflow (`buildSectionManifestField` now wires // into `cmdInitPlanPhase`/`cmdInitNewProject`/`cmdInitNewMilestone`/ // `cmdInitQuick`/`cmdInitProgress` too — src/init.cts:740/787/839/885/1810). - // The OLD assertion here ("section_manifest must be execute-phase-only") - // is stale: plan-phase's field is now PRESENT, but `null` — the shipped - // gsd-core/workflows/section-manifest.json still keys only `execute-phase` - // (design row C3: a workflow absent from the artifact's `workflows` map - // degrades to `null`, never an absent field). `resume` has no dedicated - // `cmdInit*` manifest wiring at all (design's withheld-atom survey), so its - // field truly remains absent — that half of the guard is unchanged. + // #2993 (epic #1671 Phase 6.2) makes the OLD assertion here doubly stale: + // plan-phase's field is no longer `null` at all — the shipped + // gsd-core/workflows/section-manifest.json now keys `plan-phase` too, with + // 6 real sections (row C1). A no-flag invocation excludes every one of + // them (none of the 5 governing flags/state were supplied). `resume` has + // no dedicated `cmdInit*` manifest wiring at all (design's withheld-atom + // survey), so its field truly remains absent — that half of the guard is + // unchanged. - describe('init dispatch: other subcommands unaffected (#2932/#2992 row 59, CRITICAL radius guard)', () => { - test('planPhaseEmitsANullManifestNotAnAbsentField (row C3/C16 — absence is not empty)', (t) => { + describe('init dispatch: other subcommands unaffected (#2932/#2992/#2993 row 59, CRITICAL radius guard)', () => { + test('planPhaseEmitsARealComputedManifestNotNull (#2993 — the shipped artifact now has a plan-phase key)', (t) => { const dir = seedSinglePhaseProject(t, 'gsd-e59-'); const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase'); assert.equal(planPhase.phase_found, true); - assert.ok('section_manifest' in planPhase, 'section_manifest field must be present (computed, degraded to null) for plan-phase'); - assert.equal(planPhase.section_manifest, null, 'plan-phase has no key in the shipped artifact, so it must degrade to null, never {included:[]}'); + assert.ok('section_manifest' in planPhase, 'section_manifest field must be present for plan-phase'); + assert.deepStrictEqual(planPhase.section_manifest, { + workflow: 'plan-phase', + included: [], + excluded: [ + 'reviews-prerequisite', + 'prd-express-gate', + 'adr-ingest-express-path', + 'research-only-modifiers', + 'research-only-early-exit', + 'chunked-planning-mode', + ], + read: [], + }); }); test('resumeNeverEmitsASectionManifestField', (t) => { @@ -3638,6 +3651,221 @@ describe('init section manifest', () => { }); }); + // ── C6 (#2993): plan-phase's section_manifest degrades to null under a + // missing/malformed manifest artifact, exactly like execute-phase's own + // E57/E58 rows — the "safe superset" contract (all 6 sections read) is + // documented per-section in plan-phase.md's own stub prose ("If + // `section_manifest` is `null` or `""` is in its `included` list: + // read ... Otherwise skip"), verified for each of the 6 ids below; this + // test proves the JS-side half — that plan-phase really does receive + // `null`, never a stale/partial selection, under a degraded artifact. + + describe('init plan-phase: section_manifest degrades to null (#2993 row C6)', () => { + test('degradesToNullManifestWhenArtifactMissing', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-c6-missing-'); + const missingPath = path.join(dir, 'does-not-exist-section-manifest.json'); + const result = runSectionManifestCli(['init.plan-phase', '1'], dir, { GSD_SECTION_MANIFEST: missingPath }); + assert.equal(result.status, 0, `expected exit 0 despite missing manifest artifact, got ${result.status} (stderr: ${result.stderr})`); + assertNoStackTrace(result.stderr, 'plan-phase-manifest-missing'); + const body = JSON.parse(result.stdout); + assert.equal(body.section_manifest, null); + }); + + test('degradesToNullManifestWhenArtifactMalformed', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-c6-malformed-'); + const badPath = path.join(dir, 'bad-section-manifest.json'); + fs.writeFileSync(badPath, '{ this is not valid json'); + const result = runSectionManifestCli(['init.plan-phase', '1'], dir, { GSD_SECTION_MANIFEST: badPath }); + assert.equal(result.status, 0, `expected exit 0 despite malformed manifest artifact, got ${result.status} (stderr: ${result.stderr})`); + assertNoStackTrace(result.stderr, 'plan-phase-manifest-malformed'); + const body = JSON.parse(result.stdout); + assert.equal(body.section_manifest, null); + }); + + test('everyPlanPhaseSectionsHostStubDocumentsTheNullSafeSuperset (doc-level half of row C6)', () => { + const planPhasePath = path.join(GSD_ROOT, 'workflows', 'plan-phase.md'); + const content = fs.readFileSync(planPhasePath, 'utf-8'); + const ids = [ + 'reviews-prerequisite', + 'prd-express-gate', + 'adr-ingest-express-path', + 'research-only-modifiers', + 'research-only-early-exit', + 'chunked-planning-mode', + ]; + for (const id of ids) { + const expectedGate = `If \`section_manifest\` is \`null\` or \`"${id}"\` is in its \`included\` list:`; + assert.ok( + content.includes(expectedGate), + `plan-phase.md must document the null-safe-superset gate for "${id}": expected to find "${expectedGate}"`, + ); + } + }); + }); + + // ── #2993 (epic #1671 Phase 6.2) rows B1-B11: plan-phase facts assembly, + // driven through the REAL CLI (prod-shape — matrix section B header) ───── + + describe('init plan-phase: new flag facts assembly (#2993 rows B1-B6)', () => { + function runPlanPhase(phaseArgs, cwd, env = {}) { + return runSectionManifestCli(['init.plan-phase', ...phaseArgs], cwd, env); + } + + test('reviewsFlagPresentIncludesOnlyReviewsPrerequisite (row B1)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b1-present-'); + const body = parseOkJson(runPlanPhase(['1', '--reviews'], dir), 'b1-present'); + assert.deepStrictEqual(body.section_manifest.included, ['reviews-prerequisite']); + }); + + test('reviewsFlagAbsentExcludesReviewsPrerequisite (row B1)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b1-absent-'); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b1-absent'); + assert.ok(!body.section_manifest.included.includes('reviews-prerequisite')); + assert.ok(body.section_manifest.excluded.includes('reviews-prerequisite')); + }); + + test('prdFlagWithValuePresentIncludesOnlyPrdExpressGate (row B2)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b2-present-'); + const body = parseOkJson(runPlanPhase(['1', '--prd', 'some-prd.md'], dir), 'b2-present'); + assert.deepStrictEqual(body.section_manifest.included, ['prd-express-gate']); + }); + + test('prdFlagAbsentExcludesPrdExpressGate (row B2 — value flag absence is null)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b2-absent-'); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b2-absent'); + assert.ok(!body.section_manifest.included.includes('prd-express-gate')); + assert.ok(body.section_manifest.excluded.includes('prd-express-gate')); + }); + + test('ingestFlagWithValuePresentIncludesOnlyAdrIngestExpressPath (row B3)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b3-present-'); + const body = parseOkJson(runPlanPhase(['1', '--ingest', 'some-adr.md'], dir), 'b3-present'); + assert.deepStrictEqual(body.section_manifest.included, ['adr-ingest-express-path']); + }); + + test('ingestFlagAbsentExcludesAdrIngestExpressPath (row B3)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b3-absent-'); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b3-absent'); + assert.ok(!body.section_manifest.included.includes('adr-ingest-express-path')); + assert.ok(body.section_manifest.excluded.includes('adr-ingest-express-path')); + }); + + test('researchPhaseFlagWithValuePresentIncludesBothResearchOnlySections (row B4)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b4-present-'); + const body = parseOkJson(runPlanPhase(['1', '--research-phase', '3'], dir), 'b4-present'); + assert.deepStrictEqual(body.section_manifest.included, ['research-only-modifiers', 'research-only-early-exit']); + }); + + test('researchPhaseFlagAbsentExcludesBothResearchOnlySections (row B4)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b4-absent-'); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b4-absent'); + assert.ok(!body.section_manifest.included.includes('research-only-modifiers')); + assert.ok(!body.section_manifest.included.includes('research-only-early-exit')); + }); + + test('emptyPrdValueIsFalsyAndTreatedAsAbsent (row B5 — no spurious inclusion)', (t) => { + // `--prd` immediately followed by another flag token (no value token + // present at all) resolves to `null` in parseNamedArgs — the "empty + // value" shape for a value flag. Combined with a second, independently + // gated flag to prove ONLY the second flag's section activates. + const dir = seedSinglePhaseProject(t, 'gsd-b5-'); + const body = parseOkJson(runPlanPhase(['1', '--prd', '--reviews'], dir), 'b5'); + assert.ok(!body.section_manifest.included.includes('prd-express-gate'), '--prd with no value must never spuriously include prd-express-gate'); + assert.deepStrictEqual(body.section_manifest.included, ['reviews-prerequisite']); + }); + + test('chunkedFlagPresentIncludesChunkedPlanningMode (row B6)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b6-'); + const body = parseOkJson(runPlanPhase(['1', '--chunked'], dir), 'b6'); + assert.deepStrictEqual(body.section_manifest.included, ['chunked-planning-mode']); + }); + }); + + describe('init plan-phase: state:chunked-mode disjunction — flag OR config (#2993 rows B7-B11)', () => { + function runPlanPhase(phaseArgs, cwd, env = {}) { + return runSectionManifestCli(['init.plan-phase', ...phaseArgs], cwd, env); + } + + function writeConfig(dir, workflowConfig) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: workflowConfig })); + } + + test('chunkedFlagAbsentConfigTrueIncludesChunkedPlanningMode (row B7 — config arm of the disjunction)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b7-'); + writeConfig(dir, { plan_chunked: true }); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b7'); + assert.deepStrictEqual(body.section_manifest.included, ['chunked-planning-mode']); + }); + + test('chunkedFlagAbsentConfigFalseExcludesChunkedPlanningMode (row B8)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b8-false-'); + writeConfig(dir, { plan_chunked: false }); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-false'); + assert.ok(!body.section_manifest.included.includes('chunked-planning-mode')); + }); + + test('chunkedFlagAbsentConfigAbsentExcludesChunkedPlanningMode (row B8)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b8-absent-'); + writeConfig(dir, {}); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-absent'); + assert.ok(!body.section_manifest.included.includes('chunked-planning-mode')); + }); + + test('chunkedFlagAbsentConfigFileMissingExcludesChunkedPlanningMode (row B8)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b8-nofile-'); + assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false, 'sanity: no config.json'); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b8-nofile'); + assert.ok(!body.section_manifest.included.includes('chunked-planning-mode')); + }); + + test('configStringTrueDegradesToFalse (row B9 — strict === true, never coerced)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b9-'); + writeConfig(dir, { plan_chunked: 'true' }); + const body = parseOkJson(runPlanPhase(['1'], dir), 'b9'); + assert.ok( + !body.section_manifest.included.includes('chunked-planning-mode'), + 'a string "true" config value must never coerce to boolean true', + ); + }); + + test('configReadThrowsDegradesToFalseBoundedNeverPropagates (row B10)', (t) => { + // `readConfigJsonBoolean` (src/init.cts) is private and reads + // `.planning/config.json` via `fs.readFileSync`, so its `catch` clause + // cannot be exercised via an in-process fs monkeypatch across the + // spawned-CLI process boundary this suite otherwise drives (the whole + // matrix section is prod-shape: "drive the real CLI"). A directory at + // the config path forces a REAL, deterministic, cross-platform fs + // fault (EISDIR-class on every OS `fs.readFileSync` targets) through + // the real CLI — never a chmod/permission trick — landing on the same + // bounded, non-throwing degrade path a monkeypatched throw would. + const dir = seedSinglePhaseProject(t, 'gsd-b10-'); + // sanity: seedSinglePhaseProject never writes a config.json, so this is + // the fixture's own natural state, not a removal. + assert.equal(fs.existsSync(path.join(dir, '.planning', 'config.json')), false); + fs.mkdirSync(path.join(dir, '.planning', 'config.json')); + const result = runPlanPhase(['1'], dir); + assert.equal(result.status, 0, `expected exit 0 despite an unreadable config.json, got ${result.status} (stderr: ${result.stderr})`); + assertNoStackTrace(result.stderr, 'config-read-throws'); + const body = JSON.parse(result.stdout); + assert.ok(!body.section_manifest.included.includes('chunked-planning-mode'), 'a config read fault must degrade chunkedMode to false, never throw or propagate'); + }); + + test('nonObjectConfigJsonDegradesToFalse (row B11)', (t) => { + const dir = seedSinglePhaseProject(t, 'gsd-b11-'); + for (const hostileJson of ['0', '"s"', '[]', 'null', 'true']) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), hostileJson); + const result = runPlanPhase(['1'], dir); + assert.equal(result.status, 0, `hostile config JSON ${hostileJson}: expected exit 0 (stderr: ${result.stderr})`); + assertNoStackTrace(result.stderr, `hostile-config-json:${hostileJson}`); + const body = JSON.parse(result.stdout); + assert.ok( + !body.section_manifest.included.includes('chunked-planning-mode'), + `hostile config JSON ${hostileJson} must degrade chunkedMode to false`, + ); + } + }); + }); + // ── Row 62: stub @-refs still resolve (ADR-0002) ──── describe('commands/gsd/execute-phase.md: @-refs resolve (#2932 row 62)', () => { diff --git a/tests/issue-2762-plan-reviews-chunked.test.cjs b/tests/issue-2762-plan-reviews-chunked.test.cjs index 5a2ab415a..5ba56f8c2 100644 --- a/tests/issue-2762-plan-reviews-chunked.test.cjs +++ b/tests/issue-2762-plan-reviews-chunked.test.cjs @@ -14,7 +14,11 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); +// #2993 fragmentization moved §8.5 (chunked planning mode, including §8.5.1 / +// §8.5.2) out of plan-phase.md into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md. +// Read that step file directly — it is the sole remaining source of the §8.5.1/§8.5.2 +// content these regression guards assert on. +const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md'); const read = () => fs.readFileSync(MD, 'utf8'); test('§8.5.1 outline agent writes the resume marker into the file (#2762 defect A)', () => { diff --git a/tests/plan-phase-drift-guard.test.cjs b/tests/plan-phase-drift-guard.test.cjs index 35a1ccc81..c46f3cb69 100644 --- a/tests/plan-phase-drift-guard.test.cjs +++ b/tests/plan-phase-drift-guard.test.cjs @@ -32,6 +32,27 @@ const WORKFLOW_PATH = path.join( 'plan-phase.md' ); +const PLAN_PHASE_STEPS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps'); + +/** + * plan-phase.md was fragmented (#2993) into gsd-core/workflows/plan-phase/steps/*.md. + * Content this drift guard asserts on may now live in the host file, an extracted + * step file, or be split across both (e.g. a rule-label count). Guards that need to + * see the full picture read this combined blob instead of `workflow` alone so they + * don't go blind the next time a section moves out of the host. + */ +function readPlanPhaseCombined() { + let combined = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + if (fs.existsSync(PLAN_PHASE_STEPS_DIR)) { + for (const entry of fs.readdirSync(PLAN_PHASE_STEPS_DIR).sort()) { + if (entry.endsWith('.md')) { + combined += '\n' + fs.readFileSync(path.join(PLAN_PHASE_STEPS_DIR, entry), 'utf8'); + } + } + } + return combined; +} + // ─── Fixture ────────────────────────────────────────────────────────────────── const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); @@ -185,11 +206,15 @@ describe('plan-phase workflow: top-level spawn guard (#913)', () => { }); test('workflow contains ALL RUNTIMES orchestrator rule labels (count preserved)', () => { - // Must have all 7 agent-spawn wait rules still present (none dropped during rename) - const allRuntimesCount = (workflow.match(/ORCHESTRATOR RULE — ALL RUNTIMES/g) || []).length; + // Must have all 7 agent-spawn wait rules still present (none dropped during rename). + // #2993 fragmentization moved some spawn sites (e.g. chunked planning) into + // gsd-core/workflows/plan-phase/steps/*.md, so the count is taken across the + // host file AND every extracted step file — not the host alone. + const combined = readPlanPhaseCombined(); + const allRuntimesCount = (combined.match(/ORCHESTRATOR RULE — ALL RUNTIMES/g) || []).length; assert.ok( allRuntimesCount >= 7, - `plan-phase must have at least 7 "ORCHESTRATOR RULE — ALL RUNTIMES" labels (one per agent spawn site); found ${allRuntimesCount} (#913)` + `plan-phase (host + plan-phase/steps/*.md) must have at least 7 "ORCHESTRATOR RULE — ALL RUNTIMES" labels (one per agent spawn site); found ${allRuntimesCount} (#913)` ); }); }); @@ -780,12 +805,17 @@ describe('plan-phase.md — chunked mode flag and config (#2310)', () => { }); describe('plan-phase.md — chunked mode implementation (#2310)', () => { - const content = fs.readFileSync(PLAN_PHASE, 'utf-8'); + // #2993 fragmentization moved §8.5 (chunked planning mode) out of the host file + // into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md. These + // assertions now read the combined host + step-files blob (readPlanPhaseCombined, + // defined at module scope) so they keep guarding the same property regardless of + // which file the content physically lives in. + const content = readPlanPhaseCombined(); test('step 8.5 chunked planning section exists', () => { assert.ok( content.includes('## 8.5.'), - 'plan-phase.md must have a step 8.5 section for chunked planning mode' + 'plan-phase.md (or its extracted plan-phase/steps/*.md) must have a step 8.5 section for chunked planning mode' ); }); @@ -942,16 +972,22 @@ describe('enh #3209: plan-phase ADR ingest express path', () => { ); }); + // #2993 fragmentization moved the ADR ingest express-path step (former §3.6) out + // of the host file into gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md. + // These three tests read the combined host + step-files blob (readPlanPhaseCombined, + // defined at module scope) so they keep guarding the same property regardless of + // which file the content physically lives in. + test('workflow defines an ADR ingest express-path step', () => { - const workflow = read(WORKFLOW_PATH); + const workflow = readPlanPhaseCombined(); assert.ok(/##\s*(?:\d+(?:\.\d+)*)?\.?\s*Handle ADR Ingest Express Path/i.test(workflow), - 'plan-phase workflow must include a dedicated ADR ingest express-path step'); + 'plan-phase workflow (host + plan-phase/steps/*.md) must include a dedicated ADR ingest express-path step'); assert.ok(workflow.includes('ADR Ingest Express Path'), 'workflow must display ADR ingest express-path banner text'); }); test('ADR ingest context template includes scope fence and ADR source attribution', () => { - const workflow = read(WORKFLOW_PATH); + const workflow = readPlanPhaseCombined(); assert.ok(workflow.includes(''), 'ADR ingest context template must include for hard out-of-scope exclusions'); assert.ok(workflow.includes('Source:** ADR Ingest Express Path'), @@ -959,7 +995,7 @@ describe('enh #3209: plan-phase ADR ingest express path', () => { }); test('workflow documents status gate and no-decisions fallback', () => { - const workflow = read(WORKFLOW_PATH); + const workflow = readPlanPhaseCombined(); assert.ok( workflow.includes('Reject `superseded`/`rejected`/`deprecated`') || workflow.includes('reject `superseded`/`rejected`/`deprecated`') || diff --git a/tests/section-manifest.property.test.cjs b/tests/section-manifest.property.test.cjs index 9cba9e819..fc9a7ddc9 100644 --- a/tests/section-manifest.property.test.cjs +++ b/tests/section-manifest.property.test.cjs @@ -82,6 +82,10 @@ const factsArb = fc.record({ needsCodebaseMap: fc.boolean(), phaseMvpMode: fc.boolean(), worktreesEnabled: fc.boolean(), + // #2993 (epic #1671 Phase 6.2): chunkedMode is a shape addition to + // InvocationFacts — must also participate in the partition/totality + // properties below, not just the six pre-existing fact keys. + chunkedMode: fc.boolean(), }); // ─── Row 25: exact partition ──────────────────────────────────────────────── @@ -132,8 +136,8 @@ describe('property: never throws for vocabulary-valid when and arbitrary facts', test('neverThrowsWhenFactsAreMissingKeysEntirely', () => { // Totality also over PARTIAL facts objects (row 19's property-level - // twin): dropping zero or more of the six fact keys must never throw. - const factKeys = ['flags', 'phaseNumber', 'hasPriorPhases', 'needsCodebaseMap', 'phaseMvpMode', 'worktreesEnabled']; + // twin): dropping zero or more of the seven fact keys must never throw. + const factKeys = ['flags', 'phaseNumber', 'hasPriorPhases', 'needsCodebaseMap', 'phaseMvpMode', 'worktreesEnabled', 'chunkedMode']; fc.assert( fc.property(sectionsArb, factsArb, fc.subarray(factKeys), (sections, facts, keysToKeep) => { const partialFacts = {}; diff --git a/tests/section-manifest.test.cjs b/tests/section-manifest.test.cjs index e328a1c6c..29a4d189f 100644 --- a/tests/section-manifest.test.cjs +++ b/tests/section-manifest.test.cjs @@ -31,7 +31,10 @@ const BRANCH_SECTIONS = Object.freeze([ ]); function facts(overrides) { - return { flags: new Set(), phaseNumber: null, hasPriorPhases: false, ...overrides }; + // #2993 (epic #1671 Phase 6.2): chunkedMode is a shape addition to + // InvocationFacts — defaulted false here so every pre-existing call site + // above keeps its prior behavior unchanged. + return { flags: new Set(), phaseNumber: null, hasPriorPhases: false, chunkedMode: false, ...overrides }; } // ─── Rows 1-8: happy path + combinations over W/D/P ───────────────────────── @@ -361,6 +364,85 @@ describe('state:needs-codebase-map / state:phase-mvp-mode / state:worktrees-enab }); }); +// ─── #2993 (epic #1671 Phase 6.2) row A6: flag:--research-phase and +// flag:--research are DISTINCT atoms — neither aliases the other, each +// gates only its own sections ───────────────────────────────────────────── + +describe('flag:--research-phase and flag:--research do not alias each other (row A6)', () => { + const sections = Object.freeze([ + { id: 'research-section', when: 'flag:--research' }, + { id: 'research-phase-section', when: 'flag:--research-phase' }, + ]); + + test('onlyResearchFlagIncludesOnlyTheResearchSection', () => { + const result = selectSections(sections, facts({ flags: new Set(['--research']) })); + assert.deepEqual(result, { included: ['research-section'], excluded: ['research-phase-section'] }); + }); + + test('onlyResearchPhaseFlagIncludesOnlyTheResearchPhaseSection', () => { + const result = selectSections(sections, facts({ flags: new Set(['--research-phase']) })); + assert.deepEqual(result, { included: ['research-phase-section'], excluded: ['research-section'] }); + }); + + test('bothFlagsIncludeBothSections', () => { + const result = selectSections(sections, facts({ flags: new Set(['--research', '--research-phase']) })); + assert.deepEqual(result, { included: ['research-section', 'research-phase-section'], excluded: [] }); + }); + + test('neitherFlagExcludesBothSections', () => { + const result = selectSections(sections, facts({ flags: new Set() })); + assert.deepEqual(result, { included: [], excluded: ['research-section', 'research-phase-section'] }); + }); + + test('predicatesAreIndependentFunctionsNotSharedByToken', () => { + // A stronger structural guard than the behavioral ones above: the two + // predicates must not literally be the same function reference (which + // would make aliasing possible by construction, even if it happened to + // pass every input/output check above by coincidence). + assert.notEqual(WHEN_PREDICATES['flag:--research'], WHEN_PREDICATES['flag:--research-phase']); + }); +}); + +// ─── #2993 (epic #1671 Phase 6.2) row A8: state:chunked-mode fact true / +// false / absent ──────────────────────────────────────────────────────── + +describe('state:chunked-mode predicate (row A8)', () => { + test('chunkedModeTrueWhenFactIsTrue', () => { + assert.equal(WHEN_PREDICATES['state:chunked-mode'](facts({ chunkedMode: true })), true); + }); + + test('chunkedModeFalseWhenFactIsFalse', () => { + assert.equal(WHEN_PREDICATES['state:chunked-mode'](facts({ chunkedMode: false })), false); + }); + + test('chunkedModeFalseWhenFactIsAbsent', () => { + assert.doesNotThrow(() => WHEN_PREDICATES['state:chunked-mode'](facts({}))); + const { chunkedMode: _omit, ...withoutChunkedMode } = facts({}); + assert.doesNotThrow(() => WHEN_PREDICATES['state:chunked-mode'](withoutChunkedMode)); + assert.equal(WHEN_PREDICATES['state:chunked-mode'](withoutChunkedMode), false); + }); + + test('chunkedModeFalseWhenFactIsUndefined', () => { + assert.doesNotThrow(() => WHEN_PREDICATES['state:chunked-mode'](facts({ chunkedMode: undefined }))); + assert.equal(WHEN_PREDICATES['state:chunked-mode'](facts({ chunkedMode: undefined })), false); + }); + + test('chunkedModeFalseForTheStringLiteralTrue (strict === true, never coerced)', () => { + // Mirrors readConfigJsonBoolean's own strict discipline (src/init.cts): + // a truthy-but-non-boolean value must never pass. This is the FACT's own + // strictness contract; the config-string-"true" case itself is exercised + // at the init seam in tests/init.test.cjs (prod-shape, row B9). + assert.equal(WHEN_PREDICATES['state:chunked-mode'](facts({ chunkedMode: 'true' })), false); + }); + + test('selectSectionsIncludesChunkedPlanningModeSectionOnlyWhenFactIsTrue', () => { + const sections = [{ id: 'chunked-section', when: 'state:chunked-mode' }]; + assert.deepEqual(selectSections(sections, facts({ chunkedMode: true })), { included: ['chunked-section'], excluded: [] }); + assert.deepEqual(selectSections(sections, facts({ chunkedMode: false })), { included: [], excluded: ['chunked-section'] }); + assert.deepEqual(selectSections(sections, facts({})), { included: [], excluded: ['chunked-section'] }); + }); +}); + // ─── Rows 25-33: Object.prototype-shaped when= values fail closed ────────── // Added during review — prototype-chain fail-open found by isolated // adversarial pass. A bracket lookup on a plain frozen object resolves diff --git a/tests/skill-frontmatter-contract.test.cjs b/tests/skill-frontmatter-contract.test.cjs index 7aa9a0458..5a2f32685 100644 --- a/tests/skill-frontmatter-contract.test.cjs +++ b/tests/skill-frontmatter-contract.test.cjs @@ -174,7 +174,10 @@ describe('skill frontmatter: /gsd-plan-phase --research-phase flag absorbs the s }); test('research-only mode auto-uses existing RESEARCH.md (no update/view/skip prompt)', () => { - const content = read('gsd-core/workflows/plan-phase.md'); + // #2993 fragmentization moved §5.0 (research-only modifiers, including the + // existing-RESEARCH.md auto-use notice) out of plan-phase.md into + // gsd-core/workflows/plan-phase/steps/research-only-modifiers.md. + const content = read('gsd-core/workflows/plan-phase/steps/research-only-modifiers.md'); // #159: the §5.0 existing-RESEARCH.md path no longer prompts // update/view/skip. When RESEARCH.md exists and neither --research nor // --view is set, the workflow emits a brief "using it" notice naming diff --git a/tests/workflow-fragments-emission.install.test.cjs b/tests/workflow-fragments-emission.install.test.cjs index aa242dcbc..16f647d37 100644 --- a/tests/workflow-fragments-emission.install.test.cjs +++ b/tests/workflow-fragments-emission.install.test.cjs @@ -63,9 +63,13 @@ const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md'); const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL); // 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 -// gate and cannot absorb marker overhead) — it is now a genuinely unmarked -// file again, so row 33 uses it instead of plan-phase.md. -const UNMARKED_REL = path.join('gsd-core', 'workflows', 'plan-phase.md'); +// gate and cannot absorb marker overhead) — it was a genuinely unmarked +// file again, so row 33 used it instead of plan-phase.md. #2993 (epic #1671 +// Phase 6.2) now marks plan-phase.md itself (6 sections, the fragmentization +// this change ships), so it is no longer a valid "genuinely unmarked" fixture +// either — retargeted a second time to discuss-phase.md, which carries no +// gsd:section markers as of this change. +const UNMARKED_REL = path.join('gsd-core', 'workflows', 'discuss-phase.md'); const RUNTIMES = Object.keys(RUNTIME_META); @@ -292,8 +296,8 @@ test('unmarkedWorkflowEmitsByteIdenticalForEveryRuntime', () => { ); const realPath = path.join(real.configDir, UNMARKED_REL); const stubPath = path.join(stub.configDir, UNMARKED_REL); - assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing plan-phase.md`); - assert.ok(fs.existsSync(stubPath), `${runtime}: stub install is missing plan-phase.md`); + assert.ok(fs.existsSync(realPath), `${runtime}: real install is missing discuss-phase.md`); + assert.ok(fs.existsSync(stubPath), `${runtime}: stub install is missing discuss-phase.md`); // Normalize each side's own randomly-generated temp root out of the // content before hashing: some runtimes (opencode) embed the @@ -306,14 +310,14 @@ test('unmarkedWorkflowEmitsByteIdenticalForEveryRuntime', () => { assert.equal( Buffer.byteLength(realText, 'utf8'), Buffer.byteLength(stubText, 'utf8'), - `${runtime}: plan-phase.md byte size drifted between real compose and identity-stub compose`, + `${runtime}: discuss-phase.md byte size drifted between real compose and identity-stub compose`, ); const realHash = crypto.createHash('sha256').update(realText).digest('hex'); const stubHash = crypto.createHash('sha256').update(stubText).digest('hex'); assert.equal( realHash, stubHash, - `${runtime}: plan-phase.md content drifted between real compose and identity-stub compose`, + `${runtime}: discuss-phase.md content drifted between real compose and identity-stub compose`, ); } finally { cleanup(real.root); @@ -461,26 +465,34 @@ test('nonWorkflowMarkdownWithMarkerShapedLineIsNotComposed', () => { test('leavesUnmarkedWorkflowEmissionByteIdentical', () => { const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows'); + // execute-phase.md (#2932 Phase 5) and plan-phase.md (#2993 Phase 6.2) are + // the two files this repo has fragmentized with gsd:section markers — both + // excluded here since composeWorkflow is deliberately NOT a no-op for them. + const MARKED_WORKFLOWS = new Set(['execute-phase.md', 'plan-phase.md']); const workflowFiles = fs .readdirSync(workflowsDir, { withFileTypes: true }) .filter((d) => d.isFile() && d.name.endsWith('.md')) .map((d) => d.name); - assert.ok(workflowFiles.length > 1, 'sanity: there must be more than the pilot workflow on disk'); + assert.ok(workflowFiles.length > MARKED_WORKFLOWS.size, 'sanity: there must be more than the marked workflows on disk'); let checkedCount = 0; for (const fileName of workflowFiles) { - if (fileName === 'execute-phase.md') continue; // the one file #2932 Phase 5 migrates + if (MARKED_WORKFLOWS.has(fileName)) continue; const filePath = path.join(workflowsDir, fileName); const source = fs.readFileSync(filePath, 'utf8'); const composed = composeWorkflow(source, { sourcePath: filePath }); assert.equal( composed, source, - `${fileName}: emission drifted — Phase 5's execute-phase.md extraction must not touch any other workflow`, + `${fileName}: emission drifted — a marked workflow's extraction must not touch any other workflow`, ); checkedCount += 1; } - assert.equal(checkedCount, workflowFiles.length - 1, 'every workflow file except execute-phase.md must have been checked'); + assert.equal( + checkedCount, + workflowFiles.length - MARKED_WORKFLOWS.size, + 'every workflow file except the marked ones must have been checked', + ); }); test('malformedMarkersFailInstallWithoutPartialEmit', () => { diff --git a/tests/workflow-fragments.test.cjs b/tests/workflow-fragments.test.cjs index f60d3d92f..fa1a86bed 100644 --- a/tests/workflow-fragments.test.cjs +++ b/tests/workflow-fragments.test.cjs @@ -29,12 +29,18 @@ const { REASON, } = require('../gsd-core/bin/lib/workflow-fragments.cjs'); const { composeWithinBudget } = require('../gsd-core/bin/lib/context-composer.cjs'); +const { selectSections } = require('../gsd-core/bin/lib/section-manifest.cjs'); const measureBytes = (text) => Buffer.byteLength(text, 'utf8'); /** Compose a document string from an array of lines, joined with '\n'. */ const doc = (...lines) => lines.join('\n'); +/** Minimal InvocationFacts factory for the real-plan-phase.md D4 test below. */ +function facts(overrides) { + return { flags: new Set(), phaseNumber: null, hasPriorPhases: false, ...overrides }; +} + // ─── Row 1: unmarked document (the 88/89 production shape) ───────────────── describe('unmarked document round trip', () => { @@ -182,6 +188,126 @@ describe('real execute-phase.md', () => { }); }); +// ─── #2993 (epic #1671 Phase 6.2): real plan-phase.md — C1/D1/D4/D5 ─────── + +describe('real plan-phase.md (#2993)', () => { + const PLAN_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md'); + const STEPS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps'); + + // The 6 sections the #2993 design survey names, in document order. + const EXPECTED_SECTIONS = Object.freeze([ + { id: 'reviews-prerequisite', when: 'flag:--reviews' }, + { id: 'prd-express-gate', when: 'flag:--prd' }, + { id: 'adr-ingest-express-path', when: 'flag:--ingest' }, + { id: 'research-only-modifiers', when: 'flag:--research-phase' }, + { id: 'research-only-early-exit', when: 'flag:--research-phase' }, + { id: 'chunked-planning-mode', when: 'state:chunked-mode' }, + ]); + + test('parsesExactlySixSectionsInDocumentOrder (row C1)', () => { + const source = fs.readFileSync(PLAN_PHASE_PATH, 'utf8'); + const sections = parseWorkflowSections(source, PLAN_PHASE_PATH); + const explicitSections = sections.filter((s) => s.explicit); + assert.deepEqual( + explicitSections.map((s) => ({ id: s.id, when: s.when })), + [...EXPECTED_SECTIONS], + ); + }); + + test('parsesAndRendersWithoutThrowingAndStripsEveryMarker', () => { + const source = fs.readFileSync(PLAN_PHASE_PATH, 'utf8'); + const composed = composeWorkflow(source, { sourcePath: PLAN_PHASE_PATH }); + assert.equal(composed.includes('gsd:section'), false); + assert.ok(Buffer.byteLength(composed, 'utf8') < Buffer.byteLength(source, 'utf8')); + }); + + test('everyExtractedStepFileExistsIsNonEmptyAndTheHostNoLongerCarriesItsBody (row D1)', () => { + // Behavioral, never a source-grep substring match: for each of the 6 + // sections, the step file on disk (1) exists, (2) is non-empty, and (3) + // is EXACTLY the section's own parsed body (the bytes the marker pair + // wraps in the host) is a stub/reference line, not a re-paste of the + // step file's content — the host's marker body and the step file's + // content are two DIFFERENT, disjoint pieces of text after the move. + const source = fs.readFileSync(PLAN_PHASE_PATH, 'utf8'); + const sections = parseWorkflowSections(source, PLAN_PHASE_PATH); + const byId = new Map(sections.filter((s) => s.explicit).map((s) => [s.id, s])); + + for (const { id } of EXPECTED_SECTIONS) { + const stepPath = path.join(STEPS_DIR, `${id}.md`); + assert.ok(fs.existsSync(stepPath), `expected step file to exist: ${stepPath}`); + const stepContent = fs.readFileSync(stepPath, 'utf8'); + assert.ok(stepContent.length > 0, `expected non-empty step file: ${stepPath}`); + + const hostSection = byId.get(id); + assert.ok(hostSection, `expected an explicit host section for id="${id}"`); + // The host's marker body is a short stub (the conditional read-and-execute + // instruction), never the step file's own moved content — proves the + // body actually left the host rather than being duplicated in place. + assert.ok( + hostSection.body.length < stepContent.length, + `expected host stub body for "${id}" to be shorter than the extracted step file`, + ); + assert.equal( + hostSection.body.includes(stepContent.trim()), + false, + `host stub body for "${id}" must not still contain the moved step file's content verbatim`, + ); + } + }); + + test('prdExpressPathIsReadOnlyWhenPrdSectionIsIncluded (row D4)', () => { + // prd-express-path.md was previously read unconditionally; the #2993 + // wrapper (prd-express-gate, when="flag:--prd") is what now actually + // gates it. Prove the gate via the real evaluator: absent --prd excludes + // prd-express-gate; present --prd includes it. + const source = fs.readFileSync(PLAN_PHASE_PATH, 'utf8'); + const sections = parseWorkflowSections(source, PLAN_PHASE_PATH).filter((s) => s.explicit); + const withoutPrd = selectSections(sections, facts({})); + assert.ok(withoutPrd.excluded.includes('prd-express-gate'), 'prd-express-gate must be excluded when --prd is absent'); + assert.ok(!withoutPrd.included.includes('prd-express-gate')); + + const withPrd = selectSections(sections, facts({ flags: new Set(['--prd']) })); + assert.ok(withPrd.included.includes('prd-express-gate'), 'prd-express-gate must be included when --prd is present'); + assert.ok(!withPrd.excluded.includes('prd-express-gate')); + + // The host's marker body is a stub that reads plan-phase/steps/prd-express-gate.md + // ONLY when the section is included; THAT step file (nested, one hop + // further — the reachability shape gen-section-manifest.cjs's own + // "nested step reference" precedent covers) is what references + // prd-express-path.md — proving the express-path read is reachable only + // through the now-conditional wrapper, never as a second, independent + // unconditional read site elsewhere in the host. + const gateSection = sections.find((s) => s.id === 'prd-express-gate'); + assert.ok(gateSection.body.includes('prd-express-gate.md'), 'prd-express-gate\'s host stub must read its own step file'); + const gateStepContent = fs.readFileSync(path.join(STEPS_DIR, 'prd-express-gate.md'), 'utf8'); + assert.ok( + gateStepContent.includes('prd-express-path.md'), + 'prd-express-gate.md must be the (nested) step that references prd-express-path.md', + ); + }); + + test('skipIfProseAppearsExactlyOnceAcrossHostAndStepFile (row D5)', () => { + // For each section, the "Skip if:" gating prose must live in exactly ONE + // place — either the host stub or the step file — never duplicated in + // both after the move. + const source = fs.readFileSync(PLAN_PHASE_PATH, 'utf8'); + const sections = parseWorkflowSections(source, PLAN_PHASE_PATH); + const byId = new Map(sections.filter((s) => s.explicit).map((s) => [s.id, s])); + + for (const { id } of EXPECTED_SECTIONS) { + const stepContent = fs.readFileSync(path.join(STEPS_DIR, `${id}.md`), 'utf8'); + const hostBody = byId.get(id).body; + const hostHasSkipIf = /Skip if:/.test(hostBody); + const stepHasSkipIf = /Skip if:/.test(stepContent); + assert.notEqual( + hostHasSkipIf && stepHasSkipIf, + true, + `"Skip if:" prose for "${id}" must not appear in BOTH the host stub and the step file`, + ); + } + }); +}); + // ─── Row 5/6: fence negative space ────────────────────────────────────────── describe('marker lookalikes inside fences', () => { @@ -483,6 +609,12 @@ describe('frozen when= vocabulary', () => { // for an array only reflects index positions '0','1',... and would not // catch a value being silently renamed). See the dispatch report for // this deliberate deviation from the test matrix's literal wording. + // + // #2993 (epic #1671 Phase 6.2, matrix row A3) widens this lock 14 -> 19: + // flag:--ingest, flag:--prd, flag:--research-phase, flag:--reviews, + // state:chunked-mode. This is the row the lock exists to force — a + // deliberate, coordinated update, never a silent drift (Greenspun's + // Tenth Rule / ADR-1671:69). assert.equal(Object.isFrozen(WHEN_VOCABULARY), true); assert.deepEqual( [...WHEN_VOCABULARY].sort(), @@ -492,10 +624,15 @@ describe('frozen when= vocabulary', () => { 'flag:--discuss', 'flag:--forensic', 'flag:--full', + 'flag:--ingest', + 'flag:--prd', 'flag:--research', + 'flag:--research-phase', 'flag:--reset-phase-numbers', + 'flag:--reviews', 'flag:--validate', 'flag:--wave', + 'state:chunked-mode', 'state:gap-closure-phase', 'state:has-prior-phases', 'state:needs-codebase-map', @@ -506,6 +643,63 @@ describe('frozen when= vocabulary', () => { }); }); +// ─── #2993 (epic #1671 Phase 6.2): widened when= vocabulary (19 atoms) ───── +// 50-test-matrix.md rows A1/A6. + +describe('widened when= vocabulary (#2993)', () => { + // The 5 net-new atoms shipped by #2993, fragmentizing plan-phase.md. + const NET_NEW_ATOMS_2993 = Object.freeze([ + 'flag:--ingest', + 'flag:--prd', + 'flag:--research-phase', + 'flag:--reviews', + 'state:chunked-mode', + ]); + + test('everyNetNewAtomIsInWhenVocabulary', () => { + for (const atom of NET_NEW_ATOMS_2993) { + assert.ok(WHEN_VOCABULARY.includes(atom), `expected "${atom}" in WHEN_VOCABULARY`); + } + }); + + test('acceptsEveryWidenedAtom (row A1)', () => { + for (const when of NET_NEW_ATOMS_2993) { + const source = `\nbody\n`; + const sections = parseWorkflowSections(source); + const explicitSections = sections.filter((s) => s.explicit); + assert.equal(explicitSections.length, 1, `expected acceptance for when="${when}"`); + assert.equal(explicitSections[0].when, when); + assert.equal(composeWorkflow(source), 'body\n'); + } + }); + + test('researchPhaseAndResearchAreDistinctAtomsAtTheParserLevel (row A6)', () => { + // flag:--research-phase (net-new, #2993) and flag:--research (pre-existing, + // #2992) are DISTINCT vocabulary entries — the parser must accept both, + // as different `when` values, on sections that sit side by side, and must + // never conflate one for the other (e.g. via prefix-matching or a shared + // token derivation). See section-manifest.test.cjs for the predicate-level + // half of this guard (no aliasing at evaluation time). + const source = doc( + '', + 'researchBody', + '', + '', + 'researchPhaseBody', + '', + ); + const sections = parseWorkflowSections(source); + const explicitSections = sections.filter((s) => s.explicit); + assert.deepEqual( + explicitSections.map((s) => ({ id: s.id, when: s.when })), + [ + { id: 'research-section', when: 'flag:--research' }, + { id: 'research-phase-section', when: 'flag:--research-phase' }, + ], + ); + }); +}); + // ─── #2992 (epic #1671 Phase 6.1): widened when= vocabulary (14 atoms) ───── // 50-test-matrix.md rows A2/A5/A6/A14/A19.