* feat(#3778): dispatch plan:pre planner contributions in quick.md - Add plan:pre capability gate to quick.md Step 5, mirroring plan-phase.md's existing render + generic contribution dispatch pattern - Inject planner-targeted contribution fragments into the planner prompt, after AGENT_SKILLS_PLANNER, matching D-08 ordering - Add tests/quick-plan-pre-capabilities.test.cjs proving the dispatch is generic (D-01) via real scanWiredKinds/coveredKindsInRegion functions - Record quick.md's byte-growth rationale in this commit trailer for every gsd-core-verbatim runtime Emitted-Drift-Ack-Growth: quick.md — #3778: Step 5 (Spawn planner, quick mode) gains a `plan:pre` capability gate, mirroring `plan-phase.md:420-424` and `:797`. This is shipped shell and prose read by an agent at runtime, not compiled, so the reasoning has to travel with the feature rather than being deferred to a reference doc: (1) the dispatch paragraph phrases role routing possessively ("the role each entry's `into` names") rather than as an `into ==` equality, because `coveredKindsInRegion` (scripts/gen-loop-host-contract.cjs) voids a segment's `kind == "contribution"` coverage credit when a role or capability equality shares that same segment — an equality phrasing here would silently fail the generic-dispatch proof required by D-01; (2) `activeHooks` is read directly in-context from `PLAN_PRE_HOOKS_JSON`/`HOOKS_JSON` and the unfiltered `rendered` digest is explicitly forbidden from being pasted, because `rendered` carries every kind and role — including non-planner-targeted contributions such as a `into: "checker"` twin — and pasting it would leak checker-scoped guidance into the planner's prompt (T-01-02 in the threat model); (3) the injection block sits inside `<planning_context>` AFTER `${AGENT_SKILLS_PLANNER}` and after the Project skills line, matching plan-phase's `:741` -> `:797` ordering (D-08), so agent-skills content is never shadowed by capability-contributed prose. No prose was moved into an eagerly `@`-imported reference to shrink the measured file — @gsd-core/references/loop-hook-dispatch.md already existed before this change and is deferred to for the generic contract only, exactly as plan-phase.md already does. * test(#3778): expand quick.md plan:pre dispatch coverage to all nine locked conditions Extend tests/quick-plan-pre-capabilities.test.cjs with D-02 (silent omit-when-empty), D-03 (single shared planner spawn), D-06 (array-order dispatch phrasing), D-07 (planner-only into filter), and D-08 (render call < agent-skills placeholder < injection block < spawn ordering) assertions, all extracted via a brace-bounded slice anchored on the literal injection instruction rather than a naive first-brace scan (${AGENT_SKILLS_PLANNER} and the surrounding prompt's ${VALIDATE_MODE ? ...} ternaries also contain brace pairs). Add a capability-registry.test.cjs describe block proving the registry-wide D-07 exclusion is meaningful: at least one plan:pre contribution exists, every plan:pre contribution has a non-empty into/fragment.inline, and the registry as a whole carries at least one non-planner-into contribution. Add a loop-host-contract.test.cjs regression pin for D-09: quick.md stays absent from STEP_WORKFLOWS, parseLoopHostBlock still throws on quick.md's real content, and buildContract() still yields exactly 5 entries. Verified red-without-Task-1 by temporarily reverting quick.md to its pre-f30de9cc content and re-running these three suites (D-08 failed as expected), then restored via git checkout and re-confirmed green. * docs(#3778): note quick planning also renders plan:pre in the tutorial The tutorial's Step 6 named only /gsd-plan-phase as the trigger for the plan:pre hook set. Since quick.md now dispatches the same hook set (f30de9cc), the sentence understated the capability's real reach. * feat(#3778): add changeset fragment * chore(#3778): reference the upstream issue in the changeset fragment The fragment was the only one of 81 in .changeset/ without a trailing (#NNNN) reference or a bold lead-in. serializeChangelog auto-appends only the pr: field, so the rendered CHANGELOG entry carried no link back to issue #3778. * test(#3778): scope the D-07 registry assertion to what it actually proves The registry-wide non-planner check was named "D-07 exclusion is meaningful", which overclaims: it proves only that `into` takes non-planner values somewhere in the registry, not that anything is excluded at plan:pre. Every plan:pre contribution is currently into: "planner", so the filter is a forward-looking safeguard there. Narrowing the assertion to plan:pre (as review suggested) would fail today. Asserting plan:pre is all-planner would be brittle — it would break the day a legitimate non-planner plan:pre contribution lands, which is exactly when the safeguard starts doing work. So the assertion is unchanged and only the name and comment are corrected. * chore(#3778): point the changeset fragment at the upstream PR The fragment carried pr: 3, the fork staging PR. changeset lint derives the real PR number from GITHUB_EVENT_PATH, so on the upstream PR that would read as pr-field drift. Point it at open-gsd/gsd-core#3934. * test(#3778): require contributions in Quick revision prompts * test(loop-host): require Quick auxiliary registration * fix(#3778): preserve contributions in Quick plan revisions * fix(#3778): validate Quick as a planner contribution host * fix(#3778): tighten Quick contribution contract * test(#3778): drop unnecessary source-contract exemption * fix(#3778): require Quick planner target coverage * docs(#3778): describe targeted auxiliary coverage --------- Co-authored-by: davdittrich <davdittrich@gmail.com> Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
committed by
GitHub
parent
4c6643a8d7
commit
b848b23861
@@ -30,7 +30,13 @@ const CONTRACT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'loop-host-contr
|
||||
// The five step workflows in pipeline order
|
||||
const STEP_WORKFLOWS = [
|
||||
{ file: 'discuss-phase.md', step: 'discuss' },
|
||||
{ file: 'plan-phase.md', step: 'plan' },
|
||||
{
|
||||
file: 'plan-phase.md',
|
||||
step: 'plan',
|
||||
auxiliaryHosts: [
|
||||
{ file: 'quick.md', point: 'plan:pre', kinds: ['contribution'], into: 'planner' },
|
||||
],
|
||||
},
|
||||
{ file: 'execute-phase.md', step: 'execute' },
|
||||
{ file: 'verify-work.md', step: 'verify' },
|
||||
{ file: 'ship.md', step: 'ship' },
|
||||
@@ -295,7 +301,7 @@ function buildContract(workflowsDir) {
|
||||
const contract = [];
|
||||
const allErrors = [];
|
||||
|
||||
for (const { file, step } of STEP_WORKFLOWS) {
|
||||
for (const { file, step, auxiliaryHosts = [] } of STEP_WORKFLOWS) {
|
||||
const filePath = path.join(resolvedDir, file);
|
||||
let content;
|
||||
try {
|
||||
@@ -325,6 +331,34 @@ function buildContract(workflowsDir) {
|
||||
const roleErrors = crossCheckRoles(content, entry.agentRoles, file);
|
||||
allErrors.push(...roleErrors);
|
||||
|
||||
for (const auxiliary of auxiliaryHosts) {
|
||||
let auxiliaryContent;
|
||||
try {
|
||||
auxiliaryContent = fs.readFileSync(path.join(resolvedDir, auxiliary.file), 'utf8');
|
||||
} catch (err) {
|
||||
allErrors.push('Could not read auxiliary host ' + auxiliary.file + ': ' + String(err.message));
|
||||
continue;
|
||||
}
|
||||
|
||||
const wiredPoints = scanWiredPoints(auxiliaryContent);
|
||||
if (!wiredPoints.has(auxiliary.point)) {
|
||||
allErrors.push(
|
||||
auxiliary.file + ': auxiliary host missing expected point "' + auxiliary.point + '"',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wiredKinds = scanWiredKinds(auxiliaryContent, auxiliary.into).get(auxiliary.point) || new Set();
|
||||
for (const kind of auxiliary.kinds) {
|
||||
if (!wiredKinds.has(kind)) {
|
||||
allErrors.push(
|
||||
auxiliary.file + ': auxiliary host point "' + auxiliary.point +
|
||||
'" missing expected kind "' + kind + '"',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contract.push(entry);
|
||||
}
|
||||
|
||||
@@ -437,7 +471,10 @@ function main() {
|
||||
* registry generator, conformance gate) must derive from this rather than maintaining
|
||||
* a separate hardcoded list.
|
||||
*/
|
||||
const HOST_LOOP_FILES = STEP_WORKFLOWS.map((w) => 'gsd-core/workflows/' + w.file);
|
||||
const HOST_LOOP_FILES = STEP_WORKFLOWS.flatMap(({ file, auxiliaryHosts = [] }) => [
|
||||
'gsd-core/workflows/' + file,
|
||||
...auxiliaryHosts.map((host) => 'gsd-core/workflows/' + host.file),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pure function: scan a text string for `loop render-hooks <point>` call sites.
|
||||
@@ -475,9 +512,11 @@ const HOOK_KINDS = ['contribution', 'step', 'gate'];
|
||||
* - A deferral line (one carrying an `@`-included path to the generic
|
||||
* contract, e.g. `@gsd-core/references/loop-hook-dispatch.md`) that names a
|
||||
* kind (`kind == "step"`) covers that kind; a deferral line with no kind
|
||||
* discriminator ("apply each entry") covers every kind. A bare §-citation
|
||||
* of the reference (validation guidance only, no `@`) covers nothing —
|
||||
* plan-phase cites the gate-validation section while dispatching only gates.
|
||||
* discriminator ("apply each entry") covers every kind only when no role
|
||||
* target is required. With `expectedInto`, the same segment must explicitly
|
||||
* name both the kind and that exact `into` target. A bare §-citation of the
|
||||
* reference (validation guidance only, no `@`) covers nothing — plan-phase
|
||||
* cites the gate-validation section while dispatching only gates.
|
||||
* - Otherwise a kind is covered when some LINE dispatches it unconditionally:
|
||||
* a `kind == "<kind>"` discriminator with NO same-line narrowing to one
|
||||
* hook (`ref.skill ==`, `ref.agent ==`, `ref.command ==`). A narrowed line
|
||||
@@ -491,19 +530,26 @@ const HOOK_KINDS = ['contribution', 'step', 'gate'];
|
||||
* Pure: same input, same output; CRLF-safe (line splitting tolerates \r).
|
||||
*
|
||||
* @param {string} region Dispatch text following one call site.
|
||||
* @param {string} [expectedInto] Optional role target required in the same segment.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function coveredKindsInRegion(region) {
|
||||
function coveredKindsInRegion(region, expectedInto) {
|
||||
const covered = new Set();
|
||||
// Same-SEGMENT narrowing to ONE hook voids credit: `ref.skill ==`, `capId ==`,
|
||||
// and `into ==` each special-case a subset, not the kind generally
|
||||
// and `into ==` each special-case a subset, not the kind generally. An
|
||||
// auxiliary host may name the one role it actually hosts via `expectedInto`;
|
||||
// any other role target still voids credit.
|
||||
// (plan-phase's `kind == "contribution" and capId == "security"` is the
|
||||
// hand-rolled shape; `into == "planner"` covers only planner-targeted
|
||||
// contributions). Segments, not lines: execute-phase legitimately writes
|
||||
// "dispatch `kind == "step"` hooks per … . `ref.skill == "code-review"`:" —
|
||||
// the deferral is one sentence, the specialization the next; narrowing in a
|
||||
// DIFFERENT segment must not void the deferral's credit.
|
||||
const narrowingRe = /(?:ref\.(?:skill|agent|command)|capId|into)\s*={2,3}/;
|
||||
const identityNarrowingRe = /(?:ref\.(?:skill|agent|command)|capId)\s*={2,3}/;
|
||||
const intoNarrowingRe = /into\s*={2,3}/;
|
||||
const expectedIntoRe = expectedInto
|
||||
? new RegExp(`into\\s*={2,3}\\s*["']${escapeRegExp(expectedInto)}["']`)
|
||||
: null;
|
||||
// Negated mentions describe an absence, not a dispatch ("Branch 1 — no active
|
||||
// step hooks (`activeHooks` has no entry with `kind == "step"`)" — ship.md).
|
||||
const negationRe = /\b(?:no|without|absent|lacks?|missing)\b[^.|]*kind\s*={2,3}/;
|
||||
@@ -519,17 +565,22 @@ function coveredKindsInRegion(region) {
|
||||
}
|
||||
if (kindDiscriminators.length === 0) {
|
||||
// A deferral with no kind discriminator ("apply each entry per …")
|
||||
// still covers every kind.
|
||||
if (deferralRe.test(segment)) for (const kind of HOOK_KINDS) covered.add(kind);
|
||||
// still covers every kind for generic hosts. An auxiliary host with a
|
||||
// required role target must state both its kind and target explicitly.
|
||||
if (!expectedInto && deferralRe.test(segment)) for (const kind of HOOK_KINDS) covered.add(kind);
|
||||
continue;
|
||||
}
|
||||
if (negationRe.test(segment)) continue;
|
||||
const narrowed = identityNarrowingRe.test(segment) ||
|
||||
(expectedInto
|
||||
? !expectedIntoRe.test(segment)
|
||||
: intoNarrowingRe.test(segment));
|
||||
if (deferralRe.test(segment)) {
|
||||
// Deferral naming kinds ("dispatch `kind == "step"` hooks per …").
|
||||
if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
if (!narrowed) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
continue;
|
||||
}
|
||||
if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
if (!narrowed) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
}
|
||||
}
|
||||
return covered;
|
||||
@@ -540,9 +591,10 @@ function coveredKindsInRegion(region) {
|
||||
* per point, the union of hook kinds its dispatch regions cover (#3606).
|
||||
*
|
||||
* @param {string} text Content of a workflow file (or any text).
|
||||
* @param {string} [expectedInto] Optional role target an auxiliary host must dispatch.
|
||||
* @returns {Map<string, Set<string>>} point → covered kinds.
|
||||
*/
|
||||
function scanWiredKinds(text) {
|
||||
function scanWiredKinds(text, expectedInto) {
|
||||
const result = new Map();
|
||||
const siteRe = CALL_SITE_RE;
|
||||
const sites = [];
|
||||
@@ -552,7 +604,7 @@ function scanWiredKinds(text) {
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
const regionEnd = i + 1 < sites.length ? sites[i + 1].start : Math.min(text.length, sites[i].start + REGION_CAP);
|
||||
const region = text.slice(sites[i].start, regionEnd);
|
||||
const covered = coveredKindsInRegion(region);
|
||||
const covered = coveredKindsInRegion(region, expectedInto);
|
||||
if (!result.has(sites[i].point)) result.set(sites[i].point, new Set());
|
||||
for (const kind of covered) result.get(sites[i].point).add(kind);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user