diff --git a/lib/autoplan-revise.ts b/lib/autoplan-revise.ts index e32e1e02f..bb1ba3fa3 100644 --- a/lib/autoplan-revise.ts +++ b/lib/autoplan-revise.ts @@ -23,7 +23,7 @@ import * as path from 'node:path'; import { exportRun } from './autoplan-export'; import { applyRenames, approve, closeDraftDirections, finishGate, planReopen } from './autoplan-gate'; import { carryLines, voidPendingCheck } from './autoplan-carry'; -import { atCap, escalateLine, pendingDeltas, roundCap, roundOf } from './autoplan-budget'; +import { atCap, pendingDeltas, roundCap, roundOf } from './autoplan-budget'; import { diffStat, lineDiff } from './autoplan-confirm'; import { SETTLEABLE_CHANGES } from './autoplan-reconcile'; import { normalizeRevision, reviewHash, scaffold } from './autoplan-plan-text'; @@ -112,9 +112,7 @@ function approveOrRegate(state: RunState, at: string): string[] { if (pendingDeltas(state).length) { finishGate(state); const carried = carryLines(state); - if (state.status !== 'gate_pending') return carried; - const escalate = escalateLine(state); - return [...carried, `GATE: gate_rev=${state.gate_rev} (a plan change after a round cap needs the owner) render with: gstack-gate render ${path.join(state.out, 'gate.json')}`, ...(escalate ? [escalate] : [])]; + return carried; } return [...approve(state, at), `APPROVED: gate_rev=${state.gate_rev} (every accepted change is in the plan or settled)`]; } diff --git a/lib/autoplan-run.ts b/lib/autoplan-run.ts index 921e46370..5755c64bd 100644 --- a/lib/autoplan-run.ts +++ b/lib/autoplan-run.ts @@ -50,7 +50,7 @@ export function withRunLock(out: string, fn: () => T, opts: { steal?: boolean const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), at: now() }); const staged = `${lock}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`; fs.writeFileSync(staged, mine); - for (let i = 0; ; i++) { + for (let reclaims = 0; ;) { try { fs.linkSync(staged, lock); fs.unlinkSync(staged); @@ -69,7 +69,7 @@ export function withRunLock(out: string, fn: () => T, opts: { steal?: boolean if (alive && Date.now() < waitUntil) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); continue; } if (alive) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', unreadable ? `${lock} is being written by another command` : `pid ${owner.pid} on ${owner.host} since ${owner.at}`, 3); } try { fs.unlinkSync(lock); } catch { /* raced with its owner's exit */ } - if (i > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); } + if (++reclaims > 50) { fs.rmSync(staged, { force: true }); throw new RunError('RUN_LOCKED', `could not reclaim ${lock}`, 3); } } } try { return fn(); } finally { try { if (fs.readFileSync(lock, 'utf8') === mine) fs.unlinkSync(lock); } catch { /* already gone */ } } diff --git a/lib/autoplan-shadow.ts b/lib/autoplan-shadow.ts index 53c5104e8..9b055c4e0 100644 --- a/lib/autoplan-shadow.ts +++ b/lib/autoplan-shadow.ts @@ -205,8 +205,8 @@ export function buildPage(state: RunState, list: GateList, deciderText: string): }); const planText = fs.existsSync(state.active_plan) ? fs.readFileSync(state.active_plan, 'utf8') : ''; const plan = (extractHeading(planText, 'Implementation plan') ?? planText).slice(0, PLAN_CHARS); - const prompt = deciderText.replace('{{PLAN}}', () => plan).replace('{{QUESTIONS}}', () => '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```') - .replace('{{IDS}}', () => items.map(i => i.item.id).join(', ')); + const slots: Record = { PLAN: plan, QUESTIONS: '```json\n' + JSON.stringify(items.map(i => i.frozen), null, 2) + '\n```', IDS: items.map(i => i.item.id).join(', ') }; + const prompt = deciderText.replace(/\{\{(PLAN|QUESTIONS|IDS)\}\}/g, (_, k: string) => slots[k]!); return { prompt, items, plan_sha256: sha256(planText), prompt_sha256: sha256(deciderText), request_sha256: sha256(prompt) }; } diff --git a/test/autoplan-shadow.test.ts b/test/autoplan-shadow.test.ts index f8628a8e2..65f93e1ce 100644 --- a/test/autoplan-shadow.test.ts +++ b/test/autoplan-shadow.test.ts @@ -126,6 +126,15 @@ describe('the reply contract: exactly one valid object per requested id', () => expect(page.prompt).toContain('"id": "uc1"'); expect(page.prompt).not.toContain('{{'); }); + test('slot names inside the plan text stay plan text; the template slots are still filled', () => { + const planFile = path.join(TMP, 'slot-plan.md'); + fs.writeFileSync(planFile, '## Implementation plan\n\nfill {{QUESTIONS}} and {{IDS}} in {{PLAN}}\n'); + const p = buildPage({ ...readState(out), active_plan: planFile }, gateOf(out), decider()); + expect(p.prompt).toContain('fill {{QUESTIONS}} and {{IDS}} in {{PLAN}}'); + expect(p.prompt).toContain('"id": "uc1"'); + expect(p.prompt).toContain('uc1, uc2, p1'); + expect(p.prompt.split('{{').length).toBe(4); + }); test('a good reply is clean', () => { expect(parsePage(reply(valid), page.items).clean).toBe(true); }); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index bca79b7a2..3cd44fe55 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -200,6 +200,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'], 'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'], 'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'], + 'test/fixtures/shadow-fake-model.ts': ['test/autoplan-shadow-driver.test.ts', 'test/autoplan-shadow.test.ts'], 'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'], 'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'], 'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'],