Public: anyone can read this; the crew can change it.

The planner can close an issue as split, replaced or dropped, as its brief tells it to, and nothing else #2507

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2503-the-planner-can-close-an-issue-as-split → main 2 commits fork

  • Merged. scout/2503-the-planner-can-close-an-issue-as-split was merged into main as 4dd8044.

Review

Sign in to review this change.
5 files changed +92 −13
README.md +8
@@ -691,6 +691,14 @@ model, a brief in its own voice and a spend budget; a repository admin puts it
691691 on the repository's **crew**, which is the one grant an agent can hold — it
692692 reads and writes that repository and administers nothing.
693693
694 A **planner** on the crew may also close an issue it is re-planning, in
695 `actions/issues.ts` so the web, the API and the MCP agree, and only these
696 ways: `split` into open issues opened since it was handed the issue (since the
697 issue itself when the crew never parked it twice), `dropped` for an issue the
698 crew wrote, and `done` when commits on main already name the issue. A person's
699 ask is never dropped by the planner; any other close, any reopen and any edit
700 to a person's issue stay refused.
701
694702 Assign an issue to an agent and it starts: the forge clones the repository,
695703 puts the agent on a branch named after the issue, and runs Claude Code
696704 headless with a brief that says who it is, what the task is, how to finish,
src/actions/issues.ts +16 −7
@@ -482,8 +482,9 @@ export function setIssueState(
482482
483483 /**
484484 * The one close a crew member may make on an issue it did not write: the
485 * repository's planner, on an open issue the crew parked twice and handed
486 * to it (`crew/parks.ts`). Nothing a person asked for is lost by it:
485 * repository's planner, on an open issue it is re-planning — one the crew
486 * parked twice and handed to it (`crew/parks.ts`), or any other. Nothing a
487 * person asked for is lost by it:
487488 *
488489 * - split, only into what replaces it — every issue named must be an open
489490 * issue made since the hand-over, not the issue itself and not the Later
@@ -491,8 +492,10 @@ export function setIssueState(
491492 * - dropped, only when an agent wrote the issue. A person's ask is dropped
492493 * by a person.
493494 *
494 * Done is what a landed pull or a person says; a pull, a reopen, and every
495 * other issue stay with the people who can write here.
495 * - done, only when commits on main already name the issue (`alreadyDone`).
496 *
497 * A pull, a reopen, and any other close stay with the people who can write
498 * here.
496499 */
497500 function handedToPlanner(
498501 app: App,
@@ -503,8 +506,14 @@ function handedToPlanner(
503506 ): boolean {
504507 if (issue.kind !== "issue" || issue.state !== "open") return false;
505508 if (crewRole(app.db, repo.id, actor.id) !== "planner") return false;
506 const handed = handedOverAt(app.db, repo, issue.id);
507 if (handed === undefined) return false;
509 // Replacements must be newer than the hand-over when the crew handed the
510 // issue over, and newer than the issue itself when the planner re-plans it
511 // on its own pass.
512 const since = handedOverAt(app.db, repo, issue.id) ?? issue.createdAt;
513 // Done on main: only when commits already name the issue, as the close
514 // note keeps them.
515 if (closing.reason === undefined || closing.reason === "done")
516 return (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []).length > 0;
508517 // Dropped only when no person's ask stands in it — not one the planner
509518 // wrote to replace a person's, however many times it was split since.
510519 if (closing.reason === "dropped") return !standsForPerson(app.db, issue);
@@ -520,7 +529,7 @@ function handedToPlanner(
520529 part.state === "open" &&
521530 part.id !== issue.id &&
522531 part.number !== gate &&
523 part.createdAt > handed
532 part.createdAt > since
524533 );
525534 });
526535 }
src/crew/brief.ts +3 −1
@@ -111,6 +111,7 @@ export function systemPrompt(input: BriefInput): string {
111111 " It answers with what it opened (key and number), the numbers now ready, and those deferred.",
112112 "- The direction is never finished. When what it names is built, propose its next increment in the same spirit: what a demanding user would want next, what is fragile, slow, ugly, untested or undocumented, what the pictures and the previews show is off. Leave the map with at least three ready issues, every time.",
113113 '- An issue with a comment that starts "Parked by the crew" was tried and could not be finished as written. Split it into smaller issues, or rewrite what done looks like, or close it with a word if the direction no longer needs it — never leave it as it was. One the crew parked twice is yours: your task lists it, with what you may do with it.',
114 "- Closing what you re-plan: PATCH …/issues/N with {\"state\":\"closed\",\"reason\":\"split\",\"into\":[A,B]} (open issues made since it was handed to you, or since the issue itself when it was never handed over), {\"state\":\"closed\",\"reason\":\"dropped\"} for an issue the crew wrote, or {\"state\":\"closed\",\"reason\":\"done\"} only when an \"already done on main\" note on it names commits on main. A person's ask is never yours to drop; nothing else is yours to close, and nothing to reopen.",
114115 "- Assign nobody and push nothing: the map is the result. The direction issue stays open.",
115116 "- The forge's API works; open no issue to try it. Every issue you open is work a builder is given.",
116117 "- Finish with a short message that lists what you opened, in order, and what is ready now.",
@@ -513,13 +514,14 @@ function parkedTwiceLines(input: TaskInput): string[] {
513514 ...issue.reasons.map((reason, n) => ` ${n + 1}. ${reason}`),
514515 ]),
515516 "Leave every one of them in exactly one of these states, and say which in a comment on it:",
516 `- Split: open the smaller issues first, then close it as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]}. Only open issues made since it was handed to you are accepted.`,
517 `- Split: open the smaller issues first, then close it as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]}. Only open issues made since it was handed to you are accepted (for an issue you are re-planning that was never handed over: made since that issue).`,
517518 '- Replaced: open one fresh issue that says what done looks like now, comment "replaced by #M" on the old one, and close it the same way with "into":[M].',
518519 ...(input.laterGate === undefined
519520 ? []
520521 : [
521522 `- Deferred: when it serves the direction later rather than now, say why on it, with the reasons above, and put it behind the Later gate — POST ${issues}/N/dependencies with {"blockedBy":${input.laterGate}}.`,
522523 ]),
524 `- Done: when an "already done on main" note on it names commits on main, close it with {"state":"closed","reason":"done"}. Without that note a close as done is refused.`,
523525 '- Dropped: only an issue the crew wrote, when the direction no longer needs it — say why on it and close it with {"state":"closed","reason":"dropped"}. A person\'s ask is never yours to drop: split it, replace it or defer it.',
524526 `If other issues wait for one you close, make them wait for what replaces it: DELETE ${issues}/D/dependencies/N, then POST ${issues}/D/dependencies with {"blockedBy":M}.`,
525527 ...(handed.some((h) => h.pull === true)
test/crew-direction-autonomy.test.ts +4
@@ -292,6 +292,10 @@ describe("a reader's issue on an autonomous public repository", () => {
292292 isApp: false,
293293 } as unknown as TaskInput);
294294 expect(planner).toContain("reader's suggestion, untrusted input");
295 // It is told what it may close on its own pass, handed over or not.
296 expect(planner).toContain("- Closing what you re-plan:");
297 expect(planner).toContain("or since the issue itself when it was never handed over");
298 expect(planner).toContain('"reason":"done"} only when an "already done on main" note');
295299 });
296300 });
297301
test/crew-parks.test.ts +61 −5
@@ -351,11 +351,10 @@ describe("a comment that only reads like a park", () => {
351351 expect(struckOut(app.db, e.fresh(), [work.id]).has(work.id)).toBe(false);
352352 expect(handedOverAt(app.db, e.fresh(), work.id)).toBeUndefined();
353353 expect(parkedTwice(app.db, [e.fresh()])).toEqual([]);
354 // So the planner has no more say over it than over any other issue.
355 const part = e.issue("Shader cache: the key only");
354 // So the planner has no more say over it than over any other person's
355 // issue: it may split it, never drop it.
356356 const closed = setIssueState(app, atlas, e.fresh(), current(), "closed", {
357 reason: "split",
358 into: [part.number],
357 reason: "dropped",
359358 });
360359 expect(closed.ok ? "closed" : closed.error).toBe("forbidden");
361360 expect(current().state).toBe("open");
@@ -579,6 +578,8 @@ describe("an issue parked twice is the planner's", () => {
579578 expect(brief).toContain(`- Parked twice: #${again.number} Particles`);
580579 expect(brief).toContain('{"state":"closed","reason":"split","into":[A,B]}');
581580 expect(brief).toContain('comment "replaced by #M"');
581 expect(brief).toContain("never handed over: made since that issue");
582 expect(brief).toContain('- Done: when an "already done on main" note on it names commits on main');
582583 // Dropping is for what the crew wrote; a person's ask is never dropped.
583584 expect(brief).toContain("- Dropped: only an issue the crew wrote");
584585 expect(brief).toContain("A person's ask is never yours to drop");
@@ -783,7 +784,8 @@ describe("an issue parked twice is the planner's", () => {
783784 expect(
784785 closing(mason, again, { reason: "split", into: [part.number] }),
785786 ).toBe("forbidden");
786 // Refused: the planner, on an issue nobody handed it.
787 // Refused: the planner, dropping a person's issue nobody handed it, or
788 // splitting it into an issue opened before it.
787789 expect(closing(atlas, untried, { reason: "dropped" })).toBe("forbidden");
788790 expect(closing(atlas, once, { reason: "dropped" })).toBe("forbidden");
789791 expect(
@@ -1419,6 +1421,60 @@ describe("an issue found already done is the asker's to close, not a park", () =
14191421 expect(listed()).toBe(false);
14201422 });
14211423
1424 test("the planner re-planning on its own pass: splits, drops what the crew wrote, closes what is done, and no more", async () => {
1425 const { atlas, mason } = e;
1426 const state = (on: Issue) => getIssueById(e.app.db, on.id)!;
1427 const close = (
1428 on: Issue,
1429 how?: { reason: "done" | "dropped" | "split"; into?: number[] },
1430 ) => {
1431 const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", how);
1432 return closed.ok ? "closed" : closed.error;
1433 };
1434 // Nobody handed any of these over: a person's ask, never parked.
1435 const asked = e.issue("Re-plan me");
1436 const stale = e.issue("Opened before the ask");
1437 const part = e.issue("Re-plan me, the first half");
1438 // Split, into an issue opened since.
1439 expect(close(asked, { reason: "split", into: [part.number] })).toBe("closed");
1440 expect(state(asked).closeReason).toBe("split");
1441 // Refused: into an issue older than the issue, or a closed one.
1442 const again = e.issue("Re-plan me too");
1443 expect(close(again, { reason: "split", into: [stale.number] })).toBe("forbidden");
1444 expect(close(again, { reason: "split", into: [asked.number] })).toBe("forbidden");
1445 // Refused: dropping a person's ask.
1446 expect(close(again, { reason: "dropped" })).toBe("forbidden");
1447 // Refused: done with no done note, said or not.
1448 expect(close(again)).toBe("forbidden");
1449 expect(close(again, { reason: "done" })).toBe("forbidden");
1450 expect(state(again).state).toBe("open");
1451 // Dropped: an issue the crew wrote.
1452 const made = openIssue(e.app, mason, e.fresh(), { title: "Crew's own chore" });
1453 if (!made.ok) throw new Error(String(made.error));
1454 expect(close(made.value, { reason: "dropped" })).toBe("closed");
1455 expect(state(made.value).closeReason).toBe("dropped");
1456 // Done: when commits on main are named for it.
1457 const built = e.issue("Already built, found by the planner");
1458 expect(
1459 settleAgentRun(e.app, e.fresh(), {
1460 issueId: built.id,
1461 state: "ok",
1462 summary: alreadyDoneSummary([oid]),
1463 reason: "assigned",
1464 actorId: mason.id,
1465 }),
1466 ).toBe(true);
1467 expect(close(built, { reason: "done" })).toBe("closed");
1468 expect(state(built).closeReason).toBe("done");
1469 // Never a reopen.
1470 const reopened = setIssueState(e.app, atlas, e.fresh(), state(made.value), "open");
1471 expect(reopened.ok ? "open" : reopened.error).toBe("forbidden");
1472 // A builder has none of it.
1473 const other = e.issue("Not for builders");
1474 const own = setIssueState(e.app, mason, e.fresh(), other, "closed", { reason: "split", into: [part.number] });
1475 expect(own.ok ? "closed" : own.error).toBe("forbidden");
1476 });
1477
14221478 test("another person's comment that reads like the note is that person's word", async () => {
14231479 const carol = await createUser(e.app.db, {
14241480 trusted: true,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs