diff --git a/.gitignore b/.gitignore index 4bb4bc1c3..b431ccedb 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ build/ /gsd-core/bin/lib/phase-id.cjs /gsd-core/bin/lib/config-loader.cjs /gsd-core/bin/lib/model-resolver.cjs +/gsd-core/bin/lib/loop-resolver.cjs /gsd-core/bin/lib/federated-config.cjs /gsd-core/bin/lib/phase-locator.cjs /gsd-core/bin/lib/roadmap-parser.cjs diff --git a/CONTEXT.md b/CONTEXT.md index a7b03af7c..18699859e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -154,8 +154,8 @@ Generated central manifest projecting all co-located Capability declarations int ### Federated Config ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. Wired into `loadConfig` as a true no-op today: every Capability config key is still in the central config-schema, so `isCentralKey()` returns true for all of them and `values` is always empty. The channel becomes live when a key is atomically removed from the central schema at cutover (the ADR-857 migration step). `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests. -### Loop Extension Point [Planned] -A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; ~12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query (extending the `init.*` resolution seam) that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. +### Loop Extension Point +A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. Wiring a workflow to call this query is the ADR-857 phase-6 cutover (out of scope here). ### Runtime Capability [Planned] A `role: runtime` variant of a Capability (a Capability carries `role: feature | runtime`) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2328a5fa0..145f7208f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -372,6 +372,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | +| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; filters `byLoopPoint` by config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks ` | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index a7e3bbc41..e44086525 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -309,6 +309,7 @@ "learnings.cjs", "legacy-cleanup.cjs", "loop-host-contract.cjs", + "loop-resolver.cjs", "milestone.cjs", "model-catalog.cjs", "model-profiles.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 31def503e..01bd82715 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (100 shipped) +## CLI Modules (101 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -420,6 +420,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | +| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; given a canonical loop point, filters `byLoopPoint` by config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks ` | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | diff --git a/eslint.config.mjs b/eslint.config.mjs index 876d53be2..644bf64aa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -74,6 +74,7 @@ export default tseslint.config( 'gsd-core/bin/lib/config-schema.cjs', 'gsd-core/bin/lib/model-profiles.cjs', 'gsd-core/bin/lib/model-resolver.cjs', + 'gsd-core/bin/lib/loop-resolver.cjs', 'gsd-core/bin/lib/federated-config.cjs', 'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs', 'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7d50dffcd..33907e867 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -163,6 +163,12 @@ * learnings prune --older-than Remove entries older than duration (e.g. 90d) * learnings delete Delete a learning by ID * + * Loop Extension Point Queries (ADR-857 phase 3c): + * loop render-hooks Resolve + render active Capability hooks at a loop point + * Returns JSON envelope { point, activeHooks, rendered } + * Valid points: discuss:pre/post, plan:pre/post, + * execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post + * * GSD-2 Migration: * from-gsd2 [--path ] [--force] [--dry-run] * Import a GSD-2 (.gsd/) project back to GSD v1 (.planning/) format @@ -201,6 +207,7 @@ const { routeVerifyCommand } = require('./lib/verify-command-router.cjs'); const { routeVerificationCommand } = require('./lib/verification-command-router.cjs'); const verification = require('./lib/verification.cjs'); const { routeInitCommand } = require('./lib/init-command-router.cjs'); +const loopResolver = require('./lib/loop-resolver.cjs'); const { routePhaseCommand } = require('./lib/phase-command-router.cjs'); const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); const { routeValidateCommand } = require('./lib/validate-command-router.cjs'); @@ -379,7 +386,7 @@ async function main() { 'current-timestamp, detect-custom-files, docs-init, effort, extract-messages, find-phase, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + - 'classify-confidence, learnings, list-todos, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + + 'classify-confidence, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + 'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + 'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + @@ -1118,6 +1125,20 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'loop': { + // loop render-hooks + const loopSubcommand = args[1]; + if (loopSubcommand === 'render-hooks') { + loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {}); + } else { + error( + `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`, + core.ERROR_REASON ? core.ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined, + ); + } + break; + } + case 'phase-plan-index': { phase.cmdPhasePlanIndex(cwd, args[1], raw); break; diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts new file mode 100644 index 000000000..6dbedee3e --- /dev/null +++ b/src/loop-resolver.cts @@ -0,0 +1,533 @@ +/** + * Loop Resolver — ADR-857 phase 3c registry-consuming query + * + * Given a loop point (one of the 12 canonical points from loop-host-contract.cjs), + * filters the materialized Capability Registry by config activation and returns + * the active hooks as a JSON envelope with a rendered-markdown field. + * + * REGISTRY-ONLY: no workflow calls this yet (phase-6 cutover is out of scope). + * + * Command surface: gsd-tools loop render-hooks + * + * Exports (three things): + * resolveLoopHooks({ point, registry, config }) → { point, activeHooks } + * renderLoopHooks(resolved) → markdown string + * cmdLoopRenderHooks(cwd, point, raw, options) — I/O entry point + * + * Both pure functions (resolveLoopHooks, renderLoopHooks) take explicit + * registry/config arguments so they are trivially testable without I/O. + * + * Dependencies (leaf modules only — no core.cjs circular risk): + * - node:fs / node:path (raw config.json read for capability-key activation) + * - ./config-loader.cjs (loadConfig) + * - ./planning-workspace.cjs (planningDir — to locate config.json) + * - ./core.cjs (output, error) + * - loop-host-contract.cjs (CANONICAL_POINTS via LOOP_HOST_CONTRACT) + * - capability-registry.cjs (byLoopPoint, consumed at call time) + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import core = require('./core.cjs'); +const { output: coreOutput, error: coreError } = core; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import configLoaderModule = require('./config-loader.cjs'); +const { loadConfig } = configLoaderModule; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspaceMod = require('./planning-workspace.cjs'); +const { planningDir, planningRoot } = planningWorkspaceMod; + +// ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ─── + +// FIX 2: Derive the authoritative canonical set from LOOP_HOST_CONTRACT so it +// cannot drift from the host contract. CANONICAL_POINTS_FALLBACK is kept as an +// alias for backward compatibility in tests and exports. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const _loopHostContract = require('./loop-host-contract.cjs') as { LOOP_HOST_CONTRACT: Array<{ points: string[] }> }; +const CANONICAL_POINTS: ReadonlyArray = (() => { + try { + const contract = _loopHostContract.LOOP_HOST_CONTRACT; + if (Array.isArray(contract)) { + const pts: string[] = []; + for (const step of contract) { + if (step && Array.isArray(step.points)) { + for (const p of step.points) { + if (typeof p === 'string') pts.push(p); + } + } + } + if (pts.length > 0) return pts; + } + } catch { /* fall through to hardcoded fallback */ } + return [ + 'discuss:pre', + 'discuss:post', + 'plan:pre', + 'plan:post', + 'execute:pre', + 'execute:wave:pre', + 'execute:wave:post', + 'execute:post', + 'verify:pre', + 'verify:post', + 'ship:pre', + 'ship:post', + ]; +})(); + +// Alias for backward compatibility (tests import this name) +const CANONICAL_POINTS_FALLBACK: ReadonlyArray = CANONICAL_POINTS; + +// FIX 2: _getCanonicalPoints now returns the authoritative CANONICAL_POINTS set +// derived from LOOP_HOST_CONTRACT — not the registry's byLoopPoint keys. +// The registry's byLoopPoint is only used to READ hooks, not to define valid points. +function _getCanonicalPoints(_registry: Record): ReadonlyArray { + return CANONICAL_POINTS; +} + +// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ─────────────── + +/** + * Traverse a dotted config key through a nested config object. + * E.g. "workflow.ui_phase" in { workflow: { ui_phase: true } } → { found: true, value: true } + * Returns { found: false } if any segment is a forbidden key or not an own property. + */ +function _getNestedConfigValue( + config: Record, + dotKey: string, +): { found: boolean; value: unknown } { + const segments = dotKey.split('.'); + let current: unknown = config; + for (const seg of segments) { + // Inline literal prototype-pollution guard (CodeQL barrier) + if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { + return { found: false, value: undefined }; + } + if (typeof current !== 'object' || current === null) { + return { found: false, value: undefined }; + } + const cur = current as Record; + if (!Object.prototype.hasOwnProperty.call(cur, seg)) { + return { found: false, value: undefined }; + } + current = cur[seg]; + } + return { found: true, value: current }; +} + +// ─── Single-key activation resolver (FIX 1) ─────────────────────────────────── + +/** + * Warn-once set for raw config.json parse errors. + * Avoids noisy per-call stderr from a single malformed file. + */ +const _warnedRawConfigPaths = new Set(); + +/** + * Read a raw config.json file and perform a guarded nested-lookup of a single + * dotted key. Returns { found: false } if the file is missing (ENOENT) or if + * the key is absent/forbidden. On a genuine JSON parse error: warns once to + * stderr and returns { found: false } — never throws. + */ +function _readRawConfigKey( + filePath: string, + dotKey: string, +): { found: boolean; value: unknown } { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + if (!_warnedRawConfigPaths.has(filePath)) { + _warnedRawConfigPaths.add(filePath); + try { + process.stderr.write( + `gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`, + ); + } catch { /* stderr might be closed */ } + } + return { found: false, value: undefined }; + } + return _getNestedConfigValue(parsed, dotKey); + } catch { + // ENOENT (missing file) is expected → skip silently. All other errors → also skip (defensive). + return { found: false, value: undefined }; + } +} + +/** + * FIX 1: Resolve the effective value for a hook's `when` key using the + * four-level precedence: + * + * 1. loadConfig result (`config` arg) — guarded nested-lookup of the dotted key. + * This is the post-cutover federated path (covers keys that loadConfig now exposes). + * 2. Raw workstream `.planning/.../config.json` — guarded single-key lookup. + * Workstream wins over root (mirrors loadConfig inheritance). + * 3. Raw root `.planning/config.json` — guarded single-key lookup. + * 4. `registry.configSchema[when]?.default` — schema default. + * A `default: true` hook is active out-of-the-box without any config. + * 5. Absent → inactive (return false). + * + * Never constructs a merged object from raw JSON keys — only reads the single + * leaf value at the guarded dotted path. Prototype-pollution sink is eliminated. + */ +function _resolveActivationValue( + dotKey: string, + config: Record, + cwd: string | undefined, + registry: Record, +): boolean { + // Level 1: loadConfig result + const fromConfig = _getNestedConfigValue(config, dotKey); + if (fromConfig.found) return Boolean(fromConfig.value); + + // Level 2 + 3: raw config.json files (only when cwd is available) + if (cwd) { + // Level 2: workstream config (planningDir respects GSD_WORKSTREAM env) + const wsConfigPath = path.join(planningDir(cwd), 'config.json'); + // Level 3: root config (planningRoot = cwd/.planning always) + const rootConfigPath = path.join(planningRoot(cwd), 'config.json'); + + // Workstream wins over root (mirroring loadConfig root→workstream precedence: + // workstream overlays root, so workstream value takes precedence). + const fromWs = _readRawConfigKey(wsConfigPath, dotKey); + if (fromWs.found) return Boolean(fromWs.value); + + // Only read root if it differs from the workstream path (avoids double-read + // when no workstream is active and both paths resolve to the same file). + if (wsConfigPath !== rootConfigPath) { + const fromRoot = _readRawConfigKey(rootConfigPath, dotKey); + if (fromRoot.found) return Boolean(fromRoot.value); + } + } + + // Level 4: registry configSchema default + const schemaEntry = (registry['configSchema'] as Record | undefined)?.[dotKey]; + if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) { + const def = (schemaEntry as Record)['default']; + if (def !== undefined) return Boolean(def); + } + + // Level 5: absent → inactive + return false; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface HookRef { + skill?: string; + [key: string]: unknown; +} + +interface RawHook { + capId?: unknown; + point?: unknown; + ref?: unknown; + into?: unknown; + produces?: unknown; + consumes?: unknown; + when?: unknown; + onError?: unknown; + blocking?: unknown; + check?: unknown; +} + +type HookKind = 'step' | 'contribution' | 'gate'; + +interface ActiveHook { + capId: string; + kind: HookKind; + ref?: HookRef; + into?: string; + when?: string; + produces?: string[]; + consumes?: string[]; + blocking?: boolean; + check?: unknown; + onError?: string; +} + +interface ResolveLoopHooksInput { + point: string; + registry: Record; + config: Record; + /** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */ + cwd?: string; +} + +interface ResolveLoopHooksResult { + point: string; + activeHooks: ActiveHook[]; +} + +// ─── Pure resolver ───────────────────────────────────────────────────────────── + +/** + * Pure resolver: given a point, registry, and config, returns the active hooks. + * + * Throws if `point` is not one of the 12 canonical points (caller converts to + * core.error). Never throws for malformed registry/hook entries — skips and + * continues. + * + * Ordering: steps first, then contributions, then gates. Within each array, + * the materialized registry order is preserved. + * + * Activation: a hook with no `when` is always active. With `when` (dotted key), + * resolved against `config`; active iff truthy. Inactive hooks are filtered out. + */ +function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult { + const { point, registry, config, cwd } = input; + + // Validate point + const canonicalPoints = _getCanonicalPoints(registry); + if (!canonicalPoints.includes(point)) { + throw new Error( + `Invalid loop point: "${point}". Valid points: ${canonicalPoints.join(', ')}`, + ); + } + + // Guard: registry missing byLoopPoint + const byLoopPoint = registry['byLoopPoint']; + if (!byLoopPoint || typeof byLoopPoint !== 'object' || Array.isArray(byLoopPoint)) { + return { point, activeHooks: [] }; + } + const byLoopPointMap = byLoopPoint as Record; + + // Guard: point missing in registry + const entry = byLoopPointMap[point]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { point, activeHooks: [] }; + } + const entryMap = entry as Record; + + const activeHooks: ActiveHook[] = []; + + // Helper: check activation using single-key precedence resolver (FIX 1 + FIX 3) + function isActive(hook: RawHook): boolean { + const when = hook['when']; + // No `when` → unconditional hook, always active + if (when === undefined || when === null) return true; + // FIX 3: `when` present but not a non-empty string → malformed registry data → INACTIVE + if (typeof when !== 'string' || when.length === 0) return false; + return _resolveActivationValue(when, config, cwd, registry); + } + + // Helper: safe string array + function toStringArray(v: unknown): string[] { + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === 'string'); + } + + // Process steps + const stepsRaw = entryMap['steps']; + const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : []; + for (const hook of steps) { + if (!hook || typeof hook !== 'object') continue; + if (!isActive(hook)) continue; + const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null) + ? (hook['ref'] as HookRef) + : undefined; + const when = typeof hook['when'] === 'string' ? hook['when'] : undefined; + const produces = toStringArray(hook['produces']); + const consumes = toStringArray(hook['consumes']); + const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined; + const active: ActiveHook = { capId, kind: 'step' }; + if (ref !== undefined) active.ref = ref; + if (when !== undefined) active.when = when; + if (produces.length > 0) active.produces = produces; + if (consumes.length > 0) active.consumes = consumes; + if (onError !== undefined) active.onError = onError; + activeHooks.push(active); + } + + // Process contributions + const contributionsRaw = entryMap['contributions']; + const contributions: RawHook[] = Array.isArray(contributionsRaw) ? (contributionsRaw as RawHook[]) : []; + for (const hook of contributions) { + if (!hook || typeof hook !== 'object') continue; + if (!isActive(hook)) continue; + const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + const into = typeof hook['into'] === 'string' ? hook['into'] : undefined; + const when = typeof hook['when'] === 'string' ? hook['when'] : undefined; + const produces = toStringArray(hook['produces']); + const consumes = toStringArray(hook['consumes']); + const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined; + const active: ActiveHook = { capId, kind: 'contribution' }; + if (into !== undefined) active.into = into; + if (when !== undefined) active.when = when; + if (produces.length > 0) active.produces = produces; + if (consumes.length > 0) active.consumes = consumes; + if (onError !== undefined) active.onError = onError; + activeHooks.push(active); + } + + // Process gates + const gatesRaw = entryMap['gates']; + const gates: RawHook[] = Array.isArray(gatesRaw) ? (gatesRaw as RawHook[]) : []; + for (const hook of gates) { + if (!hook || typeof hook !== 'object') continue; + if (!isActive(hook)) continue; + const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + const when = typeof hook['when'] === 'string' ? hook['when'] : undefined; + const check = hook['check'] !== undefined ? hook['check'] : undefined; + const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined; + const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined; + const active: ActiveHook = { capId, kind: 'gate' }; + if (when !== undefined) active.when = when; + if (check !== undefined) active.check = check; + if (blocking !== undefined) active.blocking = blocking; + if (onError !== undefined) active.onError = onError; + activeHooks.push(active); + } + + return { point, activeHooks }; +} + +// ─── Pure renderer ───────────────────────────────────────────────────────────── + +/** + * Pure renderer: given a resolved result, returns a deterministic markdown string. + * + * Empty active set → returns a "no active hooks" placeholder line. + * Steps: heading with ordinal + skill ref + capId, produces/consumes lines. + * Contributions: labeled block. + * Gates: check name, blocking flag, onError. + */ +function renderLoopHooks(resolved: ResolveLoopHooksResult): string { + const { point, activeHooks } = resolved; + + if (activeHooks.length === 0) { + return `_No active hooks at ${point}._`; + } + + const lines: string[] = []; + let stepOrdinal = 0; + + for (const hook of activeHooks) { + if (hook.kind === 'step') { + stepOrdinal += 1; + const refStr = hook.ref?.skill + ? `skill:${hook.ref.skill}` + : JSON.stringify(hook.ref ?? {}); + lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`); + if (hook.produces && hook.produces.length > 0) { + lines.push(`- produces: ${hook.produces.join(', ')}`); + } + if (hook.consumes && hook.consumes.length > 0) { + lines.push(`- consumes: ${hook.consumes.join(', ')}`); + } + if (hook.when) { + lines.push(`- when: \`${hook.when}\``); + } + if (hook.onError) { + lines.push(`- onError: ${hook.onError}`); + } + lines.push(''); + } else if (hook.kind === 'contribution') { + lines.push(``); + if (hook.produces && hook.produces.length > 0) { + lines.push(`- produces: ${hook.produces.join(', ')}`); + } + if (hook.consumes && hook.consumes.length > 0) { + lines.push(`- consumes: ${hook.consumes.join(', ')}`); + } + if (hook.when) { + lines.push(`- when: \`${hook.when}\``); + } + lines.push(''); + } else if (hook.kind === 'gate') { + let checkStr = '(none)'; + if (hook.check !== undefined && hook.check !== null) { + checkStr = typeof hook.check === 'object' + ? JSON.stringify(hook.check) + : typeof hook.check === 'string' || typeof hook.check === 'number' || typeof hook.check === 'boolean' + ? String(hook.check) + : '(complex)'; + } + lines.push(`**Gate** (${hook.capId}): check=${checkStr}, blocking=${String(hook.blocking ?? false)}, onError=${hook.onError ?? 'skip'}`); + if (hook.when) { + lines.push(`- when: \`${hook.when}\``); + } + lines.push(''); + } + } + + // Trim trailing blank line + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines.join('\n'); +} + +// ─── I/O command handler ─────────────────────────────────────────────────────── + +/** + * Command entry point: load registry + config, resolve + render, emit envelope. + * + * Envelope: { point, activeHooks, rendered } + * On invalid point, emits core.error instead of throwing. + * + * Config note: FIX 1 replaced _loadMergedConfig (whole-config deep-merge) with a + * per-hook single-key activation resolver (_resolveActivationValue). The resolver + * checks loadConfig result first, then raw config.json files directly (workstream + * then root), then the registry's configSchema default. This eliminates the + * merged-object-from-untrusted-keys security concern and correctly handles + * pre-cutover keys like `workflow.ui_phase` that live in config.json but are not + * yet exposed through loadConfig's whitelist. + */ +function cmdLoopRenderHooks( + cwd: string, + point: string, + raw: boolean, + _options: Record = {}, +): void { + if (!point) { + coreError('loop render-hooks requires a argument. Valid points: ' + CANONICAL_POINTS.join(', ')); + return; + } + + // Load registry at call time (generated file, not at module load time) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const registry = require('./capability-registry.cjs') as Record; + // FIX 1: Pass loadConfig result as `config` (level 1 of precedence); + // raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue + // via the `cwd` argument passed to resolveLoopHooks. + const config = loadConfig(cwd); + + let resolved: ResolveLoopHooksResult; + try { + resolved = resolveLoopHooks({ point, registry, config, cwd }); + } catch (err: unknown) { + const msg = (err instanceof Error) ? err.message : String(err); + coreError(msg); + return; + } + + const rendered = renderLoopHooks(resolved); + const envelope = { + point: resolved.point, + activeHooks: resolved.activeHooks, + rendered, + }; + + coreOutput(envelope, raw); +} + +export = { + resolveLoopHooks, + renderLoopHooks, + cmdLoopRenderHooks, + // Exported for tests + _getNestedConfigValue, + _resolveActivationValue, + _readRawConfigKey, + CANONICAL_POINTS_FALLBACK, + CANONICAL_POINTS, +}; diff --git a/tests/loop-render-hooks.test.cjs b/tests/loop-render-hooks.test.cjs new file mode 100644 index 000000000..8e75784e9 --- /dev/null +++ b/tests/loop-render-hooks.test.cjs @@ -0,0 +1,777 @@ +'use strict'; + +/** + * loop-render-hooks.test.cjs — behavioral tests for loop-resolver.cjs. + * + * ADR-857 phase 3c. + * Uses node:test + node:assert/strict. + * Pure-function tests (resolveLoopHooks, renderLoopHooks) pass registry+config + * directly — no I/O. End-to-end tests use cmdLoopRenderHooks + a temp project. + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { cleanup } = require('./helpers.cjs'); + +const { + resolveLoopHooks, + renderLoopHooks, + _getNestedConfigValue, + _resolveActivationValue, + _readRawConfigKey, + CANONICAL_POINTS_FALLBACK, + CANONICAL_POINTS, +} = require('../gsd-core/bin/lib/loop-resolver.cjs'); + +// The real registry for integration tests +const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +// ─── Synthetic registry fixtures ───────────────────────────────────────────── + +/** + * Build a minimal synthetic registry with a single step hook at a given point. + * Optionally include a configSchema for testing default-based activation. + */ +function makeRegistry({ point = 'plan:pre', steps = [], contributions = [], gates = {}, configSchema = {} } = {}) { + const byLoopPoint = {}; + for (const p of CANONICAL_POINTS_FALLBACK) { + byLoopPoint[p] = { steps: [], contributions: [], gates: [] }; + } + if (steps.length) byLoopPoint[point].steps = steps; + if (contributions.length) byLoopPoint[point].contributions = contributions; + if (gates[point]) byLoopPoint[point].gates = gates[point]; + return { byLoopPoint, configSchema }; +} + +// ─── Temp project helpers ───────────────────────────────────────────────────── + +let tmpProjectDir; +// A project with NO .planning/config.json — relies on schema defaults +let tmpEmptyProjectDir; +// A project where ui_phase is explicitly false in root config +let tmpFalseConfigProjectDir; + +before(() => { + tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-test-')); + const planningDir = path.join(tmpProjectDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + // Write minimal config.json with all UI flags enabled + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }), + 'utf8', + ); + + // Empty project — no config.json: schema defaults drive activation + tmpEmptyProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-empty-')); + fs.mkdirSync(path.join(tmpEmptyProjectDir, '.planning'), { recursive: true }); + + // False config project — ui_phase explicitly false in root config + tmpFalseConfigProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-false-')); + const falseConfigPlanningDir = path.join(tmpFalseConfigProjectDir, '.planning'); + fs.mkdirSync(falseConfigPlanningDir, { recursive: true }); + fs.writeFileSync( + path.join(falseConfigPlanningDir, 'config.json'), + JSON.stringify({ workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } }), + 'utf8', + ); +}); + +after(() => { + if (tmpProjectDir) cleanup(tmpProjectDir); + if (tmpEmptyProjectDir) cleanup(tmpEmptyProjectDir); + if (tmpFalseConfigProjectDir) cleanup(tmpFalseConfigProjectDir); +}); + +// ─── 1. Canonical-point validation ─────────────────────────────────────────── + +describe('canonical point validation', () => { + test('all 12 canonical points are accepted by resolveLoopHooks with empty registry', () => { + const emptyRegistry = makeRegistry(); + const config = {}; + for (const p of CANONICAL_POINTS_FALLBACK) { + const result = resolveLoopHooks({ point: p, registry: emptyRegistry, config }); + assert.strictEqual(result.point, p); + assert.deepEqual(result.activeHooks, []); + } + }); + + test('12 canonical points total', () => { + assert.strictEqual(CANONICAL_POINTS_FALLBACK.length, 12); + }); + + test('invalid point throws with a clear message', () => { + const emptyRegistry = makeRegistry(); + assert.throws( + () => resolveLoopHooks({ point: 'plan:mid', registry: emptyRegistry, config: {} }), + (err) => { + assert.ok(err instanceof Error); + assert.match(err.message, /Invalid loop point/); + assert.match(err.message, /plan:mid/); + return true; + }, + ); + }); + + test('empty string point throws', () => { + const emptyRegistry = makeRegistry(); + assert.throws( + () => resolveLoopHooks({ point: '', registry: emptyRegistry, config: {} }), + /Invalid loop point/, + ); + }); + + test('close typo throws', () => { + const emptyRegistry = makeRegistry(); + assert.throws( + () => resolveLoopHooks({ point: 'plan:pre ', registry: emptyRegistry, config: {} }), + /Invalid loop point/, + ); + }); + + // FIX 2: non-canonical point rejected even if the registry has it as a byLoopPoint key + test('non-canonical point in registry byLoopPoint is still rejected', () => { + // Craft a registry that has a synthetic non-canonical key in byLoopPoint + const registry = { + byLoopPoint: { + // All canonical points (needed so the registry is well-formed) + ...Object.fromEntries(CANONICAL_POINTS_FALLBACK.map(p => [p, { steps: [], contributions: [], gates: [] }])), + // A non-canonical key that a malformed registry might inject + 'inject:arbitrary': { steps: [{ capId: 'evil', ref: { skill: 'bad' } }], contributions: [], gates: [] }, + }, + }; + assert.throws( + () => resolveLoopHooks({ point: 'inject:arbitrary', registry, config: {} }), + /Invalid loop point/, + ); + }); + + // FIX 2: all 12 canonical points are listed in the error message + test('invalid point error lists the canonical 12', () => { + const emptyRegistry = makeRegistry(); + assert.throws( + () => resolveLoopHooks({ point: 'not:real', registry: emptyRegistry, config: {} }), + (err) => { + assert.ok(err instanceof Error); + for (const p of CANONICAL_POINTS_FALLBACK) { + assert.ok(err.message.includes(p), `Expected "${p}" in error message: ${err.message}`); + } + return true; + }, + ); + }); + + // CANONICAL_POINTS is derived from LOOP_HOST_CONTRACT, not from registry keys + test('CANONICAL_POINTS and CANONICAL_POINTS_FALLBACK are the same 12 points', () => { + assert.deepEqual(CANONICAL_POINTS, CANONICAL_POINTS_FALLBACK); + assert.strictEqual(CANONICAL_POINTS.length, 12); + }); +}); + +// ─── 2. Activation tests ───────────────────────────────────────────────────── + +describe('activation filter', () => { + test('hook with no "when" is always active', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' } }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 1); + assert.strictEqual(result.activeHooks[0].capId, 'test-cap'); + }); + + test('hook with when="mytool.on", config{mytool:{on:true}} → active', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + }); + const config = { mytool: { on: true } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 1); + assert.strictEqual(result.activeHooks[0].kind, 'step'); + }); + + test('hook with when="mytool.on", config{mytool:{on:false}} → filtered', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + }); + const config = { mytool: { on: false } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('hook with when="mytool.on", config{} (absent key) → filtered', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('hook with when="mytool.on", config{mytool:{}} → filtered (key absent)', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + }); + const config = { mytool: {} }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + // FIX 3: non-string `when` → INACTIVE (not always-active) + test('hook with when=true (boolean) → inactive (FIX 3: malformed non-string when)', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: true }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0, 'non-string when=true must be treated as inactive'); + }); + + test('hook with when=42 (number) → inactive (FIX 3)', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 42 }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0, 'non-string when=42 must be inactive'); + }); + + test('hook with when={} (object) → inactive (FIX 3)', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: {} }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0, 'non-string when={} must be inactive'); + }); + + // FIX 4: configSchema default=true → active with absent config (no cwd → level 4 applies) + test('configSchema default=true + absent config → active', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + configSchema: { + 'mytool.on': { type: 'boolean', default: true, description: 'Enable mytool.' }, + }, + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 1, 'schema default=true should activate the hook'); + assert.strictEqual(result.activeHooks[0].capId, 'test-cap'); + }); + + // FIX 4: configSchema default=false → inactive with absent config + test('configSchema default=false + absent config → inactive', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + configSchema: { + 'mytool.on': { type: 'boolean', default: false, description: 'Disabled by default.' }, + }, + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0, 'schema default=false should keep hook inactive'); + }); + + // FIX 4: configSchema default=true but explicit config override=false → inactive (config wins) + test('configSchema default=true but config override false → inactive (config wins)', () => { + const registry = makeRegistry({ + steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }], + configSchema: { + 'mytool.on': { type: 'boolean', default: true, description: 'Enabled by default.' }, + }, + }); + const config = { mytool: { on: false } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 0, 'explicit config=false overrides schema default=true'); + }); +}); + +// ─── 3. UI pilot integration tests ─────────────────────────────────────────── + +describe('UI pilot integration', () => { + test('plan:pre with workflow.ui_phase=true → ui-phase step active', () => { + const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok(uiStep, 'Expected ui step at plan:pre'); + assert.deepEqual(uiStep.ref, { skill: 'ui-phase' }); + assert.ok(Array.isArray(uiStep.produces)); + assert.ok(uiStep.produces.includes('UI-SPEC.md')); + }); + + test('plan:pre with workflow.ui_phase=false → ui-phase step filtered', () => { + const config = { workflow: { ui_phase: false, ui_review: true, ui_safety_gate: true } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.strictEqual(uiStep, undefined, 'Expected ui step to be filtered'); + }); + + // FIX 4 INVERSION: empty config + real registry → ui-phase IS active (schema default=true) + test('plan:pre with empty config + real registry → ui-phase step active by default (FIX 4)', () => { + // realRegistry has configSchema['workflow.ui_phase'].default === true + // So with no config and no cwd, the schema default kicks in → active + const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config: {} }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok( + uiStep, + 'Expected ui step to be active by default (configSchema.default=true). Got: ' + + JSON.stringify(result.activeHooks), + ); + assert.strictEqual(uiStep.when, 'workflow.ui_phase'); + }); + + test('execute:wave:post with workflow.ui_safety_gate=true → ui gate active', () => { + const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }; + const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config }); + const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate'); + assert.ok(uiGate, 'Expected ui gate at execute:wave:post'); + assert.strictEqual(uiGate.blocking, true); + assert.strictEqual(uiGate.onError, 'halt'); + }); + + test('execute:wave:post with workflow.ui_safety_gate=false → ui gate filtered', () => { + const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } }; + const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config }); + const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate'); + assert.strictEqual(uiGate, undefined, 'Expected ui gate to be filtered'); + }); + + // FIX 4: execute:wave:post with empty config → ui gate active by schema default + test('execute:wave:post with empty config → ui gate active by schema default', () => { + const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config: {} }); + const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate'); + assert.ok(uiGate, 'Expected ui gate active by default (configSchema.default=true)'); + assert.strictEqual(uiGate.blocking, true); + }); +}); + +// ─── 4. Ordering tests ──────────────────────────────────────────────────────── + +describe('hook ordering', () => { + test('steps appear before contributions before gates', () => { + const registry = makeRegistry({ + point: 'plan:pre', + steps: [{ capId: 'c1', point: 'plan:pre', ref: { skill: 'sk1' } }], + contributions: [{ capId: 'c2', point: 'plan:pre', into: 'planner' }], + gates: { 'plan:pre': [{ capId: 'c3', point: 'plan:pre', check: { query: 'some-gate' }, blocking: false }] }, + }); + const config = {}; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 3); + assert.strictEqual(result.activeHooks[0].kind, 'step'); + assert.strictEqual(result.activeHooks[1].kind, 'contribution'); + assert.strictEqual(result.activeHooks[2].kind, 'gate'); + }); + + test('within steps, registry order is preserved', () => { + const registry = makeRegistry({ + point: 'plan:pre', + steps: [ + { capId: 'cap-a', point: 'plan:pre', ref: { skill: 'a' } }, + { capId: 'cap-b', point: 'plan:pre', ref: { skill: 'b' } }, + { capId: 'cap-c', point: 'plan:pre', ref: { skill: 'c' } }, + ], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.deepEqual(result.activeHooks.map(h => h.capId), ['cap-a', 'cap-b', 'cap-c']); + }); +}); + +// ─── 5. Envelope shape ──────────────────────────────────────────────────────── + +describe('envelope shape', () => { + test('envelope has point, activeHooks, rendered from renderLoopHooks', () => { + const registry = makeRegistry({ + steps: [{ capId: 'cap-a', point: 'plan:pre', ref: { skill: 'my-skill' }, produces: ['A.md'], consumes: ['B.md'] }], + }); + const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + const rendered = renderLoopHooks(resolved); + assert.strictEqual(resolved.point, 'plan:pre'); + assert.ok(Array.isArray(resolved.activeHooks)); + assert.strictEqual(typeof rendered, 'string'); + }); + + test('empty activeHooks → rendered is non-empty placeholder string', () => { + const registry = makeRegistry(); // all empty + const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + const rendered = renderLoopHooks(resolved); + assert.strictEqual(resolved.activeHooks.length, 0); + assert.ok(rendered.length > 0, 'rendered should be a non-empty placeholder'); + assert.match(rendered, /plan:pre/); + }); + + test('rendered contains hook content when hooks are active', () => { + const registry = makeRegistry({ + steps: [{ capId: 'ui', point: 'plan:pre', ref: { skill: 'ui-phase' }, produces: ['UI-SPEC.md'], consumes: ['CONTEXT.md'], when: 'workflow.ui_phase', onError: 'skip' }], + }); + const config = { workflow: { ui_phase: true } }; + const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config }); + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /ui-phase/); + assert.match(rendered, /ui/); + assert.match(rendered, /UI-SPEC\.md/); + }); + + test('rendered for UI pilot at plan:pre with all flags on', () => { + const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }; + const resolved = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config }); + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /ui-phase/); + assert.match(rendered, /UI-SPEC\.md/); + }); +}); + +// ─── 6. Malformed registry resilience ──────────────────────────────────────── + +describe('malformed registry resilience', () => { + test('missing byLoopPoint → no throw, empty activeHooks', () => { + const badRegistry = {}; // no byLoopPoint + // No throw — but point validation falls back to CANONICAL_POINTS_FALLBACK + const result = resolveLoopHooks({ point: 'plan:pre', registry: badRegistry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('null hook in steps array → skipped', () => { + const registry = makeRegistry({ + steps: [null, { capId: 'ok', point: 'plan:pre', ref: { skill: 'ok-skill' } }, undefined], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 1); + assert.strictEqual(result.activeHooks[0].capId, 'ok'); + }); + + test('byLoopPoint[point] missing arrays → no throw, empty result', () => { + const registry = { byLoopPoint: { 'plan:pre': {} } }; // no steps/contributions/gates keys + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('byLoopPoint[point] has non-array steps → treated as empty', () => { + const registry = { byLoopPoint: { 'plan:pre': { steps: 'bad', contributions: [], gates: [] } } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('byLoopPoint[point] is null → no throw, empty result', () => { + const registry = { byLoopPoint: { 'plan:pre': null } }; + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); +}); + +// ─── 7. Prototype-pollution guard ──────────────────────────────────────────── + +describe('prototype-pollution guard', () => { + test('when="__proto__.x" does not pollute Object.prototype', () => { + const registry = makeRegistry({ + steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: '__proto__.x' }], + }); + const config = { x: 'injected' }; + // Should not throw and should not activate (guard returns found:false) + const result = resolveLoopHooks({ point: 'plan:pre', registry, config }); + assert.strictEqual(result.activeHooks.length, 0); + // Object.prototype must not be polluted + assert.strictEqual(({}).x, undefined); + }); + + test('when="constructor.x" does not pollute', () => { + const registry = makeRegistry({ + steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: 'constructor.x' }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('when="prototype.x" does not pollute', () => { + const registry = makeRegistry({ + steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: 'prototype.x' }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} }); + assert.strictEqual(result.activeHooks.length, 0); + }); + + test('_getNestedConfigValue: __proto__ segment returns found:false', () => { + const r = _getNestedConfigValue({}, '__proto__.x'); + assert.strictEqual(r.found, false); + }); + + test('_getNestedConfigValue: constructor segment returns found:false', () => { + const r = _getNestedConfigValue({}, 'constructor.toString'); + assert.strictEqual(r.found, false); + }); + + test('_getNestedConfigValue: normal dotted key traversal works', () => { + const config = { workflow: { ui_phase: true } }; + const r = _getNestedConfigValue(config, 'workflow.ui_phase'); + assert.strictEqual(r.found, true); + assert.strictEqual(r.value, true); + }); + + // FIX 4: raw config.json with __proto__ key does not pollute via _readRawConfigKey + test('raw config.json with "__proto__" key does not pollute Object.prototype', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-proto-')); + try { + // Write a raw config.json containing __proto__ at top level and nested + // (JSON.parse of {"__proto__":{"x":"polluted"}} does NOT set prototype in modern Node, + // but we verify our guarded traversal returns found:false for such keys) + const maliciousConfig = '{"__proto__":{"x":"polluted"},"workflow":{"ui_phase":true}}'; + fs.writeFileSync(path.join(tmpDir, 'config.json'), maliciousConfig, 'utf8'); + // _readRawConfigKey with '__proto__.x' should return found:false (guard) + const r1 = _readRawConfigKey(path.join(tmpDir, 'config.json'), '__proto__.x'); + assert.strictEqual(r1.found, false, '__proto__ lookup must be guarded'); + // Normal key should work + const r2 = _readRawConfigKey(path.join(tmpDir, 'config.json'), 'workflow.ui_phase'); + assert.strictEqual(r2.found, true); + assert.strictEqual(r2.value, true); + // Object.prototype must not be polluted + assert.strictEqual(({}).x, undefined); + } finally { + cleanup(tmpDir); + } + }); + + // FIX 4: _resolveActivationValue with cwd pointing to project with __proto__ config key + test('_resolveActivationValue: raw config with __proto__ key does not pollute', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-proto2-')); + try { + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync( + path.join(planningDir, 'config.json'), + '{"__proto__":{"y":"polluted2"}}', + 'utf8', + ); + const registry = makeRegistry({ + steps: [{ capId: 'test', point: 'plan:pre', ref: { skill: 'sk' }, when: '__proto__.y' }], + }); + const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {}, cwd: tmpDir }); + assert.strictEqual(result.activeHooks.length, 0, '__proto__ when must be inactive'); + assert.strictEqual(({}).y, undefined, 'Object.prototype.y must not be polluted'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── 7b. Raw config.json override paths (FIX 4) ────────────────────────────── + +describe('raw config.json override paths (FIX 4)', () => { + // FIX 4: user sets workflow.ui_phase=false in root config.json → hook filtered + test('root config.json with ui_phase=false overrides schema default=true → inactive', () => { + // tmpFalseConfigProjectDir has .planning/config.json { workflow: { ui_phase: false } } + const result = resolveLoopHooks({ + point: 'plan:pre', + registry: realRegistry, + config: {}, // empty loadConfig result (simulating pre-cutover) + cwd: tmpFalseConfigProjectDir, + }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.strictEqual( + uiStep, + undefined, + 'root config.json override false must beat schema default=true', + ); + }); + + // FIX 4: root config.json with ui_phase=true (explicit) → hook active + test('root config.json with ui_phase=true → active (raw config read path)', () => { + // tmpProjectDir has .planning/config.json { workflow: { ui_phase: true } } + const result = resolveLoopHooks({ + point: 'plan:pre', + registry: realRegistry, + config: {}, // empty loadConfig result (simulating pre-cutover) + cwd: tmpProjectDir, + }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok(uiStep, 'root config.json ui_phase=true should activate hook'); + }); + + // FIX 4: no config.json at all → falls through to schema default=true → active + test('no config.json → schema default=true → hook active', () => { + // tmpEmptyProjectDir has .planning/ directory but no config.json + const result = resolveLoopHooks({ + point: 'plan:pre', + registry: realRegistry, + config: {}, // empty loadConfig result + cwd: tmpEmptyProjectDir, + }); + const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok(uiStep, 'no config.json → schema default=true → hook should be active'); + }); + + // FIX 4: _readRawConfigKey returns found:false for missing file (ENOENT — silent) + test('_readRawConfigKey: missing file → found:false, no throw', () => { + const result = _readRawConfigKey('/nonexistent/path/config.json', 'workflow.ui_phase'); + assert.strictEqual(result.found, false); + }); + + // FIX 4: _readRawConfigKey returns found:false for malformed JSON, warns once + test('_readRawConfigKey: malformed JSON → found:false, no throw', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-malformed-')); + try { + const malformedPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(malformedPath, '{ invalid json }', 'utf8'); + const result = _readRawConfigKey(malformedPath, 'workflow.ui_phase'); + assert.strictEqual(result.found, false, 'malformed JSON should return found:false'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// ─── 8. Renderer tests ──────────────────────────────────────────────────────── + +describe('renderLoopHooks', () => { + test('step hook renders skill ref, capId, produces, consumes', () => { + const resolved = { + point: 'plan:pre', + activeHooks: [{ + capId: 'ui', + kind: 'step', + ref: { skill: 'ui-phase' }, + when: 'workflow.ui_phase', + produces: ['UI-SPEC.md'], + consumes: ['CONTEXT.md'], + onError: 'skip', + }], + }; + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /Step 1/); + assert.match(rendered, /skill:ui-phase/); + assert.match(rendered, /\(ui\)/); + assert.match(rendered, /UI-SPEC\.md/); + assert.match(rendered, /CONTEXT\.md/); + assert.match(rendered, /workflow\.ui_phase/); + assert.match(rendered, /skip/); + }); + + test('contribution hook renders into role', () => { + const resolved = { + point: 'plan:pre', + activeHooks: [{ + capId: 'contrib-cap', + kind: 'contribution', + into: 'planner', + }], + }; + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /contribution/); + assert.match(rendered, /contrib-cap/); + assert.match(rendered, /planner/); + }); + + test('gate hook renders check, blocking, onError', () => { + const resolved = { + point: 'execute:wave:post', + activeHooks: [{ + capId: 'ui', + kind: 'gate', + check: { query: 'ui.safety-gate' }, + blocking: true, + onError: 'halt', + }], + }; + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /Gate/); + assert.match(rendered, /ui/); + assert.match(rendered, /blocking=true/); + assert.match(rendered, /halt/); + }); + + test('multiple hooks in order render with correct ordinals', () => { + const resolved = { + point: 'plan:pre', + activeHooks: [ + { capId: 'cap-a', kind: 'step', ref: { skill: 'a' }, produces: ['A.md'], consumes: [] }, + { capId: 'cap-b', kind: 'step', ref: { skill: 'b' }, produces: ['B.md'], consumes: ['A.md'] }, + ], + }; + const rendered = renderLoopHooks(resolved); + assert.match(rendered, /Step 1/); + assert.match(rendered, /Step 2/); + const idx1 = rendered.indexOf('Step 1'); + const idx2 = rendered.indexOf('Step 2'); + assert.ok(idx1 < idx2, 'Step 1 should appear before Step 2'); + }); + + test('empty hooks returns placeholder containing the point name', () => { + const rendered = renderLoopHooks({ point: 'ship:post', activeHooks: [] }); + assert.match(rendered, /ship:post/); + assert.ok(rendered.length > 0); + }); + + test('rendered is deterministic (same input → same output)', () => { + const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }; + const resolved = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config }); + const r1 = renderLoopHooks(resolved); + const r2 = renderLoopHooks(resolved); + assert.strictEqual(r1, r2); + }); +}); + +// ─── 9. End-to-end cmdLoopRenderHooks (via gsd-tools subprocess) ───────────── + +const { spawnSync } = require('node:child_process'); +const ROOT = path.resolve(__dirname, '..'); +const GSD_TOOLS = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'); + +describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { + test('loop render-hooks plan:pre returns JSON envelope with ui-phase step active', () => { + const result = spawnSync( + process.execPath, + [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpProjectDir], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + const envelope = JSON.parse(result.stdout.trim()); + assert.strictEqual(envelope.point, 'plan:pre'); + assert.ok(Array.isArray(envelope.activeHooks)); + assert.strictEqual(typeof envelope.rendered, 'string'); + // With ui_phase=true in tmpProjectDir config, ui-phase step should be active + const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok(uiStep, 'Expected ui step in activeHooks. Got: ' + JSON.stringify(envelope.activeHooks)); + assert.match(envelope.rendered, /ui-phase/); + }); + + // FIX 4: schema-default activation — no config.json in project → ui-phase step active by default + test('loop render-hooks plan:pre with no config.json → ui-phase step active by schema default', () => { + const result = spawnSync( + process.execPath, + [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpEmptyProjectDir], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + const envelope = JSON.parse(result.stdout.trim()); + const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.ok( + uiStep, + 'Expected ui step active by default. Got: ' + JSON.stringify(envelope.activeHooks), + ); + assert.match(envelope.rendered, /ui-phase/); + }); + + // FIX 4: explicit false in config.json overrides schema default + test('loop render-hooks plan:pre with ui_phase=false in config.json → ui-phase step absent', () => { + const result = spawnSync( + process.execPath, + [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpFalseConfigProjectDir], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + const envelope = JSON.parse(result.stdout.trim()); + const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); + assert.strictEqual( + uiStep, + undefined, + 'ui-phase step should be absent when config.json sets ui_phase=false', + ); + }); + + test('loop render-hooks invalid-point exits non-zero', () => { + const result = spawnSync( + process.execPath, + [GSD_TOOLS, 'loop', 'render-hooks', 'plan:mid', '--cwd', tmpProjectDir], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.notStrictEqual(result.status, 0, 'Expected non-zero exit for invalid point'); + assert.match(result.stderr, /plan:mid|Invalid loop point/); + }); +});