From 0d6ceaa63e03b1f8ff4572a3c4636e771660fd05 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 08:08:39 +0200 Subject: [PATCH 1/8] [US-402] docs: PM-tool adapters document membership + assignee (T-1..T-4, T-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapters said how to write an item's state but never how the item becomes visible. Two gaps, one reproduced: `Find the Item ID` had no branch for "not a project item yet", so it yielded an empty id and the status write either died or was silently skipped and reported as done. - github: Step 2b adds the issue as a project item before Step 3 writes the status; the empty-id lookup outcome is an explicit branch; the add call is documented idempotent, so it is safe as an unconditional precondition - github: assignee in the create/update recipes, plus "a PR author is not a PR assignee" — the case that made items invisible in the filtered view - azure-devops, filesystem: membership stated as implicit, so an agent does not invent GitHub's add-item step where it does not exist - README: documenting membership semantics + the assignee mechanic is now a stated requirement of the adapter contract Membership is explicit only on GitHub; a generic rule in the skill would be false for the other adapters, and unnecessary — write-issue Step 7.2 already delegates field configuration to the guide. Dataset edited first, mirrored to root with the transform applied. Refs #402 Co-Authored-By: Claude Opus 5 --- .../project-management-tool/README.md | 15 ++++ .../azure-devops-implementation.md | 14 +++- .../filesystem-implementation.md | 13 ++++ .../github-implementation.md | 78 +++++++++++++++++++ .../project-management-tool/README.md | 15 ++++ .../azure-devops-implementation.md | 14 +++- .../filesystem-implementation.md | 13 ++++ .../github-implementation.md | 78 +++++++++++++++++++ 8 files changed, 238 insertions(+), 2 deletions(-) diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index 77b84daf..e5a3afbc 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -74,6 +74,21 @@ All implementation guides include: - ✅ Team collaboration patterns - ✅ Development workflow integration +### Adapter Contract — Required Coverage + +Every implementation guide in this directory is an **adapter**: skills stay tool-agnostic and delegate tool-specific mechanics here. An adapter that omits one of the following is incomplete, and the omission surfaces as an item that exists, is open, is green — and is invisible to the team. + +**Membership and assignee are independent, and both are required for visibility** — assigned but not a member is invisible on the board; a member but unassigned is invisible in the assignee-filtered view the team reads. Fixing one does not fix the other. + +Every adapter therefore carries a section headed **`Item Visibility: Membership and Assignee`** documenting: + +1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. +2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. +3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. +4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). + +Conformance coverage is **data-driven over the `*-implementation.md` files present**, in both the dataset and the generated root mirror — see `packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts`. Adding an adapter therefore enrols it in the contract automatically; **no adapter count is asserted anywhere**, and an omission fails the gate instead of being discovered in production. + ## Tool Selection Decision Framework ### Decision Matrix diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index ccf60c7c..f6e30993 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -148,17 +148,29 @@ Azure Boards states differ per process and per team board columns. Skills resolv ## Working with Work Items +### Item Visibility: Membership and Assignee + +**Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. + +**The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. + ### Create ```bash -# Create a user story (PBI in Scrum process) +# Create a user story (PBI in Scrum process) — assignee set as part of the create az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Story body — markdown per user-story-template]" \ + --assigned-to "[user@example.com]" \ --project + +# Existing work item: set or correct the assignee +az boards work-item update --id [id] --assigned-to "[user@example.com]" ``` +Azure Repos pull requests carry a creator and reviewers, **not an assignee field** — so the assignee lives on the linked work item. Link the PR with `--work-items` (see the PR recipe below) so the assignee-filtered board view reaches the PR through its work item. + ### Link Parent-Child ```bash diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index a860b1f9..93eb22c9 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -203,6 +203,18 @@ Status changes are reflected by moving files between directories: - **under-review/**: Completed work awaiting review/validation - **completed/**: Finished and accepted items +### Item Visibility: Membership and Assignee + +**Board membership is implicit here — the file's location _is_ its membership.** Writing the file under `03-user-stories/[status]/` puts the item in the tracked view; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a file that exists in the backlog tree is already a member of it. Consequently a status write can never fail for "not a member yet" — the failure mode is a file in the _wrong_ directory, not a missing one. + +**The assignee is not implicit** and is still required — the backlog is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). There is no assignee field in a filesystem board, so it lives in the item file's header as an **`Assignee`** line, written as part of the create, never as a follow-up step: + +```markdown +**Assignee**: [name or handle of the person the work is done for] +``` + +Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. + ## Working with Initiatives ### Creating Initiatives @@ -333,6 +345,7 @@ Fill out all required sections: - **Parent Epic**: Link to parent epic file - **Acceptance Criteria**: Clear, testable conditions - **Priority and Sizing**: Business priority and development estimate +- **Assignee**: the person the work is done for — required, see [Item Visibility](#item-visibility-membership-and-assignee) ### Refining User Stories diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 2d1c16d5..402ef9fd 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -383,6 +383,45 @@ mcp__github__issue_write: # Step 3: Repeat for initiative (epic's parent) ``` +### Item Visibility: Membership and Assignee + +**Visibility takes two independent writes, and neither substitutes for the other.** + +| Missing | Symptom | +| ------------------ | ---------------------------------------------------------------------------------- | +| Assignee | Open, on the board, green — and absent from the assignee-filtered view teams read | +| Project membership | Open, assigned, green — and absent from the board entirely | + +**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**, and `gh issue create` produces only the issue. Membership is a separate `addProjectV2ItemById` call (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. + +The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. + +#### Create an Issue with its Assignee + +```text +# MCP-first +mcp__github__issue_write: + method: create + owner: [org] + repo: [repo] + title: "[item title]" + body: "[body — markdown per the item's template]" + labels: ["[type label]", "[classification labels]"] + assignees: ["[login]"] +``` + +```bash +# CLI fallback +gh issue create --title "[title]" --body-file [file] --assignee "[login]" + +# Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally +gh issue edit [NUMBER] --add-assignee "[login]" +``` + +A pull request needs the same write: `gh pr create --assignee "[login]"`. A PR's `author` is **not** its `assignees`, so an author-only PR is invisible in an assignee-filtered view. + +**If the assignee cannot be resolved** (not a repository collaborator, org/SSO restriction): **report it** — never drop it silently, which reproduces the invisibility this recipe exists to prevent. + ### Project Board Status Transitions Update the project board status field for intermediate transitions (Todo → Refined, Refined → In Progress) and final transitions (→ Done). @@ -430,8 +469,47 @@ gh api graphql -f query='{ # Use pageInfo.endCursor for pagination if needed ``` +**Branch on the result — an empty id is a state, not an error to ignore:** + +| Lookup result | Meaning | Next | +| ---------------------------- | ------------------------------------------ | ------------------------------------------- | +| An item id | The issue is already a project item | Step 3 | +| No match after the last page | The issue is **not a project item yet** | **Step 2b**, then Step 3 on the returned id | +| No project configured at all | Minimal board (D4) — nothing to write | No-op, not a failure (see below) | + +Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with `gh: Could not resolve to a node with the global id of ''`. **And never treat the empty id as "board write skipped" and report success** — a silently skipped board write is exactly how an item ends up open, assigned, green, and absent from the board. Paginate to the last page before concluding "not an item": a match on page 3 that was never fetched looks identical to no match. + +**When the issue belongs to more than one project**: target the project named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). Never take the first project found. + +#### Step 2b: Add the Issue as a Project Item (when Step 2 found nothing) + +Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. + +```bash +# Get the issue's node id, then add it to the project +ISSUE_NODE_ID=$(gh api graphql -f query='{ + repository(owner: "[ORG]", name: "[REPO]") { + issue(number: [ISSUE_NUMBER]) { id } + } +}' --jq '.data.repository.issue.id') + +# Returns the item id — feed it straight into Step 3 +gh api graphql -f query="mutation { + addProjectV2ItemById(input: { + projectId: \"[PROJECT_ID]\" + contentId: \"$ISSUE_NODE_ID\" + }) { item { id } } +}" --jq '.data.addProjectV2ItemById.item.id' +``` + +**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. + +**No project configured (minimal board, D4)**: the membership step **no-ops**, consistent with the existing "no board state maps to this macrostate" degradation in [canonical-states.md](canonical-states.md). It is never a HALT for a project that legitimately has no board. + #### Step 3: Update the Status Field +Requires an item id from Step 2 or Step 2b — the item exists by this point, by construction. + ```bash # Transition to any status (e.g., Ready, In Progress, Done) gh api graphql -f query='mutation { diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index fd13d4b5..8a2c4b46 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -74,6 +74,21 @@ All implementation guides include: - ✅ Team collaboration patterns - ✅ Development workflow integration +### Adapter Contract — Required Coverage + +Every implementation guide in this directory is an **adapter**: skills stay tool-agnostic and delegate tool-specific mechanics here. An adapter that omits one of the following is incomplete, and the omission surfaces as an item that exists, is open, is green — and is invisible to the team. + +**Membership and assignee are independent, and both are required for visibility** — assigned but not a member is invisible on the board; a member but unassigned is invisible in the assignee-filtered view the team reads. Fixing one does not fix the other. + +Every adapter therefore carries a section headed **`Item Visibility: Membership and Assignee`** documenting: + +1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. +2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. +3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. +4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). + +Conformance coverage is **data-driven over the `*-implementation.md` files present**, in both the dataset and the generated root mirror — see `packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts`. Adding an adapter therefore enrols it in the contract automatically; **no adapter count is asserted anywhere**, and an omission fails the gate instead of being discovered in production. + ## Tool Selection Decision Framework ### Decision Matrix diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index c2db4930..420a6096 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -148,17 +148,29 @@ Azure Boards states differ per process and per team board columns. Skills resolv ## Working with Work Items +### Item Visibility: Membership and Assignee + +**Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. + +**The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. + ### Create ```bash -# Create a user story (PBI in Scrum process) +# Create a user story (PBI in Scrum process) — assignee set as part of the create az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Story body — markdown per user-story-template]" \ + --assigned-to "[user@example.com]" \ --project + +# Existing work item: set or correct the assignee +az boards work-item update --id [id] --assigned-to "[user@example.com]" ``` +Azure Repos pull requests carry a creator and reviewers, **not an assignee field** — so the assignee lives on the linked work item. Link the PR with `--work-items` (see the PR recipe below) so the assignee-filtered board view reaches the PR through its work item. + ### Link Parent-Child ```bash diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 6298403b..85c151e9 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -203,6 +203,18 @@ Status changes are reflected by moving files between directories: - **under-review/**: Completed work awaiting review/validation - **completed/**: Finished and accepted items +### Item Visibility: Membership and Assignee + +**Board membership is implicit here — the file's location _is_ its membership.** Writing the file under `03-user-stories/[status]/` puts the item in the tracked view; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a file that exists in the backlog tree is already a member of it. Consequently a status write can never fail for "not a member yet" — the failure mode is a file in the _wrong_ directory, not a missing one. + +**The assignee is not implicit** and is still required — the backlog is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). There is no assignee field in a filesystem board, so it lives in the item file's header as an **`Assignee`** line, written as part of the create, never as a follow-up step: + +```markdown +**Assignee**: [name or handle of the person the work is done for] +``` + +Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. + ## Working with Initiatives ### Creating Initiatives @@ -333,6 +345,7 @@ Fill out all required sections: - **Parent Epic**: Link to parent epic file - **Acceptance Criteria**: Clear, testable conditions - **Priority and Sizing**: Business priority and development estimate +- **Assignee**: the person the work is done for — required, see [Item Visibility](#item-visibility-membership-and-assignee) ### Refining User Stories diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 9363a91b..4a3fcc8a 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -383,6 +383,45 @@ mcp__github__issue_write: # Step 3: Repeat for initiative (epic's parent) ``` +### Item Visibility: Membership and Assignee + +**Visibility takes two independent writes, and neither substitutes for the other.** + +| Missing | Symptom | +| ------------------ | ---------------------------------------------------------------------------------- | +| Assignee | Open, on the board, green — and absent from the assignee-filtered view teams read | +| Project membership | Open, assigned, green — and absent from the board entirely | + +**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**, and `gh issue create` produces only the issue. Membership is a separate `addProjectV2ItemById` call (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. + +The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. + +#### Create an Issue with its Assignee + +```text +# MCP-first +mcp__github__issue_write: + method: create + owner: [org] + repo: [repo] + title: "[item title]" + body: "[body — markdown per the item's template]" + labels: ["[type label]", "[classification labels]"] + assignees: ["[login]"] +``` + +```bash +# CLI fallback +gh issue create --title "[title]" --body-file [file] --assignee "[login]" + +# Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally +gh issue edit [NUMBER] --add-assignee "[login]" +``` + +A pull request needs the same write: `gh pr create --assignee "[login]"`. A PR's `author` is **not** its `assignees`, so an author-only PR is invisible in an assignee-filtered view. + +**If the assignee cannot be resolved** (not a repository collaborator, org/SSO restriction): **report it** — never drop it silently, which reproduces the invisibility this recipe exists to prevent. + ### Project Board Status Transitions Update the project board status field for intermediate transitions (Todo → Refined, Refined → In Progress) and final transitions (→ Done). @@ -430,8 +469,47 @@ gh api graphql -f query='{ # Use pageInfo.endCursor for pagination if needed ``` +**Branch on the result — an empty id is a state, not an error to ignore:** + +| Lookup result | Meaning | Next | +| ---------------------------- | ------------------------------------------ | ------------------------------------------- | +| An item id | The issue is already a project item | Step 3 | +| No match after the last page | The issue is **not a project item yet** | **Step 2b**, then Step 3 on the returned id | +| No project configured at all | Minimal board (D4) — nothing to write | No-op, not a failure (see below) | + +Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with `gh: Could not resolve to a node with the global id of ''`. **And never treat the empty id as "board write skipped" and report success** — a silently skipped board write is exactly how an item ends up open, assigned, green, and absent from the board. Paginate to the last page before concluding "not an item": a match on page 3 that was never fetched looks identical to no match. + +**When the issue belongs to more than one project**: target the project named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). Never take the first project found. + +#### Step 2b: Add the Issue as a Project Item (when Step 2 found nothing) + +Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. + +```bash +# Get the issue's node id, then add it to the project +ISSUE_NODE_ID=$(gh api graphql -f query='{ + repository(owner: "[ORG]", name: "[REPO]") { + issue(number: [ISSUE_NUMBER]) { id } + } +}' --jq '.data.repository.issue.id') + +# Returns the item id — feed it straight into Step 3 +gh api graphql -f query="mutation { + addProjectV2ItemById(input: { + projectId: \"[PROJECT_ID]\" + contentId: \"$ISSUE_NODE_ID\" + }) { item { id } } +}" --jq '.data.addProjectV2ItemById.item.id' +``` + +**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. + +**No project configured (minimal board, D4)**: the membership step **no-ops**, consistent with the existing "no board state maps to this macrostate" degradation in [canonical-states.md](canonical-states.md). It is never a HALT for a project that legitimately has no board. + #### Step 3: Update the Status Field +Requires an item id from Step 2 or Step 2b — the item exists by this point, by construction. + ```bash # Transition to any status (e.g., Ready, In Progress, Done) gh api graphql -f query='mutation { From 749b90bc2aacb752f030831bb715b22b2c172597 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 08:08:42 +0200 Subject: [PATCH 2/8] [US-402] test: conformance for adapter visibility coverage (T-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 59 assertions over both corpora, data-driven: adapters are discovered from disk and each is asserted individually, so no count is hardcoded — #389 adds linear-implementation.md as a sixth and the suite absorbs it. Injection-tested: removing the idempotency claim, the mutation name, or the assignee prose each turns the suite red; restoring turns it green. The strongest assertion is structural — Step 2b must appear before Step 3, so the ordering cannot regress into prose that merely mentions both. Refs #402 Co-Authored-By: Claude Opus 5 --- .../pm-tool-adapter-contract.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts new file mode 100644 index 00000000..61cee8c8 --- /dev/null +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync } from 'fs' +import { join } from 'path' + +/** + * Conformance guard for the PM-tool **adapter contract** (story #402). + * + * Cross-cutting invariant across every `*-implementation.md` adapter, so it + * lives in its own file per the per-artifact ADL's explicit exception for + * "a genuinely new cross-cutting invariant spanning many artifacts" + * (decision-log/2026-07-18-conformance-test-per-file-not-per-story.md) — + * it is NOT a story-named file. + * + * The invariant: an item is only visible when it has BOTH board membership and + * an assignee, and the two are independent. Membership semantics are per-tool + * (explicit on GitHub Projects, implicit on Azure Boards and on a filesystem + * backlog), so the mechanics live in the adapters and the skills stay + * tool-agnostic. Observed 2026-07-30: #384 and #372 were open, assigned, green, + * and absent from the board entirely — no gate caught it. + * + * DATA-DRIVEN BY CONSTRUCTION, NO COUNT ASSERTED. The adapter set is discovered + * from disk (`*-implementation.md`) in BOTH corpora — the dataset (authoring + * source of truth) and the generated root mirror. A new adapter is enrolled in + * the contract the moment its file lands; nothing here needs editing, and no + * assertion breaks because the set grew. When `linear-implementation.md` arrives + * (#389) it is enrolled automatically — supplying its two sections is #403's + * job, and this guard going red is precisely how that omission stays visible + * instead of shipping as another invisible-item bug. + */ + +const REL = '.pair/knowledge/guidelines/collaboration/project-management-tool' +const CORPORA = [ + { label: 'dataset', dir: join(__dirname, '../../dataset', REL) }, + { label: 'generated root', dir: join(__dirname, '../../../../', REL) }, +] + +/** The heading every adapter carries, per the README adapter contract. */ +const VISIBILITY_HEADING = 'Item Visibility: Membership and Assignee' + +/** + * Strips markdown emphasis and code spans, then collapses whitespace. + * + * Without this, an assertion requiring two adjacent words goes vacuously green + * the moment prose puts a `code span` or **bold** marker between them — the + * exact way an earlier story shipped guard regexes that could never fail. + * Every claim below is matched against normalized text, and every one was + * injection-tested by deleting the claim and confirming the assertion reddens. + * That sweep found one real vacuous assertion (see the create-recipe test) and + * confirmed the two degenerate cases fail loudly rather than passing empty: + * zero adapter files on disk fails at collection, and a discovery filter that + * stops matching reddens the non-empty guard below. + */ +function normalize(markdown: string): string { + return markdown.replace(/[*`_]/g, '').replace(/\s+/g, ' ').toLowerCase() +} + +/** Adapter files present on disk — discovered, never enumerated. */ +function adapterFiles(dir: string): string[] { + return readdirSync(dir) + .filter(name => name.endsWith('-implementation.md')) + .sort() +} + +/** + * The body of a `### ` section: everything up to the next heading of + * level 3 or shallower, so the adapter's own `####` sub-recipes stay inside. + * + * Fence-aware on purpose: these guides are mostly shell recipes, and a `# MCP-first` + * comment inside a fenced block reads as an h1 to a naive regex — which truncated + * the section before its own create recipe and made the code-span assertions + * unsatisfiable. + */ +function section(markdown: string, heading: string): string { + const lines = markdown.split('\n') + const start = lines.findIndex(line => line.trimEnd() === `### ${heading}`) + if (start === -1) return '' + + const body: string[] = [] + let inFence = false + for (const line of lines.slice(start + 1)) { + if (/^\s*```/.test(line)) inFence = !inFence + if (!inFence && /^#{1,3} /.test(line)) break + body.push(line) + } + return body.join('\n') +} + +const adapterCases = CORPORA.flatMap(({ label, dir }) => + adapterFiles(dir).map(file => ({ + corpus: label, + file, + content: readFileSync(join(dir, file), 'utf-8'), + })), +) + +describe('PM-tool adapter contract — every adapter present documents visibility (#402)', () => { + it('discovers adapters from disk in both corpora', () => { + // Not a count assertion — a non-empty guard so the data-driven cases below + // can never pass vacuously by iterating an empty list. + for (const { label, dir } of CORPORA) { + expect(adapterFiles(dir).length, `no adapters found in ${label}`).toBeGreaterThan(0) + } + }) + + it.each(adapterCases)('$corpus/$file has the visibility section', ({ content }) => { + expect(content).toContain(`### ${VISIBILITY_HEADING}`) + }) + + it.each(adapterCases)( + '$corpus/$file declares its membership semantics as explicit or implicit (AC4)', + ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toMatch(/membership is (explicit|implicit)/) + }, + ) + + it.each(adapterCases)( + '$corpus/$file sets the assignee as part of the create, not as a follow-up (AC3)', + ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toContain('as part of the create, never as a follow-up step') + }, + ) + + it.each(adapterCases)( + '$corpus/$file reports an unresolvable assignee instead of dropping it', + ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toContain('if the assignee cannot be resolved') + expect(body).toContain('never drop it silently') + }, + ) +}) + +const readmeCases = CORPORA.map(({ label, dir }) => ({ + corpus: label, + content: readFileSync(join(dir, 'README.md'), 'utf-8'), +})) + +describe('PM-tool README — the adapter contract is a stated requirement (#402 AC5)', () => { + it.each(readmeCases)('$corpus: states the required-coverage contract', ({ content }) => { + expect(content).toMatch(/^#+ Adapter Contract — Required Coverage$/m) + }) + + it.each(readmeCases)( + '$corpus: states membership and assignee are independent and both required', + ({ content }) => { + expect(normalize(content)).toContain( + 'membership and assignee are independent, and both are required for visibility', + ) + }, + ) + + it.each(readmeCases)('$corpus: names the section every adapter must carry', ({ content }) => { + expect(normalize(content)).toContain(normalize(VISIBILITY_HEADING)) + }) + + it.each(readmeCases)( + '$corpus: requires the membership/assignee/status-write claims of a new adapter', + ({ content }) => { + const body = normalize(content) + expect(body).toContain('membership is explicit / membership is implicit') + expect(body).toContain('as part of the create, never as a follow-up step') + expect(body).toContain('if the assignee cannot be resolved') + expect(body).toContain('never drop it silently') + }, + ) + + it.each(readmeCases)( + '$corpus: declares the coverage data-driven and count-free', + ({ content }) => { + const body = normalize(content) + // Expected literals go through the same normalization as the corpus, so an + // emphasis marker in the prose can neither satisfy nor defeat the claim. + expect(body).toContain(normalize('data-driven over the `*-implementation.md` files present')) + expect(body).toContain(normalize('**no adapter count is asserted anywhere**')) + }, + ) +}) + +const githubCases = CORPORA.map(({ label, dir }) => ({ + corpus: label, + content: readFileSync(join(dir, 'github-implementation.md'), 'utf-8'), +})) + +describe('github-implementation.md — explicit membership precedes the status write (#402 AC1)', () => { + it.each(githubCases)('$corpus: membership is explicit and a separate call', ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toContain('membership is explicit') + expect(body).toContain('distinct objects') + expect(body).toContain('addprojectv2itembyid') + }) + + it.each(githubCases)( + '$corpus: has an add-the-item step before the status write', + ({ content }) => { + expect(content).toMatch(/^#### Step 2b: Add the Issue as a Project Item/m) + expect(normalize(content)).toContain( + 'membership must exist before the status field can be written', + ) + const addItem = content.indexOf('#### Step 2b:') + const statusWrite = content.indexOf('#### Step 3: Update the Status Field') + expect(addItem).toBeGreaterThan(-1) + expect(statusWrite).toBeGreaterThan(addItem) + }, + ) + + it.each(githubCases)('$corpus: the status write requires an id from 2 or 2b', ({ content }) => { + expect(normalize(section(content, 'Project Board Status Transitions'))).toContain( + 'requires an item id from step 2 or step 2b', + ) + }) + + it.each(githubCases)('$corpus: documents the add-item call as idempotent', ({ content }) => { + expect(normalize(content)).toContain('returns that existing item instead of duplicating it') + }) + + it.each(githubCases)('$corpus: no-ops when no project is configured (D4)', ({ content }) => { + const body = normalize(content) + expect(body).toContain('the membership step no-ops') + expect(body).toContain('never a halt') + }) + + it.each(githubCases)('$corpus: targets the adopted project, never the first', ({ content }) => { + expect(normalize(content)).toContain('never take the first project found') + }) +}) + +describe('github-implementation.md — the empty item id is an explicit branch (#402 AC2)', () => { + it.each(githubCases)( + '$corpus: names not-a-project-item-yet as a lookup outcome', + ({ content }) => { + const body = normalize(content) + expect(body).toContain('branch on the result') + expect(body).toContain('not a project item yet') + }, + ) + + it.each(githubCases)( + '$corpus: forbids carrying an empty id into the status write', + ({ content }) => { + expect(normalize(content)).toContain('never carry an empty id into step 3') + }, + ) + + it.each(githubCases)( + '$corpus: forbids reporting success on a skipped board write', + ({ content }) => { + const body = normalize(content) + expect(body).toContain('never treat the empty id as "board write skipped" and report success') + expect(body).toContain('paginate to the last page before concluding "not an item"') + }, + ) +}) + +describe('github-implementation.md — assignee is part of the write (#402 AC3)', () => { + it.each(githubCases)('$corpus: the create recipe carries the assignee', ({ content }) => { + const body = section(content, VISIBILITY_HEADING) + expect(body).toContain('assignees: ["[login]"]') + // Pinned as a whole command: a bare `--assignee "[login]"` check was VACUOUS — + // the sibling `gh pr create --assignee` line satisfied it even with the flag + // deleted from `gh issue create` (found by injection-testing this guard). + expect(body).toMatch(/gh issue create\b[^\n]*--assignee "\[login\]"/) + }) + + it.each(githubCases)('$corpus: adding an assignee is safe to re-run', ({ content }) => { + expect(normalize(section(content, VISIBILITY_HEADING))).toContain( + 'adds without replacing, so it is safe to run unconditionally', + ) + }) + + it.each(githubCases)('$corpus: states a PR author is not a PR assignee', ({ content }) => { + expect(normalize(section(content, VISIBILITY_HEADING))).toContain( + "a pr's author is not its assignees", + ) + }) +}) From 0582fbde1df83305abbb304a12100a30fb39aa1a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 12:52:29 +0200 Subject: [PATCH 3/8] =?UTF-8?q?[US-402]=20fix:=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20the=20create=20recipe=20now=20writes=20membership,?= =?UTF-8?q?=20not=20only=20the=20assignee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #404's independent review: 1 Major (M1) + 3 Minor. M2 and M3 are handled outside the diff (PR description and #403's scope). M1 — the create recipe wrote the assignee and NOT the membership, so `/write-issue` called without `$status` (the follow-up path, which is exactly how #384 and #372 were filed) produced the defect this story exists to remove: open, assigned, green, off the board. AC1 only closed the status-write path. - the section is now "Create an Issue with its Assignee and its Membership" and opens with "Creating does not imply membership", naming the reachable path - `gh issue create` carries `--project "[project title]"` in the one-shot form - the MCP block states it has NO project field, so Step 2b is REQUIRED after it - the unqualified "gh issue create produces only the issue" is now qualified with `--project`, resolving a contradiction with this file's own :206 snippet Minor — that snippet passed `[PROJECT_ID]` where the flag takes a title or number; corrected in both `issue create` and `pr create`, with a pointer to the visibility section. `### Issue Management` now points at the create recipe, which lives further down next to the board mechanics an agent would not think to look under. The filesystem adapter says WHERE the `Assignee` line goes, since the item templates carry no such field and copying one leaves no slot. Minor — `membership is (explicit|implicit)` accepted either word for every adapter, so a flip stayed green. The loose form is kept for adapters this suite has never seen; the three known ones are now pinned by name via their own `board membership is ` declaration. Still count-free. 59 → 71 assertions. All five new claims injection-tested. Two of my own assertions were wrong and the tests caught them: - a bare `not.toContain('membership is implicit')` failed against github's legitimate cross-reference to the other adapters - the `[PROJECT_ID]` regression guard was VACUOUS: `normalize()` strips `_`, so a check written with the underscore could never match. Reinstating the regression left the suite green. Now asserted on raw content, and red on injection in both corpora. Mirror ported by hand, not regenerated: `github-implementation.md` carries pre-existing skill-reference transforms (`/review` -> `/pair-process-review`) that a blind copy would have reverted (#352 precedent). `pnpm quality-gate` green — 589 tests, 23 files. Refs #402 Co-Authored-By: Claude Opus 5 --- .../filesystem-implementation.md | 2 + .../github-implementation.md | 29 ++++++-- .../filesystem-implementation.md | 2 + .../github-implementation.md | 29 ++++++-- .../pm-tool-adapter-contract.test.ts | 73 ++++++++++++++++++- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 93eb22c9..9adce94e 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -213,6 +213,8 @@ Status changes are reflected by moving files between directories: **Assignee**: [name or handle of the person the work is done for] ``` +**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. + Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. ## Working with Initiatives diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 402ef9fd..3dd51044 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -43,6 +43,8 @@ npm install -g @github/github-mcp-server - Issue workflow automation - Integration with project boards +**Creating an issue?** The create recipe — assignee **and** project membership, which are two independent writes — lives in [Item Visibility: Membership and Assignee](#item-visibility-membership-and-assignee) further down this file. It sits next to the board-status mechanics it depends on rather than here. + ### Project Tracking #### → See [../project-tracking/](../project-tracking/README.md) @@ -203,8 +205,13 @@ npm install -g @github/github-mcp-server ```bash # Fallback commands when MCP unavailable gh project list --owner [ORG] -gh issue create --project [PROJECT_ID] -gh pr create --project [PROJECT_ID] + +# --project takes the project's TITLE (or number), never its node ID. +# Passing it at create time is what makes the item a board member — +# see "Item Visibility: Membership and Assignee" below for why that matters +# and for the Step 2b path when this flag is not used. +gh issue create --project "[project title]" +gh pr create --project "[project title]" ``` ### API Integration @@ -392,14 +399,18 @@ mcp__github__issue_write: | Assignee | Open, on the board, green — and absent from the assignee-filtered view teams read | | Project membership | Open, assigned, green — and absent from the board entirely | -**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**, and `gh issue create` produces only the issue. Membership is a separate `addProjectV2ItemById` call (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. +**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**. `mcp__github__issue_write` has **no project field at all**, and `gh issue create` produces only the issue **unless you pass `--project`**. Either way membership is a separate decision, made by `--project` at create time or by `addProjectV2ItemById` afterwards (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. -#### Create an Issue with its Assignee +#### Create an Issue with its Assignee and its Membership + +**Creating does not imply membership.** Both writes belong to the create, because the two failure modes above are independent: an issue created with an assignee and no membership is open, assigned, green and absent from the board — which is the exact defect this section exists to prevent, and it is reachable through the **most common** path, a follow-up issue filed with no status transition. ```text -# MCP-first +# MCP-first — NOTE: issue_write has no project field. +# This call creates the issue and its assignee ONLY. +# Step 2b is REQUIRED after it, not optional. mcp__github__issue_write: method: create owner: [org] @@ -411,8 +422,12 @@ mcp__github__issue_write: ``` ```bash -# CLI fallback -gh issue create --title "[title]" --body-file [file] --assignee "[login]" +# CLI fallback — one shot: --project takes the project's TITLE (or number), +# never its node ID, and the project is the one named in way-of-working.md. +gh issue create --title "[title]" --body-file [file] --assignee "[login]" --project "[project title]" + +# Without --project (or after any MCP create): membership is still missing. +# Run Step 2b below — it is idempotent, so it is safe to run unconditionally. # Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally gh issue edit [NUMBER] --add-assignee "[login]" diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 85c151e9..20876a7b 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -213,6 +213,8 @@ Status changes are reflected by moving files between directories: **Assignee**: [name or handle of the person the work is done for] ``` +**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. + Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. ## Working with Initiatives diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 4a3fcc8a..98116b47 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -43,6 +43,8 @@ npm install -g @github/github-mcp-server - Issue workflow automation - Integration with project boards +**Creating an issue?** The create recipe — assignee **and** project membership, which are two independent writes — lives in [Item Visibility: Membership and Assignee](#item-visibility-membership-and-assignee) further down this file. It sits next to the board-status mechanics it depends on rather than here. + ### Project Tracking #### → See [../project-tracking/](../project-tracking/README.md) @@ -203,8 +205,13 @@ npm install -g @github/github-mcp-server ```bash # Fallback commands when MCP unavailable gh project list --owner [ORG] -gh issue create --project [PROJECT_ID] -gh pr create --project [PROJECT_ID] + +# --project takes the project's TITLE (or number), never its node ID. +# Passing it at create time is what makes the item a board member — +# see "Item Visibility: Membership and Assignee" below for why that matters +# and for the Step 2b path when this flag is not used. +gh issue create --project "[project title]" +gh pr create --project "[project title]" ``` ### API Integration @@ -392,14 +399,18 @@ mcp__github__issue_write: | Assignee | Open, on the board, green — and absent from the assignee-filtered view teams read | | Project membership | Open, assigned, green — and absent from the board entirely | -**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**, and `gh issue create` produces only the issue. Membership is a separate `addProjectV2ItemById` call (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. +**Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**. `mcp__github__issue_write` has **no project field at all**, and `gh issue create` produces only the issue **unless you pass `--project`**. Either way membership is a separate decision, made by `--project` at create time or by `addProjectV2ItemById` afterwards (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. -#### Create an Issue with its Assignee +#### Create an Issue with its Assignee and its Membership + +**Creating does not imply membership.** Both writes belong to the create, because the two failure modes above are independent: an issue created with an assignee and no membership is open, assigned, green and absent from the board — which is the exact defect this section exists to prevent, and it is reachable through the **most common** path, a follow-up issue filed with no status transition. ```text -# MCP-first +# MCP-first — NOTE: issue_write has no project field. +# This call creates the issue and its assignee ONLY. +# Step 2b is REQUIRED after it, not optional. mcp__github__issue_write: method: create owner: [org] @@ -411,8 +422,12 @@ mcp__github__issue_write: ``` ```bash -# CLI fallback -gh issue create --title "[title]" --body-file [file] --assignee "[login]" +# CLI fallback — one shot: --project takes the project's TITLE (or number), +# never its node ID, and the project is the one named in way-of-working.md. +gh issue create --title "[title]" --body-file [file] --assignee "[login]" --project "[project title]" + +# Without --project (or after any MCP create): membership is still missing. +# Run Step 2b below — it is idempotent, so it is safe to run unconditionally. # Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally gh issue edit [NUMBER] --add-assignee "[login]" diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts index 61cee8c8..fe3a606b 100644 --- a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { readFileSync, readdirSync } from 'fs' +import { readFileSync, readdirSync, existsSync } from 'fs' import { join } from 'path' /** @@ -114,6 +114,41 @@ describe('PM-tool adapter contract — every adapter present documents visibilit }, ) + // The assertion above accepts either word, by design: it must hold for an + // adapter this suite has never seen. But for the adapters that DO exist, the + // loose form lets a regression flip azure/filesystem to "explicit" — or github + // to "implicit" — and stay green, which is exactly the confusion AC4 exists to + // prevent ("silence is what makes an agent invent an add-item step"). So pin + // the known values by name. Still count-free: a sixth adapter is governed by + // the loose assertion above until someone states its semantics here. + const KNOWN_SEMANTICS = [ + { file: 'github-implementation.md', word: 'explicit' }, + { file: 'azure-devops-implementation.md', word: 'implicit' }, + { file: 'filesystem-implementation.md', word: 'implicit' }, + ] + const knownCases = CORPORA.flatMap(({ label, dir }) => + KNOWN_SEMANTICS.filter(k => existsSync(join(dir, k.file))).map(k => ({ + corpus: label, + file: k.file, + word: k.word, + content: readFileSync(join(dir, k.file), 'utf-8'), + })), + ) + + it.each(knownCases)( + '$corpus/$file pins its membership as $word, so a flip cannot pass (AC4)', + ({ content, word }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + const other = word === 'explicit' ? 'implicit' : 'explicit' + // Pin the adapter's OWN declaration — the `board membership is …` sentence. + // A bare `membership is ` negative was wrong: github's section legitimately + // says "on tools where membership is implicit, the guide says so", a pointer to + // the other adapters, not a claim about GitHub. Caught by this test failing. + expect(body).toContain(`board membership is ${word}`) + expect(body).not.toContain(`board membership is ${other}`) + }, + ) + it.each(adapterCases)( '$corpus/$file sets the assignee as part of the create, not as a follow-up (AC3)', ({ content }) => { @@ -274,4 +309,40 @@ describe('github-implementation.md — assignee is part of the write (#402 AC3)' "a pr's author is not its assignees", ) }) + + // Review finding M1 on PR #404. The create recipe used to write the assignee and + // NOT the membership, so `/write-issue` called without `$status` — the follow-up + // path, which is how #384 and #372 were actually filed — produced the exact + // defect this story exists to remove: open, assigned, green, off the board. + // AC1 only closes the status-write path; these pin the create path. + it.each(githubCases)( + '$corpus: the create recipe states that creating does not imply membership', + ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toContain('creating does not imply membership') + }, + ) + + it.each(githubCases)('$corpus: the create recipe carries the membership write', ({ content }) => { + const body = section(content, VISIBILITY_HEADING) + // Same whole-command discipline as the assignee pin above: a bare `--project` + // check would be satisfied by the `gh pr create` line alone. + expect(body).toMatch(/gh issue create\b[^\n]*--project "\[project title\]"/) + // MCP has no project field, so Step 2b is mandatory after it — not optional. + expect(normalize(body)).toContain('step 2b is required after it, not optional') + }) + + it.each(githubCases)( + '$corpus: --project is documented as a title, never a node id', + ({ content }) => { + // The Advanced Features snippet passed [PROJECT_ID], contradicting this file's + // own visibility section AND being wrong about the flag's argument type. + // Asserted on RAW content, not normalize()d: normalize strips `_`, so + // `[PROJECT_ID]` becomes `[projectid]` and a check written with the underscore + // could never match. That exact vacuity was caught by injection-testing this + // guard — the regression was reinstated and the suite stayed green. + expect(content).not.toMatch(/gh (issue|pr) create --project \[PROJECT_ID\]/i) + expect(normalize(content)).toContain("takes the project's title (or number), never its node") + }, + ) }) From bdd175c8a56e93b0c13f7220689bfd40ce5ff4f2 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 18:24:05 +0200 Subject: [PATCH 4/8] =?UTF-8?q?[US-402]=20docs:=20linear's=20Item=20Visibi?= =?UTF-8?q?lity=20section=20=E2=80=94=20closes=20the=20#389=20merge=20cons?= =?UTF-8?q?traint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constraint this PR declared has come due. #389 merged first, bringing `linear-implementation.md` (312 lines, zero occurrences of "assignee", no `Item Visibility` section) onto main. The guard added here requires that section from EVERY adapter present, so main would have gone red the moment this PR merged — 8 assertions (4 × 2 corpora), reproduced before the fix. Linear's membership is **implicit**, and for a sharper reason than the other implicit adapter: `issueCreate` *requires* `teamId`, so an issue cannot exist without belonging to a team. There is no add-to-board step to invent, and a state write can never fail for "not a member yet". Optional `projectId` narrows which project view shows it — a grouping inside the team, never what makes the issue visible. Stated explicitly, because silence is what makes an agent invent GitHub's `addProjectV2ItemById` for a tool that has no such concept. The assignee is NOT implicit and gets the same treatment as the other adapters: set as part of the create (`assigneeId` on `issueCreate`, with the user-id lookup shown), idempotent on update so it is safe to run unconditionally, and reported rather than dropped when it cannot be resolved. One Linear-specific note: the code host is separate from the PM tool here (#236's way-of-working override, merged as #389), so the PR-assignee write belongs to the host's adapter, not this one. Conformance 71 → 79. `pnpm quality-gate` green. Refs #402 --- .../linear-implementation.md | 29 +++++++++++++++++++ .../linear-implementation.md | 29 +++++++++++++++++++ .../src/conformance/code-host-routing.test.ts | 3 +- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index 9819f1f5..404a5d34 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -211,6 +211,35 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc `issue.identifier` (e.g. `ENG-412`) is the **item id** pair uses everywhere — in commits, branch names, and the PR's `Refs:` line. +### Item Visibility: Membership and Assignee + +**Visibility takes two independent writes, and neither substitutes for the other.** + +| Missing | Symptom | +| ------------------ | ---------------------------------------------------------------------------------- | +| Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | +| Board membership | not possible here — see below | + +**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows *which* project view shows it, but it is a grouping within the team, never the thing that makes the issue visible. + +**The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it **as part of the create**, never as a follow-up step: + +```bash +# Create with the assignee — resolve the user id first +linear_gql '{"query":"{ users(filter:{isMe:{eq:true}}) { nodes { id name } } }"}' + +linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { identifier } } }", + "variables":{"i":{"teamId":"","title":"[title]","assigneeId":""}}}' + +# Existing issue — assigning is idempotent, so it is safe to run unconditionally +linear_gql '{"query":"mutation($id:String!,$i:IssueUpdateInput!){ issueUpdate(id:$id,input:$i){ success } }", + "variables":{"id":"","i":{"assigneeId":""}}}' +``` + +A pull request needs the same write on the code host — a PR's `author` is **not** its `assignees`, so an author-only PR is invisible in an assignee-filtered view. On Linear the code host is separate from the PM tool (see the way-of-working override), so that write belongs to the host's own adapter. + +**If the assignee cannot be resolved** (not a workspace member, SSO restriction): **report it** — never drop it silently, which reproduces the invisibility this recipe exists to prevent. + ### Link Parent-Child ```bash diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index 03a58a8e..f35eb9d8 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -211,6 +211,35 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc `issue.identifier` (e.g. `ENG-412`) is the **item id** pair uses everywhere — in commits, branch names, and the PR's `Refs:` line. +### Item Visibility: Membership and Assignee + +**Visibility takes two independent writes, and neither substitutes for the other.** + +| Missing | Symptom | +| ------------------ | ---------------------------------------------------------------------------------- | +| Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | +| Board membership | not possible here — see below | + +**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible. + +**The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it **as part of the create**, never as a follow-up step: + +```bash +# Create with the assignee — resolve the user id first +linear_gql '{"query":"{ users(filter:{isMe:{eq:true}}) { nodes { id name } } }"}' + +linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { identifier } } }", + "variables":{"i":{"teamId":"","title":"[title]","assigneeId":""}}}' + +# Existing issue — assigning is idempotent, so it is safe to run unconditionally +linear_gql '{"query":"mutation($id:String!,$i:IssueUpdateInput!){ issueUpdate(id:$id,input:$i){ success } }", + "variables":{"id":"","i":{"assigneeId":""}}}' +``` + +A pull request needs the same write on the code host — a PR's `author` is **not** its `assignees`, so an author-only PR is invisible in an assignee-filtered view. On Linear the code host is separate from the PM tool (see the way-of-working override), so that write belongs to the host's own adapter. + +**If the assignee cannot be resolved** (not a workspace member, SSO restriction): **report it** — never drop it silently, which reproduces the invisibility this recipe exists to prevent. + ### Link Parent-Child ```bash diff --git a/packages/knowledge-hub/src/conformance/code-host-routing.test.ts b/packages/knowledge-hub/src/conformance/code-host-routing.test.ts index 113a7034..be1d208d 100644 --- a/packages/knowledge-hub/src/conformance/code-host-routing.test.ts +++ b/packages/knowledge-hub/src/conformance/code-host-routing.test.ts @@ -490,8 +490,7 @@ describe('code-host / PM-tool split — the machine-read slots are actually mach }) it('the branching guideline is parametrised on base-branch, not hardcoded on main', () => { - const rel = - '.pair/knowledge/guidelines/technical-standards/git-workflow/development-process.md' + const rel = '.pair/knowledge/guidelines/technical-standards/git-workflow/development-process.md' for (const root of [DATASET, REPO_ROOT]) { const guideline = read(root, rel) expect(guideline, root).toContain('') From 2ee2e333ed8bc480e239052bc9c074dd7efa6633 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 19:04:59 +0200 Subject: [PATCH 5/8] =?UTF-8?q?[US-402]=20fix:=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20no-op=20degradation=20cited=20correctly,=20KB-wide?= =?UTF-8?q?=20create=20recipe,=20ADL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 findings, all resolved, none escalated. Major: - no-project no-op no longer cites canonical-states rule 5 (which HALTs) or D4 (missing-Ready-column): stated as its own degradation, contrasted with both. Outcome moved to Step 1 where discovery happens, with a table that separates "discovery returned no project" (no-op) from "discovery failed" (report). - every KB issue-create recipe now writes assignee + membership (github x2, azure x3, linear x1, secure-development x1), each pointing at the adapter's Item Visibility section. New KB-wide guard: no `gh issue create` without `--assignee`, whole-command regex, count-free. Minor: - KNOWN_SEMANTICS pins linear as implicit; README contract clause 4 and the heading-level/pinned-sentence form now asserted; docblock de-staled. - root mirror `_which_` — mirrors re-derived from dataset + transform only. - implicit membership != cannot be invisible: azure area path, linear projectId when the adoption names a project view. - Step 2b heading reframed as an unconditional precondition; its mutation parameterised (-F) with an empty-node-id abort. - ADL 2026-07-31-pm-adapter-visibility-contract records the contract invariant, the adapter-not-skill rationale (constrains #403) and the assignee-not-in- templates choice; cited from README as a path (not a link: adoption is per-project add-behaviour content). Conformance 79 -> 94, every new assertion injection-tested red-then-green. Gates: ts:check, lint, build, test (876), prettier, markdownlint — PASS. Refs: #402 --- ...26-07-31-pm-adapter-visibility-contract.md | 46 +++++++++ .../issue-management/azure-devops-issues.md | 5 + .../issue-management/github-issues.md | 6 +- .../issue-management/linear-issues.md | 3 + .../project-management-tool/README.md | 6 +- .../azure-devops-implementation.md | 2 + .../filesystem-implementation.md | 2 + .../github-implementation.md | 50 +++++++--- .../linear-implementation.md | 4 +- .../security/secure-development.md | 6 +- .../issue-management/azure-devops-issues.md | 5 + .../issue-management/github-issues.md | 6 +- .../issue-management/linear-issues.md | 3 + .../project-management-tool/README.md | 6 +- .../azure-devops-implementation.md | 2 + .../filesystem-implementation.md | 2 + .../github-implementation.md | 50 +++++++--- .../linear-implementation.md | 4 +- .../security/secure-development.md | 6 +- .../pm-tool-adapter-contract.test.ts | 99 +++++++++++++++++-- 20 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 .pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md diff --git a/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md b/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md new file mode 100644 index 00000000..475d6158 --- /dev/null +++ b/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md @@ -0,0 +1,46 @@ +# Decision: item visibility (board membership + assignee) is a gate-enforced PM-tool adapter contract, not a skill rule + +## Date + +2026-07-31 + +## Status + +Active + +## Category + +Convention Adoption + +## Context + +`way-of-working.md` › Assignment requires skills to set an item's assignee "as part of the write, never as a follow-up step". Nothing implemented it: `assignee` appeared zero times in `pair-capability-write-issue/SKILL.md` and zero times across every PM-tool implementation guide. Separately, `/write-issue` Step 7.3 writes the board status field "per the implementation guide", presuming the item was already in the tracked view — on GitHub Projects an issue and a project item are distinct objects, so the item lookup could yield an empty id and either fail hard (`gh: Could not resolve to a node with the global id of ''`) or, worse, be treated as "board write skipped" and reported as success. + +Observed 2026-07-30: issues #384 and #372 were open, assigned, green, and absent from the board entirely — invisible in the assignee-filtered view the team reads. No gate caught it. Two questions had to be settled before writing anything: **where** the fix lives, and **how** it stays true for adapters that do not exist yet. + +## Decision + +1. **The coverage lives in the adapters (`*-implementation.md`), not in the skills.** Each adapter carries a level-3 section `### Item Visibility: Membership and Assignee` stating (a) its board-membership semantics in the pinned form `Board membership is explicit …` / `Board membership is implicit …`, (b) the concrete assignee mechanic set as part of the create, (c) the unresolvable-assignee behaviour (report it, never drop it silently), and (d) for explicit-membership tools, that membership precedes the state write with an explicit branch for an empty lookup — never a silently skipped board write reported as success. + +2. **The contract is a stated, gate-enforced requirement**, recorded in `project-management-tool/README.md` › _Adapter Contract — Required Coverage_ and enforced by `packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts`. The guard is data-driven over the `*-implementation.md` files present in both corpora, so a new adapter is enrolled the moment its file lands and **no adapter count is asserted anywhere**. An omission reddens CI instead of surfacing in production. + +3. **The assignee stays out of the shared item templates.** `user-story-template.md` / `task-template.md` gain no `Assignee` slot: on every adapter but one the assignee is a native tracker field (GitHub `assignees`, Azure `--assigned-to`, Linear `assigneeId`), where a template slot would be dead weight and a second source of truth competing with the field the board filters on. The filesystem adapter — which has no native field — owns the convention itself and states the placement rule in its own visibility section. + +## Alternatives Considered + +- **A generic "add the item to the board first" rule in `/write-issue` (skill layer)**: Rejected. Membership-as-a-separate-step is **GitHub-specific**. On Azure Boards the work item belongs to its team project on create, on Linear `issueCreate` requires `teamId`, and in the filesystem adapter the file's location _is_ its membership — so the generic rule would be false for three adapters out of four, and would invite an agent to invent an `addProjectV2ItemById` equivalent that those tools have no concept of. It is also unnecessary: `/write-issue` Step 7.2 already delegates field mechanics to the guide, so a corrected guide is picked up through the existing delegation with **no skill edit**. This constrains #403: the skill half is limited to the tool-agnostic invariant (`$assignee` in the write contract, membership-precedes-state as a precondition, HALT on an unresolvable item) and never to a tool-shaped step. +- **Prose only, no conformance guard**: Rejected. The gap being closed existed _because_ an adoption rule had no enforcement — repeating that shape for the adapters would have re-created it one layer down. +- **A count-based conformance assertion** ("all four adapters carry the section"): Rejected. It reddens on adapter _addition_ rather than on adapter _omission_, and hardcodes a number that goes stale; disk discovery plus a non-empty guard gives the same protection without the count. +- **Adding an `Assignee` field to the shared templates**: Rejected, per decision 3 above. + +## Consequences + +- Adding a PM-tool adapter now requires its `Item Visibility: Membership and Assignee` section, at level 3, with the pinned sentence form — otherwise `pm-tool-adapter-contract.test.ts` fails. +- Adapters whose semantics the guard knows are pinned **by name** (`github` explicit; `azure-devops`, `linear`, `filesystem` implicit), so a regression that flips one cannot pass on the loose either-word assertion. +- `gh issue create` recipes are guarded KB-wide for `--assignee`, so the issue-management guides cannot drift back into a second, incomplete create recipe that contradicts the adapter. +- Implicit membership is documented as _not_ equivalent to "cannot be invisible": Azure's area path and, when the adoption names a project view, Linear's `projectId` remain part of visibility. + +## Adoption Impact + +- [project-management-tool/README.md](../../knowledge/guidelines/collaboration/project-management-tool/README.md): the Adapter Contract section references this ADL by path for the "why the adapter, not the skill" rationale. +- No dataset mirror of this file: sibling ADLs in `adoption/decision-log/` are adoption-only records — the dataset is a curated seed, not an auto-mirror of adoption, and a link into it from the shipped KB would break in a freshly seeded install (which is why the README cites the path rather than linking it). diff --git a/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md b/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md index a72283f4..97b5caea 100644 --- a/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md +++ b/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md @@ -22,6 +22,8 @@ Inherited processes with custom type names override this table in `way-of-workin ## Creating Work Items +> **The authoritative create recipe is the adapter's.** Every create below carries `--assigned-to`, because a work item without an assignee is open, green — and invisible in the assignee-filtered board view the team reads. The full mechanics (membership is implicit here, but the **area path** still decides whether the team's board shows the item; what to do when the assignee cannot be resolved) live in [azure-devops-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/azure-devops-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ### User Story (PBI) ```bash @@ -29,6 +31,7 @@ az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Body following the user-story-template]" \ + --assigned-to "[user@example.com]" \ --project ``` @@ -41,6 +44,7 @@ az boards work-item create \ --type "Task" \ --title "[Task title]" \ --description "[Body following the task-template]" \ + --assigned-to "[user@example.com]" \ --project ``` @@ -51,6 +55,7 @@ az boards work-item create \ --type "Bug" \ --title "[Bug title]" \ --description "[Reproduction steps, expected vs actual]" \ + --assigned-to "[user@example.com]" \ --project ``` diff --git a/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md b/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md index 7b7d729f..43093e69 100644 --- a/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md +++ b/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md @@ -6,6 +6,8 @@ GitHub Issues workflow and configuration for comprehensive issue management inte This guide covers GitHub Issues setup and workflows for managing user stories, tasks, bugs, and feature requests within the pair development framework. +> **The authoritative create recipe is the adapter's.** Every `gh issue create` below carries `--assignee` and `--project` because an issue without them is open, green — and invisible in the board and assignee-filtered views the team reads. The full mechanics (MCP has no project field, so the add-item step is mandatory after it; what to do when the assignee cannot be resolved) live in [github-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/github-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ## Issue Types and Labels ### Type Labels (Always Recommended) @@ -51,7 +53,7 @@ pair "Create a new user story: 'As a user, I want to [functionality] so that [be #### Via GitHub CLI: ```bash -gh issue create --title "User Story: [Title]" --body "[Description]" --label "user story,P1" +gh issue create --title "User Story: [Title]" --body "[Description]" --label "user story,P1" --assignee "[login]" --project "[project title]" ``` ### User Story Template @@ -99,7 +101,7 @@ pair "Break down user story #[story_number] into development tasks and create Gi #### Manual Task Creation: ```bash -gh issue create --title "Task: [Title]" --body "[Description]" --label "task,P1" --milestone "[Sprint]" +gh issue create --title "Task: [Title]" --body "[Description]" --label "task,P1" --milestone "[Sprint]" --assignee "[login]" --project "[project title]" ``` ### Task Template diff --git a/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md b/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md index aca5399d..0b034695 100644 --- a/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md +++ b/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md @@ -25,12 +25,15 @@ Labels are **per team**: resolve label ids for the adopted team before using the All examples use the GraphQL API through the **`linear_gql` helper** defined once in [linear-implementation.md → GraphQL API setup](../project-management-tool/linear-implementation.md#quick-setup) — endpoint and credentials live there, and the key is never passed inline on the command line. Define the helper before running anything below. With MCP, call the server's equivalent tool with the same arguments. +> **The authoritative create recipe is the adapter's.** Every `issueCreate` below carries `assigneeId`, because an issue without an assignee is open, green — and invisible in the assignee-filtered view the team reads. The full mechanics (resolving the user id, membership is implicit via `teamId`, when `projectId` becomes part of visibility, what to do when the assignee cannot be resolved) live in [linear-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/linear-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ### User Story ```bash linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { id identifier url } } }", "variables":{"i":{"teamId":"","title":"[Story title]", "description":"[Story body — markdown per user-story-template]", + "assigneeId":"", "parentId":"","labelIds":[""], "estimate":5,"priority":2}}}' ``` diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index e5a3afbc..9b881ab0 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -78,11 +78,13 @@ All implementation guides include: Every implementation guide in this directory is an **adapter**: skills stay tool-agnostic and delegate tool-specific mechanics here. An adapter that omits one of the following is incomplete, and the omission surfaces as an item that exists, is open, is green — and is invisible to the team. +Why this coverage lives in the **adapters** and not in the skills, and why the contract is gate-enforced rather than advisory, is recorded as an ADL in the adopting project's decision log — in this repository, `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. (Referenced as a path, not a link: the decision log is per-project `add`-behaviour content, so the file does not exist in a freshly seeded install.) + **Membership and assignee are independent, and both are required for visibility** — assigned but not a member is invisible on the board; a member but unassigned is invisible in the assignee-filtered view the team reads. Fixing one does not fix the other. -Every adapter therefore carries a section headed **`Item Visibility: Membership and Assignee`** documenting: +Every adapter therefore carries a **level-3** section headed **`### Item Visibility: Membership and Assignee`** — level 3 exactly, because the conformance guard locates the section by that heading level and reads only its body. Documenting: -1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. +1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. Write it in the pinned sentence form — **`Board membership is explicit …`** or **`Board membership is implicit …`** — the `Board` prefix included: the guard pins that whole phrase for every adapter whose semantics it knows, so `membership is implicit` without it reddens the gate. 2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. 3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. 4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index f6e30993..8e8aeaad 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -152,6 +152,8 @@ Azure Boards states differ per process and per team board columns. Skills resolv **Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. +**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So set the area path to one the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. + **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. ### Create diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 9adce94e..8a17a6b7 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -215,6 +215,8 @@ Status changes are reflected by moving files between directories: **Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. +**Why the slot is not in the shared templates** (a deliberate choice, not an oversight): on every other adapter the assignee is a **native tracker field** — GitHub's `assignees`, Azure's `--assigned-to`, Linear's `assigneeId` — so a template slot would be dead weight there, and a second source of truth competing with the field the board actually filters on. The templates therefore stay tool-neutral and this adapter owns the convention, which is why the placement rule is stated here and is looser than a template field would be. Recorded with the rest of the contract rationale in this repository's `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. + Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. ## Working with Initiatives diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 3dd51044..0b834036 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -210,8 +210,8 @@ gh project list --owner [ORG] # Passing it at create time is what makes the item a board member — # see "Item Visibility: Membership and Assignee" below for why that matters # and for the Step 2b path when this flag is not used. -gh issue create --project "[project title]" -gh pr create --project "[project title]" +gh issue create --assignee "[login]" --project "[project title]" +gh pr create --assignee "[login]" --project "[project title]" ``` ### API Integration @@ -464,6 +464,16 @@ gh api graphql -f query='{ }' ``` +**Branch on the discovery outcome — this is where "there is no board" is observed, before any item lookup:** + +| Discovery outcome | Meaning | Next | +| ---------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | +| A project id + status field id | The board exists | Step 2 | +| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | The membership step **no-ops** (see Step 2b) | +| The query **failed** (error, 404, permission denied) | Unknown — not evidence of absence | **Report it.** Never a no-op, never "board write skipped" | + +Keep the last two apart: "no project configured" is a **successful** query with an empty result; an error or a permission denial says nothing about whether the project exists, and treating it as absence is how a failed board write disappears into a green report. + #### Step 2: Find the Item ID for the Issue ```bash @@ -490,15 +500,16 @@ gh api graphql -f query='{ | ---------------------------- | ------------------------------------------ | ------------------------------------------- | | An item id | The issue is already a project item | Step 3 | | No match after the last page | The issue is **not a project item yet** | **Step 2b**, then Step 3 on the returned id | -| No project configured at all | Minimal board (D4) — nothing to write | No-op, not a failure (see below) | + +(The "no project configured at all" outcome is not reachable here — it is observed in **Step 1**, whose table above owns it.) Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with `gh: Could not resolve to a node with the global id of ''`. **And never treat the empty id as "board write skipped" and report success** — a silently skipped board write is exactly how an item ends up open, assigned, green, and absent from the board. Paginate to the last page before concluding "not an item": a match on page 3 that was never fetched looks identical to no match. **When the issue belongs to more than one project**: target the project named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). Never take the first project found. -#### Step 2b: Add the Issue as a Project Item (when Step 2 found nothing) +#### Step 2b: Add the Issue as a Project Item (idempotent — safe as an unconditional precondition) -Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. +Reached whenever Step 2 found nothing, and safe to run even when it found something (see **Idempotent** below) — so the conditional shape is an optimisation, never a requirement. Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. ```bash # Get the issue's node id, then add it to the project @@ -508,18 +519,29 @@ ISSUE_NODE_ID=$(gh api graphql -f query='{ } }' --jq '.data.repository.issue.id') -# Returns the item id — feed it straight into Step 3 -gh api graphql -f query="mutation { - addProjectV2ItemById(input: { - projectId: \"[PROJECT_ID]\" - contentId: \"$ISSUE_NODE_ID\" - }) { item { id } } -}" --jq '.data.addProjectV2ItemById.item.id' +# Abort if the lookup yielded nothing — same empty-id discipline as Step 3. +# An empty value would send contentId: "" and corrupt the mutation instead of failing. +[ -n "$ISSUE_NODE_ID" ] || { echo "issue node id lookup failed — aborting" >&2; exit 1; } + +# Parameterised, never interpolated into the query document: values travel as +# GraphQL variables (-F), so an unexpected value fails cleanly rather than +# rewriting the query. Returns the item id — feed it straight into Step 3. +gh api graphql \ + -f query='mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { + item { id } + } + }' \ + -F project="[PROJECT_ID]" \ + -F content="$ISSUE_NODE_ID" \ + --jq '.data.addProjectV2ItemById.item.id' ``` -**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. +**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. Prefer that unconditional shape: a Step 2 that returned a stale or partially-paginated result otherwise reaches Step 3 with no membership. + +**No project configured at all**: a project with **no board at all** has no field to write, so the membership step **no-ops** — it is **never a HALT** for a project that legitimately has no board. This is its own degradation, and it is **not** the one in [canonical-states.md](canonical-states.md): that document's write rule 5 **HALTs** when a board _exists_ but no board state maps to the target macrostate. Nor is it the D4 readiness fallback ([definition-of-ready-and-done.md](definition-of-ready-and-done.md)), which is a board that exists and lacks a _Ready column_. -**No project configured (minimal board, D4)**: the membership step **no-ops**, consistent with the existing "no board state maps to this macrostate" degradation in [canonical-states.md](canonical-states.md). It is never a HALT for a project that legitimately has no board. +The no-op applies **only** to the Step 1 outcome "discovery succeeded and returned no project". A discovery that **failed** — error, 404, permission denied — is **reported**, never absorbed as a no-op; otherwise this branch launders an error into a success and reproduces the very defect the empty-id rule above forbids. #### Step 3: Update the Status Field diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index 404a5d34..d15f5bf2 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -220,7 +220,9 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc | Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | | Board membership | not possible here — see below | -**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows *which* project view shows it, but it is a grouping within the team, never the thing that makes the issue visible. +**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible **in the team view**. + +**Implicit membership is not the same as "cannot be invisible."** When [way-of-working.md](../../../../adoption/tech/way-of-working.md) names a **project** view rather than the team view as the board the team reads, `projectId` becomes part of visibility and must be set on the create — an issue with the right `teamId` and no `projectId` is then created, assigned, and absent from the view the team actually reads, exactly as on GitHub. Read the adopted board from `way-of-working.md`; only when it is the team view is `projectId` genuinely optional. **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it **as part of the create**, never as a follow-up step: diff --git a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index cb06779b..593d3d69 100644 --- a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -367,10 +367,14 @@ case $SEVERITY in echo "⚠️ High priority security incident response..." # Create incident tracking issue + # --assignee is not optional: an unassigned incident issue is invisible + # in the assignee-filtered view the team reads (see the PM-tool adapter's + # "Item Visibility: Membership and Assignee" section). gh issue create \ --title "🚨 Security Incident: $INCIDENT_TYPE" \ --body "High priority security incident requiring immediate attention" \ - --label "security,incident,high-priority" + --label "security,incident,high-priority" \ + --assignee "$SECURITY_ONCALL" ;; esac diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md index b8202c0b..99af3af5 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md @@ -22,6 +22,8 @@ Inherited processes with custom type names override this table in `way-of-workin ## Creating Work Items +> **The authoritative create recipe is the adapter's.** Every create below carries `--assigned-to`, because a work item without an assignee is open, green — and invisible in the assignee-filtered board view the team reads. The full mechanics (membership is implicit here, but the **area path** still decides whether the team's board shows the item; what to do when the assignee cannot be resolved) live in [azure-devops-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/azure-devops-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ### User Story (PBI) ```bash @@ -29,6 +31,7 @@ az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Body following the user-story-template]" \ + --assigned-to "[user@example.com]" \ --project ``` @@ -41,6 +44,7 @@ az boards work-item create \ --type "Task" \ --title "[Task title]" \ --description "[Body following the task-template]" \ + --assigned-to "[user@example.com]" \ --project ``` @@ -51,6 +55,7 @@ az boards work-item create \ --type "Bug" \ --title "[Bug title]" \ --description "[Reproduction steps, expected vs actual]" \ + --assigned-to "[user@example.com]" \ --project ``` diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md index 7b7d729f..43093e69 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/github-issues.md @@ -6,6 +6,8 @@ GitHub Issues workflow and configuration for comprehensive issue management inte This guide covers GitHub Issues setup and workflows for managing user stories, tasks, bugs, and feature requests within the pair development framework. +> **The authoritative create recipe is the adapter's.** Every `gh issue create` below carries `--assignee` and `--project` because an issue without them is open, green — and invisible in the board and assignee-filtered views the team reads. The full mechanics (MCP has no project field, so the add-item step is mandatory after it; what to do when the assignee cannot be resolved) live in [github-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/github-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ## Issue Types and Labels ### Type Labels (Always Recommended) @@ -51,7 +53,7 @@ pair "Create a new user story: 'As a user, I want to [functionality] so that [be #### Via GitHub CLI: ```bash -gh issue create --title "User Story: [Title]" --body "[Description]" --label "user story,P1" +gh issue create --title "User Story: [Title]" --body "[Description]" --label "user story,P1" --assignee "[login]" --project "[project title]" ``` ### User Story Template @@ -99,7 +101,7 @@ pair "Break down user story #[story_number] into development tasks and create Gi #### Manual Task Creation: ```bash -gh issue create --title "Task: [Title]" --body "[Description]" --label "task,P1" --milestone "[Sprint]" +gh issue create --title "Task: [Title]" --body "[Description]" --label "task,P1" --milestone "[Sprint]" --assignee "[login]" --project "[project title]" ``` ### Task Template diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md index 94e10a15..a25b4ccf 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md @@ -25,12 +25,15 @@ Labels are **per team**: resolve label ids for the adopted team before using the All examples use the GraphQL API through the **`linear_gql` helper** defined once in [linear-implementation.md → GraphQL API setup](../project-management-tool/linear-implementation.md#quick-setup) — endpoint and credentials live there, and the key is never passed inline on the command line. Define the helper before running anything below. With MCP, call the server's equivalent tool with the same arguments. +> **The authoritative create recipe is the adapter's.** Every `issueCreate` below carries `assigneeId`, because an issue without an assignee is open, green — and invisible in the assignee-filtered view the team reads. The full mechanics (resolving the user id, membership is implicit via `teamId`, when `projectId` becomes part of visibility, what to do when the assignee cannot be resolved) live in [linear-implementation.md → Item Visibility: Membership and Assignee](../project-management-tool/linear-implementation.md#item-visibility-membership-and-assignee). The snippets here are abbreviations of it, never an alternative to it. + ### User Story ```bash linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { id identifier url } } }", "variables":{"i":{"teamId":"","title":"[Story title]", "description":"[Story body — markdown per user-story-template]", + "assigneeId":"", "parentId":"","labelIds":[""], "estimate":5,"priority":2}}}' ``` diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index 8a2c4b46..ee8379b8 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -78,11 +78,13 @@ All implementation guides include: Every implementation guide in this directory is an **adapter**: skills stay tool-agnostic and delegate tool-specific mechanics here. An adapter that omits one of the following is incomplete, and the omission surfaces as an item that exists, is open, is green — and is invisible to the team. +Why this coverage lives in the **adapters** and not in the skills, and why the contract is gate-enforced rather than advisory, is recorded as an ADL in the adopting project's decision log — in this repository, `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. (Referenced as a path, not a link: the decision log is per-project `add`-behaviour content, so the file does not exist in a freshly seeded install.) + **Membership and assignee are independent, and both are required for visibility** — assigned but not a member is invisible on the board; a member but unassigned is invisible in the assignee-filtered view the team reads. Fixing one does not fix the other. -Every adapter therefore carries a section headed **`Item Visibility: Membership and Assignee`** documenting: +Every adapter therefore carries a **level-3** section headed **`### Item Visibility: Membership and Assignee`** — level 3 exactly, because the conformance guard locates the section by that heading level and reads only its body. Documenting: -1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. +1. **Board membership semantics** — whether membership in the tracked view is **explicit** (a separate call, e.g. GitHub Projects' `addProjectV2ItemById`, because an issue and a project item are distinct objects) or **implicit** (creating the item is its membership, e.g. Azure Boards' team project, or the file's location in a filesystem backlog). State it either way, in the words `membership is explicit` / `membership is implicit`: **silence is what makes an agent invent an add-item step that does not exist for that tool**, or skip one that does. Write it in the pinned sentence form — **`Board membership is explicit …`** or **`Board membership is implicit …`** — the `Board` prefix included: the guard pins that whole phrase for every adapter whose semantics it knows, so `membership is implicit` without it reddens the gate. 2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. 3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. 4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index 420a6096..17d32f04 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -152,6 +152,8 @@ Azure Boards states differ per process and per team board columns. Skills resolv **Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. +**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So set the area path to one the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. + **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. ### Create diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 20876a7b..30d190d9 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -215,6 +215,8 @@ Status changes are reflected by moving files between directories: **Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. +**Why the slot is not in the shared templates** (a deliberate choice, not an oversight): on every other adapter the assignee is a **native tracker field** — GitHub's `assignees`, Azure's `--assigned-to`, Linear's `assigneeId` — so a template slot would be dead weight there, and a second source of truth competing with the field the board actually filters on. The templates therefore stay tool-neutral and this adapter owns the convention, which is why the placement rule is stated here and is looser than a template field would be. Recorded with the rest of the contract rationale in this repository's `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. + Grep is the filtered view: `grep -rl 'Assignee.*' .pair/adoption/product/backlog/` — which is exactly why an item with the line missing is invisible. **If the assignee cannot be resolved**: **report it** — never drop it silently by writing the file with the line omitted or left as a placeholder. ## Working with Initiatives diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 98116b47..6abb812f 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -210,8 +210,8 @@ gh project list --owner [ORG] # Passing it at create time is what makes the item a board member — # see "Item Visibility: Membership and Assignee" below for why that matters # and for the Step 2b path when this flag is not used. -gh issue create --project "[project title]" -gh pr create --project "[project title]" +gh issue create --assignee "[login]" --project "[project title]" +gh pr create --assignee "[login]" --project "[project title]" ``` ### API Integration @@ -464,6 +464,16 @@ gh api graphql -f query='{ }' ``` +**Branch on the discovery outcome — this is where "there is no board" is observed, before any item lookup:** + +| Discovery outcome | Meaning | Next | +| ---------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | +| A project id + status field id | The board exists | Step 2 | +| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | The membership step **no-ops** (see Step 2b) | +| The query **failed** (error, 404, permission denied) | Unknown — not evidence of absence | **Report it.** Never a no-op, never "board write skipped" | + +Keep the last two apart: "no project configured" is a **successful** query with an empty result; an error or a permission denial says nothing about whether the project exists, and treating it as absence is how a failed board write disappears into a green report. + #### Step 2: Find the Item ID for the Issue ```bash @@ -490,15 +500,16 @@ gh api graphql -f query='{ | ---------------------------- | ------------------------------------------ | ------------------------------------------- | | An item id | The issue is already a project item | Step 3 | | No match after the last page | The issue is **not a project item yet** | **Step 2b**, then Step 3 on the returned id | -| No project configured at all | Minimal board (D4) — nothing to write | No-op, not a failure (see below) | + +(The "no project configured at all" outcome is not reachable here — it is observed in **Step 1**, whose table above owns it.) Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with `gh: Could not resolve to a node with the global id of ''`. **And never treat the empty id as "board write skipped" and report success** — a silently skipped board write is exactly how an item ends up open, assigned, green, and absent from the board. Paginate to the last page before concluding "not an item": a match on page 3 that was never fetched looks identical to no match. **When the issue belongs to more than one project**: target the project named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). Never take the first project found. -#### Step 2b: Add the Issue as a Project Item (when Step 2 found nothing) +#### Step 2b: Add the Issue as a Project Item (idempotent — safe as an unconditional precondition) -Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. +Reached whenever Step 2 found nothing, and safe to run even when it found something (see **Idempotent** below) — so the conditional shape is an optimisation, never a requirement. Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. ```bash # Get the issue's node id, then add it to the project @@ -508,18 +519,29 @@ ISSUE_NODE_ID=$(gh api graphql -f query='{ } }' --jq '.data.repository.issue.id') -# Returns the item id — feed it straight into Step 3 -gh api graphql -f query="mutation { - addProjectV2ItemById(input: { - projectId: \"[PROJECT_ID]\" - contentId: \"$ISSUE_NODE_ID\" - }) { item { id } } -}" --jq '.data.addProjectV2ItemById.item.id' +# Abort if the lookup yielded nothing — same empty-id discipline as Step 3. +# An empty value would send contentId: "" and corrupt the mutation instead of failing. +[ -n "$ISSUE_NODE_ID" ] || { echo "issue node id lookup failed — aborting" >&2; exit 1; } + +# Parameterised, never interpolated into the query document: values travel as +# GraphQL variables (-F), so an unexpected value fails cleanly rather than +# rewriting the query. Returns the item id — feed it straight into Step 3. +gh api graphql \ + -f query='mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { + item { id } + } + }' \ + -F project="[PROJECT_ID]" \ + -F content="$ISSUE_NODE_ID" \ + --jq '.data.addProjectV2ItemById.item.id' ``` -**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. +**Idempotent**: `addProjectV2ItemById` on an issue that is already an item returns that existing item instead of duplicating it, so this step is safe to run unconditionally — including as a precondition of every status write, without a preceding existence check. Prefer that unconditional shape: a Step 2 that returned a stale or partially-paginated result otherwise reaches Step 3 with no membership. + +**No project configured at all**: a project with **no board at all** has no field to write, so the membership step **no-ops** — it is **never a HALT** for a project that legitimately has no board. This is its own degradation, and it is **not** the one in [canonical-states.md](canonical-states.md): that document's write rule 5 **HALTs** when a board _exists_ but no board state maps to the target macrostate. Nor is it the D4 readiness fallback ([definition-of-ready-and-done.md](definition-of-ready-and-done.md)), which is a board that exists and lacks a _Ready column_. -**No project configured (minimal board, D4)**: the membership step **no-ops**, consistent with the existing "no board state maps to this macrostate" degradation in [canonical-states.md](canonical-states.md). It is never a HALT for a project that legitimately has no board. +The no-op applies **only** to the Step 1 outcome "discovery succeeded and returned no project". A discovery that **failed** — error, 404, permission denied — is **reported**, never absorbed as a no-op; otherwise this branch launders an error into a success and reproduces the very defect the empty-id rule above forbids. #### Step 3: Update the Status Field diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index f35eb9d8..85ab0201 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -220,7 +220,9 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc | Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | | Board membership | not possible here — see below | -**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible. +**Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible **in the team view**. + +**Implicit membership is not the same as "cannot be invisible."** When [way-of-working.md](../../../../adoption/tech/way-of-working.md) names a **project** view rather than the team view as the board the team reads, `projectId` becomes part of visibility and must be set on the create — an issue with the right `teamId` and no `projectId` is then created, assigned, and absent from the view the team actually reads, exactly as on GitHub. Read the adopted board from `way-of-working.md`; only when it is the team view is `projectId` genuinely optional. **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it **as part of the create**, never as a follow-up step: diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index cb06779b..593d3d69 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -367,10 +367,14 @@ case $SEVERITY in echo "⚠️ High priority security incident response..." # Create incident tracking issue + # --assignee is not optional: an unassigned incident issue is invisible + # in the assignee-filtered view the team reads (see the PM-tool adapter's + # "Item Visibility: Membership and Assignee" section). gh issue create \ --title "🚨 Security Incident: $INCIDENT_TYPE" \ --body "High priority security incident requiring immediate attention" \ - --label "security,incident,high-priority" + --label "security,incident,high-priority" \ + --assignee "$SECURITY_ONCALL" ;; esac diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts index fe3a606b..22c6584f 100644 --- a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -13,8 +13,8 @@ import { join } from 'path' * * The invariant: an item is only visible when it has BOTH board membership and * an assignee, and the two are independent. Membership semantics are per-tool - * (explicit on GitHub Projects, implicit on Azure Boards and on a filesystem - * backlog), so the mechanics live in the adapters and the skills stay + * (explicit on GitHub Projects; implicit on Azure Boards, on Linear, and on a + * filesystem backlog), so the mechanics live in the adapters and the skills stay * tool-agnostic. Observed 2026-07-30: #384 and #372 were open, assigned, green, * and absent from the board entirely — no gate caught it. * @@ -22,10 +22,15 @@ import { join } from 'path' * from disk (`*-implementation.md`) in BOTH corpora — the dataset (authoring * source of truth) and the generated root mirror. A new adapter is enrolled in * the contract the moment its file lands; nothing here needs editing, and no - * assertion breaks because the set grew. When `linear-implementation.md` arrives - * (#389) it is enrolled automatically — supplying its two sections is #403's - * job, and this guard going red is precisely how that omission stays visible - * instead of shipping as another invisible-item bug. + * assertion breaks because the set grew. + * + * `linear-implementation.md` (#389, merged as 2da33a88) is enrolled and COVERED + * HERE: it landed in this same story, since #389 merging first put the obligation + * on #402 rather than on #403. Its semantics are implicit — `issueCreate` + * requires `teamId`, so an issue cannot exist without belonging to a team — and + * they are pinned by name in KNOWN_SEMANTICS below. #403 retains only the + * skill-contract half of the original split ($assignee in /write-issue's + * parameters, plus the tool-agnostic membership-precedes-state invariant). */ const REL = '.pair/knowledge/guidelines/collaboration/project-management-tool' @@ -125,6 +130,11 @@ describe('PM-tool adapter contract — every adapter present documents visibilit { file: 'github-implementation.md', word: 'explicit' }, { file: 'azure-devops-implementation.md', word: 'implicit' }, { file: 'filesystem-implementation.md', word: 'implicit' }, + // Linear is a known adapter with stated semantics as of this story: `issueCreate` + // requires `teamId`, so membership cannot be a separate call. Unpinned, a flip to + // "explicit" stays green and tells an agent to invent an `addProjectV2ItemById` + // equivalent Linear has no concept of — the precise AC4 failure. + { file: 'linear-implementation.md', word: 'implicit' }, ] const knownCases = CORPORA.flatMap(({ label, dir }) => KNOWN_SEMANTICS.filter(k => existsSync(join(dir, k.file))).map(k => ({ @@ -198,6 +208,25 @@ describe('PM-tool README — the adapter contract is a stated requirement (#402 expect(body).toContain('as part of the create, never as a follow-up step') expect(body).toContain('if the assignee cannot be resolved') expect(body).toContain('never drop it silently') + // Clause 4 — the one that encodes AC1+AC2 for every FUTURE adapter. It was + // unguarded while the test name already claimed it: deleting the whole clause + // from both corpora left the suite green. + expect(body).toContain('membership precedes the state write') + expect(body).toContain('never a silently skipped board write reported as success') + }, + ) + + it.each(readmeCases)( + '$corpus: states the heading level and the pinned membership sentence form', + ({ content }) => { + const body = normalize(content) + // The prose used to be looser than the gate: `section()` matches `### ` + // only, and KNOWN_SEMANTICS pins `board membership is `. An author writing + // `## Item Visibility…` or `membership is implicit` without the `Board` prefix + // satisfied the contract as written and reddened CI with a non-obvious message. + expect(body).toContain(`level-3 section headed ### ${VISIBILITY_HEADING}`.toLowerCase()) + expect(body).toContain('board membership is explicit') + expect(body).toContain('board membership is implicit') }, ) @@ -346,3 +375,61 @@ describe('github-implementation.md — assignee is part of the write (#402 AC3)' }, ) }) + +/** + * The adapter is authoritative, but it is not the only place the KB teaches + * `gh issue create`. `issue-management/github-issues.md` carried two create + * recipes with neither `--assignee` nor `--project` — the exact item this story + * exists to forbid — and an agent reaching them by index or grep saw nothing + * pointing back at the adapter. Two contradictory recipes per tool is worse than + * one incomplete one, so the invariant is enforced KB-wide rather than per file. + * + * Whole-command discipline, same as the adapter assertions above: the flag must + * be on the `gh issue create` invocation itself, not merely somewhere in the file + * (a neighbouring `gh pr create --assignee` satisfied the naive form). + */ +const KB_ROOTS = [ + { label: 'dataset', dir: join(__dirname, '../../dataset/.pair/knowledge') }, + { label: 'generated root', dir: join(__dirname, '../../../../.pair/knowledge') }, +] + +function markdownFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return markdownFiles(path) + return entry.name.endsWith('.md') ? [path] : [] + }) +} + +/** Joins backslash continuations so a multi-line `gh issue create` reads as one command. */ +function logicalLines(markdown: string): string[] { + return markdown.replace(/\\\n\s*/g, ' ').split('\n') +} + +const createRecipeCases = KB_ROOTS.flatMap(({ label, dir }) => + markdownFiles(dir).flatMap(path => + logicalLines(readFileSync(path, 'utf-8')) + .filter(line => line.includes('gh issue create ')) + .map((command, index) => ({ + corpus: label, + file: path.slice(dir.length + 1), + occurrence: index + 1, + command, + })), + ), +) + +describe('KB-wide — no issue-create recipe omits the assignee (#402)', () => { + it('finds the create recipes it means to guard', () => { + // Non-empty guard, not a count: if the discovery breaks, the cases below would + // pass vacuously over an empty list. + expect(createRecipeCases.length).toBeGreaterThan(0) + }) + + it.each(createRecipeCases)( + '$corpus/$file recipe $occurrence sets --assignee on the create itself', + ({ command }) => { + expect(command).toMatch(/gh issue create\b[^\n]*--assignee\b/) + }, + ) +}) From 33d5a973d4100dfc62ef1a100dfa90d234e76d79 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 19:27:35 +0200 Subject: [PATCH 6/8] =?UTF-8?q?[US-402]=20fix:=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20project=20scope=20documented,=20adapter=20create=20?= =?UTF-8?q?recipes=20carry=20the=20visibility=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - github: the `project` OAuth scope (gh auth refresh -s project) + the rule that a missing scope is reported, never worked around by dropping --project - github: "(or number)" dropped — gh 2.97 documents `-p, --project title` only - github: Step 1's no-project row now states its terminal state (skip Steps 2-3) - azure: --area on the create recipe; linear: projectId on the create, table row no longer contradicts its own body; linear ### Create carries assigneeId - secure-development: SECURITY_ONCALL declared beside the positional inputs - guard: KB-wide create families for az + linear (not gh only), discovery limited to fenced command-shaped lines, and the prose-only no-op/caveat rules pinned Co-Authored-By: Claude Opus 5 --- .../azure-devops-implementation.md | 5 +- .../github-implementation.md | 18 +- .../linear-implementation.md | 10 +- .../security/secure-development.md | 4 + .../azure-devops-implementation.md | 5 +- .../github-implementation.md | 18 +- .../linear-implementation.md | 10 +- .../security/secure-development.md | 4 + .../pm-tool-adapter-contract.test.ts | 267 ++++++++++++++++-- 9 files changed, 296 insertions(+), 45 deletions(-) diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index 8e8aeaad..9c4fdb9a 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -152,7 +152,7 @@ Azure Boards states differ per process and per team board columns. Skills resolv **Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. -**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So set the area path to one the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. +**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So pass `--area` on the create with a path the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. @@ -160,11 +160,14 @@ Azure Boards states differ per process and per team board columns. Skills resolv ```bash # Create a user story (PBI in Scrum process) — assignee set as part of the create +# --area: the team's board reads only its configured area paths, so an item +# outside them is created, assigned, and absent from that board. az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Story body — markdown per user-story-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project # Existing work item: set or correct the assignee diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 0b834036..558a8038 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -206,7 +206,9 @@ npm install -g @github/github-mcp-server # Fallback commands when MCP unavailable gh project list --owner [ORG] -# --project takes the project's TITLE (or number), never its node ID. +# --project takes the project's TITLE, never its node ID and never its number +# (gh 2.97 documents "-p, --project title"; numbers belong to `gh project`). +# Both flags need the `project` OAuth scope: gh auth refresh -s project # Passing it at create time is what makes the item a board member — # see "Item Visibility: Membership and Assignee" below for why that matters # and for the Step 2b path when this flag is not used. @@ -401,6 +403,10 @@ mcp__github__issue_write: **Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**. `mcp__github__issue_write` has **no project field at all**, and `gh issue create` produces only the issue **unless you pass `--project`**. Either way membership is a separate decision, made by `--project` at create time or by `addProjectV2ItemById` afterwards (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. +**Both membership writes need the `project` OAuth scope.** `--project` (on `gh issue create`, `gh pr create`, `gh issue edit --add-project`) and the `addProjectV2ItemById` mutation are both refused by a default `gh auth login` token: `your authentication token is missing required scopes [project]`. Grant it once with `gh auth refresh -s project` — the same scope the `projectV2` queries in Steps 1-3 need, and the same one the token the MCP server runs with must carry. `gh` resolves the project **before** it creates the issue, so a missing scope fails the whole command and **no issue is created**: nothing is half-written, and re-running after the refresh is safe. + +**A missing-scope error is reported, never worked around by dropping `--project`.** Retrying without the flag succeeds and lands on exactly the defect this section exists to remove — open, assigned, green, off the board. Same discipline as the status write below: report it, never silently degrade to a create that skips membership. + The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. #### Create an Issue with its Assignee and its Membership @@ -422,8 +428,10 @@ mcp__github__issue_write: ``` ```bash -# CLI fallback — one shot: --project takes the project's TITLE (or number), -# never its node ID, and the project is the one named in way-of-working.md. +# CLI fallback — one shot: --project takes the project's TITLE, never its node +# ID and never its number, and the project is the one named in way-of-working.md. +# Needs the `project` OAuth scope (gh auth refresh -s project); without it the +# command fails and creates NOTHING — report that, never retry without --project. gh issue create --title "[title]" --body-file [file] --assignee "[login]" --project "[project title]" # Without --project (or after any MCP create): membership is still missing. @@ -469,7 +477,7 @@ gh api graphql -f query='{ | Discovery outcome | Meaning | Next | | ---------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | | A project id + status field id | The board exists | Step 2 | -| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | The membership step **no-ops** (see Step 2b) | +| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | **Skip Steps 2-3** — there is no board field to write; the membership step **no-ops** (see Step 2b) | | The query **failed** (error, 404, permission denied) | Unknown — not evidence of absence | **Report it.** Never a no-op, never "board write skipped" | Keep the last two apart: "no project configured" is a **successful** query with an empty result; an error or a permission denial says nothing about whether the project exists, and treating it as absence is how a failed board write disappears into a green report. @@ -526,6 +534,8 @@ ISSUE_NODE_ID=$(gh api graphql -f query='{ # Parameterised, never interpolated into the query document: values travel as # GraphQL variables (-F), so an unexpected value fails cleanly rather than # rewriting the query. Returns the item id — feed it straight into Step 3. +# Needs the `project` OAuth scope, exactly like --project on the create: +# gh auth refresh -s project. A missing scope is reported, never skipped. gh api graphql \ -f query='mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index d15f5bf2..5730749e 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -203,9 +203,12 @@ GraphQL examples below; with MCP, use the server's equivalent tool with the same ### Create ```bash +# assigneeId is part of the create, never a follow-up; add projectId too when the +# adopted board is a project view — see Item Visibility: Membership and Assignee. linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { id identifier url } } }", "variables":{"i":{"teamId":"","title":"[Story title]", "description":"[Story body — markdown per user-story-template]", + "assigneeId":"", "labelIds":[""],"estimate":5,"priority":2}}}' ``` @@ -218,7 +221,7 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc | Missing | Symptom | | ------------------ | ---------------------------------------------------------------------------------- | | Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | -| Board membership | not possible here — see below | +| Board membership | not possible in the team view; when the adopted board is a project view, a missing `projectId` reproduces it — see below | **Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible **in the team view**. @@ -230,8 +233,11 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc # Create with the assignee — resolve the user id first linear_gql '{"query":"{ users(filter:{isMe:{eq:true}}) { nodes { id name } } }"}' +# projectId is required when way-of-working.md names a project view, not the team +# view — without it the issue is created, assigned, and absent from that board. linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { identifier } } }", - "variables":{"i":{"teamId":"","title":"[title]","assigneeId":""}}}' + "variables":{"i":{"teamId":"","title":"[title]","assigneeId":"", + "projectId":""}}}' # Existing issue — assigning is idempotent, so it is safe to run unconditionally linear_gql '{"query":"mutation($id:String!,$i:IssueUpdateInput!){ issueUpdate(id:$id,input:$i){ success } }", diff --git a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index 593d3d69..b5dec81c 100644 --- a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -343,6 +343,10 @@ jobs: INCIDENT_TYPE=$1 SEVERITY=$2 +# Login the incident issue is assigned to. Declared here (not left to the +# environment) because an unset value sends --assignee "" and gh then fails the +# create outright — the incident issue would not exist at all. +SECURITY_ONCALL=${SECURITY_ONCALL:-@me} echo "🚨 Security incident detected: $INCIDENT_TYPE (Severity: $SEVERITY)" diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md index 17d32f04..da43208a 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/azure-devops-implementation.md @@ -152,7 +152,7 @@ Azure Boards states differ per process and per team board columns. Skills resolv **Board membership is implicit on Azure Boards.** A work item belongs to its team project the moment it is created (`--project`), and the board is a view over the project's work items — there is **no separate add-to-board call**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, here creating the work item **is** its membership. Area path and iteration path refine _which_ board view shows it, they do not grant membership. -**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So set the area path to one the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. +**Implicit membership is not the same as "cannot be invisible."** A work item whose **area path** falls outside the area paths configured for the team (Project settings → Team configuration → Areas) is genuinely absent from that team's board — created, assigned, and invisible in the view the team actually reads. So pass `--area` on the create with a path the team's board covers, taking the team named in [way-of-working.md](../../../../adoption/tech/way-of-working.md). This is the Azure analogue of the GitHub membership defect: the field write itself cannot fail, but the item can still land outside the read view. **The assignee is not implicit** and is still required — the board is read filtered by assignee (Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md)). Set it via `--assigned-to` as part of the create, never as a follow-up step. **If the assignee cannot be resolved** (not a project member, AAD/licensing restriction): **report it** — never drop it silently, which leaves the item invisible in the filtered view. @@ -160,11 +160,14 @@ Azure Boards states differ per process and per team board columns. Skills resolv ```bash # Create a user story (PBI in Scrum process) — assignee set as part of the create +# --area: the team's board reads only its configured area paths, so an item +# outside them is created, assigned, and absent from that board. az boards work-item create \ --type "Product Backlog Item" \ --title "[Story title]" \ --description "[Story body — markdown per user-story-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project # Existing work item: set or correct the assignee diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 6abb812f..c648f693 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -206,7 +206,9 @@ npm install -g @github/github-mcp-server # Fallback commands when MCP unavailable gh project list --owner [ORG] -# --project takes the project's TITLE (or number), never its node ID. +# --project takes the project's TITLE, never its node ID and never its number +# (gh 2.97 documents "-p, --project title"; numbers belong to `gh project`). +# Both flags need the `project` OAuth scope: gh auth refresh -s project # Passing it at create time is what makes the item a board member — # see "Item Visibility: Membership and Assignee" below for why that matters # and for the Step 2b path when this flag is not used. @@ -401,6 +403,10 @@ mcp__github__issue_write: **Board membership is explicit on GitHub**: an issue and a project item are **distinct objects**. `mcp__github__issue_write` has **no project field at all**, and `gh issue create` produces only the issue **unless you pass `--project`**. Either way membership is a separate decision, made by `--project` at create time or by `addProjectV2ItemById` afterwards (Step 2b below). This is tool-specific — on tools where membership is implicit, the guide says so; never assume the GitHub shape elsewhere. +**Both membership writes need the `project` OAuth scope.** `--project` (on `gh issue create`, `gh pr create`, `gh issue edit --add-project`) and the `addProjectV2ItemById` mutation are both refused by a default `gh auth login` token: `your authentication token is missing required scopes [project]`. Grant it once with `gh auth refresh -s project` — the same scope the `projectV2` queries in Steps 1-3 need, and the same one the token the MCP server runs with must carry. `gh` resolves the project **before** it creates the issue, so a missing scope fails the whole command and **no issue is created**: nothing is half-written, and re-running after the refresh is safe. + +**A missing-scope error is reported, never worked around by dropping `--project`.** Retrying without the flag succeeds and lands on exactly the defect this section exists to remove — open, assigned, green, off the board. Same discipline as the status write below: report it, never silently degrade to a create that skips membership. + The assignee is required by the Assignment rule in [way-of-working.md](../../../../adoption/tech/way-of-working.md): the board is read filtered by assignee. Set it **as part of the create**, never as a follow-up step. #### Create an Issue with its Assignee and its Membership @@ -422,8 +428,10 @@ mcp__github__issue_write: ``` ```bash -# CLI fallback — one shot: --project takes the project's TITLE (or number), -# never its node ID, and the project is the one named in way-of-working.md. +# CLI fallback — one shot: --project takes the project's TITLE, never its node +# ID and never its number, and the project is the one named in way-of-working.md. +# Needs the `project` OAuth scope (gh auth refresh -s project); without it the +# command fails and creates NOTHING — report that, never retry without --project. gh issue create --title "[title]" --body-file [file] --assignee "[login]" --project "[project title]" # Without --project (or after any MCP create): membership is still missing. @@ -469,7 +477,7 @@ gh api graphql -f query='{ | Discovery outcome | Meaning | Next | | ---------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | | A project id + status field id | The board exists | Step 2 | -| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | The membership step **no-ops** (see Step 2b) | +| The query **succeeded** and returned **no project** | No project is configured — there is no board field to write at all | **Skip Steps 2-3** — there is no board field to write; the membership step **no-ops** (see Step 2b) | | The query **failed** (error, 404, permission denied) | Unknown — not evidence of absence | **Report it.** Never a no-op, never "board write skipped" | Keep the last two apart: "no project configured" is a **successful** query with an empty result; an error or a permission denial says nothing about whether the project exists, and treating it as absence is how a failed board write disappears into a green report. @@ -526,6 +534,8 @@ ISSUE_NODE_ID=$(gh api graphql -f query='{ # Parameterised, never interpolated into the query document: values travel as # GraphQL variables (-F), so an unexpected value fails cleanly rather than # rewriting the query. Returns the item id — feed it straight into Step 3. +# Needs the `project` OAuth scope, exactly like --project on the create: +# gh auth refresh -s project. A missing scope is reported, never skipped. gh api graphql \ -f query='mutation($project: ID!, $content: ID!) { addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md index 85ab0201..d4a1f686 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/linear-implementation.md @@ -203,9 +203,12 @@ GraphQL examples below; with MCP, use the server's equivalent tool with the same ### Create ```bash +# assigneeId is part of the create, never a follow-up; add projectId too when the +# adopted board is a project view — see Item Visibility: Membership and Assignee. linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { id identifier url } } }", "variables":{"i":{"teamId":"","title":"[Story title]", "description":"[Story body — markdown per user-story-template]", + "assigneeId":"", "labelIds":[""],"estimate":5,"priority":2}}}' ``` @@ -218,7 +221,7 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc | Missing | Symptom | | ------------------ | ---------------------------------------------------------------------------------- | | Assignee | Open, in the team's view, green — and absent from the assignee-filtered view teams read | -| Board membership | not possible here — see below | +| Board membership | not possible in the team view; when the adopted board is a project view, a missing `projectId` reproduces it — see below | **Board membership is implicit on Linear — an issue always belongs to a team.** `issueCreate` requires `teamId`, so an issue cannot exist without membership; there is **no separate add-to-board step**. Do not invent one: unlike GitHub Projects, where an issue and a project item are distinct objects requiring an explicit `addProjectV2ItemById`, a Linear issue is a member of its team the moment it exists. Consequently a state write can never fail for "not a member yet". Optional `projectId` narrows _which_ project view shows it, but it is a grouping within the team, never the thing that makes the issue visible **in the team view**. @@ -230,8 +233,11 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc # Create with the assignee — resolve the user id first linear_gql '{"query":"{ users(filter:{isMe:{eq:true}}) { nodes { id name } } }"}' +# projectId is required when way-of-working.md names a project view, not the team +# view — without it the issue is created, assigned, and absent from that board. linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ success issue { identifier } } }", - "variables":{"i":{"teamId":"","title":"[title]","assigneeId":""}}}' + "variables":{"i":{"teamId":"","title":"[title]","assigneeId":"", + "projectId":""}}}' # Existing issue — assigning is idempotent, so it is safe to run unconditionally linear_gql '{"query":"mutation($id:String!,$i:IssueUpdateInput!){ issueUpdate(id:$id,input:$i){ success } }", diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index 593d3d69..b5dec81c 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -343,6 +343,10 @@ jobs: INCIDENT_TYPE=$1 SEVERITY=$2 +# Login the incident issue is assigned to. Declared here (not left to the +# environment) because an unset value sends --assignee "" and gh then fails the +# create outright — the incident issue would not exist at all. +SECURITY_ONCALL=${SECURITY_ONCALL:-@me} echo "🚨 Security incident detected: $INCIDENT_TYPE (Severity: $SEVERITY)" diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts index 22c6584f..c5f142af 100644 --- a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -159,6 +159,28 @@ describe('PM-tool adapter contract — every adapter present documents visibilit }, ) + // Round-3 finding: the "implicit membership is not the same as 'cannot be + // invisible'" caveat was prose-only in both implicit-membership adapters — + // deleting it from both corpora left the suite green. It is the sentence that + // stops an agent reading "implicit" as "nothing else can hide the item". + const IMPLICIT_CAVEAT_FILES = ['azure-devops-implementation.md', 'linear-implementation.md'] + const caveatCases = CORPORA.flatMap(({ label, dir }) => + IMPLICIT_CAVEAT_FILES.filter(file => existsSync(join(dir, file))).map(file => ({ + corpus: label, + file, + content: readFileSync(join(dir, file), 'utf-8'), + })), + ) + + it.each(caveatCases)( + '$corpus/$file states implicit membership still allows an invisible item', + ({ content }) => { + expect(normalize(section(content, VISIBILITY_HEADING))).toContain( + 'implicit membership is not the same as', + ) + }, + ) + it.each(adapterCases)( '$corpus/$file sets the assignee as part of the create, not as a follow-up (AC3)', ({ content }) => { @@ -371,22 +393,70 @@ describe('github-implementation.md — assignee is part of the write (#402 AC3)' // could never match. That exact vacuity was caught by injection-testing this // guard — the regression was reinstated and the suite stayed green. expect(content).not.toMatch(/gh (issue|pr) create --project \[PROJECT_ID\]/i) - expect(normalize(content)).toContain("takes the project's title (or number), never its node") + // "(or number)" was dropped: gh 2.97.0 documents `-p, --project title` (title + // only, identically for `gh pr create` and `gh issue edit --add-project`); the + // numeric form belongs to the separate `gh project` commands. The pinned half + // is the never-a-node-ID one, which is what an agent gets wrong. + expect(normalize(content)).toContain("takes the project's title, never its node") + expect(normalize(content)).not.toContain('(or number)') + }, + ) + + // The `project` OAuth scope. A default `gh auth login` token does not carry it, + // and gh resolves projects BEFORE creating the issue — so `--project` on a + // scope-less token fails the whole command and creates nothing. Undocumented, the + // obvious agent recovery is to retry without `--project`, which lands exactly on + // the off-board item this story exists to remove. + it.each(githubCases)('$corpus: names the project scope and how to grant it', ({ content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + expect(body).toContain('gh auth refresh -s project') + expect(body).toContain('missing required scopes [project]') + }) + + it.each(githubCases)( + '$corpus: forbids working around a missing scope by dropping --project', + ({ content }) => { + expect(normalize(section(content, VISIBILITY_HEADING))).toContain( + 'never worked around by dropping --project', + ) + }, + ) + + // Round-3 finding: every no-project remediation was prose-only, so a future edit + // could restore the round-1 defect (a failed board write laundered into a green + // report) with CI green. The two assertions above pin the PERMISSION to skip; these + // pin its LIMIT — the failed-discovery branch and the Step 1 outcome that owns it. + it.each(githubCases)( + '$corpus: a FAILED discovery is reported, never absorbed as the no-op', + ({ content }) => { + const body = normalize(content) + expect(body).toContain('never absorbed as a no-op') + expect(body).toContain('not evidence of absence') + // The no-project branch states its terminal state, not just the membership no-op: + // Steps 2-3 interpolate a project number/id that does not exist. + expect(body).toContain('skip steps 2-3') }, ) }) /** - * The adapter is authoritative, but it is not the only place the KB teaches - * `gh issue create`. `issue-management/github-issues.md` carried two create - * recipes with neither `--assignee` nor `--project` — the exact item this story - * exists to forbid — and an agent reaching them by index or grep saw nothing - * pointing back at the adapter. Two contradictory recipes per tool is worse than - * one incomplete one, so the invariant is enforced KB-wide rather than per file. + * The adapter is authoritative, but it is not the only place the KB teaches an + * issue create. `issue-management/github-issues.md` carried two create recipes with + * neither `--assignee` nor `--project` — the exact item this story exists to forbid + * — and an agent reaching them by index or grep saw nothing pointing back at the + * adapter. Two contradictory recipes per tool is worse than one incomplete one, so + * the invariant is enforced KB-wide rather than per file. * - * Whole-command discipline, same as the adapter assertions above: the flag must - * be on the `gh issue create` invocation itself, not merely somewhere in the file - * (a neighbouring `gh pr create --assignee` satisfied the naive form). + * ALL THREE tracker CLIs are guarded, not just gh: the first cut matched + * `gh issue create` alone while the prose it protects is tool-wide, so azure's + * `--assigned-to` and Linear's `assigneeId` were pinned by nothing (verified by + * deleting them from both corpora — the suite stayed green). The adapters' own + * `### Create` sections are covered too: `section()` stops at the next `###`, so + * they sit outside the body the per-adapter assertions above read. + * + * Whole-command discipline, same as the adapter assertions above: the flag must be + * on the create invocation itself, not merely somewhere in the file (a neighbouring + * `gh pr create --assignee` satisfied the naive form). */ const KB_ROOTS = [ { label: 'dataset', dir: join(__dirname, '../../dataset/.pair/knowledge') }, @@ -401,35 +471,170 @@ function markdownFiles(dir: string): string[] { }) } -/** Joins backslash continuations so a multi-line `gh issue create` reads as one command. */ -function logicalLines(markdown: string): string[] { - return markdown.replace(/\\\n\s*/g, ' ').split('\n') +/** + * Shell commands inside fenced code blocks, one logical line each. + * + * Fenced-only, and command-shaped: a plain-prose sentence mentioning a create verb + * is NOT a recipe. The earlier substring filter (`line.includes('gh issue create ')`) + * passed only incidentally — the prose mention in the adapter happens to be written + * with a code span, so a backtick and not a space followed the phrase. Written + * without code spans, that sentence would have reddened the guard with a message + * about a recipe the author never added. + * + * Fence tracking is marker-aware (a ```` block is not closed by an inner ```), so a + * file that quotes fences cannot silently swallow the recipes after it. Two joins, + * because the KB uses both shapes: trailing-backslash continuations (`az`, `gh`) and + * a single-quoted argument spanning lines (the `linear_gql` JSON payloads). Comment + * lines are dropped before joining — an apostrophe in a comment would otherwise + * unbalance the quote count and hide the command underneath it. + */ +/** Marks a fence boundary in the line stream: no command spans two code blocks. */ +const FENCE_BREAK = 'fence' + +/** Code-block lines only, shell prompts stripped, blanks and comments dropped. */ +function fencedLines(markdown: string): string[] { + const lines: string[] = [] + let open: string | null = null + + for (const raw of markdown.split('\n')) { + const marker = /^\s*(`{3,}|~{3,})/.exec(raw)?.[1] + if (marker) { + if (open === null) open = marker + else if (marker[0] === open[0] && marker.length >= open.length) open = null + lines.push(FENCE_BREAK) + continue + } + if (open === null) continue + const line = raw.replace(/^\s*\$\s+/, '').trim() + if (line !== '' && !line.startsWith('#')) lines.push(line) + } + return lines +} + +/** Joins trailing-backslash continuations and single-quoted arguments spanning lines. */ +function joinContinuations(lines: string[]): string[] { + const commands: string[] = [] + let pending = '' + const flush = (): void => { + if (pending) commands.push(pending) + pending = '' + } + + for (const line of lines) { + if (line === FENCE_BREAK) { + flush() + continue + } + const body = line.replace(/\\$/, '').trim() + pending = pending ? `${pending} ${body}` : body + const unbalancedQuote = (pending.split("'").length - 1) % 2 === 1 + if (!line.endsWith('\\') && !unbalancedQuote) flush() + } + flush() + return commands +} + +function fencedCommands(markdown: string): string[] { + return joinContinuations(fencedLines(markdown)) } +/** + * One family per tracker CLI. `match` selects the create invocations; `required` is + * the flag/field without which the created item is invisible in the view the team + * reads. Each family also carries its own non-empty guard, so a discovery that stops + * matching one tool reddens instead of going quietly vacuous. + */ +const CREATE_FAMILIES = [ + { tool: 'gh', match: /^gh issue create\b/, required: /--assignee\b/, flag: '--assignee' }, + { + tool: 'az', + match: /^az boards work-item create\b/, + required: /--assigned-to\b/, + flag: '--assigned-to', + }, + { + tool: 'linear', + match: /^linear_gql\b.*\bissueCreate\b/, + required: /assigneeId/, + flag: 'assigneeId', + }, +] + const createRecipeCases = KB_ROOTS.flatMap(({ label, dir }) => - markdownFiles(dir).flatMap(path => - logicalLines(readFileSync(path, 'utf-8')) - .filter(line => line.includes('gh issue create ')) - .map((command, index) => ({ - corpus: label, - file: path.slice(dir.length + 1), - occurrence: index + 1, - command, - })), - ), + markdownFiles(dir) + .flatMap(path => + fencedCommands(readFileSync(path, 'utf-8')).flatMap(command => { + const family = CREATE_FAMILIES.find(candidate => candidate.match.test(command)) + return family + ? [ + { + corpus: label, + file: path.slice(dir.length + 1), + tool: family.tool, + family, + command, + }, + ] + : [] + }), + ) + .map((recipe, index) => ({ ...recipe, occurrence: index + 1 })), ) -describe('KB-wide — no issue-create recipe omits the assignee (#402)', () => { - it('finds the create recipes it means to guard', () => { - // Non-empty guard, not a count: if the discovery breaks, the cases below would - // pass vacuously over an empty list. - expect(createRecipeCases.length).toBeGreaterThan(0) +describe('KB-wide — no issue-create recipe omits the assignee (gh, az, linear) (#402)', () => { + it.each( + KB_ROOTS.flatMap(({ label }) => CREATE_FAMILIES.map(({ tool }) => ({ corpus: label, tool }))), + )('$corpus: finds the $tool create recipes it means to guard', ({ corpus, tool }) => { + // Non-empty per family, not a count: if the discovery breaks for one tool, the + // cases below would pass vacuously over the recipes of the other two. + expect( + createRecipeCases.filter(c => c.corpus === corpus && c.tool === tool).length, + ).toBeGreaterThan(0) }) it.each(createRecipeCases)( - '$corpus/$file recipe $occurrence sets --assignee on the create itself', - ({ command }) => { - expect(command).toMatch(/gh issue create\b[^\n]*--assignee\b/) + '$corpus/$file $tool recipe $occurrence sets the assignee on the create itself', + ({ command, family }) => { + expect(command, `missing ${family.flag}`).toMatch(family.required) + }, + ) +}) + +/** + * The two implicit-membership adapters name a real visibility field in their prose; + * the create recipe below it must actually carry that field, or copying the recipe + * still reproduces the story's symptom one layer under the warning. + */ +const VISIBILITY_FIELD_CASES = CORPORA.flatMap(({ label, dir }) => + [ + { + file: 'azure-devops-implementation.md', + field: '--area', + match: /^az boards work-item create\b.*--area\b/, + }, + { + file: 'linear-implementation.md', + field: 'projectId', + match: /^linear_gql\b.*\bissueCreate\b.*projectId/, + }, + ] + .filter(({ file }) => existsSync(join(dir, file))) + .map(entry => ({ + corpus: label, + ...entry, + content: readFileSync(join(dir, entry.file), 'utf-8'), + })), +) + +describe('implicit-membership adapters ship the visibility field on a create recipe (#402)', () => { + it.each(VISIBILITY_FIELD_CASES)( + '$corpus/$file has a create recipe carrying $field', + ({ content, match, field }) => { + const commands = fencedCommands(content) + expect( + commands.some(command => match.test(command)), + `no create recipe with ${field}`, + ).toBe(true) }, ) }) From 7df44f36276d7c63bcb01fc10533804392f60a58 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 20:46:45 +0200 Subject: [PATCH 7/8] =?UTF-8?q?[US-402]=20fix:=20round-4=20review=20?= =?UTF-8?q?=E2=80=94=20contract=20clause=205,=20membership=20on=20create?= =?UTF-8?q?=20recipes,=20test=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README contract gains clause 5: implicit membership must still name the field that decides the read view. Guarded table-driven, keyed on the PINNED sentence form so github's cross-reference to implicit semantics is not read as a self-declaration. - Create recipes carry the membership field: --area on the three azure snippets; linear states the projectId condition next to the snippet (JSON has no comments); the security incident create says why it is deliberately board-less. - github-implementation: the no-project case is a legitimate outcome (omit --project, skip Step 2b), and [PROJECT_ID] is sourced from Step 1. - filesystem-implementation: the Assignee placement rule now covers epics and initiatives, and the initiative checklist carries the bullet the story one had. - occurrence numbers per file, not across the corpus. - The '(or number)' negative is scoped to the claim instead of the whole file. - FENCE_BREAK was a LITERAL NUL byte in the source, invisible in every editor and read as a space by the reviewer; now escaped and documented. - ADL records the azure/linear inlining asymmetry as deliberate. Co-Authored-By: Claude Opus 5 --- ...26-07-31-pm-adapter-visibility-contract.md | 3 +- .../issue-management/azure-devops-issues.md | 3 + .../issue-management/linear-issues.md | 2 + .../project-management-tool/README.md | 1 + .../filesystem-implementation.md | 3 +- .../github-implementation.md | 6 ++ .../security/secure-development.md | 3 + .../issue-management/azure-devops-issues.md | 3 + .../issue-management/linear-issues.md | 2 + .../project-management-tool/README.md | 1 + .../filesystem-implementation.md | 3 +- .../github-implementation.md | 6 ++ .../security/secure-development.md | 3 + .../pm-tool-adapter-contract.test.ts | 75 ++++++++++++++++++- 14 files changed, 107 insertions(+), 7 deletions(-) diff --git a/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md b/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md index 475d6158..a192196a 100644 --- a/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md +++ b/.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md @@ -38,7 +38,8 @@ Observed 2026-07-30: issues #384 and #372 were open, assigned, green, and absent - Adding a PM-tool adapter now requires its `Item Visibility: Membership and Assignee` section, at level 3, with the pinned sentence form — otherwise `pm-tool-adapter-contract.test.ts` fails. - Adapters whose semantics the guard knows are pinned **by name** (`github` explicit; `azure-devops`, `linear`, `filesystem` implicit), so a regression that flips one cannot pass on the loose either-word assertion. - `gh issue create` recipes are guarded KB-wide for `--assignee`, so the issue-management guides cannot drift back into a second, incomplete create recipe that contradicts the adapter. -- Implicit membership is documented as _not_ equivalent to "cannot be invisible": Azure's area path and, when the adoption names a project view, Linear's `projectId` remain part of visibility. +- Implicit membership is documented as _not_ equivalent to "cannot be invisible": Azure's area path and, when the adoption names a project view, Linear's `projectId` remain part of visibility. Contract clause 5 makes that a **requirement** — an adapter declaring implicit membership must name the field that decides the read view — and the guard is table-driven, so a new implicit adapter has to be enrolled deliberately rather than inheriting silence. +- **Azure inlines `--area` in its create snippet; Linear does not inline `projectId`. Deliberate, not an oversight.** The two fields differ in conditionality: a team's board on Azure always reads configured area paths, so the flag belongs in the recipe unconditionally, whereas a Linear project view exists only when the adoption names one — inlining `projectId` would teach an agent to send a project id on every create, including the (common) case of a team-scoped board where there is none. Linear's snippet therefore carries the field in a comment with its condition, and the prose states it; the guard requires the field to be **named in the visibility section**, not present in the recipe, for exactly this reason. ## Adoption Impact diff --git a/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md b/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md index 97b5caea..e1f2b2b8 100644 --- a/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md +++ b/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md @@ -32,6 +32,7 @@ az boards work-item create \ --title "[Story title]" \ --description "[Body following the user-story-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` @@ -45,6 +46,7 @@ az boards work-item create \ --title "[Task title]" \ --description "[Body following the task-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` @@ -56,6 +58,7 @@ az boards work-item create \ --title "[Bug title]" \ --description "[Reproduction steps, expected vs actual]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` diff --git a/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md b/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md index 0b034695..d553b33b 100644 --- a/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md +++ b/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md @@ -40,6 +40,8 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc `issue.identifier` (e.g. `ENG-412`) is the item id pair uses in commits, branch names, and the PR's `Refs:` line. +Add `"projectId":""` to the same `input` **only when [way-of-working.md](../../../../adoption/tech/way-of-working.md) names a project view** — on Linear membership comes from the required `teamId`, so a project is a grouping inside the team, not what makes the issue visible. JSON carries no comments, which is why the condition is stated here rather than inside the snippet. + ### Epic Same mutation without `parentId`, labelled `epic`. Attach it to a Project via `projectId` when initiatives are tracked as Projects. diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index 9b881ab0..258687f3 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -88,6 +88,7 @@ Every adapter therefore carries a **level-3** section headed **`### Item Visibil 2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. 3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. 4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). +5. **Implicit membership that can still miss the read view** — "implicit" answers *how* membership happens, not *whether the item is visible*. Where creating the item makes it a member and it can still land outside the view the team reads (Azure Boards: an **area path** outside the team's configured areas; Linear: a project-scoped view the issue was never added to), name the **field** that decides it AND carry that field on the create recipe, not only in prose. An adapter that stops at "membership is implicit" hands an agent a create that satisfies the contract and produces an invisible item — the exact failure the contract exists to prevent. Conformance coverage is **data-driven over the `*-implementation.md` files present**, in both the dataset and the generated root mirror — see `packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts`. Adding an adapter therefore enrols it in the contract automatically; **no adapter count is asserted anywhere**, and an omission fails the gate instead of being discovered in production. diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 8a17a6b7..2b04edfb 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -213,7 +213,7 @@ Status changes are reflected by moving files between directories: **Assignee**: [name or handle of the person the work is done for] ``` -**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. +**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story; for an **epic** or an **initiative**, in its own header/context block, the one carrying `**Priority**`. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. **Why the slot is not in the shared templates** (a deliberate choice, not an oversight): on every other adapter the assignee is a **native tracker field** — GitHub's `assignees`, Azure's `--assigned-to`, Linear's `assigneeId` — so a template slot would be dead weight there, and a second source of truth competing with the field the board actually filters on. The templates therefore stay tool-neutral and this adapter owns the convention, which is why the placement rule is stated here and is looser than a template field would be. Recorded with the rest of the contract rationale in this repository's `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. @@ -248,6 +248,7 @@ Fill out all sections of the initiative template: - **Business Objective**: Clear statement of business goals - **Success Metrics**: Measurable outcomes and KPIs - **Priority**: P0 (Must-Have) | P1 (Should-Have) | P2 (Could-Have) +- **Assignee**: the person the work is done for — required, see [Item Visibility](#item-visibility-membership-and-assignee) - **Estimated Timeline**: High-level timeframe - **Dependencies**: External dependencies and blockers - **Epic References**: Links to related epics (added as epics are created) diff --git a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index 558a8038..14936c43 100644 --- a/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -436,6 +436,10 @@ gh issue create --title "[title]" --body-file [file] --assignee "[login]" --proj # Without --project (or after any MCP create): membership is still missing. # Run Step 2b below — it is idempotent, so it is safe to run unconditionally. +# +# No project named in way-of-working.md at all: omit --project AND skip Step 2b. +# That is the Step 1 no-project outcome, not a workaround — there is no board to +# be absent from, so the item is not invisible. # Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally gh issue edit [NUMBER] --add-assignee "[login]" @@ -519,6 +523,8 @@ Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with Reached whenever Step 2 found nothing, and safe to run even when it found something (see **Idempotent** below) — so the conditional shape is an optimisation, never a requirement. Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. +`[PROJECT_ID]` below comes from **Step 1**: run it first when you arrive here straight from the create recipe rather than through Step 2. + ```bash # Get the issue's node id, then add it to the project ISSUE_NODE_ID=$(gh api graphql -f query='{ diff --git a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index b5dec81c..180d6bee 100644 --- a/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -374,6 +374,9 @@ case $SEVERITY in # --assignee is not optional: an unassigned incident issue is invisible # in the assignee-filtered view the team reads (see the PM-tool adapter's # "Item Visibility: Membership and Assignee" section). + # No --project on purpose: an incident is not a backlog item, so it is + # deliberately board-less. If your team DOES track incidents on the board, + # add --project and the adapter's add-item step (Step 2b) after this call. gh issue create \ --title "🚨 Security Incident: $INCIDENT_TYPE" \ --body "High priority security incident requiring immediate attention" \ diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md index 99af3af5..c02620fa 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/azure-devops-issues.md @@ -32,6 +32,7 @@ az boards work-item create \ --title "[Story title]" \ --description "[Body following the user-story-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` @@ -45,6 +46,7 @@ az boards work-item create \ --title "[Task title]" \ --description "[Body following the task-template]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` @@ -56,6 +58,7 @@ az boards work-item create \ --title "[Bug title]" \ --description "[Reproduction steps, expected vs actual]" \ --assigned-to "[user@example.com]" \ + --area "[team area path]" \ --project ``` diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md index a25b4ccf..79fb5b3a 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/issue-management/linear-issues.md @@ -40,6 +40,8 @@ linear_gql '{"query":"mutation($i:IssueCreateInput!){ issueCreate(input:$i){ suc `issue.identifier` (e.g. `ENG-412`) is the item id pair uses in commits, branch names, and the PR's `Refs:` line. +Add `"projectId":""` to the same `input` **only when [way-of-working.md](../../../../adoption/tech/way-of-working.md) names a project view** — on Linear membership comes from the required `teamId`, so a project is a grouping inside the team, not what makes the issue visible. JSON carries no comments, which is why the condition is stated here rather than inside the snippet. + ### Epic Same mutation without `parentId`, labelled `epic`. Attach it to a Project via `projectId` when initiatives are tracked as Projects. diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md index ee8379b8..e87746f9 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/README.md @@ -88,6 +88,7 @@ Every adapter therefore carries a **level-3** section headed **`### Item Visibil 2. **The assignee mechanic** — the concrete field/flag that sets the assignee `as part of the create, never as a follow-up step`, per the Assignment rule in the project's `way-of-working.md`. 3. **The unresolvable-assignee behaviour** — `if the assignee cannot be resolved`, report it and `never drop it silently`. Dropping it reproduces the invisibility the section exists to remove. 4. **The status-write precondition** — for explicit-membership tools, that membership precedes the state write, plus what happens when the item lookup returns nothing (an explicit branch, never an unhandled empty value and never a silently skipped board write reported as success). +5. **Implicit membership that can still miss the read view** — "implicit" answers *how* membership happens, not *whether the item is visible*. Where creating the item makes it a member and it can still land outside the view the team reads (Azure Boards: an **area path** outside the team's configured areas; Linear: a project-scoped view the issue was never added to), name the **field** that decides it AND carry that field on the create recipe, not only in prose. An adapter that stops at "membership is implicit" hands an agent a create that satisfies the contract and produces an invisible item — the exact failure the contract exists to prevent. Conformance coverage is **data-driven over the `*-implementation.md` files present**, in both the dataset and the generated root mirror — see `packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts`. Adding an adapter therefore enrols it in the contract automatically; **no adapter count is asserted anywhere**, and an omission fails the gate instead of being discovered in production. diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md index 30d190d9..9ecf69a7 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/filesystem-implementation.md @@ -213,7 +213,7 @@ Status changes are reflected by moving files between directories: **Assignee**: [name or handle of the person the work is done for] ``` -**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. +**Where the line goes**: the item templates carry **no `Assignee` field**, so copying a template does not leave a slot for it. Add it to the item's context block — for a user story, in **`## Epic Context`** immediately after `**Priority**`; for a task, in the same header block that carries the parent story; for an **epic** or an **initiative**, in its own header/context block, the one carrying `**Priority**`. Writing it anywhere the grep below finds it is correct; leaving it out because the template lacked a field is not. **Why the slot is not in the shared templates** (a deliberate choice, not an oversight): on every other adapter the assignee is a **native tracker field** — GitHub's `assignees`, Azure's `--assigned-to`, Linear's `assigneeId` — so a template slot would be dead weight there, and a second source of truth competing with the field the board actually filters on. The templates therefore stay tool-neutral and this adapter owns the convention, which is why the placement rule is stated here and is looser than a template field would be. Recorded with the rest of the contract rationale in this repository's `.pair/adoption/decision-log/2026-07-31-pm-adapter-visibility-contract.md`. @@ -248,6 +248,7 @@ Fill out all sections of the initiative template: - **Business Objective**: Clear statement of business goals - **Success Metrics**: Measurable outcomes and KPIs - **Priority**: P0 (Must-Have) | P1 (Should-Have) | P2 (Could-Have) +- **Assignee**: the person the work is done for — required, see [Item Visibility](#item-visibility-membership-and-assignee) - **Estimated Timeline**: High-level timeframe - **Dependencies**: External dependencies and blockers - **Epic References**: Links to related epics (added as epics are created) diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md index c648f693..cacc4b76 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/collaboration/project-management-tool/github-implementation.md @@ -436,6 +436,10 @@ gh issue create --title "[title]" --body-file [file] --assignee "[login]" --proj # Without --project (or after any MCP create): membership is still missing. # Run Step 2b below — it is idempotent, so it is safe to run unconditionally. +# +# No project named in way-of-working.md at all: omit --project AND skip Step 2b. +# That is the Step 1 no-project outcome, not a workaround — there is no board to +# be absent from, so the item is not invisible. # Existing issue — --add-assignee adds without replacing, so it is safe to run unconditionally gh issue edit [NUMBER] --add-assignee "[login]" @@ -519,6 +523,8 @@ Never carry an empty id into Step 3: `updateProjectV2ItemFieldValue` fails with Reached whenever Step 2 found nothing, and safe to run even when it found something (see **Idempotent** below) — so the conditional shape is an optimisation, never a requirement. Membership must exist before the status field can be written — the field lives on the **item**, not on the issue. +`[PROJECT_ID]` below comes from **Step 1**: run it first when you arrive here straight from the create recipe rather than through Step 2. + ```bash # Get the issue's node id, then add it to the project ISSUE_NODE_ID=$(gh api graphql -f query='{ diff --git a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md index b5dec81c..180d6bee 100644 --- a/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md +++ b/packages/knowledge-hub/dataset/.pair/knowledge/guidelines/quality-assurance/security/secure-development.md @@ -374,6 +374,9 @@ case $SEVERITY in # --assignee is not optional: an unassigned incident issue is invisible # in the assignee-filtered view the team reads (see the PM-tool adapter's # "Item Visibility: Membership and Assignee" section). + # No --project on purpose: an incident is not a backlog item, so it is + # deliberately board-less. If your team DOES track incidents on the board, + # add --project and the adapter's add-item step (Step 2b) after this call. gh issue create \ --title "🚨 Security Incident: $INCIDENT_TYPE" \ --body "High priority security incident requiring immediate attention" \ diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts index c5f142af..822bef0d 100644 --- a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -119,6 +119,40 @@ describe('PM-tool adapter contract — every adapter present documents visibilit }, ) + // Contract clause 5. "Implicit" answers HOW membership happens, not WHETHER the item + // is visible: on Azure an area path outside the team's configured areas, and on Linear + // a project-scoped view, both leave a created-and-assigned item out of the view the + // team reads. So an implicit-membership adapter must also NAME what decides that view. + // Table-driven rather than a generic regex, and it fails loudly on an adapter it does + // not know — enrolling a new implicit adapter is a deliberate edit, not a default. + const IMPLICIT_VIEW_DECIDERS: Record = { + 'azure-devops-implementation.md': '--area', + 'linear-implementation.md': 'projectId', + // No separate field exists here: the file's path IS both membership and view. + 'filesystem-implementation.md': "location", + } + + it.each(adapterCases)( + '$corpus/$file — implicit membership still names what decides the read view (clause 5)', + ({ file, content }) => { + const body = normalize(section(content, VISIBILITY_HEADING)) + // The PINNED sentence form (`Board` prefix), not the bare phrase: an adapter + // legitimately cross-references the other semantics in prose — github's section + // explains what implicit membership means elsewhere — and matching the bare + // phrase read that as github declaring itself implicit. + if (!/board membership is implicit/.test(body)) return + const decider = IMPLICIT_VIEW_DECIDERS[file] + expect( + decider, + `${file} declares implicit membership but is not enrolled in IMPLICIT_VIEW_DECIDERS — ` + + `add the field that decides its read view (contract clause 5)`, + ).toBeDefined() + expect(body, `${file} must name '${decider}' in its visibility section`).toContain( + normalize(decider as string), + ) + }, + ) + // The assertion above accepts either word, by design: it must hold for an // adapter this suite has never seen. But for the adapters that DO exist, the // loose form lets a regression flip azure/filesystem to "explicit" — or github @@ -398,7 +432,10 @@ describe('github-implementation.md — assignee is part of the write (#402 AC3)' // numeric form belongs to the separate `gh project` commands. The pinned half // is the never-a-node-ID one, which is what an agent gets wrong. expect(normalize(content)).toContain("takes the project's title, never its node") - expect(normalize(content)).not.toContain('(or number)') + // Scoped to the CLAIM, not to the whole file: "(or number)" is legitimate prose + // elsewhere, so a bare ban would redden on a sentence that has nothing to do with + // --project. + expect(content).not.toMatch(/takes the project's \*?\*?title\*?\*?[^.\n]*\(or number\)/i) }, ) @@ -488,8 +525,17 @@ function markdownFiles(dir: string): string[] { * lines are dropped before joining — an apostrophe in a comment would otherwise * unbalance the quote count and hide the command underneath it. */ -/** Marks a fence boundary in the line stream: no command spans two code blocks. */ -const FENCE_BREAK = 'fence' +/** + * Marks a fence boundary in the line stream: no command spans two code blocks. + * + * The leading NUL is what makes the sentinel collision-proof — markdown cannot + * contain one, so no content line can ever equal it. Written ESCAPED on purpose: it + * used to be a literal NUL byte in this source, invisible in every editor and diff + * (it renders as a space, and a round-4 reviewer read it as one), so a well-meant + * "tidy the whitespace" edit would have silently turned it into a value markdown CAN + * produce. Keep the escape. + */ +const FENCE_BREAK = '\u0000fence' /** Code-block lines only, shell prompts stripped, blanks and comments dropped. */ function fencedLines(markdown: string): string[] { @@ -560,6 +606,15 @@ const CREATE_FAMILIES = [ }, ] +type CreateRecipeCase = { + corpus: string + file: string + tool: string + family: (typeof CREATE_FAMILIES)[number] + command: string + occurrence: number +} + const createRecipeCases = KB_ROOTS.flatMap(({ label, dir }) => markdownFiles(dir) .flatMap(path => @@ -578,7 +633,19 @@ const createRecipeCases = KB_ROOTS.flatMap(({ label, dir }) => : [] }), ) - .map((recipe, index) => ({ ...recipe, occurrence: index + 1 })), + .reduce<{ perFile: Map; out: CreateRecipeCase[] }>( + // `occurrence` numbers recipes WITHIN a file. It exists to disambiguate two + // recipes in the same document; numbering across the corpus made a case name + // depend on how many recipes happened to precede it in unrelated files, so + // adding a snippet to one adapter renamed the cases of every later one. + (acc, recipe) => { + const n = (acc.perFile.get(recipe.file) ?? 0) + 1 + acc.perFile.set(recipe.file, n) + acc.out.push({ ...recipe, occurrence: n }) + return acc + }, + { perFile: new Map(), out: [] }, + ).out, ) describe('KB-wide — no issue-create recipe omits the assignee (gh, az, linear) (#402)', () => { From 2a131d0f481812ca4fedf2f9149f1998e9bafc5e Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Fri, 31 Jul 2026 20:55:05 +0200 Subject: [PATCH 8/8] [US-402] style: prettier on the clause-5 guard and the per-file numbering Co-Authored-By: Claude Opus 5 --- .../pm-tool-adapter-contract.test.ts | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts index 822bef0d..e42479d2 100644 --- a/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts +++ b/packages/knowledge-hub/src/conformance/pm-tool-adapter-contract.test.ts @@ -129,7 +129,7 @@ describe('PM-tool adapter contract — every adapter present documents visibilit 'azure-devops-implementation.md': '--area', 'linear-implementation.md': 'projectId', // No separate field exists here: the file's path IS both membership and view. - 'filesystem-implementation.md': "location", + 'filesystem-implementation.md': 'location', } it.each(adapterCases)( @@ -615,37 +615,38 @@ type CreateRecipeCase = { occurrence: number } -const createRecipeCases = KB_ROOTS.flatMap(({ label, dir }) => - markdownFiles(dir) - .flatMap(path => - fencedCommands(readFileSync(path, 'utf-8')).flatMap(command => { - const family = CREATE_FAMILIES.find(candidate => candidate.match.test(command)) - return family - ? [ - { - corpus: label, - file: path.slice(dir.length + 1), - tool: family.tool, - family, - command, - }, - ] - : [] - }), - ) - .reduce<{ perFile: Map; out: CreateRecipeCase[] }>( - // `occurrence` numbers recipes WITHIN a file. It exists to disambiguate two - // recipes in the same document; numbering across the corpus made a case name - // depend on how many recipes happened to precede it in unrelated files, so - // adding a snippet to one adapter renamed the cases of every later one. - (acc, recipe) => { - const n = (acc.perFile.get(recipe.file) ?? 0) + 1 - acc.perFile.set(recipe.file, n) - acc.out.push({ ...recipe, occurrence: n }) - return acc - }, - { perFile: new Map(), out: [] }, - ).out, +const createRecipeCases = KB_ROOTS.flatMap( + ({ label, dir }) => + markdownFiles(dir) + .flatMap(path => + fencedCommands(readFileSync(path, 'utf-8')).flatMap(command => { + const family = CREATE_FAMILIES.find(candidate => candidate.match.test(command)) + return family + ? [ + { + corpus: label, + file: path.slice(dir.length + 1), + tool: family.tool, + family, + command, + }, + ] + : [] + }), + ) + .reduce<{ perFile: Map; out: CreateRecipeCase[] }>( + // `occurrence` numbers recipes WITHIN a file. It exists to disambiguate two + // recipes in the same document; numbering across the corpus made a case name + // depend on how many recipes happened to precede it in unrelated files, so + // adding a snippet to one adapter renamed the cases of every later one. + (acc, recipe) => { + const n = (acc.perFile.get(recipe.file) ?? 0) + 1 + acc.perFile.set(recipe.file, n) + acc.out.push({ ...recipe, occurrence: n }) + return acc + }, + { perFile: new Map(), out: [] }, + ).out, ) describe('KB-wide — no issue-create recipe omits the assignee (gh, az, linear) (#402)', () => {