diff --git a/.changes/bugs/feat-issue-body-contract-skills-f098795e.md b/.changes/bugs/feat-issue-body-contract-skills-f098795e.md new file mode 100644 index 00000000000..44fce690964 --- /dev/null +++ b/.changes/bugs/feat-issue-body-contract-skills-f098795e.md @@ -0,0 +1,24 @@ +--- +bump: minor +--- + +### Added + +- read issue-body skeletons from the org issue-body contract + + track add, decompose, the agent brief and bugs:write now take their + issue-body headings from the issue-body contract the standards sync + delivers into each plugin's reference/ directory, the same record the + org issue forms are generated from. triage checks an issue body against + the contract with scripts/check-body.mjs, which accepts a field under + a `##` or `###` heading and treats a form's skipped answer as empty. The tidy + deferred-item body follows the task type. + +- fan the synced issue-body contract into the plugins + + The standards sync delivers the contract once, to + docs/contracts/issue-body/contract.json. sync-shared-copies.sh now + copies a JSON canonical byte for byte (JSON has no comment syntax for a + header) and generates plugins/bugs and plugins/work-items + reference/issue-body-contract.json from it; the cross-plugin registry + records the cluster. diff --git a/.changes/code-tidying/feat-issue-body-contract-skills-f3b63720.md b/.changes/code-tidying/feat-issue-body-contract-skills-f3b63720.md new file mode 100644 index 00000000000..03ce6986da8 --- /dev/null +++ b/.changes/code-tidying/feat-issue-body-contract-skills-f3b63720.md @@ -0,0 +1,15 @@ +--- +bump: minor +--- + +### Added + +- read issue-body skeletons from the org issue-body contract + + track add, decompose, the agent brief and bugs:write now take their + issue-body headings from the issue-body contract the standards sync + delivers into each plugin's reference/ directory, the same record the + org issue forms are generated from. triage checks an issue body against + the contract with scripts/check-body.mjs, which accepts a field under + a `##` or `###` heading and treats a form's skipped answer as empty. The tidy + deferred-item body follows the task type. diff --git a/.changes/work-items/feat-issue-body-contract-skills-4c8a6d60.md b/.changes/work-items/feat-issue-body-contract-skills-4c8a6d60.md new file mode 100644 index 00000000000..44fce690964 --- /dev/null +++ b/.changes/work-items/feat-issue-body-contract-skills-4c8a6d60.md @@ -0,0 +1,24 @@ +--- +bump: minor +--- + +### Added + +- read issue-body skeletons from the org issue-body contract + + track add, decompose, the agent brief and bugs:write now take their + issue-body headings from the issue-body contract the standards sync + delivers into each plugin's reference/ directory, the same record the + org issue forms are generated from. triage checks an issue body against + the contract with scripts/check-body.mjs, which accepts a field under + a `##` or `###` heading and treats a form's skipped answer as empty. The tidy + deferred-item body follows the task type. + +- fan the synced issue-body contract into the plugins + + The standards sync delivers the contract once, to + docs/contracts/issue-body/contract.json. sync-shared-copies.sh now + copies a JSON canonical byte for byte (JSON has no comment syntax for a + header) and generates plugins/bugs and plugins/work-items + reference/issue-body-contract.json from it; the cross-plugin registry + records the cluster. diff --git a/plugins/bugs/reference/issue-body-contract.json b/plugins/bugs/reference/issue-body-contract.json new file mode 100644 index 00000000000..93a7c625dea --- /dev/null +++ b/plugins/bugs/reference/issue-body-contract.json @@ -0,0 +1,235 @@ +{ + "schemaVersion": 1, + "headingLevels": [2, 3], + "emptyAnswer": "_No response_", + "floorLabels": ["needs-triage"], + "types": { + "bug": { + "form": "bug-report.yml", + "name": "Bug Report", + "description": "Report an unexpected problem or behavior.", + "nativeType": "Bug", + "labels": ["needs-triage"], + "fields": [ + { + "id": "steps", + "heading": "Steps to reproduce", + "kind": "textarea", + "required": true, + "description": "Minimal steps that reproduce the behavior.", + "placeholder": "1. ...\n2. ...\n3. ..." + }, + { + "id": "expected", + "heading": "Expected behavior", + "kind": "textarea", + "required": true, + "description": "What should happen?" + }, + { + "id": "actual", + "heading": "Actual behavior", + "kind": "textarea", + "required": true, + "description": "What actually happens? Include error messages, stack traces, or screenshots." + }, + { + "id": "severity", + "heading": "Severity", + "kind": "dropdown", + "required": true, + "description": "How bad is it? Say why under Severity justification.", + "options": ["low", "medium", "high", "critical"] + }, + { + "id": "severity-justification", + "heading": "Severity justification", + "kind": "textarea", + "required": true, + "description": "One sentence: the blast radius, what it blocks, or the data at risk." + }, + { + "id": "fix-location", + "heading": "Suggested fix location", + "kind": "input", + "required": false, + "description": "The file and function or class where the fix likely belongs, if known. No patch." + }, + { + "id": "environment", + "heading": "Environment", + "kind": "input", + "required": false, + "description": "OS, runtime and tool versions, and anything else relevant." + } + ] + }, + "feature": { + "form": "feature-request.yml", + "name": "Feature Request", + "description": "Propose a new feature or enhancement.", + "nativeType": "Feature", + "labels": ["needs-triage"], + "fields": [ + { + "id": "user-story", + "heading": "User story", + "kind": "textarea", + "required": true, + "description": "Who benefits and why?", + "placeholder": "As a [role], I want [capability] so that [benefit]." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "How do we know this is done? One verifiable check per line.", + "placeholder": "- [ ] Criterion 1\n- [ ] Criterion 2" + }, + { + "id": "area", + "heading": "Affected area / component", + "kind": "input", + "required": false, + "description": "Which part of the project would this affect?" + } + ] + }, + "task": { + "form": "task.yml", + "name": "Task", + "description": "A chore, maintenance task, audit, or other housekeeping item.", + "nativeType": "Task", + "labels": ["needs-triage"], + "fields": [ + { + "id": "context", + "heading": "Context", + "kind": "textarea", + "required": true, + "description": "What surfaced this, and what leaving it costs." + }, + { + "id": "proposed-work", + "heading": "Proposed work", + "kind": "textarea", + "required": true, + "description": "The concrete next actions.", + "placeholder": "- ..." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "One verifiable check per line.", + "placeholder": "- [ ] ..." + }, + { + "id": "references", + "heading": "References", + "kind": "textarea", + "required": false, + "description": "Rules, files, pull requests, or docs this relates to." + } + ] + }, + "agent-ready": { + "form": "agent-ready.yml", + "name": "Agent-Ready Work Item", + "description": "Work written as an agent brief. Triage decides its type and whether it is agent-ready.", + "nativeType": null, + "labels": ["needs-triage"], + "fields": [ + { + "id": "summary", + "heading": "Summary", + "kind": "input", + "required": true, + "description": "The change, in one line." + }, + { + "id": "root-cause", + "heading": "Root cause", + "kind": "input", + "required": false, + "description": "Bugs only: unknown, or known: the cause in one line." + }, + { + "id": "current-behavior", + "heading": "Current behavior", + "kind": "textarea", + "required": true, + "description": "What the code does today." + }, + { + "id": "desired-behavior", + "heading": "Desired behavior", + "kind": "textarea", + "required": true, + "description": "What is true once this lands, edge cases and failure handling included. The end state, not edit instructions." + }, + { + "id": "key-interfaces", + "heading": "Key interfaces", + "kind": "textarea", + "required": false, + "description": "The types, signatures, config keys, or observable behaviors that change, named by contract rather than file path or line." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "Checks that each give a yes or no.", + "placeholder": "- [ ] ..." + }, + { + "id": "out-of-scope", + "heading": "Out of scope", + "kind": "textarea", + "required": true, + "description": "The neighboring code and features this change leaves as they are." + } + ] + }, + "decomposed-item": { + "form": null, + "name": "Decomposed work item", + "description": "A vertical slice /work-items:decompose publishes, born triaged; no public form.", + "nativeType": null, + "labels": [], + "fields": [ + { + "id": "outcome", + "heading": "Outcome", + "kind": "textarea", + "required": true, + "description": "The one observable change a user gets when this slice merges, end to end." + }, + { + "id": "key-interfaces", + "heading": "Key interfaces", + "kind": "textarea", + "required": true, + "description": "The design this slice touches, named by contract, or None." + }, + { + "id": "done-when", + "heading": "Done when", + "kind": "textarea", + "required": true, + "description": "The slice's acceptance criteria, each a check with a pass or fail answer." + }, + { + "id": "out-of-scope", + "heading": "Out of scope", + "kind": "textarea", + "required": true, + "description": "Adjacent work this slice must not change." + } + ] + } + } +} diff --git a/plugins/bugs/skills/write/context/template.md b/plugins/bugs/skills/write/context/template.md index 3fcb9b453d7..f7c48078d0d 100644 --- a/plugins/bugs/skills/write/context/template.md +++ b/plugins/bugs/skills/write/context/template.md @@ -1,36 +1,33 @@ # `/bugs:write` template -Loaded on demand by `/bugs:write`. Contains: the full Markdown template (5 fields), the `--file` frontmatter, and the "No bug confirmed" form. +Loaded on demand by `/bugs:write`. Contains: the full Markdown template (5 fields, laid out by the issue-body contract), the `--file` frontmatter, and the "No bug confirmed" form. ## Full template +The body sections come from the org issue-body contract, so a filed report matches the org bug form +and passes the intake heading check. Read the `bug` type of +[`reference/issue-body-contract.json`](../../../reference/issue-body-contract.json) +and write one `## ` section per entry of its `fields`, in that order, never a heading the +contract does not name except `## Notes`. Fill each from the matching part of the five fields: + +- Steps to reproduce: a numbered list. +- Expected and actual behavior: one section each. +- Severity: one of the field's `options`, alone; Severity justification: the one sentence that + justifies it (blast radius, blocking factor, data-integrity impact). +- Suggested fix location: `` `` `` ``, no patch. +- A field the report cannot back, required or not, gets the placeholder. + Default emission (stdout), no frontmatter: ```markdown # Bug: -**Severity**: <low | medium | high | critical> -**Suggested fix location**: `<file path>` `<function or class name>` (no patch) **Reported by**: <reporter identity, or session ID> **Reported at**: <ISO-8601 timestamp> -## Steps to reproduce - -1. <step> -2. <step> -3. <step> - -## Expected behavior - -<what should happen> - -## Actual behavior - -<what does happen> - -## Severity justification +## <each contract field heading, in contract order> -<one sentence: why this severity. Cite blast radius / blocking factor / data-integrity impact> +<its value> ## Notes diff --git a/plugins/code-tidying/skills/tidy/reference/scope-budget.md b/plugins/code-tidying/skills/tidy/reference/scope-budget.md index 74b08610356..ee7f5e18e60 100644 --- a/plugins/code-tidying/skills/tidy/reference/scope-budget.md +++ b/plugins/code-tidying/skills/tidy/reference/scope-budget.md @@ -39,27 +39,21 @@ Read the house style (`<plugin-root>/reference/issue-house-style.md`) before fil ### Body format (use as-is, fill in the angle-bracket placeholders) +The sections follow the `task` type of the org issue-body contract, which `/work-items:track add` +reads from its plugin's `reference/issue-body-contract.json`: Context, Proposed work and Acceptance +criteria are its required fields, so the item passes intake's body check. + ```markdown ## Context Deferred from tidy run on `<branch-name>` (anchor: `<anchor-sha>`). The hunt found this candidate but the PR scope budget's hard cap was reached before it could be included. -## Tidying type - -<one of the named tidyings from reference/tidyings.md, e.g., "Beck #5: Reading Order"> - -## Files - -- `<path/to/file1.ext>` (<estimated LOC delta>) -- `<path/to/file2.ext>` (<estimated LOC delta>) - -## Estimated scope - -<estimate>: ~<N> LOC across <M> files. Should fit within the scope budget's target as a future tidy run. - -## Lane +## Proposed work -`<lane-name>` +- Tidying: <one of the named tidyings from reference/tidyings.md, e.g., "Beck #5: Reading Order"> +- Files: `<path/to/file1.ext>` (<estimated LOC delta>), `<path/to/file2.ext>` (<estimated LOC delta>) +- Estimated scope: ~<N> LOC across <M> files; should fit the scope budget's target as a future tidy run. +- Lane: `<lane-name>` ## Acceptance criteria diff --git a/plugins/work-items/reference/agent-brief.md b/plugins/work-items/reference/agent-brief.md index bb5384933e9..05317d0956b 100644 --- a/plugins/work-items/reference/agent-brief.md +++ b/plugins/work-items/reference/agent-brief.md @@ -28,32 +28,20 @@ Each pair below comes from one of two changes, a retry backoff cap and a CSV exp ## Template +The brief's fields are the `agent-ready` type of the issue-body contract +([`${CLAUDE_PLUGIN_ROOT}/reference/issue-body-contract.json`](${CLAUDE_PLUGIN_ROOT}/reference/issue-body-contract.json)), +the record the org agent-ready issue form is generated from. Write one `### <heading>` per entry of +its `fields`, in issue-body contract order, under the `## Agent Brief` heading; a form-filed item already +carries them as `###` sections. The type is the issue's own (native Issue Type on org repos, `type:` +label on personal / non-org repos), never a brief field. Each field's `description` says what it +holds; the rules table above says how to write the four that go stale. Shape: + ```markdown ## Agent Brief -**Type:** Bug / Feature / Task (the issue's type: native Issue Type on org repos, `type:` label on personal / non-org repos) -**Summary:** <the change, in one line> - -**Root cause:** <bugs only: `unknown`, or the cause in one line, e.g. `known: stale cache key in RetryPolicy`> - -**Current behavior:** -<What the code does today. A bug: the faulty result. A feature: the existing behavior it extends.> - -**Desired behavior:** -<What the code does once this lands, including how it handles edge cases and failures.> - -**Key interfaces:** -- `TypeName`: <the change and the reason for it> -- `FunctionName()` return type: <today's value vs the intended one> -- Config shape: <settings added or changed> - -**Acceptance criteria:** -- [ ] <a check with a yes-or-no result> -- [ ] <a check with a yes-or-no result> +### <each agent-ready field heading, in contract order> -**Out of scope:** -- <something this change must not touch> -- <a nearby feature that stays as it is> +<its value; `- [ ]` checks under Acceptance criteria, bullets under Key interfaces and Out of scope> ``` ## When to use diff --git a/plugins/work-items/reference/dogfood-filing.md b/plugins/work-items/reference/dogfood-filing.md index 1fc0360c423..d8e42f23f2e 100644 --- a/plugins/work-items/reference/dogfood-filing.md +++ b/plugins/work-items/reference/dogfood-filing.md @@ -47,9 +47,9 @@ Four beats, in order. The two **mechanical** beats reuse existing machinery verb filer records what it observed, not a verified diagnosis. 3. **File with the fixed shape (mechanical).** File through the canonical `track add` path, which - owns the body template (Context / Proposed work / Acceptance criteria / References / Metadata) and - the argv-safe `create-item` write ([`../skills/track/actions/add.md`](../skills/track/actions/add.md) - "Build body", "Create the item"). The shape is not restated here. `track add` is its source of + builds the body from the issue-body contract ([`issue-body-contract.json`](issue-body-contract.json)) + and owns the argv-safe `create-item` write ([`../skills/track/actions/add.md`](../skills/track/actions/add.md) + "Build body", "Create the item"). The shape is not restated here: the contract is its source of truth, so a change to the template lands in one place. The item **title** follows the convention in [`issue-conventions.md`](issue-conventions.md). diff --git a/plugins/work-items/reference/issue-body-contract.json b/plugins/work-items/reference/issue-body-contract.json new file mode 100644 index 00000000000..93a7c625dea --- /dev/null +++ b/plugins/work-items/reference/issue-body-contract.json @@ -0,0 +1,235 @@ +{ + "schemaVersion": 1, + "headingLevels": [2, 3], + "emptyAnswer": "_No response_", + "floorLabels": ["needs-triage"], + "types": { + "bug": { + "form": "bug-report.yml", + "name": "Bug Report", + "description": "Report an unexpected problem or behavior.", + "nativeType": "Bug", + "labels": ["needs-triage"], + "fields": [ + { + "id": "steps", + "heading": "Steps to reproduce", + "kind": "textarea", + "required": true, + "description": "Minimal steps that reproduce the behavior.", + "placeholder": "1. ...\n2. ...\n3. ..." + }, + { + "id": "expected", + "heading": "Expected behavior", + "kind": "textarea", + "required": true, + "description": "What should happen?" + }, + { + "id": "actual", + "heading": "Actual behavior", + "kind": "textarea", + "required": true, + "description": "What actually happens? Include error messages, stack traces, or screenshots." + }, + { + "id": "severity", + "heading": "Severity", + "kind": "dropdown", + "required": true, + "description": "How bad is it? Say why under Severity justification.", + "options": ["low", "medium", "high", "critical"] + }, + { + "id": "severity-justification", + "heading": "Severity justification", + "kind": "textarea", + "required": true, + "description": "One sentence: the blast radius, what it blocks, or the data at risk." + }, + { + "id": "fix-location", + "heading": "Suggested fix location", + "kind": "input", + "required": false, + "description": "The file and function or class where the fix likely belongs, if known. No patch." + }, + { + "id": "environment", + "heading": "Environment", + "kind": "input", + "required": false, + "description": "OS, runtime and tool versions, and anything else relevant." + } + ] + }, + "feature": { + "form": "feature-request.yml", + "name": "Feature Request", + "description": "Propose a new feature or enhancement.", + "nativeType": "Feature", + "labels": ["needs-triage"], + "fields": [ + { + "id": "user-story", + "heading": "User story", + "kind": "textarea", + "required": true, + "description": "Who benefits and why?", + "placeholder": "As a [role], I want [capability] so that [benefit]." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "How do we know this is done? One verifiable check per line.", + "placeholder": "- [ ] Criterion 1\n- [ ] Criterion 2" + }, + { + "id": "area", + "heading": "Affected area / component", + "kind": "input", + "required": false, + "description": "Which part of the project would this affect?" + } + ] + }, + "task": { + "form": "task.yml", + "name": "Task", + "description": "A chore, maintenance task, audit, or other housekeeping item.", + "nativeType": "Task", + "labels": ["needs-triage"], + "fields": [ + { + "id": "context", + "heading": "Context", + "kind": "textarea", + "required": true, + "description": "What surfaced this, and what leaving it costs." + }, + { + "id": "proposed-work", + "heading": "Proposed work", + "kind": "textarea", + "required": true, + "description": "The concrete next actions.", + "placeholder": "- ..." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "One verifiable check per line.", + "placeholder": "- [ ] ..." + }, + { + "id": "references", + "heading": "References", + "kind": "textarea", + "required": false, + "description": "Rules, files, pull requests, or docs this relates to." + } + ] + }, + "agent-ready": { + "form": "agent-ready.yml", + "name": "Agent-Ready Work Item", + "description": "Work written as an agent brief. Triage decides its type and whether it is agent-ready.", + "nativeType": null, + "labels": ["needs-triage"], + "fields": [ + { + "id": "summary", + "heading": "Summary", + "kind": "input", + "required": true, + "description": "The change, in one line." + }, + { + "id": "root-cause", + "heading": "Root cause", + "kind": "input", + "required": false, + "description": "Bugs only: unknown, or known: the cause in one line." + }, + { + "id": "current-behavior", + "heading": "Current behavior", + "kind": "textarea", + "required": true, + "description": "What the code does today." + }, + { + "id": "desired-behavior", + "heading": "Desired behavior", + "kind": "textarea", + "required": true, + "description": "What is true once this lands, edge cases and failure handling included. The end state, not edit instructions." + }, + { + "id": "key-interfaces", + "heading": "Key interfaces", + "kind": "textarea", + "required": false, + "description": "The types, signatures, config keys, or observable behaviors that change, named by contract rather than file path or line." + }, + { + "id": "acceptance-criteria", + "heading": "Acceptance criteria", + "kind": "textarea", + "required": true, + "description": "Checks that each give a yes or no.", + "placeholder": "- [ ] ..." + }, + { + "id": "out-of-scope", + "heading": "Out of scope", + "kind": "textarea", + "required": true, + "description": "The neighboring code and features this change leaves as they are." + } + ] + }, + "decomposed-item": { + "form": null, + "name": "Decomposed work item", + "description": "A vertical slice /work-items:decompose publishes, born triaged; no public form.", + "nativeType": null, + "labels": [], + "fields": [ + { + "id": "outcome", + "heading": "Outcome", + "kind": "textarea", + "required": true, + "description": "The one observable change a user gets when this slice merges, end to end." + }, + { + "id": "key-interfaces", + "heading": "Key interfaces", + "kind": "textarea", + "required": true, + "description": "The design this slice touches, named by contract, or None." + }, + { + "id": "done-when", + "heading": "Done when", + "kind": "textarea", + "required": true, + "description": "The slice's acceptance criteria, each a check with a pass or fail answer." + }, + { + "id": "out-of-scope", + "heading": "Out of scope", + "kind": "textarea", + "required": true, + "description": "Adjacent work this slice must not change." + } + ] + } + } +} diff --git a/plugins/work-items/skills/decompose/SKILL.md b/plugins/work-items/skills/decompose/SKILL.md index 692f2575a43..ae5b466e67f 100644 --- a/plugins/work-items/skills/decompose/SKILL.md +++ b/plugins/work-items/skills/decompose/SKILL.md @@ -217,7 +217,7 @@ Run each call as its own plain Bash command: no `$(...)`, no `git`, and no `&&` - **As of**: 2026-10-11 - **Recheck trigger**: a Claude Code release in which the pre-#6929 snippet (tracker path fallback through `$(git rev-parse --show-toplevel)`, body through `"$(cat "$BODY_FILE")"`) runs from a worktree-isolated session. -Every item body uses the one structure below. It is the agent-brief template ([`${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md`](${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md)) laid out as sections: `## Outcome` carries that template's Summary and Current/Desired behavior, `## Done when` carries its Acceptance criteria, and `## Key interfaces` and `## Out of scope` are its fields of the same names, so an AFK item needs no second `## Agent Brief` block. When the source is a PR (an item with attached code), use that reference's PR-variant (current-behavior-of-the-diff, finish-what-exists); do not replace the bug/feature template for ordinary items. Body structure: +Every item body uses the one structure below. It is the agent-brief template ([`${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md`](${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md)) laid out as sections: `## Outcome` carries that template's Summary and Current/Desired behavior, `## Done when` carries its Acceptance criteria, and `## Key interfaces` and `## Out of scope` are its fields of the same names, so an AFK item needs no second `## Agent Brief` block. When the source is a PR (an item with attached code), use that reference's PR-variant (current-behavior-of-the-diff, finish-what-exists); do not replace the bug/feature template for ordinary items. The required sections (Outcome, Key interfaces, Done when, Out of scope) are the `decomposed-item` type of the issue-body contract ([`${CLAUDE_PLUGIN_ROOT}/reference/issue-body-contract.json`](${CLAUDE_PLUGIN_ROOT}/reference/issue-body-contract.json)): take their headings and order from its `fields`, and when the contract and the structure below disagree, the contract wins. Body structure: ```markdown ## Parent diff --git a/plugins/work-items/skills/track/actions/add.md b/plugins/work-items/skills/track/actions/add.md index 3f68a569751..86674d4c3ae 100644 --- a/plugins/work-items/skills/track/actions/add.md +++ b/plugins/work-items/skills/track/actions/add.md @@ -5,7 +5,7 @@ Create a new work item with labels from the taxonomy. **Defaults applied by this action:** - **Priority.** When the `--priority` flag is absent, set no priority, on the `Priority` field or as a `priority:` label. Urgency is assigned at triage, not at filing. Apply the bare floor label `needs-triage` when that exact name exists in the live set ([`${CLAUDE_PLUGIN_ROOT}/reference/label-taxonomy.md`](${CLAUDE_PLUGIN_ROOT}/reference/label-taxonomy.md)). Do not pass it as `--priority`, and do not substitute a low priority or a prefixed `needs-triage` spelling. Omit the floor when the live set has no `needs-triage` label. -- **Body template.** When `--body` is not provided, fall back to the default skeleton: a `## Context` paragraph (what observation surfaced this item, what's the cost of leaving it), a `## Proposed work` bullet list (concrete next actions), `## Acceptance criteria` (one verifiable assertion per bullet), and `## References` (cross-references to rules, files, prior PRs, or external docs). The concrete body the workflow builds is detailed in step "Build body" below. +- **Body template.** When `--body-file` is not provided, the body's sections come from the issue-body contract ([`reference/issue-body-contract.json`](../../../reference/issue-body-contract.json)), the record the org issue forms are generated from: the contract type matching the resolved issue type, or `agent-ready` with `--agent-ready`. The concrete body the workflow builds is detailed in step "Build body" below. - **Label taxonomy.** Labels are validated against the structure documented in [`${CLAUDE_PLUGIN_ROOT}/reference/label-taxonomy.md`](${CLAUDE_PLUGIN_ROOT}/reference/label-taxonomy.md). - **Title shape.** The item title follows the convention in [`${CLAUDE_PLUGIN_ROOT}/reference/issue-conventions.md`](${CLAUDE_PLUGIN_ROOT}/reference/issue-conventions.md). @@ -52,25 +52,9 @@ Create a new work item with labels from the taxonomy. 1. **Build labels list** `{labels}` (comma-separated for the seam) from the remaining flags. With no `--priority` and no `--agent-ready`, include no `priority:` label and include the bare `needs-triage` floor when the live set has it. A supplied `--priority`, or `--agent-ready`, does not leave the floor on: a known tier or a complete brief is already past intake. Also start from `category:general` and replace it with a supplied `--category`. Append `--area`/`--ecosystem` labels when provided. When `--agent-ready` is set, also append the autonomous-eligible role label (default `agent-ready`) so the item is eligible for autonomous pickup. A default that the consuming repo doesn't define is omitted rather than passed. With `--parent`, or a `--label` that is the binding's container label (`config.container_label`, default `work-map`), skip the `category:general` default and the `needs-triage` floor: a container or its child is not intake. Append each `--label` verbatim, after checking them as the flag says. -1. **Build body.** With `--body-file`, the file is the body. With `--parent` or the container label and no `--body-file`, the description is the body. Otherwise, if `--agent-ready`, use the agent-brief template from [`${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md`](${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md) (Type, Summary, Current behavior, Desired behavior, Key interfaces, Acceptance criteria, Out of scope). Otherwise use the default template: +1. **Build body.** With `--body-file`, the file is the body. With `--parent` or the container label and no `--body-file`, the description is the body. Otherwise, if `--agent-ready`, use the agent-brief template from [`${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md`](${CLAUDE_PLUGIN_ROOT}/reference/agent-brief.md), whose fields are the issue-body contract's `agent-ready` type. Otherwise take the contract type for the resolved issue type (`Bug` → `bug`, `Feature` → `feature`, `Task` → `task`) and write one `## <heading>` section per entry of its `fields`, in contract order, each filled from the description and `--context` as its `description` asks, with every required field filled (a field nothing backs reads `unknown`, never invented). Then append the sections below, which the contract does not hold: ```markdown -## Context - -{what observation surfaced this item; the cost of leaving it, from the description and --context} - -## Proposed work - -- {concrete next action derived from the description} - -## Acceptance criteria - -- [ ] {one verifiable assertion per bullet} - -## References - -- {cross-references to rules, files, prior PRs, or external docs, or "none"} - ## Metadata | Field | Value | diff --git a/plugins/work-items/skills/triage/SKILL.md b/plugins/work-items/skills/triage/SKILL.md index 4f680b57a7e..24c83eec88b 100644 --- a/plugins/work-items/skills/triage/SKILL.md +++ b/plugins/work-items/skills/triage/SKILL.md @@ -366,7 +366,7 @@ from layer `default`. A value other than `true` or `false` is named with its sou ### 1. Gather context -Read the item body, comments, and any linked PRs, plus the diff when the item is a PR (adapter: "View item", bare read). Read earlier triage notes and do not ask again what they already answered. An earlier verification, needs-info or other triage comment counts as a triage result only when the tracker seam's write identity or a user with `write`, `maintain` or `admin` posted it (on GitHub, `author_association` `OWNER`, `MEMBER` or `COLLABORATOR`, then the collaborator-permission read decides, because the association alone does not show the permission ([Get repository permissions for a user](https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user), as of 2026-11-10; recheck when that endpoint's role mapping changes); a failed read does not count). Any other author's comment, the reporter's included, is a claim to verify: a reporter posting "Reproduction: reproduced" in the needs-info shape settles nothing, and its reproduction counts toward neither the repro bar nor `status: confirmed`. +Read the item body, comments, and any linked PRs, plus the diff when the item is a PR (adapter: "View item", bare read). Read earlier triage notes and do not ask again what they already answered. An earlier verification, needs-info or other triage comment counts as a triage result only when the tracker seam's write identity or a user with `write`, `maintain` or `admin` posted it (on GitHub, `author_association` `OWNER`, `MEMBER` or `COLLABORATOR`, then the collaborator-permission read decides, because the association alone does not show the permission ([Get repository permissions for a user](https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user), as of 2026-11-10; recheck when that endpoint's role mapping changes); a failed read does not count). Any other author's comment, the reporter's included, is a claim to verify: a reporter posting "Reproduction: reproduced" in the needs-info shape settles nothing, and its reproduction counts toward neither the repro bar nor `status: confirmed`. For an issue, check the body against the issue-body contract per [context/body-check.md](context/body-check.md), running `node "${CLAUDE_PLUGIN_ROOT}/skills/triage/scripts/check-body.mjs"`. **Read the project's own record before evaluating.** Once the item text shows which area it touches, and before any check below, read three things the repository may keep, so the evaluation diff --git a/plugins/work-items/skills/triage/context/body-check.md b/plugins/work-items/skills/triage/context/body-check.md new file mode 100644 index 00000000000..df2ea555555 --- /dev/null +++ b/plugins/work-items/skills/triage/context/body-check.md @@ -0,0 +1,17 @@ +# Body check against the issue-body contract + +Loaded by `/work-items:triage` step 1 for an issue (not a PR). + +1. Take the contract type for the issue's type: `Bug` → `bug`, `Feature` → `feature`, `Task` → + `task`; an item filed through the agent-ready form, or one being briefed for `agent-ready`, → + `agent-ready`. An item with no type yet gets the check after step 2 picks its type. +2. Write the body to a temp file with the Write tool and run the script SKILL.md names with + `--type <type> --body-file <temp path>`. It reads the plugin's + [`reference/issue-body-contract.json`](../../../reference/issue-body-contract.json), the record + the org issue forms are generated from, accepts a field under any of the contract's + `headingLevels` (`##` from a skill, `###` from a form), and prints the required fields that are + `missing` or `empty`; a form's skipped-field answer counts as empty. Exit 0 means none. +3. A gap is not a verdict on its own. Fill it from the item and its comments when they hold the + answer, and ask for what only the reporter knows in the needs-info comment, naming each field by + its contract heading. An item filed before the forms carried these headings is held to the same + fields, by content rather than heading. diff --git a/plugins/work-items/skills/triage/scripts/check-body.mjs b/plugins/work-items/skills/triage/scripts/check-body.mjs new file mode 100755 index 00000000000..1c99d55b3f5 --- /dev/null +++ b/plugins/work-items/skills/triage/scripts/check-body.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +// Checks an issue body against one type of the issue-body contract +// (plugins/work-items/reference/issue-body-contract.json, synced from melodic-software/standards). +// A field counts as present under a heading at any of the contract's `headingLevels` (`##` from a +// skill, `###` from an issue form), with text other than the contract's `emptyAnswer` before the +// next heading at that level or shallower, deeper sub-sections included. HTML comments and fenced +// code do not count. +// +// Usage: check-body.mjs --type <type> --body-file <path> [--contract <path>] +// Prints one JSON object: {type, missing: [heading...], empty: [heading...], unknownType}. +// Exit 0 when every required field is present and answered, 1 when one is not, 2 on usage error. + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const DEFAULT_CONTRACT = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../../reference/issue-body-contract.json", +); + +const normalize = (text) => text.trim().replace(/\s+/g, " ").toLowerCase(); + +// Lines outside fenced code, with HTML comments removed. +function proseLines(body) { + const lines = body.replace(/<!--[\s\S]*?-->/g, "").split(/\r?\n/); + let fence = null; + return lines.map((line) => { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (fence) { + if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null; + return ""; + } + if (marker) { + fence = marker[1]; + return ""; + } + return line; + }); +} + +// Maps each normalized heading at an allowed level to the text under it, its sub-sections' text included. +export function sections(body, levels) { + const found = new Map(); + const open = []; + for (const line of proseLines(body)) { + const heading = line.match(/^ {0,3}(#{1,6})[ \t]+(.*?)[ \t#]*$/); + if (heading) { + const depth = heading[1].length; + while (open.length && open.at(-1).depth >= depth) open.pop(); + if (levels.includes(depth)) { + const section = { depth, text: [] }; + open.push(section); + const key = normalize(heading[2]); + if (!found.has(key)) found.set(key, section.text); + } + continue; + } + for (const section of open) section.text.push(line); + } + return found; +} + +export function checkBody(contract, typeKey, body) { + const type = contract.types[typeKey]; + if (!type) return { type: typeKey, unknownType: true, missing: [], empty: [] }; + const found = sections(body, contract.headingLevels); + const missing = []; + const empty = []; + for (const field of type.fields.filter((f) => f.required)) { + const text = found.get(normalize(field.heading)); + if (text === undefined) missing.push(field.heading); + else { + const answer = text.join("\n").trim(); + if (answer === "" || answer === contract.emptyAnswer) empty.push(field.heading); + } + } + return { type: typeKey, unknownType: false, missing, empty }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const args = process.argv.slice(2); + const flag = (name) => { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; + }; + const typeKey = flag("--type"); + const bodyFile = flag("--body-file"); + if (!typeKey || !bodyFile) { + process.stderr.write("usage: check-body.mjs --type <type> --body-file <path> [--contract <path>]\n"); + process.exit(2); + } + const contract = JSON.parse(await readFile(flag("--contract") ?? DEFAULT_CONTRACT, "utf8")); + const result = checkBody(contract, typeKey, await readFile(bodyFile, "utf8")); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(result.unknownType ? 2 : result.missing.length + result.empty.length > 0 ? 1 : 0); +} diff --git a/plugins/work-items/skills/triage/scripts/check-body.test.mjs b/plugins/work-items/skills/triage/scripts/check-body.test.mjs new file mode 100644 index 00000000000..c1efa68e9cf --- /dev/null +++ b/plugins/work-items/skills/triage/scripts/check-body.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { checkBody } from "./check-body.mjs"; + +const contract = { + headingLevels: [2, 3], + emptyAnswer: "_No response_", + types: { + task: { + fields: [ + { heading: "Context", required: true }, + { heading: "Proposed work", required: true }, + { heading: "References", required: false }, + ], + }, + }, +}; + +describe("checkBody", () => { + it("accepts `##` headings a skill writes", () => { + const body = "## Context\n\nWhy.\n\n## Proposed work\n\n- do it\n"; + assert.deepEqual(checkBody(contract, "task", body), { + type: "task", + unknownType: false, + missing: [], + empty: [], + }); + }); + + it("accepts `###` headings an issue form renders, matching heading case loosely", () => { + const body = "### context\n\nWhy.\n\n### Proposed work\n\n- do it\n\n### References\n\n_No response_\n"; + assert.deepEqual(checkBody(contract, "task", body).missing, []); + assert.deepEqual(checkBody(contract, "task", body).empty, []); + }); + + it("reports a missing required heading and ignores a missing optional one", () => { + assert.deepEqual(checkBody(contract, "task", "## Context\n\nWhy.\n").missing, ["Proposed work"]); + }); + + it("reports a form's skipped field and an empty section as empty", () => { + const body = "### Context\n\n_No response_\n\n### Proposed work\n\n### References\n\nx\n"; + assert.deepEqual(checkBody(contract, "task", body).empty, ["Context", "Proposed work"]); + }); + + it("does not count a heading at a level the contract does not accept", () => { + const body = "# Context\n\nWhy.\n\n#### Proposed work\n\n- do it\n"; + assert.deepEqual(checkBody(contract, "task", body).missing, ["Context", "Proposed work"]); + }); + + it("does not count headings inside fenced code or HTML comments", () => { + const body = "```markdown\n## Context\n\nWhy.\n```\n\n<!--\n## Proposed work\nx\n-->\n"; + assert.deepEqual(checkBody(contract, "task", body).missing, ["Context", "Proposed work"]); + }); + + it("keeps a deeper sub-heading's text inside its section", () => { + const body = "## Context\n\n#### Detail\n\nWhy.\n\n## Proposed work\n\n- x\n"; + assert.deepEqual(checkBody(contract, "task", body).empty, []); + }); + + it("counts an allowed-level sub-heading's text toward its parent field", () => { + const body = "## Context\n\n### Background\n\nWhy.\n\n## Proposed work\n\n- x\n"; + assert.deepEqual(checkBody(contract, "task", body).empty, []); + }); + + it("closes a sub-section at the next parent-level heading", () => { + const body = "## Context\n\n### Background\n\nWhy.\n\n## Proposed work\n\n_No response_\n"; + assert.deepEqual(checkBody(contract, "task", body).empty, ["Proposed work"]); + }); + + it("flags an unknown type", () => { + assert.equal(checkBody(contract, "epic", "").unknownType, true); + }); +}); diff --git a/plugins/work-items/skills/triage/scripts/check-body.test.sh b/plugins/work-items/skills/triage/scripts/check-body.test.sh new file mode 100755 index 00000000000..2286c4bd3b6 --- /dev/null +++ b/plugins/work-items/skills/triage/scripts/check-body.test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Discovery wrapper for check-body.test.mjs: run-plugin-tests.sh discovers only *.test.sh, +# so the cases live in the .mjs file and this only runs node and forwards its exit status. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node not installed" >&2 + exit 0 +fi + +node --test "$SELF_DIR/check-body.test.mjs" diff --git a/scripts/cross-plugin-source-registry.txt b/scripts/cross-plugin-source-registry.txt index d6c1587eb09..28d19d26d46 100644 --- a/scripts/cross-plugin-source-registry.txt +++ b/scripts/cross-plugin-source-registry.txt @@ -712,3 +712,11 @@ evals/fixtures/scaffold-guard.sh # repository but the run's own temporary home with the same guard. Canonical copy: # plugins/bugs/evals/fixtures/repo-scaffold-guard.sh. evals/fixtures/repo-scaffold-guard.sh + +# Dedicated check: scripts/sync-shared-copies.sh --check. Canonical: +# docs/contracts/issue-body/contract.json, synced from melodic-software/standards. +reference/issue-body-contract.json + +# Dedicated check: scripts/sync-shared-copies.sh --check. Cluster line for the +# duplication audit: the synced canonical contract and the copies. +docs/contracts/issue-body/contract.json -> plugins/*/reference/issue-body-contract.json diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index b7d82bca796..9f0a209a69d 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -804,3 +804,5 @@ docs/contracts/plugin-reconfiguration/README.md plugins/user-interface/reference .github/scripts/lib/lane-binding.mjs plugins/work-items/lib/lane-binding.mjs .github/scripts/lib/lane-binding.fixtures.mjs plugins/review/lib/lane-binding.fixtures.mjs .github/scripts/lib/lane-binding.fixtures.mjs plugins/work-items/lib/lane-binding.fixtures.mjs +docs/contracts/issue-body/contract.json plugins/bugs/reference/issue-body-contract.json +docs/contracts/issue-body/contract.json plugins/work-items/reference/issue-body-contract.json diff --git a/scripts/sync-shared-copies.sh b/scripts/sync-shared-copies.sh index 85df92e6d05..f9bb15b5fe8 100755 --- a/scripts/sync-shared-copies.sh +++ b/scripts/sync-shared-copies.sh @@ -101,10 +101,16 @@ load_registry() { # JavaScript copy takes a two-line comment header after any shebang; a Markdown # copy takes the same header as an HTML comment after any frontmatter block; an # HTML copy takes it as an HTML comment after a leading doctype line, which a -# browser needs first to stay out of quirks mode. +# browser needs first to stay out of quirks mode. JSON has no comment syntax, so +# a JSON copy is the canonical's bytes unchanged; shared-copies.txt and --check +# are what mark it generated. render() { local src="$1" prefix first="" close="" case "$src" in + *.json) + cat "$src" + return 0 + ;; *.mjs | *.cjs | *.js | *.ts) prefix='//' ;; *.sh | *.bash | *.py | *.ps1 | *.psm1 | *.awk) prefix='#' ;; *.html) diff --git a/scripts/sync-shared-copies.test.sh b/scripts/sync-shared-copies.test.sh index d7336087c99..bee2d8f6d2c 100755 --- a/scripts/sync-shared-copies.test.sh +++ b/scripts/sync-shared-copies.test.sh @@ -287,6 +287,22 @@ fi out="$(run --check)" expect "--check passes on generated Markdown copies" 0 $? "$out" +# --- a JSON copy is the canonical's bytes, with no header ------------------------- +fixture +printf '{\n "a": 1\n}\n' >"$root/lib/data.json" +printf 'lib/data.json plugins/alpha/ref/data.json\n' >"$root/scripts/shared-copies.txt" +run >/dev/null +if cmp -s "$root/lib/data.json" "$root/plugins/alpha/ref/data.json"; then + pass "a JSON copy is byte-identical to its canonical" +else + bad "a JSON copy is byte-identical to its canonical" "got: $(cat "$root/plugins/alpha/ref/data.json")" +fi +out="$(run --check)" +expect "--check passes on a generated JSON copy" 0 $? "$out" +printf '{\n "a": 2\n}\n' >"$root/plugins/alpha/ref/data.json" +out="$(run --check)" +expect "--check fails on an edited JSON copy" 1 $? "$out" + # --- an HTML copy takes an HTML-comment header, after any doctype --------------- fixture printf '<!doctype html>\n<html lang="en">\n</html>\n' >"$root/lib/page.html" @@ -342,7 +358,7 @@ reject "a copy registered twice is rejected" "registered twice" "lib/esc.mjs plu reject "a canonical registered as a copy is rejected" "both a canonical and a copy" \ "plugins/alpha/lib/esc.mjs plugins/gamma/lib/esc.mjs" plugins/alpha/lib/esc.mjs reject "an extension with no known comment syntax is rejected" "no comment syntax known" \ - "lib/data.json plugins/alpha/data.json" lib/data.json + "lib/data.toml plugins/alpha/data.toml" lib/data.toml fixture out="$(run --bogus)" diff --git a/scripts/test_check_vocabulary.py b/scripts/test_check_vocabulary.py index 29be6fa84f3..cfe3a45b580 100755 --- a/scripts/test_check_vocabulary.py +++ b/scripts/test_check_vocabulary.py @@ -717,6 +717,11 @@ def eval_rows(sense: str, line: str): r"\bname[- ]by[- ]contract\b", "The brief follows the name-by-contract rule.", ), + phrase( + "contract-brief", + r"\bissue-body contract\b", + "The brief's fields are the agent-ready type of the issue-body contract.", + ), phrase( "contract-brief", r"\bcontract case\b", diff --git a/scripts/vocabulary-senses.json b/scripts/vocabulary-senses.json index 48c8ae14681..31d956d15bf 100644 --- a/scripts/vocabulary-senses.json +++ b/scripts/vocabulary-senses.json @@ -196,6 +196,7 @@ {"phrase": "\\binterface contracts?\\b", "reason": "an interface contract at design"}, {"phrase": "\\b(pr|api|feed|payload|behavioral|presentation|execution|detection|return|framing|findings)[- ]contracts?\\b", "reason": "a named contract that is not the Brief: a PR, API, feed or payload contract, the agent brief's behavioral contract, plan's presentation contract, the interview's execution contract, the handoff detection contract, an agent's return contract, a spawn brief's framing contract or a review's findings contract"}, {"phrase": "\\bname[- ]by[- ]contract\\b", "reason": "the agent brief's name-by-contract rule"}, + {"phrase": "\\bissue-body contract\\b", "reason": "the org issue-body contract synced from melodic-software/standards, whose agent-ready type holds the Agent Brief fields"}, {"phrase": "\\bcontract case\\b", "reason": "an eval's Contract case label names a behavior case, not the Brief"}, {"path": "**/evals/**/samples/**", "reason": "recorded model output; an edit changes what the eval measures"}, {"path": "**/evals/**/graders/**", "reason": "grader patterns matched against recorded samples; an edit changes what the eval measures"},