diff --git a/.changeset/friendly-provider-workflows.md b/.changeset/friendly-provider-workflows.md new file mode 100644 index 00000000..19af5b48 --- /dev/null +++ b/.changeset/friendly-provider-workflows.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Generate provider-form tracker configuration in new workflows and document migration from deprecated flat tracker keys (#710). diff --git a/README.md b/README.md index 30e6c488..86fe1b36 100644 --- a/README.md +++ b/README.md @@ -311,16 +311,17 @@ Use a Project single-select field: ```yaml tracker: kind: github-project - project_id: PVT_kwDOxxxxxx - state_field: Status - priority: - source: project-field - field: Priority - values: - Urgent: 0 - High: 1 - Medium: 2 - Low: 3 + provider: + project_id: PVT_kwDOxxxxxx + state_field: Status + priority: + source: project-field + field: Priority + values: + Urgent: 0 + High: 1 + Medium: 2 + Low: 3 ``` Or use exact repository labels: @@ -328,14 +329,15 @@ Or use exact repository labels: ```yaml tracker: kind: github-project - project_id: PVT_kwDOxxxxxx - state_field: Status - priority: - source: labels - labels: - P0: 0 - P1: 1 - P2: 2 + provider: + project_id: PVT_kwDOxxxxxx + state_field: Status + priority: + source: labels + labels: + P0: 0 + P1: 1 + P2: 2 ``` Or disable priority dispatch explicitly: @@ -343,13 +345,14 @@ Or disable priority dispatch explicitly: ```yaml tracker: kind: github-project - priority: - source: disabled + provider: + priority: + source: disabled ``` Lower numbers dispatch first. If an issue has multiple configured priority labels, Symphony uses the lowest numeric value and emits `priority.label_conflict_resolved`. If an active issue carries an unmapped configured-source value, it resolves to `priority = null` and emits `priority.unmapped`. -Legacy `tracker.priority_field: Priority` remains supported for existing workflows, but it is deprecated because it uses live Project option order. Project field definitions are cached for the process lifetime, so field creation, removal, and option changes take effect after the daemon restarts. To migrate, replace it with `tracker.priority.source: project-field`, copy the exact field name, and write explicit option-name-to-number mappings. If both legacy and explicit config are present, explicit `tracker.priority` wins and diagnostics warn about the conflict. +Legacy `tracker.priority_field: Priority` remains supported for existing workflows, but it is deprecated because it uses live Project option order. Project field definitions are cached for the process lifetime, so field creation, removal, and option changes take effect after the daemon restarts. To migrate, replace it with `tracker.provider.priority.source: project-field`, copy the exact field name, and write explicit option-name-to-number mappings. If both legacy and explicit config are present, explicit `tracker.provider.priority` wins and diagnostics warn about the conflict. `gh-symphony workflow validate` reports local config errors and legacy priority warnings. Strict front-matter failures use stable workflow error codes; with `--json`, `workflow validate` includes both `error.code` and `error.path`. `gh-symphony doctor` additionally checks live Project/repository drift: missing fields, missing labels, unmapped live options, stale configured mappings, and active issues that currently resolve to `priority = null` because their priority-like value is unmapped. @@ -708,8 +711,9 @@ host: ```yaml tracker: kind: github-project - endpoint: https://github.example/api/graphql - project_id: PVT_xxx + provider: + endpoint: https://github.example/api/graphql + project_id: PVT_xxx ``` Then initialize and validate the repository runtime: @@ -722,9 +726,9 @@ gh-symphony doctor --smoke --issue owner/repo#123 ``` `GITHUB_GRAPHQL_API_URL` remains an optional process-level override. If both -`tracker.endpoint` and `GITHUB_GRAPHQL_API_URL` are set, keep them identical; +`tracker.provider.endpoint` and `GITHUB_GRAPHQL_API_URL` are set, keep them identical; `doctor` reports the resolved endpoint and warns when they disagree. During -dispatch, the GitHub tracker injects the configured `tracker.endpoint` into the +dispatch, the GitHub tracker injects the configured `tracker.provider.endpoint` into the worker as `GITHUB_GRAPHQL_API_URL`, so worker-side `github_graphql` calls do not fall back to `https://api.github.com/graphql`. @@ -752,7 +756,8 @@ tick; dispatch events also include `workflowRevision`. The generated file includes: -- **Lifecycle**: `active_states`, `terminal_states`, explicit `blocker_check_states`, and `planning_states` derived from the status column mapping. Lifecycle state names are matched case-insensitively after trimming. Missing blocker configuration defaults to the first active state; an explicit `blocker_check_states: []` disables blocker gating as an intentional spec divergence. Planning remains disabled unless configured explicitly. +- **Lifecycle**: core `tracker.active_states` and `tracker.terminal_states`, plus provider-owned `blocker_check_states` and `planning_states`, derived from the status column mapping. Lifecycle state names are matched case-insensitively after trimming. Missing blocker configuration defaults to the first active state; an explicit `tracker.provider.blocker_check_states: []` disables blocker gating as an intentional spec divergence. Planning remains disabled unless configured explicitly. +- **Tracker provider**: adapter-owned settings are generated under `tracker.provider`. Flat tracker keys are deprecated aliases and will be removed in the next major release (#679). - **Runtime**: `agent_command` derived from `gh-symphony workflow init` - **Hooks**: `after_create` hook path - **Scheduler**: `poll_interval_ms` @@ -775,7 +780,7 @@ Available template variables: | `{{execution_phase}}` | `planning`, `implementation`, or null | | `{{guidelines}}` | Prompt guidelines from WORKFLOW.md | -`tracker.planning_states` classifies matching states as `planning`; it does not +`tracker.provider.planning_states` classifies matching states as `planning`; it does not impose a built-in plan-only gate or make a state eligible for dispatch. Use `execution_phase` in the prompt body when policy should change agent behavior, for example: diff --git a/docs/adr/2026-08-29_tracker-provider-alias-deprecation.md b/docs/adr/2026-08-29_tracker-provider-alias-deprecation.md new file mode 100644 index 00000000..190545f0 --- /dev/null +++ b/docs/adr/2026-08-29_tracker-provider-alias-deprecation.md @@ -0,0 +1,42 @@ +# ADR: Retain flat tracker keys as deprecated provider aliases + +- **Date**: 2026-08-29 +- **Status**: Accepted +- **Related Issues**: #669, #679, #710 +- **Related Spec**: `docs/symphony-spec.md` §5.3.1, §6.1 (read-only) + +## Context + +The upstream configuration model places adapter-owned tracker settings in the +opaque `tracker.provider` object. Existing GitHub Symphony workflows used flat +`tracker.*` keys such as `project_id`, `endpoint`, `state_field`, `priority`, +and `pickup_labels`. Removing those keys immediately would break committed +workflows and installed repository runtimes. + +## Decision + +New generated workflows, reference workflows, and skill templates use +`tracker.provider`. The parser continues to promote supported flat tracker keys +into that provider object as deprecated, non-breaking aliases. Diagnostics from +`gh-symphony workflow validate` and `gh-symphony repo doctor` identify the +aliases and print a copyable normalized provider block. + +The aliases are scheduled for removal in the next major release. The removal +work is tracked separately in #679 and must not begin before its required sign +off. + +## Upstream conformance and divergence + +Provider-form configuration aligns with the upstream specification. Retaining +flat aliases is an intentional, time-bounded repository compatibility extension +rather than an upstream-spec change. `docs/symphony-spec.md` remains +unchanged. + +## Consequences + +- Newly initialized repositories follow the provider-owned configuration + boundary without migration work. +- Existing repositories keep working while operators migrate using doctor + output. +- Documentation consistently labels flat keys as deprecated and directs new + configuration to `tracker.provider`. diff --git a/docs/configuration.md b/docs/configuration.md index 9df488a5..3756b0fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,12 +31,26 @@ Human-readable `gh-symphony repo status`, `gh-symphony project status`, and their `--watch` dashboards show the applied revision; `--json` exposes the same metadata for automation. -Tracker connection settings are persisted in runtime `config.json`; changing -them does not reconfigure an already-running daemon. This is an intentional -repository-local live-reload divergence. In standalone project mode, stop and -start the daemon to apply tracker endpoint, credential, or binding changes. In -repo-embedded mode, those settings are fixed when `gh-symphony repo init` -creates the runtime, so run `repo init` again before restarting `repo start`. +### Tracker provider binding and live reload + +`repo init` writes the runtime's selected tracker adapter and repository/project +binding to `config.json` and its project record. Changing that binding — for +example, switching `tracker.kind`, moving to a different initialized project, +or changing a runtime-owned provider path — requires running `gh-symphony repo +init` again and restarting the daemon. A `WORKFLOW.md` edit never rewrites +`config.json`. + +The workflow policy passed to the already-selected adapter is live-reloaded on +every reconciliation tick: core `tracker.active_states`, +`tracker.terminal_states`, `tracker.state_field`, and the provider-owned +`blocker_check_states` and `planning_states` apply on the next tick. The +remaining provider settings — `project_id` or `project_slug`, `endpoint`, +`priority`, `priority_field_name`, and `pickup_labels` — are read from the +project record that `repo init` wrote, so editing them in `WORKFLOW.md` has no +effect until the runtime is initialized again and the daemon restarts. Existing +workers keep the policy and tracker dependencies captured for their own run. +This is the same tick-based reload boundary described above, not a +watcher-driven update. ## Runtime, Retry, and Hook Divergences @@ -123,7 +137,7 @@ is pending adapter work in #660-B. ## Workflow Lifecycle Policy -`tracker.blocker_check_states` selects the workflow states where the GitHub and +`tracker.provider.blocker_check_states` selects the workflow states where the GitHub and Linear adapters derive `dispatchable: false` from unresolved `blocked_by` dependencies. The orchestrator consumes that normalized result and does not interpret provider blocker semantics. When the field is omitted, the default is @@ -136,7 +150,7 @@ GitHub source-closed blockers and blockers whose Project workflow state is in `tracker.terminal_states` are resolved. Linear uses its workflow-state relation data directly. In both adapters, `blocked_by` remains best-effort metadata and `dispatchReason` identifies an unresolved dependency. Omitting -`planning_states` keeps planning disabled; blocker defaults do not enable the +`tracker.provider.planning_states` keeps planning disabled; blocker defaults do not enable the planning/human-review execution phase. Linear `blocked_by` metadata is derived from inverse relations of type `blocks`; source-side relations describe issues blocked by the current issue and are not blockers of it. @@ -285,7 +299,7 @@ comma-separated strings) and must be configured explicitly unless the selected adapter supplies lifecycle defaults. `tracker.active_states` controls dispatch eligibility, while -`tracker.planning_states` classifies states for prompt policy and status +`tracker.provider.planning_states` classifies states for prompt policy and status surfaces. Classification is independent of dispatch eligibility: a matching planning state resolves to `planning` even when it is absent from `active_states`. Both lists use trimmed, case-insensitive state matching. @@ -316,8 +330,6 @@ tracker: project_id: PVT_kwDOxxxxxx endpoint: https://api.github.com/graphql state_field: Status - active_states: [Todo, In Progress] - terminal_states: [Done] blocker_check_states: [Todo] planning_states: [] pickup_labels: @@ -325,6 +337,8 @@ tracker: exclude: [blocked] priority: source: disabled + active_states: [Todo, In Progress] + terminal_states: [Done] ``` The documented GitHub Project lifecycle profile is `Status`, active states @@ -335,7 +349,8 @@ or field is omitted. Flat `tracker.project_id`, `tracker.endpoint`, `tracker.blocker_check_states`, and `tracker.planning_states` aliases remain supported for compatibility, but `gh-symphony workflow validate` and `gh-symphony repo doctor` warn and print a copyable `tracker.provider` block. -They are scheduled for removal in the next major release. +They are scheduled for removal in the next major release; see +[ADR 2026-08-29](adr/2026-08-29_tracker-provider-alias-deprecation.md). ## Skill Layering @@ -381,7 +396,7 @@ container environments. | Variable | Default | Read by | Audience | Notes | | ------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GITHUB_GRAPHQL_TOKEN` | unset | CLI, orchestrator, GitHub tracker, Codex runtime, Claude runtime, Git credential helper | User-facing | Token-only GitHub auth source. Requires `repo`, `read:org`, and `project` scopes. Takes priority over `gh` CLI auth where both are supported. | -| `GITHUB_GRAPHQL_API_URL` | unset; GitHub tooling falls back to the public GitHub GraphQL endpoint unless tracker config injects an endpoint | CLI doctor, Codex runtime, Claude runtime | User-facing, GHES | Process-level GraphQL endpoint override. For GHES, prefer `tracker.endpoint` in `WORKFLOW.md`; if both are set, keep them identical. | +| `GITHUB_GRAPHQL_API_URL` | unset; GitHub tooling falls back to the public GitHub GraphQL endpoint unless tracker config injects an endpoint | CLI doctor, Codex runtime, Claude runtime | User-facing, GHES | Process-level GraphQL endpoint override. For GHES, prefer `tracker.provider.endpoint` in `WORKFLOW.md`; if both are set, keep them identical. | | `GITHUB_PROJECT_ID` | unset; injected from project config for workers | Codex runtime, Claude runtime | Internal unless running a runtime launcher manually | Passed to GitHub GraphQL tooling so agent tools can target the active Project. | | `LINEAR_API_KEY` | unset | CLI, Codex runtime, Claude runtime | User-facing for Linear tracker projects | Required for Linear repo startup. The built-in Linear MCP server receives it in its declared environment and uses it as the raw `Authorization` value. | | `LINEAR_AUTHORIZATION` | unset | Codex runtime, Claude runtime | Advanced | Optional raw Linear authorization value for the built-in Linear MCP server; it takes priority over `LINEAR_API_KEY`. | diff --git a/docs/examples/linear-WORKFLOW.md b/docs/examples/linear-WORKFLOW.md index 28c79f5f..187bd848 100644 --- a/docs/examples/linear-WORKFLOW.md +++ b/docs/examples/linear-WORKFLOW.md @@ -1,28 +1,29 @@ --- tracker: kind: linear - endpoint: https://api.linear.app/graphql - api_key: $LINEAR_API_KEY - project_slug: symphony-0c79b11b75ea + provider: + endpoint: https://api.linear.app/graphql + api_key: $LINEAR_API_KEY + project_slug: symphony-0c79b11b75ea + pickup_labels: + include: + - agent + - dev-ready + exclude: + - no-agent + - needs-spec + blocker_check_states: + - Todo + planning_states: [] active_states: - Todo - In Progress - Rework - pickup_labels: - include: - - agent - - dev-ready - exclude: - - no-agent - - needs-spec terminal_states: - Done - Canceled - Cancelled - Duplicate - blocker_check_states: - - Todo - planning_states: [] polling: interval_ms: 30000 workspace: @@ -54,13 +55,13 @@ runtime: ## Linear Tracker Policy -`WORKFLOW.md` is the source of truth for Linear tracker setup. Use `tracker.kind: linear` with `tracker.project_slug`; do not use `tracker.project_id`, `projectId`, `project_id`, `teamId`, or `.gh-symphony/config.json` as Linear configuration inputs. +`WORKFLOW.md` is the source of truth for Linear tracker setup. Use `tracker.kind: linear` with `tracker.provider.project_slug`; do not use `tracker.project_id`, `projectId`, `project_id`, `teamId`, or `.gh-symphony/config.json` as Linear configuration inputs. `LINEAR_API_KEY` must be available when running `gh-symphony repo init`, `gh-symphony repo start`, or `gh-symphony workflow preview ENG-123`. The orchestrator reads Linear by polling the configured project. Linear webhook setup is a non-goal and no webhook command is expected. -`tracker.pickup_labels` only controls whether active-state issues are eligible for new worker pickup. Exclude labels win over include labels. If `include` is omitted or empty, active-state issues remain pickup-eligible unless excluded. Do not use label changes to stop already running workers; move the Linear issue state to control interruption, review, and completion. +`tracker.provider.pickup_labels` only controls whether active-state issues are eligible for new worker pickup. Exclude labels win over include labels. If `include` is omitted or empty, active-state issues remain pickup-eligible unless excluded. Do not use label changes to stop already running workers; move the Linear issue state to control interruption, review, and completion. -`tracker.blocker_check_states` defaults to the first active state (`Todo` in this example). In those states, non-terminal blockers prevent dispatch. Linear blockers are derived only from inverse relations of type `blocks`; an explicit `blocker_check_states: []` disables this gate as an intentional repository-level divergence from the vendored Symphony specification. Planning remains disabled unless `planning_states` is explicitly configured. +`tracker.provider.blocker_check_states` defaults to the first active state (`Todo` in this example). In those states, non-terminal blockers prevent dispatch. Linear blockers are derived only from inverse relations of type `blocks`; an explicit `tracker.provider.blocker_check_states: []` disables this gate as an intentional repository-level divergence from the vendored Symphony specification. Planning remains disabled unless `tracker.provider.planning_states` is explicitly configured. ## Workpad Policy diff --git a/packages/cli/README.md b/packages/cli/README.md index 4dbcf083..30e60be0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -132,11 +132,11 @@ resets it; it is not a total turn-duration cap. Lifecycle generation enables blocker checks for the first configured active state (`Todo` with built-in defaults) while leaving planning states disabled. -An explicit `tracker.blocker_check_states: []` disables blocker gating; this is +An explicit `tracker.provider.blocker_check_states: []` disables blocker gating; this is an intentional repository-level opt-out from the vendored Symphony spec's unconditional blocker rule. -`tracker.planning_states` classifies a matching state's `execution_phase` as +`tracker.provider.planning_states` classifies a matching state's `execution_phase` as `planning`; other matching active states use `implementation`. Classification is independent of dispatch eligibility and does not make a state active. State names are trimmed and compared case-insensitively. The classification is @@ -150,23 +150,24 @@ Before dispatch, GitHub candidates are checked against the source issue and link ### Explicit Priority Mapping -GitHub Project V2 does not have a native issue priority. For GitHub Project workflows, dispatch priority is controlled only by the explicit `tracker.priority` policy in `WORKFLOW.md`; there is no fallback from Project fields to labels and no guessed label naming convention. Unmapped values resolve to `priority = null`, so dispatch falls back to created time and identifier. +GitHub Project V2 does not have a native issue priority. For GitHub Project workflows, dispatch priority is controlled only by the explicit `tracker.provider.priority` policy in `WORKFLOW.md`; there is no fallback from Project fields to labels and no guessed label naming convention. Unmapped values resolve to `priority = null`, so dispatch falls back to created time and identifier. Project field source: ```yaml tracker: kind: github-project - project_id: PVT_kwDOxxxxxx - state_field: Status - priority: - source: project-field - field: Priority - values: - Urgent: 0 - High: 1 - Medium: 2 - Low: 3 + provider: + project_id: PVT_kwDOxxxxxx + state_field: Status + priority: + source: project-field + field: Priority + values: + Urgent: 0 + High: 1 + Medium: 2 + Low: 3 ``` Label source: @@ -174,15 +175,16 @@ Label source: ```yaml tracker: kind: github-project - project_id: PVT_kwDOxxxxxx - state_field: Status - priority: - source: labels - labels: - P0: 0 - P1: 1 - P2: 2 - P3: 3 + provider: + project_id: PVT_kwDOxxxxxx + state_field: Status + priority: + source: labels + labels: + P0: 0 + P1: 1 + P2: 2 + P3: 3 ``` Disabled: @@ -190,11 +192,17 @@ Disabled: ```yaml tracker: kind: github-project - priority: - source: disabled + provider: + priority: + source: disabled ``` -Legacy `tracker.priority_field: Priority` still works, but it is deprecated because it derives numeric priority from the live Project option order. Project field definitions are cached for the process lifetime, so field creation, removal, and option changes take effect after the daemon restarts. Migrate by copying the field name into `tracker.priority.field` and writing each option display name under `values` with the intended number. If both keys are present, `tracker.priority` wins and `gh-symphony doctor` reports a warning. +Legacy `tracker.priority_field: Priority` still works, but it is deprecated because it derives numeric priority from the live Project option order. Project field definitions are cached for the process lifetime, so field creation, removal, and option changes take effect after the daemon restarts. Migrate by copying the field name into `tracker.provider.priority.field` and writing each option display name under `values` with the intended number. If both keys are present, `tracker.provider.priority` wins and `gh-symphony doctor` reports a warning. + +All flat `tracker.*` provider settings are deprecated, non-breaking aliases. +Use `tracker.provider` for new workflows; `gh-symphony doctor` prints the +normalized provider block for migration. The aliases are removed in the next +major release (#679). Run `gh-symphony workflow validate` for local schema errors and `gh-symphony doctor` for live drift warnings such as missing Project fields, missing labels, unmapped live options, stale mappings, and active issues whose priority-like value resolves to `priority = null`. Strict front-matter failures use stable workflow error codes; `workflow validate --json` also emits the failing `error.path`. diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 457d7c95..4124eea1 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -622,7 +622,7 @@ Prompt body "required": true, "status": "warn", "summary": "Flat tracker key(s) project_id, pickup_labels are deprecated and remain supported for compatibility.", - "title": "Deprecated GitHub tracker keys", + "title": "Deprecated tracker provider keys", } `); }); diff --git a/packages/cli/src/commands/workflow-init.test.ts b/packages/cli/src/commands/workflow-init.test.ts index a3318dd2..317b7e03 100644 --- a/packages/cli/src/commands/workflow-init.test.ts +++ b/packages/cli/src/commands/workflow-init.test.ts @@ -439,16 +439,18 @@ describe("init command config output", () => { expect(plan.workflowMd).toContain("tracker:\n kind: linear\n"); expect(plan.workflowMd).toContain( - " endpoint: https://api.linear.app/graphql\n" + " provider:\n endpoint: https://api.linear.app/graphql\n" ); - expect(plan.workflowMd).toContain(" api_key: $LINEAR_API_KEY\n"); + expect(plan.workflowMd).toContain(" api_key: $LINEAR_API_KEY\n"); expect(plan.workflowMd).toContain( - " project_slug: symphony-0c79b11b75ea\n" + " project_slug: symphony-0c79b11b75ea\n" ); expect(plan.workflowMd).not.toContain(" pickup_labels:"); expect(plan.workflowMd).not.toContain(" project_id:"); - expect(plan.workflowMd).toContain(" blocker_check_states:\n - Todo\n"); - expect(plan.workflowMd).toContain(" planning_states: []\n"); + expect(plan.workflowMd).toContain( + " blocker_check_states:\n - Todo\n" + ); + expect(plan.workflowMd).toContain(" planning_states: []\n"); }); it("writes the simplified project config", async () => { diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 548cec38..d62658fc 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -153,7 +153,7 @@ Prompt {{ issue.identifier }} } expect(stdout.output()).toContain("Warnings"); - expect(stdout.output()).toContain("Deprecated GitHub tracker keys"); + expect(stdout.output()).toContain("Deprecated tracker provider keys"); expect(stdout.output()).toContain("tracker:\n provider:"); expect(stdout.output()).toContain('priority_field: "Priority"'); expect(stdout.output()).toContain("Legacy priority mapping"); diff --git a/packages/cli/src/priority-diagnostics.ts b/packages/cli/src/priority-diagnostics.ts index 6ad1c750..85e23a1f 100644 --- a/packages/cli/src/priority-diagnostics.ts +++ b/packages/cli/src/priority-diagnostics.ts @@ -56,14 +56,11 @@ export function buildPriorityConfigDiagnostics( export function buildProviderDeprecationDiagnostics( workflow: ParsedWorkflow ): PriorityDiagnostic[] { - if ( - workflow.tracker.kind !== "github-project" || - workflow.tracker.deprecatedKeys.length === 0 - ) { + if (workflow.tracker.deprecatedKeys.length === 0) { return []; } - const githubKeys = [ + const providerKeys = [ "project_id", "project_slug", "api_key", @@ -75,7 +72,7 @@ export function buildProviderDeprecationDiagnostics( "blocker_check_states", "planning_states", ]; - const migrated = githubKeys.filter((key) => + const migrated = providerKeys.filter((key) => workflow.tracker.deprecatedKeys.includes(key) ); if (migrated.length === 0) return []; @@ -86,7 +83,7 @@ export function buildProviderDeprecationDiagnostics( const providerBlock = renderProviderBlock(provider); return [ { - title: "Deprecated GitHub tracker keys", + title: "Deprecated tracker provider keys", summary: `Flat tracker key(s) ${migrated.join(", ")} are deprecated and remain supported for compatibility.`, remediation: `Move them under tracker.provider (flat aliases will be removed in the next major release):\n\n${providerBlock}`, details: { deprecatedKeys: migrated, providerBlock }, diff --git a/packages/cli/src/skills/templates/gh-symphony.test.ts b/packages/cli/src/skills/templates/gh-symphony.test.ts index 6ca0e614..23c88832 100644 --- a/packages/cli/src/skills/templates/gh-symphony.test.ts +++ b/packages/cli/src/skills/templates/gh-symphony.test.ts @@ -1,3 +1,4 @@ +import { parseWorkflowMarkdown } from "@gh-symphony/core"; import { describe, it, expect } from "vitest"; import { generateGhSymphonySkill } from "./gh-symphony.js"; import { GH_SYMPHONY_REFERENCE_FILES } from "./gh-symphony-references/index.js"; @@ -78,6 +79,27 @@ describe("generateGhSymphonySkill", () => { const result = generateGhSymphonySkill(mockCtx); expect(result).not.toMatch(/\{\{/); }); + + it("documents provider-form tracker configuration", () => { + const result = generateGhSymphonySkill(mockCtx); + expect(result).toContain(" provider:"); + expect(result).toContain(" project_id: PVT_xxx"); + expect(result).not.toContain("\n project_id: PVT_xxx"); + }); + + it("emits a parseable provider-form front matter example", () => { + const result = generateGhSymphonySkill(mockCtx); + const frontMatter = result.match(/```yaml\n([\s\S]*?)\n```/)?.[1]; + + expect(frontMatter).toBeDefined(); + const parsed = parseWorkflowMarkdown( + `---\n${frontMatter}\n---\nPrompt`, + {} + ); + + expect(parsed.tracker.blockerCheckStates).toEqual(["Blocked"]); + expect(parsed.lifecycle.activeStates).toEqual(["Todo", "In Progress"]); + }); }); describe("gh-symphony reference files", () => { diff --git a/packages/cli/src/skills/templates/gh-symphony.ts b/packages/cli/src/skills/templates/gh-symphony.ts index cec569aa..b6ff7e46 100644 --- a/packages/cli/src/skills/templates/gh-symphony.ts +++ b/packages/cli/src/skills/templates/gh-symphony.ts @@ -112,11 +112,12 @@ export function generateGhSymphonySkill(ctx: SkillTemplateContext): string { lines.push("```yaml"); lines.push("tracker:"); lines.push(" kind: github-project"); - lines.push(" project_id: PVT_xxx"); - lines.push(" state_field: Status"); + lines.push(" provider:"); + lines.push(" project_id: PVT_xxx"); + lines.push(" state_field: Status"); + lines.push(" blocker_check_states: [Blocked]"); lines.push(" active_states: [Todo, In Progress]"); lines.push(" terminal_states: [Done, Cancelled]"); - lines.push(" blocker_check_states: [Blocked]"); lines.push("polling:"); lines.push(" interval_ms: 30000"); lines.push("workspace:"); diff --git a/packages/cli/src/workflow/generate-reference-workflow.test.ts b/packages/cli/src/workflow/generate-reference-workflow.test.ts index 79c0f563..ababb66b 100644 --- a/packages/cli/src/workflow/generate-reference-workflow.test.ts +++ b/packages/cli/src/workflow/generate-reference-workflow.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { parseWorkflowMarkdown } from "@gh-symphony/core"; import { DEFAULT_AFTER_CREATE_HOOK_COMMENT, DEFAULT_AFTER_CREATE_HOOK_PATH, @@ -147,9 +148,10 @@ describe("generateReferenceWorkflow", () => { expect(output).not.toMatch(/\{\{[^}]+\}\}/); }); - it("includes projectId in front matter", () => { + it("includes projectId in provider-form front matter", () => { const output = generateReferenceWorkflow(defaultInput); - expect(output).toContain("project_id: PVT_abc123"); + expect(output).toContain(" provider:\n project_id: PVT_abc123"); + expect(output).not.toContain("\n project_id:"); }); it("includes a Linear tracker example without webhook setup", () => { @@ -157,8 +159,9 @@ describe("generateReferenceWorkflow", () => { expect(output).toContain("# Linear tracker example:"); expect(output).toContain("# kind: linear"); - expect(output).toContain("# api_key: $LINEAR_API_KEY"); - expect(output).toContain("# project_slug: symphony-0c79b11b75ea"); + expect(output).toContain("# provider:"); + expect(output).toContain("# api_key: $LINEAR_API_KEY"); + expect(output).toContain("# project_slug: symphony-0c79b11b75ea"); expect(output).toContain("# a Linear webhook setup command."); expect(output).not.toContain("# project_id:"); expect(output).not.toContain("# teamId:"); @@ -204,15 +207,43 @@ describe("generateReferenceWorkflow", () => { it("includes terminal states in tracker section", () => { const output = generateReferenceWorkflow(defaultInput); expect(output).toContain("terminal_states:"); - expect(output).toContain(" - Done"); + expect(output).toContain(" terminal_states:\n - Done"); }); it("defaults blocker_check_states to Todo without lifecycle input", () => { const output = generateReferenceWorkflow(defaultInput); - expect(output).toContain("blocker_check_states:\n - Todo"); + expect(output).toContain("blocker_check_states:\n - Todo"); expect(output).toContain("planning_states: []"); }); + it("keeps the commented labels priority template valid when enabled", () => { + const output = generateReferenceWorkflow({ + ...defaultInput, + priority: null, + }); + const enabled = output + .replace( + " priority:\n source: disabled", + " # priority:\n # source: disabled" + ) + .replace( + " # Optional template: labels priority source.\n # priority:", + " # Optional template: labels priority source.\n priority:" + ) + .replace(" # source: labels", " source: labels") + .replace(" # labels:", " labels:") + .replace(" # P0: 0", " P0: 0") + .replace(" # P1: 1", " P1: 1"); + + const workflowMarkdown = enabled.slice(enabled.indexOf("---")); + expect( + parseWorkflowMarkdown(workflowMarkdown, {}).tracker.priority + ).toEqual({ + source: "labels", + labels: { P0: 0, P1: 1 }, + }); + }); + it("defaults blocker checks to the first custom active column", () => { const output = generateReferenceWorkflow({ ...defaultInput, @@ -223,8 +254,8 @@ describe("generateReferenceWorkflow", () => { ], }); - expect(output).toContain("blocker_check_states:\n - Ready"); - expect(output).not.toContain("blocker_check_states:\n - Todo"); + expect(output).toContain("blocker_check_states:\n - Ready"); + expect(output).not.toContain("blocker_check_states:\n - Todo"); expect(output).toContain("planning_states: []"); expect(output).toContain( "Classifies matching active runs as planning; does not enforce a plan-only gate." @@ -247,8 +278,8 @@ describe("generateReferenceWorkflow", () => { }, }); - expect(output).toContain("blocker_check_states:\n - Todo"); - expect(output).toContain("planning_states:\n - In Progress"); + expect(output).toContain("blocker_check_states:\n - Todo"); + expect(output).toContain("planning_states:\n - In Progress"); }); it("handles null role columns in Status Map", () => { diff --git a/packages/cli/src/workflow/generate-reference-workflow.ts b/packages/cli/src/workflow/generate-reference-workflow.ts index bd27a96b..566e8c98 100644 --- a/packages/cli/src/workflow/generate-reference-workflow.ts +++ b/packages/cli/src/workflow/generate-reference-workflow.ts @@ -53,10 +53,6 @@ export function generateReferenceWorkflow( lines.push("tracker:"); lines.push(" kind: github-project"); - lines.push(` project_id: ${input.projectId}`); - lines.push(" state_field: Status"); - lines.push(...buildReferencePriorityLines(input.priority)); - lines.push(""); const activeColumns = input.statusColumns.filter((c) => c.role === "active"); const waitColumns = input.statusColumns.filter((c) => c.role === "wait"); @@ -88,24 +84,30 @@ export function generateReferenceWorkflow( lines.push(" terminal_states: [{terminal column names}]"); } + lines.push(" provider:"); + lines.push(` project_id: ${input.projectId}`); + lines.push(" state_field: Status"); + lines.push(...buildReferencePriorityLines(input.priority, 4)); + lines.push( - ...buildReferenceStringList("blocker_check_states", blockerCheckStates) + ...buildReferenceStringList("blocker_check_states", blockerCheckStates, 4) ); lines.push( - " # Classifies matching active runs as planning; does not enforce a plan-only gate." + " # Classifies matching active runs as planning; does not enforce a plan-only gate." ); lines.push( - " # Matching is trimmed/case-insensitive; use execution_phase in prompt policy." + " # Matching is trimmed/case-insensitive; use execution_phase in prompt policy." ); - lines.push(...buildReferenceStringList("planning_states", planningStates)); + lines.push(...buildReferenceStringList("planning_states", planningStates, 4)); lines.push(""); lines.push("# Linear tracker example:"); lines.push("# tracker:"); lines.push("# kind: linear"); - lines.push("# endpoint: https://api.linear.app/graphql"); - lines.push("# api_key: $LINEAR_API_KEY"); - lines.push("# project_slug: symphony-0c79b11b75ea"); + lines.push("# provider:"); + lines.push("# endpoint: https://api.linear.app/graphql"); + lines.push("# api_key: $LINEAR_API_KEY"); + lines.push("# project_slug: symphony-0c79b11b75ea"); lines.push("# active_states:"); lines.push("# - Todo"); lines.push("# - In Progress"); @@ -404,68 +406,80 @@ export function generateReferenceWorkflow( return lines.join("\n"); } -function buildReferenceStringList(key: string, values: string[]): string[] { +function buildReferenceStringList( + key: string, + values: string[], + indent: number +): string[] { + const padding = " ".repeat(indent); if (values.length === 0) { - return [` ${key}: []`]; + return [`${padding}${key}: []`]; } - return [` ${key}:`, ...values.map((value) => ` - ${value}`)]; + return [ + `${padding}${key}:`, + ...values.map((value) => `${padding} - ${value}`), + ]; } function buildReferencePriorityLines( - priority: WorkflowPriorityConfig | null + priority: WorkflowPriorityConfig | null, + indent: number ): string[] { + const padding = " ".repeat(indent); + const nestedPadding = " ".repeat(indent + 2); + const valuePadding = " ".repeat(indent + 4); const lines: string[] = []; if (priority?.source === "project-field" || priority?.source === "labels") { lines.push( - " # Priority is explicit. Numbers below are editable policy (lower = higher priority)." + `${padding}# Priority is explicit. Numbers below are editable policy (lower = higher priority).` ); } else { lines.push( - " # Priority dispatch is disabled until an operator chooses one explicit source." + `${padding}# Priority dispatch is disabled until an operator chooses one explicit source.` ); } lines.push( - " # See docs/adr/2026-05-18_explicit-dispatch-priority-mappings.md" + `${padding}# See docs/adr/2026-05-18_explicit-dispatch-priority-mappings.md` ); if (priority?.source === "project-field") { - lines.push(" priority:"); - lines.push(" source: project-field"); - lines.push(` field: ${JSON.stringify(priority.field)}`); - lines.push(" values:"); + lines.push(`${padding}priority:`); + lines.push(`${nestedPadding}source: project-field`); + lines.push(`${nestedPadding}field: ${JSON.stringify(priority.field)}`); + lines.push(`${nestedPadding}values:`); for (const [name, value] of Object.entries(priority.values)) { - lines.push(` ${JSON.stringify(name)}: ${value}`); + lines.push(`${valuePadding}${JSON.stringify(name)}: ${value}`); } return lines; } if (priority?.source === "labels") { - lines.push(" priority:"); - lines.push(" source: labels"); - lines.push(" labels:"); + lines.push(`${padding}priority:`); + lines.push(`${nestedPadding}source: labels`); + lines.push(`${nestedPadding}labels:`); for (const [name, value] of Object.entries(priority.labels)) { - lines.push(` ${JSON.stringify(name)}: ${value}`); + lines.push(`${valuePadding}${JSON.stringify(name)}: ${value}`); } return lines; } - lines.push(" priority:"); - lines.push(" source: disabled"); - lines.push(""); - lines.push(" # Optional template: project-field priority source."); - lines.push(" # priority:"); - lines.push(" # source: project-field"); - lines.push(" # field: Priority"); - lines.push(" # values:"); - lines.push(" # Urgent: 0"); - lines.push(" # High: 1"); - lines.push(""); - lines.push(" # Optional template: labels priority source."); - lines.push(" # priority:"); - lines.push(" # source: labels"); - lines.push(" # labels:"); - lines.push(" # P0: 0"); - lines.push(" # P1: 1"); + lines.push(`${padding}priority:`); + lines.push(`${nestedPadding}source: disabled`); + lines.push(""); + lines.push(`${padding}# Optional template: project-field priority source.`); + lines.push(`${padding}# priority:`); + lines.push(`${padding}# source: project-field`); + lines.push(`${padding}# field: Priority`); + lines.push(`${padding}# values:`); + lines.push(`${padding}# Urgent: 0`); + lines.push(`${padding}# High: 1`); + lines.push(""); + lines.push(`${padding}# Optional template: labels priority source.`); + lines.push(`${padding}# priority:`); + lines.push(`${padding}# source: labels`); + lines.push(`${padding}# labels:`); + lines.push(`${padding}# P0: 0`); + lines.push(`${padding}# P1: 1`); return lines; } diff --git a/packages/cli/src/workflow/generate-workflow-md.test.ts b/packages/cli/src/workflow/generate-workflow-md.test.ts index 10afba26..dcd78911 100644 --- a/packages/cli/src/workflow/generate-workflow-md.test.ts +++ b/packages/cli/src/workflow/generate-workflow-md.test.ts @@ -46,12 +46,19 @@ describe("generateWorkflowMarkdown", () => { it("generates valid WORKFLOW.md that round-trips through parseWorkflowMarkdown", () => { const markdown = generateWorkflowMarkdown(defaultInput); - const parsed = parseWorkflowMarkdown(markdown, {}, { - compatibilityMode: "legacy", - }); + const parsed = parseWorkflowMarkdown( + markdown, + {}, + { + compatibilityMode: "legacy", + } + ); expect(parsed.format).toBe("front-matter"); expect(parsed.githubProjectId).toBe("PVT_abc123"); + expect(parsed.tracker.deprecatedKeys).toEqual([]); + expect(markdown).toContain(" provider:"); + expect(markdown).not.toContain("\n project_id:"); }); it("produces lifecycle config matching the input", () => { @@ -210,15 +217,15 @@ describe("generateWorkflowMarkdown", () => { includePriorityTemplates: true, }); const uncommented = markdown - .replace(" # priority:", " priority:") - .replace(" # source: project-field", " source: project-field") - .replace(" # field: Priority", " field: Priority") - .replace(" # values:", " values:") - .replace(" # Urgent: 0", " Urgent: 0") - .replace(" # High: 1", " High: 1"); + .replace(" # priority:", " priority:") + .replace(" # source: project-field", " source: project-field") + .replace(" # field: Priority", " field: Priority") + .replace(" # values:", " values:") + .replace(" # Urgent: 0", " Urgent: 0") + .replace(" # High: 1", " High: 1"); const parsed = parseWorkflowMarkdown(uncommented, {}); - expect(markdown).toContain(" # priority:"); + expect(markdown).toContain(" # priority:"); expect(markdown).not.toContain("\n# priority:"); expect(parsed.tracker.priority).toEqual({ source: "project-field", @@ -230,6 +237,16 @@ describe("generateWorkflowMarkdown", () => { }); }); + it("keeps core lifecycle states outside the provider block", () => { + const markdown = generateWorkflowMarkdown(defaultInput); + const parsed = parseWorkflowMarkdown(markdown, {}); + + expect(markdown).toContain(" active_states:"); + expect(markdown).toContain(" terminal_states:"); + expect(parsed.tracker.provider).not.toHaveProperty("active_states"); + expect(parsed.tracker.provider).not.toHaveProperty("terminal_states"); + }); + it("includes a Status Map section in the prompt body", () => { const markdown = generateWorkflowMarkdown(defaultInput); @@ -272,9 +289,13 @@ describe("generateWorkflowMarkdown", () => { }, }, }); - const parsed = parseWorkflowMarkdown(markdown, {}, { - compatibilityMode: "legacy", - }); + const parsed = parseWorkflowMarkdown( + markdown, + {}, + { + compatibilityMode: "legacy", + } + ); expect(markdown).toContain(JSON.stringify(injectedEndpoint)); expect(markdown).toContain(JSON.stringify(injectedProjectSlug)); @@ -285,6 +306,8 @@ describe("generateWorkflowMarkdown", () => { include: ["ready\nruntime:\n command: malicious", "security:high"], exclude: ["blocked # not a comment"], }); + expect(parsed.tracker.deprecatedKeys).toEqual([]); + expect(markdown).toContain(" provider:"); expect(parsed.runtime?.kind).toBe("codex-app-server"); expect(parsed.runtime?.command).toBe("codex"); }); diff --git a/packages/cli/src/workflow/generate-workflow-md.ts b/packages/cli/src/workflow/generate-workflow-md.ts index 7a174a29..4e3fcb90 100644 --- a/packages/cli/src/workflow/generate-workflow-md.ts +++ b/packages/cli/src/workflow/generate-workflow-md.ts @@ -92,20 +92,21 @@ function buildFrontMatter(input: GenerateWorkflowInput): string { function buildTrackerFrontMatter( input: GenerateWorkflowInput ): Record { - const tracker: Record = {}; + const provider: Record = {}; + const tracker: Record = { + kind: input.tracker?.kind ?? "github-project", + }; if (input.tracker?.kind === "linear") { - tracker.kind = "linear"; - tracker.endpoint = + provider.endpoint = input.tracker.endpoint ?? "https://api.linear.app/graphql"; - tracker.api_key = input.tracker.apiKey ?? "$LINEAR_API_KEY"; - tracker.project_slug = input.tracker.projectSlug; + provider.api_key = input.tracker.apiKey ?? "$LINEAR_API_KEY"; + provider.project_slug = input.tracker.projectSlug; } else { - tracker.kind = "github-project"; - tracker.project_id = input.projectId; - tracker.state_field = input.stateFieldName; + provider.project_id = input.projectId; + provider.state_field = input.stateFieldName; const priority = buildPriorityFrontMatter(input); if (priority) { - tracker.priority = priority; + provider.priority = priority; } } @@ -117,6 +118,8 @@ function buildTrackerFrontMatter( tracker.terminal_states = input.lifecycle.terminalStates; } + tracker.provider = provider; + if (input.tracker?.kind === "linear") { const include = input.tracker.pickupLabels?.include ?? []; const exclude = input.tracker.pickupLabels?.exclude ?? []; @@ -128,12 +131,12 @@ function buildTrackerFrontMatter( if (exclude.length > 0) { pickupLabels.exclude = exclude; } - tracker.pickup_labels = pickupLabels; + provider.pickup_labels = pickupLabels; } } - tracker.blocker_check_states = input.lifecycle.blockerCheckStates ?? []; - tracker.planning_states = input.lifecycle.planningStates; + provider.blocker_check_states = input.lifecycle.blockerCheckStates ?? []; + provider.planning_states = input.lifecycle.planningStates; return tracker; } @@ -226,32 +229,32 @@ function buildTrackerFrontMatterComments( const comments = input.priority.source === "disabled" ? [ - " # Priority dispatch is disabled until an operator chooses one explicit source.", + " # Priority dispatch is disabled until an operator chooses one explicit source.", ] : [ - " # Priority is explicit. Numbers below are editable policy (lower = higher priority).", + " # Priority is explicit. Numbers below are editable policy (lower = higher priority).", ]; comments.push( - " # See docs/adr/2026-05-18_explicit-dispatch-priority-mappings.md" + " # See docs/adr/2026-05-18_explicit-dispatch-priority-mappings.md" ); if (input.priority.source === "disabled" && input.includePriorityTemplates) { comments.push( "", - " # Optional template: project-field priority source.", - " # priority:", - " # source: project-field", - " # field: Priority", - " # values:", - " # Urgent: 0", - " # High: 1", + " # Optional template: project-field priority source.", + " # priority:", + " # source: project-field", + " # field: Priority", + " # values:", + " # Urgent: 0", + " # High: 1", "", - " # Optional template: labels priority source.", - " # priority:", - " # source: labels", - " # labels:", - " # P0: 0", - " # P1: 1" + " # Optional template: labels priority source.", + " # priority:", + " # source: labels", + " # labels:", + " # P0: 0", + " # P1: 1" ); }