Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/commands/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}) } : {}),
Expand Down Expand Up @@ -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('"', '\\"')}" "<rule>" --check "<command>" --red <commit> --green <commit>`);
if (card.toVerify.length) say('review queue:');
Expand Down Expand Up @@ -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 });
Expand Down
38 changes: 38 additions & 0 deletions src/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof store.rowDecisions>} 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<typeof pendingRowApply>} 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<typeof pendingRowApply>} 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.
Expand Down
34 changes: 34 additions & 0 deletions test/resume-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down