@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-mempalace-curator.md +47 -0
- package/bin/install.js +25 -7
- package/commands/gsd/mempalace-capture.md +71 -0
- package/commands/gsd/mempalace-recall.md +102 -0
- package/commands/gsd/ns-context.md +4 -2
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +110 -92
- package/gsd-core/bin/lib/capability-activation.cjs +86 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
- package/gsd-core/bin/lib/capability-state.cjs +31 -18
- package/gsd-core/bin/lib/check-command-router.cjs +328 -1
- package/gsd-core/bin/lib/clusters.cjs +2 -0
- package/gsd-core/bin/lib/command-roster.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +33 -10
- package/gsd-core/bin/lib/config-loader.cjs +7 -8
- package/gsd-core/bin/lib/config-schema.cjs +32 -3
- package/gsd-core/bin/lib/config.cjs +1 -2
- package/gsd-core/bin/lib/core.cjs +5 -2
- package/gsd-core/bin/lib/init.cjs +8 -5
- package/gsd-core/bin/lib/install-profiles.cjs +2 -1
- package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
- package/gsd-core/bin/lib/model-resolver.cjs +47 -5
- package/gsd-core/bin/lib/phase.cjs +71 -11
- package/gsd-core/bin/lib/profile-output.cjs +45 -4
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
- package/gsd-core/bin/lib/roadmap.cjs +97 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +20 -28
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
- package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
- package/gsd-core/bin/lib/state-command-router.cjs +15 -3
- package/gsd-core/bin/lib/state-document.cjs +46 -1
- package/gsd-core/bin/lib/state.cjs +221 -35
- package/gsd-core/bin/lib/verify.cjs +19 -4
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
- package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
- package/gsd-core/bin/shared/model-catalog.json +1 -0
- package/gsd-core/references/loop-hook-dispatch.md +61 -0
- package/gsd-core/templates/config.json +1 -1
- package/gsd-core/workflows/audit-milestone.md +5 -3
- package/gsd-core/workflows/autonomous.md +10 -5
- package/gsd-core/workflows/code-review-fix.md +9 -7
- package/gsd-core/workflows/code-review.md +8 -6
- package/gsd-core/workflows/discuss-phase.md +14 -0
- package/gsd-core/workflows/execute-phase.md +82 -143
- package/gsd-core/workflows/help/modes/full.md +3 -1
- package/gsd-core/workflows/new-project.md +3 -3
- package/gsd-core/workflows/plan-phase.md +89 -221
- package/gsd-core/workflows/profile-user.md +3 -1
- package/gsd-core/workflows/progress.md +58 -1
- package/gsd-core/workflows/quick.md +9 -6
- package/gsd-core/workflows/review.md +19 -2
- package/gsd-core/workflows/secure-phase.md +4 -2
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/ship.md +57 -0
- package/gsd-core/workflows/validate-phase.md +4 -2
- package/gsd-core/workflows/verify-work.md +27 -11
- package/package.json +3 -2
- package/scripts/affected-tests-lib.cjs +16 -4
- package/scripts/gen-capability-registry.cjs +359 -48
- package/scripts/gen-inventory-manifest.cjs +1 -4
- package/scripts/gen-loop-host-contract.cjs +55 -0
- package/scripts/issue-version-gate.cjs +140 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
- package/scripts/lint-allow-test-rule-refs.cjs +162 -0
- package/scripts/lint-test-file-count.allowlist.json +8 -0
- package/scripts/release-tarball-smoke.cjs +7 -1
- package/scripts/run-tests.cjs +74 -14
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unified capability-state resolver that composes the three toggle systems
|
|
6
6
|
* (install profile, runtime surface, config activation) into one per-capability
|
|
7
|
-
* view.
|
|
8
|
-
*
|
|
7
|
+
* view. The loop resolver consumes this state so workflow dispatch and the
|
|
8
|
+
* `gsd-tools capability state` diagnostic share the same enablement answer.
|
|
9
9
|
*
|
|
10
10
|
* Exports (three things, mirroring loop-resolver):
|
|
11
11
|
* resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* cmdCapabilityState is the I/O handler.
|
|
20
20
|
*
|
|
21
21
|
* Dependencies (leaf modules only — no core.cjs circular risk):
|
|
22
|
-
* - node:path
|
|
22
|
+
* - node:path
|
|
23
23
|
* - ./core.cjs (output, error)
|
|
24
|
-
* - ./
|
|
24
|
+
* - ./capability-activation.cjs (_resolveActivationValue)
|
|
25
25
|
* - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
|
|
26
26
|
* - ./surface.cjs (resolveSurface)
|
|
27
27
|
* - ./config-loader.cjs (loadConfig)
|
|
@@ -36,8 +36,8 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
36
36
|
const core = require("./core.cjs");
|
|
37
37
|
const { output: coreOutput, error: coreError } = core;
|
|
38
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
-
const
|
|
40
|
-
const { _resolveActivationValue } =
|
|
39
|
+
const activationMod = require("./capability-activation.cjs");
|
|
40
|
+
const { _resolveActivationValue } = activationMod;
|
|
41
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
42
|
const configLoaderMod = require("./config-loader.cjs");
|
|
43
43
|
const { loadConfig } = configLoaderMod;
|
|
@@ -133,6 +133,7 @@ function resolveCapabilityState(input) {
|
|
|
133
133
|
else {
|
|
134
134
|
surfaced = skills.every((s) => surfacedSkills.has(s));
|
|
135
135
|
}
|
|
136
|
+
const enabled = installed && surfaced;
|
|
136
137
|
// ── hooks ──────────────────────────────────────────────────────────────────
|
|
137
138
|
// Collect from steps, gates, contributions. Each may have a `when` key.
|
|
138
139
|
// Activation semantics (mirrors loop-resolver.isActive exactly):
|
|
@@ -149,21 +150,21 @@ function resolveCapabilityState(input) {
|
|
|
149
150
|
const point = typeof h['point'] === 'string' ? h['point'] : '';
|
|
150
151
|
// Carry the raw `when` value through for visibility
|
|
151
152
|
const whenRaw = h['when'];
|
|
152
|
-
let
|
|
153
|
+
let configured;
|
|
153
154
|
if (whenRaw === undefined || whenRaw === null) {
|
|
154
155
|
// No `when` field → unconditional, always active
|
|
155
|
-
|
|
156
|
+
configured = true;
|
|
156
157
|
}
|
|
157
158
|
else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
|
|
158
159
|
// Non-empty string `when` → resolve via _resolveActivationValue
|
|
159
|
-
|
|
160
|
+
configured = _resolveActivationValue(whenRaw, config, cwd, registry);
|
|
160
161
|
}
|
|
161
162
|
else {
|
|
162
163
|
// Present-but-empty-string or non-string `when` → malformed, inactive
|
|
163
164
|
// (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
|
|
164
|
-
|
|
165
|
+
configured = false;
|
|
165
166
|
}
|
|
166
|
-
hooks.push({ point, kind, when: whenRaw, active });
|
|
167
|
+
hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
const stepsRaw = capObj['steps'];
|
|
@@ -172,7 +173,7 @@ function resolveCapabilityState(input) {
|
|
|
172
173
|
processHooks(Array.isArray(stepsRaw) ? stepsRaw : [], 'step');
|
|
173
174
|
processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
|
|
174
175
|
processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
|
|
175
|
-
results.push({ id: capId, tier, skills, installed, surfaced, hooks });
|
|
176
|
+
results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
|
|
176
177
|
}
|
|
177
178
|
// Deterministic sort by id for stable output across calls
|
|
178
179
|
results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
@@ -222,7 +223,7 @@ function _resolveCommandsGsdDir() {
|
|
|
222
223
|
* @param raw Whether to emit raw JSON (core.output raw mode)
|
|
223
224
|
* @param _options Reserved for future use
|
|
224
225
|
*/
|
|
225
|
-
function
|
|
226
|
+
function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
|
|
226
227
|
const warnings = [];
|
|
227
228
|
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
|
|
228
229
|
// When not provided, getGlobalConfigDir(runtime) is called with 'claude'
|
|
@@ -277,7 +278,6 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
|
|
|
277
278
|
// Genuine resolution failure — surface it so the caller is not misled.
|
|
278
279
|
const msg = err instanceof Error ? err.message : String(err);
|
|
279
280
|
warnings.push(`profile-resolution failed: ${msg}`);
|
|
280
|
-
coreError(`capability state: profile resolution failed: ${msg}`);
|
|
281
281
|
// Degrade to empty set (not '*') so installed=false is reported accurately.
|
|
282
282
|
installedSkills = new Set();
|
|
283
283
|
}
|
|
@@ -297,7 +297,6 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
|
|
|
297
297
|
// Genuine surface resolution failure — surface it so the caller is not misled.
|
|
298
298
|
const msg = err instanceof Error ? err.message : String(err);
|
|
299
299
|
warnings.push(`surface-resolution failed: ${msg}`);
|
|
300
|
-
coreError(`capability state: surface resolution failed: ${msg}`);
|
|
301
300
|
surfacedSkills = new Set();
|
|
302
301
|
}
|
|
303
302
|
// ── Load config ───────────────────────────────────────────────────────────────
|
|
@@ -316,19 +315,33 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
|
|
|
316
315
|
config,
|
|
317
316
|
cwd,
|
|
318
317
|
});
|
|
318
|
+
return {
|
|
319
|
+
runtimeConfigDir: resolvedConfigDir,
|
|
320
|
+
warnings,
|
|
321
|
+
registry,
|
|
322
|
+
config,
|
|
323
|
+
capabilities: result.capabilities,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
|
|
327
|
+
const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
|
328
|
+
for (const warning of result.warnings) {
|
|
329
|
+
coreError(`capability state: ${warning}`);
|
|
330
|
+
}
|
|
319
331
|
// Build envelope — include warnings array only when non-empty so the nominal
|
|
320
332
|
// path keeps the output clean and callers can check `warnings` for degraded state.
|
|
321
333
|
const envelope = {
|
|
322
|
-
runtimeConfigDir:
|
|
334
|
+
runtimeConfigDir: result.runtimeConfigDir,
|
|
323
335
|
capabilities: result.capabilities,
|
|
324
336
|
};
|
|
325
|
-
if (warnings.length > 0) {
|
|
326
|
-
envelope.warnings = warnings;
|
|
337
|
+
if (result.warnings.length > 0) {
|
|
338
|
+
envelope.warnings = result.warnings;
|
|
327
339
|
}
|
|
328
340
|
coreOutput(envelope, raw);
|
|
329
341
|
}
|
|
330
342
|
module.exports = {
|
|
331
343
|
resolveCapabilityState,
|
|
344
|
+
resolveCapabilityRuntimeState,
|
|
332
345
|
cmdCapabilityState,
|
|
333
346
|
// Exported for tests
|
|
334
347
|
_resolveCommandsGsdDir,
|
|
@@ -18,8 +18,14 @@ const { output, error, ERROR_REASON } = core;
|
|
|
18
18
|
const decisions_cjs_1 = require("./decisions.cjs");
|
|
19
19
|
const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
const verifyModule = require("./verify.cjs");
|
|
22
|
+
const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule;
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
24
|
const roadmapModule = require("./roadmap.cjs");
|
|
22
25
|
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
|
+
const gapCheckerModule = require("./gap-checker.cjs");
|
|
28
|
+
const { runGapAnalysis } = gapCheckerModule;
|
|
23
29
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
30
|
function normalizePhrase(text) {
|
|
25
31
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
@@ -439,6 +445,297 @@ function cmdUiPlanGate(projectDir, args, raw) {
|
|
|
439
445
|
}
|
|
440
446
|
output(computeUiPlanGate(projectDir, phase), raw, undefined);
|
|
441
447
|
}
|
|
448
|
+
// ─── ui-safety-gate ───────────────────────────────────────────────────────────
|
|
449
|
+
/**
|
|
450
|
+
* ui-safety-gate: post-wave check that verifies UI-changed files conform to
|
|
451
|
+
* the active UI-SPEC for the phase. Called after each wave by execute:wave:post.
|
|
452
|
+
*
|
|
453
|
+
* Returns JSON: { frontend: boolean, hasUiFiles: boolean, hasUiSpec: boolean, block: boolean, message?: string }
|
|
454
|
+
* block = frontend && hasUiFiles && !hasUiSpec
|
|
455
|
+
*
|
|
456
|
+
* Args: check ui-safety-gate <phase>
|
|
457
|
+
* Invocable as: gsd_run check ui-safety-gate <phase>
|
|
458
|
+
* or gsd_run check ui.safety-gate <phase> (dots normalized to hyphens)
|
|
459
|
+
*
|
|
460
|
+
* Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
|
|
461
|
+
* Checks whether any files changed in recent git history match frontend file patterns.
|
|
462
|
+
* Also checks whether a *-UI-SPEC.md exists in the phase directory (same as ui-plan-gate).
|
|
463
|
+
*
|
|
464
|
+
* Limitation: uses git diff HEAD~1..HEAD which covers only the last commit; in a
|
|
465
|
+
* multi-plan wave the wave-start commit would be more accurate but is not yet stored
|
|
466
|
+
* in the wave manifest. This is tracked as a known limitation.
|
|
467
|
+
*/
|
|
468
|
+
const UI_FILE_EXTENSIONS_RE = /\.(tsx|jsx|css|scss|sass|less|vue|svelte|html)$/i;
|
|
469
|
+
const UI_PATH_PATTERNS_RE = /\/(components|pages|views|screens|layouts|ui|frontend)\//i;
|
|
470
|
+
/**
|
|
471
|
+
* Pure logic for ui-safety-gate — exposed for direct behavioral testing.
|
|
472
|
+
*
|
|
473
|
+
* Given a projectDir and phase number:
|
|
474
|
+
* (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
|
|
475
|
+
* same lookup as computeUiPlanGate — to determine if this is a frontend phase.
|
|
476
|
+
* (b) Runs checkUiPresence (frontend detection) — no reimplementation.
|
|
477
|
+
* (c) Checks git diff HEAD~1..HEAD for UI file changes in the current worktree.
|
|
478
|
+
* (d) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
|
|
479
|
+
*
|
|
480
|
+
* Returns: { frontend, hasUiFiles, hasUiSpec, block, message?, phaseLookupFailed? }
|
|
481
|
+
* block = frontend && hasUiFiles && !hasUiSpec
|
|
482
|
+
* phaseLookupFailed = ROADMAP.md present but phase header not found
|
|
483
|
+
*/
|
|
484
|
+
function computeUiSafetyGate(projectDir, phase) {
|
|
485
|
+
// (a) Read the phase section text (same two-pass lookup as computeUiPlanGate)
|
|
486
|
+
let phaseSection = '';
|
|
487
|
+
let phaseLookupFailed;
|
|
488
|
+
try {
|
|
489
|
+
const section = getRoadmapPhaseWithFallback(projectDir, phase);
|
|
490
|
+
if (section === null) {
|
|
491
|
+
const planDir = typeof core['planningDir'] === 'function'
|
|
492
|
+
? core['planningDir'](projectDir)
|
|
493
|
+
: node_path_1.default.join(projectDir, '.planning');
|
|
494
|
+
const roadmapPath = node_path_1.default.join(planDir, 'ROADMAP.md');
|
|
495
|
+
if (node_fs_1.default.existsSync(roadmapPath)) {
|
|
496
|
+
phaseLookupFailed = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
phaseSection = section;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch { /* roadmap read failure → treat as empty (non-frontend) */ }
|
|
504
|
+
// (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
|
|
505
|
+
const presenceResult = (0, ui_safety_gate_cjs_1.checkUiPresence)(phaseSection);
|
|
506
|
+
const frontend = presenceResult.hasUI;
|
|
507
|
+
// (c) Check whether any UI files were changed in recent git commits
|
|
508
|
+
// Uses git diff HEAD~1..HEAD to detect frontend file changes since last commit.
|
|
509
|
+
// Known limitation: multi-plan waves may need the wave-start commit for full coverage.
|
|
510
|
+
let hasUiFiles = false;
|
|
511
|
+
try {
|
|
512
|
+
const changed = (0, node_child_process_1.execFileSync)('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], {
|
|
513
|
+
cwd: projectDir,
|
|
514
|
+
encoding: 'utf-8',
|
|
515
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
516
|
+
windowsHide: true,
|
|
517
|
+
});
|
|
518
|
+
hasUiFiles = changed.split('\n').some((f) => f.trim() && (UI_FILE_EXTENSIONS_RE.test(f) || UI_PATH_PATTERNS_RE.test(f)));
|
|
519
|
+
}
|
|
520
|
+
catch { /* git unavailable or no prior commit — treat as no UI files changed */ }
|
|
521
|
+
// (d) Resolve phase directory and check for *-UI-SPEC.md (same as computeUiPlanGate)
|
|
522
|
+
const coreModule = core;
|
|
523
|
+
let phaseDir = '';
|
|
524
|
+
try {
|
|
525
|
+
const findPhase = coreModule['findPhaseInternal'];
|
|
526
|
+
if (typeof findPhase === 'function') {
|
|
527
|
+
const result = findPhase(projectDir, phase);
|
|
528
|
+
if (result && typeof result === 'object') {
|
|
529
|
+
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
|
530
|
+
if (relDir) {
|
|
531
|
+
phaseDir = node_path_1.default.resolve(projectDir, relDir);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else if (typeof result === 'string') {
|
|
535
|
+
phaseDir = result;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch { /* phase dir lookup failure → hasUiSpec=false */ }
|
|
540
|
+
const uiSpecPath = findUiSpecInDir(phaseDir);
|
|
541
|
+
const hasUiSpec = uiSpecPath !== '';
|
|
542
|
+
// block only when: this is a frontend phase AND UI files were changed AND no UI-SPEC exists
|
|
543
|
+
const block = frontend && hasUiFiles && !hasUiSpec;
|
|
544
|
+
const result = { frontend, hasUiFiles, hasUiSpec, block };
|
|
545
|
+
if (block) {
|
|
546
|
+
result.message = `UI files changed in this wave but no UI-SPEC.md exists for Phase ${phase}. ` +
|
|
547
|
+
`Run /gsd:ui-phase ${phase} to generate the design contract before continuing.`;
|
|
548
|
+
}
|
|
549
|
+
if (phaseLookupFailed)
|
|
550
|
+
result.phaseLookupFailed = true;
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
function cmdUiSafetyGate(projectDir, args, raw) {
|
|
554
|
+
// args[0] = 'check', args[1] = 'ui-safety-gate', args[2] = phase
|
|
555
|
+
const phase = args[2] || '';
|
|
556
|
+
if (!phase) {
|
|
557
|
+
error('ui-safety-gate requires a phase argument: check ui-safety-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
output(computeUiSafetyGate(projectDir, phase), raw, undefined);
|
|
561
|
+
}
|
|
562
|
+
function cmdTddReviewCheckpoint(projectDir, args, raw) {
|
|
563
|
+
// args[0] = 'check', args[1] = 'tdd-review-checkpoint' (normalized), args[2] = phase
|
|
564
|
+
const phase = args[2] || '';
|
|
565
|
+
if (!phase) {
|
|
566
|
+
error('tdd.review-checkpoint requires a phase argument: check tdd.review-checkpoint <phase>', ERROR_REASON.SDK_MISSING_ARG);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Resolve phase directory
|
|
570
|
+
const coreModule = core;
|
|
571
|
+
let phaseDir = '';
|
|
572
|
+
try {
|
|
573
|
+
const findPhase = coreModule['findPhaseInternal'];
|
|
574
|
+
if (typeof findPhase === 'function') {
|
|
575
|
+
const result = findPhase(projectDir, phase);
|
|
576
|
+
if (result && typeof result === 'object') {
|
|
577
|
+
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
|
578
|
+
if (relDir)
|
|
579
|
+
phaseDir = node_path_1.default.resolve(projectDir, relDir);
|
|
580
|
+
}
|
|
581
|
+
else if (typeof result === 'string') {
|
|
582
|
+
phaseDir = result;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch { /* phase dir lookup failure */ }
|
|
587
|
+
// Find all PLAN.md files with type: tdd in frontmatter
|
|
588
|
+
const tddPlanFiles = [];
|
|
589
|
+
if (phaseDir) {
|
|
590
|
+
try {
|
|
591
|
+
const files = node_fs_1.default.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
|
|
592
|
+
for (const file of files) {
|
|
593
|
+
const planPath = node_path_1.default.join(phaseDir, file);
|
|
594
|
+
const content = readIfExists(planPath);
|
|
595
|
+
// Check frontmatter for type: tdd
|
|
596
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
597
|
+
if (frontmatterMatch) {
|
|
598
|
+
const fm = frontmatterMatch[1];
|
|
599
|
+
if (/^type:\s*tdd\s*$/m.test(fm)) {
|
|
600
|
+
tddPlanFiles.push(planPath);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch { /* directory read failure */ }
|
|
606
|
+
}
|
|
607
|
+
if (tddPlanFiles.length === 0) {
|
|
608
|
+
const result = {
|
|
609
|
+
// Uniform gate contract: block = violations > 0 (advisory; never truly blocks).
|
|
610
|
+
block: false,
|
|
611
|
+
passed: true,
|
|
612
|
+
tddPlans: 0,
|
|
613
|
+
violations: 0,
|
|
614
|
+
table: '',
|
|
615
|
+
rows: [],
|
|
616
|
+
message: `No type:tdd plans found in phase ${phase}. TDD review skipped.`,
|
|
617
|
+
};
|
|
618
|
+
// Pass undefined as rawValue so --raw emits JSON (not plain text).
|
|
619
|
+
// The human-readable report is carried in `result.message` for the
|
|
620
|
+
// dispatch's advisory branch to surface.
|
|
621
|
+
output(result, raw, undefined);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// For each TDD plan, extract the plan ID (padded plan number) and check git log
|
|
625
|
+
const rows = [];
|
|
626
|
+
for (const planPath of tddPlanFiles) {
|
|
627
|
+
// Extract plan ID from filename (e.g. "01-02-PLAN.md" → "01-02", or "03-PLAN.md" → "03")
|
|
628
|
+
const basename = node_path_1.default.basename(planPath, '-PLAN.md');
|
|
629
|
+
// planId for commit grep: phase-plan format, e.g. "01-02"
|
|
630
|
+
const planId = basename;
|
|
631
|
+
// Check for RED gate commit: test({planId}):
|
|
632
|
+
let red = false;
|
|
633
|
+
let green = false;
|
|
634
|
+
let refactor = false;
|
|
635
|
+
try {
|
|
636
|
+
const redCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
|
|
637
|
+
red = redCommit.trim().length > 0;
|
|
638
|
+
}
|
|
639
|
+
catch { /* git unavailable or no match */ }
|
|
640
|
+
try {
|
|
641
|
+
const greenCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
|
|
642
|
+
green = greenCommit.trim().length > 0;
|
|
643
|
+
}
|
|
644
|
+
catch { /* git unavailable or no match */ }
|
|
645
|
+
try {
|
|
646
|
+
const refactorCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
|
|
647
|
+
refactor = refactorCommit.trim().length > 0;
|
|
648
|
+
}
|
|
649
|
+
catch { /* git unavailable or no match */ }
|
|
650
|
+
const missing = [];
|
|
651
|
+
if (!red)
|
|
652
|
+
missing.push('RED');
|
|
653
|
+
if (!green)
|
|
654
|
+
missing.push('GREEN');
|
|
655
|
+
const status = missing.length === 0 ? 'Pass' : 'FAIL';
|
|
656
|
+
rows.push({ planId, red, green, refactor, status, missing });
|
|
657
|
+
}
|
|
658
|
+
const violations = rows.filter(r => r.status === 'FAIL').length;
|
|
659
|
+
// Build review table
|
|
660
|
+
const sep = '━'.repeat(53);
|
|
661
|
+
const tableHeader = '| Plan | RED | GREEN | REFACTOR | Status |';
|
|
662
|
+
const tableDivider = '|------|-----|-------|----------|--------|';
|
|
663
|
+
const tableRows = rows.map(r => `| ${r.planId.padEnd(4)} | ${r.red ? ' ✓ ' : ' ✗ '} | ${r.green ? ' ✓ ' : ' ✗ '} | ${r.refactor ? ' ✓ ' : ' — '} | ${r.status.padEnd(6)} |`);
|
|
664
|
+
let table = [
|
|
665
|
+
sep,
|
|
666
|
+
` TDD REVIEW — Phase ${phase}`,
|
|
667
|
+
sep,
|
|
668
|
+
'',
|
|
669
|
+
`TDD Plans: ${tddPlanFiles.length} | Gate violations: ${violations}`,
|
|
670
|
+
'',
|
|
671
|
+
tableHeader,
|
|
672
|
+
tableDivider,
|
|
673
|
+
...tableRows,
|
|
674
|
+
].join('\n');
|
|
675
|
+
if (violations > 0) {
|
|
676
|
+
table += '\n\n⚠ Gate violations are advisory — review before advancing.';
|
|
677
|
+
for (const r of rows.filter(row => row.status === 'FAIL')) {
|
|
678
|
+
table += `\n Plan ${r.planId} missing: ${r.missing.join(', ')} gate commit(s).`;
|
|
679
|
+
table += `\n Expected commit pattern: test(${r.planId}): ... → feat(${r.planId}): ...`;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const result = {
|
|
683
|
+
// Uniform gate contract: block = violations > 0.
|
|
684
|
+
// This gate is advisory (blocking: false in capability.json) so block:true
|
|
685
|
+
// only surfaces as a warning, never halts. Kept here so the host-loop
|
|
686
|
+
// dispatch can read a single consistent `block` field.
|
|
687
|
+
block: violations > 0,
|
|
688
|
+
passed: true,
|
|
689
|
+
tddPlans: tddPlanFiles.length,
|
|
690
|
+
violations,
|
|
691
|
+
table,
|
|
692
|
+
rows,
|
|
693
|
+
// Human-readable report in `message` so the dispatch's advisory branch
|
|
694
|
+
// can surface it. --raw emits JSON (rawValue=undefined), not plain text.
|
|
695
|
+
message: table,
|
|
696
|
+
};
|
|
697
|
+
// Pass undefined as rawValue so --raw emits JSON (not the raw table text).
|
|
698
|
+
// The review table is carried in `result.message` and `result.table` so
|
|
699
|
+
// the host-loop dispatch's advisory branch can surface it.
|
|
700
|
+
output(result, raw, undefined);
|
|
701
|
+
}
|
|
702
|
+
// ─── gap-analysis-plan-post ───────────────────────────────────────────────────
|
|
703
|
+
/**
|
|
704
|
+
* gap-analysis-plan-post: non-blocking advisory check that runs the post-planning
|
|
705
|
+
* gap analysis after all PLAN.md files are generated for a phase.
|
|
706
|
+
*
|
|
707
|
+
* Cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md
|
|
708
|
+
* against the concatenated text of all *-PLAN.md files, emitting a coverage table.
|
|
709
|
+
*
|
|
710
|
+
* This gate is always advisory (passed: true) — it never blocks phase advancement.
|
|
711
|
+
*
|
|
712
|
+
* Args: check gap-analysis.plan-post <phase-dir> [phase-req-ids]
|
|
713
|
+
* Invocable as: gsd_run check gap-analysis.plan-post <phase-dir> [phase-req-ids]
|
|
714
|
+
*/
|
|
715
|
+
function cmdGapAnalysisPlanPost(projectDir, args, raw) {
|
|
716
|
+
// args[0] = 'check', args[1] = 'gap-analysis-plan-post' (normalized), args[2] = phaseDir, args[3] = phaseReqIds
|
|
717
|
+
const phaseDir = args[2] || '';
|
|
718
|
+
if (!phaseDir) {
|
|
719
|
+
error('gap-analysis.plan-post requires a phase-dir argument: check gap-analysis.plan-post <phase-dir> [phase-req-ids]', ERROR_REASON.SDK_MISSING_ARG);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const phaseReqIds = args[3] ?? undefined;
|
|
723
|
+
const result = runGapAnalysis(projectDir, phaseDir, { phaseReqIds });
|
|
724
|
+
// Uniform gate contract: block = false (gap-analysis is always advisory, never blocks).
|
|
725
|
+
// `message` carries the human-readable gap analysis report so the dispatch's
|
|
726
|
+
// advisory branch can surface it. --raw emits JSON (rawValue=undefined), not
|
|
727
|
+
// plain markdown text.
|
|
728
|
+
output({
|
|
729
|
+
block: false,
|
|
730
|
+
passed: true,
|
|
731
|
+
enabled: result.enabled,
|
|
732
|
+
table: result.table,
|
|
733
|
+
summary: result.summary,
|
|
734
|
+
counts: result.counts,
|
|
735
|
+
// Human-readable report in `message` for the host-loop advisory branch.
|
|
736
|
+
message: result.table || result.summary || '',
|
|
737
|
+
}, raw, undefined);
|
|
738
|
+
}
|
|
442
739
|
function routeCheckCommand({ args, cwd, raw }) {
|
|
443
740
|
// Normalize dots to hyphens in the subcommand so both forms are accepted.
|
|
444
741
|
// This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
|
|
@@ -464,11 +761,41 @@ function routeCheckCommand({ args, cwd, raw }) {
|
|
|
464
761
|
cmdUiPlanGate(cwd, args, raw);
|
|
465
762
|
return;
|
|
466
763
|
}
|
|
467
|
-
|
|
764
|
+
if (subcommand === 'gap-analysis-plan-post') {
|
|
765
|
+
cmdGapAnalysisPlanPost(cwd, args, raw);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (subcommand === 'tdd-review-checkpoint') {
|
|
769
|
+
cmdTddReviewCheckpoint(cwd, args, raw);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (subcommand === 'ui-safety-gate') {
|
|
773
|
+
cmdUiSafetyGate(cwd, args, raw);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (subcommand === 'verify-schema-drift') {
|
|
777
|
+
// Delegates to verify.schema-drift — drift capability gate at execute:wave:post (blocking).
|
|
778
|
+
// Dot-to-hyphen normalization means query "verify.schema-drift" routes here.
|
|
779
|
+
// Honor GSD_SKIP_SCHEMA_CHECK=true to bypass the gate (preserves the original inline gate behavior).
|
|
780
|
+
const phaseArg = typeof args[2] === 'string' ? args[2] : '';
|
|
781
|
+
const skipSchemaCheck = process.env['GSD_SKIP_SCHEMA_CHECK'] === 'true';
|
|
782
|
+
cmdVerifySchemaDrift(cwd, phaseArg, skipSchemaCheck, raw);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (subcommand === 'verify-codebase-drift') {
|
|
786
|
+
// Delegates to verify.codebase-drift — drift capability gate at execute:wave:post (non-blocking).
|
|
787
|
+
// Dot-to-hyphen normalization means query "verify.codebase-drift" routes here.
|
|
788
|
+
cmdVerifyCodebaseDrift(cwd, raw);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
468
792
|
}
|
|
469
793
|
module.exports = {
|
|
470
794
|
routeCheckCommand,
|
|
471
795
|
decisionMentioned,
|
|
472
796
|
extractPlanDesignatedSections,
|
|
473
797
|
computeUiPlanGate,
|
|
798
|
+
computeUiSafetyGate,
|
|
799
|
+
cmdGapAnalysisPlanPost,
|
|
800
|
+
cmdTddReviewCheckpoint,
|
|
474
801
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Command Roster Module
|
|
4
|
+
*
|
|
5
|
+
* Read-only helper for discovering canonical commands/gsd command stems and
|
|
6
|
+
* applying the shared GSD slash-command namespace transform.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
|
+
const slashCommandTransformer = require('../../../scripts/fix-slash-commands.cjs');
|
|
10
|
+
function readGsdCommandNames() {
|
|
11
|
+
return slashCommandTransformer.readCmdNames();
|
|
12
|
+
}
|
|
13
|
+
module.exports = {
|
|
14
|
+
readGsdCommandNames,
|
|
15
|
+
transformContentToHyphen: slashCommandTransformer.transformContentToHyphen,
|
|
16
|
+
transformContent: slashCommandTransformer.transformContent,
|
|
17
|
+
buildPattern: slashCommandTransformer.buildPattern,
|
|
18
|
+
buildColonPattern: slashCommandTransformer.buildColonPattern,
|
|
19
|
+
};
|
|
@@ -44,11 +44,19 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
|
|
|
44
44
|
const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
|
|
45
45
|
if (verificationFile) {
|
|
46
46
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, verificationFile)) || '';
|
|
47
|
-
|
|
47
|
+
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false
|
|
48
|
+
// matches from historical body metadata such as `previous_status: gaps_found`.
|
|
49
|
+
// Full-text regexes like /status:\s*gaps_found/ match the substring inside
|
|
50
|
+
// `previous_status: gaps_found`, producing incorrect phase status labels.
|
|
51
|
+
const fm = extractFrontmatter(content);
|
|
52
|
+
// Normalise to lower-case to preserve the prior case-insensitive behaviour
|
|
53
|
+
// while reading only the frontmatter `status` key (not the full body text).
|
|
54
|
+
const fmStatus = typeof fm['status'] === 'string' ? fm['status'].trim().toLowerCase() : '';
|
|
55
|
+
if (fmStatus === 'passed')
|
|
48
56
|
return 'Complete';
|
|
49
|
-
if (
|
|
57
|
+
if (fmStatus === 'human_needed')
|
|
50
58
|
return 'Needs Review';
|
|
51
|
-
if (
|
|
59
|
+
if (fmStatus === 'gaps_found')
|
|
52
60
|
return 'Executed';
|
|
53
61
|
// Verification exists but unrecognized status — treat as executed
|
|
54
62
|
return 'Executed';
|
|
@@ -505,13 +513,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
505
513
|
/**
|
|
506
514
|
* Route a list of changed files to their sub-repo prefixes.
|
|
507
515
|
*
|
|
508
|
-
* Bucket sub-repos by their first path segment. Any file that matches a
|
|
516
|
+
* Bucket sub-repos by their first path segment (#311). Any file that matches a
|
|
509
517
|
* sub-repo prefix must share that sub-repo's first segment, so we only scan
|
|
510
|
-
* the (small)
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
* inner startsWith). (#311)
|
|
518
|
+
* the (small) same-first-segment bucket instead of all sub-repos. Within that
|
|
519
|
+
* bucket all candidates are scanned to find the longest (most-specific)
|
|
520
|
+
* matching prefix, so nested sub_repos (e.g. ['packages', 'packages/core'])
|
|
521
|
+
* route to the deepest match regardless of sub_repos array order (#391).
|
|
515
522
|
*
|
|
516
523
|
* @param files - changed file paths (relative to project root)
|
|
517
524
|
* @param subRepos - sub-repo path prefixes from config.sub_repos
|
|
@@ -531,7 +538,23 @@ function groupFilesBySubrepo(files, subRepos) {
|
|
|
531
538
|
const unmatched = [];
|
|
532
539
|
for (const file of files) {
|
|
533
540
|
const candidates = reposByFirstSeg.get(file.split('/')[0]);
|
|
534
|
-
|
|
541
|
+
// Select the longest (most-specific) matching sub-repo prefix so nested
|
|
542
|
+
// sub_repos (e.g. ['packages', 'packages/core']) route correctly regardless
|
|
543
|
+
// of array order. (#391) String() guards the length read so non-string
|
|
544
|
+
// entries never throw, matching the tolerance of the prior `.find` path.
|
|
545
|
+
let match;
|
|
546
|
+
let matchLen = -1;
|
|
547
|
+
if (candidates) {
|
|
548
|
+
for (const repo of candidates) {
|
|
549
|
+
if (file.startsWith(repo + '/')) {
|
|
550
|
+
const repoLen = String(repo).length;
|
|
551
|
+
if (repoLen > matchLen) {
|
|
552
|
+
match = repo;
|
|
553
|
+
matchLen = repoLen;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
535
558
|
if (match) {
|
|
536
559
|
(grouped[match] ||= []).push(file);
|
|
537
560
|
}
|
|
@@ -38,7 +38,7 @@ const { detectSubRepos } = coreUtilsModule;
|
|
|
38
38
|
const configuration_cjs_1 = require("./configuration.cjs");
|
|
39
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
40
|
const configSchema = require("./config-schema.cjs");
|
|
41
|
-
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS,
|
|
41
|
+
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
|
|
42
42
|
const model_catalog_cjs_1 = require("./model-catalog.cjs");
|
|
43
43
|
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -331,7 +331,7 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
|
|
|
331
331
|
return baseConfig;
|
|
332
332
|
const _fedOverlay = mergeFederatedConfig({
|
|
333
333
|
configSchema: _fedRegistrySchema,
|
|
334
|
-
isCentralKey: (key) =>
|
|
334
|
+
isCentralKey: (key) => _isCentralConfigKeyFn(key),
|
|
335
335
|
userConfig,
|
|
336
336
|
});
|
|
337
337
|
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
|
|
@@ -472,7 +472,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
472
472
|
// Internal keys loadConfig reads but config-set doesn't expose
|
|
473
473
|
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
|
|
474
474
|
// Deprecated keys (still accepted for migration, not in config-set)
|
|
475
|
-
'depth', 'multiRepo', 'branching_strategy',
|
|
475
|
+
'depth', 'multiRepo', 'branching_strategy', 'research',
|
|
476
476
|
]);
|
|
477
477
|
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
|
|
478
478
|
// federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
|
|
@@ -483,7 +483,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
483
483
|
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
|
484
484
|
const _earlyOverlay = mergeFederatedConfig({
|
|
485
485
|
configSchema: _fedRegistrySchemaEarly,
|
|
486
|
-
isCentralKey: (key) =>
|
|
486
|
+
isCentralKey: (key) => _isCentralConfigKeyFn(key),
|
|
487
487
|
userConfig: parsed,
|
|
488
488
|
});
|
|
489
489
|
_preWarningFedValidKeys = _earlyOverlay.validKeys;
|
|
@@ -554,7 +554,6 @@ function loadConfig(cwd, options = {}) {
|
|
|
554
554
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
555
555
|
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
|
556
556
|
exa_search: get('exa_search') ?? defaults.exa_search,
|
|
557
|
-
tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
|
|
558
557
|
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
|
|
559
558
|
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
|
560
559
|
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
|
|
@@ -608,7 +607,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
608
607
|
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
|
609
608
|
const _fedOverlay = mergeFederatedConfig({
|
|
610
609
|
configSchema: _fedRegistrySchema,
|
|
611
|
-
isCentralKey: (key) =>
|
|
610
|
+
isCentralKey: (key) => _isCentralConfigKeyFn(key),
|
|
612
611
|
userConfig: parsed,
|
|
613
612
|
});
|
|
614
613
|
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
|
|
@@ -634,8 +633,8 @@ function loadConfig(cwd, options = {}) {
|
|
|
634
633
|
return loadConfig(cwd, { workstream: null });
|
|
635
634
|
}
|
|
636
635
|
// FIX 2: Apply the federated overlay on the no-config path.
|
|
637
|
-
//
|
|
638
|
-
//
|
|
636
|
+
// Migrated Capability keys are surfaced from the generated registry even
|
|
637
|
+
// when the project has no config.json, so schema defaults still apply.
|
|
639
638
|
try {
|
|
640
639
|
return _applyFederatedOverlay(defaults, {});
|
|
641
640
|
}
|