Merge branch 'next' into kimi-runtime-support

This commit is contained in:
Viktorplus
2026-06-09 13:31:21 +02:00
committed by GitHub
22 changed files with 3480 additions and 91 deletions

View File

@@ -35,8 +35,30 @@ const { detectSubRepos } = coreUtilsModule;
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema;
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs';
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import federatedConfigModule = require('./federated-config.cjs');
const { mergeFederatedConfig } = federatedConfigModule;
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
// Module-level registry reference. Defaults to the real generated registry.
// Overridable for tests via _setFederatedRegistryForTests.
let _capabilityRegistry: { configSchema?: Record<string, unknown> } = _capabilityRegistryReal;
/** Test-only seam: inject a synthetic registry. Call _resetFederatedRegistryForTests() to restore. */
function _setFederatedRegistryForTests(reg: { configSchema?: Record<string, unknown> }): void {
_capabilityRegistry = reg;
}
/** Test-only seam: restore the real generated registry. */
function _resetFederatedRegistryForTests(): void {
_capabilityRegistry = _capabilityRegistryReal;
}
// ─── File & Config utilities ──────────────────────────────────────────────────
@@ -272,6 +294,81 @@ function _resetRuntimeWarningCacheForTests(): void {
_warnedConfigKeys.clear();
}
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
/**
* Apply federated key values into a mutable config object.
* Handles N-level dotted keys (e.g. "a.b.c" → obj.a.b.c).
* Only adds keys that are not already present (does not clobber).
* Inline prototype-pollution guards at every segment.
*/
function _applyFederatedValues(
obj: Record<string, unknown>,
values: Record<string, unknown>,
validKeys: string[],
): void {
for (const dottedKey of validKeys) {
// S2: inline literal guard on full key
if (dottedKey === '__proto__' || dottedKey === 'constructor' || dottedKey === 'prototype') continue;
const parts = dottedKey.split('.');
if (parts.length === 1) {
const topKey = parts[0];
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
if (!Object.prototype.hasOwnProperty.call(obj, topKey)) {
obj[topKey] = values[dottedKey];
}
}
} else {
// N-level nested key: traverse/create intermediate objects
let cur: Record<string, unknown> = obj;
let ok = true;
for (let i = 0; i < parts.length - 1; i++) {
const seg = parts[i];
// S2: inline literal guard on each segment
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { ok = false; break; }
if (!Object.prototype.hasOwnProperty.call(cur, seg) || cur[seg] === null) {
cur[seg] = {};
}
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) { ok = false; break; }
cur = cur[seg] as Record<string, unknown>;
}
if (!ok) continue;
const leafKey = parts[parts.length - 1];
// S2: inline literal guard on leaf
if (leafKey === '__proto__' || leafKey === 'constructor' || leafKey === 'prototype') continue;
if (!Object.prototype.hasOwnProperty.call(cur, leafKey)) {
cur[leafKey] = values[dottedKey];
}
}
}
}
/**
* FIX 2: Apply the federated overlay to a base config object.
* When validKeys is empty (current registry — all keys are central),
* returns the baseConfig UNCHANGED (true no-op, preserves reference identity).
* When validKeys is non-empty, applies values into a shallow clone to avoid
* mutating shared CONFIG_DEFAULTS/module constants.
*/
function _applyFederatedOverlay(
baseConfig: Record<string, unknown>,
userConfig: Record<string, unknown>,
): Record<string, unknown> {
const _fedRegistrySchema = _capabilityRegistry.configSchema;
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig,
});
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
if (_fedOverlay.validKeys.length === 0) return baseConfig;
// Clone shallowly to avoid mutating shared constants, then apply nested values
const cloned: Record<string, unknown> = { ...baseConfig };
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
return cloned;
}
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
? options['workstream']
@@ -397,6 +494,31 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
// Deprecated keys (still accepted for migration, not in config-set)
'depth', 'multiRepo', 'branching_strategy',
]);
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
// federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
// This is hoisted out of the try-catch below so validKeys are available here.
let _preWarningFedValidKeys: string[] = [];
try {
const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
const _earlyOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchemaEarly,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig: parsed,
});
_preWarningFedValidKeys = _earlyOverlay.validKeys;
for (const dottedKey of _preWarningFedValidKeys) {
const topKey = dottedKey.split('.')[0];
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
KNOWN_TOP_LEVEL.add(topKey);
}
}
}
} catch {
// Defensive: if registry access fails here, proceed without pre-warning keys
}
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
if (unknownKeys.length > 0) {
const warnKey = unknownKeys.join(',');
@@ -429,7 +551,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
return defaults.parallelization;
})();
return {
const _baseConfig: Record<string, unknown> = {
model_profile: get('model_profile') ?? defaults.model_profile,
commit_docs: (() => {
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
@@ -492,14 +614,54 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
claude_md_path: get('claude_md_path') || null,
claude_md_assembly: (parsed['claude_md_assembly']) || null,
};
// ─── ADR-857 phase 3b: federated config overlay ───────────────────────────
// FIX 2: Use the pre-computed _preWarningFedValidKeys (from the FIX 3 block above)
// plus a fresh overlay call to get values. The KNOWN_TOP_LEVEL was already updated.
// TODAY: every UI key is still in the central config-schema, so isCentralKey()
// returns true for all of them → validKeys is empty → _baseConfig is returned UNCHANGED
// (true no-op: no clone, no reorder, byte-identical output).
// This becomes a live channel once a key is atomically removed from the central schema.
try {
if (_preWarningFedValidKeys.length > 0) {
// There are actual federated values — re-use the already-computed overlay
// (we run mergeFederatedConfig again here to get the values map; the validKeys
// are guaranteed identical since it's the same inputs).
const _fedRegistrySchema = _capabilityRegistry.configSchema;
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig: parsed,
});
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
// WITHOUT clobbering existing keys. N-level nesting supported.
_applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
}
}
// Pending-migration warnings are suppressed at load time to avoid noisy output on
// every loadConfig call. They are surfaced at registry-generation time (--check/--write).
} catch {
// Defensive: if the federated overlay throws for any reason, return the base config unchanged.
// This keeps loadConfig's no-throw contract intact regardless of capability registry state.
}
return _baseConfig;
} catch {
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
if (fs.existsSync(planningDir(cwd, ws))) {
if (rootParsed) {
// Workstream has no config.json: re-parse using root config as the sole source.
// (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
return loadConfig(cwd, { workstream: null });
}
return defaults;
// FIX 2: Apply the federated overlay on the no-config path.
// With the current registry (all keys central), _applyFederatedOverlay returns
// `defaults` UNCHANGED (true no-op, preserves byte-identical output).
try {
return _applyFederatedOverlay(defaults, {});
} catch {
return defaults;
}
}
try {
const home = process.env['GSD_HOME'] || os.homedir();
@@ -507,7 +669,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
const raw = platformReadSync(globalDefaultsPath);
if (raw === null) throw new Error('missing');
const globalDefaults = JSON.parse(raw) as Record<string, unknown>;
return {
const _globalBaseCfg: Record<string, unknown> = {
...defaults,
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
@@ -534,8 +696,21 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
agent_skills: (globalDefaults['agent_skills']) || {},
response_language: (globalDefaults['response_language']) || null,
};
// FIX 2: Apply federated overlay on global-defaults path.
// With the current registry this is a true no-op (returns _globalBaseCfg unchanged).
try {
return _applyFederatedOverlay(_globalBaseCfg, globalDefaults);
} catch {
return _globalBaseCfg;
}
} catch {
return defaults;
// FIX 2: Apply federated overlay on the final fallback path.
// With the current registry this is a true no-op (returns `defaults` unchanged).
try {
return _applyFederatedOverlay(defaults, {});
} catch {
return defaults;
}
}
}
}
@@ -553,4 +728,6 @@ export = {
_warnedConfigKeys,
_gitIgnoredCache,
RUNTIME_OVERRIDE_TIERS,
_setFederatedRegistryForTests,
_resetFederatedRegistryForTests,
};

230
src/federated-config.cts Normal file
View File

@@ -0,0 +1,230 @@
/**
* Federated Config — Defensive merge of capability-declared config keys
*
* ADR-857 phase 3b: wires the Capability Registry's configSchema into
* loadConfig as a provably-empty no-op channel until capability keys are
* migrated out of the central config-schema.
*
* Exported function:
* mergeFederatedConfig({ configSchema, isCentralKey, userConfig })
* → { values, validKeys, warnings }
*
* Design:
* - For each key in configSchema:
* If isCentralKey(key) → SKIP; push a pending-migration warning.
* Else if slice is malformed → SKIP; push a warning. Never throw.
* Else (valid federated key absent from central):
* resolvedValue = nested userConfig lookup if present & type-matches; else slice.default.
* Add key→resolvedValue to values; add key to validKeys.
* - Guard all object writes with inline literal __proto__/constructor/prototype checks.
* - Zero external dependencies; no ajv; hand-rolled type checks only.
*
* ADR-857 no-op guarantee:
* With the current registry, every UI key is still present in the central
* config-schema, so isCentralKey() returns true for all of them and values
* is always empty. The channel is live but carries no traffic until a key
* is atomically removed from the central schema (the cutover step).
*
* Dependencies: none (zero-dep module).
*/
// ─── Types ─────────────────────────────────────────────────────────────────────
/** Shape of one entry in the capability registry's configSchema index. */
interface ConfigSliceEntry {
owner: string;
type: string;
default: unknown;
description: string;
values?: string[]; // required for enum type
[key: string]: unknown;
}
interface MergeFederatedConfigInput {
/** configSchema index from the capability registry: { [key]: ConfigSliceEntry } */
configSchema: Record<string, unknown>;
/** Returns true if the given key is owned by the central config-schema. */
isCentralKey: (key: string) => boolean;
/** The raw/merged user config object already loaded in loadConfig. */
userConfig: Record<string, unknown>;
}
interface MergeFederatedConfigResult {
/** Resolved values for federated (non-central) keys: { key → resolvedValue } */
values: Record<string, unknown>;
/** Array of keys that are now valid federated keys (i.e. were added to values). */
validKeys: string[];
/** Human-readable diagnostic strings (pending-migration, malformed-slice, type-mismatch). */
warnings: string[];
}
// ─── Allowed slice types (mirrors gen-capability-registry.cjs VALID_CONFIG_SLICE_TYPES) ──
const VALID_SLICE_TYPES = new Set<string>(['boolean', 'string', 'number', 'enum']);
// ─── Internal helpers ──────────────────────────────────────────────────────────
/**
* Returns true if `slice` has a non-empty type, a `default` property, and a
* non-empty string description. Does NOT throw.
*/
function _isWellFormedSlice(slice: unknown): slice is ConfigSliceEntry {
if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) return false;
const s = slice as Record<string, unknown>;
if (typeof s['type'] !== 'string' || s['type'].length === 0) return false;
if (!VALID_SLICE_TYPES.has(s['type'])) return false;
if (!Object.prototype.hasOwnProperty.call(s, 'default')) return false;
return true;
}
/**
* Returns true if `value` matches the declared type in the slice.
* For enum, also validates against slice.values if present.
*/
function _typeMatches(value: unknown, slice: ConfigSliceEntry): boolean {
switch (slice.type) {
case 'boolean': return typeof value === 'boolean';
case 'string': return typeof value === 'string';
case 'number': return typeof value === 'number';
case 'enum':
// Must be a string AND, if values list is present, must be in it
if (typeof value !== 'string') return false;
if (Array.isArray(slice.values) && slice.values.length > 0) {
return slice.values.includes(value);
}
return true;
default: return false;
}
}
/**
* Traverse a dotted key path through a nested config object.
* E.g. key="workflow.ui_phase", obj={workflow:{ui_phase:false}} → {found:true, value:false}
* Returns {found:false} if any segment is missing or not an own property.
* Handles 1, 2, or N segments generically.
*/
function _getNestedValue(obj: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
const segments = key.split('.');
let current: unknown = obj;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
// Inline literal prototype-pollution guard
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<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
return { found: false, value: undefined };
}
current = cur[seg];
}
return { found: true, value: current };
}
// ─── Public API ────────────────────────────────────────────────────────────────
/**
* Defensive merge of capability-declared config slices into the loadConfig
* return value.
*
* DEFENSIVE contract (never throws, even on bad capability data):
* - Null/undefined/non-object input → returns empty result.
* - Null/undefined/non-object userConfig → treated as {} (no overrides).
* - Central keys are skipped with a pending-migration warning.
* - Malformed slices are skipped with a warning.
* - User-supplied values with wrong types (or out-of-enum values) fall back
* to the slice default (a type-mismatch warning is pushed but the key is
* still federated with its default; this is best-effort degraded operation).
*/
function mergeFederatedConfig(input: MergeFederatedConfigInput): MergeFederatedConfigResult {
// FIX 4: Guard null/undefined/non-object input
if (input === null || input === undefined || typeof input !== 'object') {
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
}
const { configSchema, isCentralKey } = input;
// FIX 4: Guard null/undefined/non-object userConfig — treat as {}
const userConfig: Record<string, unknown> =
(input.userConfig !== null && input.userConfig !== undefined && typeof input.userConfig === 'object' && !Array.isArray(input.userConfig))
? input.userConfig
: {};
// FIX 6b: Use null-prototype object for all return paths
const values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
const validKeys: string[] = [];
const warnings: string[] = [];
if (typeof configSchema !== 'object' || configSchema === null) {
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
}
for (const key of Object.keys(configSchema)) {
// S2: inline literal prototype-pollution guard (CodeQL barrier)
// Guard both the full key AND all dotted-path segments
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const _keySegments = key.split('.');
if (_keySegments.some((s) => s === '__proto__' || s === 'constructor' || s === 'prototype')) continue;
const slice = configSchema[key];
// If this key is still in the central schema → pending migration, skip
try {
if (isCentralKey(key)) {
warnings.push(
'federated-config: key "' + key + '" is still in the central config-schema (pending-migration); ' +
'skipping federated resolution until the central schema entry is removed',
);
continue;
}
} catch {
// isCentralKey threw — treat as unknown, skip defensively
warnings.push('federated-config: isCentralKey("' + key + '") threw; skipping key');
continue;
}
// Validate slice shape — skip malformed entries
if (!_isWellFormedSlice(slice)) {
warnings.push(
'federated-config: config slice for key "' + key + '" is malformed (missing or invalid type/default); skipping',
);
continue;
}
const sliceEntry = slice;
// FIX 1: Resolve value using NESTED dotted-path lookup through userConfig
let resolvedValue: unknown = sliceEntry.default;
const { found: userHasKey, value: userValue } = _getNestedValue(userConfig, key);
if (userHasKey && userValue !== undefined) {
// FIX 5b: For enum, validate against slice.values if present; otherwise check type
if (_typeMatches(userValue, sliceEntry)) {
resolvedValue = userValue;
} else {
const typeDesc = sliceEntry.type === 'enum' && Array.isArray(sliceEntry.values)
? 'enum(' + sliceEntry.values.join('|') + ')'
: sliceEntry.type;
warnings.push(
'federated-config: user-supplied value for "' + key + '" has wrong type or invalid enum value ' +
'(expected ' + typeDesc + ', got ' + typeof userValue +
(typeof userValue === 'string' ? ' "' + String(userValue) + '"' : '') +
'); falling back to slice default',
);
// resolvedValue stays as slice default
}
}
// S2: inline literal guard before writing to values
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
values[key] = resolvedValue;
validKeys.push(key);
}
}
return { values, validKeys, warnings };
}
export = { mergeFederatedConfig };

533
src/loop-resolver.cts Normal file
View File

@@ -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 <point>
*
* 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<string> = (() => {
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<string> = 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<string, unknown>): ReadonlyArray<string> {
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<string, unknown>,
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<string, unknown>;
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<string>();
/**
* 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<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown>,
cwd: string | undefined,
registry: Record<string, unknown>,
): 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<string, unknown> | undefined)?.[dotKey];
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
const def = (schemaEntry as Record<string, unknown>)['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<string, unknown>;
config: Record<string, unknown>;
/** 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<string, unknown>;
// 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<string, unknown>;
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(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}"/>`);
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<string, unknown> = {},
): void {
if (!point) {
coreError('loop render-hooks requires a <point> 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<string, unknown>;
// 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,
};