Merge remote-tracking branch 'origin/next' into feat/1279-fail-first-prover
# Conflicts: # docs/adr/550-spec-phase-probe-contract.md # gsd-core/workflows/verify-phase.md # tests/workflow-size-baseline.json
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
* - ./surface.cjs (resolveSurface)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
* - ./runtime-homes.cjs (getGlobalConfigDir — for runtimeConfigDir auto-detection)
|
||||
* - ./runtime-slash.cjs (resolveRuntime — GSD_RUNTIME > config.runtime > 'claude' precedence)
|
||||
* - capability-registry.cjs (loaded at call time)
|
||||
*/
|
||||
|
||||
@@ -89,6 +90,18 @@ interface CapabilityStateEntry {
|
||||
surfaced: boolean;
|
||||
/** True when the capability is both installed and surfaced. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* True when the capability is enabled AND its config activation resolves to
|
||||
* true. Config activation is determined by resolving the capability's
|
||||
* `activationKey` (a dotted config key, e.g. `graphify.enabled`) via
|
||||
* `_resolveActivationValue`. When `activationKey` is absent, configActivation
|
||||
* defaults to `true` — the capability has no config gate.
|
||||
*
|
||||
* active = enabled && configActivation
|
||||
*
|
||||
* Note: `enabled` stays exactly `installed && surfaced` (unchanged).
|
||||
*/
|
||||
active: boolean;
|
||||
/** Resolved hook activation state across steps, gates, and contributions */
|
||||
hooks: HookEntry[];
|
||||
}
|
||||
@@ -211,6 +224,20 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
|
||||
|
||||
const enabled = installed && surfaced;
|
||||
|
||||
// ── per-capability config activation ──────────────────────────────────────
|
||||
// Resolve the capability's own activationKey (if present). This is the
|
||||
// config-level toggle that gates the whole capability — separate from the
|
||||
// per-hook `when` keys that gate individual hooks. When activationKey is
|
||||
// absent, configActivation defaults to true (no config gate on the cap).
|
||||
// active = enabled && configActivation (enabled unchanged: installed && surfaced)
|
||||
const activationKey = typeof capObj['activationKey'] === 'string' && capObj['activationKey'].length > 0
|
||||
? capObj['activationKey']
|
||||
: undefined;
|
||||
const configActivation: boolean = activationKey !== undefined
|
||||
? _resolveActivationValue(activationKey, config, cwd, registry)
|
||||
: true;
|
||||
const active = enabled && configActivation;
|
||||
|
||||
// ── hooks ──────────────────────────────────────────────────────────────────
|
||||
// Collect from steps, gates, contributions. Each may have a `when` key.
|
||||
// Activation semantics (mirrors loop-resolver.isActive exactly):
|
||||
@@ -242,7 +269,12 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
|
||||
// (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
|
||||
configured = false;
|
||||
}
|
||||
hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
|
||||
// Hook active = capability-level active AND hook's own config gate.
|
||||
// The capability's `active` constant (= enabled && configActivation) is
|
||||
// used here so that a config-disabled capability (active=false) cannot
|
||||
// produce active hooks even when the hook's own `when` is unconditional
|
||||
// (configured=true). The capability gate cascades to all its hooks.
|
||||
hooks.push({ point, kind, when: whenRaw, configured, active: active && configured });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +286,7 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
|
||||
processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
|
||||
processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
|
||||
|
||||
results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
|
||||
results.push({ id: capId, tier, skills, installed, surfaced, enabled, active, hooks });
|
||||
}
|
||||
|
||||
// Deterministic sort by id for stable output across calls
|
||||
@@ -368,11 +400,15 @@ function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string
|
||||
* Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
|
||||
*
|
||||
* runtimeConfigDir resolution (when not provided or empty):
|
||||
* Uses the canonical getGlobalConfigDir from runtime-homes.cjs to detect the
|
||||
* active runtime's config dir — the same resolver used by install.js. This
|
||||
* correctly handles all supported runtimes (claude, codex, cursor, gemini,
|
||||
* opencode, grok, etc.) and their env-var overrides. Defaults to claude
|
||||
* (falls back to ~/.claude) if the resolver throws.
|
||||
* Detects the active runtime via the canonical precedence:
|
||||
* process.env.GSD_RUNTIME → config.runtime → 'claude'
|
||||
* (using resolveRuntime() from runtime-slash.cjs, the same precedence used
|
||||
* by profile-output.cjs and the rest of the runtime resolution chain).
|
||||
* Then calls getGlobalConfigDir(detectedRuntime) from runtime-homes.cjs —
|
||||
* the same resolver used by install.js. This correctly handles all supported
|
||||
* runtimes (claude, codex, cursor, gemini, opencode, grok, etc.) and their
|
||||
* env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_CONFIG_DIR, …).
|
||||
* Defaults to ~/.claude if either resolver throws.
|
||||
*
|
||||
* Failure surfacing: genuine resolution failures (manifest/profile/surface
|
||||
* errors) are reported in the `warnings` array in the envelope. The output
|
||||
@@ -398,9 +434,12 @@ function resolveCapabilityRuntimeState(
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
|
||||
// When not provided, getGlobalConfigDir(runtime) is called with 'claude'
|
||||
// as the default runtime — the same fallback as install.js. The canonical
|
||||
// resolver handles all env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME,
|
||||
// When not provided, the active runtime is detected via the canonical
|
||||
// precedence: process.env.GSD_RUNTIME → config.runtime → 'claude'
|
||||
// (mirrors resolveRuntime() from runtime-slash.cjs and the precedence used
|
||||
// by profile-output.cjs and the rest of the runtime resolution chain).
|
||||
// getGlobalConfigDir(detectedRuntime) is then called, which honours the
|
||||
// runtime-specific env-var override (CLAUDE_CONFIG_DIR, CODEX_HOME,
|
||||
// CURSOR_CONFIG_DIR, GROK_AGENTS_HOME, etc.) correctly and without
|
||||
// fabricating env vars that don't exist upstream.
|
||||
let resolvedConfigDir: string = runtimeConfigDir || '';
|
||||
@@ -410,13 +449,15 @@ function resolveCapabilityRuntimeState(
|
||||
const runtimeHomes = require('./runtime-homes.cjs') as {
|
||||
getGlobalConfigDir: (runtime: string) => string;
|
||||
};
|
||||
// Delegate runtime detection entirely to getGlobalConfigDir: calling it
|
||||
// with 'claude' causes it to check CLAUDE_CONFIG_DIR first, falling back
|
||||
// to ~/.claude. The canonical resolver already encodes the correct env-var
|
||||
// precedence for each runtime — we do not re-implement that logic here.
|
||||
// For non-claude runtimes, the caller should pass --config-dir explicitly
|
||||
// (or set the runtime-specific env var, which getGlobalConfigDir honors).
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir('claude');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeSlash = require('./runtime-slash.cjs') as {
|
||||
resolveRuntime: (projectDir: string | null | undefined) => string;
|
||||
};
|
||||
// Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
|
||||
// resolveRuntime reads config.json directly (no side effects) and returns
|
||||
// a lowercased canonical runtime name.
|
||||
const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
|
||||
} catch {
|
||||
// Defensive fallback: use ~/.claude if the canonical resolver throws.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -533,9 +574,28 @@ function cmdCapabilityState(
|
||||
coreOutput(envelope, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate: returns true if the capability identified by `capId`
|
||||
* is active (installed && surfaced && config-enabled) in the current runtime
|
||||
* environment at `cwd`.
|
||||
*
|
||||
* Internally calls `resolveCapabilityRuntimeState(cwd, undefined)` and returns
|
||||
* the `active` field of the matching CapabilityStateEntry.
|
||||
* Returns `false` when the capability is not found in the registry.
|
||||
*
|
||||
* @param capId Capability identifier (e.g. 'graphify', 'intel')
|
||||
* @param cwd Project root directory for config resolution
|
||||
*/
|
||||
function isCapabilityActive(capId: string, cwd: string): boolean {
|
||||
const result = resolveCapabilityRuntimeState(cwd, undefined);
|
||||
const entry = result.capabilities.find((c) => c.id === capId);
|
||||
return entry !== undefined ? entry.active : false;
|
||||
}
|
||||
|
||||
export = {
|
||||
resolveCapabilityState,
|
||||
resolveCapabilityRuntimeState,
|
||||
isCapabilityActive,
|
||||
cmdCapabilityState,
|
||||
// Exported for tests
|
||||
_resolveCommandsGsdDir,
|
||||
|
||||
@@ -11,32 +11,11 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateMod = require('./capability-state.cjs');
|
||||
const { isCapabilityActive } = capabilityStateMod;
|
||||
|
||||
/**
|
||||
* Check whether graphify is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no graphify key, or on error).
|
||||
*/
|
||||
function isGraphifyEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return false;
|
||||
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'graphify' in config &&
|
||||
config.graphify &&
|
||||
typeof config.graphify === 'object' &&
|
||||
'enabled' in config.graphify &&
|
||||
(config.graphify as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface DisabledResponse {
|
||||
disabled: true;
|
||||
@@ -396,7 +375,7 @@ function countCommitsBetween(cwd: string, from: string, to: string): number | nu
|
||||
*/
|
||||
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
@@ -435,7 +414,7 @@ function graphifyQuery(cwd: string, term: string, options: { budget?: number | n
|
||||
*/
|
||||
function graphifyStatus(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
@@ -498,7 +477,7 @@ function graphifyStatus(cwd: string): unknown {
|
||||
*/
|
||||
function graphifyDiff(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
@@ -554,7 +533,7 @@ function graphifyDiff(cwd: string): unknown {
|
||||
*/
|
||||
function graphifyBuild(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const installed = checkGraphifyInstalled();
|
||||
if (!installed.installed) return { error: installed.message };
|
||||
@@ -619,7 +598,6 @@ function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
|
||||
|
||||
export = {
|
||||
// Config gate
|
||||
isGraphifyEnabled,
|
||||
disabledResponse,
|
||||
// Subprocess
|
||||
execGraphify,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Intel files live in .planning/intel/ and store structured data about
|
||||
* the project's files, APIs, dependencies, architecture, and tech stack.
|
||||
*
|
||||
* All public functions gate on intel.enabled config (no-op when false).
|
||||
* All public functions gate on isCapabilityActive('intel', cwd) — the shared
|
||||
* tri-state resolver (installed + surfaced + intel.enabled config key).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/intel.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
@@ -17,6 +18,10 @@ import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateMod = require('./capability-state.cjs');
|
||||
const { isCapabilityActive } = capabilityStateMod;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const INTEL_DIR = '.planning/intel';
|
||||
@@ -41,29 +46,20 @@ function ensureIntelDir(planningDir: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether intel is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no intel key, or on error).
|
||||
* Check whether intel is active (installed, surfaced, and config-enabled) for the project at cwd.
|
||||
* Delegates to the shared tri-state capability resolver (isCapabilityActive) which honours the
|
||||
* install profile, runtime surface, and activationKey (intel.enabled config gate).
|
||||
*
|
||||
* NOTE: planningDir is the legacy entry-point; cwd is derived as path.dirname(planningDir).
|
||||
* Callers that have cwd directly may call isCapabilityActive('intel', cwd) themselves.
|
||||
*
|
||||
* INVARIANT: planningDir is always `<cwd>/.planning` (i.e. path.join(cwd, '.planning')).
|
||||
* The intel-command-router always constructs planningDir as path.join(cwd, '.planning'),
|
||||
* so path.dirname(planningDir) === cwd is guaranteed. If a workstream-aware planningDir
|
||||
* were ever passed here, the dirname would be wrong — but no caller does that.
|
||||
*/
|
||||
function isIntelEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
const raw = platformReadSync(configPath);
|
||||
if (raw === null) return false;
|
||||
const config: unknown = JSON.parse(raw);
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'intel' in config &&
|
||||
config.intel &&
|
||||
typeof config.intel === 'object' &&
|
||||
'enabled' in config.intel &&
|
||||
(config.intel as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function isIntelCapabilityActive(planningDir: string): boolean {
|
||||
return isCapabilityActive('intel', path.dirname(planningDir));
|
||||
}
|
||||
|
||||
interface DisabledResponse {
|
||||
@@ -188,7 +184,7 @@ interface IntelQueryResult {
|
||||
* Searches across all JSON intel files in INTEL_FILES (keys and values), including arch-decisions.json (parsed as JSON, not as text).
|
||||
*/
|
||||
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
|
||||
let total = 0;
|
||||
@@ -225,7 +221,7 @@ interface IntelStatusResult {
|
||||
* A file is considered stale if its updated_at is older than 24 hours.
|
||||
*/
|
||||
function intelStatus(planningDir: string): IntelStatusResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const now = Date.now();
|
||||
@@ -273,7 +269,7 @@ interface IntelDiffResult {
|
||||
* Show changes since the last full refresh by comparing file hashes.
|
||||
*/
|
||||
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
|
||||
const snapshot = safeReadJson(snapshotPath);
|
||||
@@ -309,7 +305,7 @@ function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true }
|
||||
* The actual update is performed by the intel-updater agent (PLAN-02).
|
||||
*/
|
||||
function intelUpdate(planningDir: string): { action: string; message: string } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
return {
|
||||
action: 'spawn_agent',
|
||||
@@ -359,7 +355,7 @@ function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
|
||||
* Writes .last-refresh.json with accurate timestamps and hashes.
|
||||
*/
|
||||
function intelSnapshot(planningDir: string): SaveRefreshResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
return saveRefreshSnapshot(planningDir);
|
||||
}
|
||||
|
||||
@@ -373,7 +369,7 @@ interface IntelValidateResult {
|
||||
* Validate all intel files for correctness and freshness.
|
||||
*/
|
||||
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -470,7 +466,7 @@ interface IntelApiSurfaceResult {
|
||||
* mistake silence for "nothing exists".
|
||||
*/
|
||||
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
const apiMapPath = path.join(intelPath, INTEL_FILES.apis);
|
||||
@@ -535,7 +531,7 @@ interface IntelPatchMetaResult {
|
||||
* Patch _meta.updated_at in a JSON intel file to the current timestamp.
|
||||
* Reads the file, updates _meta.updated_at, increments version, writes back.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
|
||||
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary file paths
|
||||
* for use by agents patching individual files outside the intel store.
|
||||
*/
|
||||
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
|
||||
@@ -576,7 +572,7 @@ interface IntelExtractExportsResult {
|
||||
/**
|
||||
* Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
|
||||
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary source files
|
||||
* for use by agents building intel data from project files.
|
||||
*/
|
||||
function intelExtractExports(filePath: string): IntelExtractExportsResult {
|
||||
@@ -711,7 +707,7 @@ export = {
|
||||
|
||||
// Utilities
|
||||
ensureIntelDir,
|
||||
isIntelEnabled,
|
||||
isIntelCapabilityActive,
|
||||
|
||||
// Constants
|
||||
INTEL_FILES,
|
||||
|
||||
@@ -269,8 +269,16 @@ interface ResolveLoopHooksInput {
|
||||
config: Record<string, unknown>;
|
||||
/** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */
|
||||
cwd?: string;
|
||||
/** Optional capability-state map; when present, disabled capabilities do not render hooks. */
|
||||
capabilityStatesById?: Map<string, { enabled?: boolean }> | Record<string, { enabled?: boolean }>;
|
||||
/**
|
||||
* Optional capability-state map; when present, inactive capabilities do not render hooks.
|
||||
* Each entry carries both `enabled` (installed+surfaced) and `active` (enabled+configActivation).
|
||||
* The resolver gates on `active` so that the config activation key (activationKey) is
|
||||
* honoured even when no per-hook `when` guard is present (Phase 4 tri-state alignment).
|
||||
*
|
||||
* `active` is REQUIRED (not optional) so the gate is fail-closed: a missing or undefined
|
||||
* `active` field is a compile error, never silently treated as truthy.
|
||||
*/
|
||||
capabilityStatesById?: Map<string, { enabled?: boolean; active: boolean }> | Record<string, { enabled?: boolean; active: boolean }>;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksResult {
|
||||
@@ -330,13 +338,18 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
return _resolveActivationValue(when, config, cwd, registry);
|
||||
}
|
||||
|
||||
function isCapabilityEnabled(capId: string): boolean {
|
||||
function isCapabilityActive(capId: string): boolean {
|
||||
if (!capabilityStatesById) return true;
|
||||
const state = capabilityStatesById instanceof Map
|
||||
? capabilityStatesById.get(capId)
|
||||
: capabilityStatesById[capId];
|
||||
if (!state) return false;
|
||||
return state.enabled !== false;
|
||||
// Fail-closed gate: only render the hook when active is explicitly true.
|
||||
// A capability can be installed and surfaced (enabled=true) but config-disabled
|
||||
// (active=false); in that case the hook must not render.
|
||||
// Phase 4 tri-state alignment: `active` is now required (not optional), so
|
||||
// `=== true` is the correct fail-closed check (not `!== false`).
|
||||
return state.active === true;
|
||||
}
|
||||
|
||||
// Helper: safe string array
|
||||
@@ -401,7 +414,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of steps) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
|
||||
? (hook['ref'] as HookRef)
|
||||
@@ -427,7 +440,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of contributions) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
|
||||
const fragment = toFragment(hook['fragment']);
|
||||
@@ -453,7 +466,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of gates) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const check = hook['check'] !== undefined ? hook['check'] : undefined;
|
||||
@@ -613,11 +626,11 @@ function cmdLoopRenderHooks(
|
||||
warnings?: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean }>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
|
||||
};
|
||||
const registry = state.registry;
|
||||
const config = state.config || loadConfig(cwd);
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean }>();
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
|
||||
for (const cap of state.capabilities || []) {
|
||||
capabilityStatesById.set(cap.id, cap);
|
||||
}
|
||||
|
||||
@@ -290,6 +290,15 @@ export interface Prohibition {
|
||||
resolution: string | null;
|
||||
reason: string | null;
|
||||
statement: string;
|
||||
// Optional flat-scalar wired-check descriptor (#1278). A resolved test-tier prohibition may carry
|
||||
// these before projection; `projectProhibitions` emits them as the LOCKED flat scalar keys
|
||||
// `check_kind`/`check_target`/`check_rule` that round-trip the EXISTING flat `parseMustHavesBlock`
|
||||
// (a nested `check:{}` object is rejected per IMPL-SCOPING §3 — it flattens through the shared
|
||||
// parser). These mirror `CheckDescriptor.kind/target/rule` (prohibition-enforcement.cts:62) MINUS
|
||||
// the caller-attested `failFirst`, which is deliberately NOT a Prohibition field (#1279).
|
||||
check_kind?: 'node-test' | 'lint-rule';
|
||||
check_target?: string;
|
||||
check_rule?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,6 +338,15 @@ export function validateProhibitionResolution(resolution: Resolution<Prohibition
|
||||
* and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
|
||||
* statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
|
||||
* empty/zero-prohibition case), never a throw.
|
||||
*
|
||||
* An OPTIONAL wired-check descriptor (#1278) projects as the LOCKED flat scalar keys
|
||||
* `check_kind`/`check_target`/`check_rule` (NEVER a nested `check:{}` object; `failFirst` is never
|
||||
* projected). These ride the EXISTING continuation-KV path of `parseMustHavesBlock`
|
||||
* (src/frontmatter.cts:344) with NO shared-parser rewrite (IMPL-SCOPING §3 Option 1). The keys are
|
||||
* emitted ONLY for a well-formed descriptor (valid `check_kind` + non-empty `check_target`; plus
|
||||
* `check_rule` only for a lint-rule that carries one); a descriptor-less or under-specified item is
|
||||
* byte-identical to today (CHK-07), so an under-specified descriptor projects absent and fails closed
|
||||
* at the producer downstream (CHK-06), never as a partial-but-locatable green.
|
||||
*/
|
||||
export function projectProhibitions(
|
||||
items: unknown,
|
||||
@@ -345,6 +363,20 @@ export function projectProhibitions(
|
||||
};
|
||||
if (p.verification != null) entry.verification = String(p.verification);
|
||||
if (p.reason != null && String(p.reason).trim()) entry.reason = String(p.reason);
|
||||
// Optional wired-check descriptor (#1278): emit flat scalars ONLY when well-formed. A valid kind
|
||||
// plus a non-empty target is the minimum; under that bar nothing is emitted (CHK-07 byte-identity,
|
||||
// and the producer fails closed on the absent descriptor — CHK-06).
|
||||
const kind = p.check_kind;
|
||||
const targetOk = typeof p.check_target === 'string' && p.check_target.trim() !== '';
|
||||
if ((kind === 'node-test' || kind === 'lint-rule') && targetOk) {
|
||||
entry.check_kind = kind;
|
||||
entry.check_target = String(p.check_target);
|
||||
// `check_rule` rides only the lint-rule path (node-test never carries one); a lint-rule missing
|
||||
// its rule leaves check_rule absent so the producer's fail-closed locate rejects it (CHK-06).
|
||||
if (kind === 'lint-rule' && typeof p.check_rule === 'string' && p.check_rule.trim() !== '') {
|
||||
entry.check_rule = String(p.check_rule);
|
||||
}
|
||||
}
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -78,6 +78,52 @@ export interface CheckDescriptor {
|
||||
violationFixture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* READ-BACK ADAPTER (#1278, plan 01-03): reconstruct a `CheckDescriptor` from the flat scalar keys
|
||||
* `projectProhibitions` emits onto a prohibition item (`check_kind` / `check_target` / `check_rule`,
|
||||
* src/probe-core.cts). This is the deterministic bridge from the projected descriptor back into the
|
||||
* merged #1259 producer request — the verify-phase caller reads the projected scalars, this rebuilds
|
||||
* the `{ kind, target, rule? }` request, and `runProhibitionEnforcement`'s EXISTING fail-closed LOCATE
|
||||
* guard (validKind/validTarget/validRule, below) is the single source of fail-closed truth.
|
||||
*
|
||||
* Contract:
|
||||
* - `null`/`undefined`/non-object input -> `null`.
|
||||
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
|
||||
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
|
||||
* ONLY when `check_rule` is a non-empty string.
|
||||
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
|
||||
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
|
||||
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
|
||||
* scalars are present (e.g. `{check_kind:'lint-rule', check_target:'src/'}` with no `check_rule`
|
||||
* reconstructs to `{kind:'lint-rule', target:'src/'}`), letting the existing LOCATE guard reject
|
||||
* an under-specified descriptor (located:false, never green). It does NOT re-implement that guard.
|
||||
* - Pure, deterministic, no-throw.
|
||||
*/
|
||||
export function descriptorFromProjection(
|
||||
projected: Record<string, unknown> | null | undefined,
|
||||
): CheckDescriptor | null {
|
||||
if (!projected || typeof projected !== 'object') return null;
|
||||
if (!('check_kind' in projected)) return null;
|
||||
// The shared `parseMustHavesBlock` (src/frontmatter.cts) coerces /^\d+$/ scalar values to NUMBERS on
|
||||
// round-trip, so a numeric-looking check_kind/check_target/check_rule arrives here as a number. Normalize
|
||||
// ONLY string|number scalars back to string — a non-scalar (object/array/bool) or absent value yields ''
|
||||
// (never an `[object Object]` stringification, no `as string` lie over a number). This keeps the
|
||||
// descriptor honestly typed and the round-trip lossless across the full string domain; an under-specified
|
||||
// '' target/kind is rejected by the producer's locate guard (fail-closed; never green).
|
||||
const scalar = (v: unknown): string =>
|
||||
typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '';
|
||||
const kind = scalar(projected.check_kind) as CheckKind;
|
||||
const target = scalar(projected.check_target);
|
||||
const descriptor: CheckDescriptor = { kind, target };
|
||||
// `rule` belongs only to the lint-rule kind; a stray check_rule on a node-test descriptor is dropped
|
||||
// (the projector never emits one there — defense in depth). failFirst is NOT sourced here (#1279).
|
||||
if (kind === 'lint-rule') {
|
||||
const rule = scalar(projected.check_rule);
|
||||
if (rule.trim().length > 0) descriptor.rule = rule;
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result a check-runner returns: whether the check genuinely, non-vacuously PASSED. The runner
|
||||
* reports only what it can OBSERVE (a real clean pass) — it does NOT determine fail-first. Whether the
|
||||
|
||||
Reference in New Issue
Block a user