diff --git a/.changeset/clever-jaguars-hum.md b/.changeset/clever-jaguars-hum.md new file mode 100644 index 000000000..8146e966a --- /dev/null +++ b/.changeset/clever-jaguars-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3803 +--- +**/gsd-pr-branch now refuses to verify a PR branch that would delete planning files the target branch tracks** — the verification step counts planning-tree deletions via git diff --name-status and fails on any non-zero count, instead of reporting clean while pre-existing planning content was stripped. The underlying deletion class in the cherry-pick filter was already fixed by the strict-mode rewrite; this makes the workflow able to detect it. (#3679) diff --git a/CONTEXT.md b/CONTEXT.md index 6e1bf3f5b..03551d2d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -234,7 +234,7 @@ Module owning agent-presence resolution and verification, extracted from the Cor Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). ### Planning Publication Gate (`planning.pr_strict`) -The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`. +The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`. ### Planning Commit Gate (`commit_docs`) The seam deciding whether `.planning/` artifacts reach git. `commit_docs` resolves in the Config Loader Module (`loadConfigResolved`) through an ordered chain — an explicit value (top-level `commit_docs` or its `planning.commit_docs` alias) wins; absent that, `isGitIgnored(cwd, '.planning/')` auto-resolves it to `false`; otherwise the manifest default (`true`) applies. `cmdCommit` (Command Module) is the ONLY sanctioned writer: it returns the typed skip envelope `{ committed: false, skipped: true, hash: null, reason }` with `reason ∈ { 'skipped_commit_docs_false', 'skipped_gitignored', 'skipped_commit_docs_phase_false' }` rather than erroring, and `skipped: true` is explicit so agent prompts match a first-class success signal instead of inferring a skip from a missing `committed` and improvising a raw-git fallback (#3678). Those `reason` strings are a de facto public contract — `agents/gsd-executor.md` pattern-matches on them — so they are additive-only. diff --git a/gsd-core/workflows/pr-branch.md b/gsd-core/workflows/pr-branch.md index 013f6c9e9..053feeabb 100644 --- a/gsd-core/workflows/pr-branch.md +++ b/gsd-core/workflows/pr-branch.md @@ -389,6 +389,13 @@ ALLOWED=$((PLANNING_TOTAL - FORBIDDEN)) TOTAL_FILES=$(echo "$DIFF_PATHS" | grep -c . || true) PR_COMMITS=$(git rev-list --count "$TARGET".."$PR_BRANCH") +# #3679: a DELETED planning path is never legitimate — this workflow only ever +# excludes content a cherry-picked commit ADDED; pre-existing target-tracked +# planning files must survive byte-identical. Name-only counting cannot see +# status (a deleted structural/allowed path verifies clean there), so gate on +# deletions explicitly, across every planning category. +PLANNING_DELETIONS=$(git diff --name-status --no-renames "$TARGET".."$PR_BRANCH" | grep "^D" | grep -c "\.planning/" || true) + # Default mode preserves anything under .planning/ that is neither transient nor # structural — config.json, intel/, workstreams/. That is deliberate and unchanged, but it # must not be silent: report it so the user can choose strict mode knowingly. @@ -398,6 +405,12 @@ OTHER=$(echo "$DIFF_PATHS" | grep "^\.planning/" | grep -Ev "$FORBIDDEN_RE" | gr `$FORBIDDEN` is the pass/fail number — it must be `0`. A non-zero value means the filter did not do what this mode promised; report it and do not tell the user to push. +`$PLANNING_DELETIONS` is a second hard gate (#3679) — it must also be `0`. A non-zero +value means the PR branch would DELETE planning files the target branch tracks +(`git diff --name-status --no-renames "$TARGET".."$PR_BRANCH" | grep "^D" | grep "\.planning/"` lists +them). That is data loss, not filtering — report it, do not tell the user to push, and +rebuild the branch. + Display results: ``` ✅ PR branch created: {PR_BRANCH} @@ -406,6 +419,7 @@ Original: {AHEAD} commits, {ORIGINAL_FILES} files PR branch: {PR_COMMITS} commits, {TOTAL_FILES} files Mode: {PR_MODE} Planning paths in diff: {PLANNING_TOTAL} (allowed {ALLOWED}, forbidden {FORBIDDEN} — must be 0) +Planning deletions: {PLANNING_DELETIONS} (must be 0 — #3679) Next steps: git push origin {PR_BRANCH} diff --git a/tests/emitted-drift-acks/2971-pr-branch-strict-planning-filter.json b/tests/emitted-drift-acks/2971-pr-branch-strict-planning-filter.json index 4364c266f..41fd6db84 100644 --- a/tests/emitted-drift-acks/2971-pr-branch-strict-planning-filter.json +++ b/tests/emitted-drift-acks/2971-pr-branch-strict-planning-filter.json @@ -2,7 +2,7 @@ "version": 1, "paths": { "pr-branch.md": { - "reason": "#2971: +5729 bytes vs next. Adds the planning.pr_strict filter mode and repairs two verified defects in the same cherry-pick loop. The growth is four things, none of them prose padding: (1) the canonical TRANSIENT_DIRS/STRUCTURAL_RE declarations plus their per-mode FILTER_PATHS/FORBIDDEN_RE projections, which replace two duplicated hardcoded lists so create_pr_branch and verify can no longer disagree about what the filter promised; (2) a rewritten create_pr_branch loop — the old `git rm -r --cached` staged a DELETION of any .planning/ path the target branch already tracked, and left the picked file untracked on disk so a later commit touching that path aborted with \"untracked working tree files would be overwritten\" and every remaining commit was dropped, both reproduced against real git before the fix; the replacement forces filtered paths back to HEAD in index and worktree, halts on a conflict outside the filter instead of improvising, and skips a commit left empty by filtering; (3) a mode-derived verify step plus the advisory line naming the .planning/ paths default mode deliberately keeps, so correcting the assertion does not trade a permanently-wrong signal for silence; (4) a clean-working-tree precondition, required because the corrected filter now removes files from the working tree. Comments carry the why for each, because every one of them is a place a future edit would otherwise reintroduce the defect." + "reason": "#3679 re-arm: +14 lines vs the #2971-acknowledged size. The #2971 rewrite already fixed the deletion class this issue reported (rm -f --ignore-unmatch + checkout HEAD restore — pre-existing target-tracked planning files survive); #3679 adds the piece the brief still required of verify: a PLANNING_DELETIONS count over git diff --name-status with a second must-be-0 gate beside $FORBIDDEN (name-only counting cannot see status, so a deleted allowed/structural planning path verified clean), plus its display line. Prior reason kept below for the audit trail. — #2971: +5729 bytes vs next. Adds the planning.pr_strict filter mode and repairs two verified defects in the same cherry-pick loop. The growth is four things, none of them prose padding: (1) the canonical TRANSIENT_DIRS/STRUCTURAL_RE declarations plus their per-mode FILTER_PATHS/FORBIDDEN_RE projections, which replace two duplicated hardcoded lists so create_pr_branch and verify can no longer disagree about what the filter promised; (2) a rewritten create_pr_branch loop — the old `git rm -r --cached` staged a DELETION of any .planning/ path the target branch already tracked, and left the picked file untracked on disk so a later commit touching that path aborted with \"untracked working tree files would be overwritten\" and every remaining commit was dropped, both reproduced against real git before the fix; the replacement forces filtered paths back to HEAD in index and worktree, halts on a conflict outside the filter instead of improvising, and skips a commit left empty by filtering; (3) a mode-derived verify step plus the advisory line naming the .planning/ paths default mode deliberately keeps, so correcting the assertion does not trade a permanently-wrong signal for silence; (4) a clean-working-tree precondition, required because the corrected filter now removes files from the working tree. Comments carry the why for each, because every one of them is a place a future edit would otherwise reintroduce the defect." } } } diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs index 1b666e225..244e57733 100644 --- a/tests/git-base-branch.test.cjs +++ b/tests/git-base-branch.test.cjs @@ -1263,3 +1263,251 @@ describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', }); }); } + +// ─── #3679 — pr-branch: pre-existing planning content + verify deletion gate ── +// +// The original deletion class (blanket `git rm -r --cached` of transient dirs +// stripping pre-existing base-branch files) was fixed as a side effect of the +// #2971 strict-mode rewrite (rm -f --ignore-unmatch + `git checkout HEAD --` +// restore). These rows PIN that guarantee behaviorally so it cannot silently +// regress, and add the remaining piece from the #3679 brief: the verify step +// must FAIL when the PR-branch diff deletes planning files the target tracks +// (a deleted allowed/structural path verifies clean today — name-only +// counting cannot see status). + +describe('#3679 — pr-branch pre-existing planning content + verify deletion gate', () => { + const PR_BRANCH_MD = path.join(WORKFLOW_DIR, 'pr-branch.md'); + + function extractStepBash(stepName) { + const content = readFileNormalized(PR_BRANCH_MD); + const lines = content.split('\n'); + let start = -1; + let end = -1; + for (let i = 0; i < lines.length; i += 1) { + if (start === -1 && new RegExp(`^\\s*$`).test(lines[i])) { + start = i + 1; + } else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) { + end = i; + break; + } + } + assert.ok(start !== -1 && end !== -1, `pr-branch.md must contain the ${stepName} step`); + const bashBlocks = []; + let inBash = false; + let buffer = []; + for (let i = start; i < end; i += 1) { + const line = lines[i]; + if (!inBash && /^```bash\s*$/.test(line)) { + inBash = true; + buffer = []; + continue; + } + if (inBash && /^```\s*$/.test(line)) { + bashBlocks.push(buffer.join('\n')); + inBash = false; + continue; + } + if (inBash) buffer.push(line); + } + assert.ok(bashBlocks.length > 0, `${stepName} step contains bash blocks`); + return bashBlocks.join('\n'); + } + + test('verify step gates on planning-tree deletions', () => { + const bash = extractStepBash('verify'); + assert.ok( + /diff-filter=D|--name-status/.test(bash), + 'verify must distinguish deletions (diff-filter=D or --name-status)', + ); + assert.ok( + /PLANNING_DELETIONS/.test(bash), + 'verify must compute a planning-deletions count', + ); + // The must-be-0 gate lives in the step PROSE and the display template, + // outside every ```bash block — assert it against the full file text so + // the enforcement half of criterion 4 is pinned, not just the computation. + const fullText = readFileNormalized(PR_BRANCH_MD); + assert.ok( + /PLANNING_DELETIONS[^\n]*must be .?0/.test(fullText), + 'verify prose must gate on a zero PLANNING_DELETIONS count', + ); + assert.ok( + /Planning deletions: \{PLANNING_DELETIONS\}/.test(fullText), + 'verify display must surface the deletion count', + ); + }); + + test('verify fails when the PR diff deletes target-tracked planning files', (t) => { + const repo = createTempDir('gsd-3679-verify-fail-'); + t.after(() => cleanup(repo)); + const g = (args) => gitOrThrow(args, { cwd: repo }); + g(['init', '-q', '-b', 'main']); + g(['config', 'user.email', 't@t']); + g(['config', 'user.name', 't']); + fs.mkdirSync(path.join(repo, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n'); + fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n'); + g(['add', '-A']); + g(['commit', '-qm', 'base']); + g(['checkout', '-qb', 'pr']); + fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n'); + // The failure class under test: the PR branch deletes a planning file the + // target tracks (here structural — an allowed category, so the existing + // name-only forbidden count cannot catch it). + g(['rm', '-q', '.planning/STATE.md']); + g(['add', '-A']); + g(['commit', '-qm', 'changes + planning deletion']); + + const verifyBash = extractStepBash('verify'); + const script = [ + 'set -u', + 'TARGET=main', + 'PR_BRANCH=pr', + 'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"', + 'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"', + verifyBash, + 'echo "GATE_FORBIDDEN=$FORBIDDEN"', + 'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"', + ].join('\n'); + fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n'); + const r = runHook(path.join(repo, 'verify.sh'), [], { + interpreter: 'bash', + cwd: repo, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + const out = r.stdout + r.stderr; + assert.match(out, /GATE_PLANNING_DELETIONS=1/, `deletion count must be non-zero: ${out.slice(0, 400)}`); + }); + + test('verify passes a clean diff with zero deletions', (t) => { + const repo = createTempDir('gsd-3679-verify-clean-'); + t.after(() => cleanup(repo)); + const g = (args) => gitOrThrow(args, { cwd: repo }); + g(['init', '-q', '-b', 'main']); + g(['config', 'user.email', 't@t']); + g(['config', 'user.name', 't']); + fs.mkdirSync(path.join(repo, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n'); + fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n'); + g(['add', '-A']); + g(['commit', '-qm', 'base']); + g(['checkout', '-qb', 'pr']); + fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n'); + g(['add', '-A']); + g(['commit', '-qm', 'code only']); + + const verifyBash = extractStepBash('verify'); + const script = [ + 'set -u', + 'TARGET=main', + 'PR_BRANCH=pr', + 'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"', + 'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"', + verifyBash, + 'echo "GATE_FORBIDDEN=$FORBIDDEN"', + 'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"', + ].join('\n'); + fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n'); + const r = runHook(path.join(repo, 'verify.sh'), [], { + interpreter: 'bash', + cwd: repo, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + const out = r.stdout + r.stderr; + assert.match(out, /GATE_PLANNING_DELETIONS=0/, `clean diff must count zero deletions: ${out.slice(0, 400)}`); + }); + + test('create loop preserves pre-existing transient content (#3720 guarantee pinned)', (t) => { + const repo = createTempDir('gsd-3679-create-mixed-'); + t.after(() => cleanup(repo)); + const g = (args) => gitOrThrow(args, { cwd: repo }); + g(['init', '-q', '-b', 'main']); + g(['config', 'user.email', 't@t']); + g(['config', 'user.name', 't']); + fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true }); + fs.mkdirSync(path.join(repo, '.planning', 'research'), { recursive: true }); + fs.mkdirSync(path.join(repo, 'infra'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n'); + fs.writeFileSync(path.join(repo, '.planning', 'research', 'context.md'), 'ctx\n'); + // Structural planning state on the TARGET (criterion 3): must survive the + // create loop byte-identical alongside the transient content. + fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n'); + fs.writeFileSync(path.join(repo, '.planning', 'ROADMAP.md'), 'roadmap\n'); + fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n'); + g(['add', '-A']); + g(['commit', '-qm', 'base: code + pre-existing planning']); + g(['checkout', '-qb', 'feature']); + fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n'); + fs.writeFileSync(path.join(repo, '.planning', 'phases', '2.0-SUMMARY.md'), 'summary\n'); + g(['add', '-A']); + g(['commit', '-qm', 'mixed: code + new transient']); + const hash = g(['rev-parse', 'HEAD']).trim(); + + // Execute the shipped create_pr_branch loop verbatim (default-mode paths). + const createBash = extractStepBash('create_pr_branch'); + const script = [ + 'set -u', + `CURRENT_BRANCH=feature`, + 'TARGET=main', + `INCLUDED_COMMITS="${hash}"`, + 'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "', + createBash, + ].join('\n'); + fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n'); + const r = runHook(path.join(repo, 'create.sh'), [], { + interpreter: 'bash', + cwd: repo, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`); + + const status = g(['diff', '--name-status', 'main..feature-pr']); + assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`); + const diffPaths = g(['diff', '--name-only', 'main..feature-pr']); + assert.ok(!diffPaths.includes('2.0-SUMMARY.md'), "commit's own transient file must be excluded"); + assert.ok(diffPaths.includes('script2.sh'), 'code change must be present'); + assert.ok(!diffPaths.includes('1.0-PLAN.md'), 'pre-existing plan must be untouched'); + assert.ok(!diffPaths.includes('STATE.md'), 'structural STATE.md must survive (criterion 3)'); + assert.ok(!diffPaths.includes('ROADMAP.md'), 'structural ROADMAP.md must survive (criterion 3)'); + }); + + test('create loop preserves planning content on pure-code commits', (t) => { + const repo = createTempDir('gsd-3679-create-pure-'); + t.after(() => cleanup(repo)); + const g = (args) => gitOrThrow(args, { cwd: repo }); + g(['init', '-q', '-b', 'main']); + g(['config', 'user.email', 't@t']); + g(['config', 'user.name', 't']); + fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true }); + fs.mkdirSync(path.join(repo, 'infra'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n'); + fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n'); + g(['add', '-A']); + g(['commit', '-qm', 'base']); + g(['checkout', '-qb', 'feature']); + fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n'); + g(['add', '-A']); + g(['commit', '-qm', 'pure code']); + const hash = g(['rev-parse', 'HEAD']).trim(); + + const createBash = extractStepBash('create_pr_branch'); + const script = [ + 'set -u', + 'CURRENT_BRANCH=feature', + 'TARGET=main', + `INCLUDED_COMMITS="${hash}"`, + 'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "', + createBash, + ].join('\n'); + fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n'); + const r = runHook(path.join(repo, 'create.sh'), [], { + interpreter: 'bash', + cwd: repo, + timeoutMs: HOOK_FANOUT_TIMEOUT_MS, + }); + assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`); + const status = g(['diff', '--name-status', 'main..feature-pr']); + assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`); + assert.ok(status.includes('script2.sh'), 'code change must be present'); + }); +});