From 9700d326f51f0e6d1e5946265c83b2fb231256be Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Wed, 27 May 2026 22:30:56 -0700 Subject: [PATCH 1/9] plan(add-first-class-dependency-handoff-for-builds): planning artifacts Models-Used: gpt-5.5 Co-Authored-By: forged-by-eforge --- .../orchestration.yaml | 129 ++++++++++++++++++ .../plan-01-build-dependency-core.md | 106 ++++++++++++++ .../plan-02-playbook-placement-parity.md | 67 +++++++++ .../plan-03-consumer-surfaces-docs.md | 100 ++++++++++++++ 4 files changed, 402 insertions(+) create mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml create mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md create mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md create mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml b/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml new file mode 100644 index 00000000..bd2c78ca --- /dev/null +++ b/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml @@ -0,0 +1,129 @@ +name: add-first-class-dependency-handoff-for-builds +description: Add first-class afterQueueId dependency handoff for normal build + enqueue flows, including shared queue placement, CLI/daemon/API plumbing, + playbook placement parity, Pi/Claude consumer surfaces, docs, and tests. +base_branch: main +mode: excursion +validate: + - pnpm maintainability:check + - pnpm type-check + - pnpm test +plans: + - id: plan-01-build-dependency-core + name: Build Dependency Handoff Core Plumbing + depends_on: [] + branch: add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core + build: + - implement + - test-cycle + - review-cycle + review: + strategy: parallel + perspectives: + - code + - api + maxRounds: 2 + evaluatorStrictness: strict + agents: + builder: + effort: high + rationale: Touches shared queue semantics, daemon request validation, CLI + delegation, and engine enqueue behavior with bounded edits in several + large files. + reviewer: + effort: high + rationale: The route and queue placement semantics need careful review for API + compatibility and stale-id edge cases. + tester: + effort: high + rationale: Needs targeted coverage for active vs completed-artifact placement + and CLI/daemon plumbing. + - id: plan-02-playbook-placement-parity + name: Playbook Dependency Placement Parity + depends_on: + - plan-01-build-dependency-core + branch: add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity + build: + - implement + - test-cycle + - review-cycle + review: + strategy: auto + perspectives: + - code + - test + maxRounds: 1 + evaluatorStrictness: standard + agents: + builder: + effort: medium + rationale: Uses the helper from plan 01 to fix one route path and expand route + tests. + tester: + effort: high + rationale: Completed-artifact vs active-upstream playbook placement needs + file-location assertions. + - id: plan-03-consumer-surfaces-docs + name: Consumer Surfaces and Documentation + depends_on: + - plan-01-build-dependency-core + - plan-02-playbook-placement-parity + branch: add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs + build: + - implement + - doc-sync + - test-cycle + - review-cycle + review: + strategy: parallel + perspectives: + - docs + - api + maxRounds: 2 + evaluatorStrictness: strict + agents: + builder: + effort: high + rationale: Keeps Pi and Claude Code plugin surfaces in parity while updating + skill docs and bounded sections of large extension files. + reviewer: + effort: high + rationale: Consumer-facing schema, skill, plugin-version, and documentation + changes need parity review. + tester: + effort: high + rationale: Needs source-wiring tests plus Pi native-command behavior tests. + doc-syncer: + effort: medium + rationale: Several user-facing docs and skill files must describe the new flag + and deterministic handoff behavior consistently. +pipeline: + scope: excursion + compile: + - planner + - plan-review-cycle + defaultBuild: + - - implement + - doc-author + - test-write + - review-cycle + - doc-sync + - test-cycle + defaultReview: + strategy: parallel + perspectives: + - api + - test + maxRounds: 2 + evaluatorStrictness: standard + rationale: This is a cohesive cross-cutting feature touching engine queue + placement, daemon/client API contracts, CLI, Pi, Claude plugin parity, docs, + and tests, but a single planner can enumerate the work and dependencies, so + excursion is appropriate rather than expedition. A plan review gate is + useful because the change spans many surfaces and has edge cases around + waiting versus completed-artifact placement. The build runs documentation + authoring alongside implementation, then writes targeted tests, performs an + iterative review cycle focused on API contract and test coverage risk, syncs + docs against the final diff, and finishes with an iterative test cycle to + catch integration regressions. +diff_base_ref: 8d3fdabf464a2ea574ae18da2b18bfd6282220e4 diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md new file mode 100644 index 00000000..6aa0c3b0 --- /dev/null +++ b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md @@ -0,0 +1,106 @@ +--- +id: plan-01-build-dependency-core +name: Build Dependency Handoff Core Plumbing +branch: add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core +agents: + builder: + effort: high + rationale: Touches shared queue semantics, daemon request validation, CLI + delegation, and engine enqueue behavior with bounded edits in several + large files. + reviewer: + effort: high + rationale: The route and queue placement semantics need careful review for API + compatibility and stale-id edge cases. + tester: + effort: high + rationale: Needs targeted coverage for active vs completed-artifact placement + and CLI/daemon plumbing. +--- + +# Build Dependency Handoff Core Plumbing + +## Architecture Context + +Normal build surfaces need a deterministic way to express that a build waits for a specific upstream queue item. Queue and stacking layers already operate on `depends_on`; this plan adds the public request, CLI, daemon, and engine path that converts `afterQueueId` into `depends_on` and chooses whether the new PRD belongs in `.eforge/queue/waiting/` or the queue root. + +Constraints: + +- Route constants and daemon request shapes are owned by `@eforge-build/client`. +- Queue mutation is filesystem state under `.eforge/queue/`; no DB migration is needed. +- Large files must receive bounded exact edits. +- Scheduler stack inference remains the owner of `stack_parent`; this plan only persists `depends_on`. + +## Implementation + +### Overview + +Add `afterQueueId` to the normal enqueue contract. Implement a shared queue dependency placement helper in `prd-queue.ts`, use it from `EforgeEngine.enqueue()`, validate it in the daemon enqueue route before worker spawn, and pass it through CLI paths (`eforge enqueue --after`, `eforge build --after`, and daemon delegation). Explicit `afterQueueId` overrides dependency-detector output; dependency detection remains active for requests without `afterQueueId`. + +### Key Decisions + +1. Use `afterQueueId` as the public field name because autonomous playbooks already use that name. +2. Add a placement helper that returns `{ dependsOn: [id], intoWaiting }` for explicit handoffs. Active root/waiting queue items and live running upstreams map to `intoWaiting: true`; completed upstreams with a usable durable artifact map to `intoWaiting: false`; failed, skipped, unknown, or completed-without-artifact upstreams throw. +3. Re-run placement in the enqueue worker even when the daemon route already prevalidates. This handles races where the upstream completes between HTTP request validation and worker execution. +4. Bump `DAEMON_API_VERSION` and update the version test because older daemons would silently ignore `afterQueueId`, violating deterministic handoff semantics. + +## Scope + +### In Scope + +- Add `afterQueueId?: string` to `EnqueueRequest` in `packages/client/src/routes.ts` with documentation. +- Bump `DAEMON_API_VERSION` from 43 to 44 in `packages/client/src/api-version-const.ts` and update `test/daemon-recovery.test.ts`. +- Add `afterQueueId?: string` to `EnqueueOptions` in `packages/engine/src/events.ts`. +- Add a shared dependency placement helper in `packages/engine/src/prd-queue.ts`; keep `validateDependsOnExists()` available and adapt it to share classification code. +- Update `packages/engine/src/eforge.ts` so explicit `afterQueueId` writes `depends_on: [id]`, sets `intoWaiting` from the helper, and skips dependency-detector output. +- Update `packages/monitor/src/server.ts` `POST /api/enqueue` to reject non-string `afterQueueId`, validate/classify string values before spawning a worker, include the invalid id in error text, and pass `--after ` to the enqueue worker. +- Update `packages/eforge/src/cli/index.ts` with `eforge enqueue --after ` and `eforge build --after `. +- Update `packages/eforge/src/cli/run-or-delegate.ts` so daemon delegation sends `afterQueueId`, in-process enqueue passes it to `engine.enqueue()`, and active-upstream waiting handoff does not try to run a waiting PRD immediately. +- Add/update tests covering queue helper classification, engine explicit dependency precedence, daemon enqueue route validation, CLI flag plumbing, and API version. + +### Out of Scope + +- Multi-dependency selection for normal builds. +- Manual `stack_parent` selection. +- Scheduler stack inference changes beyond consuming the persisted `depends_on`. +- Pi and Claude tool/skill UX changes; those are handled in plan 03. +- Autonomous playbook route placement; that is handled in plan 02. + +## Files + +### Create + +- None expected. + +### Modify + +- `packages/client/src/routes.ts` — add `afterQueueId?: string` to `EnqueueRequest`. +- `packages/client/src/api-version-const.ts` — bump to 44 and prepend a v44 note. +- `packages/engine/src/events.ts` — add `afterQueueId?: string` to `EnqueueOptions`. +- `packages/engine/src/prd-queue.ts` — add the placement helper and retain `validateDependsOnExists()` behavior. +- `packages/engine/src/eforge.ts` — thread explicit dependency through enqueue and bypass dependency detection when present. +- `packages/monitor/src/server.ts` — validate and forward `afterQueueId` in `POST /api/enqueue`. +- `packages/eforge/src/cli/index.ts` — add `--after ` to `enqueue` and `build` commands. +- `packages/eforge/src/cli/run-or-delegate.ts` — include `afterQueueId` in `BuildRunOpts`, delegated `apiEnqueue` bodies, and foreground engine enqueue. +- `test/queue-piggyback.test.ts` — add placement helper cases for active root, live running, active waiting, completed artifact, failed, skipped, completed-without-artifact, and unknown ids. +- `test/acceptance-criteria-quality.test.ts` or a new focused engine enqueue test file — verify explicit `afterQueueId` persists `depends_on` and dependency-detector output is not used. +- `test/playbook-api.test.ts` — add `POST /api/enqueue` route tests for valid active, valid running, valid completed-artifact, non-string, unknown, failed, and skipped `afterQueueId` values. +- `test/extension-tooling-wiring.test.ts` or a focused CLI test file — verify `eforge enqueue --after q-abc` and `eforge build --after q-abc` pass `afterQueueId` through CLI/delegation paths. +- `test/daemon-recovery.test.ts` — update the expected daemon API version and version-history comment. + +## Verification + +- [ ] `EnqueueRequest` exposes optional `afterQueueId?: string` and `apiEnqueue({ body: { source, afterQueueId } })` type-checks. +- [ ] The placement helper returns `intoWaiting: true` for root queue items. +- [ ] The placement helper returns `intoWaiting: true` for live running upstreams. +- [ ] The placement helper returns `intoWaiting: true` for waiting queue items. +- [ ] The placement helper returns `intoWaiting: false` for completed upstreams with a usable artifact registry record. +- [ ] The placement helper throws for failed, skipped, completed-without-artifact, and unknown upstream ids. +- [ ] `EforgeEngine.enqueue()` writes `depends_on: ["q-abc"]` when called with `afterQueueId: "q-abc"`. +- [ ] `EforgeEngine.enqueue()` does not invoke or persist dependency-detector output when `afterQueueId` is provided. +- [ ] `POST /api/enqueue` returns 400 for non-string `afterQueueId`. +- [ ] `POST /api/enqueue` returns an error containing the invalid upstream id for unknown, failed, and skipped upstream ids. +- [ ] `POST /api/enqueue` spawns an enqueue worker with `--after ` for a valid upstream id. +- [ ] `eforge enqueue --after q-abc ` passes `afterQueueId: "q-abc"` into engine enqueue. +- [ ] `eforge build --after q-abc ` includes `afterQueueId: "q-abc"` in daemon `apiEnqueue` bodies. +- [ ] `eforge build --after q-abc --foreground ` passes `afterQueueId: "q-abc"` into foreground engine enqueue. diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md new file mode 100644 index 00000000..561f54b8 --- /dev/null +++ b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md @@ -0,0 +1,67 @@ +--- +id: plan-02-playbook-placement-parity +name: Playbook Dependency Placement Parity +branch: add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity +agents: + builder: + effort: medium + rationale: Uses the helper from plan 01 to fix one route path and expand route tests. + tester: + effort: high + rationale: Completed-artifact vs active-upstream playbook placement needs + file-location assertions. +--- + +# Playbook Dependency Placement Parity + +## Architecture Context + +Autonomous playbooks already expose `afterQueueId`, but the daemon route writes every dependent into `waiting/`. That strands dependents when the upstream already completed with a usable artifact because no future completion event will unblock them. Plan 01 provides the shared placement helper; this plan moves playbooks onto it. + +## Implementation + +### Overview + +Update `POST /api/playbook/run` for autonomous playbooks so it validates and classifies `afterQueueId` via the shared helper. Active upstreams still write to `.eforge/queue/waiting/`; completed upstreams with usable durable artifacts write to the queue root with `depends_on` preserved; invalid upstreams fail before queue mutation. + +### Key Decisions + +1. Reuse the plan 01 helper instead of retaining a playbook-only validator. +2. Keep planning-mode playbooks unchanged: they return `requires-agent` even when `afterQueueId` is present because no PRD is enqueued on that path. +3. Preserve AC quality gate order: invalid autonomous playbook acceptance criteria still return 400 before dependency validation. + +## Scope + +### In Scope + +- Update the autonomous playbook route in `packages/monitor/src/server.ts` to call the shared placement helper and pass its `dependsOn` and `intoWaiting` values into `enqueuePrd()`. +- Preserve existing `afterQueueId` validation failure status and message expectations where tests already assert them. +- Add playbook route tests for active upstream, completed-artifact upstream, unknown upstream, failed upstream, skipped upstream, and completed-without-artifact upstream. +- Add assertions that completed-artifact dependents are written to the queue root and active dependents are written to `waiting/`. + +### Out of Scope + +- Pi or Claude playbook UX changes. +- Normal `/api/enqueue` behavior; plan 01 owns that path. +- Scheduler or stack provider changes. + +## Files + +### Create + +- None expected. + +### Modify + +- `packages/monitor/src/server.ts` — replace the playbook route's validator-only logic plus unconditional `intoWaiting: true` with shared placement helper output. +- `test/playbook-api.test.ts` — expand autonomous playbook `afterQueueId` coverage for active, completed-artifact, failed, skipped, unknown, and completed-without-artifact upstreams. +- `test/queue-piggyback.test.ts` — adjust helper tests if plan 01 placed any playbook-specific edge case there. + +## Verification + +- [ ] Autonomous playbook run with active upstream writes the dependent PRD under `.eforge/queue/waiting/`. +- [ ] Autonomous playbook run with completed upstream plus usable artifact writes the dependent PRD under `.eforge/queue/` root. +- [ ] Both active and completed-artifact playbook dependents contain `depends_on: [""]` in frontmatter. +- [ ] Failed, skipped, unknown, and completed-without-artifact playbook upstreams return an error before queue mutation. +- [ ] Planning-mode playbook run with `afterQueueId` still returns `{ kind: "requires-agent" }` and writes no queue file. +- [ ] Existing AC-quality tests still return AC errors before dependency errors. diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md new file mode 100644 index 00000000..9c126e09 --- /dev/null +++ b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md @@ -0,0 +1,100 @@ +--- +id: plan-03-consumer-surfaces-docs +name: Consumer Surfaces and Documentation +branch: add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs +agents: + builder: + effort: high + rationale: Keeps Pi and Claude Code plugin surfaces in parity while updating + skill docs and bounded sections of large extension files. + reviewer: + effort: high + rationale: Consumer-facing schema, skill, plugin-version, and documentation + changes need parity review. + tester: + effort: high + rationale: Needs source-wiring tests plus Pi native-command behavior tests. + doc-syncer: + effort: medium + rationale: Several user-facing docs and skill files must describe the new flag + and deterministic handoff behavior consistently. +--- + +# Consumer Surfaces and Documentation + +## Architecture Context + +After plans 01 and 02 add the API and placement semantics, user-facing integrations need to expose the same deterministic handoff. Repo policy requires `packages/pi-eforge/` and `eforge-plugin/` to stay in sync for consumer-facing behavior, and the Claude plugin version must be bumped when plugin files change. + +## Implementation + +### Overview + +Add optional `afterQueueId` to the `eforge_build` MCP/Pi tool schemas and forwarding bodies. Extend the native Pi `/eforge:build` command with a wait-or-run-now selection for active queue items, modeled on `playbook-commands.ts`. Update Pi and Claude build skill docs to parse `--after ` and pass `afterQueueId` to the build tool. Update user docs to explain explicit deterministic handoff, active vs completed placement, and single-dependency stack inference. + +### Key Decisions + +1. Keep Pi native UI user-friendly: show active build titles/statuses, send the resolved queue id internally. +2. Keep non-interactive and script paths deterministic via `--after ` and direct tool `afterQueueId`. +3. Bump `eforge-plugin/.claude-plugin/plugin.json` because plugin-visible behavior changes. +4. Prefer shared Pi UI helper extraction only if it avoids duplicating the playbook active-build selector without creating circular imports. + +## Scope + +### In Scope + +- Add optional `afterQueueId` to `eforge_build` schema and request body forwarding in `packages/pi-eforge/extensions/eforge/index.ts`. +- Add optional `afterQueueId` to `eforge_build` schema and request body forwarding in `packages/eforge/src/cli/mcp-proxy.ts` for Claude Code plugin parity. +- Extend `packages/pi-eforge/extensions/eforge/build-command.ts` so UI mode lists active queue items, offers “Run now”, and appends `--after ` when the user chooses an upstream. +- Reuse or extract the active-build fetch/filter/select pattern from `packages/pi-eforge/extensions/eforge/playbook-commands.ts` where that keeps file ownership simple. +- Update `packages/pi-eforge/skills/eforge-build/SKILL.md` to document `--after ` and to pass `afterQueueId` in `eforge_build` calls. +- Update `eforge-plugin/skills/build/build.md` with the same `--after ` behavior and `mcp__eforge__eforge_build` payload guidance. +- Bump `eforge-plugin/.claude-plugin/plugin.json` patch version. +- Update `README.md`, `docs/architecture.md`, `docs/config.md`, and `docs/stacking.md` only where the new behavior changes existing build/dependency/stack wording. +- Add/update tests for Pi tool forwarding, MCP tool schema/forwarding, Pi native build wait selection, skill parity, and docs route/type references. + +### Out of Scope + +- New monitor UI state. +- Multi-dependency selection UI. +- Manual stack-parent UI. +- Pi package version bump in `packages/pi-eforge/package.json`. + +## Files + +### Create + +- Optional: `packages/pi-eforge/extensions/eforge/build-dependency-selection.ts` — shared Pi helper for active-build fetching and wait-option formatting if the implementation would otherwise duplicate more than a small helper block. + +### Modify + +- `packages/pi-eforge/extensions/eforge/index.ts` — add `afterQueueId` to `eforge_build` tool schema and enqueue body. +- `packages/eforge/src/cli/mcp-proxy.ts` — add `afterQueueId` to `eforge_build` schema and enqueue body. +- `packages/pi-eforge/extensions/eforge/build-command.ts` — add active-build wait selection and append `--after ` to the skill args. +- `packages/pi-eforge/extensions/eforge/playbook-commands.ts` — export or reuse active-build helper only if shared extraction is chosen. +- `packages/pi-eforge/skills/eforge-build/SKILL.md` — document `--after ` and tool payload behavior. +- `eforge-plugin/skills/build/build.md` — mirror Pi build skill documentation with MCP tool naming. +- `eforge-plugin/.claude-plugin/plugin.json` — bump plugin patch version. +- `README.md` — add CLI/user-facing example for `eforge build --after ` or `eforge enqueue --after `. +- `docs/architecture.md` — clarify build handoff writes active dependencies to `waiting/` and completed-artifact dependencies to queue root. +- `docs/config.md` — update PRD queue/dependency text for explicit `--after` handoff. +- `docs/stacking.md` — state that a single explicit dependency from `afterQueueId` participates in existing stack-parent inference when stacking is enabled. +- `test/profile-wiring.test.ts` or a focused MCP/Pi wiring test — assert both `eforge_build` schemas include `afterQueueId` and both handlers forward it. +- `test/pi-build-command.test.ts` — assert native `/eforge:build` appends `--after ` when the active-build wait option is selected and preserves landing/profile args. +- `test/build-profile-selection-skill.test.ts` or a new skill-doc test — assert Pi and Claude build skill docs mention `--after ` and include `afterQueueId` in build tool calls. + +## Verification + +- [ ] Pi `eforge_build` schema accepts optional `afterQueueId`. +- [ ] Pi `eforge_build` request body includes `afterQueueId` when supplied. +- [ ] MCP `eforge_build` schema accepts optional `afterQueueId`. +- [ ] MCP `eforge_build` request body includes `afterQueueId` when supplied. +- [ ] Native Pi `/eforge:build` UI mode offers “Run now” plus active build wait options when queue items are active. +- [ ] Native Pi `/eforge:build` appends `--after ` to `/skill:eforge-build` args when a wait option is selected. +- [ ] Native Pi `/eforge:build` omits `--after` when “Run now” is selected. +- [ ] Pi and Claude build skill docs both document `--after `. +- [ ] Pi and Claude build skill docs both instruct tool callers to send `afterQueueId` when `--after` is present. +- [ ] Claude plugin version in `eforge-plugin/.claude-plugin/plugin.json` increases by one patch version. +- [ ] Documentation states explicit handoff is deterministic and dependency detector inference remains best effort. +- [ ] Documentation states a single explicit dependency becomes the stack parent when stacking is enabled. +- [ ] Documentation states active upstream dependencies wait and completed-artifact upstream dependencies enqueue as eligible dependents. From a5146f17f598a8ce7c0e8e109238b583ddbe92ce Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Wed, 27 May 2026 22:30:57 -0700 Subject: [PATCH 2/9] build(add-first-class-dependency-handoff-for-builds): record PRD provenance Co-Authored-By: forged-by-eforge --- ...rst-class-dependency-handoff-for-builds.md | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 eforge/prds/add-first-class-dependency-handoff-for-builds.md diff --git a/eforge/prds/add-first-class-dependency-handoff-for-builds.md b/eforge/prds/add-first-class-dependency-handoff-for-builds.md new file mode 100644 index 00000000..dcb05877 --- /dev/null +++ b/eforge/prds/add-first-class-dependency-handoff-for-builds.md @@ -0,0 +1,319 @@ +--- +title: Add First-Class Dependency Handoff for Builds +created: 2026-05-28 +profile: gpt-claude-combo +landing: pr +landing_auto_merge: true +--- + +# Add First-Class Dependency Handoff for Builds + +## Problem / Motivation + +Users reasonably expect `/eforge:build` to support the same explicit wait/dependency handoff that the lower-level queue and stacking systems already support. + +Today, normal build enqueue can only rely on best-effort dependency detection, and the user-facing build surfaces do not expose a reliable `afterQueueId` path. This creates a mismatch: + +- The scheduler and stacking layers know how to wait and stack. +- Autonomous playbooks can pass `afterQueueId`. +- `/eforge:build` cannot reliably express "this build depends on that queue item". + +As a result, users cannot safely hand off a dependent session plan while an upstream build is running and trust eforge to wait, then branch from the upstream artifact branch. + +This gap was discovered while trying to enqueue Console UI work that explicitly depended on an in-flight stack-sync build. The desired user model is: a user hands a build to `/eforge:build`, selects or supplies an upstream queue item, and eforge persists the dependency, waits until the upstream artifact exists, and, when stacking is enabled with PR landing, builds the child on top of the upstream artifact branch. + +Evidence gathered: + +- `docs/architecture.md` documents piggyback scheduling: PRDs with dependencies are held in `waiting`, unblocked after upstream completion, and skipped transitively if upstream fails or is cancelled. +- `docs/stacking.md` documents single-dependency stack inference: when a PRD has exactly one `depends_on` and stacking is enabled, eforge infers `stack_parent` at dispatch time. +- `packages/engine/src/prd-queue.ts` already supports `enqueuePrd({ depends_on, intoWaiting })`, `validateDependsOnExists`, `propagateSkip`, and `unblockWaiting`. +- `packages/engine/src/queue/scheduler.ts` already waits for dependency artifacts, records completion availability, unblocks waiting PRDs, and persists inferred `stack_parent` for a single dependency before spawning a child. +- `packages/engine/src/stacking/base-resolver.ts` resolves a child stack base from the parent artifact registry entry or stack layer projection. +- `packages/client/src/routes.ts` `EnqueueRequest` currently has `source`, `flags`, `profile`, `landingAction`, and `landingAutoMerge`, but no explicit `afterQueueId` or dependency field. +- `packages/pi-eforge/extensions/eforge/index.ts` `eforge_build` tool schema currently has no dependency field and therefore cannot pass an explicit dependency through to `POST /api/enqueue`. +- `packages/monitor/src/server.ts` enqueue route validates profile and landing inputs, then spawns an `enqueue` worker with CLI flags; it currently has no handling for an explicit upstream dependency. +- `packages/eforge/src/cli/index.ts` `eforge enqueue` has no `--after` option, and `EforgeEngine.enqueue()` currently relies on best-effort dependency detection rather than an explicit dependency contract. +- `packages/pi-eforge/extensions/eforge/playbook-commands.ts` already implements a useful UX pattern for autonomous playbooks: detect active builds, offer "Run now" or "Wait for", resolve the internal queue id, and call `apiPlaybookRun` with `afterQueueId`. +- `packages/client/src/routes.ts` `PlaybookRunRequest` already carries `afterQueueId`; `packages/monitor/src/server.ts` validates it and enqueues autonomous playbooks into `waiting/`. +- Follow-up inspection on 2026-05-27 confirmed a refinement: the autonomous playbook route currently uses `intoWaiting: afterQueueId ? true : false`, which can strand dependents when `afterQueueId` references a completed upstream with a usable artifact because no future completion event will unblock them. +- `test/queue-piggyback.test.ts`, `test/artifact-aware-scheduler.test.ts`, `test/playbook-api.test.ts`, and `test/pi-playbook-commands.test.ts` already cover much of the lower-level dependency/waiting/stack-parent behavior for playbooks and scheduler internals. + +Classification: this is a **feature / focused** change. It adds a first-class dependency handoff capability to existing build/enqueue surfaces without changing the underlying queue or stacking model. + +## Goal + +Add a first-class dependency handoff capability to normal build enqueue flows so builds can explicitly wait for upstream queue items and become stacked PR children when stacking is enabled. + +The outcome is that `/eforge:build`, CLI, daemon APIs, Pi, and Claude Code plugin surfaces can deterministically pass an upstream queue id via `afterQueueId`, persist it as `depends_on`, place dependents correctly based on upstream readiness, and preserve existing scheduler-based stack inference. + +## Approach + +Use `afterQueueId` as the public field name for parity with playbooks. + +Rationale: playbook APIs and Pi playbook UX already use `afterQueueId`. Reusing the name avoids introducing a second concept for the same user intent. + +Treat explicit dependency as authoritative and skip dependency detector output for that dependency decision. + +Rationale: user-stated dependency should not be overridden by a best-effort agent. Dependency detector can still run for requests without explicit `afterQueueId`. + +Validate explicit dependencies before spawning enqueue workers or writing queue files. + +Rationale: stale queue ids should fail early with a clear message instead of creating stuck dependents. + +Keep stack topology inference in the scheduler. + +Rationale: the stacking docs already promise that a single `depends_on` infers `stack_parent`. Build surfaces should express dependency intent, not duplicate stacking topology logic. + +Add a shared queue dependency placement helper rather than duplicating active-vs-completed checks. + +Rationale: playbooks, CLI enqueue, daemon enqueue, and future wrappers need consistent behavior. The helper should validate the upstream id and return both the accepted dependency list and queue placement, for example `{ dependsOn: [afterQueueId], intoWaiting: boolean }`. Active upstreams should produce `intoWaiting: true`; completed upstreams with usable durable artifacts should produce `intoWaiting: false`; failed/skipped/unknown/completed-without-artifact upstreams should be rejected. + +Apply the placement helper to autonomous playbooks as well as normal builds. + +Rationale: current playbook route evidence shows `afterQueueId` is validated and then always enqueued with `intoWaiting: true`. That is safe for active upstreams but wrong for completed upstreams with usable artifacts because no future completion event will unblock the dependent. This feature should avoid creating a new correct path for `/eforge:build` while leaving playbooks with the old stuck-waiting edge case. + +Extend Pi `/eforge:build` with an optional wait selection when active queue items exist. + +Rationale: users should not need to type internal queue ids. The UI can show build titles while sending the resolved id internally, matching playbook UX. + +Support non-interactive explicit handoff with `--after `. + +Rationale: scripts, headless Pi, Claude Code, and direct CLI users need a deterministic path that does not depend on native UI selection. + +Keep monitor queue display based on existing queue state. + +Rationale: a dependent build in `waiting/` should naturally appear in queue state through existing queue APIs; a dependent build whose upstream already has an artifact should appear as a normal pending/root queue item with `depends_on` preserved. No special monitor-only state is needed. + +Likely code changes: + +- `packages/client/src/routes.ts` + - Add `afterQueueId?: string` to `EnqueueRequest` with documentation that it is the queue item id this build should run after. +- `packages/client/src/api/queue.ts` + - No route literal changes should be needed if `apiEnqueue` continues to use `EnqueueRequest`. +- `packages/engine/src/events.ts` + - Add `afterQueueId?: string` or equivalent explicit dependency option to `EnqueueOptions`. +- `packages/engine/src/prd-queue.ts` + - Add or refactor a helper that validates dependency ids and returns queue placement information, for example active dependency vs completed artifact dependency. + - Prefer a single helper used by normal enqueue and playbook enqueue, rather than a validator-only helper plus local `intoWaiting` decisions. + - Preserve `validateDependsOnExists` for existing callers or adapt it without breaking playbook behavior. +- `packages/engine/src/eforge.ts` + - Thread explicit enqueue dependency into `enqueuePrd` as `depends_on: [afterQueueId]`. + - Use `intoWaiting` only when the upstream is active/waiting rather than already completed with a usable artifact. + - Avoid replacing explicit `afterQueueId` with dependency-detector output. +- `packages/monitor/src/server.ts` + - Accept and validate `afterQueueId` on `POST /api/enqueue`. + - Reject a non-string `afterQueueId` with 400 before spawning a worker. + - Return a clear 404 or 400 when the selected upstream id is stale, unknown, failed, skipped, or completed without a usable artifact. + - Pass `--after ` to the enqueue worker. + - Update the autonomous playbook route to use the shared placement helper instead of unconditional `intoWaiting: true` whenever `afterQueueId` is provided. +- `packages/eforge/src/cli/index.ts` + - Add `eforge enqueue --after `. + - Add `eforge build --after ` if normal build delegation remains the primary user path for daemon-backed enqueue. + - Pass `afterQueueId` into `engine.enqueue()` for in-process enqueue/build paths. + - Include `--after` in daemon worker argument handling if daemon route delegates to CLI workers. +- `packages/eforge/src/cli/run-or-delegate.ts` + - Include `afterQueueId` in delegated `apiEnqueue` calls and in foreground `engine.enqueue()` calls. +- `packages/pi-eforge/extensions/eforge/index.ts` + - Add optional `afterQueueId` to the `eforge_build` tool schema and forward it in the enqueue body. +- `packages/pi-eforge/extensions/eforge/build-command.ts` + - Add a wait-or-run-now UI step for active queue items, using the existing playbook command pattern where possible. + - Append `--after ` to delegated `/skill:eforge-build` args when the user selects an upstream build. +- `packages/pi-eforge/skills/eforge-build/SKILL.md` + - Document `--after ` and tool-call behavior. +- `eforge-plugin/` + - Keep Claude Code plugin parity by adding the same MCP tool parameter and skill documentation updates. + - Bump `eforge-plugin/.claude-plugin/plugin.json` because plugin-facing behavior changes. +- `docs/config.md`, `docs/architecture.md`, `docs/stacking.md`, and/or README. + - Clarify that `/eforge:build` supports explicit dependency handoff and that single-dependency PRDs become stacked children when stacking is enabled. + +Architecture impact: + +No new subsystem is required. The change should connect existing layers: + +```mermaid +flowchart TD + User[/User selects or passes upstream build/] --> BuildSurface[/eforge:build or eforge_build/] + BuildSurface --> EnqueueRequest[POST /api/enqueue afterQueueId] + EnqueueRequest --> Validate[Validate dependency and classify placement] + Validate -->|active upstream| Waiting[Write PRD to queue/waiting with depends_on] + Validate -->|completed artifact| Pending[Write PRD to queue root with depends_on] + Waiting --> Unblock[unblockWaiting after upstream completion] + Unblock --> Pending + Pending --> Scheduler[Queue scheduler] + Scheduler --> StackInference[Infer stack_parent from single depends_on] + StackInference --> BaseResolver[Resolve parent artifact branch] + BaseResolver --> Build[Build child PRD] +``` + +The key architectural rule is that build surfaces express dependency intent; queue/scheduler/stacking layers remain responsible for readiness, artifact availability, and stack base resolution. + +Documentation impact: + +- Update user-facing docs to state that normal builds can be handed off after an upstream queue item. +- Document that Pi `/eforge:build` can offer active builds as "wait for" choices. +- Document that CLI can use `eforge enqueue --after `. +- Document that tool callers can pass `afterQueueId` to `eforge_build`. +- Document that with stacking enabled and PR landing, a single explicit dependency is enough for stack-parent inference. +- Avoid implying that eforge always auto-detects every dependency. +- State that explicit handoff is deterministic. +- State that dependency detector remains best effort. + +Risks and mitigations: + +- **Stuck waiting PRDs**: writing a completed-artifact dependency into `waiting/` could leave it stuck because no future upstream completion event will arrive. Mitigation: classify dependency placement and only use `waiting/` for active upstreams. +- **Existing playbook stuck-waiting edge case**: current autonomous playbook route evidence shows `afterQueueId` dependents are always written with `intoWaiting: true`. Mitigation: move playbooks onto the same placement helper as normal builds and add completed-artifact playbook tests. +- **False confidence from dependency detector**: keeping implicit detection as-is could obscure the new explicit behavior. Mitigation: explicit `afterQueueId` takes precedence and is clearly reported. +- **Stale queue ids**: active builds can finish between selection and enqueue. Mitigation: validate at enqueue time and classify the current state; if the upstream now has a usable artifact, enqueue the dependent in the queue root instead of failing or waiting; if the upstream is failed/skipped/unknown, return a clear error. +- **Plugin/Pi drift**: this touches both consumer integrations. Mitigation: update both `packages/pi-eforge/` and `eforge-plugin/`, and bump the Claude plugin version. +- **Ambiguous future multi-dependency stacking**: this plan only covers one explicit `afterQueueId`. Mitigation: defer multi-dependency and manual `stack_parent` selection. +- **Daemon API versioning**: adding a request field is additive for tolerant servers, but first-party clients and daemon must remain compatible. Mitigation: update shared `EnqueueRequest` and only bump `DAEMON_API_VERSION` if project policy treats this as a breaking route contract change. + +Recommended tests: + +- Client route type tests or TypeScript usage tests for `EnqueueRequest.afterQueueId`. +- Daemon route tests for `POST /api/enqueue` with valid and invalid `afterQueueId`. +- Engine enqueue tests proving explicit `afterQueueId` persists `depends_on` and bypasses dependency-detector replacement. +- Queue placement tests proving active upstreams write to `waiting/` and completed artifact upstreams can write to queue root. +- Playbook API tests proving autonomous playbooks use the same placement helper: active upstreams write to `waiting/`, completed-artifact upstreams write to queue root, and failed/skipped/unknown upstreams are rejected. +- CLI tests proving `eforge enqueue --after q-abc` and, if added, `eforge build --after q-abc` pass the dependency through. +- Pi tool tests proving `eforge_build` forwards `afterQueueId`. +- Pi native build command tests proving active-build wait selection appends `--after`. +- Plugin parity tests or schema snapshots if present. +- Existing `pnpm type-check` and targeted test suites pass. + +Assumptions and validation: + +| Assumption | Evidence / validation performed | Confidence | Cost to validate further | Validation path | Impact if wrong | +|------------|----------------------------------|------------|--------------------------|-----------------|-----------------| +| Existing queue/scheduler/stacking layers already support the core wait-then-stack behavior once `depends_on` is present. | Read `prd-queue.ts`, `queue/scheduler.ts`, `stacking/base-resolver.ts`, `docs/architecture.md`, and `docs/stacking.md`; tests already cover waiting, artifact readiness, and stack-parent inference. | high | low | Add an end-to-end route/engine test for `afterQueueId` through queue placement and scheduler dispatch. | If wrong, implementation expands beyond surface plumbing into scheduler fixes. | +| `afterQueueId` is the right public field name. | Existing playbook API and Pi playbook command use `afterQueueId` for the same user intent. | high | low | Confirm docs/API naming during implementation review. | If wrong, API churn and duplicated concepts. | +| Active upstreams should enqueue dependents into `waiting/`, while completed-artifact upstreams should enqueue into root/pending. | `unblockWaiting` is completion-event driven; completed artifacts will not emit a future completion event. `validateDependsOnExists` already accepts both live upstreams and completed upstreams with usable artifacts, but does not itself return placement. | high | low | Write placement tests for both active and completed-artifact upstreams. | If wrong, dependents can get stuck or dispatch too early. | +| Autonomous playbooks should use the same active-vs-completed placement behavior as normal builds. | Current code inspection shows the playbook route validates `afterQueueId` then calls `enqueuePrd` with `depends_on: [afterQueueId]` and `intoWaiting: true` whenever `afterQueueId` is present. This is correct for active upstreams but wrong for completed upstreams with artifacts. | high | low | Add playbook API tests for active upstream and completed-artifact upstream placement before/after refactor. | If wrong, playbooks keep a stuck-waiting edge case or diverge from normal build semantics. | +| Build surfaces should not set `stack_parent` directly for the single-dependency case. | Stacking docs and scheduler code already infer `stack_parent` from one `depends_on`. | high | low | Existing artifact-aware scheduler tests verify inference; add one route-level integration assertion if needed. | If wrong, stack topology may be duplicated or inconsistent. | +| Pi build UI can reuse active-build selection patterns from playbook commands. | `playbook-commands.ts` already fetches running items, presents wait choices, and forwards `afterQueueId`. | medium | low | Inspect helper reuse options during implementation; extract common helper if duplication grows. | If wrong, Pi UI implementation takes slightly longer but the API can still ship. | +| Claude plugin parity is required. | `AGENTS.md` requires keeping `eforge-plugin/` and `packages/pi-eforge/` in sync for consumer-facing behavior. | high | low | Search plugin build tool and skill files during implementation. | If missed, Claude users lack the feature and repo policy is violated. | +| Additive `afterQueueId` does not require a daemon API version bump. | The field is optional and backward-compatible at the TypeScript shape level, but project policy may define API versioning more strictly. | medium | low | Inspect `DAEMON_API_VERSION` policy and existing route-contract tests before implementation. | If wrong, clients may see version mismatch or route-contract drift. | + +No low-confidence, high-impact assumptions remain unresolved. The main implementation-time check is queue placement for completed-artifact dependencies, including autonomous playbooks, so dependents do not get stuck in `waiting/`. + +Recommended profile: **Excursion**. + +Rationale: the work crosses client types, daemon route handling, engine enqueue plumbing, CLI, Pi, Claude plugin parity, docs, and tests, but it is a cohesive single capability. It should not require delegated module planners; one cohesive plan can enumerate the necessary changes and dependencies. + +## Scope + +In scope: + +- Add an explicit dependency handoff field to normal build enqueue APIs, likely named `afterQueueId` for parity with playbooks. +- Thread this field through `@eforge-build/client`, daemon HTTP route handling, CLI `eforge enqueue`, Pi `eforge_build` tool, Pi `/eforge:build` native command, Claude Code plugin MCP schema/skill docs, and user-facing docs. +- Make explicit dependency handoff deterministic and stronger than dependency detector inference. +- Validate explicit upstream ids before queue mutation using existing dependency validation rules. +- Classify explicit dependency placement before queue mutation: active/live upstreams should put dependents in `.eforge/queue/waiting/`; completed upstreams with usable durable artifacts should put dependents in the queue root so they are eligible to dispatch immediately. +- Persist `depends_on: []` in the queued PRD frontmatter for both waiting and immediately-eligible dependents. +- Bring autonomous playbook `afterQueueId` enqueue placement onto the same shared placement path. Current evidence shows the playbook route validates `afterQueueId` but always calls `enqueuePrd(..., intoWaiting: true)` when `afterQueueId` is present; this refinement should fix that so completed-artifact playbook dependencies do not get stuck in `waiting/`. +- Preserve existing dependency-detector behavior for enqueue requests without explicit dependency handoff. +- Preserve stacking inference: when stacking is enabled and the PRD has exactly one `depends_on`, scheduler dispatch should infer and persist `stack_parent` rather than requiring the build surface to set it manually. +- Reuse the playbook wait-or-run-now UX pattern in Pi `/eforge:build` where technically feasible. +- Add targeted tests for route contract, daemon validation, engine enqueue behavior, CLI flag plumbing, Pi tool plumbing, playbook placement parity, and docs/skill parity. + +Out of scope: + +- General multi-dependency selection UI for normal builds. +- Manual `stack_parent` selection for ambiguous multi-dependency stacks. +- Queue reordering or priority editing. +- New stack providers. +- Changing scheduler artifact-readiness semantics beyond preventing already-completed artifact dependencies from being written to `waiting/`. +- Replacing dependency detector inference. +- Automatically deciding dependencies without user confirmation when an explicit wait choice is available. +- Preserving the current playbook behavior that always writes `afterQueueId` dependents to `waiting/`; that behavior is now treated as part of the placement bug to correct. + +## Acceptance Criteria + +- `EnqueueRequest` in `packages/client/src/routes.ts` includes optional `afterQueueId?: string` with documentation that it is the queue item id this build should run after. +- The shared `apiEnqueue` helper accepts a request body containing `afterQueueId` without local type errors. +- The Pi `eforge_build` tool schema accepts optional `afterQueueId`. +- The Pi `eforge_build` tool forwards `afterQueueId` to `POST /api/enqueue` when it is provided. +- The Claude Code plugin build tool schema accepts optional `afterQueueId`. +- The Claude Code plugin build tool forwards `afterQueueId` to `POST /api/enqueue` when it is provided. +- The CLI command `eforge enqueue --after ` parses `` as an explicit upstream dependency. +- The CLI command `eforge build --after ` parses `` as an explicit upstream dependency if normal build remains the daemon-delegated user path. +- The daemon `POST /api/enqueue` route accepts optional `afterQueueId`. +- The daemon `POST /api/enqueue` route rejects a non-string `afterQueueId` with a 400 response. +- The daemon `POST /api/enqueue` route rejects an unknown `afterQueueId` before spawning an enqueue worker. +- The daemon `POST /api/enqueue` route returns an error message that includes the invalid upstream id when `afterQueueId` validation fails. +- The enqueue worker receives the selected upstream id when `POST /api/enqueue` is called with `afterQueueId`. +- `EforgeEngine.enqueue()` accepts an explicit upstream dependency option. +- `EforgeEngine.enqueue()` writes `depends_on: [""]` when an explicit upstream dependency option is provided. +- `EforgeEngine.enqueue()` does not replace an explicit upstream dependency with dependency-detector output. +- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live pending upstream queue item. +- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live running upstream queue item. +- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live waiting upstream queue item. +- The queue dependency placement helper returns `intoWaiting: false` for an explicit dependency that refers to a completed upstream with a usable durable artifact record. +- The queue dependency placement helper rejects an explicit dependency that refers to a failed upstream queue item. +- The queue dependency placement helper rejects an explicit dependency that refers to a skipped upstream queue item. +- The queue dependency placement helper rejects an explicit dependency that refers to a completed upstream without a usable durable artifact record. +- The queue dependency placement helper rejects an explicit dependency that refers to an unknown queue item id. +- A build enqueued with `afterQueueId` referencing an active upstream is written under `.eforge/queue/waiting/`. +- A build enqueued with `afterQueueId` referencing an active upstream does not dispatch before the upstream has a usable artifact registry record. +- A build enqueued with `afterQueueId` referencing a completed upstream with a usable artifact is written to the queue root instead of `.eforge/queue/waiting/`. +- A build enqueued with `afterQueueId` referencing a completed upstream with a usable artifact is eligible to dispatch without waiting for a future completion event. +- An autonomous playbook run with `afterQueueId` referencing an active upstream is written under `.eforge/queue/waiting/`. +- An autonomous playbook run with `afterQueueId` referencing a completed upstream with a usable artifact is written to the queue root instead of `.eforge/queue/waiting/`. +- An autonomous playbook run with `afterQueueId` referencing a failed upstream is rejected before queue mutation. +- An autonomous playbook run with `afterQueueId` referencing a skipped upstream is rejected before queue mutation. +- An autonomous playbook run with `afterQueueId` referencing an unknown upstream is rejected before queue mutation. +- An autonomous playbook run with `afterQueueId` referencing a completed-without-artifact upstream is rejected before queue mutation. +- A dependent build with exactly one `depends_on` has `stack_parent` inferred and persisted before dispatch when `stacking.enabled` is true. +- A dependent stacked build resolves its base branch from the parent artifact branch recorded for the upstream queue item. +- A dependent waiting build moves to `skipped/` when the selected upstream build fails. +- A dependent waiting build moves to `skipped/` when the selected upstream build is cancelled or skipped. +- Pi `/eforge:build` presents a run-now option and wait-for-active-build options when active queue items are available in UI mode. +- Pi `/eforge:build` passes the selected active build id as `--after ` to the build skill when the user chooses to wait. +- `/skill:eforge-build` documents `--after ` as the way to enqueue a normal build after an upstream queue item. +- `/skill:eforge-build` passes `afterQueueId` to `eforge_build` when `--after ` is present. +- Queue piggyback tests continue to pass. +- Artifact-aware scheduler stack-parent inference tests continue to pass. +- Existing autonomous playbook `afterQueueId` tests for active upstreams continue to pass. +- New daemon enqueue route tests cover a valid active upstream case. +- New daemon enqueue route tests cover a valid completed-artifact upstream case. +- New daemon enqueue route tests cover an unknown upstream case. +- New daemon enqueue route tests cover a failed upstream case. +- New daemon enqueue route tests cover a skipped upstream case. +- New playbook API tests cover a valid active upstream case. +- New playbook API tests cover a valid completed-artifact upstream case. +- New playbook API tests cover an unknown upstream case. +- New playbook API tests cover a failed upstream case. +- New playbook API tests cover a skipped upstream case. +- New CLI tests cover `eforge enqueue --after ` argument plumbing. +- New CLI tests cover `eforge build --after ` argument plumbing if the flag is added to the build command. +- New Pi tests cover `eforge_build` tool forwarding. +- New Pi tests cover native build wait selection. +- Client route type tests or TypeScript usage tests verify `EnqueueRequest.afterQueueId`. +- Daemon route tests verify `POST /api/enqueue` behavior with a valid `afterQueueId`. +- Daemon route tests verify `POST /api/enqueue` behavior with an invalid `afterQueueId`. +- Engine enqueue tests prove explicit `afterQueueId` persists `depends_on`. +- Engine enqueue tests prove explicit `afterQueueId` bypasses dependency-detector replacement. +- Queue placement tests prove active upstreams write to `waiting/`. +- Queue placement tests prove completed artifact upstreams can write to the queue root. +- Playbook API tests prove autonomous playbooks use the same placement helper as normal builds. +- Playbook API tests prove autonomous playbooks with active upstreams write to `waiting/`. +- Playbook API tests prove autonomous playbooks with completed-artifact upstreams write to the queue root. +- Playbook API tests prove autonomous playbooks with failed upstreams are rejected. +- Playbook API tests prove autonomous playbooks with skipped upstreams are rejected. +- Playbook API tests prove autonomous playbooks with unknown upstreams are rejected. +- CLI tests prove `eforge enqueue --after q-abc` passes the dependency through. +- CLI tests prove `eforge build --after q-abc` passes the dependency through if `eforge build --after` is added. +- Pi tool tests prove `eforge_build` forwards `afterQueueId`. +- Pi native build command tests prove active-build wait selection appends `--after`. +- Plugin parity tests or schema snapshots pass if present. +- Documentation explains that explicit dependency handoff is deterministic and dependency detector inference remains best effort. +- Documentation explains that a single explicit dependency becomes the stack parent automatically when stacking is enabled. +- Documentation explains that active upstream dependencies wait, while completed upstream dependencies with usable artifacts enqueue as immediately eligible dependents. +- The Claude Code plugin version is bumped if any plugin files change. +- `pnpm type-check` exits 0. +- Targeted tests for queue piggybacking exit 0. +- Targeted tests for artifact-aware scheduling exit 0. +- Targeted tests for playbook API behavior exit 0. +- Targeted tests for build enqueue route behavior exit 0. +- Targeted tests for CLI enqueue behavior exit 0. +- Targeted tests for Pi build command behavior exit 0. From 10885d01facae2dcdd5220e8d579e83eea7b8d66 Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Wed, 27 May 2026 23:03:47 -0700 Subject: [PATCH 3/9] feat(plan-01-build-dependency-core): Build Dependency Handoff Core Plumbing Models-Used: claude-sonnet-4-6, gpt-5.5 Co-Authored-By: forged-by-eforge --- packages/client/src/api-version-const.ts | 2 +- packages/client/src/routes.ts | 18 ++ packages/eforge/src/cli/index.ts | 18 ++ packages/eforge/src/cli/run-or-delegate.ts | 35 ++++ packages/engine/src/eforge.ts | 88 +++++---- packages/engine/src/events.ts | 8 + packages/engine/src/prd-queue.ts | 132 ++++++++++++- packages/monitor/src/server.ts | 43 ++++- scripts/agent-maintainability-baseline.json | 25 ++- test/daemon-recovery.test.ts | 8 +- test/extension-tooling-wiring.test.ts | 61 ++++++ test/playbook-api.test.ts | 102 ++++++++++ test/queue-piggyback.test.ts | 198 ++++++++++++++++++++ web/content/reference/cli.md | 2 + web/public/llms-full.txt | 2 + web/public/reference/cli.md | 2 + 16 files changed, 699 insertions(+), 45 deletions(-) diff --git a/packages/client/src/api-version-const.ts b/packages/client/src/api-version-const.ts index 107acae0..2cb49629 100644 --- a/packages/client/src/api-version-const.ts +++ b/packages/client/src/api-version-const.ts @@ -16,4 +16,4 @@ * the version. Removing a field, renaming a route, or changing a response's * required fields IS breaking and must bump the version. */ -export const DAEMON_API_VERSION = 43; // v43: stack sync `retry-deferred` trigger added to route validation and event schemas; new `stack:sync:skipped` event variant added; `deferred` added to closed `StackSyncResponse.outcome` union; `activeBuildSkips` and `providerCommands` fields added to `StackSyncStatusWire`; `current.outcome` made optional in daemon stream snapshot schema. v42: `plan:build:review:fix:continuation` event variant added; review-fixer now has a retry policy with turn-budget continuation support. v41: `landing:auto-merge:start`, `landing:auto-merge:complete`, `landing:auto-merge:skipped` event variants added; `landingAutoMerge` (boolean) optional field added to `EnqueueRequest`, `PlaybookRunRequest`, `BuildOptions`, `EnqueueOptions`, and PRD frontmatter (`landing_auto_merge`); `landing.pr.autoMerge` config field added (`ask|always|never`, default `ask`). v40: `gap_close:complete.passed` is now a required field on the wire event; older clients/daemons that treated it as optional will disagree on the event schema. v39: landing action vocabulary changed from full strings (merge-to-base-branch|issue-pr|leave-branch) to canonical shorthands (pr|merge|leave) in EnqueueRequest.landingAction and PlaybookRunRequest.landingAction (replacing onSuccess); daemon rejects onSuccess in request bodies with a migration error; CLI workers spawned with --landing-action instead of --on-success. v38: `landing:start` wire event removes `feature-pr-after-local-merge` workflow literal and replaces it with `feature-pr`; older clients that validated the event against the previous schema union will reject events emitted by the new daemon. v37: optional `onSuccess` field added to `PlaybookRunRequest` (per-playbook-run landing action override: 'merge-to-base-branch' | 'issue-pr' | 'leave-branch'); daemon `/api/playbook/run` validates and passes `onSuccess` to `enqueuePrd(...)` for autonomous playbooks; older daemons would silently ignore the field. v36: optional `onSuccess` field added to `EnqueueRequest` (per-build landing action override: 'merge-to-base-branch' | 'issue-pr' | 'leave-branch'); `onSuccess` persisted in PRD frontmatter so override survives queue scheduler child-process boundaries; new `landing:start`, `landing:complete`, `landing:skipped` event variants added in plan-01. v35: optional `profile` field added to playbook frontmatter wire shapes (`PlaybookListEntry`, `PlaybookData`, `PlaybookFrontmatterFields`); optional `agent_profile` field added to `SessionPlanDataWire` and `SessionPlanCreateRequest`; `/api/playbook/run` for autonomous playbooks with `profile` persists profile in queued PRD frontmatter; `/api/session-plan/create` and `/api/session-plan/create-from-playbook` propagate `agent_profile`; `/api/enqueue` validates and propagates inherited session-plan `agent_profile` to worker `--profile` arg. v34: `POST /api/playbook/run` for planning-mode playbooks now returns `{ kind: 'requires-agent'; mode: 'planning'; name; message }` (HTTP 200) instead of creating a session-plan file; `PlaybookRunResponse` discriminated union becomes `enqueued | requires-agent` (removing `planning`); `mode` field added to `GET /api/playbook/list` entries and `GET /api/playbook/show` playbook object. v33: renames route `POST /api/playbook/enqueue` → `POST /api/playbook/run` (old route returns 404); adds new route `POST /api/session-plan/create-from-playbook`; `POST /api/playbook/run` now returns a discriminated union `{ kind: 'enqueued'; id }` or `{ kind: 'planning'; session; path }` based on playbook.mode; MCP/Pi action `'enqueue'` renamed to `'run'` on eforge_playbook tool; new MCP/Pi action `'create-from-playbook'` added to eforge_session_plan tool; playbook frontmatter now requires `mode` field. v32: adds input-source/enricher provenance events (`extension:input-source:fetched`, `extension:input-source:failed`, `extension:prd-enricher:applied`, `extension:prd-enricher:failed`). v31: adds persisted `daemon:auto-build:transition` event plus optional auto-build lifecycle detail fields (`desired`, `mode`, `scheduler`, `lastTransition`, `reason`) on auto-build snapshots and heartbeat payloads. v30: adds `error_transient_transport` to the closed `AgentTerminalSubtype` union used by `plan:build:failed.terminalSubtype` and `agent:retry.subtype`. v29: adds 'pending' to RunSummary.plans[].status; /api/run-summary/:id now seeds plans from the latest planning:complete event before overlaying plan:build:start/complete/failed (falls back to build events when planning:complete is absent). v28: per-build profile override (EnqueueRequest.profile, session:profile source 'override'). v27: adds `planning:decision` event variant with `PlanningDecisionSchema` (inner discriminated union over four planning-phase decision kinds: scope-selected, build-pipeline-chosen, review-profile-chosen, plan-set-shape); introduces `emitPlanningDecision` helper in engine; extends planner to emit decisions at planning:complete; adds planning decision rendering to monitor-ui. v26: adds `plan:build:decision` event variant with `BuildDecisionSchema` (inner discriminated union over seven decision kinds); introduces `emitBuildDecision` helper in engine; adds `decisions` slice to monitor-ui reducer. v25: adds `daemon:run:upsert` daemon-scoped persisted event as authoritative source of `DaemonState.runs`; removes run synthesis from `session:start` projector; removes run termination from `session:end` projector; drops `project` functions from `enqueue:start`/`enqueue:complete`/`enqueue:failed` (replaced by `daemon:run:upsert`); enriches `queue:prd:stale` with required `prdId`+`title` fields; enriches `queue:prd:commit-failed` with required `title` field; adds `project` functions for `queue:prd:stale` and `queue:prd:commit-failed` for live queue parity. v24: rowToRunInfo now maps nullable SQL columns (session_id, completed_at, pid) to undefined rather than null — wire shape conforms to DaemonRunRecordSchema (.optional(), not .nullable()); `enqueue:complete` event gains required `planSet` field (plan-set name, currently mirrors `title`). v23: stream:hello SSE handshake primitive; removal of the v18 resync-marker mechanism on initial daemon-events connect; removal of on-connect heartbeat write; removal of on-connect heartbeat write; snapshot envelope added to stream:hello for both daemon-events and per-session streams. +export const DAEMON_API_VERSION = 44; // v44: `afterQueueId` optional field added to `EnqueueRequest`; `POST /api/enqueue` validates and forwards `afterQueueId` to the enqueue worker via `--after `; explicit `afterQueueId` bypasses dependency-detector output in the engine enqueue path; older daemons would silently ignore `afterQueueId`, violating deterministic handoff semantics. v43: stack sync `retry-deferred` trigger added to route validation and event schemas; new `stack:sync:skipped` event variant added; `deferred` added to closed `StackSyncResponse.outcome` union; `activeBuildSkips` and `providerCommands` fields added to `StackSyncStatusWire`; `current.outcome` made optional in daemon stream snapshot schema. v42: `plan:build:review:fix:continuation` event variant added; review-fixer now has a retry policy with turn-budget continuation support. v41: `landing:auto-merge:start`, `landing:auto-merge:complete`, `landing:auto-merge:skipped` event variants added; `landingAutoMerge` (boolean) optional field added to `EnqueueRequest`, `PlaybookRunRequest`, `BuildOptions`, `EnqueueOptions`, and PRD frontmatter (`landing_auto_merge`); `landing.pr.autoMerge` config field added (`ask|always|never`, default `ask`). v40: `gap_close:complete.passed` is now a required field on the wire event; older clients/daemons that treated it as optional will disagree on the event schema. v39: landing action vocabulary changed from full strings (merge-to-base-branch|issue-pr|leave-branch) to canonical shorthands (pr|merge|leave) in EnqueueRequest.landingAction and PlaybookRunRequest.landingAction (replacing onSuccess); daemon rejects onSuccess in request bodies with a migration error; CLI workers spawned with --landing-action instead of --on-success. v38: `landing:start` wire event removes `feature-pr-after-local-merge` workflow literal and replaces it with `feature-pr`; older clients that validated the event against the previous schema union will reject events emitted by the new daemon. v37: optional `onSuccess` field added to `PlaybookRunRequest` (per-playbook-run landing action override: 'merge-to-base-branch' | 'issue-pr' | 'leave-branch'); daemon `/api/playbook/run` validates and passes `onSuccess` to `enqueuePrd(...)` for autonomous playbooks; older daemons would silently ignore the field. v36: optional `onSuccess` field added to `EnqueueRequest` (per-build landing action override: 'merge-to-base-branch' | 'issue-pr' | 'leave-branch'); `onSuccess` persisted in PRD frontmatter so override survives queue scheduler child-process boundaries; new `landing:start`, `landing:complete`, `landing:skipped` event variants added in plan-01. v35: optional `profile` field added to playbook frontmatter wire shapes (`PlaybookListEntry`, `PlaybookData`, `PlaybookFrontmatterFields`); optional `agent_profile` field added to `SessionPlanDataWire` and `SessionPlanCreateRequest`; `/api/playbook/run` for autonomous playbooks with `profile` persists profile in queued PRD frontmatter; `/api/session-plan/create` and `/api/session-plan/create-from-playbook` propagate `agent_profile`; `/api/enqueue` validates and propagates inherited session-plan `agent_profile` to worker `--profile` arg. v34: `POST /api/playbook/run` for planning-mode playbooks now returns `{ kind: 'requires-agent'; mode: 'planning'; name; message }` (HTTP 200) instead of creating a session-plan file; `PlaybookRunResponse` discriminated union becomes `enqueued | requires-agent` (removing `planning`); `mode` field added to `GET /api/playbook/list` entries and `GET /api/playbook/show` playbook object. v33: renames route `POST /api/playbook/enqueue` → `POST /api/playbook/run` (old route returns 404); adds new route `POST /api/session-plan/create-from-playbook`; `POST /api/playbook/run` now returns a discriminated union `{ kind: 'enqueued'; id }` or `{ kind: 'planning'; session; path }` based on playbook.mode; MCP/Pi action `'enqueue'` renamed to `'run'` on eforge_playbook tool; new MCP/Pi action `'create-from-playbook'` added to eforge_session_plan tool; playbook frontmatter now requires `mode` field. v32: adds input-source/enricher provenance events (`extension:input-source:fetched`, `extension:input-source:failed`, `extension:prd-enricher:applied`, `extension:prd-enricher:failed`). v31: adds persisted `daemon:auto-build:transition` event plus optional auto-build lifecycle detail fields (`desired`, `mode`, `scheduler`, `lastTransition`, `reason`) on auto-build snapshots and heartbeat payloads. v30: adds `error_transient_transport` to the closed `AgentTerminalSubtype` union used by `plan:build:failed.terminalSubtype` and `agent:retry.subtype`. v29: adds 'pending' to RunSummary.plans[].status; /api/run-summary/:id now seeds plans from the latest planning:complete event before overlaying plan:build:start/complete/failed (falls back to build events when planning:complete is absent). v28: per-build profile override (EnqueueRequest.profile, session:profile source 'override'). v27: adds `planning:decision` event variant with `PlanningDecisionSchema` (inner discriminated union over four planning-phase decision kinds: scope-selected, build-pipeline-chosen, review-profile-chosen, plan-set-shape); introduces `emitPlanningDecision` helper in engine; extends planner to emit decisions at planning:complete; adds planning decision rendering to monitor-ui. v26: adds `plan:build:decision` event variant with `BuildDecisionSchema` (inner discriminated union over seven decision kinds); introduces `emitBuildDecision` helper in engine; adds `decisions` slice to monitor-ui reducer. v25: adds `daemon:run:upsert` daemon-scoped persisted event as authoritative source of `DaemonState.runs`; removes run synthesis from `session:start` projector; removes run termination from `session:end` projector; drops `project` functions from `enqueue:start`/`enqueue:complete`/`enqueue:failed` (replaced by `daemon:run:upsert`); enriches `queue:prd:stale` with required `prdId`+`title` fields; enriches `queue:prd:commit-failed` with required `title` field; adds `project` functions for `queue:prd:stale` and `queue:prd:commit-failed` for live queue parity. v24: rowToRunInfo now maps nullable SQL columns (session_id, completed_at, pid) to undefined rather than null — wire shape conforms to DaemonRunRecordSchema (.optional(), not .nullable()); `enqueue:complete` event gains required `planSet` field (plan-set name, currently mirrors `title`). v23: stream:hello SSE handshake primitive; removal of the v18 resync-marker mechanism on initial daemon-events connect; removal of on-connect heartbeat write; removal of on-connect heartbeat write; snapshot envelope added to stream:hello for both daemon-events and per-session streams. diff --git a/packages/client/src/routes.ts b/packages/client/src/routes.ts index 1ea1564b..89ce4167 100644 --- a/packages/client/src/routes.ts +++ b/packages/client/src/routes.ts @@ -22,6 +22,24 @@ export interface EnqueueRequest { /** When true, enable GitHub PR auto-merge after PR creation (requires the effective landing action to be 'pr', whether supplied via landingAction or resolved from project config). */ landingAutoMerge?: boolean; // --- eforge:endregion plan-01-core-engine-auto-merge --- + // --- eforge:region plan-01-build-dependency-core --- + /** + * Optional upstream queue item id. When provided, the enqueued PRD gains + * `depends_on: [afterQueueId]` in its frontmatter. Placement depends on the + * upstream state: + * - Active upstream (pending/running/waiting): placed in `.eforge/queue/waiting/` + * and unblocked by the queue scheduler when the upstream completes. + * - Completed upstream with a usable artifact: placed in the queue root as an + * immediately eligible dependent (no waiting required). + * + * Failed, skipped, and unknown ids are rejected with an error containing the + * invalid id. + * + * Explicit `afterQueueId` takes precedence over any automatic dependency + * detection performed during enqueue. + */ + afterQueueId?: string; + // --- eforge:endregion plan-01-build-dependency-core --- } /** POST /api/auto-build */ diff --git a/packages/eforge/src/cli/index.ts b/packages/eforge/src/cli/index.ts index dd1e7cc1..e5a58a7b 100644 --- a/packages/eforge/src/cli/index.ts +++ b/packages/eforge/src/cli/index.ts @@ -500,6 +500,9 @@ export function createProgram(abortController?: AbortController, version?: strin .option('--landing-auto-merge', 'Enable PR auto-merge for this build') .option('--no-landing-auto-merge', 'Disable PR auto-merge for this build') // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + .option('--after ', 'Wait for an upstream queue item to complete before building') + // --- eforge:endregion plan-01-build-dependency-core --- .action( async ( source: string, @@ -512,6 +515,9 @@ export function createProgram(abortController?: AbortController, version?: strin // --- eforge:region plan-02-request-surfaces-and-pi-ux --- landingAutoMerge?: boolean; // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + after?: string; + // --- eforge:endregion plan-01-build-dependency-core --- }, ) => { let resolvedLandingAction: 'pr' | 'merge' | 'leave' | undefined; @@ -591,6 +597,9 @@ export function createProgram(abortController?: AbortController, version?: strin // --- eforge:region plan-02-request-surfaces-and-pi-ux --- ...(resolvedLandingAutoMerge !== undefined && { landingAutoMerge: resolvedLandingAutoMerge }), // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + ...(options.after !== undefined && { afterQueueId: options.after }), + // --- eforge:endregion plan-01-build-dependency-core --- }); } // --- eforge:endregion plan-02-enqueue-preprocessing-runtime --- @@ -631,6 +640,9 @@ export function createProgram(abortController?: AbortController, version?: strin .option('--landing-auto-merge', 'Enable PR auto-merge for this build') .option('--no-landing-auto-merge', 'Disable PR auto-merge for this build') // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + .option('--after ', 'Wait for an upstream queue item to complete before building') + // --- eforge:endregion plan-01-build-dependency-core --- .action( async ( source: string | undefined, @@ -652,6 +664,9 @@ export function createProgram(abortController?: AbortController, version?: strin // --- eforge:region plan-02-request-surfaces-and-pi-ux --- landingAutoMerge?: boolean; // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + after?: string; + // --- eforge:endregion plan-01-build-dependency-core --- }, ) => { let resolvedLandingActionBuild: 'pr' | 'merge' | 'leave' | undefined; @@ -745,6 +760,9 @@ export function createProgram(abortController?: AbortController, version?: strin // --- eforge:region plan-02-request-surfaces-and-pi-ux --- landingAutoMerge: resolvedLandingAutoMergeBuild, // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + afterQueueId: options.after, + // --- eforge:endregion plan-01-build-dependency-core --- }, abortController, onMonitor: (m) => { activeMonitor = m; }, diff --git a/packages/eforge/src/cli/run-or-delegate.ts b/packages/eforge/src/cli/run-or-delegate.ts index eadc74b9..27c5754f 100644 --- a/packages/eforge/src/cli/run-or-delegate.ts +++ b/packages/eforge/src/cli/run-or-delegate.ts @@ -83,6 +83,14 @@ export interface BuildRunOpts { /** Per-run PR auto-merge intent override. Requires landingAction: 'pr'. */ landingAutoMerge?: boolean; // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + /** + * Explicit upstream queue item id. When provided, the enqueued PRD gains + * `depends_on: [afterQueueId]` and is placed in waiting/ until the upstream + * completes. Overrides automatic dependency detection. + */ + afterQueueId?: string; + // --- eforge:endregion plan-01-build-dependency-core --- }; abortController?: AbortController; /** Called with the active monitor on start and undefined on teardown. */ @@ -332,6 +340,9 @@ async function runBuild(opts: BuildRunOpts): Promise { // --- eforge:region plan-02-request-surfaces-and-pi-ux --- ...(options.landingAutoMerge !== undefined && { landingAutoMerge: options.landingAutoMerge }), // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + ...(options.afterQueueId !== undefined && { afterQueueId: options.afterQueueId }), + // --- eforge:endregion plan-01-build-dependency-core --- }, }); const result = data as EnqueueResponse; @@ -431,6 +442,9 @@ async function runBuild(opts: BuildRunOpts): Promise { // Phase 1: Enqueue (with build-source preprocessing for session-plan and enricher support) let enqueuedName: string | undefined; let enqueueResult: 'completed' | 'failed' | 'skipped' = 'completed'; + // --- eforge:region plan-01-build-dependency-core --- + let enqueuedFilePath: string | undefined; + // --- eforge:endregion plan-01-build-dependency-core --- const enqueueSessionId = randomUUID(); await withRunMonitor(options.monitor === false, async (monitor) => { @@ -468,6 +482,9 @@ async function runBuild(opts: BuildRunOpts): Promise { // --- eforge:region plan-02-request-surfaces-and-pi-ux --- ...(options.landingAutoMerge !== undefined && { landingAutoMerge: options.landingAutoMerge }), // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + ...(options.afterQueueId !== undefined && { afterQueueId: options.afterQueueId }), + // --- eforge:endregion plan-01-build-dependency-core --- }); } @@ -487,6 +504,9 @@ async function runBuild(opts: BuildRunOpts): Promise { renderEvent(event); if (event.type === 'enqueue:complete') { enqueuedName = options.name ?? event.id; + // --- eforge:region plan-01-build-dependency-core --- + enqueuedFilePath = event.filePath; + // --- eforge:endregion plan-01-build-dependency-core --- } if (event.type === 'session:end') { enqueueResult = event.result.status; @@ -499,6 +519,21 @@ async function runBuild(opts: BuildRunOpts): Promise { return { code: 1 }; } + // --- eforge:region plan-01-build-dependency-core --- + // When afterQueueId is provided AND the PRD was placed in waiting/ (active + // upstream), do not attempt to run it immediately — the queue scheduler will + // unblock it when the upstream completes. + // If the upstream already completed with a usable artifact, the PRD lands in + // the queue root (enqueuedFilePath does NOT include /waiting/) and is + // immediately eligible, so we fall through to the runQueue path below. + // This guard must run before --dry-run so that a waiting PRD is not passed + // to runDryRun(), which searches only the queue root. + if (options.afterQueueId !== undefined && enqueuedFilePath?.includes('/waiting/')) { + console.log(chalk.dim(`PRD enqueued and waiting for upstream "${options.afterQueueId}" to complete.`)); + return { code: 0 }; + } + // --- eforge:endregion plan-01-build-dependency-core --- + // Path 2: --dry-run if (options.dryRun) { return runDryRun(engine, enqueuedName, options, abortController, onMonitor); diff --git a/packages/engine/src/eforge.ts b/packages/engine/src/eforge.ts index 8e9f17c4..33d80b1e 100644 --- a/packages/engine/src/eforge.ts +++ b/packages/engine/src/eforge.ts @@ -24,7 +24,7 @@ import type { RecoveryVerdict, BuildFailureSummary, } from './events.js'; -import { loadQueue, resolveQueueOrder, getHeadHash, getPrdDiffSummary, enqueuePrd, inferTitle, claimPrd, releasePrd, movePrdToSubdir, moveFailedWithSidecar, materializePrdArtifact, cleanupCompletedPrd, QueueExecExitCode, QueueSkipReason, propagateSkip as propagateSkipFS, unblockWaiting } from './prd-queue.js'; +import { loadQueue, resolveQueueOrder, getHeadHash, getPrdDiffSummary, enqueuePrd, inferTitle, claimPrd, releasePrd, movePrdToSubdir, moveFailedWithSidecar, materializePrdArtifact, cleanupCompletedPrd, QueueExecExitCode, QueueSkipReason, propagateSkip as propagateSkipFS, unblockWaiting, classifyAfterQueueId } from './prd-queue.js'; import { runStalenessAssessor } from './agents/staleness-assessor.js'; import { runRecoveryAnalyst } from './agents/recovery-analyst.js'; import { buildFailureSummary } from './recovery/failure-summary.js'; @@ -582,44 +582,59 @@ export class EforgeEngine { } // --- eforge:endregion plan-01-complete-ac-quality-gate --- - // Run dependency detection (graceful fallback on failure) + // --- eforge:region plan-01-build-dependency-core --- + // When an explicit afterQueueId is provided, classify the upstream and + // skip dependency-detector output. Otherwise, run dependency detection. let dependsOn: string[] = []; - try { - const queue = await loadQueue(this.config.prdQueue.dir, cwd); - const queueItems: QueueItemSummary[] = queue - .map((p) => ({ - id: p.id, - title: p.frontmatter.title, - scopeSummary: p.content.slice(0, 500), - })); - - // In CLI-only mode, running builds are not tracked via state.json. - // Daemon-mode dependency detection consults monitor data separately. - const runningBuilds: RunningBuildSummary[] = []; - - if (queueItems.length > 0 || runningBuilds.length > 0) { - const depDetectorConfig = resolveAgentConfig('dependency-detector', this.config); - const depGen = runDependencyDetector({ - ...depDetectorConfig, - prdContent: formattedBody, - queueItems, - runningBuilds, - verbose, - abortController, - phase: 'standalone', - harness: this.agentRuntimes.forRole('dependency-detector'), - }); - let depResult = await depGen.next(); - while (!depResult.done) { - yield depResult.value; - depResult = await depGen.next(); + let intoWaiting = false; + if (options.afterQueueId !== undefined) { + const classification = await classifyAfterQueueId( + options.afterQueueId, + this.config.prdQueue.dir, + cwd, + ); + dependsOn = classification.dependsOn; + intoWaiting = classification.intoWaiting; + } else { + // Run dependency detection (graceful fallback on failure) + try { + const queue = await loadQueue(this.config.prdQueue.dir, cwd); + const queueItems: QueueItemSummary[] = queue + .map((p) => ({ + id: p.id, + title: p.frontmatter.title, + scopeSummary: p.content.slice(0, 500), + })); + + // In CLI-only mode, running builds are not tracked via state.json. + // Daemon-mode dependency detection consults monitor data separately. + const runningBuilds: RunningBuildSummary[] = []; + + if (queueItems.length > 0 || runningBuilds.length > 0) { + const depDetectorConfig = resolveAgentConfig('dependency-detector', this.config); + const depGen = runDependencyDetector({ + ...depDetectorConfig, + prdContent: formattedBody, + queueItems, + runningBuilds, + verbose, + abortController, + phase: 'standalone', + harness: this.agentRuntimes.forRole('dependency-detector'), + }); + let depResult = await depGen.next(); + while (!depResult.done) { + yield depResult.value; + depResult = await depGen.next(); + } + dependsOn = depResult.value?.dependsOn ?? []; } - dependsOn = depResult.value?.dependsOn ?? []; + } catch { + // Dependency detection failure should not block enqueue + dependsOn = []; } - } catch { - // Dependency detection failure should not block enqueue - dependsOn = []; } + // --- eforge:endregion plan-01-build-dependency-core --- // Write to queue (filesystem-only — queue state is runtime, not tracked in git) const enqueueResult = await enqueuePrd({ @@ -628,6 +643,9 @@ export class EforgeEngine { queueDir: this.config.prdQueue.dir, cwd, depends_on: dependsOn, + // --- eforge:region plan-01-build-dependency-core --- + ...(intoWaiting && { intoWaiting: true }), + // --- eforge:endregion plan-01-build-dependency-core --- ...(options.profile !== undefined && { profile: options.profile }), ...(options.landingAction !== undefined && { landingAction: options.landingAction }), // --- eforge:region plan-01-core-engine-auto-merge --- diff --git a/packages/engine/src/events.ts b/packages/engine/src/events.ts index 32d8a944..7aa532e1 100644 --- a/packages/engine/src/events.ts +++ b/packages/engine/src/events.ts @@ -125,4 +125,12 @@ export interface EnqueueOptions { /** Stack provider override for this PRD. */ stack_provider?: 'git-spice'; // --- eforge:endregion plan-01-engine-config-and-landing --- + // --- eforge:region plan-01-build-dependency-core --- + /** + * Explicit upstream queue item id. When provided, the enqueued PRD gains + * `depends_on: [afterQueueId]` and placement is determined by upstream state. + * Overrides dependency-detector output when set. + */ + afterQueueId?: string; + // --- eforge:endregion plan-01-build-dependency-core --- } diff --git a/packages/engine/src/prd-queue.ts b/packages/engine/src/prd-queue.ts index 9da7446b..d9241da2 100644 --- a/packages/engine/src/prd-queue.ts +++ b/packages/engine/src/prd-queue.ts @@ -883,6 +883,131 @@ export async function setQueuedPrdStackParent( // Piggyback scheduling helpers // --------------------------------------------------------------------------- +// --- eforge:region plan-01-build-dependency-core --- +/** + * Result of classifying an explicit `afterQueueId` dependency. + */ +export interface AfterQueueClassification { + /** The dependency list to persist in PRD frontmatter (`depends_on`). */ + dependsOn: string[]; + /** + * Whether the new PRD should be placed in `.eforge/queue/waiting/`. + * True when the upstream is still active (pending, running, waiting). + * False when the upstream is already completed with a usable artifact. + */ + intoWaiting: boolean; +} + +/** + * Classify an explicit `afterQueueId` and return placement metadata. + * + * Classification rules (evaluated in order): + * 1. Active root queue item (pending/running) → `intoWaiting: true` + * 2. Active waiting queue item → `intoWaiting: true` + * 3. Live running upstream (lock file alive) → `intoWaiting: true` + * 4. Failed or skipped queue directory → throw with id in message + * 5. Completion registry: failed/skipped/completed-without-artifact → throw with id in message + * 6. Completed upstream with usable artifact → `intoWaiting: false` + * 7. Unknown id → throw with id in message + * + * Throws an `Error` whose message contains the `afterQueueId` value for all + * invalid or non-actionable upstream states. + */ +export async function classifyAfterQueueId( + afterQueueId: string, + queueDir: string, + cwd: string, +): Promise { + // 1 & 2: Check active root/waiting queue items + const [pendingPrds, waitingPrds] = await Promise.all([ + loadQueue(queueDir, cwd).catch((): QueuedPrd[] => []), + loadQueue(`${queueDir}/waiting`, cwd).catch((): QueuedPrd[] => []), + ]); + + if (pendingPrds.some((p) => p.id === afterQueueId)) { + return { dependsOn: [afterQueueId], intoWaiting: true }; + } + if (waitingPrds.some((p) => p.id === afterQueueId)) { + return { dependsOn: [afterQueueId], intoWaiting: true }; + } + + // 3: Check live running upstream (lock file alive) - handles race where PRD + // file may have been consumed but lock is still live at classification time + const lockStatus = await readPrdLockStatus(afterQueueId, cwd); + if (lockStatus.state === 'live') { + return { dependsOn: [afterQueueId], intoWaiting: true }; + } + + // 4: Check terminal state directories (failed, skipped) — must come before + // artifact registry so a stale usable-artifact record cannot mask a failed + // or skipped upstream. + const [failedPrds, skippedPrds] = await Promise.all([ + loadQueue(`${queueDir}/failed`, cwd).catch((): QueuedPrd[] => []), + loadQueue(`${queueDir}/skipped`, cwd).catch((): QueuedPrd[] => []), + ]); + + if (failedPrds.some((p) => p.id === afterQueueId)) { + throw new Error( + `afterQueueId "${afterQueueId}" references a failed upstream queue item. ` + + `Only pending, running, waiting, or completed-with-artifact items can be used as upstream dependencies.`, + ); + } + if (skippedPrds.some((p) => p.id === afterQueueId)) { + throw new Error( + `afterQueueId "${afterQueueId}" references a skipped upstream queue item. ` + + `Only pending, running, waiting, or completed-with-artifact items can be used as upstream dependencies.`, + ); + } + + // 5: Check completion registry for terminal states — also before artifact + // registry so that failed/skipped/completed-without-artifact completion + // records override any stale artifact entry. + const completionRegistry = await loadCompletionRegistry(cwd); + const completionRecord = lookupCompletion(completionRegistry, afterQueueId); + if (completionRecord) { + if (completionRecord.status === 'failed') { + throw new Error( + `afterQueueId "${afterQueueId}" references a failed upstream (completion registry). ` + + `Only pending, running, waiting, or completed-with-artifact items can be used as upstream dependencies.`, + ); + } + if (completionRecord.status === 'skipped') { + throw new Error( + `afterQueueId "${afterQueueId}" references a skipped upstream (completion registry). ` + + `Only pending, running, waiting, or completed-with-artifact items can be used as upstream dependencies.`, + ); + } + if (completionRecord.status === 'completed' && !completionRecord.artifactAvailable) { + throw new Error( + `afterQueueId "${afterQueueId}" references a completed upstream without a usable artifact. ` + + `Re-run the upstream build to produce a usable artifact before adding dependents.`, + ); + } + } + + // 6: Check artifact registry — completed with usable artifact → ready immediately. + // Only reached when no terminal/non-artifact state has overridden it above. + const registry = await loadArtifactRegistry(cwd); + if (hasUsableArtifact(registry, afterQueueId)) { + return { dependsOn: [afterQueueId], intoWaiting: false }; + } + + // completed with artifactAvailable in completion registry but no durable artifact — inconsistency + if (completionRecord?.status === 'completed') { + throw new Error( + `afterQueueId "${afterQueueId}" references a completed upstream without a durable artifact in the registry. ` + + `Re-run the upstream build to produce a usable artifact before adding dependents.`, + ); + } + + // 7: Unknown id + throw new Error( + `afterQueueId "${afterQueueId}" references an unknown queue item. ` + + `Only pending, running, waiting, or completed-with-artifact queue items can be used as upstream dependencies.`, + ); +} +// --- eforge:endregion plan-01-build-dependency-core --- + /** * Find all PRDs in the given array that list `upstreamId` in their `depends_on`. */ @@ -955,8 +1080,13 @@ export async function validateDependsOnExists( for (const dep of depends_on) { // 1. Active root/waiting queue item: accept. if (existingIds.has(dep)) continue; + // 2. Live running upstream (lock file alive): accept. Handles the race where + // the PRD file has been consumed by the worker but the lock is still live. + // eslint-disable-next-line no-await-in-loop + const lockStatus = await readPrdLockStatus(dep, cwd); + if (lockStatus.state === 'live') continue; // --- eforge:region plan-02-artifact-registry-dependency-readiness --- - // 2. Failed/skipped queue directory item: error containing "artifact". + // 3. Failed/skipped queue directory item: error containing "artifact". // Failed/skipped queue items never satisfy dependencies, even if an old // artifact record is still present from an earlier successful attempt. if (terminalIds.has(dep)) { diff --git a/packages/monitor/src/server.ts b/packages/monitor/src/server.ts index 0a999212..e90eec22 100644 --- a/packages/monitor/src/server.ts +++ b/packages/monitor/src/server.ts @@ -2096,7 +2096,7 @@ export async function startServer( return true; } try { - const body = await parseJsonBody(req) as { source?: string; flags?: string[]; profile?: string; landingAction?: string; onSuccess?: unknown; landingAutoMerge?: unknown }; + const body = await parseJsonBody(req) as { source?: string; flags?: string[]; profile?: string; landingAction?: string; onSuccess?: unknown; landingAutoMerge?: unknown; afterQueueId?: unknown }; if (!body.source || typeof body.source !== 'string') { sendJsonError(res, 400, 'Missing required field: source'); return true; @@ -2152,6 +2152,42 @@ export async function startServer( explicitLandingAutoMerge = body.landingAutoMerge; } // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + // Validate afterQueueId: reject non-string, validate string values by + // classifying the upstream state. The worker re-runs classification + // on spawn to handle races where the upstream completes in between. + let validatedAfterQueueId: string | undefined; + if (body.afterQueueId !== undefined) { + if (typeof body.afterQueueId !== 'string') { + sendJsonError(res, 400, 'Invalid field: afterQueueId must be a string'); + return true; + } + if (cwd) { + let classifyFn: typeof import('@eforge-build/engine/prd-queue').classifyAfterQueueId; + let queueDir: string; + try { + const [prdQueueModule, configModule] = await Promise.all([ + import('@eforge-build/engine/prd-queue'), + import('@eforge-build/engine/config'), + ]); + classifyFn = prdQueueModule.classifyAfterQueueId; + const { config: cfg } = await configModule.loadConfig(cwd); + queueDir = cfg.prdQueue.dir; + } catch (importErr) { + sendJsonError(res, 500, `Server error loading dependencies: ${importErr instanceof Error ? importErr.message : String(importErr)}`); + return true; + } + try { + await classifyFn(body.afterQueueId, queueDir, cwd); + } catch (classifyErr) { + const msg = classifyErr instanceof Error ? classifyErr.message : `Invalid afterQueueId: ${body.afterQueueId}`; + sendJsonError(res, 400, msg); + return true; + } + } + validatedAfterQueueId = body.afterQueueId; + } + // --- eforge:endregion plan-01-build-dependency-core --- // --- eforge:region plan-01-per-build-profile-override --- // Validate explicit profile override before spawning any worker. let explicitProfileName: string | undefined; @@ -2239,6 +2275,11 @@ export async function startServer( args.push('--no-landing-auto-merge'); } // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-01-build-dependency-core --- + if (validatedAfterQueueId) { + args.push('--after', validatedAfterQueueId); + } + // --- eforge:endregion plan-01-build-dependency-core --- // --- eforge:region plan-01-semantic-enqueue-wake --- // Wake is now driven by the persisted enqueue:complete DB event via the // daemon semantic-event reaction path (daemon-event-reactions.ts). diff --git a/scripts/agent-maintainability-baseline.json b/scripts/agent-maintainability-baseline.json index dec4b100..7bac5f17 100644 --- a/scripts/agent-maintainability-baseline.json +++ b/scripts/agent-maintainability-baseline.json @@ -8,12 +8,12 @@ }, { "path": "packages/monitor/src/server.ts", - "noGrowthCeiling": 4908, + "noGrowthCeiling": 4915, "category": "implementation" }, { "path": "packages/engine/src/eforge.ts", - "noGrowthCeiling": 2787, + "noGrowthCeiling": 2805, "category": "implementation" }, { @@ -33,7 +33,7 @@ }, { "path": "packages/eforge/src/cli/index.ts", - "noGrowthCeiling": 1955, + "noGrowthCeiling": 1973, "category": "implementation" }, { @@ -78,7 +78,7 @@ }, { "path": "packages/engine/src/prd-queue.ts", - "noGrowthCeiling": 1132, + "noGrowthCeiling": 1250, "category": "implementation" }, { @@ -171,6 +171,16 @@ "noGrowthCeiling": 613, "category": "implementation" }, + { + "path": "packages/client/src/routes.ts", + "noGrowthCeiling": 604, + "category": "implementation" + }, + { + "path": "packages/eforge/src/cli/run-or-delegate.ts", + "noGrowthCeiling": 607, + "category": "implementation" + }, { "path": "packages/client/src/__tests__/events-schemas.test.ts", "noGrowthCeiling": 4079, @@ -243,7 +253,12 @@ }, { "path": "test/daemon-recovery.test.ts", - "noGrowthCeiling": 1226, + "noGrowthCeiling": 1230, + "category": "test" + }, + { + "path": "test/playbook-api.test.ts", + "noGrowthCeiling": 1284, "category": "test" } ] diff --git a/test/daemon-recovery.test.ts b/test/daemon-recovery.test.ts index 314afc36..ca8c5992 100644 --- a/test/daemon-recovery.test.ts +++ b/test/daemon-recovery.test.ts @@ -125,7 +125,11 @@ afterEach(async () => { // --------------------------------------------------------------------------- describe('DAEMON_API_VERSION', () => { - it('is 43', () => { + it('is 44', () => { + // v44: `afterQueueId` optional field added to `EnqueueRequest`; `POST /api/enqueue` + // validates and forwards `afterQueueId` to the enqueue worker via `--after `; + // explicit `afterQueueId` in engine enqueue bypasses dependency-detector output; + // older daemons would silently ignore `afterQueueId`, violating deterministic handoff semantics. // v43: stack sync `retry-deferred` trigger added to route validation and event schemas; // `stack:sync:failed` no longer emitted for skipped outcomes; `deferred` added to closed // `StackSyncResponse.outcome` union; `activeBuildSkips` and `providerCommands` fields added to @@ -146,7 +150,7 @@ describe('DAEMON_API_VERSION', () => { // v38: `landing:start` wire event removes `feature-pr-after-local-merge` workflow literal // and replaces it with `feature-pr`; older clients that validated the event against the // previous schema union will reject events emitted by the new daemon. - expect(DAEMON_API_VERSION).toBe(43); + expect(DAEMON_API_VERSION).toBe(44); }); }); diff --git a/test/extension-tooling-wiring.test.ts b/test/extension-tooling-wiring.test.ts index d741aaf5..d040cb08 100644 --- a/test/extension-tooling-wiring.test.ts +++ b/test/extension-tooling-wiring.test.ts @@ -764,3 +764,64 @@ describe('MCP/Pi eforge_extension parity', () => { expect(source).toContain('trustProjectExtensions'); }); }); + +// --- eforge:region plan-01-build-dependency-core --- +describe('CLI --after flag wiring', () => { + it('eforge enqueue command declares --after option', () => { + const cliIndexSource = readRepoFile('packages/eforge/src/cli/index.ts'); + // The enqueue command should register --after + const enqueueBlock = cliIndexSource.slice( + cliIndexSource.indexOf(".command('enqueue ')"), + cliIndexSource.indexOf(".command('build [source]')"), + ); + expect(enqueueBlock).toContain("--after "); + expect(enqueueBlock).toContain('afterQueueId: options.after'); + }); + + it('eforge build command declares --after option', () => { + const cliIndexSource = readRepoFile('packages/eforge/src/cli/index.ts'); + const buildBlock = cliIndexSource.slice( + cliIndexSource.indexOf(".command('build [source]')"), + cliIndexSource.indexOf(".command('monitor')"), + ); + expect(buildBlock).toContain("--after "); + expect(buildBlock).toContain('afterQueueId: options.after'); + }); + + it('run-or-delegate BuildRunOpts declares afterQueueId option', () => { + const rodSource = readRepoFile('packages/eforge/src/cli/run-or-delegate.ts'); + expect(rodSource).toContain('afterQueueId?: string'); + }); + + it('run-or-delegate daemon delegation path includes afterQueueId in apiEnqueue body', () => { + const rodSource = readRepoFile('packages/eforge/src/cli/run-or-delegate.ts'); + expect(rodSource).toContain('afterQueueId: options.afterQueueId'); + }); + + it('run-or-delegate in-process enqueue path passes afterQueueId to engine.enqueue', () => { + const rodSource = readRepoFile('packages/eforge/src/cli/run-or-delegate.ts'); + // The afterQueueId spread must appear in the engine.enqueue call + const enqueueCall = rodSource.slice( + rodSource.indexOf('yield* engine.enqueue(normalizedSource,'), + rodSource.indexOf('yield* engine.enqueue(normalizedSource,') + 800, + ); + expect(enqueueCall).toContain('afterQueueId'); + }); + + it('EnqueueRequest exposes optional afterQueueId field', () => { + const routesSource = readRepoFile('packages/client/src/routes.ts'); + expect(routesSource).toContain('afterQueueId?: string'); + // Should be inside the EnqueueRequest interface + const enqueueRequestBlock = routesSource.slice( + routesSource.indexOf('export interface EnqueueRequest {'), + routesSource.indexOf('}', routesSource.indexOf('export interface EnqueueRequest {')), + ); + expect(enqueueRequestBlock).toContain('afterQueueId?: string'); + }); + + it('EnqueueOptions exposes optional afterQueueId field', () => { + const eventsSource = readRepoFile('packages/engine/src/events.ts'); + expect(eventsSource).toContain('afterQueueId?: string'); + }); +}); +// --- eforge:endregion plan-01-build-dependency-core --- diff --git a/test/playbook-api.test.ts b/test/playbook-api.test.ts index 5daa8e07..9b3f3a92 100644 --- a/test/playbook-api.test.ts +++ b/test/playbook-api.test.ts @@ -1180,3 +1180,105 @@ describe('POST /api/playbook/run - landingAutoMerge persistence', () => { }); // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + +// --- eforge:region plan-01-build-dependency-core --- + +// --------------------------------------------------------------------------- +// Route: POST /api/enqueue — afterQueueId validation +// --------------------------------------------------------------------------- + +// Recording workerTracker so tests can inspect spawned args +function makeRecordingWorkerTracker(): WorkerTracker & { calls: Array<{ command: string; args: string[] }> } { + const calls: Array<{ command: string; args: string[] }> = []; + return { + calls, + spawnWorker(command: string, args: string[]): { sessionId: string; pid: number } { + calls.push({ command, args }); + return { sessionId: 'rec-session', pid: 88888 }; + }, + cancelWorker(_sessionId: string): boolean { + return false; + }, + }; +} + +describe('POST /api/enqueue - afterQueueId validation', () => { + it('returns 400 when afterQueueId is not a string (number)', async () => { + await setup({ workerTracker: makeStubWorkerTracker() }); + + const res = await post(`http://localhost:${server.port}${API_ROUTES.enqueue}`, { + source: 'implement a new feature', + afterQueueId: 42, + }); + expect(res.status).toBe(400); + + const data = await res.json() as { error: string }; + expect(data.error).toContain('afterQueueId'); + expect(data.error).toContain('string'); + }); + + it('returns 400 when afterQueueId is not a string (boolean)', async () => { + await setup({ workerTracker: makeStubWorkerTracker() }); + + const res = await post(`http://localhost:${server.port}${API_ROUTES.enqueue}`, { + source: 'implement a new feature', + afterQueueId: true, + }); + expect(res.status).toBe(400); + + const data = await res.json() as { error: string }; + expect(data.error).toContain('afterQueueId'); + }); + + it('returns 400 with the invalid id in error text for an unknown afterQueueId', async () => { + const { tmpDir } = await init(); + // Initialize git repo so loadQueue works + execFileSync('git', ['init', '-b', 'main'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir }); + await start(tmpDir, { workerTracker: makeStubWorkerTracker() }); + + const res = await post(`http://localhost:${server.port}${API_ROUTES.enqueue}`, { + source: 'implement a new feature', + afterQueueId: 'nonexistent-q-abc', + }); + expect(res.status).toBe(400); + + const data = await res.json() as { error: string }; + expect(data.error).toContain('nonexistent-q-abc'); + }); + + it('passes --after to enqueue worker when afterQueueId is valid (active root item)', async () => { + const { tmpDir, configDir } = await init(); + execFileSync('git', ['init', '-b', 'main'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir }); + + // Write an active PRD to the queue root + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + await mkdir(queueDir, { recursive: true }); + await writeFile( + resolve(queueDir, 'active-upstream.md'), + '---\ntitle: active-upstream\ncreated: 2026-01-01\n---\n\n# Active upstream\n', + 'utf-8', + ); + + const tracker = makeRecordingWorkerTracker(); + await start(tmpDir, { workerTracker: tracker }); + + const res = await post(`http://localhost:${server.port}${API_ROUTES.enqueue}`, { + source: 'implement a dependent feature', + afterQueueId: 'active-upstream', + }); + expect(res.status).toBe(200); + + // Worker should have been spawned with --after active-upstream + const call = tracker.calls.find((c) => c.command === 'enqueue'); + expect(call).toBeDefined(); + expect(call!.args).toContain('--after'); + const afterIdx = call!.args.indexOf('--after'); + expect(call!.args[afterIdx + 1]).toBe('active-upstream'); + }); +}); + +// --- eforge:endregion plan-01-build-dependency-core --- diff --git a/test/queue-piggyback.test.ts b/test/queue-piggyback.test.ts index 8ae9057f..eaccc8ce 100644 --- a/test/queue-piggyback.test.ts +++ b/test/queue-piggyback.test.ts @@ -17,6 +17,7 @@ import { validateDependsOnExists, enqueuePrd, loadQueue, + classifyAfterQueueId, type QueuedPrd, } from '@eforge-build/engine/prd-queue'; import { upsertArtifact, upsertCompletion } from '@eforge-build/engine/artifacts'; @@ -719,3 +720,200 @@ describe('enqueuePrd with intoWaiting', () => { expect(prd!.frontmatter.depends_on).toEqual(['upstream-build']); }); }); + +// --------------------------------------------------------------------------- +// classifyAfterQueueId — placement helper +// --------------------------------------------------------------------------- + +// --- eforge:region plan-01-build-dependency-core --- +describe('classifyAfterQueueId', () => { + const makeTempDir = useTempDir('eforge-classify-after-'); + + it('returns intoWaiting: true for an active root queue item', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + writePrdToQueue(cwd, queueDir, 'pending-upstream'); + + const result = await classifyAfterQueueId('pending-upstream', queueDir, cwd); + expect(result).toEqual({ dependsOn: ['pending-upstream'], intoWaiting: true }); + }); + + it('returns intoWaiting: true for an active waiting queue item', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + writePrdToWaiting(cwd, queueDir, 'waiting-upstream', ['some-parent']); + + const result = await classifyAfterQueueId('waiting-upstream', queueDir, cwd); + expect(result).toEqual({ dependsOn: ['waiting-upstream'], intoWaiting: true }); + }); + + it('returns intoWaiting: false for a completed upstream with usable artifact', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // No queue file — upstream has already completed + await recordArtifact(cwd, 'completed-upstream'); + + const result = await classifyAfterQueueId('completed-upstream', queueDir, cwd); + expect(result).toEqual({ dependsOn: ['completed-upstream'], intoWaiting: false }); + }); + + it('throws for a failed upstream in the failed/ directory', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + const failedDir = join(cwd, queueDir, 'failed'); + mkdirSync(failedDir, { recursive: true }); + writeFileSync( + join(failedDir, 'failed-upstream.md'), + '---\ntitle: failed-upstream\n---\n\n# failed\n', + ); + + await expect( + classifyAfterQueueId('failed-upstream', queueDir, cwd), + ).rejects.toThrow('failed-upstream'); + }); + + it('throws for a skipped upstream in the skipped/ directory', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + const skippedDir = join(cwd, queueDir, 'skipped'); + mkdirSync(skippedDir, { recursive: true }); + writeFileSync( + join(skippedDir, 'skipped-upstream.md'), + '---\ntitle: skipped-upstream\n---\n\n# skipped\n', + ); + + await expect( + classifyAfterQueueId('skipped-upstream', queueDir, cwd), + ).rejects.toThrow('skipped-upstream'); + }); + + it('throws for a completed upstream without a usable artifact (completion registry)', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + const now = new Date().toISOString(); + await upsertCompletion(cwd, { + prdId: 'completed-no-artifact', + status: 'completed', + artifactAvailable: false, + completedAt: now, + updatedAt: now, + }); + + await expect( + classifyAfterQueueId('completed-no-artifact', queueDir, cwd), + ).rejects.toThrow('completed-no-artifact'); + }); + + it('throws for an unknown upstream id', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + + await expect( + classifyAfterQueueId('nonexistent-id', queueDir, cwd), + ).rejects.toThrow('nonexistent-id'); + }); + + it('error messages contain the afterQueueId for all failure cases', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + + const unknownErr = await classifyAfterQueueId('unknown-xyz', queueDir, cwd).catch((e: Error) => e); + expect(unknownErr).toBeInstanceOf(Error); + expect((unknownErr as Error).message).toContain('unknown-xyz'); + }); + + it('throws for a failed upstream even when a stale usable artifact record exists', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // Stale artifact record for the same id + await recordArtifact(cwd, 'stale-failed'); + // But the PRD is in the failed/ directory + const failedDir = join(cwd, queueDir, 'failed'); + writeFileSync( + join(failedDir, 'stale-failed.md'), + '---\ntitle: stale-failed\n---\n\n# stale-failed\n', + ); + + await expect( + classifyAfterQueueId('stale-failed', queueDir, cwd), + ).rejects.toThrow('stale-failed'); + }); + + it('throws for a skipped upstream even when a stale usable artifact record exists', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // Stale artifact record for the same id + await recordArtifact(cwd, 'stale-skipped'); + // But the PRD is in the skipped/ directory + const skippedDir = join(cwd, queueDir, 'skipped'); + writeFileSync( + join(skippedDir, 'stale-skipped.md'), + '---\ntitle: stale-skipped\n---\n\n# stale-skipped\n', + ); + + await expect( + classifyAfterQueueId('stale-skipped', queueDir, cwd), + ).rejects.toThrow('stale-skipped'); + }); + + it('throws for a completion-registry failed upstream even when a stale usable artifact record exists', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // Stale artifact record + await recordArtifact(cwd, 'stale-completion-failed'); + // But the completion registry says failed + const now = new Date().toISOString(); + await upsertCompletion(cwd, { + prdId: 'stale-completion-failed', + status: 'failed', + artifactAvailable: false, + completedAt: now, + updatedAt: now, + }); + + await expect( + classifyAfterQueueId('stale-completion-failed', queueDir, cwd), + ).rejects.toThrow('stale-completion-failed'); + }); + + it('throws for a completion-registry skipped upstream even when a stale usable artifact record exists', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // Stale artifact record + await recordArtifact(cwd, 'stale-completion-skipped'); + // But the completion registry says skipped + const now = new Date().toISOString(); + await upsertCompletion(cwd, { + prdId: 'stale-completion-skipped', + status: 'skipped', + artifactAvailable: false, + completedAt: now, + updatedAt: now, + }); + + await expect( + classifyAfterQueueId('stale-completion-skipped', queueDir, cwd), + ).rejects.toThrow('stale-completion-skipped'); + }); + + it('throws for a completion-registry completed-without-artifact upstream even when a stale usable artifact record exists', async () => { + const dir = makeTempDir(); + const { cwd, queueDir } = setupGitQueue(dir); + // Stale artifact record + await recordArtifact(cwd, 'stale-completed-no-artifact'); + // But the completion registry says completed without artifact + const now = new Date().toISOString(); + await upsertCompletion(cwd, { + prdId: 'stale-completed-no-artifact', + status: 'completed', + artifactAvailable: false, + completedAt: now, + updatedAt: now, + }); + + await expect( + classifyAfterQueueId('stale-completed-no-artifact', queueDir, cwd), + ).rejects.toThrow('stale-completed-no-artifact'); + }); +}); +// --- eforge:endregion plan-01-build-dependency-core --- diff --git a/web/content/reference/cli.md b/web/content/reference/cli.md index dd746efa..e18397bf 100644 --- a/web/content/reference/cli.md +++ b/web/content/reference/cli.md @@ -25,6 +25,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `build` @@ -53,6 +54,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `monitor` diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index a4d9c933..18fd6864 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -27,6 +27,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `build` @@ -55,6 +56,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `monitor` diff --git a/web/public/reference/cli.md b/web/public/reference/cli.md index dd746efa..e18397bf 100644 --- a/web/public/reference/cli.md +++ b/web/public/reference/cli.md @@ -25,6 +25,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `build` @@ -53,6 +54,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | +| `--after ` | Wait for an upstream queue item to complete before building | ### `monitor` From 1e87349f0adb539a32bd621aa9c84901a1a6de5c Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Wed, 27 May 2026 23:46:00 -0700 Subject: [PATCH 4/9] feat(plan-02-playbook-placement-parity): Playbook Dependency Placement Parity Models-Used: claude-sonnet-4-6, gpt-5.5 Co-Authored-By: forged-by-eforge --- packages/eforge/src/cli/run-or-delegate.ts | 4 +- packages/monitor/src/server.ts | 36 ++-- scripts/agent-maintainability-baseline.json | 10 +- test/playbook-api.test.ts | 202 +++++++++++++++++++- 4 files changed, 230 insertions(+), 22 deletions(-) diff --git a/packages/eforge/src/cli/run-or-delegate.ts b/packages/eforge/src/cli/run-or-delegate.ts index 27c5754f..dfaee98d 100644 --- a/packages/eforge/src/cli/run-or-delegate.ts +++ b/packages/eforge/src/cli/run-or-delegate.ts @@ -16,7 +16,7 @@ import chalk from 'chalk'; import { randomUUID } from 'node:crypto'; -import { resolve } from 'node:path'; +import { resolve, dirname } from 'node:path'; import { stat as fsStat, readFile as fsReadFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; @@ -528,7 +528,7 @@ async function runBuild(opts: BuildRunOpts): Promise { // immediately eligible, so we fall through to the runQueue path below. // This guard must run before --dry-run so that a waiting PRD is not passed // to runDryRun(), which searches only the queue root. - if (options.afterQueueId !== undefined && enqueuedFilePath?.includes('/waiting/')) { + if (options.afterQueueId !== undefined && enqueuedFilePath !== undefined && dirname(resolve(enqueuedFilePath)) === resolve(engine.resolvedConfig.prdQueue.dir, 'waiting')) { console.log(chalk.dim(`PRD enqueued and waiting for upstream "${options.afterQueueId}" to complete.`)); return { code: 0 }; } diff --git a/packages/monitor/src/server.ts b/packages/monitor/src/server.ts index e90eec22..220c7988 100644 --- a/packages/monitor/src/server.ts +++ b/packages/monitor/src/server.ts @@ -3733,7 +3733,9 @@ export async function startServer( sendJsonError(res, 400, 'Invalid playbook name (must be kebab-case)'); return true; } - const afterQueueId = typeof body.afterQueueId === 'string' ? body.afterQueueId : undefined; + if (body.afterQueueId !== undefined && typeof body.afterQueueId !== 'string') { sendJsonError(res, 400, 'Invalid field: afterQueueId must be a string'); return true; } + if (body.afterQueueId !== undefined && body.afterQueueId === '') { sendJsonError(res, 400, 'Invalid field: afterQueueId must not be empty'); return true; } + const afterQueueId = body.afterQueueId as string | undefined; // Reject legacy onSuccess with a migration pointer. if (body.onSuccess !== undefined) { sendJsonError(res, 400, 'Field "onSuccess" is no longer supported. Use "landingAction: pr|merge|leave" instead.'); @@ -3829,34 +3831,40 @@ export async function startServer( } } // --- eforge:endregion plan-01-core-profile-propagation --- - // --- eforge:region plan-05-piggyback-and-queue-scheduling --- - const { enqueuePrd, inferTitle, validateDependsOnExists } = await import('@eforge-build/engine/prd-queue'); - // --- eforge:endregion plan-05-piggyback-and-queue-scheduling --- + // --- eforge:region plan-02-playbook-placement-parity --- + const { enqueuePrd, inferTitle, classifyAfterQueueId: classifyPlaybookUpstream } = await import('@eforge-build/engine/prd-queue'); + // --- eforge:endregion plan-02-playbook-placement-parity --- const queueDir = options?.queueDir ?? '.eforge/queue'; const title = inferTitle(plan.source, plan.name); - // --- eforge:region plan-05-piggyback-and-queue-scheduling --- - // Validate upstream exists before enqueueing; reject with 404 if not found. + // --- eforge:region plan-02-playbook-placement-parity --- + // Classify upstream state and determine placement; reject with 404 if the + // upstream is invalid or non-actionable (failed, skipped, unknown, or + // completed without a usable artifact). + let playbookDependsOn: string[] | undefined; + let playbookIntoWaiting = false; if (afterQueueId) { try { - await validateDependsOnExists([afterQueueId], queueDir, cwd); - } catch (validationErr) { - const msg = validationErr instanceof Error ? validationErr.message : 'Invalid afterQueueId'; + const placement = await classifyPlaybookUpstream(afterQueueId, queueDir, cwd); + playbookDependsOn = placement.dependsOn; + playbookIntoWaiting = placement.intoWaiting; + } catch (classifyErr) { + const msg = classifyErr instanceof Error ? classifyErr.message : `Invalid afterQueueId: ${afterQueueId}`; sendJsonError(res, 404, msg); return true; } } - // --- eforge:endregion plan-05-piggyback-and-queue-scheduling --- + // --- eforge:endregion plan-02-playbook-placement-parity --- const result = await enqueuePrd({ body: plan.source, title, queueDir, cwd, - depends_on: afterQueueId ? [afterQueueId] : undefined, - // --- eforge:region plan-05-piggyback-and-queue-scheduling --- - intoWaiting: afterQueueId ? true : false, - // --- eforge:endregion plan-05-piggyback-and-queue-scheduling --- + depends_on: playbookDependsOn, + // --- eforge:region plan-02-playbook-placement-parity --- + intoWaiting: playbookIntoWaiting, + // --- eforge:endregion plan-02-playbook-placement-parity --- postMerge: plan.postMerge, // --- eforge:region plan-01-core-profile-propagation --- profile: plan.profile, diff --git a/scripts/agent-maintainability-baseline.json b/scripts/agent-maintainability-baseline.json index 7bac5f17..5a70111c 100644 --- a/scripts/agent-maintainability-baseline.json +++ b/scripts/agent-maintainability-baseline.json @@ -8,7 +8,7 @@ }, { "path": "packages/monitor/src/server.ts", - "noGrowthCeiling": 4915, + "noGrowthCeiling": 4932, "category": "implementation" }, { @@ -78,7 +78,7 @@ }, { "path": "packages/engine/src/prd-queue.ts", - "noGrowthCeiling": 1250, + "noGrowthCeiling": 1262, "category": "implementation" }, { @@ -173,12 +173,12 @@ }, { "path": "packages/client/src/routes.ts", - "noGrowthCeiling": 604, + "noGrowthCeiling": 608, "category": "implementation" }, { "path": "packages/eforge/src/cli/run-or-delegate.ts", - "noGrowthCeiling": 607, + "noGrowthCeiling": 618, "category": "implementation" }, { @@ -258,7 +258,7 @@ }, { "path": "test/playbook-api.test.ts", - "noGrowthCeiling": 1284, + "noGrowthCeiling": 1484, "category": "test" } ] diff --git a/test/playbook-api.test.ts b/test/playbook-api.test.ts index 9b3f3a92..661c627e 100644 --- a/test/playbook-api.test.ts +++ b/test/playbook-api.test.ts @@ -17,6 +17,7 @@ import { openDatabase } from '@eforge-build/monitor/db'; import { startServer, type DaemonState, type MonitorServer, type StartServerOptions, type WorkerTracker } from '@eforge-build/monitor/server'; import { API_ROUTES } from '@eforge-build/client'; import { AutoBuildSupervisor, type AutoBuildQueueMutationReason } from '@eforge-build/monitor/auto-build-supervisor'; +import { upsertArtifact, upsertCompletion } from '@eforge-build/engine/artifacts'; const makeTempDir = useTempDir('eforge-playbook-api-'); @@ -520,7 +521,8 @@ describe('POST /api/playbook/run', () => { expect(res.status).toBe(404); const data = await res.json() as { error: string }; - expect(data.error).toContain('depends_on references unknown queue item: "missing-upstream"'); + expect(data.error).toContain('missing-upstream'); + expect(data.error).toContain('unknown queue item'); const queueDir = resolve(tmpDir, '.eforge', 'queue'); await expect(readdir(queueDir)).rejects.toThrow(); @@ -999,6 +1001,204 @@ describe('POST /api/playbook/run — profile field', () => { expect(autoBuildWakeReasons).toEqual([]); }); + // --- eforge:region plan-02-playbook-placement-parity --- + + it('writes dependent PRD to waiting/ when autonomous upstream is active (in queue root)', async () => { + const { tmpDir, configDir } = await init(); + + // Write a playbook to use as the dependent + const teamDir = resolve(configDir, 'playbooks'); + await mkdir(teamDir, { recursive: true }); + await writeFile(resolve(teamDir, 'my-dependent.md'), validPlaybookRaw({ name: 'my-dependent', mode: 'autonomous' }), 'utf-8'); + + // Write an active PRD directly to the queue root (simulating an in-progress upstream) + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + await mkdir(queueDir, { recursive: true }); + const upstreamId = 'active-upstream-for-playbook'; + await writeFile( + resolve(queueDir, `${upstreamId}.md`), + `---\ntitle: Active Upstream\ncreated: 2026-01-01\n---\n\n# Active Upstream\n`, + 'utf-8', + ); + + await start(tmpDir, { daemonState: makeDaemonState() }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.playbookRun}`, { + name: 'my-dependent', + afterQueueId: upstreamId, + }); + expect(res.status).toBe(200); + + const data = await res.json() as { kind: string; id: string }; + expect(data.kind).toBe('enqueued'); + + // Dependent must be in waiting/ not queue root + const waitingFile = resolve(queueDir, 'waiting', `${data.id}.md`); + await expect(access(waitingFile)).resolves.toBeUndefined(); + // Must NOT be in queue root + await expect(access(resolve(queueDir, `${data.id}.md`))).rejects.toThrow(); + + const content = await readFile(waitingFile, 'utf-8'); + expect(content).toContain('depends_on'); + expect(content).toContain(upstreamId); + + expect(autoBuildWakeReasons).toContain('playbook-enqueue'); + }); + + it('writes dependent PRD to queue root when autonomous upstream is completed with usable artifact', async () => { + const { tmpDir, configDir } = await init(); + + const teamDir = resolve(configDir, 'playbooks'); + await mkdir(teamDir, { recursive: true }); + await writeFile(resolve(teamDir, 'my-dependent.md'), validPlaybookRaw({ name: 'my-dependent', mode: 'autonomous' }), 'utf-8'); + + // Record a usable artifact for a completed upstream (no queue file — already completed) + const upstreamId = 'completed-upstream-with-artifact'; + const now = new Date().toISOString(); + await upsertArtifact(tmpDir, { + prdId: upstreamId, + artifactBranch: `eforge/${upstreamId}`, + commitSha: 'abc123', + resolvedBase: 'main', + landingAction: 'leave', + status: 'built', + recordedAt: now, + updatedAt: now, + }); + + await start(tmpDir, { daemonState: makeDaemonState() }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.playbookRun}`, { + name: 'my-dependent', + afterQueueId: upstreamId, + }); + expect(res.status).toBe(200); + + const data = await res.json() as { kind: string; id: string }; + expect(data.kind).toBe('enqueued'); + + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + // Dependent must be in queue root (NOT waiting/) because upstream is already completed + const queueFile = resolve(queueDir, `${data.id}.md`); + await expect(access(queueFile)).resolves.toBeUndefined(); + // Must NOT be in waiting/ + const waitingFile = resolve(queueDir, 'waiting', `${data.id}.md`); + await expect(access(waitingFile)).rejects.toThrow(); + + const content = await readFile(queueFile, 'utf-8'); + expect(content).toContain('depends_on'); + expect(content).toContain(upstreamId); + + expect(autoBuildWakeReasons).toContain('playbook-enqueue'); + }); + + it('returns 404 and does not enqueue when autonomous upstream is in failed/ directory', async () => { + const { tmpDir, configDir } = await init(); + + const teamDir = resolve(configDir, 'playbooks'); + await mkdir(teamDir, { recursive: true }); + await writeFile(resolve(teamDir, 'my-dependent.md'), validPlaybookRaw({ name: 'my-dependent', mode: 'autonomous' }), 'utf-8'); + + // Write an upstream PRD to the failed/ directory + const failedDir = resolve(tmpDir, '.eforge', 'queue', 'failed'); + await mkdir(failedDir, { recursive: true }); + const upstreamId = 'failed-upstream'; + await writeFile( + resolve(failedDir, `${upstreamId}.md`), + `---\ntitle: Failed Upstream\ncreated: 2026-01-01\n---\n\n# Failed Upstream\n`, + 'utf-8', + ); + + await start(tmpDir, { daemonState: makeDaemonState() }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.playbookRun}`, { + name: 'my-dependent', + afterQueueId: upstreamId, + }); + expect(res.status).toBe(404); + + const data = await res.json() as { error: string }; + expect(data.error).toContain(upstreamId); + + // No dependent should have been written + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + const files = await readdir(queueDir); + expect(files.filter((f) => f.endsWith('.md'))).toHaveLength(0); + expect(autoBuildWakeReasons).toEqual([]); + }); + + it('returns 404 and does not enqueue when autonomous upstream is in skipped/ directory', async () => { + const { tmpDir, configDir } = await init(); + + const teamDir = resolve(configDir, 'playbooks'); + await mkdir(teamDir, { recursive: true }); + await writeFile(resolve(teamDir, 'my-dependent.md'), validPlaybookRaw({ name: 'my-dependent', mode: 'autonomous' }), 'utf-8'); + + // Write an upstream PRD to the skipped/ directory + const skippedDir = resolve(tmpDir, '.eforge', 'queue', 'skipped'); + await mkdir(skippedDir, { recursive: true }); + const upstreamId = 'skipped-upstream'; + await writeFile( + resolve(skippedDir, `${upstreamId}.md`), + `---\ntitle: Skipped Upstream\ncreated: 2026-01-01\n---\n\n# Skipped Upstream\n`, + 'utf-8', + ); + + await start(tmpDir, { daemonState: makeDaemonState() }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.playbookRun}`, { + name: 'my-dependent', + afterQueueId: upstreamId, + }); + expect(res.status).toBe(404); + + const data = await res.json() as { error: string }; + expect(data.error).toContain(upstreamId); + + // No dependent should have been written + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + const files = await readdir(queueDir); + expect(files.filter((f) => f.endsWith('.md'))).toHaveLength(0); + expect(autoBuildWakeReasons).toEqual([]); + }); + + it('returns 404 and does not enqueue when autonomous upstream completed without usable artifact', async () => { + const { tmpDir, configDir } = await init(); + + const teamDir = resolve(configDir, 'playbooks'); + await mkdir(teamDir, { recursive: true }); + await writeFile(resolve(teamDir, 'my-dependent.md'), validPlaybookRaw({ name: 'my-dependent', mode: 'autonomous' }), 'utf-8'); + + // Record completion without artifact for the upstream + const upstreamId = 'completed-no-artifact-upstream'; + const now = new Date().toISOString(); + await upsertCompletion(tmpDir, { + prdId: upstreamId, + status: 'completed', + artifactAvailable: false, + completedAt: now, + updatedAt: now, + }); + + await start(tmpDir, { daemonState: makeDaemonState() }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.playbookRun}`, { + name: 'my-dependent', + afterQueueId: upstreamId, + }); + expect(res.status).toBe(404); + + const data = await res.json() as { error: string }; + expect(data.error).toContain(upstreamId); + + // No dependent should have been written + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + await expect(readdir(queueDir)).rejects.toThrow(); + expect(autoBuildWakeReasons).toEqual([]); + }); + + // --- eforge:endregion plan-02-playbook-placement-parity --- + it('save/show/list round-trip includes profile field and required mode', async () => { await setup(); From c88c17d563f50789154299c392319979856f4466 Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 00:28:58 -0700 Subject: [PATCH 5/9] feat(plan-03-consumer-surfaces-docs): Consumer Surfaces and Documentation Models-Used: claude-sonnet-4-6, gpt-5.5 Co-Authored-By: forged-by-eforge --- README.md | 6 + docs/architecture.md | 4 +- docs/config.md | 9 +- docs/stacking.md | 4 + eforge-plugin/.claude-plugin/plugin.json | 2 +- eforge-plugin/skills/build/build.md | 7 +- packages/eforge/src/cli/index.ts | 4 +- packages/eforge/src/cli/mcp-proxy.ts | 11 +- .../extensions/eforge/build-command.ts | 75 +++++++++- packages/pi-eforge/extensions/eforge/index.ts | 8 ++ .../pi-eforge/skills/eforge-build/SKILL.md | 5 +- scripts/agent-maintainability-baseline.json | 6 +- test/build-profile-selection-skill.test.ts | 60 ++++++++ test/pi-build-command.test.ts | 132 +++++++++++++++++- test/profile-wiring.test.ts | 57 ++++++++ web/content/docs/configuration.md | 4 +- web/content/docs/stacking.md | 4 + web/public/docs/configuration.md | 2 + web/public/docs/stacking.md | 4 + 19 files changed, 386 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 55748f87..0936b649 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,12 @@ Standalone CLI: npx @eforge-build/eforge build "Add rate limiting to the API" npx @eforge-build/eforge build plans/my-feature-prd.md +# Deterministic handoff: enqueue a build that waits for an upstream build to finish +# Use --after to create an explicit dependency on an active build. +# Active upstream (pending/running/waiting): held until upstream completes. +# Completed upstream with artifact: enqueued immediately as an eligible dependent. +npx @eforge-build/eforge build "Add e2e tests for rate limiting" --after q-abc123 + # Run a saved playbook npx @eforge-build/eforge play docs-sync diff --git a/docs/architecture.md b/docs/architecture.md index a15b87c7..44d97d72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -289,7 +289,9 @@ The **daemon** (`eforge daemon start`) is a long-running process that watches th **Auto-build** mode (default) automatically processes PRDs on enqueue. The daemon spawns a worker process for each build, tracking progress via SQLite. Failed builds pause auto-build until manually restarted. The daemon shuts down after a configurable idle timeout. -**Piggyback scheduling** — PRDs with a non-empty `dependsOn` array are held in a `waiting` state and not dispatched until each referenced upstream entry reaches a terminal state. On any terminal transition the dispatcher re-evaluates all `waiting` entries: upstream `completed` removes that id from the dependent's unsatisfied set and, once the set is empty, transitions the dependent to `pending` so the normal concurrency limit picks it up; upstream `failed` or `cancelled` transitions the dependent directly to `skipped` with a reason of `upstream `. Skip propagation is recursive - if a `skipped` entry itself has dependents, those also become `skipped`. The `eforge queue list` command renders piggybacked entries indented under their parent (two-space indent with an `↳ ` prefix); the monitor UI applies the same nesting using shadcn primitives. Piggybacked playbooks auto-enqueue their generated plan directly into the build pipeline without an interactive review gate - the user approved the playbook contents at authoring time. +**Explicit deterministic handoff** — Pass `afterQueueId` to the `eforge_build` MCP/Pi tool or `--after ` to the CLI to create an explicit dependency on an upstream queue entry. Active upstream items (pending/running/waiting) cause the dependent PRD to be placed in `.eforge/queue/waiting/` and unblocked when the upstream reaches a terminal state. Completed upstream items with a usable artifact cause the dependent PRD to be enqueued immediately as an eligible dependent in the queue root. Failed, skipped, and unknown IDs are rejected at enqueue time. Explicit `afterQueueId` takes precedence over any automatic dependency detection performed during enqueue. Dependency detector inference remains best effort and is used only when no explicit dependency is supplied. + +**Piggyback scheduling** — PRDs with unsatisfied active dependencies (upstream entries still in `pending`, `running`, or `waiting/`) are held in a `waiting` state and not dispatched until each such upstream entry reaches a terminal state. PRDs whose dependencies are already completed with usable artifacts are eligible immediately and remain in the queue root rather than `waiting/`. On any terminal transition the dispatcher re-evaluates all `waiting` entries: upstream `completed` removes that id from the dependent's unsatisfied set and, once the set is empty, transitions the dependent to `pending` so the normal concurrency limit picks it up; upstream `failed` or `cancelled` transitions the dependent directly to `skipped` with a reason of `upstream `. Skip propagation is recursive - if a `skipped` entry itself has dependents, those also become `skipped`. The `eforge queue list` command renders piggybacked entries indented under their parent (two-space indent with an `↳ ` prefix); the monitor UI applies the same nesting using shadcn primitives. Piggybacked playbooks auto-enqueue their generated plan directly into the build pipeline without an interactive review gate - the user approved the playbook contents at authoring time. When a build fails, the queue parent's finalize handler runs the recovery-analyst agent inline (synchronously, before the PRD is moved) against the still-present `state.json`. The PRD is moved to `failed/` and both sidecar files (`.recovery.md`, `.recovery.json`) are written via filesystem-only operations - there is no window where `failed/` has a PRD without sidecars. Queue state lives under `.eforge/queue/` which is gitignored; no git commits are produced for queue mutations. The recovery-analyst operates with `tools: 'none'` and runs under a 90-second timeout; on any error or timeout, a `manual` verdict sidecar is written anyway. The sidecar schema is v2, which adds optional `partial` and `recoveryError` fields to the verdict for degraded-context cases. Recovery can also be triggered manually via the `eforge_recover` MCP tool (Claude Code plugin), the `recover` Pi tool, or `eforge recover` CLI - useful for backfilling sidecars on PRDs already in `failed/` before this architecture was in place. When `state.json` is missing (manual backfill scenario), `buildFailureSummary` synthesizes a partial summary from monitor.db events and git history, setting `partial: true` on the result. The resulting sidecar can be read back through `eforge_read_recovery_sidecar` / `readRecoverySidecar`. Once a verdict has been reviewed, `applyRecovery(prdId)` on `EforgeEngine` enacts it: `retry` moves the PRD back to the queue root and removes both sidecars (filesystem-only); `split` routes the agent's successor PRD body through `enqueuePrd` with `depends_on: []` — stripping any agent-emitted frontmatter before writing so the successor always starts with clean, dependency-free frontmatter — leaving the failed PRD and sidecars in place; `abandon` removes all three paths under `.eforge/queue/failed/`; `manual` is a no-op that returns `noAction: true`. The apply path is exposed as `POST /api/recover/apply` on the daemon, the `eforge_apply_recovery` MCP tool (Claude Code plugin and Pi), and the `eforge apply-recovery ` CLI subcommand. The `/api/queue` endpoint post-filters `dependsOn` before responding: terminal items (`failed`, `skipped`) never expose a `dependsOn` field, and live items (`pending`, `running`) expose only `dependsOn` IDs that match other live items in the same response - mirroring `resolveQueueOrder`'s runtime semantics so the UI's view of dependencies is always consistent with what the scheduler actually acts on. diff --git a/docs/config.md b/docs/config.md index e80dcda3..b6c44a15 100644 --- a/docs/config.md +++ b/docs/config.md @@ -166,6 +166,13 @@ prdQueue: dir: .eforge/queue # Where queued PRDs are stored (gitignored — runtime state) autoBuild: true # Daemon automatically builds after enqueue watchPollIntervalMs: 5000 # Poll interval for watch mode (ms) + # Explicit build dependency (per-enqueue, not a config key): + # Pass --after to the CLI or afterQueueId to the eforge_build tool + # to create a deterministic dependency on an active or completed queue entry. + # Active upstream items (pending/running/waiting) are held in .eforge/queue/waiting/ + # and unblocked when the upstream completes. Completed upstream items with a usable + # artifact are enqueued immediately as eligible dependents. Explicit afterQueueId + # takes precedence over automatic dependency detection (which remains best effort). daemon: idleShutdownMs: 7200000 # Idle timeout before auto-shutdown (2 hours). Set to 0 to disable. @@ -855,7 +862,7 @@ eforge has two dimensions of parallelism: Controls the maximum number of PRDs built concurrently when processing the queue (`eforge build --queue` or `eforge queue run`). Default: `2`. -PRDs with `depends_on` frontmatter are held in a `waiting` state until their upstream builds reach a terminal state. When an upstream build completes, its dependents transition from `waiting` to `pending` and are dispatched normally. If an upstream build fails or is cancelled, all transitive dependents transition to `skipped` with a reason recording the upstream id and terminal state. Skip propagation is recursive - if a `skipped` entry itself has dependents, those also become `skipped`. +PRDs with `depends_on` frontmatter whose upstream builds are still active (pending, running, or waiting) are held in a `waiting/` subdirectory until each upstream reaches a terminal state. PRDs whose upstream dependencies have already completed with usable artifacts are eligible immediately and remain in the queue root rather than `waiting/`. When an active upstream build completes, its waiting dependents transition from `waiting` to `pending` and are dispatched normally. If an upstream build fails or is cancelled, all transitive dependents transition to `skipped` with a reason recording the upstream id and terminal state. Skip propagation is recursive - if a `skipped` entry itself has dependents, those also become `skipped`. CLI override: `--max-concurrent-builds ` diff --git a/docs/stacking.md b/docs/stacking.md index a3979a8c..517201c7 100644 --- a/docs/stacking.md +++ b/docs/stacking.md @@ -41,6 +41,10 @@ When a PRD has exactly one `depends_on` entry and stacking is enabled, eforge au When a PRD has multiple `depends_on` entries, eforge cannot infer the stack parent unambiguously. You must set `stack_parent` explicitly to indicate which dependency is the direct parent layer. +### Explicit handoff and stack parent + +When you use `--after ` (CLI) or `afterQueueId` (MCP/Pi tool) to create an explicit dependency, the resulting single `depends_on` entry participates in the same stack parent inference described above. If stacking is enabled and the explicit dependency is the only `depends_on` entry, eforge infers `stack_parent` from it at dispatch time — no extra configuration is needed. The explicit handoff is deterministic: dependency detector inference is not used when `afterQueueId` is supplied. + ### Multi-dependency ambiguity Multi-dependency stacking requires explicit `stack_parent`: diff --git a/eforge-plugin/.claude-plugin/plugin.json b/eforge-plugin/.claude-plugin/plugin.json index acd516ed..88ee3c1c 100644 --- a/eforge-plugin/.claude-plugin/plugin.json +++ b/eforge-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "eforge", "description": "Conversational planning and MCP-based daemon integration for eforge — enqueue, run, and monitor builds from within Claude Code", - "version": "0.25.33", + "version": "0.25.34", "commands": [ "./skills/build/build.md", "./skills/status/status.md", diff --git a/eforge-plugin/skills/build/build.md b/eforge-plugin/skills/build/build.md index 551f2f6c..29b129ef 100644 --- a/eforge-plugin/skills/build/build.md +++ b/eforge-plugin/skills/build/build.md @@ -1,6 +1,6 @@ --- description: Enqueue a source for the eforge daemon to build via MCP tool -argument-hint: "[source] [--infer] [--profile ] [--landing-action ]" +argument-hint: "[source] [--infer] [--profile ] [--landing-action ] [--after ]" disable-model-invocation: true --- @@ -16,12 +16,13 @@ Enqueue a PRD file or description for the eforge daemon to build. Uses the eforg - `--landing-action ` (optional) - Override the landing action for this build. One of `pr`, `merge`, or `leave`. Precedence: this argument > PRD frontmatter > `landing.action` in `eforge/config.yaml` > engine default (`merge`). If omitted, the project config default applies. Note: `merge` on the trunk branch requires `build.allowLocalMergeToTrunk: true` in `eforge/config.yaml`. - `--landing-auto-merge` (optional) - Enable GitHub PR auto-merge for this build. Only applies when the effective landing action is `pr`. Sends `landingAutoMerge: true` in the enqueue body, overriding the configured `landing.pr.autoMerge` policy for this run. - `--no-landing-auto-merge` (optional) - Disable GitHub PR auto-merge for this build. Only applies when the effective landing action is `pr`. Sends `landingAutoMerge: false` in the enqueue body, overriding the configured `landing.pr.autoMerge` policy for this run. +- `--after ` (optional) - Explicit upstream dependency. When provided, the enqueued PRD gains `depends_on: [queue-id]`. Active upstream items (pending/running/waiting) are held in `waiting/` and start automatically when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. This is a deterministic handoff - it takes precedence over any automatic dependency detection. A single explicit dependency becomes the stack parent when stacking is enabled. ## Workflow ### Step 1: Resolve Source Input -Parse and remember any `--profile ` override before resolving the source. Determine the working source from one of four branches: +Parse and remember any `--profile ` override and any `--after ` flag before resolving the source. Remove both flags from `$ARGUMENTS` before source resolution so they are not mistaken for build content. Keep the remembered queue id to include as `afterQueueId` in the enqueue call at Step 5. Determine the working source from one of four branches: **Branch A — File path**: If `$ARGUMENTS` is a file path (ends in `.md`, `.txt`, `.yaml`, or contains `/`): 1. Verify the file exists with the Read tool @@ -175,7 +176,7 @@ First, validate the project config by calling the `mcp__eforge__eforge_config` t - If `valid` is `true`, continue silently. -Call the `mcp__eforge__eforge_build` tool with `{ source: "" }`. If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** +Call the `mcp__eforge__eforge_build` tool with `{ source: "" }`. If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If `$ARGUMENTS` contains `--after `, extract the queue id and include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when `--after` is not present in `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** Present the landing selector conversationally with the four choices (Use project default, pr, merge, leave) before calling the tool, unless an explicit override is already in `$ARGUMENTS`. On protected trunk with unsafe effective default, exclude merge and project-default-as-merge from the normal options and offer the remediation choices instead (pr, leave, update config if applicable, cancel). diff --git a/packages/eforge/src/cli/index.ts b/packages/eforge/src/cli/index.ts index e5a58a7b..df39d689 100644 --- a/packages/eforge/src/cli/index.ts +++ b/packages/eforge/src/cli/index.ts @@ -501,7 +501,7 @@ export function createProgram(abortController?: AbortController, version?: strin .option('--no-landing-auto-merge', 'Disable PR auto-merge for this build') // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- // --- eforge:region plan-01-build-dependency-core --- - .option('--after ', 'Wait for an upstream queue item to complete before building') + .option('--after ', 'Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact') // --- eforge:endregion plan-01-build-dependency-core --- .action( async ( @@ -641,7 +641,7 @@ export function createProgram(abortController?: AbortController, version?: strin .option('--no-landing-auto-merge', 'Disable PR auto-merge for this build') // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- // --- eforge:region plan-01-build-dependency-core --- - .option('--after ', 'Wait for an upstream queue item to complete before building') + .option('--after ', 'Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact') // --- eforge:endregion plan-01-build-dependency-core --- .action( async ( diff --git a/packages/eforge/src/cli/mcp-proxy.ts b/packages/eforge/src/cli/mcp-proxy.ts index cb71793d..10450d2b 100644 --- a/packages/eforge/src/cli/mcp-proxy.ts +++ b/packages/eforge/src/cli/mcp-proxy.ts @@ -215,8 +215,14 @@ export async function runMcpProxy(cwd: string): Promise { .optional() .describe("When true and landingAction is 'pr' (or default), enable GitHub PR auto-merge. When false, explicitly disable auto-merge even if the project default is 'always'. Omit to use the project default."), // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-03-consumer-surfaces-docs --- + afterQueueId: z + .string() + .optional() + .describe("Optional upstream queue entry ID. When provided, the enqueued PRD gains depends_on: [afterQueueId]. Active upstream items (pending/running/waiting) are held in waiting/ and start when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. Failed, skipped, and unknown IDs are rejected."), + // --- eforge:endregion plan-03-consumer-surfaces-docs --- }, - handler: async ({ source, profile, landingAction, landingAutoMerge }, { cwd: toolCwd }) => { + handler: async ({ source, profile, landingAction, landingAutoMerge, afterQueueId }, { cwd: toolCwd }) => { const { resolveAndValidateLandingFlags: resolveFlags, CLILandingFlagError: LandingFlagError } = await import('./landing-options.js'); let resolvedLandingAction: 'pr' | 'merge' | 'leave' | undefined; try { @@ -231,6 +237,9 @@ export async function runMcpProxy(cwd: string): Promise { // --- eforge:region plan-02-request-surfaces-and-pi-ux --- if (landingAutoMerge !== undefined) body.landingAutoMerge = landingAutoMerge; // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-03-consumer-surfaces-docs --- + if (afterQueueId !== undefined) body.afterQueueId = afterQueueId; + // --- eforge:endregion plan-03-consumer-surfaces-docs --- const { data, port } = await daemonRequest(toolCwd, 'POST', API_ROUTES.enqueue, body); return { ...data, monitorUrl: `http://localhost:${port}` }; }, diff --git a/packages/pi-eforge/extensions/eforge/build-command.ts b/packages/pi-eforge/extensions/eforge/build-command.ts index b72cef95..e45a7f6b 100644 --- a/packages/pi-eforge/extensions/eforge/build-command.ts +++ b/packages/pi-eforge/extensions/eforge/build-command.ts @@ -18,8 +18,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { apiListProfilesIfRunning, apiSessionPlanListIfRunning, + apiGetQueueIfRunning, type AgentRuntimeProfileInfo, type SessionPlanListEntryWire, + type QueueItem, } from "@eforge-build/client"; import { DAEMON_NOT_RUNNING_GUIDANCE } from "./daemon-requests.js"; import { @@ -76,6 +78,67 @@ function hasLandingOverride(args: string): boolean { // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- } +// --- eforge:region plan-03-consumer-surfaces-docs --- + +/** + * Returns true when args already contain an explicit --after override so the + * active-build wait selector should be bypassed. + */ +function hasAfterOverride(args: string): boolean { + return /(?:^|\s)--after(?:\s|=|$)/.test(args); +} + +/** Fetch active (pending/running/queued/waiting) queue items for dependency selection. */ +async function fetchActiveBuildsForDependency(cwd: string): Promise { + try { + const result = await apiGetQueueIfRunning({ cwd }); + if (result === null) return []; + return result.data.filter( + (item) => + item.status === "running" || + item.status === "pending" || + item.status === "queued" || + item.status === "waiting", + ); + } catch { + return []; + } +} + +/** + * Show active-build wait selector. Returns updated args string with `--after ` appended + * when the user picks an upstream, original args when "Run now" is chosen, or null if cancelled. + */ +async function selectActiveBuildsForDependency(ctx: UIContext, args: string): Promise { + if (hasAfterOverride(args)) return args; + + const activeItems = await withLoader(ctx, "Checking queue...", () => + fetchActiveBuildsForDependency(ctx.cwd), + ); + if (activeItems.length === 0) return args; + + const waitOptions = [ + { + value: "__now__", + label: "Run now", + description: "Enqueue immediately, no dependency", + }, + ...activeItems.map((item) => ({ + value: item.id, + label: `Wait for: ${item.title}`, + description: `[${item.status}] Runs after this build finishes`, + })), + ]; + + const choice = await showSelectPanel(ctx, "eforge - Active Builds Detected", waitOptions); + if (!choice) return null; + if (choice === "__now__") return args; + + return `${args} --after ${quoteSkillArg(choice)}`; +} + +// --- eforge:endregion plan-03-consumer-surfaces-docs --- + async function selectProfileArgs(ctx: UIContext, args: string): Promise { if (hasProfileOverride(args)) return args; @@ -190,8 +253,12 @@ export async function handleBuildCommand( // If explicit landing override already in args, bypass the landing selector if (hasLandingOverride(argsWithProfile)) { - sendBuildSkill(pi, argsWithProfile); + // --- eforge:region plan-03-consumer-surfaces-docs --- + const afterArgsWithLanding = await selectActiveBuildsForDependency(ctx, argsWithProfile); + if (afterArgsWithLanding === null) return; + sendBuildSkill(pi, afterArgsWithLanding); return; + // --- eforge:endregion plan-03-consumer-surfaces-docs --- } // Landing selection: show the full selector with "Use project default" @@ -210,5 +277,9 @@ export async function handleBuildCommand( } // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- - sendBuildSkill(pi, finalArgs); + // --- eforge:region plan-03-consumer-surfaces-docs --- + const finalArgsWithDep = await selectActiveBuildsForDependency(ctx, finalArgs); + if (finalArgsWithDep === null) return; + sendBuildSkill(pi, finalArgsWithDep); + // --- eforge:endregion plan-03-consumer-surfaces-docs --- } diff --git a/packages/pi-eforge/extensions/eforge/index.ts b/packages/pi-eforge/extensions/eforge/index.ts index 854e103c..bba05cc9 100644 --- a/packages/pi-eforge/extensions/eforge/index.ts +++ b/packages/pi-eforge/extensions/eforge/index.ts @@ -336,6 +336,11 @@ export default function eforgeExtension(pi: ExtensionAPI) { description: "When true and landingAction is 'pr' (or default), enable GitHub PR auto-merge — the PR will be merged automatically once all required checks pass. When false, explicitly disable auto-merge even if the project default is 'always'. Omit to use the project default.", })), // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-03-consumer-surfaces-docs --- + afterQueueId: Type.Optional(Type.String({ + description: "Optional upstream queue entry ID. When provided, the enqueued PRD gains depends_on: [afterQueueId]. Active upstream items (pending/running/waiting) are held in waiting/ and start when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. Failed, skipped, and unknown IDs are rejected.", + })), + // --- eforge:endregion plan-03-consumer-surfaces-docs --- }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const policyChoice = await promptForBuildLandingGate( @@ -359,6 +364,9 @@ export default function eforgeExtension(pi: ExtensionAPI) { const effectiveLandingAutoMerge = policyChoice.landingAutoMerge ?? params.landingAutoMerge; if (effectiveLandingAutoMerge !== undefined) body.landingAutoMerge = effectiveLandingAutoMerge; // --- eforge:endregion plan-02-request-surfaces-and-pi-ux --- + // --- eforge:region plan-03-consumer-surfaces-docs --- + if (params.afterQueueId !== undefined) body.afterQueueId = params.afterQueueId; + // --- eforge:endregion plan-03-consumer-surfaces-docs --- const { data, port } = await requireDaemon( ctx.cwd, "POST", diff --git a/packages/pi-eforge/skills/eforge-build/SKILL.md b/packages/pi-eforge/skills/eforge-build/SKILL.md index 0bd0ed3d..ba4ea76c 100644 --- a/packages/pi-eforge/skills/eforge-build/SKILL.md +++ b/packages/pi-eforge/skills/eforge-build/SKILL.md @@ -16,12 +16,13 @@ Enqueue a PRD file or description for the eforge daemon to build. Uses the eforg - `--landing-action ` (optional) - Override the landing action for this build. One of `pr`, `merge`, or `leave`. Precedence: this argument > PRD frontmatter > `landing.action` in `eforge/config.yaml` > engine default (`merge`). If omitted, the project config default applies. Note: `merge` on the trunk branch requires `build.allowLocalMergeToTrunk: true` in `eforge/config.yaml`. - `--landing-auto-merge` (optional) - Enable GitHub PR auto-merge for this build. Only applies when the effective landing action is `pr`. Sends `landingAutoMerge: true` in the enqueue body, overriding the configured `landing.pr.autoMerge` policy for this run. - `--no-landing-auto-merge` (optional) - Disable GitHub PR auto-merge for this build. Only applies when the effective landing action is `pr`. Sends `landingAutoMerge: false` in the enqueue body, overriding the configured `landing.pr.autoMerge` policy for this run. +- `--after ` (optional) - Explicit upstream dependency. When provided, the enqueued PRD gains `depends_on: [queue-id]`. Active upstream items (pending/running/waiting) are held in `waiting/` and start automatically when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. This is a deterministic handoff - it takes precedence over any automatic dependency detection. A single explicit dependency becomes the stack parent when stacking is enabled. ## Workflow ### Step 1: Resolve Source Input -Parse and remember any `--profile ` override before resolving the source. Determine the working source from one of four branches: +Parse and remember any `--profile ` override and any `--after ` flag before resolving the source. Remove both flags from `$ARGUMENTS` before source resolution so they are not mistaken for build content. Keep the remembered queue id to include as `afterQueueId` in the enqueue call at Step 5. Determine the working source from one of four branches: **Branch A — File path**: If `$ARGUMENTS` is a file path (ends in `.md`, `.txt`, `.yaml`, or contains `/`): 1. Verify the file exists with the Read tool @@ -159,7 +160,7 @@ First, validate the project config by calling the `eforge_config` tool with `{ a - If `valid` is `true`, continue silently. -Call the `eforge_build` tool with `{ source: "" }`, using the latest working source (including the edited `source` returned by `eforge_confirm_build` on confirmation for non-file-path sources). If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** +Call the `eforge_build` tool with `{ source: "" }`, using the latest working source (including the edited `source` returned by `eforge_confirm_build` on confirmation for non-file-path sources). If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If `$ARGUMENTS` contains `--after `, extract the queue id and include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when `--after` is not present in `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** The Pi native `/eforge:build` command presents the landing selector before invoking this skill. By the time the skill reaches the enqueue step in UI mode, the landing decision is already encoded in `$ARGUMENTS`: either an explicit override was appended, or no override was appended (meaning project default applies). Do not show the landing selector again if an explicit override is already present in `$ARGUMENTS`. diff --git a/scripts/agent-maintainability-baseline.json b/scripts/agent-maintainability-baseline.json index 5a70111c..3957cbb0 100644 --- a/scripts/agent-maintainability-baseline.json +++ b/scripts/agent-maintainability-baseline.json @@ -8,7 +8,7 @@ }, { "path": "packages/monitor/src/server.ts", - "noGrowthCeiling": 4932, + "noGrowthCeiling": 4934, "category": "implementation" }, { @@ -28,7 +28,7 @@ }, { "path": "packages/pi-eforge/extensions/eforge/index.ts", - "noGrowthCeiling": 2553, + "noGrowthCeiling": 2561, "category": "implementation" }, { @@ -58,7 +58,7 @@ }, { "path": "packages/eforge/src/cli/mcp-proxy.ts", - "noGrowthCeiling": 1246, + "noGrowthCeiling": 1255, "category": "implementation" }, { diff --git a/test/build-profile-selection-skill.test.ts b/test/build-profile-selection-skill.test.ts index b6aee4b4..ae3ee619 100644 --- a/test/build-profile-selection-skill.test.ts +++ b/test/build-profile-selection-skill.test.ts @@ -52,3 +52,63 @@ describe('Claude Code /eforge:build profile selection guidance', () => { expect(compareSemver(manifest.version, '0.25.31')).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// Build skill --after documentation parity (plan-03-consumer-surfaces-docs) +// --------------------------------------------------------------------------- + +describe('Pi /eforge:build --after documentation (plan-03-consumer-surfaces-docs)', () => { + const piSkill = readRepoFile('packages/pi-eforge/skills/eforge-build/SKILL.md'); + + it('documents --after argument', () => { + expect(piSkill).toContain('--after '); + }); + + it('explains active upstream items are held in waiting/', () => { + expect(piSkill).toMatch(/waiting\//); + expect(piSkill).toMatch(/active|pending.*running.*waiting/i); + }); + + it('instructs passing afterQueueId in eforge_build tool calls', () => { + expect(piSkill).toContain('afterQueueId'); + expect(piSkill).toContain('--after'); + }); + + it('describes explicit handoff as deterministic', () => { + expect(piSkill).toMatch(/deterministic/i); + }); + + it('mentions stack parent inference for single explicit dependency', () => { + expect(piSkill).toMatch(/stack parent|stacking/i); + }); +}); + +describe('Claude Code /eforge:build --after documentation (plan-03-consumer-surfaces-docs)', () => { + const claudeSkill = readRepoFile('eforge-plugin/skills/build/build.md'); + + it('documents --after argument', () => { + expect(claudeSkill).toContain('--after '); + }); + + it('explains active upstream items are held in waiting/', () => { + expect(claudeSkill).toMatch(/waiting\//); + }); + + it('instructs passing afterQueueId in mcp__eforge__eforge_build tool calls', () => { + expect(claudeSkill).toContain('afterQueueId'); + expect(claudeSkill).toContain('mcp__eforge__eforge_build'); + }); + + it('describes explicit handoff as deterministic', () => { + expect(claudeSkill).toMatch(/deterministic/i); + }); + + it('mentions stack parent inference for single explicit dependency', () => { + expect(claudeSkill).toMatch(/stack parent|stacking/i); + }); + + it('bumps the Claude Code plugin manifest version for the consumer-surfaces change', () => { + const manifest = JSON.parse(readRepoFile('eforge-plugin/.claude-plugin/plugin.json')) as { version: string }; + expect(compareSemver(manifest.version, '0.25.33')).toBeGreaterThan(0); + }); +}); diff --git a/test/pi-build-command.test.ts b/test/pi-build-command.test.ts index 6a583d32..99bf276b 100644 --- a/test/pi-build-command.test.ts +++ b/test/pi-build-command.test.ts @@ -26,10 +26,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock Pi TUI helpers — avoids loading @earendil-works/pi-tui peer dep // --------------------------------------------------------------------------- +// vi.hoisted ensures this is available when the vi.mock factory runs (hoisted above imports). +const { mockShowSelectPanel } = vi.hoisted(() => ({ + mockShowSelectPanel: vi.fn(), +})); + vi.mock('../packages/pi-eforge/extensions/eforge/ui-helpers.js', () => ({ withLoader: vi.fn(async (_ctx: unknown, _msg: unknown, fn: () => unknown) => fn()), showInfoPanel: vi.fn(), - showSelectPanel: vi.fn(), + showSelectPanel: mockShowSelectPanel, showSearchableSelectPanel: vi.fn(), })); @@ -53,11 +58,13 @@ vi.mock('@eforge-build/client', async (importOriginal) => { ...actual, apiListProfilesIfRunning: vi.fn(), apiSessionPlanListIfRunning: vi.fn(), + apiGetQueueIfRunning: vi.fn(), }; }); import { handleBuildCommand } from '../packages/pi-eforge/extensions/eforge/build-command.js'; import { promptForBuildLandingGate } from '../packages/pi-eforge/extensions/eforge/landing-gate.js'; +import { apiGetQueueIfRunning } from '@eforge-build/client'; // --------------------------------------------------------------------------- // Helpers @@ -92,6 +99,14 @@ function mockLandingGate(result: { landingAction?: string; cancelled?: boolean; (promptForBuildLandingGate as ReturnType).mockResolvedValue(result); } +function mockQueue(items: Array<{ id: string; title: string; status: string }>) { + (apiGetQueueIfRunning as ReturnType).mockResolvedValue({ data: items }); +} + +function mockQueueEmpty() { + (apiGetQueueIfRunning as ReturnType).mockResolvedValue({ data: [] }); +} + // --------------------------------------------------------------------------- // Tests: headless fallback // --------------------------------------------------------------------------- @@ -333,6 +348,118 @@ describe('handleBuildCommand - profile override preservation', () => { }); }); +// --- eforge:region plan-03-consumer-surfaces-docs --- + +// --------------------------------------------------------------------------- +// Tests: active-build wait selection (--after ) +// --------------------------------------------------------------------------- + +describe('handleBuildCommand - active-build wait selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLandingGate({}); + }); + + it('skips active-build selector when queue is empty', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockQueueEmpty(); + + // Use --profile to bypass profile selection (not the concern of this test) + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast"'); + + expect(pi.sendUserMessage).toHaveBeenCalledOnce(); + const call = captureSkillCall(pi)!; + expect(call).not.toContain('--after'); + // showSelectPanel should not be called for active-build (only for profile/source which are mocked out) + }); + + it('appends --after when user selects an active build to wait for', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockQueue([{ id: 'q-abc123', title: 'Add rate limiting', status: 'running' }]); + // Simulate user selecting the active build (returns the queue id) + mockShowSelectPanel.mockResolvedValueOnce('q-abc123'); + + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast"'); + + expect(pi.sendUserMessage).toHaveBeenCalledOnce(); + const call = captureSkillCall(pi)!; + expect(call).toContain('--after'); + expect(call).toContain('"q-abc123"'); + }); + + it('omits --after when user selects "Run now"', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockQueue([{ id: 'q-abc123', title: 'Add rate limiting', status: 'running' }]); + // Simulate user selecting "Run now" + mockShowSelectPanel.mockResolvedValueOnce('__now__'); + + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast"'); + + expect(pi.sendUserMessage).toHaveBeenCalledOnce(); + const call = captureSkillCall(pi)!; + expect(call).not.toContain('--after'); + }); + + it('does not call skill when user cancels the active-build selector', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockQueue([{ id: 'q-abc123', title: 'Add rate limiting', status: 'running' }]); + // Simulate user cancelling (returns null/undefined) + mockShowSelectPanel.mockResolvedValueOnce(null); + + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast"'); + + expect(pi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it('preserves --profile and --landing-action alongside --after', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockLandingGate({ landingAction: 'pr' }); + mockQueue([{ id: 'q-xyz', title: 'Feature B', status: 'pending' }]); + mockShowSelectPanel.mockResolvedValueOnce('q-xyz'); + + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast"'); + + const call = captureSkillCall(pi)!; + expect(call).toContain('--profile'); + expect(call).toContain('"fast"'); + expect(call).toContain('--landing-action pr'); + expect(call).toContain('--after'); + expect(call).toContain('"q-xyz"'); + }); + + it('bypasses active-build selector when --after is already in args', async () => { + const pi = makePi(); + const ctx = makeCtx(); + mockQueue([{ id: 'q-abc', title: 'Active build', status: 'running' }]); + // --after already in args from headless/scripted caller; --profile bypasses profile selection + await handleBuildCommand(pi as any, ctx as any, '--infer --profile "fast" --after "q-already"'); + + // Active-build selector should not be shown (mockShowSelectPanel not called for dependency) + // The call should pass through with the existing --after + const call = captureSkillCall(pi); + expect(call).toContain('--after'); + expect(call).toContain('q-already'); + }); + + it('does not show active-build selector in headless mode', async () => { + const pi = makePi(); + mockQueue([{ id: 'q-abc', title: 'Active build', status: 'running' }]); + + await handleBuildCommand(pi as any, null, '--infer'); + + // headless: sends skill directly, no UI prompts + expect(pi.sendUserMessage).toHaveBeenCalledOnce(); + expect(captureSkillCall(pi)).toBe('/skill:eforge-build --infer'); + }); +}); + +// --- eforge:endregion plan-03-consumer-surfaces-docs --- + // --- eforge:region plan-02-request-surfaces-and-pi-ux --- // --------------------------------------------------------------------------- @@ -342,6 +469,9 @@ describe('handleBuildCommand - profile override preservation', () => { describe('handleBuildCommand - PR auto-merge selection', () => { beforeEach(() => { vi.clearAllMocks(); + // Plan-03 added selectActiveBuildsForDependency to all UI paths; mock the queue as empty + // so tests focused on landing/auto-merge behavior are not affected by the dependency selector. + mockQueueEmpty(); }); it('appends --landing-auto-merge when gate returns landingAutoMerge: true', async () => { diff --git a/test/profile-wiring.test.ts b/test/profile-wiring.test.ts index 684f7757..6cbdef03 100644 --- a/test/profile-wiring.test.ts +++ b/test/profile-wiring.test.ts @@ -1132,3 +1132,60 @@ describe('/eforge:init redesign (plan-02-consumers)', () => { expect(block).toContain("StringEnum(['local', 'user'])"); }); }); + +// --------------------------------------------------------------------------- +// eforge_build afterQueueId wiring (plan-03-consumer-surfaces-docs) +// --------------------------------------------------------------------------- + +describe('eforge_build afterQueueId schema and forwarding parity (plan-03-consumer-surfaces-docs)', () => { + const mcpSource = readRepoFile('packages/eforge/src/cli/mcp-proxy.ts'); + const piSource = readRepoFile('packages/pi-eforge/extensions/eforge/index.ts'); + + function getMcpBuildBlock(): string { + const start = mcpSource.indexOf("name: 'eforge_build',"); + expect(start).toBeGreaterThan(-1); + const next = mcpSource.indexOf('createDaemonTool(', start + 1); + return next > start ? mcpSource.slice(start, next) : mcpSource.slice(start); + } + + function getPiBuildBlock(): string { + const start = piSource.indexOf('name: "eforge_build"'); + expect(start).toBeGreaterThan(-1); + const next = piSource.indexOf('pi.registerTool(', start + 1); + const blockStart = piSource.lastIndexOf('pi.registerTool(', start); + return next > blockStart ? piSource.slice(blockStart, next) : piSource.slice(blockStart); + } + + it('MCP proxy eforge_build schema declares optional afterQueueId field', () => { + const block = getMcpBuildBlock(); + expect(block).toContain('afterQueueId'); + expect(block).toMatch(/afterQueueId:\s*z[\s\S]*?\.string\(\)[\s\S]*?\.optional\(\)/); + }); + + it('Pi extension eforge_build schema declares optional afterQueueId field', () => { + const block = getPiBuildBlock(); + expect(block).toContain('afterQueueId'); + expect(block).toMatch(/afterQueueId:\s*Type\.Optional\(Type\.String/); + }); + + it('MCP proxy eforge_build handler forwards afterQueueId to the enqueue body', () => { + const block = getMcpBuildBlock(); + expect(block).toMatch(/body\.afterQueueId\s*=\s*afterQueueId/); + }); + + it('Pi extension eforge_build handler forwards afterQueueId to the enqueue body', () => { + const block = getPiBuildBlock(); + expect(block).toMatch(/body\.afterQueueId\s*=\s*params\.afterQueueId/); + }); + + it('MCP proxy eforge_build only sets afterQueueId in body when defined', () => { + const block = getMcpBuildBlock(); + // Must be guarded by a defined check (not unconditionally assigned) + expect(block).toMatch(/afterQueueId\s*!==\s*undefined/); + }); + + it('Pi extension eforge_build only sets afterQueueId in body when defined', () => { + const block = getPiBuildBlock(); + expect(block).toMatch(/params\.afterQueueId\s*!==\s*undefined/); + }); +}); diff --git a/web/content/docs/configuration.md b/web/content/docs/configuration.md index cbfbaefc..52228a89 100644 --- a/web/content/docs/configuration.md +++ b/web/content/docs/configuration.md @@ -342,7 +342,9 @@ depends_on: [api-v2] # wait for pending/running/waiting queue item ids --- ``` -`depends_on` is validated at enqueue time and only live queue items can be dependencies. Items blocked on dependencies live under the queue's `waiting/` state until all upstream items complete; if an upstream item fails or is cancelled, its waiting dependents move to `skipped/`. +`depends_on` is validated at enqueue time. Dependencies may be active queue items (pending/running/waiting) or completed items with usable artifacts. Items blocked on active upstream dependencies live under the queue's `waiting/` subdirectory until all upstream items complete; items whose dependencies are already completed with usable artifacts are eligible immediately and remain in the queue root. If an upstream item fails or is cancelled, its waiting dependents move to `skipped/`. + +**Explicit deterministic handoff**: instead of writing `depends_on` in frontmatter, pass `--after ` to the CLI or `afterQueueId` to the `eforge_build` MCP/Pi tool to create an explicit dependency on an active or completed queue entry. Active upstream items (pending/running/waiting) are held in `waiting/` and unblocked when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. Explicit `afterQueueId` takes precedence over automatic dependency detection, which remains best effort and is only used when no explicit dependency is supplied. Failed, skipped, and unknown IDs are rejected at enqueue time. ## Post-Merge Commands diff --git a/web/content/docs/stacking.md b/web/content/docs/stacking.md index ea2ae95c..50b226ca 100644 --- a/web/content/docs/stacking.md +++ b/web/content/docs/stacking.md @@ -43,6 +43,10 @@ When a PRD has exactly one `depends_on` entry and stacking is enabled, eforge au When a PRD has multiple `depends_on` entries, eforge cannot infer the stack parent. You must set `stack_parent` explicitly to indicate which dependency is the direct parent layer. If `stack_parent` is missing and there are multiple `depends_on` entries, dispatch fails with a clear error. +### Explicit handoff and stack parent + +When you use `--after ` (CLI) or `afterQueueId` (MCP/Pi tool) to create an explicit dependency, the resulting single `depends_on` entry participates in the same stack parent inference described above. If stacking is enabled and the explicit dependency is the only `depends_on` entry, eforge infers `stack_parent` from it at dispatch time - no extra configuration is needed. The explicit handoff is deterministic: dependency detector inference is not used when `afterQueueId` is supplied. + ## Enable stacking The guided path is `/eforge:workflow`. Choose a stacked workflow preset to write the required `landing.action: pr` and `stacking.enabled: true` keys to `eforge/config.yaml`. The `stacked-pr-autosync` preset also writes `stacking.sync.afterBuild: true` for daemon-owned automatic stack sync. diff --git a/web/public/docs/configuration.md b/web/public/docs/configuration.md index cbfbaefc..66e4d983 100644 --- a/web/public/docs/configuration.md +++ b/web/public/docs/configuration.md @@ -344,6 +344,8 @@ depends_on: [api-v2] # wait for pending/running/waiting queue item ids `depends_on` is validated at enqueue time and only live queue items can be dependencies. Items blocked on dependencies live under the queue's `waiting/` state until all upstream items complete; if an upstream item fails or is cancelled, its waiting dependents move to `skipped/`. +**Explicit deterministic handoff**: instead of writing `depends_on` in frontmatter, pass `--after ` to the CLI or `afterQueueId` to the `eforge_build` MCP/Pi tool to create an explicit dependency on an active or completed queue entry. Active upstream items (pending/running/waiting) are held in `waiting/` and unblocked when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. Explicit `afterQueueId` takes precedence over automatic dependency detection, which remains best effort and is only used when no explicit dependency is supplied. Failed, skipped, and unknown IDs are rejected at enqueue time. + ## Post-Merge Commands Commands to run after all plans merge - compile, test, lint, or any validation step: diff --git a/web/public/docs/stacking.md b/web/public/docs/stacking.md index ea2ae95c..50b226ca 100644 --- a/web/public/docs/stacking.md +++ b/web/public/docs/stacking.md @@ -43,6 +43,10 @@ When a PRD has exactly one `depends_on` entry and stacking is enabled, eforge au When a PRD has multiple `depends_on` entries, eforge cannot infer the stack parent. You must set `stack_parent` explicitly to indicate which dependency is the direct parent layer. If `stack_parent` is missing and there are multiple `depends_on` entries, dispatch fails with a clear error. +### Explicit handoff and stack parent + +When you use `--after ` (CLI) or `afterQueueId` (MCP/Pi tool) to create an explicit dependency, the resulting single `depends_on` entry participates in the same stack parent inference described above. If stacking is enabled and the explicit dependency is the only `depends_on` entry, eforge infers `stack_parent` from it at dispatch time - no extra configuration is needed. The explicit handoff is deterministic: dependency detector inference is not used when `afterQueueId` is supplied. + ## Enable stacking The guided path is `/eforge:workflow`. Choose a stacked workflow preset to write the required `landing.action: pr` and `stacking.enabled: true` keys to `eforge/config.yaml`. The `stacked-pr-autosync` preset also writes `stacking.sync.afterBuild: true` for daemon-owned automatic stack sync. From 3cde0aab11bbbd68586b9c7fddc16716bd96ab48 Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 00:31:05 -0700 Subject: [PATCH 6/9] fix: resolve validation failures Co-Authored-By: forged-by-eforge --- web/content/reference/cli.md | 4 ++-- web/public/docs/configuration.md | 2 +- web/public/llms-full.txt | 4 ++-- web/public/reference/cli.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/content/reference/cli.md b/web/content/reference/cli.md index e18397bf..e3e0f99b 100644 --- a/web/content/reference/cli.md +++ b/web/content/reference/cli.md @@ -25,7 +25,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `build` @@ -54,7 +54,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `monitor` diff --git a/web/public/docs/configuration.md b/web/public/docs/configuration.md index 66e4d983..52228a89 100644 --- a/web/public/docs/configuration.md +++ b/web/public/docs/configuration.md @@ -342,7 +342,7 @@ depends_on: [api-v2] # wait for pending/running/waiting queue item ids --- ``` -`depends_on` is validated at enqueue time and only live queue items can be dependencies. Items blocked on dependencies live under the queue's `waiting/` state until all upstream items complete; if an upstream item fails or is cancelled, its waiting dependents move to `skipped/`. +`depends_on` is validated at enqueue time. Dependencies may be active queue items (pending/running/waiting) or completed items with usable artifacts. Items blocked on active upstream dependencies live under the queue's `waiting/` subdirectory until all upstream items complete; items whose dependencies are already completed with usable artifacts are eligible immediately and remain in the queue root. If an upstream item fails or is cancelled, its waiting dependents move to `skipped/`. **Explicit deterministic handoff**: instead of writing `depends_on` in frontmatter, pass `--after ` to the CLI or `afterQueueId` to the `eforge_build` MCP/Pi tool to create an explicit dependency on an active or completed queue entry. Active upstream items (pending/running/waiting) are held in `waiting/` and unblocked when the upstream completes. Completed upstream items with a usable artifact are enqueued immediately as eligible dependents. Explicit `afterQueueId` takes precedence over automatic dependency detection, which remains best effort and is only used when no explicit dependency is supplied. Failed, skipped, and unknown IDs are rejected at enqueue time. diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 18fd6864..3a76355a 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -27,7 +27,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `build` @@ -56,7 +56,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `monitor` diff --git a/web/public/reference/cli.md b/web/public/reference/cli.md index e18397bf..e3e0f99b 100644 --- a/web/public/reference/cli.md +++ b/web/public/reference/cli.md @@ -25,7 +25,7 @@ Normalize input and add it to the PRD queue | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `build` @@ -54,7 +54,7 @@ Compile + build + validate in one step | `--landing-action ` | Landing action for this build (pr\|merge\|leave) | | `--landing-auto-merge` | Enable PR auto-merge for this build | | `--no-landing-auto-merge` | Disable PR auto-merge for this build | -| `--after ` | Wait for an upstream queue item to complete before building | +| `--after ` | Explicit upstream dependency: waits in waiting/ if the upstream is active; enqueues immediately as an eligible dependent if the upstream completed with a usable artifact | ### `monitor` From e789701db91b731f38d30b47233cda2a84e91572 Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 00:46:43 -0700 Subject: [PATCH 7/9] feat(gap-close): PRD Gap Close Models-Used: claude-sonnet-4-6, gpt-5.5 Co-Authored-By: forged-by-eforge --- eforge-plugin/skills/build/build.md | 2 +- packages/engine/src/prompts/prd-validator.md | 4 +++- packages/pi-eforge/skills/eforge-build/SKILL.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/eforge-plugin/skills/build/build.md b/eforge-plugin/skills/build/build.md index 29b129ef..f3be0254 100644 --- a/eforge-plugin/skills/build/build.md +++ b/eforge-plugin/skills/build/build.md @@ -176,7 +176,7 @@ First, validate the project config by calling the `mcp__eforge__eforge_config` t - If `valid` is `true`, continue silently. -Call the `mcp__eforge__eforge_build` tool with `{ source: "" }`. If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If `$ARGUMENTS` contains `--after `, extract the queue id and include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when `--after` is not present in `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** +Call the `mcp__eforge__eforge_build` tool with `{ source: "" }`. If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If a queue id was remembered from the `--after` flag parsed in Step 1, include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when no `--after` flag was present in the original `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** Present the landing selector conversationally with the four choices (Use project default, pr, merge, leave) before calling the tool, unless an explicit override is already in `$ARGUMENTS`. On protected trunk with unsafe effective default, exclude merge and project-default-as-merge from the normal options and offer the remediation choices instead (pr, leave, update config if applicable, cancel). diff --git a/packages/engine/src/prompts/prd-validator.md b/packages/engine/src/prompts/prd-validator.md index e5a017ed..33a0d97f 100644 --- a/packages/engine/src/prompts/prd-validator.md +++ b/packages/engine/src/prompts/prd-validator.md @@ -37,7 +37,9 @@ Some files appear with a marker of the form `[summarized: ...]` instead of a ful ## Output Format -Output a single JSON block with your analysis. Include a `completionPercent` field (0-100 integer) estimating overall PRD completion, a `complexity` field per gap, and an `acceptanceVerdicts` array with one entry per acceptance criterion. +Your entire response MUST consist of exactly one fenced ` ```json ``` ` block. Do not add any prose, explanation, preamble, or text before or after the JSON block — the block must be the first and only thing in your response. + +The JSON block must include a `completionPercent` field (0-100 integer) estimating overall PRD completion, a `complexity` field per gap, and an `acceptanceVerdicts` array with one entry per acceptance criterion. Complexity definitions: - `trivial` - missing log line, config tweak, or minor wiring diff --git a/packages/pi-eforge/skills/eforge-build/SKILL.md b/packages/pi-eforge/skills/eforge-build/SKILL.md index ba4ea76c..235bacb0 100644 --- a/packages/pi-eforge/skills/eforge-build/SKILL.md +++ b/packages/pi-eforge/skills/eforge-build/SKILL.md @@ -160,7 +160,7 @@ First, validate the project config by calling the `eforge_config` tool with `{ a - If `valid` is `true`, continue silently. -Call the `eforge_build` tool with `{ source: "" }`, using the latest working source (including the edited `source` returned by `eforge_confirm_build` on confirmation for non-file-path sources). If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If `$ARGUMENTS` contains `--after `, extract the queue id and include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when `--after` is not present in `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** +Call the `eforge_build` tool with `{ source: "" }`, using the latest working source (including the edited `source` returned by `eforge_confirm_build` on confirmation for non-file-path sources). If the user explicitly specified a profile override, include `profile: ""` in the call. If an explicit landing action was selected (anything other than "Use project default"), include `landingAction: ""` in the call. **Do not include `landingAction` when the user selected "Use project default" or when no landing override is present in `$ARGUMENTS` — omitting the key lets the engine inherit the configured default.** If an explicit auto-merge override was selected (anything other than "Use policy default"), include `landingAutoMerge: ` in the call. **Do not include `landingAutoMerge` when "Use policy default" was selected or when neither `--landing-auto-merge` nor `--no-landing-auto-merge` is present in `$ARGUMENTS` — omitting the key defers to the `landing.pr.autoMerge` policy.** If a queue id was remembered from the `--after` flag parsed in Step 1, include `afterQueueId: ""` in the call. **Do not include `afterQueueId` when no `--after` flag was present in the original `$ARGUMENTS` — omitting the key means no explicit dependency and automatic dependency detection applies.** The Pi native `/eforge:build` command presents the landing selector before invoking this skill. By the time the skill reaches the enqueue step in UI mode, the landing decision is already encoded in `$ARGUMENTS`: either an explicit override was appended, or no override was appended (meaning project default applies). Do not show the landing selector again if an explicit override is already present in `$ARGUMENTS`. From 3f39cef374d79b7bad5e14326511402d47f8f182 Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 07:34:47 -0700 Subject: [PATCH 8/9] test: cover explicit dependency enqueue handoff gaps --- test/daemon-enqueue-after-queue-id.test.ts | 144 +++++++++++++++++++++ test/engine-enqueue-after-queue-id.test.ts | 118 +++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 test/daemon-enqueue-after-queue-id.test.ts create mode 100644 test/engine-enqueue-after-queue-id.test.ts diff --git a/test/daemon-enqueue-after-queue-id.test.ts b/test/daemon-enqueue-after-queue-id.test.ts new file mode 100644 index 00000000..284bdd86 --- /dev/null +++ b/test/daemon-enqueue-after-queue-id.test.ts @@ -0,0 +1,144 @@ +/** + * Daemon route tests for explicit build dependency handoff via afterQueueId. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { API_ROUTES } from '@eforge-build/client'; +import { upsertArtifact } from '@eforge-build/engine/artifacts'; +import { openDatabase } from '@eforge-build/monitor/db'; +import { startServer, type MonitorServer, type StartServerOptions, type WorkerTracker } from '@eforge-build/monitor/server'; +import { useTempDir } from './test-tmpdir.js'; + +const makeTempDir = useTempDir('eforge-daemon-enqueue-after-'); + +let server: MonitorServer | undefined; + +afterEach(async () => { + await server?.stop(); + server = undefined; +}); + +async function setupProject(tmpDir: string): Promise { + const gitOpts = { cwd: tmpDir }; + execFileSync('git', ['init', '-b', 'main'], gitOpts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], gitOpts); + execFileSync('git', ['config', 'user.name', 'Test'], gitOpts); + execFileSync('git', ['commit', '--allow-empty', '-m', 'chore: initial commit'], gitOpts); + + await mkdir(resolve(tmpDir, 'eforge'), { recursive: true }); + await writeFile(resolve(tmpDir, 'eforge', 'config.yaml'), '', 'utf-8'); +} + +async function start(tmpDir: string, opts: StartServerOptions = {}): Promise { + const db = openDatabase(resolve(tmpDir, 'monitor.db')); + server = await startServer(db, 0, { strictPort: true, cwd: tmpDir, ...opts }); +} + +async function post(url: string, body: unknown): Promise { + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeRecordingWorkerTracker(): WorkerTracker & { calls: Array<{ command: string; args: string[] }> } { + const calls: Array<{ command: string; args: string[] }> = []; + return { + calls, + spawnWorker(command: string, args: string[]): { sessionId: string; pid: number } { + calls.push({ command, args }); + return { sessionId: 'rec-session', pid: 88888 }; + }, + cancelWorker(_sessionId: string): boolean { + return false; + }, + }; +} + +async function recordBuiltArtifact(cwd: string, prdId: string): Promise { + const now = new Date().toISOString(); + await upsertArtifact(cwd, { + prdId, + artifactBranch: `eforge/${prdId}`, + commitSha: 'abc123', + resolvedBase: 'main', + landingAction: 'leave', + status: 'built', + recordedAt: now, + updatedAt: now, + }); +} + +async function writeTerminalQueuePrd(tmpDir: string, state: 'failed' | 'skipped', id: string): Promise { + const terminalDir = resolve(tmpDir, '.eforge', 'queue', state); + await mkdir(terminalDir, { recursive: true }); + await writeFile( + resolve(terminalDir, `${id}.md`), + `---\ntitle: ${id}\ncreated: 2026-01-01\n---\n\n# ${id}\n`, + 'utf-8', + ); +} + +describe('POST /api/enqueue — afterQueueId dependency states', () => { + it('accepts a completed-artifact upstream and forwards --after to the enqueue worker', async () => { + const tmpDir = makeTempDir(); + await setupProject(tmpDir); + await recordBuiltArtifact(tmpDir, 'completed-upstream'); + + const tracker = makeRecordingWorkerTracker(); + await start(tmpDir, { workerTracker: tracker }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.enqueue}`, { + source: 'implement a dependent feature', + afterQueueId: 'completed-upstream', + }); + expect(res.status).toBe(200); + + const call = tracker.calls.find((entry) => entry.command === 'enqueue'); + expect(call).toBeDefined(); + expect(call!.args).toContain('--after'); + const afterIdx = call!.args.indexOf('--after'); + expect(call!.args[afterIdx + 1]).toBe('completed-upstream'); + }); + + it('rejects a failed upstream before spawning an enqueue worker', async () => { + const tmpDir = makeTempDir(); + await setupProject(tmpDir); + await writeTerminalQueuePrd(tmpDir, 'failed', 'failed-upstream'); + + const tracker = makeRecordingWorkerTracker(); + await start(tmpDir, { workerTracker: tracker }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.enqueue}`, { + source: 'implement a dependent feature', + afterQueueId: 'failed-upstream', + }); + expect(res.status).toBe(400); + + const data = await res.json() as { error: string }; + expect(data.error).toContain('failed-upstream'); + expect(tracker.calls).toHaveLength(0); + }); + + it('rejects a skipped upstream before spawning an enqueue worker', async () => { + const tmpDir = makeTempDir(); + await setupProject(tmpDir); + await writeTerminalQueuePrd(tmpDir, 'skipped', 'skipped-upstream'); + + const tracker = makeRecordingWorkerTracker(); + await start(tmpDir, { workerTracker: tracker }); + + const res = await post(`http://localhost:${server!.port}${API_ROUTES.enqueue}`, { + source: 'implement a dependent feature', + afterQueueId: 'skipped-upstream', + }); + expect(res.status).toBe(400); + + const data = await res.json() as { error: string }; + expect(data.error).toContain('skipped-upstream'); + expect(tracker.calls).toHaveLength(0); + }); +}); diff --git a/test/engine-enqueue-after-queue-id.test.ts b/test/engine-enqueue-after-queue-id.test.ts new file mode 100644 index 00000000..ac1185a8 --- /dev/null +++ b/test/engine-enqueue-after-queue-id.test.ts @@ -0,0 +1,118 @@ +/** + * Behavioral tests for EforgeEngine.enqueue explicit dependency handoff. + */ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { EforgeEngine } from '@eforge-build/engine/eforge'; +import type { EforgeEvent } from '@eforge-build/engine/events'; +import { StubHarness } from './stub-harness.js'; +import { useTempDir } from './test-tmpdir.js'; + +const makeTempDir = useTempDir('eforge-engine-enqueue-after-'); + +async function setupProject(tmpDir: string): Promise { + const gitOpts = { cwd: tmpDir }; + execFileSync('git', ['init', '-b', 'main'], gitOpts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], gitOpts); + execFileSync('git', ['config', 'user.name', 'Test'], gitOpts); + execFileSync('git', ['commit', '--allow-empty', '-m', 'chore: initial commit'], gitOpts); + + await mkdir(resolve(tmpDir, 'eforge'), { recursive: true }); + await writeFile(resolve(tmpDir, 'eforge', 'config.yaml'), 'plugins:\n enabled: false\n', 'utf-8'); +} + +async function writeActiveQueuePrd(tmpDir: string, id: string): Promise { + const queueDir = resolve(tmpDir, '.eforge', 'queue'); + await mkdir(queueDir, { recursive: true }); + await writeFile( + resolve(queueDir, `${id}.md`), + `---\ntitle: ${id}\ncreated: 2026-01-01\n---\n\n# ${id}\n`, + 'utf-8', + ); +} + +type EnqueueCompleteEvent = Extract; + +function findEnqueueComplete(events: EforgeEvent[]): EnqueueCompleteEvent | undefined { + return events.find((event): event is EnqueueCompleteEvent => event.type === 'enqueue:complete'); +} + +function validFormattedPrd(): string { + return [ + '# Dependent Feature', + '', + '## Problem / Motivation', + '', + 'A dependent feature needs to be queued after an upstream build.', + '', + '## Goal', + '', + 'Queue the dependent feature.', + '', + '## Approach', + '', + 'Use the explicit dependency handoff path.', + '', + '## Scope', + '', + '- In scope: enqueue behavior.', + '', + '## Acceptance Criteria', + '', + '- The dependent PRD is queued with the selected upstream dependency.', + ].join('\n'); +} + +describe('EforgeEngine.enqueue — explicit afterQueueId', () => { + it('persists depends_on frontmatter for an explicit afterQueueId', async () => { + const tmpDir = makeTempDir(); + await setupProject(tmpDir); + await writeActiveQueuePrd(tmpDir, 'upstream-build'); + + const harness = new StubHarness([{ text: validFormattedPrd() }]); + const engine = await EforgeEngine.create({ + cwd: tmpDir, + agentRuntimes: harness, + config: { plugins: { enabled: false } }, + }); + + const events: EforgeEvent[] = []; + for await (const event of engine.enqueue('raw source', { afterQueueId: 'upstream-build' })) { + events.push(event); + } + + const complete = findEnqueueComplete(events); + expect(complete).toBeDefined(); + const queuedContent = await readFile(complete!.filePath, 'utf-8'); + expect(queuedContent).toContain('depends_on: ["upstream-build"]'); + }); + + it('does not invoke dependency detection or override an explicit afterQueueId', async () => { + const tmpDir = makeTempDir(); + await setupProject(tmpDir); + await writeActiveQueuePrd(tmpDir, 'explicit-upstream'); + await writeActiveQueuePrd(tmpDir, 'other-queued-build'); + + const harness = new StubHarness([{ text: validFormattedPrd() }]); + const engine = await EforgeEngine.create({ + cwd: tmpDir, + agentRuntimes: harness, + config: { plugins: { enabled: false } }, + }); + + const events: EforgeEvent[] = []; + for await (const event of engine.enqueue('raw source', { afterQueueId: 'explicit-upstream' })) { + events.push(event); + } + + expect(harness.calls).toHaveLength(1); + expect(events.some((event) => event.type === 'enqueue:complete')).toBe(true); + const complete = findEnqueueComplete(events); + expect(complete).toBeDefined(); + const queuedContent = await readFile(complete!.filePath, 'utf-8'); + expect(queuedContent).toContain('depends_on: ["explicit-upstream"]'); + expect(queuedContent).not.toContain('other-queued-build'); + }); +}); From 942867f3e1e2269cc36714dbfd11bae4cfaa8fea Mon Sep 17 00:00:00 2001 From: Mark Schaake Date: Thu, 28 May 2026 07:34:55 -0700 Subject: [PATCH 9/9] cleanup: remove dependency handoff plan artifacts --- .../orchestration.yaml | 129 ------- .../plan-01-build-dependency-core.md | 106 ------ .../plan-02-playbook-placement-parity.md | 67 ---- .../plan-03-consumer-surfaces-docs.md | 100 ------ ...rst-class-dependency-handoff-for-builds.md | 319 ------------------ 5 files changed, 721 deletions(-) delete mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml delete mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md delete mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md delete mode 100644 eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md delete mode 100644 eforge/prds/add-first-class-dependency-handoff-for-builds.md diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml b/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml deleted file mode 100644 index bd2c78ca..00000000 --- a/eforge/plans/add-first-class-dependency-handoff-for-builds/orchestration.yaml +++ /dev/null @@ -1,129 +0,0 @@ -name: add-first-class-dependency-handoff-for-builds -description: Add first-class afterQueueId dependency handoff for normal build - enqueue flows, including shared queue placement, CLI/daemon/API plumbing, - playbook placement parity, Pi/Claude consumer surfaces, docs, and tests. -base_branch: main -mode: excursion -validate: - - pnpm maintainability:check - - pnpm type-check - - pnpm test -plans: - - id: plan-01-build-dependency-core - name: Build Dependency Handoff Core Plumbing - depends_on: [] - branch: add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core - build: - - implement - - test-cycle - - review-cycle - review: - strategy: parallel - perspectives: - - code - - api - maxRounds: 2 - evaluatorStrictness: strict - agents: - builder: - effort: high - rationale: Touches shared queue semantics, daemon request validation, CLI - delegation, and engine enqueue behavior with bounded edits in several - large files. - reviewer: - effort: high - rationale: The route and queue placement semantics need careful review for API - compatibility and stale-id edge cases. - tester: - effort: high - rationale: Needs targeted coverage for active vs completed-artifact placement - and CLI/daemon plumbing. - - id: plan-02-playbook-placement-parity - name: Playbook Dependency Placement Parity - depends_on: - - plan-01-build-dependency-core - branch: add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity - build: - - implement - - test-cycle - - review-cycle - review: - strategy: auto - perspectives: - - code - - test - maxRounds: 1 - evaluatorStrictness: standard - agents: - builder: - effort: medium - rationale: Uses the helper from plan 01 to fix one route path and expand route - tests. - tester: - effort: high - rationale: Completed-artifact vs active-upstream playbook placement needs - file-location assertions. - - id: plan-03-consumer-surfaces-docs - name: Consumer Surfaces and Documentation - depends_on: - - plan-01-build-dependency-core - - plan-02-playbook-placement-parity - branch: add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs - build: - - implement - - doc-sync - - test-cycle - - review-cycle - review: - strategy: parallel - perspectives: - - docs - - api - maxRounds: 2 - evaluatorStrictness: strict - agents: - builder: - effort: high - rationale: Keeps Pi and Claude Code plugin surfaces in parity while updating - skill docs and bounded sections of large extension files. - reviewer: - effort: high - rationale: Consumer-facing schema, skill, plugin-version, and documentation - changes need parity review. - tester: - effort: high - rationale: Needs source-wiring tests plus Pi native-command behavior tests. - doc-syncer: - effort: medium - rationale: Several user-facing docs and skill files must describe the new flag - and deterministic handoff behavior consistently. -pipeline: - scope: excursion - compile: - - planner - - plan-review-cycle - defaultBuild: - - - implement - - doc-author - - test-write - - review-cycle - - doc-sync - - test-cycle - defaultReview: - strategy: parallel - perspectives: - - api - - test - maxRounds: 2 - evaluatorStrictness: standard - rationale: This is a cohesive cross-cutting feature touching engine queue - placement, daemon/client API contracts, CLI, Pi, Claude plugin parity, docs, - and tests, but a single planner can enumerate the work and dependencies, so - excursion is appropriate rather than expedition. A plan review gate is - useful because the change spans many surfaces and has edge cases around - waiting versus completed-artifact placement. The build runs documentation - authoring alongside implementation, then writes targeted tests, performs an - iterative review cycle focused on API contract and test coverage risk, syncs - docs against the final diff, and finishes with an iterative test cycle to - catch integration regressions. -diff_base_ref: 8d3fdabf464a2ea574ae18da2b18bfd6282220e4 diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md deleted file mode 100644 index 6aa0c3b0..00000000 --- a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -id: plan-01-build-dependency-core -name: Build Dependency Handoff Core Plumbing -branch: add-first-class-dependency-handoff-for-builds/plan-01-build-dependency-core -agents: - builder: - effort: high - rationale: Touches shared queue semantics, daemon request validation, CLI - delegation, and engine enqueue behavior with bounded edits in several - large files. - reviewer: - effort: high - rationale: The route and queue placement semantics need careful review for API - compatibility and stale-id edge cases. - tester: - effort: high - rationale: Needs targeted coverage for active vs completed-artifact placement - and CLI/daemon plumbing. ---- - -# Build Dependency Handoff Core Plumbing - -## Architecture Context - -Normal build surfaces need a deterministic way to express that a build waits for a specific upstream queue item. Queue and stacking layers already operate on `depends_on`; this plan adds the public request, CLI, daemon, and engine path that converts `afterQueueId` into `depends_on` and chooses whether the new PRD belongs in `.eforge/queue/waiting/` or the queue root. - -Constraints: - -- Route constants and daemon request shapes are owned by `@eforge-build/client`. -- Queue mutation is filesystem state under `.eforge/queue/`; no DB migration is needed. -- Large files must receive bounded exact edits. -- Scheduler stack inference remains the owner of `stack_parent`; this plan only persists `depends_on`. - -## Implementation - -### Overview - -Add `afterQueueId` to the normal enqueue contract. Implement a shared queue dependency placement helper in `prd-queue.ts`, use it from `EforgeEngine.enqueue()`, validate it in the daemon enqueue route before worker spawn, and pass it through CLI paths (`eforge enqueue --after`, `eforge build --after`, and daemon delegation). Explicit `afterQueueId` overrides dependency-detector output; dependency detection remains active for requests without `afterQueueId`. - -### Key Decisions - -1. Use `afterQueueId` as the public field name because autonomous playbooks already use that name. -2. Add a placement helper that returns `{ dependsOn: [id], intoWaiting }` for explicit handoffs. Active root/waiting queue items and live running upstreams map to `intoWaiting: true`; completed upstreams with a usable durable artifact map to `intoWaiting: false`; failed, skipped, unknown, or completed-without-artifact upstreams throw. -3. Re-run placement in the enqueue worker even when the daemon route already prevalidates. This handles races where the upstream completes between HTTP request validation and worker execution. -4. Bump `DAEMON_API_VERSION` and update the version test because older daemons would silently ignore `afterQueueId`, violating deterministic handoff semantics. - -## Scope - -### In Scope - -- Add `afterQueueId?: string` to `EnqueueRequest` in `packages/client/src/routes.ts` with documentation. -- Bump `DAEMON_API_VERSION` from 43 to 44 in `packages/client/src/api-version-const.ts` and update `test/daemon-recovery.test.ts`. -- Add `afterQueueId?: string` to `EnqueueOptions` in `packages/engine/src/events.ts`. -- Add a shared dependency placement helper in `packages/engine/src/prd-queue.ts`; keep `validateDependsOnExists()` available and adapt it to share classification code. -- Update `packages/engine/src/eforge.ts` so explicit `afterQueueId` writes `depends_on: [id]`, sets `intoWaiting` from the helper, and skips dependency-detector output. -- Update `packages/monitor/src/server.ts` `POST /api/enqueue` to reject non-string `afterQueueId`, validate/classify string values before spawning a worker, include the invalid id in error text, and pass `--after ` to the enqueue worker. -- Update `packages/eforge/src/cli/index.ts` with `eforge enqueue --after ` and `eforge build --after `. -- Update `packages/eforge/src/cli/run-or-delegate.ts` so daemon delegation sends `afterQueueId`, in-process enqueue passes it to `engine.enqueue()`, and active-upstream waiting handoff does not try to run a waiting PRD immediately. -- Add/update tests covering queue helper classification, engine explicit dependency precedence, daemon enqueue route validation, CLI flag plumbing, and API version. - -### Out of Scope - -- Multi-dependency selection for normal builds. -- Manual `stack_parent` selection. -- Scheduler stack inference changes beyond consuming the persisted `depends_on`. -- Pi and Claude tool/skill UX changes; those are handled in plan 03. -- Autonomous playbook route placement; that is handled in plan 02. - -## Files - -### Create - -- None expected. - -### Modify - -- `packages/client/src/routes.ts` — add `afterQueueId?: string` to `EnqueueRequest`. -- `packages/client/src/api-version-const.ts` — bump to 44 and prepend a v44 note. -- `packages/engine/src/events.ts` — add `afterQueueId?: string` to `EnqueueOptions`. -- `packages/engine/src/prd-queue.ts` — add the placement helper and retain `validateDependsOnExists()` behavior. -- `packages/engine/src/eforge.ts` — thread explicit dependency through enqueue and bypass dependency detection when present. -- `packages/monitor/src/server.ts` — validate and forward `afterQueueId` in `POST /api/enqueue`. -- `packages/eforge/src/cli/index.ts` — add `--after ` to `enqueue` and `build` commands. -- `packages/eforge/src/cli/run-or-delegate.ts` — include `afterQueueId` in `BuildRunOpts`, delegated `apiEnqueue` bodies, and foreground engine enqueue. -- `test/queue-piggyback.test.ts` — add placement helper cases for active root, live running, active waiting, completed artifact, failed, skipped, completed-without-artifact, and unknown ids. -- `test/acceptance-criteria-quality.test.ts` or a new focused engine enqueue test file — verify explicit `afterQueueId` persists `depends_on` and dependency-detector output is not used. -- `test/playbook-api.test.ts` — add `POST /api/enqueue` route tests for valid active, valid running, valid completed-artifact, non-string, unknown, failed, and skipped `afterQueueId` values. -- `test/extension-tooling-wiring.test.ts` or a focused CLI test file — verify `eforge enqueue --after q-abc` and `eforge build --after q-abc` pass `afterQueueId` through CLI/delegation paths. -- `test/daemon-recovery.test.ts` — update the expected daemon API version and version-history comment. - -## Verification - -- [ ] `EnqueueRequest` exposes optional `afterQueueId?: string` and `apiEnqueue({ body: { source, afterQueueId } })` type-checks. -- [ ] The placement helper returns `intoWaiting: true` for root queue items. -- [ ] The placement helper returns `intoWaiting: true` for live running upstreams. -- [ ] The placement helper returns `intoWaiting: true` for waiting queue items. -- [ ] The placement helper returns `intoWaiting: false` for completed upstreams with a usable artifact registry record. -- [ ] The placement helper throws for failed, skipped, completed-without-artifact, and unknown upstream ids. -- [ ] `EforgeEngine.enqueue()` writes `depends_on: ["q-abc"]` when called with `afterQueueId: "q-abc"`. -- [ ] `EforgeEngine.enqueue()` does not invoke or persist dependency-detector output when `afterQueueId` is provided. -- [ ] `POST /api/enqueue` returns 400 for non-string `afterQueueId`. -- [ ] `POST /api/enqueue` returns an error containing the invalid upstream id for unknown, failed, and skipped upstream ids. -- [ ] `POST /api/enqueue` spawns an enqueue worker with `--after ` for a valid upstream id. -- [ ] `eforge enqueue --after q-abc ` passes `afterQueueId: "q-abc"` into engine enqueue. -- [ ] `eforge build --after q-abc ` includes `afterQueueId: "q-abc"` in daemon `apiEnqueue` bodies. -- [ ] `eforge build --after q-abc --foreground ` passes `afterQueueId: "q-abc"` into foreground engine enqueue. diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md deleted file mode 100644 index 561f54b8..00000000 --- a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -id: plan-02-playbook-placement-parity -name: Playbook Dependency Placement Parity -branch: add-first-class-dependency-handoff-for-builds/plan-02-playbook-placement-parity -agents: - builder: - effort: medium - rationale: Uses the helper from plan 01 to fix one route path and expand route tests. - tester: - effort: high - rationale: Completed-artifact vs active-upstream playbook placement needs - file-location assertions. ---- - -# Playbook Dependency Placement Parity - -## Architecture Context - -Autonomous playbooks already expose `afterQueueId`, but the daemon route writes every dependent into `waiting/`. That strands dependents when the upstream already completed with a usable artifact because no future completion event will unblock them. Plan 01 provides the shared placement helper; this plan moves playbooks onto it. - -## Implementation - -### Overview - -Update `POST /api/playbook/run` for autonomous playbooks so it validates and classifies `afterQueueId` via the shared helper. Active upstreams still write to `.eforge/queue/waiting/`; completed upstreams with usable durable artifacts write to the queue root with `depends_on` preserved; invalid upstreams fail before queue mutation. - -### Key Decisions - -1. Reuse the plan 01 helper instead of retaining a playbook-only validator. -2. Keep planning-mode playbooks unchanged: they return `requires-agent` even when `afterQueueId` is present because no PRD is enqueued on that path. -3. Preserve AC quality gate order: invalid autonomous playbook acceptance criteria still return 400 before dependency validation. - -## Scope - -### In Scope - -- Update the autonomous playbook route in `packages/monitor/src/server.ts` to call the shared placement helper and pass its `dependsOn` and `intoWaiting` values into `enqueuePrd()`. -- Preserve existing `afterQueueId` validation failure status and message expectations where tests already assert them. -- Add playbook route tests for active upstream, completed-artifact upstream, unknown upstream, failed upstream, skipped upstream, and completed-without-artifact upstream. -- Add assertions that completed-artifact dependents are written to the queue root and active dependents are written to `waiting/`. - -### Out of Scope - -- Pi or Claude playbook UX changes. -- Normal `/api/enqueue` behavior; plan 01 owns that path. -- Scheduler or stack provider changes. - -## Files - -### Create - -- None expected. - -### Modify - -- `packages/monitor/src/server.ts` — replace the playbook route's validator-only logic plus unconditional `intoWaiting: true` with shared placement helper output. -- `test/playbook-api.test.ts` — expand autonomous playbook `afterQueueId` coverage for active, completed-artifact, failed, skipped, unknown, and completed-without-artifact upstreams. -- `test/queue-piggyback.test.ts` — adjust helper tests if plan 01 placed any playbook-specific edge case there. - -## Verification - -- [ ] Autonomous playbook run with active upstream writes the dependent PRD under `.eforge/queue/waiting/`. -- [ ] Autonomous playbook run with completed upstream plus usable artifact writes the dependent PRD under `.eforge/queue/` root. -- [ ] Both active and completed-artifact playbook dependents contain `depends_on: [""]` in frontmatter. -- [ ] Failed, skipped, unknown, and completed-without-artifact playbook upstreams return an error before queue mutation. -- [ ] Planning-mode playbook run with `afterQueueId` still returns `{ kind: "requires-agent" }` and writes no queue file. -- [ ] Existing AC-quality tests still return AC errors before dependency errors. diff --git a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md b/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md deleted file mode 100644 index 9c126e09..00000000 --- a/eforge/plans/add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -id: plan-03-consumer-surfaces-docs -name: Consumer Surfaces and Documentation -branch: add-first-class-dependency-handoff-for-builds/plan-03-consumer-surfaces-docs -agents: - builder: - effort: high - rationale: Keeps Pi and Claude Code plugin surfaces in parity while updating - skill docs and bounded sections of large extension files. - reviewer: - effort: high - rationale: Consumer-facing schema, skill, plugin-version, and documentation - changes need parity review. - tester: - effort: high - rationale: Needs source-wiring tests plus Pi native-command behavior tests. - doc-syncer: - effort: medium - rationale: Several user-facing docs and skill files must describe the new flag - and deterministic handoff behavior consistently. ---- - -# Consumer Surfaces and Documentation - -## Architecture Context - -After plans 01 and 02 add the API and placement semantics, user-facing integrations need to expose the same deterministic handoff. Repo policy requires `packages/pi-eforge/` and `eforge-plugin/` to stay in sync for consumer-facing behavior, and the Claude plugin version must be bumped when plugin files change. - -## Implementation - -### Overview - -Add optional `afterQueueId` to the `eforge_build` MCP/Pi tool schemas and forwarding bodies. Extend the native Pi `/eforge:build` command with a wait-or-run-now selection for active queue items, modeled on `playbook-commands.ts`. Update Pi and Claude build skill docs to parse `--after ` and pass `afterQueueId` to the build tool. Update user docs to explain explicit deterministic handoff, active vs completed placement, and single-dependency stack inference. - -### Key Decisions - -1. Keep Pi native UI user-friendly: show active build titles/statuses, send the resolved queue id internally. -2. Keep non-interactive and script paths deterministic via `--after ` and direct tool `afterQueueId`. -3. Bump `eforge-plugin/.claude-plugin/plugin.json` because plugin-visible behavior changes. -4. Prefer shared Pi UI helper extraction only if it avoids duplicating the playbook active-build selector without creating circular imports. - -## Scope - -### In Scope - -- Add optional `afterQueueId` to `eforge_build` schema and request body forwarding in `packages/pi-eforge/extensions/eforge/index.ts`. -- Add optional `afterQueueId` to `eforge_build` schema and request body forwarding in `packages/eforge/src/cli/mcp-proxy.ts` for Claude Code plugin parity. -- Extend `packages/pi-eforge/extensions/eforge/build-command.ts` so UI mode lists active queue items, offers “Run now”, and appends `--after ` when the user chooses an upstream. -- Reuse or extract the active-build fetch/filter/select pattern from `packages/pi-eforge/extensions/eforge/playbook-commands.ts` where that keeps file ownership simple. -- Update `packages/pi-eforge/skills/eforge-build/SKILL.md` to document `--after ` and to pass `afterQueueId` in `eforge_build` calls. -- Update `eforge-plugin/skills/build/build.md` with the same `--after ` behavior and `mcp__eforge__eforge_build` payload guidance. -- Bump `eforge-plugin/.claude-plugin/plugin.json` patch version. -- Update `README.md`, `docs/architecture.md`, `docs/config.md`, and `docs/stacking.md` only where the new behavior changes existing build/dependency/stack wording. -- Add/update tests for Pi tool forwarding, MCP tool schema/forwarding, Pi native build wait selection, skill parity, and docs route/type references. - -### Out of Scope - -- New monitor UI state. -- Multi-dependency selection UI. -- Manual stack-parent UI. -- Pi package version bump in `packages/pi-eforge/package.json`. - -## Files - -### Create - -- Optional: `packages/pi-eforge/extensions/eforge/build-dependency-selection.ts` — shared Pi helper for active-build fetching and wait-option formatting if the implementation would otherwise duplicate more than a small helper block. - -### Modify - -- `packages/pi-eforge/extensions/eforge/index.ts` — add `afterQueueId` to `eforge_build` tool schema and enqueue body. -- `packages/eforge/src/cli/mcp-proxy.ts` — add `afterQueueId` to `eforge_build` schema and enqueue body. -- `packages/pi-eforge/extensions/eforge/build-command.ts` — add active-build wait selection and append `--after ` to the skill args. -- `packages/pi-eforge/extensions/eforge/playbook-commands.ts` — export or reuse active-build helper only if shared extraction is chosen. -- `packages/pi-eforge/skills/eforge-build/SKILL.md` — document `--after ` and tool payload behavior. -- `eforge-plugin/skills/build/build.md` — mirror Pi build skill documentation with MCP tool naming. -- `eforge-plugin/.claude-plugin/plugin.json` — bump plugin patch version. -- `README.md` — add CLI/user-facing example for `eforge build --after ` or `eforge enqueue --after `. -- `docs/architecture.md` — clarify build handoff writes active dependencies to `waiting/` and completed-artifact dependencies to queue root. -- `docs/config.md` — update PRD queue/dependency text for explicit `--after` handoff. -- `docs/stacking.md` — state that a single explicit dependency from `afterQueueId` participates in existing stack-parent inference when stacking is enabled. -- `test/profile-wiring.test.ts` or a focused MCP/Pi wiring test — assert both `eforge_build` schemas include `afterQueueId` and both handlers forward it. -- `test/pi-build-command.test.ts` — assert native `/eforge:build` appends `--after ` when the active-build wait option is selected and preserves landing/profile args. -- `test/build-profile-selection-skill.test.ts` or a new skill-doc test — assert Pi and Claude build skill docs mention `--after ` and include `afterQueueId` in build tool calls. - -## Verification - -- [ ] Pi `eforge_build` schema accepts optional `afterQueueId`. -- [ ] Pi `eforge_build` request body includes `afterQueueId` when supplied. -- [ ] MCP `eforge_build` schema accepts optional `afterQueueId`. -- [ ] MCP `eforge_build` request body includes `afterQueueId` when supplied. -- [ ] Native Pi `/eforge:build` UI mode offers “Run now” plus active build wait options when queue items are active. -- [ ] Native Pi `/eforge:build` appends `--after ` to `/skill:eforge-build` args when a wait option is selected. -- [ ] Native Pi `/eforge:build` omits `--after` when “Run now” is selected. -- [ ] Pi and Claude build skill docs both document `--after `. -- [ ] Pi and Claude build skill docs both instruct tool callers to send `afterQueueId` when `--after` is present. -- [ ] Claude plugin version in `eforge-plugin/.claude-plugin/plugin.json` increases by one patch version. -- [ ] Documentation states explicit handoff is deterministic and dependency detector inference remains best effort. -- [ ] Documentation states a single explicit dependency becomes the stack parent when stacking is enabled. -- [ ] Documentation states active upstream dependencies wait and completed-artifact upstream dependencies enqueue as eligible dependents. diff --git a/eforge/prds/add-first-class-dependency-handoff-for-builds.md b/eforge/prds/add-first-class-dependency-handoff-for-builds.md deleted file mode 100644 index dcb05877..00000000 --- a/eforge/prds/add-first-class-dependency-handoff-for-builds.md +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: Add First-Class Dependency Handoff for Builds -created: 2026-05-28 -profile: gpt-claude-combo -landing: pr -landing_auto_merge: true ---- - -# Add First-Class Dependency Handoff for Builds - -## Problem / Motivation - -Users reasonably expect `/eforge:build` to support the same explicit wait/dependency handoff that the lower-level queue and stacking systems already support. - -Today, normal build enqueue can only rely on best-effort dependency detection, and the user-facing build surfaces do not expose a reliable `afterQueueId` path. This creates a mismatch: - -- The scheduler and stacking layers know how to wait and stack. -- Autonomous playbooks can pass `afterQueueId`. -- `/eforge:build` cannot reliably express "this build depends on that queue item". - -As a result, users cannot safely hand off a dependent session plan while an upstream build is running and trust eforge to wait, then branch from the upstream artifact branch. - -This gap was discovered while trying to enqueue Console UI work that explicitly depended on an in-flight stack-sync build. The desired user model is: a user hands a build to `/eforge:build`, selects or supplies an upstream queue item, and eforge persists the dependency, waits until the upstream artifact exists, and, when stacking is enabled with PR landing, builds the child on top of the upstream artifact branch. - -Evidence gathered: - -- `docs/architecture.md` documents piggyback scheduling: PRDs with dependencies are held in `waiting`, unblocked after upstream completion, and skipped transitively if upstream fails or is cancelled. -- `docs/stacking.md` documents single-dependency stack inference: when a PRD has exactly one `depends_on` and stacking is enabled, eforge infers `stack_parent` at dispatch time. -- `packages/engine/src/prd-queue.ts` already supports `enqueuePrd({ depends_on, intoWaiting })`, `validateDependsOnExists`, `propagateSkip`, and `unblockWaiting`. -- `packages/engine/src/queue/scheduler.ts` already waits for dependency artifacts, records completion availability, unblocks waiting PRDs, and persists inferred `stack_parent` for a single dependency before spawning a child. -- `packages/engine/src/stacking/base-resolver.ts` resolves a child stack base from the parent artifact registry entry or stack layer projection. -- `packages/client/src/routes.ts` `EnqueueRequest` currently has `source`, `flags`, `profile`, `landingAction`, and `landingAutoMerge`, but no explicit `afterQueueId` or dependency field. -- `packages/pi-eforge/extensions/eforge/index.ts` `eforge_build` tool schema currently has no dependency field and therefore cannot pass an explicit dependency through to `POST /api/enqueue`. -- `packages/monitor/src/server.ts` enqueue route validates profile and landing inputs, then spawns an `enqueue` worker with CLI flags; it currently has no handling for an explicit upstream dependency. -- `packages/eforge/src/cli/index.ts` `eforge enqueue` has no `--after` option, and `EforgeEngine.enqueue()` currently relies on best-effort dependency detection rather than an explicit dependency contract. -- `packages/pi-eforge/extensions/eforge/playbook-commands.ts` already implements a useful UX pattern for autonomous playbooks: detect active builds, offer "Run now" or "Wait for", resolve the internal queue id, and call `apiPlaybookRun` with `afterQueueId`. -- `packages/client/src/routes.ts` `PlaybookRunRequest` already carries `afterQueueId`; `packages/monitor/src/server.ts` validates it and enqueues autonomous playbooks into `waiting/`. -- Follow-up inspection on 2026-05-27 confirmed a refinement: the autonomous playbook route currently uses `intoWaiting: afterQueueId ? true : false`, which can strand dependents when `afterQueueId` references a completed upstream with a usable artifact because no future completion event will unblock them. -- `test/queue-piggyback.test.ts`, `test/artifact-aware-scheduler.test.ts`, `test/playbook-api.test.ts`, and `test/pi-playbook-commands.test.ts` already cover much of the lower-level dependency/waiting/stack-parent behavior for playbooks and scheduler internals. - -Classification: this is a **feature / focused** change. It adds a first-class dependency handoff capability to existing build/enqueue surfaces without changing the underlying queue or stacking model. - -## Goal - -Add a first-class dependency handoff capability to normal build enqueue flows so builds can explicitly wait for upstream queue items and become stacked PR children when stacking is enabled. - -The outcome is that `/eforge:build`, CLI, daemon APIs, Pi, and Claude Code plugin surfaces can deterministically pass an upstream queue id via `afterQueueId`, persist it as `depends_on`, place dependents correctly based on upstream readiness, and preserve existing scheduler-based stack inference. - -## Approach - -Use `afterQueueId` as the public field name for parity with playbooks. - -Rationale: playbook APIs and Pi playbook UX already use `afterQueueId`. Reusing the name avoids introducing a second concept for the same user intent. - -Treat explicit dependency as authoritative and skip dependency detector output for that dependency decision. - -Rationale: user-stated dependency should not be overridden by a best-effort agent. Dependency detector can still run for requests without explicit `afterQueueId`. - -Validate explicit dependencies before spawning enqueue workers or writing queue files. - -Rationale: stale queue ids should fail early with a clear message instead of creating stuck dependents. - -Keep stack topology inference in the scheduler. - -Rationale: the stacking docs already promise that a single `depends_on` infers `stack_parent`. Build surfaces should express dependency intent, not duplicate stacking topology logic. - -Add a shared queue dependency placement helper rather than duplicating active-vs-completed checks. - -Rationale: playbooks, CLI enqueue, daemon enqueue, and future wrappers need consistent behavior. The helper should validate the upstream id and return both the accepted dependency list and queue placement, for example `{ dependsOn: [afterQueueId], intoWaiting: boolean }`. Active upstreams should produce `intoWaiting: true`; completed upstreams with usable durable artifacts should produce `intoWaiting: false`; failed/skipped/unknown/completed-without-artifact upstreams should be rejected. - -Apply the placement helper to autonomous playbooks as well as normal builds. - -Rationale: current playbook route evidence shows `afterQueueId` is validated and then always enqueued with `intoWaiting: true`. That is safe for active upstreams but wrong for completed upstreams with usable artifacts because no future completion event will unblock the dependent. This feature should avoid creating a new correct path for `/eforge:build` while leaving playbooks with the old stuck-waiting edge case. - -Extend Pi `/eforge:build` with an optional wait selection when active queue items exist. - -Rationale: users should not need to type internal queue ids. The UI can show build titles while sending the resolved id internally, matching playbook UX. - -Support non-interactive explicit handoff with `--after `. - -Rationale: scripts, headless Pi, Claude Code, and direct CLI users need a deterministic path that does not depend on native UI selection. - -Keep monitor queue display based on existing queue state. - -Rationale: a dependent build in `waiting/` should naturally appear in queue state through existing queue APIs; a dependent build whose upstream already has an artifact should appear as a normal pending/root queue item with `depends_on` preserved. No special monitor-only state is needed. - -Likely code changes: - -- `packages/client/src/routes.ts` - - Add `afterQueueId?: string` to `EnqueueRequest` with documentation that it is the queue item id this build should run after. -- `packages/client/src/api/queue.ts` - - No route literal changes should be needed if `apiEnqueue` continues to use `EnqueueRequest`. -- `packages/engine/src/events.ts` - - Add `afterQueueId?: string` or equivalent explicit dependency option to `EnqueueOptions`. -- `packages/engine/src/prd-queue.ts` - - Add or refactor a helper that validates dependency ids and returns queue placement information, for example active dependency vs completed artifact dependency. - - Prefer a single helper used by normal enqueue and playbook enqueue, rather than a validator-only helper plus local `intoWaiting` decisions. - - Preserve `validateDependsOnExists` for existing callers or adapt it without breaking playbook behavior. -- `packages/engine/src/eforge.ts` - - Thread explicit enqueue dependency into `enqueuePrd` as `depends_on: [afterQueueId]`. - - Use `intoWaiting` only when the upstream is active/waiting rather than already completed with a usable artifact. - - Avoid replacing explicit `afterQueueId` with dependency-detector output. -- `packages/monitor/src/server.ts` - - Accept and validate `afterQueueId` on `POST /api/enqueue`. - - Reject a non-string `afterQueueId` with 400 before spawning a worker. - - Return a clear 404 or 400 when the selected upstream id is stale, unknown, failed, skipped, or completed without a usable artifact. - - Pass `--after ` to the enqueue worker. - - Update the autonomous playbook route to use the shared placement helper instead of unconditional `intoWaiting: true` whenever `afterQueueId` is provided. -- `packages/eforge/src/cli/index.ts` - - Add `eforge enqueue --after `. - - Add `eforge build --after ` if normal build delegation remains the primary user path for daemon-backed enqueue. - - Pass `afterQueueId` into `engine.enqueue()` for in-process enqueue/build paths. - - Include `--after` in daemon worker argument handling if daemon route delegates to CLI workers. -- `packages/eforge/src/cli/run-or-delegate.ts` - - Include `afterQueueId` in delegated `apiEnqueue` calls and in foreground `engine.enqueue()` calls. -- `packages/pi-eforge/extensions/eforge/index.ts` - - Add optional `afterQueueId` to the `eforge_build` tool schema and forward it in the enqueue body. -- `packages/pi-eforge/extensions/eforge/build-command.ts` - - Add a wait-or-run-now UI step for active queue items, using the existing playbook command pattern where possible. - - Append `--after ` to delegated `/skill:eforge-build` args when the user selects an upstream build. -- `packages/pi-eforge/skills/eforge-build/SKILL.md` - - Document `--after ` and tool-call behavior. -- `eforge-plugin/` - - Keep Claude Code plugin parity by adding the same MCP tool parameter and skill documentation updates. - - Bump `eforge-plugin/.claude-plugin/plugin.json` because plugin-facing behavior changes. -- `docs/config.md`, `docs/architecture.md`, `docs/stacking.md`, and/or README. - - Clarify that `/eforge:build` supports explicit dependency handoff and that single-dependency PRDs become stacked children when stacking is enabled. - -Architecture impact: - -No new subsystem is required. The change should connect existing layers: - -```mermaid -flowchart TD - User[/User selects or passes upstream build/] --> BuildSurface[/eforge:build or eforge_build/] - BuildSurface --> EnqueueRequest[POST /api/enqueue afterQueueId] - EnqueueRequest --> Validate[Validate dependency and classify placement] - Validate -->|active upstream| Waiting[Write PRD to queue/waiting with depends_on] - Validate -->|completed artifact| Pending[Write PRD to queue root with depends_on] - Waiting --> Unblock[unblockWaiting after upstream completion] - Unblock --> Pending - Pending --> Scheduler[Queue scheduler] - Scheduler --> StackInference[Infer stack_parent from single depends_on] - StackInference --> BaseResolver[Resolve parent artifact branch] - BaseResolver --> Build[Build child PRD] -``` - -The key architectural rule is that build surfaces express dependency intent; queue/scheduler/stacking layers remain responsible for readiness, artifact availability, and stack base resolution. - -Documentation impact: - -- Update user-facing docs to state that normal builds can be handed off after an upstream queue item. -- Document that Pi `/eforge:build` can offer active builds as "wait for" choices. -- Document that CLI can use `eforge enqueue --after `. -- Document that tool callers can pass `afterQueueId` to `eforge_build`. -- Document that with stacking enabled and PR landing, a single explicit dependency is enough for stack-parent inference. -- Avoid implying that eforge always auto-detects every dependency. -- State that explicit handoff is deterministic. -- State that dependency detector remains best effort. - -Risks and mitigations: - -- **Stuck waiting PRDs**: writing a completed-artifact dependency into `waiting/` could leave it stuck because no future upstream completion event will arrive. Mitigation: classify dependency placement and only use `waiting/` for active upstreams. -- **Existing playbook stuck-waiting edge case**: current autonomous playbook route evidence shows `afterQueueId` dependents are always written with `intoWaiting: true`. Mitigation: move playbooks onto the same placement helper as normal builds and add completed-artifact playbook tests. -- **False confidence from dependency detector**: keeping implicit detection as-is could obscure the new explicit behavior. Mitigation: explicit `afterQueueId` takes precedence and is clearly reported. -- **Stale queue ids**: active builds can finish between selection and enqueue. Mitigation: validate at enqueue time and classify the current state; if the upstream now has a usable artifact, enqueue the dependent in the queue root instead of failing or waiting; if the upstream is failed/skipped/unknown, return a clear error. -- **Plugin/Pi drift**: this touches both consumer integrations. Mitigation: update both `packages/pi-eforge/` and `eforge-plugin/`, and bump the Claude plugin version. -- **Ambiguous future multi-dependency stacking**: this plan only covers one explicit `afterQueueId`. Mitigation: defer multi-dependency and manual `stack_parent` selection. -- **Daemon API versioning**: adding a request field is additive for tolerant servers, but first-party clients and daemon must remain compatible. Mitigation: update shared `EnqueueRequest` and only bump `DAEMON_API_VERSION` if project policy treats this as a breaking route contract change. - -Recommended tests: - -- Client route type tests or TypeScript usage tests for `EnqueueRequest.afterQueueId`. -- Daemon route tests for `POST /api/enqueue` with valid and invalid `afterQueueId`. -- Engine enqueue tests proving explicit `afterQueueId` persists `depends_on` and bypasses dependency-detector replacement. -- Queue placement tests proving active upstreams write to `waiting/` and completed artifact upstreams can write to queue root. -- Playbook API tests proving autonomous playbooks use the same placement helper: active upstreams write to `waiting/`, completed-artifact upstreams write to queue root, and failed/skipped/unknown upstreams are rejected. -- CLI tests proving `eforge enqueue --after q-abc` and, if added, `eforge build --after q-abc` pass the dependency through. -- Pi tool tests proving `eforge_build` forwards `afterQueueId`. -- Pi native build command tests proving active-build wait selection appends `--after`. -- Plugin parity tests or schema snapshots if present. -- Existing `pnpm type-check` and targeted test suites pass. - -Assumptions and validation: - -| Assumption | Evidence / validation performed | Confidence | Cost to validate further | Validation path | Impact if wrong | -|------------|----------------------------------|------------|--------------------------|-----------------|-----------------| -| Existing queue/scheduler/stacking layers already support the core wait-then-stack behavior once `depends_on` is present. | Read `prd-queue.ts`, `queue/scheduler.ts`, `stacking/base-resolver.ts`, `docs/architecture.md`, and `docs/stacking.md`; tests already cover waiting, artifact readiness, and stack-parent inference. | high | low | Add an end-to-end route/engine test for `afterQueueId` through queue placement and scheduler dispatch. | If wrong, implementation expands beyond surface plumbing into scheduler fixes. | -| `afterQueueId` is the right public field name. | Existing playbook API and Pi playbook command use `afterQueueId` for the same user intent. | high | low | Confirm docs/API naming during implementation review. | If wrong, API churn and duplicated concepts. | -| Active upstreams should enqueue dependents into `waiting/`, while completed-artifact upstreams should enqueue into root/pending. | `unblockWaiting` is completion-event driven; completed artifacts will not emit a future completion event. `validateDependsOnExists` already accepts both live upstreams and completed upstreams with usable artifacts, but does not itself return placement. | high | low | Write placement tests for both active and completed-artifact upstreams. | If wrong, dependents can get stuck or dispatch too early. | -| Autonomous playbooks should use the same active-vs-completed placement behavior as normal builds. | Current code inspection shows the playbook route validates `afterQueueId` then calls `enqueuePrd` with `depends_on: [afterQueueId]` and `intoWaiting: true` whenever `afterQueueId` is present. This is correct for active upstreams but wrong for completed upstreams with artifacts. | high | low | Add playbook API tests for active upstream and completed-artifact upstream placement before/after refactor. | If wrong, playbooks keep a stuck-waiting edge case or diverge from normal build semantics. | -| Build surfaces should not set `stack_parent` directly for the single-dependency case. | Stacking docs and scheduler code already infer `stack_parent` from one `depends_on`. | high | low | Existing artifact-aware scheduler tests verify inference; add one route-level integration assertion if needed. | If wrong, stack topology may be duplicated or inconsistent. | -| Pi build UI can reuse active-build selection patterns from playbook commands. | `playbook-commands.ts` already fetches running items, presents wait choices, and forwards `afterQueueId`. | medium | low | Inspect helper reuse options during implementation; extract common helper if duplication grows. | If wrong, Pi UI implementation takes slightly longer but the API can still ship. | -| Claude plugin parity is required. | `AGENTS.md` requires keeping `eforge-plugin/` and `packages/pi-eforge/` in sync for consumer-facing behavior. | high | low | Search plugin build tool and skill files during implementation. | If missed, Claude users lack the feature and repo policy is violated. | -| Additive `afterQueueId` does not require a daemon API version bump. | The field is optional and backward-compatible at the TypeScript shape level, but project policy may define API versioning more strictly. | medium | low | Inspect `DAEMON_API_VERSION` policy and existing route-contract tests before implementation. | If wrong, clients may see version mismatch or route-contract drift. | - -No low-confidence, high-impact assumptions remain unresolved. The main implementation-time check is queue placement for completed-artifact dependencies, including autonomous playbooks, so dependents do not get stuck in `waiting/`. - -Recommended profile: **Excursion**. - -Rationale: the work crosses client types, daemon route handling, engine enqueue plumbing, CLI, Pi, Claude plugin parity, docs, and tests, but it is a cohesive single capability. It should not require delegated module planners; one cohesive plan can enumerate the necessary changes and dependencies. - -## Scope - -In scope: - -- Add an explicit dependency handoff field to normal build enqueue APIs, likely named `afterQueueId` for parity with playbooks. -- Thread this field through `@eforge-build/client`, daemon HTTP route handling, CLI `eforge enqueue`, Pi `eforge_build` tool, Pi `/eforge:build` native command, Claude Code plugin MCP schema/skill docs, and user-facing docs. -- Make explicit dependency handoff deterministic and stronger than dependency detector inference. -- Validate explicit upstream ids before queue mutation using existing dependency validation rules. -- Classify explicit dependency placement before queue mutation: active/live upstreams should put dependents in `.eforge/queue/waiting/`; completed upstreams with usable durable artifacts should put dependents in the queue root so they are eligible to dispatch immediately. -- Persist `depends_on: []` in the queued PRD frontmatter for both waiting and immediately-eligible dependents. -- Bring autonomous playbook `afterQueueId` enqueue placement onto the same shared placement path. Current evidence shows the playbook route validates `afterQueueId` but always calls `enqueuePrd(..., intoWaiting: true)` when `afterQueueId` is present; this refinement should fix that so completed-artifact playbook dependencies do not get stuck in `waiting/`. -- Preserve existing dependency-detector behavior for enqueue requests without explicit dependency handoff. -- Preserve stacking inference: when stacking is enabled and the PRD has exactly one `depends_on`, scheduler dispatch should infer and persist `stack_parent` rather than requiring the build surface to set it manually. -- Reuse the playbook wait-or-run-now UX pattern in Pi `/eforge:build` where technically feasible. -- Add targeted tests for route contract, daemon validation, engine enqueue behavior, CLI flag plumbing, Pi tool plumbing, playbook placement parity, and docs/skill parity. - -Out of scope: - -- General multi-dependency selection UI for normal builds. -- Manual `stack_parent` selection for ambiguous multi-dependency stacks. -- Queue reordering or priority editing. -- New stack providers. -- Changing scheduler artifact-readiness semantics beyond preventing already-completed artifact dependencies from being written to `waiting/`. -- Replacing dependency detector inference. -- Automatically deciding dependencies without user confirmation when an explicit wait choice is available. -- Preserving the current playbook behavior that always writes `afterQueueId` dependents to `waiting/`; that behavior is now treated as part of the placement bug to correct. - -## Acceptance Criteria - -- `EnqueueRequest` in `packages/client/src/routes.ts` includes optional `afterQueueId?: string` with documentation that it is the queue item id this build should run after. -- The shared `apiEnqueue` helper accepts a request body containing `afterQueueId` without local type errors. -- The Pi `eforge_build` tool schema accepts optional `afterQueueId`. -- The Pi `eforge_build` tool forwards `afterQueueId` to `POST /api/enqueue` when it is provided. -- The Claude Code plugin build tool schema accepts optional `afterQueueId`. -- The Claude Code plugin build tool forwards `afterQueueId` to `POST /api/enqueue` when it is provided. -- The CLI command `eforge enqueue --after ` parses `` as an explicit upstream dependency. -- The CLI command `eforge build --after ` parses `` as an explicit upstream dependency if normal build remains the daemon-delegated user path. -- The daemon `POST /api/enqueue` route accepts optional `afterQueueId`. -- The daemon `POST /api/enqueue` route rejects a non-string `afterQueueId` with a 400 response. -- The daemon `POST /api/enqueue` route rejects an unknown `afterQueueId` before spawning an enqueue worker. -- The daemon `POST /api/enqueue` route returns an error message that includes the invalid upstream id when `afterQueueId` validation fails. -- The enqueue worker receives the selected upstream id when `POST /api/enqueue` is called with `afterQueueId`. -- `EforgeEngine.enqueue()` accepts an explicit upstream dependency option. -- `EforgeEngine.enqueue()` writes `depends_on: [""]` when an explicit upstream dependency option is provided. -- `EforgeEngine.enqueue()` does not replace an explicit upstream dependency with dependency-detector output. -- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live pending upstream queue item. -- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live running upstream queue item. -- The queue dependency placement helper returns `intoWaiting: true` for an explicit dependency that refers to a live waiting upstream queue item. -- The queue dependency placement helper returns `intoWaiting: false` for an explicit dependency that refers to a completed upstream with a usable durable artifact record. -- The queue dependency placement helper rejects an explicit dependency that refers to a failed upstream queue item. -- The queue dependency placement helper rejects an explicit dependency that refers to a skipped upstream queue item. -- The queue dependency placement helper rejects an explicit dependency that refers to a completed upstream without a usable durable artifact record. -- The queue dependency placement helper rejects an explicit dependency that refers to an unknown queue item id. -- A build enqueued with `afterQueueId` referencing an active upstream is written under `.eforge/queue/waiting/`. -- A build enqueued with `afterQueueId` referencing an active upstream does not dispatch before the upstream has a usable artifact registry record. -- A build enqueued with `afterQueueId` referencing a completed upstream with a usable artifact is written to the queue root instead of `.eforge/queue/waiting/`. -- A build enqueued with `afterQueueId` referencing a completed upstream with a usable artifact is eligible to dispatch without waiting for a future completion event. -- An autonomous playbook run with `afterQueueId` referencing an active upstream is written under `.eforge/queue/waiting/`. -- An autonomous playbook run with `afterQueueId` referencing a completed upstream with a usable artifact is written to the queue root instead of `.eforge/queue/waiting/`. -- An autonomous playbook run with `afterQueueId` referencing a failed upstream is rejected before queue mutation. -- An autonomous playbook run with `afterQueueId` referencing a skipped upstream is rejected before queue mutation. -- An autonomous playbook run with `afterQueueId` referencing an unknown upstream is rejected before queue mutation. -- An autonomous playbook run with `afterQueueId` referencing a completed-without-artifact upstream is rejected before queue mutation. -- A dependent build with exactly one `depends_on` has `stack_parent` inferred and persisted before dispatch when `stacking.enabled` is true. -- A dependent stacked build resolves its base branch from the parent artifact branch recorded for the upstream queue item. -- A dependent waiting build moves to `skipped/` when the selected upstream build fails. -- A dependent waiting build moves to `skipped/` when the selected upstream build is cancelled or skipped. -- Pi `/eforge:build` presents a run-now option and wait-for-active-build options when active queue items are available in UI mode. -- Pi `/eforge:build` passes the selected active build id as `--after ` to the build skill when the user chooses to wait. -- `/skill:eforge-build` documents `--after ` as the way to enqueue a normal build after an upstream queue item. -- `/skill:eforge-build` passes `afterQueueId` to `eforge_build` when `--after ` is present. -- Queue piggyback tests continue to pass. -- Artifact-aware scheduler stack-parent inference tests continue to pass. -- Existing autonomous playbook `afterQueueId` tests for active upstreams continue to pass. -- New daemon enqueue route tests cover a valid active upstream case. -- New daemon enqueue route tests cover a valid completed-artifact upstream case. -- New daemon enqueue route tests cover an unknown upstream case. -- New daemon enqueue route tests cover a failed upstream case. -- New daemon enqueue route tests cover a skipped upstream case. -- New playbook API tests cover a valid active upstream case. -- New playbook API tests cover a valid completed-artifact upstream case. -- New playbook API tests cover an unknown upstream case. -- New playbook API tests cover a failed upstream case. -- New playbook API tests cover a skipped upstream case. -- New CLI tests cover `eforge enqueue --after ` argument plumbing. -- New CLI tests cover `eforge build --after ` argument plumbing if the flag is added to the build command. -- New Pi tests cover `eforge_build` tool forwarding. -- New Pi tests cover native build wait selection. -- Client route type tests or TypeScript usage tests verify `EnqueueRequest.afterQueueId`. -- Daemon route tests verify `POST /api/enqueue` behavior with a valid `afterQueueId`. -- Daemon route tests verify `POST /api/enqueue` behavior with an invalid `afterQueueId`. -- Engine enqueue tests prove explicit `afterQueueId` persists `depends_on`. -- Engine enqueue tests prove explicit `afterQueueId` bypasses dependency-detector replacement. -- Queue placement tests prove active upstreams write to `waiting/`. -- Queue placement tests prove completed artifact upstreams can write to the queue root. -- Playbook API tests prove autonomous playbooks use the same placement helper as normal builds. -- Playbook API tests prove autonomous playbooks with active upstreams write to `waiting/`. -- Playbook API tests prove autonomous playbooks with completed-artifact upstreams write to the queue root. -- Playbook API tests prove autonomous playbooks with failed upstreams are rejected. -- Playbook API tests prove autonomous playbooks with skipped upstreams are rejected. -- Playbook API tests prove autonomous playbooks with unknown upstreams are rejected. -- CLI tests prove `eforge enqueue --after q-abc` passes the dependency through. -- CLI tests prove `eforge build --after q-abc` passes the dependency through if `eforge build --after` is added. -- Pi tool tests prove `eforge_build` forwards `afterQueueId`. -- Pi native build command tests prove active-build wait selection appends `--after`. -- Plugin parity tests or schema snapshots pass if present. -- Documentation explains that explicit dependency handoff is deterministic and dependency detector inference remains best effort. -- Documentation explains that a single explicit dependency becomes the stack parent automatically when stacking is enabled. -- Documentation explains that active upstream dependencies wait, while completed upstream dependencies with usable artifacts enqueue as immediately eligible dependents. -- The Claude Code plugin version is bumped if any plugin files change. -- `pnpm type-check` exits 0. -- Targeted tests for queue piggybacking exit 0. -- Targeted tests for artifact-aware scheduling exit 0. -- Targeted tests for playbook API behavior exit 0. -- Targeted tests for build enqueue route behavior exit 0. -- Targeted tests for CLI enqueue behavior exit 0. -- Targeted tests for Pi build command behavior exit 0.