From 75d256bbb99119a59028d22af2e982445be97b26 Mon Sep 17 00:00:00 2001 From: Harsha Bharadwaj Date: Mon, 19 Oct 2026 21:00:42 +0530 Subject: [PATCH 1/2] feat: hand off work and store a task network Roles can hand an accepted section to its owner. A lead plan stays pending until it is accepted, and a run calls each ready node once. Issue #37 stays open. --- .../design.md | 23 + .../feature.md | 126 ++ .../local-test-plan.md | 29 + .../local-test-results.md | 47 + .../meta.yaml | 43 + .../metrics.json | 50 + .../tasks.md | 9 + docs/reference/cli.md | 4 +- .../src/__tests__/role.integration.test.ts | 16 + packages/cli/src/role-cmd.ts | 143 +++ .../core/src/__tests__/role-handoff.test.ts | 1053 +++++++++++++++++ packages/core/src/role/handoff.ts | 790 +++++++++++++ packages/core/src/role/index.ts | 9 + packages/core/src/role/run.ts | 39 +- 14 files changed, 2374 insertions(+), 7 deletions(-) create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/design.md create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/feature.md create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/local-test-plan.md create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/metrics.json create mode 100644 changes/2026-10-19-hand-off-work-and-the-task-network/tasks.md create mode 100644 packages/core/src/__tests__/role-handoff.test.ts create mode 100644 packages/core/src/role/handoff.ts diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/design.md b/changes/2026-10-19-hand-off-work-and-the-task-network/design.md new file mode 100644 index 00000000..698de45c --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/design.md @@ -0,0 +1,23 @@ +# Design + +> **Hand off work and the task network** · `2026-10-19-hand-off-work-and-the-task-network` + +The sentences in `feature.md` are the contract. + +## Modules + +Add `packages/core/src/role/handoff.ts` for messaging, handoff, group, and the network. `packages/core/src/role/run.ts` keeps the accuracy loop. Handoff imports the existing accepted-section reader, conversation path, model call, and finish path only if those stay exported. If they are private, duplicate the small accepted-section parser in `handoff.ts` rather than changing critique or revision prompt strings. + +`packages/core/src/role/index.ts` exports the new functions. `packages/cli/src/role-cmd.ts` adds `messaging`, `handoff`, `group`, and `network` as thin wrappers. Story sentences go to stdout with no consola prefix. + +## Files + +- `.sdd/role.yaml` keys `role_messaging` and the existing `approval_timeout_minutes`. +- Receiver and sender logs stay `.sdd/role-runs/conversation//.jsonl`. +- Plan file: `.sdd/role-runs/plans//
.json`. + +## Tests + +`packages/core/src/__tests__/role-handoff.test.ts` uses a fake clock and a fake `RoleModel`. Count `complete` calls and assert `roleId`. One CLI test in `packages/cli/src/__tests__/role.integration.test.ts` runs messaging off and a refused self handoff through the built CLI. + +Do not edit the shipped `feature.yaml` files. diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/feature.md b/changes/2026-10-19-hand-off-work-and-the-task-network/feature.md new file mode 100644 index 00000000..a7415147 --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/feature.md @@ -0,0 +1,126 @@ +# Feature + +> **Hand off work and the task network** · `2026-10-19-hand-off-work-and-the-task-network` · 2026-10-19 + +Issue #37, development-order item 2. Epic `issue-37`, story `S-004`. This story does not close #37. + +## Problem / opportunity + +`sdd role run` can draft and can ask a different role to critique. It cannot hand a task to another role, and it cannot tell one named service from a cross-scope task. A lead must not start writing before the person accepts a plan. + +## Users & value + +A person who already has role files and an accepted section. They hand one task to the owner of that section, or they ask which service, connection, or lead plan applies. Messaging between roles can be turned off. + +## Scope + +Slice 1 behavior stays. This story adds messaging, handoff, group, and the task network. Names below are the stdout sentences. + +### Messaging + +`.sdd/role.yaml` may contain `role_messaging`. Missing, or boolean `true`, means on. Boolean `false` means off. Any other value is invalid. + +`sdd role messaging on` writes `role_messaging: true` and prints `Messaging between roles is on.` +`sdd role messaging off` writes `role_messaging: false` and prints `Messaging between roles is off.` + +An existing `approval_timeout_minutes` stays. A missing file is created with only the messaging key. An invalid file prints `role.yaml is invalid` and is not rewritten. + +### Accepted section lines + +Inside an accepted section (`decision:` and `accepted: yes`, same rules as today), these trimmed lines are read. Other lines, including the words frontend and backend, are not names. + +- `service: ` and `module: ` and each id in `connection: ` match `/^[a-z][a-z0-9-]{0,39}$/`. +- `owner: ` and `lead: ` use a canonical role name. +- `connection:` has exactly two ids. +- `scope: product` marks the product-lead section. + +A section that is not accepted does not contribute names. This story does not rewrite `accepted.md`. + +### Handoff + +`sdd role handoff --to --task "..." --change --section ` + +Check order. The first failure returns and writes no conversation line: + +1. A name that is not canonical prints `A role name is not valid.` +2. The same role on both sides prints `A role cannot hand off to itself.` No model call. +3. A missing role prints `Role not found.` +4. A task that matches `/@X|x\.com|twitter\.com/` prints `X is not a role tool.` +5. A missing accepted section prints `This needs an accepted section before a draft.` +6. The section lists one or more `owner:` lines and `--to` is not one of them. Print `One owner handles this section.` +7. Messaging is off. Exit 0. Print `Messaging between roles is off.` No model call and no conversation line. +8. No model prints `No role model is configured.` +9. Invalid `role.yaml` prints `role.yaml is invalid` + +On success the runner appends the same object to both conversation logs, then calls the model once with `roleId` equal to `--to`: + +`{"type":"handoff","from":"","to":"","change":"","section":"","decision":""}` + +The prompt is Job, Description, Task, `Kind: handoff`, Section, Decision, and Section text. It does not include the sender memory file or the sender transcript. + +The receiver log then follows the existing finish rules for that one reply: text only, Allow once / Always allow / Deny, Ask first, a forbidden action, or `This action is not available in this slice.` The sender log gets one more object and nothing else from the receiver transcript: + +`{"type":"handoff_reply","from":"","to":"","change":"","section":"","decision":"","summary":""}` + +A failed model call writes no `handoff_reply`. Stdout and the exit code are the receiver's. The approval stop is only in the receiver log. + +### Group + +`sdd role group --change ` prints each `owner:` from accepted sections, in file order, once. No owners prints `No roles in this group.` Exit 0. It does not call a model and does not post anywhere. + +### Task network + +`sdd role network --change --section --task "..."` reads that section and the other sections in the same file. It does not rewrite the file. A missing file, a missing section, or a section that is not accepted prints `This needs an accepted section before a draft.` No model call and no plan file. + +The first matching case wins. The other cases do not run. + +1. Connection. The section itself is an accepted connection, or an accepted connection's two ids include the `service:` on this section. Use the first such connection section in the file. Its `owner:` lines, in file order and without duplicates, are the owner list. Print `Connection hands off. Lead is absent.` and then each owner on its own line. For each consecutive pair, append the handoff object to both conversation logs. The object cites that connection section and its decision. No model call. Messaging off prints only `Messaging between roles is off.` and writes no handoff line. `accepted.md` bytes stay the same. +2. No `service:` line on any accepted section. Print `One scope. No lead.` No plan file and no model call. +3. Exactly one distinct `service:` value, and case 1 did not match. Print `Service runs .` The owner is the `owner:` on the accepted section that has that `service:`. No plan file and no model call. A missing owner line prints `This service needs an owner.` A missing role file prints `Role not found.` +4. Cross-scope. This is the only case that calls the lead. A service's module is the `module:` line on the accepted section that has that `service:`. A missing `module:` line means that service has no module. All distinct services in the file are involved. A module lead applies only when every involved service has the same module and that module has more than one service. The lead is the first `lead:` on an accepted section with that `module:`. A product lead applies only when the involved services have more than one module id. The lead is the first `lead:` on an accepted section with `scope: product`. Anything else, including two services that have no module, prints `One scope. No lead.` and does not call a lead. A missing lead line prints `This module needs a lead.` or `This product needs a lead.` A missing lead role prints `Role not found.` + +The lead prompt includes `--task`, the service ids, the module id of each service or `none`, the owners, and the decision ids. It does not include any file under `.sdd/role-runs/memory/` and does not include `allow.json`. + +The reply has `text` and `plan`. `plan` is a list of root nodes. Each node has `scope`, `owner`, `decision`, `after`, `artifact`, `boundary`, and optional `children`. `boundary` is `ask` or `stop`. `scope` is a named service or module id. `owner` is an existing role. `decision` and every `after` id are decision ids already on accepted sections. `artifact` must pass the slice 1 safe relative path check, or the plan is not valid. `children` is a list of nodes. A child that has a non-empty `children` list is depth 3. + +A bad plan prints `The plan is not valid.` A plan deeper than 2 prints `A plan is deeper than 2.` Neither writes a plan file and neither changes `rebuilds` or `stalled`. That call does not block a later `sdd role network` on the same section. + +A valid plan is stored at `.sdd/role-runs/plans//
.json` with `status: pending`, `rebuilds: 0`, `stalled: false`, `task` set to the `--task` string, and no `results` yet. Stdout is `Accept this plan before anyone writes.` then one line per node, parent before its children: ` after artifact boundary `. No specialist is called. + +`sdd role network accept --change --section ` with no plan file prints `No plan is stored.` A plan whose status is not `pending` prints `The plan is not waiting for acceptance.` A pending plan becomes `accepted` and prints `Plan accepted.` Accept does not call a model and does not reset `rebuilds` or `stalled`. + +`sdd role network run --change --section ` with no plan file prints `No plan is stored.` A `pending` plan prints `Accept this plan before anyone writes.` A `done` plan prints `The parent is done.` Only `accepted` or `open` runs nodes. One command runs every node that is ready, then any node that became ready because those results arrived, including a parent after its children in that same command. A node is ready when every `after` id has a result and every child has a result. Each ready node is one model call as that owner. The prompt includes the task text stored on the plan, the node artifact, and the boundary. It does not include another role's memory or transcript. The result stored on the plan is only `{decision, artifact, summary}` where `summary` is the reply `text`. The plan file has no transcript. The owner's conversation log can hold the model line. An action still stops on that owner's run and is not applied. + +A node that is still not ready is not called. Stdout includes `A branch is waiting for its prerequisites.` While any node has no result, status is `open` and stdout includes `The parent stays open.` When every node has a result, status is `done` and stdout includes `The parent is done.` + +`sdd role network rebuild --change --section ` with no plan file prints `No plan is stored.` It calls the lead once when `rebuilds` is 0, or when `rebuilds` is 1, `stalled` is false, and the current plan is `accepted`. Otherwise it prints `A second rebuild does not start.` and does not call the model. A bad or too-deep reply leaves the plan, `rebuilds`, and `stalled` unchanged. A reply with the same artifact ids leaves the nodes and the status in place, prints `Stopped. The plan made no progress.`, sets `stalled` true, and increments `rebuilds`. A later rebuild while `stalled` is true does not call the model. A reply with different artifact ids stores the new plan as `pending`, sets `stalled` false, and increments `rebuilds`. Specialists do not run until the person accepts that plan. + +### Docs + +`docs/reference/cli.md` section `sdd role` gains these commands in a few sentences. The local model example stays. The connectors sentence becomes `Connectors and routines are not in this slice.` + +## Non-goals + +- Do not add connectors, routines, Slack, Teams, a page, or a schedule. +- Do not run commit, push, tag, pull request, merge, or deploy. +- Do not call `createChange`, `sdd next`, or `sdd complete`. +- Do not close #37. +- Do not change the critique or revision prompt text. +- Do not edit `packages/core/src/report.ts`, `packages/core/src/agents.ts`, or a `feature.yaml`. + +## Success criteria + +- [x] Messaging off blocks a handoff before any model call or conversation line, and turns back on. +- [x] A handoff to the same role writes nothing and calls no model. +- [x] A handoff to the section owner writes the same handoff object on both logs, calls the receiver once, and stores only a summary on the sender log. +- [x] An owner line that does not name `--to` prints `One owner handles this section.` +- [x] One named service prints `Service runs .` and creates no plan file. Frontend and backend in the body add no service. +- [x] An accepted connection prints `Connection hands off. Lead is absent.`, writes handoff lines, and leaves every decision line unchanged. +- [x] A cross-scope plan is not stored until it is valid and at most depth 2, and no specialist runs before accept. +- [x] A branch with an unmet `after` id does not call the model. The parent stays open until every node has a result. The stored result has no transcript. +- [x] A second rebuild does not start unless the previous rebuild was accepted. A no-progress rebuild keeps the nodes. +- [x] `sdd role group` lists accepted owners and does not call a model. + +## Risks / open questions + +The lead plan is a file under `.sdd/role-runs/`, which is gitignored. The person accepts it with `sdd role network accept` on this machine. This story does not copy that file into the change pack. diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-plan.md b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-plan.md new file mode 100644 index 00000000..cc2b93bf --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-plan.md @@ -0,0 +1,29 @@ +# Local test plan + +> **Hand off work and the task network** · `2026-10-19-hand-off-work-and-the-task-network` + +Node v20.19.3. Branch `story/issue-37/S-004` from main `f6dd94b`. The `EBADENGINE` warning for `engines.node >= 24` is expected. No network. + +| # | Check | Expected | +|---|--------|----------| +| 1 | Messaging off, then on | Off blocks the handoff with no model call and no conversation file. The timeout value stays. On writes `role_messaging: true`. | +| 2 | Handoff to the same role | Exit 1. `A role cannot hand off to itself.` No model call. | +| 3 | Handoff to the section owner | One model call as the receiver. Both logs share the handoff object. The sender log stores only the summary. The file is not written. Allow can still see the receiver stop. | +| 4 | Receiver is not an owner | `One owner handles this section.` | +| 5 | No service lines | `One scope. No lead.` No plan file. | +| 6 | One service | `Service billing runs billing-builder.` No plan file. | +| 7 | Accepted connection | `Connection hands off. Lead is absent.` Both logs cite the connection decision. `accepted.md` is unchanged. | +| 8 | Depth 3, then a valid plan | The deep reply writes no file. The valid reply waits for accept. Run before accept does not call a specialist. | +| 9 | Run after accept | The child runs, then the parent, in one command. The plan stores summaries and no transcript. The write stays stopped. | +| 10 | Unmet `after` and rebuild | The waiting node is not called. A same-artifact rebuild keeps the nodes and stalls. The next rebuild does not call the model. | +| 11 | Two modules | The model role id is `product-lead`. | +| 12 | Two services and no module | `One scope. No lead.` No model call. | +| 13 | Custom `changes_path` | The layout copy is read. The repo-root copy is not. | +| 14 | CLI | `sdd role messaging off` and a self handoff print the spec sentences. | +| 15 | `pnpm test` | Pass. | +| 16 | Repeated connection owners | Each owner is printed once. The pair is not a handoff to the same role. | +| 17 | A later `service:` id is on the connection | `Connection hands off. Lead is absent.` No model call. | +| 18 | Two module ids, and one service with no module | The model role id is `product-lead`. | +| 19 | Product `lead:` removed before rebuild | `This product needs a lead.` The model is not called again. | +| 20 | Same artifact ids in a different order | `Stopped. The plan made no progress.` The stored node order stays. | +| 21 | One run, then a missing owner | Each ready node is called once. A node that becomes ready still runs. A missing owner keeps the earlier result. | diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md new file mode 100644 index 00000000..964da6cd --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md @@ -0,0 +1,47 @@ +# Local test results + +> **Hand off work and the task network** · `2026-10-19-hand-off-work-and-the-task-network` + +| # | Result | +|---|--------| +| 1 | PASS. Messaging off writes no conversation and keeps `approval_timeout_minutes: 10`. | +| 2 | PASS. Same-role handoff exits 1 and calls no model. | +| 3 | PASS. Receiver call, shared handoff object, sender summary, and deny on the shared run id. | +| 4 | PASS. `One owner handles this section.` | +| 5 | PASS. Frontend and backend prose prints `One scope. No lead.` | +| 6 | PASS. `Service billing runs billing-builder.` and no plan file. | +| 7 | PASS. Connection owners and the `D-link` handoff object. Decision text is unchanged. | +| 8 | PASS. Depth 3 writes no file. Accept is required before a specialist call. | +| 9 | PASS. Child then parent. The plan file has the two summaries and no transcript. `notes/child.md` is absent. | +| 10 | PASS. Waiting sentence, stalled plan, second rebuild refused. | +| 11 | PASS in the product-lead test. The model role id is `product-lead`. | +| 12 | PASS. Two services with no module do not call the model. | +| 13 | PASS. `changes_path: .sdd/changes` supplies the owner. | +| 14 | PASS. CLI messaging off and the self handoff sentence. | +| 15 | PASS. `pnpm test` exited 0 under `sdd verify`. | +| 16 | PASS. Duplicate `owner:` lines print once, and the billing log has one handoff. | +| 17 | PASS. `service: billing` after `service: other` selects the connection and calls no model. | +| 18 | PASS. A service with no module still asks `product-lead` when two module ids are present. | +| 19 | PASS. Rebuild prints `This product needs a lead.` and does not call the model. | +| 20 | PASS. Swapped artifact ids stall and leave `notes/a.md` before `notes/b.md`. | +| 21 | PASS. The side node is called once, the billing node still runs, and a missing owner keeps `exp done`. | + +--- + +## Latest run (2026-10-19T15:10:29.096Z) + +> Overall: PASS +> Stage: local_verify + + +- **Workspace tests**: exit 0 (`pnpm test`) + +--- + +## Latest run (2026-10-19T15:25:08.731Z) + +> Overall: PASS +> Stage: local_verify + + +- **Workspace tests**: exit 0 (`pnpm test`) diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml b/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml new file mode 100644 index 00000000..e75018e9 --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml @@ -0,0 +1,43 @@ +id: 2026-10-19-hand-off-work-and-the-task-network +title: Hand off work and the task network +workflow: feature +created: 2026-10-19T14:41:11.398Z +updated: 2026-10-19T15:25:08.732Z +status: in_progress +stage: local_verify +branch: story/issue-37/S-004 +epic: issue-37 +story: S-004 +depends: [] +flags: {} +overrides: + skip_stages: + - clarify_intent + - brainstorm + - clarify_design + - clarify_tasks + gates: {} + extra_stages: [] +skipped: + - stage: clarify_intent + reason: the approved spec has no open product choice + at: 2026-10-19T14:43:00.836Z + - stage: brainstorm + reason: one approach is enough + at: 2026-10-19T14:43:03.360Z + - stage: clarify_design + reason: the approved design has no open product choice + at: 2026-10-19T14:43:08.770Z + - stage: clarify_tasks + reason: the approved design is the task list + at: 2026-10-19T14:43:13.572Z +gates: {} +verify_results: + local_verify: + ok: true + status: pass + head_sha: f6dd94b4ab8cf453a78372743512d04b10b79ab3 + at: 2026-10-19T15:25:08.732Z + results: + - name: Workspace tests + exitCode: 0 diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/metrics.json b/changes/2026-10-19-hand-off-work-and-the-task-network/metrics.json new file mode 100644 index 00000000..8c2293d8 --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/metrics.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "phases": [ + { + "phase": "intent", + "startedAt": "2026-10-19T14:41:11.398Z", + "endedAt": "2026-10-19T14:42:57.514Z" + }, + { + "phase": "clarify_intent", + "startedAt": "2026-10-19T14:42:57.514Z", + "endedAt": "2026-10-19T14:43:00.838Z" + }, + { + "phase": "brainstorm", + "startedAt": "2026-10-19T14:43:00.838Z", + "endedAt": "2026-10-19T14:43:03.362Z" + }, + { + "phase": "design", + "startedAt": "2026-10-19T14:43:03.362Z", + "endedAt": "2026-10-19T14:43:05.872Z" + }, + { + "phase": "clarify_design", + "startedAt": "2026-10-19T14:43:05.872Z", + "endedAt": "2026-10-19T14:43:08.771Z" + }, + { + "phase": "tasks", + "startedAt": "2026-10-19T14:43:08.771Z", + "endedAt": "2026-10-19T14:43:11.356Z" + }, + { + "phase": "clarify_tasks", + "startedAt": "2026-10-19T14:43:11.356Z", + "endedAt": "2026-10-19T14:43:13.573Z" + }, + { + "phase": "implement", + "startedAt": "2026-10-19T14:43:13.573Z", + "endedAt": "2026-10-19T15:03:29.562Z" + }, + { + "phase": "local_verify", + "startedAt": "2026-10-19T15:03:29.562Z" + } + ], + "requests": [] +} diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/tasks.md b/changes/2026-10-19-hand-off-work-and-the-task-network/tasks.md new file mode 100644 index 00000000..b3335d52 --- /dev/null +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Add messaging on and off, preserving a valid timeout and refusing an invalid `role.yaml`. +- [x] Add `sdd role handoff` with the check order, both logs, one receiver call, and the sender summary. +- [x] Add `sdd role group` for accepted owners. +- [x] Add network classify: one scope, one service, connection, module lead, and product lead. +- [x] Store a pending plan, accept it, run ready nodes, and keep the parent open. +- [x] Rebuild when `rebuilds` is 0, or when `rebuilds` is 1, `stalled` is false, and the plan is accepted. Otherwise refuse. A no-progress reply keeps the nodes and sets `stalled`. +- [x] Document the commands in `docs/reference/cli.md`. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 712fa0bc..558bd28a 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -149,7 +149,9 @@ Related change specs and memory pages can be packed as a short extractive summar ## sdd role -A role file at `.sdd/roles/.md` is the profile a product repo can commit. It is not a host stub from `sdd agents install`. `sdd role run` stops in plain language before it writes a file. Allow once writes only the named file. Always allow remembers that file and those inputs for that role. Connectors are not in this slice. +A role file at `.sdd/roles/.md` is the profile a product repo can commit. It is not a host stub from `sdd agents install`. `sdd role run` stops in plain language before it writes a file. Allow once writes only the named file. Always allow remembers that file and those inputs for that role. Connectors and routines are not in this slice. + +`sdd role messaging off` stops a handoff before any model call. `sdd role handoff --to --change --section --task "..."` writes the same handoff record on both conversations and calls only the receiver. `sdd role group --change ` lists accepted owners. `sdd role network` prints one service, a connection with the lead absent, or asks the lead for a plan of depth at most 2. Nobody writes until `sdd role network accept`. `sdd role network run` runs a node only after its prerequisites, and the parent stays open until every node has a result. A local adapter shows the three passes. It reads `{"roleId","prompt"}` and writes one JSON object. It does not call a network. diff --git a/packages/cli/src/__tests__/role.integration.test.ts b/packages/cli/src/__tests__/role.integration.test.ts index b64ce5aa..d0cd575e 100644 --- a/packages/cli/src/__tests__/role.integration.test.ts +++ b/packages/cli/src/__tests__/role.integration.test.ts @@ -85,6 +85,22 @@ process.stdin.on("end", () => { expect(missing.stdout.trim()).toBe("No role model is configured."); }); + it("turns messaging off and refuses a handoff to the same role", async () => { + const root = await mkdtemp(join(tmpdir(), "sdd-role-handoff-")); + temps.push(root); + expect(runSdd(root, ["init", "--here", "--no-agent"]).status).toBe(0); + expect( + runSdd(root, ["role", "create", "--name", "builder", "--label", "Builder", "--job", "Build"]) + .status, + ).toBe(0); + const off = runSdd(root, ["role", "messaging", "off"]); + expect(off.status).toBe(0); + expect(off.stdout.trim()).toBe("Messaging between roles is off."); + const same = runSdd(root, ["role", "handoff", "builder", "--to", "builder", "--task", "draft"]); + expect(same.status).toBe(1); + expect(same.stdout.trim()).toBe("A role cannot hand off to itself."); + }); + it("describes agents install as one host", async () => { const root = await mkdtemp(join(tmpdir(), "sdd-role-help-")); temps.push(root); diff --git a/packages/cli/src/role-cmd.ts b/packages/cli/src/role-cmd.ts index 8c5d5a2f..e2ed3f35 100644 --- a/packages/cli/src/role-cmd.ts +++ b/packages/cli/src/role-cmd.ts @@ -1,14 +1,21 @@ import { spawn } from "node:child_process"; import { type RoleModel, + acceptNetwork, allowRole, createRole, deleteRole, duplicateRole, editRole, + handoffRole, hideRole, pinRole, + planNetwork, + rebuildNetwork, + runNetwork, runRole, + setRoleMessaging, + showRoleGroup, showRoles, } from "@structured-vibe-coding/core"; import { defineCommand } from "citty"; @@ -179,6 +186,138 @@ const run = defineCommand({ }, }); +const messagingOn = defineCommand({ + meta: { name: "on", description: "Allow roles to hand work to each other" }, + async run() { + await withProject(async ({ root }) => { + emit(await setRoleMessaging(root, true)); + }); + }, +}); + +const messagingOff = defineCommand({ + meta: { name: "off", description: "Stop roles from handing work to each other" }, + async run() { + await withProject(async ({ root }) => { + emit(await setRoleMessaging(root, false)); + }); + }, +}); + +const messaging = defineCommand({ + meta: { name: "messaging", description: "Turn handoff between roles on or off" }, + subCommands: { on: messagingOn, off: messagingOff }, +}); + +const handoff = defineCommand({ + meta: { name: "handoff", description: "Hand one accepted section to another role" }, + args: { + from: { type: "positional", description: "Role that hands off", required: true }, + to: { type: "string", description: "Role that receives the task", required: true }, + task: { type: "string", description: "What to ask the receiver" }, + change: { type: "string", description: "Change id that owns accepted.md" }, + section: { type: "string", description: "Section id in accepted.md" }, + }, + async run({ args }) { + await withProject(async ({ root }) => { + emit( + await handoffRole(root, { + from: args.from, + to: textArg(args.to) ?? "", + task: textArg(args.task), + change: textArg(args.change), + section: textArg(args.section), + model: modelFromEnv(), + }), + ); + }); + }, +}); + +const group = defineCommand({ + meta: { name: "group", description: "List accepted owners for one change" }, + args: { + change: { type: "string", description: "Change id that owns accepted.md", required: true }, + }, + async run({ args }) { + await withProject(async ({ root }) => { + emit(await showRoleGroup(root, textArg(args.change) ?? "")); + }); + }, +}); + +function networkArgs() { + return { + change: { type: "string" as const, description: "Change id that owns accepted.md" }, + section: { type: "string" as const, description: "Section id in accepted.md" }, + task: { type: "string" as const, description: "Task for the lead or the owners" }, + }; +} + +const networkAccept = defineCommand({ + meta: { name: "accept", description: "Accept a pending task-network plan" }, + args: networkArgs(), + async run({ args }) { + await withProject(async ({ root }) => { + emit( + await acceptNetwork(root, { change: textArg(args.change), section: textArg(args.section) }), + ); + }); + }, +}); + +const networkRun = defineCommand({ + meta: { name: "run", description: "Run ready nodes in an accepted plan" }, + args: networkArgs(), + async run({ args }) { + await withProject(async ({ root }) => { + emit( + await runNetwork(root, { + change: textArg(args.change), + section: textArg(args.section), + model: modelFromEnv(), + }), + ); + }); + }, +}); + +const networkRebuild = defineCommand({ + meta: { name: "rebuild", description: "Ask the lead for one new plan" }, + args: networkArgs(), + async run({ args }) { + await withProject(async ({ root }) => { + emit( + await rebuildNetwork(root, { + change: textArg(args.change), + section: textArg(args.section), + model: modelFromEnv(), + }), + ); + }); + }, +}); + +const network = defineCommand({ + meta: { name: "network", description: "Classify a change or store a lead plan" }, + args: networkArgs(), + subCommands: { accept: networkAccept, run: networkRun, rebuild: networkRebuild }, + async run({ args }) { + const next = process.argv[process.argv.indexOf("network") + 1]; + if (next === "accept" || next === "run" || next === "rebuild") return; + await withProject(async ({ root }) => { + emit( + await planNetwork(root, { + change: textArg(args.change), + section: textArg(args.section), + task: textArg(args.task), + model: modelFromEnv(), + }), + ); + }); + }, +}); + const allow = defineCommand({ meta: { name: "allow", description: "Allow once, always allow, or deny a stopped run" }, args: { @@ -212,5 +351,9 @@ export const roleCmd = defineCommand({ delete: named("delete", "Delete one role, its conversation, and its memory", deleteRole), run, allow, + messaging, + handoff, + group, + network, }, }); diff --git a/packages/core/src/__tests__/role-handoff.test.ts b/packages/core/src/__tests__/role-handoff.test.ts new file mode 100644 index 00000000..eeac276f --- /dev/null +++ b/packages/core/src/__tests__/role-handoff.test.ts @@ -0,0 +1,1053 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "pathe"; +import { afterEach, describe, expect, it } from "vitest"; +import { initProject, pathExists } from "../index.js"; +import { + acceptNetwork, + allowRole, + createRole, + handoffRole, + planNetwork, + rebuildNetwork, + runNetwork, + setRoleMessaging, + showRoleGroup, +} from "../role/index.js"; +import type { RoleModel } from "../role/types.js"; + +const temps: string[] = []; +const T0 = "2026-10-19T00:00:00.000Z"; + +afterEach(async () => { + while (temps.length) { + const dir = temps.pop(); + if (dir) await rm(dir, { recursive: true, force: true }); + } +}); + +async function project(): Promise { + const root = await mkdtemp(join(tmpdir(), "sdd-handoff-")); + temps.push(root); + return root; +} + +function clock(seconds = 0): () => Date { + return () => new Date(Date.parse(T0) + seconds * 1000); +} + +function fake(replies: string[]): { + model: RoleModel; + calls: { roleId: string; prompt: string }[]; +} { + const calls: { roleId: string; prompt: string }[] = []; + return { + calls, + model: { + async complete(input) { + calls.push(input); + const next = replies[calls.length - 1]; + if (next === undefined) throw new Error("unexpected model call"); + if (next === "THROW") throw new Error("down"); + return next; + }, + }, + }; +} + +async function role(root: string, name: string): Promise { + const result = await createRole(root, { + name, + label: name, + job: `Job ${name}`, + description: `Description ${name}`, + }); + expect(result.exitCode).toBe(0); +} + +async function accept(root: string, body: string, change = "pay"): Promise { + const dir = join(root, "changes", change); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "accepted.md"), body); +} + +const ONE = `# Spec + +## billing +decision: use-ledger +accepted: yes +owner: billing-builder +service: billing + +The frontend and the backend stay together. +`; + +describe("role handoff", () => { + it("refuses the same role and writes nothing", async () => { + const root = await project(); + await role(root, "builder"); + const model = fake([]); + const result = await handoffRole(root, { + from: "builder", + to: "builder", + task: "draft", + change: "pay", + section: "billing", + model: model.model, + now: clock(), + }); + expect(result).toMatchObject({ exitCode: 1, stdout: "A role cannot hand off to itself." }); + expect(model.calls).toEqual([]); + expect(await pathExists(join(root, ".sdd/role-runs"))).toBe(false); + }); + + it("turns messaging off before any call, then back on", async () => { + const root = await project(); + await role(root, "builder"); + await role(root, "qa"); + await accept(root, ONE.replace("owner: billing-builder\n", "")); + await writeFile(join(root, ".sdd/role.yaml"), "approval_timeout_minutes: 10\n"); + expect((await setRoleMessaging(root, false)).stdout).toBe("Messaging between roles is off."); + const yaml = await readFile(join(root, ".sdd/role.yaml"), "utf8"); + expect(yaml).toContain("approval_timeout_minutes: 10"); + expect(yaml).toContain("role_messaging: false"); + const model = fake([]); + const blocked = await handoffRole(root, { + from: "builder", + to: "qa", + task: "draft", + change: "pay", + section: "billing", + model: model.model, + now: clock(), + }); + expect(blocked).toMatchObject({ exitCode: 0, stdout: "Messaging between roles is off." }); + expect(model.calls).toEqual([]); + expect(await pathExists(join(root, ".sdd/role-runs/conversation"))).toBe(false); + expect((await setRoleMessaging(root, true)).stdout).toBe("Messaging between roles is on."); + }); + + it("writes both logs, calls the receiver once, and keeps the sender summary", async () => { + const root = await project(); + await role(root, "builder"); + await role(root, "billing-builder"); + await accept(root, ONE); + await mkdir(join(root, ".sdd/role-runs/memory/builder"), { recursive: true }); + await writeFile(join(root, ".sdd/role-runs/memory/builder/notes.md"), "secret note\n"); + const model = fake([ + JSON.stringify({ + text: "Checked billing.", + action: { + name: "write_file", + words: "Write the note.", + inputs: { path: "notes/a.md", contents: "x" }, + }, + }), + ]); + const result = await handoffRole(root, { + from: "builder", + to: "billing-builder", + task: "check the ledger", + change: "pay", + section: "billing", + model: model.model, + now: clock(), + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Allow once, Always allow, or Deny."); + expect(model.calls).toHaveLength(1); + expect(model.calls[0]?.roleId).toBe("billing-builder"); + expect(model.calls[0]?.prompt).not.toContain("secret note"); + expect(await pathExists(join(root, "notes/a.md"))).toBe(false); + const sender = await readFile( + join(root, ".sdd/role-runs/conversation/builder/20261019T000000Z.jsonl"), + "utf8", + ); + const receiver = await readFile( + join(root, ".sdd/role-runs/conversation/billing-builder/20261019T000000Z.jsonl"), + "utf8", + ); + const senderLines = sender + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type: string }); + const receiverLines = receiver + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type: string }); + expect(senderLines.map((line) => line.type)).toEqual(["handoff", "handoff_reply"]); + expect(receiverLines.map((line) => line.type)).toEqual(["handoff", "model", "pending"]); + expect(senderLines[0]).toEqual(receiverLines[0]); + expect(sender).toContain("Checked billing."); + expect(sender).not.toContain("Write the note."); + const allowed = await allowRole(root, { + runId: "20261019T000000Z", + mode: "deny", + now: clock(1), + }); + expect(allowed.exitCode).toBe(0); + }); + + it("refuses a receiver who is not the owner", async () => { + const root = await project(); + await role(root, "builder"); + await role(root, "qa"); + await accept(root, ONE); + const result = await handoffRole(root, { + from: "builder", + to: "qa", + task: "draft", + change: "pay", + section: "billing", + model: fake([]).model, + now: clock(), + }); + expect(result).toMatchObject({ exitCode: 1, stdout: "One owner handles this section." }); + }); +}); + +describe("role network", () => { + it("treats frontend and backend prose as one scope", async () => { + const root = await project(); + await accept( + root, + `# Spec + +## app +decision: one-app +accepted: yes + +The frontend talks to the backend. +`, + ); + const result = await planNetwork(root, { change: "pay", section: "app", task: "ship" }); + expect(result).toMatchObject({ exitCode: 0, stdout: "One scope. No lead." }); + expect(await pathExists(join(root, ".sdd/role-runs/plans"))).toBe(false); + }); + + it("names the one service and creates no plan", async () => { + const root = await project(); + await role(root, "billing-builder"); + await accept(root, ONE); + const result = await planNetwork(root, { + change: "pay", + section: "billing", + task: "ship", + model: fake([JSON.stringify({ text: "no" })]).model, + }); + expect(result).toMatchObject({ exitCode: 0, stdout: "Service billing runs billing-builder." }); + expect(await pathExists(join(root, ".sdd/role-runs/plans"))).toBe(false); + }); + + it("hands off an accepted connection without a lead and keeps decisions", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + const body = `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: money +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: money +owner: billing-builder + +## link +decision: D-link +accepted: yes +connection: expenses billing +owner: expenses-builder +owner: billing-builder +`; + await accept(root, body); + const before = await readFile(join(root, "changes/pay/accepted.md"), "utf8"); + const result = await planNetwork(root, { + change: "pay", + section: "billing", + task: "link them", + now: clock(), + }); + expect(result.stdout).toBe( + "Connection hands off. Lead is absent.\nexpenses-builder\nbilling-builder", + ); + expect(await readFile(join(root, "changes/pay/accepted.md"), "utf8")).toBe(before); + const left = await readFile( + join(root, ".sdd/role-runs/conversation/expenses-builder/20261019T000000Z.jsonl"), + "utf8", + ); + const right = await readFile( + join(root, ".sdd/role-runs/conversation/billing-builder/20261019T000000Z.jsonl"), + "utf8", + ); + expect(left.trim()).toBe(right.trim()); + expect(JSON.parse(left)).toMatchObject({ + type: "handoff", + section: "link", + decision: "D-link", + }); + expect(await showRoleGroup(root, "pay")).toMatchObject({ + exitCode: 0, + stdout: "expenses-builder\nbilling-builder", + }); + }); + + it("stores a depth-2 plan and runs a parent only after its child", async () => { + const root = await project(); + await role(root, "money-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: money +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: money +owner: billing-builder + +## lead +decision: D-lead +accepted: yes +module: money +lead: money-lead +`, + ); + await mkdir(join(root, ".sdd/role-runs/memory/expenses-builder"), { recursive: true }); + await writeFile(join(root, ".sdd/role-runs/memory/expenses-builder/notes.md"), "secret note\n"); + const plan = { + text: "Split the work.", + plan: [ + { + scope: "money", + owner: "money-lead", + decision: "D-lead", + after: [], + artifact: "notes/parent.md", + boundary: "stop", + children: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/child.md", + boundary: "ask", + }, + ], + }, + ], + }; + const deep = { + text: "Too deep.", + plan: [ + { + scope: "money", + owner: "money-lead", + decision: "D-lead", + after: [], + artifact: "notes/parent.md", + boundary: "stop", + children: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/child.md", + boundary: "ask", + children: [ + { + scope: "billing", + owner: "billing-builder", + decision: "D-bill", + after: [], + artifact: "notes/grand.md", + boundary: "ask", + }, + ], + }, + ], + }, + ], + }; + const model = fake([JSON.stringify(deep), JSON.stringify(plan)]); + const rejected = await planNetwork(root, { + change: "pay", + section: "expenses", + task: "link money", + model: model.model, + now: clock(), + }); + expect(rejected).toMatchObject({ exitCode: 1, stdout: "A plan is deeper than 2." }); + expect(await pathExists(join(root, ".sdd/role-runs/plans"))).toBe(false); + const stored = await planNetwork(root, { + change: "pay", + section: "expenses", + task: "link money", + model: model.model, + now: clock(), + }); + expect(stored.stdout.startsWith("Accept this plan before anyone writes.")).toBe(true); + expect(model.calls[1]?.prompt).toContain("link money"); + expect(model.calls[1]?.prompt).not.toContain("secret note"); + const early = await runNetwork(root, { + change: "pay", + section: "expenses", + model: model.model, + now: clock(), + }); + expect(early.stdout).toBe("Accept this plan before anyone writes."); + expect((await acceptNetwork(root, { change: "pay", section: "expenses" })).stdout).toBe( + "Plan accepted.", + ); + const ran = await runNetwork(root, { + change: "pay", + section: "expenses", + model: fake([ + JSON.stringify({ + text: "child done", + action: { + name: "write_file", + words: "Write.", + inputs: { path: "notes/child.md", contents: "no" }, + }, + }), + JSON.stringify({ text: "parent done" }), + ]).model, + now: clock(), + }); + expect(ran.stdout).toContain("expenses-builder: child done"); + expect(ran.stdout).toContain("money-lead: parent done"); + expect(ran.stdout).toContain("The parent is done."); + expect(await pathExists(join(root, "notes/child.md"))).toBe(false); + const planFile = await readFile(join(root, ".sdd/role-runs/plans/pay/expenses.json"), "utf8"); + expect(planFile).not.toContain("secret note"); + expect(planFile).not.toContain("transcript"); + const results = (JSON.parse(planFile) as { results: { summary: string }[] }).results; + expect(results.map((item) => item.summary)).toEqual(["child done", "parent done"]); + }); + + it("waits for an unmet prerequisite and refuses a second idle rebuild", async () => { + const root = await project(); + await role(root, "money-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: money +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: money +owner: billing-builder + +## lead +decision: D-lead +accepted: yes +module: money +lead: money-lead +`, + ); + const waiting = { + text: "Wait.", + plan: [ + { + scope: "billing", + owner: "billing-builder", + decision: "D-bill", + after: ["D-exp"], + artifact: "notes/bill.md", + boundary: "stop", + }, + ], + }; + const same = waiting; + const changed = { + text: "Next.", + plan: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/exp.md", + boundary: "ask", + }, + ], + }; + const model = fake([JSON.stringify(waiting)]); + expect( + ( + await planNetwork(root, { + change: "pay", + section: "expenses", + task: "link", + model: model.model, + now: clock(), + }) + ).exitCode, + ).toBe(0); + await acceptNetwork(root, { change: "pay", section: "expenses" }); + const ran = await runNetwork(root, { + change: "pay", + section: "expenses", + model: fake([]).model, + now: clock(), + }); + expect(ran.stdout).toContain("A branch is waiting for its prerequisites."); + expect(ran.stdout).toContain("The parent stays open."); + expect(model.calls).toHaveLength(1); + const idle = await rebuildNetwork(root, { + change: "pay", + section: "expenses", + model: fake([JSON.stringify(same)]).model, + now: clock(5), + }); + expect(idle.stdout).toBe("Stopped. The plan made no progress."); + const blocked = await rebuildNetwork(root, { + change: "pay", + section: "expenses", + model: fake([JSON.stringify(changed)]).model, + now: clock(6), + }); + expect(blocked).toMatchObject({ exitCode: 1, stdout: "A second rebuild does not start." }); + const kept = JSON.parse( + await readFile(join(root, ".sdd/role-runs/plans/pay/expenses.json"), "utf8"), + ) as { + nodes: { artifact: string }[]; + stalled: boolean; + }; + expect(kept.stalled).toBe(true); + expect(kept.nodes[0]?.artifact).toBe("notes/bill.md"); + }); + + it("uses a product lead when services sit in two modules", async () => { + const root = await project(); + await role(root, "product-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: spend +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: invoice +owner: billing-builder + +## product +decision: D-product +accepted: yes +scope: product +lead: product-lead +`, + ); + const model = fake([ + JSON.stringify({ + text: "Plan it.", + plan: [ + { + scope: "spend", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/spend.md", + boundary: "stop", + }, + ], + }), + ]); + const stored = await planNetwork(root, { + change: "pay", + section: "expenses", + task: "both", + model: model.model, + now: clock(), + }); + expect(stored.stdout.startsWith("Accept this plan before anyone writes.")).toBe(true); + expect(model.calls[0]?.roleId).toBe("product-lead"); + }); + + it("does not call a lead when two services have no module", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +owner: billing-builder +`, + ); + const model = fake([JSON.stringify({ text: "no" })]); + const result = await planNetwork(root, { + change: "pay", + section: "expenses", + task: "both", + model: model.model, + }); + expect(result).toMatchObject({ exitCode: 0, stdout: "One scope. No lead." }); + expect(model.calls).toEqual([]); + }); + + it("reads accepted.md from changes_path", async () => { + const root = await project(); + await initProject({ projectRoot: root, agents: false, layoutRoot: ".sdd" }); + await role(root, "billing-builder"); + const dir = join(root, ".sdd/changes/pay"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "accepted.md"), ONE); + await mkdir(join(root, "changes/pay"), { recursive: true }); + await writeFile(join(root, "changes/pay/accepted.md"), ONE.replace("billing-builder", "other")); + const result = await planNetwork(root, { change: "pay", section: "billing", task: "ship" }); + expect(result.stdout).toBe("Service billing runs billing-builder."); + }); + + it("lists each connection owner once", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## link +decision: D-link +accepted: yes +connection: expenses billing +owner: expenses-builder +owner: billing-builder +owner: billing-builder +`, + ); + const result = await planNetwork(root, { + change: "pay", + section: "link", + task: "link them", + now: clock(), + }); + expect(result.stdout).toBe( + "Connection hands off. Lead is absent.\nexpenses-builder\nbilling-builder", + ); + const right = await readFile( + join(root, ".sdd/role-runs/conversation/billing-builder/20261019T000000Z.jsonl"), + "utf8", + ); + expect(right.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(right)).toMatchObject({ from: "expenses-builder", to: "billing-builder" }); + }); + + it("matches a connection on a later service id", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## wide +decision: D-wide +accepted: yes +service: other +service: billing +owner: billing-builder + +## link +decision: D-link +accepted: yes +connection: expenses billing +owner: expenses-builder +owner: billing-builder +`, + ); + const model = fake([JSON.stringify({ text: "no" })]); + const result = await planNetwork(root, { + change: "pay", + section: "wide", + task: "link them", + model: model.model, + now: clock(), + }); + expect(result.stdout).toBe( + "Connection hands off. Lead is absent.\nexpenses-builder\nbilling-builder", + ); + expect(model.calls).toEqual([]); + }); + + it("asks the product lead when another service has no module", async () => { + const root = await project(); + await role(root, "product-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await role(root, "audit-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: spend +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: invoice +owner: billing-builder + +## audit +decision: D-audit +accepted: yes +service: audit +owner: audit-builder + +## product +decision: D-product +accepted: yes +scope: product +lead: product-lead +`, + ); + const model = fake([ + JSON.stringify({ + text: "Plan it.", + plan: [ + { + scope: "spend", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/spend.md", + boundary: "stop", + }, + ], + }), + ]); + const stored = await planNetwork(root, { + change: "pay", + section: "expenses", + task: "both", + model: model.model, + now: clock(), + }); + expect(stored.stdout.startsWith("Accept this plan before anyone writes.")).toBe(true); + expect(model.calls[0]?.roleId).toBe("product-lead"); + }); + + it("names a missing product lead on rebuild", async () => { + const root = await project(); + await role(root, "product-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + const body = `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: spend +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: invoice +owner: billing-builder + +## product +decision: D-product +accepted: yes +scope: product +lead: product-lead +`; + await accept(root, body); + const model = fake([ + JSON.stringify({ + text: "Plan it.", + plan: [ + { + scope: "spend", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/spend.md", + boundary: "stop", + }, + ], + }), + ]); + expect( + ( + await planNetwork(root, { + change: "pay", + section: "expenses", + task: "both", + model: model.model, + now: clock(), + }) + ).exitCode, + ).toBe(0); + await accept(root, body.replace("lead: product-lead\n", "")); + const again = await rebuildNetwork(root, { + change: "pay", + section: "expenses", + model: model.model, + now: clock(5), + }); + expect(again).toMatchObject({ exitCode: 1, stdout: "This product needs a lead." }); + expect(model.calls).toHaveLength(1); + }); + + it("treats the same artifact ids in a new order as no progress", async () => { + const root = await project(); + await role(root, "money-lead"); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + await accept( + root, + `# Spec + +## expenses +decision: D-exp +accepted: yes +service: expenses +module: money +owner: expenses-builder + +## billing +decision: D-bill +accepted: yes +service: billing +module: money +owner: billing-builder + +## lead +decision: D-lead +accepted: yes +module: money +lead: money-lead +`, + ); + const original = { + text: "Order.", + plan: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/a.md", + boundary: "ask", + }, + { + scope: "billing", + owner: "billing-builder", + decision: "D-bill", + after: [], + artifact: "notes/b.md", + boundary: "stop", + }, + ], + }; + const swapped = { + text: "Swapped.", + plan: [original.plan[1], original.plan[0]], + }; + const model = fake([JSON.stringify(original), JSON.stringify(swapped)]); + expect( + ( + await planNetwork(root, { + change: "pay", + section: "expenses", + task: "link", + model: model.model, + now: clock(), + }) + ).exitCode, + ).toBe(0); + const idle = await rebuildNetwork(root, { + change: "pay", + section: "expenses", + model: model.model, + now: clock(5), + }); + expect(idle.stdout).toBe("Stopped. The plan made no progress."); + const kept = JSON.parse( + await readFile(join(root, ".sdd/role-runs/plans/pay/expenses.json"), "utf8"), + ) as { status: string; stalled: boolean; nodes: { artifact: string }[] }; + expect(kept.status).toBe("pending"); + expect(kept.stalled).toBe(true); + expect(kept.nodes.map((node) => node.artifact)).toEqual(["notes/a.md", "notes/b.md"]); + }); + + it("calls each ready node once and keeps a result when a later node fails", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "side-builder"); + await role(root, "billing-builder"); + const plan = { + status: "accepted", + rebuilds: 0, + stalled: false, + task: "ship", + nodes: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/exp.md", + boundary: "stop", + children: [], + }, + { + scope: "expenses", + owner: "side-builder", + decision: "D-side", + after: [], + artifact: "notes/side.md", + boundary: "stop", + children: [], + }, + { + scope: "billing", + owner: "billing-builder", + decision: "D-bill", + after: ["D-exp"], + artifact: "notes/bill.md", + boundary: "stop", + children: [], + }, + ], + results: [], + }; + const dir = join(root, ".sdd/role-runs/plans/pay"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "expenses.json"), `${JSON.stringify(plan)}\n`); + const model = fake([ + JSON.stringify({ text: "exp done" }), + "not-json", + JSON.stringify({ text: "bill done" }), + ]); + const ran = await runNetwork(root, { + change: "pay", + section: "expenses", + model: model.model, + now: clock(), + }); + expect(model.calls.map((call) => call.roleId)).toEqual([ + "expenses-builder", + "side-builder", + "billing-builder", + ]); + expect(ran.stdout).toContain("expenses-builder: exp done"); + expect(ran.stdout).toContain("billing-builder: bill done"); + expect(ran.stdout).toContain("A branch is waiting for its prerequisites."); + expect(ran.stdout).toContain("The parent stays open."); + }); + + it("keeps earlier run results when a later owner is missing", async () => { + const root = await project(); + await role(root, "expenses-builder"); + await role(root, "billing-builder"); + const plan = { + status: "accepted", + rebuilds: 0, + stalled: false, + task: "ship", + nodes: [ + { + scope: "expenses", + owner: "expenses-builder", + decision: "D-exp", + after: [], + artifact: "notes/exp.md", + boundary: "stop", + children: [], + }, + { + scope: "billing", + owner: "billing-builder", + decision: "D-bill", + after: [], + artifact: "notes/bill.md", + boundary: "stop", + children: [], + }, + ], + results: [], + }; + const dir = join(root, ".sdd/role-runs/plans/pay"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "expenses.json"), `${JSON.stringify(plan)}\n`); + await rm(join(root, ".sdd/roles/billing-builder.md")); + const model = fake([JSON.stringify({ text: "exp done" })]); + const lost = await runNetwork(root, { + change: "pay", + section: "expenses", + model: model.model, + now: clock(), + }); + expect(lost).toMatchObject({ exitCode: 1, stdout: "Role not found." }); + expect(model.calls.map((call) => call.roleId)).toEqual(["expenses-builder"]); + const kept = JSON.parse(await readFile(join(dir, "expenses.json"), "utf8")) as { + results: { summary: string }[]; + }; + expect(kept.results.map((item) => item.summary)).toEqual(["exp done"]); + const again = await runNetwork(root, { + change: "pay", + section: "expenses", + model: fake([]).model, + now: clock(5), + }); + expect(again).toMatchObject({ exitCode: 1, stdout: "Role not found." }); + }); +}); diff --git a/packages/core/src/role/handoff.ts b/packages/core/src/role/handoff.ts new file mode 100644 index 00000000..bc09af0f --- /dev/null +++ b/packages/core/src/role/handoff.ts @@ -0,0 +1,790 @@ +import { join } from "pathe"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { pathExists, readText, writeText } from "../fs.js"; +import { canonicalRoleName, conversationDir, readRole } from "./profile.js"; +import { + acceptedFile, + appendJson, + isSafeProductPath, + parseReply, + parseSection, + runIdFrom, + settleRoleReply, + timeoutMinutes, +} from "./run.js"; +import { ROLE_TEXT, type RoleCommandResult, type RoleModel, type RoleProfile } from "./types.js"; + +const ID = /^[a-z][a-z0-9-]{0,39}$/; +const ROLE_YAML = ["role.yaml"]; + +interface SectionFacts { + id: string; + decision: string; + body: string; + services: string[]; + modules: string[]; + owners: string[]; + leads: string[]; + connection: [string, string] | null; + product: boolean; +} + +interface PlanNode { + scope: string; + owner: string; + decision: string; + after: string[]; + artifact: string; + boundary: "ask" | "stop"; + children: PlanNode[]; +} + +interface PlanFile { + status: "pending" | "accepted" | "open" | "done"; + rebuilds: number; + stalled: boolean; + task: string; + nodes: PlanNode[]; + results: { decision: string; artifact: string; summary: string }[]; +} + +interface PlanContext { + services: Set; + modules: Set; + decisions: Set; +} + +function ok(stdout = "", runId?: string): RoleCommandResult { + return runId ? { exitCode: 0, stdout, runId } : { exitCode: 0, stdout }; +} + +function fail(stdout: string, runId?: string): RoleCommandResult { + return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout }; +} + +function mentionsX(task: string): boolean { + return /@X|x\.com|twitter\.com/.test(task); +} + +function safeChangeId(id: string): boolean { + if (!id || id.length > 200 || id === "." || id === "..") return false; + return !id.includes("/") && !id.includes("\\") && !id.includes("\0"); +} + +function yamlPath(root: string): string { + return join(root, ".sdd", ...ROLE_YAML); +} + +function planPath(root: string, changeId: string, sectionId: string): string { + return join(root, ".sdd", "role-runs", "plans", changeId, `${sectionId}.json`); +} + +async function loadYaml(root: string): Promise | "missing" | "invalid"> { + const path = yamlPath(root); + if (!(await pathExists(path))) return "missing"; + let data: unknown; + try { + data = parseYaml(await readText(path)); + } catch { + return "invalid"; + } + if (data == null) return {}; + if (typeof data !== "object" || Array.isArray(data)) return "invalid"; + const rec = data as Record; + if ("approval_timeout_minutes" in rec && rec.approval_timeout_minutes != null) { + const minutes = rec.approval_timeout_minutes; + if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes < 0) return "invalid"; + } + if ( + "role_messaging" in rec && + rec.role_messaging != null && + typeof rec.role_messaging !== "boolean" + ) { + return "invalid"; + } + return rec; +} + +async function messagingOn(root: string): Promise { + const loaded = await loadYaml(root); + if (loaded === "invalid") return "invalid"; + if (loaded === "missing") return true; + return loaded.role_messaging !== false; +} + +export async function setRoleMessaging(root: string, enabled: boolean): Promise { + const loaded = await loadYaml(root); + if (loaded === "invalid") return fail(ROLE_TEXT.badYaml); + const raw = loaded === "missing" ? {} : loaded; + raw.role_messaging = enabled; + await writeText(yamlPath(root), stringifyYaml(raw)); + return ok(enabled ? "Messaging between roles is on." : "Messaging between roles is off."); +} + +function field(body: string, prefix: string): string[] { + const found: string[] = []; + for (const row of body.split(/\r?\n/)) { + const trimmed = row.trim(); + if (!trimmed.startsWith(prefix)) continue; + const value = trimmed.slice(prefix.length).trim(); + if (value) found.push(value); + } + return found; +} + +function unique(items: string[]): string[] { + const seen = new Set(); + const found: string[] = []; + for (const item of items) { + if (seen.has(item)) continue; + seen.add(item); + found.push(item); + } + return found; +} + +function factsFrom(markdown: string): SectionFacts[] { + const ids: string[] = []; + for (const row of markdown.split(/\r?\n/)) { + if (row.startsWith("## ")) { + const id = row.slice(3).trim(); + if (id) ids.push(id); + } + } + const facts: SectionFacts[] = []; + for (const id of ids) { + const section = parseSection(markdown, id); + if (!section) continue; + const services = field(section.body, "service: ").filter((item) => ID.test(item)); + const modules = field(section.body, "module: ").filter((item) => ID.test(item)); + const owners = field(section.body, "owner: ").filter( + (item) => canonicalRoleName(item) === item, + ); + const leads = field(section.body, "lead: ").filter((item) => canonicalRoleName(item) === item); + const connection = connectionOf(section.body); + facts.push({ + id: section.id, + decision: section.decision, + body: section.body, + services, + modules, + owners, + leads, + connection, + product: field(section.body, "scope: ").includes("product"), + }); + } + return facts; +} + +function connectionOf(body: string): [string, string] | null { + for (const value of field(body, "connection: ")) { + const parts = value.split(/\s+/); + if (parts.length === 2 && parts[0] && parts[1] && ID.test(parts[0]) && ID.test(parts[1])) { + return [parts[0], parts[1]]; + } + } + return null; +} + +async function acceptedFacts( + root: string, + changeId: string, + sectionId: string, +): Promise<{ markdown: string; current: SectionFacts; all: SectionFacts[] } | null> { + if (!safeChangeId(changeId) || !sectionId) return null; + const path = await acceptedFile(root, changeId); + if (!(await pathExists(path))) return null; + const markdown = await readText(path); + const all = factsFrom(markdown); + const current = all.find((item) => item.id === sectionId); + if (!current) return null; + return { markdown, current, all }; +} + +function handoffRecord( + from: string, + to: string, + changeId: string, + section: SectionFacts, +): Record { + return { + type: "handoff", + from, + to, + change: changeId, + section: section.id, + decision: section.decision, + }; +} + +function receiverPrompt(role: RoleProfile, task: string, section: SectionFacts): string { + return [ + `Job: ${role.job}`, + `Description: ${role.description}`, + `Task: ${task}`, + "Kind: handoff", + `Section: ${section.id}`, + `Decision: ${section.decision}`, + "Section text:", + section.body, + ].join("\n"); +} + +export async function handoffRole( + root: string, + input: { + from: string; + to: string; + task?: string; + change?: string; + section?: string; + model?: RoleModel; + now?: () => Date; + }, +): Promise { + const from = canonicalRoleName(input.from); + const to = canonicalRoleName(input.to); + if (!from || !to) return fail(ROLE_TEXT.badName); + if (from === to) return fail("A role cannot hand off to itself."); + const sender = await readRole(root, from); + const receiver = await readRole(root, to); + if (!sender || !receiver) return fail(ROLE_TEXT.notFound); + const task = input.task ?? ""; + if (mentionsX(task)) return fail(ROLE_TEXT.x); + const loaded = await acceptedFacts(root, input.change?.trim() ?? "", input.section?.trim() ?? ""); + if (!loaded) return fail(ROLE_TEXT.needsSection); + if (loaded.current.owners.length > 0 && !loaded.current.owners.includes(to)) { + return fail("One owner handles this section."); + } + const messaging = await messagingOn(root); + if (messaging === false) return ok("Messaging between roles is off."); + if (!input.model) return fail(ROLE_TEXT.noModel); + if (messaging === "invalid") return fail(ROLE_TEXT.badYaml); + + const now = (input.now ?? (() => new Date()))(); + const runId = runIdFrom(now); + const senderFile = join(conversationDir(root, from), `${runId}.jsonl`); + const receiverFile = join(conversationDir(root, to), `${runId}.jsonl`); + const record = handoffRecord(from, to, input.change?.trim() ?? "", loaded.current); + await appendJson(senderFile, record); + await appendJson(receiverFile, record); + let raw: string; + try { + raw = await input.model.complete({ + roleId: to, + prompt: receiverPrompt(receiver, task, loaded.current), + }); + } catch { + await appendJson(receiverFile, { type: "bad_call", roleId: to }); + return fail("", runId); + } + const parsed = parseReply(raw); + if (!parsed) { + await appendJson(receiverFile, { type: "bad_call", roleId: to }); + return fail("", runId); + } + await appendJson(receiverFile, { type: "model", pass: 1, roleId: to, text: parsed.text }); + const settled = await settleRoleReply(root, receiverFile, receiver, parsed, now, runId); + await appendJson(senderFile, { + type: "handoff_reply", + from: to, + to: from, + change: record.change, + section: record.section, + decision: record.decision, + summary: parsed.text, + }); + return settled; +} + +export async function showRoleGroup(root: string, changeId: string): Promise { + const path = safeChangeId(changeId) ? await acceptedFile(root, changeId) : ""; + if (!path || !(await pathExists(path))) return ok("No roles in this group."); + const seen = new Set(); + const owners: string[] = []; + for (const section of factsFrom(await readText(path))) { + for (const owner of section.owners) { + if (seen.has(owner)) continue; + seen.add(owner); + owners.push(owner); + } + } + return ok(owners.length > 0 ? owners.join("\n") : "No roles in this group."); +} + +function distinctServices(all: SectionFacts[]): Map { + const services = new Map(); + for (const section of all) { + for (const service of section.services) { + if (!services.has(service)) services.set(service, section); + } + } + return services; +} + +function nodeLine(node: PlanNode): string { + const after = node.after.length > 0 ? node.after.join(",") : "none"; + return `${node.scope} ${node.owner} ${node.decision} after ${after} artifact ${node.artifact} boundary ${node.boundary}`; +} + +function planLines(nodes: PlanNode[]): string[] { + const lines: string[] = []; + for (const node of nodes) { + lines.push(nodeLine(node)); + lines.push(...planLines(node.children)); + } + return lines; +} + +function artifactsOf(nodes: PlanNode[]): string[] { + const artifacts: string[] = []; + for (const node of nodes) { + artifacts.push(node.artifact); + artifacts.push(...artifactsOf(node.children)); + } + return artifacts; +} + +async function readPlan( + root: string, + changeId: string, + sectionId: string, +): Promise { + const path = planPath(root, changeId, sectionId); + if (!(await pathExists(path))) return null; + try { + const data = JSON.parse(await readText(path)) as PlanFile; + if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.results)) return null; + return data; + } catch { + return null; + } +} + +async function writePlan( + root: string, + changeId: string, + sectionId: string, + plan: PlanFile, +): Promise { + await writeText(planPath(root, changeId, sectionId), `${JSON.stringify(plan, null, 2)}\n`); +} + +function contextOf(all: SectionFacts[]): PlanContext { + const services = new Set(); + const modules = new Set(); + const decisions = new Set(); + for (const section of all) { + for (const service of section.services) services.add(service); + for (const moduleId of section.modules) modules.add(moduleId); + decisions.add(section.decision); + } + return { services, modules, decisions }; +} + +async function parsePlan( + root: string, + raw: string, + ctx: PlanContext, +): Promise { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return "invalid"; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid"; + const rec = value as Record; + if (typeof rec.text !== "string" || !Array.isArray(rec.plan)) return "invalid"; + const seen = new Set(); + const nodes = await parseNodes(root, rec.plan, ctx, seen, true); + return nodes; +} + +async function parseNodes( + root: string, + value: unknown, + ctx: PlanContext, + seen: Set, + roots: boolean, +): Promise { + if (!Array.isArray(value)) return "invalid"; + const nodes: PlanNode[] = []; + for (const item of value) { + const parsed = await parseNode(root, item, ctx, seen); + if (parsed === "invalid" || parsed === "deep") return parsed; + if (!roots && parsed.children.length > 0) return "deep"; + for (const child of parsed.children) { + if (child.children.length > 0) return "deep"; + } + nodes.push(parsed); + } + return nodes; +} + +async function parseNode( + root: string, + value: unknown, + ctx: PlanContext, + seen: Set, +): Promise { + if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid"; + const rec = value as Record; + const scope = typeof rec.scope === "string" ? rec.scope : ""; + const owner = typeof rec.owner === "string" ? rec.owner : ""; + const decision = typeof rec.decision === "string" ? rec.decision : ""; + const artifact = typeof rec.artifact === "string" ? rec.artifact : ""; + const boundary = rec.boundary; + if (!ctx.services.has(scope) && !ctx.modules.has(scope)) return "invalid"; + if (!(await readRole(root, owner))) return "invalid"; + if (!ctx.decisions.has(decision) || seen.has(decision)) return "invalid"; + if (!Array.isArray(rec.after) || rec.after.some((id) => typeof id !== "string")) return "invalid"; + if ((rec.after as string[]).some((id) => !ctx.decisions.has(id))) return "invalid"; + if (!isSafeProductPath(artifact)) return "invalid"; + if (boundary !== "ask" && boundary !== "stop") return "invalid"; + seen.add(decision); + let children: PlanNode[] = []; + if (rec.children !== undefined) { + const parsed = await parseNodes(root, rec.children, ctx, seen, false); + if (parsed === "invalid" || parsed === "deep") return parsed; + children = parsed; + } + return { + scope, + owner, + decision, + after: rec.after as string[], + artifact, + boundary, + children, + }; +} + +function leadPrompt(task: string, services: Map): string { + const rows = [`Task: ${task}`]; + for (const [service, section] of services) { + const moduleId = section.modules[0] ?? "none"; + const owner = section.owners[0] ?? "none"; + rows.push(`${service} module ${moduleId} owner ${owner} decision ${section.decision}`); + } + return rows.join("\n"); +} + +async function connectionResult( + root: string, + changeId: string, + section: SectionFacts, + now: Date, +): Promise { + const messaging = await messagingOn(root); + if (messaging === "invalid") return fail(ROLE_TEXT.badYaml); + if (!messaging) return ok("Messaging between roles is off."); + const owners = unique(section.owners); + const runId = runIdFrom(now); + for (let i = 0; i < owners.length - 1; i++) { + const from = owners[i] ?? ""; + const to = owners[i + 1] ?? ""; + const record = handoffRecord(from, to, changeId, section); + await appendJson(join(conversationDir(root, from), `${runId}.jsonl`), record); + await appendJson(join(conversationDir(root, to), `${runId}.jsonl`), record); + } + const lines = ["Connection hands off. Lead is absent.", ...owners]; + return ok(lines.join("\n")); +} + +function freshPlan(task: string, nodes: PlanNode[]): PlanFile { + return { status: "pending", rebuilds: 0, stalled: false, task, nodes, results: [] }; +} + +export async function planNetwork( + root: string, + input: { change?: string; section?: string; task?: string; model?: RoleModel; now?: () => Date }, +): Promise { + const changeId = input.change?.trim() ?? ""; + const sectionId = input.section?.trim() ?? ""; + const loaded = await acceptedFacts(root, changeId, sectionId); + if (!loaded) return fail(ROLE_TEXT.needsSection); + const task = input.task ?? ""; + const now = (input.now ?? (() => new Date()))(); + const connection = loaded.all.find( + (item) => + item.connection && + (item.id === loaded.current.id || + loaded.current.services.some((service) => item.connection?.includes(service))), + ); + if (connection?.connection) return connectionResult(root, changeId, connection, now); + + const services = distinctServices(loaded.all); + if (services.size === 0) return ok("One scope. No lead."); + if (services.size === 1) { + const entry = [...services.entries()][0]; + if (!entry) return ok("One scope. No lead."); + const [name, section] = entry; + const owner = section.owners[0]; + if (!owner) return fail("This service needs an owner."); + if (!(await readRole(root, owner))) return fail(ROLE_TEXT.notFound); + return ok(`Service ${name} runs ${owner}.`); + } + + const picked = pickLead(loaded.all, services); + if (picked.kind === "none") return ok("One scope. No lead."); + if (picked.kind === "missing") return fail(picked.sentence); + const lead = await readRole(root, picked.name); + if (!lead) return fail(ROLE_TEXT.notFound); + if ((await messagingOn(root)) === "invalid") return fail(ROLE_TEXT.badYaml); + if (!input.model) return fail(ROLE_TEXT.noModel); + return storeLeadPlan( + root, + changeId, + sectionId, + task, + lead, + services, + loaded.all, + input.model, + null, + ); +} + +async function storeLeadPlan( + root: string, + changeId: string, + sectionId: string, + task: string, + lead: RoleProfile, + services: Map, + all: SectionFacts[], + model: RoleModel, + previous: PlanFile | null, +): Promise { + let raw: string; + try { + raw = await model.complete({ roleId: lead.name, prompt: leadPrompt(task, services) }); + } catch { + return fail(""); + } + const parsed = await parsePlan(root, raw, contextOf(all)); + if (previous && (parsed === "invalid" || parsed === "deep")) return fail(planFailure(parsed)); + if (parsed === "invalid") return fail("The plan is not valid."); + if (parsed === "deep") return fail("A plan is deeper than 2."); + if (previous && sameArtifacts(previous.nodes, parsed)) { + previous.stalled = true; + previous.rebuilds += 1; + await writePlan(root, changeId, sectionId, previous); + return ok("Stopped. The plan made no progress."); + } + const next = previous + ? { + ...previous, + status: "pending" as const, + stalled: false, + rebuilds: previous.rebuilds + 1, + nodes: parsed, + results: [], + } + : freshPlan(task, parsed); + await writePlan(root, changeId, sectionId, next); + return ok(["Accept this plan before anyone writes.", ...planLines(parsed)].join("\n")); +} + +function planFailure(parsed: "invalid" | "deep"): string { + return parsed === "deep" ? "A plan is deeper than 2." : "The plan is not valid."; +} + +function sameArtifacts(left: PlanNode[], right: PlanNode[]): boolean { + const a = artifactsOf(left).slice().sort(); + const b = artifactsOf(right).slice().sort(); + return a.length === b.length && a.every((item, index) => item === b[index]); +} + +export async function acceptNetwork( + root: string, + input: { change?: string; section?: string }, +): Promise { + const changeId = input.change?.trim() ?? ""; + const sectionId = input.section?.trim() ?? ""; + if (!safeChangeId(changeId) || !sectionId) return fail("No plan is stored."); + const plan = await readPlan(root, changeId, sectionId); + if (!plan) return fail("No plan is stored."); + if (plan.status !== "pending") return fail("The plan is not waiting for acceptance."); + plan.status = "accepted"; + await writePlan(root, changeId, sectionId, plan); + return ok("Plan accepted."); +} + +function walk(nodes: PlanNode[]): PlanNode[] { + const flat: PlanNode[] = []; + for (const node of nodes) { + flat.push(node); + flat.push(...walk(node.children)); + } + return flat; +} + +function ready(node: PlanNode, results: PlanFile["results"]): boolean { + if (results.some((item) => item.decision === node.decision)) return false; + const afterOk = node.after.every((id) => results.some((item) => item.decision === id)); + const childrenOk = node.children.every((child) => + results.some((item) => item.decision === child.decision), + ); + return afterOk && childrenOk; +} + +export async function runNetwork( + root: string, + input: { change?: string; section?: string; model?: RoleModel; now?: () => Date }, +): Promise { + const changeId = input.change?.trim() ?? ""; + const sectionId = input.section?.trim() ?? ""; + if (!safeChangeId(changeId) || !sectionId) return fail("No plan is stored."); + const plan = await readPlan(root, changeId, sectionId); + if (!plan) return fail("No plan is stored."); + if (plan.status === "pending") return ok("Accept this plan before anyone writes."); + if (plan.status === "done") return ok("The parent is done."); + if (plan.status !== "accepted" && plan.status !== "open") return fail("No plan is stored."); + if ((await messagingOn(root)) === "invalid") return fail(ROLE_TEXT.badYaml); + const minutes = await timeoutMinutes(root); + if (minutes === "invalid") return fail(ROLE_TEXT.badYaml); + if (!input.model) return fail(ROLE_TEXT.noModel); + const now = (input.now ?? (() => new Date()))(); + const summaries: string[] = []; + const called = new Set(); + let index = 0; + let progressed = true; + let missingOwner = false; + while (progressed && !missingOwner) { + progressed = false; + for (const node of walk(plan.nodes)) { + if (!ready(node, plan.results) || called.has(node.decision)) continue; + called.add(node.decision); + const owner = await readRole(root, node.owner); + if (!owner) { + missingOwner = true; + break; + } + const when = new Date(now.getTime() + index * 1000); + index += 1; + const runId = runIdFrom(when); + const file = join(conversationDir(root, owner.name), `${runId}.jsonl`); + let raw: string; + try { + raw = await input.model.complete({ + roleId: owner.name, + prompt: [ + `Job: ${owner.job}`, + `Description: ${owner.description}`, + `Task: ${plan.task}`, + `Artifact: ${node.artifact}`, + `Boundary: ${node.boundary}`, + ].join("\n"), + }); + } catch { + continue; + } + const parsed = parseReply(raw); + if (!parsed) continue; + await appendJson(file, { type: "model", pass: 1, roleId: owner.name, text: parsed.text }); + if (parsed.action) { + const expiresAt = new Date(when.getTime() + minutes * 60_000).toISOString(); + await appendJson(file, { + type: "pending", + action: { + name: parsed.action.name, + words: parsed.action.words, + inputs: parsed.action.inputs, + }, + expiresAt, + }); + } + plan.results.push({ + decision: node.decision, + artifact: node.artifact, + summary: parsed.text, + }); + summaries.push(`${owner.name}: ${parsed.text}`); + progressed = true; + } + } + const all = walk(plan.nodes); + const waiting = all.some((node) => !plan.results.some((item) => item.decision === node.decision)); + plan.status = waiting ? "open" : "done"; + await writePlan(root, changeId, sectionId, plan); + if (missingOwner) return fail(ROLE_TEXT.notFound); + const lines = [...summaries]; + if (waiting) { + lines.push("A branch is waiting for its prerequisites."); + lines.push("The parent stays open."); + } else { + lines.push("The parent is done."); + } + return ok(lines.join("\n")); +} + +export async function rebuildNetwork( + root: string, + input: { change?: string; section?: string; model?: RoleModel; now?: () => Date }, +): Promise { + const changeId = input.change?.trim() ?? ""; + const sectionId = input.section?.trim() ?? ""; + if (!safeChangeId(changeId) || !sectionId) return fail("No plan is stored."); + const plan = await readPlan(root, changeId, sectionId); + if (!plan) return fail("No plan is stored."); + const allowed = + plan.rebuilds === 0 || (plan.rebuilds === 1 && !plan.stalled && plan.status === "accepted"); + if (!allowed) return fail("A second rebuild does not start."); + const loaded = await acceptedFacts(root, changeId, sectionId); + if (!loaded) return fail(ROLE_TEXT.needsSection); + if (!input.model) return fail(ROLE_TEXT.noModel); + const services = distinctServices(loaded.all); + const picked = pickLead(loaded.all, services); + if (picked.kind === "missing") return fail(picked.sentence); + if (picked.kind !== "lead") return fail("This module needs a lead."); + const lead = await readRole(root, picked.name); + if (!lead) return fail(ROLE_TEXT.notFound); + const before = JSON.stringify(plan); + const result = await storeLeadPlan( + root, + changeId, + sectionId, + plan.task, + lead, + services, + loaded.all, + input.model, + plan, + ); + if (result.exitCode !== 0) { + const current = await readPlan(root, changeId, sectionId); + if (current && JSON.stringify(current) !== before) { + await writePlan(root, changeId, sectionId, JSON.parse(before) as PlanFile); + } + } + return result; +} + +function pickLead( + all: SectionFacts[], + services: Map, +): { kind: "lead"; name: string } | { kind: "missing"; sentence: string } | { kind: "none" } { + const modules = new Set(); + let missingModule = false; + for (const section of services.values()) { + const moduleId = section.modules[0]; + if (!moduleId) missingModule = true; + else modules.add(moduleId); + } + const moduleId = [...modules][0]; + if (!missingModule && modules.size === 1 && moduleId && services.size > 1) { + const name = + all.find((item) => item.modules.includes(moduleId) && item.leads[0])?.leads[0] ?? ""; + if (!name) return { kind: "missing", sentence: "This module needs a lead." }; + return { kind: "lead", name }; + } + if (modules.size > 1) { + const name = all.find((item) => item.product && item.leads[0])?.leads[0] ?? ""; + if (!name) return { kind: "missing", sentence: "This product needs a lead." }; + return { kind: "lead", name }; + } + return { kind: "none" }; +} diff --git a/packages/core/src/role/index.ts b/packages/core/src/role/index.ts index e2bb6190..bd7d9968 100644 --- a/packages/core/src/role/index.ts +++ b/packages/core/src/role/index.ts @@ -1,3 +1,12 @@ +export { + acceptNetwork, + handoffRole, + planNetwork, + rebuildNetwork, + runNetwork, + setRoleMessaging, + showRoleGroup, +} from "./handoff.js"; export { allowRole, runRole } from "./run.js"; export type { AllowRoleInput, RunRoleInput } from "./run.js"; export { diff --git a/packages/core/src/role/run.ts b/packages/core/src/role/run.ts index b4e4f2da..5bc33710 100644 --- a/packages/core/src/role/run.ts +++ b/packages/core/src/role/run.ts @@ -146,7 +146,7 @@ function sameCiteSet(changed: string[], cites: string[]): boolean { return left.length === right.length && left.every((cite, index) => cite === right[index]); } -function parseSection(markdown: string, sectionId: string): AcceptedSection | null { +export function parseSection(markdown: string, sectionId: string): AcceptedSection | null { const rows = markdown.split(/\r?\n/); let start = -1; for (let i = 0; i < rows.length; i++) { @@ -182,7 +182,7 @@ function parseSection(markdown: string, sectionId: string): AcceptedSection | nu return { id: sectionId, decision, checks, body: body.join("\n") }; } -function parseReply(raw: string): ModelReply | null { +export function parseReply(raw: string): ModelReply | null { let value: unknown; try { value = JSON.parse(raw); @@ -232,7 +232,7 @@ function parseReply(raw: string): ModelReply | null { return { text: rec.text, gaps, changed, action }; } -async function appendJson(file: string, value: unknown): Promise { +export async function appendJson(file: string, value: unknown): Promise { await ensureDir(join(file, "..")); await appendFile(file, `${JSON.stringify(value)}\n`, "utf8"); } @@ -251,7 +251,7 @@ async function readJsonLines(file: string): Promise { return lines; } -async function timeoutMinutes(root: string): Promise { +export async function timeoutMinutes(root: string): Promise { const path = join(root, ".sdd", "role.yaml"); if (!(await pathExists(path))) return 10; let data: unknown; @@ -323,7 +323,7 @@ function revisionPrompt( ].join("\n"); } -async function acceptedFile(root: string, changeId: string): Promise { +export async function acceptedFile(root: string, changeId: string): Promise { if (!(await pathExists(configPath(root)))) return join(root, "changes", changeId, "accepted.md"); const config = await loadConfig(root); return join(changesDir(root, config), changeId, "accepted.md"); @@ -447,6 +447,28 @@ async function finishRun( return ok(linesOf(action.words, ROLE_TEXT.choice), runId); } +/** Apply slice 1 stop rules to one reply. Does not change the critique prompts. */ +export async function settleRoleReply( + root: string, + file: string, + role: RoleProfile, + reply: { text: string; action: ModelReply["action"] }, + now: Date, + runId: string, +): Promise { + const minutes = await timeoutMinutes(root); + if (minutes === "invalid") return fail(ROLE_TEXT.badYaml, runId); + return finishRun( + root, + file, + role, + { text: reply.text, gaps: [], changed: null, action: reply.action }, + minutes, + now, + runId, + ); +} + async function latestRunFile(root: string, name: string): Promise { const dir = conversationDir(root, name); const files = (await listFiles(dir)).filter((file) => file.endsWith(".jsonl")).sort(); @@ -596,7 +618,12 @@ async function findRun( const file = join(conversationDir(root, roleName), `${runId}.jsonl`); if (await pathExists(file)) hits.push({ file, roleName }); } - return hits.length === 1 ? hits[0]! : null; + if (hits.length === 1) return hits[0]!; + const pendingHits: { file: string; roleName: string }[] = []; + for (const hit of hits) { + if (openPending(await readJsonLines(hit.file))) pendingHits.push(hit); + } + return pendingHits.length === 1 ? pendingHits[0]! : null; } export async function allowRole(root: string, input: AllowRoleInput): Promise { From b7b058a615d5fa5972b203287a8896694c600ccf Mon Sep 17 00:00:00 2001 From: Harsha Bharadwaj Date: Mon, 19 Oct 2026 21:01:12 +0530 Subject: [PATCH 2/2] chore: sync the local verify SHA to this feature commit --- .../local-test-results.md | 4 ++-- .../2026-10-19-hand-off-work-and-the-task-network/meta.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md index 964da6cd..ee71c105 100644 --- a/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/local-test-results.md @@ -32,7 +32,7 @@ > Overall: PASS > Stage: local_verify - + - **Workspace tests**: exit 0 (`pnpm test`) @@ -42,6 +42,6 @@ > Overall: PASS > Stage: local_verify - + - **Workspace tests**: exit 0 (`pnpm test`) diff --git a/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml b/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml index e75018e9..df6ba846 100644 --- a/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml +++ b/changes/2026-10-19-hand-off-work-and-the-task-network/meta.yaml @@ -2,7 +2,7 @@ id: 2026-10-19-hand-off-work-and-the-task-network title: Hand off work and the task network workflow: feature created: 2026-10-19T14:41:11.398Z -updated: 2026-10-19T15:25:08.732Z +updated: 2026-10-19T15:31:01.809Z status: in_progress stage: local_verify branch: story/issue-37/S-004 @@ -36,7 +36,7 @@ verify_results: local_verify: ok: true status: pass - head_sha: f6dd94b4ab8cf453a78372743512d04b10b79ab3 + head_sha: 75d256bbb99119a59028d22af2e982445be97b26 at: 2026-10-19T15:25:08.732Z results: - name: Workspace tests