diff --git a/.changeset/plucky-koalas-snooze.md b/.changeset/plucky-koalas-snooze.md new file mode 100644 index 000000000..148e1dada --- /dev/null +++ b/.changeset/plucky-koalas-snooze.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3687 +--- +**Capability hooks can no longer be silently registered-but-never-run** — the capability validator now checks that each host call site's dispatch text covers every hook KIND registered at that point (a gate-only consumer fails validation when a step or contribution hook is registered there), and the plan/execute/verify host consumers now dispatch steps and contributions generically per the loop hook contract instead of hand-rolling one kind. (#3606) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 9a86ee4c6..966755143 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -593,6 +593,7 @@ "execute-phase/steps/post-merge-gate.md", "execute-phase/steps/regression-gate-run.md", "execute-phase/steps/regression-gate.md", + "execute-phase/steps/wave-post-gate-hooks.md", "execute-phase/steps/worktree-recovery-policy.md", "new-milestone/steps/project-md-milestone-write.md", "new-milestone/steps/reset-phase-safety.md", diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index fa196ce97..88c68361e 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -3435,26 +3435,55 @@ function topoSortContributions(entries) { // ─── Gen-time wired guard ───────────────────────────────────────────────────── +/** + * Hook group (capability.json array name) → hook kind (dispatch discriminator). + * Single source of truth for both validateHooksWired and the scanner-parity + * test in tests/capability-registry.test.cjs (#3606). + */ +const HOOK_GROUP_KINDS = Object.freeze({ + steps: 'step', + contributions: 'contribution', + gates: 'gate', +}); + /** * Validate that every hook point declared by a capability has a corresponding - * `loop render-hooks ` call site in one of the host-loop workflow files. + * `loop render-hooks ` call site in one of the host-loop workflow files, + * AND — #3606 — that the call site's dispatch text covers the hook's KIND. + * + * A call site proves hooks are rendered, not dispatched: a consumer that + * iterates only `kind == "gate"` (or narrows `kind == "step"` to one + * `ref.skill`) silently drops every other registered kind — a capability can + * be wired, enabled, resolved active, and never run. See + * gsd-core/references/loop-hook-dispatch.md ("A point whose workflow + * hand-rolls one kind does not implement this contract"). * * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points * are already caught by validateStep/validateContribution/validateGate — do not * double-report. * + * KNOWN LIMITATION (#3606): coverage is the UNION across all call sites for a + * point in the five STEP_WORKFLOWS host files. Consumers outside that universe + * (quick.md, autonomous.md, code-review*.md, audit-milestone.md, + * secure-phase.md, validate-phase.md) are not per-file checked — a narrowed + * consumer there passes as long as one host file covers the point. Per-file + * coverage maps are the tightening path. + * * @param {object} cap Validated capability object. - * @param {Set} wiredSet Set of points that have call sites in host workflows. - * @returns {string[]} Array of error strings; empty means all points are wired. + * @param {Map>} wiredKinds Per point, the hook kinds the + * host workflows' call-site dispatch text covers (getWiredKinds). A point + * absent from the map is unwired. + * @returns {string[]} Array of error strings; empty means all points + * are wired and every registered kind is covered. */ -function validateHooksWired(cap, wiredSet) { +function validateHooksWired(cap, wiredKinds) { const errors = []; const capId = cap.id || '(unknown)'; - function checkPoint(point, groupName, idx) { + function checkPoint(point, groupName, kind, idx) { // Only flag valid points that are unwired — invalid points are schema-validator's job. if (!VALID_LOOP_POINTS.has(point)) return; - if (!wiredSet.has(point)) { + if (!wiredKinds.has(point)) { errors.push( 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + '" is declared but not wired in any host-loop workflow ' + @@ -3462,20 +3491,34 @@ function validateHooksWired(cap, wiredSet) { 'Wire the call site in the host workflow ' + '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.', ); + return; + } + const covered = wiredKinds.get(point); + if (covered.size === 0) { + errors.push( + 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + + '" has `loop render-hooks ' + point + '` call site(s), but their dispatch text covers NO ' + + 'hook kind — every consumer is narrowed to specific hooks. Dispatch every registered kind ' + + 'per gsd-core/references/loop-hook-dispatch.md.', + ); + return; + } + if (!covered.has(kind)) { + errors.push( + 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + + '" registers a ' + kind + ' hook, but the host call site\'s dispatch text never ' + + 'covers `kind == "' + kind + '"` (it covers: ' + [...covered].sort().join(', ') + '). ' + + 'A hand-rolled single-kind consumer silently never dispatches the other kinds — ' + + 'dispatch every registered kind per gsd-core/references/loop-hook-dispatch.md.', + ); } } - for (let i = 0; i < (cap.steps || []).length; i++) { - const hook = cap.steps[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'steps', i); - } - for (let i = 0; i < (cap.contributions || []).length; i++) { - const hook = cap.contributions[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i); - } - for (let i = 0; i < (cap.gates || []).length; i++) { - const hook = cap.gates[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'gates', i); + for (const [group, kind] of Object.entries(HOOK_GROUP_KINDS)) { + for (let i = 0; i < (cap[group] || []).length; i++) { + const hook = cap[group][i]; + if (hook.point !== undefined) checkPoint(hook.point, group, kind, i); + } } return errors; @@ -3699,6 +3742,7 @@ module.exports = { topoSortSteps, topoSortContributions, validateHooksWired, + HOOK_GROUP_KINDS, validateConfigSliceEntry, classifyCrossErrors, runConfigFormatParityGate, diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 071868ebe..d897f9854 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -644,7 +644,7 @@ increases monotonically across waves. `{status}` is `complete` (success), WAVE_PRE_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:pre --raw) ``` - If a contribution's `activeHooks` entry provides an alternate wave dispatch, follow it instead of step 3's inline loop; otherwise proceed to step 3. + **Contribution dispatch:** inject every `kind == "contribution"` fragment per @gsd-core/references/loop-hook-dispatch.md (skip when none); one naming an alternate wave dispatch replaces step 3's inline loop. Then proceed to step 3. 3. **Spawn executor agents:** @@ -1014,40 +1014,9 @@ increases monotonically across waves. `{status}` is `complete` (success), **If `activeHooks` is empty or absent:** Skip silently to step 5.8. - ⚠ **Validate `check` before shell use** (third-party manifest input) — `loop-hook-dispatch.md` § `gate`. + **Contribution dispatch:** inject every `kind == "contribution"` fragment per @gsd-core/references/loop-hook-dispatch.md (skip when none), before the gates below. - **For each active entry where `kind == "gate"`** (process in array order), run the gate check — a `predicate` gate (ADR-2008 / #2008) substitutes `gsd_run check predicate --predicate '' --phase-number "${PHASE_NUMBER}" --raw`: - - ```bash - GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw) - CHECK_EXIT=$? - ``` - - **Step 1 — did the CHECK COMMAND itself succeed?** - - If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON): - - `onError == "halt"` → treat as a fatal error: stop wave completion, do NOT proceed to step 5.8, and surface: `⚠ Gate check command failed ({hook.capId}): command error. Resolve before continuing.` - - `onError == "skip"` → log a warning and continue to the next hook. Do NOT read `GATE_RESULT.block`. - - **Step 2 — read `GATE_RESULT.block` (boolean).** This step is only reached when the command succeeded. - - - **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == true`:** HALT — stop wave completion, do NOT proceed to step 5.8, and present: - - ``` - ⚠ Wave {N} blocked by capability gate ({hook.capId}): {GATE_RESULT.message} - Resolve before continuing to next wave. - ``` - - This halt is **not** bypassed by `onError` — `onError` only covers command errors (step 1 above), not the gate's block decision. - - - **Non-blocking gate (`hook.blocking == false`):** never halts. If `GATE_RESULT.block` is `true` (or non-empty `message`), print `⚠ {hook.capId} advisory (wave {N}): {GATE_RESULT.message}`, then: - - If `GATE_RESULT.spawn_mapper == true` OR `GATE_RESULT.directive == "auto-remap"`: spawn `gsd-codebase-mapper` per `execute-phase/steps/codebase-drift-gate.md`; pass `--paths {GATE_RESULT.affected_paths}`. Continue regardless (wave NOT failed by remap failure). - - Otherwise: continue after advisory. - - If block `false` and no `message`: continue silently. - - - **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == false`:** continue silently. - - **When all active gates are processed without a blocking halt:** continue to step 5.8. + **For each active entry where `kind == "gate"`** (process in array order): read and execute `gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md` for the full evaluation contract (check validation, `onError`, blocking semantics, mapper spawn). When all active gates are processed without a blocking halt, continue to step 5.8. 5.8. **Handle test gate failures (when `WAVE_FAILURE_COUNT > 0`):** @@ -1202,7 +1171,7 @@ VERIFY_POST_HOOKS_JSON=$(gsd_run loop render-hooks verify:post --raw) SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1) ``` -Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "secure-phase"`. +Dispatch every `kind == "step"` hook per @gsd-core/references/loop-hook-dispatch.md (skip when none). The secure-phase routing below applies when that specific hook is active. If no active secure-phase step hook exists: skip. diff --git a/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md b/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md new file mode 100644 index 000000000..85a70bcfa --- /dev/null +++ b/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md @@ -0,0 +1,39 @@ +# Wave-Post Gate Hook Evaluation (execute:wave:post) + +Detail for the `kind == "gate"` dispatch at the execute:wave:post hook point, extracted from the host step 5.7 (#3606 — the host file carries a frozen pre-phase-6 byte ceiling, #1168; evaluation detail lives here). + +⚠ **Validate `check` before shell use** (third-party manifest input) — `gsd-core/references/loop-hook-dispatch.md` § `gate`. + +**For each active entry where `kind == "gate"`** (process in array order), run the gate check — a `predicate` gate (ADR-2008 / #2008) uses the check CLI's `predicate` form with the predicate JSON and phase number (shown in the block below): + +```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 +GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw) +CHECK_EXIT=$? +``` + +**Step 1 — did the CHECK COMMAND itself succeed?** + +If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON): +- `onError == "halt"` → treat as a fatal error: stop wave completion, do NOT proceed to step 5.8, and surface: `⚠ Gate check command failed ({hook.capId}): command error. Resolve before continuing.` +- `onError == "skip"` → log a warning and continue to the next hook. Do NOT read `GATE_RESULT.block`. + +**Step 2 — read `GATE_RESULT.block` (boolean).** This step is only reached when the command succeeded. + +- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == true`:** HALT — stop wave completion, do NOT proceed to step 5.8, and present: + + ``` + ⚠ Wave {N} blocked by capability gate ({hook.capId}): {GATE_RESULT.message} + Resolve before continuing to next wave. + ``` + + This halt is **not** bypassed by `onError` — `onError` only covers command errors (step 1 above), not the gate's block decision. + +- **Non-blocking gate (`hook.blocking == false`):** never halts. If `GATE_RESULT.block` is `true` (or non-empty `message`), print `⚠ {hook.capId} advisory (wave {N}): {GATE_RESULT.message}`, then: + - If `GATE_RESULT.spawn_mapper == true` OR `GATE_RESULT.directive == "auto-remap"`: spawn `gsd-codebase-mapper` per `execute-phase/steps/codebase-drift-gate.md`; pass `--paths {GATE_RESULT.affected_paths}`. Continue regardless (wave NOT failed by remap failure). + - Otherwise: continue after advisory. + - If block `false` and no `message`: continue silently. + +- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == false`:** continue silently. + +**When all active gates are processed without a blocking halt:** continue to step 5.8. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 52f7001fb..8f29695e4 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -423,6 +423,8 @@ test -f "${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md" && echo "VALIDATION_CREATED PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw) ``` +**Contribution dispatch (#3606):** inject every `kind == "contribution"` fragment from `PLAN_PRE_HOOKS_JSON` per @gsd-core/references/loop-hook-dispatch.md, in array order, into the role each entry's `into` names — planner-targeted ones land in the prompt block below, orchestrator-targeted ones in your working context. The security specialization below is one such contribution, not a replacement for the generic dispatch. + Resolve active contribution hooks from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "security"`. **If no active security contribution hook exists:** Skip to step 5.6. @@ -1416,8 +1418,12 @@ PHASE_REQ_IDS=$(gsd_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/d PHASE_REQ_IDS="${PHASE_REQ_IDS:-TBD}" ``` -Read the `activeHooks` array from `PLAN_POST_HOOKS_JSON` in-context. If the -`gap-analysis` gate hook is absent (capability inactive), skip this step. +Read the `activeHooks` array from `PLAN_POST_HOOKS_JSON` in-context. If +`activeHooks` is empty or absent, skip this step silently — do NOT key the skip +on any one capability's gate being absent (#3606: that skip silently dropped +every other registered hook at this point). + +**Step and contribution dispatch:** dispatch every `kind == "step"` hook and inject every `kind == "contribution"` fragment per @gsd-core/references/loop-hook-dispatch.md (skip each kind silently when none), before gate evaluation below. ⚠ **Validate `check` before shell use** (third-party manifest input) — `loop-hook-dispatch.md` § `gate`. diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index 9c4d3c6dc..5ce182897 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -549,9 +549,11 @@ Skip this step entirely if `$FULL_MODE` is false. EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw) ``` +**Generic step dispatch (#3606):** dispatch every `kind == "step"` hook from `EXECUTE_POST_HOOKS_JSON` per @gsd-core/references/loop-hook-dispatch.md (skip silently when none); each step is advisory and best-effort. The code-review specialization below is one such hook, not a replacement for the generic dispatch. + Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`. -If no active code-review step hook exists, skip with message "Code review skipped (code-review capability inactive)". +If no active code-review step hook exists, skip with message "Code review skipped (code-review capability inactive)" — after dispatching any other active step hooks above — and proceed. **Scope files from executor's commits:** ```bash diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index 21299bc6a..eeae64bed 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -534,6 +534,8 @@ VERIFY_POST_HOOKS_JSON=$(gsd_run loop render-hooks verify:post --raw) SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1) ``` +**Generic step dispatch:** dispatch every `kind == "step"` hook from `VERIFY_POST_HOOKS_JSON` per @gsd-core/references/loop-hook-dispatch.md (skip silently when none). Each step is advisory and best-effort — honor `onError` and continue. The secure-phase handling below is an additional specialization of one such hook, not a replacement for the generic dispatch. + Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "secure-phase"`. If an active secure-phase step hook exists AND `SECURITY_FILE` is empty, dispatch the registry-provided skill stem: diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index dbd167dc4..4e0946358 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -33,8 +33,8 @@ const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config- // registry generator and the loop-host-contract generator share one source of truth. const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs'); -// Wired-points helper — tells us which points actually have render-hooks call sites. -const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs'); +// Wired-kinds helper — per point, which hook kinds the render-hooks call sites' dispatch text covers. +const { getWiredKinds } = require('./gen-loop-host-contract.cjs'); // Capability validator — shared runtime-callable module extracted per ADR-1244 D2. const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs'); @@ -419,9 +419,11 @@ function loadAndValidate(centralKeys, capabilitiesDir, centralPatterns) { return { capMap, errors, warnings }; } - // Compute wired points ONCE before iterating capabilities so the filesystem - // scan is not repeated per-capability. ROOT is the repo root (defined at top of file). - const wiredSet = getWiredLoopPoints(ROOT); + // Compute wired points + covered kinds ONCE before iterating capabilities so + // the filesystem scan is not repeated per-capability. ROOT is the repo root + // (defined at top of file). #3606: the kinds map carries, per point, which + // hook kinds the call sites' dispatch text actually covers. + const wiredKinds = getWiredKinds(ROOT); const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) @@ -459,7 +461,7 @@ function loadAndValidate(centralKeys, capabilitiesDir, centralPatterns) { } // Gen-time wired guard: reject hooks that declare a valid point with no call site. - const wiredErrors = validateHooksWired(cap, wiredSet); + const wiredErrors = validateHooksWired(cap, wiredKinds); if (wiredErrors.length > 0) { for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e); continue; diff --git a/scripts/gen-loop-host-contract.cjs b/scripts/gen-loop-host-contract.cjs index 1347df829..426db9e9e 100644 --- a/scripts/gen-loop-host-contract.cjs +++ b/scripts/gen-loop-host-contract.cjs @@ -446,8 +446,11 @@ const HOST_LOOP_FILES = STEP_WORKFLOWS.map((w) => 'gsd-core/workflows/' + w.file * @param {string} text Content of a workflow file (or any text). * @returns {Set} */ +/** The call-site shape both scanners key on — one regex, two consumers (#3606). */ +const CALL_SITE_RE = /loop render-hooks\s+([a-z:]+)/g; + function scanWiredPoints(text) { - const re = /loop render-hooks\s+([a-z:]+)/g; + const re = CALL_SITE_RE; const result = new Set(); let m; while ((m = re.exec(text)) !== null) { @@ -456,6 +459,132 @@ function scanWiredPoints(text) { return result; } +// ─── Hook-kind coverage (#3606) ────────────────────────────────────────────── + +const HOOK_KINDS = ['contribution', 'step', 'gate']; + +/** + * The kinds one call site's dispatch text actually covers (#3606). + * + * A point having a `loop render-hooks ` call site proves the hooks are + * RENDERED, not that they are DISPATCHED — a consumer that iterates only + * `kind == "gate"` (or narrows `kind == "step"` to one `ref.skill`) silently + * drops every other registered kind. Coverage rules for the text following a + * call site, up to the next call site or the region cap, judged LINE by line: + * + * - A deferral line (one carrying an `@`-included path to the generic + * contract, e.g. `@gsd-core/references/loop-hook-dispatch.md`) that names a + * kind (`kind == "step"`) covers that kind; a deferral line with no kind + * discriminator ("apply each entry") covers every kind. A bare §-citation + * of the reference (validation guidance only, no `@`) covers nothing — + * plan-phase cites the gate-validation section while dispatching only gates. + * - Otherwise a kind is covered when some LINE dispatches it unconditionally: + * a `kind == ""` discriminator with NO same-line narrowing to one + * hook (`ref.skill ==`, `ref.agent ==`, `ref.command ==`). A narrowed line + * special-cases ONE hook and proves nothing about the kind generally — the + * exact hand-rolled-consumer shape the reference warns about. + * + * Quote style and spacing vary across the corpus (`kind == "step"`, + * `kind === 'gate'`), so the matcher is tolerant of both quote characters and + * of `==`/`===`. + * + * Pure: same input, same output; CRLF-safe (line splitting tolerates \r). + * + * @param {string} region Dispatch text following one call site. + * @returns {Set} + */ +function coveredKindsInRegion(region) { + const covered = new Set(); + // Same-SEGMENT narrowing to ONE hook voids credit: `ref.skill ==`, `capId ==`, + // and `into ==` each special-case a subset, not the kind generally + // (plan-phase's `kind == "contribution" and capId == "security"` is the + // hand-rolled shape; `into == "planner"` covers only planner-targeted + // contributions). Segments, not lines: execute-phase legitimately writes + // "dispatch `kind == "step"` hooks per … . `ref.skill == "code-review"`:" — + // the deferral is one sentence, the specialization the next; narrowing in a + // DIFFERENT segment must not void the deferral's credit. + const narrowingRe = /(?:ref\.(?:skill|agent|command)|capId|into)\s*={2,3}/; + // Negated mentions describe an absence, not a dispatch ("Branch 1 — no active + // step hooks (`activeHooks` has no entry with `kind == "step"`)" — ship.md). + const negationRe = /\b(?:no|without|absent|lacks?|missing)\b[^.|]*kind\s*={2,3}/; + const deferralRe = /@\S*loop-hook-dispatch\.md/; + for (const line of region.split(/\r?\n/)) { + // Sentence segments: a `.`/`;` followed by whitespace ends a segment. A + // period NOT followed by whitespace (the `.md` inside a deferral path, + // `ref.skill`) is not a boundary. + for (const segment of line.split(/(?<=[.;])\s+/)) { + const kindDiscriminators = []; + for (const kind of HOOK_KINDS) { + if (new RegExp(`kind\\s*={2,3}\\s*["']${kind}["']`).test(segment)) kindDiscriminators.push(kind); + } + if (kindDiscriminators.length === 0) { + // A deferral with no kind discriminator ("apply each entry per …") + // still covers every kind. + if (deferralRe.test(segment)) for (const kind of HOOK_KINDS) covered.add(kind); + continue; + } + if (negationRe.test(segment)) continue; + if (deferralRe.test(segment)) { + // Deferral naming kinds ("dispatch `kind == "step"` hooks per …"). + if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind); + continue; + } + if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind); + } + } + return covered; +} + +/** + * Scan every `loop render-hooks ` call site in `text` and accumulate, + * per point, the union of hook kinds its dispatch regions cover (#3606). + * + * @param {string} text Content of a workflow file (or any text). + * @returns {Map>} point → covered kinds. + */ +function scanWiredKinds(text) { + const result = new Map(); + const siteRe = CALL_SITE_RE; + const sites = []; + let m; + while ((m = siteRe.exec(text)) !== null) sites.push({ point: m[1], start: m.index }); + const REGION_CAP = 6000; + for (let i = 0; i < sites.length; i++) { + const regionEnd = i + 1 < sites.length ? sites[i + 1].start : Math.min(text.length, sites[i].start + REGION_CAP); + const region = text.slice(sites[i].start, regionEnd); + const covered = coveredKindsInRegion(region); + if (!result.has(sites[i].point)) result.set(sites[i].point, new Set()); + for (const kind of covered) result.get(sites[i].point).add(kind); + } + return result; +} + +/** + * Read every host-loop workflow file and return, per point, the union of hook + * kinds its call sites' dispatch text covers (#3606). + * + * @param {string} [repoRoot] Path to the repository root. Defaults to ROOT. + * @returns {Map>} + */ +function getWiredKinds(repoRoot) { + const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT; + const result = new Map(); + for (const relPath of HOST_LOOP_FILES) { + const absPath = path.join(resolvedRoot, relPath); + let content; + try { + content = fs.readFileSync(absPath, 'utf8'); + } catch (err) { + throw new Error('getWiredKinds: cannot read host-loop file ' + absPath + ': ' + err.message); + } + for (const [point, kinds] of scanWiredKinds(content)) { + if (!result.has(point)) result.set(point, new Set()); + for (const kind of kinds) result.get(point).add(kind); + } + } + return result; +} + /** * Read every host-loop workflow file and return the union of all wired loop points * (i.e. points that have a `loop render-hooks ` call site). @@ -497,6 +626,10 @@ module.exports = { ROLE_TO_AGENT, scanWiredPoints, getWiredLoopPoints, + coveredKindsInRegion, + scanWiredKinds, + getWiredKinds, + HOOK_KINDS, }; // ─── CLI entry point ────────────────────────────────────────────────────────── diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 2140482aa..34261797c 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -106,7 +106,8 @@ "verify-work-auto-transition.test.cjs", "verify.test.cjs" ], - "issue": "3767" + "issue": "3767", + "justification": "verify-command-grounding added by #2401/#3678; allowlist follow-up landed with #3606 (base was red on this lane)." }, "install": { "files": [ diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index ceceab16f..2cd0ef06f 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -4893,7 +4893,12 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { // ─── Defect 2 — gen-time wired guard ──────────────────────────────────────── - describe('Defect 2: validateHooksWired gen-time guard', () => { + // Shared #3606 fixtures: every hook kind, and a Map> builder + // for validateHooksWired's wiredKinds argument. + const ALL = ['contribution', 'step', 'gate']; + const kindsMap = (spec) => new Map(Object.entries(spec).map(([pt, ks]) => [pt, new Set(ks)])); + + describe('Defect 2: validateHooksWired gen-time wired guard', () => { /** Minimal capability fixture with one hook at a given point */ function makeCapWithStep(point) { return { @@ -4930,7 +4935,7 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { test('returns non-empty error array mentioning "not wired" when step point is not in wiredSet', () => { const cap = makeCapWithStep('discuss:pre'); - const wiredSet = new Set(['plan:pre', 'plan:post']); // discuss:pre absent + const wiredSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL }); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(Array.isArray(errs), 'must return an array'); assert.ok(errs.length > 0, 'must return errors when point is unwired'); @@ -4942,7 +4947,7 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { test('returns non-empty error array when contribution point is not in wiredSet', () => { const cap = makeCapWithContribution('discuss:pre'); - const wiredSet = new Set(['plan:pre']); // discuss:pre absent + const wiredSet = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(errs.length > 0, 'must return errors for unwired contribution point'); assert.match(errs.join(' '), /not wired/i); @@ -4950,7 +4955,7 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { test('returns non-empty error array when gate point is not in wiredSet', () => { const cap = makeCapWithGate('discuss:pre'); - const wiredSet = new Set(['plan:pre']); // discuss:pre absent + const wiredSet = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(errs.length > 0, 'must return errors for unwired gate point'); assert.match(errs.join(' '), /not wired/i); @@ -4958,35 +4963,37 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { test('returns empty array when all declared points are in wiredSet', () => { const cap = makeCapWithStep('plan:pre'); - const wiredSet = new Set(['plan:pre', 'plan:post', 'execute:post']); + const wiredSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL, 'execute:post': ALL }); const errs = validateHooksWired(cap, wiredSet); - assert.deepEqual(errs, [], 'must return empty array when all points are wired'); + assert.deepEqual(errs, [], 'must return empty array when all points are wired and kind-covered'); }); test('boundary: cap declaring discuss:pre is rejected against wiredSet lacking it', () => { const cap = makeCapWithStep('discuss:pre'); - const smallSet = new Set(['plan:pre', 'plan:post']); + const smallSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL }); const errs = validateHooksWired(cap, smallSet); assert.ok(errs.length > 0, 'must reject discuss:pre against a set that lacks it'); }); - test('boundary: cap declaring discuss:pre is accepted against real getWiredLoopPoints(ROOT) post-fix', () => { + test('boundary: cap declaring discuss:pre is accepted against real getWiredKinds(ROOT) post-fix', () => { const cap = makeCapWithStep('discuss:pre'); - const realWired = getWiredLoopPoints(ROOT); + const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs'); + const realWired = getWiredKinds(ROOT); const errs = validateHooksWired(cap, realWired); assert.deepEqual( errs, [], - `discuss:pre must be wired after the fix. Errors: ${errs.join('; ')}`, + `discuss:pre must be wired and kind-covered after the fix. Errors: ${errs.join('; ')}`, ); }); - test('boundary: cap declaring discuss:post is accepted against real getWiredLoopPoints(ROOT) post-fix', () => { + test('boundary: cap declaring discuss:post is accepted against real getWiredKinds(ROOT) post-fix', () => { const cap = makeCapWithContribution('discuss:post'); - const realWired = getWiredLoopPoints(ROOT); + const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs'); + const realWired = getWiredKinds(ROOT); const errs = validateHooksWired(cap, realWired); assert.deepEqual( errs, [], - `discuss:post must be wired after the fix. Errors: ${errs.join('; ')}`, + `discuss:post must be wired and kind-covered after the fix. Errors: ${errs.join('; ')}`, ); }); @@ -4999,7 +5006,7 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { gates: [], config: {}, }; - const wiredSet = new Set(['plan:pre']); // the invalid point is not here either + const wiredSet = kindsMap({ 'plan:pre': ALL }); // the invalid point is not here either const errs = validateHooksWired(cap, wiredSet); // Should NOT flag it — invalid points are the schema validator's job const notWiredErrors = errs.filter((e) => /not wired/i.test(e)); @@ -5010,6 +5017,228 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => { }); }); + // ─── Defect 3: hook-kind coverage (#3606) ──────────────────────────────────── + // + // A point having a call site proves hooks are RENDERED, not DISPATCHED. A + // consumer that iterates only `kind == "gate"` (or narrows `kind == "step"` + // to one ref.skill) silently drops every other registered kind — mempalace's + // step hooks were wired, active, and never run for five days across + // plan:post / execute:wave:post / verify:post / execute:post consumers. + // The guard must validate, per registered kind, that the call site's + // dispatch text covers that kind. + + describe('Defect 3: validateHooksWired hook-kind coverage (#3606)', () => { + + test('a step hook at a point whose site covers only gate fails with a kind-coverage error', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [{ point: 'plan:post', ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }], + contributions: [], + gates: [], + config: {}, + }; + const wired = kindsMap({ 'plan:post': ['gate'] }); // hand-rolled gate-only consumer + const errs = validateHooksWired(cap, wired); + assert.ok(errs.length > 0, 'a step registered at a gate-only point must fail validation'); + const joined = errs.join(' '); + assert.match(joined, /plan:post/, 'error must name the point'); + assert.match(joined, /test-cap/, 'error must name the capability id'); + assert.match(joined, /step/, 'error must name the uncovered kind'); + assert.doesNotMatch(joined, /not wired/i, 'the point IS wired — this is the coverage error, not the wiring error'); + }); + + test('a contribution hook at a point whose site covers only gate fails', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [], + contributions: [{ point: 'execute:wave:post', into: 'orchestrator', fragment: { inline: 'hi' }, produces: [], consumes: [] }], + gates: [], + config: {}, + }; + const wired = kindsMap({ 'execute:wave:post': ['gate'] }); + const errs = validateHooksWired(cap, wired); + assert.ok(errs.length > 0, 'a contribution registered at a gate-only point must fail validation'); + assert.match(errs.join(' '), /contribution/); + }); + + test('a point covered for exactly the registered kinds passes (boundary: no over-reach)', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [{ point: 'ship:post', ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }], + contributions: [], + gates: [], + config: {}, + }; + const wired = kindsMap({ 'ship:post': ['step'] }); // ship.md's real coverage + assert.deepEqual(validateHooksWired(cap, wired), [], 'step at a step-covered point must pass'); + }); + + test('unwired-point errors are unchanged when the arg is a kinds Map', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [{ point: 'discuss:pre', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }], + contributions: [], + gates: [], + config: {}, + }; + const wired = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent entirely + const errs = validateHooksWired(cap, wired); + assert.ok(errs.length > 0, 'unwired point still fails'); + assert.match(errs.join(' '), /not wired/i, 'the existing unwired error text is preserved'); + }); + + test('boundary: invalid points are not kind-flagged (schema validator owns them)', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [{ point: 'not:a:real:point', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }], + contributions: [], + gates: [], + config: {}, + }; + const errs = validateHooksWired(cap, kindsMap({})); + assert.deepEqual(errs, [], 'invalid points produce neither wiring nor coverage errors'); + }); + + test('the real tree passes the extended guard for every in-tree registered kind', () => { + // Self-enforcement: the repo's own capabilities and host workflows must + // satisfy the new check — this is the test that forces consumer fixes. + const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs'); + const wiredKinds = getWiredKinds(ROOT); + const failures = []; + for (const capPath of fs.readdirSync(path.join(ROOT, 'capabilities'))) { + const manifest = path.join(ROOT, 'capabilities', capPath, 'capability.json'); + if (!fs.existsSync(manifest)) continue; + let cap; + try { cap = JSON.parse(fs.readFileSync(manifest, 'utf8')); } catch { continue; } + for (const err of validateHooksWired(cap, wiredKinds)) failures.push(`${capPath}: ${err}`); + } + assert.deepEqual( + failures, [], + `in-tree capabilities must be fully kind-covered by host workflows:\n ${failures.join('\n ')}`, + ); + }); + }); + + describe('coveredKindsInRegion: dispatch-text kind coverage (#3606)', () => { + test('deferral line without a kind discriminator covers every kind', () => { + const region = 'Apply each entry in `activeHooks` per @gsd-core/references/loop-hook-dispatch.md\n'; + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + assert.deepEqual([...coveredKindsInRegion(region)].sort(), ['contribution', 'gate', 'step']); + }); + + test('deferral line naming one kind covers only that kind', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = 'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.\n'; + assert.deepEqual([...coveredKindsInRegion(region)], ['step']); + }); + + test('a bare §-citation of the reference covers nothing (validation guidance, not dispatch)', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = '⚠ **Validate `check` before shell use** — `loop-hook-dispatch.md` § `gate`.\n**For each active entry where `kind == "gate"`**\n'; + assert.deepEqual([...coveredKindsInRegion(region)], ['gate'], 'gate dispatched unconditionally, step/contribution NOT'); + }); + + test('a narrowed kind line (kind == step AND ref.skill ==) does not cover the kind', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = 'Resolve hooks where `kind == "step"` and `ref.skill == "secure-phase"`.\n'; + assert.deepEqual([...coveredKindsInRegion(region)], [], 'a one-skill special case proves nothing about the kind generally'); + }); + + test('quote and operator variants are tolerated (=== and single quotes)', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = "for h in hooks: if h.kind === 'contribution' then inject\n"; + assert.deepEqual([...coveredKindsInRegion(region)], ['contribution']); + }); + + test('CRLF input yields the same verdicts as LF', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const lf = 'per @gsd-core/references/loop-hook-dispatch.md\n`kind == "gate"` unconditional\n'; + const crlf = lf.replace(/\n/g, '\r\n'); + assert.deepEqual([...coveredKindsInRegion(crlf)].sort(), [...coveredKindsInRegion(lf)].sort()); + }); + + test('scanWiredKinds accumulates per-point unions across multiple call sites', () => { + const { scanWiredKinds } = require('../scripts/gen-loop-host-contract.cjs'); + const text = [ + 'X=$(gsd_run loop render-hooks verify:post --raw)', + '**For each active entry where `kind == "gate"`**', + 'Y=$(gsd_run loop render-hooks verify:post --raw)', + 'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.', + '', + ].join('\n'); + const m = scanWiredKinds(text); + assert.ok(m.has('verify:post'), 'point must be present'); + assert.deepEqual( + [...m.get('verify:post')].sort(), + ['gate', 'step'], + 'two call sites at one point accumulate their coverage', + ); + }); + + test('capId == and into == narrowing void contribution credit (#3606 adversarial finding)', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const capIdNarrowed = 'Resolve hooks where `kind == "contribution"` and `capId == "security"`.\n'; + assert.deepEqual([...coveredKindsInRegion(capIdNarrowed)], [], + 'a one-capability hand-roll is not generic contribution coverage'); + const intoNarrowed = 'For each entry where `kind == "contribution"` and `into == "planner"`: inject.\n'; + assert.deepEqual([...coveredKindsInRegion(intoNarrowed)], [], + 'planner-targeted-only injection leaves orchestrator-targeted contributions dispatched by no one'); + }); + + test('a negated kind mention describes an absence, not a dispatch', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = 'Branch 1 — no active `ship:post` step hooks (`activeHooks` has no entry with `kind == "step"`): Skip.\n'; + assert.deepEqual([...coveredKindsInRegion(region)], [], + '"has no entry with kind == step" must not count as step coverage'); + }); + + test('a deferral sentence keeps its credit when the NEXT sentence specializes (segment split)', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = 'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md. `ref.skill == "code-review"`:\n'; + assert.deepEqual([...coveredKindsInRegion(region)], ['step'], + 'the generic deferral and the one-hook specialization are separate sentences'); + }); + + test('a deferral path with a period (loop-hook-dispatch.md) is not split into segments', () => { + const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs'); + const region = 'Apply each entry per @~/.claude/gsd-core/references/loop-hook-dispatch.md\n'; + assert.deepEqual([...coveredKindsInRegion(region)].sort(), ['contribution', 'gate', 'step'], + 'the .md inside the deferral path is not a sentence boundary'); + }); + + test('a site whose consumers are all narrowed yields the zero-coverage error, not "not wired"', () => { + const cap = { + id: 'test-cap', + role: 'feature', + steps: [{ point: 'verify:post', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }], + contributions: [], + gates: [], + config: {}, + }; + const wired = new Map([['verify:post', new Set()]]); // sites exist, all narrowed + const errs = validateHooksWired(cap, wired); + assert.ok(errs.length > 0, 'all-narrowed consumers must fail'); + const joined = errs.join(' '); + assert.match(joined, /covers NO hook kind/i, 'error must name the zero-coverage diagnosis'); + assert.doesNotMatch(joined, /not wired/i, 'the site EXISTS — misdiagnosing it as unwired points at the wrong remedy'); + }); + + test('HOOK_KINDS vocabulary parity: scanner kinds match the validator group->kind mapping', () => { + const { HOOK_KINDS } = require('../scripts/gen-loop-host-contract.cjs'); + const { HOOK_GROUP_KINDS } = require('../gsd-core/bin/lib/capability-validator.cjs'); + assert.deepEqual( + [...HOOK_KINDS].sort(), + Object.values(HOOK_GROUP_KINDS).sort(), + 'the scanner vocabulary must stay in lock-step with validateHooksWired\u2019s exported group->kind mapping — a rename on either side must fail here', + ); + }); + }); + // ─── Anti-pattern parity guards ────────────────────────────────────────────── describe('Anti-pattern parity: host-file set has a single source of truth', () => { diff --git a/tests/emitted-drift-acks/3357-verification-resolver.json b/tests/emitted-drift-acks/3357-verification-resolver.json index 2153fe28a..2436adb80 100644 --- a/tests/emitted-drift-acks/3357-verification-resolver.json +++ b/tests/emitted-drift-acks/3357-verification-resolver.json @@ -2,7 +2,7 @@ "version": 1, "paths": { "verify-work.md": { - "reason": "#3357: the `03-VERIFICATION.md` staleness check now resolves the phase's own report via `gsd_run query verification.resolve-file \"$PHASE_DIR\" --raw` instead of `ls \"${PHASE_DIR}\"/*-VERIFICATION.md | head -1`, routing through the same shared resolver seam as transition.md so both call sites agree on which file is canonical. Growth is +13 bytes (38,983 -> 38,996), the delta between the old ls/head-1 pipeline and the gsd_run call. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 38996 -> 39107 bytes (+111)." + "reason": "#3357: the `03-VERIFICATION.md` staleness check now resolves the phase's own report via `gsd_run query verification.resolve-file \"$PHASE_DIR\" --raw` instead of `ls \"${PHASE_DIR}\"/*-VERIFICATION.md | head -1`, routing through the same shared resolver seam as transition.md so both call sites agree on which file is canonical. Growth is +13 bytes (38,983 -> 38,996), the delta between the old ls/head-1 pipeline and the gsd_run call. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 38996 -> 39107 bytes (+111). \u2014 #3606 append: verify:post consumer gains generic step dispatch (deferral to loop-hook-dispatch.md) before the secure-phase specialization, so ui/security/mempalace/nyquist steps registered at verify:post actually run instead of being filtered out by the one-skill narrow." } } } diff --git a/tests/emitted-drift-acks/3370-execute-phase-gate-conflation.json b/tests/emitted-drift-acks/3370-execute-phase-gate-conflation.json index 0a0b7df1f..66db56b2f 100644 --- a/tests/emitted-drift-acks/3370-execute-phase-gate-conflation.json +++ b/tests/emitted-drift-acks/3370-execute-phase-gate-conflation.json @@ -4,4 +4,4 @@ "execute-phase.md": "#3370: the step-3 executor-routing line now also cites #3370 \u2014 the checkpoint gate rule itself lives in the execute-phase/steps/per-plan-executor-routing.md fragment (loaded per plan in every isolation mode immediately before the dispatch prompt is composed), the same keep-the-host-lean pattern #1689/#3417 used, because the host sits under the frozen ADR-857 Phase 6 ceiling (\u226493400). Net growth is 6 bytes (93386 -> 93392): the routing citation only; the rule text, which forbids the orchestrator from composing dispatch text that refuses or overrides auto-approval for the default gate=\"blocking\" (only blocking-human always surfaces), is in the fragment. Supersedes the spent #3324 fragment (merged into next), which also named execute-phase.md and would otherwise double-ack the same path. \u2014 #3423 append (epic #1891 F8): grew 12 bytes with the -> tag rename (4 tag tokens, +3 bytes each), then trimmed 8 bytes of redundant prose (dropped 'current' from the model-inheritance note; #3478's growth had left only 8 bytes of ADR-857 margin) to stay under the Phase 6 margin ceiling; net +4 bytes against the #3370 baseline. \u2014 #3210 append: the checkpoint_handling blocking-human carve-out names precondition-unmet checkpoints (+29-byte marker '(precondition-unmet, #3210)'), and the decision bullet's 'Except blocking-human' conditional \u2014 required by tests/package-legitimacy-gate.test.cjs \u2014 was restored (+29), funded by trimming ', regardless of type' (-20, the carve-out already overrides all branches) and '(standard flow below)' -> '(standard flow)' (-6); net +3 bytes (93395 -> 93398), still under the frozen ADR-857 Phase 6 ceiling (<=93400). \u2014 #3576 append: bare `references/.md` cites repaired to the canonical `gsd-core/references/.md` form (+9 bytes, 1 cite(s) \u00d7 9). Dead-pointer fix; no content change.", "execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path." } -} \ No newline at end of file +} diff --git a/tests/emitted-drift-acks/3409-unreachable-guard-arms.json b/tests/emitted-drift-acks/3409-unreachable-guard-arms.json index 23f5bd453..28c654936 100644 --- a/tests/emitted-drift-acks/3409-unreachable-guard-arms.json +++ b/tests/emitted-drift-acks/3409-unreachable-guard-arms.json @@ -1,12 +1,12 @@ { "version": 1, "paths": { - "gsd-phase-researcher.md": "#3409: guarded `cat \"$phase_dir\"/*-CONTEXT.md` against nullglob wiping the pattern to zero operands when no CONTEXT.md exists — a bare `cat` with no operands blocks reading stdin (hangs the agent) instead of the `2>/dev/null` guard ever firing, since a stalled read is not a failing exit. Now checks `${_CTX[0]}` is a real path before invoking cat. Growth is the array-guard idiom itself (+43 bytes).", - "gsd-verifier.md": "#3409: same nullglob-hang fix as gsd-phase-researcher.md, applied to `cat \"$PHASE_DIR\"/*-VERIFICATION.md` in Step 0 — an absent VERIFICATION.md previously left a zero-operand `cat` blocking on stdin instead of falling through to first-verification mode. Growth is the array-guard idiom (+49 bytes). — #3206 append (merged into this fragment because two ack sources may never name the same path): +52 bytes, 49098 -> 49150 (2 under the LARGE cap). The growth is the literal fix for the term 5b used undefined: the compressed explicit-evidence definition inlined at 5b (+34 net on the rewritten line — the trailing honest-verifier cite there is dropped as superseded by the inline definition; honest-verifier.md stays cited at 5c) plus gsd-core/ path-prefix repairs on the two 404ing bare references/ cites at 5c (honest-verifier.md) and the MVP-mode section (verify-mvp-mode.md) (+9 each). Lazy extraction remains untakeable in this change: the large extractable blocks are content-pinned by tests that read the agent file directly (tests/verifier-behavior-unverified.test.cjs, tests/verification-overrides.test.cjs), so extraction is its own coordinated change.", - "complete-milestone.md": "#3409: guarded `cat .planning/phases/*-*/*-SUMMARY.md` — with `shopt -s nullglob` active in this block's preamble (#2962), zero matching phase summaries collapses the glob to nothing and a bare `cat` blocks reading stdin rather than producing empty output, wedging the milestone-completion review. Growth is the array-existence-check idiom (+73 bytes, two glob segments makes this longer than the single-glob sites). — #2142 append (merged into this fragment because two ack sources may never name the same path): +1605 bytes, 40498 -> 42103. The `archive_milestone` step now documents the opt-in `--archive-quick` quick-task archival flag (default OFF, deliberately NOT symmetrical with phase archival's default-ON posture), folds the AskUserQuestion decision for it into the SAME `milestone.complete` invocation (avoiding a redundant second call), and states the known bucket-all provenance limit.", - "discuss-phase-assumptions.md": "#3409: replaced the unreachable `AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo \"false\")` — `||` never fires because the query exits 0 with empty stdout when the field is absent, not a failure, so AUTO_MODE silently ended up empty rather than \"false\" — with a two-line capture-then-default (`AUTO_MODE=\"${AUTO_MODE:-false}\"`) that actually reaches the fallback. Growth is the extra default-assignment line (+19 bytes).", - "plan-phase.md": "#3409: three sites. `AUTO_CHAIN` and `PHASE_REQ_IDS` get the same unreachable-`||`-fallback fix as discuss-phase-assumptions.md (empty-but-successful `gsd_run query` output never triggered `|| echo`, now uses `${VAR:-default}`); `PRIOR_SUMMARIES` additionally swapped `gsd_run query phases.list --pick summaries_total` for `--type summaries --pick count` since the old pick key produced the same unreachable-fallback failure mode for the walking-skeleton check. Net growth across the three sites is +39 bytes. — #3576 append: bare `references/.md` cites repaired to the canonical `gsd-core/references/.md` form (+36 bytes, 4 cite(s) × 9). Dead-pointer fix; no content change. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 90516 -> 90627 bytes (+111). — #2401 append (merged into this fragment because two ack sources may never name the same path): plan-phase.md now dispatches the deterministic `gsd_run check verify-command-paths` probe before spawning the plan-check pass and interpolates its result into the verification prompt as {VERIFY_PATHS} inside a new block, plus a block carrying prior_verify_commands into planning context so the planner can reuse a proven path instead of re-deriving one. 90627 -> 92807 bytes (+2180). Deliberate runtime-loaded workflow text for the new feature, not converter drift.", - "session-report.md": "#3409: guarded `ls -la .planning/reports/SESSION_REPORT*.md 2>/dev/null || echo \"No previous reports\"` — with nullglob active, zero prior reports collapses the pattern to nothing and `ls -la` with no operands lists the current directory (a successful exit, wrong output) instead of failing into the `|| echo` fallback, so the report-existence check silently printed a directory listing. Replaced with an array-existence check that only lists when a real report file is present. Growth is the guard idiom (+58 bytes).", - "transition.md": "#3409: guarded `cat .planning/phases/XX-current/*-SUMMARY.md` — same nullglob-hang defect as complete-milestone.md's phase-summary read: zero summaries left a bare `cat` blocking on stdin instead of proceeding with no summary content during PROJECT.md evolution. Growth is the array-existence-check idiom (+73 bytes)." + "gsd-phase-researcher.md": "#3409: guarded `cat \"$phase_dir\"/*-CONTEXT.md` against nullglob wiping the pattern to zero operands when no CONTEXT.md exists \u2014 a bare `cat` with no operands blocks reading stdin (hangs the agent) instead of the `2>/dev/null` guard ever firing, since a stalled read is not a failing exit. Now checks `${_CTX[0]}` is a real path before invoking cat. Growth is the array-guard idiom itself (+43 bytes).", + "gsd-verifier.md": "#3409: same nullglob-hang fix as gsd-phase-researcher.md, applied to `cat \"$PHASE_DIR\"/*-VERIFICATION.md` in Step 0 \u2014 an absent VERIFICATION.md previously left a zero-operand `cat` blocking on stdin instead of falling through to first-verification mode. Growth is the array-guard idiom (+49 bytes). \u2014 #3206 append (merged into this fragment because two ack sources may never name the same path): +52 bytes, 49098 -> 49150 (2 under the LARGE cap). The growth is the literal fix for the term 5b used undefined: the compressed explicit-evidence definition inlined at 5b (+34 net on the rewritten line \u2014 the trailing honest-verifier cite there is dropped as superseded by the inline definition; honest-verifier.md stays cited at 5c) plus gsd-core/ path-prefix repairs on the two 404ing bare references/ cites at 5c (honest-verifier.md) and the MVP-mode section (verify-mvp-mode.md) (+9 each). Lazy extraction remains untakeable in this change: the large extractable blocks are content-pinned by tests that read the agent file directly (tests/verifier-behavior-unverified.test.cjs, tests/verification-overrides.test.cjs), so extraction is its own coordinated change.", + "complete-milestone.md": "#3409: guarded `cat .planning/phases/*-*/*-SUMMARY.md` \u2014 with `shopt -s nullglob` active in this block's preamble (#2962), zero matching phase summaries collapses the glob to nothing and a bare `cat` blocks reading stdin rather than producing empty output, wedging the milestone-completion review. Growth is the array-existence-check idiom (+73 bytes, two glob segments makes this longer than the single-glob sites). \u2014 #2142 append (merged into this fragment because two ack sources may never name the same path): +1605 bytes, 40498 -> 42103. The `archive_milestone` step now documents the opt-in `--archive-quick` quick-task archival flag (default OFF, deliberately NOT symmetrical with phase archival's default-ON posture), folds the AskUserQuestion decision for it into the SAME `milestone.complete` invocation (avoiding a redundant second call), and states the known bucket-all provenance limit.", + "discuss-phase-assumptions.md": "#3409: replaced the unreachable `AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo \"false\")` \u2014 `||` never fires because the query exits 0 with empty stdout when the field is absent, not a failure, so AUTO_MODE silently ended up empty rather than \"false\" \u2014 with a two-line capture-then-default (`AUTO_MODE=\"${AUTO_MODE:-false}\"`) that actually reaches the fallback. Growth is the extra default-assignment line (+19 bytes).", + "plan-phase.md": "#3409: three sites. `AUTO_CHAIN` and `PHASE_REQ_IDS` get the same unreachable-`||`-fallback fix as discuss-phase-assumptions.md (empty-but-successful `gsd_run query` output never triggered `|| echo`, now uses `${VAR:-default}`); `PRIOR_SUMMARIES` additionally swapped `gsd_run query phases.list --pick summaries_total` for `--type summaries --pick count` since the old pick key produced the same unreachable-fallback failure mode for the walking-skeleton check. Net growth across the three sites is +39 bytes. \u2014 #3576 append: bare `references/.md` cites repaired to the canonical `gsd-core/references/.md` form (+36 bytes, 4 cite(s) \u00d7 9). Dead-pointer fix; no content change. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 90516 -> 90627 bytes (+111). \u2014 #2401 append (merged into this fragment because two ack sources may never name the same path): plan-phase.md now dispatches the deterministic `gsd_run check verify-command-paths` probe before spawning the plan-check pass and interpolates its result into the verification prompt as {VERIFY_PATHS} inside a new block, plus a block carrying prior_verify_commands into planning context so the planner can reuse a proven path instead of re-deriving one. 90627 -> 92807 bytes (+2180). Deliberate runtime-loaded workflow text for the new feature, not converter drift. \u2014 #3606 append: plan:pre gains generic contribution dispatch (deferral covering every `into` target, not just planner); plan:post gains generic step + contribution dispatch and its skip condition no longer keys on the gap-analysis gate\u2019s absence (that skip silently dropped every other registered hook at the point).", + "session-report.md": "#3409: guarded `ls -la .planning/reports/SESSION_REPORT*.md 2>/dev/null || echo \"No previous reports\"` \u2014 with nullglob active, zero prior reports collapses the pattern to nothing and `ls -la` with no operands lists the current directory (a successful exit, wrong output) instead of failing into the `|| echo` fallback, so the report-existence check silently printed a directory listing. Replaced with an array-existence check that only lists when a real report file is present. Growth is the guard idiom (+58 bytes).", + "transition.md": "#3409: guarded `cat .planning/phases/XX-current/*-SUMMARY.md` \u2014 same nullglob-hang defect as complete-milestone.md's phase-summary read: zero summaries left a bare `cat` blocking on stdin instead of proceeding with no summary content during PROJECT.md evolution. Growth is the array-existence-check idiom (+73 bytes)." } } diff --git a/tests/emitted-drift-acks/3606-hook-kind-coverage.json b/tests/emitted-drift-acks/3606-hook-kind-coverage.json new file mode 100644 index 000000000..ecf3b8a96 --- /dev/null +++ b/tests/emitted-drift-acks/3606-hook-kind-coverage.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "paths": { + "quick.md": { + "reason": "#3606: the execute:post consumer gains generic step dispatch (deferral to loop-hook-dispatch.md) before the code-review specialization, so refactor-trigger's ref.command step and any other registered execute:post steps actually run on /gsd:quick runs instead of being filtered out by the one-skill narrow. Deliberate growth, not converter drift." + } + } +} diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 12da547a2..e85096d79 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 9a39fe298..eed662883 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 69b2609e5..193a8796f 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 5a1026639..313af7e6d 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/cline.json b/tests/fixtures/install-tree/cline.json index d9cd84e7b..476a168e2 100644 --- a/tests/fixtures/install-tree/cline.json +++ b/tests/fixtures/install-tree/cline.json @@ -275,6 +275,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 86c5c258c..ed14b6b80 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/codex.json b/tests/fixtures/install-tree/codex.json index c77a42fce..6d40d610a 100644 --- a/tests/fixtures/install-tree/codex.json +++ b/tests/fixtures/install-tree/codex.json @@ -308,6 +308,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/copilot.json b/tests/fixtures/install-tree/copilot.json index 651bfdc20..ba10b60a2 100644 --- a/tests/fixtures/install-tree/copilot.json +++ b/tests/fixtures/install-tree/copilot.json @@ -274,6 +274,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index ad8b776a0..83caf772a 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 4ee532115..1a449c1da 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index ad54daca7..502ec3057 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index 296397d10..923166495 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -274,6 +274,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index f658c764a..1c16a1cd3 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -309,6 +309,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index ee0d64368..d79a66a56 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index de708f986..564f9ec88 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -241,6 +241,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index dfa85441a..de2b99d47 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/trae.json b/tests/fixtures/install-tree/trae.json index c43accede..55931f2ed 100644 --- a/tests/fixtures/install-tree/trae.json +++ b/tests/fixtures/install-tree/trae.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/windsurf.json b/tests/fixtures/install-tree/windsurf.json index e63740c10..282e1ebe5 100644 --- a/tests/fixtures/install-tree/windsurf.json +++ b/tests/fixtures/install-tree/windsurf.json @@ -273,6 +273,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md", diff --git a/tests/fixtures/install-tree/zcode.json b/tests/fixtures/install-tree/zcode.json index 4734baac1..0f7d2ad56 100644 --- a/tests/fixtures/install-tree/zcode.json +++ b/tests/fixtures/install-tree/zcode.json @@ -344,6 +344,7 @@ "gsd-core/workflows/execute-phase/steps/post-merge-gate.md", "gsd-core/workflows/execute-phase/steps/regression-gate-run.md", "gsd-core/workflows/execute-phase/steps/regression-gate.md", + "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", "gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md", "gsd-core/workflows/execute-plan.md", "gsd-core/workflows/explore.md",