Merge branch 'next' into feat/1279-fail-first-prover
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
"gsd-domain-researcher.md": 6938,
|
||||
"gsd-eval-auditor.md": 7761,
|
||||
"gsd-eval-planner.md": 7008,
|
||||
"gsd-executor.md": 42519,
|
||||
"gsd-executor.md": 43343,
|
||||
"gsd-framework-selector.md": 6778,
|
||||
"gsd-integration-checker.md": 15148,
|
||||
"gsd-intel-updater.md": 18122,
|
||||
|
||||
@@ -99,4 +99,41 @@ describe('quick.md pre-dispatch PLAN.md commit (#2432)', () => {
|
||||
'executor files_to_read must NOT contain hardcoded absolute paths'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 records both parent base and plan commit for worktree dispatch', () => {
|
||||
const step56Start = content.indexOf('Step 5.6');
|
||||
const step6Start = content.indexOf('Step 6:', step56Start);
|
||||
const step56Block = content.slice(step56Start, step6Start);
|
||||
const step6Block = content.slice(step6Start, content.indexOf('After executor returns:', step6Start));
|
||||
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_PARENT'),
|
||||
'quick worktree mode must record the pre-dispatch parent SHA before committing PLAN.md (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step56Block.includes('QUICK_PLAN_COMMIT'),
|
||||
'quick worktree mode must record the PLAN.md commit SHA after the pre-dispatch commit (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
step6Block.includes('EXPECTED_BASE_ALTERNATE'),
|
||||
'executor guard embed must carry the alternate accepted base for parent-vs-plan worktree forks (#1265)'
|
||||
);
|
||||
});
|
||||
|
||||
test('#1265 executor materializes PLAN.md from the plan commit when worktree starts at parent', () => {
|
||||
const executorTask = content.indexOf('subagent_type="gsd-executor"');
|
||||
assert.ok(executorTask !== -1, 'executor Task() spawn must exist');
|
||||
const promptStart = content.lastIndexOf('prompt="', executorTask);
|
||||
const promptEnd = content.indexOf('subagent_type="gsd-executor"', promptStart);
|
||||
const executorPrompt = content.slice(promptStart, promptEnd);
|
||||
|
||||
assert.ok(
|
||||
executorPrompt.includes('git show') && executorPrompt.includes('QUICK_PLAN_COMMIT'),
|
||||
'executor prompt must materialize PLAN.md from git show <plan-commit>:<plan-path> when absent (#1265)'
|
||||
);
|
||||
assert.ok(
|
||||
executorPrompt.indexOf('git show') < executorPrompt.indexOf('<files_to_read>'),
|
||||
'PLAN.md materialization must be a first action before files_to_read can prime paths (#1265)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
* Context: context:fork was added by #769 to protect context budget, but
|
||||
* plan-phase, execute-phase, and autonomous are spawning orchestrators — a
|
||||
* forked subagent has no Agent/Task tool, breaking their core function.
|
||||
* effort: xhigh is preserved; context: fork is removed from these three.
|
||||
* effort: max is preserved; context: fork is removed from these three.
|
||||
* The converter still passes context: fork through if a source file has it
|
||||
* (for any future leaf skill that legitimately needs isolation).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: xhigh
|
||||
* 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: xhigh
|
||||
* 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: xhigh
|
||||
* 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max
|
||||
* 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max
|
||||
* 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max
|
||||
* 4. Source commands/gsd/progress.md has effort: low
|
||||
* 5. Source commands/gsd/stats.md has effort: low
|
||||
* 6. Claude global install: SKILL.md for autonomous has effort: xhigh, NOT context: fork
|
||||
* 7. Claude global install: SKILL.md for execute-phase has effort: xhigh, NOT context: fork
|
||||
* 8. Claude global install: SKILL.md for plan-phase has effort: xhigh, NOT context: fork
|
||||
* 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork
|
||||
* 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork
|
||||
* 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork
|
||||
* 9. Claude global install: SKILL.md for progress has effort: low
|
||||
* 10. Claude global install: SKILL.md for stats has effort: low
|
||||
* 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills)
|
||||
* 12. convertClaudeCommandToClaudeSkill preserves effort: field
|
||||
* 12. convertClaudeCommandToClaudeSkill emits portable effort: field values
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -107,18 +107,20 @@ function runClaudeGlobalInstall(claudeHome) {
|
||||
// #921/#922: spawning orchestrators must NOT carry context: fork — a forked
|
||||
// subagent has no Agent/Task tool, making it impossible for orchestrators to
|
||||
// spawn their required subagents. context: fork is appropriate only for leaf
|
||||
// skills that do not themselves dispatch agents. effort: xhigh is preserved.
|
||||
describe('#769/#921 source commands: spawning orchestrators have effort: xhigh but NOT context: fork', () => {
|
||||
// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models.
|
||||
describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => {
|
||||
test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/autonomous.md has effort: xhigh', () => {
|
||||
test('commands/gsd/autonomous.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`autonomous.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`autonomous.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => {
|
||||
@@ -127,10 +129,12 @@ describe('#769/#921 source commands: spawning orchestrators have effort: xhigh b
|
||||
`execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md has effort: xhigh', () => {
|
||||
test('commands/gsd/execute-phase.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`execute-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => {
|
||||
@@ -139,10 +143,12 @@ describe('#769/#921 source commands: spawning orchestrators have effort: xhigh b
|
||||
`plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md has effort: xhigh', () => {
|
||||
test('commands/gsd/plan-phase.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`plan-phase.md frontmatter must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,7 +168,7 @@ describe('#769 source commands: quick-status skills have effort: low', () => {
|
||||
|
||||
// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ───────
|
||||
|
||||
describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort fields', () => {
|
||||
describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => {
|
||||
test('preserves context: fork in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
@@ -186,7 +192,7 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
|
||||
`SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('preserves effort: xhigh in emitted SKILL.md frontmatter', () => {
|
||||
test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-heavy',
|
||||
@@ -205,8 +211,10 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`SKILL.md frontmatter must include effort: xhigh\nActual frontmatter:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('preserves effort: low in emitted SKILL.md frontmatter', () => {
|
||||
@@ -256,8 +264,8 @@ describe('#769 convertClaudeCommandToClaudeSkill: preserves context and effort f
|
||||
// ─── describe 3: Claude global install — SKILL.md files include new fields ────
|
||||
|
||||
// #921/#922: after install, spawning orchestrators must NOT carry context: fork
|
||||
// in their emitted SKILL.md. effort: xhigh is still emitted (preserved from source).
|
||||
describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files have effort: xhigh but NOT context: fork', () => {
|
||||
// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort.
|
||||
describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
|
||||
@@ -279,12 +287,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
|
||||
`gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => {
|
||||
test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
|
||||
@@ -295,12 +305,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
|
||||
`gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
|
||||
@@ -311,12 +323,14 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files
|
||||
`gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => {
|
||||
test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-progress SKILL.md has effort: low after global install', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
@@ -4279,6 +4279,71 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
return { planningDir, phase5Dir };
|
||||
}
|
||||
|
||||
function setupPhase1316Project(tmpDir) {
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.mkdirSync(phasesDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v3.0',
|
||||
'',
|
||||
'- [ ] Phase 32: Backlog-Closeout Lib Extraction',
|
||||
'- [ ] Phase 33: Follow Up Implementation',
|
||||
'',
|
||||
'### Phase 32: Backlog-Closeout Lib Extraction',
|
||||
'**Goal:** Complete closeout extraction',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
'### Phase 33: Follow Up Implementation',
|
||||
'**Goal:** Continue implementation',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'status: executing',
|
||||
'current_phase: "32"',
|
||||
'last_activity: "2026-06-14"',
|
||||
'progress:',
|
||||
' total_phases: 2',
|
||||
' completed_phases: 0',
|
||||
' total_plans: 1',
|
||||
' completed_plans: 0',
|
||||
' percent: 0',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 32 — Backlog-Closeout Lib Extraction',
|
||||
'Plan: 1 of 1',
|
||||
'Status: Executing Phase 32',
|
||||
'Last activity: 2026-06-14 — recorded planning complete',
|
||||
'',
|
||||
'## Session',
|
||||
'',
|
||||
'Last session: 2026-06-14T00:00:00.000Z',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const phase32Dir = path.join(phasesDir, '32-backlog-closeout-lib-extraction');
|
||||
fs.mkdirSync(phase32Dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase32Dir, '32-01-PLAN.md'), '# Plan', 'utf8');
|
||||
fs.writeFileSync(path.join(phase32Dir, '32-01-SUMMARY.md'), '# Summary', 'utf8');
|
||||
fs.mkdirSync(path.join(phasesDir, '33-follow-up-implementation'), { recursive: true });
|
||||
|
||||
return { planningDir };
|
||||
}
|
||||
|
||||
describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -4434,6 +4499,39 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
assert.match(state, /Phase:\s*0?6\b/, 'numeric Phase line should advance to phase 6');
|
||||
});
|
||||
|
||||
test('prose-block STATE keeps next phase name without field-miss warnings (#1316)', () => {
|
||||
const { planningDir } = setupPhase1316Project(tmpDir);
|
||||
|
||||
const result = spawnSync(process.execPath, [GSD_TOOLS_BIN, 'phase', 'complete', '32'], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, `phase complete failed: ${result.stderr || result.stdout}`);
|
||||
assert.ok(
|
||||
!result.stderr.includes('Current Phase Name'),
|
||||
`phase.complete must not warn about missing Current Phase Name on prose-block STATE.md; stderr:\n${result.stderr}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.stderr.includes('Last Activity Description'),
|
||||
`phase.complete must not warn about missing Last Activity Description on prose-block STATE.md; stderr:\n${result.stderr}`,
|
||||
);
|
||||
|
||||
const state = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf8');
|
||||
assert.match(state, /current_phase:\s*"?33"?/, 'current_phase frontmatter must advance to 33');
|
||||
assert.match(
|
||||
state,
|
||||
/^Phase:\s*33\s+—\s+Follow Up Implementation\b/m,
|
||||
`Current Position Phase line must keep the next phase name; state:\n${state}`,
|
||||
);
|
||||
assert.match(
|
||||
state,
|
||||
/^Last activity:\s*\d{4}-\d{2}-\d{2}\s+—\s+Phase 32 complete/m,
|
||||
`Last activity line must use the template em-dash delimiter with narrative; state:\n${state}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('body By Phase table row for completed phase shows correct plan count', () => {
|
||||
setupPhase3517Project(tmpDir);
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
|
||||
@@ -3825,8 +3825,8 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md'
|
||||
|
||||
// Last activity cell must include date + narrative (not bare date)
|
||||
assert.ok(
|
||||
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 execution started\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '-- Phase 1 execution started'; got Current Position:\n${cpSection}`
|
||||
/\|\s*Last activity\s*\|[^|]*—\s*Phase 1 execution started\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '— Phase 1 execution started'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
@@ -3909,8 +3909,8 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md'
|
||||
|
||||
// Bug 2: Last activity cell must include date + narrative (not bare date)
|
||||
assert.ok(
|
||||
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 marked complete\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '-- Phase 1 marked complete'; got Current Position:\n${cpSection}`
|
||||
/\|\s*Last activity\s*\|[^|]*—\s*Phase 1 marked complete\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '— Phase 1 marked complete'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
|
||||
@@ -533,6 +533,99 @@ expected: |
|
||||
assert.ok(result.output.includes('It ends at the section boundary.'));
|
||||
});
|
||||
|
||||
test('resumes paused Current Test placeholder from first pending test (#1300)', () => {
|
||||
fs.writeFileSync(uatPath, [
|
||||
'---',
|
||||
'status: partial',
|
||||
'phase: 01-test-phase',
|
||||
'started: 2026-06-15T00:00:00Z',
|
||||
'updated: 2026-06-15T00:00:00Z',
|
||||
'---',
|
||||
'',
|
||||
'## Current Test',
|
||||
'',
|
||||
'[testing paused — 2 items outstanding]',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. First test',
|
||||
'expected: something observable',
|
||||
'result: pass',
|
||||
'',
|
||||
'### 2. Second test',
|
||||
'expected: another observable thing',
|
||||
'result: [pending]',
|
||||
'',
|
||||
'## Summary',
|
||||
'',
|
||||
'total: 2',
|
||||
'passed: 1',
|
||||
'issues: 0',
|
||||
'pending: 1',
|
||||
'skipped: 0',
|
||||
'blocked: 0',
|
||||
'',
|
||||
'## Gaps',
|
||||
'',
|
||||
'[none yet]',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.test_number, 2);
|
||||
assert.strictEqual(output.test_name, 'Second test');
|
||||
assert.strictEqual(output.file_path, '.planning/phases/01-test-phase/01-UAT.md');
|
||||
});
|
||||
|
||||
test('raw checkpoint mode accepts paused Current Test placeholder (#1300)', () => {
|
||||
fs.writeFileSync(uatPath, [
|
||||
'---',
|
||||
'status: partial',
|
||||
'phase: 01-test-phase',
|
||||
'---',
|
||||
'',
|
||||
'## Current Test',
|
||||
'',
|
||||
'[testing paused — 1 item outstanding]',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. First pending test',
|
||||
'expected: raw mode checkpoint is available',
|
||||
'result: [pending]',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
|
||||
assert.ok(result.output.length > 0, 'raw mode must emit a checkpoint');
|
||||
});
|
||||
|
||||
test('non-structured Current Test with no pending tests reports actionable resume error (#1300)', () => {
|
||||
fs.writeFileSync(uatPath, [
|
||||
'---',
|
||||
'status: partial',
|
||||
'phase: 01-test-phase',
|
||||
'---',
|
||||
'',
|
||||
'## Current Test',
|
||||
'',
|
||||
'[testing paused — 0 items outstanding]',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. Already handled test',
|
||||
'expected: completed behavior',
|
||||
'result: pass',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'Should fail when a paused placeholder has no pending test to resume');
|
||||
assert.ok(result.error.includes('no pending UAT test remains'));
|
||||
assert.ok(!result.error.includes('Current Test section is malformed'));
|
||||
});
|
||||
|
||||
test('fails when testing is already complete', () => {
|
||||
fs.writeFileSync(uatPath, `---
|
||||
status: complete
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"docs-update.md": 54770,
|
||||
"edit-phase.md": 12883,
|
||||
"eval-review.md": 9923,
|
||||
"execute-phase.md": 93122,
|
||||
"execute-phase.md": 93157,
|
||||
"execute-plan.md": 31365,
|
||||
"explore.md": 10497,
|
||||
"extract-learnings.md": 12849,
|
||||
@@ -57,7 +57,7 @@
|
||||
"pr-branch.md": 9561,
|
||||
"profile-user.md": 20650,
|
||||
"progress.md": 29387,
|
||||
"quick.md": 47251,
|
||||
"quick.md": 48435,
|
||||
"reapply-patches.md": 20393,
|
||||
"remove-phase.md": 8469,
|
||||
"remove-workspace.md": 7507,
|
||||
|
||||
@@ -684,12 +684,35 @@ describe('bug #3384: worktree cleanup workflow contracts', () => {
|
||||
assert.doesNotMatch(content, /done < <\(git worktree list --porcelain \| grep "\^worktree " \| grep "\\\.claude\/worktrees\/agent-"/);
|
||||
});
|
||||
|
||||
test('#1297 gsd-executor self-reports authoritative worktree metadata', () => {
|
||||
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf8');
|
||||
assert.match(content, /<worktree_metadata_capture>/);
|
||||
assert.match(content, /git rev-parse --show-toplevel/);
|
||||
assert.match(content, /git rev-parse --abbrev-ref HEAD/);
|
||||
assert.match(content, /GSD_WORKTREE_EXPECTED_BASE=\$\(git rev-parse HEAD\)/);
|
||||
assert.match(content, /<worktree_metadata>/);
|
||||
assert.match(content, /"worktree_path":/);
|
||||
assert.match(content, /"branch":/);
|
||||
assert.match(content, /"expected_base":/);
|
||||
});
|
||||
|
||||
test('#1297 execute-phase consumes executor-returned worktree metadata before harness metadata', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf8');
|
||||
assert.match(content, /<worktree_metadata>/);
|
||||
assert.match(content, /executor-returned worktree metadata/i);
|
||||
assert.match(content, /harness metadata/i);
|
||||
assert.ok(
|
||||
content.indexOf('executor-returned worktree metadata') < content.indexOf('harness metadata'),
|
||||
'execute-phase must prefer executor-returned worktree metadata before runtime harness metadata (#1297)'
|
||||
);
|
||||
});
|
||||
|
||||
test('quick contract requires a cleanup manifest instead of global worktree discovery', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf8');
|
||||
assert.match(content, /WAVE_WORKTREE_MANIFEST|QUICK_WORKTREE_MANIFEST/);
|
||||
assert.match(content, /worktree\.cleanup-wave/);
|
||||
assert.match(content, /mktemp "\$\{TMPDIR:-\/tmp\}\/gsd-quick-worktree-/);
|
||||
assert.match(content, /append its returned `\{agent_id, worktree_path, branch, expected_base\}`/);
|
||||
assert.match(content, /append its returned `\{agent_id, worktree_path, branch, expected_base, allowed_bases\}`/);
|
||||
// After #3797 architectural fix: quick.md delegates entirely to the SDK's cleanup-wave
|
||||
// command (which handles manifest parsing internally). The shell fallback with manual
|
||||
// QUICK_WORKTREE_MANIFEST node-e code is removed — the gsd_run call with || exit 1 is the
|
||||
|
||||
@@ -518,6 +518,7 @@ describe('planWorktreeWaveCleanup', () => {
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
allowed_bases: ['abc123', 'def456'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -527,11 +528,13 @@ describe('planWorktreeWaveCleanup', () => {
|
||||
worktree_path: entry.worktree_path,
|
||||
branch: entry.branch,
|
||||
expected_base: entry.expected_base,
|
||||
allowed_bases: entry.allowed_bases,
|
||||
})), [{
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
allowed_bases: ['abc123', 'def456'],
|
||||
}]);
|
||||
assert.equal(plan.discovery, 'manifest');
|
||||
});
|
||||
@@ -562,6 +565,84 @@ describe('planWorktreeWaveCleanup', () => {
|
||||
// ─── executeWorktreeWaveCleanupPlan ───────────────────────────────────────────
|
||||
|
||||
describe('executeWorktreeWaveCleanupPlan', () => {
|
||||
test('#1265 accepts a merge-base listed in allowed_bases even when expected_base is the plan commit', () => {
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: '/repo/main',
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'plancommit',
|
||||
allowed_bases: ['plancommit', 'parentcommit'],
|
||||
}],
|
||||
};
|
||||
const result = executeWorktreeWaveCleanupPlan(plan, {
|
||||
execGit: (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') {
|
||||
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' };
|
||||
}
|
||||
if (key === 'merge-base HEAD worktree-agent-a1') {
|
||||
return { exitCode: 0, stdout: 'parentcommit', stderr: '' };
|
||||
}
|
||||
if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (key.startsWith('merge worktree-agent-a1')) {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (key === 'branch -D worktree-agent-a1') {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.entries[0].status, 'merged_removed');
|
||||
});
|
||||
|
||||
test('#1265 still blocks a merge-base outside expected_base and allowed_bases', () => {
|
||||
const plan = {
|
||||
ok: true,
|
||||
repoRoot: '/repo/main',
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'plancommit',
|
||||
allowed_bases: ['plancommit', 'parentcommit'],
|
||||
}],
|
||||
};
|
||||
const result = executeWorktreeWaveCleanupPlan(plan, {
|
||||
execGit: (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') {
|
||||
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' };
|
||||
}
|
||||
if (key === 'merge-base HEAD worktree-agent-a1') {
|
||||
return { exitCode: 0, stdout: 'unrelatedbase', stderr: '' };
|
||||
}
|
||||
throw new Error(`unexpected git call after rejected base: ${key}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.entries[0].status, 'blocked');
|
||||
assert.equal(result.entries[0].reason, 'base_mismatch');
|
||||
});
|
||||
|
||||
test('does not delete a branch when worktree removal fails', () => {
|
||||
const calls = [];
|
||||
const plan = {
|
||||
|
||||
@@ -176,8 +176,9 @@ describe('canonical worktree-branch-check fragment is the single source of truth
|
||||
assert.ok(block.includes('update-ref'), 'fragment block must reference update-ref prohibition');
|
||||
});
|
||||
|
||||
test('fragment block asserts exact base and fails closed with exit 42 (#48)', () => {
|
||||
assert.ok(block.includes('git rev-parse HEAD') && block.includes('{EXPECTED_BASE}'), 'fragment must assert HEAD equals {EXPECTED_BASE} exactly (#48)');
|
||||
test('fragment block asserts an allowed base set and fails closed with exit 42 (#48, #1265)', () => {
|
||||
assert.ok(block.includes('git rev-parse HEAD') && block.includes('{EXPECTED_BASE}'), 'fragment must assert HEAD against {EXPECTED_BASE} (#48)');
|
||||
assert.ok(block.includes('{EXPECTED_BASE_ALTERNATE}'), 'fragment must support one orchestrator-approved alternate base for quick parent/plan forks (#1265)');
|
||||
assert.ok(/exit 42/.test(block), 'fragment must fail closed with exit 42 on mismatch (#48)');
|
||||
});
|
||||
});
|
||||
@@ -848,4 +849,3 @@ done < <(${DISCOVERY_PIPELINE})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user