feat(#1023): first phase-6 cutover — ui-review (verify:post) inline → loop.render-hooks dispatch (#1024)

Replace the inlined ui-review invocation in autonomous.md §3d.5 with a
loop.render-hooks verify:post dispatch — the first workflow to consume
render-hooks and fire a skill from it (closes the #1018 live-execution residual
as real wiring). Capability-driven, equivalence-preserving for the current
registry (only ui-review at verify:post, default on): fires gsd-ui-review under
the same precondition (UI-SPEC exists via consumes-gate + workflow.ui_review).

Gate findings (real pattern issues, fixed so every future cutover inherits them):
- bug-2643 static "Skill() references a real skill" check vs templated
  Skill(skill="gsd-${ref.skill}") dispatch → skip ${...}-templated names.
- Coverage moved, not lost: gen-capability-registry now validates
  steps[].ref.skill in skills + ref.agent in agents + rejects gsd- double-prefix.
- Tightened §3d.5 tests; markdown clarity (consumes rule, LLM-native JSON read,
  UI-REVIEW.md score hint).

gsd-ui-review skill + §3a.5/ui-phase untouched. §5.6/ui-phase cutover deferred
(#1022 step-can-halt-vs-gate model question).

Closes #1023

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 22:40:58 -04:00
committed by GitHub
parent 7eea991884
commit 2ac6592096
5 changed files with 300 additions and 32 deletions

View File

@@ -418,11 +418,16 @@ function validateFeatureBody(cap) {
}
}
// Build the declared skill/agent sets for ref membership checks (used in validateStep).
// Only build these if the arrays are valid (already validated above).
const declaredSkills = Array.isArray(cap.skills) ? new Set(cap.skills.filter((s) => typeof s === 'string')) : null;
const declaredAgents = Array.isArray(cap.agents) ? new Set(cap.agents.filter((a) => typeof a === 'string')) : null;
if (!Array.isArray(cap.steps)) {
errors.push('steps must be an array');
} else {
for (let i = 0; i < cap.steps.length; i++) {
errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']'));
errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']', declaredSkills, declaredAgents));
}
}
@@ -491,7 +496,18 @@ function validateRuntimeBody(cap) {
return errors;
}
function validateStep(step, prefix) {
/**
* Validate a single step entry.
*
* @param {object} step The step to validate.
* @param {string} prefix Path prefix for error messages (e.g. "steps[0]").
* @param {Set|null} declaredSkills Set of skill stems declared in this capability's skills array,
* or null if the skills array was not valid (skip membership check).
* @param {Set|null} declaredAgents Set of agent names declared in this capability's agents array,
* or null if the agents array was not valid (skip membership check).
* @returns {string[]}
*/
function validateStep(step, prefix, declaredSkills, declaredAgents) {
const errors = [];
if (!VALID_LOOP_POINTS.has(step.point)) {
@@ -511,9 +527,31 @@ function validateStep(step, prefix) {
}
if (hasSkill && typeof step.ref.skill !== 'string') {
errors.push(prefix + '.ref.skill must be a string');
} else if (hasSkill && typeof step.ref.skill === 'string' && step.ref.skill.startsWith('gsd-')) {
// Double-prefix guard: ref.skill is an unprefixed stem (e.g. "ui-review").
// Workflow dispatch prepends "gsd-" at runtime → "gsd-ui-review".
// A stem that already starts with "gsd-" would produce "gsd-gsd-..." at dispatch.
errors.push(
prefix + '.ref.skill "' + step.ref.skill + '" must not start with "gsd-" ' +
'(it is an unprefixed stem; the workflow prepends "gsd-" at dispatch — ' +
'starting with "gsd-" would produce "gsd-' + step.ref.skill + '")',
);
} else if (hasSkill && typeof step.ref.skill === 'string' && declaredSkills !== null && !declaredSkills.has(step.ref.skill)) {
// Membership check: ref.skill must be declared in this capability's skills array.
// This catches typos and ensures every dispatched skill is owned by this capability.
errors.push(
prefix + '.ref.skill "' + step.ref.skill + '" is not declared in this capability\'s skills: [' +
[...declaredSkills].join(', ') + ']',
);
}
if (hasAgent && typeof step.ref.agent !== 'string') {
errors.push(prefix + '.ref.agent must be a string');
} else if (hasAgent && typeof step.ref.agent === 'string' && declaredAgents !== null && !declaredAgents.has(step.ref.agent)) {
// Membership check: ref.agent must be declared in this capability's agents array.
errors.push(
prefix + '.ref.agent "' + step.ref.agent + '" is not declared in this capability\'s agents: [' +
[...declaredAgents].join(', ') + ']',
);
}
}