From e45b839b49596217fb215c61eb8eb0dcd04dc71c Mon Sep 17 00:00:00 2001 From: Ben Silverstein Date: Wed, 4 Nov 2026 14:40:05 -0500 Subject: [PATCH] fix(resume): name row decisions awaiting spec apply [N19] The local view records a person's row decision silently, so a coordinator reading only the needs-the-person section never learns to run spec apply. Co-Authored-By: Claude Fable 5.1 --- src/commands/resume.js | 6 ++++++ src/resume.js | 38 ++++++++++++++++++++++++++++++++++++++ test/resume-flow.test.js | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/commands/resume.js b/src/commands/resume.js index 85c3e3c4f..be8a18eb7 100644 --- a/src/commands/resume.js +++ b/src/commands/resume.js @@ -49,12 +49,15 @@ import { unlinkedRelayProjects, } from '../relay-setup.js'; import { + applyFirst, commitFirst, commitOwnerLines, commitOwners, coordinatorInbox, coordinatorInboxLines, inboxFirst, + pendingRowApply, + rowDecisionLines, } from '../resume.js'; import { resumeSentinels, @@ -193,6 +196,7 @@ export async function resumeHere(io, view, diagnostics = false, { json = false } health: recordedHealthFindings(board), all, needsYou: personNeeds(needsState), + ...(isMain ? { pendingApply: pendingRowApply(store.rowDecisions(board)) } : {}), ...(me.id === COORDINATOR ? { rigor: { undrafted: needsState.rigorUndrafted, ...(needsState.rigorNotice ? { notice: needsState.rigorNotice } : {}) } } : {}), ...(decisionSections ? { ...decisions, ...(diagnostics ? verifiedMergeGroups(root, pendingMerges, shouts, ctx.clock.now().getTime()) : {}), flow, ...(proofStatsFallback ? { proofStatsFallback } : {}) } : {}), @@ -321,6 +325,7 @@ export async function resumeHere(io, view, diagnostics = false, { json = false } section(card.needsYou, (need) => need.kind === 'decision' ? `waiting on the person: #${need.id} from ${need.ref} (${span(ctx, need.at)}): ${firstLine(need.text)}` : `needs the person: ${need.ref}: ${firstLine(need.text)} (${need.what})`); + for (const line of rowDecisionLines(card.pendingApply, root)) say(line); if (card.rigor?.notice) say(card.rigor.notice); section(card.rigor?.undrafted ?? [], (cause) => `Rigor needs a proved draft: ${cause.key}, items ${cause.items.map(item => `#${item.id}`).join(', ')}; pullboard rigor draft "${cause.key.replaceAll('"', '\\"')}" "" --check "" --red --green `); if (card.toVerify.length) say('review queue:'); @@ -350,6 +355,7 @@ export async function resumeHere(io, view, diagnostics = false, { json = false } else if (card.awaiting.length) next = 'pullboard next --wait 9 (minutes); a rejected item comes back to your lane'; else next = 'pullboard next; take its ranked work, including eligible reviews or work in another lane'; if (card.setup) next = card.setup.agentNext ?? card.setup.command; + if (card.pendingApply?.length) next = applyFirst(card.pendingApply, root, next); if (card.commits) next = commitFirst(card.commits, next); if (card.inbox) next = inboxFirst(card.inbox, next); io.result?.({ ...card, root, ...(diagnostics ? { dirty } : {}), next }); diff --git a/src/resume.js b/src/resume.js index 27260eaea..fd0a6adee 100644 --- a/src/resume.js +++ b/src/resume.js @@ -127,6 +127,44 @@ export function commitOwnerLines(owners) { ]; } +/** + * The person's row decisions recorded on the board but not yet applied to the row file. The local + * view records a decision silently, so resume must name it or the coordinator never applies it. + * + * @param {ReturnType} records + * @returns {{ id: string, kind: string, decision: string }[]} + */ +export function pendingRowApply(records) { + return records.filter((record) => !record.applied).map(({ id, kind, decision }) => ({ id, kind, decision })); +} + +/** One decision as resume says it: `G9 approved`. */ +const decided = (record) => `${record.id} ${record.decision === 'approve' ? 'approved' : 'declined'}`; + +/** + * @param {ReturnType} pending + * @param {string} root the main checkout + * @returns {string[]} + */ +export function rowDecisionLines(pending, root) { + if (!pending.length) return []; + return [`The coordinator must apply the person's recorded row decision${pending.length === 1 ? '' : 's'}: ${pending.map(decided).join(', ')}, pending apply: cd ${root} && pullboard spec apply`]; +} + +/** + * The coordinator's next step with a recorded row decision still unapplied, so it applies first: + * every other step would plan against rows the person has already decided differently. + * + * @param {ReturnType} pending + * @param {string} root + * @param {string} next the step the board names otherwise + * @returns {string} + */ +export function applyFirst(pending, root, next) { + if (!pending.length) return next; + return `apply the person's recorded row decisions first: cd ${root} && pullboard spec apply (${pending.map(decided).join(', ')}); then ${next}`; +} + /** * The coordinator's next step with an applied row file still uncommitted, so it commits first: * a worktree started before that commit would work from the old rows. diff --git a/test/resume-flow.test.js b/test/resume-flow.test.js index 340243fc2..9b2fa6464 100644 --- a/test/resume-flow.test.js +++ b/test/resume-flow.test.js @@ -79,6 +79,40 @@ test('a worktree names its main checkout\'s uncommitted init files [I1,I2]', (t) for (const path of ['pullboard.json', 'SPEC.md', 'DOCTRINE.md']) assert.ok(fromMain.stdout.includes(path), fromMain.stdout); }); +/** + * Prove the coordinator's resume names a person's row decision that awaits `pullboard spec apply`, + * and puts the apply ahead of every other step [N19]. The local view records the decision silently, + * so a coordinator that reads only `needs the person` sees an approved row as still pending. + */ +test('resume names row decisions awaiting spec apply [N19]', t => { + const APPLY_LINE = "The coordinator must apply the person's recorded row decision: "; + const box = releaseFixture(t, { freshSetup: true, historyBeforeInit: true }); + const drafted = box.run(box.repo, 'spec', 'draft', 'The person can name the project.'); + assert.equal(drafted.status, 0, drafted.failure || drafted.stderr || drafted.stdout); + box.git(box.repo, 'add', '-A'); + box.git(box.repo, 'commit', '-q', '-m', 'chore: set up pullboard'); + const approved = box.run(box.repo, 'spec', 'approve', 'G1'); + assert.equal(approved.status, 0, approved.failure || approved.stderr || approved.stdout); + /** Resume's text and JSON from the main checkout, without diagnostics: the session-start form. */ + const resume = () => { + const text = box.run(box.repo, 'resume'); + assert.equal(text.status, 0, text.failure || text.stderr || text.stdout); + const json = box.run(box.repo, 'resume', '--json'); + assert.equal(json.status, 0, json.failure || json.stderr || json.stdout); + return { lines: text.stdout.split('\n'), card: JSON.parse(json.stdout) }; + }; + const before = resume(); + assert.deepEqual(before.card.pendingApply, [{ id: 'G1', kind: 'spec', decision: 'approve' }], JSON.stringify(before.card.pendingApply ?? null)); + assert.ok(before.lines.includes(`${APPLY_LINE}G1 approved, pending apply: cd ${box.repo} && pullboard spec apply`), before.lines.join('\n')); + assert.match(before.card.next, /^apply the person's recorded row decisions first: cd .+ && pullboard spec apply \(G1 approved\); then /u); + const applied = box.run(box.repo, 'spec', 'apply'); + assert.equal(applied.status, 0, applied.failure || applied.stderr || applied.stdout); + const after = resume(); + assert.deepEqual(after.card.pendingApply, [], 'an applied decision is no longer pending'); + assert.equal(after.lines.some((line) => line.startsWith(APPLY_LINE)), false, after.lines.join('\n')); + assert.doesNotMatch(after.card.next, /^apply the person's recorded row decisions first/u); +}); + /** * Prove resume gives each uncommitted file its right owner: the row file `spec apply` changed goes * to the coordinator, the setup files init wrote go to the person, and nothing else is named [I1,L3].