From c8f14c62d80f0529bdbb6d06598d118ed4b954b8 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 31 Aug 2026 15:03:05 -0700 Subject: [PATCH 1/3] fix(workflows): target activation labels via gh-aw temporary IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/squad plan activate` told the model to read a real issue number back from each `create-issue` call and then target `add_labels` at it. gh-aw never returns one — issue creation is deferred to the post-agent safe-output job, so the agent only ever sees a success acknowledgement. Every operation that depended on a "returned" number was therefore unsatisfiable, and an `add_labels` call with no resolvable `item_number` does not error: it silently labels the triggering intent issue. Switch the full activation path to gh-aw's supported `temporary_id` linkage: - Enable `require-temporary-id: true` on the `create-issue` safe output, so a call that omits a temporary ID is rejected rather than silently mis-targeted. - Add a Temporary-ID Contract section covering the real gh-aw pattern (`^#?aw_[A-Za-z0-9_]{3,12}$`), a deterministic derive-don't-invent minting scheme (`#aw_epic{K}` / `#aw_task{N}`), and an explicit uniqueness rule — gh-aw does not reject duplicates, it silently lets the last writer own the mapping. - Rewrite the Hallucination Guard to state that no issue number is returned, and forbid predicting, inferring, or waiting for one. - Target `add_labels.item_number`, `create_issue.parent`, and `create_issue.blocked_by` at temporary IDs; keep verified real numbers for pre-existing/reused issues and the triggering issue. Roster correspondence, base `squad`, @copilot mapping, multi-owner and non-roster behavior, and origin-issue safety are unchanged. `require-temporary-id` is workflow-global (enforced at the MCP tool layer from the single create_issue config), so the `squad-plan-accept` fast path needed a minimal compliance edit to mint a temporary ID. Its label provisioning remains out of scope — that is #1959. Verified against the pinned gh-aw v0.87.10. Closes #1962 Parent #1957 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...h-aw-activation-label-provisioning.test.ts | 23 +- test/gh-aw-activation-temporary-ids.test.ts | 279 ++++++++++++++++++ workflows/squad.md | 80 +++-- 3 files changed, 360 insertions(+), 22 deletions(-) create mode 100644 test/gh-aw-activation-temporary-ids.test.ts diff --git a/test/gh-aw-activation-label-provisioning.test.ts b/test/gh-aw-activation-label-provisioning.test.ts index 52cddb632..b2c234a89 100644 --- a/test/gh-aw-activation-label-provisioning.test.ts +++ b/test/gh-aw-activation-label-provisioning.test.ts @@ -146,11 +146,28 @@ describe('gh-aw: fresh-repo label provisioning in squad-plan-activate (#1955)', ).toBe(true); }); - it('never calls add_labels before create-issue has returned and been verified', () => { + it('forbids the invalid "wait for a returned real issue number" contract (#1962)', () => { + // The original version of this rule told the model to wait until create-issue + // "returned and been verified" before calling add_labels. gh-aw never returns a real + // issue number to the agent — creation is deferred to the safe-output job — so that + // instruction was unsatisfiable and is exactly the defect #1962 corrects. The prose + // must now say the opposite: do not wait, because no number arrives. expect( /never call `add_labels` before/i.test(activateProse), - 'The prose must explicitly forbid calling add_labels before create-issue has ' + - 'returned a real issue number — there is nothing to target yet.', + 'The old "never call add_labels before create-issue has returned a real issue ' + + 'number" rule must be gone — it encoded the invalid assumption #1962 fixes.', + ).toBe(false); + + expect( + /do not wait for a returned issue number/i.test(activateProse), + 'The prose must explicitly tell the model NOT to wait for a returned issue ' + + 'number, since gh-aw never provides one during the run.', + ).toBe(true); + + expect( + /does \*\*not\*\* return a real GitHub issue number/i.test(activateProse), + 'The Hallucination Guard must state plainly that create-issue does not return a ' + + 'real issue number during the run.', ).toBe(true); }); diff --git a/test/gh-aw-activation-temporary-ids.test.ts b/test/gh-aw-activation-temporary-ids.test.ts new file mode 100644 index 000000000..df5ca28d1 --- /dev/null +++ b/test/gh-aw-activation-temporary-ids.test.ts @@ -0,0 +1,279 @@ +/** + * gh-aw temporary-ID targeting for `/squad plan activate` (#1962, parent #1957). + * + * The activation prose previously told the model to read a real GitHub issue number back + * from each `create-issue` call and then target `add_labels` at it. gh-aw never does that: + * issue creation is deferred to the post-agent safe-output job, so the agent only ever + * receives a success acknowledgement. Every downstream operation that depended on a + * "returned" number was therefore either blocked forever or satisfied by a hallucinated + * number — and an `add_labels` call with no resolvable `item_number` silently falls back to + * labelling the *triggering intent issue*. + * + * gh-aw's supported linkage is `temporary_id`: `create_issue` mints one, and + * `add_labels.item_number`, `create_issue.parent`, and `create_issue.blocked_by` accept it. + * The safe-output job resolves temporary IDs to real issue numbers after the agent exits. + * + * Verified against the pinned gh-aw v0.87.10 (.github/workflows/squad-ci.yml): + * - `create_issue.temporary_id` — optional string, pattern `^#?aw_[A-Za-z0-9_]{3,12}$` + * - `add_labels.item_number` — number | string, `^(\d+|#?aw_[A-Za-z0-9_]{3,12})$` + * - `require-temporary-id: true` under `safe-outputs.create-issue` compiles to + * `"require_temporary_id":true` plus `required_field_additions.create_issue` + * + * This suite locks the corrected contract. It is deliberately narrow: the broad activation + * behavioral suite is #1960, the `/squad activate` fast path is #1959, and the activation + * reporting redesign is #1963. + */ + +import { afterAll, describe, it, expect } from 'vitest'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +const WORKFLOWS_DIR = join(process.cwd(), 'workflows'); +const SQUAD_WORKFLOW = join(WORKFLOWS_DIR, 'squad.md'); +const TEST_WORKSPACES_DIR = join(process.cwd(), '.test-workspaces-temporary-ids'); + +afterAll(() => { + rmSync(TEST_WORKSPACES_DIR, { recursive: true, force: true }); +}); + +/** Read a text file with line endings normalized to LF (Windows checkouts materialize CRLF). */ +function readText(filePath: string): string { + return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); +} + +const workflow = readText(SQUAD_WORKFLOW); + +const ACTIVATE_START = workflow.indexOf('## skill: `squad-plan-activate`'); +const ACTIVATE_END = workflow.indexOf('## end skill: `squad-plan-activate`'); +expect(ACTIVATE_START, '"## skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan(-1); +expect(ACTIVATE_END, '"## end skill: `squad-plan-activate`" is missing from workflows/squad.md').toBeGreaterThan( + ACTIVATE_START, +); +const activateSkill = workflow.slice(ACTIVATE_START, ACTIVATE_END); + +/** Whitespace-collapsed view for prose assertions that may span wrapped lines. */ +const activateProse = activateSkill.replace(/\s+/g, ' '); + +/** Isolate a `**{marker}**` step body, running to the next bold step heading. */ +function stepBody(marker: string): string { + const start = activateSkill.indexOf(marker); + expect(start, `"${marker}" is missing from the squad-plan-activate skill`).toBeGreaterThan(-1); + const rest = activateSkill.slice(start + marker.length); + const nextStep = rest.search(/\n\*\*\d[a-z]?\./); + return (nextStep === -1 ? rest : rest.slice(0, nextStep)).replace(/\s+/g, ' '); +} + +describe('gh-aw: temporary-ID targeting in squad-plan-activate (#1962)', () => { + it('requires a temporary_id on every create-issue via frontmatter', () => { + // Without this, a create_issue call that forgets its temporary_id still succeeds and + // the matching add_labels has nothing resolvable to target. The flag makes the + // omission a hard rejection instead of a silent mis-label. + const frontmatterEnd = workflow.indexOf('\n---', 4); + expect(frontmatterEnd, 'workflows/squad.md frontmatter is unterminated').toBeGreaterThan(0); + const frontmatter = workflow.slice(0, frontmatterEnd); + + const createIssueAt = frontmatter.indexOf('create-issue:'); + expect(createIssueAt, '`create-issue:` is missing from the safe-outputs frontmatter').toBeGreaterThan(-1); + + expect( + frontmatter.slice(createIssueAt), + '`require-temporary-id: true` must be declared under safe-outputs.create-issue.', + ).toMatch(/create-issue:[\s\S]*?require-temporary-id: true/); + }); + + it('documents the exact gh-aw temporary-ID format, not an invented one', () => { + // A value outside gh-aw's pattern is rejected at the MCP tool layer, so the prose has + // to carry the real pattern rather than a plausible-looking approximation. + expect( + activateProse, + 'The prose must cite gh-aw\'s actual temporary_id pattern.', + ).toContain('^#?aw_[A-Za-z0-9_]{3,12}$'); + }); + + it('mints deterministic temporary IDs derived from the plan, never invented ones', () => { + expect(activateProse).toMatch(/#aw_epic\{K\}/); + expect(activateProse).toMatch(/#aw_task\{N\}/); + expect( + /derive, never invent/i.test(activateProse), + 'The minting scheme must be derived from the plan\'s own identifiers so a rerun ' + + 'produces the same IDs and two items can never collide.', + ).toBe(true); + }); + + it('makes temporary-ID uniqueness the prompt\'s responsibility', () => { + // Verified in gh-aw v0.87.10: a duplicate temporary_id is NOT rejected. The + // temporaryIdMap set() silently overwrites, so the last create_issue using a value + // owns it and every earlier reference resolves to the wrong issue. gh-aw emits no + // warning for this on create_issue, so the prompt must enforce it. + expect( + /uniqueness is your responsibility/i.test(activateProse), + 'The prose must state that gh-aw does not enforce temporary-ID uniqueness.', + ).toBe(true); + expect( + /does not reject a duplicate `temporary_id`/i.test(activateProse), + 'The prose must name the concrete failure mode: duplicates are silently accepted.', + ).toBe(true); + }); + + it('mandates an explicit add_labels item_number and names the silent-fallback hazard', () => { + // This is the actual production defect: an add_labels with no item_number does not + // error — it labels the triggering intent issue, branding the user's own request. + expect( + /every `add_labels` call MUST pass `item_number`/i.test(activateProse), + 'Explicit item_number targeting must be mandatory, not implied.', + ).toBe(true); + expect( + /triggering intent issue/i.test(activateProse), + 'The prose must name what an omitted item_number silently hits.', + ).toBe(true); + }); + + it('targets add_labels at the create-issue call\'s temporary_id', () => { + expect( + /`item_number` set to that call's `temporary_id`/i.test(activateProse), + 'add_labels must be wired to the minting create-issue call\'s temporary_id.', + ).toBe(true); + }); + + it('never waits for, predicts, or reads back an issue number for issues it created', () => { + expect( + /does \*\*not\*\* return a real GitHub issue number/i.test(activateProse), + 'The Hallucination Guard must state plainly that create-issue returns no number.', + ).toBe(true); + expect( + /never predict, infer, or "read back" a number/i.test(activateProse), + 'Predicting or inferring a created issue\'s number must be explicitly forbidden.', + ).toBe(true); + expect( + /do not wait for a returned issue number/i.test(activateProse), + 'The prose must tell the model not to block on a number that never arrives.', + ).toBe(true); + }); + + it('still uses real, verified numbers for pre-existing and reused issues', () => { + // Temporary IDs only map issues THIS run created. An idempotent rerun or a + // dedup-by-title match has a genuine number and must use it. + expect( + /has a real, verified number: target it by that number, not a temporary ID/i.test(activateProse), + 'Reused/pre-existing issues must be targeted by their verified real number.', + ).toBe(true); + }); + + it('declares a temporary ID in both the epic (2b) and task (2c) creation steps', () => { + for (const marker of ['**2b. Create Epic Issues:**', '**2c. Create Task Issues:**']) { + expect(stepBody(marker), `${marker} must declare a Temporary ID field`).toMatch(/- Temporary ID:/); + } + }); + + it('passes the epic\'s temporary ID as each task\'s parent', () => { + // The epic does not exist yet when the task create-issue is emitted, so `parent` + // cannot be a real number. gh-aw resolves a temporary ID here. + expect(stepBody('**2c. Create Task Issues:**')).toMatch(/#aw_epic\{K\}/); + }); + + it('uses the triggering issue\'s own real number as the epic parent', () => { + // The one place a real number IS correct: the intent issue that triggered the run + // already exists and its number is independently known. + expect(stepBody('**2b. Create Epic Issues:**')).toMatch(/triggering issue/i); + }); + + it('expresses native dependency edges with temporary IDs on create-issue', () => { + // blocked_by must be declared on the create_issue call itself (gh-aw's topological + // sort only inspects create_issue.blocked_by), not patched in afterwards with a + // number the agent does not have. + const start = activateSkill.indexOf('##### Step 3: Native Dependency Edges'); + expect(start, '"##### Step 3: Native Dependency Edges" is missing').toBeGreaterThan(-1); + const rest = activateSkill.slice(start); + const nextHeading = rest.slice(1).search(/\n#{1,5} /); + const stepThree = (nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1)).replace(/\s+/g, ' '); + + expect(stepThree).toMatch(/blocked_by/); + expect(stepThree).toMatch(/#aw_task\{N\}/); + expect( + /on the `create-issue` call itself/i.test(stepThree), + 'blocked_by must be declared on the create-issue call, not applied afterwards.', + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Compiled-artifact verification +// --------------------------------------------------------------------------- +// +// The prose assertions above are only a contract if gh-aw actually compiles the +// requirement into the safe-output handler config. Fails closed (never skips) per this +// repo's #1833/#1834 convention: an unmeasured contract is indistinguishable from a +// violated one. + +const GH_AW_INSTALL_HINT = + '`gh aw` is required to compile the workflow this gate inspects. Install it with ' + + '`gh extension install --pin v0.87.10 github/gh-aw` (matches .github/workflows/squad-ci.yml). ' + + 'This gate fails closed rather than skipping: an unmeasured contract is ' + + 'indistinguishable from a violated one (#1834).'; + +describe('gh-aw: require-temporary-id compiles into the real safe-output config (#1962)', () => { + let compiledLock: string | null = null; + + function lockText(): string { + if (compiledLock !== null) return compiledLock; + + const versionProbe = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }); + if (versionProbe.status !== 0) { + throw new Error(`gh aw --version failed. ${GH_AW_INSTALL_HINT}`); + } + + mkdirSync(TEST_WORKSPACES_DIR, { recursive: true }); + const workspace = mkdtempSync(join(TEST_WORKSPACES_DIR, 'temp-ids-')); + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + // This repo ships the gh-aw *source* from a top-level `workflows/` dir; downstream + // consumers install it into `.github/workflows/` via `gh aw add`. Mirror that real + // deployment layout, matching .github/workflows/squad-ci.yml's own compile gate. + cpSync(WORKFLOWS_DIR, join(workspace, '.github', 'workflows'), { recursive: true }); + execFileSync('gh', ['aw', 'compile', '.github/workflows/squad.md', '--strict', '--approve', '--no-check-update'], { + cwd: workspace, + encoding: 'utf8', + stdio: 'pipe', + }); + + const lockPath = join(workspace, '.github', 'workflows', 'squad.lock.yml'); + if (!existsSync(lockPath)) { + throw new Error( + `gh aw compile produced no squad.lock.yml — the compiled artifact this gate ` + + `inspects is absent, so the contract is unmeasured. ${GH_AW_INSTALL_HINT}`, + ); + } + compiledLock = readText(lockPath); + return compiledLock; + } + + it('strict-compiles with require-temporary-id declared', () => { + expect(lockText().length).toBeGreaterThan(0); + }); + + it('wires require_temporary_id: true into the create_issue handler config', () => { + // gh-aw embeds this JSON inside a double-quoted YAML env value with every `"` + // backslash-escaped. De-escape before matching so this holds regardless of a given + // compiler version's quoting choice. + const compiled = lockText().replace(/\\"/g, '"'); + expect(compiled).toMatch(/"create_issue":\{[^}]*"require_temporary_id":true/); + }); + + it('adds temporary_id to create_issue\'s required fields', () => { + // gh-aw emits this block pretty-printed across multiple lines, so match with + // whitespace tolerance rather than assuming compact JSON. + const compiled = lockText().replace(/\\"/g, '"'); + expect(compiled).toMatch( + /"required_field_additions":\s*\{\s*"create_issue":\s*\[\s*"temporary_id"\s*\]/, + ); + }); + + it('tells the agent, in its own tool constraints, that temporary_id is required', () => { + // The prompt-side constraint string is what the model actually reads at runtime; + // without it the enforcement is a silent rejection the agent cannot anticipate. + // De-escaping reintroduces quotes inside the value (`Labels ["squad"]`), so match + // with a bounded lazy span rather than a quote-delimited one. + const compiled = lockText().replace(/\\"/g, '"'); + expect(compiled).toMatch(/"create_issue":\s*"\s*CONSTRAINTS:[\s\S]{0,300}?temporary_id is required/); + }); +}); diff --git a/workflows/squad.md b/workflows/squad.md index 407c48611..24039d5d1 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -105,6 +105,7 @@ safe-outputs: create-issue: labels: [squad] max: 75 + require-temporary-id: true add-labels: allowed: [squad, "squad:*"] create-if-missing: true @@ -1275,9 +1276,10 @@ substitute, re-route, or fall back to another identity during acceptance. For each work item, `create-issue`: - Title: work item title +- Temporary ID: `temporary_id` is **required** on every `create-issue` call in this workflow (`require-temporary-id: true`), so mint a unique one per item: `#aw_ph{N}` for a phase issue and `#aw_wi{N}` for a work item, where `{N}` is that row's plan number with non-alphanumeric characters replaced by `_`. It must match `^#?aw_[A-Za-z0-9_]{3,12}$` and must not repeat within this run — gh-aw does not reject a duplicate, it silently lets the last writer own the mapping. - Labels: `squad` (color `9B8FCC`), plus `squad:{owner}` (color `9B8FCC`) where `{owner}` is the frozen row `Owner` lowercased. Mint the member label only from that task's certified binding; never re-read team.md, re-route the task, or carry another row's owner forward. On `ROSTER_UNREADABLE:`, stop and report that reason; never mint from a preset or remembered roster. - Body: scope, acceptance criteria, context (parent, phase, size, depends on, owner), notes, footer -- Parent: phase issue (hierarchical) or root (flat) +- Parent: phase issue (hierarchical) or root (flat). For a phase issue created in this run, pass its `#aw_ph{N}` temporary ID — `create-issue` resolves it. The flat-plan root is the triggering issue's own real number. Never guess a number for an issue this run created. - Size: set Project field if available, else body `**Size:**` line Copy every frozen `Depends On` value into the created issue body. Cross-phase @@ -1736,7 +1738,39 @@ description: Activate an accepted plan by creating the epic and task issues on G ##### Hallucination Guard -After EVERY `create-issue` call: verify returned issue number, stop on failure, NEVER predict issue numbers. +`create-issue` does **not** return a real GitHub issue number during this run — gh-aw defers +creation to the safe-output job, so the agent sees only a success acknowledgement. NEVER +predict, infer, or "read back" a number for an issue this run created, and never wait for +one. Use a real number only when verified independently: a pre-existing issue matched by +title, the triggering issue, or one recorded in a prior run's artifact. + +##### Temporary-ID Contract + +Every `create-issue` call MUST carry a `temporary_id`, and every operation pointing back at +that issue MUST reuse the identical value. `require-temporary-id: true` enforces the first +half — a call without one is rejected. The second half is yours. + +**Form.** Matches `^#?aw_[A-Za-z0-9_]{3,12}$`. Write the canonical `#aw_…` form. Dots, +hyphens, spaces, and `:` are illegal. + +**Minting — derive, never invent.** Epic: `#aw_epic{K}`, `{K}` = the epic's 1-based position +in the accepted plan. Task: `#aw_task{N}`, `{N}` = that task's own `#` cell with every +character outside `A-Za-z0-9` replaced by `_` (`2.3` → `#aw_task2_3`). If a derived ID +exceeds 12 characters after `aw_`, drop the `epic`/`task` word (`#aw_e{K}` / `#aw_t{N}`) +rather than truncating the number — two items must never collapse onto one ID. + +**Uniqueness is your responsibility.** gh-aw does not reject a duplicate `temporary_id`; it +silently lets the last `create-issue` using it own the mapping, so every later reference +lands on the wrong issue. Confirm each ID is unused before calling. Epics and tasks share one +namespace, hence the distinct prefixes. + +**Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. Omitting +it does not fail — it silently labels the **triggering intent issue**, branding the user's own +request with an activated item's owner label. + +**Existing and reused issues.** An issue recognized by Step 1's idempotent-rerun path or a +dedup-by-title match has a real, verified number: target it by that number, not a temporary +ID, which only maps issues this run created. ##### Output Budget Awareness @@ -1788,14 +1822,16 @@ repository instead of creating them, which is the behavior that previously produ "prerequisite gap" reported here. Do not rely on `create-issue`'s `labels:` field alone to land a label on a fresh repository. -Immediately after each `create-issue` call in Steps 2b/2c returns its real issue number, -call `add_labels` targeting that number with exactly the label set Steps 4-8 above -computed for that issue — `squad` alone, or `squad` plus the one `squad:{agent}` label -the correspondence rule (Step 7) certified. `create-if-missing` creates any label that -does not yet exist before applying it; re-applying an already-present label on a rerun is -a no-op, so this is safe under the Step 1 idempotent-rerun path. Never call `add_labels` -before the matching `create-issue` call has returned and been verified — there is no -issue number to target yet. +In the same turn as each `create-issue` call in Steps 2b/2c, call `add_labels` with +`item_number` set to that call's `temporary_id` and exactly the label set Steps 4-8 computed +for that issue — `squad` alone, or `squad` plus the one `squad:{agent}` label the +correspondence rule (Step 7) certified. Do not wait for a returned issue number; none +arrives. gh-aw resolves `add_labels` after the `create-issue` that minted the ID, so +`create-issue` first and `add_labels` immediately after is the supported order. +`create-if-missing` creates any label that does not yet exist before applying it; +re-applying an already-present label on a rerun is a no-op, so this is safe under the Step 1 +idempotent-rerun path. Never emit `add_labels` for an item whose `create-issue` call was not +made in this run, and never reuse another item's temporary ID. ##### Transient Failure Handling @@ -1803,7 +1839,7 @@ On `5xx` response from `create-issue`: wait briefly and retry once. On second fa ##### Sub-issue Fallback -When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference (`Parent: #{issue_number}`), then continue. Never fail activation over sub-issue API unavailability. +When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference, then continue. Write it as the parent's temporary ID (`Parent: #aw_epic{K}`); gh-aw rewrites an `#aw_…` reference in an issue body to the real `#{number}`, so no number is predicted. Use a literal `Parent: #{issue_number}` only for a pre-existing, verified parent. Never fail activation over sub-issue API unavailability. ##### Step 2: Create Issues — Full Hierarchy @@ -1813,29 +1849,31 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. **2b. Create Epic Issues:** `create-issue` per epic (dedup by title `[Epic] {name}` if already exists from prior phase). - Title: `[Epic] {name}` +- Temporary ID: `temporary_id: "#aw_epic{K}"` per the Temporary-ID Contract, where `{K}` is this epic's 1-based position in the accepted plan. Required — the call is rejected without it. - Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct roster value → mint `squad:{that agent}`; exactly `@copilot` → mint `squad:copilot`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. - Body: outcome, stories, epic-level acceptance criteria, context (parent, initiative, milestone, deps) -- Parent: sub-issue of root intent issue +- Parent: sub-issue of root intent issue — the triggering issue's own real number, which is known independently of this run's creations - Milestone: assigned -- Label application: once `create-issue` returns and the epic's issue number is verified, call `add_labels` on that number with this epic's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. +- Label application: in the same turn as this `create-issue` call, call `add_labels` with `item_number` set to this epic's `#aw_epic{K}` temporary ID and this epic's computed label set (see Label Pre-flight). A dedup-by-title match instead targets that existing issue's verified real number. `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. **⚠️ DO NOT STOP after epics. Tasks MUST follow immediately.** **2c. Create Task Issues:** `create-issue` per task in dependency order. > **⚠️ ATOMIC CONTRACT — strictly one task at a time:** -> For each task: compose ONLY that task's body → call `create-issue` immediately → verify the returned issue number → call `add_labels` on that verified number with the task's computed label set → then move to the next task. -> **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one call → one verify → one label-application call, repeated per task. +> For each task: compose ONLY that task's body → call `create-issue` immediately, carrying that task's `temporary_id` → call `add_labels` with `item_number` set to that same temporary ID and the task's computed label set → then move to the next task. +> **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one `create-issue` call → one `add_labels` call, repeated per task. Do not pause for a returned issue number between the two calls; none is returned. - Title: task title +- Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract, where `{N}` is this task's own `#` cell with non-alphanumeric characters replaced by `_`. Required, and unique across every epic and task in this run. - Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **this task's own `Agent` cell**, lowercased — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. - Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) -- Parent: sub-issue of EPIC (not root) +- Parent: sub-issue of EPIC (not root) — pass the epic's `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. Never guess the epic's real number. - Milestone: same as parent epic - Size: Project field if available, else body line -- Label application: same as epics — call `add_labels` on the verified task issue number with this task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. +- Label application: same as epics — call `add_labels` with `item_number` set to this task's temporary ID and the task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. -**2d. Self-Validation:** Compare created/recognized task count vs expected (use the plan's declared total — not the safe-output cap). If created count is below expected: call `report_incomplete` immediately with `created={N}`, `expected={M}`, and the last verified issue number — never noop. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as the reason for a partial run. +**2d. Self-Validation:** Compare requested/recognized task count vs expected (use the plan's declared total — not the safe-output cap). If the requested count is below expected: call `report_incomplete` immediately with `created={N}`, `expected={M}`, and the last task's temporary ID — never noop, and never substitute a guessed issue number. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as the reason for a partial run. Labels must have descriptions and intentional colors when they already exist in the repository. A label auto-provisioned by `add-labels`'s `create-if-missing` on a fresh @@ -1844,7 +1882,11 @@ is expected, not a failure, and must not be reported as one. ##### Step 3: Native Dependency Edges -Add `blockedBy` via API for tasks and epics. Graceful fallback. Never fail activation over edge creation. +Declare `blocked_by` on the `create-issue` call itself, passing the blocking item's +temporary ID (`#aw_task{N}` / `#aw_epic{K}`) — `blocked_by` resolves temporary IDs, so this +needs no real issue number. Use a verified real number only for a dependency on a +pre-existing issue. Never call a write API with a guessed number to add an edge. Graceful +fallback to a body reference. Never fail activation over edge creation. ##### Step 4: Post Activation Record From 528ba9b0a00f20e05aacaad3f367dbc5361f7ba5 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 31 Aug 2026 15:15:06 -0700 Subject: [PATCH 2/3] fix(workflows): use real numbers for reused epics in activation parent refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: Step 2b dedups epics by title against prior phases, so a reused epic was never minted in this run and has no entry in gh-aw's temporary-ID map. Passing `#aw_epic{K}` for it was an unresolvable parent reference. - Step 2c parent targeting now branches on provenance: `#aw_epic{K}` only when 2b minted the epic this run; the verified real number when 2b matched a pre-existing epic by title. - Sub-issue Fallback body reference gets the same carve-out. This matters more there: gh-aw leaves an unresolved `#aw_…` body reference VERBATIM rather than stripping it, so a temporary ID it never minted would ship to the user as a literal "#aw_epic3". - Adds two focused tests covering reused-epic parent targeting and the fallback provenance branch. Prose trimmed elsewhere to stay under the existing 160 KB source-growth guard rather than raising it. Refs #1962 Parent #1957 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-activation-temporary-ids.test.ts | 52 +++++++++++++++++++++ workflows/squad.md | 31 ++++++------ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/test/gh-aw-activation-temporary-ids.test.ts b/test/gh-aw-activation-temporary-ids.test.ts index df5ca28d1..de0d4ff1f 100644 --- a/test/gh-aw-activation-temporary-ids.test.ts +++ b/test/gh-aw-activation-temporary-ids.test.ts @@ -172,6 +172,58 @@ describe('gh-aw: temporary-ID targeting in squad-plan-activate (#1962)', () => { expect(stepBody('**2c. Create Task Issues:**')).toMatch(/#aw_epic\{K\}/); }); + it('uses a verified real number when 2b reused a deduped prior-phase epic', () => { + // 2b dedups epics by title against prior phases. A reused epic was never minted in + // this run, so it has no entry in gh-aw's temporary-ID map — passing `#aw_epic{K}` + // for it would be an unresolvable parent reference. The prose must carve this out + // instead of applying the temporary ID unconditionally. + const parentLine = stepBody('**2c. Create Task Issues:**'); + + expect( + /minted this epic in this run/i.test(parentLine), + 'Task parent targeting must condition the temporary ID on the epic having been ' + + 'minted in this run.', + ).toBe(true); + + expect( + /matched a pre-existing epic by title[\s\S]*?verified real number/i.test(parentLine), + 'A dedup-by-title/prior-phase epic match must be targeted by its verified real ' + + 'number, not a temporary ID this run never minted.', + ).toBe(true); + + expect( + /never pass a temporary ID that was not minted this run/i.test(parentLine), + 'The prose must forbid passing a temporary ID that was not minted this run.', + ).toBe(true); + }); + + it('never leaves an unresolved temporary ID in the sub-issue fallback body reference', () => { + // gh-aw leaves an `#aw_…` reference it cannot resolve in the body VERBATIM, so a + // reused/pre-existing parent written as a temporary ID would ship a literal + // "#aw_epic3" string to the user. The fallback must branch on provenance. + const start = workflow.indexOf('##### Sub-issue Fallback'); + expect(start, '"##### Sub-issue Fallback" is missing').toBeGreaterThan(-1); + const rest = workflow.slice(start); + const nextHeading = rest.slice(1).search(/\n#{1,5} /); + const fallback = (nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1)).replace(/\s+/g, ' '); + + expect( + /if that parent was minted this run/i.test(fallback), + 'The fallback must use a temporary ID only for a parent minted in this run.', + ).toBe(true); + + expect( + /pre-existing or was matched by dedup[\s\S]*?verified real number/i.test(fallback), + 'A pre-existing or deduped parent must be written as its verified real number.', + ).toBe(true); + + expect( + /leaves an unresolved `#aw_…` reference in the body verbatim/i.test(fallback), + 'The prose must state WHY: an unresolved temporary ID is not stripped, so it ' + + 'would ship to the user as a meaningless literal.', + ).toBe(true); + }); + it('uses the triggering issue\'s own real number as the epic parent', () => { // The one place a real number IS correct: the intent issue that triggered the run // already exists and its number is independently known. diff --git a/workflows/squad.md b/workflows/squad.md index 24039d5d1..e5a6921cb 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -1276,7 +1276,7 @@ substitute, re-route, or fall back to another identity during acceptance. For each work item, `create-issue`: - Title: work item title -- Temporary ID: `temporary_id` is **required** on every `create-issue` call in this workflow (`require-temporary-id: true`), so mint a unique one per item: `#aw_ph{N}` for a phase issue and `#aw_wi{N}` for a work item, where `{N}` is that row's plan number with non-alphanumeric characters replaced by `_`. It must match `^#?aw_[A-Za-z0-9_]{3,12}$` and must not repeat within this run — gh-aw does not reject a duplicate, it silently lets the last writer own the mapping. +- Temporary ID: `temporary_id` is required on every `create-issue` call (`require-temporary-id: true`). Mint one per item: `#aw_ph{N}` for a phase issue and `#aw_wi{N}` for a work item, where `{N}` is that row's plan number with non-alphanumeric characters replaced by `_`. Must match `^#?aw_[A-Za-z0-9_]{3,12}$` and be unique in this run — gh-aw silently lets a duplicate's last writer own the mapping. - Labels: `squad` (color `9B8FCC`), plus `squad:{owner}` (color `9B8FCC`) where `{owner}` is the frozen row `Owner` lowercased. Mint the member label only from that task's certified binding; never re-read team.md, re-route the task, or carry another row's owner forward. On `ROSTER_UNREADABLE:`, stop and report that reason; never mint from a preset or remembered roster. - Body: scope, acceptance criteria, context (parent, phase, size, depends on, owner), notes, footer - Parent: phase issue (hierarchical) or root (flat). For a phase issue created in this run, pass its `#aw_ph{N}` temporary ID — `create-issue` resolves it. The flat-plan root is the triggering issue's own real number. Never guess a number for an issue this run created. @@ -1757,7 +1757,7 @@ hyphens, spaces, and `:` are illegal. in the accepted plan. Task: `#aw_task{N}`, `{N}` = that task's own `#` cell with every character outside `A-Za-z0-9` replaced by `_` (`2.3` → `#aw_task2_3`). If a derived ID exceeds 12 characters after `aw_`, drop the `epic`/`task` word (`#aw_e{K}` / `#aw_t{N}`) -rather than truncating the number — two items must never collapse onto one ID. +rather than truncating the number. **Uniqueness is your responsibility.** gh-aw does not reject a duplicate `temporary_id`; it silently lets the last `create-issue` using it own the mapping, so every later reference @@ -1826,12 +1826,11 @@ In the same turn as each `create-issue` call in Steps 2b/2c, call `add_labels` w `item_number` set to that call's `temporary_id` and exactly the label set Steps 4-8 computed for that issue — `squad` alone, or `squad` plus the one `squad:{agent}` label the correspondence rule (Step 7) certified. Do not wait for a returned issue number; none -arrives. gh-aw resolves `add_labels` after the `create-issue` that minted the ID, so -`create-issue` first and `add_labels` immediately after is the supported order. -`create-if-missing` creates any label that does not yet exist before applying it; -re-applying an already-present label on a rerun is a no-op, so this is safe under the Step 1 -idempotent-rerun path. Never emit `add_labels` for an item whose `create-issue` call was not -made in this run, and never reuse another item's temporary ID. +arrives. gh-aw resolves `add_labels` after the `create-issue` that minted the ID, so that +order is the supported one. `create-if-missing` creates any label that does not yet exist +before applying it; re-applying an already-present label on a rerun is a no-op, so this is +safe under the Step 1 idempotent-rerun path. Never emit `add_labels` for an item whose +`create-issue` call was not made in this run. ##### Transient Failure Handling @@ -1839,7 +1838,7 @@ On `5xx` response from `create-issue`: wait briefly and retry once. On second fa ##### Sub-issue Fallback -When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference, then continue. Write it as the parent's temporary ID (`Parent: #aw_epic{K}`); gh-aw rewrites an `#aw_…` reference in an issue body to the real `#{number}`, so no number is predicted. Use a literal `Parent: #{issue_number}` only for a pre-existing, verified parent. Never fail activation over sub-issue API unavailability. +When setting a `parent` sub-issue relationship returns `404` or `422` (feature disabled or repo plan): degrade gracefully — record the intended parent as a body reference, then continue. If that parent was minted this run, write its temporary ID (`Parent: #aw_epic{K}`); gh-aw rewrites an `#aw_…` body reference to the real `#{number}`, so no number is predicted. If the parent is pre-existing or was matched by dedup, write its verified real number (`Parent: #{issue_number}`) — gh-aw leaves an unresolved `#aw_…` reference in the body verbatim, so a temporary ID it never minted would ship as a meaningless literal. Never fail activation over sub-issue API unavailability. ##### Step 2: Create Issues — Full Hierarchy @@ -1849,7 +1848,7 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. **2b. Create Epic Issues:** `create-issue` per epic (dedup by title `[Epic] {name}` if already exists from prior phase). - Title: `[Epic] {name}` -- Temporary ID: `temporary_id: "#aw_epic{K}"` per the Temporary-ID Contract, where `{K}` is this epic's 1-based position in the accepted plan. Required — the call is rejected without it. +- Temporary ID: `temporary_id: "#aw_epic{K}"` per the Temporary-ID Contract. Required — the call is rejected without it. - Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **derived from this epic's own tasks**: collect the `Agent` values of every implementation-plan row whose `Epic` cell names this epic. Exactly one distinct roster value → mint `squad:{that agent}`; exactly `@copilot` → mint `squad:copilot`. Two or more → multi-owner epic: apply only `squad` and record it under `Non-roster agent values`. Never mint a single agent label for a multi-owner epic, and never choose one of several. - Body: outcome, stories, epic-level acceptance criteria, context (parent, initiative, milestone, deps) - Parent: sub-issue of root intent issue — the triggering issue's own real number, which is known independently of this run's creations @@ -1865,10 +1864,10 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. > **DO NOT** compose or buffer multiple task bodies before making calls. One compose → one `create-issue` call → one `add_labels` call, repeated per task. Do not pause for a returned issue number between the two calls; none is returned. - Title: task title -- Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract, where `{N}` is this task's own `#` cell with non-alphanumeric characters replaced by `_`. Required, and unique across every epic and task in this run. +- Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract. Required, and unique across every epic and task in this run. - Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **this task's own `Agent` cell**, lowercased — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. - Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) -- Parent: sub-issue of EPIC (not root) — pass the epic's `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. Never guess the epic's real number. +- Parent: sub-issue of EPIC (not root). If 2b minted this epic in this run, pass its `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. If 2b instead matched a pre-existing epic by title (a prior phase created it), that epic has no temporary ID in this run — pass its verified real number. Never guess the epic's real number, and never pass a temporary ID that was not minted this run. - Milestone: same as parent epic - Size: Project field if available, else body line - Label application: same as epics — call `add_labels` with `item_number` set to this task's temporary ID and the task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. @@ -1883,10 +1882,10 @@ is expected, not a failure, and must not be reported as one. ##### Step 3: Native Dependency Edges Declare `blocked_by` on the `create-issue` call itself, passing the blocking item's -temporary ID (`#aw_task{N}` / `#aw_epic{K}`) — `blocked_by` resolves temporary IDs, so this -needs no real issue number. Use a verified real number only for a dependency on a -pre-existing issue. Never call a write API with a guessed number to add an edge. Graceful -fallback to a body reference. Never fail activation over edge creation. +temporary ID (`#aw_task{N}` / `#aw_epic{K}`) — `blocked_by` resolves temporary IDs. Use a +verified real number only for a dependency on a pre-existing issue. Never call a write API +with a guessed number to add an edge. Graceful fallback to a body reference. Never fail +activation over edge creation. ##### Step 4: Post Activation Record From c95d43aba16f9c390ad1e9e064e081dd15ceebba Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 31 Aug 2026 15:51:26 -0700 Subject: [PATCH 3/3] fix(workflows): name the Step 2d self-validation count consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: 2d compared a "requested/recognized" count but then told the agent to report it as `created={N}`, leaving {N} undefined. - Defines the quantity once as the **created count** — the number of `create-issue` calls this run emitted — and uses that one term throughout. - Binds both parameters explicitly: `created={N}` set to that created count, `expected={M}` set to the declared total. - Adds a focused test that locks the single term and both parameter bindings, and rejects a regression to mixed terminology. The assertion bounds its slice at the next heading so it cannot reach into Step 4, whose separate binding wording is #1963's scope. Step 4's `Activation bindings:` block is deliberately untouched; its reporting inconsistency is tracked in #1963. Prose trimmed elsewhere to stay under the existing 160 KB source-growth guard. Refs #1962 Parent #1957 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-activation-temporary-ids.test.ts | 26 +++++++++++++++++++++ workflows/squad.md | 7 +++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/test/gh-aw-activation-temporary-ids.test.ts b/test/gh-aw-activation-temporary-ids.test.ts index de0d4ff1f..fb7dae491 100644 --- a/test/gh-aw-activation-temporary-ids.test.ts +++ b/test/gh-aw-activation-temporary-ids.test.ts @@ -247,6 +247,32 @@ describe('gh-aw: temporary-ID targeting in squad-plan-activate (#1962)', () => { 'blocked_by must be declared on the create-issue call, not applied afterwards.', ).toBe(true); }); + + it('names the 2d self-validation count with one consistent term', () => { + // The reviewer flagged a mixed-terminology hazard: comparing a "requested/recognized" + // count but then reporting it as `created={N}` left {N} undefined. One term, defined + // once, and both report_incomplete parameters bound explicitly to it. + // + // 2d is the last bold step, so bound the slice at the next heading too — otherwise it + // swallows Step 4, whose separate "created/recognized" binding wording belongs to #1963. + const marker = '**2d. Self-Validation:**'; + const start = activateSkill.indexOf(marker); + expect(start, `"${marker}" is missing`).toBeGreaterThan(-1); + const rest = activateSkill.slice(start + marker.length); + const end = rest.search(/\n#{1,5} |\n\*\*\d[a-z]?\./); + const body = (end === -1 ? rest : rest.slice(0, end)).replace(/\s+/g, ' '); + + expect(body, '2d must define the counted quantity once, as the created count').toMatch( + /\*\*created count\*\* is the number of `create-issue` calls this run emitted/i, + ); + // Nothing may reintroduce a competing noun for the same quantity. + expect(body, '2d must not mix in a second term for the same count').not.toMatch( + /requested\/recognized|created\/recognized|requested count|recognized count/i, + ); + // Both parameters must state what they carry, so {N} and {M} are unambiguous. + expect(body).toMatch(/`created=\{N\}` set to that created count/); + expect(body).toMatch(/`expected=\{M\}` set to the declared total/); + }); }); // --------------------------------------------------------------------------- diff --git a/workflows/squad.md b/workflows/squad.md index e5a6921cb..8d932a931 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -1761,8 +1761,7 @@ rather than truncating the number. **Uniqueness is your responsibility.** gh-aw does not reject a duplicate `temporary_id`; it silently lets the last `create-issue` using it own the mapping, so every later reference -lands on the wrong issue. Confirm each ID is unused before calling. Epics and tasks share one -namespace, hence the distinct prefixes. +lands on the wrong issue. Confirm each ID is unused; epics and tasks share one namespace. **Explicit targeting is mandatory.** Every `add_labels` call MUST pass `item_number`. Omitting it does not fail — it silently labels the **triggering intent issue**, branding the user's own @@ -1867,12 +1866,12 @@ Root → Epics → Tasks. Phase-specific: filter to matching phase heading. - Temporary ID: `temporary_id: "#aw_task{N}"` per the Temporary-ID Contract. Required, and unique across every epic and task in this run. - Labels: `squad` (0075ca), `squad:{agent}` (e4e669) where `{agent}` is **this task's own `Agent` cell**, lowercased — read from the implementation-plan row whose `#` matches this task. Map `@copilot` to `squad:copilot`. Never inherit the parent epic's agent, and never carry the previous task's value forward: re-read the `Agent` cell for every task, because consecutive tasks under one epic routinely have different agents. No `size:*` labels unless policy says so. - Body: one sentence describing scope; 1-2 acceptance criteria; one compact context line (parent epic, size, deps) -- Parent: sub-issue of EPIC (not root). If 2b minted this epic in this run, pass its `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. If 2b instead matched a pre-existing epic by title (a prior phase created it), that epic has no temporary ID in this run — pass its verified real number. Never guess the epic's real number, and never pass a temporary ID that was not minted this run. +- Parent: sub-issue of EPIC (not root). If 2b minted this epic in this run, pass its `#aw_epic{K}` temporary ID, which `create-issue`'s `parent` field accepts. If 2b instead matched a pre-existing epic by title, that epic has no temporary ID in this run — pass its verified real number. Never guess the epic's real number, and never pass a temporary ID that was not minted this run. - Milestone: same as parent epic - Size: Project field if available, else body line - Label application: same as epics — call `add_labels` with `item_number` set to this task's temporary ID and the task's computed label set (see Label Pre-flight). `create-if-missing` provisions `squad`/`squad:{agent}` on a fresh repository automatically. -**2d. Self-Validation:** Compare requested/recognized task count vs expected (use the plan's declared total — not the safe-output cap). If the requested count is below expected: call `report_incomplete` immediately with `created={N}`, `expected={M}`, and the last task's temporary ID — never noop, and never substitute a guessed issue number. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as the reason for a partial run. +**2d. Self-Validation:** The **created count** is the number of `create-issue` calls this run emitted. Compare it against the plan's declared total (not the safe-output cap). If it is lower: call `report_incomplete` immediately with `created={N}` set to that created count, `expected={M}` set to the declared total, and the last task's temporary ID — never noop, and never substitute a guessed issue number. Post: `N of M issues created so far — rerun the identical activation command to continue.` Re-runs are idempotent via title match. Never surface the `create-issue` or `add-comment` safe-output caps as the reason for a partial run. Labels must have descriptions and intentional colors when they already exist in the repository. A label auto-provisioned by `add-labels`'s `create-if-missing` on a fresh