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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2503-the-planner-can-close-an-issue-as-split → main 2 commits fork
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 | ||
| 691 | 691 | on the repository's **crew**, which is the one grant an agent can hold — it |
| 692 | 692 | reads and writes that repository and administers nothing. |
| 693 | 693 | |
| 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 | ||
| 694 | 702 | Assign an issue to an agent and it starts: the forge clones the repository, |
| 695 | 703 | puts the agent on a branch named after the issue, and runs Claude Code |
| 696 | 704 | 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( | ||
| 482 | 482 | |
| 483 | 483 | /** |
| 484 | 484 | * 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: | |
| 487 | 488 | * |
| 488 | 489 | * - split, only into what replaces it — every issue named must be an open |
| 489 | 490 | * issue made since the hand-over, not the issue itself and not the Later |
| @@ -491,8 +492,10 @@ export function setIssueState( | ||
| 491 | 492 | * - dropped, only when an agent wrote the issue. A person's ask is dropped |
| 492 | 493 | * by a person. |
| 493 | 494 | * |
| 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. | |
| 496 | 499 | */ |
| 497 | 500 | function handedToPlanner( |
| 498 | 501 | app: App, |
| @@ -503,8 +506,14 @@ function handedToPlanner( | ||
| 503 | 506 | ): boolean { |
| 504 | 507 | if (issue.kind !== "issue" || issue.state !== "open") return false; |
| 505 | 508 | 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; | |
| 508 | 517 | // Dropped only when no person's ask stands in it — not one the planner |
| 509 | 518 | // wrote to replace a person's, however many times it was split since. |
| 510 | 519 | if (closing.reason === "dropped") return !standsForPerson(app.db, issue); |
| @@ -520,7 +529,7 @@ function handedToPlanner( | ||
| 520 | 529 | part.state === "open" && |
| 521 | 530 | part.id !== issue.id && |
| 522 | 531 | part.number !== gate && |
| 523 | part.createdAt > handed | |
| 532 | part.createdAt > since | |
| 524 | 533 | ); |
| 525 | 534 | }); |
| 526 | 535 | } |
src/crew/brief.ts +3 −1
| @@ -111,6 +111,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 111 | 111 | " It answers with what it opened (key and number), the numbers now ready, and those deferred.", |
| 112 | 112 | "- 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.", |
| 113 | 113 | '- 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.", | |
| 114 | 115 | "- Assign nobody and push nothing: the map is the result. The direction issue stays open.", |
| 115 | 116 | "- The forge's API works; open no issue to try it. Every issue you open is work a builder is given.", |
| 116 | 117 | "- 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[] { | ||
| 513 | 514 | ...issue.reasons.map((reason, n) => ` ${n + 1}. ${reason}`), |
| 514 | 515 | ]), |
| 515 | 516 | "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).`, | |
| 517 | 518 | '- 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].', |
| 518 | 519 | ...(input.laterGate === undefined |
| 519 | 520 | ? [] |
| 520 | 521 | : [ |
| 521 | 522 | `- 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}}.`, |
| 522 | 523 | ]), |
| 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.`, | |
| 523 | 525 | '- 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.', |
| 524 | 526 | `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}.`, |
| 525 | 527 | ...(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", () => { | ||
| 292 | 292 | isApp: false, |
| 293 | 293 | } as unknown as TaskInput); |
| 294 | 294 | 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'); | |
| 295 | 299 | }); |
| 296 | 300 | }); |
| 297 | 301 |
test/crew-parks.test.ts +61 −5
| @@ -351,11 +351,10 @@ describe("a comment that only reads like a park", () => { | ||
| 351 | 351 | expect(struckOut(app.db, e.fresh(), [work.id]).has(work.id)).toBe(false); |
| 352 | 352 | expect(handedOverAt(app.db, e.fresh(), work.id)).toBeUndefined(); |
| 353 | 353 | 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. | |
| 356 | 356 | const closed = setIssueState(app, atlas, e.fresh(), current(), "closed", { |
| 357 | reason: "split", | |
| 358 | into: [part.number], | |
| 357 | reason: "dropped", | |
| 359 | 358 | }); |
| 360 | 359 | expect(closed.ok ? "closed" : closed.error).toBe("forbidden"); |
| 361 | 360 | expect(current().state).toBe("open"); |
| @@ -579,6 +578,8 @@ describe("an issue parked twice is the planner's", () => { | ||
| 579 | 578 | expect(brief).toContain(`- Parked twice: #${again.number} Particles`); |
| 580 | 579 | expect(brief).toContain('{"state":"closed","reason":"split","into":[A,B]}'); |
| 581 | 580 | 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'); | |
| 582 | 583 | // Dropping is for what the crew wrote; a person's ask is never dropped. |
| 583 | 584 | expect(brief).toContain("- Dropped: only an issue the crew wrote"); |
| 584 | 585 | 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", () => { | ||
| 783 | 784 | expect( |
| 784 | 785 | closing(mason, again, { reason: "split", into: [part.number] }), |
| 785 | 786 | ).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. | |
| 787 | 789 | expect(closing(atlas, untried, { reason: "dropped" })).toBe("forbidden"); |
| 788 | 790 | expect(closing(atlas, once, { reason: "dropped" })).toBe("forbidden"); |
| 789 | 791 | expect( |
| @@ -1419,6 +1421,60 @@ describe("an issue found already done is the asker's to close, not a park", () = | ||
| 1419 | 1421 | expect(listed()).toBe(false); |
| 1420 | 1422 | }); |
| 1421 | 1423 | |
| 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 | ||
| 1422 | 1478 | test("another person's comment that reads like the note is that person's word", async () => { |
| 1423 | 1479 | const carol = await createUser(e.app.db, { |
| 1424 | 1480 | trusted: true, |
new above
offline