* enhance(#4402): split plan-phase into a spine + detail, add the shared compact-content gate ADR-4139 Decisions 3-5, Phase 2 of the #4139 Compact Content epic. Pilot split for plan-phase.md, the largest of the 58 eagerly-@-included workflow files (98,290 bytes): the spine keeps every happy-path step, every protected-content block (planner/checker prompt templates, quality gates, the failing-direction few-shot example, the two ScheduleWakeup guardrail paragraphs — each marked with a <!-- gsd:protected --> sentinel), and condensed one-paragraph summaries of five rare/opt-in fallback paths (planner and checker filesystem-hang recovery, phase-split recommendation, source-audit gaps, the thinking-partner conditional, and plan bounce). The full text of those five moves verbatim to gsd-core/workflows/plan-phase/detail.md (9.9KB, well under the 32,768-byte NEW_FILE_CAP), read by the spine only when workflow.compact_content is false (the default) — the exact same resolution rule now stated once in the new shared gsd-core/references/compact-content-gate.md, which every future split references instead of restating. Verified mechanically (tests/plan-phase-compact-split.test.cjs, scoped to this one split — Phase 3/#4403 owns the generalized guard): the union of spine + detail contains every non-trivial line the parent commit carried (0 missing), no non-trivial line is duplicated between them (0 duplicated), and every declared protected block is well-formed and non-empty. The spine shrinks from 98,290 to 93,206 bytes (-5.2% of the eager-window cost this epic exists to reduce); detail.md's 9,853 bytes are only ever paid by a project that has NOT opted in. Verified live, end to end, twice, against this actual repo (not a synthetic fixture) — real gsd-planner and gsd-plan-checker subagent spawns, real PLAN.md output: - workflow.compact_content=false: planned a real disposable phase (a docs/how-to page for enabling the key itself); planner returned PLANNING COMPLETE, checker returned VERIFICATION PASSED, all fact-checks against real repo state confirmed. - workflow.compact_content=true (detail.md never read): planned a second real disposable phase; planner returned PLANNING COMPLETE with frontmatter.validate and verify.plan-structure both clean, again fully grounded against real repo state. The five condensed fallback sections were independently re-read spine-only and confirmed sufficient to act on correctly without detail.md's elaboration. Also drafts gsd-core/references/compact-content-protected-content.md — the protected-content category list and <!-- gsd:protected --> sentinel syntax ADR-4139 Decision 5 calls for, written to move to Phase 3 (#4403) unchanged once it lands there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4402): move detail.md into the ADR-4139-mandated detail/ subdirectory Two independent review sub-agents (Standards and Spec axes of /code-review) caught the same structural defect: ADR-4139 Decision 6 mandates gsd-core/workflows/<name>/detail/*.md ("one or more parts... individually skippable"), and this PR had shipped a flat plan-phase/detail.md instead, copying issue #4402's own (inconsistent) restatement rather than the locked ADR text. Fixed by git-mv to plan-phase/detail/elaboration.md and updating every cross-reference (the spine's step 0.5 gate pointer, the shared compact-content-gate.md's own resolution-rule wording, and the completeness test's path constants). Also, from the same review pass: - docs/CONFIGURATION.md and gsd-core/references/planning-config.md's workflow.compact_content rows said "nothing branches on it yet" — no longer true now that plan-phase.md's spine does. Updated both to name plan-phase as the pilot and note the rest of the corpus is still pending. - Regenerated all 19 tests/fixtures/install-tree/*.json golden fixtures (npm run gen:install-tree) — the three new shipped files were missing from the installer emitted-tree goldens. - Found via a cache-busted `eslint . --max-warnings 0` (this repo's eslint --cache has produced false-greens before): the split test's `git show` call had a bare `timeout: 10000` literal, tripping local/no-adhoc-timeout-literal. Extracted to the existing GIT_TIMEOUT_MS constant from tests/helpers/timeouts.cjs instead of a second guessed copy of the same class of timeout. Verified NOT needed, by tracing the actual mechanism rather than asserting (tests/helpers/emitted-provenance.cjs's gsd-core-verbatim rule attributes every gsd-core/{workflows,references}/** path to itself as an identity source): an Emitted-Drift-Ack-Hash/-Growth trailer. Every changed/added path in this diff is hand-authored and present in the diff itself, so diffEmitted's attribution loop resolves `via` to the path's own source before ever reaching the ack-lookup branch — there is no unattributed delta to acknowledge. The spine also shrank (98,290 to 93,206 bytes), so the growth ratchet has nothing to ack either. Re-verified after these changes: the completeness/disjointness self-check (0 missing, 0 duplicated) still holds against the relocated detail file, and a full `npm run lint:ci` passes clean with the eslint cache cleared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4402): restore literal content the pre-existing drift guards pin on The first gsd-test run against this split (19 failures) surfaced real regressions: several pre-existing structural guards pin the EXACT text of the sections this split condensed, and paraphrasing broke them. - tests/plan-phase-drift-guard.test.cjs expects the literal `DISK_PLANS=$(gsd_run query find-phase ...)` bash assignment inside plan-phase.md itself, not a prose description of the same check. Restored the exact line into both §9a and §11a's spine summaries. - tests/thinking-partner.test.cjs expects plan-phase.md to literally offer "No, I'll decide" as the skip option. Restored that exact phrase into the condensed thinking-partner paragraph. - Both restores would have duplicated the same text into plan-phase/detail/elaboration.md (which still carries the full elaboration). Removed the now-redundant restatements from the detail file instead of leaving them duplicated — the spine already computes DISK_PLANS before the detail elaboration is ever read, so the detail file references it rather than recomputing it. - Re-running scripts/sync-runtime-launcher.cjs after that edit found the canonical gsd_run preamble had also become an unintentional spine/detail duplicate (both files call gsd_run and each is required, by runtime-launcher-parity's own contract, to carry its own copy). That's sanctioned duplication under a DIFFERENT contract, not lost/copy-pasted content, so tests/plan-phase-compact-split.test.cjs now excludes it from the disjointness check the same way it already excludes trivial fences/headings. - Applied the adversarial-review finding on tests/plan-phase-compact-split.test.cjs's own isTrivial(): a blanket `line.length <= 15` cutoff silently swallowed real content (e.g. the 14-char `<quality_gate>` sentinel). Replaced it with a specific bare-label-line pattern (`Options:`, `Display banner:` etc.) — verified 0 missing / 0 duplicated against the actual split, an improvement over both the original cutoff and a naive full removal (which produces false-positive "duplicates" on generic recurring labels). - gsd-core/references/planning-config.md's own workflow.compact_content row used `/gsd-plan-phase` (hyphen). That file is Claude-facing source text (gsd-core/references/), which tests/slash-command-namespace.test.cjs requires in colon form; docs/CONFIGURATION.md's use of the hyphen form is correct as-is since docs/ is human-facing and outside that test's scanned directories. Fixed to `/gsd:plan-phase`. - tests/plan-phase-compact-split.test.cjs's own `git show` of the parent commit failed inside the gsd-test sandbox ("detected dubious ownership") because the checkout is mounted under a UID the invoking user doesn't own. Scoped `-c safe.directory=<repo-root>` to that one git invocation rather than touching global git config. - docs/INVENTORY.md still had one outstanding "detail.md part" wording fix from the earlier adversarial-review pass, staged now. Re-verified locally against the exact assertions in all four affected test files (all pass) before dispatching a fresh gsd-test run — no change here should have broken any of the other 18 gates; `npm run lint` is clean with the eslint cache cleared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4402): restore the full marker enumeration to §9a's spine trigger line The isolated Spec-axis review flagged that §9a's "Triggered when" line was condensed to "Agent() returns but the return contains no recognized marker" — dropping the literal `## PLANNING COMPLETE` / `## PHASE SPLIT RECOMMENDED` / `## ⚠ Source Audit` / `## CHECKPOINT REACHED` / `## PLANNING INCONCLUSIVE` enumeration, which is exactly the "machine- parsed structural headings" category compact-content-protected-content.md lists as protected. The load-bearing use of that same list (the gsd_stall_watch call and the Handle Planner Return bullets a few lines above) was never touched — only this one descriptive restatement was genericized — but leaving any instance of a protected category unsentineled is the silent erosion ADR-4139 Decision 4(c) warns sufficiency isn't machine-checkable enough to catch on its own. Restored the full enumeration into the spine. That reintroduced an exact duplicate into plan-phase/detail/elaboration.md, which still stated the same trigger sentence verbatim. Reworded the detail file's version to reference the spine's trigger condition instead of restating it, since the spine is now the single place that sentence lives in full — mirroring the DISK_PLANS/"already computed above" pattern from the previous commit. Re-verified locally: completeness/disjointness (0 missing, 0 duplicated) and all previously-fixed literal-content assertions still hold. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4402): backfill changeset pr number to 4471 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
8.7 KiB
JavaScript
173 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Issue #4402 (ADR-4139 Decision 5): verifies the plan-phase.md spine /
|
|
* plan-phase/detail/ split is complete (nothing lost), disjoint (nothing
|
|
* duplicated), size-capped, and preserves the protected-content sentinels
|
|
* this pilot draws from gsd-core/references/compact-content-protected-content.md.
|
|
*
|
|
* Scoped to this one split — Phase 3 (#4403) owns the generalized guard that
|
|
* runs this class of check against every future split.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
// The commit immediately before this branch split plan-phase.md (PR #4441's merge to next).
|
|
const PARENT_SHA = 'e54d3aa159810b2308cd777047b9de9c04de418a';
|
|
const SPINE_REL = 'gsd-core/workflows/plan-phase.md';
|
|
const DETAIL_REL = 'gsd-core/workflows/plan-phase/detail/elaboration.md';
|
|
|
|
/** Trivial lines (fences, rules, bare headings, bare labels) are excluded from
|
|
* both the completeness and disjointness checks — they are boilerplate that
|
|
* legitimately repeats throughout any markdown file with code blocks, not
|
|
* content that could be silently lost or duplicated in a meaningful sense.
|
|
* A blanket short-line length cutoff would swallow real content (e.g. the
|
|
* 14-char `<quality_gate>` sentinel), so short lines are trivial only when
|
|
* they match a specific boilerplate shape rather than by length alone. */
|
|
function isTrivial(line) {
|
|
if (/^`{3,}/.test(line)) return true;
|
|
if (/^-{3,}$/.test(line)) return true;
|
|
if (/^#+\s*$/.test(line)) return true;
|
|
if (/^[A-Za-z][A-Za-z ]*:$/.test(line)) return true;
|
|
return false;
|
|
}
|
|
|
|
/** The canonical gsd_run launcher preamble (see gsd-core/workflows/_runtime-launcher.snippet.sh,
|
|
* tests/runtime-launcher-parity.test.cjs). runtime-launcher-parity mandates exactly one inlined
|
|
* copy in EVERY workflow/agent .md file that calls gsd_run — spine and detail both call gsd_run,
|
|
* so both are required to carry their own copy. That is sanctioned duplication by a different
|
|
* contract, not content this split lost or copy-pasted; exclude it from the disjointness check
|
|
* the same way trivial fences/headings are excluded. */
|
|
function isCanonicalLauncherPreamble(line) {
|
|
return line.startsWith('_GSD_SHIM_NAME="gsd-tools.cjs";');
|
|
}
|
|
|
|
function normalizeNonTrivialLines(content) {
|
|
return content
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0 && !isTrivial(l));
|
|
}
|
|
|
|
function readParentSpine() {
|
|
// -c safe.directory=<ROOT> (scoped to this one invocation, not global config):
|
|
// a sandboxed test runner can mount the repo under a UID that doesn't own the
|
|
// checkout, and git refuses every operation with "detected dubious ownership"
|
|
// until the directory is trusted. Passing it per-call avoids mutating shared
|
|
// git config for a check this test alone needs.
|
|
return execFileSync('git', ['-c', `safe.directory=${ROOT}`, 'show', `${PARENT_SHA}:${SPINE_REL}`], {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: GIT_TIMEOUT_MS,
|
|
});
|
|
}
|
|
|
|
function readCurrent(relPath) {
|
|
return fs.readFileSync(path.join(ROOT, relPath), 'utf-8');
|
|
}
|
|
|
|
describe('plan-phase compact-content spine/detail split (#4402, ADR-4139 Decision 5)', () => {
|
|
test('union of spine + detail contains every non-trivial line the parent commit carried', () => {
|
|
const parentLines = normalizeNonTrivialLines(readParentSpine());
|
|
const spineLines = normalizeNonTrivialLines(readCurrent(SPINE_REL));
|
|
const detailLines = normalizeNonTrivialLines(readCurrent(DETAIL_REL));
|
|
const unionSet = new Set([...spineLines, ...detailLines]);
|
|
|
|
const missing = parentLines.filter((l) => !unionSet.has(l));
|
|
assert.deepStrictEqual(
|
|
missing,
|
|
[],
|
|
`${missing.length} line(s) from the parent commit are missing from spine+detail:\n${missing.slice(0, 15).join('\n')}${missing.length > 15 ? `\n(+${missing.length - 15} more)` : ''}`,
|
|
);
|
|
});
|
|
|
|
test('no non-trivial line appears in both spine and detail', () => {
|
|
const spineLines = normalizeNonTrivialLines(readCurrent(SPINE_REL));
|
|
const detailLines = normalizeNonTrivialLines(readCurrent(DETAIL_REL));
|
|
const spineSet = new Set(spineLines);
|
|
const duplicated = detailLines
|
|
.filter((l) => spineSet.has(l))
|
|
.filter((l) => !isCanonicalLauncherPreamble(l));
|
|
assert.deepStrictEqual(
|
|
duplicated,
|
|
[],
|
|
`${duplicated.length} line(s) appear in both spine and detail:\n${duplicated.slice(0, 15).join('\n')}`,
|
|
);
|
|
});
|
|
|
|
test('detail.md is a new shipped file under the NEW_FILE_CAP (32768 bytes, tests/helpers/emitted-diff.cjs)', () => {
|
|
const size = fs.statSync(path.join(ROOT, DETAIL_REL)).size;
|
|
assert.ok(size < 32768, `plan-phase/detail/elaboration.md is ${size} bytes; NEW_FILE_CAP is 32768`);
|
|
});
|
|
|
|
test('the spine is smaller than the parent commit\'s file (eager-window byte reduction is real)', () => {
|
|
const parentSize = Buffer.byteLength(readParentSpine(), 'utf-8');
|
|
const spineSize = fs.statSync(path.join(ROOT, SPINE_REL)).size;
|
|
assert.ok(
|
|
spineSize < parentSize,
|
|
`spine (${spineSize}B) is not smaller than the parent commit's plan-phase.md (${parentSize}B)`,
|
|
);
|
|
});
|
|
|
|
test('the spine references the shared compact-content gate exactly once', () => {
|
|
const spine = readCurrent(SPINE_REL);
|
|
const matches = spine.match(/compact-content-gate\.md/g) || [];
|
|
assert.strictEqual(matches.length, 1, `expected exactly one reference to compact-content-gate.md, found ${matches.length}`);
|
|
});
|
|
|
|
test('every gsd:protected sentinel in the spine is well-formed (start/end paired, or a single-line marker followed by content)', () => {
|
|
const spine = readCurrent(SPINE_REL);
|
|
const lines = spine.split(/\r?\n/);
|
|
let openStart = -1;
|
|
const singleMarkers = [];
|
|
const pairedBlocks = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (line === '<!-- gsd:protected:start -->') {
|
|
assert.strictEqual(openStart, -1, `nested/unclosed gsd:protected:start at line ${i + 1}`);
|
|
openStart = i;
|
|
} else if (line === '<!-- gsd:protected:end -->') {
|
|
assert.notStrictEqual(openStart, -1, `gsd:protected:end with no matching start at line ${i + 1}`);
|
|
pairedBlocks.push({ start: openStart, end: i });
|
|
openStart = -1;
|
|
} else if (line === '<!-- gsd:protected -->') {
|
|
singleMarkers.push(i);
|
|
}
|
|
}
|
|
assert.strictEqual(openStart, -1, 'a gsd:protected:start sentinel was never closed');
|
|
assert.ok(pairedBlocks.length >= 4, `expected at least 4 paired protected blocks, found ${pairedBlocks.length}`);
|
|
assert.ok(singleMarkers.length >= 2, `expected at least 2 single-line protected markers, found ${singleMarkers.length}`);
|
|
|
|
// Each paired block must actually enclose non-trivial content (not an empty/decorative wrap).
|
|
for (const block of pairedBlocks) {
|
|
const enclosed = lines.slice(block.start + 1, block.end).join('\n').trim();
|
|
assert.ok(enclosed.length > 0, `protected block at lines ${block.start + 1}-${block.end + 1} encloses no content`);
|
|
}
|
|
// Each single marker must be immediately followed by non-trivial content on the next non-empty line.
|
|
for (const idx of singleMarkers) {
|
|
let j = idx + 1;
|
|
while (j < lines.length && lines[j].trim() === '') j++;
|
|
assert.ok(j < lines.length && lines[j].trim().length > 0, `single protected marker at line ${idx + 1} has no following content`);
|
|
}
|
|
});
|
|
|
|
test('the four protected-content categories named in gsd-core/references/compact-content-protected-content.md are represented among the spine\'s protected blocks', () => {
|
|
const spine = readCurrent(SPINE_REL);
|
|
// Output-format contracts:
|
|
assert.match(spine, /<!-- gsd:protected:start -->\s*<quality_gate>/, 'quality_gate output-format contract must be protected');
|
|
assert.match(spine, /<!-- gsd:protected:start -->\s*<success_criteria>/, 'success_criteria output-format contract must be protected');
|
|
assert.match(spine, /<!-- gsd:protected:start -->\s*<downstream_consumer>/, 'downstream_consumer output-format contract must be protected');
|
|
// Few-shot example the workflow's own steps depend on:
|
|
assert.match(spine, /<!-- gsd:protected:start -->\s*<failing_direction_contract>/, 'failing_direction_contract few-shot example must be protected');
|
|
// Negative instruction / guardrail:
|
|
const guardrailCount = (spine.match(/<!-- gsd:protected -->\n> \*\*ORCHESTRATOR RULE[^]*?Never call `ScheduleWakeup`/g) || []).length;
|
|
assert.strictEqual(guardrailCount, 2, `expected 2 protected ScheduleWakeup guardrail paragraphs, found ${guardrailCount}`);
|
|
});
|
|
});
|