Skip to content

feat(plan): add polyphony plan derive-ancestor-chain verb (Phase 3 P7a) - #82

Merged
PolyphonyRequiem merged 1 commit into
mainfrom
sdlc/plan-derive-ancestor-chain
May 6, 2026
Merged

feat(plan): add polyphony plan derive-ancestor-chain verb (Phase 3 P7a)#82
PolyphonyRequiem merged 1 commit into
mainfrom
sdlc/plan-derive-ancestor-chain

Conversation

@PolyphonyRequiem

Copy link
Copy Markdown
Owner

Summary

Phase 3 P7a — preparation step for the upcoming plan-level.yaml rewrite (P7b).

Adds a small routing-style verb that walks the work-item parent chain from a
given --item-id up to (but not past) the run's --root-id, and emits the
inputs the Phase 3 plan-PR verbs (branch ensure-plan, pr open-plan-pr,
pr merge-plan-pr) need:

Output field Maps to Notes
root_id --root-id (echo) Same as input
is_root_plan n/a Convenience flag — true when item_id == root_id
parent_item_id --parent-item-id null for root plan + direct children of root; else parent's id
ancestor_ids --ancestor-ids (CSV string) Immediate parent first, ending in literal "root". Empty for root
ancestor_chain n/a Same as ancestor_ids but as string[]
depth n/a Plan-tree depth (0 for root, 1 for direct child, etc.)
error n/a Human-readable error or null on success

Why a verb instead of Jinja

plan-level.yaml only knows work_item_id (echo of workflow.input.work_item_id)
and — once P7b lands — root_id (propagated through for_each). Computing
parent_item_id + ancestor_ids from those two requires walking
WorkItem.ParentId repeatedly, which is brittle in Jinja and impossible to
unit-test.

This verb does it in ~120 lines of C# with full test coverage. The workflow
becomes:

- name: ancestor_chain
  type: script
  command: polyphony
  args: [plan, derive-ancestor-chain, --root-id, "{{ workflow.input.root_id }}",
         --item-id, "{{ workflow.input.work_item_id }}"]
- name: open_plan_pr
  type: script
  command: polyphony
  args: [pr, open-plan-pr,
         --root-id, "{{ workflow.input.root_id }}",
         --item-id, "{{ workflow.input.work_item_id }}",
         --parent-item-id, "{{ ancestor_chain.output.parent_item_id | default(0) }}",
         --ancestor-ids, "{{ ancestor_chain.output.ancestor_ids }}"]

(The pr open-plan-pr verb already treats --parent-item-id 0 as "omit",
matching the convention from branch ensure-plan and pr merge-plan-pr.)

What's covered

11 unit tests (PlanCommandsDeriveAncestorChainTests.cs):

  • Input validation: --root-id <= 0, --item-id <= 0
  • item_id == root_id → root-plan special case (empty chain)
  • Direct child of root (parent_item_id is null, chain is ["root"])
  • Grandchild (chain is [parent, "root"])
  • Great-grandchild (chain is [parent, grandparent, "root"])
  • Item not found in cache
  • Item has no parent (not a descendant of root)
  • Chain dead-ends at non-root ancestor
  • Ancestor in the middle of the chain is missing from cache
  • Cycle detected in the parent chain

Plus type-agnostic lint passing and full suite green: 1772/1772 (was 1761; +11).

What's NOT in this PR

  • Workflow consumer (plan-level.yaml rewrite) — that's P7b, which
    follows separately. P7a was split out so the verb can ship + be reviewed
    independently of the larger workflow change.
  • The literal "root" token convention is inherited from
    pr open-plan-pr's existing --ancestor-ids contract (see
    PrCommands.OpenPlanPr.cs XML doc); this PR doesn't change that.

Phase 3 progress

PRs P0–P6 merged. P7-pre decision applied (option 2: LLM advisory + human approval).
This is P7a. Next: P7b (the plan-level.yaml rewrite itself), then
P8 (scope renegotiation), P9 (ancestor cascade), P10 (operator UX).

…P7a)

Helper verb that walks the work-item parent chain from --item-id up to (but
not past) --root-id and emits the inputs the Phase 3 plan-PR verbs need:

  * --root-id          — echo of the input
  * --parent-item-id   — immediate plan-tree parent (null for root plan and
                         direct children of root, where the parent is implicit)
  * --ancestor-ids     — comma-separated chain (immediate parent first),
                         with the literal "root" token in place of the root
                         work-item id; empty for the root plan

The workflow consumer (`plan-level.yaml`, P7b) only needs to know root_id +
work_item_id; the helper derives parent_item_id and ancestor_ids by walking
WorkItem.ParentId. This shifts the chain-construction logic out of brittle
Jinja templates and into testable C#.

The verb is a routing-style script: always exits 0, surfaces errors via the
`error` field. Error paths covered:

  * --root-id / --item-id non-positive
  * Item not found in the local cache
  * Item is not a descendant of root (parent chain dead-ends)
  * Ancestor in the chain is missing from the local cache
  * Cycle detected in parent chain (de-dup set)
  * Walk exceeds 50 steps (cycle suspected, defensive)

Tests: 11 new unit tests covering the root-plan special case, direct child
of root, grandchild, great-grandchild, and all error paths. Full suite:
1772/1772 passing (was 1761; +11). Type-agnostic lint passes.

Phase 3 P7a — preparation for the `plan-level.yaml` rewrite (P7b). Follows
P7-pre identity-decision (option 2: LLM advisory + human approval).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PolyphonyRequiem
PolyphonyRequiem merged commit 7e51332 into main May 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant